From 06f0b08a18e0d26d4ba47d8a975715b268f9f0ba Mon Sep 17 00:00:00 2001 From: sravan Date: Tue, 30 Jun 2026 17:01:15 +0530 Subject: [PATCH] feat(chat): rich shared-media view, status selector, drag-drop upload + fixes Chat / shared media: - Media/Docs/Links: clean underline tabs (green active), audio & video now classified as Media and rendered as tiles (download + headphone/play + duration) instead of broken-image glyphs; image thumbnails -> lightbox - Drag-and-drop a file/video/image onto a conversation to send it - Fix: removed #chatPanel{position:relative} override that collapsed the conversation pane (messages spilled into a clipped right-edge strip) - "Media, links & docs" row cleaned up (no folder/placeholder icon); media popup keeps the back arrow, drops the redundant close button Presence / status: - Single current-status row with an arrow that expands Available/Away/On leave - On leave = circle with minus, In a call = solid red indicators - Fix: selected-status tick now follows the chosen option Icons: added headphones + play; bumped icons.js cache-bust to v4 Co-Authored-By: Claude Opus 4.8 --- server/db.js | 15 + server/public/connect.html | 6 +- server/public/dashboard.html | 19 +- server/public/home.html | 532 ++++++++++++++++++++++++++++++----- server/public/icons.js | 8 + server/public/index.html | 28 +- server/public/manifest.json | 2 +- server/public/share.html | 4 +- server/public/sw.js | 4 +- server/repos.js | 17 +- server/routes.js | 63 ++++- server/signaling.js | 7 +- 12 files changed, 589 insertions(+), 116 deletions(-) diff --git a/server/db.js b/server/db.js index 4397b0c..e658ce3 100644 --- a/server/db.js +++ b/server/db.js @@ -213,6 +213,11 @@ try { db.exec('ALTER TABLE conversations ADD COLUMN admin_only INTEGER NOT NULL try { db.exec('ALTER TABLE messages ADD COLUMN poll_id TEXT'); } catch (e) { /* exists */ } // Activity/event lines (e.g. 'call-start','call-end') render as centered system messages. try { db.exec('ALTER TABLE messages ADD COLUMN msg_type TEXT'); } catch (e) { /* exists */ } +// Deleted ("delete for everyone"): the row stays so threads/ordering hold, but body+attachment +// are cleared and clients render a "This message was deleted" placeholder. +try { db.exec('ALTER TABLE messages ADD COLUMN deleted INTEGER NOT NULL DEFAULT 0'); } catch (e) { /* exists */ } +// User-set presence status: 'active' | 'away' | 'onleave'. ('incall' is derived live, not stored.) +try { db.exec("ALTER TABLE users ADD COLUMN status TEXT NOT NULL DEFAULT 'active'"); } catch (e) { /* exists */ } db.exec(` CREATE TABLE IF NOT EXISTS polls ( id TEXT PRIMARY KEY, @@ -301,4 +306,14 @@ CREATE TABLE IF NOT EXISTS push_subscriptions ( CREATE INDEX IF NOT EXISTS idx_push_user ON push_subscriptions(user_id); `); +// Favourite conversations (per user). target = 'dm:' or 'group:'. +db.exec(` +CREATE TABLE IF NOT EXISTS favorites ( + user_id TEXT NOT NULL, + target TEXT NOT NULL, + created_at INTEGER NOT NULL, + PRIMARY KEY (user_id, target) +); +`); + module.exports = db; diff --git a/server/public/connect.html b/server/public/connect.html index d89d202..d3bea81 100644 --- a/server/public/connect.html +++ b/server/public/connect.html @@ -3,7 +3,7 @@ -BizGaze Support — Agent Console +Biz Connect — Agent Console ' + - '

BizGaze Connect — Connection report

' + + '

Biz Connect — Connection report

