From 27355cec76f8362ad7a96ca36885e4a62373ed10 Mon Sep 17 00:00:00 2001 From: sravan Date: Tue, 23 Jun 2026 16:15:29 +0530 Subject: [PATCH] BizGaze Connect: chat, meetings, recordings, mobile, directory + UI fixes Co-Authored-By: Claude Opus 4.8 --- .gitignore | 1 + ARCHITECTURE.md | 17 +- CLAUDE.md | 8 +- server/auth.js | 4 +- server/bizgaze.js | 15 + server/config.js | 6 +- server/db.js | 206 ++++ server/presence.js | 9 + server/public/connect.html | 26 +- server/public/dashboard.html | 105 +- server/public/home.html | 2020 ++++++++++++++++++++++++++++++++-- server/public/host.html | 4 +- server/public/index.html | 10 +- server/public/share.html | 48 +- server/public/viewer.html | 4 +- server/repos.js | 170 ++- server/routes.js | 960 +++++++++++++++- server/session.js | 18 +- server/signaling.js | 182 ++- server/static.js | 77 +- server/test/e2e.js | 270 +++++ 21 files changed, 3952 insertions(+), 208 deletions(-) diff --git a/.gitignore b/.gitignore index d0131be..c8d4a07 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,7 @@ out/ # Runtime media (created at startup by config.js) server/recordings/ server/transcripts/ +server/uploads/ # OS files .DS_Store diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 0ebd370..9ba864e 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -133,10 +133,19 @@ viewer CONTROLS the remote screen** (reuses the WebRTC `inputChannel` + OS input - [x] **`Authorization: Bearer `** accepted in `currentUser()` across HTTP + WS, alongside the cookie (session.js `tokenFromReq`); `/api/login` now also returns the `token` for native clients. WS upgrades carry the token in the Authorization header (native) or `?access_token=` (browser fallback). -- [ ] **Refresh tokens** (short access token + long refresh) so native apps stay signed in safely. -- [ ] **API keys** table + middleware (scoped per *tenant*, hashed at rest). -- [ ] **Push-notification hooks** (APNs/FCM) for incoming sessions/calls on mobile. -- [ ] **OIDC/JWT** SSO; per-tenant **webhook subscriptions** with retries. +- [x] **Refresh tokens** — `/api/v1/auth/refresh` exchanges a long-lived (90d) refresh token for a + fresh access token, with **rotation** (old token revoked on use) + replay rejection. Stored as a + SHA-256 hash (`refresh_tokens` table). Login returns one; logout/deactivate/reset/delete revoke them. + Web/cookie path unchanged. +- [x] **API keys** — admin-managed (`POST/GET /api/v1/keys`, `POST /api/v1/keys/revoke`), per-tenant, + scoped (`report:read`, `audit:read`), `bzc_`-prefixed, SHA-256 hashed at rest, shown once. Accepted via + `X-API-Key` or `Authorization: Bearer bzc_…` on `/api/v1/report` + `/api/v1/audit` with scope enforcement. +- [x] **Per-tenant webhook subscriptions** — admin-managed (`/api/v1/webhooks` create/list/delete + + `/webhooks/events`), each with its own signing secret; events `session.started`/`session.ended` + delivered HMAC-SHA256-signed (`X-BizGaze-Signature`) with in-memory retries. Legacy global + `BIZGAZE_WEBHOOK_URL` still works. (webhooks.js + signaling emits.) +- [ ] **Push-notification hooks** (APNs/FCM) for incoming sessions/calls on mobile — needs Apple/Google creds to test. +- [ ] **OIDC/JWT** SSO — needs an identity provider to test against. ### Phase 3 — Licensing + scale (last, per priority) - [ ] Elevate **tenant → `organization`** (additive migration: add `organizations`, keep `team_id` diff --git a/CLAUDE.md b/CLAUDE.md index 3f18415..304deba 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -111,8 +111,12 @@ First registered user becomes admin; registration then closes (unless ALLOW_REGI the dev team for: shared secret, token format (JWT preferred), SSO start URL, signup URL, role mapping. (See BizGaze-Connect-SSO-SPEC.md.) 2. **New post-login home (NEXT TASK)** — see below. -3. **Persistent chat** (Slack-style 1:1 + group messaging between registered users) — large new system. -4. **Meetings** (multi-party video) — large new system (needs SFU or mesh). +3. **Persistent chat** — 1:1 messaging is BUILT (messages table, `/api/v1/messages/*`, live delivery + over `/ws` via `chat-hello`/`chat-message`, wired into home.html). Group chat is the remaining part. +4. **Meetings** (multi-party video) — **mesh (P2P) MVP BUILT**: in-memory rooms + signaling + (`meeting-create/join/signal/leave` in signaling.js), video-grid UI in home.html's Meeting tab + (start/join by 6-digit code, mic/cam toggles, leave). Good for small groups; **SFU upgrade** is + the next step for larger rooms. 5. **Downloadable Android app** — the only way to support phone screen-sharing. ## NEXT TASK: new post-login home (start with a mockup) diff --git a/server/auth.js b/server/auth.js index 4066dba..5dc3035 100644 --- a/server/auth.js +++ b/server/auth.js @@ -15,6 +15,8 @@ function verifyPassword(password, salt, expectedHash) { // ---- Random tokens ---- const token = (bytes = 24) => crypto.randomBytes(bytes).toString('hex'); const id = () => crypto.randomBytes(8).toString('hex'); +// Deterministic hash for storing high-value tokens (e.g. refresh tokens) at rest. +const hashToken = (t) => crypto.createHash('sha256').update(String(t)).digest('hex'); const numericCode = (digits = 6) => String(crypto.randomInt(0, 10 ** digits)).padStart(digits, '0'); @@ -70,6 +72,6 @@ function otpauthUrl(secret, email, issuer = 'RemoteAccess') { } module.exports = { - hashPassword, verifyPassword, token, id, numericCode, + hashPassword, verifyPassword, token, id, hashToken, numericCode, newMfaSecret, totp, verifyTotp, otpauthUrl, }; diff --git a/server/bizgaze.js b/server/bizgaze.js index 1801983..6a588cb 100644 --- a/server/bizgaze.js +++ b/server/bizgaze.js @@ -9,6 +9,20 @@ function loginUrl() { return process.env.BIZGAZE_LOGIN_URL || ''; } const isEnabled = () => !!loginUrl(); +// Origin of the BizGaze app (e.g. https://c02.bizgaze.app), derived from the login URL. +function loginOrigin() { try { return new URL(loginUrl()).origin; } catch { return ''; } } + +// Build an absolute profile-photo URL from the session payload. BizGaze returns a +// relative path like "_files/documents/.../x.jpg" plus an asset/app base; we try the +// asset host first, then the app host, then the login origin. Absolute URLs pass through. +function photoUrlFrom(s) { + const raw = s.photoUrl || s.PhotoUrl || s.photo || s.profilePic || s.imageUrl || ''; + if (!raw || typeof raw !== 'string') return null; + if (/^https?:\/\//i.test(raw)) return raw; + const base = String(s.assetUrl || s.appUrl || loginOrigin() || '').replace(/\/+$/, ''); + return base ? base + '/' + raw.replace(/^\/+/, '') : null; +} + async function validateLogin(username, password) { const url = loginUrl(); if (!url) return { ok: false, configured: false }; @@ -30,6 +44,7 @@ async function validateLogin(username, password) { return { ok: true, configured: true, name: s.name || null, + avatarUrl: photoUrlFrom(s), isAdmin: !!s.isAdmin, tenantRef: s.tenantId != null ? String(s.tenantId) : null, // BizGaze tenant (org) id bizgazeUserId: s.userId != null ? String(s.userId) : null, diff --git a/server/config.js b/server/config.js index bb50de2..07aafbf 100644 --- a/server/config.js +++ b/server/config.js @@ -5,8 +5,10 @@ const path = require('path'); const PUBLIC_DIR = path.join(__dirname, 'public'); const REC_DIR = path.join(__dirname, 'recordings'); const TRANS_DIR = path.join(__dirname, 'transcripts'); +const UPLOADS_DIR = path.join(__dirname, 'uploads'); try { fs.mkdirSync(REC_DIR, { recursive: true }); } catch (e) {} try { fs.mkdirSync(TRANS_DIR, { recursive: true }); } catch (e) {} +try { fs.mkdirSync(UPLOADS_DIR, { recursive: true }); } catch (e) {} module.exports = { PORT: process.env.PORT || 8090, @@ -14,5 +16,7 @@ module.exports = { PUBLIC_DIR, REC_DIR, TRANS_DIR, - SESSION_TTL: 1000 * 60 * 60 * 24, // 24h auto-logout + UPLOADS_DIR, + SESSION_TTL: 1000 * 60 * 60 * 24, // 24h access-token / cookie lifetime + REFRESH_TTL: 1000 * 60 * 60 * 24 * 90, // 90d refresh-token lifetime (native clients) }; diff --git a/server/db.js b/server/db.js index 31ed1e0..3ad8760 100644 --- a/server/db.js +++ b/server/db.js @@ -81,4 +81,210 @@ CREATE TABLE IF NOT EXISTS sessions_log ( try { db.exec('ALTER TABLE sessions_log ADD COLUMN recording TEXT'); } catch (e) { /* exists */ } try { db.exec('ALTER TABLE sessions_log ADD COLUMN transcript TEXT'); } catch (e) { /* exists */ } +// Refresh tokens for native (desktop/mobile) clients: long-lived, rotated on use, +// stored as a SHA-256 hash so a DB leak doesn't expose usable tokens. +db.exec(` +CREATE TABLE IF NOT EXISTS refresh_tokens ( + token_hash TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + revoked INTEGER NOT NULL DEFAULT 0 +); +`); + +// API keys for third-party / system integrations (machine-to-machine, no human login). +// Scoped per tenant; the key is stored as a SHA-256 hash (plaintext shown once at creation). +db.exec(` +CREATE TABLE IF NOT EXISTS api_keys ( + id TEXT PRIMARY KEY, + team_id TEXT NOT NULL, + name TEXT, + key_hash TEXT NOT NULL UNIQUE, + scopes TEXT NOT NULL DEFAULT '', + created_by TEXT, + created_at INTEGER NOT NULL, + last_used_at INTEGER, + revoked INTEGER NOT NULL DEFAULT 0 +); +`); + +// Outbound webhook subscriptions: per-tenant endpoints that receive signed event +// callbacks (session.started / session.ended). Each has its own signing secret. +db.exec(` +CREATE TABLE IF NOT EXISTS webhooks ( + id TEXT PRIMARY KEY, + team_id TEXT NOT NULL, + url TEXT NOT NULL, + secret TEXT NOT NULL, + events TEXT NOT NULL DEFAULT '', + active INTEGER NOT NULL DEFAULT 1, + created_by TEXT, + created_at INTEGER NOT NULL, + last_status INTEGER, + last_error TEXT, + last_at INTEGER +); +`); + +// Persistent 1:1 chat between users in the same team. +db.exec(` +CREATE TABLE IF NOT EXISTS messages ( + id TEXT PRIMARY KEY, + team_id TEXT NOT NULL, + sender_id TEXT NOT NULL, + recipient_id TEXT NOT NULL, + body TEXT NOT NULL, + created_at INTEGER NOT NULL, + read_at INTEGER +); +CREATE INDEX IF NOT EXISTS idx_messages_pair ON messages(team_id, sender_id, recipient_id, created_at); +CREATE INDEX IF NOT EXISTS idx_messages_unread ON messages(team_id, recipient_id, sender_id, read_at); +`); +// Migration: a message can quote/reply to another message. +try { db.exec('ALTER TABLE messages ADD COLUMN reply_to TEXT'); } catch (e) { /* exists */ } + +// Emoji reactions on messages (one row per user+message+emoji; toggling adds/removes). +db.exec(` +CREATE TABLE IF NOT EXISTS message_reactions ( + message_id TEXT NOT NULL, + user_id TEXT NOT NULL, + emoji TEXT NOT NULL, + created_at INTEGER NOT NULL, + PRIMARY KEY (message_id, user_id, emoji) +); +`); + +// File attachments for chat messages (file bytes stored on disk at uploads/). +db.exec(` +CREATE TABLE IF NOT EXISTS attachments ( + id TEXT PRIMARY KEY, + team_id TEXT NOT NULL, + uploader_id TEXT NOT NULL, + name TEXT NOT NULL, + mime TEXT, + size INTEGER, + created_at INTEGER NOT NULL +); +`); +try { db.exec('ALTER TABLE messages ADD COLUMN attachment_id TEXT'); } catch (e) { /* exists */ } + +// Group conversations + membership. (1:1 DMs keep using sender_id/recipient_id directly; +// group messages set conversation_id instead, with recipient_id left blank.) +db.exec(` +CREATE TABLE IF NOT EXISTS conversations ( + id TEXT PRIMARY KEY, + team_id TEXT NOT NULL, + type TEXT NOT NULL DEFAULT 'group', + name TEXT, + created_by TEXT, + created_at INTEGER NOT NULL +); +CREATE TABLE IF NOT EXISTS conversation_members ( + conversation_id TEXT NOT NULL, + user_id TEXT NOT NULL, + last_read_at INTEGER NOT NULL DEFAULT 0, + joined_at INTEGER NOT NULL, + PRIMARY KEY (conversation_id, user_id) +); +`); +try { db.exec('ALTER TABLE messages ADD COLUMN conversation_id TEXT'); } catch (e) { /* exists */ } +try { db.exec('CREATE INDEX IF NOT EXISTS idx_messages_conv ON messages(conversation_id, created_at)'); } catch (e) {} +// Group admins: 1 = this member is an admin (multiple admins allowed). Creator seeded as admin. +try { db.exec('ALTER TABLE conversation_members ADD COLUMN admin INTEGER NOT NULL DEFAULT 0'); } catch (e) { /* exists */ } +try { db.exec('UPDATE conversation_members SET admin=1 WHERE user_id IN (SELECT created_by FROM conversations WHERE conversations.id=conversation_members.conversation_id) AND admin=0'); } catch (e) {} + +// Avatars: a user's profile picture (BizGaze photo URL) and a group's uploaded image +// (an attachment id, served via /files/ with group-membership auth). +try { db.exec('ALTER TABLE users ADD COLUMN avatar_url TEXT'); } catch (e) { /* exists */ } +try { db.exec('ALTER TABLE conversations ADD COLUMN avatar_id TEXT'); } catch (e) { /* exists */ } + +// @mentions on a (group) message: JSON array of mentioned user ids, and/or the literal +// "everyone" for @everyone/@all. Used to highlight and notify mentioned members. +try { db.exec('ALTER TABLE messages ADD COLUMN mentions TEXT'); } catch (e) { /* exists */ } + +// Delivered receipt for DMs (double tick): set when the recipient's client acknowledges. +try { db.exec('ALTER TABLE messages ADD COLUMN delivered_at INTEGER'); } catch (e) { /* exists */ } +// Group setting: when 1, only the creator can add/remove members. +try { db.exec('ALTER TABLE conversations ADD COLUMN admin_only INTEGER NOT NULL DEFAULT 0'); } catch (e) { /* exists */ } + +// Polls live within a group conversation, attached to a message (the poll's question is +// the message body). options is a JSON array of option strings; votes are one row each. +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 */ } +db.exec(` +CREATE TABLE IF NOT EXISTS polls ( + id TEXT PRIMARY KEY, + team_id TEXT NOT NULL, + conversation_id TEXT NOT NULL, + message_id TEXT, + question TEXT NOT NULL, + options TEXT NOT NULL, + multi INTEGER NOT NULL DEFAULT 0, + closed INTEGER NOT NULL DEFAULT 0, + created_by TEXT NOT NULL, + created_at INTEGER NOT NULL +); +CREATE TABLE IF NOT EXISTS poll_votes ( + poll_id TEXT NOT NULL, + user_id TEXT NOT NULL, + option_idx INTEGER NOT NULL, + created_at INTEGER NOT NULL, + PRIMARY KEY (poll_id, user_id, option_idx) +); +`); + +// Scheduled meetings/calls. Each carries a stable room_code so a scheduled call can be +// joined later (the live mesh room is created on first join). group_id is optional — a +// scheduled meeting may target a specific group conversation or be standalone. +db.exec(` +CREATE TABLE IF NOT EXISTS scheduled_meetings ( + id TEXT PRIMARY KEY, + team_id TEXT NOT NULL, + group_id TEXT, + room_code TEXT NOT NULL, + title TEXT NOT NULL, + description TEXT, + scheduled_at INTEGER NOT NULL, + created_by TEXT NOT NULL, + created_at INTEGER NOT NULL, + ended_at INTEGER +); +CREATE INDEX IF NOT EXISTS idx_sched_team ON scheduled_meetings(team_id, scheduled_at); +CREATE INDEX IF NOT EXISTS idx_sched_code ON scheduled_meetings(room_code); +`); +// Invited participants (JSON array of user ids) + a one-shot "10-min reminder sent" flag. +try { db.exec('ALTER TABLE scheduled_meetings ADD COLUMN participants TEXT'); } catch (e) { /* exists */ } +try { db.exec('ALTER TABLE scheduled_meetings ADD COLUMN reminded INTEGER NOT NULL DEFAULT 0'); } catch (e) { /* exists */ } +// Cancelled meetings are kept (shown as "Cancelled"), not deleted. +try { db.exec('ALTER TABLE scheduled_meetings ADD COLUMN cancelled INTEGER NOT NULL DEFAULT 0'); } catch (e) { /* exists */ } +try { db.exec('ALTER TABLE scheduled_meetings ADD COLUMN duration_mins INTEGER'); } catch (e) { /* exists */ } +// Weekly recurrence: JSON array of weekdays (0=Sun..6=Sat), or null for a one-off. +try { db.exec('ALTER TABLE scheduled_meetings ADD COLUMN recurrence TEXT'); } catch (e) { /* exists */ } + +// Meeting recordings & transcripts. Video bytes live in recordings/m_.webm, transcript text +// in transcripts/m_.txt. Tied to a room (and group/scheduled meeting when applicable) so they +// surface under "Past meetings". kind = 'video' | 'transcript'. +db.exec(` +CREATE TABLE IF NOT EXISTS recordings ( + id TEXT PRIMARY KEY, + team_id TEXT NOT NULL, + room TEXT, + group_id TEXT, + meeting_id TEXT, + title TEXT, + kind TEXT NOT NULL, + file TEXT, + mime TEXT, + size INTEGER, + duration_ms INTEGER, + created_by TEXT, + created_by_name TEXT, + created_at INTEGER NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_rec_team ON recordings(team_id, created_at); +CREATE INDEX IF NOT EXISTS idx_rec_room ON recordings(room); +`); + module.exports = db; diff --git a/server/presence.js b/server/presence.js index f3f1de5..b2f6872 100644 --- a/server/presence.js +++ b/server/presence.js @@ -4,4 +4,13 @@ module.exports = { onlineAgents: new Map(), // machineId -> { ws, machine } liveSessions: new Map(), // sessionId -> { agentWs, viewerWs, machine, user } pendingShares: new Map(), // code -> { sharerWs, sessionId } (no-install ad-hoc shares) + chatClients: new Map(), // userId -> Set (a user may have several tabs/devices open) + meetingRooms: new Map(), // roomCode -> Map(peerId -> { ws, name }) (mesh meetings MVP) + groupCalls: new Map(), // groupId -> { room, startedAt, startedBy, startedByName } (shared group calls) + roomToGroupCall: new Map(),// roomCode -> groupId (end a group call when its room empties) + dmCalls: new Map(), // pairKey "a|b" -> { room, startedAt, startedBy, startedByName, users:[a,b] } + roomToDmCall: new Map(), // roomCode -> pairKey (end a 1:1 call when its room empties) + roomHost: new Map(), // roomCode -> userId (the meeting creator = host; transferable in-call) + transcriptBuffers: new Map(),// roomCode -> [{ t, speaker, text }] (shared full-conversation buffer) + transcriptSubs: new Map(), // roomCode -> Set(userId) (who wants a private copy of the transcript) }; diff --git a/server/public/connect.html b/server/public/connect.html index dea61c7..07eb440 100644 --- a/server/public/connect.html +++ b/server/public/connect.html @@ -31,6 +31,10 @@ .topbar2.show{display:flex;} #barStatus{font-weight:600;font-size:.9rem;color:var(--blue);} #endBtn{padding:.45rem 1rem;background:#fee2e2;color:#b91c1c;border:none;border-radius:8px;font-weight:600;cursor:pointer;} #video{width:100vw;height:calc(100vh - 46px);background:#0b1220;object-fit:contain;display:none;cursor:crosshair;outline:none;} + /* Control bar sits vertically on the RIGHT; reserve a thin right strip so the screen uses the full + height/width otherwise (maximised view) and the icons never overlay the shared content. */ + body.has-bar #video{width:calc(100vw - 76px);} + body.has-bar{background:#0b1220;} .profile{position:relative} .profile .pbtn{display:flex;align-items:center;gap:.4rem;background:rgba(255,255,255,.14);color:#fff;border:1px solid #46598c;border-radius:10px;padding:.45rem .85rem;font-weight:600;font-size:.88rem;cursor:pointer} .profile .pbtn:hover{background:rgba(255,255,255,.24)} @@ -53,10 +57,11 @@ html.embed #homeLink{display:none!important;} html.embed #video{height:100vh!important;} + -← Home + Home
BizGaze Support
@@ -68,7 +73,7 @@ + diff --git a/server/public/dashboard.html b/server/public/dashboard.html index 524c946..e226356 100644 --- a/server/public/dashboard.html +++ b/server/public/dashboard.html @@ -28,6 +28,11 @@ th,td{text-align:left;padding:.55rem .5rem;border-bottom:1px solid var(--line);} .pill{font-size:.74rem;font-weight:600;padding:.15rem .55rem;border-radius:99px;} .pill.on{background:#ecfdf3;color:#15803d;} + .pill.off{background:#fee2e2;color:var(--red);} + .reveal{margin-top:1rem;background:#f1f7ec;border:1px solid #cfe8bf;border-radius:10px;padding:.8rem 1rem;} + .reveal code{flex:1;word-break:break-all;background:#fff;border:1px solid var(--line);border-radius:8px;padding:.5rem .6rem;font-size:.85rem;} + .chk{display:flex;align-items:center;gap:.4rem;font-size:.85rem;} + .chk input{width:16px;height:16px;margin:0;accent-color:var(--blue);} .hidden{display:none;} .tabs{display:flex;gap:.5rem;margin-bottom:1.2rem;} .tabs button{background:#eef1f6;color:var(--muted);font-weight:600;} @@ -64,7 +69,9 @@ .profile .pmenu a{display:block;padding:.6rem .9rem;color:#1f2430;text-decoration:none;font-size:.9rem;cursor:pointer} .profile .pmenu a:hover{background:#f1f5f9} .profile .pmenu a.danger{color:#b91c1c;border-top:1px solid #eef1f6} + .ic{display:inline-block;vertical-align:middle} +
@@ -188,21 +195,109 @@ async function dashboard(me) {
From
To
- - + +
${IS_ADMIN ? '' : ''} ${IS_ADMIN ? '' : ''}
DateStart timeAgentTicketTime spentRecording / Transcript

- `); + + ${IS_ADMIN ? ` +
+

API keys — let other systems read your data programmatically

+
NameScopesCreatedLast usedStatus
+
+
Name
+ + + +
+
+
+
+

Webhooks — signed event callbacks to your systems

+
EndpointEventsStatusLast delivery
+
+
Endpoint URL
+ + + +
+
+
` : ''}`); document.getElementById('fApply').onclick = loadReport; document.getElementById('fExcel').onclick = exportExcel; document.getElementById('fPdf').onclick = exportPdf; if (IS_ADMIN) await populateAgentFilter(); await loadReport(); + if (IS_ADMIN) { + document.getElementById('kAdd').onclick = createKey; + document.getElementById('hAdd').onclick = createHook; + await loadKeys(); + await loadHooks(); + } } +// ---------- Integrations: API keys + webhooks (admin) ---------- +function fmtTs(ms){ return ms ? new Date(ms).toLocaleString() : '—'; } +function revealBox(label, value, note){ + return '
'+esc(label)+' — copy now
' + + '
'+esc(value)+'' + + '
' + + '
'+esc(note)+'
'; +} +function wireCopy(){ const b=document.getElementById('copyReveal'); if(!b)return; b.onclick=async()=>{ try{ await navigator.clipboard.writeText(document.getElementById('revealVal').textContent); }catch(_){} b.textContent='Copied'; setTimeout(()=>{b.textContent='Copy';},1500); }; } + +async function loadKeys(){ + let rows=[]; try{ rows = await api('/api/keys', null, 'GET'); }catch(e){ return; } + document.querySelector('#keys tbody').innerHTML = rows.length ? rows.map(k=>` + + ${esc(k.name||'—')} + ${esc(k.scopes||'')} + ${fmtTs(k.created_at)} + ${fmtTs(k.last_used_at)} + ${k.revoked?'revoked':'active'} + ${k.revoked?'':``} + `).join('') : 'No API keys yet.'; +} +async function createKey(){ + const scopes=[]; if(document.getElementById('kReport').checked)scopes.push('report:read'); if(document.getElementById('kAudit').checked)scopes.push('audit:read'); + if(!scopes.length){ document.getElementById('kOut').innerHTML='

Select at least one scope.

'; return; } + try{ + const r = await api('/api/keys', { name: document.getElementById('kName').value, scopes }, 'POST'); + document.getElementById('kName').value=''; + document.getElementById('kOut').innerHTML = revealBox('API key', r.key, "Send this to the integrator. It won't be shown again — revoke and re-issue if lost."); + wireCopy(); loadKeys(); + }catch(e){ document.getElementById('kOut').innerHTML='

'+esc(e.message)+'

'; } +} +window.revokeKey = async (id)=>{ if(!confirm('Revoke this API key? Integrations using it will stop working.'))return; try{ await api('/api/keys/revoke',{id},'POST'); loadKeys(); }catch(e){} }; + +async function loadHooks(){ + let rows=[]; try{ rows = await api('/api/webhooks', null, 'GET'); }catch(e){ return; } + document.querySelector('#hooks tbody').innerHTML = rows.length ? rows.map(h=>` + + ${esc(h.url)} + ${esc(h.events||'')} + ${h.last_status==null?'':(h.last_status?'ok':'failing')} + ${fmtTs(h.last_at)}${h.last_error?' '+ic('alertTriangle',13)+'':''} + + `).join('') : 'No webhooks yet.'; +} +async function createHook(){ + const events=[]; if(document.getElementById('hStarted').checked)events.push('session.started'); if(document.getElementById('hEnded').checked)events.push('session.ended'); + const url=document.getElementById('hUrl').value.trim(); + if(!/^https?:\/\//i.test(url)){ document.getElementById('hOut').innerHTML='

Enter a valid http(s) URL.

'; return; } + if(!events.length){ document.getElementById('hOut').innerHTML='

Select at least one event.

'; return; } + try{ + const r = await api('/api/webhooks', { url, events }, 'POST'); + document.getElementById('hUrl').value=''; + document.getElementById('hOut').innerHTML = revealBox('Signing secret', r.secret, 'Verify the X-BizGaze-Signature header (HMAC-SHA256 of the body) with this. Shown once.'); + wireCopy(); loadHooks(); + }catch(e){ document.getElementById('hOut').innerHTML='

'+esc(e.message)+'

'; } +} +window.deleteHook = async (id)=>{ if(!confirm('Delete this webhook?'))return; try{ await api('/api/webhooks/delete',{id},'POST'); loadHooks(); }catch(e){} }; + const PER_PAGE = 5; function pagerHTML(page, pages, total, fn){ if (total <= PER_PAGE) return total ? `${total} total` : ''; @@ -241,8 +336,8 @@ function reportRowHTML(r){ ${esc(r.ticket || 'Direct session')} ${r.ended_at ? fmtDuration(dur) : 'in progress'} ${[ - r.recording ? `⬇ Video` : '', - r.transcript ? `⬇ Text` : '' + r.recording ? `${ic('download',14)} Video` : '', + r.transcript ? `${ic('download',14)} Text` : '' ].join('') || ''} `; } diff --git a/server/public/home.html b/server/public/home.html index a6ef601..a52f542 100644 --- a/server/public/home.html +++ b/server/public/home.html @@ -8,27 +8,50 @@ :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;} html,body{height:100%;} - body{font-family:'Segoe UI',system-ui,sans-serif;background:var(--bg);color:var(--ink);margin:0;display:flex;flex-direction:column;height:100vh;overflow:hidden;} + 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;} + 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;} .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;} + /* ---- 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: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-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;} + .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;} + .bell-list{max-height:62vh;overflow:auto;} + .bell-item{display:flex;gap:.6rem;align-items:flex-start;padding:.6rem .85rem;border-bottom:1px solid #f4f6fa;cursor:pointer;} + .bell-item:hover{background:#f6f8fb;} + .bell-item.unread{background:#eef4ff;} + .bell-ico{width:30px;height:30px;border-radius:50%;background:var(--blue-soft);color:var(--blue);display:grid;place-items:center;flex:0 0 30px;} + .bell-body{min-width:0;} + .bell-tx{font-size:.85rem;color:var(--ink);line-height:1.3;} + .bell-tm{font-size:.7rem;color:var(--muted);margin-top:.1rem;} + .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{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{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 .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} + .profile .pbtn .pav img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover} .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} .profile .pmenu .phead{padding:.7rem .9rem;border-bottom:1px solid #eef1f6} .profile .pmenu .phead .n{font-weight:700;font-size:.9rem} .profile .pmenu .phead .e{color:var(--muted);font-size:.78rem} - .profile .pmenu a{display:block;padding:.6rem .9rem;color:#1f2430;text-decoration:none;font-size:.9rem;cursor:pointer} + .profile .pmenu a{display:flex;align-items:center;gap:.55rem;padding:.6rem .9rem;color:#1f2430;text-decoration:none;font-size:.9rem;cursor:pointer} .profile .pmenu a:hover{background:#f1f5f9} + .profile .pmenu a .ic{color:var(--muted)} .profile .pmenu a.danger{color:#b91c1c;border-top:1px solid #eef1f6} + .profile .pmenu a.danger .ic{color:#b91c1c} /* ---- Shell ---- */ .shell{flex:1 1 auto;display:flex;min-height:0;} @@ -44,7 +67,7 @@ .railbtn.active .livedot{border-color:var(--blue);} .railbtn.live .livedot{display:block;animation:livePulse 1.4s infinite;} @keyframes livePulse{0%,100%{opacity:1}50%{opacity:.3}} - .railbtn .rlabel{font-size:.6rem;margin-top:0;} + .railbtn .rlabel{display:none;font-size:.6rem;margin-top:0;} /* tooltip */ .railbtn::after{content:attr(data-tip);position:absolute;left:calc(100% + 12px);top:50%;transform:translateY(-50%);background:var(--blue-d);color:#fff;padding:.35rem .6rem;border-radius:8px;font-size:.78rem;font-weight:600;white-space:nowrap;opacity:0;pointer-events:none;transition:opacity .14s;z-index:200;box-shadow:0 6px 16px rgba(0,0,0,.25);} .railbtn::before{content:"";position:absolute;left:calc(100% + 6px);top:50%;transform:translateY(-50%);border:6px solid transparent;border-right-color:var(--blue-d);opacity:0;pointer-events:none;transition:opacity .14s;z-index:200;} @@ -55,33 +78,44 @@ /* ---- Chat list column ---- */ .chatcol{width:312px;flex:0 0 312px;background:var(--card);border-right:1px solid var(--line);display:flex;flex-direction:column;min-height:0;} .chatcol.hidden{display:none;} + .side-sec{font-size:.68rem;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);font-weight:700;padding:.6rem .9rem .25rem;} + .contact-row,.dir-row{display:flex;align-items:center;gap:.6rem;padding:.5rem .9rem;cursor:pointer;} + .contact-row:hover,.dir-row:hover{background:var(--blue-soft);} + .contact-row .cr-name,.dir-row .cr-name{font-size:.9rem;color:var(--ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;} + .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;} .search{position:relative;} - .search svg{position:absolute;left:.65rem;top:50%;transform:translateY(-50%);color:var(--muted);} + .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;} .search input:focus{outline:none;border-color:var(--brand);} + .search input{padding-right:2.1rem;} + .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;} .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);} + .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 .dot{position:absolute;right:-1px;bottom:-1px;width:11px;height:11px;border-radius:50%;border:2px solid #fff;background:#cbd2dd;} + .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);} .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:600;font-size:.92rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;} + .chat-name{font-weight:400;font-size:.92rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;} .chat-time{color:var(--muted);font-size:.72rem;flex:0 0 auto;} .chat-bottom{display:flex;justify-content:space-between;align-items:center;gap:.5rem;margin-top:.15rem;} .chat-prev{color:var(--muted);font-size:.82rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1 1 auto;} .chat-row.unread .chat-prev{color:var(--ink);font-weight:500;} .chat-row.unread .chat-name{font-weight:700;} - .badge{flex:0 0 auto;background:var(--blue);color:#fff;font-size:.7rem;font-weight:700;min-width:19px;height:19px;border-radius:99px;padding:0 .35rem;display:grid;place-items:center;} + .badge{flex:0 0 auto;background:var(--brand);color:var(--blue);font-size:.7rem;font-weight:800;min-width:19px;height:19px;border-radius:99px;padding:0 .35rem;display:grid;place-items:center;} .no-results{padding:2rem 1rem;text-align:center;color:var(--muted);font-size:.85rem;} .demo-note{padding:.5rem 1rem;border-top:1px solid var(--line);color:var(--muted);font-size:.72rem;text-align:center;background:#fbfcfe;} @@ -117,12 +151,358 @@ /* 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-back{border:none;background:var(--blue-soft);color:var(--blue);width:34px;height:34px;border-radius:9px;font-size:1.1rem;cursor:pointer;display:grid;place-items:center;flex:0 0 auto;} + .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);} .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 */ + .convo-msgs{flex:1;overflow-y:auto;padding:1rem 1.2rem;display:flex;flex-direction:column;gap:.35rem;background:var(--bg);} + .bubble{max-width:72%;padding:.5rem .75rem;border-radius:14px;font-size:.9rem;line-height:1.4;white-space:pre-wrap;word-break:break-word;box-shadow:0 1px 2px rgba(20,30,60,.06);} + .bubble.them{align-self:flex-start;background:#fff;border:1px solid var(--line);color:var(--ink);border-bottom-left-radius:4px;} + .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;} + .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;} + .jump-latest:hover{background:var(--brand);color:var(--blue);border-color:var(--brand-d);} + .empty-thread{align-self:center;font-size:.85rem;color:var(--muted);margin:auto;} + .sys-msg{align-self:center;font-size:.76rem;color:var(--muted);background:rgba(0,0,0,.04);padding:.25rem .7rem;border-radius:99px;margin:.2rem 0;max-width:90%;text-align:center;} + .bubble .t{display:flex;align-items:center;justify-content:flex-end;gap:.25rem;font-size:.64rem;opacity:.65;margin-top:.15rem;} + .bubble .rcpt{display:inline-flex;} + .bubble.mine .rcpt{opacity:.8;} + .bubble.mine .rcpt.seen{color:#8fd3ff;opacity:1;} + .att-img{cursor:zoom-in;} + .fmt-bar{display:flex;align-items:center;gap:.05rem;padding:.3rem .4rem .1rem;flex-wrap:wrap;border-bottom:1px dashed var(--line);} + .fmt-bar button{border:none;background:transparent;color:var(--muted);cursor:pointer;width:30px;height:30px;border-radius:7px;display:grid;place-items:center;} + .fmt-bar button:hover{color:var(--blue);background:var(--blue-soft);} + .fmt-sep{width:1px;height:18px;background:var(--line);margin:0 .3rem;} + .bubble .msg-list{margin:.15rem 0;padding-left:1.25rem;} + .bubble .msg-list li{margin:.05rem 0;} + .bubble code{background:rgba(0,0,0,.08);padding:.05rem .3rem;border-radius:5px;font-size:.86em;font-family:ui-monospace,Consolas,monospace;} + .bubble.mine code{background:rgba(255,255,255,.2);} + .lightbox{position:fixed;inset:0;z-index:6000;background:rgba(8,12,22,.88);display:flex;align-items:center;justify-content:center;} + .lightbox img{max-width:92vw;max-height:88vh;border-radius:10px;box-shadow:0 16px 50px rgba(0,0,0,.5);} + .lightbox .lb-close,.lightbox .lb-dl{position:absolute;top:18px;border:none;background:rgba(255,255,255,.14);color:#fff;width:44px;height:44px;border-radius:50%;display:grid;place-items:center;cursor:pointer;text-decoration:none;} + .lightbox .lb-close{right:18px;} + .lightbox .lb-dl{right:74px;} + .lightbox .lb-close:hover,.lightbox .lb-dl:hover{background:rgba(255,255,255,.28);} + .composer{display:flex;align-items:flex-end;gap:.5rem;padding:.6rem .8rem;border-top:1px solid var(--line);background:var(--card);} + .composer-box{flex:1;min-width:0;border:1.5px solid var(--line);border-radius:16px;background:#fbfcfe;display:flex;flex-direction:column;overflow:hidden;} + .composer-box:focus-within{border-color:var(--blue);} + .composer-row{display:flex;align-items:flex-end;gap:.15rem;padding:.15rem .3rem;} + .composer-row input,.composer-row textarea{flex:1;min-width:0;box-sizing:border-box;border:none;background:transparent;padding:.5rem .4rem;margin:0;font-size:.92rem;color:var(--ink);font-family:inherit;resize:none;line-height:1.45;max-height:140px;overflow-y:hidden;display:block;scrollbar-width:thin;} + .composer-row input:focus,.composer-row textarea:focus{outline:none;} + .ic-btn{border:none;background:transparent;color:var(--muted);cursor:pointer;width:36px;height:36px;border-radius:10px;display:grid;place-items:center;flex:0 0 auto;} + .ic-btn:hover{color:var(--blue);background:var(--blue-soft);} + .attach-preview{padding:.55rem .6rem .15rem;} + .ap-item{display:inline-flex;align-items:center;gap:.55rem;background:#fff;border:1px solid var(--line);border-radius:11px;padding:.35rem .5rem;max-width:100%;} + .ap-thumb{width:42px;height:42px;border-radius:8px;object-fit:cover;flex:0 0 auto;} + .ap-ic{display:grid;place-items:center;color:var(--blue);flex:0 0 auto;} + .ap-name{font-size:.82rem;color:var(--ink);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:240px;} + .ap-x{border:none;background:transparent;color:var(--muted);cursor:pointer;display:grid;place-items:center;padding:.15rem;border-radius:6px;flex:0 0 auto;} + .ap-x:hover{color:var(--red);background:#fee2e2;} + /* meetings */ + .meet{display:flex;flex-direction:column;width:100%;height:100%;} + .meet-grid{flex:1;display:grid;gap:.6rem;padding:.8rem;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));align-content:start;overflow:auto;background:#0b1220;} + .meet-tile{position:relative;background:#0b1220;border-radius:12px;overflow:hidden;aspect-ratio:4/3;border:1px solid #1e293b;transition:box-shadow .12s,border-color .12s;} + .meet-tile.speaking{border-color:#22c55e;box-shadow:0 0 0 2px #22c55e, 0 0 14px rgba(34,197,94,.5);} + .meet-tile .meet-screen{position:absolute;right:.5rem;top:.5rem;display:inline-flex;align-items:center;gap:.25rem;background:rgba(37,99,235,.92);color:#fff;font-size:.66rem;font-weight:700;padding:.14rem .42rem;border-radius:6px;} + .meet-tile.sharing{grid-column:span 2;border-color:#2563eb;} + /* Screen-share "stage": the chosen shared screen (.stage) fills a FIXED area on the left; every + other tile — participants AND any other shared screens — sits in a persistent small column on the + right. Click another shared screen there to switch which one is on the stage. Screen never shrinks. */ + .meet-grid.sharing-mode{display:flex;flex-flow:column wrap;align-content:flex-start;height:100%;min-height:0;gap:.5rem;} + .meet-grid.sharing-mode .meet-tile{aspect-ratio:auto;} + .meet-grid.sharing-mode .meet-tile.stage{order:-1;height:100%;width:calc(100% - 176px);min-width:0;border-color:#2563eb;} + .meet-grid.sharing-mode .meet-tile.stage video{object-fit:contain;background:#000;} + .meet-grid.sharing-mode .meet-tile:not(.stage){width:160px;height:94px;flex:0 0 auto;} + .meet-grid.sharing-mode .meet-tile.sharing:not(.stage){cursor:pointer;} + .meet-grid.sharing-mode .meet-tile.sharing:not(.stage)::after{content:"Click to view";position:absolute;left:0;right:0;bottom:0;font-size:.58rem;text-align:center;background:rgba(37,99,235,.88);color:#fff;padding:1px 0;} + @media (max-width:760px){ + .meet-grid.sharing-mode{flex-flow:row wrap;height:auto;} + .meet-grid.sharing-mode .meet-tile.stage{width:100%;height:auto;flex:1 1 100%;min-height:40vh;} + .meet-grid.sharing-mode .meet-tile:not(.stage){width:46%;height:84px;} + } + .meet-bar .meet-ic.on{background:var(--blue);color:#fff;} + .meet-bar #meetRecBtn.on{background:#dc2626;color:#fff;animation:livePulse 1.6s infinite;} + .rec-notice{position:fixed;top:14px;left:50%;transform:translateX(-50%);z-index:6500;display:inline-flex;align-items:center;gap:.4rem;background:rgba(220,38,38,.95);color:#fff;font-size:.82rem;font-weight:700;padding:.32rem .7rem;border-radius:99px;box-shadow:0 6px 18px rgba(220,38,38,.4);} + .rec-notice .rec-dot{width:9px;height:9px;border-radius:50%;background:#fff;animation:livePulse 1.4s infinite;} + .tx-notice{position:fixed;top:14px;left:14px;z-index:6500;display:inline-flex;align-items:center;gap:.35rem;background:rgba(37,99,235,.95);color:#fff;font-size:.78rem;font-weight:700;padding:.3rem .65rem;border-radius:99px;box-shadow:0 6px 18px rgba(37,99,235,.4);} + .si-recs{display:flex;flex-wrap:wrap;gap:.5rem;margin-top:.55rem;} + .rec-dl{display:inline-flex;align-items:center;gap:.35rem;font-size:.78rem;font-weight:700;text-decoration:none;border-radius:9px;padding:.36rem .7rem;border:1px solid transparent;transition:filter .12s;} + .rec-dl:hover{filter:brightness(.96);} + .rec-dl.vid{background:#eef2ff;color:#4338ca;border-color:#c7d2fe;} + .rec-dl.txt{background:#ecfdf5;color:#047857;border-color:#a7f3d0;} + .rec-dl .rd-dur{background:rgba(0,0,0,.09);border-radius:6px;padding:.04rem .32rem;font-size:.72rem;font-weight:700;} + .meet-panel .pp-screen{color:var(--blue);display:inline-flex;} + .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-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;} + .meet-panel .mp-muteall{border:1px solid var(--line);background:#fee2e2;color:var(--red);border-radius:8px;padding:.3rem .55rem;font-size:.78rem;font-weight:600;cursor:pointer;display:inline-flex;align-items:center;gap:.25rem;} + .meet-panel .mp-x{border:none;background:transparent;color:var(--muted);cursor:pointer;display:grid;place-items:center;} + .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 .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;} + .meet-panel .chk .mn{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;} + .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-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;} + .iconbtn.role{color:var(--muted);} .iconbtn.role.is-admin{color:#d4a106;} .iconbtn.role:hover{color:#d4a106;} + .si-invited{display:inline-flex;align-items:center;gap:.3rem;font-size:.76rem;color:var(--muted);margin-top:.25rem;} + .meet-tile.novid .meet-av{display:flex;} + .meet-tile.novid video{visibility:hidden;} + .meet-bar{display:flex;align-items:center;gap:.6rem;padding:.7rem 1rem;background:var(--card);border-top:1px solid var(--line);} + .meet-bar .code{margin-right:auto;color:var(--muted);font-size:.85rem;} + .meet-bar .code b{color:var(--blue);font-size:1rem;letter-spacing:.06em;} + .meet-bar button{border:none;border-radius:10px;padding:.6rem 1rem;font-weight:600;cursor:pointer;font-size:.9rem;display:inline-flex;align-items:center;gap:.35rem;} + .meet-bar .tgl{background:#eef1f6;color:var(--blue);} + .meet-bar .tgl.off{background:#fee2e2;color:var(--red);} + .meet-bar .leave{background:#dc2626;color:#fff;} + .meet-bar .meet-ic{width:46px;height:46px;padding:0;border-radius:50%;background:#e8edf5;color:var(--blue);display:inline-flex;align-items:center;justify-content:center;} + .meet-bar .meet-ic:hover{background:#dbe4f0;} + .meet-bar .meet-ic.off{background:#fee2e2;color:var(--red);} + .meet-bar .meet-ic.leave,.meet-bar .meet-ic.leave.off{background:#dc2626;color:#fff;} + .meet-bar .meet-ic.leave:hover{background:#b91c1c;} + /* Meetings dashboard (full-width, scrollable) */ + .meet-dash{width:100%;height:100%;overflow-y:auto;padding:1.4rem clamp(1rem,4vw,2.4rem) 2rem;background:var(--bg);} + .md-top{display:flex;align-items:flex-start;justify-content:space-between;gap:1rem;flex-wrap:wrap;margin-bottom:.5rem;} + .md-title h1{margin:0;font-size:1.5rem;color:var(--ink);} + .md-title p{margin:.3rem 0 0;color:var(--muted);font-size:.9rem;max-width:520px;} + .md-actions{display:flex;align-items:stretch;gap:.5rem;flex-wrap:wrap;} + .md-join{display:flex;gap:.4rem;flex:1 1 240px;min-width:0;} + .md-join input{flex:1 1 auto;min-width:0;text-align:center;letter-spacing:.18rem;font-size:1.05rem;font-weight:600;border:1px solid var(--line);border-radius:11px;padding:.7rem .6rem;background:var(--card);color:var(--ink);} + .md-join .btn{flex:0 0 auto;padding:.55rem 1.1rem;font-size:.9rem;} + .md-actions > .btn{white-space:nowrap;padding:.7rem 1.05rem;} + .btn.primary{background:var(--blue);color:#fff;} + .btn.primary:hover{filter:brightness(1.06);} + .meet-dash .hint{min-height:0;color:var(--red);font-size:.82rem;margin:.2rem 0;} + .sched-empty{color:var(--muted);font-size:.92rem;background:var(--card);border:1px dashed var(--line);border-radius:12px;padding:1.6rem;text-align:center;margin-top:1rem;} + /* chat: reply + emoji */ + .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-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;} + .reply-bar .rx:hover{color:var(--red);} + .reply-btn,.react-btn{display:grid;place-items:center;} + .emoji-pop{position:absolute;bottom:64px;left:12px;width:330px;height:300px;background:#fff;border:1px solid var(--line);border-radius:12px;box-shadow:0 10px 28px rgba(0,0,0,.18);z-index:50;display:flex;flex-direction:column;overflow:hidden;} + .mention-pop{position:absolute;left:12px;right:12px;bottom:64px;max-height:240px;overflow:auto;background:var(--card);border:1px solid var(--line);border-radius:12px;box-shadow:0 10px 28px rgba(0,0,0,.18);z-index:60;padding:.3rem;} + .mention-pop .mrow{display:flex;align-items:center;gap:.55rem;padding:.4rem .55rem;border-radius:8px;cursor:pointer;} + .mention-pop .mrow.sel{background:var(--blue-soft);} + .mention-pop .mn{font-weight:600;color:var(--ink);} + .mention-pop .sub{color:var(--muted);font-size:.78rem;margin-left:auto;} + .mention{color:var(--blue);background:var(--blue-soft);border-radius:5px;padding:0 .18rem;font-weight:600;} + .bubble.mine .mention{color:#fff;background:rgba(255,255,255,.22);} + .bubble.mention-me{box-shadow:0 1px 2px rgba(20,30,60,.06),inset 3px 0 0 var(--brand);} + .bubble.has-poll{max-width:88%;} + .poll{margin-top:.5rem;display:flex;flex-direction:column;gap:.35rem;min-width:240px;} + .poll-q{font-size:.72rem;font-weight:700;letter-spacing:.03em;text-transform:uppercase;opacity:.7;display:flex;align-items:center;gap:.3rem;} + .poll-opt{position:relative;overflow:hidden;display:flex;align-items:center;justify-content:space-between;gap:.5rem;border:1px solid var(--line);background:var(--card);color:var(--ink);border-radius:9px;padding:.45rem .6rem;font-size:.86rem;cursor:pointer;text-align:left;} + .poll-opt:hover:not([disabled]){border-color:var(--blue);} + .poll-opt[disabled]{cursor:default;} + .poll-opt.mine{border-color:var(--blue);background:var(--blue-soft);} + .poll-opt .po-bar{position:absolute;left:0;top:0;bottom:0;background:var(--blue-soft);z-index:0;transition:width .25s;} + .poll-opt.mine .po-bar{background:#d8e4f8;} + .poll-opt .po-txt{position:relative;z-index:1;font-weight:600;display:flex;align-items:center;gap:.3rem;} + .poll-opt .po-pct{position:relative;z-index:1;color:var(--muted);font-size:.78rem;flex:0 0 auto;} + .poll-foot{font-size:.76rem;color:var(--muted);margin-top:.1rem;} + .poll-foot .poll-close{color:var(--red);cursor:pointer;font-weight:600;} + .bubble.mine .poll-opt{color:var(--ink);} + .poll-opt-row{display:flex;align-items:center;gap:.4rem;margin-bottom:.4rem;} + .emoji-tabs{display:flex;border-bottom:1px solid var(--line);flex:0 0 auto;} + .emoji-tabs button{flex:1;border:none;background:transparent;font-size:1.05rem;padding:.35rem 0;cursor:pointer;opacity:.55;} + .emoji-tabs button.active{opacity:1;background:var(--blue-soft);} + .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;} + .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);} + /* attachments */ + .attach-btn,.emoji-btn{border:none;background:transparent;cursor:pointer;padding:.35rem;color:var(--muted);display:grid;place-items:center;border-radius:8px;} + .attach-btn:hover,.emoji-btn:hover{color:var(--blue);background:var(--blue-soft);} + .composer-row .sendbtn{flex:0 0 auto;align-self:flex-end;width:34px;height:34px;margin:1px;border:none;background:var(--brand);color:var(--ink);cursor:pointer;border-radius:50%;display:grid;place-items:center;} + .composer-row .sendbtn:hover{background:var(--brand-d);} + .att-img{max-width:240px;max-height:240px;border-radius:8px;display:block;margin:.15rem 0;} + .att-file{display:inline-flex;align-items:center;gap:.4rem;background:rgba(0,0,0,.06);border:1px solid var(--line);border-radius:8px;padding:.4rem .6rem;color:inherit;text-decoration:none;font-size:.85rem;margin:.15rem 0;max-width:240px;} + .att-file span{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;} + .bubble.mine .att-file{background:rgba(255,255,255,.18);border-color:rgba(255,255,255,.3);} + .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 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;} + .modal{background:#fff;border-radius:16px;padding:1.4rem;max-width:380px;width:100%;box-shadow:0 18px 44px rgba(0,0,0,.3);max-height:calc(100vh - 2rem);max-height:calc(100dvh - 2rem);overflow-y:auto;} + .modal h3{margin:0 0 .8rem;color:var(--blue);} + .modal input#grpName,.modal input#giName{width:100%;padding:.6rem .7rem;border:2px solid var(--line);border-radius:10px;background:#fbfcfe;font-size:.95rem;margin-bottom:.8rem;} + .modal input#grpName:focus,.modal input#giName:focus{outline:none;border-color:var(--brand);} + .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-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;} + .convo-call.joinable{background:#dcfce7;color:#15803d;} + .convo-call.joinable:hover{background:#bbf7d0;} + .convo-call span{font-size:.84rem;} + .call-on{display:inline-flex;align-items:center;gap:.3rem;color:#15803d;font-weight:600;} + .call-invite{position:fixed;right:18px;bottom:18px;z-index:6000;display:flex;align-items:center;gap:.7rem;background:#fff;border:1px solid var(--line);border-left:4px solid #15803d;border-radius:14px;padding:.7rem .9rem;box-shadow:0 12px 30px rgba(20,30,60,.25);max-width:340px;} + .call-invite .ci-ico{width:38px;height:38px;border-radius:50%;background:#dcfce7;color:#15803d;display:grid;place-items:center;flex:0 0 auto;} + .call-invite .ci-txt{font-size:.88rem;color:var(--ink);line-height:1.25;} + .call-invite .ci-join{border:none;background:#15803d;color:#fff;border-radius:9px;padding:.45rem .7rem;font-weight:700;cursor:pointer;display:inline-flex;align-items:center;gap:.3rem;flex:0 0 auto;} + .call-invite .ci-join:hover{background:#13703a;} + .call-invite .ci-x{border:none;background:transparent;color:var(--muted);cursor:pointer;display:grid;place-items:center;flex:0 0 auto;} + .call-invite .ci-x:hover{color:var(--red);} + .call-invite .ci-decline{border:none;background:#fee2e2;color:#b91c1c;border-radius:9px;padding:.45rem .7rem;font-weight:700;cursor:pointer;display:inline-flex;align-items:center;gap:.3rem;flex:0 0 auto;} + .call-invite .ci-decline:hover{background:#fecaca;} + .convo-info:hover{background:#dbe6fb;} + /* group info (Slack-style) */ + .modal.gi{max-width:400px;} + .gi-head{display:flex;align-items:center;gap:.7rem;margin-bottom:.6rem;} + .gi-name{flex:1;min-width:0;} + .gi-name input{width:100%;border:none;border-bottom:2px solid transparent;font-size:1.1rem;font-weight:700;color:var(--ink);padding:.1rem 0;background:transparent;} + .gi-name input:focus{outline:none;border-bottom-color:var(--brand);} + .gi-sub{font-size:.78rem;color:var(--muted);} + .gi-name-row{display:flex;align-items:center;gap:.4rem;} + .gi-title{font-size:1.1rem;font-weight:700;color:var(--ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;} + .iconbtn.sm{width:24px;height:24px;} + .gi-edit{display:flex;align-items:center;gap:.3rem;} + .gi-edit input{flex:1;min-width:0;border:2px solid var(--line);border-radius:8px;padding:.3rem .5rem;font-size:1rem;font-weight:600;background:#fbfcfe;color:var(--ink);} + .gi-edit input:focus{outline:none;border-color:var(--brand);} + .gi-actions{display:flex;gap:.6rem;margin:.2rem 0 .7rem;} + .gi-search{position:relative;margin-bottom:.5rem;} + .gi-search input{width:100%;border:1px solid var(--line);border-radius:9px;padding:.45rem 2rem .45rem .6rem;font-size:.88rem;background:#fbfcfe;color:var(--ink);box-sizing:border-box;} + .gi-search input:focus{outline:none;border-color:var(--blue);} + .gi-noresult{text-align:center;color:var(--muted);font-size:.84rem;padding:.7rem 0;} + .gi-created{font-size:.78rem;color:var(--muted);margin:0 0 .7rem;line-height:1.45;} + .gi-created svg{vertical-align:-2px;margin-right:.25rem;} + .gi-created b{color:var(--ink);font-weight:600;} + .gi-setting{display:flex;align-items:center;justify-content:space-between;gap:.5rem;font-size:.84rem;color:var(--ink);background:#f6f8fb;border:1px solid var(--line);border-radius:9px;padding:.5rem .7rem;margin:0 0 .7rem;cursor:pointer;} + .gi-setting span{display:inline-flex;align-items:center;gap:.4rem;} + .switch{position:relative;display:inline-block;width:40px;height:22px;flex:0 0 auto;} + .switch input{opacity:0;width:0;height:0;position:absolute;margin:0;} + .switch .slider{position:absolute;inset:0;background:#cbd2dd;border-radius:99px;transition:background .15s;cursor:pointer;} + .switch .slider::before{content:"";position:absolute;width:18px;height:18px;left:2px;top:2px;background:#fff;border-radius:50%;transition:transform .15s;box-shadow:0 1px 2px rgba(0,0,0,.25);} + .switch input:checked + .slider{background:var(--blue);} + .switch input:checked + .slider::before{transform:translateX(18px);} + .seenby{display:block;margin-top:.2rem;border:none;background:transparent;color:var(--muted);font-size:.68rem;cursor:pointer;padding:0;display:inline-flex;align-items:center;gap:.25rem;} + .seenby:hover{color:var(--blue);} + .bubble.mine .seenby{color:rgba(255,255,255,.8);} + .bubble.mine .seenby:hover{color:#fff;} + .gi-act{flex:1;border:1px solid var(--line);background:var(--card);border-radius:10px;padding:.6rem;font-weight:600;color:var(--blue);cursor:pointer;display:flex;align-items:center;justify-content:center;gap:.4rem;} + .gi-act:hover{background:var(--blue-soft);border-color:#c7d6f0;} + .gi-open{cursor:pointer;} + .sched-wrap{width:100%;margin:1.2rem 0 0;text-align:left;} + .sched-sec{margin-bottom:1.4rem;} + .sched-h{font-size:.74rem;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);margin:0 0 .55rem;font-weight:700;} + .sched-list{display:grid;grid-template-columns:repeat(auto-fill,minmax(330px,1fr));gap:.7rem;} + .sched-item{display:flex;align-items:flex-start;gap:.6rem;background:var(--card);border:1px solid var(--line);border-radius:12px;padding:.8rem .9rem;} + .sched-item.live{border-color:#34d399;background:#f0fdf4;} + .sched-item.cancelled{opacity:.7;} + .sched-item.cancelled .si-title{text-decoration:line-through;text-decoration-color:var(--muted);} + .cancel-tag{font-size:.62rem;font-weight:700;background:#fee2e2;color:#b91c1c;padding:.05rem .4rem;border-radius:99px;text-decoration:none;} + /* Schedule form: custom date/time pickers, recurring day chips, inline error highlight */ + .sch-row{display:flex;gap:.6rem;flex-wrap:wrap;} + .sch-row > div{flex:1 1 140px;min-width:0;} + .picker-field{position:relative;} + .pick-btn{display:flex;align-items:center;gap:.45rem;width:100%;text-align:left;cursor:pointer;color:var(--ink);} + .pick-btn svg{color:var(--blue);flex:0 0 auto;} + .pick-pop{position:absolute;z-index:60;top:100%;left:0;margin-top:.3rem;background:#fff;border:1px solid var(--line);border-radius:14px;box-shadow:0 14px 36px rgba(20,30,60,.22);padding:.6rem;width:280px;max-width:84vw;} + .pick-pop.time-pop{display:grid;grid-template-columns:repeat(3,1fr);gap:.35rem;max-height:240px;overflow:auto;width:240px;} + .pick-pop.hidden{display:none !important;} /* must beat .pick-pop.time-pop's display:grid */ + .cal-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:.4rem;font-size:.92rem;color:var(--ink);} + .cal-nav{border:none;background:var(--blue-soft);color:var(--blue);width:30px;height:30px;border-radius:8px;cursor:pointer;display:grid;place-items:center;} + .cal-nav:disabled{opacity:.35;cursor:default;} + .cal-grid{display:grid;grid-template-columns:repeat(7,1fr);gap:2px;} + .cal-dow{text-align:center;font-size:.66rem;color:var(--muted);font-weight:700;padding:.2rem 0;} + .cal-day{border:none;background:transparent;color:var(--ink);height:34px;border-radius:9px;cursor:pointer;font-size:.84rem;} + .cal-day:hover:not(:disabled){background:var(--blue-soft);} + .cal-day.today{outline:1px solid var(--blue);} + .cal-day.sel{background:var(--blue);color:#fff;font-weight:700;} + .cal-day:disabled{color:#cbd2dc;cursor:default;} + .time-chip{border:1px solid var(--line);background:var(--card);color:var(--ink);border-radius:8px;padding:.4rem .2rem;font-size:.78rem;cursor:pointer;} + .time-chip:hover{background:var(--blue-soft);} + .time-chip.sel{background:var(--blue);color:#fff;border-color:var(--blue);font-weight:700;} + .switch-row{display:flex;align-items:center;justify-content:space-between;background:#f6f8fb;border:1px solid var(--line);border-radius:10px;padding:.55rem .8rem;margin:.8rem 0 .4rem;} + .switch-row > span:first-child{display:flex;align-items:center;gap:.45rem;font-size:.88rem;color:var(--ink);} + .sch-days{display:flex;flex-wrap:wrap;align-items:center;gap:.4rem;margin:.3rem 0 .2rem;} + .day-chip{width:34px;height:34px;border:1px solid var(--line);background:var(--card);color:var(--ink);border-radius:50%;font-size:.78rem;font-weight:700;cursor:pointer;display:grid;place-items:center;} + .day-chip.on{background:var(--blue);color:#fff;border-color:var(--blue);} + .day-all{margin-left:auto;border:1px solid var(--line);background:var(--card);color:var(--blue);border-radius:99px;padding:.32rem .7rem;font-size:.78rem;font-weight:700;cursor:pointer;} + .si-actions .iconbtn{width:28px;height:28px;border-radius:7px;} + .si-actions .iconbtn.edit{color:var(--blue);} .si-actions .iconbtn.edit:hover{background:var(--blue-soft);} + .si-actions .iconbtn.cancel-ic{color:#b91c1c;} .si-actions .iconbtn.cancel-ic:hover{background:#fee2e2;} + .finput.field-err,.pick-btn.field-err{border-color:#dc2626 !important;box-shadow:0 0 0 2px rgba(220,38,38,.18);} + .si-main{flex:1;min-width:0;} + .si-title{font-weight:700;color:var(--ink);display:flex;align-items:center;gap:.5rem;} + .si-meta{font-size:.8rem;color:var(--muted);margin-top:.15rem;} + .si-desc{font-size:.84rem;color:var(--ink);margin-top:.35rem;opacity:.85;} + .livedot{color:#059669;font-size:.72rem;font-weight:700;} + .si-actions{display:flex;align-items:center;gap:.25rem;flex:0 0 auto;} + .btn.sm{padding:.28rem .7rem;font-size:.8rem;border-radius:7px;line-height:1.2;} + .btn.join{background:var(--blue);color:#fff;border:none;cursor:pointer;} + .btn.sm.cancel{background:#fee2e2;color:#b91c1c;border:1px solid #fecaca;} + .btn.sm.cancel:hover{background:#fecaca;} + .modal.sched{max-width:440px;} + .flbl{display:block;font-size:.78rem;font-weight:600;color:var(--ink);margin:.6rem 0 .25rem;} + .flbl .opt{color:var(--muted);font-weight:400;} + .finput{width:100%;border:1px solid var(--line);border-radius:9px;padding:.55rem .65rem;font-size:.92rem;font-family:inherit;background:#fbfcfe;color:var(--ink);box-sizing:border-box;} + .finput:focus{outline:none;border-color:var(--blue);} + .iconbtn{border:none;background:transparent;color:var(--muted);cursor:pointer;width:30px;height:30px;border-radius:8px;display:grid;place-items:center;flex:0 0 auto;} + .iconbtn:hover{background:#f1f5f9;color:var(--blue);} + .iconbtn.rm:hover{color:var(--red);background:#fee2e2;} + .gi-save{width:100%;background:var(--brand);color:var(--ink);margin-bottom:.6rem;} + .gi-sec-h{display:flex;align-items:center;justify-content:space-between;font-size:.74rem;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);margin:.5rem 0 .3rem;} + .gobtn{border:none;border-radius:11px;padding:.7rem 1rem;font-weight:700;font-size:.92rem;cursor:pointer;background:var(--blue);color:#fff;transition:filter .12s;} + .gobtn:hover{filter:brightness(1.08);} + .linkbtn{border:1px solid var(--line);background:var(--card);color:var(--blue);cursor:pointer;font-size:.8rem;font-weight:600;display:inline-flex;align-items:center;gap:.35rem;padding:.35rem .7rem;border-radius:8px;text-transform:none;letter-spacing:0;} + .linkbtn:hover{background:var(--blue-soft);border-color:#c7d6f0;} + .gi-list{display:flex;flex-direction:column;gap:.1rem;max-height:240px;overflow-y:auto;} + .mrow{display:flex;align-items:center;gap:.6rem;padding:.35rem .3rem;border-radius:8px;font-size:.92rem;} + .mrow:hover{background:#f6f8fb;} + .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 .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;} + .gi-photo-cam{position:absolute;right:-4px;bottom:-4px;width:22px;height:22px;border-radius:50%;background:var(--blue);color:#fff;display:grid;place-items:center;border:2px solid var(--card);box-shadow:0 1px 3px rgba(0,0,0,.22);} + .gi-photo-cam .ic{width:12px;height:12px;} + .gi-photo:hover .gi-photo-cam{filter:brightness(1.12);} + .youtag{font-size:.62rem;background:var(--blue-soft);color:var(--blue);padding:.05rem .35rem;border-radius:99px;margin-left:.3rem;vertical-align:middle;text-transform:uppercase;letter-spacing:.03em;} + .gi-add{margin-top:.4rem;border:1px solid var(--line);border-radius:10px;padding:.5rem;} + .gi-add .chk{display:flex;align-items:center;gap:.5rem;padding:.25rem;border-radius:6px;cursor:pointer;} + .gi-add .chk:hover{background:#f6f8fb;} + .gi-leave{width:100%;margin-top:.9rem;border:1px solid #fecaca;background:#fff;color:var(--red);border-radius:10px;padding:.55rem;font-weight:600;cursor:pointer;display:flex;align-items:center;justify-content:center;gap:.4rem;} + .gi-leave:hover{background:#fee2e2;} + .grp-members{max-height:220px;overflow-y:auto;border:1px solid var(--line);border-radius:10px;padding:.5rem .6rem;display:flex;flex-direction:column;gap:.4rem;} + .modal .chk{display:flex;align-items:center;gap:.5rem;font-size:.9rem;cursor:pointer;} + .modal .chk input{width:16px;height:16px;accent-color:var(--blue);margin:0;} + .modal-actions{display:flex;gap:.6rem;margin-top:1rem;} + .modal-actions .gobtn{flex:1;border:none;border-radius:10px;padding:.6rem;font-weight:700;cursor:pointer;} /* ---- Login (shown on /home when logged out) ---- */ .authwrap{flex:1 1 auto;display:none;align-items:center;justify-content:center;padding:1.5rem;min-height:0;} @@ -151,39 +531,94 @@ .toast{position:fixed;left:50%;bottom:1.6rem;transform:translateX(-50%) translateY(1rem);background:var(--blue);color:#fff;padding:.7rem 1.2rem;border-radius:10px;font-size:.88rem;box-shadow:0 10px 28px rgba(0,0,0,.22);opacity:0;pointer-events:none;transition:opacity .2s,transform .2s;z-index:9500;} .toast.show{opacity:1;transform:translateX(-50%) translateY(0);} + /* Hamburger menu button (header) */ + .navtoggle{background:transparent;border:none;color:#fff;cursor:pointer;display:grid;place-items:center;width:38px;height:38px;border-radius:9px;} + .navtoggle:hover{background:rgba(255,255,255,.14);} + /* Desktop: collapse the rail to enlarge content */ + body.rail-hidden .rail{display:none;} + .rail-backdrop{display:none;position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:1100;} + + @media (max-width:900px){ + .chatcol{width:280px;flex:0 0 280px;} + } + /* ---- Mobile / tablet ---- */ @media (max-width:760px){ - .chatcol{width:260px;flex:0 0 260px;} + header{padding:.5rem .8rem;} + .brand{font-size:.98rem;} + .navtoggle{display:none;} .rail-backdrop{display:none!important;} /* bottom nav replaces the drawer */ + /* App-style bottom navigation bar */ + .rail{position:fixed;left:0;right:0;bottom:0;top:auto;width:auto;height:60px;flex-direction:row;justify-content:space-around;align-items:center;gap:0;padding:0 .3rem env(safe-area-inset-bottom,0) .3rem;border-right:none;border-top:1px solid var(--line);box-shadow:0 -3px 14px rgba(20,30,60,.08);transform:none!important;z-index:1200;} + .rail .rail-spacer{display:none;} + .railbtn{display:flex;flex-direction:column;justify-content:center;gap:2px;width:62px;height:50px;border-radius:12px;} + .railbtn .rlabel{display:block;font-size:.66rem;font-weight:700;line-height:1;} + .railbtn svg{width:20px;height:20px;} + .railbtn::after,.railbtn::before{display:none;} + .railbtn:not(.active){color:#334155;background:transparent;} + .railbtn.active{background:var(--blue-soft);color:var(--blue);} + /* 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;} + 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 */ + /* Bigger touch targets */ + .chat-row{padding:.7rem .85rem;} + .convo-head{padding:.7rem .85rem;} + .modal{width:94vw;} + /* Meeting + embedded panels full-width, controls above the nav */ + .meet-grid{grid-template-columns:repeat(auto-fit,minmax(150px,1fr));} + .meet-bar{flex-wrap:wrap;gap:.4rem;padding:.5rem .6rem;} + .meet-bar .code{flex:1 1 100%;margin:0 0 .2rem;} + .meet-bar .meet-ic{width:44px;height:44px;} + .meet-panel{width:90vw;max-width:none;right:5vw;top:6px;bottom:74px;} + .md-top{flex-direction:column;} + /* Code+Join on a full-width row; Start & Schedule as two equal halves below */ + .md-actions{width:100%;display:grid;grid-template-columns:1fr 1fr;gap:.5rem;} + .md-join{grid-column:1 / -1;} + .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;} + /* Touch devices have no hover: keep member-row actions (make-admin / remove) always visible */ + .mrow .iconbtn{opacity:1;} } +
Loading…
+
BizGaze Connect
-
+
+
@@ -198,10 +633,11 @@
-
💬 Chat is coming soon — showing sample conversations
+
💬 Messages with your BizGaze teammates
@@ -215,19 +651,8 @@
- -
-
-
- -
- COMING SOON -

Meetings are on the way

-

Soon you'll be able to host multi-party video meetings with your BizGaze team and customers — right here, no install needed.

- -
In the meantime, use Share Screen or Connect Screen from the left.
-
-
+ +
@@ -239,6 +664,12 @@ // ---------- Helpers ---------- function pEsc(s){return String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));} function initials(name){const p=String(name||'?').trim().split(/\s+/);return ((p[0]||'?')[0]+(p[1]?p[1][0]:'')).toUpperCase();} +function fmtDateTime(ts){ if(!ts) return ''; const d=new Date(ts); return d.toLocaleDateString([],{year:'numeric',month:'short',day:'numeric'})+' · '+d.toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'}); } +function fmtClock(ts){ if(!ts) return ''; return new Date(ts).toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'}); } // bubble time = clock only; date is the center separator +function autoGrow(el){ if(!el) return; el.style.height='auto'; const max=140; el.style.height=Math.min(el.scrollHeight,max)+'px'; el.style.overflowY=el.scrollHeight>max?'auto':'hidden'; } +// Mild, light tint for a quoted reply, color-coded by who is being quoted. [bg, bar] +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>>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']; 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];} @@ -249,13 +680,14 @@ function toast(msg){const t=document.getElementById('toast');t.textContent=msg;t // ---------- Profile dropdown (mirrors profileHTML()/wireProfile() from console.html) ---------- function profileHTML(u){ const display=u.name||u.email; - return '
' + const img=u.avatarUrl?'':''; + return '
' + '
' + '
'+pEsc(display)+'
'+pEsc(u.email)+(u.role?' · '+pEsc(u.role):'')+'
' - + 'Dashboard' - + 'Logout' + + ''+ic('layoutDashboard',16)+' Dashboard' + + ''+ic('settings',16)+' Settings' + + ''+ic('logOut',16)+' Logout' + '
'; } function wireProfile(){ @@ -265,60 +697,154 @@ function wireProfile(){ 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='/';}; + const ps=document.getElementById('psettings'); + if(ps)ps.onclick=()=>{ menu.classList.remove('open'); openSettings(); }; +} +// ---------- Notification bell (activity center) ---------- +// Captures things that otherwise have no home: scheduled-meeting invites, reminders, reactions to +// your messages, new polls, and new chat requests. Stored per user in this browser. +let NOTIFS=[]; +function notifKey(){ return 'notifs_'+((ME&&ME.id)||''); } +function loadNotifs(){ try{ NOTIFS=JSON.parse(localStorage.getItem(notifKey())||'[]'); }catch(_){ NOTIFS=[]; } if(!Array.isArray(NOTIFS)) NOTIFS=[]; updateBellBadge(); } +function saveNotifs(){ try{ NOTIFS=NOTIFS.slice(0,50); localStorage.setItem(notifKey(), JSON.stringify(NOTIFS)); }catch(_){} } +function addNotif(n){ NOTIFS.unshift(Object.assign({ id:'n'+Date.now()+'_'+Math.round((window.performance&&performance.now())||Math.random()*1e6), ts:Date.now(), read:false }, n)); saveNotifs(); updateBellBadge(); const menu=document.getElementById('bellMenu'); if(menu&&menu.classList.contains('open')) renderBell(); } +function bellHTML(){ return '
'; } +function updateBellBadge(){ const d=document.getElementById('bellDot'); if(!d) return; const n=NOTIFS.filter(x=>!x.read).length; if(n>0){ d.textContent=n>99?'99+':n; d.style.display='grid'; } else d.style.display='none'; } +function renderBell(){ const menu=document.getElementById('bellMenu'); if(!menu) return; + const items=NOTIFS.length?NOTIFS.map(n=>'
'+ic(n.icon||'bell',16)+'
'+n.text+'
'+fmtTime(n.ts)+'
').join(''):'
No notifications yet
'; + menu.innerHTML='
Notifications'+(NOTIFS.length?'':'')+'
'+items+'
'; + const cl=menu.querySelector('#bellClear'); if(cl) cl.onclick=(e)=>{ e.stopPropagation(); NOTIFS=[]; saveNotifs(); updateBellBadge(); renderBell(); }; + menu.querySelectorAll('.bell-item').forEach(el=>el.onclick=()=>{ const n=NOTIFS.find(x=>x.id===el.dataset.id); if(n) openNotif(n); }); +} +function openNotif(n){ NOTIFS=NOTIFS.filter(x=>x.id!==n.id); saveNotifs(); updateBellBadge(); const menu=document.getElementById('bellMenu'); if(menu) menu.classList.remove('open'); + if(n.link){ if(n.link.kind==='meeting'){ switchTab('meeting'); loadScheduledMeetings(); } else if(n.link.kind==='dm'||n.link.kind==='group'){ switchTab('chat'); selectChat(n.link.kind, n.link.id); } } // meeting → just open the tab, don't auto-join + 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(); } }; + document.addEventListener('click',()=>menu.classList.remove('open')); menu.onclick=(e)=>e.stopPropagation(); updateBellBadge(); +} +// Settings: notification preferences (browser permission + per-type), stored per browser. +function openSettings(){ + if(document.getElementById('setModal')) return; + const ov=document.createElement('div'); ov.className='modal-ov'; ov.id='setModal'; + const sw=(id,label,on)=>''; + const granted=('Notification' in window) && Notification.permission==='granted'; + ov.innerHTML=''; + document.body.appendChild(ov); + ov.onclick=e=>{ if(e.target===ov) ov.remove(); }; + ov.querySelector('#setClose').onclick=()=>ov.remove(); + 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'); } else toast('Notifications blocked — allow them in your browser site settings'); }catch(_){ toast('Notifications need HTTPS or localhost'); } }; } -// ---------- Mock chat data (placeholder — no chat backend yet, see CLAUDE.md) ---------- -const CHATS=[ - {name:'Anwi Systems', msg:"Perfect, the screen share worked great. Thanks!", time:'9:42 AM', unread:0, online:true}, - {name:'Priya Sharma', msg:"Can you connect to my screen at 3pm?", time:'9:15 AM', unread:2, online:true}, - {name:'GAPL Group', msg:"You: I've shared the 6-digit code with you", time:'Yesterday', unread:0, online:false}, - {name:'Battery Doctors', msg:"The invoice module is throwing an error again", time:'Yesterday', unread:5, online:true}, - {name:'Ramesh Marketing', msg:"You: Let me know once you're at your desk", time:'Mon', unread:0, online:false}, - {name:'STC Support', msg:"Typing…", time:'Mon', unread:1, online:true}, - {name:'Samruddhi Traders',msg:"Thanks for the help earlier 👍", time:'Sun', unread:0, online:false}, - {name:'DMS 3.0 Team', msg:"You: Closing the ticket, all resolved", time:'Fri', unread:0, online:false}, -]; -let selectedChat=null; // index into CHATS, or null = welcome - +// ---------- Chat (1:1 + groups) ---------- +let ME={}; +let CONTACTS=[]; // team users (for new DMs / picking group members) +let ROWS=[]; // sidebar items: {kind:'dm'|'group', id, name, online?, members?, last_body, last_at, last_from_me, unread} +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) +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(); const listEl=document.getElementById('chatlist'); -function chatRowHTML(c,i){ - const cls=['chat-row']; - if(selectedChat===i)cls.push('active'); - if(c.unread>0)cls.push('unread'); - return '
' - + '
'+pEsc(initials(c.name)) - + '
' - + '
' - + '
'+pEsc(c.name)+''+pEsc(c.time)+'
' - + '
'+pEsc(c.msg)+'' - + (c.unread>0?''+c.unread+'':'')+'
' - + '
'; +function searchVal(){ const s=document.getElementById('chatSearch'); return s?s.value:''; } +function rowFor(kind,id){ return ROWS.find(r=>r.kind===kind && r.id===id); } +function currentName(){ const it=selected&&rowFor(selected.kind,selected.id); return it?it.name:''; } + +function fmtTime(ts){ + if(!ts) return ''; + const d=new Date(ts), n=new Date(); + if(d.toDateString()===n.toDateString()) return d.toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'}); + const y=new Date(n); y.setDate(n.getDate()-1); + if(d.toDateString()===y.toDateString()) return 'Yesterday'; + return d.toLocaleDateString([], {month:'short',day:'numeric'}); +} +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?''+(it.members>99?'99+':it.members)+'':'') + :''; + 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. + const img=it.avatar?'':''; + return '
'+inner+img+corner+'
'; +} +function rowHTML(it){ + const active=selected&&selected.kind===it.kind&&selected.id===it.id; + const cls=['chat-row']; if(active)cls.push('active'); if(it.unread>0)cls.push('unread'); + const isG=it.kind==='group'; + const preview=it.last_body?((it.last_from_me?'You: ':'')+it.last_body):(isG?((it.members||0)+' members'):'No messages yet'); + return '
' + + avatarHTML(it,false) + + '
'+pEsc(it.name)+''+pEsc(fmtTime(it.last_at))+'
' + + '
'+(it.callActive?''+ic('phone',12)+' Ongoing call':pEsc(preview))+''+(it.unread>0?''+(it.unread>99?'99+':it.unread)+'':'')+'
'; } function renderChats(filter){ const q=(filter||'').trim().toLowerCase(); - const rows=CHATS.map((c,i)=>({c,i})).filter(({c})=>!q||c.name.toLowerCase().includes(q)||c.msg.toLowerCase().includes(q)); - listEl.innerHTML = rows.length - ? rows.map(({c,i})=>chatRowHTML(c,i)).join('') - : '
No chats match “'+pEsc(filter)+'”.
'; - listEl.querySelectorAll('.chat-row').forEach(row=>{ - row.onclick=()=>{ - selectedChat=+row.dataset.i; - CHATS[selectedChat].unread=0; - renderChats(document.getElementById('chatSearch').value); - renderChatPanel(); - updateRailUnread(); - }; - }); + 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('') + : '
'+(ROWS.length?('No chats match “'+pEsc(filter)+'”.'):'No conversations yet.')+'
'; + 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)); + const cmatch=CONTACTS.filter(c=>!haveDm.has(c.id) && ((c.name||'').toLowerCase().includes(q)||(c.email||'').toLowerCase().includes(q))); + if(cmatch.length) html+='
Start a chat
'+cmatch.map(c=>'
'+pEsc(initials(c.name))+''+pEsc(c.name)+'
').join(''); + html+='
'; // BizGaze directory (filled async) + } + listEl.innerHTML=html; + listEl.querySelectorAll('.chat-row').forEach(row=>{ row.onclick=()=>selectChat(row.dataset.kind, row.dataset.id); }); + listEl.querySelectorAll('.contact-row').forEach(row=>{ row.onclick=()=>selectChat('dm', row.dataset.id); }); + if(q.length>=2) queryDirectory(filter.trim()); +} +let _dirT=null, _dirSeq=0; +// Search the wider BizGaze directory (cross-tenant) — proxied server-side. Debounced. +function queryDirectory(q){ + clearTimeout(_dirT); const seq=++_dirSeq; + _dirT=setTimeout(async()=>{ + let list=[]; try{ list=await fetch('/api/directory/search?q='+encodeURIComponent(q)).then(r=>r.json()); }catch(_){ } + if(seq!==_dirSeq) return; // a newer search superseded this one + const box=document.getElementById('dirResults'); if(!box) return; + const haveEmail=new Set(CONTACTS.map(c=>(c.email||'').toLowerCase()).filter(Boolean)); + const ext=(Array.isArray(list)?list:[]).filter(p=>!(p.email&&haveEmail.has(p.email.toLowerCase()))); // hide dups of team contacts + if(!ext.length){ box.innerHTML=''; return; } + box.innerHTML='
On BizGaze
'+ext.map((p,i)=>{ const sub=[p.org,p.phone].filter(Boolean).join(' · '); + return '
'+pEsc(initials(p.name||'?'))+''+pEsc(p.name||p.email||'Unknown')+''+(sub?''+pEsc(sub)+'':'')+''+(p.onConnect?'':'Not on Connect')+'
'; }).join(''); + box.querySelectorAll('.dir-row').forEach(row=>{ const p=ext[+row.dataset.i]; row.onclick=()=>{ if(p.onConnect&&p.connectId) selectChat('dm', p.connectId); else toast(pEsc(p.name||'This person')+' is on BizGaze but hasn’t joined Connect yet — they’ll be reachable once they sign in.'); }; }); + }, 280); } function updateRailUnread(){ - const total=CHATS.reduce((a,c)=>a+(c.unread||0),0); + let chats=0; ROWS.forEach(it=>{ if((it.unread||0)>0) chats++; }); // number of chats with unread, not total messages const d=document.getElementById('railUnread'); - if(total>0){ d.textContent=total>99?'99+':total; d.style.display='grid'; } - else d.style.display='none'; + if(chats>0){ d.textContent=chats>99?'99+':chats; d.style.display='grid'; } else d.style.display='none'; +} +async function loadSidebar(){ + let convos=[], contacts=[]; + try{ [convos, contacts]=await Promise.all([ fetch('/api/messages/conversations').then(r=>r.json()), fetch('/api/messages/contacts').then(r=>r.json()) ]); }catch(_){} + CONTACTS=Array.isArray(contacts)?contacts:[]; + const items=Array.isArray(convos)?convos.slice():[]; + const dmIds=new Set(items.filter(i=>i.kind==='dm').map(i=>i.id)); + for(const c of CONTACTS){ if(!dmIds.has(c.id)) items.push({ kind:'dm', id:c.id, name:c.name, online:!!c.online, last_body:'', last_at:0, last_from_me:false, unread:0 }); } + const onlineById={}; CONTACTS.forEach(c=>onlineById[c.id]=!!c.online); + items.forEach(it=>{ if(it.kind==='dm') it.online=!!onlineById[it.id]; }); + ROWS=items; + renderChats(searchVal()); + updateRailUnread(); } -// ---------- Chat main panel: welcome OR conversation placeholder ---------- -let ME={}; +// ----- conversation view ----- function welcomeHTML(){ return '
' + '
👋
' @@ -330,25 +856,1301 @@ function welcomeHTML(){ + '

Meeting

Multi-party video — coming soon

' + '
'; } -function convoHTML(c){ +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'); return '
' + '
' - + '' - + '
'+pEsc(initials(c.name))+'
' - + '
'+pEsc(c.name)+'
'+(c.online?'Online':'Offline')+'
' + + '' + + avatarHTML(it,true) + + '
'+pEsc(it.name)+'
'+pEsc(sub)+'
' + + '' + + (isG?'':'') + '
' - + '
💬
Messaging is coming soon
Persistent 1:1 chat with '+pEsc(c.name)+' will live here.
For now, start a screen session from the left.
' + + '
' + + '' + + '' + + '' + + '
' + + '
' + + '' + + '' + + '
' + + '' + + '' + + '' + + (isG?'':'') + + '' + + '' + + '
' + + '
' + + '' + + '
' + + '' + + '' + '
'; } +// Small round avatar (photo if the member has one, else colored initials) for a group message sender. +function senderAvatar(id, name){ const mem=convoMembers.find(x=>x.id===id); const av=mem&&mem.avatar; return ''+(av?'':'')+pEsc(initials(name||'?'))+''; } +function bubbleHTML(m){ + if(m.evt==='call-start') return '
📞 '+(m.from===ME.id?'You':pEsc(m.byName||'Someone'))+' started a call
'; + if(m.system||m.from==='__system__') return '
'+pEsc(m.body)+'
'; + const mine=m.from===ME.id; + const sender=(convoIsGroup && !mine && m.fromName)?'
'+senderAvatar(m.from, m.fromName)+''+pEsc(m.fromName)+'
':''; + let quote=''; + if(m.reply){ const t=replyTint(m.reply.from||m.reply.fromName); quote='
'+pEsc(m.reply.fromName||'')+': '+pEsc(m.reply.body)+'
'; } + const reacts=(m.reactions&&m.reactions.length)?'
'+m.reactions.map(r=>'').join('')+'
':''; + const att = m.attachment ? (m.attachment.isImage + ? ''+pEsc(m.attachment.name)+'' + : ''+ic('file',15)+' '+pEsc(m.attachment.name)+' '+fmtSize(m.attachment.size)+'') : ''; + const mentionsMe=convoIsGroup && !mine && Array.isArray(m.mentions) && (m.mentions.includes(ME.id)||m.mentions.includes('everyone')); + // DM ticks: sent (1 grey) → delivered (2 grey) → read (2 blue). + let rcpt=''; + if(mine && !convoIsGroup){ const st=m.read_at?'seen':(m.delivered_at?'delivered':'sent'); rcpt=''+ic(st==='sent'?'check':'checkCheck',13)+''; } + // Group "Seen by …" on my own messages. + let seen=''; + if(mine && convoIsGroup && m.id===_lastMineId && Array.isArray(m.seenBy) && m.seenBy.length){ const ns=m.seenBy, head=ns.slice(0,3).join(', '), more=ns.length>3?(' +'+(ns.length-3)+' more'):''; seen=''; } + return '
' + + sender + quote + att + renderMsgBody(m) + pollHTML(m) + + '' + + '' + + ''+pEsc(fmtClock(m.created_at))+rcpt+'' + + reacts + seen + '
'; +} +// reply + emoji state/helpers +let replyTarget=null; +// Categorized emoji set (covers the common ones used across Slack/Teams/WhatsApp). +const EMOJI_CATS=[ + { icon:'😀', list:'😀 😃 😄 😁 😆 😅 🤣 😂 🙂 🙃 🫠 😉 😊 😇 🥰 😍 🤩 😘 😗 😚 😙 🥲 😋 😛 😜 🤪 😝 🤑 🤗 🤭 🫢 🫣 🤫 🤔 🫡 🤐 🤨 😐 😑 😶 🫥 😏 😒 🙄 😬 🤥 😌 😔 😪 🤤 😴 😷 🤒 🤕 🤢 🤮 🤧 🥵 🥶 🥴 😵 🤯 🤠 🥳 🥸 😎 🤓 🧐 😕 🫤 😟 🙁 ☹️ 😮 😯 😲 😳 🥺 🥹 😦 😧 😨 😰 😥 😢 😭 😱 😖 😣 😞 😓 😩 😫 🥱 😤 😡 😠 🤬 😈 👿 💀 ☠️ 💩 🤡 👹 👺 👻 👽 👾 🤖 😺 😸 😹 😻 😼 😽 🙀 😿 😾' }, + { icon:'👍', list:'👋 🤚 🖐️ ✋ 🖖 🫱 🫲 🫳 🫴 👌 🤌 🤏 ✌️ 🤞 🫰 🤟 🤘 🤙 👈 👉 👆 🖕 👇 ☝️ 🫵 👍 👎 ✊ 👊 🤛 🤜 👏 🙌 🫶 👐 🤲 🤝 🙏 ✍️ 💅 🤳 💪 🦾 🦵 🦶 👣 👀 👁️ 🫦 👄 🫀 🫁 🧠 🦷 🦴 👶 🧒 👦 👧 🧑 👨 👩 🧔 👱 🧓 👴 👵 🙇 💁 🙅 🙆 🙋 🤦 🤷 👮 🕵️ 💂 👷 🤴 👸 👳 🧕 🤵 👰 🤰 🤱 👼 🎅 🤶 🦸 🦹 🧙 🧚 🧛 🧜 🧝 🧞 🧟 💆 💇 🚶 🧍 🧎 🏃 💃 🕺 👯 🧘' }, + { icon:'❤️', list:'❤️ 🧡 💛 💚 💙 💜 🖤 🤍 🤎 💔 ❣️ 💕 💞 💓 💗 💖 💘 💝 💟 ❤️‍🔥 ❤️‍🩹 💋 💯 💢 💥 💫 💦 💨 💬 🗨️ 🗯️ 💭 💤 ♥️ ✨ ⭐ 🌟 ⚡ 🔥 🌈 ☀️ ⛅ ☁️ 🌧️ ⛈️ 🌩️ ❄️ ☃️ ⛄ 💧 🌊' }, + { icon:'🐻', list:'🐶 🐱 🐭 🐹 🐰 🦊 🐻 🐼 🐻‍❄️ 🐨 🐯 🦁 🐮 🐷 🐽 🐸 🐵 🙈 🙉 🙊 🐒 🐔 🐧 🐦 🐤 🐣 🐥 🦆 🦅 🦉 🦇 🐺 🐗 🐴 🦄 🐝 🐛 🦋 🐌 🐞 🐜 🪰 🪲 🦗 🕷️ 🦂 🐢 🐍 🦎 🦖 🦕 🐙 🦑 🦐 🦞 🦀 🐡 🐠 🐟 🐬 🐳 🐋 🦈 🐊 🐅 🐆 🦓 🦍 🦧 🐘 🦛 🦏 🐪 🐫 🦒 🦘 🐃 🐄 🐎 🐖 🐏 🐑 🐐 🦌 🐕 🐩 🦮 🐈 🐓 🦃 🦚 🦜 🦢 🕊️ 🐇 🦝 🦨 🦦 🦥 🐁 🐀 🐿️ 🦔 🐾 🐉 🌵 🎄 🌲 🌳 🌴 🌱 🌿 ☘️ 🍀 🍃 🍂 🍁 🍄 🐚 🌾 💐 🌷 🌹 🥀 🌺 🌸 🌼 🌻' }, + { icon:'🍔', list:'🍏 🍎 🍐 🍊 🍋 🍌 🍉 🍇 🍓 🫐 🍈 🍒 🍑 🥭 🍍 🥥 🥝 🍅 🍆 🥑 🥦 🥬 🥒 🌶️ 🫑 🌽 🥕 🫒 🧄 🧅 🥔 🍠 🥐 🥯 🍞 🥖 🥨 🧀 🥚 🍳 🧈 🥞 🧇 🥓 🥩 🍗 🍖 🌭 🍔 🍟 🍕 🫓 🥪 🥙 🧆 🌮 🌯 🫔 🥗 🥘 🫕 🥫 🍝 🍜 🍲 🍛 🍣 🍱 🥟 🦪 🍤 🍙 🍚 🍘 🍥 🥠 🥮 🍢 🍡 🍧 🍨 🍦 🥧 🧁 🍰 🎂 🍮 🍭 🍬 🍫 🍿 🍩 🍪 🌰 🥜 🍯 🥛 🍼 ☕ 🍵 🧃 🥤 🧋 🍶 🍺 🍻 🥂 🍷 🥃 🍸 🍹 🧉 🍾' }, + { icon:'⚽', list:'⚽ 🏀 🏈 ⚾ 🥎 🎾 🏐 🏉 🥏 🎱 🪀 🏓 🏸 🏒 🏑 🥍 🏏 🥅 ⛳ 🪁 🏹 🎣 🤿 🥊 🥋 🎽 🛹 🛼 🛷 ⛸️ 🥌 🎿 ⛷️ 🏂 🪂 🏋️ 🤼 🤸 ⛹️ 🤺 🤾 🏌️ 🏇 🧘 🏄 🏊 🤽 🚣 🧗 🚵 🚴 🏆 🥇 🥈 🥉 🏅 🎖️ 🏵️ 🎗️ 🎫 🎟️ 🎪 🤹 🎭 🩰 🎨 🎬 🎤 🎧 🎼 🎹 🥁 🪘 🎷 🎺 🪗 🎸 🪕 🎻 🎲 ♟️ 🎯 🎳 🎮 🎰 🧩' }, + { icon:'🚗', list:'🚗 🚕 🚙 🚌 🚎 🏎️ 🚓 🚑 🚒 🚐 🛻 🚚 🚛 🚜 🛴 🚲 🛵 🏍️ 🛺 🚨 🚔 🚍 🚘 🚖 🚡 🚠 🚟 🚃 🚋 🚞 🚝 🚄 🚅 🚈 🚂 🚆 🚇 🚊 🚉 ✈️ 🛫 🛬 🛩️ 💺 🛰️ 🚀 🛸 🚁 🛶 ⛵ 🚤 🛥️ 🛳️ ⛴️ 🚢 ⚓ ⛽ 🚧 🚦 🚥 🗺️ 🗿 🗽 🗼 🏰 🏯 🏟️ 🎡 🎢 🎠 ⛲ ⛱️ 🏖️ 🏝️ 🏜️ 🌋 ⛰️ 🏔️ 🗻 🏕️ ⛺ 🏠 🏡 🏘️ 🏗️ 🏭 🏢 🏬 🏣 🏥 🏦 🏨 🏪 🏫 ⛪ 🕌 🛕 🕋 🌁 🌃 🏙️ 🌅 🌆 🌉 🌌 🎇 🎆 🌠' }, + { icon:'💡', list:'⌚ 📱 📲 💻 ⌨️ 🖥️ 🖨️ 🖱️ 🕹️ 💽 💾 💿 📀 📼 📷 📸 📹 🎥 📽️ 🎞️ 📞 ☎️ 📟 📠 📺 📻 🎙️ 🎚️ 🎛️ 🧭 ⏱️ ⏲️ ⏰ 🕰️ ⌛ ⏳ 📡 🔋 🔌 💡 🔦 🕯️ 🪔 🧯 💸 💵 💴 💶 💷 🪙 💰 💳 💎 ⚖️ 🪜 🧰 🪛 🔧 🔨 ⚒️ 🛠️ ⛏️ 🔩 ⚙️ 🧱 ⛓️ 🧲 🔫 💣 🧨 🪓 🔪 🗡️ ⚔️ 🛡️ 🚬 ⚰️ ⚱️ 🏺 🔮 📿 🧿 💈 ⚗️ 🔭 🔬 💊 💉 🩸 🧬 🦠 🧫 🧪 🌡️ 🧹 🧺 🧻 🚽 🚿 🛁 🧼 🪥 🧽 🪣 🧴 🛎️ 🔑 🗝️ 🚪 🪑 🛋️ 🛏️ 🧸 🖼️ 🛍️ 🛒 🎁 🎈 🎏 🎀 🪄 🎊 🎉 🏮 🎐 🧧 ✉️ 📩 📨 📧 💌 📦 🏷️ 📜 📄 📊 📈 📉 📅 📋 📁 📂 🗂️ 📰 📓 📔 📕 📗 📘 📙 📚 📖 🔖 🔗 📎 📐 📏 🧮 📌 📍 ✂️ 🖊️ 🖋️ ✒️ 🖌️ 🖍️ 📝 ✏️ 🔍 🔎 🔒 🔓' }, + { icon:'✅', list:'✅ ❌ ⭕ 🚫 ❓ ❔ ❗ ❕ ‼️ ⁉️ 💯 🔅 🔆 ⚠️ 🚸 ☢️ ☣️ ⬆️ ↗️ ➡️ ↘️ ⬇️ ↙️ ⬅️ ↖️ ↕️ ↔️ ↩️ ↪️ ⤴️ ⤵️ 🔃 🔄 🔙 🔚 🔛 🔜 🔝 🛐 ⚛️ 🕉️ ✡️ ☸️ ☯️ ✝️ ☦️ ☪️ ☮️ 🕎 🔯 ♈ ♉ ♊ ♋ ♌ ♍ ♎ ♏ ♐ ♑ ♒ ♓ ⛎ 🔀 🔁 🔂 ▶️ ⏩ ⏭️ ◀️ ⏪ ⏮️ 🔼 ⏫ 🔽 ⏬ ⏸️ ⏹️ ⏺️ ⏏️ ♀️ ♂️ ⚧️ ✖️ ➕ ➖ ➗ ♾️ 💲 💱 ™️ ©️ ®️ 〰️ ➰ ➿ ✔️ ☑️ 🔘 🔴 🟠 🟡 🟢 🔵 🟣 🟤 ⚫ ⚪ 🟥 🟧 🟨 🟩 🟦 🟪 🟫 ⬛ ⬜ 🔶 🔷 🔸 🔹 🔺 🔻 💠 🔳 🔲' }, + { icon:'🏁', list:'🏁 🚩 🎌 🏴 🏳️ 🏳️‍🌈 🏳️‍⚧️ 🏴‍☠️ 🇮🇳 🇺🇸 🇬🇧 🇨🇦 🇦🇺 🇩🇪 🇫🇷 🇮🇹 🇪🇸 🇯🇵 🇰🇷 🇨🇳 🇧🇷 🇷🇺 🇿🇦 🇦🇪 🇸🇬 🇲🇾 🇮🇩 🇵🇭 🇹🇭 🇳🇿 🇮🇪 🇳🇱 🇸🇪 🇨🇭' }, +]; +let emojiCat=0, emojiMode='compose'; +function setReply(m){ + replyTarget=m; + const bar=document.getElementById('replyBar'); if(!bar) return; + const who=m.from===ME.id?'yourself':(m.fromName||currentName()||'them'); + bar.innerHTML=''+ic('reply',13)+' Replying to '+pEsc(who)+': '+pEsc(m.body.length>80?m.body.slice(0,80)+'…':m.body)+''+ic('x',15)+''; + bar.style.display='flex'; + document.getElementById('replyCancel').onclick=clearReply; + const inp=document.getElementById('msgInput'); if(inp) inp.focus(); +} +function clearReply(){ replyTarget=null; const bar=document.getElementById('replyBar'); if(bar){ bar.style.display='none'; bar.innerHTML=''; } } +// attachments +let pendingAttach=null; +function fmtSize(b){ b=+b||0; if(b<1024) return b+' B'; if(b<1048576) return Math.round(b/1024)+' KB'; return (b/1048576).toFixed(1)+' MB'; } +async function uploadFile(file){ + if(!file) return; + if(file.size>25*1024*1024){ toast('File too large (max 25 MB)'); return; } + showAttachPending(file.name, true); + try{ + const r=await fetch('/api/messages/upload',{ method:'POST', headers:{ 'Content-Type':file.type||'application/octet-stream', 'X-Filename':encodeURIComponent(file.name) }, body:file }); + const d=await r.json(); if(!r.ok) throw new Error(d.error||'upload failed'); + pendingAttach=d; showAttachPending(d.name,false); + const inp=document.getElementById('msgInput'); if(inp) inp.focus(); + }catch(e){ pendingAttach=null; hideAttach(); toast(e.message||'Upload failed'); } +} +function showAttachPending(name, uploading){ + const bar=document.getElementById('attachBar'); if(!bar) return; + const a=pendingAttach; + const isImg=a && /^image\//.test(a.mime||''); + const lead=(!uploading && isImg) + ? '' + : ''+ic(uploading?'paperclip':(isImg?'camera':'file'),18)+''; + bar.innerHTML='
'+lead+''+pEsc(name)+(uploading?' · uploading…':'')+''+(uploading?'':'')+'
'; + bar.style.display='block'; + const x=document.getElementById('attachCancel'); if(x) x.onclick=()=>{ pendingAttach=null; hideAttach(); }; +} +function hideAttach(){ const bar=document.getElementById('attachBar'); if(bar){ bar.style.display='none'; bar.innerHTML=''; } const fi=document.getElementById('fileInput'); if(fi) fi.value=''; } +// Paste an image from the clipboard (e.g. a screenshot) straight into the composer. +function onPaste(e){ + const items=(e.clipboardData&&e.clipboardData.items)||[]; + for(const it of items){ + if(it.type&&it.type.indexOf('image')===0){ + const blob=it.getAsFile(); + if(blob){ e.preventDefault(); const ext=(blob.type.split('/')[1]||'png'); uploadFile(new File([blob],'screenshot-'+Date.now()+'.'+ext,{type:blob.type})); return; } + } + } +} +// Close the emoji picker when clicking anywhere outside it (or its button). +document.addEventListener('click',(e)=>{ + if(!emojiOpen) return; + const pop=document.getElementById('emojiPop'), btn=document.getElementById('emojiBtn'); + if(pop && !pop.contains(e.target) && !(btn&&btn.contains(e.target)) && !e.target.closest('.react-btn')) closeEmoji(); +}); +let emojiOpen=false; +function openEmoji(mode, anchorEl){ emojiMode=mode||'compose'; const pop=document.getElementById('emojiPop'); if(!pop) return; emojiOpen=true; renderEmojiPop(pop); pop.style.display='flex'; positionEmojiPop(pop, anchorEl); } +// Anchor the picker at its trigger: above the composer emoji icon, or at the message you reacted to. +function positionEmojiPop(pop, anchorEl){ + const parent=pop.offsetParent || pop.parentElement; if(!parent){ return; } + const pr=parent.getBoundingClientRect(); const popW=pop.offsetWidth||330, popH=pop.offsetHeight||300; + let left, top; + if(anchorEl && anchorEl.getBoundingClientRect){ const r=anchorEl.getBoundingClientRect(); left=r.left-pr.left; top=r.top-pr.top-popH-8; } + else { left=12; top=pr.height-popH-64; } + left=Math.max(8, Math.min(left, pr.width-popW-8)); + top=Math.max(8, Math.min(top, pr.height-popH-8)); + pop.style.left=left+'px'; pop.style.top=top+'px'; pop.style.bottom='auto'; +} +function closeEmoji(){ const pop=document.getElementById('emojiPop'); if(pop) pop.style.display='none'; emojiOpen=false; } +function renderEmojiPop(pop){ + pop.innerHTML='
'+EMOJI_CATS.map((c,i)=>'').join('')+'
'; + pop.querySelectorAll('.emoji-tabs button').forEach(b=>b.onclick=()=>{ emojiCat=+b.dataset.i; renderEmojiPop(pop); }); + const grid=pop.querySelector('#emojiGrid'); + grid.innerHTML=EMOJI_CATS[emojiCat].list.trim().split(/\s+/).map(e=>'').join(''); + grid.querySelectorAll('button').forEach(b=>b.onclick=()=>onEmojiPick(b.textContent)); +} +function onEmojiPick(e){ if(emojiMode && emojiMode.react){ reactToMessage(emojiMode.react, e); closeEmoji(); } else { insertEmoji(e); } } +function openEmojiForReact(messageId, anchorEl){ openEmoji({ react: messageId }, anchorEl); } +function insertEmoji(e){ + const inp=document.getElementById('msgInput'); if(!inp) return; + const s=inp.selectionStart??inp.value.length, en=inp.selectionEnd??inp.value.length; + inp.value=inp.value.slice(0,s)+e+inp.value.slice(en); + const np=s+e.length; inp.focus(); try{ inp.setSelectionRange(np,np); }catch(_){} +} +// reactions +function applyReaction(m, emoji, userId, added){ + m.reactions=m.reactions||[]; + let r=m.reactions.find(x=>x.emoji===emoji); + if(added){ if(!r){ r={emoji,count:0,mine:false}; m.reactions.push(r); } r.count++; if(userId===ME.id) r.mine=true; } + else if(r){ r.count--; if(userId===ME.id) r.mine=false; if(r.count<=0) m.reactions=m.reactions.filter(x=>x.emoji!==emoji); } +} +async function reactToMessage(messageId, emoji){ + const m=THREAD.find(x=>x.id===messageId); + try{ const r=await postJSON('/api/messages/react',{ messageId, emoji }); if(m){ m.reactions=r.reactions||[]; updateBubble(m); } }catch(_){} +} +function showMeetingCancelled(mtg){ if(!mtg) return; try{ playPing(); }catch(_){} + const w=mtg.when?(' on '+mtg.when):''; + addNotif({icon:'calendarX', text:pEsc(mtg.by||'Someone')+' cancelled “'+pEsc(mtg.title||'a meeting')+'”'+pEsc(w), link:{kind:'meeting'}}); + try{ notify('❌ Meeting cancelled', (mtg.by||'Someone')+' cancelled '+(mtg.title||'a meeting')+w); }catch(_){} + toast('❌ “'+(mtg.title||'Meeting')+'”'+w+' was cancelled'); if(currentTab()==='meeting') loadScheduledMeetings(); } +function onGroupRole(d){ if(!d||!d.group) return; toast(d.admin?'You are now an admin of this group':'You are no longer an admin'); const gi=document.getElementById('groupInfo'); if(gi){ gi.remove(); if(selected&&selected.kind==='group'&&selected.id===d.group) openGroupInfo(d.group); } } +// A group's membership changed → refresh the sidebar (member count) live; refresh open group-info. +function onGroupUpdate(d){ if(!d||!d.group) return; + loadSidebar().then(()=>{ if(selected&&selected.kind==='group'&&selected.id===d.group){ if(d.removed){ showWelcome(); toast('You were removed from the group'); } else openConvo('group', d.group); } }); + const gi=document.getElementById('groupInfo'); if(gi){ gi.remove(); if(!d.removed && selected&&selected.kind==='group'&&selected.id===d.group) openGroupInfo(d.group); } // refresh open group-info +} +function onChatReaction(d){ const m=THREAD.find(x=>x.id===d.messageId); if(m && d.reactions){ m.reactions=d.reactions; updateBubble(m); } + if(d.added && d.owner===ME.id && d.byId && d.byId!==ME.id){ addNotif({icon:'smilePlus', text:pEsc(d.by||'Someone')+' reacted '+(d.emoji||'')+' to your message', link:d.convId?{kind:'group',id:d.convId}:{kind:'dm',id:d.byId}}); } +} +// 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(); } } +// 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. +function onGroupRead(d){ if(!d||!d.group) return; if(selected && selected.kind==='group' && selected.id===d.group){ THREAD.forEach(m=>{ if(m.from===ME.id && m.created_at<=d.at){ m.seenBy=m.seenBy||[]; if(d.byName && !m.seenBy.includes(d.byName)){ m.seenBy.push(d.byName); updateBubble(m); } } }); } } +// Shared group call: start it (or join the live one — the server returns the existing room). +async function startOrJoinGroupCall(group){ + try{ const r=await postJSON('/api/groups/call/start',{ group }); if(r&&r.room){ meetReturn={kind:'group',id:group}; switchTab('meeting'); enterMeeting(r.room); } } + catch(e){ toast(e.message||'Could not start the call'); } +} +function updateCallBtn(active){ const cc=document.getElementById('convoCall'); if(!cc) return; cc.classList.toggle('joinable',active); cc.title=active?'Join call':'Start call'; cc.innerHTML=ic(active?'video':'phone',18)+(active?'Join':''); } +function onGroupCall(d){ + if(!d||!d.group) return; const it=rowFor('group',d.group); if(it){ it.callActive=!!d.active; it.callRoom=d.room||null; } + if(selected&&selected.kind==='group'&&selected.id===d.group) updateCallBtn(!!d.active); + if(d.active && d.by && d.by!==ME.id && meetRoom!==d.room) showCallInvite(d.room, d.startedByName, {kind:'group',id:d.group}, d.groupName||(it&&it.name)||'Group'); // ring members in + if(!d.active) dismissCallInvite(d.room); // call ended — stop ringing + renderChats(searchVal()); +} +function dismissCallInvite(room){ if(!room) return; const el=document.getElementById('ci-'+room); if(el){ try{ el.remove(); }catch(_){} stopRing(); } } +// 1:1 call: start/join from the DM header; live state updates the button + shows an incoming invite. +async function startOrJoinDmCall(otherId){ + try{ const r=await postJSON('/api/calls/dm/start',{ to:otherId }); if(r&&r.room){ meetReturn={kind:'dm',id:otherId}; switchTab('meeting'); enterMeeting(r.room); } } + catch(e){ toast(e.message||'Could not start the call'); } +} +function onDmCall(d){ + if(!d) return; const it=rowFor('dm', d.with); if(it){ it.callActive=!!d.active; it.callRoom=d.room||null; } + if(selected&&selected.kind==='dm'&&selected.id===d.with) updateCallBtn(!!d.active); + if(d.active && d.by && d.by!==ME.id) showCallInvite(d.room, d.byName, {kind:'dm',id:d.with}); // incoming 1:1 call + if(!d.active) dismissCallInvite(d.room); // call ended/declined — stop ringing + renderChats(searchVal()); +} +// Incoming-call banner (1:1 call or an add-participant invite) with Join / Dismiss. +// ret: where to land when the call ends (the originating chat), or null for the meetings tab. +// sub: a second line under the caller — e.g. the group name for a group call. +function showCallInvite(room, byName, ret, sub){ + if(!room || document.getElementById('ci-'+room)) return; + startRing(); + const who=byName||'Someone'; + const line2 = sub ? ('is calling · '+pEsc(sub)) : 'is calling you'; + const el=document.createElement('div'); el.className='call-invite'; el.id='ci-'+room; + el.innerHTML=''+ic('phone',18)+''+pEsc(who)+'
'+line2+'
' + +'' + +''; + document.body.appendChild(el); + try{ notify('📞 '+who, (sub?('Group call · '+sub):'is calling you'), ret&&ret.kind, ret&&ret.id); }catch(_){} // OS notification too + let closed=false; + const close=()=>{ if(closed) return; closed=true; try{ el.remove(); }catch(_){} stopRing(); }; + el.querySelector('.ci-join').onclick=()=>{ close(); meetReturn=ret||null; switchTab('meeting'); enterMeeting(room); }; + el.querySelector('.ci-decline').onclick=()=>{ close(); if(ret&&ret.kind==='dm'){ postJSON('/api/calls/decline',{room}).catch(()=>{}); } }; // 1:1 → notify caller; group → silent + setTimeout(close, 45000); +} +// Scheduled-meeting invitation (toast + the meeting shows up in their list). +function showMeetingInvite(mtg){ if(!mtg) return; try{ playPing(); }catch(_){} toast('📅 '+(mtg.by||'Someone')+' invited you to “'+mtg.title+'”'+(mtg.whenText?(' · '+mtg.whenText):'')); + addNotif({icon:'calendar', text:pEsc(mtg.by||'Someone')+' invited you to “'+pEsc(mtg.title||'a meeting')+'”'+(mtg.whenText?(' · '+pEsc(mtg.whenText)):''), link:{kind:'meeting', code:mtg.room}}); + try{ notify('📅 Meeting invite', (mtg.by||'Someone')+' invited you to '+(mtg.title||'a meeting')); }catch(_){} + if(currentTab()==='meeting') loadScheduledMeetings(); } +// 10-minute reminder before a scheduled meeting — prominent banner with Join. +function showMeetingReminder(mtg){ if(!mtg||!mtg.room) return; try{ playPing(); }catch(_){} + addNotif({icon:'bell', text:'“'+pEsc(mtg.title||'A meeting')+'” starts in ~10 minutes', link:{kind:'meeting', code:mtg.room}}); + try{ notify('⏰ Meeting reminder', (mtg.title||'A meeting')+' starts in ~10 minutes'); }catch(_){} + if(document.getElementById('mr-'+mtg.id)) return; + const el=document.createElement('div'); el.className='call-invite'; el.id='mr-'+mtg.id; + el.innerHTML=''+ic('calendar',18)+''+pEsc(mtg.title)+'
starts in ~10 minutes
'; + document.body.appendChild(el); + const close=()=>{ try{ el.remove(); }catch(_){} }; + el.querySelector('.ci-join').onclick=()=>{ close(); switchTab('meeting'); enterMeeting(mtg.room); }; + el.querySelector('.ci-x').onclick=close; +} +// 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(); } +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(); } +function renderMeetPanel(){ + const p=document.getElementById('meetPanel'); if(!p) return; + const list=meetParticipantsList(); + const isHostRow=pp=> pp.id==='__local' ? meetIsHost : pp.id===meetHostId; // YOUR row uses meetIsHost + const head='
Participants'+((meetIsHost&&meetPanelTab==='people')?'':'')+'
'; + const tabs='
'; + let body; + if(meetPanelTab==='add'){ + // Group call → only that group's members may be added. 1:1/ad-hoc → all team contacts. + const inGroup = meetReturn && meetReturn.kind==='group'; + if(inGroup && _addPool===null){ _addPool=[]; fetch('/api/groups/members?group='+encodeURIComponent(meetReturn.id)).then(r=>r.json()).then(ms=>{ _addPool=(Array.isArray(ms)?ms:[]).map(x=>({id:x.id,name:x.name})); if(document.getElementById('meetPanel')&&meetPanelTab==='add') renderMeetPanel(); }).catch(()=>{ _addPool=[]; }); } + const pool = inGroup ? (_addPool||[]) : (CONTACTS||[]); + 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())); + body='
'+(avail.length?avail.map(c=>'').join(''):'
'+(inGroup&&_addPool===null?'Loading…':'Everyone\'s already here')+'
')+'
'; + } else { + body='
'+list.map(pp=>'
'+pEsc(initials(pp.name))+''+pEsc(pp.name)+''+(isHostRow(pp)?''+ic('crown',11)+' Host':'')+((pp.id==='__local'?meetScreen:meetSharers.has(pp.id))?''+ic('monitor',13)+'':'')+(meetMuted.get(pp.id)?''+ic('micOff',13)+'':'')+((meetIsHost&&pp.id!=='__local'&&!isHostRow(pp))?'':'')+'
').join('')+'
' + +(meetIsHost?'':''); + } + p.innerHTML=head+tabs+body; + 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'); } }; +} +// Add people to the current call (from the in-call bar). +function openInvitePicker(room){ + if(!room || document.getElementById('invModal')) return; + const ov=document.createElement('div'); ov.className='modal-ov'; ov.id='invModal'; + ov.innerHTML=''; + document.body.appendChild(ov); + ov.onclick=e=>{ if(e.target===ov) ov.remove(); }; + ov.querySelector('#invClose').onclick=()=>ov.remove(); + ov.querySelector('#invBtn').onclick=async()=>{ const ids=[...ov.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, userIds:ids }); ov.remove(); toast('Invited '+r.invited+(r.invited===1?' person':' people')); }catch(e){ toast(e.message||'Could not invite'); } }; +} +// In-chat image viewer (lightbox): open on click, close on ✕ / backdrop / Esc. +function openLightbox(src){ + if(document.getElementById('lightbox')) return; + const ov=document.createElement('div'); ov.className='lightbox'; ov.id='lightbox'; + ov.innerHTML=''+ic('download',20)+''; + document.body.appendChild(ov); + const close=()=>{ ov.remove(); document.removeEventListener('keydown', onKey); }; + const onKey=(e)=>{ if(e.key==='Escape'){ e.preventDefault(); close(); } }; + ov.addEventListener('click',(e)=>{ if(e.target===ov || e.target.closest('.lb-close')) close(); }); + document.addEventListener('keydown', onKey); +} +function updateBubble(m){ + const sel=(window.CSS&&CSS.escape)?CSS.escape(m.id):m.id; + const el=document.querySelector('#msgs .bubble[data-id="'+sel+'"]'); + if(el) el.outerHTML=bubbleHTML(m); +} +// Floating date pill (updates to the day at the top of the viewport) + jump-to-latest button. +function onMsgsScroll(){ + const box=document.getElementById('msgs'); if(!box) return; + const nearBottom=(box.scrollHeight - box.scrollTop - box.clientHeight) < 120; + const jl=document.getElementById('jumpLatest'); if(jl) jl.style.display=nearBottom?'none':'grid'; + const fd=document.getElementById('floatDate'); if(!fd) return; + if(nearBottom){ fd.style.display='none'; return; } + const boxTop=box.getBoundingClientRect().top; let label=''; + box.querySelectorAll('.day-sep').forEach(s=>{ if(s.getBoundingClientRect().top - boxTop <= 10) label=s.textContent; }); + if(label){ fd.style.top=(box.offsetTop+6)+'px'; fd.textContent=label; fd.style.display='block'; } else fd.style.display='none'; +} +function dayKey(ts){ return new Date(ts||Date.now()).toDateString(); } +function dayLabel(ts){ const d=new Date(ts||Date.now()), n=new Date(); if(d.toDateString()===n.toDateString()) return 'Today'; const y=new Date(n); y.setDate(n.getDate()-1); if(d.toDateString()===y.toDateString()) return 'Yesterday'; return d.toLocaleDateString([], {weekday:'long', month:'long', day:'numeric', year:'numeric'}); } +let _lastDay='', _lastMineId=''; // _lastMineId: only my newest message shows the group "Seen by" +function lastMineId(){ for(let i=THREAD.length-1;i>=0;i--){ if(THREAD[i].from===ME.id && !THREAD[i].system) return THREAD[i].id; } return ''; } +function renderThread(){ + const box=document.getElementById('msgs'); if(!box) return; + rendered.clear(); _lastDay=''; _lastMineId=lastMineId(); + if(!THREAD.length){ box.innerHTML='
No messages yet — say hello 👋
'; return; } + let html=''; + for(const m of THREAD){ const dk=dayKey(m.created_at); if(dk!==_lastDay){ html+='
'+pEsc(dayLabel(m.created_at))+'
'; _lastDay=dk; } rendered.add(m.id); html+=bubbleHTML(m); } + box.innerHTML=html; box.scrollTop=box.scrollHeight; +} +function appendBubble(m){ + if(rendered.has(m.id)) return; rendered.add(m.id); + const box=document.getElementById('msgs'); if(!box) return; + const empty=box.querySelector('.empty-thread'); if(empty) empty.remove(); + // A new message of mine becomes the newest: drop the "Seen by" from the previous one. + if(m.from===ME.id && !m.system){ if(_lastMineId){ const pe=box.querySelector('.bubble[data-id="'+((window.CSS&&CSS.escape)?CSS.escape(_lastMineId):_lastMineId)+'"] .seenby'); if(pe) pe.remove(); } _lastMineId=m.id; } + const dk=dayKey(m.created_at); if(dk!==_lastDay){ box.insertAdjacentHTML('beforeend', '
'+pEsc(dayLabel(m.created_at))+'
'); _lastDay=dk; } + box.insertAdjacentHTML('beforeend', bubbleHTML(m)); + box.scrollTop=box.scrollHeight; +} +async function openConvo(kind,id){ + const it=rowFor(kind,id)||{kind,id,name:'Conversation'}; + convoIsGroup=(kind==='group'); + const el=document.getElementById('chatPanel'); el.classList.remove('center'); + el.innerHTML=convoShellHTML(it); + 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(); + const ab2=document.getElementById('attachBtn'); if(ab2) ab2.onclick=()=>{ const fi=document.getElementById('fileInput'); if(fi) fi.click(); }; + const fi=document.getElementById('fileInput'); if(fi) fi.onchange=()=>{ if(fi.files&&fi.files[0]) uploadFile(fi.files[0]); }; + const eb=document.getElementById('emojiBtn'); if(eb) eb.onclick=(e)=>{ e.stopPropagation(); emojiOpen?closeEmoji():openEmoji('compose', eb); }; + const fb=document.getElementById('fmtBtn'); if(fb) fb.onclick=()=>{ const bar=document.getElementById('fmtBar'); if(bar) bar.style.display=bar.style.display==='none'?'flex':'none'; }; + const fbar=document.getElementById('fmtBar'); if(fbar) fbar.querySelectorAll('button[data-fmt]').forEach(b=>b.onclick=()=>applyFmt(b.dataset.fmt)); + const pb=document.getElementById('pollBtn'); if(pb) pb.onclick=()=>openPollModal(id); + const inpEl=document.getElementById('msgInput'); + if(inpEl){ + inpEl.addEventListener('paste', onPaste); + inpEl.addEventListener('input', ()=>autoGrow(inpEl)); + 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 cc=document.getElementById('convoCall'); if(cc) cc.onclick=()=>(kind==='group'?startOrJoinGroupCall(id):startOrJoinDmCall(id)); + 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 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; } + }); + if(box) box.addEventListener('scroll', onMsgsScroll); + 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 + // when opened from a notification; an async-only render would defer the paint until a click). + const ckey=kind+':'+id; + if(THREAD_CACHE.has(ckey)){ THREAD=THREAD_CACHE.get(ckey).slice(); renderThread(); } + if(kind==='group'){ try{ convoMembers=await fetch('/api/groups/members?group='+encodeURIComponent(id)).then(r=>r.json())||[]; }catch(_){ convoMembers=[]; } } + if(!selected||selected.kind!==kind||selected.id!==id) return; + wireMentions(); + const url=kind==='group'?('/api/messages/thread?group='+encodeURIComponent(id)):('/api/messages/thread?with='+encodeURIComponent(id)); + let msgs=[]; try{ msgs=await fetch(url).then(r=>r.json()); }catch(_){} + if(!selected||selected.kind!==kind||selected.id!==id) return; // switched away while loading + THREAD=Array.isArray(msgs)?msgs:[]; + THREAD_CACHE.set(ckey, THREAD.slice()); + renderThread(); + const inp=document.getElementById('msgInput'); if(inp) inp.focus(); +} +// ---------- @mentions (group chat) ---------- +let mentionItems=[], mentionIdx=0, mentionStart=-1; +function wireMentions(){ + const inp=document.getElementById('msgInput'); if(!inp) return; + inp.addEventListener('input', onMentionInput); + inp.addEventListener('keydown', onMentionKey); + inp.addEventListener('blur', ()=>setTimeout(closeMention,150)); +} +function onMentionInput(e){ + const inp=e.target; if(!convoIsGroup){ closeMention(); return; } + const pos=inp.selectionStart; + const m=inp.value.slice(0,pos).match(/(?:^|\s)@([\p{L}\p{N}_]*)$/u); + if(!m){ closeMention(); return; } + mentionStart=pos-m[1].length-1; + const q=m[1].toLowerCase(); + const opts=[]; + if(!q||'everyone'.startsWith(q)||'all'.startsWith(q)) opts.push({id:'everyone',name:'everyone',sub:'Notify the whole group'}); + for(const mem of convoMembers){ if(mem.id===ME.id) continue; if(!q||(mem.name||'').toLowerCase().includes(q)) opts.push({id:mem.id,name:mem.name,avatar:mem.avatar}); } + if(!opts.length){ closeMention(); return; } + mentionItems=opts.slice(0,8); mentionIdx=0; renderMentionPop(); +} +function renderMentionPop(){ + const pop=document.getElementById('mentionPop'); if(!pop) return; + pop.innerHTML=mentionItems.map((o,i)=>'
' + +(o.id==='everyone'?''+ic('users',15)+'':''+pEsc(initials(o.name))+(o.avatar?'':'')+'') + +''+(o.id==='everyone'?'@everyone':pEsc(o.name))+''+(o.sub?''+pEsc(o.sub)+'':'')+'
').join(''); + pop.style.display='block'; + pop.querySelectorAll('.mrow').forEach(r=>{ r.onmousedown=(ev)=>{ ev.preventDefault(); chooseMention(+r.dataset.i); }; }); +} +function closeMention(){ const pop=document.getElementById('mentionPop'); if(pop){ pop.style.display='none'; pop.innerHTML=''; } mentionItems=[]; mentionStart=-1; } +function onMentionKey(e){ + if(!mentionItems.length) return; + if(e.key==='ArrowDown'){ e.preventDefault(); mentionIdx=(mentionIdx+1)%mentionItems.length; renderMentionPop(); } + else if(e.key==='ArrowUp'){ e.preventDefault(); mentionIdx=(mentionIdx-1+mentionItems.length)%mentionItems.length; renderMentionPop(); } + else if(e.key==='Enter'||e.key==='Tab'){ e.preventDefault(); chooseMention(mentionIdx); } + else if(e.key==='Escape'){ closeMention(); } +} +function chooseMention(i){ + const o=mentionItems[i]; if(!o) return; + const inp=document.getElementById('msgInput'); if(!inp||mentionStart<0) return; + const pos=inp.selectionStart; + const before=inp.value.slice(0,mentionStart), after=inp.value.slice(pos); + const token=(o.id==='everyone')?'@everyone':('@'+o.name); + inp.value=before+token+' '+after; + const np=(before+token+' ').length; inp.setSelectionRange(np,np); + composeMentions.set(o.id==='everyone'?'everyone':token, o.id); + closeMention(); inp.focus(); +} +// Resolve the draft's mentions to ids (only those still present in the text) + literal @everyone/@all. +function collectMentions(text){ + const out=[]; + for(const [tok,idv] of composeMentions){ if(tok==='everyone') continue; if(text.includes(tok)) out.push(idv); } + if(/(?:^|\s)@(everyone|all)\b/i.test(text)) out.push('everyone'); + return [...new Set(out)]; +} +// ---- Composer formatting toolbar (Markdown-style) ---- +function applyFmt(kind){ + const inp=document.getElementById('msgInput'); if(!inp) return; + const wrap=(pre,suf)=>{ const s=inp.selectionStart, e=inp.selectionEnd, sel=inp.value.slice(s,e)||'text'; inp.value=inp.value.slice(0,s)+pre+sel+suf+inp.value.slice(e); inp.focus(); inp.setSelectionRange(s+pre.length, s+pre.length+sel.length); autoGrow(inp); }; + const prefixLines=(ol)=>{ const v=inp.value; let s=inp.selectionStart, e=inp.selectionEnd; let ls=v.lastIndexOf('\n', s-1)+1, le=v.indexOf('\n', e); if(le===-1) le=v.length; const lines=v.slice(ls,le).split('\n'); const out=lines.map((ln,i)=>(ol?((i+1)+'. '):'- ')+ln).join('\n'); inp.value=v.slice(0,ls)+out+v.slice(le); inp.focus(); inp.setSelectionRange(ls, ls+out.length); autoGrow(inp); }; + if(kind==='bold') wrap('**','**'); else if(kind==='italic') wrap('*','*'); else if(kind==='strike') wrap('~~','~~'); else if(kind==='code') wrap('`','`'); else if(kind==='ul') prefixLines(false); else if(kind==='ol') prefixLines(true); +} +// Inline Markdown on an already-HTML-escaped line (code/bold/strike/italic). +function fmtInline(s){ + s=s.replace(/`([^`\n]+)`/g,'$1'); + s=s.replace(/\*\*([^*\n]+)\*\*/g,'$1'); + s=s.replace(/~~([^~\n]+)~~/g,'$1'); + s=s.replace(/(^|[^\w*])\*([^*\n]+)\*(?=[^\w*]|$)/g,'$1$2'); + return s; +} +function fmtMentions(s){ // s already HTML-escaped + s=s.replace(/(^|\s)@(everyone|all)\b/gi,(mm,pre)=>pre+'@everyone'); + if(convoIsGroup){ for(const mem of convoMembers){ if(!mem.name) continue; const esc=pEsc('@'+mem.name); if(esc&&s.includes(esc)) s=s.split(esc).join(''+esc+''); } } + return s; +} +const fmtSeg=(line)=>fmtInline(fmtMentions(line)); +// Render a message body: lists (-, *, •, 1.) + inline Markdown + mentions, newlines as
. +function renderMsgBody(m){ + const lines=pEsc(m.body||'').split('\n'); + let out='', list=null; const buf=[], para=[]; + const flushList=()=>{ if(list){ out+='<'+list+' class="msg-list">'+buf.map(x=>'
  • '+x+'
  • ').join('')+''; buf.length=0; list=null; } }; + const flushPara=()=>{ if(para.length){ out+=para.join('
    '); para.length=0; } }; + for(const ln of lines){ + const ul=ln.match(/^\s*(?:[-*•])\s+(.+)$/), ol=ln.match(/^\s*\d+[.)]\s+(.+)$/); + if(ul){ flushPara(); if(list!=='ul'){ flushList(); list='ul'; } buf.push(fmtSeg(ul[1])); } + else if(ol){ flushPara(); if(list!=='ol'){ flushList(); list='ol'; } buf.push(fmtSeg(ol[1])); } + else { flushList(); para.push(fmtSeg(ln)); } + } + flushList(); flushPara(); + return out; +} +// ---------- Polls (group chat) ---------- +function pollHTML(m){ + const p=m.poll; if(!p) return ''; + const max=Math.max(1, ...p.options.map(o=>o.votes||0)); + const opts=p.options.map((o,i)=>{ + const pct=p.totalVotes?Math.round(o.votes/p.totalVotes*100):0; + const w=Math.round((o.votes||0)/max*100); + return ''; + }).join(''); + const foot='
    '+p.voters+' voter'+(p.voters===1?'':'s')+(p.multi?' · choose multiple':'')+(p.closed?' · Closed':'') + +((p.isOwner&&!p.closed)?' · Close poll':'')+'
    '; + return '
    '+ic('barChart',14)+' Poll'+(p.multi?' · multiple':'')+'
    '+opts+foot+'
    '; +} +function updatePollInThread(messageId, poll){ + const m=THREAD.find(x=>x.id===messageId); if(m) m.poll=poll; + document.querySelectorAll('.poll').forEach(el=>{ if(el.getAttribute('data-msg')===messageId && m){ el.outerHTML=pollHTML(m); } }); +} +async function votePoll(pollId, idx){ + try{ const poll=await postJSON('/api/polls/vote',{ pollId, optionIdx:idx }); const m=THREAD.find(x=>x.poll&&x.poll.id===pollId); if(m) updatePollInThread(m.id, poll); } + catch(e){ toast(e.message||'Could not vote'); } +} +async function closePoll(pollId){ + if(!confirm('Close this poll? No more votes can be cast.')) return; + try{ const poll=await postJSON('/api/polls/close',{ pollId }); const m=THREAD.find(x=>x.poll&&x.poll.id===pollId); if(m) updatePollInThread(m.id, poll); } + catch(e){ toast(e.message||'Could not close poll'); } +} +function onPollUpdate(d){ const m=THREAD.find(x=>x.poll&&x.poll.id===d.poll.id); if(m) updatePollInThread(m.id, d.poll); } +function openPollModal(gid){ + if(document.getElementById('pollModal')) return; + const ov=document.createElement('div'); ov.className='modal-ov'; ov.id='pollModal'; + ov.innerHTML=''; + document.body.appendChild(ov); + const optsWrap=ov.querySelector('#pollOpts'); + const addOpt=()=>{ if(optsWrap.children.length>=10) return; const row=document.createElement('div'); row.className='poll-opt-row'; row.innerHTML=''; optsWrap.appendChild(row); row.querySelector('.rm').onclick=()=>{ if(optsWrap.children.length>2) row.remove(); }; }; + addOpt(); addOpt(); + ov.onclick=e=>{ if(e.target===ov) ov.remove(); }; + ov.querySelector('#pollClose').onclick=()=>ov.remove(); + ov.querySelector('#pollAdd').onclick=addOpt; + setTimeout(()=>{ const q=ov.querySelector('#pollQ'); if(q) q.focus(); },0); + ov.querySelector('#pollCreate').onclick=async()=>{ + const q=ov.querySelector('#pollQ').value.trim(); + const options=[...optsWrap.querySelectorAll('input')].map(i=>i.value.trim()).filter(Boolean); + const multi=ov.querySelector('#pollMulti').checked; + const err=ov.querySelector('#pollErr'); + if(!q){ err.textContent='Add a question.'; return; } + if(options.length<2){ err.textContent='Add at least two options.'; return; } + try{ await postJSON('/api/polls',{ group:gid, question:q, options, multi }); ov.remove(); } + catch(e){ err.textContent=e.message||'Could not create poll'; } + }; +} +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; + renderChats(searchVal()); + updateRailUnread(); + await openConvo(kind,id); +} +async function sendMessage(){ + const inp=document.getElementById('msgInput'); if(!inp) return; + const text=inp.value.trim(); if((!text&&!pendingAttach)||!selected) return; + inp.value=''; inp.style.height='auto'; + const replyTo=replyTarget?replyTarget.id:null; + const attachmentId=pendingAttach?pendingAttach.id:null; + const payload = selected.kind==='group' ? { group:selected.id, body:text, replyTo, attachmentId, mentions:collectMentions(text) } : { to:selected.id, body:text, replyTo, attachmentId }; + try{ + const m=await postJSON('/api/messages', payload); + composeMentions=new Map(); + THREAD.push(m); appendBubble(m); clearReply(); pendingAttach=null; hideAttach(); + { const ck=selected.kind+':'+selected.id; if(THREAD_CACHE.has(ck)){ const a=THREAD_CACHE.get(ck); if(!a.some(x=>x.id===m.id)) a.push(m); } } + const it=rowFor(selected.kind,selected.id); + if(it){ it.last_body=m.body||(m.attachment?'📎 '+(m.attachment.name||'Attachment'):''); it.last_at=m.created_at; it.last_from_me=true; it.unread=0; } + 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. +function notifOn(kind){ try{ return localStorage.getItem('notif_'+kind)!=='off'; }catch(_){ return true; } } +document.addEventListener('click', ensureNotifyPermission, { once:true }); +// System notification for a new message (shown when the tab isn't focused). +// Open a chat from a notification (called via the service worker, or the fallback path). +// Remove the old service worker (an earlier attempt) so it can't interfere. +if('serviceWorker' in navigator){ try{ navigator.serviceWorker.getRegistrations().then(rs=>rs.forEach(r=>r.unregister())).catch(()=>{}); }catch(_){} } +// Open the chat from a 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. +function notify(title, body, kind, id){ + try{ + if(!('Notification' in window) || Notification.permission!=='granted') return; + const n=new Notification(title, { body, icon:'/logo.png' }); + n.onclick=()=>{ try{ window.focus(); }catch(_){} const u='/home?openKind='+encodeURIComponent(kind||'')+'&openId='+encodeURIComponent(id||''); n.close(); location.assign(u); }; + setTimeout(()=>{ try{ n.close(); }catch(_){} }, 8000); + }catch(_){} +} +let _audioCtx=null; +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); + }catch(_){} +} +// Continuous incoming-call ring. A soft, soothing bell chime — a gentle ascending arpeggio +// (C major) of pure sine tones with an octave shimmer and a long smooth decay, so it rings +// rather than buzzes. Ref-counted: several pending invites share one ring; it stops on the last close. +let _ringTimer=null, _ringRefs=0; +function ringTone(t, freq, dur, peak){ + const g=_audioCtx.createGain(); g.connect(_audioCtx.destination); + g.gain.setValueAtTime(0.0001,t); + g.gain.exponentialRampToValueAtTime(peak,t+0.03); // gentle attack (no click) + g.gain.exponentialRampToValueAtTime(0.0001,t+dur); // long, smooth bell-like decay + const o=_audioCtx.createOscillator(); o.type='sine'; o.frequency.value=freq; o.connect(g); o.start(t); o.stop(t+dur); + const o2=_audioCtx.createOscillator(); o2.type='sine'; o2.frequency.value=freq*2; // octave shimmer + const g2=_audioCtx.createGain(); g2.gain.value=0.3; o2.connect(g2); g2.connect(g); o2.start(t); o2.stop(t+dur); +} +function ringOnce(){ + try{ + _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 + }catch(_){} +} +function startRing(){ _ringRefs++; if(_ringTimer) return; ringOnce(); _ringTimer=setInterval(ringOnce, 3500); } +function stopRing(force){ _ringRefs = force ? 0 : Math.max(0, _ringRefs-1); if(_ringRefs>0) return; if(_ringTimer){ clearInterval(_ringTimer); _ringTimer=null; } } +function onChatMessage(m){ + const isGroupMsg=!!m.conversation_id; + const kind=isGroupMsg?'group':'dm'; + const rid=isGroupMsg?m.conversation_id:(m.from===ME.id?m.to:m.from); + let it=rowFor(kind,rid); const wasNew=!it; + if(!it){ loadSidebar(); } // first DM / a new group we were added to — refresh the list + // Keep the thread cache warm so a notification click can render this chat synchronously. + const ckey=kind+':'+rid; + if(THREAD_CACHE.has(ckey)){ const arr=THREAD_CACHE.get(ckey); if(!arr.some(x=>x.id===m.id)) arr.push(m); } + else { const pu=(kind==='group'?'/api/messages/thread?group='+encodeURIComponent(rid):'/api/messages/thread?with='+encodeURIComponent(rid))+'&peek=1'; fetch(pu).then(r=>r.json()).then(a=>{ if(Array.isArray(a)) THREAD_CACHE.set(ckey,a); }).catch(()=>{}); } + const isOpen=selected&&selected.kind===kind&&selected.id===rid&¤tTab()==='chat'; + const isSys=!!m.system || m.from==='__system__'; // activity lines: show in chat, but no ping/notify/unread + if(m.from!==ME.id && !isSys){ + if(!isGroupMsg && chatWs && chatWs.readyState===1){ try{ chatWs.send(JSON.stringify({type:'chat-delivered', id:m.id})); }catch(_){} } // ack DM delivery + if(notifOn(kind)){ playPing(); if(!(isOpen && !document.hidden)) notify((m.fromName||'New message'), m.body?(m.body.length>80?m.body.slice(0,80)+'…':m.body):'Sent an attachment', kind, rid); } + // Activity-center entries for things easy to miss. + if(m.poll) addNotif({icon:'barChart', text:pEsc(m.fromName||'Someone')+' created a poll'+(m.poll.question?': '+pEsc(m.poll.question):''), link:{kind, id:rid}}); + else if(kind==='dm' && wasNew) addNotif({icon:'chat', text:'New chat from '+pEsc(m.fromName||'someone'), link:{kind:'dm', id:rid}}); + } + if(it){ + it.last_body=isSys?m.body:(m.body||(m.attachment?'📎 '+(m.attachment.name||'Attachment'):'')); it.last_at=m.created_at; it.last_from_me=(m.from===ME.id); + if(m.from!==ME.id && !isOpen && !isSys) it.unread=(it.unread||0)+1; + } + if(isOpen){ + THREAD.push(m); appendBubble(m); + if(m.from!==ME.id && !isSys){ if(it) it.unread=0; const body=JSON.stringify(kind==='group'?{group:rid}:{with:rid}); try{ fetch('/api/messages/read',{method:'POST',headers:{'Content-Type':'application/json'},body}); }catch(_){} } + } else if(m.from!==ME.id && !isSys && notifOn(kind)){ + toast((m.fromName||'New message')+': '+(m.body?(m.body.length>60?m.body.slice(0,60)+'…':m.body):'📎 Attachment')); + } + renderChats(searchVal()); updateRailUnread(); +} +let chatWs=null; +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.onclose=()=>{ setTimeout(connectChatWs, 3000); }; // auto-reconnect + }catch(_){} +} function renderChatPanel(){ const el=document.getElementById('chatPanel'); - if(selectedChat==null){ el.classList.add('center'); el.innerHTML=welcomeHTML(); wireWelcome(); } - else { el.classList.remove('center'); el.innerHTML=convoHTML(CHATS[selectedChat]); const b=document.getElementById('convoBack'); if(b) b.onclick=showWelcome; } + if(!selected){ el.classList.add('center'); el.innerHTML=welcomeHTML(); wireWelcome(); } + else { el.classList.remove('center'); openConvo(selected.kind, selected.id); } } -function wireWelcome(){ - document.querySelectorAll('#chatPanel .wcard').forEach(card=>{ - card.onclick=()=>switchTab(card.dataset.go); - }); +// ----- New group modal ----- +function openNewGroup(){ + if(document.getElementById('groupModal')) return; + const ov=document.createElement('div'); ov.className='modal-ov'; ov.id='groupModal'; + ov.innerHTML=''; + 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 n=document.getElementById('grpName'); if(n) n.focus(); +} +async function createGroup(){ + const name=document.getElementById('grpName').value.trim(); + const ids=[...document.querySelectorAll('#groupModal .grp-members input:checked')].map(i=>i.value); + if(!name){ toast('Enter a group name'); return; } + if(!ids.length){ toast('Pick at least one member'); return; } + try{ + const g=await postJSON('/api/groups',{ name, memberIds:ids }); + const ov=document.getElementById('groupModal'); if(ov) ov.remove(); + await loadSidebar(); + selectChat('group', g.id); + }catch(e){ toast(e.message||'Could not create group'); } +} +async function openGroupInfo(gid){ + let info; try{ info=await fetch('/api/groups/info?group='+encodeURIComponent(gid)).then(r=>r.json()); }catch(_){ return; } + if(!info||!info.id) return; + const inSet=new Set(info.members.map(m=>m.id)); + const addable=CONTACTS.filter(c=>!inSet.has(c.id)); + const canManage=info.isAdmin || !info.adminOnly; // who may add/remove members + const miniAv=(name,avatar)=>'
    '+pEsc(initials(name))+(avatar?'':'')+'
    '; + const ov=document.createElement('div'); ov.className='modal-ov'; ov.id='groupInfo'; + ov.innerHTML=''; + const _old=document.getElementById('groupInfo'); if(_old) _old.remove(); // never stack group-info modals (stale close buttons) + document.body.appendChild(ov); + ov.onclick=(e)=>{ if(e.target===ov) ov.remove(); }; + ov.querySelector('#giClose').onclick=()=>ov.remove(); + 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(); }; + document.getElementById('giCancelEdit').onclick=()=>{ nameInp.value=info.name; editRow.classList.add('hidden'); view.classList.remove('hidden'); closeBtn.style.display=''; }; + const doRename=async()=>{ const nm=nameInp.value.trim(); if(!nm){ toast('Name required'); return; } try{ await postJSON('/api/groups/rename',{group:gid,name:nm}); ov.remove(); await refresh(); toast('Group renamed'); }catch(e){ toast(e.message); } }; + document.getElementById('giSave').onclick=doRename; + nameInp.addEventListener('keydown',e=>{ if(e.key==='Enter'){ e.preventDefault(); doRename(); } }); + document.getElementById('giCall').onclick=()=>startGroupCall(gid); + document.getElementById('giSchedule').onclick=()=>scheduleGroupCall(gid); + const adminOnlyCb=document.getElementById('giAdminOnly'); if(adminOnlyCb) adminOnlyCb.onchange=async()=>{ try{ await postJSON('/api/groups/admin-only',{ group:gid, value:adminOnlyCb.checked }); ov.remove(); await refresh(); openGroupInfo(gid); }catch(e){ adminOnlyCb.checked=!adminOnlyCb.checked; toast(e.message||'Could not update'); } }; + const addTgl=document.getElementById('giAddToggle'); if(addTgl) addTgl.onclick=()=>{ const w=document.getElementById('giAddWrap'); if(w){ w.classList.toggle('hidden'); if(!w.classList.contains('hidden')){ const s=document.getElementById('giAddSearch'); if(s) setTimeout(()=>s.focus(),0); } } }; + const addSearch=document.getElementById('giAddSearch'); + if(addSearch){ + const filter=()=>{ const q=addSearch.value.trim().toLowerCase(); const xb=document.getElementById('giAddSearchX'); if(xb) xb.style.display=q?'grid':'none'; let shown=0; ov.querySelectorAll('#giAddList .chk').forEach(l=>{ const vis=(!q||(l.dataset.name||'').includes(q)); l.style.display=vis?'':'none'; if(vis) shown++; }); const empty=document.getElementById('giAddEmpty'); if(empty) empty.style.display=shown?'none':'block'; }; + addSearch.addEventListener('input', filter); + const xb=document.getElementById('giAddSearchX'); if(xb) xb.onclick=()=>{ addSearch.value=''; filter(); addSearch.focus(); }; + } + const addBtn=document.getElementById('giAddBtn'); if(addBtn) addBtn.onclick=async()=>{ const ids=[...ov.querySelectorAll('#giAddWrap input:checked')].map(i=>i.value); if(!ids.length){ toast('Pick members to add'); return; } try{ await postJSON('/api/groups/add',{group:gid,memberIds:ids}); ov.remove(); await refresh(); openGroupInfo(gid); }catch(e){ toast(e.message); } }; + const nameOf=(uid)=>{ const mm=info.members.find(x=>x.id===uid); return mm?mm.name:'this person'; }; + ov.querySelectorAll('[data-rm]').forEach(b=>b.onclick=async()=>{ const nm=nameOf(b.dataset.rm); if(!confirm('Remove '+nm+' from the group?\n\nThey will lose access to this conversation.')) return; try{ await postJSON('/api/groups/remove',{group:gid,userId:b.dataset.rm}); ov.remove(); await refresh(); openGroupInfo(gid); }catch(e){ toast(e.message); } }); + ov.querySelectorAll('[data-role]').forEach(b=>b.onclick=async()=>{ const mk=b.dataset.val==='1'; const nm=nameOf(b.dataset.role); if(!confirm(mk?('Make '+nm+' an admin?\n\nThey will be able to add or remove members and manage this group.'):('Remove '+nm+' as an admin?'))) return; try{ await postJSON('/api/groups/admin',{group:gid,userId:b.dataset.role,value:mk}); ov.remove(); await refresh(); openGroupInfo(gid); }catch(e){ toast(e.message); } }); + document.getElementById('giLeave').onclick=async()=>{ + const admins=info.members.filter(m=>m.admin), others=info.members.filter(m=>!m.isMe); + // #10: last admin must pick a successor before leaving. + if(info.isAdmin && others.length && admins.length===1){ ov.remove(); pickSuccessorThenLeave(gid, others); return; } + if(!confirm('Leave this group?')) return; + try{ await postJSON('/api/groups/remove',{group:gid}); ov.remove(); selected=null; await loadSidebar(); renderChatPanel(); }catch(e){ toast(e.message); } + }; + // Group photo: click avatar -> pick image -> upload -> set as group image. + const photoBtn=document.getElementById('giPhoto'), photoInput=document.getElementById('giPhotoInput'); + if(photoBtn&&photoInput){ + photoBtn.onclick=()=>photoInput.click(); + photoInput.onchange=async()=>{ + const file=photoInput.files&&photoInput.files[0]; if(!file) return; + if(!/^image\//.test(file.type||'')){ toast('Please choose an image file'); return; } + try{ + const up=await fetch('/api/messages/upload',{ method:'POST', headers:{ 'Content-Type':file.type||'application/octet-stream', 'X-Filename':encodeURIComponent(file.name) }, body:file }).then(r=>r.json()); + if(!up||!up.id) throw new Error(up&&up.error||'Upload failed'); + await postJSON('/api/groups/avatar',{ group:gid, attachmentId:up.id }); + ov.remove(); await refresh(); openGroupInfo(gid); toast('Group photo updated'); + }catch(e){ toast(e.message||'Could not update photo'); } + }; + } +} +// Start an audio call with the group: spin up an audio-only meeting and post the join code to the group chat. +// Group-info "Call" button → start (or join) the SHARED group call so every member is notified/rung. +function startGroupCall(gid){ const ov=document.getElementById('groupInfo'); if(ov) ov.remove(); startOrJoinGroupCall(gid); } +// Schedule a call tied to this group: close group info, open the schedule modal. +function scheduleGroupCall(gid){ const ov=document.getElementById('groupInfo'); if(ov) ov.remove(); openScheduleModal(gid); } + +// ---------- Meetings (mesh P2P video) ---------- +let meetWs=null, meetLocalStream=null, meetRoom=null, meetMyId=null, meetState='idle'; +let meetAnnounceGroup=null; // when set, the new meeting's code is posted to this group chat +let meetAudioOnly=false; // audio call (no camera) — tiles show avatars instead of video +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) +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' }] }; +let meetMic=true, meetCam=true; +let meetIsHost=false, meetHostId=null; // host = the meeting creator (transferable by the host only) +let meetScreen=false, meetScreenStream=null; // am I sharing my screen + the display stream +const meetSharers=new Set(); // peerIds of OTHERS currently sharing their screen +let meetMultiShare=false; // host setting: allow several people to share at once (default: one at a time) +let meetRec=null; // active composite recording (host) {rec, stop()} +let meetTranscribe=false; // am I subscribed to a transcript copy +let meetRoomTx=false; // is the room transcription active (≥1 subscriber → all mics transcribe) +let meetSR=null; // my SpeechRecognition instance +let meetStageId=null; // which shared screen is currently on the stage (peerId|'__local') +function meetSend(o){ try{ if(meetWs && meetWs.readyState===1) meetWs.send(JSON.stringify(o)); }catch(_){} } +function meetRailLive(on){ const b=document.querySelector('.railbtn[data-tab="meeting"]'); if(b) b.classList.toggle('live', !!on); } + +function renderMeetingLobby(){ + meetState='idle'; + const el=document.getElementById('meetingPanel'); if(!el) return; + el.innerHTML='
    ' + + '
    ' + + '

    Meetings

    Start or join a video meeting, or schedule one for later. Small group (mesh) for now — larger rooms coming with the SFU.

    ' + + '
    ' + + '
    ' + + '' + + '' + + '
    ' + + '
    ' + + '
    ' + + '
    Loading meetings…
    ' + + '
    '; + document.getElementById('meetStart').onclick=()=>enterMeeting(null); + document.getElementById('meetSchedule').onclick=()=>openScheduleModal(null); + const doJoin=()=>{ const c=document.getElementById('meetCode').value.trim(); if(/^\d{6}$/.test(c)) enterMeeting(c); else document.getElementById('meetErr').textContent='Enter a valid 6-digit code.'; }; + document.getElementById('meetJoinBtn').onclick=doJoin; + document.getElementById('meetCode').addEventListener('keydown', e=>{ if(e.key==='Enter') doJoin(); }); + loadScheduledMeetings(); +} +// Fetch + render scheduled meetings, bucketed into Running / Upcoming / Past. +async function loadScheduledMeetings(){ + const wrap=document.getElementById('schedWrap'); if(!wrap) return; + let list; try{ list=await fetch('/api/meetings').then(r=>r.json()); }catch(_){ return; } + if(!Array.isArray(list)||!list.length){ wrap.innerHTML='
    No meetings yet. Start one now or schedule it for later.
    '; return; } + const running=list.filter(m=>m.status==='running'); + const upcoming=list.filter(m=>m.status==='upcoming').sort((a,b)=>a.scheduledAt-b.scheduledAt); + const past=list.filter(m=>m.status==='past'||m.status==='cancelled').sort((a,b)=>b.scheduledAt-a.scheduledAt).slice(0,12); + const fmt=ts=>new Date(ts).toLocaleString([],{weekday:'short',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}); + const card=m=>{ + const cancelled=m.status==='cancelled'; + const meta=[]; if(m.groupName) meta.push(pEsc(m.groupName)); meta.push(fmt(m.scheduledAt)); + if(m.durationMins) meta.push(m.durationMins+' min'); + if(m.recurrenceLabel) meta.push('🔁 '+pEsc(m.recurrenceLabel)); + if(m.status==='running') meta.push(m.inCall+' in call'); + meta.push(m.isHost?'You\'re the host':('Host: '+pEsc(m.createdByName||'—'))); + if(m.invited&&m.invited.length) meta.push(m.invited.length+' invited'); + // Cancel only on a FUTURE upcoming meeting (#13: not once the time has passed). + const canCancel=m.canManage && m.status==='upcoming' && m.scheduledAt>Date.now(); + // Start any time BEFORE the meeting's end; once the window passes it's 'past' and can't start (#3). + const canStart=m.status==='running' || m.status==='upcoming'; + return '
    ' + +'
    '+pEsc(m.title)+(m.status==='running'?'● Live':'')+(cancelled?'Cancelled':'')+(m.isHost?''+ic('crown',11)+' Host':'')+'
    ' + +'
    '+meta.join(' · ')+'
    ' + +(m.description?'
    '+pEsc(m.description)+'
    ':'') + +(m.invited&&m.invited.length?'
    '+ic('users',12)+' '+pEsc(m.invited.slice(0,3).join(', '))+(m.invited.length>3?(' +'+(m.invited.length-3)):'')+'
    ':'') + +(m.recordings&&m.recordings.length?'':'')+'
    ' + +'
    ' + +((m.status!=='past'&&!cancelled&&canStart)?'':'') + +(canCancel?'':'') + +(canCancel?'':'') + +'
    '; + }; + const byId={}; list.forEach(m=>byId[m.id]=m); + const sec=(title,arr)=>arr.length?('
    '+title+'
    '+arr.map(card).join('')+'
    '):''; + wrap.innerHTML=sec('Ongoing now',running)+sec('Upcoming meetings',upcoming)+sec('Past meetings',past); + wrap.querySelectorAll('[data-code]').forEach(b=>b.onclick=()=>enterMeeting(b.dataset.code)); + wrap.querySelectorAll('[data-edit]').forEach(b=>b.onclick=()=>{ const m=byId[b.dataset.edit]; if(m) openScheduleModal(m.groupId||null, m); }); + wrap.querySelectorAll('[data-cancel]').forEach(b=>b.onclick=()=>cancelMeeting(byId[b.dataset.cancel])); +} +// Last admin leaving must hand off: pick who becomes the new admin, then leave (#10, selective). +function pickSuccessorThenLeave(gid, others){ + const ov=document.createElement('div'); ov.className='modal-ov'; + ov.innerHTML=''; + document.body.appendChild(ov); ov.onclick=e=>{ if(e.target===ov) ov.remove(); }; + ov.querySelector('#heirGo').onclick=async()=>{ const sel=ov.querySelector('input[name=heir]:checked'); if(!sel){ ov.querySelector('#heirErr').textContent='Please pick a member.'; return; } + try{ await postJSON('/api/groups/remove',{group:gid, newAdmin:sel.value}); ov.remove(); selected=null; await loadSidebar(); renderChatPanel(); toast('Left the group'); }catch(e){ ov.querySelector('#heirErr').textContent=e.message||'Could not leave'; } }; +} +// Cancel a meeting. Recurring → ask whether to cancel just this occurrence or all future (#recurring). +function cancelMeeting(m){ + if(!m) return; + const doCancel=async(scope)=>{ try{ await postJSON('/api/meetings/cancel',{id:m.id, scope}); loadScheduledMeetings(); }catch(e){ toast(e.message); } }; + if(m.recurrence&&m.recurrence.length){ + const dlabel=new Date(m.scheduledAt).toLocaleString([],{weekday:'short',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}); + const ov=document.createElement('div'); ov.className='modal-ov'; + ov.innerHTML=''; + document.body.appendChild(ov); ov.onclick=e=>{ if(e.target===ov) ov.remove(); }; + ov.querySelector('#cmOne').onclick=()=>{ ov.remove(); doCancel('one'); }; + ov.querySelector('#cmAll').onclick=()=>{ ov.remove(); doCancel('all'); }; + ov.querySelector('#cmNo').onclick=()=>ov.remove(); + } else { if(confirm('Cancel this meeting? Participants will be notified.')) doCancel('all'); } +} +// Schedule / edit modal with a custom calendar + time picker. gid ties it to a group; editMtg edits. +function openScheduleModal(gid, editMtg){ + const pad=n=>String(n).padStart(2,'0'); const now=new Date(); + const startOfDay=d=>{ const x=new Date(d); x.setHours(0,0,0,0); return x; }; + const editing=!!editMtg; + const base=editing?new Date(editMtg.scheduledAt):new Date(now.getTime()+30*60000); + let selDate=startOfDay(base); + let selMin=Math.round((base.getHours()*60+base.getMinutes())/15)*15; + let viewY=selDate.getFullYear(), viewM=selDate.getMonth(); + let recur=(editing&&Array.isArray(editMtg.recurrence))?editMtg.recurrence.slice():[]; + const DAYW=['Sun','Mon','Tue','Wed','Thu','Fri','Sat'], DAY1=['S','M','T','W','T','F','S']; + const todayMid=startOfDay(now).getTime(); + const label12=m=>{ let h=Math.floor(m/60),mm=m%60; const ap=h<12?'AM':'PM'; let h12=h%12||12; return h12+':'+pad(mm)+' '+ap; }; + const dateLabel=d=>d.toLocaleDateString([],{weekday:'short',month:'short',day:'numeric'}); + const invitedIds=new Set(editing&&Array.isArray(editMtg.invitedIds)?editMtg.invitedIds:[]); + const ov=document.createElement('div'); ov.className='modal-ov'; ov.id='schedModal'; + ov.innerHTML=''; + document.body.appendChild(ov); + ov.onclick=e=>{ if(e.target===ov) ov.remove(); }; + document.getElementById('schClose').onclick=()=>ov.remove(); + const $=id=>document.getElementById(id); + if(editing){ $('schTitle').value=editMtg.title||''; $('schDesc').value=editMtg.description||''; if(editMtg.durationMins) $('schDur').value=String(editMtg.durationMins); } + else $('schDur').value='30'; + const err=$('schErr'); + const dateBtn=$('schDateBtn'), timeBtn=$('schTimeBtn'), cal=$('schCal'), timePop=$('schTimePop'); + const clearErrAll=()=>{ err.textContent=''; [dateBtn,timeBtn,$('schTitle')].forEach(e=>e&&e.classList.remove('field-err')); }; + function syncLabels(){ $('schDateLbl').textContent=dateLabel(selDate); $('schTimeLbl').textContent=label12(selMin); } + function renderCal(){ + const first=new Date(viewY,viewM,1), startDow=first.getDay(), dim=new Date(viewY,viewM+1,0).getDate(); + const canPrev=!(viewY===now.getFullYear()&&viewM===now.getMonth()); + let h='
    '+first.toLocaleDateString([],{month:'long',year:'numeric'})+'
    '+DAY1.map(d=>''+d+'').join(''); + for(let i=0;i'+day+''; } + cal.innerHTML=h+'
    '; + const pv=cal.querySelector('#calPrev'); if(pv&&canPrev) pv.onclick=()=>{ if(--viewM<0){viewM=11;viewY--;} renderCal(); }; + cal.querySelector('#calNext').onclick=()=>{ if(++viewM>11){viewM=0;viewY++;} renderCal(); }; + cal.querySelectorAll('.cal-day:not([disabled])').forEach(b=>b.onclick=()=>{ selDate=new Date(viewY,viewM,+b.dataset.day); cal.classList.add('hidden'); clearErrAll(); renderTimes(); syncLabels(); }); + } + function renderTimes(){ + const isToday=selDate.getTime()===todayMid; const minM=isToday?(now.getHours()*60+now.getMinutes()):-1; + if(selMin<=minM){ selMin=Math.ceil((minM+1)/15)*15; } + let h='',any=false; + for(let m=0;m<24*60;m+=15){ if(m<=minM) continue; any=true; h+=''; } + timePop.innerHTML=any?h:'
    No times left today
    '; + timePop.querySelectorAll('.time-chip').forEach(b=>b.onclick=()=>{ selMin=+b.dataset.m; timePop.classList.add('hidden'); clearErrAll(); syncLabels(); }); + syncLabels(); + } + dateBtn.onclick=e=>{ e.stopPropagation(); timePop.classList.add('hidden'); cal.classList.toggle('hidden'); if(!cal.classList.contains('hidden')){ viewY=selDate.getFullYear(); viewM=selDate.getMonth(); renderCal(); } }; + timeBtn.onclick=e=>{ e.stopPropagation(); cal.classList.add('hidden'); timePop.classList.toggle('hidden'); if(!timePop.classList.contains('hidden')){ renderTimes(); const s=timePop.querySelector('.time-chip.sel'); if(s) s.scrollIntoView({block:'center'}); } }; + ov.addEventListener('click',e=>{ if(!e.target.closest('.picker-field')){ cal.classList.add('hidden'); timePop.classList.add('hidden'); } }); + renderTimes(); syncLabels(); + // Repeat (iOS switch) + circular day chips + const repeat=$('schRepeat'), daysWrap=$('schDays'); + if(editing&&recur.length){ repeat.checked=true; daysWrap.classList.remove('hidden'); recur.forEach(d=>{ const b=daysWrap.querySelector('.day-chip[data-d="'+d+'"]'); if(b) b.classList.add('on'); }); } + repeat.onchange=()=>{ daysWrap.classList.toggle('hidden', !repeat.checked); }; + daysWrap.querySelectorAll('.day-chip').forEach(b=>b.onclick=()=>b.classList.toggle('on')); + daysWrap.querySelector('.day-all').onclick=()=>{ const allOn=daysWrap.querySelectorAll('.day-chip.on').length===7; daysWrap.querySelectorAll('.day-chip').forEach(x=>x.classList.toggle('on', !allOn)); }; + $('schTitle').addEventListener('input',clearErrAll); + setTimeout(()=>$('schTitle').focus(),0); + $('schSave').onclick=async()=>{ + const title=$('schTitle').value.trim(); + const desc=$('schDesc').value.trim(); + const durationMins=parseInt($('schDur').value,10)||30; + const participants=[...ov.querySelectorAll('#schPeople input:checked')].map(i=>i.value); + clearErrAll(); + if(!title){ err.textContent='Please add a title.'; $('schTitle').classList.add('field-err'); $('schTitle').focus(); return; } + const ts=new Date(selDate.getFullYear(),selDate.getMonth(),selDate.getDate(),Math.floor(selMin/60),selMin%60,0,0).getTime(); + if(ts < Date.now()+60000){ err.textContent='That time has already passed — pick a future time.'; timeBtn.classList.add('field-err'); return; } + let recurrence=[]; if(repeat.checked){ recurrence=[...daysWrap.querySelectorAll('.day-chip.on')].map(b=>+b.dataset.d); if(!recurrence.length) recurrence=[new Date(ts).getDay()]; } + const whenText=new Date(ts).toLocaleString([],{weekday:'short',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}); + try{ + if(editing){ await postJSON('/api/meetings/update',{ id:editMtg.id, title, description:desc, scheduledAt:ts, durationMins, participants, recurrence }); toast('Meeting updated'); } + else { await postJSON('/api/meetings/schedule',{ group:gid||undefined, title, description:desc, scheduledAt:ts, whenText, participants, durationMins, recurrence }); toast('Meeting scheduled'+(participants.length?' · '+participants.length+' invited':'')); } + ov.remove(); switchTab('meeting'); loadScheduledMeetings(); + }catch(e){ err.textContent=e.message||'Could not save'; } + }; +} +function renderCall(){ + const el=document.getElementById('meetingPanel'); if(!el) return; + el.innerHTML='
    ' + + '
    Room '+pEsc(meetRoom||'')+' · share to invite' + + '' + + '' + + '' + + '' + + '' + + '' + + '
    '; + document.getElementById('meetMicBtn').onclick=toggleMic; + document.getElementById('meetCamBtn').onclick=toggleCam; + document.getElementById('meetScreenBtn').onclick=toggleScreen; + document.getElementById('meetRecBtn').onclick=toggleRecord; + document.getElementById('meetTransBtn').onclick=toggleTranscribe; + document.getElementById('meetPplBtn').onclick=toggleMeetPanel; + document.getElementById('meetLeaveBtn').onclick=leaveMeeting; + updateHostControls(); + // Click another shared screen (in the side column) to bring it onto the stage. + const grid=document.getElementById('meetGrid'); if(grid) grid.addEventListener('click', e=>{ if(!grid.classList.contains('sharing-mode')) return; const t=e.target.closest('.meet-tile.sharing'); if(t && !t.classList.contains('stage')){ setStage(t.id.replace('meet-tile-','')); } }); + addTile('__local', meetLocalStream, (ME&&ME.name)?ME.name:'You', true); + setTileMute('__local', !meetMic); +} +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='
    '+pEsc(initials(label||'?'))+'
    '+pEsc(label||'')+''; 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); + 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); } +// ---- 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 + meetUnwatch(id); + let ctx; try{ ctx=new (window.AudioContext||window.webkitAudioContext)(); }catch(_){ return; } + try{ ctx.resume(); }catch(_){} + let src; try{ src=ctx.createMediaStreamSource(stream); }catch(_){ try{ctx.close();}catch(e){} return; } + const an=ctx.createAnalyser(); an.fftSize=512; an.smoothingTimeConstant=0.6; src.connect(an); + const data=new Uint8Array(an.frequencyBinCount); const rec={ctx,an,data,src,raf:0,on:false}; + meetVU.set(id, rec); + const tick=()=>{ + an.getByteFrequencyData(data); let sum=0; for(let i=0;i16 && !meetMuted.get(id); + if(speaking!==rec.on){ rec.on=speaking; const t=document.getElementById('meet-tile-'+id); if(t) t.classList.toggle('speaking', speaking); } + rec.raf=requestAnimationFrame(tick); + }; + rec.raf=requestAnimationFrame(tick); +} +function meetUnwatch(id){ const r=meetVU.get(id); if(!r) return; try{cancelAnimationFrame(r.raf);}catch(_){} try{r.src.disconnect();}catch(_){} try{r.ctx.close();}catch(_){} meetVU.delete(id); const t=document.getElementById('meet-tile-'+id); if(t) t.classList.remove('speaking'); } +function meetUnwatchAll(){ for(const id of Array.from(meetVU.keys())) meetUnwatch(id); } +function meetMakePeer(peerId, name){ + const pc=new RTCPeerConnection(MEET_ICE); + const entry={ pc, name, vsender:null }; meetPeers.set(peerId, entry); + addTile(peerId, null, meetNames.get(peerId)||name||'Guest', false); // show the tile right away (avatar) + meetLocalStream.getTracks().forEach(t=>{ const s=pc.addTrack(t, meetLocalStream); if(t.kind==='video') entry.vsender=s; }); + // If I'm already sharing my screen, send the screen (not the camera) to this new peer. + if(meetScreen && meetScreenStream){ const st=meetScreenStream.getVideoTracks()[0]; if(st){ try{ if(entry.vsender) entry.vsender.replaceTrack(st); else entry.vsender=pc.addTrack(st, meetLocalStream); }catch(_){} } } + pc.onicecandidate=(ev)=>{ if(ev.candidate) meetSend({type:'meeting-signal',to:peerId,data:{candidate:ev.candidate}}); }; + pc.ontrack=(ev)=>{ addTile(peerId, ev.streams[0], (meetPeers.get(peerId)||{}).name||name||'Guest', false); meetWatchStream(peerId, ev.streams[0]); }; + return pc; +} +// Screen share (mesh): swap the outgoing video track for a display-capture track via replaceTrack +// (no extra tiles, the peer's video just shows the screen). Falls back to addTrack+renegotiate if +// no video sender exists yet (camera never turned on). Stopping restores the camera (or avatar). +async function toggleScreen(){ + if(meetScreen){ stopScreen(); return; } + if(!meetMultiShare && meetSharers.size>0){ toast('Someone is already sharing their screen'); return; } + let ds; try{ ds=await navigator.mediaDevices.getDisplayMedia({ video:true, audio:false }); } + catch(e){ return; } // user cancelled the picker + const track=ds.getVideoTracks()[0]; if(!track){ try{ ds.getTracks().forEach(t=>t.stop()); }catch(_){} return; } + meetScreenStream=ds; meetScreen=true; + track.onended=()=>stopScreen(); // browser's native "Stop sharing" bar + for(const [pid,p] of meetPeers){ + if(p.vsender){ try{ await p.vsender.replaceTrack(track); }catch(_){} } + else { try{ p.vsender=p.pc.addTrack(track, meetLocalStream); const off=await p.pc.createOffer(); await p.pc.setLocalDescription(off); meetSend({type:'meeting-signal',to:pid,data:{sdp:p.pc.localDescription}}); }catch(_){} } + } + addTile('__local', ds, ((ME&&ME.name)?ME.name:'You')+' (screen)', true); setTileScreen('__local', true); + updateScreenBtn(); meetSend({ type:'meeting-screen', on:true }); +} +function stopScreen(){ + if(!meetScreen) return; meetScreen=false; + const cam=meetLocalStream && meetLocalStream.getVideoTracks()[0]; + for(const [,p] of meetPeers){ if(p.vsender){ try{ p.vsender.replaceTrack(cam||null); }catch(_){} } } + if(meetScreenStream){ try{ meetScreenStream.getTracks().forEach(t=>t.stop()); }catch(_){} meetScreenStream=null; } + addTile('__local', meetLocalStream, (ME&&ME.name)?ME.name:'You', true); setTileScreen('__local', false); + updateScreenBtn(); meetSend({ type:'meeting-screen', on:false }); +} +function updateScreenBtn(){ const b=document.getElementById('meetScreenBtn'); if(!b) return; b.classList.toggle('on', meetScreen); b.title=meetScreen?'Stop sharing':'Share screen'; b.innerHTML=ic('monitor',20); } +function setTileScreen(id, on){ const t=document.getElementById('meet-tile-'+id); if(!t) return; t.classList.toggle('sharing', !!on); let b=t.querySelector('.meet-screen'); if(on){ if(!b){ b=document.createElement('div'); b.className='meet-screen'; b.innerHTML=ic('monitor',12)+' Screen'; t.appendChild(b); } } else if(b){ b.remove(); } updateShareMode(); } +// Stage mode: one chosen shared screen fills the area; other screens + people are small on the right. +function getSharerIds(){ const a=[]; if(meetScreen) a.push('__local'); meetSharers.forEach(id=>a.push(id)); return a; } +function updateShareMode(){ const g=document.getElementById('meetGrid'); if(!g) return; + const sharers=getSharerIds(); const on=sharers.length>0; + g.classList.toggle('sharing-mode', on); + if(on){ if(!meetStageId || sharers.indexOf(meetStageId)<0) meetStageId=sharers[0]; } else meetStageId=null; + applyStage(); +} +function applyStage(){ const g=document.getElementById('meetGrid'); if(!g) return; g.querySelectorAll('.meet-tile.stage').forEach(t=>t.classList.remove('stage')); if(meetStageId){ const t=document.getElementById('meet-tile-'+meetStageId); if(t) t.classList.add('stage'); } } +function setStage(id){ if(!id) return; meetStageId=id; applyStage(); } +// Record + transcript are host-only; show/hide their buttons when host status changes. +function updateHostControls(){ document.querySelectorAll('.meet-bar .host-only').forEach(b=>{ b.style.display=meetIsHost?'inline-flex':'none'; }); } +// ---- Recording: composite all tiles onto a canvas + mix everyone's audio, then MediaRecorder ---- +function drawCover(ctx,v,x,y,w,h){ const vw=v.videoWidth,vh=v.videoHeight; if(!vw||!vh) return; const s=Math.max(w/vw,h/vh),dw=vw*s,dh=vh*s; ctx.save(); ctx.beginPath(); ctx.rect(x,y,w,h); ctx.clip(); ctx.drawImage(v, x+(w-dw)/2, y+(h-dh)/2, dw, dh); ctx.restore(); } +function recMime(){ const c=['video/webm;codecs=vp9,opus','video/webm;codecs=vp8,opus','video/webm']; for(const t of c){ if(window.MediaRecorder&&MediaRecorder.isTypeSupported(t)) return t; } return ''; } +function toggleRecord(){ if(!meetIsHost){ toast('Only the host can record'); return; } if(meetRec){ meetRec.stop(); return; } startRecord(); } +function startRecord(){ + if(!window.MediaRecorder){ toast('Recording is not supported in this browser'); return; } + const canvas=document.createElement('canvas'); canvas.width=1280; canvas.height=720; const ctx=canvas.getContext('2d'); + let raf=0; + const draw=()=>{ + const vids=[...document.querySelectorAll('#meetGrid .meet-tile video')].filter(v=>v.srcObject); + ctx.fillStyle='#0b1220'; ctx.fillRect(0,0,canvas.width,canvas.height); + const n=Math.max(1,vids.length), cols=Math.ceil(Math.sqrt(n)), rows=Math.ceil(n/cols), cw=canvas.width/cols, ch=canvas.height/rows; + vids.forEach((v,i)=>{ const r=Math.floor(i/cols), c=i%cols; drawCover(ctx,v,c*cw,r*ch,cw-4,ch-4); }); + raf=requestAnimationFrame(draw); + }; + const cstream=canvas.captureStream(25); + let actx=null, dest=null; + try{ actx=new (window.AudioContext||window.webkitAudioContext)(); dest=actx.createMediaStreamDestination(); const seen=new Set(); + document.querySelectorAll('#meetGrid .meet-tile video').forEach(v=>{ const s=v.srcObject; if(s&&!seen.has(s)&&s.getAudioTracks&&s.getAudioTracks().length){ seen.add(s); try{ actx.createMediaStreamSource(s).connect(dest); }catch(_){} } }); + }catch(_){} + const tracks=[...cstream.getVideoTracks()]; if(dest) tracks.push(...dest.stream.getAudioTracks()); + let rec; try{ rec=new MediaRecorder(new MediaStream(tracks), recMime()?{mimeType:recMime()}:undefined); } + catch(e){ toast('Recording is not supported in this browser'); try{actx&&actx.close();}catch(_){} return; } + const chunks=[]; const startedAt=Date.now(); let stopped=false; + rec.ondataavailable=e=>{ if(e.data&&e.data.size) chunks.push(e.data); }; + rec.onstop=()=>{ try{ cancelAnimationFrame(raf); }catch(_){} try{ actx&&actx.close(); }catch(_){} const blob=new Blob(chunks,{type:(chunks[0]&&chunks[0].type)||'video/webm'}); uploadRecording(blob, Date.now()-startedAt); }; + const stop=()=>{ if(stopped) return; stopped=true; try{ rec.stop(); }catch(_){} meetRec=null; updateRecBtn(); meetSend({type:'meeting-recording', on:false}); recNotice(false); toast('Recording saved to Past meetings'); }; + meetRec={ rec, stop }; draw(); rec.start(2000); updateRecBtn(); + meetSend({type:'meeting-recording', on:true}); recNotice(true, 'You'); // notify everyone (visual + voice) +} +function speak(text){ try{ if(window.speechSynthesis){ const u=new SpeechSynthesisUtterance(text); u.rate=1; speechSynthesis.cancel(); speechSynthesis.speak(u); } }catch(_){} } +function fmtElapsed(ms){ const s=Math.max(0,Math.floor(ms/1000)); const m=Math.floor(s/60); return String(m).padStart(2,'0')+':'+String(s%60).padStart(2,'0'); } +let _recTimer=null, _recStart=0; +function recNotice(on, by){ + let el=document.getElementById('recNotice'); + if(on){ + if(_recTimer) return; // already showing + if(!el){ el=document.createElement('div'); el.id='recNotice'; el.className='rec-notice'; document.body.appendChild(el); } + _recStart=Date.now(); + const tick=()=>{ const e=document.getElementById('recNotice'); if(e) e.innerHTML=' Recording · '+fmtElapsed(Date.now()-_recStart); }; + tick(); _recTimer=setInterval(tick, 1000); + speak('This meeting is now being recorded'); + } else { + if(_recTimer){ clearInterval(_recTimer); _recTimer=null; } + if(el) el.remove(); + speak('Recording stopped'); + } +} +function updateRecBtn(){ const b=document.getElementById('meetRecBtn'); if(!b) return; b.classList.toggle('on', !!meetRec); b.title=meetRec?'Stop recording':'Record meeting'; } +async function uploadRecording(blob, durMs){ + if(!blob||!blob.size) return; + try{ const gid=(meetReturn&&meetReturn.kind==='group')?meetReturn.id:''; + const q='/api/meetings/recording?room='+encodeURIComponent(meetRoom||'')+(gid?('&group='+encodeURIComponent(gid)):'')+'&dur='+Math.round(durMs||0); + const r=await fetch(q,{method:'POST',headers:{'Content-Type':'application/octet-stream'},body:blob}); + if(!r.ok) throw 0; + }catch(_){ toast('Could not upload the recording'); } +} +// ---- Live transcript: each participant transcribes their own mic; the server assembles it ---- +// Transcript: subscribe to get your OWN private copy of the FULL conversation. While anyone is +// subscribed, every client transcribes its own mic into one shared transcript (merged with speaker +// names); each subscriber gets a private copy. Unsubscribing only drops YOUR copy, not others'. +function toggleTranscribe(){ meetTranscribe=!meetTranscribe; meetSend({type:'meeting-transcribe', on:meetTranscribe}); updateTransBtn(); toast(meetTranscribe?'Transcript on — your private copy is saved to Past meetings after the call':'You left the transcript — your copy is being saved'); } +function applyRoomTx(active){ if(active===meetRoomTx) return; meetRoomTx=active; if(active) startSR(); else stopSR(); transcribeNotice(active); } +function startSR(){ if(meetSR) return; const SR=window.SpeechRecognition||window.webkitSpeechRecognition; if(!SR){ if(meetTranscribe) toast('Live transcript needs Chrome or Edge'); return; } + try{ meetSR=new SR(); }catch(_){ return; } + meetSR.continuous=true; meetSR.interimResults=false; meetSR.lang='en-US'; + meetSR.onresult=(e)=>{ for(let i=e.resultIndex;i{}; meetSR.onend=()=>{ if(meetRoomTx){ try{ meetSR.start(); }catch(_){} } }; + try{ meetSR.start(); }catch(_){} +} +function stopSR(){ if(meetSR){ try{ meetSR.onend=null; meetSR.stop(); }catch(_){} meetSR=null; } } +function updateTransBtn(){ const b=document.getElementById('meetTransBtn'); if(!b) return; b.classList.toggle('on', meetTranscribe); b.title=meetTranscribe?'Stop my transcript':'Transcribe (your private copy)'; } +function transcribeNotice(on){ let el=document.getElementById('txNotice'); if(on){ if(!el){ el=document.createElement('div'); el.id='txNotice'; el.className='tx-notice'; el.innerHTML=ic('fileText',12)+' Transcribing'; document.body.appendChild(el); } } else if(el){ el.remove(); } } +async function enterMeeting(code, audioOnly){ + if(meetState==='call'){ switchTab('meeting'); return; } // already in a call — ignore double-join + meetAudioOnly=!!audioOnly; + // Start with NO media — mic & cam OFF by default (no permission prompt until the user + // turns one on). Tracks are acquired on demand by toggleMic / toggleCam. + meetLocalStream=new MediaStream(); + meetMic=false; meetCam=false; meetIsHost=false; meetHostId=null; + meetScreen=false; meetScreenStream=null; meetSharers.clear(); meetMultiShare=false; + meetRec=null; meetTranscribe=false; meetRoomTx=false; meetSR=null; _addPool=null; meetStageId=null; + try{ const c=await fetch('/api/ice').then(r=>r.json()); if(c&&c.iceServers) MEET_ICE=c; }catch(_){} + meetState='call'; meetRailLive(true); + meetWs=new WebSocket((location.protocol==='https:'?'wss://':'ws://')+location.host+'/ws'); + meetWs.onmessage=onMeetMsg; + meetWs.onopen=()=>{ if(code){ meetRoom=code; renderCall(); meetSend({type:'meeting-join', room:code, name:(ME.name||ME.email||'Guest')}); } else { meetSend({type:'meeting-create'}); } }; +} +async function onMeetMsg(e){ + let m; try{ m=JSON.parse(e.data); }catch(_){ return; } + if(m.type==='meeting-created'){ meetRoom=m.room; renderCall(); meetSend({type:'meeting-join', room:m.room, name:(ME.name||ME.email||'Guest')}); if(meetAnnounceGroup){ const g=meetAnnounceGroup; meetAnnounceGroup=null; try{ postJSON('/api/messages',{group:g, body:'📹 Started a group call — join with code '+m.room}); }catch(_){} } return; } + if(m.type==='meeting-joined'){ + meetMyId=m.peerId; + if(m.isHost){ meetIsHost=true; meetHostId=meetMyId; } // host = the meeting creator (server-decided) + 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); } + 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(); + return; + } + 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); + 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}); + if(meetIsHost){ meetSend({type:'meeting-host', to:meetHostId}); meetSend({type:'meeting-sharemode', multi:meetMultiShare}); if(meetRec) meetSend({type:'meeting-recording', on:true}); } + 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-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 + if(m.type==='meeting-peer-screen'){ if(m.on) meetSharers.add(m.from); else meetSharers.delete(m.from); setTileScreen(m.from, !!m.on); refreshMeetPanel(); return; } + if(m.type==='meeting-sharemode'){ meetMultiShare=!!m.multi; refreshMeetPanel(); return; } + if(m.type==='meeting-muteall'){ if(meetMic && meetLocalStream){ meetMic=false; meetLocalStream.getAudioTracks().forEach(t=>t.enabled=false); updateMicBtn(); setTileMute('__local', true); meetSend({type:'meeting-state', muted:true, camOff:!meetCam}); } toast('You were muted by the host'); return; } + if(m.type==='meeting-peer-left'){ const p=meetPeers.get(m.peerId); if(p){ try{p.pc.close();}catch(_){} meetPeers.delete(m.peerId);} meetSharers.delete(m.peerId); removeTile(m.peerId); refreshMeetPanel(); return; } + if(m.type==='meeting-signal'){ + const from=m.from, d=m.data||{}; + if(d.sdp){ + let p=meetPeers.get(from), pc=p&&p.pc; + if(d.sdp.type==='offer'){ + if(!pc) pc=meetMakePeer(from, meetNames.get(from)||'Guest'); + try{ await pc.setRemoteDescription(d.sdp); const ans=await pc.createAnswer(); await pc.setLocalDescription(ans); meetSend({type:'meeting-signal',to:from,data:{sdp:pc.localDescription}}); }catch(_){} + } else if(d.sdp.type==='answer'){ if(pc){ try{ await pc.setRemoteDescription(d.sdp); }catch(_){} } } + } else if(d.candidate){ const p=meetPeers.get(from); if(p&&p.pc){ try{ await p.pc.addIceCandidate(d.candidate); }catch(_){} } } + return; + } + if(m.type==='error'){ const msg=m.message; leaveMeeting(); const e2=document.getElementById('meetErr'); if(e2) e2.textContent=msg||'Meeting error'; return; } +} +function updateMicBtn(){ const b=document.getElementById('meetMicBtn'); if(b){ b.classList.toggle('off',!meetMic); b.title=meetMic?'Mute':'Unmute'; b.innerHTML=ic(meetMic?'mic':'micOff',20); } } +function updateCamBtn(){ const b=document.getElementById('meetCamBtn'); if(b){ b.classList.toggle('off',!meetCam); b.title=meetCam?'Turn camera off':'Turn camera on'; b.innerHTML=ic(meetCam?'video':'videoOff',20); } } +// Unmute acquires the mic on demand (no prompt until then) and renegotiates with peers. +async function toggleMic(){ + if(!meetLocalStream) return; + const hasTrack=meetLocalStream.getAudioTracks().length>0; + if(!hasTrack){ + let astream; try{ astream=await navigator.mediaDevices.getUserMedia({ audio:true }); } + catch(e){ toast('Microphone permission is required to unmute'); return; } + const track=astream.getAudioTracks()[0]; if(!track) return; + meetLocalStream.addTrack(track); meetMic=true; meetWatchStream('__local', meetLocalStream); + for(const [pid,p] of meetPeers){ try{ p.pc.addTrack(track, meetLocalStream); const off=await p.pc.createOffer(); await p.pc.setLocalDescription(off); meetSend({type:'meeting-signal',to:pid,data:{sdp:p.pc.localDescription}}); }catch(_){} } + } else { + meetMic=!meetMic; meetLocalStream.getAudioTracks().forEach(t=>t.enabled=meetMic); + } + updateMicBtn(); setTileMute('__local', !meetMic); meetSend({type:'meeting-state', muted:!meetMic, camOff:!meetCam}); +} +// Toggle the camera. In an audio call (no video track yet) this acquires the camera and +// renegotiates with every peer, so you can always turn video on once a meeting has started. +async function toggleCam(){ + if(!meetLocalStream) return; + const hasTrack=meetLocalStream.getVideoTracks().length>0; + if(!hasTrack){ + let vstream; try{ vstream=await navigator.mediaDevices.getUserMedia({ video:true }); } + catch(e){ toast('Camera permission is required to turn on video'); return; } + const track=vstream.getVideoTracks()[0]; if(!track) return; + meetLocalStream.addTrack(track); meetCam=true; meetAudioOnly=false; + for(const [pid,p] of meetPeers){ try{ const s=p.pc.addTrack(track, meetLocalStream); if(!p.vsender) p.vsender=s; const off=await p.pc.createOffer(); await p.pc.setLocalDescription(off); meetSend({type:'meeting-signal',to:pid,data:{sdp:p.pc.localDescription}}); }catch(_){} } + const chip=document.getElementById('meetCodeChip'); if(chip) chip.innerHTML='Room '+pEsc(meetRoom||'')+' · share to invite'; + } else { + meetCam=!meetCam; meetLocalStream.getVideoTracks().forEach(t=>t.enabled=meetCam); + } + updateCamBtn(); + addTile('__local', meetLocalStream, (ME&&ME.name)?ME.name:'You', true); + setTileMute('__local', !meetMic); meetSend({type:'meeting-state', muted:!meetMic, camOff:!meetCam}); +} +let meetLeaving=false; +function leaveMeeting(){ + if(meetLeaving) return; meetLeaving=true; + // Host leaving voluntarily must hand off so the meeting isn't left host-less. + if(meetIsHost && meetPeers.size>0){ + const next=meetPeers.keys().next().value; // first remaining participant + if(next){ meetSend({type:'meeting-host', to:next}); toast('Host handed to '+(meetNames.get(next)||'a participant')); } + } + if(meetRec) meetRec.stop(); // saves the recording + if(meetTranscribe) meetSend({type:'meeting-transcribe', on:false}); // server saves my copy + meetTranscribe=false; meetRoomTx=false; stopSR(); transcribeNotice(false); + if(meetScreen) stopScreen(); + 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(); + 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); + const ret=meetReturn; meetReturn=null; meetLeaving=false; + if(ret){ switchTab('chat'); selectChat(ret.kind, ret.id); } // land back on the originating chat + else renderMeetingLobby(); } // ---------- Tabs (icon rail) ---------- @@ -363,20 +2165,30 @@ function switchTab(tab){ railBtns.forEach(b=>b.classList.toggle('active',b.dataset.tab===tab)); panels.forEach(p=>p.classList.toggle('active',p.dataset.panel===tab)); chatcol.classList.toggle('hidden', tab!=='chat'); + document.querySelector('.shell').classList.toggle('is-chat', tab==='chat'); // mobile one-pane layout + if(tab!=='chat') document.body.classList.remove('chat-open'); + document.body.classList.remove('rail-open'); // close the mobile drawer after picking a tab // Lazy-load the embedded flows on first open; keep them mounted afterwards so a // live session survives tab switches. if(tab==='share' && !loaded.share){ document.getElementById('sharePanel').innerHTML=''; loaded.share=true; } if(tab==='connect' && !loaded.connect){ document.getElementById('connectPanel').innerHTML=''; loaded.connect=true; } + if(tab==='meeting' && meetState==='idle'){ renderMeetingLobby(); } } -function showWelcome(){ selectedChat=null; renderChats(document.getElementById('chatSearch').value); renderChatPanel(); updateRailUnread(); } +function showWelcome(){ selected=null; document.body.classList.remove('chat-open'); renderChats(searchVal()); renderChatPanel(); updateRailUnread(); } railBtns.forEach(btn=>{ btn.onclick=()=>{ const tab=btn.dataset.tab; // Re-clicking Chat (while already on it) returns to the welcome screen. - if(tab==='chat' && currentTab()==='chat' && selectedChat!=null){ showWelcome(); } + if(tab==='chat' && currentTab()==='chat' && selected!=null){ showWelcome(); } switchTab(tab); }; }); // Esc clears the open conversation and brings back the welcome screen. -document.addEventListener('keydown',(e)=>{ if(e.key==='Escape' && currentTab()==='chat' && selectedChat!=null){ showWelcome(); } }); +document.addEventListener('keydown',(e)=>{ if(e.key==='Escape' && currentTab()==='chat' && selected!=null){ showWelcome(); } }); +// 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; +const _railBd=document.getElementById('railBackdrop'); if(_railBd) _railBd.onclick=()=>document.body.classList.remove('rail-open'); +// Mobile chat: "back" returns from an open conversation to the chat list. +function chatBack(){ document.body.classList.remove('chat-open'); } // Embedded Share/Connect flows report session start/stop so the rail can show a "live" // dot — that's how you know a session is still running after switching to Chat. @@ -393,9 +2205,11 @@ window.addEventListener('message',(e)=>{ }); // Sidebar + misc wiring -document.getElementById('chatSearch').addEventListener('input',e=>renderChats(e.target.value)); -document.getElementById('newChat').onclick=()=>toast('New chat is coming soon'); -document.getElementById('notifyBtn').onclick=()=>toast("Thanks! We'll let you know when Meetings launches."); +document.getElementById('chatSearch').addEventListener('input',e=>{ const x=document.getElementById('chatSearchX'); if(x) x.style.display=e.target.value?'grid':'none'; renderChats(e.target.value); }); +(function(){ const x=document.getElementById('chatSearchX'); if(x) x.onclick=()=>{ const s=document.getElementById('chatSearch'); s.value=''; x.style.display='none'; renderChats(''); s.focus(); }; })(); +document.getElementById('newChat').title='New group'; +document.getElementById('newChat').innerHTML=ic('userPlus',18); +document.getElementById('newChat').onclick=()=>openNewGroup(); // ---------- Login (shown here on /home when logged out) ---------- const EYE_OFF=''; @@ -466,12 +2280,18 @@ async function doRegister(){ try{ const r=await fetch('/api/me'); if(r.ok) me=await r.json(); }catch(_){} if(!me){ await renderLogin(); document.getElementById('loading').style.display='none'; return; } ME=me; - document.getElementById('hdrRight').innerHTML=profileHTML(me); - wireProfile(); - renderChats(''); - renderChatPanel(); - updateRailUnread(); + document.getElementById('hdrRight').innerHTML=bellHTML()+profileHTML(me); + loadNotifs(); wireBell(); wireProfile(); + connectChatWs(); 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. + let oid=null, okind='dm'; + try{ const q=new URLSearchParams(location.search); oid=q.get('openId'); okind=q.get('openKind')||'dm'; if(oid) history.replaceState(null,'','/home'); }catch(_){} + if(oid){ + selectChat(okind, oid); // open the chat right away (only waits on the thread fetch) + loadSidebar().then(()=>{ try{ const it=rowFor(okind,oid); const nm=document.querySelector('#chatPanel .convo-head .nm'); if(it&&nm) nm.textContent=it.name; }catch(_){} }); + } else { await loadSidebar(); renderChatPanel(); } })(); diff --git a/server/public/host.html b/server/public/host.html index 7642645..4801343 100644 --- a/server/public/host.html +++ b/server/public/host.html @@ -19,10 +19,11 @@ .indicator { position:fixed; bottom:0; left:0; right:0; background:#b91c1c; color:#fff; text-align:center; padding:.4rem; font-size:.85rem; display:none; } .indicator.show { display:block; } +
    -

    🖥️ Browser Host (no install)

    +

    Browser Host (no install)

    Shares this screen with a technician. Paste the enroll token from the console and click Go online.

    @@ -98,5 +99,6 @@ function teardown() { } function esc(s){return String(s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));} + diff --git a/server/public/index.html b/server/public/index.html index 45c5976..0bd599f 100644 --- a/server/public/index.html +++ b/server/public/index.html @@ -17,9 +17,9 @@ .inner{max-width:780px;width:100%;text-align:center;} h1{color:var(--blue);font-size:1.8rem;margin:0 0 .4rem;} .sub{color:var(--muted);margin-bottom:2.2rem;} - .ssobtn{display:inline-flex;align-items:center;justify-content:center;gap:.6rem;background:var(--blue);color:#fff;text-decoration:none;font-weight:700;font-size:1.02rem;padding:.95rem 2rem;border-radius:12px;box-shadow:0 10px 26px rgba(31,59,115,.28);transition:transform .12s,box-shadow .12s,background .12s;} - .ssobtn:hover{transform:translateY(-2px);box-shadow:0 16px 34px rgba(31,59,115,.34);background:var(--blue-d);} - .ssobtn .bmark{width:26px;height:26px;border-radius:7px;background:var(--brand);color:var(--blue);display:grid;place-items:center;font-weight:800;font-size:.9rem;} + .ssobtn{display:inline-flex;align-items:center;justify-content:center;gap:.6rem;background:var(--brand);color:var(--blue);text-decoration:none;font-weight:700;font-size:1.02rem;padding:.95rem 2rem;border-radius:12px;box-shadow:0 10px 26px rgba(224,172,0,.32);transition:transform .12s,box-shadow .12s,background .12s;} + .ssobtn:hover{transform:translateY(-2px);box-shadow:0 16px 34px rgba(224,172,0,.4);background:var(--brand-d);} + .ssobtn .bmark{width:26px;height:26px;border-radius:7px;background:var(--blue);color:var(--brand);display:grid;place-items:center;font-weight:800;font-size:.9rem;} .divider{display:flex;align-items:center;gap:1rem;color:var(--muted);font-size:.85rem;max-width:360px;margin:1.8rem auto;} .divider::before,.divider::after{content:"";flex:1;height:1px;background:var(--line);} .choices{display:flex;gap:1.4rem;flex-wrap:wrap;justify-content:center;} @@ -41,6 +41,7 @@ .profile .pmenu a:hover{background:#f1f5f9} .profile .pmenu a.danger{color:#b91c1c;border-top:1px solid #eef1f6} +
    @@ -63,7 +64,7 @@

    Share my screen

    Get a one-time code and show your screen to a BizGaze support agent — no login, no download.

    -
    🔒 Screen sharing only starts after you approve it, and can be stopped anytime.
    +
    Screen sharing only starts after you approve it, and can be stopped anytime.
    © BizGaze · Remote Support
    @@ -81,5 +82,6 @@ makeBrandClickable(); const dv=document.querySelector('.divider'); if(dv) dv.textContent='need to help someone? share your screen'; }}catch(_){}})(); + diff --git a/server/public/share.html b/server/public/share.html index fc24557..afe2f27 100644 --- a/server/public/share.html +++ b/server/public/share.html @@ -20,7 +20,7 @@ .sub{color:var(--muted);font-size:.97rem;line-height:1.5;margin-bottom:1.6rem;} .codewrap{background:#fffdf2;border:2px dashed var(--brand);border-radius:14px;padding:1.2rem;} .codelabel{font-size:.78rem;letter-spacing:.08em;text-transform:uppercase;color:var(--muted);margin-bottom:.3rem;} - .code{font-size:clamp(1.9rem,12vw,3rem);letter-spacing:clamp(.18rem,3vw,.5rem);font-weight:800;color:var(--ink);white-space:nowrap;overflow:hidden;text-overflow:clip;} + .code{font-size:clamp(1.6rem,9vw,2.6rem);letter-spacing:clamp(.1rem,2vw,.4rem);padding-left:clamp(.1rem,2vw,.4rem);font-weight:800;color:var(--ink);white-space:nowrap;} .status{margin-top:1.3rem;padding:.7rem 1rem;border-radius:10px;background:#f1f5f9;color:#475569;font-size:.92rem;} .status.on{background:#ecfdf3;color:#15803d;} .consent{margin-top:1.3rem;border:1px solid #c7d6f0;background:var(--blue-soft);border-left:5px solid var(--blue);border-radius:12px;padding:1.3rem;text-align:left;color:var(--blue-d);} @@ -46,11 +46,12 @@ .profile .pmenu a:hover{background:#f1f5f9} .profile .pmenu a.danger{color:#b91c1c;border-top:1px solid #eef1f6} +
    ● Your screen is being shared — close this tab anytime to stop
    -← Home + Home
    @@ -65,12 +66,12 @@
    Your session code
    ······
    - +
    Preparing your code…
    -
    🔒 You stay in control. The agent can only see your screen after you tap Allow, and you can stop anytime.
    +
    You stay in control. The agent can only see your screen after you tap Allow, and you can stop anytime.
    @@ -79,7 +80,7 @@ let ICE={iceServers:[{urls:'stun:stun.l.google.com:19302'}]}; let SHARER_NAME='Customer'; try{fetch('/api/me').then(r=>r.ok?r.json():null).then(m=>{if(m&&(m.name||m.email))SHARER_NAME=m.name||m.email;}).catch(()=>{});}catch(_){} const IS_MOBILE=/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile/i.test(navigator.userAgent||''); -let __icePromise=Promise.resolve();try{__icePromise=fetch('/api/ice').then(r=>r.ok?r.json():null).then(c=>{if(c&&c.iceServers&&IS_MOBILE)ICE=c;}).catch(()=>{});}catch(_){} +let __icePromise=Promise.resolve();try{__icePromise=fetch('/api/ice').then(r=>r.ok?r.json():null).then(c=>{if(c&&c.iceServers)ICE=c;}).catch(()=>{});}catch(_){} async function ensureIce(){try{await __icePromise;}catch(_){}return ICE;} function pEsc(s){return String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));} // When embedded in the home shell, tell the parent when a session is live so the @@ -98,7 +99,7 @@ document.getElementById('copyBtn').onclick=async()=>{ try{ await navigator.clipboard.writeText(code); } catch(e){ const ta=document.createElement('textarea');ta.value=code;document.body.appendChild(ta);ta.select();document.execCommand('copy');ta.remove(); } const b=document.getElementById('copyBtn'); const old=b.innerHTML; - b.innerHTML=''; + b.innerHTML=''; setTimeout(()=>{b.innerHTML=old;},1500); }; let ws,pc,localStream,chatChannel,sessionId; @@ -144,7 +145,9 @@ function showConsent(m){ async function beginCapture(){ try{ localStream=await navigator.mediaDevices.getDisplayMedia({video:{displaySurface:'monitor',frameRate:{ideal:30}},audio:false,monitorTypeSurfaces:'include'}); } catch(err){ return false; } - try{ const mic=await navigator.mediaDevices.getUserMedia({audio:true}); window.__mic=mic; mic.getAudioTracks().forEach(t=>localStream.addTrack(t)); }catch(e){} + // Mic is OFF by default — we do NOT prompt for it here. Asking for the screen and the + // mic at once confused customers and silently cancelled the share. The mic permission + // is requested only when the customer taps Unmute (see the bar's mic button). try{ ensureIce(); }catch(_){} return true; } @@ -152,11 +155,10 @@ async function startStreaming(){ // If the Allow tap already captured the screen (mobile path), reuse it. if(!localStream){ await ensureIce(); - let mic=null; try{ mic=await navigator.mediaDevices.getUserMedia({audio:true}); }catch(e){ mic=null; } setStatus('In the popup: choose your screen, then tap Share / Start.','on'); + // Screen only — mic stays off until the customer taps Unmute (avoids the dual prompt). try{ localStream=await navigator.mediaDevices.getDisplayMedia({video:{displaySurface:'monitor',frameRate:{ideal:30}},audio:false,monitorTypeSurfaces:'include'}); } - catch(err){ if(mic){try{mic.getTracks().forEach(t=>t.stop());}catch(_){}} try{ws.send(JSON.stringify({type:'end-session',sessionId,reason:'share-cancelled'}));}catch(e){} setStatus('Screen share was cancelled. Refresh the page to try again.'); return; } - if(mic){ window.__mic=mic; try{mic.getAudioTracks().forEach(t=>localStream.addTrack(t));}catch(_){} } + catch(err){ try{ws.send(JSON.stringify({type:'end-session',sessionId,reason:'share-cancelled'}));}catch(e){} setStatus('Screen share was cancelled. Refresh the page to try again.'); return; } } await ensureIce(); indicator.classList.add('show'); setStatus('You are now sharing your screen with your agent.','on'); bzcSession(true); @@ -168,11 +170,14 @@ async function startStreaming(){ pc.ondatachannel=(ev)=>{ev.channel.onmessage=()=>{};}; pc.ontrack=(ev)=>{ if(ev.track.kind==='audio'){ let a=document.getElementById('remoteAudio'); if(!a){a=document.createElement('audio');a.id='remoteAudio';a.autoplay=true;document.body.appendChild(a);} a.srcObject=ev.streams[0]; } }; pc.onicecandidate=(ev)=>{if(ev.candidate)ws.send(JSON.stringify({type:'ice-candidate',sessionId,candidate:ev.candidate}));}; - pc.onconnectionstatechange=()=>{ if(pc&&pc.connectionState==='failed'){ try{ws.send(JSON.stringify({type:'end-session',sessionId,reason:'customer-ended'}));}catch(_){} endShareSession('The connection was lost. Tap below for a new code if you still need help.'); } }; + pc.onconnectionstatechange=()=>{ if(!pc) return; if(pc.connectionState==='connected'){ clearTimeout(window.__connWatch); } if(pc.connectionState==='failed'){ clearTimeout(window.__connWatch); try{ws.send(JSON.stringify({type:'end-session',sessionId,reason:'customer-ended'}));}catch(_){} endShareSession("Couldn't connect on this network — it may be blocking screen sharing. Try a different network (e.g. mobile data / hotspot), then tap below for a new code."); } }; chatChannel=pc.createDataChannel('chat',{ordered:true}); chatChannel.onmessage=(e)=>{try{addChat(JSON.parse(e.data));}catch(_){}}; const offer=await pc.createOffer(); await pc.setLocalDescription(offer); ws.send(JSON.stringify({type:'offer',sessionId,sdp:pc.localDescription})); + // Watchdog: clear failure message instead of a blank screen if no path establishes. + clearTimeout(window.__connWatch); + window.__connWatch=setTimeout(()=>{ if(pc && pc.connectionState!=='connected' && !sessionOver){ try{ws.send(JSON.stringify({type:'end-session',sessionId,reason:'customer-ended'}));}catch(_){} endShareSession("Couldn't connect on this network — it may be blocking screen sharing. Try a different network (e.g. mobile data / hotspot), then tap below for a new code."); } }, 20000); localStream.getVideoTracks()[0].onended=()=>{ws.send(JSON.stringify({type:'end-session',sessionId,reason:'customer-ended'}));teardown();}; } const SR = window.SpeechRecognition || window.webkitSpeechRecognition; @@ -221,18 +226,28 @@ let chatOpen=false; const SVG_MIC=''; const SVG_MICOFF=''; const SVG_CHAT=''; -const SVG_END=''; -function _btn(id,svg,label,bg){const b=document.createElement('button');b.id=id;b.innerHTML=''+svg+''+label+'';b.style.cssText='display:inline-flex;align-items:center;gap:7px;border:none;border-radius:12px;padding:.62rem 1rem;font-weight:600;font-size:.9rem;cursor:pointer;color:#fff;background:'+bg+';transition:background .15s,transform .08s';b.onmouseenter=()=>b.style.transform='translateY(-1px)';b.onmouseleave=()=>b.style.transform='none';return b;} +const SVG_END=''; +function _btn(id,svg,label,bg){const b=document.createElement('button');b.id=id;b.title=label;b.setAttribute('aria-label',label);b.innerHTML=''+svg+'';b.style.cssText='display:inline-flex;align-items:center;justify-content:center;width:48px;height:48px;border:none;border-radius:50%;cursor:pointer;color:#fff;background:'+bg+';box-shadow:0 2px 6px rgba(0,0,0,.25);transition:background .15s,transform .08s';b.onmouseenter=()=>b.style.transform='translateY(-2px)';b.onmouseleave=()=>b.style.transform='none';return b;} function buildBar(){ if(document.getElementById('sessionBar'))return; const bar=document.createElement('div'); bar.id='sessionBar'; bar.style.cssText='position:fixed;right:18px;bottom:18px;z-index:2147483000;display:flex;gap:10px;align-items:center;background:rgba(15,23,42,.94);padding:8px 12px;border-radius:16px;box-shadow:0 10px 28px rgba(0,0,0,.35)'; - const mic=_btn('micBtn',SVG_MIC,'Mic','#2563eb'); + const mic=_btn('micBtn',SVG_MICOFF,'Muted','#6b7280'); const chat=_btn('chatBtn',SVG_CHAT,'Chat','#475569'); - const end=_btn('endBtn2',SVG_END,'Stop','#dc2626'); + const end=_btn('endBtn2',SVG_END,'End','#dc2626'); bar.appendChild(mic);bar.appendChild(chat);bar.appendChild(end); document.body.appendChild(bar); - mic.onclick=()=>{const m=window.__mic;if(!m)return;const t=m.getAudioTracks()[0];if(!t)return;t.enabled=!t.enabled;mic.innerHTML=''+(t.enabled?SVG_MIC:SVG_MICOFF)+''+(t.enabled?'Mic':'Muted')+'';mic.style.background=t.enabled?'#2563eb':'#6b7280';}; + const setMic=(on)=>{mic.title=on?'Mute':'Unmute';mic.innerHTML=''+(on?SVG_MIC:SVG_MICOFF)+'';mic.style.background=on?'#2563eb':'#6b7280';}; + mic.onclick=async()=>{ + if(!window.__mic){ + // First unmute: NOW request mic permission, add the track, and renegotiate. + let m; try{ m=await navigator.mediaDevices.getUserMedia({audio:true}); }catch(e){ setStatus('Microphone permission was blocked. Allow it in the browser to talk.'); return; } + window.__mic=m; const t=m.getAudioTracks()[0]; + try{ localStream.addTrack(t); if(pc){ pc.addTrack(t,localStream); const offer=await pc.createOffer(); await pc.setLocalDescription(offer); ws.send(JSON.stringify({type:'offer',sessionId,sdp:pc.localDescription})); } }catch(_){} + setMic(true); return; + } + const t=window.__mic.getAudioTracks()[0]; if(!t) return; t.enabled=!t.enabled; setMic(t.enabled); + }; chat.onclick=toggleChat; end.onclick=()=>{ try{ws.send(JSON.stringify({type:'end-session',sessionId,reason:'customer-ended'}));}catch(_){} teardown(); }; buildChatPanel(); @@ -263,5 +278,6 @@ function removeSessionUI(){['sessionBar','chatPanel','remoteAudio','muteBtn','ms function esc(s){return String(s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));} + diff --git a/server/public/viewer.html b/server/public/viewer.html index f5143b0..662cc61 100644 --- a/server/public/viewer.html +++ b/server/public/viewer.html @@ -11,12 +11,13 @@ button { padding: 0.5rem 1rem; background: #334155; color: #fff; border: none; border-radius: 6px; cursor: pointer; } a { color: #3b82f6; } +
    Connecting…
    - ← Console + Console
    @@ -103,5 +104,6 @@ document.getElementById('endBtn').onclick = () => { setTimeout(() => (location.href = '/'), 300); }; + diff --git a/server/repos.js b/server/repos.js index 1e09678..34a6fc9 100644 --- a/server/repos.js +++ b/server/repos.js @@ -25,7 +25,7 @@ const users = { byEmail: (email) => db.prepare('SELECT * FROM users WHERE email=? COLLATE NOCASE').get(email), emailExists: (email) => !!db.prepare('SELECT 1 FROM users WHERE email=? COLLATE NOCASE').get(email), listByTenant: (tenantId) => - db.prepare('SELECT id,email,name,role,active,created_at FROM users WHERE team_id=?').all(tenantId), + db.prepare('SELECT id,email,name,role,active,avatar_url,created_at FROM users WHERE team_id=?').all(tenantId), inTenant: (id, tenantId) => db.prepare('SELECT * FROM users WHERE id=? AND team_id=?').get(id, tenantId), create: ({ tenantId, email, hash, salt, role, name, mfaSecret }) => { @@ -37,8 +37,10 @@ const users = { }, enableMfa: (id) => db.prepare('UPDATE users SET mfa_enabled=1 WHERE id=?').run(id), setName: (id, name) => db.prepare('UPDATE users SET name=? WHERE id=?').run(name, id), + setRole: (id, role) => db.prepare('UPDATE users SET role=? WHERE id=?').run(role, id), 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), remove: (id) => db.prepare('DELETE FROM users WHERE id=?').run(id), }; @@ -100,4 +102,168 @@ const sessionsLog = { }, }; -module.exports = { teams, users, authSessions, machines, audit, sessionsLog }; +const refreshTokens = { + create: ({ userId, tokenHash, ttl }) => + db.prepare('INSERT INTO refresh_tokens (token_hash,user_id,created_at,expires_at,revoked) VALUES (?,?,?,?,0)') + .run(tokenHash, userId, now(), now() + ttl), + byHash: (h) => db.prepare('SELECT * FROM refresh_tokens WHERE token_hash=?').get(h), + revoke: (h) => db.prepare('UPDATE refresh_tokens SET revoked=1 WHERE token_hash=?').run(h), + revokeByUser: (userId) => db.prepare('UPDATE refresh_tokens SET revoked=1 WHERE user_id=?').run(userId), +}; + +const apiKeys = { + create: ({ id, tenantId, name, keyHash, scopes, createdBy }) => + db.prepare('INSERT INTO api_keys (id,team_id,name,key_hash,scopes,created_by,created_at,revoked) VALUES (?,?,?,?,?,?,?,0)') + .run(id, tenantId, name || null, keyHash, scopes || '', createdBy || null, now()), + byHash: (h) => db.prepare('SELECT * FROM api_keys WHERE key_hash=?').get(h), + listByTenant: (tenantId) => + db.prepare('SELECT id,name,scopes,created_by,created_at,last_used_at,revoked FROM api_keys WHERE team_id=? ORDER BY created_at DESC').all(tenantId), + revoke: (id, tenantId) => db.prepare('UPDATE api_keys SET revoked=1 WHERE id=? AND team_id=?').run(id, tenantId), + touch: (id) => db.prepare('UPDATE api_keys SET last_used_at=? WHERE id=?').run(now(), id), +}; + +const webhooks = { + create: ({ id, tenantId, url, secret, events, createdBy }) => + db.prepare('INSERT INTO webhooks (id,team_id,url,secret,events,active,created_by,created_at) VALUES (?,?,?,?,?,1,?,?)') + .run(id, tenantId, url, secret, events || '', createdBy || null, now()), + activeForTenant: (tenantId) => db.prepare('SELECT * FROM webhooks WHERE team_id=? AND active=1').all(tenantId), + listByTenant: (tenantId) => + db.prepare('SELECT id,url,events,active,created_by,created_at,last_status,last_error,last_at FROM webhooks WHERE team_id=? ORDER BY created_at DESC').all(tenantId), + remove: (id, tenantId) => db.prepare('DELETE FROM webhooks WHERE id=? AND team_id=?').run(id, tenantId), + setStatus: (id, status, err) => db.prepare('UPDATE webhooks SET last_status=?, last_error=?, last_at=? WHERE id=?').run(status, err || null, now(), id), +}; + +const messages = { + send: ({ id, teamId, senderId, recipientId, body, replyTo, attachmentId, conversationId, mentions, msgType }) => + db.prepare('INSERT INTO messages (id,team_id,sender_id,recipient_id,body,created_at,reply_to,attachment_id,conversation_id,mentions,msg_type) VALUES (?,?,?,?,?,?,?,?,?,?,?)') + .run(id, teamId, senderId, recipientId || '', body, now(), replyTo || null, attachmentId || null, conversationId || null, (mentions && mentions.length) ? JSON.stringify(mentions) : null, msgType || null), + byId: (id) => db.prepare('SELECT * FROM messages WHERE id=?').get(id), + 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), + // 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 + AND ((sender_id=? AND recipient_id=?) OR (sender_id=? AND recipient_id=?)) + ORDER BY created_at ASC LIMIT ?`).all(teamId, a, b, b, a, limit), + markRead: (teamId, recipientId, senderId) => + db.prepare('UPDATE messages SET read_at=? WHERE team_id=? AND conversation_id IS NULL AND recipient_id=? AND sender_id=? AND read_at IS NULL') + .run(now(), teamId, recipientId, senderId), + // Recent DM messages involving a user (newest first) — reduced into per-contact conversations. + recentFor: (teamId, userId, limit = 1000) => + db.prepare('SELECT * FROM messages WHERE team_id=? AND conversation_id IS NULL AND (sender_id=? OR recipient_id=?) ORDER BY created_at DESC LIMIT ?') + .all(teamId, userId, userId, limit), + // Group conversation helpers. + threadByConversation: (conversationId, limit = 300) => + db.prepare('SELECT * FROM messages WHERE conversation_id=? ORDER BY created_at ASC LIMIT ?').all(conversationId, limit), + lastInConversation: (conversationId) => + db.prepare('SELECT * FROM messages WHERE conversation_id=? ORDER BY created_at DESC LIMIT 1').get(conversationId), + unreadInConversation: (conversationId, userId, since) => + db.prepare('SELECT COUNT(*) AS c FROM messages WHERE conversation_id=? AND sender_id<>? AND created_at>?').get(conversationId, userId, since).c, +}; + +const reactions = { + // Toggle with ONE reaction per user per message: picking an emoji replaces any prior + // reaction by that user; picking the same one again removes it. Returns true if added. + toggle: (messageId, userId, emoji) => { + const had = db.prepare('SELECT 1 FROM message_reactions WHERE message_id=? AND user_id=? AND emoji=?').get(messageId, userId, emoji); + db.prepare('DELETE FROM message_reactions WHERE message_id=? AND user_id=?').run(messageId, userId); + if (had) return false; + db.prepare('INSERT INTO message_reactions (message_id,user_id,emoji,created_at) VALUES (?,?,?,?)').run(messageId, userId, emoji, now()); + return true; + }, + forMessage: (messageId) => db.prepare('SELECT user_id, emoji FROM message_reactions WHERE message_id=? ORDER BY created_at ASC').all(messageId), + // All reactions on the messages in a 1:1 thread. + forPair: (teamId, a, b) => + db.prepare(`SELECT r.message_id, r.user_id, r.emoji FROM message_reactions r + JOIN messages m ON r.message_id = m.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=?))`).all(teamId, a, b, b, a), + // All reactions on the messages in a group conversation. + forConversation: (conversationId) => + db.prepare(`SELECT r.message_id, r.user_id, r.emoji FROM message_reactions r + JOIN messages m ON r.message_id = m.id WHERE m.conversation_id=?`).all(conversationId), +}; + +const conversations = { + create: ({ id, teamId, name, createdBy }) => + db.prepare('INSERT INTO conversations (id,team_id,type,name,created_by,created_at) VALUES (?,?,?,?,?,?)').run(id, teamId, 'group', name || null, createdBy || null, now()), + byId: (id) => db.prepare('SELECT * FROM conversations WHERE id=?').get(id), + addMember: (conversationId, userId, admin) => + db.prepare('INSERT OR IGNORE INTO conversation_members (conversation_id,user_id,last_read_at,joined_at,admin) VALUES (?,?,?,?,?)').run(conversationId, userId, 0, now(), admin ? 1 : 0), + members: (conversationId) => db.prepare('SELECT user_id FROM conversation_members WHERE conversation_id=? ORDER BY joined_at ASC').all(conversationId).map((r) => r.user_id), + isMember: (conversationId, userId) => !!db.prepare('SELECT 1 FROM conversation_members WHERE conversation_id=? AND user_id=?').get(conversationId, userId), + // ---- Group admins (multiple allowed) ---- + isAdmin: (conversationId, userId) => !!db.prepare('SELECT 1 FROM conversation_members WHERE conversation_id=? AND user_id=? AND admin=1').get(conversationId, userId), + admins: (conversationId) => db.prepare('SELECT user_id FROM conversation_members WHERE conversation_id=? AND admin=1').all(conversationId).map((r) => r.user_id), + setMemberAdmin: (conversationId, userId, v) => db.prepare('UPDATE conversation_members SET admin=? WHERE conversation_id=? AND user_id=?').run(v ? 1 : 0, conversationId, userId), + oldestMember: (conversationId) => { const r = db.prepare('SELECT user_id FROM conversation_members WHERE conversation_id=? ORDER BY joined_at ASC LIMIT 1').get(conversationId); return r ? r.user_id : null; }, + listForUser: (teamId, userId) => + db.prepare('SELECT c.* FROM conversations c JOIN conversation_members m ON m.conversation_id=c.id WHERE c.team_id=? AND m.user_id=?').all(teamId, userId), + lastReadAt: (conversationId, userId) => { const r = db.prepare('SELECT last_read_at FROM conversation_members WHERE conversation_id=? AND user_id=?').get(conversationId, userId); return r ? r.last_read_at : 0; }, + memberReads: (conversationId) => db.prepare('SELECT user_id, last_read_at FROM conversation_members WHERE conversation_id=?').all(conversationId), + setAdminOnly: (id, v) => db.prepare('UPDATE conversations SET admin_only=? WHERE id=?').run(v ? 1 : 0, id), + markRead: (conversationId, userId) => db.prepare('UPDATE conversation_members SET last_read_at=? WHERE conversation_id=? AND user_id=?').run(now(), conversationId, userId), + rename: (id, name) => db.prepare('UPDATE conversations SET name=? WHERE id=?').run(name, id), + setAvatar: (id, attachmentId) => db.prepare('UPDATE conversations SET avatar_id=? WHERE id=?').run(attachmentId || null, id), + byAvatar: (attachmentId) => db.prepare('SELECT * FROM conversations WHERE avatar_id=? LIMIT 1').get(attachmentId), + removeMember: (conversationId, userId) => db.prepare('DELETE FROM conversation_members WHERE conversation_id=? AND user_id=?').run(conversationId, userId), + remove: (id) => { db.prepare('DELETE FROM conversation_members WHERE conversation_id=?').run(id); db.prepare('DELETE FROM conversations WHERE id=?').run(id); }, +}; + +const attachments = { + create: ({ id, teamId, uploaderId, name, mime, size }) => + db.prepare('INSERT INTO attachments (id,team_id,uploader_id,name,mime,size,created_at) VALUES (?,?,?,?,?,?,?)') + .run(id, teamId, uploaderId, name, mime || null, size || 0, now()), + byId: (id) => db.prepare('SELECT * FROM attachments WHERE id=?').get(id), +}; + +const scheduledMeetings = { + create: ({ id, teamId, groupId, roomCode, title, description, scheduledAt, createdBy, participants, durationMins, recurrence }) => + db.prepare('INSERT INTO scheduled_meetings (id,team_id,group_id,room_code,title,description,scheduled_at,created_by,created_at,participants,duration_mins,recurrence) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)') + .run(id, teamId, groupId || null, roomCode, title, description || null, scheduledAt, createdBy, now(), (participants && participants.length) ? JSON.stringify(participants) : null, durationMins || null, (recurrence && recurrence.length) ? JSON.stringify(recurrence) : null), + byId: (id) => db.prepare('SELECT * FROM scheduled_meetings WHERE id=?').get(id), + byCode: (code) => db.prepare('SELECT * FROM scheduled_meetings WHERE room_code=? ORDER BY created_at DESC LIMIT 1').get(code), + // Meetings a user can see: created by them, a member of the group, or an invited participant. + listForUser: (teamId, userId) => + db.prepare(`SELECT s.* FROM scheduled_meetings s + WHERE s.team_id=? AND ( + s.created_by=? OR + (s.group_id IS NOT NULL AND EXISTS (SELECT 1 FROM conversation_members cm WHERE cm.conversation_id=s.group_id AND cm.user_id=?)) OR + (s.participants IS NOT NULL AND s.participants LIKE '%'||?||'%')) + ORDER BY s.scheduled_at ASC`).all(teamId, userId, userId, '"' + userId + '"'), + dueForReminder: (fromTs, toTs) => db.prepare('SELECT * FROM scheduled_meetings WHERE reminded=0 AND ended_at IS NULL AND scheduled_at>=? AND scheduled_at<=?').all(fromTs, toTs), + markReminded: (id) => db.prepare('UPDATE scheduled_meetings SET reminded=1 WHERE id=?').run(id), + end: (id, teamId) => db.prepare('UPDATE scheduled_meetings SET ended_at=? WHERE id=? AND team_id=?').run(now(), id, teamId), + cancel: (id, teamId) => db.prepare('UPDATE scheduled_meetings SET cancelled=1, ended_at=? WHERE id=? AND team_id=?').run(now(), id, teamId), + reschedule: (id, teamId, ts) => db.prepare('UPDATE scheduled_meetings SET scheduled_at=?, reminded=0 WHERE id=? AND team_id=?').run(ts, id, teamId), // recurrence: roll to next occurrence + update: (id, teamId, { title, description, scheduledAt, durationMins, participants, recurrence }) => + db.prepare('UPDATE scheduled_meetings SET title=?, description=?, scheduled_at=?, duration_mins=?, participants=?, recurrence=?, reminded=0 WHERE id=? AND team_id=?') + .run(title, description || null, scheduledAt, durationMins || null, (participants && participants.length) ? JSON.stringify(participants) : null, (recurrence && recurrence.length) ? JSON.stringify(recurrence) : null, id, teamId), + remove: (id, teamId) => db.prepare('DELETE FROM scheduled_meetings WHERE id=? AND team_id=?').run(id, teamId), +}; + +const recordings = { + create: ({ id, teamId, room, groupId, meetingId, title, kind, file, mime, size, durationMs, createdBy, createdByName }) => + db.prepare('INSERT INTO recordings (id,team_id,room,group_id,meeting_id,title,kind,file,mime,size,duration_ms,created_by,created_by_name,created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)') + .run(id, teamId, room || null, groupId || null, meetingId || null, title || null, kind, file || null, mime || null, size || null, durationMs || null, createdBy || null, createdByName || null, now()), + byId: (id) => db.prepare('SELECT * FROM recordings WHERE id=?').get(id), + forTeam: (teamId) => db.prepare('SELECT * FROM recordings WHERE team_id=? ORDER BY created_at DESC').all(teamId), +}; + +const polls = { + create: ({ id, teamId, conversationId, messageId, question, options, multi, createdBy }) => + db.prepare('INSERT INTO polls (id,team_id,conversation_id,message_id,question,options,multi,closed,created_by,created_at) VALUES (?,?,?,?,?,?,?,0,?,?)') + .run(id, teamId, conversationId, messageId || null, question, JSON.stringify(options), multi ? 1 : 0, createdBy, now()), + byId: (id) => db.prepare('SELECT * FROM polls WHERE id=?').get(id), + close: (id) => db.prepare('UPDATE polls SET closed=1 WHERE id=?').run(id), +}; + +const pollVotes = { + forPoll: (pollId) => db.prepare('SELECT user_id, option_idx FROM poll_votes WHERE poll_id=?').all(pollId), + hasVoted: (pollId, userId, idx) => !!db.prepare('SELECT 1 FROM poll_votes WHERE poll_id=? AND user_id=? AND option_idx=?').get(pollId, userId, idx), + add: (pollId, userId, idx) => db.prepare('INSERT OR IGNORE INTO poll_votes (poll_id,user_id,option_idx,created_at) VALUES (?,?,?,?)').run(pollId, userId, idx, now()), + remove: (pollId, userId, idx) => db.prepare('DELETE FROM poll_votes WHERE poll_id=? AND user_id=? AND option_idx=?').run(pollId, userId, idx), + clearUser: (pollId, userId) => db.prepare('DELETE FROM poll_votes WHERE poll_id=? AND user_id=?').run(pollId, userId), +}; + +module.exports = { teams, users, authSessions, machines, audit, sessionsLog, refreshTokens, apiKeys, webhooks, messages, reactions, attachments, conversations, scheduledMeetings, recordings, polls, pollVotes }; diff --git a/server/routes.js b/server/routes.js index bb8d543..aee6a83 100644 --- a/server/routes.js +++ b/server/routes.js @@ -5,10 +5,92 @@ const path = require('path'); const R = require('./repos'); const A = require('./auth'); const BZ = require('./bizgaze'); +const W = require('./webhooks'); +const CHAT = require('./chat'); +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 }); +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; } +const RDAY = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; +function recurrenceLabel(days){ if (!days || !days.length) return ''; if (days.length === 7) return 'Every day'; return 'Every ' + days.slice().sort().map((d) => RDAY[d]).join(', '); } +// Post a centered "activity" line into a group (member added/removed/renamed/left) and push it. +function postSystemMessage(conversationId, teamId, text){ + const id = A.id(); + R.messages.send({ id, teamId, senderId: SYSTEM_SENDER, recipientId: '', body: text, conversationId }); + const dto = buildMsgDTO(R.messages.byId(id), {}, ''); + for (const mid of R.conversations.members(conversationId)) { try { CHAT.pushToUser(mid, { type: 'chat-message', message: dto }); } catch (_) {} } + return dto; +} +// Tell clients a group's membership changed so they refresh the member count / sidebar immediately. +function pushGroupUpdate(group, alsoUsers){ + const seen = new Set(); + for (const mid of R.conversations.members(group)) { seen.add(mid); try { CHAT.pushToUser(mid, { type: 'group-update', group }); } catch (_) {} } + for (const mid of (alsoUsers || [])) { if (!seen.has(mid)) { try { CHAT.pushToUser(mid, { type: 'group-update', group, removed: true }); } catch (_) {} } } +} +// Group a flat reaction list into { messageId: [{emoji,count,mine,who}] } for the current user. +function groupReactions(list, userId, names){ + const rxBy = {}; + for (const r of list) { + const byEmoji = (rxBy[r.message_id] || (rxBy[r.message_id] = {})); + const e = (byEmoji[r.emoji] || (byEmoji[r.emoji] = { count: 0, mine: false, who: [] })); + e.count++; if (r.user_id === userId) e.mine = true; + e.who.push((names && names[r.user_id]) || 'Someone'); + } + return rxBy; +} +const dtoReactions = (rxBy, id) => (rxBy[id] ? Object.entries(rxBy[id]).map(([emoji, v]) => ({ emoji, count: v.count, mine: v.mine, who: v.who })) : []); +// Full reaction DTO for ONE message, from `userId`'s perspective (mine/who). +function reactionsForMessage(messageId, userId, names){ + const rows = R.reactions.forMessage(messageId).map((r) => ({ message_id: messageId, user_id: r.user_id, emoji: r.emoji })); + return dtoReactions(groupReactions(rows, userId, names), messageId); +} +// Poll tally for a given viewer ("mine" = this user voted that option). +function buildPollDTO(poll, userId){ + let opts = []; try { opts = JSON.parse(poll.options); } catch { opts = []; } + const counts = opts.map(() => 0); const mine = opts.map(() => false); const voters = new Set(); + for (const v of R.pollVotes.forPoll(poll.id)) { + if (v.option_idx >= 0 && v.option_idx < counts.length) { counts[v.option_idx]++; if (v.user_id === userId) mine[v.option_idx] = true; } + voters.add(v.user_id); + } + return { + id: poll.id, question: poll.question, multi: !!poll.multi, closed: !!poll.closed, + options: opts.map((t, i) => ({ text: t, votes: counts[i], mine: mine[i] })), + totalVotes: counts.reduce((a, b) => a + b, 0), voters: voters.size, isOwner: poll.created_by === userId, + }; +} +// DTO enriched with a small preview of the quoted message (if this is a reply). +function buildMsgDTO(m, names, userId){ + const d = msgDTO(m); + if (m.reply_to) { + const r = R.messages.byId(m.reply_to); + if (r) d.reply = { id: r.id, from: r.sender_id, fromName: (names && names[r.sender_id]) || '', body: r.body.length > 140 ? r.body.slice(0, 140) + '…' : r.body }; + } + if (m.attachment_id) { + const a = R.attachments.byId(m.attachment_id); + if (a) d.attachment = { id: a.id, name: a.name, mime: a.mime, size: a.size, isImage: /^image\//.test(a.mime || '') }; + } + if (m.poll_id) { const p = R.polls.byId(m.poll_id); if (p) d.poll = buildPollDTO(p, userId); } + if (m.msg_type) d.byName = (names && names[m.sender_id]) || ''; + return d; +} const { now, json, readBody, parseCookies } = require('./lib'); -const { audit, currentUser } = require('./session'); -const { onlineAgents } = require('./presence'); -const { REC_DIR, TRANS_DIR, SESSION_TTL } = require('./config'); +const { audit, currentUser, tokenFromReq, apiKeyFromReq, keyHasScope } = require('./session'); +const API_KEY_SCOPES = ['report:read', 'audit:read']; +const { onlineAgents, meetingRooms, groupCalls, dmCalls } = require('./presence'); +const CALLS = require('./calls'); +require('./reminders'); // start the 10-minute meeting-reminder loop +const { REC_DIR, TRANS_DIR, UPLOADS_DIR, SESSION_TTL, REFRESH_TTL } = require('./config'); +const MAX_FILE_BYTES = 25 * 1024 * 1024; // 25 MB per chat attachment + +// Issue a refresh token (native clients), store only its hash, return the plaintext once. +function issueRefreshToken(userId) { + const rtok = A.token(32); + R.refreshTokens.create({ userId, tokenHash: A.hashToken(rtok), ttl: REFRESH_TTL }); + return rtok; +} const routes = {}; const route = (method, p, fn) => (routes[`${method} ${p}`] = fn); @@ -42,17 +124,23 @@ route('POST', '/api/mfa/enable', async (req, res) => { // Provision (or refresh) a local user from a successful BizGaze identity check. // The local row exists so sessions, audit, and team-scoped data work; BizGaze stays // the source of truth for credentials (the local password is random + unused). +// Emails that must always be admins regardless of what BizGaze returns (lockout safety net). +const ADMIN_EMAILS = (process.env.ADMIN_EMAILS || '').split(',').map((s) => s.trim().toLowerCase()).filter(Boolean); function provisionFromBizgaze(email, bz) { + const role = (bz.isAdmin || ADMIN_EMAILS.includes(String(email).toLowerCase())) ? 'admin' : 'technician'; const existing = R.users.byEmail(email); if (!existing) { const team = R.teams.first() || R.teams.create('BizGaze'); const { hash, salt } = A.hashPassword(A.token()); - const role = bz.isAdmin ? 'admin' : 'technician'; const id = R.users.create({ tenantId: team.id, email, hash, salt, role, name: bz.name || null, mfaSecret: A.newMfaSecret() }); + if (bz.avatarUrl) R.users.setAvatar(id, bz.avatarUrl); audit({ team_id: team.id, user_id: id, user_email: email, action: 'sso_user_created', detail: 'via BizGaze' }); return R.users.byId(id); } + // BizGaze is the source of truth: keep name + avatar + role in sync on each login. if (bz.name && bz.name !== existing.name) R.users.setName(existing.id, bz.name); + if (bz.avatarUrl && bz.avatarUrl !== existing.avatar_url) R.users.setAvatar(existing.id, bz.avatarUrl); + if (existing.role !== role) R.users.setRole(existing.id, role); return R.users.byId(existing.id); } @@ -64,22 +152,30 @@ route('POST', '/api/login', async (req, res) => { const existing = R.users.byEmail(email); if (existing && existing.active === 0) return json(res, 403, { error: 'This account has been deactivated' }); - let u = (existing && A.verifyPassword(password, existing.pw_salt, existing.pw_hash)) ? existing : null; - let bzMsg = null; - if (!u) { + // Production: when BizGaze is the IdP, verify ONLY against BizGaze (no local-password + // fallback) so stale in-app accounts can't shadow a BizGaze login and everyone lands in + // the same tenant. Local accounts stay usable for dev/testing via ALLOW_LOCAL_LOGIN=1. + const bizgazeOnly = BZ.isEnabled() && process.env.ALLOW_LOCAL_LOGIN !== '1'; + let u = null, bzMsg = null; + if (bizgazeOnly) { const bz = await BZ.validateLogin(email, password); - if (bz.ok) u = provisionFromBizgaze(email, bz); - else if (bz.error) return json(res, 503, { error: bz.error }); - else bzMsg = bz.message || null; // BizGaze was configured and rejected the credentials - } - if (!u) { - // Specific feedback where we can be truthful: - if (existing) return json(res, 401, { error: 'Incorrect password. Please try again.' }); - // No local account. BizGaze (the identity provider) doesn't reveal whether an email - // exists, so when it rejects we surface its own message (covers wrong password + - // any lockout warning). Only when BizGaze isn't in play can we say "not registered". - if (bzMsg) return json(res, 401, { error: bzMsg }); - return json(res, 404, { error: 'This email is not registered.' }); + if (bz.error) return json(res, 503, { error: bz.error }); + if (!bz.ok) return json(res, 401, { error: bz.message || 'Username or password do not match.' }); + u = provisionFromBizgaze(email, bz); + if (u && u.active === 0) return json(res, 403, { error: 'This account has been deactivated' }); + } else { + u = (existing && A.verifyPassword(password, existing.pw_salt, existing.pw_hash)) ? existing : null; + if (!u) { + const bz = await BZ.validateLogin(email, password); + if (bz.ok) u = provisionFromBizgaze(email, bz); + else if (bz.error) return json(res, 503, { error: bz.error }); + else bzMsg = bz.message || null; // BizGaze configured and rejected the credentials + } + if (!u) { + if (existing) return json(res, 401, { error: 'Incorrect password. Please try again.' }); + if (bzMsg) return json(res, 401, { error: bzMsg }); + return json(res, 404, { error: 'This email is not registered.' }); + } } const tok = A.token(); @@ -87,9 +183,27 @@ route('POST', '/api/login', async (req, res) => { R.authSessions.create({ token: tok, userId: u.id, mfaPassed: true, ttl }); res.setHeader('Set-Cookie', `sid=${tok}; HttpOnly; Path=/; Max-Age=${ttl / 1000}`); audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'login' }); - // Cookie for the web app; token in the body for native desktop/mobile clients - // (they send it back as `Authorization: Bearer `). - json(res, 200, { ok: true, mfaRequired: false, token: tok, expiresAt: now() + ttl }); + // Cookie for the web app; access token + refresh token in the body for native + // desktop/mobile clients (access via `Authorization: Bearer`, refresh via /api/v1/auth/refresh). + const refreshToken = issueRefreshToken(u.id); + json(res, 200, { ok: true, mfaRequired: false, token: tok, expiresAt: now() + ttl, refreshToken, refreshExpiresAt: now() + REFRESH_TTL }); +}); + +// Exchange a refresh token for a fresh access token (with rotation). Native clients call +// this when their access token expires, so the user stays signed in without re-entering a password. +route('POST', '/api/auth/refresh', async (req, res) => { + const { refreshToken } = await readBody(req); + if (!refreshToken) return json(res, 400, { error: 'refreshToken required' }); + const h = A.hashToken(refreshToken); + const row = R.refreshTokens.byHash(h); + if (!row || row.revoked || row.expires_at < now()) return json(res, 401, { error: 'invalid or expired refresh token' }); + const u = R.users.byId(row.user_id); + if (!u || u.active === 0) return json(res, 401, { error: 'account unavailable' }); + R.refreshTokens.revoke(h); // rotate: one-time use + const tok = A.token(); + R.authSessions.create({ token: tok, userId: u.id, mfaPassed: true, ttl: SESSION_TTL }); + const newRefresh = issueRefreshToken(u.id); + json(res, 200, { ok: true, token: tok, expiresAt: now() + SESSION_TTL, refreshToken: newRefresh, refreshExpiresAt: now() + REFRESH_TTL }); }); // Login step 2: TOTP code -> marks session mfa_passed @@ -106,8 +220,10 @@ route('POST', '/api/login/mfa', async (req, res) => { }); route('POST', '/api/logout', async (req, res) => { - const tok = parseCookies(req).sid; + const tok = tokenFromReq(req); // cookie (web) or Bearer (native) if (tok) R.authSessions.deleteByToken(tok); + const { refreshToken } = await readBody(req); + if (refreshToken) R.refreshTokens.revoke(A.hashToken(refreshToken)); res.setHeader('Set-Cookie', 'sid=; HttpOnly; Path=/; Max-Age=0'); json(res, 200, { ok: true }); }); @@ -117,16 +233,21 @@ route('GET', '/api/setup-state', async (req, res) => { json(res, 200, { registrationOpen: !anyUser || process.env.ALLOW_REGISTRATION === '1' }); }); -// ICE servers for WebRTC. Always includes a public STUN; adds managed TURN if -// configured via env (TURN_URLS comma-separated, TURN_USERNAME, TURN_CREDENTIAL). +// ICE servers for WebRTC. Always includes a public STUN; adds our TURN relay if +// configured. TURN_SECRET (coturn use-auth-secret) -> time-limited HMAC credentials +// (no permanent password exposed); otherwise static TURN_USERNAME + TURN_CREDENTIAL. route('GET', '/api/ice', async (req, res) => { const iceServers = [{ urls: 'stun:stun.l.google.com:19302' }]; if (process.env.TURN_URLS) { - iceServers.push({ - urls: process.env.TURN_URLS.split(',').map((u) => u.trim()).filter(Boolean), - username: process.env.TURN_USERNAME || '', - credential: process.env.TURN_CREDENTIAL || '', - }); + const urls = process.env.TURN_URLS.split(',').map((u) => u.trim()).filter(Boolean); + let username = process.env.TURN_USERNAME || ''; + let credential = process.env.TURN_CREDENTIAL || ''; + if (process.env.TURN_SECRET) { + const ttl = parseInt(process.env.TURN_TTL || '86400', 10); + username = String(Math.floor(Date.now() / 1000) + ttl); + credential = require('crypto').createHmac('sha1', process.env.TURN_SECRET).update(username).digest('base64'); + } + iceServers.push({ urls, username, credential }); } json(res, 200, { iceServers }); }); @@ -134,7 +255,7 @@ 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 }); + 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 }); }); // ---------- BizGaze SSO: agent arrives already logged in ---------- @@ -177,6 +298,9 @@ route('POST', '/api/users', async (req, res) => { const u = currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); if (u.role !== 'admin') return json(res, 403, { error: 'only admins can add agents' }); + // With BizGaze as the sole IdP, logins are created in BizGaze, not here (creating local + // accounts is what previously shadowed BizGaze and split tenants). Allowed in dev. + if (BZ.isEnabled() && process.env.ALLOW_LOCAL_LOGIN !== '1') return json(res, 400, { error: 'Logins are managed in BizGaze. Add the user there; they appear here on first sign-in.' }); const { email, password, name, role } = await readBody(req); if (!email || !password) return json(res, 400, { error: 'email and temporary password required' }); if (R.users.emailExists(email)) @@ -218,6 +342,7 @@ route('POST', '/api/users/manage', async (req, res) => { const { hash, salt } = A.hashPassword(password); R.users.setPassword(target.id, hash, salt); R.authSessions.deleteByUser(target.id); // force re-login + R.refreshTokens.revokeByUser(target.id); audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'agent_password_reset', detail: target.email }); return json(res, 200, { ok: true }); } @@ -232,6 +357,7 @@ route('POST', '/api/users/manage', async (req, res) => { if (target.id === u.id) return json(res, 400, { error: 'you cannot deactivate your own account' }); R.users.setActive(target.id, false); R.authSessions.deleteByUser(target.id); + R.refreshTokens.revokeByUser(target.id); audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'agent_deactivated', detail: target.email }); return json(res, 200, { ok: true }); } @@ -243,6 +369,7 @@ route('POST', '/api/users/manage', async (req, res) => { case 'delete': { if (target.id === u.id) return json(res, 400, { error: 'you cannot delete your own account' }); R.authSessions.deleteByUser(target.id); + R.refreshTokens.revokeByUser(target.id); R.users.remove(target.id); audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'agent_deleted', detail: target.email }); return json(res, 200, { ok: true }); @@ -251,17 +378,100 @@ route('POST', '/api/users/manage', async (req, res) => { } }); -// Session report: one row per session, filterable by agent and date period -route('GET', '/api/report', async (req, res) => { +// ---------- API keys (admin-managed, for third-party / system integrations) ---------- +route('POST', '/api/keys', async (req, res) => { const u = currentUser(req); if (!u) return json(res, 401, { error: 'unauthorized' }); + if (u.role !== 'admin') return json(res, 403, { error: 'only admins can manage API keys' }); + const { name, scopes } = await readBody(req); + const sc = (Array.isArray(scopes) ? scopes : ['report:read']).filter((s) => API_KEY_SCOPES.includes(s)); + if (!sc.length) return json(res, 400, { error: 'at least one valid scope required (' + API_KEY_SCOPES.join(', ') + ')' }); + const key = 'bzc_' + A.token(24); // shown once, never stored in plaintext + const id = A.id(); + R.apiKeys.create({ id, tenantId: u.team_id, name: name || null, keyHash: A.hashToken(key), scopes: sc.join(','), createdBy: u.id }); + audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'api_key_created', detail: (name || id) + ' [' + sc.join(',') + ']' }); + json(res, 200, { id, name: name || null, scopes: sc, key }); +}); + +route('GET', '/api/keys', async (req, res) => { + const u = currentUser(req); + if (!u) return json(res, 401, { error: 'unauthorized' }); + if (u.role !== 'admin') return json(res, 403, { error: 'only admins can manage API keys' }); + json(res, 200, R.apiKeys.listByTenant(u.team_id)); +}); + +route('POST', '/api/keys/revoke', async (req, res) => { + const u = currentUser(req); + if (!u) return json(res, 401, { error: 'unauthorized' }); + if (u.role !== 'admin') return json(res, 403, { error: 'only admins can manage API keys' }); + const { id } = await readBody(req); + if (!id) return json(res, 400, { error: 'id required' }); + R.apiKeys.revoke(id, u.team_id); + audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'api_key_revoked', detail: id }); + json(res, 200, { ok: true }); +}); + +// ---------- Webhook subscriptions (admin-managed, outbound event delivery) ---------- +route('POST', '/api/webhooks', async (req, res) => { + const u = currentUser(req); + if (!u) return json(res, 401, { error: 'unauthorized' }); + if (u.role !== 'admin') return json(res, 403, { error: 'only admins can manage webhooks' }); + const { url, events, secret } = await readBody(req); + if (!url || !/^https?:\/\//i.test(url)) return json(res, 400, { error: 'a valid http(s) url is required' }); + let ev = Array.isArray(events) ? events.filter((e) => e === '*' || W.EVENTS.includes(e)) : W.EVENTS.slice(); + if (!ev.length) ev = W.EVENTS.slice(); + const sec = (secret && String(secret).length >= 8) ? String(secret) : A.token(24); + const id = A.id(); + R.webhooks.create({ id, tenantId: u.team_id, url, secret: sec, events: ev.join(','), createdBy: u.id }); + audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'webhook_created', detail: url + ' [' + ev.join(',') + ']' }); + // Secret returned so the receiver can verify the X-BizGaze-Signature header. + json(res, 200, { id, url, events: ev, secret: sec }); +}); + +route('GET', '/api/webhooks', async (req, res) => { + const u = currentUser(req); + if (!u) return json(res, 401, { error: 'unauthorized' }); + if (u.role !== 'admin') return json(res, 403, { error: 'only admins can manage webhooks' }); + json(res, 200, R.webhooks.listByTenant(u.team_id)); +}); + +route('POST', '/api/webhooks/delete', async (req, res) => { + const u = currentUser(req); + if (!u) return json(res, 401, { error: 'unauthorized' }); + if (u.role !== 'admin') return json(res, 403, { error: 'only admins can manage webhooks' }); + const { id } = await readBody(req); + if (!id) return json(res, 400, { error: 'id required' }); + R.webhooks.remove(id, u.team_id); + audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'webhook_deleted', detail: id }); + json(res, 200, { ok: true }); +}); + +// Available webhook event types (for integrators / an admin UI). +route('GET', '/api/webhooks/events', async (req, res) => { + const u = currentUser(req); + if (!u) return json(res, 401, { error: 'unauthorized' }); + json(res, 200, { events: W.EVENTS }); +}); + +// Session report — readable by a logged-in user OR an API key with `report:read`. +route('GET', '/api/report', async (req, res) => { const q = new URLSearchParams(req.url.split('?')[1] || ''); - // Admins see the whole team (and may filter by agent); everyone else sees only - // their own sessions, regardless of any agent filter passed. - const agentEmail = u.role !== 'admin' ? u.email : (q.get('agent') || null); + let tenantId, agentEmail; + const u = currentUser(req); + if (u) { + // Admins see the whole team (and may filter by agent); everyone else only their own. + tenantId = u.team_id; + agentEmail = u.role !== 'admin' ? u.email : (q.get('agent') || null); + } else { + const key = apiKeyFromReq(req); + if (!keyHasScope(key, 'report:read')) return json(res, 401, { error: 'unauthorized' }); + R.apiKeys.touch(key.id); + tenantId = key.teamId; // a key sees its whole tenant + agentEmail = q.get('agent') || null; + } const from = q.get('from') ? new Date(q.get('from') + 'T00:00:00').getTime() : null; const to = q.get('to') ? new Date(q.get('to') + 'T23:59:59').getTime() : null; - json(res, 200, R.sessionsLog.report({ tenantId: u.team_id, agentEmail, from, to })); + json(res, 200, R.sessionsLog.report({ tenantId, agentEmail, from, to })); }); // List machines for the team (with live online status from signaling layer) @@ -286,9 +496,15 @@ route('POST', '/api/machines', async (req, res) => { route('GET', '/api/audit', async (req, res) => { const u = currentUser(req); - if (!u) return json(res, 401, { error: 'unauthorized' }); - const rows = R.audit.listByTenant(u.team_id); - json(res, 200, rows); + let tenantId; + if (u) tenantId = u.team_id; + else { + const key = apiKeyFromReq(req); + if (!keyHasScope(key, 'audit:read')) return json(res, 401, { error: 'unauthorized' }); + R.apiKeys.touch(key.id); + tenantId = key.teamId; + } + json(res, 200, R.audit.listByTenant(tenantId)); }); // ---------- session recording: upload (agent) ---------- @@ -338,6 +554,670 @@ route('POST', '/api/transcript', async (req, res) => { req.on('error', () => { try { res.end(); } catch (e) {} }); }); +// ---------- Chat (persistent 1:1 messaging between team members) ---------- +// Contacts = other active users in the tenant (the people you can message). +route('GET', '/api/messages/contacts', async (req, res) => { + const u = currentUser(req); + if (!u) return json(res, 401, { error: 'unauthorized' }); + const rows = R.users.listByTenant(u.team_id).filter((x) => x.id !== u.id && x.active !== 0); + json(res, 200, rows.map((x) => ({ id: x.id, name: x.name || x.email, email: x.email, online: CHAT.isOnline(x.id), avatar: x.avatar_url || null }))); +}); + +// Cross-tenant people search via the BizGaze directory (token stays server-side). Results are +// tagged onConnect=true when the person already has a Connect account in this tenant (chat-ready). +route('GET', '/api/directory/search', async (req, res) => { + const u = currentUser(req); + if (!u) return json(res, 401, { error: 'unauthorized' }); + const q = (new URLSearchParams(req.url.split('?')[1] || '').get('q') || '').trim(); + if (q.length < 2) return json(res, 200, []); + const results = await require('./directory').search(q); + // Map directory people to existing Connect users in this tenant (by email) so they're chat-ready. + const mine = R.users.listByTenant(u.team_id).filter((x) => x.id !== u.id && x.active !== 0); + const byEmail = new Map(mine.map((x) => [(x.email || '').toLowerCase(), x])); + const out = results.map((p) => { + const local = p.email ? byEmail.get(p.email.toLowerCase()) : null; + return { name: p.name, email: p.email, phone: p.phone, org: p.org, avatar: p.avatar, + onConnect: !!local, connectId: local ? local.id : null }; + }); + json(res, 200, out); +}); + +// Conversation list: DMs (per counterparty) + group conversations, merged + sorted. +route('GET', '/api/messages/conversations', async (req, res) => { + const u = currentUser(req); + 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; } + // DMs + const byOther = new Map(); + for (const m of R.messages.recentFor(u.team_id, u.id)) { + const other = m.sender_id === u.id ? m.recipient_id : m.sender_id; + if (!other) continue; + if (!byOther.has(other)) byOther.set(other, { other, last: m, unread: 0 }); + if (m.recipient_id === u.id && m.sender_id === other && !m.read_at) byOther.get(other).unread++; + } + const dmItems = [...byOther.values()].map((c) => { + const dc = dmCalls.get(CALLS.pairKey(u.id, c.other)); + return { + kind: 'dm', id: c.other, contactId: c.other, name: names[c.other] || 'Unknown', online: CHAT.isOnline(c.other), avatar: avatars[c.other] || null, + callActive: !!dc, callRoom: dc ? dc.room : null, + 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 + const groupItems = R.conversations.listForUser(u.team_id, u.id).map((g) => { + 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, + 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, + }; + }); + json(res, 200, [...dmItems, ...groupItems].sort((a, b) => b.last_at - a.last_at)); +}); + +// Full thread: a DM (?with=userId) or a group (?group=conversationId). Marks it read. +route('GET', '/api/messages/thread', async (req, res) => { + const u = currentUser(req); + if (!u) return json(res, 401, { error: 'unauthorized' }); + const q = new URLSearchParams(req.url.split('?')[1] || ''); + const peek = !!q.get('peek'); // prefetch only — do NOT mark the conversation read + const names = namesFor(u.team_id); + const group = q.get('group'); + if (group) { + if (!R.conversations.isMember(group, u.id)) return json(res, 403, { error: 'not a member of this group' }); + const rows = R.messages.threadByConversation(group); + if (!peek) { + R.conversations.markRead(group, u.id); + const evt = { type: 'group-read', group, by: u.id, byName: names[u.id] || u.email, at: now() }; + for (const mid of R.conversations.members(group)) { if (mid !== u.id) { try { CHAT.pushToUser(mid, evt); } catch (_) {} } } + } + const rxBy = groupReactions(R.reactions.forConversation(group), u.id, names); + const reads = R.conversations.memberReads(group).filter((r) => r.user_id !== u.id); // others' read times + return json(res, 200, rows.map((m) => { + const d = buildMsgDTO(m, names, u.id); d.fromName = names[m.sender_id] || ''; d.reactions = dtoReactions(rxBy, m.id); + if (m.sender_id === u.id) d.seenBy = reads.filter((r) => r.last_read_at >= m.created_at).map((r) => names[r.user_id] || 'Someone'); + return d; + })); + } + const other = q.get('with'); + if (!other) return json(res, 400, { error: 'with or group required' }); + if (!R.users.inTenant(other, u.team_id)) return json(res, 404, { error: 'no such contact' }); + const rows = R.messages.thread(u.team_id, u.id, other); + if (!peek) { R.messages.markRead(u.team_id, u.id, other); try { CHAT.pushToUser(other, { type: 'chat-read', by: u.id }); } catch (_) {} } + const rxBy = groupReactions(R.reactions.forPair(u.team_id, u.id, other), u.id, names); + return json(res, 200, rows.map((m) => { const d = buildMsgDTO(m, names, u.id); d.reactions = dtoReactions(rxBy, m.id); return d; })); +}); + +// Create a group conversation with the given members (creator is always added). +route('POST', '/api/groups', async (req, res) => { + const u = currentUser(req); + if (!u) return json(res, 401, { error: 'unauthorized' }); + const { name, memberIds } = await readBody(req); + const nm = String(name || '').trim().slice(0, 80); + if (!nm) return json(res, 400, { error: 'group name required' }); + const ids = (Array.isArray(memberIds) ? memberIds : []).filter((x) => typeof x === 'string' && x !== u.id && R.users.inTenant(x, u.team_id)); + const id = A.id(); + R.conversations.create({ id, teamId: u.team_id, name: nm, createdBy: u.id }); + R.conversations.addMember(id, u.id, true); // creator is the first admin + for (const mid of ids) R.conversations.addMember(id, mid); + audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'group_created', detail: nm + ' (' + (ids.length + 1) + ' members)' }); + json(res, 200, { id, name: nm, members: ids.length + 1 }); +}); + +// Members of a group (id + name), for the group header / member list. +route('GET', '/api/groups/members', async (req, res) => { + const u = currentUser(req); + if (!u) return json(res, 401, { error: 'unauthorized' }); + const gid = new URLSearchParams(req.url.split('?')[1] || '').get('group'); + if (!gid || !R.conversations.isMember(gid, u.id)) return json(res, 403, { error: 'not a member' }); + 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 adminSet = new Set(R.conversations.admins(gid)); + json(res, 200, R.conversations.members(gid).map((mid) => ({ id: mid, name: names[mid] || 'Unknown', avatar: avatars[mid] || null, admin: adminSet.has(mid) }))); +}); + +// Full group info: name, creator flag, members (with isMe). +route('GET', '/api/groups/info', async (req, res) => { + const u = currentUser(req); + if (!u) return json(res, 401, { error: 'unauthorized' }); + const gid = new URLSearchParams(req.url.split('?')[1] || '').get('group'); + if (!gid || !R.conversations.isMember(gid, u.id)) return json(res, 403, { error: 'not a member' }); + const g = R.conversations.byId(gid); + const tenantUsers = R.users.listByTenant(u.team_id); + const names = {}; const avatars = {}; + for (const x of tenantUsers) { names[x.id] = x.name || x.email; avatars[x.id] = x.avatar_url || null; } + const adminSet = new Set(R.conversations.admins(gid)); + json(res, 200, { + id: gid, name: g.name || 'Group', createdBy: g.created_by, isCreator: g.created_by === u.id, + isAdmin: adminSet.has(u.id), + adminOnly: !!g.admin_only, callActive: groupCalls.has(gid), callRoom: (groupCalls.get(gid) || {}).room || null, + createdByName: names[g.created_by] || 'Someone', createdAt: g.created_at, + avatar: g.avatar_id ? ('/files/' + g.avatar_id) : null, + members: R.conversations.members(gid).map((mid) => ({ id: mid, name: names[mid] || 'Unknown', avatar: avatars[mid] || null, isMe: mid === u.id, admin: adminSet.has(mid) })), + }); +}); + +// Rename a group (any member). +route('POST', '/api/groups/rename', async (req, res) => { + const u = currentUser(req); + if (!u) return json(res, 401, { error: 'unauthorized' }); + const { group, name } = await readBody(req); + if (!group || !R.conversations.isMember(group, u.id)) return json(res, 403, { error: 'not a member' }); + const nm = String(name || '').trim().slice(0, 80); + if (!nm) return json(res, 400, { error: 'group name required' }); + R.conversations.rename(group, nm); + postSystemMessage(group, u.team_id, (u.name || u.email) + ' renamed the group to “' + nm + '”'); + audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'group_renamed', detail: nm }); + json(res, 200, { ok: true, name: nm }); +}); + +// Start (or join) the group's shared call — returns the mesh room to connect to. No code: +// members see a Join button driven by the live call state. +route('POST', '/api/groups/call/start', async (req, res) => { + const u = currentUser(req); + if (!u) return json(res, 401, { error: 'unauthorized' }); + const { group } = await readBody(req); + if (!group || !R.conversations.isMember(group, u.id)) return json(res, 403, { error: 'not a member of this group' }); + const r = CALLS.startGroupCall(group, u.team_id, u); + json(res, 200, r); +}); + +// Start (or join) a 1:1 call with another user. +route('POST', '/api/calls/dm/start', async (req, res) => { + const u = currentUser(req); + if (!u) return json(res, 401, { error: 'unauthorized' }); + const { to } = await readBody(req); + if (!to || !R.users.inTenant(to, u.team_id)) return json(res, 404, { error: 'no such contact' }); + json(res, 200, CALLS.startDmCall(u, to, u.team_id)); +}); + +// Invite more people into the call I'm in (turns a 1:1 into multi-party). Pushes them an +// incoming-call notification carrying the room to join. +route('POST', '/api/calls/invite', async (req, res) => { + const u = currentUser(req); + if (!u) return json(res, 401, { error: 'unauthorized' }); + const { room, userIds } = await readBody(req); + if (!room || !meetingRooms.has(String(room))) return json(res, 404, { error: 'call not found' }); + const ids = (Array.isArray(userIds) ? userIds : []).filter((x) => typeof x === 'string' && x !== u.id && R.users.inTenant(x, u.team_id)); + for (const id of ids) { try { CHAT.pushToUser(id, { type: 'call-invite', room: String(room), byName: (u.name || u.email) }); } catch (_) {} } + json(res, 200, { ok: true, invited: ids.length }); +}); + +// Decline an incoming 1:1 call: drops the caller, posts a "Call declined" line, clears the call. +route('POST', '/api/calls/decline', async (req, res) => { + const u = currentUser(req); + if (!u) return json(res, 401, { error: 'unauthorized' }); + const { room } = await readBody(req); + if (!room) return json(res, 400, { error: 'room required' }); + json(res, 200, CALLS.declineDmCall(String(room), u)); +}); + +// Toggle "only admins can add/remove members" (any admin). +route('POST', '/api/groups/admin-only', async (req, res) => { + const u = currentUser(req); + if (!u) return json(res, 401, { error: 'unauthorized' }); + const { group, value } = await readBody(req); + const g = group && R.conversations.byId(group); + if (!g || g.team_id !== u.team_id || !R.conversations.isMember(group, u.id)) return json(res, 403, { error: 'not a member' }); + if (!R.conversations.isAdmin(group, u.id)) return json(res, 403, { error: 'only a group admin can change this' }); + R.conversations.setAdminOnly(group, !!value); + postSystemMessage(group, u.team_id, (u.name || u.email) + (value ? ' restricted adding members to admins only' : ' allowed everyone to add members')); + json(res, 200, { ok: true, adminOnly: !!value }); +}); + +// Promote/demote a member as admin (#9, multiple admins allowed). Only an admin can change roles. +route('POST', '/api/groups/admin', async (req, res) => { + const u = currentUser(req); + if (!u) return json(res, 401, { error: 'unauthorized' }); + const { group, userId, value } = await readBody(req); + const g = group && R.conversations.byId(group); + if (!g || g.team_id !== u.team_id || !R.conversations.isMember(group, u.id)) return json(res, 403, { error: 'not a member' }); + if (!R.conversations.isAdmin(group, u.id)) return json(res, 403, { error: 'only a group admin can change roles' }); + if (!userId || !R.conversations.isMember(group, userId)) return json(res, 404, { error: 'not a member of this group' }); + if (!value && R.conversations.admins(group).length <= 1 && R.conversations.isAdmin(group, userId)) return json(res, 400, { error: 'a group must have at least one admin' }); + R.conversations.setMemberAdmin(group, userId, !!value); + const names = namesFor(u.team_id); + postSystemMessage(group, u.team_id, (u.name || u.email) + (value ? ' made ' + (names[userId] || 'someone') + ' an admin' : ' removed ' + (names[userId] || 'someone') + ' as admin')); + pushGroupUpdate(group); + try { CHAT.pushToUser(userId, { type: 'group-role', group, admin: !!value, by: u.name || u.email }); } catch (_) {} // notify the affected member + json(res, 200, { ok: true }); +}); + +// Set a group's image. Pass an attachmentId from /api/messages/upload (must be an image +// the caller uploaded). Pass null/empty to clear it. +route('POST', '/api/groups/avatar', async (req, res) => { + const u = currentUser(req); + if (!u) return json(res, 401, { error: 'unauthorized' }); + const { group, attachmentId } = await readBody(req); + if (!group || !R.conversations.isMember(group, u.id)) return json(res, 403, { error: 'not a member' }); + if (attachmentId) { + const a = R.attachments.byId(attachmentId); + if (!a || a.team_id !== u.team_id || a.uploader_id !== u.id) return json(res, 400, { error: 'invalid attachment' }); + if (!/^image\//.test(a.mime || '')) return json(res, 400, { error: 'group image must be an image file' }); + } + R.conversations.setAvatar(group, attachmentId || null); + audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'group_avatar_set', detail: group }); + json(res, 200, { ok: true, avatar: attachmentId ? ('/files/' + attachmentId) : null }); +}); + +// Add members to a group (any member). +route('POST', '/api/groups/add', async (req, res) => { + const u = currentUser(req); + if (!u) return json(res, 401, { error: 'unauthorized' }); + const { group, memberIds } = await readBody(req); + if (!group || !R.conversations.isMember(group, u.id)) return json(res, 403, { error: 'not a member' }); + const gA = R.conversations.byId(group); + if (gA && gA.admin_only && !R.conversations.isAdmin(group, u.id)) return json(res, 403, { error: 'Only a group admin can add members' }); + const ids = (Array.isArray(memberIds) ? memberIds : []).filter((x) => typeof x === 'string' && R.users.inTenant(x, u.team_id) && !R.conversations.isMember(group, x)); + for (const mid of ids) R.conversations.addMember(group, mid); + if (ids.length) { + const names = namesFor(u.team_id); + postSystemMessage(group, u.team_id, (u.name || u.email) + ' added ' + ids.map((x) => names[x] || 'someone').join(', ')); + } + if (ids.length) pushGroupUpdate(group); // live member-count refresh for everyone (incl. the new members) + audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'group_members_added', detail: ids.length + ' to ' + group }); + json(res, 200, { ok: true, added: ids.length }); +}); + +// Remove a member (creator removes others; anyone can remove themselves = leave). +route('POST', '/api/groups/remove', async (req, res) => { + const u = currentUser(req); + if (!u) return json(res, 401, { error: 'unauthorized' }); + const { group, userId, newAdmin } = await readBody(req); + if (!group || !R.conversations.isMember(group, u.id)) return json(res, 403, { error: 'not a member' }); + const target = userId || u.id; + const isSelf = target === u.id; + // Leaving (self) is always allowed; removing others requires admin when admin_only is on. + if (!isSelf) { const gR = R.conversations.byId(group); if (gR && gR.admin_only && !R.conversations.isAdmin(group, u.id)) return json(res, 403, { error: 'Only a group admin can remove members' }); } + const wasAdmin = R.conversations.isAdmin(group, target); + // #10: the last admin must hand off to a chosen successor before leaving (no auto-assign). + const others = R.conversations.members(group).filter((m) => m !== target); + if (wasAdmin && others.length && R.conversations.admins(group).filter((a) => a !== target).length === 0) { + if (!newAdmin || !R.conversations.isMember(group, newAdmin) || newAdmin === target) return json(res, 400, { error: 'NEED_ADMIN', message: 'Choose a member to be the new admin before leaving.' }); + R.conversations.setMemberAdmin(group, newAdmin, true); + const names0 = namesFor(u.team_id); + postSystemMessage(group, u.team_id, (names0[newAdmin] || 'A member') + ' is now an admin'); + try { CHAT.pushToUser(newAdmin, { type: 'group-role', group, admin: true }); } catch (_) {} + } + // Post the activity BEFORE removing, so the removed person's tab also receives it. + if (target !== u.id && R.conversations.isMember(group, target)) { + const names = namesFor(u.team_id); + postSystemMessage(group, u.team_id, (u.name || u.email) + ' removed ' + (names[target] || 'someone')); + } else if (isSelf) { + postSystemMessage(group, u.team_id, (u.name || u.email) + ' left the group'); + } + R.conversations.removeMember(group, target); + if (R.conversations.members(group).length === 0) { R.conversations.remove(group); } // drop empty groups + else pushGroupUpdate(group, [target]); // live member-count refresh; the removed person drops the group + audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: isSelf ? 'group_left' : 'group_member_removed', detail: group }); + json(res, 200, { ok: true, left: isSelf }); +}); + +// ---------- Meetings (scheduled calls) ---------- +// Schedule a call (optionally tied to a group). Gets a stable room code so it can be +// joined later; the live mesh room is created on first join. Announces in the group chat. +route('POST', '/api/meetings/schedule', async (req, res) => { + const u = currentUser(req); + if (!u) return json(res, 401, { error: 'unauthorized' }); + const { group, title, description, scheduledAt, whenText, participants, durationMins, recurrence } = await readBody(req); + const t = String(title || '').trim().slice(0, 120); + if (!t) return json(res, 400, { error: 'title required' }); + const when = Number(scheduledAt); + if (!Number.isFinite(when) || when <= 0) return json(res, 400, { error: 'valid scheduledAt (ms) required' }); + if (when < Date.now()) return json(res, 400, { error: 'cannot schedule a meeting in the past' }); // #1 + const dur = [15, 30, 45, 60, 90, 120].includes(Number(durationMins)) ? Number(durationMins) : 30; + const recur = Array.isArray(recurrence) ? [...new Set(recurrence.map(Number).filter((d) => d >= 0 && d <= 6))] : []; + let groupId = null; + if (group) { + if (!R.conversations.isMember(group, u.id)) return json(res, 403, { error: 'not a member of this group' }); + groupId = group; + } + const desc = String(description || '').trim().slice(0, 1000); + // Invited participants: tenant users, excluding the host (creator). + const invited = [...new Set((Array.isArray(participants) ? participants : []).filter((x) => typeof x === 'string' && x !== u.id && R.users.inTenant(x, u.team_id)))]; + let code; do { code = A.numericCode(6); } while (R.scheduledMeetings.byCode(code) || meetingRooms.has(code)); + const id = A.id(); + R.scheduledMeetings.create({ id, teamId: u.team_id, groupId, roomCode: code, title: t, description: desc, scheduledAt: when, createdBy: u.id, participants: invited, durationMins: dur, recurrence: recur }); + audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'meeting_scheduled', detail: t }); + const label = (typeof whenText === 'string' && whenText.trim()) ? whenText.trim() : new Date(when).toLocaleString(); + if (groupId) { + const mid = A.id(); + R.messages.send({ id: mid, teamId: u.team_id, senderId: u.id, recipientId: '', body: '📅 Scheduled a call: ' + t + ' — ' + label, conversationId: groupId }); + const dto = buildMsgDTO(R.messages.byId(mid), namesFor(u.team_id), u.id); dto.fromName = u.name || u.email; + for (const m of R.conversations.members(groupId)) { try { CHAT.pushToUser(m, { type: 'chat-message', message: dto }); } catch (_) {} } + } + // Invitation notification to each invited participant. + const inviteEvt = { type: 'meeting-invite', meeting: { id, title: t, scheduledAt: when, whenText: label, room: code, by: u.name || u.email } }; + for (const pid of invited) { try { CHAT.pushToUser(pid, inviteEvt); } catch (_) {} } + json(res, 200, { id, roomCode: code, title: t, description: desc, scheduledAt: when, groupId, participants: invited }); +}); + +// List the meetings this user can see, bucketed into running / upcoming / past. +route('GET', '/api/meetings', async (req, res) => { + const u = currentUser(req); + if (!u) return json(res, 401, { error: 'unauthorized' }); + const names = namesFor(u.team_id); + const nowTs = Date.now(); + const rows = R.scheduledMeetings.listForUser(u.team_id, u.id).map((s) => { + let recur = []; try { recur = JSON.parse(s.recurrence || '[]'); } catch (_) {} + let schedAt = s.scheduled_at; + const live = meetingRooms.get(s.room_code); + const running = !!(live && live.size > 0); + // Recurring + its window has passed (and not live/cancelled) → roll forward to the next occurrence. + if (recur.length && !running && !s.cancelled && !s.ended_at && nowTs > schedAt + ((s.duration_mins || 60) * 60000)) { + const nxt = nextOccurrence(schedAt, recur, nowTs); + if (nxt !== schedAt) { try { R.scheduledMeetings.reschedule(s.id, u.team_id, nxt); } catch (_) {} schedAt = nxt; } + } + const endTime = schedAt + ((s.duration_mins || 60) * 60000); // can't be started past this (#3) + let status = 'upcoming'; + if (s.cancelled) status = 'cancelled'; + else if (running) status = 'running'; + else if (s.ended_at) status = 'past'; + else if (nowTs > endTime) status = 'past'; // its scheduled window has fully passed + let invited = []; try { invited = JSON.parse(s.participants || '[]'); } catch (_) {} + return { + id: s.id, roomCode: s.room_code, title: s.title, description: s.description || '', + scheduledAt: schedAt, groupId: s.group_id, + groupName: s.group_id ? ((R.conversations.byId(s.group_id) || {}).name || 'Group') : null, + createdBy: s.created_by, createdByName: names[s.created_by] || '', canManage: s.created_by === u.id, isHost: s.created_by === u.id, + invited: invited.map((pid) => names[pid] || 'Someone'), invitedIds: invited, + durationMins: s.duration_mins || null, recurrence: recur, recurrenceLabel: recurrenceLabel(recur), + status, inCall: running ? live.size : 0, recordings: [], + }; + }); + // Attach recordings/transcripts. A recording is visible to its creator, group members, or people + // who can see the scheduled meeting it belongs to. Recordings not tied to a listed meeting become + // their own "Past meeting" entry (group calls show the group name). + const recDTO = (r) => ({ id: r.id, kind: r.kind, url: '/mrec/' + r.id, createdAt: r.created_at, durationMs: r.duration_ms, size: r.size, by: r.created_by_name }); + const canSeeRec = (r) => { + if (r.kind === 'transcript') return r.created_by === u.id; // transcripts are private to their owner + if (r.created_by === u.id) return true; + if (r.group_id) return R.conversations.isMember(r.group_id, u.id); + if (r.meeting_id) { const s = R.scheduledMeetings.byId(r.meeting_id); if (s) return s.created_by === u.id || (s.participants && s.participants.includes('"' + u.id + '"')); } + return false; + }; + const schedById = new Map(rows.map((m) => [m.id, m])); + const schedByRoom = new Map(rows.map((m) => [m.roomCode, m])); + const unsched = new Map(); + for (const r of R.recordings.forTeam(u.team_id)) { + if (!canSeeRec(r)) continue; + const m = (r.meeting_id && schedById.get(r.meeting_id)) || (r.room && schedByRoom.get(r.room)); + if (m) { m.recordings.push(recDTO(r)); } + else { const k = r.room || r.id; if (!unsched.has(k)) unsched.set(k, []); unsched.get(k).push(r); } + } + const synth = [...unsched.values()].map((list) => { + list.sort((a, b) => a.created_at - b.created_at); const f = list[0]; + return { + id: 'rec-' + (f.room || f.id), roomCode: f.room || '', title: f.title || 'Meeting', description: '', + scheduledAt: f.created_at, groupId: f.group_id || null, + groupName: f.group_id ? ((R.conversations.byId(f.group_id) || {}).name || 'Group') : null, + createdBy: f.created_by, createdByName: f.created_by_name || '', canManage: false, isHost: false, + invited: [], status: 'past', inCall: 0, recordings: list.map(recDTO), + }; + }); + json(res, 200, rows.concat(synth)); +}); + +// Host uploads an in-browser meeting recording (webm). Stored + indexed so it shows under Past meetings. +route('POST', '/api/meetings/recording', (req, res) => { + const u = currentUser(req); + if (!u) return json(res, 401, { error: 'unauthorized' }); + const params = new URLSearchParams(req.url.split('?')[1] || ''); + const room = params.get('room') || ''; + const groupHint = params.get('group') || ''; + const dur = parseInt(params.get('dur') || '0', 10) || null; + const chunks = []; let total = 0, aborted = false; + req.on('data', (c) => { total += c.length; if (total > MAX_REC_BYTES) { aborted = true; req.destroy(); return; } chunks.push(c); }); + req.on('end', () => { + if (aborted) return json(res, 413, { error: 'recording too large' }); + if (!total) return json(res, 400, { error: 'empty recording' }); + const ctx = CALLS.meetingContext(room); + const groupId = ctx.groupId || (groupHint && R.conversations.isMember(groupHint, u.id) ? groupHint : null); + let title = ctx.title; if ((!title || title === 'Meeting') && groupId) { const g = R.conversations.byId(groupId); if (g) title = g.name || 'Group'; } + const id = A.id(); const file = 'm_' + id + '.webm'; + try { + fs.writeFileSync(path.join(REC_DIR, file), Buffer.concat(chunks)); + R.recordings.create({ id, teamId: u.team_id, room, groupId, meetingId: ctx.meetingId, title, kind: 'video', file, mime: 'video/webm', size: total, durationMs: dur, createdBy: u.id, createdByName: u.name || u.email }); + audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'meeting_recording_saved', detail: 'room ' + room }); + json(res, 200, { ok: true, id }); + } catch (e) { json(res, 500, { error: 'could not save recording' }); } + }); + req.on('error', () => { try { res.end(); } catch (e) {} }); +}); + +// Cancel a scheduled meeting (organizer only). +route('POST', '/api/meetings/cancel', async (req, res) => { + const u = currentUser(req); + if (!u) return json(res, 401, { error: 'unauthorized' }); + const { id, scope } = await readBody(req); + const s = id && R.scheduledMeetings.byId(id); + if (!s || s.team_id !== u.team_id) return json(res, 404, { error: 'not found' }); + if (s.created_by !== u.id) return json(res, 403, { error: 'only the organizer can cancel' }); + if (s.cancelled || s.ended_at) return json(res, 400, { error: 'this meeting can no longer be cancelled' }); + if (s.scheduled_at <= Date.now()) return json(res, 400, { error: 'the meeting time has passed — it can no longer be cancelled' }); // #13 + let recur = []; try { recur = JSON.parse(s.recurrence || '[]'); } catch (_) {} + const recips = new Set(); try { JSON.parse(s.participants || '[]').forEach((x) => recips.add(x)); } catch (_) {} + if (s.group_id) for (const mid of R.conversations.members(s.group_id)) recips.add(mid); + if (recur.length && scope === 'one') { + const occ = s.scheduled_at; + const whenLabel = new Date(occ).toLocaleString([], { weekday: 'short', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }); + // Snapshot this cancelled occurrence (own non-recurring row) so it appears under Past meetings. + try { + let sc; do { sc = A.numericCode(6); } while (R.scheduledMeetings.byCode(sc)); + let parts = []; try { parts = JSON.parse(s.participants || '[]'); } catch (_) {} + const sid = A.id(); + R.scheduledMeetings.create({ id: sid, teamId: u.team_id, groupId: s.group_id, roomCode: sc, title: s.title, description: s.description, scheduledAt: occ, createdBy: s.created_by, participants: parts, durationMins: s.duration_mins, recurrence: [] }); + R.scheduledMeetings.cancel(sid, u.team_id); + } catch (_) {} + // Roll the recurring series forward to its next occurrence. + const nxt = nextOccurrence(occ, recur, occ); + if (nxt !== occ) R.scheduledMeetings.reschedule(id, u.team_id, nxt); + const cevt = { type: 'meeting-cancelled', meeting: { id: s.id, title: s.title, by: u.name || u.email, when: whenLabel } }; + recips.forEach((rid) => { if (rid !== u.id) { try { CHAT.pushToUser(rid, cevt); } catch (_) {} } }); + return json(res, 200, { ok: true, skipped: true }); + } + R.scheduledMeetings.cancel(id, u.team_id); // keep it (marked cancelled), don't delete — #12 + const cevt = { type: 'meeting-cancelled', meeting: { id: s.id, title: s.title, by: u.name || u.email } }; + recips.forEach((rid) => { if (rid !== u.id) { try { CHAT.pushToUser(rid, cevt); } catch (_) {} } }); + json(res, 200, { ok: true }); +}); + +// Edit a scheduled meeting (organizer only, while still upcoming). +route('POST', '/api/meetings/update', async (req, res) => { + const u = currentUser(req); + if (!u) return json(res, 401, { error: 'unauthorized' }); + const { id, title, description, scheduledAt, durationMins, participants, recurrence } = await readBody(req); + const s = id && R.scheduledMeetings.byId(id); + if (!s || s.team_id !== u.team_id) return json(res, 404, { error: 'not found' }); + if (s.created_by !== u.id) return json(res, 403, { error: 'only the organizer can edit' }); + if (s.cancelled || s.ended_at) return json(res, 400, { error: 'this meeting can no longer be edited' }); + const t = String(title || '').trim().slice(0, 120); if (!t) return json(res, 400, { error: 'title required' }); + const when = Number(scheduledAt); if (!Number.isFinite(when) || when < Date.now()) return json(res, 400, { error: 'pick a valid future time' }); + const dur = [15, 30, 45, 60, 90, 120].includes(Number(durationMins)) ? Number(durationMins) : (s.duration_mins || 30); + const recur = Array.isArray(recurrence) ? [...new Set(recurrence.map(Number).filter((d) => d >= 0 && d <= 6))] : []; + const invited = [...new Set((Array.isArray(participants) ? participants : []).filter((x) => typeof x === 'string' && x !== u.id && R.users.inTenant(x, u.team_id)))]; + R.scheduledMeetings.update(id, u.team_id, { title: t, description: String(description || '').trim().slice(0, 1000), scheduledAt: when, durationMins: dur, participants: invited, recurrence: recur }); + const label = new Date(when).toLocaleString(); + const evt = { type: 'meeting-invite', meeting: { id, title: t, scheduledAt: when, whenText: label, room: s.room_code, by: u.name || u.email, updated: true } }; + const recips = new Set(invited); if (s.group_id) for (const mid of R.conversations.members(s.group_id)) recips.add(mid); + recips.forEach((rid) => { if (rid !== u.id) { try { CHAT.pushToUser(rid, evt); } catch (_) {} } }); + json(res, 200, { ok: true }); +}); + +// ---------- Polls (within a group conversation) ---------- +// Create a poll: stores it + a message (body = question) and pushes the message to members. +route('POST', '/api/polls', async (req, res) => { + const u = currentUser(req); + if (!u) return json(res, 401, { error: 'unauthorized' }); + const { group, question, options, multi } = await readBody(req); + if (!group || !R.conversations.isMember(group, u.id)) return json(res, 403, { error: 'not a member of this group' }); + const q = String(question || '').trim().slice(0, 300); + const opts = (Array.isArray(options) ? options : []).map((s) => String(s || '').trim()).filter(Boolean).slice(0, 10); + if (!q) return json(res, 400, { error: 'question required' }); + if (opts.length < 2) return json(res, 400, { error: 'at least two options required' }); + const pollId = A.id(); const msgId = A.id(); + R.messages.send({ id: msgId, teamId: u.team_id, senderId: u.id, recipientId: '', body: q, conversationId: group }); + R.polls.create({ id: pollId, teamId: u.team_id, conversationId: group, messageId: msgId, question: q, options: opts, multi: !!multi, createdBy: u.id }); + R.messages.setPoll(msgId, pollId); + audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'poll_created', detail: q }); + const names = namesFor(u.team_id); + for (const mid of R.conversations.members(group)) { + try { const dto = buildMsgDTO(R.messages.byId(msgId), names, mid); dto.fromName = u.name || u.email; CHAT.pushToUser(mid, { type: 'chat-message', message: dto }); } catch (_) {} + } + json(res, 200, buildPollDTO(R.polls.byId(pollId), u.id)); +}); + +// Vote on a poll option (toggle). Single-choice replaces the prior vote; multi toggles. +route('POST', '/api/polls/vote', async (req, res) => { + const u = currentUser(req); + if (!u) return json(res, 401, { error: 'unauthorized' }); + const { pollId, optionIdx } = await readBody(req); + const p = pollId && R.polls.byId(pollId); + if (!p || p.team_id !== u.team_id) return json(res, 404, { error: 'poll not found' }); + if (!R.conversations.isMember(p.conversation_id, u.id)) return json(res, 403, { error: 'not a member' }); + if (p.closed) return json(res, 400, { error: 'poll is closed' }); + let opts = []; try { opts = JSON.parse(p.options); } catch {} + const idx = Number(optionIdx); + if (!Number.isInteger(idx) || idx < 0 || idx >= opts.length) return json(res, 400, { error: 'invalid option' }); + if (p.multi) { + if (R.pollVotes.hasVoted(p.id, u.id, idx)) R.pollVotes.remove(p.id, u.id, idx); else R.pollVotes.add(p.id, u.id, idx); + } else { + const had = R.pollVotes.hasVoted(p.id, u.id, idx); + R.pollVotes.clearUser(p.id, u.id); + if (!had) R.pollVotes.add(p.id, u.id, idx); + } + for (const mid of R.conversations.members(p.conversation_id)) { + try { CHAT.pushToUser(mid, { type: 'poll-update', poll: buildPollDTO(p, mid), messageId: p.message_id, conversationId: p.conversation_id }); } catch (_) {} + } + json(res, 200, buildPollDTO(p, u.id)); +}); + +// Close a poll (creator only) — no more votes accepted. +route('POST', '/api/polls/close', async (req, res) => { + const u = currentUser(req); + if (!u) return json(res, 401, { error: 'unauthorized' }); + const { pollId } = await readBody(req); + const p = pollId && R.polls.byId(pollId); + if (!p || p.team_id !== u.team_id) return json(res, 404, { error: 'poll not found' }); + if (p.created_by !== u.id) return json(res, 403, { error: 'only the poll creator can close it' }); + R.polls.close(p.id); + const fresh = R.polls.byId(p.id); + for (const mid of R.conversations.members(p.conversation_id)) { + try { CHAT.pushToUser(mid, { type: 'poll-update', poll: buildPollDTO(fresh, mid), messageId: p.message_id, conversationId: p.conversation_id }); } catch (_) {} + } + json(res, 200, buildPollDTO(fresh, u.id)); +}); + +// Send a message (persists + live-pushes to the recipient and the sender's other tabs). +route('POST', '/api/messages', async (req, res) => { + const u = currentUser(req); + if (!u) return json(res, 401, { error: 'unauthorized' }); + const { to, group, body, replyTo, attachmentId, mentions } = await readBody(req); + const text = String(body || '').trim(); + if (!text && !attachmentId) return json(res, 400, { error: 'message or attachment required' }); + if (text.length > MSG_MAX) return json(res, 400, { error: 'message too long' }); + if (attachmentId) { + const a = R.attachments.byId(attachmentId); + if (!a || a.team_id !== u.team_id || a.uploader_id !== u.id) return json(res, 400, { error: 'invalid attachment' }); + } + const id = A.id(); + if (group) { + if (!R.conversations.isMember(group, u.id)) return json(res, 403, { error: 'not a member of this group' }); + // Validate mentions: keep only the literal "everyone" and ids that are actual members. + let mlist = []; + if (Array.isArray(mentions)) { + const memberSet = new Set(R.conversations.members(group)); + mlist = mentions.filter((x) => x === 'everyone' || memberSet.has(x)); + mlist = [...new Set(mlist)]; + } + R.messages.send({ id, teamId: u.team_id, senderId: u.id, recipientId: '', body: text, replyTo: replyTo || null, attachmentId: attachmentId || null, conversationId: group, mentions: mlist }); + const dto = buildMsgDTO(R.messages.byId(id), namesFor(u.team_id), u.id); + dto.fromName = u.name || u.email; + const push = { type: 'chat-message', message: dto }; + for (const mid of R.conversations.members(group)) { try { CHAT.pushToUser(mid, push); } catch (_) {} } // includes sender's other tabs + return json(res, 200, dto); + } + if (!to) return json(res, 400, { error: 'to or group required' }); + if (!R.users.inTenant(to, u.team_id)) return json(res, 404, { error: 'no such contact' }); + R.messages.send({ id, teamId: u.team_id, senderId: u.id, recipientId: to, body: text, replyTo: replyTo || null, attachmentId: attachmentId || null }); + const dto = buildMsgDTO(R.messages.byId(id), namesFor(u.team_id), u.id); + const push = { type: 'chat-message', message: { ...dto, fromName: u.name || u.email } }; + try { CHAT.pushToUser(to, push); } catch (_) {} + try { CHAT.pushToUser(u.id, push); } catch (_) {} // sync the sender's other devices + json(res, 200, dto); +}); + +route('POST', '/api/messages/read', async (req, res) => { + const u = currentUser(req); + if (!u) return json(res, 401, { error: 'unauthorized' }); + const { with: other, group } = await readBody(req); + if (group) { + if (R.conversations.isMember(group, u.id)) { + R.conversations.markRead(group, u.id); + const evt = { type: 'group-read', group, by: u.id, byName: (u.name || u.email), at: now() }; + for (const mid of R.conversations.members(group)) { if (mid !== u.id) { try { CHAT.pushToUser(mid, evt); } catch (_) {} } } + } + return json(res, 200, { ok: true }); + } + if (!other) return json(res, 400, { error: 'with or group required' }); + R.messages.markRead(u.team_id, u.id, other); + try { CHAT.pushToUser(other, { type: 'chat-read', by: u.id }); } catch (_) {} + json(res, 200, { ok: true }); +}); + +// Toggle an emoji reaction on a message (live-pushed to the other party). +route('POST', '/api/messages/react', async (req, res) => { + const u = currentUser(req); + if (!u) return json(res, 401, { error: 'unauthorized' }); + const { messageId, emoji } = await readBody(req); + if (!messageId || !emoji) return json(res, 400, { error: 'messageId and emoji required' }); + const msg = R.messages.byId(messageId); + const participant = msg && msg.team_id === u.team_id && ( + msg.conversation_id ? R.conversations.isMember(msg.conversation_id, u.id) + : (msg.sender_id === u.id || msg.recipient_id === u.id)); + if (!participant) return json(res, 404, { error: 'no such message' }); + const e = String(emoji).slice(0, 16); + const added = R.reactions.toggle(messageId, u.id, e); + const names = namesFor(u.team_id); + // Push the full, recomputed reaction set for this message (per-recipient perspective). Extra + // fields (by/emoji/added/owner/convId) let the message owner show a "reacted to you" notification. + const meta = { by: u.name || u.email, byId: u.id, emoji: e, added, owner: msg.sender_id, convId: msg.conversation_id || null }; + if (msg.conversation_id) { + for (const mid of R.conversations.members(msg.conversation_id)) { + try { CHAT.pushToUser(mid, { type: 'chat-reaction', messageId, reactions: reactionsForMessage(messageId, mid, names), ...meta }); } catch (_) {} + } + } else { + const other = msg.sender_id === u.id ? msg.recipient_id : msg.sender_id; + try { CHAT.pushToUser(other, { type: 'chat-reaction', messageId, reactions: reactionsForMessage(messageId, other, names), ...meta }); } catch (_) {} + try { CHAT.pushToUser(u.id, { type: 'chat-reaction', messageId, reactions: reactionsForMessage(messageId, u.id, names), ...meta }); } catch (_) {} + } + json(res, 200, { ok: true, messageId, added, reactions: reactionsForMessage(messageId, u.id, names) }); +}); + +// Upload a chat attachment (raw body; filename in X-Filename, mime in Content-Type). +// Returns the attachment id to attach to a subsequent /api/messages send. +route('POST', '/api/messages/upload', async (req, res) => { + const u = currentUser(req); + if (!u) return json(res, 401, { error: 'unauthorized' }); + const name = decodeURIComponent(req.headers['x-filename'] || 'file').slice(0, 200); + const mime = (req.headers['content-type'] || 'application/octet-stream').split(';')[0].trim(); + const chunks = []; let total = 0, aborted = false; + req.on('data', (c) => { total += c.length; if (total > MAX_FILE_BYTES) { aborted = true; req.destroy(); return; } chunks.push(c); }); + req.on('end', () => { + if (aborted) return json(res, 413, { error: 'file too large (max 25 MB)' }); + if (!total) return json(res, 400, { error: 'empty file' }); + const id = A.id(); + try { fs.writeFileSync(path.join(UPLOADS_DIR, id), Buffer.concat(chunks)); } + catch (e) { return json(res, 500, { error: 'could not store file' }); } + R.attachments.create({ id, teamId: u.team_id, uploaderId: u.id, name, mime, size: total }); + json(res, 200, { id, name, mime, size: total }); + }); + req.on('error', () => { try { res.end(); } catch (e) {} }); +}); + // API versioning: alias every /api/* route under /api/v1/* — a frozen contract for // native desktop/mobile clients. The web app keeps using the unversioned paths, and // both share the same handlers. (/sso is a browser redirect, intentionally unversioned.) diff --git a/server/session.js b/server/session.js index 2a4879a..8103373 100644 --- a/server/session.js +++ b/server/session.js @@ -1,5 +1,6 @@ // Session/auth helpers: resolve the current user from the cookie, write audit rows. const R = require('./repos'); +const A = require('./auth'); const { parseCookies, now } = require('./lib'); function audit(entry) { @@ -35,4 +36,19 @@ function currentUser(req, { requireMfa = true } = {}) { return { ...u, _session: s }; } -module.exports = { audit, currentUser, tokenFromReq }; +// Resolve a third-party API key from `X-API-Key` or `Authorization: Bearer bzc_...`. +// Returns { id, teamId, scopes:[], name } or null. Keys are prefixed `bzc_` and stored hashed. +function apiKeyFromReq(req) { + let raw = req.headers && req.headers['x-api-key']; + if (!raw) { + const h = req.headers && (req.headers.authorization || req.headers.Authorization); + if (h && /^Bearer\s+bzc_/i.test(h)) raw = h.replace(/^Bearer\s+/i, '').trim(); + } + if (!raw || !/^bzc_/.test(raw)) return null; + const row = R.apiKeys.byHash(A.hashToken(raw)); + if (!row || row.revoked) return null; + return { id: row.id, teamId: row.team_id, scopes: String(row.scopes || '').split(',').map((s) => s.trim()).filter(Boolean), name: row.name }; +} +function keyHasScope(key, scope) { return !!key && (key.scopes.includes(scope) || key.scopes.includes('*')); } + +module.exports = { audit, currentUser, tokenFromReq, apiKeyFromReq, keyHasScope }; diff --git a/server/signaling.js b/server/signaling.js index 4f4bcb5..ea7d365 100644 --- a/server/signaling.js +++ b/server/signaling.js @@ -5,7 +5,9 @@ const R = require('./repos'); const A = require('./auth'); const { currentUser, audit } = require('./session'); -const { onlineAgents, liveSessions, pendingShares } = require('./presence'); +const { onlineAgents, liveSessions, pendingShares, meetingRooms, roomToDmCall, roomHost, transcriptBuffers, transcriptSubs } = require('./presence'); +const W = require('./webhooks'); +const CHAT = require('./chat'); function onConnection(ws, req) { const hb = setInterval(() => { @@ -20,6 +22,132 @@ function onConnection(ws, req) { function handle(ws, m, req) { switch (m.type) { + // --- Logged-in user registers this socket for live chat delivery --- + case 'chat-hello': { + const u = currentUser(req); // identity from the cookie/Bearer on the WS upgrade + if (!u) return ws.send(JSON.stringify({ type: 'error', message: 'unauthorized' })); + ws._chatUserId = u.id; ws._chatTeamId = u.team_id; + CHAT.register(u.id, ws); + ws.send(JSON.stringify({ type: 'chat-ready' })); + break; + } + // Recipient's client acknowledges a DM was delivered → mark it + tell the sender. + case 'chat-delivered': { + if (!ws._chatUserId || !m.id) break; + const msg = R.messages.byId(m.id); + if (!msg || msg.conversation_id || msg.team_id !== ws._chatTeamId) break; // DMs only + if (msg.recipient_id !== ws._chatUserId) break; // only the recipient can ack + if (!msg.delivered_at) { R.messages.markDelivered(m.id); try { CHAT.pushToUser(msg.sender_id, { type: 'chat-delivered', id: m.id }); } catch (_) {} } + break; + } + // --- Meetings (mesh): create a room, join by code, relay SDP/ICE peer-to-peer --- + case 'meeting-create': { + let code; do { code = A.numericCode(6); } while (meetingRooms.has(code)); + meetingRooms.set(code, new Map()); + const cu = currentUser(req); if (cu) roomHost.set(code, cu.id); // ad-hoc meeting: creator = host + ws.send(JSON.stringify({ type: 'meeting-created', room: code })); + break; + } + case 'meeting-join': { + const room = String(m.room || '').trim(); + let peers = meetingRooms.get(room); + // A scheduled meeting's room is created lazily on first join (its code lives in the DB). + if (!peers) { + const sched = R.scheduledMeetings.byCode(room); + if (sched && !sched.ended_at) { peers = new Map(); meetingRooms.set(room, peers); } + } + if (!peers) return ws.send(JSON.stringify({ type: 'error', message: 'Meeting not found' })); + const peerId = A.token(6); + const name = String(m.name || 'Guest').slice(0, 60); + ws.kind = 'meeting'; ws._meetingRoom = room; ws._peerId = peerId; ws._peerName = name; + // Host = the meeting's creator. roomHost is set on call/meeting creation; scheduled meetings fall back to created_by. + let hostUserId = roomHost.get(room); + 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 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 })) })); + // …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 }); + 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; + } + case 'meeting-signal': { + const peers = ws._meetingRoom && meetingRooms.get(ws._meetingRoom); + if (!peers) return; + const target = peers.get(m.to); + if (target && target.ws.readyState === 1) target.ws.send(JSON.stringify({ type: 'meeting-signal', from: ws._peerId, data: m.data })); + break; + } + // Relay a peer's mic/cam state to everyone else in the room (for the tile mute icon). + case 'meeting-state': { + const peers = ws._meetingRoom && meetingRooms.get(ws._meetingRoom); + if (!peers) return; + for (const [id, p] of peers) { if (id !== ws._peerId && p.ws.readyState === 1) p.ws.send(JSON.stringify({ type: 'meeting-peer-state', peerId: ws._peerId, muted: !!m.muted, camOff: !!m.camOff })); } + break; + } + // Relay a peer's screen-share on/off to everyone else (for the tile badge + single-share rule). + case 'meeting-screen': { + const peers = ws._meetingRoom && meetingRooms.get(ws._meetingRoom); + if (!peers) return; + for (const [id, p] of peers) { if (id !== ws._peerId && p.ws.readyState === 1) p.ws.send(JSON.stringify({ type: 'meeting-peer-screen', from: ws._peerId, on: !!m.on })); } + break; + } + // Host: set whether multiple people may share their screen at once. + case 'meeting-sharemode': { + const peers = ws._meetingRoom && meetingRooms.get(ws._meetingRoom); + if (!peers) return; + for (const [id, p] of peers) { if (id !== ws._peerId && p.ws.readyState === 1) p.ws.send(JSON.stringify({ type: 'meeting-sharemode', multi: !!m.multi })); } + break; + } + // Host starts/stops recording → tell everyone so they see (and hear) the "being recorded" notice. + case 'meeting-recording': { + const peers = ws._meetingRoom && meetingRooms.get(ws._meetingRoom); + if (!peers) return; + for (const [id, p] of peers) { if (id !== ws._peerId && p.ws.readyState === 1) p.ws.send(JSON.stringify({ type: 'meeting-recording', on: !!m.on, by: ws._peerName || 'The host' })); } + break; + } + // A participant subscribes/unsubscribes to a transcript copy. While ≥1 subscriber, EVERY client + // transcribes its own mic (full conversation); each subscriber gets their own private copy. + // Unsubscribing only drops YOUR copy — it never stops anyone else's. + case 'meeting-transcribe': { + const room = ws._meetingRoom; const peers = room && meetingRooms.get(room); + if (!peers) return; const uid = ws._meetingUserId; if (!uid) return; + let subs = transcriptSubs.get(room); if (!subs) { subs = new Set(); transcriptSubs.set(room, subs); } + if (m.on) subs.add(uid); else { try { require('./calls').finalizeTranscript(room, uid); } catch (_) {} } // finalize writes + removes the sub + const active = subs.size > 0; + for (const [, p] of peers) { if (p.ws.readyState === 1) p.ws.send(JSON.stringify({ type: 'meeting-transcribe-state', active })); } + break; + } + // A participant's recognized speech segment → appended to the room's shared transcript buffer. + case 'meeting-transcript': { + const room = ws._meetingRoom; if (!room || !meetingRooms.get(room)) return; + const text = String(m.text || '').slice(0, 1000).trim(); if (!text) return; + let buf = transcriptBuffers.get(room); if (!buf) { buf = []; transcriptBuffers.set(room, buf); } + buf.push({ t: Date.now(), speaker: ws._peerName || 'Guest', text }); + if (buf.length > 8000) buf.shift(); + break; + } + // Host: mute everyone else in the room. + case 'meeting-muteall': { + const peers = ws._meetingRoom && meetingRooms.get(ws._meetingRoom); + if (!peers) return; + for (const [id, p] of peers) { if (id !== ws._peerId && p.ws.readyState === 1) p.ws.send(JSON.stringify({ type: 'meeting-muteall', by: ws._peerId })); } + break; + } + // Host: transfer host to another peer (broadcast the new host to the room). + case 'meeting-host': { + const peers = ws._meetingRoom && meetingRooms.get(ws._meetingRoom); + if (!peers || !m.to) return; + for (const [, p] of peers) { if (p.ws.readyState === 1) p.ws.send(JSON.stringify({ type: 'meeting-host', hostPeerId: m.to })); } + break; + } + case 'meeting-leave': { + leaveMeeting(ws); + break; + } // --- Agent comes online --- case 'agent-hello': { const machine = R.machines.byEnrollToken(m.enrollToken); @@ -61,6 +189,7 @@ function handle(ws, m, req) { try { R.sessionsLog.create({ id: m.sessionId, tenantId: sess.machine.team_id, agentEmail: sess.user.email, agentName: sess.agentName || sess.user.email, ticket: sess.ticket || null }); } catch (e) { /* duplicate consent */ } + try { W.emit('session.started', sess.machine.team_id, { sessionId: m.sessionId, agent_email: sess.user.email, agent_name: sess.agentName || sess.user.email, ticket: sess.ticket || null, started_at: Date.now() }); } catch (_) {} sess.viewerWs.send(JSON.stringify({ type: 'session-ready', sessionId: m.sessionId })); sess.agentWs.send(JSON.stringify({ type: 'start-stream', sessionId: m.sessionId })); } else { @@ -133,26 +262,16 @@ function handle(ws, m, req) { } } -function notifyBizGaze(sessionId) { - const url = process.env.BIZGAZE_WEBHOOK_URL; - if (!url) return; - try { - const row = R.sessionsLog.byId(sessionId); - if (!row) return; - const body = JSON.stringify({ event: 'session.ended', sessionId: row.id, agent_email: row.agent_email, - agent_name: row.agent_name, ticket: row.ticket, started_at: row.started_at, ended_at: row.ended_at, - duration_ms: row.ended_at ? row.ended_at - row.started_at : null }); - const crypto = require('crypto'); - const sig = process.env.SSO_SECRET ? crypto.createHmac('sha256', process.env.SSO_SECRET).update(body).digest('base64url') : ''; - fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-BizGaze-Signature': sig }, body }).catch(()=>{}); - } catch (e) {} -} - function endSession(sessionId, reason) { const sess = liveSessions.get(sessionId); if (!sess) return; try { R.sessionsLog.end(sessionId); } catch (e) {} - notifyBizGaze(sessionId); + try { + const row = R.sessionsLog.byId(sessionId); + if (row) W.emit('session.ended', sess.machine.team_id, { sessionId: row.id, agent_email: row.agent_email, + agent_name: row.agent_name, ticket: row.ticket, started_at: row.started_at, ended_at: row.ended_at, + duration_ms: row.ended_at ? row.ended_at - row.started_at : null }); + } catch (e) {} audit({ team_id: sess.machine.team_id, user_id: sess.user.id, user_email: sess.user.email, machine_id: sess.machine.id, machine_name: sess.machine.name, action: 'session_ended', detail: sess.ticket ? 'Ticket ' + sess.ticket : (sess.machine.id ? null : 'Direct session') }); [sess.agentWs, sess.viewerWs].forEach((p) => { if (p && p.readyState === 1) p.send(JSON.stringify({ type: 'session-ended', sessionId, reason: reason || null })); @@ -160,7 +279,36 @@ function endSession(sessionId, reason) { liveSessions.delete(sessionId); } +function leaveMeeting(ws) { + const room = ws._meetingRoom; + if (!room) return; + const peers = meetingRooms.get(room); + ws._meetingRoom = null; + const pid = ws._peerId; + if (!peers) return; + try { require('./calls').finalizeTranscript(room, ws._meetingUserId); } catch (_) {} // save THIS user's transcript + peers.delete(pid); + // 1:1 call: when either party leaves, end it for everyone (a DM call has no "remaining" call). + if (roomToDmCall.has(room)) { + for (const [, p] of peers) { if (p.ws.readyState === 1) { try { p.ws.send(JSON.stringify({ type: 'meeting-ended' })); } catch (_) {} p._meetingRoom = null; } } + meetingRooms.delete(room); + try { require('./calls').finalizeTranscript(room); } catch (_) {} // any remaining buffers (safety) + roomHost.delete(room); + try { require('./calls').endCallByRoom(room); } catch (_) {} + return; + } + for (const [, p] of peers) { if (p.ws.readyState === 1) p.ws.send(JSON.stringify({ type: 'meeting-peer-left', peerId: pid })); } + if (peers.size === 0) { + meetingRooms.delete(room); + try { require('./calls').finalizeTranscript(room); } catch (_) {} // before endCallByRoom clears the maps + roomHost.delete(room); + try { require('./calls').endCallByRoom(room); } catch (_) {} + } +} + function cleanup(ws) { + CHAT.unregister(ws); + leaveMeeting(ws); if (ws.kind === 'agent' && ws.machineId) onlineAgents.delete(ws.machineId); if (ws.kind === 'sharer' && ws.shareCode) pendingShares.delete(ws.shareCode); if (ws.sessionId) { diff --git a/server/static.js b/server/static.js index 3f09342..b2954f2 100644 --- a/server/static.js +++ b/server/static.js @@ -5,7 +5,7 @@ const path = require('path'); const R = require('./repos'); const { json } = require('./lib'); const { currentUser } = require('./session'); -const { PUBLIC_DIR, REC_DIR, TRANS_DIR } = require('./config'); +const { PUBLIC_DIR, REC_DIR, TRANS_DIR, UPLOADS_DIR } = require('./config'); const MIME = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.svg': 'image/svg+xml', '.ico': 'image/x-icon' }; @@ -19,11 +19,21 @@ function serveStatic(req, res) { if (p === '/connect') p = '/connect.html'; const fp = path.join(PUBLIC_DIR, path.normalize(p)); if (!fp.startsWith(PUBLIC_DIR)) return json(res, 403, { error: 'forbidden' }); - fs.readFile(fp, (err, data) => { - if (err) return json(res, 404, { error: 'not found' }); + // ETag + revalidation: the browser keeps the file cached and we answer repeat loads with a + // tiny 304 (no re-download) when nothing changed — fast reloads, but always fresh on edits. + fs.stat(fp, (serr, st) => { + if (serr || !st.isFile()) return json(res, 404, { error: 'not found' }); const ct = MIME[path.extname(fp)] || 'application/octet-stream'; - res.writeHead(200, { 'Content-Type': ct, 'Cache-Control': 'no-cache' }); - res.end(data); + const etag = '"' + st.size.toString(16) + '-' + Math.round(st.mtimeMs).toString(16) + '"'; + if (req.headers['if-none-match'] === etag) { + res.writeHead(304, { ETag: etag, 'Cache-Control': 'no-cache' }); + return res.end(); + } + fs.readFile(fp, (err, data) => { + if (err) return json(res, 404, { error: 'not found' }); + res.writeHead(200, { 'Content-Type': ct, 'Cache-Control': 'no-cache', ETag: etag, 'Content-Length': st.size }); + res.end(data); + }); }); } @@ -66,6 +76,63 @@ function handleGet(req, res) { rs.pipe(res); }); } + // Meeting recordings & transcripts (/mrec/). Visible to the creator, group members, or those + // who can see the scheduled meeting it belongs to. + if (pathOnly.startsWith('/mrec/')) { + const u = currentUser(req); + if (!u) return json(res, 401, { error: 'unauthorized' }); + const id = path.basename(decodeURIComponent(pathOnly)); + const r = R.recordings.byId(id); + if (!r || r.team_id !== u.team_id || !r.file) return json(res, 404, { error: 'not found' }); + let allowed = r.created_by === u.id; + if (r.kind === 'transcript') allowed = r.created_by === u.id; // transcripts are private to their owner + else { + if (!allowed && r.group_id) allowed = R.conversations.isMember(r.group_id, u.id); + if (!allowed && r.meeting_id) { const s = R.scheduledMeetings.byId(r.meeting_id); if (s) allowed = s.created_by === u.id || (s.participants && s.participants.includes('"' + u.id + '"')); } + } + if (!allowed) return json(res, 403, { error: 'forbidden' }); + const isVideo = r.kind === 'video'; + const dir = isVideo ? REC_DIR : TRANS_DIR; + const fp = path.join(dir, r.file); + if (!fp.startsWith(dir)) return json(res, 403, { error: 'forbidden' }); + const ext = isVideo ? 'webm' : 'txt'; + const fname = String(r.title || 'meeting').replace(/[^a-z0-9 _-]/gi, '').trim().slice(0, 40) || 'meeting'; + return fs.stat(fp, (err, st) => { + if (err) return json(res, 404, { error: 'not found' }); + res.writeHead(200, { 'Content-Type': r.mime || (isVideo ? 'video/webm' : 'text/plain; charset=utf-8'), 'Content-Length': st.size, 'Content-Disposition': 'attachment; filename="' + fname + '-' + (isVideo ? 'recording' : 'transcript') + '.' + ext + '"', 'Cache-Control': 'no-store', 'Accept-Ranges': 'bytes' }); + const rs = fs.createReadStream(fp); + rs.on('error', () => { try { res.destroy(); } catch (e) {} }); + rs.pipe(res); + }); + } + if (pathOnly.startsWith('/files/')) { + const u = currentUser(req); + if (!u) return json(res, 401, { error: 'unauthorized' }); + const id = path.basename(decodeURIComponent(pathOnly)); + const a = R.attachments.byId(id); + if (!a || a.team_id !== u.team_id) return json(res, 404, { error: 'not found' }); + // Authorize: the uploader, a participant of the message carrying this attachment, + // or a member of the group that uses this attachment as its image. + const msg = R.messages.byAttachment(id); + const avatarGroup = R.conversations.byAvatar(id); + const allowed = a.uploader_id === u.id + || (avatarGroup && R.conversations.isMember(avatarGroup.id, u.id)) + || (msg && ( + msg.conversation_id ? R.conversations.isMember(msg.conversation_id, u.id) + : (msg.sender_id === u.id || msg.recipient_id === u.id))); + if (!allowed) return json(res, 403, { error: 'forbidden' }); + const fp = path.join(UPLOADS_DIR, id); + if (!fp.startsWith(UPLOADS_DIR)) return json(res, 403, { error: 'forbidden' }); + const isImage = /^image\//.test(a.mime || ''); + const safeName = String(a.name || 'file').replace(/[\r\n"]/g, ''); + return fs.stat(fp, (err, st) => { + if (err) return json(res, 404, { error: 'not found' }); + res.writeHead(200, { 'Content-Type': a.mime || 'application/octet-stream', 'Content-Length': st.size, 'Content-Disposition': (isImage ? 'inline' : 'attachment') + '; filename="' + safeName + '"', 'Cache-Control': 'private, max-age=86400' }); + const rs = fs.createReadStream(fp); + rs.on('error', () => { try { res.destroy(); } catch (e) {} }); + rs.pipe(res); + }); + } return serveStatic(req, res); } diff --git a/server/test/e2e.js b/server/test/e2e.js index ad09013..650b7c5 100644 --- a/server/test/e2e.js +++ b/server/test/e2e.js @@ -18,6 +18,8 @@ process.env.HTTPS_PORT = 8444; // avoid clashing with a running dev server on 84 const { server } = require('../server'); const A = require('../auth'); const WebSocket = require('ws'); +const http = require('http'); +const crypto = require('crypto'); const BASE = `http://localhost:${PORT}`; let passed = 0, failed = 0; @@ -64,6 +66,12 @@ function nextMsg(ws, type, timeout = 3000) { await wait(300); // let server bind console.log('E2E backend tests:'); + // Local receiver to capture outbound webhook deliveries. + const webhookHits = []; + const hookSrv = http.createServer((rq, rs) => { let b = ''; rq.on('data', (c) => (b += c)); rq.on('end', () => { webhookHits.push({ sig: rq.headers['x-bizgaze-signature'], body: b }); rs.writeHead(200); rs.end('ok'); }); }); + await new Promise((r) => hookSrv.listen(8077, r)); + const HOOK_URL = 'http://localhost:8077/hook'; + // 1. Register (first user becomes admin) const email = 'tech@example.com'; const reg = await call('/api/register', { email, password: 'supersecret', teamName: 'Acme IT' }); @@ -78,6 +86,258 @@ function nextMsg(ws, type, timeout = 3000) { const me = await get('/api/me', cookie); check('me works after login, role=admin', me.status === 200 && me.data.role === 'admin'); + // 3b. Native client path: access+refresh tokens, refresh exchange (rotated), Bearer auth + check('login returns access + refresh tokens', !!login.data.token && !!login.data.refreshToken); + const refreshed = await call('/api/v1/auth/refresh', { refreshToken: login.data.refreshToken }); + check('refresh issues a new access token', refreshed.status === 200 && !!refreshed.data.token && !!refreshed.data.refreshToken); + const meBearer = await fetch(BASE + '/api/v1/me', { headers: { Authorization: 'Bearer ' + refreshed.data.token } }); + check('new access token authorizes /api/v1/me (Bearer)', meBearer.status === 200); + const reuse = await call('/api/v1/auth/refresh', { refreshToken: login.data.refreshToken }); + check('rotated (old) refresh token is rejected', reuse.status === 401); + + // 3c. API keys (machine-to-machine integration), scoped + revocable + const mkKey = await call('/api/v1/keys', { name: 'ci', scopes: ['report:read'] }, cookie); + check('admin creates API key (bzc_ prefix)', mkKey.status === 200 && /^bzc_/.test(mkKey.data.key || '')); + const apiKey = mkKey.data.key; + const repKey = await fetch(BASE + '/api/v1/report', { headers: { 'X-API-Key': apiKey } }); + check('API key with report:read reads /api/v1/report', repKey.status === 200); + const repNone = await fetch(BASE + '/api/v1/report'); + check('no credential -> /api/v1/report 401', repNone.status === 401); + const audKey = await fetch(BASE + '/api/v1/audit', { headers: { 'X-API-Key': apiKey } }); + check('report-only key cannot read audit (scope enforced)', audKey.status === 401); + const revKey = await call('/api/v1/keys/revoke', { id: mkKey.data.id }, cookie); + check('admin revokes API key', revKey.status === 200); + const repRevoked = await fetch(BASE + '/api/v1/report', { headers: { 'X-API-Key': apiKey } }); + check('revoked API key -> 401', repRevoked.status === 401); + + // 3d. Register a webhook (delivery is asserted after the session flow below) + const mkHook = await call('/api/v1/webhooks', { url: HOOK_URL, events: ['*'] }, cookie); + check('admin registers a webhook', mkHook.status === 200 && !!mkHook.data.secret); + const hookSecret = mkHook.data.secret; + + // 3e. Chat (persistent 1:1 messaging) — two users in the same team + const adminId = me.data.id; + await call('/api/v1/users', { email: 'bob@example.com', password: 'supersecret', name: 'Bob' }, cookie); + const bobLogin = await call('/api/v1/login', { email: 'bob@example.com', password: 'supersecret' }); + const bobCookie = bobLogin.cookie; + const contacts = await get('/api/v1/messages/contacts', cookie); + check('contacts list includes the other user', contacts.status === 200 && contacts.data.some((c) => c.email === 'bob@example.com')); + const bobId = (contacts.data.find((c) => c.email === 'bob@example.com') || {}).id; + const bobWs = new WebSocket(`ws://localhost:${PORT}/ws`, { headers: { Cookie: bobCookie } }); + bobWs.q = []; bobWs.on('message', (d) => bobWs.q.push(JSON.parse(d))); + await new Promise((r) => bobWs.on('open', r)); + bobWs.send(JSON.stringify({ type: 'chat-hello' })); + await nextMsg(bobWs, 'chat-ready'); + check('chat-hello -> chat-ready', true); + const sent = await call('/api/v1/messages', { to: bobId, body: 'hello bob' }, cookie); + check('message sent', sent.status === 200 && !!sent.data.id); + const pushed = await nextMsg(bobWs, 'chat-message'); + check('recipient receives the message live over WS', pushed.message && pushed.message.body === 'hello bob'); + const bobConvos = await get('/api/v1/messages/conversations', bobCookie); + check('recipient conversation shows unread', bobConvos.data.some((c) => c.contactId === adminId && c.unread >= 1)); + const bobThread = await get('/api/v1/messages/thread?with=' + adminId, bobCookie); + check('thread returns the message', bobThread.status === 200 && bobThread.data.some((m) => m.body === 'hello bob')); + const bobConvos2 = await get('/api/v1/messages/conversations', bobCookie); + check('reading the thread clears unread', !bobConvos2.data.some((c) => c.contactId === adminId && c.unread >= 1)); + // read receipt: after bob read the thread, admin's sent message shows read_at + const adminTh = await get('/api/v1/messages/thread?with=' + bobId, cookie); + const seenMsg = adminTh.data.find((x) => x.body === 'hello bob'); + check('read receipt: sent message marked read after recipient reads', !!seenMsg && !!seenMsg.read_at); + // reply / quote + const reply = await call('/api/v1/messages', { to: bobId, body: 'replying to you', replyTo: sent.data.id }, cookie); + check('reply accepts replyTo', reply.status === 200 && reply.data.reply_to === sent.data.id); + const adminThread = await get('/api/v1/messages/thread?with=' + bobId, cookie); + check('thread carries the quoted preview', adminThread.data.some((x) => x.reply && x.reply.body === 'hello bob')); + // reactions + const react1 = await call('/api/v1/messages/react', { messageId: sent.data.id, emoji: '👍' }, cookie); + check('reaction toggles on', react1.status === 200 && react1.data.added === true); + const rpush = await nextMsg(bobWs, 'chat-reaction'); + check('reaction pushed live (full set, with who)', rpush.messageId === sent.data.id && Array.isArray(rpush.reactions) && rpush.reactions.some((r) => r.emoji === '👍' && r.count === 1 && Array.isArray(r.who) && r.who.length === 1)); + const th2 = await get('/api/v1/messages/thread?with=' + bobId, cookie); + const rmsg = th2.data.find((x) => x.id === sent.data.id); + check('thread aggregates the reaction', !!rmsg && rmsg.reactions.some((r) => r.emoji === '👍' && r.count === 1 && r.mine === true && r.who.length === 1)); + // one reaction per user: a different emoji REPLACES the previous one (no stacking) + const react1b = await call('/api/v1/messages/react', { messageId: sent.data.id, emoji: '❤️' }, cookie); + check('switching emoji replaces (one reaction per user)', react1b.data.added === true && react1b.data.reactions.length === 1 && react1b.data.reactions[0].emoji === '❤️'); + const react2 = await call('/api/v1/messages/react', { messageId: sent.data.id, emoji: '❤️' }, cookie); + check('reaction toggles off', react2.data.added === false && react2.data.reactions.length === 0); + // file sharing + const up = await fetch(BASE + '/api/v1/messages/upload', { method: 'POST', headers: { Cookie: cookie, 'Content-Type': 'text/plain', 'X-Filename': encodeURIComponent('note.txt') }, body: 'hello file' }); + const upd = await up.json(); + check('file upload returns an id', up.status === 200 && !!upd.id); + const fmsg = await call('/api/v1/messages', { to: bobId, attachmentId: upd.id }, cookie); + check('message with attachment (no text) accepted', fmsg.status === 200 && fmsg.data.attachment && fmsg.data.attachment.id === upd.id); + const dl = await fetch(BASE + '/files/' + upd.id, { headers: { Cookie: cookie } }); + const dlBody = await dl.text(); + check('attachment downloads for a participant', dl.status === 200 && dlBody === 'hello file'); + const dlNo = await fetch(BASE + '/files/' + upd.id); + check('attachment download denied without auth', dlNo.status === 401); + + // 3g. Group chat + const mkGroup = await call('/api/v1/groups', { name: 'Team Huddle', memberIds: [bobId] }, cookie); + check('group created with members', mkGroup.status === 200 && !!mkGroup.data.id && mkGroup.data.members === 2); + const gid = mkGroup.data.id; + bobWs.q.length = 0; // drain prior pushes so we catch the group message + const gsend = await call('/api/v1/messages', { group: gid, body: 'hi team' }, cookie); + check('group message sent', gsend.status === 200 && gsend.data.conversation_id === gid); + const gpush = await nextMsg(bobWs, 'chat-message'); + check('group message delivered to a member', gpush.message && gpush.message.conversation_id === gid && gpush.message.body === 'hi team'); + const gconv = await get('/api/v1/messages/conversations', bobCookie); + const gItem = gconv.data.find((c) => c.kind === 'group' && c.id === gid); + check('group appears in member conversations with unread', !!gItem && gItem.unread >= 1); + const gthread = await get('/api/v1/messages/thread?group=' + gid, bobCookie); + check('group thread returns messages with sender name', gthread.status === 200 && gthread.data.some((m) => m.body === 'hi team' && m.fromName)); + const gconv2 = await get('/api/v1/messages/conversations', bobCookie); + const gItem2 = gconv2.data.find((c) => c.kind === 'group' && c.id === gid); + check('reading group thread clears unread', !!gItem2 && gItem2.unread === 0); + // group "seen by": bob read the thread above, so admin's message lists him as a reader + const gSeen = await get('/api/v1/messages/thread?group=' + gid, cookie); + const hiTeam = gSeen.data.find((x) => x.body === 'hi team'); + check('group message shows seen-by readers', !!hiTeam && Array.isArray(hiTeam.seenBy) && hiTeam.seenBy.length >= 1); + const gmembers = await get('/api/v1/groups/members?group=' + gid, cookie); + check('group members listed', gmembers.status === 200 && gmembers.data.length === 2); + // @mentions: members + @everyone are stored; non-members are filtered out + const ment = await call('/api/v1/messages', { group: gid, body: 'ping @Bob and @everyone', mentions: [bobId, 'everyone', 'not-a-member'] }, cookie); + check('group message accepts mentions', ment.status === 200); + check('mentions filtered to members + everyone', Array.isArray(ment.data.mentions) && ment.data.mentions.includes(bobId) && ment.data.mentions.includes('everyone') && !ment.data.mentions.includes('not-a-member')); + const mthread = await get('/api/v1/messages/thread?group=' + gid, bobCookie); + const mEntry = mthread.data.find((x) => x.id === ment.data.id); + check('thread carries mentions', !!mEntry && mEntry.mentions.includes(bobId) && mEntry.mentions.includes('everyone')); + + // 3j. Polls (single + multi, vote/switch, close, inline in thread) + const mkPoll = await call('/api/v1/polls', { group: gid, question: 'Lunch?', options: ['Pizza', 'Sushi'], multi: false }, cookie); + check('poll created', mkPoll.status === 200 && mkPoll.data.options.length === 2 && mkPoll.data.totalVotes === 0); + const pid = mkPoll.data.id; + const needTwo = await call('/api/v1/polls', { group: gid, question: 'X?', options: ['only one'] }, cookie); + check('poll requires >=2 options', needTwo.status === 400); + const v1 = await call('/api/v1/polls/vote', { pollId: pid, optionIdx: 0 }, bobCookie); + check('vote recorded', v1.status === 200 && v1.data.options[0].votes === 1 && v1.data.options[0].mine === true); + const v2 = await call('/api/v1/polls/vote', { pollId: pid, optionIdx: 1 }, bobCookie); // single-choice -> switch + check('single-choice switches the vote', v2.data.options[0].votes === 0 && v2.data.options[1].votes === 1); + const av = await call('/api/v1/polls/vote', { pollId: pid, optionIdx: 0 }, cookie); + check('second voter counted', av.data.totalVotes === 2 && av.data.voters === 2); + const mkPoll2 = await call('/api/v1/polls', { group: gid, question: 'Toppings?', options: ['Cheese', 'Olives', 'Mushroom'], multi: true }, cookie); + const pid2 = mkPoll2.data.id; + await call('/api/v1/polls/vote', { pollId: pid2, optionIdx: 0 }, bobCookie); + const mv = await call('/api/v1/polls/vote', { pollId: pid2, optionIdx: 1 }, bobCookie); + check('multi-choice keeps multiple votes', mv.data.options[0].votes === 1 && mv.data.options[1].votes === 1 && mv.data.voters === 1); + const badClose = await call('/api/v1/polls/close', { pollId: pid }, bobCookie); + check('non-creator cannot close a poll', badClose.status === 403); + const closed = await call('/api/v1/polls/close', { pollId: pid }, cookie); + check('creator closes the poll', closed.status === 200 && closed.data.closed === true); + const voteClosed = await call('/api/v1/polls/vote', { pollId: pid, optionIdx: 0 }, bobCookie); + check('cannot vote on a closed poll', voteClosed.status === 400); + const pthread = await get('/api/v1/messages/thread?group=' + gid, bobCookie); + const pmsg = pthread.data.find((x) => x.poll && x.poll.id === pid); + check('poll renders inline in the thread', !!pmsg && pmsg.poll.options.length === 2 && pmsg.poll.closed === true); + + // group management: rename, info, remove(leave) + const ren = await call('/api/v1/groups/rename', { group: gid, name: 'Renamed Huddle' }, cookie); + check('group renamed', ren.status === 200 && ren.data.name === 'Renamed Huddle'); + const gthrRen = await get('/api/v1/messages/thread?group=' + gid, cookie); + check('rename posts an activity message', gthrRen.data.some((x) => x.system && /renamed/.test(x.body))); + // admin-only toggle: only the creator can change it / manage members when on + const ao1 = await call('/api/v1/groups/admin-only', { group: gid, value: true }, cookie); + check('admin enables admin-only', ao1.status === 200 && ao1.data.adminOnly === true); + const aoBobToggle = await call('/api/v1/groups/admin-only', { group: gid, value: false }, bobCookie); + check('non-admin cannot change admin-only', aoBobToggle.status === 403); + const aoBobRemove = await call('/api/v1/groups/remove', { group: gid, userId: adminId }, bobCookie); + check('admin-only blocks non-admin removing others', aoBobRemove.status === 403); + const ao0 = await call('/api/v1/groups/admin-only', { group: gid, value: false }, cookie); + check('admin disables admin-only', ao0.status === 200 && ao0.data.adminOnly === false); + // shared group call: start returns a room; a second start joins the same live call + const call1 = await call('/api/v1/groups/call/start', { group: gid }, cookie); + check('group call starts with a room', call1.status === 200 && /^\d{6}$/.test(call1.data.room || '') && call1.data.active === true); + const call2 = await call('/api/v1/groups/call/start', { group: gid }, bobCookie); + check('second start joins the same call (no code)', call2.status === 200 && call2.data.room === call1.data.room && call2.data.already === true); + const convCall = await get('/api/v1/messages/conversations', bobCookie); + const gcRow = convCall.data.find((c) => c.kind === 'group' && c.id === gid); + check('conversations expose the active call', !!gcRow && gcRow.callActive === true && gcRow.callRoom === call1.data.room); + // 1:1 call + add-participant invite + const dmCall1 = await call('/api/v1/calls/dm/start', { to: bobId }, cookie); + check('DM call starts with a room', dmCall1.status === 200 && /^\d{6}$/.test(dmCall1.data.room || '') && dmCall1.data.active === true); + const dmCall2 = await call('/api/v1/calls/dm/start', { to: adminId }, bobCookie); + check('DM call join returns the same room', dmCall2.status === 200 && dmCall2.data.room === dmCall1.data.room && dmCall2.data.already === true); + const inv = await call('/api/v1/calls/invite', { room: dmCall1.data.room, userIds: [bobId] }, cookie); + check('invite to a live call accepted', inv.status === 200 && inv.data.invited === 1); + const invBad = await call('/api/v1/calls/invite', { room: '000000', userIds: [bobId] }, cookie); + check('invite to a non-existent call rejected', invBad.status === 404); + const ginfo = await get('/api/v1/groups/info?group=' + gid, cookie); + check('group info returns name + members + isCreator', ginfo.status === 200 && ginfo.data.name === 'Renamed Huddle' && ginfo.data.isCreator === true && ginfo.data.members.length === 2); + + // 3h. Scheduled meetings (schedule -> announce -> list buckets -> lazy join -> cancel) + const future = Date.now() + 24 * 60 * 60 * 1000; + bobWs.q.length = 0; + const sched = await call('/api/v1/meetings/schedule', { group: gid, title: 'Sprint Review', description: 'Demo + retro', scheduledAt: future, whenText: 'Tomorrow' }, cookie); + check('meeting scheduled, returns 6-digit room code', sched.status === 200 && /^\d{6}$/.test(sched.data.roomCode || '')); + const schP = await call('/api/v1/meetings/schedule', { title: 'Synced', scheduledAt: Date.now() + 3600000, participants: [bobId] }, cookie); + check('meeting scheduled with participants', schP.status === 200 && Array.isArray(schP.data.participants) && schP.data.participants.includes(bobId)); + const bobMeetings = await get('/api/v1/meetings', bobCookie); + const bm = bobMeetings.data.find((m) => m.id === schP.data.id); + check('invited participant sees the meeting', !!bm && bm.isHost === false); + const schPush = await nextMsg(bobWs, 'chat-message'); + check('schedule announced in the group chat', schPush.message && /Scheduled a call/.test(schPush.message.body)); + const pastM = await call('/api/v1/meetings/schedule', { group: gid, title: 'Old Standup', scheduledAt: Date.now() - 2 * 60 * 60 * 1000 }, cookie); + check('past meeting scheduling is rejected', pastM.status === 400); // can't schedule in the past (#1) + const mlist = await get('/api/v1/meetings', cookie); + const schUp = mlist.data.find((m) => m.id === sched.data.id); + check('meetings list buckets upcoming', mlist.status === 200 && schUp && schUp.status === 'upcoming'); + const sm = wsClient(); await new Promise((r) => sm.on('open', r)); + sm.send(JSON.stringify({ type: 'meeting-join', room: sched.data.roomCode, name: 'Admin' })); + const smJoined = await nextMsg(sm, 'meeting-joined'); + check('scheduled meeting joinable by code (room created lazily)', !!smJoined.peerId && smJoined.room === sched.data.roomCode); + const mlist2 = await get('/api/v1/meetings', cookie); + const upRun = mlist2.data.find((m) => m.id === sched.data.id); + check('scheduled meeting shows running while a peer is connected', !!upRun && upRun.status === 'running' && upRun.inCall >= 1); + sm.close(); + const cancelBob = await call('/api/v1/meetings/cancel', { id: sched.data.id }, bobCookie); + check('non-organizer cannot cancel a meeting', cancelBob.status === 403); + const cancelOk = await call('/api/v1/meetings/cancel', { id: sched.data.id }, cookie); + check('organizer cancels the meeting', cancelOk.status === 200); + + // 3i. Group image (upload -> set as group avatar -> visible to members) + const imgUp = await fetch(BASE + '/api/v1/messages/upload', { method: 'POST', headers: { Cookie: cookie, 'Content-Type': 'image/png', 'X-Filename': encodeURIComponent('logo.png') }, body: Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) }); + const imgUpd = await imgUp.json(); + check('group image uploaded', imgUp.status === 200 && !!imgUpd.id); + const badAv = await call('/api/v1/groups/avatar', { group: gid, attachmentId: upd.id }, cookie); // upd.id is text/plain + check('non-image rejected as group photo', badAv.status === 400); + const setAv = await call('/api/v1/groups/avatar', { group: gid, attachmentId: imgUpd.id }, cookie); + check('group photo set', setAv.status === 200 && setAv.data.avatar === '/files/' + imgUpd.id); + const ginfoAv = await get('/api/v1/groups/info?group=' + gid, cookie); + check('group info exposes the photo', ginfoAv.status === 200 && ginfoAv.data.avatar === '/files/' + imgUpd.id); + const memberFetch = await fetch(BASE + '/files/' + imgUpd.id, { headers: { Cookie: bobCookie } }); + check('group member can fetch the group photo', memberFetch.status === 200); + + const bobLeave = await call('/api/v1/groups/remove', { group: gid }, bobCookie); // bob leaves + check('member can leave the group', bobLeave.status === 200 && bobLeave.data.left === true); + const gthrLeft = await get('/api/v1/messages/thread?group=' + gid, cookie); + check('leaving posts an activity message', gthrLeft.data.some((x) => x.system && /left/.test(x.body))); + const ginfo2 = await get('/api/v1/groups/info?group=' + gid, cookie); + check('member count drops after leave', ginfo2.status === 200 && ginfo2.data.members.length === 1); + bobWs.close(); + + // 3f. Meetings (mesh) signaling: create room, two peers join, relay signal, leave + const alice = wsClient(); await new Promise((r) => alice.on('open', r)); + alice.send(JSON.stringify({ type: 'meeting-create' })); + const created = await nextMsg(alice, 'meeting-created'); + check('meeting-create returns a 6-digit room code', /^\d{6}$/.test(created.room || '')); + alice.send(JSON.stringify({ type: 'meeting-join', room: created.room, name: 'Alice' })); + const aJoined = await nextMsg(alice, 'meeting-joined'); + check('first peer joins (room empty)', !!aJoined.peerId && aJoined.peers.length === 0); + const carol = wsClient(); await new Promise((r) => carol.on('open', r)); + carol.send(JSON.stringify({ type: 'meeting-join', room: created.room, name: 'Carol' })); + const cJoined = await nextMsg(carol, 'meeting-joined'); + check('second peer sees the first in the room', cJoined.peers.some((p) => p.peerId === aJoined.peerId)); + const aPeerJoined = await nextMsg(alice, 'meeting-peer-joined'); + check('existing peer notified of newcomer', aPeerJoined.peerId === cJoined.peerId); + alice.send(JSON.stringify({ type: 'meeting-signal', to: cJoined.peerId, data: { fake: 'offer' } })); + const relayed = await nextMsg(carol, 'meeting-signal'); + check('meeting signal relayed peer->peer', relayed.from === aJoined.peerId && relayed.data.fake === 'offer'); + carol.close(); + const aPeerLeft = await nextMsg(alice, 'meeting-peer-left'); + check('peer-left delivered on disconnect', aPeerLeft.peerId === cJoined.peerId); + alice.close(); + // 4. Wrong password rejected const badLogin = await call('/api/login', { email, password: 'wrong' }); check('wrong password rejected', badLogin.status === 401); @@ -130,6 +390,15 @@ function nextMsg(ws, type, timeout = 3000) { await nextMsg(agent, 'session-ended'); check('session-ended delivered to agent', true); + // 14b. Outbound webhook delivery (session.started + session.ended) with valid signatures + await wait(900); + const parse = (h) => { try { return JSON.parse(h.body); } catch { return {}; } }; + const hookStarted = webhookHits.find((h) => parse(h).event === 'session.started'); + const hookEnded = webhookHits.find((h) => parse(h).event === 'session.ended'); + check('webhook received session.started', !!hookStarted); + check('webhook received session.ended', !!hookEnded); + check('webhook signature is valid (HMAC-SHA256)', !!hookEnded && hookEnded.sig === crypto.createHmac('sha256', hookSecret).update(hookEnded.body).digest('base64url')); + // 15. Audit log captured the full flow const audit = await get('/api/audit', cookie); const actions = audit.data.map((a) => a.action); @@ -146,6 +415,7 @@ function nextMsg(ws, type, timeout = 3000) { check('consent denial -> viewer session-denied', !!denied); agent.close(); viewer.close(); + hookSrv.close(); console.log(`\n${passed} passed, ${failed} failed.`); server.close(); process.exit(failed ? 1 : 0);