feat: live presence, delivery-tick sync, brand rollout, call fixes, desktop 0.1.3
Live presence (fixes stale in-call/status until refresh — impossible in apps): - server broadcasts a user's status over the chat socket on connect/disconnect, call join/leave, and status change (chat.js broadcastPresence; signaling + routes hooks). - client onPresence() updates the contact dot + open-chat header live. Chat delivery ticks (#6): chat-list row now mirrors the thread (delivered→double grey, read→blue) via a new 'with' field on the delivered relay + onChatRead/onChatDelivered. Call fixes: no bogus 'host handed over' when a 1:1 call ends (leaveMeeting forced); branded call-connecting + chat-thread loaders; header subtitle tracks live call state. Notifications: web notify + sw.js use sender/group DP + brand icon (not old wordmark); desktop shell drops Web Push so only the single native toast fires (#5). Brand: master icon/splash/loaders wired everywhere (PWA/favicon/apple-touch/.ico), branded login (blue + gold CTA), branded toasts (BZToast) on all pages, Electron splash. Desktop: dev auto-targets localhost (packaged→prod); version 0.1.3 with new multi-size icon; dropped unused node-notifier; removed home-mockup.html. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@@ -0,0 +1,122 @@
|
|||||||
|
# TASK FOR CLAUDE (VS Code): Apply the Biz Connect brand — icons, splash, loaders, toasts
|
||||||
|
|
||||||
|
Read this whole file, then execute it. Work CONSERVATIVELY — only ADD / restyle, do NOT
|
||||||
|
refactor unrelated code. The app is live in production. Find the right spots in the CURRENT
|
||||||
|
code yourself (don't assume old names). Summarise all changes and STOP for my review before
|
||||||
|
committing anything.
|
||||||
|
|
||||||
|
Repo root: C:\BizGaze_Support\remote-access-app
|
||||||
|
Master brand assets are already placed in: brand-assets\
|
||||||
|
- app-icon-1024.png (1024 full-bleed app icon master)
|
||||||
|
- splash-2732-dark.png (2732 splash, brand blue — primary)
|
||||||
|
- splash-2732-light.png (2732 splash, white — optional light theme)
|
||||||
|
- loader-ring.svg (default spinner, light backgrounds)
|
||||||
|
- loader-orbit.svg (branded spinner, light backgrounds)
|
||||||
|
- loader-orbit-dark.svg (branded spinner, dark/overlay backgrounds)
|
||||||
|
Brand colours: blue #1F3B73, blue-dark #16294F, yellow #FFC708, gold #E0AC00.
|
||||||
|
|
||||||
|
Note: brand-assets/ is only the SOURCE. Put the GENERATED / used copies where each target
|
||||||
|
needs them — web assets under server/public/ (served over HTTP); native icons/splash into the
|
||||||
|
platform build folders (Electron / Android / iOS) as applicable.
|
||||||
|
|
||||||
|
## 1. App icons (from brand-assets/app-icon-1024.png)
|
||||||
|
Generate and place:
|
||||||
|
- Windows .ico (multi-size 16/32/48/256) — fixes the tiny/blurry taskbar icon.
|
||||||
|
- Favicon: server/public/favicon.ico + a 32px PNG; add to each page <head>:
|
||||||
|
<link rel="icon" href="/favicon.ico" sizes="any">
|
||||||
|
<link rel="apple-touch-icon" href="/apple-touch-icon-180.png">
|
||||||
|
- PWA icons 192, 512, and a maskable 512; reference them in the web manifest, and set the
|
||||||
|
manifest "background_color" and "theme_color" to "#1F3B73".
|
||||||
|
- Native mobile icons (Android mipmap set + adaptive, iOS AppIcon set) into their folders.
|
||||||
|
|
||||||
|
## 2. Splash (from brand-assets/splash-2732-dark.png)
|
||||||
|
- Web/PWA loading + Electron splash: brand-blue background #1F3B73 with the logo centred
|
||||||
|
(use the 2732 image, or reuse the app's splash.html if present).
|
||||||
|
- Native launch screens: use the 2732 image (Android 12 SplashScreen background #1F3B73 + the
|
||||||
|
app icon; iOS LaunchScreen background #1F3B73 + centred logo). Keep native launch STATIC.
|
||||||
|
- Use splash-2732-light.png only if a light theme is supported.
|
||||||
|
|
||||||
|
## 3. Loaders (from brand-assets/loader-*.svg)
|
||||||
|
Copy the three SVGs into server/public/ (e.g. server/public/loaders/). Wire them:
|
||||||
|
- Everyday "Loading…" and in-app spinners -> loader-ring.svg
|
||||||
|
- The hero "Connecting…" moment (session connect) -> loader-orbit.svg (light) / loader-orbit-dark.svg (on dark)
|
||||||
|
Show one centered in the middle of the screen/panel while loading; hide when done. Always pair
|
||||||
|
show with hide on BOTH the success and error paths so it can never get stuck.
|
||||||
|
|
||||||
|
## 4. Branded notification toasts
|
||||||
|
Create server/public/bizconnect-toast.css with EXACTLY:
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Biz Connect — branded notification toast. BZToast.success('…') / .error / .message / .info */
|
||||||
|
.bzt-wrap{position:fixed;top:16px;right:16px;z-index:2147483600;display:flex;flex-direction:column;gap:10px;max-width:min(380px,92vw)}
|
||||||
|
@supports(top:env(safe-area-inset-top)){.bzt-wrap{top:calc(16px + env(safe-area-inset-top));right:calc(16px + env(safe-area-inset-right))}}
|
||||||
|
.bzt{display:flex;align-items:flex-start;gap:12px;background:#fff;color:#1f2430;border-radius:14px;padding:12px 14px;
|
||||||
|
box-shadow:0 12px 30px rgba(16,26,53,.20);border-left:5px solid #1F3B73;
|
||||||
|
transform:translateX(120%);opacity:0;transition:transform .3s cubic-bezier(.2,.7,.2,1),opacity .3s}
|
||||||
|
.bzt.bzt-in{transform:translateX(0);opacity:1}
|
||||||
|
.bzt.success{border-left-color:#16a34a}.bzt.error{border-left-color:#b91c1c}
|
||||||
|
.bzt-badge{flex:none;width:34px;height:34px;border-radius:50%;display:grid;place-items:center;background:#1F3B73}
|
||||||
|
.bzt.success .bzt-badge{background:#16a34a}.bzt.error .bzt-badge{background:#b91c1c}
|
||||||
|
.bzt-badge svg{width:20px;height:20px}
|
||||||
|
.bzt-body{flex:1;min-width:0;padding-top:1px}
|
||||||
|
.bzt-title{font:700 13.5px/1.3 'Segoe UI',system-ui,sans-serif;color:#1F3B73;margin:0 0 1px}
|
||||||
|
.bzt.success .bzt-title{color:#15803d}.bzt.error .bzt-title{color:#b91c1c}
|
||||||
|
.bzt-msg{font:500 13px/1.4 'Segoe UI',system-ui,sans-serif;color:#3a4152;overflow-wrap:anywhere}
|
||||||
|
.bzt-x{flex:none;background:none;border:none;color:#9aa3b2;cursor:pointer;font-size:18px;line-height:1;padding:2px 4px}
|
||||||
|
.bzt-x:hover{color:#1f2430}
|
||||||
|
@media(prefers-reduced-motion:reduce){.bzt{transition:opacity .2s}}
|
||||||
|
```
|
||||||
|
|
||||||
|
Create server/public/bizconnect-toast.js with EXACTLY:
|
||||||
|
|
||||||
|
```js
|
||||||
|
/* Biz Connect toast API. Requires bizconnect-toast.css.
|
||||||
|
BZToast.success('Saved'); BZToast.error('Connection lost'); BZToast.message('Hi', {title:'Ravi'}); */
|
||||||
|
window.BZToast=(function(){
|
||||||
|
var wrap=null;
|
||||||
|
var ICON={
|
||||||
|
message:'<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>',
|
||||||
|
info:'<svg viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="9" stroke="#fff" stroke-width="2.2"/><path d="M12 11v5M12 8h.01" stroke="#fff" stroke-width="2.4" stroke-linecap="round"/></svg>',
|
||||||
|
success:'<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>',
|
||||||
|
error:'<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2.6" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg>'
|
||||||
|
};
|
||||||
|
function esc(s){return String(s==null?'':s).replace(/[&<>"]/g,function(c){return{'&':'&','<':'<','>':'>','"':'"'}[c];});}
|
||||||
|
function ensure(){ if(wrap) return wrap; wrap=document.createElement('div'); wrap.className='bzt-wrap'; document.body.appendChild(wrap); return wrap; }
|
||||||
|
function show(message,opts){
|
||||||
|
opts=opts||{}; var type=opts.type||'message'; var w=ensure();
|
||||||
|
var t=document.createElement('div'); t.className='bzt '+type;
|
||||||
|
t.innerHTML='<div class="bzt-badge">'+(ICON[type]||ICON.message)+'</div><div class="bzt-body">'
|
||||||
|
+(opts.title?'<div class="bzt-title">'+esc(opts.title)+'</div>':'')
|
||||||
|
+'<div class="bzt-msg">'+esc(message)+'</div></div><button class="bzt-x" aria-label="Dismiss">×</button>';
|
||||||
|
w.appendChild(t); requestAnimationFrame(function(){ t.classList.add('bzt-in'); });
|
||||||
|
var dur=(opts.duration==null?4000:opts.duration), timer;
|
||||||
|
function close(){ t.classList.remove('bzt-in'); setTimeout(function(){ if(t.parentNode) t.parentNode.removeChild(t); },320); clearTimeout(timer); }
|
||||||
|
t.querySelector('.bzt-x').onclick=close; if(dur>0) timer=setTimeout(close,dur); return close;
|
||||||
|
}
|
||||||
|
return { show:show,
|
||||||
|
message:function(m,o){o=o||{};o.type='message';return show(m,o);},
|
||||||
|
success:function(m,o){o=o||{};o.type='success';return show(m,o);},
|
||||||
|
error:function(m,o){o=o||{};o.type='error';return show(m,o);},
|
||||||
|
info:function(m,o){o=o||{};o.type='info';return show(m,o);} };
|
||||||
|
})();
|
||||||
|
```
|
||||||
|
|
||||||
|
Then, on every page that shows notifications (connect, share, home, dashboard, console),
|
||||||
|
include both files near the top BEFORE the page's own inline <script>:
|
||||||
|
<link rel="stylesheet" href="/bizconnect-toast.css">
|
||||||
|
<script src="/bizconnect-toast.js"></script>
|
||||||
|
(Load the JS via src only — never inline.)
|
||||||
|
|
||||||
|
Now REPLACE the existing toast / notification pop-ups with the branded API (keep the same
|
||||||
|
triggers and text, just route them through BZToast so they look on-brand):
|
||||||
|
- Incoming chat message -> BZToast.message(text, {title: senderName})
|
||||||
|
- A success (e.g. recording/transcript saved, agent added) -> BZToast.success('…')
|
||||||
|
- An error (connection lost, upload failed, invalid code) -> BZToast.error('…')
|
||||||
|
- Neutral info -> BZToast.info('…')
|
||||||
|
Remove the old inline toast markup/styles it replaces. Toasts appear top-right and auto-dismiss.
|
||||||
|
|
||||||
|
## 5. Verify (do not commit until I review)
|
||||||
|
- node --check any JS you extract/touch; confirm every page still loads.
|
||||||
|
- Confirm: taskbar/favicon icon is crisp, splash shows on launch, a loader appears centered
|
||||||
|
during connect/report, and notifications now use the branded toast.
|
||||||
|
- List exactly which files changed, then STOP for my review.
|
||||||
@@ -22,8 +22,9 @@ Output in `desktop/dist/`:
|
|||||||
- `latest.yml` — the update manifest electron-updater reads
|
- `latest.yml` — the update manifest electron-updater reads
|
||||||
- `*.blockmap` — enables delta downloads
|
- `*.blockmap` — enables delta downloads
|
||||||
|
|
||||||
The release build points at production (`SERVER_URL` defaults to `https://remote.bizgaze.com`
|
A PACKAGED build points at production (`main.js` defaults `SERVER_URL` to
|
||||||
in `main.js`).
|
`https://remote.bizgaze.com` when `app.isPackaged`); running from source in dev defaults to
|
||||||
|
`http://localhost:8090`. `SERVER_URL` overrides either.
|
||||||
|
|
||||||
## Publish a release (self-hosted feed)
|
## Publish a release (self-hosted feed)
|
||||||
1. Bump `version` in `desktop/package.json` (semver — electron-updater compares this).
|
1. Bump `version` in `desktop/package.json` (semver — electron-updater compares this).
|
||||||
|
|||||||
@@ -3,11 +3,15 @@
|
|||||||
Electron shell that loads the live Connect web UI and adds native screen capture. See the
|
Electron shell that loads the live Connect web UI and adds native screen capture. See the
|
||||||
overall plan in [../CLIENTS.md](../CLIENTS.md).
|
overall plan in [../CLIENTS.md](../CLIENTS.md).
|
||||||
|
|
||||||
## Run (dev)
|
## Run (dev = local testing)
|
||||||
```bash
|
```bash
|
||||||
npm install
|
npm install
|
||||||
SERVER_URL=http://localhost:8090 npm start # default is https://remote.bizgaze.com
|
npm start # dev auto-targets http://localhost:8090 (your local server)
|
||||||
|
SERVER_URL=https://remote.bizgaze.com npm start # …or point dev at production to compare
|
||||||
```
|
```
|
||||||
|
`npm start` IS the local desktop test — no separate "local" installer needed. In dev (unpackaged)
|
||||||
|
the shell defaults to the local server; a PACKAGED installer defaults to production. `SERVER_URL`
|
||||||
|
overrides either. (Bash/Git-Bash syntax above; in PowerShell: `$env:SERVER_URL='…'; npm start`.)
|
||||||
|
|
||||||
## Build installers
|
## Build installers
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -97,9 +97,26 @@ ipcMain.on('set-unread', (_e, { count, dataUrl } = {}) => {
|
|||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
});
|
});
|
||||||
|
|
||||||
const SERVER_URL = (process.env.SERVER_URL || 'https://remote.bizgaze.com').replace(/\/+$/, '');
|
// Server origin: a PACKAGED build (the installer) points at production; running from source in dev
|
||||||
|
// (`npm start`, unpackaged) defaults to the local server so you can test the shell against localhost
|
||||||
|
// with no flags or separate "local" build. SERVER_URL always overrides (e.g. point dev at prod).
|
||||||
|
const SERVER_URL = (process.env.SERVER_URL || (app.isPackaged ? 'https://remote.bizgaze.com' : 'http://localhost:8090')).replace(/\/+$/, '');
|
||||||
|
|
||||||
let win;
|
let win;
|
||||||
|
let splash;
|
||||||
|
|
||||||
|
// A tiny brand-blue splash (splash.html) shown while the web UI loads, so launch feels instant
|
||||||
|
// and on-brand instead of a blank window. Closed as soon as the main window is ready to show.
|
||||||
|
function createSplash() {
|
||||||
|
splash = new BrowserWindow({
|
||||||
|
width: 440, height: 440, frame: false, resizable: false, center: true,
|
||||||
|
backgroundColor: '#1F3B73', skipTaskbar: true, alwaysOnTop: true, show: true,
|
||||||
|
webPreferences: { contextIsolation: true, nodeIntegration: false },
|
||||||
|
});
|
||||||
|
splash.loadFile(path.join(__dirname, 'splash.html'));
|
||||||
|
splash.on('closed', () => { splash = null; });
|
||||||
|
}
|
||||||
|
function closeSplash() { if (splash) { try { splash.close(); } catch (_) {} splash = null; } }
|
||||||
|
|
||||||
function createWindow() {
|
function createWindow() {
|
||||||
win = new BrowserWindow({
|
win = new BrowserWindow({
|
||||||
@@ -108,7 +125,8 @@ function createWindow() {
|
|||||||
minWidth: 880,
|
minWidth: 880,
|
||||||
minHeight: 600,
|
minHeight: 600,
|
||||||
title: 'Biz Connect',
|
title: 'Biz Connect',
|
||||||
backgroundColor: '#0f1830',
|
backgroundColor: '#1F3B73',
|
||||||
|
show: false, // reveal only once the page is ready — the splash covers the gap
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
preload: path.join(__dirname, 'preload.js'),
|
preload: path.join(__dirname, 'preload.js'),
|
||||||
contextIsolation: true,
|
contextIsolation: true,
|
||||||
@@ -118,6 +136,12 @@ function createWindow() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Reveal the main window when its first paint is ready, and retire the splash. A fallback
|
||||||
|
// timer guarantees we never get stuck on the splash if the load stalls.
|
||||||
|
const reveal = () => { closeSplash(); if (win && !win.isVisible()) { win.show(); win.focus(); } };
|
||||||
|
win.once('ready-to-show', reveal);
|
||||||
|
setTimeout(reveal, 12000);
|
||||||
|
|
||||||
// Open the landing page (same entry as the website): the "before login" screen with the
|
// Open the landing page (same entry as the website): the "before login" screen with the
|
||||||
// no-login "Share my screen" option + sign-in. It redirects logged-in users straight to /home.
|
// no-login "Share my screen" option + sign-in. It redirects logged-in users straight to /home.
|
||||||
win.loadURL(SERVER_URL + '/');
|
win.loadURL(SERVER_URL + '/');
|
||||||
@@ -158,6 +182,7 @@ function configureSession() {
|
|||||||
|
|
||||||
app.whenReady().then(() => {
|
app.whenReady().then(() => {
|
||||||
configureSession();
|
configureSession();
|
||||||
|
createSplash();
|
||||||
createWindow();
|
createWindow();
|
||||||
Menu.setApplicationMenu(null); // hide the default menu bar; the web UI is the chrome
|
Menu.setApplicationMenu(null); // hide the default menu bar; the web UI is the chrome
|
||||||
app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) createWindow(); });
|
app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) createWindow(); });
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "biz-connect-desktop",
|
"name": "biz-connect-desktop",
|
||||||
"version": "0.1.2",
|
"version": "0.1.3",
|
||||||
"description": "Biz Connect technician desktop client — loads the Connect web UI with native screen capture",
|
"description": "Biz Connect technician desktop client — loads the Connect web UI with native screen capture",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "BizGaze",
|
"name": "BizGaze",
|
||||||
@@ -12,8 +12,7 @@
|
|||||||
"dist": "electron-builder"
|
"dist": "electron-builder"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"electron-updater": "^6.3.9",
|
"electron-updater": "^6.3.9"
|
||||||
"node-notifier": "^10.0.1"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"electron": "^31.0.0",
|
"electron": "^31.0.0",
|
||||||
@@ -26,9 +25,6 @@
|
|||||||
"buildResources": "build",
|
"buildResources": "build",
|
||||||
"output": "dist"
|
"output": "dist"
|
||||||
},
|
},
|
||||||
"asarUnpack": [
|
|
||||||
"**/node_modules/node-notifier/**"
|
|
||||||
],
|
|
||||||
"publish": [
|
"publish": [
|
||||||
{
|
{
|
||||||
"provider": "generic",
|
"provider": "generic",
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<style>
|
||||||
|
html,body{margin:0;height:100%;overflow:hidden;}
|
||||||
|
body{background:#1F3B73;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:22px;
|
||||||
|
font-family:'Segoe UI',system-ui,sans-serif;-webkit-user-select:none;user-select:none;}
|
||||||
|
/* Branded orbit mark (white C + circling yellow dot) — matches the app icon/loader */
|
||||||
|
.mark{width:96px;height:96px;}
|
||||||
|
.mark .dot{transform-origin:50px 50px;animation:spin 1.1s linear infinite;}
|
||||||
|
@keyframes spin{to{transform:rotate(360deg);}}
|
||||||
|
.name{color:#fff;font-size:20px;font-weight:700;letter-spacing:.3px;}
|
||||||
|
.name b{color:#FFC708;font-weight:700;}
|
||||||
|
.sub{color:rgba(255,255,255,.66);font-size:12.5px;margin-top:-14px;}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<svg class="mark" viewBox="0 0 100 100" aria-label="Biz Connect">
|
||||||
|
<circle cx="50" cy="50" r="34" fill="none" stroke="#ffffff" stroke-width="9" stroke-linecap="round" stroke-dasharray="165 300" transform="rotate(38 50 50)"/>
|
||||||
|
<g class="dot"><circle cx="84" cy="50" r="7" fill="#FFC708"/></g>
|
||||||
|
</svg>
|
||||||
|
<div class="name">Biz <b>Connect</b></div>
|
||||||
|
<div class="sub">Starting…</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
// Chat presence + real-time delivery. A logged-in user opens a WebSocket and sends
|
// Chat presence + real-time delivery. A logged-in user opens a WebSocket and sends
|
||||||
// `chat-hello`; signaling.js registers the socket here. Messages are persisted over HTTP
|
// `chat-hello`; signaling.js registers the socket here. Messages are persisted over HTTP
|
||||||
// (routes.js) and pushed live to the recipient's sockets via pushToUser().
|
// (routes.js) and pushed live to the recipient's sockets via pushToUser().
|
||||||
const { chatClients } = require('./presence');
|
const { chatClients, meetingRooms } = require('./presence');
|
||||||
|
let _repos = null; const repos = () => (_repos || (_repos = require('./repos'))); // lazy: avoid a require cycle
|
||||||
|
|
||||||
function register(userId, ws) {
|
function register(userId, ws) {
|
||||||
if (!chatClients.has(userId)) chatClients.set(userId, new Set());
|
if (!chatClients.has(userId)) chatClients.set(userId, new Set());
|
||||||
@@ -28,4 +29,27 @@ function pushToUser(userId, obj) {
|
|||||||
for (const ws of s) { if (ws.readyState === 1) { try { ws.send(data); } catch (_) {} } }
|
for (const ws of s) { if (ws.readyState === 1) { try { ws.send(data); } catch (_) {} } }
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { register, unregister, isOnline, pushToUser };
|
// --- Live presence -------------------------------------------------------------------------
|
||||||
|
// A user's effective status (online / in-a-call / away / …) changes with no HTTP round-trip:
|
||||||
|
// they connect or disconnect a socket, or join/leave a call. Without pushing that change, OTHER
|
||||||
|
// users only see it after a full page reload — impossible in the desktop/mobile apps. So whenever
|
||||||
|
// it changes we broadcast the user's fresh status to everyone else's sockets, and the client
|
||||||
|
// updates that contact's dot/subtitle in place.
|
||||||
|
function isInCall(userId) {
|
||||||
|
for (const [, peers] of meetingRooms) { for (const [, p] of peers) { if (p.ws && p.ws._meetingUserId === userId) return true; } }
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
function effectiveStatus(userId) {
|
||||||
|
if (isInCall(userId)) return 'incall'; // derived (overrides the stored status)
|
||||||
|
try { const u = repos().users.byId(userId); return (u && u.status) || 'active'; } catch (_) { return 'active'; }
|
||||||
|
}
|
||||||
|
function broadcastPresence(userId) {
|
||||||
|
if (!userId) return;
|
||||||
|
const payload = JSON.stringify({ type: 'presence', userId, online: isOnline(userId), status: effectiveStatus(userId) });
|
||||||
|
for (const [uid, set] of chatClients) {
|
||||||
|
if (uid === userId) continue; // no need to tell someone about their own status
|
||||||
|
for (const ws of set) { if (ws.readyState === 1) { try { ws.send(payload); } catch (_) {} } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { register, unregister, isOnline, pushToUser, broadcastPresence };
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 8.1 KiB After Width: | Height: | Size: 8.1 KiB |
@@ -0,0 +1,18 @@
|
|||||||
|
/* Biz Connect — branded notification toast. BZToast.success('…') / .error / .message / .info */
|
||||||
|
.bzt-wrap{position:fixed;top:16px;right:16px;z-index:2147483600;display:flex;flex-direction:column;gap:10px;max-width:min(380px,92vw)}
|
||||||
|
@supports(top:env(safe-area-inset-top)){.bzt-wrap{top:calc(16px + env(safe-area-inset-top));right:calc(16px + env(safe-area-inset-right))}}
|
||||||
|
.bzt{display:flex;align-items:flex-start;gap:12px;background:#fff;color:#1f2430;border-radius:14px;padding:12px 14px;
|
||||||
|
box-shadow:0 12px 30px rgba(16,26,53,.20);border-left:5px solid #1F3B73;
|
||||||
|
transform:translateX(120%);opacity:0;transition:transform .3s cubic-bezier(.2,.7,.2,1),opacity .3s}
|
||||||
|
.bzt.bzt-in{transform:translateX(0);opacity:1}
|
||||||
|
.bzt.success{border-left-color:#16a34a}.bzt.error{border-left-color:#b91c1c}
|
||||||
|
.bzt-badge{flex:none;width:34px;height:34px;border-radius:50%;display:grid;place-items:center;background:#1F3B73}
|
||||||
|
.bzt.success .bzt-badge{background:#16a34a}.bzt.error .bzt-badge{background:#b91c1c}
|
||||||
|
.bzt-badge svg{width:20px;height:20px}
|
||||||
|
.bzt-body{flex:1;min-width:0;padding-top:1px}
|
||||||
|
.bzt-title{font:700 13.5px/1.3 'Segoe UI',system-ui,sans-serif;color:#1F3B73;margin:0 0 1px}
|
||||||
|
.bzt.success .bzt-title{color:#15803d}.bzt.error .bzt-title{color:#b91c1c}
|
||||||
|
.bzt-msg{font:500 13px/1.4 'Segoe UI',system-ui,sans-serif;color:#3a4152;overflow-wrap:anywhere}
|
||||||
|
.bzt-x{flex:none;background:none;border:none;color:#9aa3b2;cursor:pointer;font-size:18px;line-height:1;padding:2px 4px}
|
||||||
|
.bzt-x:hover{color:#1f2430}
|
||||||
|
@media(prefers-reduced-motion:reduce){.bzt{transition:opacity .2s}}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
/* Biz Connect toast API. Requires bizconnect-toast.css.
|
||||||
|
BZToast.success('Saved'); BZToast.error('Connection lost'); BZToast.message('Hi', {title:'Ravi'}); */
|
||||||
|
window.BZToast=(function(){
|
||||||
|
var wrap=null;
|
||||||
|
var ICON={
|
||||||
|
message:'<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>',
|
||||||
|
info:'<svg viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="9" stroke="#fff" stroke-width="2.2"/><path d="M12 11v5M12 8h.01" stroke="#fff" stroke-width="2.4" stroke-linecap="round"/></svg>',
|
||||||
|
success:'<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>',
|
||||||
|
error:'<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2.6" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg>'
|
||||||
|
};
|
||||||
|
function esc(s){return String(s==null?'':s).replace(/[&<>"]/g,function(c){return{'&':'&','<':'<','>':'>','"':'"'}[c];});}
|
||||||
|
function ensure(){ if(wrap) return wrap; wrap=document.createElement('div'); wrap.className='bzt-wrap'; document.body.appendChild(wrap); return wrap; }
|
||||||
|
function show(message,opts){
|
||||||
|
opts=opts||{}; var type=opts.type||'message'; var w=ensure();
|
||||||
|
var t=document.createElement('div'); t.className='bzt '+type;
|
||||||
|
t.innerHTML='<div class="bzt-badge">'+(ICON[type]||ICON.message)+'</div><div class="bzt-body">'
|
||||||
|
+(opts.title?'<div class="bzt-title">'+esc(opts.title)+'</div>':'')
|
||||||
|
+'<div class="bzt-msg">'+esc(message)+'</div></div><button class="bzt-x" aria-label="Dismiss">×</button>';
|
||||||
|
w.appendChild(t); requestAnimationFrame(function(){ t.classList.add('bzt-in'); });
|
||||||
|
var dur=(opts.duration==null?4000:opts.duration), timer;
|
||||||
|
function close(){ t.classList.remove('bzt-in'); setTimeout(function(){ if(t.parentNode) t.parentNode.removeChild(t); },320); clearTimeout(timer); }
|
||||||
|
t.querySelector('.bzt-x').onclick=close; if(dur>0) timer=setTimeout(close,dur); return close;
|
||||||
|
}
|
||||||
|
return { show:show,
|
||||||
|
message:function(m,o){o=o||{};o.type='message';return show(m,o);},
|
||||||
|
success:function(m,o){o=o||{};o.type='success';return show(m,o);},
|
||||||
|
error:function(m,o){o=o||{};o.type='error';return show(m,o);},
|
||||||
|
info:function(m,o){o=o||{};o.type='info';return show(m,o);} };
|
||||||
|
})();
|
||||||
@@ -4,6 +4,12 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Biz Connect — Agent Console</title>
|
<title>Biz Connect — Agent Console</title>
|
||||||
|
<meta name="theme-color" content="#1F3B73">
|
||||||
|
<link rel="icon" href="/favicon.ico?v=3" sizes="any">
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png?v=3">
|
||||||
|
<link rel="apple-touch-icon" href="/apple-touch-icon-180.png?v=3">
|
||||||
|
<link rel="stylesheet" href="/bizconnect-toast.css">
|
||||||
|
<script src="/bizconnect-toast.js"></script>
|
||||||
<style>
|
<style>
|
||||||
:root{ --brand:#FFC708; --brand-d:#E0AC00; --blue:#1F3B73; --blue-d:#16294f; --ink:#1f2430; --muted:#6b7280; --bg:#f6f8fb; --card:#fff; --line:#e6e9ef; }
|
:root{ --brand:#FFC708; --brand-d:#E0AC00; --blue:#1F3B73; --blue-d:#16294f; --ink:#1f2430; --muted:#6b7280; --bg:#f6f8fb; --card:#fff; --line:#e6e9ef; }
|
||||||
*{box-sizing:border-box;}
|
*{box-sizing:border-box;}
|
||||||
@@ -165,7 +171,7 @@ async function startConnect(){
|
|||||||
const ticket=document.getElementById('ticketInput').value.trim();
|
const ticket=document.getElementById('ticketInput').value.trim();
|
||||||
const code=document.getElementById('codeInput').value.trim();
|
const code=document.getElementById('codeInput').value.trim();
|
||||||
if(!/^\d{6}$/.test(code)){ statusEl.textContent='Please enter the 6-digit code.'; return; }
|
if(!/^\d{6}$/.test(code)){ statusEl.textContent='Please enter the 6-digit code.'; return; }
|
||||||
statusEl.textContent='Connecting…';
|
statusEl.innerHTML='<img src="/loaders/loader-orbit.svg" width="20" height="20" style="vertical-align:-5px;margin-right:7px" alt="">Connecting…';
|
||||||
ws.send(JSON.stringify({type:'code-connect',code,ticket}));
|
ws.send(JSON.stringify({type:'code-connect',code,ticket}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,7 +179,7 @@ function connectWS(){
|
|||||||
ws=new WebSocket((location.protocol==='https:'?'wss://':'ws://')+location.host+'/ws');
|
ws=new WebSocket((location.protocol==='https:'?'wss://':'ws://')+location.host+'/ws');
|
||||||
ws.onmessage=async(e)=>{const m=JSON.parse(e.data);const statusEl=document.getElementById('status');switch(m.type){
|
ws.onmessage=async(e)=>{const m=JSON.parse(e.data);const statusEl=document.getElementById('status');switch(m.type){
|
||||||
case 'code-pending': sessionId=m.sessionId; renderWaiting(); setupPeer(); break;
|
case 'code-pending': sessionId=m.sessionId; renderWaiting(); setupPeer(); break;
|
||||||
case 'session-ready': if(statusEl)statusEl.textContent='Allowed — connecting…'; break;
|
case 'session-ready': if(statusEl)statusEl.innerHTML='<img src="/loaders/loader-orbit.svg" width="20" height="20" style="vertical-align:-5px;margin-right:7px" alt="">Allowed — connecting…'; break;
|
||||||
case 'offer': await pc.setRemoteDescription(new RTCSessionDescription(m.sdp));
|
case 'offer': await pc.setRemoteDescription(new RTCSessionDescription(m.sdp));
|
||||||
// Acquire the agent mic once; on renegotiation (e.g. customer unmutes) just answer.
|
// Acquire the agent mic once; on renegotiation (e.g. customer unmutes) just answer.
|
||||||
if(!window.__mic){ try{ const mic=await navigator.mediaDevices.getUserMedia({audio:true}); window.__mic=mic; mic.getAudioTracks().forEach(t=>pc.addTrack(t,mic)); }catch(e){} }
|
if(!window.__mic){ try{ const mic=await navigator.mediaDevices.getUserMedia({audio:true}); window.__mic=mic; mic.getAudioTracks().forEach(t=>pc.addTrack(t,mic)); }catch(e){} }
|
||||||
@@ -346,8 +352,9 @@ function buildChatPanel(){
|
|||||||
}
|
}
|
||||||
function toggleChat(){const p=document.getElementById('chatPanel');if(!p)return;chatOpen=!chatOpen;p.style.display=chatOpen?'flex':'none';const b=document.getElementById('chatBtn');if(chatOpen){b&&(b.style.background='#475569');const i=document.getElementById('chatInput');if(i)setTimeout(()=>i.focus(),50);}}
|
function toggleChat(){const p=document.getElementById('chatPanel');if(!p)return;chatOpen=!chatOpen;p.style.display=chatOpen?'flex':'none';const b=document.getElementById('chatBtn');if(chatOpen){b&&(b.style.background='#475569');const i=document.getElementById('chatInput');if(i)setTimeout(()=>i.focus(),50);}}
|
||||||
function addChat(msg){const c=document.getElementById('chatMsgs');if(!c)return;const mine=msg.from==='__self';const w=document.createElement('div');w.style.cssText='max-width:85%;padding:.4rem .6rem;border-radius:10px;'+(mine?'align-self:flex-end;background:#EAF0FB;color:#16294f':'align-self:flex-start;background:#f1f5f9;color:#1f2430');w.innerHTML='<div style="font-size:.7rem;opacity:.65;margin-bottom:2px">'+esc(msg.name||'')+'</div>'+esc(msg.text);c.appendChild(w);c.scrollTop=c.scrollHeight;if(!mine)notifyMsg(msg);}
|
function addChat(msg){const c=document.getElementById('chatMsgs');if(!c)return;const mine=msg.from==='__self';const w=document.createElement('div');w.style.cssText='max-width:85%;padding:.4rem .6rem;border-radius:10px;'+(mine?'align-self:flex-end;background:#EAF0FB;color:#16294f':'align-self:flex-start;background:#f1f5f9;color:#1f2430');w.innerHTML='<div style="font-size:.7rem;opacity:.65;margin-bottom:2px">'+esc(msg.name||'')+'</div>'+esc(msg.text);c.appendChild(w);c.scrollTop=c.scrollHeight;if(!mine)notifyMsg(msg);}
|
||||||
function notifyMsg(msg){const b=document.getElementById('chatBtn');if(b&&!chatOpen){b.style.background='#FFC708';b.style.color='#1f2430';}toast((msg.name||'Message')+': '+msg.text);try{beep();}catch(_){}if('Notification' in window && Notification.permission==='granted' && (document.hidden||!chatOpen)){try{new Notification('New message from '+(msg.name||'support'),{body:msg.text});}catch(_){}}}
|
function notifyMsg(msg){const b=document.getElementById('chatBtn');if(b&&!chatOpen){b.style.background='#FFC708';b.style.color='#1f2430';}if(window.BZToast)BZToast.message(msg.text,{title:(msg.name||'Message')});try{beep();}catch(_){}if('Notification' in window && Notification.permission==='granted' && (document.hidden||!chatOpen)){try{new Notification('New message from '+(msg.name||'support'),{body:msg.text});}catch(_){}}}
|
||||||
function toast(text){let t=document.getElementById('msgToast');if(!t){t=document.createElement('div');t.id='msgToast';t.style.cssText='position:fixed;top:18px;left:50%;transform:translateX(-50%);z-index:2147483600;background:#16a34a;color:#fff;padding:.7rem 1.1rem;border-radius:12px;box-shadow:0 10px 26px rgba(0,0,0,.35);font-size:.92rem;font-weight:600;border:2px solid #0c7a36;max-width:82vw;transition:opacity .4s';document.body.appendChild(t);}t.innerHTML='\ud83d\udcac '+text;t.style.opacity='1';clearTimeout(window.__toastT);window.__toastT=setTimeout(()=>{t.style.opacity='0';},2800);}
|
// Branded toast (BZToast, /bizconnect-toast.js). Classify by wording so errors show red.
|
||||||
|
function toast(text){var s=String(text==null?'':text);if(!window.BZToast)return;if(/could ?n.t|cannot|failed|invalid|error|denied|expired|not found|please enter/i.test(s))return BZToast.error(s);return BZToast.message(s);}
|
||||||
let __ac=null;
|
let __ac=null;
|
||||||
function ensureAudio(){try{__ac=__ac||new (window.AudioContext||window.webkitAudioContext)();if(__ac.state==='suspended')__ac.resume();}catch(_){}}
|
function ensureAudio(){try{__ac=__ac||new (window.AudioContext||window.webkitAudioContext)();if(__ac.state==='suspended')__ac.resume();}catch(_){}}
|
||||||
function beep(){ensureAudio();if(!__ac)return;try{const o=__ac.createOscillator(),g=__ac.createGain();o.type='sine';o.connect(g);g.connect(__ac.destination);const t0=__ac.currentTime;o.frequency.setValueAtTime(880,t0);o.frequency.setValueAtTime(660,t0+0.09);g.gain.setValueAtTime(0.0001,t0);g.gain.exponentialRampToValueAtTime(0.12,t0+0.02);g.gain.exponentialRampToValueAtTime(0.0001,t0+0.22);o.start(t0);o.stop(t0+0.24);}catch(_){}}
|
function beep(){ensureAudio();if(!__ac)return;try{const o=__ac.createOscillator(),g=__ac.createGain();o.type='sine';o.connect(g);g.connect(__ac.destination);const t0=__ac.currentTime;o.frequency.setValueAtTime(880,t0);o.frequency.setValueAtTime(660,t0+0.09);g.gain.setValueAtTime(0.0001,t0);g.gain.exponentialRampToValueAtTime(0.12,t0+0.02);g.gain.exponentialRampToValueAtTime(0.0001,t0+0.22);o.start(t0);o.stop(t0+0.24);}catch(_){}}
|
||||||
|
|||||||
@@ -4,6 +4,12 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Biz Connect — Dashboard</title>
|
<title>Biz Connect — Dashboard</title>
|
||||||
|
<meta name="theme-color" content="#1F3B73">
|
||||||
|
<link rel="icon" href="/favicon.ico?v=3" sizes="any">
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png?v=3">
|
||||||
|
<link rel="apple-touch-icon" href="/apple-touch-icon-180.png?v=3">
|
||||||
|
<link rel="stylesheet" href="/bizconnect-toast.css">
|
||||||
|
<script src="/bizconnect-toast.js"></script>
|
||||||
<style>
|
<style>
|
||||||
:root{ --brand:#FFC708; --brand-d:#E0AC00; --blue:#1F3B73; --blue-d:#16294f; --blue-soft:#EAF0FB; --ink:#1f2430; --muted:#6b7280; --bg:#f6f8fb; --card:#fff; --line:#e6e9ef; --green:#16a34a; --red:#b91c1c; }
|
: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;}
|
*{box-sizing:border-box;}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 650 B |
@@ -1,277 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>BizGaze Connect — Home</title>
|
|
||||||
<style>
|
|
||||||
:root{ --brand:#FFC708; --brand-d:#E0AC00; --blue:#1F3B73; --blue-d:#16294f; --blue-soft:#EAF0FB; --ink:#1f2430; --muted:#6b7280; --bg:#f6f8fb; --card:#fff; --line:#e6e9ef; --green:#16a34a; --red:#b91c1c; }
|
|
||||||
*{box-sizing:border-box;}
|
|
||||||
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;}
|
|
||||||
|
|
||||||
/* ---- Top bar (matches console.html) ---- */
|
|
||||||
header{background:var(--blue);padding:.75rem 1.5rem;display:flex;justify-content:space-between;align-items:center;flex:0 0 auto;}
|
|
||||||
.brandrow{display:flex;align-items:center;gap:.6rem;cursor:pointer;}
|
|
||||||
.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;}
|
|
||||||
.brand span.tag{color:#8ea3cf;font-weight:500;font-size:.85rem;}
|
|
||||||
|
|
||||||
/* ---- Profile dropdown (from console.html) ---- */
|
|
||||||
.profile{position:relative}
|
|
||||||
.profile .pbtn{display:flex;align-items:center;gap:.5rem;background:rgba(255,255,255,.14);color:#fff;border:1px solid #46598c;border-radius:10px;padding:.4rem .85rem .4rem .5rem;font-weight:600;font-size:.88rem;cursor:pointer}
|
|
||||||
.profile .pbtn:hover{background:rgba(255,255,255,.24)}
|
|
||||||
.profile .pbtn .pav{width:28px;height:28px;border-radius:50%;background:var(--brand);color:var(--blue);display:grid;place-items:center;font-weight:800;font-size:.78rem}
|
|
||||||
.profile .pmenu{position:absolute;right:0;top:calc(100% + 6px);background:#fff;border:1px solid #e6e9ef;border-radius:10px;box-shadow:0 10px 28px rgba(0,0,0,.18);min-width:210px;overflow:hidden;z-index:5000;display:none}
|
|
||||||
.profile .pmenu.open{display:block}
|
|
||||||
.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:hover{background:#f1f5f9}
|
|
||||||
.profile .pmenu a.danger{color:#b91c1c;border-top:1px solid #eef1f6}
|
|
||||||
|
|
||||||
/* ---- Shell ---- */
|
|
||||||
.shell{flex:1 1 auto;display:flex;min-height:0;}
|
|
||||||
|
|
||||||
/* ---- Sidebar ---- */
|
|
||||||
.sidebar{width:320px;flex:0 0 320px;background:var(--card);border-right:1px solid var(--line);display:flex;flex-direction:column;min-height:0;}
|
|
||||||
.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 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);}
|
|
||||||
|
|
||||||
.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::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 .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-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;}
|
|
||||||
.no-results{padding:2rem 1rem;text-align:center;color:var(--muted);font-size:.85rem;}
|
|
||||||
|
|
||||||
/* ---- Main content ---- */
|
|
||||||
.content{flex:1 1 auto;display:flex;flex-direction:column;min-width:0;min-height:0;}
|
|
||||||
.tabs{display:flex;gap:.4rem;padding:1rem 1.5rem 0;border-bottom:1px solid var(--line);background:var(--card);}
|
|
||||||
.tabs button{background:transparent;color:var(--muted);font-weight:600;font-size:.92rem;border:none;border-bottom:3px solid transparent;padding:.6rem .9rem .8rem;cursor:pointer;display:flex;align-items:center;gap:.45rem;border-radius:8px 8px 0 0;}
|
|
||||||
.tabs button:hover{color:var(--blue);background:#f6f8fb;}
|
|
||||||
.tabs button.active{color:var(--blue);border-bottom-color:var(--brand);}
|
|
||||||
.panel-wrap{flex:1 1 auto;overflow-y:auto;padding:2rem 1.5rem;display:flex;}
|
|
||||||
.panel{display:none;margin:auto;width:100%;max-width:560px;}
|
|
||||||
.panel.active{display:block;}
|
|
||||||
|
|
||||||
.card{background:var(--card);border:1px solid var(--line);border-radius:16px;padding:2.2rem;box-shadow:0 6px 18px rgba(20,30,60,.05);text-align:center;}
|
|
||||||
.feat-icon{width:72px;height:72px;border-radius:20px;display:grid;place-items:center;margin:0 auto 1.2rem;}
|
|
||||||
.feat-icon.blue{background:var(--blue-soft);color:var(--blue);}
|
|
||||||
.feat-icon.yellow{background:#fff6d8;color:var(--brand-d);}
|
|
||||||
.card h1{font-size:1.45rem;margin:0 0 .5rem;color:var(--blue);}
|
|
||||||
.card p{color:var(--muted);font-size:.95rem;line-height:1.55;margin:0 auto 1.6rem;max-width:400px;}
|
|
||||||
.btn{display:inline-flex;align-items:center;gap:.5rem;text-decoration:none;padding:.8rem 1.6rem;background:var(--brand);color:var(--ink);border:none;border-radius:11px;font-weight:700;font-size:.95rem;cursor:pointer;}
|
|
||||||
.btn:hover{background:var(--brand-d);}
|
|
||||||
.pill-soon{display:inline-block;background:#fff6d8;color:var(--brand-d);font-size:.74rem;font-weight:700;padding:.25rem .7rem;border-radius:99px;letter-spacing:.03em;margin-bottom:1.2rem;}
|
|
||||||
.hint{margin-top:1.4rem;font-size:.8rem;color:var(--muted);}
|
|
||||||
|
|
||||||
@media (max-width:760px){
|
|
||||||
.sidebar{width:108px;flex:0 0 108px;}
|
|
||||||
.side-title h2,.search,.chat-main{display:none;}
|
|
||||||
.chat-row{justify-content:center;}
|
|
||||||
.side-head{padding:.8rem .5rem;}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<header>
|
|
||||||
<div class="brandrow" id="brandrow">
|
|
||||||
<img src="/logo.png" alt="" style="height:46px;width:auto;max-width:190px;border-radius:8px;object-fit:contain;background:#fff;padding:5px 12px;image-rendering:-webkit-optimize-contrast" onerror="this.replaceWith(Object.assign(document.createElement('div'),{className:'logo',textContent:'B'}))">
|
|
||||||
<div class="brand">BizGaze <span class="y">Connect</span> <span class="tag">· Home</span></div>
|
|
||||||
</div>
|
|
||||||
<div id="hdrRight"></div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div class="shell">
|
|
||||||
<!-- ---------- Sidebar ---------- -->
|
|
||||||
<aside class="sidebar">
|
|
||||||
<div class="side-head">
|
|
||||||
<div class="side-title">
|
|
||||||
<h2>Chats</h2>
|
|
||||||
<button class="newchat" title="New chat" aria-label="New chat">+</button>
|
|
||||||
</div>
|
|
||||||
<div class="search">
|
|
||||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
|
||||||
<input id="chatSearch" placeholder="Search chats" autocomplete="off">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="chatlist" id="chatlist"></div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<!-- ---------- Main ---------- -->
|
|
||||||
<section class="content">
|
|
||||||
<div class="tabs">
|
|
||||||
<button data-tab="meeting" class="active">
|
|
||||||
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="23 7 16 12 23 17 23 7"/><rect x="1" y="5" width="15" height="14" rx="2" ry="2"/></svg>
|
|
||||||
Meeting
|
|
||||||
</button>
|
|
||||||
<button data-tab="share">
|
|
||||||
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
|
|
||||||
Share Screen
|
|
||||||
</button>
|
|
||||||
<button data-tab="connect">
|
|
||||||
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M1.42 9a16 16 0 0 1 21.16 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><line x1="12" y1="20" x2="12.01" y2="20"/></svg>
|
|
||||||
Connect Screen
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="panel-wrap">
|
|
||||||
<!-- Meeting -->
|
|
||||||
<div class="panel active" data-panel="meeting">
|
|
||||||
<div class="card">
|
|
||||||
<div class="feat-icon yellow">
|
|
||||||
<svg viewBox="0 0 24 24" width="34" height="34" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="23 7 16 12 23 17 23 7"/><rect x="1" y="5" width="15" height="14" rx="2" ry="2"/></svg>
|
|
||||||
</div>
|
|
||||||
<span class="pill-soon">COMING SOON</span>
|
|
||||||
<h1>Meetings are on the way</h1>
|
|
||||||
<p>Soon you'll be able to host multi-party video meetings with your BizGaze team and customers — right here, no install needed. We're putting on the finishing touches.</p>
|
|
||||||
<button class="btn" id="notifyBtn">🔔 Notify me when it's ready</button>
|
|
||||||
<div class="hint">In the meantime, use <b>Share Screen</b> or <b>Connect Screen</b> to start a session.</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Share Screen -->
|
|
||||||
<div class="panel" data-panel="share">
|
|
||||||
<div class="card">
|
|
||||||
<div class="feat-icon blue">
|
|
||||||
<svg viewBox="0 0 24 24" width="34" height="34" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
|
|
||||||
</div>
|
|
||||||
<h1>Share your screen</h1>
|
|
||||||
<p>Let a teammate or customer see your screen instantly. You'll get a 6-digit code to share — they enter it to connect. No download, works right in the browser.</p>
|
|
||||||
<a class="btn" href="/share">Start sharing →</a>
|
|
||||||
<div class="hint">Desktop browsers only — phones can't share their screen yet.</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Connect Screen -->
|
|
||||||
<div class="panel" data-panel="connect">
|
|
||||||
<div class="card">
|
|
||||||
<div class="feat-icon blue">
|
|
||||||
<svg viewBox="0 0 24 24" width="34" height="34" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M1.42 9a16 16 0 0 1 21.16 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><line x1="12" y1="20" x2="12.01" y2="20"/></svg>
|
|
||||||
</div>
|
|
||||||
<h1>Connect to a screen</h1>
|
|
||||||
<p>Helping someone out? Enter the 6-digit code they give you to view their screen and provide live support — with two-way voice and chat built in.</p>
|
|
||||||
<a class="btn" href="/connect">Open connect page →</a>
|
|
||||||
<div class="hint">The other person taps <b>Allow</b> before you can see anything.</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
// ---------- Helpers (reused patterns from console.html) ----------
|
|
||||||
function pEsc(s){return String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));}
|
|
||||||
function initials(name){return name.trim().split(/\s+/).slice(0,2).map(w=>w[0]).join('').toUpperCase();}
|
|
||||||
|
|
||||||
// Stable avatar color from a name
|
|
||||||
const AV_COLORS=['#1F3B73','#2563eb','#0e7490','#7c3aed','#be185d','#b45309','#15803d','#9d174d'];
|
|
||||||
function avColor(name){let h=0;for(const c of name)h=(h*31+c.charCodeAt(0))>>>0;return AV_COLORS[h%AV_COLORS.length];}
|
|
||||||
|
|
||||||
// Profile dropdown (mirrors profileHTML()/wireProfile() from console.html)
|
|
||||||
const SAMPLE_USER={name:'Sravan Mareddy',email:'sravanm@bizgaze.com',role:'admin'};
|
|
||||||
function profileHTML(u){
|
|
||||||
return '<div class="profile"><button class="pbtn" id="pbtn">'
|
|
||||||
+ '<span class="pav">'+pEsc(initials(u.name))+'</span>'
|
|
||||||
+ pEsc(u.name)+' <span style="font-size:.65rem">▾</span></button>'
|
|
||||||
+ '<div class="pmenu" id="pmenu">'
|
|
||||||
+ '<div class="phead"><div class="n">'+pEsc(u.name)+'</div><div class="e">'+pEsc(u.email)+' · '+pEsc(u.role)+'</div></div>'
|
|
||||||
+ '<a href="/console">Console / Dashboard</a>'
|
|
||||||
+ '<a href="#">Settings</a>'
|
|
||||||
+ '<a class="danger" id="plogout">Logout</a>'
|
|
||||||
+ '</div></div>';
|
|
||||||
}
|
|
||||||
function wireProfile(){
|
|
||||||
const btn=document.getElementById('pbtn'),menu=document.getElementById('pmenu');
|
|
||||||
if(!btn)return;
|
|
||||||
btn.onclick=(e)=>{e.stopPropagation();menu.classList.toggle('open');};
|
|
||||||
document.addEventListener('click',()=>menu.classList.remove('open'));
|
|
||||||
const lo=document.getElementById('plogout');
|
|
||||||
if(lo)lo.onclick=(e)=>{e.preventDefault();alert('Mockup — logout would sign you out and return to /.');};
|
|
||||||
}
|
|
||||||
document.getElementById('hdrRight').innerHTML=profileHTML(SAMPLE_USER);
|
|
||||||
wireProfile();
|
|
||||||
document.getElementById('brandrow').onclick=()=>{location.href='/';};
|
|
||||||
|
|
||||||
// ---------- Mock chat data ----------
|
|
||||||
const CHATS=[
|
|
||||||
{name:'Anwi Systems', msg:"Perfect, the screen share worked great. Thanks!", time:'9:42 AM', unread:0, online:true, active: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},
|
|
||||||
];
|
|
||||||
|
|
||||||
const listEl=document.getElementById('chatlist');
|
|
||||||
function chatRowHTML(c,i){
|
|
||||||
const cls=['chat-row'];
|
|
||||||
if(c.active)cls.push('active');
|
|
||||||
if(c.unread>0)cls.push('unread');
|
|
||||||
return '<div class="'+cls.join(' ')+'" data-i="'+i+'">'
|
|
||||||
+ '<div class="avatar" style="background:'+avColor(c.name)+'">'+pEsc(initials(c.name))
|
|
||||||
+ '<span class="dot'+(c.online?' on':'')+'"></span></div>'
|
|
||||||
+ '<div class="chat-main">'
|
|
||||||
+ '<div class="chat-top"><span class="chat-name">'+pEsc(c.name)+'</span><span class="chat-time">'+pEsc(c.time)+'</span></div>'
|
|
||||||
+ '<div class="chat-bottom"><span class="chat-prev">'+pEsc(c.msg)+'</span>'
|
|
||||||
+ (c.unread>0?'<span class="badge">'+c.unread+'</span>':'')+'</div>'
|
|
||||||
+ '</div></div>';
|
|
||||||
}
|
|
||||||
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('')
|
|
||||||
: '<div class="no-results">No chats match “'+pEsc(filter)+'”.</div>';
|
|
||||||
listEl.querySelectorAll('.chat-row').forEach(row=>{
|
|
||||||
row.onclick=()=>{
|
|
||||||
CHATS.forEach(c=>c.active=false);
|
|
||||||
CHATS[+row.dataset.i].active=true;
|
|
||||||
CHATS[+row.dataset.i].unread=0;
|
|
||||||
renderChats(document.getElementById('chatSearch').value);
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
renderChats('');
|
|
||||||
document.getElementById('chatSearch').addEventListener('input',e=>renderChats(e.target.value));
|
|
||||||
|
|
||||||
// ---------- Tab switching ----------
|
|
||||||
const tabBtns=document.querySelectorAll('.tabs button');
|
|
||||||
const panels=document.querySelectorAll('.panel');
|
|
||||||
tabBtns.forEach(btn=>{
|
|
||||||
btn.onclick=()=>{
|
|
||||||
const tab=btn.dataset.tab;
|
|
||||||
tabBtns.forEach(b=>b.classList.toggle('active',b===btn));
|
|
||||||
panels.forEach(p=>p.classList.toggle('active',p.dataset.panel===tab));
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// Mockup-only stubs
|
|
||||||
document.querySelector('.newchat').onclick=()=>alert('Mockup — “New chat” would open the contact picker.');
|
|
||||||
document.getElementById('notifyBtn').onclick=()=>alert("Thanks! We'll let you know when Meetings launches.");
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -7,9 +7,11 @@
|
|||||||
<!-- PWA: installable on Android & iOS ("Add to Home Screen"); also enables iOS web push -->
|
<!-- PWA: installable on Android & iOS ("Add to Home Screen"); also enables iOS web push -->
|
||||||
<link rel="manifest" href="/manifest.json">
|
<link rel="manifest" href="/manifest.json">
|
||||||
<meta name="theme-color" content="#1F3B73">
|
<meta name="theme-color" content="#1F3B73">
|
||||||
|
<link rel="icon" href="/favicon.ico?v=3" sizes="any">
|
||||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png?v=3">
|
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png?v=3">
|
||||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16.png?v=3">
|
<link rel="apple-touch-icon" href="/apple-touch-icon-180.png?v=3">
|
||||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png?v=3">
|
<link rel="stylesheet" href="/bizconnect-toast.css">
|
||||||
|
<script src="/bizconnect-toast.js"></script>
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
<meta name="mobile-web-app-capable" content="yes">
|
<meta name="mobile-web-app-capable" content="yes">
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||||
@@ -645,10 +647,18 @@
|
|||||||
.modal-actions .gobtn{flex:1;border:none;border-radius:10px;padding:.6rem;font-weight:700;cursor:pointer;}
|
.modal-actions .gobtn{flex:1;border:none;border-radius:10px;padding:.6rem;font-weight:700;cursor:pointer;}
|
||||||
|
|
||||||
/* ---- Login (shown on /home when logged out) ---- */
|
/* ---- 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;}
|
.authwrap{flex:1 1 auto;display:none;align-items:center;justify-content:center;padding:1.5rem;min-height:0;
|
||||||
.authcard{background:var(--card);border:1px solid var(--line);border-radius:16px;padding:2rem;max-width:400px;width:100%;box-shadow:0 10px 30px rgba(20,30,60,.08);}
|
background:radial-gradient(1200px 600px at 50% -10%, #24437f 0%, #1F3B73 42%, #16294F 100%);}
|
||||||
.authcard h1{font-size:1.3rem;color:var(--blue);margin:0 0 .3rem;text-align:center;}
|
.authcard{background:var(--card);border:1px solid var(--line);border-radius:18px;padding:2rem 2rem 1.7rem;max-width:400px;width:100%;box-shadow:0 24px 60px rgba(9,17,38,.38);}
|
||||||
|
.authcard .auth-brand{display:flex;flex-direction:column;align-items:center;gap:.55rem;margin-bottom:1rem;}
|
||||||
|
.authcard .auth-brand img{width:66px;height:66px;border-radius:17px;box-shadow:0 10px 24px rgba(31,59,115,.30);}
|
||||||
|
.authcard .auth-brand .wm{font-size:1.15rem;font-weight:800;color:var(--blue);letter-spacing:.2px;}
|
||||||
|
.authcard .auth-brand .wm b{color:var(--brand-d);font-weight:800;}
|
||||||
|
.authcard h1{font-size:1.12rem;color:var(--blue);margin:0 0 .3rem;text-align:center;}
|
||||||
.authcard .sub{color:var(--muted);font-size:.9rem;text-align:center;margin-bottom:1.2rem;}
|
.authcard .sub{color:var(--muted);font-size:.9rem;text-align:center;margin-bottom:1.2rem;}
|
||||||
|
/* Blue card + gold CTA so the sign-in screen carries BOTH brand colours (never mono-blue). */
|
||||||
|
.authcard .gobtn{background:var(--brand);color:var(--blue-d);}
|
||||||
|
.authcard .gobtn:hover{filter:brightness(.95);}
|
||||||
.authtabs{display:flex;gap:.5rem;margin-bottom:1.1rem;}
|
.authtabs{display:flex;gap:.5rem;margin-bottom:1.1rem;}
|
||||||
.authtabs button{flex:1;background:#eef1f6;color:var(--muted);font-weight:600;border:none;border-radius:9px;padding:.5rem;cursor:pointer;font-size:.9rem;}
|
.authtabs button{flex:1;background:#eef1f6;color:var(--muted);font-weight:600;border:none;border-radius:9px;padding:.5rem;cursor:pointer;font-size:.9rem;}
|
||||||
.authtabs button.active{background:var(--blue);color:#fff;}
|
.authtabs button.active{background:var(--blue);color:#fff;}
|
||||||
@@ -667,11 +677,14 @@
|
|||||||
.hidden{display:none;}
|
.hidden{display:none;}
|
||||||
|
|
||||||
/* ---- Loading / toast ---- */
|
/* ---- Loading / toast ---- */
|
||||||
.loading{position:fixed;inset:0;display:grid;place-items:center;background:var(--bg);z-index:9000;color:var(--muted);font-size:.9rem;}
|
.loading{position:fixed;inset:0;display:grid;place-items:center;background:#1F3B73;z-index:9000;color:rgba(255,255,255,.82);font-size:.9rem;}
|
||||||
.loading .ld-inner{display:flex;flex-direction:column;align-items:center;gap:.9rem;}
|
.loading .ld-inner{display:flex;flex-direction:column;align-items:center;gap:1rem;}
|
||||||
.loading .ld-inner img{width:64px;height:64px;}
|
.loading .ld-inner img{width:72px;height:72px;}
|
||||||
.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;}
|
/* Chat thread loading (light pane) and call-connecting (dark stage) — branded, centered. */
|
||||||
.toast.show{opacity:1;transform:translateX(-50%) translateY(0);}
|
.thread-loading{height:100%;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:.7rem;color:var(--muted);font-size:.86rem;}
|
||||||
|
.call-connecting{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:1rem;background:#16294F;color:rgba(255,255,255,.82);font-size:.95rem;}
|
||||||
|
.call-connecting .cc-txt{letter-spacing:.2px;}
|
||||||
|
/* in-app toasts now render via /bizconnect-toast.css (BZToast) */
|
||||||
|
|
||||||
/* Hamburger menu button (header) */
|
/* 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{background:transparent;border:none;color:#fff;cursor:pointer;display:grid;place-items:center;width:38px;height:38px;border-radius:9px;}
|
||||||
@@ -740,11 +753,11 @@
|
|||||||
<body>
|
<body>
|
||||||
<script src="/icons.js?v=4"></script>
|
<script src="/icons.js?v=4"></script>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/@twemoji/api@15.1.0/dist/twemoji.min.js" crossorigin="anonymous"></script>
|
<script src="https://cdn.jsdelivr.net/npm/@twemoji/api@15.1.0/dist/twemoji.min.js" crossorigin="anonymous"></script>
|
||||||
<script>window.__BUILD='2026-07-02-batch26';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD);
|
<script>window.__BUILD='2026-07-02-batch32';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD);
|
||||||
// Render modern (Twemoji) emojis in place of the OS's flat ones. No-op if the CDN didn't load
|
// Render modern (Twemoji) emojis in place of the OS's flat ones. No-op if the CDN didn't load
|
||||||
// (emojis stay as plain Unicode). (#5)
|
// (emojis stay as plain Unicode). (#5)
|
||||||
function twemojify(el){ try{ if(el && window.twemoji) window.twemoji.parse(el, { folder:'svg', ext:'.svg' }); }catch(_){} }</script>
|
function twemojify(el){ try{ if(el && window.twemoji) window.twemoji.parse(el, { folder:'svg', ext:'.svg' }); }catch(_){} }</script>
|
||||||
<div class="loading" id="loading"><div class="ld-inner"><img src="/loader-orbit.svg" alt="" width="64" height="64"><span>Loading…</span></div></div>
|
<div class="loading" id="loading"><div class="ld-inner"><img src="/loaders/loader-orbit-dark.svg" alt="" width="72" height="72"><span>Loading…</span></div></div>
|
||||||
|
|
||||||
<header>
|
<header>
|
||||||
<div class="brandrow">
|
<div class="brandrow">
|
||||||
@@ -815,7 +828,6 @@ function twemojify(el){ try{ if(el && window.twemoji) window.twemoji.parse(el, {
|
|||||||
|
|
||||||
<div class="authwrap" id="authwrap"></div>
|
<div class="authwrap" id="authwrap"></div>
|
||||||
|
|
||||||
<div class="toast" id="toast"></div>
|
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// ---------- Helpers ----------
|
// ---------- Helpers ----------
|
||||||
@@ -832,8 +844,16 @@ function firstName(name){return String(name||'').trim().split(/\s+/)[0]||'there'
|
|||||||
const AV_COLORS=['#dbeafe','#e0e7ff','#dcfce7','#cffafe','#fae8ff','#fce7f3','#fee2e2','#fef3c7','#ecfccb','#fde4cf'];
|
const AV_COLORS=['#dbeafe','#e0e7ff','#dcfce7','#cffafe','#fae8ff','#fce7f3','#fee2e2','#fef3c7','#ecfccb','#fde4cf'];
|
||||||
function avColor(name){let h=0;for(const c of String(name))h=(h*31+c.charCodeAt(0))>>>0;return AV_COLORS[h%AV_COLORS.length];}
|
function avColor(name){let h=0;for(const c of String(name))h=(h*31+c.charCodeAt(0))>>>0;return AV_COLORS[h%AV_COLORS.length];}
|
||||||
|
|
||||||
let toastTimer=null;
|
// All in-app toasts route through the branded BZToast (bizconnect-toast.js). We keep the single
|
||||||
function toast(msg){const t=document.getElementById('toast');t.textContent=msg;t.classList.add('show');clearTimeout(toastTimer);toastTimer=setTimeout(()=>t.classList.remove('show'),2600);}
|
// toast(msg) entry point so every existing call site is unchanged, and classify by wording so
|
||||||
|
// errors/successes get the right colour+icon; everything else shows as a neutral message.
|
||||||
|
function toast(msg){
|
||||||
|
var s=String(msg==null?'':msg);
|
||||||
|
if(!window.BZToast) return; // loaded synchronously in <head>
|
||||||
|
if(/could ?n.t|cannot|can.t|failed|too large|invalid|error|denied|lost|blocked|not registered|required|need|no result|already/i.test(s)) return BZToast.error(s);
|
||||||
|
if(/renamed|updated|enabled|added|saved|created|invited|removed|left the|muted|photo updated|now an admin|no longer|success/i.test(s)) return BZToast.success(s);
|
||||||
|
return BZToast.message(s);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- Profile dropdown (mirrors profileHTML()/wireProfile() from console.html) ----------
|
// ---------- Profile dropdown (mirrors profileHTML()/wireProfile() from console.html) ----------
|
||||||
function profileHTML(u){
|
function profileHTML(u){
|
||||||
@@ -1180,9 +1200,12 @@ function welcomeHTML(){
|
|||||||
+ '</div></div>';
|
+ '</div></div>';
|
||||||
}
|
}
|
||||||
function wireWelcome(){ document.querySelectorAll('#chatPanel .wcard').forEach(card=>{ card.onclick=()=>switchTab(card.dataset.go); }); }
|
function wireWelcome(){ document.querySelectorAll('#chatPanel .wcard').forEach(card=>{ card.onclick=()=>switchTab(card.dataset.go); }); }
|
||||||
|
// A DM's subtitle tracks the LIVE call state (pushed on call start AND end) so it can't get stuck
|
||||||
|
// on "In a call" after the call ends — unlike it.status, which is only derived at conversation load.
|
||||||
|
function dmSubLabel(it){ return (it&&it.callActive)?'In a call':statusLabel(it); }
|
||||||
function convoShellHTML(it){
|
function convoShellHTML(it){
|
||||||
const isG=it.kind==='group';
|
const isG=it.kind==='group';
|
||||||
const sub=it.self?'Message yourself':(isG?((it.members||0)+' members'):statusLabel(it));
|
const sub=it.self?'Message yourself':(isG?((it.members||0)+' members'):dmSubLabel(it));
|
||||||
return '<div class="convo">'
|
return '<div class="convo">'
|
||||||
+ '<div class="convo-head">'
|
+ '<div class="convo-head">'
|
||||||
+ '<button class="convo-back" id="convoBack" title="Back (Esc)" aria-label="Back">'+ic('arrowLeft',18)+'</button>'
|
+ '<button class="convo-back" id="convoBack" title="Back (Esc)" aria-label="Back">'+ic('arrowLeft',18)+'</button>'
|
||||||
@@ -1385,7 +1408,11 @@ function onChatReaction(d){ const m=THREAD.find(x=>x.id===d.messageId); if(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}}); }
|
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.
|
// 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(); } }
|
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(); }
|
||||||
|
// #6: keep the chat-list tick in sync with the thread — my last message is now read (blue double).
|
||||||
|
const it=rowFor('dm', d.by); if(it && it.last_from_me && it.last_status!=='read'){ it.last_status='read'; renderChats(searchVal()); }
|
||||||
|
}
|
||||||
// Delete-for-everyone: confirm, tell the server, then blank the message locally (the server also
|
// Delete-for-everyone: confirm, tell the server, then blank the message locally (the server also
|
||||||
// broadcasts chat-deleted to the other side / other tabs).
|
// broadcasts chat-deleted to the other side / other tabs).
|
||||||
async function deleteMessage(id){ if(!confirm('Delete this message for everyone?')) return; try{ await postJSON('/api/messages/delete',{id}); markMsgDeleted(id); }catch(e){ toast(e.message||'Could not delete'); } }
|
async function deleteMessage(id){ if(!confirm('Delete this message for everyone?')) return; try{ await postJSON('/api/messages/delete',{id}); markMsgDeleted(id); }catch(e){ toast(e.message||'Could not delete'); } }
|
||||||
@@ -1397,7 +1424,11 @@ function markMsgDeleted(id){
|
|||||||
}
|
}
|
||||||
function onChatDeleted(d){ if(!d||!d.id) return; markMsgDeleted(d.id); try{ loadSidebar(); }catch(_){} } // refresh last-message previews
|
function onChatDeleted(d){ if(!d||!d.id) return; markMsgDeleted(d.id); try{ loadSidebar(); }catch(_){} } // refresh last-message previews
|
||||||
// DM delivered: recipient's client acked → second (grey) tick.
|
// 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); } }
|
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); }
|
||||||
|
// #6: mirror the delivered state onto the chat-list tick (double grey), unless it's already read.
|
||||||
|
const it=d.with?rowFor('dm', d.with):null; if(it && it.last_from_me && it.last_status==='sent'){ it.last_status='delivered'; renderChats(searchVal()); }
|
||||||
|
}
|
||||||
// Group read: a member opened the group → add them to "Seen by" on my messages up to that time.
|
// 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); } } }); } }
|
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).
|
// Shared group call: start it (or join the live one — the server returns the existing room).
|
||||||
@@ -1419,9 +1450,20 @@ 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); } }
|
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'); }
|
catch(e){ toast(e.message||'Could not start the call'); }
|
||||||
}
|
}
|
||||||
|
// Live presence: a contact came online/offline or entered/left a call — update their dot + the
|
||||||
|
// open chat header immediately (no refresh, which the desktop/mobile apps can't do). #6
|
||||||
|
function onPresence(d){
|
||||||
|
if(!d||!d.userId) return;
|
||||||
|
const it=rowFor('dm', d.userId);
|
||||||
|
if(it){ it.online=!!d.online; it.status=d.status||'active'; renderChats(searchVal()); }
|
||||||
|
if(selected && selected.kind==='dm' && selected.id===d.userId && it){
|
||||||
|
const sub=document.querySelector('#convoTitle .st'); if(sub) sub.textContent=dmSubLabel(it);
|
||||||
|
const dot=document.querySelector('.convo-head .avatar .dot'); if(dot) dot.className='dot '+statusCls(it);
|
||||||
|
}
|
||||||
|
}
|
||||||
function onDmCall(d){
|
function onDmCall(d){
|
||||||
if(!d) return; const it=rowFor('dm', d.with); if(it){ it.callActive=!!d.active; it.callRoom=d.room||null; }
|
if(!d) return; const it=rowFor('dm', d.with); if(it){ it.callActive=!!d.active; it.callRoom=d.room||null; if(!d.active && it.status==='incall') it.status='active'; } // #4: drop the stuck "in call" status
|
||||||
if(selected&&selected.kind==='dm'&&selected.id===d.with) updateCallBtn(!!d.active);
|
if(selected&&selected.kind==='dm'&&selected.id===d.with){ updateCallBtn(!!d.active); const s=document.querySelector('#convoTitle .st'); if(s&&it) s.textContent=dmSubLabel(it); } // refresh the header subtitle live
|
||||||
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 && 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
|
if(!d.active) dismissCallInvite(d.room); // call ended/declined — stop ringing
|
||||||
renderChats(searchVal());
|
renderChats(searchVal());
|
||||||
@@ -1629,6 +1671,7 @@ async function openConvo(kind,id){
|
|||||||
// when opened from a notification; an async-only render would defer the paint until a click).
|
// when opened from a notification; an async-only render would defer the paint until a click).
|
||||||
const ckey=kind+':'+id;
|
const ckey=kind+':'+id;
|
||||||
if(THREAD_CACHE.has(ckey)){ THREAD=THREAD_CACHE.get(ckey).slice(); renderThread(); }
|
if(THREAD_CACHE.has(ckey)){ THREAD=THREAD_CACHE.get(ckey).slice(); renderThread(); }
|
||||||
|
else if(box){ box.innerHTML='<div class="thread-loading"><img src="/loaders/loader-ring.svg" width="34" height="34" alt=""><span>Loading messages…</span></div>'; } // branded loader instead of a blank pane on slow links
|
||||||
if(kind==='group'){ try{ convoMembers=await fetch('/api/groups/members?group='+encodeURIComponent(id)).then(r=>r.json())||[]; }catch(_){ convoMembers=[]; } }
|
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;
|
if(!selected||selected.kind!==kind||selected.id!==id) return;
|
||||||
wireMentions();
|
wireMentions();
|
||||||
@@ -1894,7 +1937,11 @@ async function setupNativePush(){
|
|||||||
return true; // handled the native path (skip Web Push regardless of outcome)
|
return true; // handled the native path (skip Web Push regardless of outcome)
|
||||||
}
|
}
|
||||||
async function setupPush(){
|
async function setupPush(){
|
||||||
if(await setupNativePush()) return; // native app → FCM/APNs, not Web Push
|
if(await setupNativePush()) return; // mobile native app → FCM/APNs, not Web Push
|
||||||
|
// Desktop shell: the in-page bridge already shows a native toast (notify → replyNotify) while the
|
||||||
|
// app runs, so ALSO subscribing to Web Push made every message pop TWICE (in-app toast + SW push,
|
||||||
|
// with different icons). Drop any Web Push subscription here so only the branded native toast fires.
|
||||||
|
if(nativePlatform()==='desktop'){ try{ await unsubscribePush(); }catch(_){} console.log('[push] desktop shell → native toasts only, skipping Web Push'); return; }
|
||||||
if(!('serviceWorker' in navigator) || !('PushManager' in window)){ console.warn('[push] not supported by this browser'); return; }
|
if(!('serviceWorker' in navigator) || !('PushManager' in window)){ console.warn('[push] not supported by this browser'); return; }
|
||||||
try{ await navigator.serviceWorker.register('/sw.js'); }catch(e){ console.warn('[push] SW register failed:', e); return; }
|
try{ await navigator.serviceWorker.register('/sw.js'); }catch(e){ console.warn('[push] SW register failed:', e); return; }
|
||||||
try{ _swReg=await navigator.serviceWorker.ready; }catch(e){ console.warn('[push] SW never became ready:', e); return; } // ensure an ACTIVE worker before subscribe()
|
try{ _swReg=await navigator.serviceWorker.ready; }catch(e){ console.warn('[push] SW never became ready:', e); return; } // ensure an ACTIVE worker before subscribe()
|
||||||
@@ -1960,11 +2007,16 @@ function notify(title, body, kind, id){
|
|||||||
}).catch(()=>{});
|
}).catch(()=>{});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Web / PWA: standard Notification.
|
// Web / PWA: standard Notification. Icon = the sender's/group's DP (falling back to their
|
||||||
|
// initials, then the brand app icon) — never the old wordmark. badge = brand mark.
|
||||||
if(!('Notification' in window) || Notification.permission!=='granted') return;
|
if(!('Notification' in window) || Notification.permission!=='granted') return;
|
||||||
const n=new Notification(title, { body, icon:'/logo.png' });
|
notifAvatarDataUrl(kind, id, String(title||'').replace(/^[^\p{L}\p{N}]+/u,'')).then(avatar=>{
|
||||||
n.onclick=()=>{ n.close(); openFromNotif(kind, id); };
|
const n=new Notification(title, { body, icon: avatar || '/icon-192.png', badge:'/icon-192.png' });
|
||||||
setTimeout(()=>{ try{ n.close(); }catch(_){} }, 8000);
|
n.onclick=()=>{ n.close(); openFromNotif(kind, id); };
|
||||||
|
setTimeout(()=>{ try{ n.close(); }catch(_){} }, 8000);
|
||||||
|
}).catch(()=>{
|
||||||
|
try{ const n=new Notification(title, { body, icon:'/icon-192.png' }); n.onclick=()=>{ n.close(); openFromNotif(kind, id); }; }catch(_){}
|
||||||
|
});
|
||||||
}catch(_){}
|
}catch(_){}
|
||||||
}
|
}
|
||||||
let _audioCtx=null;
|
let _audioCtx=null;
|
||||||
@@ -2038,7 +2090,7 @@ function onChatMessage(m){
|
|||||||
if(!THREAD.some(x=>x.id===m.id)){ THREAD.push(m); appendBubble(m); }
|
if(!THREAD.some(x=>x.id===m.id)){ 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(_){} }
|
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)){
|
} 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'));
|
if(window.BZToast) BZToast.message(m.body?(m.body.length>60?m.body.slice(0,60)+'…':m.body):'📎 Attachment', {title:(m.fromName||'New message')});
|
||||||
}
|
}
|
||||||
renderChats(searchVal()); updateRailUnread();
|
renderChats(searchVal()); updateRailUnread();
|
||||||
}
|
}
|
||||||
@@ -2047,7 +2099,7 @@ function connectChatWs(){
|
|||||||
try{
|
try{
|
||||||
chatWs=new WebSocket((location.protocol==='https:'?'wss://':'ws://')+location.host+'/ws');
|
chatWs=new WebSocket((location.protocol==='https:'?'wss://':'ws://')+location.host+'/ws');
|
||||||
chatWs.onopen=()=>{ try{ chatWs.send(JSON.stringify({type:'chat-hello'})); }catch(_){} };
|
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-deleted') onChatDeleted(d); else if(d.type==='chat-reaction') onChatReaction(d); else if(d.type==='poll-update' && d.poll) onPollUpdate(d); else if(d.type==='chat-read') onChatRead(d); else if(d.type==='chat-delivered') onChatDelivered(d); else if(d.type==='group-read') onGroupRead(d); else if(d.type==='group-call') onGroupCall(d); else if(d.type==='dm-call') onDmCall(d); else if(d.type==='group-update') onGroupUpdate(d); else if(d.type==='call-invite') showCallInvite(d.room, d.byName); else if(d.type==='meeting-invite') showMeetingInvite(d.meeting); else if(d.type==='meeting-reminder') showMeetingReminder(d.meeting); else if(d.type==='meeting-cancelled') showMeetingCancelled(d.meeting); else if(d.type==='group-role') onGroupRole(d); };
|
chatWs.onmessage=(e)=>{ let d; try{ d=JSON.parse(e.data); }catch(_){ return; } if(d.type==='chat-message' && d.message) onChatMessage(d.message); else if(d.type==='chat-deleted') onChatDeleted(d); else if(d.type==='chat-reaction') onChatReaction(d); else if(d.type==='poll-update' && d.poll) onPollUpdate(d); else if(d.type==='chat-read') onChatRead(d); else if(d.type==='chat-delivered') onChatDelivered(d); else if(d.type==='group-read') onGroupRead(d); else if(d.type==='group-call') onGroupCall(d); else if(d.type==='dm-call') onDmCall(d); else if(d.type==='presence') onPresence(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
|
chatWs.onclose=()=>{ setTimeout(connectChatWs, 3000); }; // auto-reconnect
|
||||||
}catch(_){}
|
}catch(_){}
|
||||||
}
|
}
|
||||||
@@ -2379,6 +2431,12 @@ function openScheduleModal(gid, editMtg){
|
|||||||
}catch(e){ err.textContent=e.message||'Could not save'; }
|
}catch(e){ err.textContent=e.message||'Could not save'; }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
// Branded connecting screen shown from the moment a call is entered until the room is ready —
|
||||||
|
// so a slow link shows our loader, not a blank/half meeting window.
|
||||||
|
function renderCallConnecting(){
|
||||||
|
const el=document.getElementById('meetingPanel'); if(!el) return;
|
||||||
|
el.innerHTML='<div class="call-connecting"><img src="/loaders/loader-orbit-dark.svg" width="76" height="76" alt=""><div class="cc-txt">Connecting to call…</div></div>';
|
||||||
|
}
|
||||||
function renderCall(){
|
function renderCall(){
|
||||||
const el=document.getElementById('meetingPanel'); if(!el) return;
|
const el=document.getElementById('meetingPanel'); if(!el) return;
|
||||||
el.innerHTML='<div class="meet"><div class="meet-grid" id="meetGrid"></div>'
|
el.innerHTML='<div class="meet"><div class="meet-grid" id="meetGrid"></div>'
|
||||||
@@ -2571,6 +2629,7 @@ async function enterMeeting(code, audioOnly){
|
|||||||
meetRec=null; meetTranscribe=false; meetRoomTx=false; meetSR=null; _addPool=null; meetStageId=null;
|
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(_){}
|
try{ const c=await fetch('/api/ice').then(r=>r.json()); if(c&&c.iceServers) MEET_ICE=c; }catch(_){}
|
||||||
meetState='call'; meetRailLive(true);
|
meetState='call'; meetRailLive(true);
|
||||||
|
renderCallConnecting(); // branded "Connecting…" until the room is created/joined (esp. on slow links)
|
||||||
meetWs=new WebSocket((location.protocol==='https:'?'wss://':'ws://')+location.host+'/ws');
|
meetWs=new WebSocket((location.protocol==='https:'?'wss://':'ws://')+location.host+'/ws');
|
||||||
meetWs.onmessage=onMeetMsg;
|
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'}); } };
|
meetWs.onopen=()=>{ if(code){ meetRoom=code; renderCall(); meetSend({type:'meeting-join', room:code, name:(ME.name||ME.email||'Guest')}); } else { meetSend({type:'meeting-create'}); } };
|
||||||
@@ -2590,7 +2649,7 @@ async function onMeetMsg(e){
|
|||||||
refreshMeetPanel(); updateHostControls();
|
refreshMeetPanel(); updateHostControls();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if(m.type==='meeting-ended'){ toast('Call ended'); leaveMeeting(); return; } // 1:1 hangup, or host ended
|
if(m.type==='meeting-ended'){ toast('Call ended'); leaveMeeting(true); return; } // 1:1 hangup, or host ended
|
||||||
if(m.type==='meeting-peer-joined'){
|
if(m.type==='meeting-peer-joined'){
|
||||||
meetNames.set(m.peerId,m.name); if(m.avatar) meetAvatars.set(m.peerId,m.avatar); if(m.uid){ meetPeerUids.set(m.peerId,m.uid); meetInviteJoined(m.uid); }
|
meetNames.set(m.peerId,m.name); if(m.avatar) meetAvatars.set(m.peerId,m.avatar); if(m.uid){ meetPeerUids.set(m.peerId,m.uid); meetInviteJoined(m.uid); }
|
||||||
const pc=meetMakePeer(m.peerId,m.name); // I'm an existing peer → I OFFER to the newcomer (carries my screen)
|
const pc=meetMakePeer(m.peerId,m.name); // I'm an existing peer → I OFFER to the newcomer (carries my screen)
|
||||||
@@ -2619,7 +2678,7 @@ async function onMeetMsg(e){
|
|||||||
} else if(d.candidate){ const p=meetPeers.get(from); if(p&&p.pc){ try{ await p.pc.addIceCandidate(d.candidate); }catch(_){} } }
|
} else if(d.candidate){ const p=meetPeers.get(from); if(p&&p.pc){ try{ await p.pc.addIceCandidate(d.candidate); }catch(_){} } }
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if(m.type==='error'){ const msg=m.message; leaveMeeting(); const e2=document.getElementById('meetErr'); if(e2) e2.textContent=msg||'Meeting error'; return; }
|
if(m.type==='error'){ const msg=m.message; leaveMeeting(true); 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 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); } }
|
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); } }
|
||||||
@@ -2658,10 +2717,14 @@ async function toggleCam(){
|
|||||||
setTileMute('__local', !meetMic); meetSend({type:'meeting-state', muted:!meetMic, camOff:!meetCam});
|
setTileMute('__local', !meetMic); meetSend({type:'meeting-state', muted:!meetMic, camOff:!meetCam});
|
||||||
}
|
}
|
||||||
let meetLeaving=false;
|
let meetLeaving=false;
|
||||||
function leaveMeeting(){
|
// forced = the call is ending for us (1:1 hangup, host ended, room closed/error) rather than us
|
||||||
|
// voluntarily stepping out of a group others are still in. Only a VOLUNTARY exit from a GROUP
|
||||||
|
// call hands the host role on; a 1:1 call (or a forced end) has nobody to hand to — handing off
|
||||||
|
// there wrongly announced "Host handed to <the person who just left>" (bug).
|
||||||
|
function leaveMeeting(forced){
|
||||||
if(meetLeaving) return; meetLeaving=true;
|
if(meetLeaving) return; meetLeaving=true;
|
||||||
// Host leaving voluntarily must hand off so the meeting isn't left host-less.
|
const isDm=!!(meetReturn && meetReturn.kind==='dm');
|
||||||
if(meetIsHost && meetPeers.size>0){
|
if(!forced && !isDm && meetIsHost && meetPeers.size>0){
|
||||||
const next=meetPeers.keys().next().value; // first remaining participant
|
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(next){ meetSend({type:'meeting-host', to:next}); toast('Host handed to '+(meetNames.get(next)||'a participant')); }
|
||||||
}
|
}
|
||||||
@@ -2770,7 +2833,8 @@ async function renderLogin(){
|
|||||||
const aw=document.getElementById('authwrap'); aw.style.display='flex';
|
const aw=document.getElementById('authwrap'); aw.style.display='flex';
|
||||||
let regOpen=false; try{ regOpen=(await (await fetch('/api/setup-state')).json()).registrationOpen; }catch(_){}
|
let regOpen=false; try{ regOpen=(await (await fetch('/api/setup-state')).json()).registrationOpen; }catch(_){}
|
||||||
aw.innerHTML=`<div class="authcard">
|
aw.innerHTML=`<div class="authcard">
|
||||||
<h1>Welcome to Biz Connect</h1>
|
<div class="auth-brand"><img src="/icon-192.png" alt="Biz Connect"><div class="wm">Biz <b>Connect</b></div></div>
|
||||||
|
<h1>Welcome back</h1>
|
||||||
<div class="sub">Sign in to access chats, screen share and connect.</div>
|
<div class="sub">Sign in to access chats, screen share and connect.</div>
|
||||||
${regOpen?`<div class="authtabs">
|
${regOpen?`<div class="authtabs">
|
||||||
<button id="tabLogin" class="active">Sign in</button>
|
<button id="tabLogin" class="active">Sign in</button>
|
||||||
|
|||||||
@@ -4,6 +4,10 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Browser Host — Remote Access</title>
|
<title>Browser Host — Remote Access</title>
|
||||||
|
<meta name="theme-color" content="#1F3B73">
|
||||||
|
<link rel="icon" href="/favicon.ico?v=3" sizes="any">
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png?v=3">
|
||||||
|
<link rel="apple-touch-icon" href="/apple-touch-icon-180.png?v=3">
|
||||||
<style>
|
<style>
|
||||||
body { font-family: system-ui, sans-serif; background:#0f172a; color:#e2e8f0; margin:0; padding:1.5rem; }
|
body { font-family: system-ui, sans-serif; background:#0f172a; color:#e2e8f0; margin:0; padding:1.5rem; }
|
||||||
.card { max-width:560px; margin:0 auto; background:#1e293b; border-radius:12px; padding:1.5rem; }
|
.card { max-width:560px; margin:0 auto; background:#1e293b; border-radius:12px; padding:1.5rem; }
|
||||||
|
|||||||
@@ -5,9 +5,11 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Biz Connect</title>
|
<title>Biz Connect</title>
|
||||||
<meta name="theme-color" content="#1F3B73">
|
<meta name="theme-color" content="#1F3B73">
|
||||||
|
<link rel="icon" href="/favicon.ico?v=3" sizes="any">
|
||||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png?v=3">
|
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png?v=3">
|
||||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16.png?v=3">
|
<link rel="apple-touch-icon" href="/apple-touch-icon-180.png?v=3">
|
||||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png?v=3">
|
<link rel="stylesheet" href="/bizconnect-toast.css">
|
||||||
|
<script src="/bizconnect-toast.js"></script>
|
||||||
<style>
|
<style>
|
||||||
:root{ --brand:#FFC708; --brand-d:#E0AC00; --blue:#1F3B73; --blue-d:#16294f; --blue-soft:#EAF0FB; --ink:#1f2430; --muted:#6b7280; --bg:#f6f8fb; --line:#e6e9ef; }
|
:root{ --brand:#FFC708; --brand-d:#E0AC00; --blue:#1F3B73; --blue-d:#16294f; --blue-soft:#EAF0FB; --ink:#1f2430; --muted:#6b7280; --bg:#f6f8fb; --line:#e6e9ef; }
|
||||||
*{box-sizing:border-box;}
|
*{box-sizing:border-box;}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 471 B After Width: | Height: | Size: 471 B |
|
Before Width: | Height: | Size: 471 B After Width: | Height: | Size: 471 B |
|
Before Width: | Height: | Size: 473 B After Width: | Height: | Size: 473 B |
@@ -4,6 +4,12 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Biz Connect — Share your screen</title>
|
<title>Biz Connect — Share your screen</title>
|
||||||
|
<meta name="theme-color" content="#1F3B73">
|
||||||
|
<link rel="icon" href="/favicon.ico?v=3" sizes="any">
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png?v=3">
|
||||||
|
<link rel="apple-touch-icon" href="/apple-touch-icon-180.png?v=3">
|
||||||
|
<link rel="stylesheet" href="/bizconnect-toast.css">
|
||||||
|
<script src="/bizconnect-toast.js"></script>
|
||||||
<style>
|
<style>
|
||||||
:root{ --brand:#FFC708; --brand-d:#E0AC00; --blue:#1F3B73; --blue-d:#16294f; --blue-soft:#EAF0FB; --ink:#1f2430; --muted:#6b7280; --bg:#f6f8fb; --card:#ffffff; --line:#e6e9ef; }
|
:root{ --brand:#FFC708; --brand-d:#E0AC00; --blue:#1F3B73; --blue-d:#16294f; --blue-soft:#EAF0FB; --ink:#1f2430; --muted:#6b7280; --bg:#f6f8fb; --card:#ffffff; --line:#e6e9ef; }
|
||||||
*{box-sizing:border-box;}
|
*{box-sizing:border-box;}
|
||||||
@@ -275,8 +281,9 @@ function buildChatPanel(){
|
|||||||
}
|
}
|
||||||
function toggleChat(){const p=document.getElementById('chatPanel');if(!p)return;chatOpen=!chatOpen;p.style.display=chatOpen?'flex':'none';const b=document.getElementById('chatBtn');if(chatOpen){b&&(b.style.background='#475569');const i=document.getElementById('chatInput');if(i)setTimeout(()=>i.focus(),50);}}
|
function toggleChat(){const p=document.getElementById('chatPanel');if(!p)return;chatOpen=!chatOpen;p.style.display=chatOpen?'flex':'none';const b=document.getElementById('chatBtn');if(chatOpen){b&&(b.style.background='#475569');const i=document.getElementById('chatInput');if(i)setTimeout(()=>i.focus(),50);}}
|
||||||
function addChat(msg){const c=document.getElementById('chatMsgs');if(!c)return;const mine=msg.from==='__self';const w=document.createElement('div');w.style.cssText='max-width:85%;padding:.4rem .6rem;border-radius:10px;'+(mine?'align-self:flex-end;background:#EAF0FB;color:#16294f':'align-self:flex-start;background:#f1f5f9;color:#1f2430');w.innerHTML='<div style="font-size:.7rem;opacity:.65;margin-bottom:2px">'+esc(msg.name||'')+'</div>'+esc(msg.text);c.appendChild(w);c.scrollTop=c.scrollHeight;if(!mine)notifyMsg(msg);}
|
function addChat(msg){const c=document.getElementById('chatMsgs');if(!c)return;const mine=msg.from==='__self';const w=document.createElement('div');w.style.cssText='max-width:85%;padding:.4rem .6rem;border-radius:10px;'+(mine?'align-self:flex-end;background:#EAF0FB;color:#16294f':'align-self:flex-start;background:#f1f5f9;color:#1f2430');w.innerHTML='<div style="font-size:.7rem;opacity:.65;margin-bottom:2px">'+esc(msg.name||'')+'</div>'+esc(msg.text);c.appendChild(w);c.scrollTop=c.scrollHeight;if(!mine)notifyMsg(msg);}
|
||||||
function notifyMsg(msg){const b=document.getElementById('chatBtn');if(b&&!chatOpen){b.style.background='#FFC708';b.style.color='#1f2430';}toast((msg.name||'Message')+': '+msg.text);try{beep();}catch(_){}if('Notification' in window && Notification.permission==='granted' && (document.hidden||!chatOpen)){try{new Notification('New message from '+(msg.name||'support'),{body:msg.text});}catch(_){}}}
|
function notifyMsg(msg){const b=document.getElementById('chatBtn');if(b&&!chatOpen){b.style.background='#FFC708';b.style.color='#1f2430';}if(window.BZToast)BZToast.message(msg.text,{title:(msg.name||'Message')});try{beep();}catch(_){}if('Notification' in window && Notification.permission==='granted' && (document.hidden||!chatOpen)){try{new Notification('New message from '+(msg.name||'support'),{body:msg.text});}catch(_){}}}
|
||||||
function toast(text){let t=document.getElementById('msgToast');if(!t){t=document.createElement('div');t.id='msgToast';t.style.cssText='position:fixed;top:18px;left:50%;transform:translateX(-50%);z-index:2147483600;background:#16a34a;color:#fff;padding:.7rem 1.1rem;border-radius:12px;box-shadow:0 10px 26px rgba(0,0,0,.35);font-size:.92rem;font-weight:600;border:2px solid #0c7a36;max-width:82vw;transition:opacity .4s';document.body.appendChild(t);}t.innerHTML='\ud83d\udcac '+text;t.style.opacity='1';clearTimeout(window.__toastT);window.__toastT=setTimeout(()=>{t.style.opacity='0';},2800);}
|
// Branded toast (BZToast, /bizconnect-toast.js). Classify by wording so errors show red.
|
||||||
|
function toast(text){var s=String(text==null?'':text);if(!window.BZToast)return;if(/could ?n.t|cannot|failed|invalid|error|denied|expired|not found|please enter/i.test(s))return BZToast.error(s);return BZToast.message(s);}
|
||||||
let __ac=null;
|
let __ac=null;
|
||||||
function ensureAudio(){try{__ac=__ac||new (window.AudioContext||window.webkitAudioContext)();if(__ac.state==='suspended')__ac.resume();}catch(_){}}
|
function ensureAudio(){try{__ac=__ac||new (window.AudioContext||window.webkitAudioContext)();if(__ac.state==='suspended')__ac.resume();}catch(_){}}
|
||||||
function beep(){ensureAudio();if(!__ac)return;try{const o=__ac.createOscillator(),g=__ac.createGain();o.type='sine';o.connect(g);g.connect(__ac.destination);const t0=__ac.currentTime;o.frequency.setValueAtTime(880,t0);o.frequency.setValueAtTime(660,t0+0.09);g.gain.setValueAtTime(0.0001,t0);g.gain.exponentialRampToValueAtTime(0.12,t0+0.02);g.gain.exponentialRampToValueAtTime(0.0001,t0+0.22);o.start(t0);o.stop(t0+0.24);}catch(_){}}
|
function beep(){ensureAudio();if(!__ac)return;try{const o=__ac.createOscillator(),g=__ac.createGain();o.type='sine';o.connect(g);g.connect(__ac.destination);const t0=__ac.currentTime;o.frequency.setValueAtTime(880,t0);o.frequency.setValueAtTime(660,t0+0.09);g.gain.setValueAtTime(0.0001,t0);g.gain.exponentialRampToValueAtTime(0.12,t0+0.02);g.gain.exponentialRampToValueAtTime(0.0001,t0+0.22);o.start(t0);o.stop(t0+0.24);}catch(_){}}
|
||||||
|
|||||||
|
After Width: | Height: | Size: 106 KiB |
@@ -15,8 +15,8 @@ self.addEventListener('push', (event) => {
|
|||||||
const title = d.title || 'Biz Connect';
|
const title = d.title || 'Biz Connect';
|
||||||
const options = {
|
const options = {
|
||||||
body: d.body || '',
|
body: d.body || '',
|
||||||
icon: '/logo.png',
|
icon: d.icon || '/icon-192.png', // sender/group DP when the server provides one, else brand icon (not the old wordmark)
|
||||||
badge: '/logo.png',
|
badge: '/icon-192.png',
|
||||||
tag: d.tag || undefined, // collapse repeats from the same chat
|
tag: d.tag || undefined, // collapse repeats from the same chat
|
||||||
renotify: !!d.tag,
|
renotify: !!d.tag,
|
||||||
data: { kind: d.kind || '', id: d.id || '' },
|
data: { kind: d.kind || '', id: d.id || '' },
|
||||||
|
|||||||
@@ -3,6 +3,10 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<title>Remote Session</title>
|
<title>Remote Session</title>
|
||||||
|
<meta name="theme-color" content="#1F3B73">
|
||||||
|
<link rel="icon" href="/favicon.ico?v=3" sizes="any">
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png?v=3">
|
||||||
|
<link rel="apple-touch-icon" href="/apple-touch-icon-180.png?v=3">
|
||||||
<style>
|
<style>
|
||||||
body { font-family: system-ui, sans-serif; background: #0f172a; color: #e2e8f0; margin: 0; }
|
body { font-family: system-ui, sans-serif; background: #0f172a; color: #e2e8f0; margin: 0; }
|
||||||
header { background: #1e293b; padding: 0.6rem 1rem; display: flex; justify-content: space-between; align-items: center; }
|
header { background: #1e293b; padding: 0.6rem 1rem; display: flex; justify-content: space-between; align-items: center; }
|
||||||
@@ -15,7 +19,7 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header>
|
<header>
|
||||||
<div id="status">Connecting…</div>
|
<div id="status"><img src="/loaders/loader-orbit-dark.svg" width="18" height="18" style="vertical-align:-4px;margin-right:6px" alt="">Connecting…</div>
|
||||||
<div>
|
<div>
|
||||||
<a href="/"><span data-ic="arrowLeft" data-sz="16"></span> Console</a>
|
<a href="/"><span data-ic="arrowLeft" data-sz="16"></span> Console</a>
|
||||||
<button id="endBtn">End session</button>
|
<button id="endBtn">End session</button>
|
||||||
|
|||||||
@@ -300,6 +300,7 @@ route('POST', '/api/me/status', async (req, res) => {
|
|||||||
const { status } = await readBody(req);
|
const { status } = await readBody(req);
|
||||||
if (!['active', 'away', 'onleave'].includes(status)) return json(res, 400, { error: 'invalid status' });
|
if (!['active', 'away', 'onleave'].includes(status)) return json(res, 400, { error: 'invalid status' });
|
||||||
try { R.users.setStatus(u.id, status); } catch (_) {}
|
try { R.users.setStatus(u.id, status); } catch (_) {}
|
||||||
|
try { CHAT.broadcastPresence(u.id); } catch (_) {} // push the new status to contacts live (no refresh)
|
||||||
json(res, 200, { ok: true, status });
|
json(res, 200, { ok: true, status });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ function handle(ws, m, req) {
|
|||||||
ws._chatUserId = u.id; ws._chatTeamId = u.team_id;
|
ws._chatUserId = u.id; ws._chatTeamId = u.team_id;
|
||||||
CHAT.register(u.id, ws);
|
CHAT.register(u.id, ws);
|
||||||
ws.send(JSON.stringify({ type: 'chat-ready' }));
|
ws.send(JSON.stringify({ type: 'chat-ready' }));
|
||||||
|
CHAT.broadcastPresence(u.id); // tell contacts this user just came online
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
// Recipient's client acknowledges a DM was delivered → mark it + tell the sender.
|
// Recipient's client acknowledges a DM was delivered → mark it + tell the sender.
|
||||||
@@ -37,7 +38,7 @@ function handle(ws, m, req) {
|
|||||||
const msg = R.messages.byId(m.id);
|
const msg = R.messages.byId(m.id);
|
||||||
if (!msg || msg.conversation_id || msg.team_id !== ws._chatTeamId) break; // DMs only
|
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.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 (_) {} }
|
if (!msg.delivered_at) { R.messages.markDelivered(m.id); try { CHAT.pushToUser(msg.sender_id, { type: 'chat-delivered', id: m.id, with: msg.recipient_id }); } catch (_) {} }
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
// --- Meetings (mesh): create a room, join by code, relay SDP/ICE peer-to-peer ---
|
// --- Meetings (mesh): create a room, join by code, relay SDP/ICE peer-to-peer ---
|
||||||
@@ -72,6 +73,7 @@ function handle(ws, m, req) {
|
|||||||
// …and tell existing peers a newcomer arrived.
|
// …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, avatar, uid: ws._meetingUserId || null })); }
|
for (const [, p] of peers) { if (p.ws.readyState === 1) p.ws.send(JSON.stringify({ type: 'meeting-peer-joined', peerId, name, avatar, uid: ws._meetingUserId || null })); }
|
||||||
peers.set(peerId, { ws, name, avatar, uid: ws._meetingUserId || null });
|
peers.set(peerId, { ws, name, avatar, uid: ws._meetingUserId || null });
|
||||||
|
if (ws._meetingUserId) CHAT.broadcastPresence(ws._meetingUserId); // now in a call → update contacts live
|
||||||
const tsubs = transcriptSubs.get(room); if (tsubs && tsubs.size > 0) ws.send(JSON.stringify({ type: 'meeting-transcribe-state', active: true })); // catch up: already transcribing
|
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;
|
break;
|
||||||
}
|
}
|
||||||
@@ -286,16 +288,20 @@ function leaveMeeting(ws) {
|
|||||||
const peers = meetingRooms.get(room);
|
const peers = meetingRooms.get(room);
|
||||||
ws._meetingRoom = null;
|
ws._meetingRoom = null;
|
||||||
const pid = ws._peerId;
|
const pid = ws._peerId;
|
||||||
if (!peers) return;
|
const leaverId = ws._meetingUserId;
|
||||||
|
if (!peers) { if (leaverId) CHAT.broadcastPresence(leaverId); return; }
|
||||||
try { require('./calls').finalizeTranscript(room, ws._meetingUserId); } catch (_) {} // save THIS user's transcript
|
try { require('./calls').finalizeTranscript(room, ws._meetingUserId); } catch (_) {} // save THIS user's transcript
|
||||||
peers.delete(pid);
|
peers.delete(pid);
|
||||||
// 1:1 call: when either party leaves, end it for everyone (a DM call has no "remaining" call).
|
// 1:1 call: when either party leaves, end it for everyone (a DM call has no "remaining" call).
|
||||||
if (roomToDmCall.has(room)) {
|
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; } }
|
const others = [...peers.values()].map((p) => p.ws && p.ws._meetingUserId).filter(Boolean);
|
||||||
|
for (const [, p] of peers) { if (p.ws.readyState === 1) { try { p.ws.send(JSON.stringify({ type: 'meeting-ended' })); } catch (_) {} p.ws._meetingRoom = null; } }
|
||||||
meetingRooms.delete(room);
|
meetingRooms.delete(room);
|
||||||
try { require('./calls').finalizeTranscript(room); } catch (_) {} // any remaining buffers (safety)
|
try { require('./calls').finalizeTranscript(room); } catch (_) {} // any remaining buffers (safety)
|
||||||
roomHost.delete(room);
|
roomHost.delete(room);
|
||||||
try { require('./calls').endCallByRoom(room); } catch (_) {}
|
try { require('./calls').endCallByRoom(room); } catch (_) {}
|
||||||
|
if (leaverId) CHAT.broadcastPresence(leaverId);
|
||||||
|
others.forEach((uid) => CHAT.broadcastPresence(uid)); // both parties are now out of the call → live update
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
for (const [, p] of peers) { if (p.ws.readyState === 1) p.ws.send(JSON.stringify({ type: 'meeting-peer-left', peerId: pid })); }
|
for (const [, p] of peers) { if (p.ws.readyState === 1) p.ws.send(JSON.stringify({ type: 'meeting-peer-left', peerId: pid })); }
|
||||||
@@ -305,11 +311,15 @@ function leaveMeeting(ws) {
|
|||||||
roomHost.delete(room);
|
roomHost.delete(room);
|
||||||
try { require('./calls').endCallByRoom(room); } catch (_) {}
|
try { require('./calls').endCallByRoom(room); } catch (_) {}
|
||||||
}
|
}
|
||||||
|
if (leaverId) CHAT.broadcastPresence(leaverId); // this user left the call → update contacts live
|
||||||
}
|
}
|
||||||
|
|
||||||
function cleanup(ws) {
|
function cleanup(ws) {
|
||||||
|
const goneUserId = ws._chatUserId; // capture before unregister so we can announce the change
|
||||||
CHAT.unregister(ws);
|
CHAT.unregister(ws);
|
||||||
leaveMeeting(ws);
|
leaveMeeting(ws);
|
||||||
|
if (goneUserId) CHAT.broadcastPresence(goneUserId); // now reflects offline / no-longer-in-call
|
||||||
|
|
||||||
if (ws.kind === 'agent' && ws.machineId) onlineAgents.delete(ws.machineId);
|
if (ws.kind === 'agent' && ws.machineId) onlineAgents.delete(ws.machineId);
|
||||||
if (ws.kind === 'sharer' && ws.shareCode) pendingShares.delete(ws.shareCode);
|
if (ws.kind === 'sharer' && ws.shareCode) pendingShares.delete(ws.shareCode);
|
||||||
if (ws.sessionId) {
|
if (ws.sessionId) {
|
||||||
|
|||||||