' + '
' + esc(IS_ADMIN ? 'Agent: ' + agentSel : 'Agent: ' + agentSel) + ' · Period: ' + esc(period) + ' · Generated ' + new Date().toLocaleString() + '
' + '' + headCells.map(h => '').join('') + '' + rows.map(r => '').join('') + diff --git a/server/public/home.html b/server/public/home.html index 29c58cd..8137bfa 100644 --- a/server/public/home.html +++ b/server/public/home.html @@ -3,7 +3,7 @@ -BizGaze Connect +Biz Connect @@ -19,7 +19,8 @@ body{font-family:'Segoe UI',system-ui,sans-serif;background:var(--bg);color:var(--ink);margin:0;display:flex;flex-direction:column;height:100vh;height:100dvh;overflow:hidden;} /* ---- Top bar ---- */ - header{background:var(--blue);padding:.7rem 1.4rem;display:flex;justify-content:space-between;align-items:center;flex:0 0 auto;position:relative;z-index:1300;} + /* #4: the top blue bar is removed everywhere — bell+profile live in the chat-list header. */ + header{display:none;} .brandrow{display:flex;align-items:center;gap:.6rem;} .logo{width:30px;height:30px;border-radius:8px;background:var(--brand);display:grid;place-items:center;font-weight:800;color:var(--blue);} .brand{font-weight:700;color:#fff;font-size:1.05rem;} .brand span.y{color:var(--brand);font-weight:700;} @@ -27,10 +28,15 @@ /* ---- Header actions: notification bell + profile ---- */ #hdrRight{display:flex;align-items:center;gap:.45rem;} .bell{position:relative;} - .bellbtn{position:relative;background:rgba(255,255,255,.14);border:1px solid #46598c;color:#fff;cursor:pointer;width:40px;height:40px;border-radius:10px;display:grid;place-items:center;} + .bellbtn{position:relative;background:var(--blue-soft);border:1px solid var(--line);color:var(--blue);cursor:pointer;width:38px;height:38px;border-radius:10px;display:grid;place-items:center;} + .bellbtn:hover{background:#dbe6fb;} .bellbtn:hover{background:rgba(255,255,255,.24);} - .bell-dot{position:absolute;top:4px;right:4px;min-width:16px;height:16px;border-radius:99px;background:var(--brand);color:var(--blue);font-size:.6rem;font-weight:800;display:grid;place-items:center;padding:0 .2rem;border:2px solid var(--blue);} + .bell-dot{position:absolute;top:2px;right:2px;min-width:16px;height:16px;border-radius:99px;background:var(--red);color:#fff;font-size:.6rem;font-weight:800;display:grid;place-items:center;padding:0 .2rem;border:2px solid var(--card);} .bell-menu{position:absolute;right:0;top:calc(100% + 6px);width:340px;max-width:92vw;background:#fff;border:1px solid #e6e9ef;border-radius:12px;box-shadow:0 12px 30px rgba(0,0,0,.18);z-index:5000;display:none;overflow:hidden;} + /* When bell+profile live in the left rail (desktop), open their menus to the RIGHT of the rail, + bottom-aligned, so they aren't clipped off-screen. */ + #rail .bell-menu{top:auto;bottom:0;right:auto;left:calc(100% + 10px);} + #rail .profile .pmenu{top:auto;bottom:0;right:auto;left:calc(100% + 10px);} .bell-menu.open{display:block;} .bell-head{display:flex;align-items:center;justify-content:space-between;padding:.6rem .85rem;border-bottom:1px solid #eef1f6;font-weight:700;font-size:.9rem;color:var(--ink);} .bell-head button{background:none;border:none;color:var(--blue);font-size:.78rem;cursor:pointer;font-weight:600;} @@ -45,7 +51,17 @@ .bell-empty{padding:1.6rem;text-align:center;color:var(--muted);font-size:.85rem;} /* ---- Profile dropdown (from console.html) ---- */ .profile{position:relative} - .profile .pbtn.icon-only{padding:.3rem;gap:0;border-radius:50%;} + .profile .pbtn.icon-only{padding:0;gap:0;border-radius:50%;background:transparent;border:none;position:relative;} + .pav-dot{position:absolute;right:-1px;bottom:-1px;width:11px;height:11px;border-radius:50%;border:2px solid var(--card);background:#cbd2dd;} + .pav-dot.active{background:#16a34a;} .pav-dot.away{background:#f59e0b;} .pav-dot.onleave{background:#ef4444;} .pav-dot.incall{background:#2563eb;} .pav-dot.offline{background:#cbd2dd;} + .pmenu .ps-current{display:flex;align-items:center;gap:.45rem;} + .pmenu .ps-current .ps-arrow{margin-left:auto;color:var(--muted);display:inline-flex;} + .pmenu .ps-options{background:#f8fafc;border-top:1px solid #eef1f6;border-bottom:1px solid #eef1f6;} + .pmenu .ps-opt{display:flex;align-items:center;gap:.45rem;padding-left:1.3rem;} + .pmenu .ps-opt.on{font-weight:700;} + .pmenu .ps-opt .ps-check{margin-left:auto;display:none;color:#16a34a;align-items:center;} + .pmenu .ps-opt.on .ps-check{display:inline-flex;} + .pmenu .ps-div{height:1px;background:#eef1f6;margin:.3rem 0;} .profile .pbtn{display:flex;align-items:center;gap:.5rem;background:rgba(255,255,255,.14);color:#fff;border:1px solid #46598c;border-radius:10px;padding:.4rem .85rem .4rem .5rem;font-weight:600;font-size:.88rem;cursor:pointer} .profile .pbtn:hover{background:rgba(255,255,255,.24)} .profile .pbtn .pav{position:relative;width:28px;height:28px;border-radius:50%;background:var(--brand);color:var(--blue);display:grid;place-items:center;font-weight:800;font-size:.78rem;overflow:hidden} @@ -66,6 +82,9 @@ /* ---- Icon rail ---- */ .rail{width:74px;flex:0 0 74px;background:var(--card);border-right:1px solid var(--line);display:flex;flex-direction:column;align-items:center;padding:.8rem 0;gap:.4rem;} + /* Bell + profile relocated to the bottom of the rail on desktop. */ + #rail #hdrRight{display:flex;flex-direction:column;align-items:center;gap:.55rem;margin-top:auto;padding:.5rem 0 .2rem;} + #rail #hdrRight .pav{width:34px;height:34px;font-size:.85rem;} .railbtn{position:relative;width:50px;height:50px;border:none;background:transparent;border-radius:14px;color:var(--muted);cursor:pointer;display:grid;place-items:center;transition:background .12s,color .12s;} .railbtn:hover{background:var(--blue-soft);color:var(--blue);} .railbtn.active{background:var(--blue);color:#fff;} @@ -93,11 +112,15 @@ .dir-row .dr-main{display:flex;flex-direction:column;min-width:0;flex:1;} .dir-row .dr-sub{font-size:.74rem;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;} .dir-row .dr-tag{font-size:.62rem;font-weight:700;color:#92600b;background:#fff3cd;border-radius:99px;padding:.1rem .4rem;flex:0 0 auto;} - .side-head{padding:1rem 1rem .75rem;border-bottom:1px solid var(--line);} - .side-title{display:flex;align-items:center;justify-content:space-between;margin-bottom:.7rem;} - .side-title h2{font-size:.95rem;margin:0;color:var(--blue);} - .newchat{width:30px;height:30px;border-radius:9px;border:none;background:var(--blue-soft);color:var(--blue);font-size:1.2rem;line-height:1;cursor:pointer;font-weight:700;display:grid;place-items:center;padding:0;} - .newchat:hover{background:#dbe6fb;} + /* Chat-list header (now the app's top area — the blue bar is gone). */ + .side-head{padding:.85rem 1rem .75rem;border-bottom:1px solid var(--line);background:linear-gradient(180deg,#f3f7fd 0%,var(--card) 100%);} + .side-title{display:flex;align-items:center;justify-content:space-between;gap:.5rem;margin-bottom:.7rem;} + .side-title h2{font-size:1.15rem;margin:0;color:var(--blue);font-weight:800;letter-spacing:-.01em;flex:1;} + .side-title #hdrRight{display:flex;align-items:center;gap:.45rem;flex:0 0 auto;} + .side-search-row{display:flex;align-items:center;gap:.5rem;} + .side-search-row .search{flex:1;min-width:0;} + .newchat{width:38px;height:38px;flex:0 0 auto;border-radius:10px;border:none;background:var(--blue);color:#fff;font-size:1.35rem;line-height:1;cursor:pointer;font-weight:700;display:grid;place-items:center;padding:0;box-shadow:0 2px 8px rgba(31,59,115,.25);} + .newchat:hover{filter:brightness(1.08);} .search{position:relative;} .search > svg{position:absolute;left:.65rem;top:50%;transform:translateY(-50%);color:var(--muted);} .search input{width:100%;padding:.55rem .7rem .55rem 2.1rem;border-radius:10px;border:2px solid var(--line);background:#fbfcfe;color:var(--ink);font-size:.9rem;} @@ -106,15 +129,41 @@ .search-x{position:absolute;right:.5rem;top:50%;transform:translateY(-50%);border:none;background:transparent;color:var(--muted);cursor:pointer;padding:.2rem;border-radius:6px;display:grid;place-items:center;} .search-x:hover{color:var(--blue);background:var(--blue-soft);} - .chatlist{overflow-y:auto;flex:1 1 auto;padding:.4rem;} + .chatlist{overflow-y:auto;flex:1 1 auto;padding:.4rem;scrollbar-width:thin;scrollbar-color:transparent transparent;} + .chatlist:hover{scrollbar-color:#c7d0dd transparent;} + .chatlist::-webkit-scrollbar{width:7px;} + .chatlist::-webkit-scrollbar-button{display:none;height:0;width:0;} + .chatlist::-webkit-scrollbar-thumb{background:transparent;border-radius:8px;} + .chatlist:hover::-webkit-scrollbar-thumb{background:#c7d0dd;} + /* Pull-to-refresh indicator (mobile) */ + .ptr-ind{position:absolute;top:6px;left:50%;width:32px;height:32px;margin-left:-16px;border-radius:50%;background:var(--card);box-shadow:0 2px 10px rgba(20,30,60,.18);display:grid;place-items:center;color:var(--blue);z-index:40;opacity:0;transform:translateY(-48px);transition:opacity .12s;pointer-events:none;} + .ptr-ind .ptr-g{font-size:1.15rem;font-weight:700;line-height:1;display:inline-block;} + .ptr-ind.spin .ptr-g{animation:ptrspin .7s linear infinite;} + @keyframes ptrspin{to{transform:rotate(360deg);}} + /* Enable-notifications prompt (esp. iOS PWA) */ + .notif-prompt{position:fixed;left:50%;transform:translateX(-50%);bottom:calc(74px + env(safe-area-inset-bottom,0));z-index:2000;display:flex;align-items:center;gap:.7rem;width:max-content;max-width:min(460px,94vw);background:var(--blue);color:#fff;padding:.6rem .7rem .6rem 1rem;border-radius:12px;box-shadow:0 10px 30px rgba(20,30,60,.32);font-size:.86rem;} + .notif-prompt .np-actions{display:flex;align-items:center;gap:.4rem;margin-left:auto;flex:0 0 auto;} + .notif-prompt .btn.sm{background:#fff;color:var(--blue);} + .notif-prompt .np-x{background:transparent;border:none;color:#fff;opacity:.85;cursor:pointer;display:grid;place-items:center;padding:.2rem;} + @media(min-width:761px){ .notif-prompt{bottom:18px;} } + .perm-state{font-size:.68rem;font-weight:700;padding:.06rem .45rem;border-radius:99px;vertical-align:middle;} + .perm-state.granted{background:#dcfce7;color:#15803d;} + .perm-state.denied{background:#fee2e2;color:#b91c1c;} + .perm-state.default,.perm-state.unsupported{background:#f1f5f9;color:#475569;} .chat-row{display:flex;gap:.7rem;align-items:center;padding:.6rem .65rem;border-radius:12px;cursor:pointer;position:relative;} .chat-row:hover{background:#f3f6fb;} .chat-row.active{background:var(--blue-soft);box-shadow:inset 3px 0 0 var(--brand);} .chat-row.active::before{content:"";position:absolute;left:0;top:.7rem;bottom:.7rem;width:3px;border-radius:3px;background:var(--blue);} - .avatar{width:42px;height:42px;flex:0 0 42px;border-radius:50%;display:grid;place-items:center;color:#fff;font-weight:700;font-size:.92rem;position:relative;} + .avatar{width:42px;height:42px;flex:0 0 42px;border-radius:50%;display:grid;place-items:center;color:#334155;font-weight:700;font-size:.92rem;position:relative;} .avatar .av-img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;border-radius:inherit;} .avatar .dot{position:absolute;right:-1px;bottom:-1px;width:11px;height:11px;border-radius:50%;border:2px solid #fff;background:#cbd2dd;z-index:1;} .avatar .dot.on{background:var(--green);} + /* presence/status dot colors (#7): in-call = solid red; on-leave = circle with a minus. */ + .dot.active{background:#16a34a;} .dot.away{background:#f59e0b;} .dot.onleave{background:#dc2626;} .dot.incall{background:#dc2626;} .dot.offline{background:#cbd2dd;} + .st-dot{position:relative;display:inline-block;width:10px;height:10px;border-radius:50%;margin-right:.4rem;vertical-align:middle;background:#cbd2dd;} + .st-dot.active{background:#16a34a;} .st-dot.away{background:#f59e0b;} .st-dot.onleave{background:#dc2626;} .st-dot.incall{background:#dc2626;} .st-dot.offline{background:#cbd2dd;} + /* the "minus in circle" for On leave (on every size of dot) */ + .dot.onleave::after,.st-dot.onleave::after,.pav-dot.onleave::after{content:"";position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);width:6px;height:1.6px;background:#fff;border-radius:1px;} .chat-main{flex:1 1 auto;min-width:0;} .chat-top{display:flex;justify-content:space-between;align-items:baseline;gap:.5rem;} .chat-name{font-weight:400;font-size:.92rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;} @@ -158,12 +207,59 @@ /* conversation placeholder (selected chat, no backend yet) */ .convo{flex-direction:column;display:flex;width:100%;height:100%;} - .convo-head{display:flex;align-items:center;gap:.7rem;padding:.9rem 1.2rem;border-bottom:1px solid var(--line);background:var(--card);} + .convo-head{display:flex;align-items:center;gap:.7rem;padding:.9rem 1.2rem;border-bottom:1px solid var(--line);background:var(--card);position:relative;} + /* NB: #chatPanel is .panel which is already position:absolute (a containing block), + so the drop overlay positions against it. Do NOT add position:relative here — an ID + selector would override .panel{position:absolute;inset:0} and collapse the pane. */ + #chatPanel.drag-over::after{content:"Drop to send";position:absolute;inset:10px;border:2.5px dashed var(--blue);border-radius:14px;background:rgba(31,59,115,.07);display:grid;place-items:center;color:var(--blue);font-weight:700;font-size:1.15rem;z-index:60;pointer-events:none;} .ic{display:inline-block;vertical-align:middle;} .convo-back{border:none;background:var(--blue-soft);color:var(--blue);width:34px;height:34px;border-radius:9px;cursor:pointer;display:grid;place-items:center;flex:0 0 auto;} .convo-back:hover{background:#dbe6fb;} .convo-head .nm{font-weight:700;color:var(--ink);} .convo-head .st{font-size:.78rem;color:var(--muted);} + /* In-chat search: the header turns into a search bar with count + up/down jump arrows. */ + .convo-search-head{position:absolute;inset:0;display:flex;align-items:center;gap:.4rem;padding:0 .8rem;background:var(--card);z-index:6;} + .convo-search-head input{flex:1;min-width:0;border:none;background:transparent;font-size:.95rem;color:var(--ink);} + .convo-search-head input:focus{outline:none;} + .csh-count{font-size:.8rem;color:var(--muted);white-space:nowrap;flex:0 0 auto;min-width:2.5rem;text-align:right;} + .csh-nav{border:none;background:var(--blue-soft);color:var(--blue);width:30px;height:30px;border-radius:8px;cursor:pointer;display:grid;place-items:center;flex:0 0 auto;} + .csh-nav:hover{background:#dbe6fb;} + mark.search-hit{background:#fde68a;color:inherit;border-radius:2px;padding:0 1px;} + mark.search-current{background:#f59e0b;color:#1f2430;} + .gi-media-row{display:flex;align-items:center;justify-content:space-between;width:100%;background:#f6f8fb;border:1px solid var(--line);border-radius:10px;padding:.65rem .8rem;cursor:pointer;color:var(--ink);font-size:.92rem;font-weight:600;} + .gi-media-row:hover{background:#eef2f8;} + .gi-media-row .gmr-l{display:flex;align-items:center;gap:.6rem;} + .gi-media-row .gmr-ic{width:32px;height:32px;flex:0 0 auto;border-radius:9px;background:linear-gradient(135deg,#2563eb,#1F3B73);color:#fff;display:grid;place-items:center;box-shadow:0 2px 6px rgba(31,59,115,.25);} + .gi-media-row .gmr-ic svg{width:17px;height:17px;} + .gi-media-row .gmr-r{color:var(--muted);font-size:.85rem;font-weight:600;} + .gi-media-strip{display:flex;gap:.3rem;margin:.4rem 0 .2rem;overflow:hidden;} + .gms-thumb{width:60px;height:60px;object-fit:cover;border-radius:8px;background:#f1f5f9;cursor:pointer;flex:0 0 auto;} + /* Media / Docs / Links — clean underline tabs (active = green underline, no chip borders) */ + .mv-tabs{display:flex;gap:0;border-bottom:1px solid var(--line);margin:.5rem 0 0;} + .mv-tabs .mp-tab{flex:1;border:none;background:transparent;color:var(--muted);font-size:.95rem;font-weight:600;padding:.6rem .3rem .65rem;cursor:pointer;border-bottom:2px solid transparent;margin-bottom:-1px;text-align:center;} + .mv-tabs .mp-tab:hover{color:var(--ink);} + .mv-tabs .mp-tab.on{color:var(--ink);font-weight:700;border-bottom-color:#16a34a;} + .mv-body{max-height:58vh;overflow:auto;margin-top:.5rem;} + /* Shared-media tiles: image thumbnail, or audio/video tile with download + duration */ + .sh-cell{margin:0;display:flex;flex-direction:column;gap:.28rem;min-width:0;} + .sh-name{font-size:.7rem;font-weight:700;color:var(--ink);text-transform:uppercase;letter-spacing:.02em;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;} + .sh-av{position:relative;display:block;width:100%;aspect-ratio:1;border-radius:8px;overflow:hidden;text-decoration:none;background:#eef2f8;cursor:pointer;} + .sh-av.aud{background:linear-gradient(135deg,#f7b733,#ee8c1a);} + .sh-av.vid{background:#0b1220;} + .sh-poster{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;pointer-events:none;} + .sh-dl{position:absolute;left:50%;top:46%;transform:translate(-50%,-50%);width:46px;height:46px;border-radius:50%;background:rgba(20,30,60,.28);display:grid;place-items:center;color:#fff;transition:background .12s;} + .sh-av:hover .sh-dl{background:rgba(20,30,60,.45);} + .sh-dur{position:absolute;left:0;right:0;bottom:0;display:flex;align-items:center;gap:.28rem;padding:.28rem .42rem;background:linear-gradient(transparent,rgba(0,0,0,.5));color:#fff;font-size:.74rem;font-weight:600;} + .sh-dur .sh-di{display:inline-flex;} .sh-dur svg{width:13px;height:13px;} + .sh-dur i{font-style:normal;} + .fav-on svg{fill:#f59e0b;color:#f59e0b;} + .sh-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(92px,1fr));gap:.4rem;} + .sh-thumb{width:100%;aspect-ratio:1;object-fit:cover;border-radius:8px;cursor:pointer;background:#f1f5f9;} + .sh-file{display:flex;align-items:center;gap:.55rem;padding:.6rem .55rem;border-bottom:1px solid var(--line);color:var(--ink);text-decoration:none;} + .sh-file:hover{background:#f6f8fb;} + .sh-file .sh-fn{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-size:.9rem;} + .sh-file .sh-sz{font-size:.74rem;color:var(--muted);flex:0 0 auto;} + .sh-file svg{color:var(--blue);flex:0 0 auto;} .convo-body{flex:1;display:grid;place-items:center;text-align:center;color:var(--muted);padding:2rem;} .convo-body .big{font-size:2.4rem;margin-bottom:.4rem;} /* message thread */ @@ -173,6 +269,8 @@ .bubble.mine{align-self:flex-end;background:var(--blue);color:#fff;border-bottom-right-radius:4px;} .bubble .t{display:block;font-size:.64rem;opacity:.65;margin-top:.15rem;text-align:right;} .day-sep{align-self:center;font-size:.72rem;margin:.7rem 0 .3rem;text-align:center;} + .new-sep{display:flex;align-items:center;gap:.6rem;margin:.6rem .2rem;color:var(--red);font-size:.72rem;font-weight:700;} + .new-sep::before,.new-sep::after{content:"";flex:1;height:1px;background:#f0c2c2;} .day-sep span{background:#fde7b0;color:#7a5b05;padding:.22rem .8rem;border-radius:99px;font-weight:600;box-shadow:0 1px 3px rgba(20,30,60,.1);} .float-date{position:absolute;top:.6rem;left:50%;transform:translateX(-50%);z-index:5;background:#fde7b0;color:#7a5b05;padding:.22rem .85rem;border-radius:99px;font-weight:600;font-size:.72rem;box-shadow:0 3px 10px rgba(20,30,60,.18);pointer-events:none;} .jump-latest{position:absolute;right:16px;bottom:86px;z-index:5;width:42px;height:42px;border-radius:50%;border:1px solid var(--line);background:var(--card);color:var(--blue);box-shadow:0 4px 14px rgba(20,30,60,.22);cursor:pointer;display:grid;place-items:center;} @@ -250,7 +348,8 @@ .meet-panel .mp-setting{margin:.3rem;font-size:.8rem;} .meet-tile video{width:100%;height:100%;object-fit:cover;background:#0b1220;} .meet-tile .nm{position:absolute;left:.5rem;bottom:.5rem;background:rgba(0,0,0,.55);color:#fff;font-size:.75rem;padding:.15rem .5rem;border-radius:6px;} - .meet-tile .meet-av{position:absolute;inset:0;margin:auto;width:84px;height:84px;border-radius:50%;display:none;align-items:center;justify-content:center;color:#fff;font-weight:700;font-size:1.9rem;} + .meet-tile .meet-av{position:absolute;inset:0;margin:auto;width:84px;height:84px;border-radius:50%;display:none;align-items:center;justify-content:center;color:#334155;font-weight:700;font-size:1.9rem;overflow:hidden;} + .meet-tile .meet-av img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;} .meet-tile .meet-mute{position:absolute;left:.5rem;top:.5rem;width:26px;height:26px;border-radius:50%;background:#dc2626;color:#fff;place-items:center;box-shadow:0 1px 3px rgba(0,0,0,.4);} .meet-panel{position:absolute;right:12px;top:12px;bottom:78px;width:280px;max-width:80vw;background:var(--card);border:1px solid var(--line);border-radius:14px;box-shadow:0 14px 40px rgba(0,0,0,.3);z-index:30;display:flex;flex-direction:column;overflow:hidden;} .meet-panel .mp-head{display:flex;align-items:center;gap:.5rem;padding:.7rem .8rem;border-bottom:1px solid var(--line);font-size:.9rem;} @@ -259,7 +358,8 @@ .meet-panel .mp-tabs{display:flex;gap:.25rem;padding:.4rem .5rem 0;border-bottom:1px solid var(--line);} .meet-panel .mp-tab{flex:1;border:none;background:transparent;color:var(--muted);font-size:.8rem;font-weight:600;padding:.45rem .3rem;cursor:pointer;border-bottom:2px solid transparent;display:inline-flex;align-items:center;justify-content:center;gap:.25rem;} .meet-panel .mp-tab.on{color:var(--blue);border-bottom-color:var(--blue);} - .meet-panel .mp-list{flex:1;overflow:auto;padding:.4rem;} + .meet-panel .mp-scroll{flex:1;overflow:auto;} + .meet-panel .mp-list{padding:.4rem;} .meet-panel .chk{display:flex;align-items:center;gap:.5rem;padding:.4rem .5rem;border-radius:8px;cursor:pointer;font-size:.88rem;} .meet-panel .chk:hover{background:#f6f8fb;} .meet-panel .chk input{width:16px;height:16px;flex:0 0 16px;accent-color:var(--blue);margin:0;} @@ -267,6 +367,9 @@ .meet-panel .mp-row{display:flex;align-items:center;gap:.5rem;padding:.4rem .5rem;border-radius:8px;} .meet-panel .mp-row .mn{flex:1;min-width:0;font-size:.88rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;} .meet-panel .mp-row .pp-mute{color:var(--red);display:inline-flex;} + .meet-panel .mp-sec{font-size:.68rem;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);font-weight:700;margin:.7rem .5rem .15rem;} + .meet-panel .mp-row.waiting{opacity:.7;} + .meet-panel .mp-row .pp-wait{margin-left:auto;font-size:.72rem;color:var(--muted);display:inline-flex;align-items:center;gap:.25rem;white-space:nowrap;} .meet-panel .mp-makehost{border:none;background:var(--blue-soft);color:var(--blue);border-radius:7px;padding:.2rem .4rem;cursor:pointer;display:grid;place-items:center;} .host-tag{display:inline-flex;align-items:center;gap:.2rem;font-size:.62rem;font-weight:700;background:#fff3cd;color:#7a5b05;padding:.05rem .35rem;border-radius:99px;} .admin-tag{display:inline-flex;align-items:center;gap:.2rem;font-size:.6rem;font-weight:700;background:#fef3c7;color:#92600b;padding:.05rem .4rem;border-radius:99px;vertical-align:middle;} @@ -304,8 +407,8 @@ .convo{position:relative;} .bubble{position:relative;} .bubble .quote{border-left:3px solid var(--line);padding:.22rem .5rem;margin-bottom:.3rem;font-size:.78rem;border-radius:6px;color:#33384a;} - .reply-btn{position:absolute;top:-9px;right:6px;background:var(--card);color:var(--blue);border:1px solid var(--line);border-radius:50%;width:22px;height:22px;font-size:.8rem;line-height:1;cursor:pointer;opacity:0;transition:opacity .12s;box-shadow:0 1px 3px rgba(0,0,0,.12);} - .bubble:hover .reply-btn{opacity:1;} + .reply-btn{position:absolute;top:-9px;right:6px;background:var(--card);color:var(--blue);border:1px solid var(--line);border-radius:50%;width:22px;height:22px;font-size:.8rem;line-height:1;cursor:pointer;opacity:0;pointer-events:none;transition:opacity .12s;box-shadow:0 1px 3px rgba(0,0,0,.12);} + .bubble:hover .reply-btn,.bubble.show-actions .reply-btn{opacity:1;pointer-events:auto;} .reply-bar{display:flex;align-items:center;gap:.5rem;padding:.45rem .8rem;border-top:1px solid var(--line);background:#eef3fb;font-size:.82rem;color:var(--muted);} .reply-bar b{color:var(--ink);} .reply-bar .rx{margin-left:auto;cursor:pointer;font-size:1rem;} @@ -341,8 +444,13 @@ .emoji-grid{flex:1;overflow-y:auto;display:grid;grid-template-columns:repeat(8,1fr);gap:.1rem;padding:.4rem;align-content:start;} .emoji-grid button{border:none;background:transparent;font-size:1.25rem;cursor:pointer;padding:.2rem;border-radius:6px;line-height:1.15;} .emoji-grid button:hover{background:var(--blue-soft);} - .react-btn{position:absolute;top:-9px;right:32px;background:var(--card);color:var(--blue);border:1px solid var(--line);border-radius:50%;width:22px;height:22px;font-size:.8rem;line-height:1;cursor:pointer;opacity:0;transition:opacity .12s;box-shadow:0 1px 3px rgba(0,0,0,.12);} - .bubble:hover .react-btn{opacity:1;} + .react-btn{position:absolute;top:-9px;right:32px;background:var(--card);color:var(--blue);border:1px solid var(--line);border-radius:50%;width:22px;height:22px;font-size:.8rem;line-height:1;cursor:pointer;opacity:0;pointer-events:none;transition:opacity .12s;box-shadow:0 1px 3px rgba(0,0,0,.12);} + .bubble:hover .react-btn,.bubble.show-actions .react-btn{opacity:1;pointer-events:auto;} + .del-btn{position:absolute;top:-9px;right:58px;background:var(--card);color:var(--red);border:1px solid var(--line);border-radius:50%;width:22px;height:22px;line-height:1;cursor:pointer;opacity:0;pointer-events:none;transition:opacity .12s;box-shadow:0 1px 3px rgba(0,0,0,.12);display:grid;place-items:center;} + .bubble:hover .del-btn,.bubble.show-actions .del-btn{opacity:1;pointer-events:auto;} + .bubble.deleted{opacity:.85;} + .bubble.deleted .del-msg{font-style:italic;color:var(--muted);font-size:.9rem;display:inline-flex;align-items:center;gap:.3rem;} + .bubble.mine.deleted .del-msg{color:rgba(255,255,255,.85);} .reacts{display:flex;flex-wrap:wrap;gap:.2rem;margin-top:.3rem;} .react-chip{border:1px solid var(--line);background:var(--card);color:var(--ink);border-radius:999px;font-size:.74rem;padding:.05rem .4rem;cursor:pointer;line-height:1.5;} .react-chip.mine{background:var(--blue-soft);border-color:#c7d6f0;color:var(--blue);} @@ -358,7 +466,7 @@ .att-file .att-sz{opacity:.65;font-size:.75rem;flex:0 0 auto;} /* groups */ .bubble .sender{font-size:.7rem;color:var(--blue);font-weight:700;margin-bottom:.12rem;display:flex;align-items:center;gap:.35rem;} - .bubble .sender .snd-av{position:relative;width:18px;height:18px;flex:0 0 18px;border-radius:50%;display:grid;place-items:center;color:#fff;font-weight:700;font-size:.55rem;overflow:hidden;} + .bubble .sender .snd-av{position:relative;width:20px;height:20px;flex:0 0 20px;border-radius:50%;display:grid;place-items:center;color:#334155;font-weight:800;font-size:.58rem;overflow:hidden;} .bubble .sender .snd-av img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;} .avatar.grp{border-radius:12px;font-size:1.15rem;} .modal-ov{position:fixed;inset:0;background:rgba(15,23,42,.45);display:flex;align-items:center;justify-content:center;z-index:9800;padding:1rem;} @@ -369,6 +477,14 @@ .avatar .mcount{position:absolute;right:-3px;bottom:-3px;min-width:16px;height:16px;border-radius:99px;background:var(--blue);color:#fff;font-size:.6rem;font-weight:800;display:grid;place-items:center;border:2px solid var(--card);padding:0 .15rem;z-index:1;} .convo-titlewrap{flex:1;min-width:0;} .convo-info{border:none;background:var(--blue-soft);color:var(--blue);width:32px;height:32px;border-radius:9px;font-size:1rem;cursor:pointer;flex:0 0 auto;display:grid;place-items:center;} + .convo-fav.on{color:#f59e0b;} + .convo-fav.on svg{fill:#f59e0b;} + .gi-title-row{display:flex;align-items:center;gap:.35rem;} + .fav-star{border:none;background:transparent;color:var(--muted);cursor:pointer;padding:.12rem;display:grid;place-items:center;border-radius:6px;flex:0 0 auto;} + .fav-star:hover{background:#f1f5f9;} + .fav-star.on{color:#f59e0b;} .fav-star.on svg{fill:#f59e0b;} + .status-dot{display:inline-block;width:8px;height:8px;border-radius:50%;background:#cbd5e1;margin-right:.35rem;vertical-align:middle;} + .status-dot.on{background:#16a34a;} .convo-info:hover{background:#dbe4f0;} .convo-call{border:none;background:var(--blue-soft);color:var(--blue);height:32px;border-radius:9px;cursor:pointer;flex:0 0 auto;display:inline-flex;align-items:center;gap:.3rem;padding:0 .6rem;font-weight:600;} .convo-call:hover{background:#dbe4f0;} @@ -493,7 +609,7 @@ .mrow .mn{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;} .mrow .iconbtn{opacity:0;} .mrow:hover .iconbtn{opacity:1;} - .mini-av{position:relative;overflow:hidden;width:30px;height:30px;flex:0 0 30px;border-radius:50%;display:grid;place-items:center;color:#fff;font-weight:700;font-size:.72rem;} + .mini-av{position:relative;overflow:hidden;width:30px;height:30px;flex:0 0 30px;border-radius:50%;display:grid;place-items:center;color:#334155;font-weight:700;font-size:.72rem;} .mini-av .av-img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;} .gi-photo{position:relative;border:none;background:transparent;padding:0;cursor:pointer;flex:0 0 auto;} .gi-photo .avatar.grp{flex:0 0 46px;} @@ -551,7 +667,14 @@ } /* ---- Mobile / tablet ---- */ @media (max-width:760px){ - header{padding:.5rem .8rem;} + /* #7: drop the top blue bar entirely on mobile to use the full screen. bell+profile are + relocated into the chat-list header (see placeHdrRight). The shell is pushed below the + device status bar so no view overlaps it. */ + header{display:none;} + .shell{padding-top:env(safe-area-inset-top,0);} + .side-title{gap:.5rem;} + .side-title h2{flex:1;} + .side-title #hdrRight{display:flex;align-items:center;gap:.4rem;flex:0 0 auto;} .brand{font-size:.98rem;} .navtoggle{display:none;} .rail-backdrop{display:none!important;} /* bottom nav replaces the drawer */ /* App-style bottom navigation bar */ @@ -566,12 +689,12 @@ /* Meeting room code: always fully visible, full-width, easy to read */ .meet-bar .code{flex:1 1 100%;font-size:.9rem;white-space:normal;word-break:break-word;text-align:center;background:var(--blue-soft);border-radius:8px;padding:.35rem .5rem;} /* Chat: one pane at a time (list, then the open conversation) */ - .chatcol{width:100%;flex:1 1 100%;padding-bottom:60px;} + .chatcol{width:100%;flex:1 1 100%;padding-bottom:calc(60px + env(safe-area-inset-bottom,0));} body.chat-open .chatcol{display:none;} .content{display:none;} body.chat-open .content, .shell:not(.is-chat) .content{display:block;} .shell:not(.is-chat) .chatcol{display:none;} - .content .panel{bottom:60px;} /* keep panel content above the bottom nav */ + .content .panel{bottom:calc(60px + env(safe-area-inset-bottom,0));} /* clear the bottom nav + home indicator */ /* Bigger touch targets */ .chat-row{padding:.7rem .85rem;} .convo-head{padding:.7rem .85rem;} @@ -589,22 +712,23 @@ .md-actions > .btn{width:100%;justify-content:center;} .md-join{flex:1 1 100%;} .call-invite{left:8px;right:8px;max-width:none;bottom:70px;} - .bell-menu{position:fixed;left:8px;right:8px;top:58px;width:auto;} + .bell-menu{position:fixed;left:8px;right:8px;top:calc(56px + env(safe-area-inset-top,0));width:auto;} + #rail .bell-menu,#rail .profile .pmenu{left:auto;} /* desktop rail rule must not leak to the mobile bottom-nav */ /* Touch devices have no hover: keep member-row actions (make-admin / remove) always visible */ .mrow .iconbtn{opacity:1;} } - - + +
Loading…
-
BizGaze Connect
+
Biz Connect
@@ -637,12 +761,14 @@

Chats

-
-
@@ -680,7 +806,8 @@ function autoGrow(el){ if(!el) return; el.style.height='auto'; const max=140; el const REPLY_TINTS=[['#eef4ff','#3b6fd4'],['#eafaf2','#1f9d57'],['#fef4e9','#d98324'],['#f6eefe','#8b46c9'],['#fdeef3','#d6457f'],['#e9fafa','#179a9a'],['#fef7e6','#c9a227']]; function replyTint(key){ let h=0; const s=String(key||''); for(let i=0;i>>0; return REPLY_TINTS[h%REPLY_TINTS.length]; } function firstName(name){return String(name||'').trim().split(/\s+/)[0]||'there';} -const AV_COLORS=['#1F3B73','#2563eb','#0e7490','#7c3aed','#be185d','#b45309','#15803d','#9d174d']; +// Soft pastel avatar backgrounds (with dark lettering) for a light, professional look. +const AV_COLORS=['#dbeafe','#e0e7ff','#dcfce7','#cffafe','#fae8ff','#fce7f3','#fee2e2','#fef3c7','#ecfccb','#fde4cf']; function avColor(name){let h=0;for(const c of String(name))h=(h*31+c.charCodeAt(0))>>>0;return AV_COLORS[h%AV_COLORS.length];} let toastTimer=null; @@ -690,10 +817,16 @@ function toast(msg){const t=document.getElementById('toast');t.textContent=msg;t function profileHTML(u){ const display=u.name||u.email; const img=u.avatarUrl?'':''; + const cur=(u&&u.status)||'active'; + const lblOf=(st)=>st==='away'?'Away':st==='onleave'?'On leave':'Available'; + const opt=(st)=>' '+lblOf(st)+''+ic('check',14)+''; return '
' + + ''+pEsc(initials(display))+img+'' + '
' + '
'+pEsc(display)+'
'+pEsc(u.email)+(u.role?' · '+pEsc(u.role):'')+'
' + + ''+lblOf(cur)+''+ic('chevronDown',14)+'' + + '' + + '
' + ''+ic('layoutDashboard',16)+' Dashboard' + ''+ic('settings',16)+' Settings' + ''+ic('logOut',16)+' Logout' @@ -702,12 +835,20 @@ function profileHTML(u){ function wireProfile(){ const btn=document.getElementById('pbtn'),menu=document.getElementById('pmenu'); if(!btn)return; - btn.onclick=(e)=>{e.stopPropagation();menu.classList.toggle('open');}; + btn.onclick=(e)=>{e.stopPropagation();const bm=document.getElementById('bellMenu');if(bm)bm.classList.remove('open');menu.classList.toggle('open');}; document.addEventListener('click',()=>menu.classList.remove('open')); const lo=document.getElementById('plogout'); - if(lo)lo.onclick=async()=>{try{await fetch('/api/logout',{method:'POST'});}catch(_){}location.href='/';}; + if(lo)lo.onclick=async()=>{ await unsubscribePush(); try{await fetch('/api/logout',{method:'POST'});}catch(_){} location.href='/';}; const ps=document.getElementById('psettings'); if(ps)ps.onclick=()=>{ menu.classList.remove('open'); openSettings(); }; + const stog=menu.querySelector('#psStatusToggle'); if(stog) stog.onclick=(e)=>{ e.stopPropagation(); const o=menu.querySelector('#psOptions'); if(o) o.style.display=(o.style.display==='none'?'block':'none'); }; + menu.querySelectorAll('.ps-opt').forEach(a=>a.onclick=async(e)=>{ e.stopPropagation(); const st=a.dataset.st; ME.status=st; + const cd=menu.querySelector('.ps-current .st-dot'); if(cd) cd.className='st-dot '+st; + const cl=menu.querySelector('.ps-cur-label'); if(cl) cl.textContent=(st==='away'?'Away':st==='onleave'?'On leave':'Available'); + menu.querySelectorAll('.ps-opt').forEach(o=>{ o.classList.toggle('on', o===a); }); const opts=menu.querySelector('#psOptions'); if(opts) opts.style.display='none'; + const pd=document.querySelector('.profile .pav-dot'); if(pd) pd.className='pav-dot '+st; + try{ await postJSON('/api/me/status',{status:st}); }catch(_){} + }); } // ---------- Notification bell (activity center) ---------- // Captures things that otherwise have no home: scheduled-meeting invites, reminders, reactions to @@ -730,7 +871,7 @@ function openNotif(n){ NOTIFS=NOTIFS.filter(x=>x.id!==n.id); saveNotifs(); updat renderBell(); } function wireBell(){ const b=document.getElementById('bellBtn'), menu=document.getElementById('bellMenu'); if(!b||!menu) return; - b.onclick=(e)=>{ e.stopPropagation(); const open=menu.classList.toggle('open'); if(open){ renderBell(); NOTIFS.forEach(x=>x.read=true); saveNotifs(); updateBellBadge(); } }; + b.onclick=(e)=>{ e.stopPropagation(); const pm=document.getElementById('pmenu'); if(pm) pm.classList.remove('open'); const open=menu.classList.toggle('open'); if(open){ renderBell(); NOTIFS.forEach(x=>x.read=true); saveNotifs(); updateBellBadge(); } }; document.addEventListener('click',()=>menu.classList.remove('open')); menu.onclick=(e)=>e.stopPropagation(); updateBellBadge(); } // Settings: notification preferences (browser permission + per-type), stored per browser. @@ -738,13 +879,15 @@ function openSettings(){ if(document.getElementById('setModal')) return; const ov=document.createElement('div'); ov.className='modal-ov'; ov.id='setModal'; const sw=(id,label,on)=>''; - const granted=('Notification' in window) && Notification.permission==='granted'; + const perm=('Notification' in window) ? Notification.permission : 'unsupported'; + const granted=perm==='granted'; + const permLabel=granted?'Allowed':(perm==='denied'?'Denied':(perm==='default'?'Not set':'Unsupported')); ov.innerHTML=''; document.body.appendChild(ov); ov.onclick=e=>{ if(e.target===ov) ov.remove(); }; @@ -752,7 +895,7 @@ function openSettings(){ const setPref=(k,v)=>{ try{ localStorage.setItem('notif_'+k, v?'on':'off'); }catch(_){} }; ov.querySelector('#setGroup').onchange=e=>setPref('group', e.target.checked); ov.querySelector('#setDm').onchange=e=>setPref('dm', e.target.checked); - const perm=ov.querySelector('#setPerm'); if(perm) perm.onclick=async()=>{ try{ const r=await Notification.requestPermission(); if(r==='granted'){ perm.textContent='Enabled'; perm.disabled=true; toast('Desktop notifications enabled'); try{ await subscribePush(); }catch(_){} } else toast('Notifications blocked — allow them in your browser site settings'); }catch(_){ toast('Notifications need HTTPS or localhost'); } }; + const permBtn=ov.querySelector('#setPerm'); if(permBtn) permBtn.onclick=async()=>{ try{ const r=await Notification.requestPermission(); if(r==='granted'){ try{ await subscribePush(); }catch(_){} } }catch(_){} ov.remove(); openSettings(); }; // just trigger the browser prompt + refresh the state (no toast) } // ---------- Chat (1:1 + groups) ---------- @@ -763,6 +906,114 @@ let selected=null; // {kind,id} or null = welcome let convoIsGroup=false; // the open thread is a group (drives per-message sender labels) let THREAD=[]; const THREAD_CACHE=new Map(); // key 'kind:id' -> messages[] ; lets a notification click render synchronously (paints immediately) +// Pull-to-refresh (mobile): drag down at the top of a scroll area to refresh. Touch-only, so it's +// a no-op on desktop. onRefresh is an async function. +function enablePullRefresh(el, onRefresh){ + if(!el || el.__ptr) return; el.__ptr=true; + if(getComputedStyle(el).position==='static') el.style.position='relative'; + const ind=document.createElement('div'); ind.className='ptr-ind'; ind.innerHTML=''; el.appendChild(ind); + let startY=0, pulling=false, dist=0, busy=false; const TRIGGER=70; + el.addEventListener('touchstart',(e)=>{ if(busy||el.scrollTop>0||e.touches.length!==1) return; startY=e.touches[0].clientY; pulling=true; dist=0; },{passive:true}); + el.addEventListener('touchmove',(e)=>{ if(!pulling||busy) return; dist=e.touches[0].clientY-startY; if(dist<=0||el.scrollTop>0){ pulling=false; ind.style.opacity='0'; ind.style.transform='translateY(-48px)'; return; } const d=Math.min(dist*0.5,84); ind.style.opacity=String(Math.min(d/56,1)); ind.style.transform='translateY('+(d-48)+'px)'; if(dist>10) e.preventDefault(); },{passive:false}); + el.addEventListener('touchend',async()=>{ if(!pulling) return; pulling=false; if(dist>=TRIGGER){ busy=true; ind.classList.add('spin'); ind.style.opacity='1'; ind.style.transform='translateY(14px)'; try{ await onRefresh(); }catch(_){} await new Promise(r=>setTimeout(r,250)); ind.classList.remove('spin'); busy=false; } ind.style.opacity='0'; ind.style.transform='translateY(-48px)'; dist=0; }); +} +async function reloadThread(){ if(!selected) return; const kind=selected.kind, id=selected.id; const url=kind==='group'?('/api/messages/thread?group='+encodeURIComponent(id)):('/api/messages/thread?with='+encodeURIComponent(id)); try{ const msgs=await fetch(url).then(r=>r.json()); if(selected&&selected.kind===kind&&selected.id===id&&Array.isArray(msgs)){ THREAD=msgs; THREAD_CACHE.set(kind+':'+id, THREAD.slice()); renderThread(); } }catch(_){} } +// #9: in-chat search — highlight every match and jump between them with up/down (no filtering). +let _searchHits=[], _searchIdx=-1; +function clearSearchHighlights(){ + const box=document.getElementById('msgs'); if(box){ box.querySelectorAll('mark.search-hit').forEach(m=>{ const t=document.createTextNode(m.textContent); m.replaceWith(t); }); box.querySelectorAll('.bubble').forEach(b=>{ try{ b.normalize(); }catch(_){} }); } + _searchHits=[]; _searchIdx=-1; +} +function _highlightIn(root, ql){ + const hits=[]; + const walker=document.createTreeWalker(root, NodeFilter.SHOW_TEXT, { acceptNode(n){ if(!n.nodeValue || n.nodeValue.toLowerCase().indexOf(ql)===-1) return NodeFilter.FILTER_REJECT; const p=n.parentElement; if(!p || p.closest('.reply-btn,.react-btn,.del-btn,.t,.reacts,.react-chip,.seenby,.rcpt,.att-sz,button')) return NodeFilter.FILTER_REJECT; return NodeFilter.FILTER_ACCEPT; } }); + const nodes=[]; while(walker.nextNode()) nodes.push(walker.currentNode); + nodes.forEach(n=>{ const txt=n.nodeValue, low=txt.toLowerCase(); let i, last=0; const frag=document.createDocumentFragment(); + while((i=low.indexOf(ql,last))!==-1){ if(i>last) frag.appendChild(document.createTextNode(txt.slice(last,i))); const mk=document.createElement('mark'); mk.className='search-hit'; mk.textContent=txt.slice(i,i+ql.length); frag.appendChild(mk); hits.push(mk); last=i+ql.length; } + if(last>0){ if(lastm.classList.remove('search-current')); + const m=_searchHits[_searchIdx]; m.classList.add('search-current'); try{ m.scrollIntoView({block:'center'}); }catch(_){} + const cnt=document.getElementById('convoSearchCount'); if(cnt) cnt.textContent=(_searchIdx+1)+'/'+_searchHits.length; +} +function closeSearch(){ clearSearchHighlights(); const h=document.getElementById('convoSearchHead'); if(h) h.style.display='none'; const i=document.getElementById('convoSearchInput'); if(i) i.value=''; } +// #10: "Shared" — Media (images) + Files tabs for a DM or group, opened by tapping the chat name. +// Compact "Media, links & docs" entry (shown in the conversation-info window). Clicking it opens +// the separate full Media/Docs/Links view (openMediaView). +function mediaRowHTML(){ return '
'; } +async function wireMediaEntry(root, kind, id, name){ + let data={media:[],docs:[],links:[]}; try{ const url=kind==='group'?('/api/messages/media?group='+encodeURIComponent(id)):('/api/messages/media?with='+encodeURIComponent(id)); const r=await fetch(url).then(x=>x.json()); if(r&&r.media) data=r; }catch(_){} + const total=data.media.length+data.docs.length+data.links.length; + const cnt=root.querySelector('#giMediaCount'); if(cnt) cnt.textContent=(total||0)+' ›'; + const strip=root.querySelector('#giMediaStrip'); if(strip){ strip.innerHTML=data.media.filter(x=>x.isImage).slice(0,4).map(x=>'').join(''); strip.addEventListener('click',e=>{ const im=e.target.closest('.att-img'); if(im&&im.dataset.img) openLightbox(im.dataset.img); }); } + const row=root.querySelector('#giMediaRow'); if(row) row.onclick=()=>openMediaView(kind,id,name,data); +} +function stripExt(n){ n=String(n||''); const i=n.lastIndexOf('.'); return (i>0?n.slice(0,i):n)||'File'; } +function fmtDur(s){ s=Math.round(s); if(!isFinite(s)||s<0) return ''; const h=Math.floor(s/3600),m=Math.floor((s%3600)/60),ss=s%60,p=(n)=>String(n).padStart(2,'0'); return h>0?(h+':'+p(m)+':'+p(ss)):(m+':'+p(ss)); } +// One cell in the shared-Media grid: image → thumbnail; audio/video → a tile with a centred +// download button plus a headphone/▶ glyph and (lazily-loaded) duration, matching the design. +function mediaCellHTML(x){ + const id=pEsc(x.id), nm=pEsc(x.name||''); + if(x.isImage) return '
'; + const isVid=!!x.isVideo, kind=isVid?'vid':'aud'; + return '
' + +'
'+pEsc(stripExt(x.name))+'
' + +'' + +(isVid?'':'') + +''+ic('download',20)+'' + +''+ic(isVid?'play':'headphones',12)+'' + +'
'; +} +// Server doesn't store media duration, so read each audio/video tile's metadata client-side. +function loadMediaDurations(root){ if(!root) return; root.querySelectorAll('i[data-dsrc]').forEach(el=>{ if(el.dataset.done) return; el.dataset.done='1'; try{ const m=document.createElement(el.dataset.dkind==='video'?'video':'audio'); m.preload='metadata'; m.muted=true; m.src=el.dataset.dsrc; m.addEventListener('loadedmetadata',()=>{ el.textContent=fmtDur(m.duration)||''; }); m.addEventListener('error',()=>{ el.textContent=''; }); }catch(_){ el.textContent=''; } }); } +// Separate full view: Media / Docs / Links tabs. +async function openMediaView(kind,id,name,data){ + if(document.getElementById('mediaView')) return; + if(!data){ try{ const url=kind==='group'?('/api/messages/media?group='+encodeURIComponent(id)):('/api/messages/media?with='+encodeURIComponent(id)); data=await fetch(url).then(x=>x.json()); }catch(_){} } + data=data&&data.media?data:{media:[],docs:[],links:[]}; + const ov=document.createElement('div'); ov.className='modal-ov'; ov.id='mediaView'; + ov.innerHTML=''; + document.body.appendChild(ov); + ov.onclick=e=>{ if(e.target===ov) ov.remove(); }; + ov.querySelector('#mvBack').onclick=()=>ov.remove(); + let tab='media'; + const render=()=>{ const b=ov.querySelector('#mvBody'); if(!b) return; + if(tab==='media') b.innerHTML=data.media.length?'
'+data.media.map(mediaCellHTML).join('')+'
':'
No media shared yet
'; + else if(tab==='docs') b.innerHTML=data.docs.length?data.docs.map(x=>''+ic('fileText',16)+''+pEsc(x.name||'file')+''+fmtSize(x.size)+'').join(''):'
No documents shared yet
'; + else b.innerHTML=data.links.length?data.links.map(x=>''+ic('link',16)+''+pEsc(x.url)+'').join(''):'
No links shared yet
'; + loadMediaDurations(b); + }; + ov.querySelectorAll('.mv-tabs .mp-tab').forEach(btn=>btn.onclick=()=>{ tab=btn.dataset.t; ov.querySelectorAll('.mv-tabs .mp-tab').forEach(x=>x.classList.toggle('on',x===btn)); render(); }); + ov.querySelector('#mvBody').addEventListener('click',e=>{ const im=e.target.closest('.att-img'); if(im&&im.dataset.img){ e.preventDefault(); openLightbox(im.dataset.img); } }); + render(); +} +// DM contact-info window (no members): header + favourite + the Media/links/docs entry. +async function openSharedItems(kind,id,name){ + if(document.getElementById('sharedModal')) return; + const r0=rowFor(kind,id); const fav=!!(r0&&r0.favorite); const online=!!(r0&&r0.online); + const ov=document.createElement('div'); ov.className='modal-ov'; ov.id='sharedModal'; + ov.innerHTML=''; + document.body.appendChild(ov); + ov.onclick=e=>{ if(e.target===ov) ov.remove(); }; + ov.querySelector('#shClose').onclick=()=>ov.remove(); + const fb=ov.querySelector('#shFav'); if(fb) fb.onclick=async()=>{ const r=rowFor(kind,id); const on=!(r&&r.favorite); if(r) r.favorite=on; fb.classList.toggle('on',on); fb.title=on?'Remove from favourites':'Add to favourites'; try{ await postJSON('/api/favorites',{kind,id,on}); }catch(_){} renderChats(searchVal()); }; + wireMediaEntry(ov, kind, id, name); +} let convoMembers=[]; // members of the currently open group (for @mentions + highlight) let composeMentions=new Map();// token ('@Name' | 'everyone') -> userId | 'everyone' for the draft const rendered=new Set(); @@ -779,11 +1030,14 @@ function fmtTime(ts){ if(d.toDateString()===y.toDateString()) return 'Yesterday'; return d.toLocaleDateString([], {month:'short',day:'numeric'}); } +// Presence/status helpers (#7): 'incall' (auto) overrides; offline if not connected. +function statusCls(it){ const s=it&&it.status; if(s==='incall') return 'incall'; if(!it||!it.online) return 'offline'; if(s==='away') return 'away'; if(s==='onleave') return 'onleave'; return 'active'; } +function statusLabel(it){ const c=statusCls(it); return c==='incall'?'In a call':c==='away'?'Away':c==='onleave'?'On leave':c==='active'?'Available':'Offline'; } function avatarHTML(it, big){ const isG=it.kind==='group'; const sz=big?'width:38px;height:38px;flex:0 0 38px;':''; const corner=isG?(it.members?''+(it.members>99?'99+':it.members)+'':'') - :''; + :''; const inner=isG?ic('users',big?20:18):pEsc(initials(it.name)); // Photo overlay (BizGaze profile picture, or an uploaded group image). If it fails to // load it removes itself, revealing the initials/glyph underneath. @@ -804,8 +1058,13 @@ function renderChats(filter){ const q=(filter||'').trim().toLowerCase(); const rows=ROWS.filter(it=>!q||it.name.toLowerCase().includes(q)||(it.last_body||'').toLowerCase().includes(q)) .sort((a,b)=>(b.last_at-a.last_at)||a.name.localeCompare(b.name)); - let html = rows.length ? rows.map(rowHTML).join('') - : '
'+(ROWS.length?('No chats match “'+pEsc(filter)+'”.'):'No conversations yet.')+'
'; + let html; + if(!q){ // group favourites at the top when not searching + const favs=rows.filter(r=>r.favorite), rest=rows.filter(r=>!r.favorite); + html = rows.length ? ((favs.length?'
'+ic('star',12)+' Favourites
'+favs.map(rowHTML).join('')+(rest.length?'
All chats
':''):'')+rest.map(rowHTML).join('')) : '
No conversations yet.
'; + } else { + html = rows.length ? rows.map(rowHTML).join('') : '
No chats match “'+pEsc(filter)+'”.
'; + } if(q.length>=1){ // Team contacts you haven't messaged yet → start a new DM. const haveDm=new Set(ROWS.filter(r=>r.kind==='dm').map(r=>r.id)); @@ -857,7 +1116,7 @@ async function loadSidebar(){ function welcomeHTML(){ return '
' + '
👋
' - + '

Hi, '+pEsc(firstName(ME.name||ME.email))+'! Welcome to BizGaze Connect

' + + '

Hi, '+pEsc(firstName(ME.name||ME.email))+'! Welcome to Biz Connect

' + '

Pick a conversation on the left to start chatting, or jump straight into a session from the sidebar.

' + '
' + '

Share Screen

Show your screen with a 6-digit code

' @@ -868,14 +1127,22 @@ function welcomeHTML(){ function wireWelcome(){ document.querySelectorAll('#chatPanel .wcard').forEach(card=>{ card.onclick=()=>switchTab(card.dataset.go); }); } function convoShellHTML(it){ const isG=it.kind==='group'; - const sub=isG?((it.members||0)+' members'):(it.online?'Online':'Offline'); + const sub=isG?((it.members||0)+' members'):statusLabel(it); return '
' + '
' + '' + avatarHTML(it,true) - + '
'+pEsc(it.name)+'
'+pEsc(sub)+'
' + + '
'+pEsc(it.name)+'
'+pEsc(sub)+'
' + + '' + '' + (isG?'':'') + + '' + '
' + '
' + '' @@ -914,6 +1181,7 @@ function bubbleHTML(m){ if(m.evt==='call-start') return '
📞 '+(m.from===ME.id?'You':pEsc(m.byName||'Someone'))+' started a call
'; if(m.system||m.from==='__system__') return '
'+pEsc(m.body)+'
'; const mine=m.from===ME.id; + if(m.deleted) return '
'+ic('trash',12)+' This message was deleted'+pEsc(fmtClock(m.created_at))+'
'; const sender=(convoIsGroup && !mine && m.fromName)?'
'+senderAvatar(m.from, m.fromName)+''+pEsc(m.fromName)+'
':''; let quote=''; if(m.reply){ const t=replyTint(m.reply.from||m.reply.fromName); quote='
'+pEsc(m.reply.fromName||'')+': '+pEsc(m.reply.body)+'
'; } @@ -932,6 +1200,7 @@ function bubbleHTML(m){ + sender + quote + att + renderMsgBody(m) + pollHTML(m) + '' + '' + + (mine?'':'') + ''+pEsc(fmtClock(m.created_at))+rcpt+'' + reacts + seen + '
'; } @@ -1059,6 +1328,16 @@ function onChatReaction(d){ const m=THREAD.find(x=>x.id===d.messageId); if(m && } // Read receipts (DM): the other party read my messages → mark mine as seen. function onChatRead(d){ if(!d||!d.by) return; if(selected && selected.kind==='dm' && selected.id===d.by){ let changed=false; THREAD.forEach(m=>{ if(m.from===ME.id && !m.read_at){ m.read_at=Date.now(); changed=true; } }); if(changed) renderThread(); } } +// Delete-for-everyone: confirm, tell the server, then blank the message locally (the server also +// broadcasts chat-deleted to the other side / other tabs). +async function deleteMessage(id){ if(!confirm('Delete this message for everyone?')) return; try{ await postJSON('/api/messages/delete',{id}); markMsgDeleted(id); }catch(e){ toast(e.message||'Could not delete'); } } +function markMsgDeleted(id){ + let changed=false; + THREAD.forEach(m=>{ if(m.id===id && !m.deleted){ m.deleted=true; m.body=''; m.attachment=null; m.reactions=[]; m.reply=null; m.poll=null; changed=true; } }); + THREAD_CACHE.forEach(arr=>arr.forEach(m=>{ if(m.id===id){ m.deleted=true; m.body=''; m.attachment=null; } })); + if(changed) renderThread(); +} +function onChatDeleted(d){ if(!d||!d.id) return; markMsgDeleted(d.id); try{ loadSidebar(); }catch(_){} } // refresh last-message previews // DM delivered: recipient's client acked → second (grey) tick. function onChatDelivered(d){ if(!d||!d.id) return; const m=THREAD.find(x=>x.id===d.id); if(m && !m.delivered_at){ m.delivered_at=Date.now(); updateBubble(m); } } // Group read: a member opened the group → add them to "Seen by" on my messages up to that time. @@ -1129,6 +1408,10 @@ function showMeetingReminder(mtg){ if(!mtg||!mtg.room) return; try{ playPing(); // In-call participants panel (everyone) + host controls (mute all, transfer host). function meetParticipantsList(){ const a=[{id:'__local', name:((ME&&ME.name)||(ME&&ME.email)||'You')+' (you)'}]; meetPeers.forEach((p,pid)=>a.push({id:pid, name:meetNames.get(pid)||p.name||'Guest'})); return a; } function refreshMeetPanel(){ if(document.getElementById('meetPanel')) renderMeetPanel(); } +// #6: track invitees who haven't joined yet. They show under "Not joined yet"; after 30s the +// invite is re-enabled for them. When they actually join (matched by user id), they're cleared. +function meetInviteJoined(uid){ const e=meetInvited.get(uid); if(e){ if(e.timer) clearTimeout(e.timer); meetInvited.delete(uid); refreshMeetPanel(); } } +function meetAddInvited(ids){ (ids||[]).forEach(uid=>{ const c=(CONTACTS||[]).find(x=>x.id===uid); const name=(c&&c.name)||'Someone'; const prev=meetInvited.get(uid); if(prev&&prev.timer) clearTimeout(prev.timer); const timer=setTimeout(()=>{ meetInvited.delete(uid); refreshMeetPanel(); }, 30000); meetInvited.set(uid,{name,timer}); }); refreshMeetPanel(); } let meetPanelTab='people'; // 'people' | 'add' let _addPool=null; // cached candidates for "Add people" (group members for a group call) function toggleMeetPanel(){ const ex=document.getElementById('meetPanel'); if(ex){ ex.remove(); return; } const meet=document.querySelector('.meet'); if(!meet) return; meetPanelTab='people'; const p=document.createElement('div'); p.id='meetPanel'; p.className='meet-panel'; meet.appendChild(p); renderMeetPanel(); } @@ -1146,19 +1429,20 @@ function renderMeetPanel(){ const pool = inGroup ? (_addPool||[]) : (CONTACTS||[]); const here=new Set(list.map(pp=>(pp.name||'').replace(/\s*\(you\)$/,'').trim().toLowerCase())); const myName=((ME&&ME.name)||(ME&&ME.email)||'').trim().toLowerCase(); - const avail=pool.filter(c=>c.id!==(ME&&ME.id) && (c.name||'').trim().toLowerCase()!==myName && !here.has((c.name||'').trim().toLowerCase())); + const avail=pool.filter(c=>c.id!==(ME&&ME.id) && (c.name||'').trim().toLowerCase()!==myName && !here.has((c.name||'').trim().toLowerCase()) && !meetInvited.has(c.id)); body='
'+(avail.length?avail.map(c=>'').join(''):'
'+(inGroup&&_addPool===null?'Loading…':'Everyone\'s already here')+'
')+'
'; } else { body='
'+list.map(pp=>'
'+pEsc(initials(pp.name))+''+pEsc(pp.name)+''+(isHostRow(pp)?''+ic('crown',11)+' Host':'')+((pp.id==='__local'?meetScreen:meetSharers.has(pp.id))?''+ic('monitor',13)+'':'')+(meetMuted.get(pp.id)?''+ic('micOff',13)+'':'')+((meetIsHost&&pp.id!=='__local'&&!isHostRow(pp))?'':'')+'
').join('')+'
' + +(meetInvited.size?'
Not joined yet
'+[...meetInvited.entries()].map(([uid,e])=>'
'+pEsc(initials(e.name))+''+pEsc(e.name)+''+ic('calendarClock',12)+' waiting…
').join('')+'
':'') +(meetIsHost?'':''); } - p.innerHTML=head+tabs+body; + p.innerHTML=head+tabs+'
'+body+'
'; const x=p.querySelector('.mp-x'); if(x) x.onclick=()=>p.remove(); p.querySelectorAll('.mp-tab').forEach(b=>b.onclick=()=>{ meetPanelTab=b.dataset.tab; renderMeetPanel(); }); const ma=p.querySelector('.mp-muteall'); if(ma) ma.onclick=()=>{ meetSend({type:'meeting-muteall'}); toast('Muted everyone'); }; const mm=p.querySelector('#mpMulti'); if(mm) mm.onchange=()=>{ meetMultiShare=mm.checked; meetSend({type:'meeting-sharemode', multi:meetMultiShare}); }; p.querySelectorAll('.mp-makehost').forEach(b=>b.onclick=()=>{ meetSend({type:'meeting-host', to:b.dataset.id}); meetHostId=b.dataset.id; meetIsHost=false; renderMeetPanel(); }); - const inv=p.querySelector('#mpInvite'); if(inv) inv.onclick=async()=>{ const ids=[...p.querySelectorAll('input:checked')].map(i=>i.value); if(!ids.length){ toast('Pick people to invite'); return; } try{ const r=await postJSON('/api/calls/invite',{ room:meetRoom, userIds:ids }); toast('Invited '+r.invited+(r.invited===1?' person':' people')); meetPanelTab='people'; renderMeetPanel(); }catch(e){ toast(e.message||'Could not invite'); } }; + const inv=p.querySelector('#mpInvite'); if(inv) inv.onclick=async()=>{ const ids=[...p.querySelectorAll('input:checked')].map(i=>i.value); if(!ids.length){ toast('Pick people to invite'); return; } try{ await postJSON('/api/calls/invite',{ room:meetRoom, userIds:ids }); meetAddInvited(ids); meetPanelTab='people'; toast(ids.length===1?'Waiting for them to join…':'Waiting for '+ids.length+' people to join…'); renderMeetPanel(); }catch(e){ toast(e.message||'Could not invite'); } }; } // Add people to the current call (from the in-call bar). function openInvitePicker(room){ @@ -1221,11 +1505,17 @@ function appendBubble(m){ box.insertAdjacentHTML('beforeend', bubbleHTML(m)); box.scrollTop=box.scrollHeight; } +let _openUnread=0; // set by selectChat (the unread count before it's reset) so we can open at the first unread async function openConvo(kind,id){ const it=rowFor(kind,id)||{kind,id,name:'Conversation'}; + const _unreadN=_openUnread; _openUnread=0; // consume the captured unread count (#3) convoIsGroup=(kind==='group'); const el=document.getElementById('chatPanel'); el.classList.remove('center'); el.innerHTML=convoShellHTML(it); + // #1: drag-and-drop a file/video/image anywhere on the conversation to send it. + el.ondragover=(e)=>{ if(e.dataTransfer&&Array.from(e.dataTransfer.types||[]).includes('Files')){ e.preventDefault(); el.classList.add('drag-over'); } }; + el.ondragleave=(e)=>{ if(e.relatedTarget===null||!el.contains(e.relatedTarget)) el.classList.remove('drag-over'); }; + el.ondrop=(e)=>{ const f=e.dataTransfer&&e.dataTransfer.files&&e.dataTransfer.files[0]; if(f){ e.preventDefault(); el.classList.remove('drag-over'); uploadFile(f); } }; const back=document.getElementById('convoBack'); if(back) back.onclick=showWelcome; const form=document.getElementById('composer'); if(form) form.addEventListener('submit',(e)=>{ e.preventDefault(); sendMessage(); }); clearReply(); pendingAttach=null; hideAttach(); @@ -1242,18 +1532,33 @@ async function openConvo(kind,id){ inpEl.addEventListener('keydown', (e)=>{ if(e.key==='Enter' && !e.shiftKey){ if(mentionItems && mentionItems.length) return; e.preventDefault(); sendMessage(); } }); // Enter sends, Shift+Enter = newline } const ci=document.getElementById('convoInfo'); if(ci) ci.onclick=()=>openGroupInfo(id); - const ct=document.getElementById('convoTitle'); if(ct) ct.onclick=()=>openGroupInfo(id); + const ct=document.getElementById('convoTitle'); if(ct) ct.onclick=()=>{ if(kind==='group') openGroupInfo(id); else { const r=rowFor(kind,id); openSharedItems('dm', id, (r&&r.name)||it.name||''); } }; // name → group: info+media; DM: media/files const cc=document.getElementById('convoCall'); if(cc) cc.onclick=()=>(kind==='group'?startOrJoinGroupCall(id):startOrJoinDmCall(id)); + const csb=document.getElementById('convoSearch'); const cshead=document.getElementById('convoSearchHead'); const csin=document.getElementById('convoSearchInput'); + if(csb) csb.onclick=()=>{ if(!cshead) return; cshead.style.display='flex'; if(csin){ csin.value=''; setTimeout(()=>csin.focus(),0); } }; + if(csin) csin.oninput=()=>runSearch(csin.value); + if(csin) csin.addEventListener('keydown',e=>{ if(e.key==='Enter'){ e.preventDefault(); gotoHit(_searchIdx + (e.shiftKey?-1:1)); } if(e.key==='Escape'){ closeSearch(); } }); + const csClose=document.getElementById('convoSearchClose'); if(csClose) csClose.onclick=closeSearch; + const csPrev=document.getElementById('convoSearchPrev'); if(csPrev) csPrev.onclick=()=>gotoHit(_searchIdx-1); + const csNext=document.getElementById('convoSearchNext'); if(csNext) csNext.onclick=()=>gotoHit(_searchIdx+1); const box=document.getElementById('msgs'); if(box) box.addEventListener('click',(e)=>{ const im=e.target.closest('.att-img'); if(im && im.dataset.img){ openLightbox(im.dataset.img); return; } const po=e.target.closest('.poll-opt'); if(po){ if(!po.disabled) votePoll(po.dataset.poll, +po.dataset.idx); return; } const pcl=e.target.closest('.poll-close'); if(pcl){ closePoll(pcl.dataset.poll); return; } const rb=e.target.closest('.reply-btn'); if(rb){ const mm=THREAD.find(x=>x.id===rb.dataset.id); if(mm) setReply(mm); return; } + const dl=e.target.closest('.del-btn'); if(dl){ deleteMessage(dl.dataset.del); return; } const ab=e.target.closest('.react-btn'); if(ab){ openEmojiForReact(ab.dataset.id, ab); return; } const ch=e.target.closest('.react-chip'); if(ch){ reactToMessage(ch.dataset.id, ch.dataset.emoji); return; } const sb=e.target.closest('.seenby'); if(sb){ const ns=(sb.dataset.seen||'').split('|').filter(Boolean); toast('Seen by: '+ns.join(', ')); return; } + // Tap-to-reveal (mobile): a tap on the bubble body (not an action) reveals its reply/react/delete + // icons; the action only fires on a SECOND tap once they're shown (icons are pointer-events:none + // until revealed). Tapping elsewhere hides them. + const bub=e.target.closest('.bubble'); const already=bub&&bub.classList.contains('show-actions'); + box.querySelectorAll('.bubble.show-actions').forEach(b=>b.classList.remove('show-actions')); + if(bub && !already && !bub.classList.contains('deleted')) bub.classList.add('show-actions'); }); if(box) box.addEventListener('scroll', onMsgsScroll); + if(box) enablePullRefresh(box, reloadThread); // pull down at the top to refresh the conversation const jl=document.getElementById('jumpLatest'); if(jl) jl.onclick=()=>{ const b=document.getElementById('msgs'); if(b) b.scrollTop=b.scrollHeight; }; composeMentions=new Map(); convoMembers=[]; // Synchronous render from cache (within the click's activation → paints immediately, even @@ -1269,6 +1574,13 @@ async function openConvo(kind,id){ THREAD=Array.isArray(msgs)?msgs:[]; THREAD_CACHE.set(ckey, THREAD.slice()); renderThread(); + // #3: if there were unread messages, drop a "New messages" divider, scroll to the first unread, + // and show the "jump to newest" arrow so the user can return to the bottom. + if(_unreadN>0 && THREAD.length>=_unreadN){ + const fu=THREAD[THREAD.length-_unreadN]; const esc=(window.CSS&&CSS.escape)?CSS.escape(fu.id):fu.id; + const el=document.querySelector('#msgs .bubble[data-id="'+esc+'"]') || document.querySelector('#msgs [data-id="'+esc+'"]'); + if(el){ const sep=document.createElement('div'); sep.className='new-sep'; sep.innerHTML='New messages'; el.parentNode.insertBefore(sep, el); el.scrollIntoView({block:'start'}); el.parentNode.scrollTop-=40; const jl=document.getElementById('jumpLatest'); if(jl) jl.style.display='grid'; } + } const inp=document.getElementById('msgInput'); if(inp) inp.focus(); } // ---------- @mentions (group chat) ---------- @@ -1426,7 +1738,7 @@ async function selectChat(kind,id){ ensureNotifyPermission(); selected={kind,id}; document.body.classList.add('chat-open'); // mobile: show the conversation pane - const it=rowFor(kind,id); if(it) it.unread=0; + const it=rowFor(kind,id); _openUnread=(it&&it.unread)||0; if(it) it.unread=0; // capture before reset (#3: open at first unread) renderChats(searchVal()); updateRailUnread(); await openConvo(kind,id); @@ -1451,10 +1763,34 @@ async function sendMessage(){ renderChats(searchVal()); }catch(e){ inp.value=text; toast(e.message||'Could not send'); } } -function ensureNotifyPermission(){ try{ if('Notification' in window && Notification.permission==='default') Notification.requestPermission(); }catch(_){} } -// Notification preferences (set in Dashboard → Settings; stored per browser). Default ON. +// Request permission from a user gesture (e.g. opening a chat) AND subscribe on grant — the +// subscribe-on-grant is essential on iOS, where permission is granted in-session and push +// won't work until a subscription exists. +function ensureNotifyPermission(){ try{ if('Notification' in window && Notification.permission==='default') Notification.requestPermission().then(r=>{ if(r==='granted'){ try{ subscribePush(); }catch(_){} } }).catch(()=>{}); }catch(_){} } +// Notification preferences (set in Settings; stored per browser). Default ON. function notifOn(kind){ try{ return localStorage.getItem('notif_'+kind)!=='off'; }catch(_){ return true; } } -document.addEventListener('click', ensureNotifyPermission, { once:true }); +// Mobile has no top bar (#7): keep bell+profile in the chat-list header; desktop keeps them in +// the top header. Re-placed on load and on resize. Moving the node preserves its wiring. +function placeHdrRight(){ + const hr=document.getElementById('hdrRight'); if(!hr) return; + // Desktop: bell+profile sit at the BOTTOM of the left rail (a clear, persistent spot). + // Mobile: top-right of the chat-list header (the main screen). + if(window.matchMedia('(max-width:760px)').matches){ const st=document.querySelector('#chatcol .side-title'); if(st && hr.parentElement!==st) st.appendChild(hr); } + else { const rail=document.getElementById('rail'); if(rail && hr.parentElement!==rail) rail.appendChild(hr); } +} +// Explicit, dismissible prompt when permission is still 'default'. This is the reliable path on +// iOS (permission must be requested from a tap inside the installed PWA). +function maybeNotifPrompt(){ + try{ + if(!('Notification' in window) || Notification.permission!=='default') return; + if(sessionStorage.getItem('notifPromptDismissed')==='1' || document.getElementById('notifPrompt')) return; + const b=document.createElement('div'); b.id='notifPrompt'; b.className='notif-prompt'; + b.innerHTML=''+ic('bell',16)+' Turn on notifications so you don’t miss messages & calls.'; + document.body.appendChild(b); + document.getElementById('npEnable').onclick=async()=>{ try{ const r=await Notification.requestPermission(); if(r==='granted'){ toast('Notifications enabled'); await subscribePush(); } else { toast('Notifications blocked — allow them in your browser/site settings'); } }catch(_){ toast('Notifications need HTTPS'); } b.remove(); }; + document.getElementById('npX').onclick=()=>{ try{ sessionStorage.setItem('notifPromptDismissed','1'); }catch(_){} b.remove(); }; + }catch(_){} +} // ----- Web Push: reliable notifications when the tab is backgrounded / frozen / closed ----- // A notification-only service worker (sw.js, no caching) shows OS popups via the push service. // pushActive => the page can leave hidden-tab popups to push (avoids double-notifying). @@ -1481,6 +1817,16 @@ async function subscribePush(){ pushActive=true; console.log('[push] subscribed OK'); }catch(e){ console.warn('[push] subscribe failed:', e); } // surfaced (not swallowed) so we can see the real reason } +// On logout, remove this device's push subscription so notifications STOP for the logged-out user. +async function unsubscribePush(){ + try{ + if(!_swReg){ try{ _swReg=await navigator.serviceWorker.getRegistration(); }catch(_){} } + if(!_swReg) return; + const sub=await _swReg.pushManager.getSubscription(); + if(sub){ try{ await postJSON('/api/push/unsubscribe',{endpoint:sub.endpoint}); }catch(_){} try{ await sub.unsubscribe(); }catch(_){} } + pushActive=false; console.log('[push] unsubscribed (logout)'); + }catch(_){} +} // Open the chat from an in-page notification. Navigation reliably repaints across browsers (a // notification click is not an in-page gesture, so an in-place open won't paint until you // tap). The reload is made fast by HTTP caching + a boot fast-path that opens the chat first. @@ -1493,14 +1839,16 @@ function notify(title, body, kind, id){ }catch(_){} } let _audioCtx=null; +// Message chime: a crisp, recognizable rising two-note ("ti-doo") — louder + brighter than the +// old single beep so it's catchy and noticed. function playPing(){ try{ _audioCtx=_audioCtx||new (window.AudioContext||window.webkitAudioContext)(); if(_audioCtx.state==='suspended') _audioCtx.resume(); - const t=_audioCtx.currentTime, o=_audioCtx.createOscillator(), g=_audioCtx.createGain(); - o.type='sine'; o.frequency.setValueAtTime(880,t); o.frequency.setValueAtTime(660,t+0.09); - g.gain.setValueAtTime(0.0001,t); g.gain.exponentialRampToValueAtTime(0.14,t+0.012); g.gain.exponentialRampToValueAtTime(0.0001,t+0.35); - o.connect(g); g.connect(_audioCtx.destination); o.start(t); o.stop(t+0.36); + const t=_audioCtx.currentTime; + const tone=(f,start,dur,peak)=>{ const o=_audioCtx.createOscillator(), g=_audioCtx.createGain(); o.type='triangle'; o.frequency.value=f; g.gain.setValueAtTime(0.0001,t+start); g.gain.exponentialRampToValueAtTime(peak,t+start+0.015); g.gain.exponentialRampToValueAtTime(0.0001,t+start+dur); o.connect(g); g.connect(_audioCtx.destination); o.start(t+start); o.stop(t+start+dur+0.03); }; + tone(784.0, 0.00, 0.16, 0.34); // G5 + tone(1174.7, 0.11, 0.40, 0.34); // D6 — rises, rings out -> "ti-doo" }catch(_){} } // Continuous incoming-call ring. A soft, soothing bell chime — a gentle ascending arpeggio @@ -1521,10 +1869,10 @@ function ringOnce(){ _audioCtx=_audioCtx||new (window.AudioContext||window.webkitAudioContext)(); if(_audioCtx.state==='suspended') _audioCtx.resume(); const t=_audioCtx.currentTime; - ringTone(t, 523.25, 1.2, 0.11); // C5 - ringTone(t+0.20, 659.25, 1.2, 0.10); // E5 - ringTone(t+0.40, 783.99, 1.5, 0.11); // G5 - ringTone(t+0.60, 1046.5, 1.9, 0.09); // C6 — rings out softly + ringTone(t, 523.25, 1.2, 0.24); // C5 (louder so an incoming call is clearly heard) + ringTone(t+0.20, 659.25, 1.2, 0.22); // E5 + ringTone(t+0.40, 783.99, 1.5, 0.24); // G5 + ringTone(t+0.60, 1046.5, 1.9, 0.20); // C6 — rings out }catch(_){} } function startRing(){ _ringRefs++; if(_ringTimer) return; ringOnce(); _ringTimer=setInterval(ringOnce, 3500); } @@ -1570,7 +1918,7 @@ function connectChatWs(){ try{ chatWs=new WebSocket((location.protocol==='https:'?'wss://':'ws://')+location.host+'/ws'); chatWs.onopen=()=>{ try{ chatWs.send(JSON.stringify({type:'chat-hello'})); }catch(_){} }; - chatWs.onmessage=(e)=>{ let d; try{ d=JSON.parse(e.data); }catch(_){ return; } if(d.type==='chat-message' && d.message) onChatMessage(d.message); else if(d.type==='chat-reaction') onChatReaction(d); else if(d.type==='poll-update' && d.poll) onPollUpdate(d); else if(d.type==='chat-read') onChatRead(d); else if(d.type==='chat-delivered') onChatDelivered(d); else if(d.type==='group-read') onGroupRead(d); else if(d.type==='group-call') onGroupCall(d); else if(d.type==='dm-call') onDmCall(d); else if(d.type==='group-update') onGroupUpdate(d); else if(d.type==='call-invite') showCallInvite(d.room, d.byName); else if(d.type==='meeting-invite') showMeetingInvite(d.meeting); else if(d.type==='meeting-reminder') showMeetingReminder(d.meeting); else if(d.type==='meeting-cancelled') showMeetingCancelled(d.meeting); else if(d.type==='group-role') onGroupRole(d); }; + chatWs.onmessage=(e)=>{ let d; try{ d=JSON.parse(e.data); }catch(_){ return; } if(d.type==='chat-message' && d.message) onChatMessage(d.message); else if(d.type==='chat-deleted') onChatDeleted(d); else if(d.type==='chat-reaction') onChatReaction(d); else if(d.type==='poll-update' && d.poll) onPollUpdate(d); else if(d.type==='chat-read') onChatRead(d); else if(d.type==='chat-delivered') onChatDelivered(d); else if(d.type==='group-read') onGroupRead(d); else if(d.type==='group-call') onGroupCall(d); else if(d.type==='dm-call') onDmCall(d); else if(d.type==='group-update') onGroupUpdate(d); else if(d.type==='call-invite') showCallInvite(d.room, d.byName); else if(d.type==='meeting-invite') showMeetingInvite(d.meeting); else if(d.type==='meeting-reminder') showMeetingReminder(d.meeting); else if(d.type==='meeting-cancelled') showMeetingCancelled(d.meeting); else if(d.type==='group-role') onGroupRole(d); }; chatWs.onclose=()=>{ setTimeout(connectChatWs, 3000); }; // auto-reconnect }catch(_){} } @@ -1585,12 +1933,16 @@ function openNewGroup(){ const ov=document.createElement('div'); ov.className='modal-ov'; ov.id='groupModal'; ov.innerHTML=''; document.body.appendChild(ov); ov.onclick=(e)=>{ if(e.target===ov) ov.remove(); }; document.getElementById('grpCancel').onclick=()=>ov.remove(); document.getElementById('grpCreate').onclick=createGroup; + const gs=document.getElementById('grpSearch'); + if(gs) gs.oninput=()=>{ const q=gs.value.trim().toLowerCase(); let shown=0; ov.querySelectorAll('.grp-members .chk').forEach(l=>{ const vis=!q||(l.dataset.name||'').includes(q); l.style.display=vis?'':'none'; if(vis) shown++; }); const nr=document.getElementById('grpNoResult'); if(nr) nr.style.display=shown?'none':'block'; }; const n=document.getElementById('grpName'); if(n) n.focus(); } async function createGroup(){ @@ -1617,7 +1969,7 @@ async function openGroupInfo(gid){ + '' + '
' + '
' - + '
'+pEsc(info.name)+'
' + + '
'+pEsc(info.name)+'
' + '' + '
'+info.members.length+' member'+(info.members.length===1?'':'s')+'
' + '
' @@ -1625,6 +1977,7 @@ async function openGroupInfo(gid){ + '
' + (info.createdByName?'
'+ic('info',13)+' Created by '+pEsc(info.createdByName)+' on '+pEsc(fmtDateTime(info.createdAt))+'
':'') + (info.isAdmin?'':'') + + '
Shared
' + mediaRowHTML() + '
Members'+(canManage?'':'')+'
' + '
'+info.members.map(m=>'
'+miniAv(m.name,m.avatar)+''+pEsc(m.name)+(m.isMe?' you':'')+(m.admin?' '+ic('crown',11)+' Admin':'')+''+((info.isAdmin&&!m.isMe)?'':'')+((canManage&&!m.isMe)?'':'')+'
').join('')+'
' + ''; const _old=document.getElementById('groupInfo'); if(_old) _old.remove(); // never stack group-info modals (stale close buttons) document.body.appendChild(ov); + wireMediaEntry(ov, 'group', gid, info.name); // "Media, links & docs" entry above the members ov.onclick=(e)=>{ if(e.target===ov) ov.remove(); }; ov.querySelector('#giClose').onclick=()=>ov.remove(); + { const gf=ov.querySelector('#giFav'); if(gf) gf.onclick=async()=>{ const r=rowFor('group',gid); const on=!(r&&r.favorite); if(r) r.favorite=on; gf.classList.toggle('on',on); gf.title=on?'Remove from favourites':'Add to favourites'; try{ await postJSON('/api/favorites',{kind:'group',id:gid,on}); }catch(_){} renderChats(searchVal()); }; } const refresh=async()=>{ await loadSidebar(); if(selected&&selected.kind==='group'&&selected.id===gid) openConvo('group',gid); }; const view=ov.querySelector('#giView'), editRow=ov.querySelector('#giEditRow'), nameInp=ov.querySelector('#giName'), closeBtn=ov.querySelector('#giClose'); document.getElementById('giEdit').onclick=()=>{ view.classList.add('hidden'); editRow.classList.remove('hidden'); closeBtn.style.display='none'; nameInp.focus(); nameInp.select(); }; @@ -1698,6 +2053,10 @@ let meetAudioOnly=false; // audio call (no camera) — tiles show avatars ins const meetPeers=new Map(); // peerId -> { pc, name } const meetMuted=new Map(); // peerId|'__local' -> muted (for the tile mic-off badge) const meetNames=new Map(); // peerId -> name (peers that arrive before their offer) +const meetAvatars=new Map(); // peerId -> avatar URL (for participant-tile profile pics) +const meetPeerUids=new Map();// peerId -> user id (to tell when an invitee has joined) +const meetCamOff=new Map(); // peerId -> camera off? (a disabled remote track still arrives, so show the avatar) +const meetInvited=new Map(); // user id -> {name, timer} for invitees who haven't joined yet (#6) let meetReturn=null; // {kind:'dm'|'group', id} — chat to land on when the call ends (null = meetings tab) const meetVU=new Map(); // peerId|'__local' -> {ctx,analyser,data,raf} active-speaker meters let MEET_ICE={ iceServers:[{ urls:'stun:stun.l.google.com:19302' }] }; @@ -1919,14 +2278,15 @@ function addTile(id, stream, label, muted){ const grid=document.getElementById('meetGrid'); if(!grid) return; let tile=document.getElementById('meet-tile-'+id); if(!tile){ tile=document.createElement('div'); tile.className='meet-tile'; tile.id='meet-tile-'+id; - tile.innerHTML='
'+pEsc(initials(label||'?'))+'
'+pEsc(label||'')+''; grid.appendChild(tile); } + const av=(id==='__local')?((ME&&ME.avatarUrl)||null):(meetAvatars.get(id)||null); // profile pic on the tile + tile.innerHTML='
'+pEsc(initials(label||'?'))+(av?'':'')+'
'+pEsc(label||'')+''; grid.appendChild(tile); } const v=tile.querySelector('video'); if(v && stream && v.srcObject!==stream) v.srcObject=stream; const hasVid=!!(stream && stream.getVideoTracks && stream.getVideoTracks().some(t=>t.enabled && t.readyState!=='ended')); - tile.classList.toggle('novid', !hasVid); + tile.classList.toggle('novid', !hasVid || meetCamOff.get(id)===true); // camOff peer → avatar even if a (disabled) track arrives if(meetMuted.has(id)) setTileMute(id, meetMuted.get(id)); // apply any known mute state } function setTileMute(id, muted){ meetMuted.set(id, !!muted); const t=document.getElementById('meet-tile-'+id); if(t){ const b=t.querySelector('.meet-mute'); if(b) b.style.display=muted?'grid':'none'; if(muted) t.classList.remove('speaking'); } } -function removeTile(id){ const t=document.getElementById('meet-tile-'+id); if(t) t.remove(); meetMuted.delete(id); meetUnwatch(id); } +function removeTile(id){ const t=document.getElementById('meet-tile-'+id); if(t) t.remove(); meetMuted.delete(id); meetCamOff.delete(id); meetUnwatch(id); } // ---- Active-speaker meter: highlight a tile while its audio is above a threshold ---- function meetWatchStream(id, stream){ if(!stream || !stream.getAudioTracks || !stream.getAudioTracks().length) return; // no audio yet @@ -2095,7 +2455,7 @@ async function onMeetMsg(e){ meetWatchStream('__local', meetLocalStream); // active-speaker detection on my own mic // Existing peers OFFER to me (their offers carry their tracks incl. any active screen share); // I just set up the connections and wait. Avoids the "newcomer can't receive screen" bug. - for(const p of (m.peers||[])){ meetNames.set(p.peerId,p.name); meetMakePeer(p.peerId,p.name); } + for(const p of (m.peers||[])){ meetNames.set(p.peerId,p.name); if(p.avatar) meetAvatars.set(p.peerId,p.avatar); if(p.uid){ meetPeerUids.set(p.peerId,p.uid); meetInviteJoined(p.uid); } meetMakePeer(p.peerId,p.name); } meetSend({type:'meeting-state', muted:!meetMic, camOff:!meetCam}); // tell existing peers my state if(meetIsHost) meetSend({type:'meeting-host', to:meetMyId}); // announce host so others know refreshMeetPanel(); updateHostControls(); @@ -2103,7 +2463,7 @@ async function onMeetMsg(e){ } if(m.type==='meeting-ended'){ toast('Call ended'); leaveMeeting(); return; } // 1:1 hangup, or host ended if(m.type==='meeting-peer-joined'){ - meetNames.set(m.peerId,m.name); + meetNames.set(m.peerId,m.name); if(m.avatar) meetAvatars.set(m.peerId,m.avatar); if(m.uid){ meetPeerUids.set(m.peerId,m.uid); meetInviteJoined(m.uid); } const pc=meetMakePeer(m.peerId,m.name); // I'm an existing peer → I OFFER to the newcomer (carries my screen) try{ const offer=await pc.createOffer(); await pc.setLocalDescription(offer); meetSend({type:'meeting-signal',to:m.peerId,data:{sdp:pc.localDescription}}); }catch(_){} meetSend({type:'meeting-state', muted:!meetMic, camOff:!meetCam}); @@ -2111,7 +2471,7 @@ async function onMeetMsg(e){ if(meetScreen) meetSend({type:'meeting-screen', on:true}); refreshMeetPanel(); return; } - if(m.type==='meeting-peer-state'){ setTileMute(m.peerId, !!m.muted); refreshMeetPanel(); return; } + if(m.type==='meeting-peer-state'){ setTileMute(m.peerId, !!m.muted); meetCamOff.set(m.peerId, !!m.camOff); const _t=document.getElementById('meet-tile-'+m.peerId); if(_t){ const v=_t.querySelector('video'), s=v&&v.srcObject; const hv=!!(s&&s.getVideoTracks&&s.getVideoTracks().some(tr=>tr.enabled&&tr.readyState!=='ended')); _t.classList.toggle('novid', !hv || !!m.camOff); } refreshMeetPanel(); return; } // camOff -> show avatar, not a black tile if(m.type==='meeting-host'){ const was=meetIsHost; meetHostId=m.hostPeerId; meetIsHost=(m.hostPeerId===meetMyId); if(meetIsHost&&!was){ toast('You are now the host'); try{ speak('You are now the host'); }catch(_){} } refreshMeetPanel(); updateHostControls(); return; } if(m.type==='meeting-transcribe-state'){ applyRoomTx(!!m.active); return; } // ≥1 subscriber → transcribe my mic if(m.type==='meeting-recording'){ recNotice(!!m.on, m.by); return; } // someone is recording — show + announce @@ -2183,7 +2543,7 @@ function leaveMeeting(){ if(_recTimer){ clearInterval(_recTimer); _recTimer=null; } const _rn=document.getElementById('recNotice'); if(_rn) _rn.remove(); // clear any "Recording" badge meetSend({type:'meeting-leave'}); meetUnwatchAll(); meetSharers.clear(); - meetPeers.forEach(p=>{ try{p.pc.close();}catch(_){} }); meetPeers.clear(); meetNames.clear(); meetMuted.clear(); + meetPeers.forEach(p=>{ try{p.pc.close();}catch(_){} }); meetPeers.clear(); meetNames.clear(); meetAvatars.clear(); meetPeerUids.clear(); meetCamOff.clear(); meetInvited.forEach(e=>{ if(e.timer) clearTimeout(e.timer); }); meetInvited.clear(); meetMuted.clear(); if(meetLocalStream){ try{ meetLocalStream.getTracks().forEach(t=>t.stop()); }catch(_){} meetLocalStream=null; } if(meetWs){ try{ meetWs.close(); }catch(_){} meetWs=null; } meetRoom=null; meetMyId=null; meetState='idle'; meetIsHost=false; meetHostId=null; meetRailLive(false); @@ -2221,7 +2581,24 @@ railBtns.forEach(btn=>{ btn.onclick=()=>{ switchTab(tab); }; }); // Esc clears the open conversation and brings back the welcome screen. -document.addEventListener('keydown',(e)=>{ if(e.key==='Escape' && currentTab()==='chat' && selected!=null){ showWelcome(); } }); +// Esc closes the topmost popup/search FIRST (capture phase, before the conversation-close below). +document.addEventListener('keydown',(e)=>{ + if(e.key!=='Escape') return; + const ovs=document.querySelectorAll('.modal-ov'); + if(ovs.length){ ovs[ovs.length-1].remove(); e.preventDefault(); e.stopPropagation(); return; } + const sh=document.getElementById('convoSearchHead'); if(sh && sh.style.display!=='none'){ closeSearch(); e.preventDefault(); e.stopPropagation(); return; } +}, true); +document.addEventListener('keydown',(e)=>{ if(e.key==='Escape' && !document.querySelector('.modal-ov') && currentTab()==='chat' && selected!=null){ showWelcome(); } }); +// #10: the device/browser Back button closes the topmost layer (popup → search → open chat on +// mobile) instead of leaving the app. We seed a history entry and re-seed after each handled back. +function bzcBack(){ + const ovs=document.querySelectorAll('.modal-ov'); if(ovs.length){ ovs[ovs.length-1].remove(); return true; } + const sh=document.getElementById('convoSearchHead'); if(sh && sh.style.display!=='none'){ closeSearch(); return true; } + if(window.matchMedia('(max-width:760px)').matches && selected){ showWelcome(); return true; } + return false; +} +try{ history.pushState(null,'',location.href); }catch(_){} +window.addEventListener('popstate', ()=>{ if(bzcBack()){ try{ history.pushState(null,'',location.href); }catch(_){} } }); // Hamburger: on mobile it slides the rail drawer in/out; on desktop it collapses the rail. function toggleNav(){ if(window.innerWidth<=760) document.body.classList.toggle('rail-open'); else document.body.classList.toggle('rail-hidden'); } const _navT=document.getElementById('navToggle'); if(_navT) _navT.onclick=toggleNav; @@ -2264,7 +2641,7 @@ async function renderLogin(){ const aw=document.getElementById('authwrap'); aw.style.display='flex'; let regOpen=false; try{ regOpen=(await (await fetch('/api/setup-state')).json()).registrationOpen; }catch(_){} aw.innerHTML=`
-

Welcome to BizGaze Connect

+

Welcome to Biz Connect

Sign in to access chats, screen share and connect.
${regOpen?`
@@ -2278,7 +2655,7 @@ async function renderLogin(){

${regOpen?`
' + esc(h) + '
' + [r.date, r.start].concat(IS_ADMIN ? [esc(r.agent)] : []).concat([esc(r.ticket), r.spent]).join('') + '