Files
BizGaze_Remote/server/public/home.html
T
Sravan fd15628393 feat(video): server-generated poster thumbnails + HTTP Range streaming
- Dockerfile: add ffmpeg (Alpine).
- static.js: new /thumbs/<id> — ffmpeg extracts the first frame (0.5s), caches it next to
  the file, serves as the video poster (cosmetic; 404s gracefully if ffmpeg unavailable).
- static.js: /files now supports HTTP Range (206 Partial Content) + Accept-Ranges, which
  iOS requires to stream/seek video reliably (fixes the buffer-before-play / multi-tap);
  media (image/video/audio) now served inline, other files still download. Shared
  attachment auth refactored into one helper used by /files and /thumbs.
- home.html: video poster points at /thumbs/<id>. build batch157.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 22:09:32 +05:30

5268 lines
466 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover, interactive-widget=resizes-content">
<title>Biz Connect</title>
<!-- PWA: installable on Android & iOS ("Add to Home Screen"); also enables iOS web push -->
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#1F3B73">
<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>
<meta name="apple-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-title" content="Connect">
<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;}
/* #8a: iOS rubber-band scrolling bounced the whole page, exposing the background BELOW the fixed
bottom rail (the "gap"). overscroll-behavior:none pins the page; the rail's own height already
includes the home-indicator inset. */
html,body{height:100%;overscroll-behavior:none;}
/* Safe-area insets. The native app enables edge-to-edge via the @capacitor-community/safe-area plugin,
which populates env(safe-area-inset-*) with real values on every device (notch, Dynamic Island, home
indicator). On the web these resolve to 0. All layout below pads with var(--sat)/var(--sab). */
:root{--sat:env(safe-area-inset-top,0px);--sab:env(safe-area-inset-bottom,0px);--kb:0px;}
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;position:fixed;inset:0;width:100%;}
/* ---- Top bar ---- */
/* #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;}
/* ---- Header actions: notification bell + profile ---- */
#hdrRight{display:flex;align-items:center;gap:.45rem;}
.bell{position:relative;}
.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: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;}
.bell-list{max-height:62vh;overflow:auto;}
.bell-item{display:flex;gap:.6rem;align-items:flex-start;padding:.6rem .85rem;border-bottom:1px solid #f4f6fa;cursor:pointer;}
.bell-item:hover{background:#f6f8fb;}
.bell-item.unread{background:#eef4ff;}
.bell-ico{width:30px;height:30px;border-radius:50%;background:var(--blue-soft);color:var(--blue);display:grid;place-items:center;flex:0 0 30px;}
.bell-body{min-width:0;}
.bell-tx{font-size:.85rem;color:var(--ink);line-height:1.3;}
.bell-tm{font-size:.7rem;color:var(--muted);margin-top:.1rem;}
.bell-empty{padding:1.6rem;text-align:center;color:var(--muted);font-size:.85rem;}
/* ---- Profile dropdown (from console.html) ---- */
.profile{position:relative}
.profile .pbtn.icon-only{padding: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}
.profile .pbtn .pav img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}
.profile .pmenu{position:absolute;right:0;top:calc(100% + 6px);background:#fff;border:1px solid #e6e9ef;border-radius:10px;box-shadow:0 10px 28px rgba(0,0,0,.18);min-width:210px;overflow:hidden;z-index:5000;display:none}
.profile .pmenu.open{display:block}
.profile .pmenu .phead{padding:.7rem .9rem;border-bottom:1px solid #eef1f6}
.profile .pmenu .phead .n{font-weight:700;font-size:.9rem}
.profile .pmenu .phead .e{color:var(--muted);font-size:.78rem}
.profile .pmenu a{display:flex;align-items:center;gap:.55rem;padding:.6rem .9rem;color:#1f2430;text-decoration:none;font-size:.9rem;cursor:pointer}
.profile .pmenu a:hover{background:#f1f5f9}
.profile .pmenu a .ic{color:var(--muted)}
.profile .pmenu a.danger{color:#b91c1c;border-top:1px solid #eef1f6}
.profile .pmenu a.danger .ic{color:#b91c1c}
/* ---- Shell ---- */
.shell{flex:1 1 auto;display:flex;min-height:0;}
/* ---- 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;}
.railbtn .rdot{position:absolute;top:8px;right:8px;min-width:16px;height:16px;border-radius:99px;background:var(--brand);color:var(--blue);font-size:.62rem;font-weight:800;display:grid;place-items:center;padding:0 .2rem;border:2px solid var(--card);}
.railbtn.active .rdot{border-color:var(--blue);}
.railbtn .livedot{position:absolute;top:8px;right:8px;width:11px;height:11px;border-radius:50%;background:var(--green);border:2px solid var(--card);display:none;}
.railbtn.active .livedot{border-color:var(--blue);}
.railbtn.live .livedot{display:block;animation:livePulse 1.4s infinite;}
@keyframes livePulse{0%,100%{opacity:1}50%{opacity:.3}}
.railbtn .rlabel{display:none;font-size:.6rem;margin-top:0;}
/* tooltip */
.railbtn::after{content:attr(data-tip);position:absolute;left:calc(100% + 12px);top:50%;transform:translateY(-50%);background:var(--blue-d);color:#fff;padding:.35rem .6rem;border-radius:8px;font-size:.78rem;font-weight:600;white-space:nowrap;opacity:0;pointer-events:none;transition:opacity .14s;z-index:200;box-shadow:0 6px 16px rgba(0,0,0,.25);}
.railbtn::before{content:"";position:absolute;left:calc(100% + 6px);top:50%;transform:translateY(-50%);border:6px solid transparent;border-right-color:var(--blue-d);opacity:0;pointer-events:none;transition:opacity .14s;z-index:200;}
.railbtn:hover::after,.railbtn:hover::before{opacity:1;}
.rail-spacer{flex:1 1 auto;}
.caption{font-size:.58rem;color:var(--muted);text-align:center;line-height:1.2;}
/* ---- Chat list column ---- */
.chatcol{width:312px;flex:0 0 312px;background:var(--card);border-right:1px solid var(--line);display:flex;flex-direction:column;min-height:0;}
.chatcol.hidden{display:none;}
.side-sec{font-size:.68rem;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);font-weight:700;padding:.6rem .9rem .25rem;}
/* Pinned "You" (note-to-self) set apart from the conversation list (#3): tinted + a divider. */
.side-div{height:1px;background:var(--line);margin:.4rem .7rem .3rem;}
.chat-row.self{background:var(--blue-soft);}
.chat-row.self .chat-name::after{content:"Note to self";font-size:.58rem;font-weight:700;color:var(--blue);background:#fff;border:1px solid var(--line);border-radius:99px;padding:.03rem .35rem;margin-left:.4rem;vertical-align:middle;text-transform:none;}
.contact-row,.dir-row{display:flex;align-items:center;gap:.6rem;padding:.5rem .9rem;cursor:pointer;}
.contact-row:hover,.dir-row:hover{background:var(--blue-soft);}
.contact-row .cr-name,.dir-row .cr-name{font-size:.9rem;color:var(--ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.dir-row .dr-main{display:flex;flex-direction:column;min-width:0;flex:1;}
.dir-row .dr-sub{font-size:.74rem;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.dir-row .dr-tag{font-size:.62rem;font-weight:700;color:#92600b;background:#fff3cd;border-radius:99px;padding:.1rem .4rem;flex:0 0 auto;}
.dir-empty{display:flex;gap:.55rem;align-items:flex-start;margin:.5rem .9rem;padding:.6rem .7rem;background:#f6f8fc;border:1px solid var(--line);border-radius:10px;color:var(--muted);}
.dir-empty svg{flex:0 0 auto;margin-top:.05rem;color:var(--blue);}
.dir-empty b{display:block;font-size:.82rem;color:var(--ink);margin-bottom:.1rem;}
.dir-empty span{font-size:.74rem;line-height:1.35;}
/* 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;}
.search input:focus{outline:none;border-color:var(--brand);}
.search input{padding-right:2.1rem;}
.search-x{position:absolute;right:.5rem;top:50%;transform:translateY(-50%);border:none;background:transparent;color:var(--muted);cursor:pointer;padding:.2rem;border-radius:6px;display:grid;place-items:center;}
.search-x:hover{color:var(--blue);background:var(--blue-soft);}
/* Modern thin scrollbars for every scroll area (chat, modals, lists, …). Without this,
Electron on Windows falls back to the classic scrollbar with up/down stepper arrows. */
::-webkit-scrollbar{width:11px;height:11px;}
::-webkit-scrollbar-button,::-webkit-scrollbar-button:start:decrement,::-webkit-scrollbar-button:end:increment,::-webkit-scrollbar-button:vertical:start,::-webkit-scrollbar-button:vertical:end,::-webkit-scrollbar-button:horizontal:start,::-webkit-scrollbar-button:horizontal:end{display:none!important;width:0!important;height:0!important;}
::-webkit-scrollbar-track{background:transparent;}
/* Was 6px with a 1px inset border (~4px grabbable) and no min height → the thumb was tiny and felt
"stuck" when dragged. Wider, solid, with a min length so it's easy to grab and drags smoothly. */
::-webkit-scrollbar-thumb{background:#b9c4d4;border-radius:8px;min-height:44px;}
::-webkit-scrollbar-thumb:hover{background:#9fadc2;}
::-webkit-scrollbar-corner{background:transparent;}
*{scrollbar-width:thin;scrollbar-color:#c7d0dd transparent;}
/* #8b: overscroll-behavior:contain stops Safari/Chrome's own rubber-band + page pull-to-refresh from
swallowing the gesture, so OUR pull-to-refresh fires on iOS as well as Android. */
.chatlist{overflow-y:auto;flex:1 1 auto;padding:.4rem;scrollbar-width:thin;scrollbar-color:transparent transparent;overscroll-behavior-y:contain;-webkit-overflow-scrolling:touch;}
.convo-msgs{overscroll-behavior-y:contain;-webkit-overflow-scrolling:touch;}
.chatlist:hover{scrollbar-color:#c7d0dd transparent;}
.chatlist::-webkit-scrollbar{width:6px;}
.chatlist::-webkit-scrollbar-button,.chatlist::-webkit-scrollbar-button:start:decrement,.chatlist::-webkit-scrollbar-button:end:increment{display:none!important;height:0!important;width:0!important;}
.convo-msgs::-webkit-scrollbar{width:10px;} .convo-msgs::-webkit-scrollbar-button{display:none!important;height:0!important;width:0!important;}
.convo-msgs::-webkit-scrollbar-thumb{min-height:44px;}
.chatlist::-webkit-scrollbar-thumb{background:transparent;border-radius:8px;}
.chatlist:hover::-webkit-scrollbar-thumb{background:#c7d0dd;}
/* Pull-to-refresh indicator (mobile) */
/* Lives on the list's NON-scrolling parent, so it stays pinned at the top and is actually seen (#6). */
.ptr-ind{position:absolute;top:calc(var(--sat) + 8px);left:50%;width:34px;height:34px;margin-left:-17px;border-radius:50%;background:var(--card);box-shadow:0 3px 12px rgba(20,30,60,.22);display:grid;place-items:center;color:var(--blue);z-index:40;opacity:0;transform:translateY(-44px);transition:opacity .12s;pointer-events:none;} /* top offset clears the notch/Dynamic Island (var(--sat)) so the spinner isn't hidden behind it */
.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;}
.ptr-ind img{display:block;} .ptr-ind.spin img{animation:ptrspin .8s 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,0px));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 0 0 0 1.5px var(--brand);} /* #6: thin yellow boundary around the selected chat */
.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;}
.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;}
.prev-tick{display:inline-flex;vertical-align:middle;color:var(--muted);} .prev-tick.read{color:#2563eb;} .prev-tick svg{width:14px;height:14px;}
.draft-lbl{color:#dc2626;font-weight:600;}
.chat-row.unread .chat-name{font-weight:700;}
.badge{flex:0 0 auto;background:var(--brand);color:var(--blue);font-size:.7rem;font-weight:800;min-width:19px;height:19px;border-radius:99px;padding:0 .35rem;display:grid;place-items:center;}
.no-results{padding:2rem 1rem;text-align:center;color:var(--muted);font-size:.85rem;}
.demo-note{padding:.5rem 1rem;border-top:1px solid var(--line);color:var(--muted);font-size:.72rem;text-align:center;background:#fbfcfe;}
/* ---- Main content ---- */
.content{flex:1 1 auto;position:relative;min-width:0;min-height:0;background:var(--bg);}
.panel{position:absolute;inset:0;display:none;}
.panel.active{display:flex;}
.panel.center{align-items:center;justify-content:center;padding:2rem;overflow-y:auto;}
.panel iframe{width:100%;height:100%;border:0;display:block;background:var(--bg);}
/* welcome + feature cards */
.welcome{text-align:center;max-width:560px;}
.welcome .wave{font-size:3rem;line-height:1;margin-bottom:.4rem;}
.welcome h1{font-size:1.8rem;color:var(--blue);margin:.2rem 0 .5rem;}
.welcome p{color:var(--muted);font-size:1rem;line-height:1.6;margin:0 auto 1.8rem;max-width:440px;}
.wcards{display:flex;gap:1rem;flex-wrap:wrap;justify-content:center;}
.wcard{flex:1;min-width:150px;max-width:180px;background:var(--card);border:1px solid var(--line);border-radius:14px;padding:1.2rem 1rem;cursor:pointer;transition:transform .12s,box-shadow .12s,border-color .12s;text-align:center;}
.wcard:hover{transform:translateY(-3px);box-shadow:0 12px 28px rgba(20,30,60,.1);border-color:var(--brand);}
.wcard .wi{width:46px;height:46px;border-radius:12px;display:grid;place-items:center;margin:0 auto .6rem;background:var(--blue-soft);color:var(--blue);}
.wcard h3{margin:0 0 .2rem;font-size:.95rem;color:var(--blue);}
.wcard p{margin:0;font-size:.78rem;color:var(--muted);line-height:1.4;}
.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;max-width:520px;}
.feat-icon{width:72px;height:72px;border-radius:20px;display:grid;place-items:center;margin:0 auto 1.2rem;}
.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;}
.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;}
.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);}
.hint{margin-top:1.4rem;font-size:.8rem;color:var(--muted);}
/* 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);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;}
img.emoji{height:1.15em;width:1.15em;margin:0 .05em;vertical-align:-0.15em;display:inline-block;}
.emoji-grid img.emoji,.emoji-tabs img.emoji{height:1.4em;width:1.4em;}
.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);}
/* Live "typing…" — brand blue + italic, in both the header subtitle and the sidebar preview. */
.convo-head .st.typing{color:var(--blue);font-weight:600;font-style:italic;}
.typing-prev{color:var(--blue);font-weight:600;font-style:italic;}
/* 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;}
.bubble.search-flash{animation:searchFlash 1.4s ease;}
@keyframes searchFlash{0%,20%{box-shadow:0 0 0 3px var(--brand);}100%{box-shadow:0 0 0 0 transparent;}}
.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 = brand-blue 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(--blue);font-weight:700;border-bottom-color:var(--blue);}
.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 */
.convo-msgs{flex:1;overflow-y:auto;overflow-x:clip;padding:1rem 1.2rem;display:flex;flex-direction:column;gap:.35rem;background:var(--bg);}
/* word-break:normal + overflow-wrap:break-word: still emergency-breaks an over-long word/URL to fit,
but keeps min-content = longest WORD (not 1 glyph) so a bubble can never collapse to ~0 width and
wrap text into hundreds of lines during a transient 0-width layout frame (the "stretch" balloon). */
.bubble{max-width:72%;padding:.5rem .75rem;border-radius:14px;font-size:.9rem;line-height:1.4;white-space:pre-wrap;word-break:normal;overflow-wrap:break-word;box-shadow:0 1px 2px rgba(20,30,60,.06);}
.bubble.them{align-self:flex-start;background:#fff;border:1px solid var(--line);color:var(--ink);border-bottom-left-radius:4px;}
.bubble.mine{align-self:flex-end;background:var(--blue);color:#fff;border-bottom-right-radius:4px;}
.bubble .t{display:block;font-size:.64rem;opacity:.65;margin-top:.15rem;text-align:right;}
.day-sep{align-self:center;font-size:.72rem;margin:.7rem 0 .3rem;text-align:center;}
.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;}
.jump-latest:hover{background:var(--brand);color:var(--blue);border-color:var(--brand-d);}
.empty-thread{align-self:center;font-size:.85rem;color:var(--muted);margin:auto;}
.sys-msg{align-self:center;font-size:.76rem;color:var(--muted);background:rgba(0,0,0,.04);padding:.25rem .7rem;border-radius:99px;margin:.2rem 0;max-width:90%;text-align:center;}
.bubble .t{display:flex;align-items:center;justify-content:flex-end;gap:.25rem;font-size:.64rem;opacity:.65;margin-top:.15rem;}
.bubble .rcpt{display:inline-flex;}
.bubble.mine .rcpt{opacity:.85;}
/* Read receipt on my own (blue) bubble: brand yellow so the double tick actually stands out. */
.bubble.mine .rcpt.seen{color:var(--brand);opacity:1;}
.bubble.mine .rcpt.grp{cursor:pointer;} /* tap a group tick to see who's read it */
.att-img{cursor:zoom-in;}
.fmt-bar{display:flex;align-items:center;gap:.05rem;padding:.3rem .4rem .1rem;flex-wrap:wrap;border-bottom:1px dashed var(--line);}
.fmt-bar button{border:none;background:transparent;color:var(--muted);cursor:pointer;width:30px;height:30px;border-radius:7px;display:grid;place-items:center;}
.fmt-bar button:hover{color:var(--blue);background:var(--blue-soft);}
.fmt-sep{width:1px;height:18px;background:var(--line);margin:0 .3rem;}
.bubble .msg-list{margin:.15rem 0;padding-left:1.25rem;}
.bubble .msg-list li{margin:.05rem 0;}
.bubble code{background:rgba(0,0,0,.08);padding:.05rem .3rem;border-radius:5px;font-size:.86em;font-family:ui-monospace,Consolas,monospace;}
.bubble.mine code{background:rgba(255,255,255,.2);}
.lightbox{position:fixed;inset:0;z-index:9900;background:rgba(8,12,22,.88);display:flex;align-items:center;justify-content:center;} /* above modals (9800) so the media→image preview opens on top */
.lightbox img{max-width:92vw;max-height:88vh;border-radius:10px;box-shadow:0 16px 50px rgba(0,0,0,.5);}
/* new #2: on iOS these sat UNDER the Dynamic Island / notch and couldn't be tapped. Push them below
the safe area (and clear of the right-side inset in landscape). */
.lightbox .lb-close,.lightbox .lb-dl{position:absolute;top:calc(18px + env(safe-area-inset-top,0px));border:1px solid rgba(255,255,255,.55);background:rgba(0,0,0,.58);color:#fff;width:46px;height:46px;border-radius:50%;display:grid;place-items:center;cursor:pointer;text-decoration:none;z-index:2;box-shadow:0 2px 12px rgba(0,0,0,.55);backdrop-filter:blur(4px);-webkit-backdrop-filter:blur(4px);} /* solid dark chip + border so the ✕ / download stay clearly visible over bright images */
.lightbox .lb-close{right:calc(18px + env(safe-area-inset-right,0px));}
.lightbox .lb-dl{right:calc(74px + env(safe-area-inset-right,0px));}
.lightbox .lb-close:hover,.lightbox .lb-dl:hover{background:rgba(0,0,0,.78);}
.lightbox img{max-width:82vw;}
/* #3 zoom: transform-based, anchored at the cursor; arrows hide while zoomed so panning isn't hijacked */
.lightbox img{transition:transform .08s linear;transform-origin:center center;will-change:transform;}
.lightbox img.zoomed{cursor:grab;} .lightbox img.grabbing{cursor:grabbing;transition:none;}
.lightbox.zooming .lb-nav{display:none;}
.lightbox .lb-zoom{position:absolute;left:50%;transform:translateX(-50%);bottom:calc(18px + env(safe-area-inset-bottom,0px));display:flex;gap:.4rem;z-index:2;}
.lightbox .lb-zoom button{position:relative;border:none;background:rgba(255,255,255,.14);color:#fff;width:44px;height:36px;border-radius:10px;display:grid;place-items:center;cursor:pointer;}
.lightbox .lb-zoom button:hover{background:rgba(255,255,255,.28);}
.lightbox .lb-zoom button b{position:absolute;right:7px;bottom:4px;font-size:.72rem;line-height:1;}
.lightbox .lb-nav{position:absolute;top:50%;transform:translateY(-50%);border:none;background:rgba(255,255,255,.16);color:#fff;width:32px;height:32px;border-radius:50%;display:grid;place-items:center;cursor:pointer;}
.lightbox .lb-nav:hover{background:rgba(255,255,255,.34);}
.lightbox .lb-prev{left:14px;} .lightbox .lb-next{right:14px;}
.composer{display:flex;align-items:flex-end;gap:.5rem;padding:.6rem .8rem;border-top:1px solid var(--line);background:var(--card);}
.composer-box{flex:1;min-width:0;border:1.5px solid var(--line);border-radius:16px;background:#fbfcfe;display:flex;flex-direction:column;overflow:hidden;}
.composer-box:focus-within{border-color:var(--blue);}
.composer-row{display:flex;align-items:flex-end;gap:.15rem;padding:.15rem .3rem;}
.composer-row input,.composer-row textarea{flex:1;min-width:0;box-sizing:border-box;border:none;background:transparent;padding:.5rem .4rem;margin:0;font-size:16px;color:var(--ink);font-family:inherit;resize:none;line-height:1.4;max-height:140px;overflow-y:hidden;display:block;scrollbar-width:thin;} /* 16px UNCONDITIONALLY (not just in the mobile @media) — anything under 16px makes iOS Safari/PWA zoom the page on focus, and the mobile media query doesn't match on iPad / landscape. */
.composer-row input:focus,.composer-row textarea:focus{outline:none;}
.ic-btn{border:none;background:transparent;color:var(--muted);cursor:pointer;width:36px;height:36px;border-radius:10px;display:grid;place-items:center;flex:0 0 auto;}
.ic-btn:hover{color:var(--blue);background:var(--blue-soft);}
.attach-preview{padding:.55rem .6rem .15rem;gap:.4rem;flex-wrap:wrap;max-height:118px;overflow-y:auto;}
.ap-item{display:inline-flex;align-items:center;gap:.55rem;background:#fff;border:1px solid var(--line);border-radius:11px;padding:.35rem .5rem;max-width:100%;}
.ap-thumb{width:42px;height:42px;border-radius:8px;object-fit:cover;flex:0 0 auto;}
.ap-ic{display:grid;place-items:center;color:var(--blue);flex:0 0 auto;}
.ap-name{font-size:.82rem;color:var(--ink);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:240px;}
/* #8: upload progress — a real bar + % so the user knows when Send is safe */
.ap-main{display:flex;flex-direction:column;gap:.2rem;min-width:0;}
.ap-prog{display:flex;align-items:center;gap:.4rem;}
.ap-track{flex:1;min-width:90px;height:4px;border-radius:99px;background:#e2e8f0;overflow:hidden;}
.ap-fill{display:block;height:100%;width:0;border-radius:99px;background:var(--blue);transition:width .15s linear;}
.ap-pct{font-size:.68rem;color:var(--muted);font-weight:600;min-width:30px;text-align:right;}
.ap-item.uploading{border-color:var(--blue);background:var(--blue-soft);}
.ap-x{border:none;background:transparent;color:var(--muted);cursor:pointer;display:grid;place-items:center;padding:.15rem;border-radius:6px;flex:0 0 auto;}
.ap-x:hover{color:var(--red);background:#fee2e2;}
/* meetings */
.meet{display:flex;flex-direction:column;width:100%;height:100%;}
.meet-grid{flex:1;min-height:0;display:grid;gap:.6rem;padding:.8rem;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));align-content:center;overflow:hidden;background:#0b1220;} /* #16: center tiles + no stray scrollbar on 1:1/small calls */
.meet-tile{position:relative;background:#0b1220;border-radius:12px;overflow:hidden;aspect-ratio:4/3;border:1px solid #1e293b;transition:box-shadow .12s,border-color .12s;}
.meet-tile .meet-sharing-self{position:absolute;left:0;right:0;bottom:0;display:flex;align-items:center;justify-content:center;gap:.3rem;font-size:.72rem;color:#fff;background:rgba(37,99,235,.9);padding:.25rem;}
/* #3: "Ringing…" sits in the MIDDLE of the tile over a dimmed avatar/name, so it reads as the tile's
state instead of a caption tucked under the name. */
.meet-tile.waiting{border-color:#334155;}
.meet-tile.waiting .meet-av,.meet-tile.waiting .nm{opacity:.32;filter:saturate(.6);}
.meet-tile.waiting .ringing{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:.4rem;
font-size:.95rem;font-weight:700;letter-spacing:.02em;color:#fff;background:rgba(8,12,22,.45);z-index:3;
animation:ringPulse 1.4s ease-in-out infinite;}
.meet-tile.waiting .ringing svg{width:22px;height:22px;color:var(--brand,#FFC708);}
@keyframes ringPulse{0%,100%{opacity:.6;}50%{opacity:1;}}
.meet-tile.speaking{border-color:#22c55e;box-shadow:0 0 0 2px #22c55e, 0 0 14px rgba(34,197,94,.5);}
.meet-tile .meet-screen{position:absolute;right:.5rem;top:.5rem;display:inline-flex;align-items:center;gap:.25rem;background:rgba(37,99,235,.92);color:#fff;font-size:.66rem;font-weight:700;padding:.14rem .42rem;border-radius:6px;}
.meet-tile.sharing{grid-column:span 2;border-color:#2563eb;}
/* Screen-share "stage": the chosen shared screen (.stage) fills a FIXED area on the left; every
other tile — participants AND any other shared screens — sits in a persistent small column on the
right. Click another shared screen there to switch which one is on the stage. Screen never shrinks. */
.meet-grid.sharing-mode{display:flex;flex-flow:column wrap;align-content:flex-start;height:100%;min-height:0;gap:.5rem;}
.meet-grid.sharing-mode .meet-tile{aspect-ratio:auto;}
.meet-grid.sharing-mode .meet-tile.stage{order:-1;height:100%;width:calc(100% - 176px);min-width:0;border-color:#2563eb;}
.meet-grid.sharing-mode .meet-tile.stage video{object-fit:contain;background:#000;}
.meet-grid.sharing-mode .meet-tile:not(.stage){width:160px;height:94px;flex:0 0 auto;}
.meet-grid.sharing-mode .meet-tile.sharing:not(.stage){cursor:pointer;}
.meet-grid.sharing-mode .meet-tile.sharing:not(.stage)::after{content:"Click to view";position:absolute;left:0;right:0;bottom:0;font-size:.58rem;text-align:center;background:rgba(37,99,235,.88);color:#fff;padding:1px 0;}
@media (max-width:760px){
.meet-grid.sharing-mode{flex-flow:row wrap;height:auto;}
.meet-grid.sharing-mode .meet-tile.stage{width:100%;height:auto;flex:1 1 100%;min-height:40vh;}
.meet-grid.sharing-mode .meet-tile:not(.stage){width:46%;height:84px;}
}
.meet-bar .meet-ic.on{background:var(--blue);color:#fff;}
.meet-bar #meetRecBtn.on{background:#dc2626;color:#fff;animation:livePulse 1.6s infinite;}
.rec-notice{position:fixed;top:14px;left:50%;transform:translateX(-50%);z-index:6500;display:inline-flex;align-items:center;gap:.4rem;background:rgba(220,38,38,.95);color:#fff;font-size:.82rem;font-weight:700;padding:.32rem .7rem;border-radius:99px;box-shadow:0 6px 18px rgba(220,38,38,.4);}
.rec-notice .rec-dot{width:9px;height:9px;border-radius:50%;background:#fff;animation:livePulse 1.4s infinite;}
.tx-notice{position:fixed;top:14px;left:14px;z-index:6500;display:inline-flex;align-items:center;gap:.35rem;background:rgba(37,99,235,.95);color:#fff;font-size:.78rem;font-weight:700;padding:.3rem .65rem;border-radius:99px;box-shadow:0 6px 18px rgba(37,99,235,.4);}
.si-recs{display:flex;flex-wrap:wrap;gap:.5rem;margin-top:.55rem;}
.rec-dl{display:inline-flex;align-items:center;gap:.35rem;font-size:.78rem;font-weight:700;text-decoration:none;border-radius:9px;padding:.36rem .7rem;border:1px solid transparent;transition:filter .12s;}
.rec-dl:hover{filter:brightness(.96);}
.rec-dl.vid{background:#eef2ff;color:#4338ca;border-color:#c7d2fe;}
.rec-dl.txt{background:#ecfdf5;color:#047857;border-color:#a7f3d0;}
.rec-dl .rd-dur{background:rgba(0,0,0,.09);border-radius:6px;padding:.04rem .32rem;font-size:.72rem;font-weight:700;}
.meet-panel .pp-screen{color:var(--blue);display:inline-flex;}
.meet-panel .mp-setting{margin:.3rem;font-size:.8rem;}
.meet-tile video{width:100%;height:100%;object-fit:cover;background:#0b1220;}
.meet-tile .nm{position:absolute;left:.5rem;bottom:.5rem;background:rgba(0,0,0,.55);color:#fff;font-size:.75rem;padding:.15rem .5rem;border-radius:6px;}
.meet-tile .meet-av{position:absolute;inset:0;margin:auto;width:84px;height:84px;border-radius:50%;display:none;align-items:center;justify-content:center;color:#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;}
.meet-panel .mp-muteall{border:1px solid var(--line);background:#fee2e2;color:var(--red);border-radius:8px;padding:.3rem .55rem;font-size:.78rem;font-weight:600;cursor:pointer;display:inline-flex;align-items:center;gap:.25rem;}
.meet-panel .mp-x{border:none;background:transparent;color:var(--muted);cursor:pointer;display:grid;place-items:center;}
.meet-panel .mp-tabs{display:flex;gap:.25rem;padding:.4rem .5rem 0;border-bottom:1px solid var(--line);}
.meet-panel .mp-tab{flex:1;border:none;background:transparent;color:var(--muted);font-size:.8rem;font-weight:600;padding:.45rem .3rem;cursor:pointer;border-bottom:2px solid transparent;display:inline-flex;align-items:center;justify-content:center;gap:.25rem;}
.meet-panel .mp-tab.on{color:var(--blue);border-bottom-color:var(--blue);}
.meet-panel .mp-scroll{flex:1;overflow:auto;}
.meet-panel .mp-invite-sticky{position:sticky;bottom:.4rem;width:calc(100% - 1rem);margin:.5rem;background:var(--blue);color:#fff;box-shadow:0 -6px 14px rgba(255,255,255,.06);} /* #4: keep Invite reachable while the list scrolls */
.meet-panel .mp-list{padding:.4rem;}
.meet-panel .mp-invite-link{display:flex;align-items:center;gap:.55rem;margin:.5rem;padding:.6rem .7rem;background:var(--blue-soft);border:1px solid var(--line);border-radius:10px;}
.meet-panel .mp-invite-link svg{flex:0 0 auto;color:var(--blue);}
.meet-panel .mp-invite-link .mil-txt{flex:1;min-width:0;}
.meet-panel .mp-invite-link .mil-txt b{display:block;font-size:.82rem;color:var(--ink);}
.meet-panel .mp-invite-link .mil-txt span{font-size:.7rem;color:var(--muted);line-height:1.3;display:block;}
.meet-panel .mp-copylink{flex:0 0 auto;display:inline-flex;align-items:center;gap:.3rem;background:var(--blue);color:#fff;border:none;border-radius:8px;padding:.4rem .7rem;font-size:.78rem;font-weight:600;cursor:pointer;}
.meet-panel .mp-copylink:hover{filter:brightness(1.05);}
/* In-meeting chat panel (brand-styled) */
.meet-chat{width:320px;}
/* Compose block at the BOTTOM: recipient picker sits right above the input (new #3). */
.meet-chat .mc-compose{border-top:1px solid var(--line);background:var(--card);}
.meet-chat .mc-compose.private{background:#fffbeb;} /* private mode is visibly different */
.meet-chat .mc-to{display:flex;align-items:center;gap:.5rem;padding:.45rem .7rem .1rem;font-size:.75rem;color:var(--muted);}
.meet-chat .mc-to select{flex:1;border:1px solid var(--line);border-radius:8px;padding:.3rem .45rem;font:inherit;font-size:.8rem;background:#fbfcfe;color:var(--ink);}
.meet-chat .mc-compose.private .mc-to select{border-color:#f5c518;background:#fffdf5;color:#7a5c00;font-weight:600;}
.meet-chat .mc-log{flex:1;overflow-y:auto;padding:.6rem .7rem;display:flex;flex-direction:column;gap:.4rem;}
.meet-chat .mc-empty{color:var(--muted);font-size:.82rem;text-align:center;margin:auto;padding:1rem;}
.meet-chat .mc-row{display:flex;}
.meet-chat .mc-row.mine{justify-content:flex-end;}
/* Everyone = neutral/blue. Private = brand amber, clearly a different conversation (new #3). */
.meet-chat .mc-bub{max-width:82%;background:#f1f5f9;border:1px solid transparent;border-radius:12px;padding:.4rem .6rem;}
.meet-chat .mc-row.mine .mc-bub{background:var(--blue);color:#fff;}
.meet-chat .mc-row.priv .mc-bub{background:#fef3c7;border-color:#fcd34d;color:#1f2430;}
.meet-chat .mc-row.mine.priv .mc-bub{background:#7a5c00;border-color:var(--brand,#FFC708);color:#fff;}
.meet-chat .mc-nm{font-size:.7rem;font-weight:700;color:var(--blue);margin-bottom:.1rem;display:flex;gap:.35rem;align-items:center;}
.meet-chat .mc-row.mine .mc-nm{color:rgba(255,255,255,.9);}
.meet-chat .mc-priv{font-weight:600;font-size:.62rem;text-transform:uppercase;letter-spacing:.03em;background:rgba(31,59,115,.1);color:var(--blue);border-radius:99px;padding:.02rem .35rem;}
.meet-chat .mc-row.mine .mc-priv{background:rgba(255,255,255,.2);color:#fff;}
.meet-chat .mc-tx{font-size:.88rem;line-height:1.35;word-wrap:break-word;overflow-wrap:anywhere;}
.meet-chat .mc-at{font-size:.62rem;opacity:.6;margin-top:.15rem;text-align:right;}
.meet-chat .mc-foot{display:flex;gap:.4rem;padding:.4rem .6rem .55rem;}
.meet-chat .mc-foot input{flex:1;border:1px solid var(--line);border-radius:9px;padding:.5rem .6rem;font:inherit;font-size:.86rem;background:#fbfcfe;color:var(--ink);outline:none;}
.meet-chat .mc-foot input:focus{border-color:var(--blue);}
.meet-chat .mc-send{flex:0 0 auto;width:38px;border:none;border-radius:9px;background:var(--brand,#FFC708);color:#1f2430;display:grid;place-items:center;cursor:pointer;}
.meet-ic .mc-badge{position:absolute;top:-3px;right:-3px;min-width:16px;height:16px;padding:0 3px;border-radius:99px;background:#e6394f;color:#fff;font-size:.62rem;font-weight:700;display:grid;place-items:center;box-shadow:0 0 0 2px var(--card,#fff);}
.meet-panel .chk{display:flex;align-items:center;gap:.5rem;padding:.4rem .5rem;border-radius:8px;cursor:pointer;font-size:.88rem;}
.meet-panel .chk:hover{background:#f6f8fb;}
.meet-panel .chk input{width:16px;height:16px;flex:0 0 16px;accent-color:var(--blue);margin:0;}
.meet-panel .chk .mn{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.meet-panel .mp-row{display:flex;align-items:center;gap:.5rem;padding:.4rem .5rem;border-radius:8px;}
.meet-panel .mp-row .mn{flex:1;min-width:0;font-size:.88rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.meet-panel .mp-row .pp-mute{color:var(--red);display:inline-flex;}
.meet-panel .mp-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;}
.iconbtn.role{color:var(--muted);} .iconbtn.role.is-admin{color:#d4a106;} .iconbtn.role:hover{color:#d4a106;}
.si-invited{display:inline-flex;align-items:center;gap:.3rem;font-size:.76rem;color:var(--muted);margin-top:.25rem;}
.meet-tile.novid .meet-av{display:flex;}
.meet-tile.novid video{visibility:hidden;}
.meet-bar{display:flex;align-items:center;gap:.6rem;padding:.7rem 1rem;background:var(--card);border-top:1px solid var(--line);}
.meet-bar .code{margin-right:auto;color:var(--muted);font-size:.85rem;}
.meet-bar .code b{color:var(--blue);font-size:1rem;letter-spacing:.06em;}
.meet-bar button{border:none;border-radius:10px;padding:.6rem 1rem;font-weight:600;cursor:pointer;font-size:.9rem;display:inline-flex;align-items:center;gap:.35rem;}
.meet-bar .tgl{background:#eef1f6;color:var(--blue);}
.meet-bar .tgl.off{background:#fee2e2;color:var(--red);}
.meet-bar .leave{background:#dc2626;color:#fff;}
.meet-bar .meet-ic{position:relative;width:46px;height:46px;padding:0;border-radius:50%;background:#e8edf5;color:var(--blue);display:inline-flex;align-items:center;justify-content:center;}
.meet-bar .meet-ic:hover{background:#dbe4f0;}
.meet-bar .meet-ic.off{background:#fee2e2;color:var(--red);}
.meet-bar .meet-ic.on{background:var(--blue);color:#fff;} /* #9: screen-share highlighted while active (record keeps its red pulse via #meetRecBtn.on) */
.meet-bar .meet-ic.leave,.meet-bar .meet-ic.leave.off{background:#dc2626;color:#fff;}
.meet-bar .meet-ic.leave:hover{background:#b91c1c;}
/* Meetings dashboard (full-width, scrollable) */
.meet-dash{width:100%;height:100%;overflow-y:auto;padding:1.4rem clamp(1rem,4vw,2.4rem) 2rem;background:var(--bg);}
.md-top{display:flex;align-items:flex-start;justify-content:space-between;gap:1rem;flex-wrap:wrap;margin-bottom:.5rem;}
.md-title h1{margin:0;font-size:1.5rem;color:var(--ink);}
.md-title p{margin:.3rem 0 0;color:var(--muted);font-size:.9rem;max-width:520px;}
.md-actions{display:flex;align-items:stretch;gap:.5rem;flex-wrap:wrap;}
.md-join{display:flex;gap:.4rem;flex:1 1 240px;min-width:0;}
.md-join input{flex:1 1 auto;min-width:0;text-align:center;letter-spacing:.18rem;font-size:1.05rem;font-weight:600;border:1px solid var(--line);border-radius:11px;padding:.7rem .6rem;background:var(--card);color:var(--ink);}
.md-join .btn{flex:0 0 auto;padding:.55rem 1.1rem;font-size:.9rem;}
.md-actions > .btn{white-space:nowrap;padding:.7rem 1.05rem;}
.btn.primary{background:var(--blue);color:#fff;}
.btn.primary:hover{filter:brightness(1.06);}
.meet-dash .hint{min-height:0;color:var(--red);font-size:.82rem;margin:.2rem 0;}
.sched-empty{color:var(--muted);font-size:.92rem;background:var(--card);border:1px dashed var(--line);border-radius:12px;padding:1.6rem;text-align:center;margin-top:1rem;}
/* chat: reply + emoji */
.convo{position:relative;}
.bubble{position:relative;}
/* Clamp the reply preview to 2 lines — a long quoted message must not stretch the bubble to 1000s of px
(device log: DIV.quote rendered 1571px). white-space:normal collapses the quoted text's newlines. */
.bubble .quote{border-left:3px solid var(--line);padding:.22rem .5rem;margin-bottom:.3rem;font-size:.78rem;border-radius:6px;color:#33384a;cursor:pointer;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;max-height:3.2em;white-space:normal;word-break:normal;overflow-wrap:break-word;}
.upd-banner{position:fixed;left:50%;bottom:16px;transform:translateX(-50%) translateY(70px);z-index:9500;display:none;align-items:center;gap:.5rem;background:var(--blue);color:#fff;border-radius:999px;padding:.5rem .5rem .5rem .9rem;box-shadow:0 10px 30px rgba(20,30,60,.32);font-size:.85rem;font-weight:600;transition:transform .25s ease;}
.upd-banner.show{display:flex;transform:translateX(-50%) translateY(0);}
.upd-banner button{border:none;background:var(--brand);color:var(--blue-d);border-radius:999px;padding:.35rem .8rem;font-weight:700;cursor:pointer;}
.upd-dot{position:absolute;top:-3px;right:-3px;width:15px;height:15px;border-radius:50%;background:var(--brand);color:var(--blue-d);font-size:.66rem;font-weight:800;font-style:italic;display:grid;place-items:center;border:2px solid var(--blue);pointer-events:none;}
#setUpd.has-update{background:var(--brand);color:var(--blue-d);}
.set-ver-i{display:inline-grid;place-items:center;width:15px;height:15px;border-radius:50%;background:var(--brand);color:var(--blue-d);font-size:.66rem;font-weight:800;font-style:italic;margin-left:.35rem;vertical-align:middle;}
.bubble .msg-link{color:inherit;text-decoration:underline;text-underline-offset:2px;overflow-wrap:break-word;}
.bubble .msg-tel{color:inherit;text-decoration:underline;text-underline-offset:2px;white-space:nowrap;cursor:pointer;} /* #9 */
.bubble.them .msg-link{color:var(--blue);} .bubble.mine .msg-link{color:#dbe9ff;}
.bubble .fwd-label{display:flex;align-items:center;gap:.2rem;font-size:.72rem;font-style:italic;opacity:.7;margin-bottom:.2rem;}
.bubble .fwd-label svg{transform:scaleX(-1) rotate(0deg);}
.bubble.mine .fwd-label{color:#e6edfb;} .bubble.them .fwd-label{color:var(--muted);}
/* All message actions live in ONE hover pill anchored to the bubble's top-right corner. It
overlaps the bubble slightly so there's no dead gap — on a short message the icons can't float
off into empty space where the hover (and the buttons) would vanish before you can click. */
/* #4: sit FULLY above the bubble (was top:-14px, which overlapped the first line of text). */
.msg-actions{position:absolute;bottom:calc(100% - 2px);right:6px;z-index:6;display:flex;align-items:center;gap:1px;white-space:nowrap;background:var(--card);border:1px solid var(--line);border-radius:999px;padding:2px 3px;box-shadow:0 4px 12px rgba(20,30,60,.2);opacity:0;pointer-events:none;transition:opacity .12s;}
/* The row is wider than a SHORT bubble. Anchored right, a received ("them") bubble pushed it off the
left edge of the panel. Anchor received messages from the LEFT so it grows into the empty space. */
.bubble.them .msg-actions{left:0;right:auto;}
.bubble.mine .msg-actions{right:0;left:auto;}
.bubble:hover .msg-actions,.bubble.show-actions .msg-actions{opacity:1;pointer-events:auto;}
.msg-actions button{position:static;width:26px;height:26px;border:none;background:none;border-radius:50%;display:grid;place-items:center;cursor:pointer;color:var(--blue);opacity:1;pointer-events:auto;box-shadow:none;padding:0;transition:background .12s;}
.msg-actions button:hover{background:var(--blue-soft);}
/* one-tap reactions render as the emoji glyph itself */
.msg-actions .qr-btn{font-size:15px;line-height:1;}
.msg-actions .qr-btn:hover{background:var(--blue-soft);transform:scale(1.15);}
.msg-more-menu .spk-opt.danger{color:var(--red);} .msg-more-menu .spk-opt.danger svg{color:var(--red);}
.msg-more-menu .spk-opt.danger:hover{background:#fee2e2;}
.msg-actions .del-btn{color:var(--red);} .msg-actions .del-btn:hover{background:#fee2e2;}
/* #1 Forward: message selection mode + target picker */
/* Selection mode: a checkbox appears on every message; selected ones highlight in green (distinct
from the blue message bubbles) with a filled tick. */
body.sel-mode .bubble{cursor:pointer;} body.sel-mode .msg-actions{display:none!important;}
body.sel-mode .bubble::after{content:"";position:absolute;top:-7px;width:20px;height:20px;border-radius:50%;border:2px solid var(--muted);background:var(--card);z-index:7;}
body.sel-mode .bubble.them::after{left:-8px;} body.sel-mode .bubble.mine::after{right:-8px;}
body.sel-mode .bubble.selected::after{border-color:#16a34a;background:#16a34a url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='3.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'/%3E%3C/svg%3E") center/12px no-repeat;}
.bubble.selected{outline:2px solid #16a34a;outline-offset:2px;}
body.sel-mode .bubble.selected{background:#eafaf0;}
body.sel-mode .bubble.mine.selected{background:#16a34a;}
.sel-bar{position:fixed;left:50%;bottom:18px;transform:translateX(-50%);z-index:9000;display:flex;align-items:center;gap:.7rem;background:var(--card);border:1px solid var(--line);border-radius:999px;padding:.4rem .5rem .4rem .4rem;box-shadow:0 10px 30px rgba(20,30,60,.25);}
.sel-bar .sb-x{border:none;background:var(--blue-soft);color:var(--blue);width:34px;height:34px;border-radius:50%;display:grid;place-items:center;cursor:pointer;}
.sel-bar .sb-n{font-size:.9rem;font-weight:600;color:var(--ink);}
.sel-bar .sb-fwd{border:none;background:var(--blue);color:#fff;border-radius:999px;padding:.5rem .9rem;font-weight:700;cursor:pointer;display:inline-flex;align-items:center;gap:.3rem;}
.sel-bar .sb-fwd:hover{background:var(--blue-d);}
.fwd-list{max-height:44vh;overflow-y:auto;display:flex;flex-direction:column;gap:2px;}
.fwd-item{display:flex;align-items:center;gap:.6rem;padding:.45rem .5rem;border-radius:10px;cursor:pointer;border:1px solid transparent;}
.fwd-item:hover{background:#f2f6fc;}
.fwd-item.sel{background:var(--blue-soft);border-color:var(--blue);}
.fwd-item .fi-name{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-size:.92rem;}
.fwd-item .fi-check{width:20px;height:20px;flex:0 0 auto;border:2px solid var(--line);border-radius:6px;display:grid;place-items:center;color:transparent;}
.fwd-item.sel .fi-check{background:var(--blue);border-color:var(--blue);color:#fff;}
.fwd-item .mini-av{width:34px;height:34px;flex:0 0 auto;border-radius:50%;display:grid;place-items:center;color:#fff;font-weight:700;font-size:.8rem;overflow:hidden;}
.fwd-item .mini-av img{width:100%;height:100%;object-fit:cover;}
.reply-bar{display:flex;align-items:center;gap:.5rem;padding:.45rem .8rem;border-top:1px solid var(--line);background:#eef3fb;font-size:.82rem;color:var(--muted);}
.reply-bar b{color:var(--ink);}
.reply-bar .rx{margin-left:auto;cursor:pointer;font-size:1rem;}
.reply-bar .rx:hover{color:var(--red);}
/* (message action button styling is under .msg-actions) */
.emoji-pop{position:absolute;bottom:64px;left:12px;width:330px;height:300px;background:#fff;border:1px solid var(--line);border-radius:12px;box-shadow:0 10px 28px rgba(0,0,0,.18);z-index:50;display:flex;flex-direction:column;overflow:hidden;}
.mention-pop{position:absolute;left:12px;right:12px;bottom:64px;max-height:240px;overflow:auto;background:var(--card);border:1px solid var(--line);border-radius:12px;box-shadow:0 10px 28px rgba(0,0,0,.18);z-index:60;padding:.3rem;}
/* Attach menu: anchored to the composer (like the emoji/mention popups) so options appear right above the
paperclip instead of the OS chooser jumping to mid-screen. */
.attach-pop{position:absolute;left:12px;bottom:64px;z-index:60;background:var(--card);border:1px solid var(--line);border-radius:12px;box-shadow:0 10px 28px rgba(0,0,0,.18);padding:.3rem;display:flex;flex-direction:column;min-width:190px;}
.attach-pop .attach-opt{display:flex;align-items:center;gap:.6rem;padding:.65rem .7rem;border:none;background:transparent;color:var(--ink);cursor:pointer;border-radius:8px;font-size:.95rem;text-align:left;width:100%;}
.attach-pop .attach-opt:hover{background:var(--blue-soft);color:var(--blue);}
.attach-pop .attach-opt svg{flex:0 0 auto;}
.mention-pop .mrow{display:flex;align-items:center;gap:.55rem;padding:.4rem .55rem;border-radius:8px;cursor:pointer;}
.mention-pop .mrow.sel{background:var(--blue-soft);}
.mention-pop .mn{font-weight:600;color:var(--ink);}
.mention-pop .sub{color:var(--muted);font-size:.78rem;margin-left:auto;}
.mention{color:var(--blue);background:var(--blue-soft);border-radius:5px;padding:0 .18rem;font-weight:600;}
.bubble.mine .mention{color:#fff;background:rgba(255,255,255,.22);}
.bubble.mention-me{box-shadow:0 1px 2px rgba(20,30,60,.06),inset 3px 0 0 var(--brand);}
.bubble.has-poll{max-width:88%;}
.poll{margin-top:.5rem;display:flex;flex-direction:column;gap:.35rem;min-width:240px;}
.poll-q{font-size:.72rem;font-weight:700;letter-spacing:.03em;text-transform:uppercase;opacity:.7;display:flex;align-items:center;gap:.3rem;}
.poll-opt{position:relative;overflow:hidden;display:flex;align-items:center;justify-content:space-between;gap:.5rem;border:1px solid var(--line);background:var(--card);color:var(--ink);border-radius:9px;padding:.45rem .6rem;font-size:.86rem;cursor:pointer;text-align:left;}
.poll-opt:hover:not([disabled]){border-color:var(--blue);}
.poll-opt[disabled]{cursor:default;}
.poll-opt.mine{border-color:var(--blue);background:var(--blue-soft);}
.poll-opt .po-bar{position:absolute;left:0;top:0;bottom:0;background:var(--blue-soft);z-index:0;transition:width .25s;}
.poll-opt.mine .po-bar{background:#d8e4f8;}
.poll-opt .po-txt{position:relative;z-index:1;font-weight:600;display:flex;align-items:center;gap:.3rem;}
.poll-opt .po-pct{position:relative;z-index:1;color:var(--muted);font-size:.78rem;flex:0 0 auto;}
.poll-foot{font-size:.76rem;color:var(--muted);margin-top:.1rem;}
.poll-foot .poll-close{color:var(--red);cursor:pointer;font-weight:600;}
.bubble.mine .poll-opt{color:var(--ink);}
.poll-opt-row{display:flex;align-items:center;gap:.4rem;margin-bottom:.4rem;}
.emoji-tabs{display:flex;border-bottom:1px solid var(--line);flex:0 0 auto;}
.emoji-tabs button{flex:1;border:none;background:transparent;font-size:1.05rem;padding:.35rem 0;cursor:pointer;opacity:.55;}
.emoji-tabs button.active{opacity:1;background:var(--blue-soft);}
/* #5 GIF tab: separated from the emoji category tabs */
.emoji-tabs .et-sep{width:1px;align-self:stretch;margin:.3rem .15rem;background:var(--line);flex:0 0 auto;}
.emoji-tabs button[data-gif]{flex:0 0 auto;font-size:.7rem;font-weight:800;letter-spacing:.02em;padding:.35rem .5rem;color:var(--blue);opacity:.7;}
.emoji-tabs button[data-gif].active{opacity:1;}
.gif-wrap{display:flex;flex-direction:column;min-height:0;flex:1 1 auto;}
.gif-search{position:relative;padding:.5rem;flex:0 0 auto;}
.gif-search input{width:100%;box-sizing:border-box;border:1px solid var(--line);border-radius:9px;padding:.5rem 2rem .5rem .6rem;font:inherit;font-size:.85rem;background:#fbfcfe;color:var(--ink);}
.gif-search input:focus{outline:none;border-color:var(--blue);}
.gif-search svg{position:absolute;right:1.1rem;top:50%;transform:translateY(-50%);color:var(--muted);pointer-events:none;}
/* fixed row height (not aspect-ratio, which collapsed to ~0 in the grid → images stacked over each
other). Uniform 92px rows keep the grid tidy. */
.gif-grid{flex:1 1 auto;overflow-y:auto;display:grid;grid-template-columns:1fr 1fr;grid-auto-rows:92px;gap:.35rem;padding:0 .5rem .3rem;align-content:start;}
.gif-cell{border:none;background:#f1f5f9;border-radius:9px;overflow:hidden;cursor:pointer;padding:0;height:92px;display:block;}
.gif-cell img{width:100%;height:100%;object-fit:cover;display:block;}
.gif-cell:hover{outline:2px solid var(--blue);}
.gif-load{grid-column:1/-1;display:grid;place-items:center;padding:1.4rem;}
.gif-load img{animation:ptrspin .8s linear infinite;}
.gif-empty{grid-column:1/-1;text-align:center;color:var(--muted);font-size:.82rem;padding:1.4rem .5rem;}
.gif-attr{flex:0 0 auto;text-align:center;font-size:.6rem;color:var(--muted);letter-spacing:.03em;padding:.15rem 0 .35rem;}
.bubble .msg-gif{max-width:230px;max-height:230px;border-radius:12px;cursor:pointer;}
.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);}
.bubble .t .edited{font-style:italic;opacity:.8;}
.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);}
/* attachments */
.attach-btn,.emoji-btn{border:none;background:transparent;cursor:pointer;padding:.35rem;color:var(--muted);display:grid;place-items:center;border-radius:8px;}
.attach-btn:hover,.emoji-btn:hover{color:var(--blue);background:var(--blue-soft);}
.composer-row .sendbtn{flex:0 0 auto;align-self:flex-end;width:34px;height:34px;margin:1px;border:none;background:var(--brand);color:var(--ink);cursor:pointer;border-radius:50%;display:grid;place-items:center;}
.composer-row .sendbtn:hover{background:var(--brand-d);}
.att-img{max-width:240px;max-height:240px;border-radius:8px;display:block;margin:.15rem 0;}
/* Belt-and-suspenders: cap ANY image inside a message bubble so a message can never momentarily balloon
the thread to thousands of px while a large picture loads at its natural size (the "stretch" glitch). */
.convo-msgs img,.bubble img{max-height:340px;max-width:100%;height:auto;}
.att-file{display:inline-flex;align-items:center;gap:.4rem;background:rgba(0,0,0,.06);border:1px solid var(--line);border-radius:8px;padding:.4rem .6rem;color:inherit;text-decoration:none;font-size:.85rem;margin:.15rem 0;max-width:240px;}
.att-file span{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
/* In-chat video: a plain native <video controls> — shows a poster frame + the OS play button, plays inline
on one tap (playsinline), streams once and is cached by the browser (no re-download on replay). Fixed box
size so loading the poster can't reflow / "dance" the thread. */
.att-vid{width:min(260px,74vw);max-height:340px;border-radius:10px;display:block;margin:.15rem 0;background:#000;object-fit:contain;}
.bubble.mine .att-file{background:rgba(255,255,255,.18);border-color:rgba(255,255,255,.3);}
.att-file .att-sz{opacity:.65;font-size:.75rem;flex:0 0 auto;}
/* groups */
.bubble .sender{font-size:.7rem;color:var(--blue);font-weight:700;margin-bottom:.12rem;display:flex;align-items:center;gap:.35rem;}
.bubble .sender .snd-av{position:relative;width: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;}
/* Guest meeting mode: an external link-joiner sees ONLY the meeting — no rail, sidebar, or profile. */
body.guest-mode .rail, body.guest-mode #chatcol, body.guest-mode #hdrRight, body.guest-mode #navToggle, body.guest-mode .demo-note{display:none!important;}
body.guest-mode .content{width:100%;flex:1 1 auto;}
/* Guest pre-join: brand backdrop, camera preview, mic/cam toggles, name. */
.guest-prejoin{position:fixed;inset:0;z-index:9900;display:flex;align-items:center;justify-content:center;padding:1rem;overflow:auto;
background:radial-gradient(1200px 600px at 50% -10%, #2b4f96 0%, #1F3B73 55%, #16294f 100%);}
.guest-prejoin .gp-card, .guest-left .gp-card{background:#fff;border-radius:20px;padding:1.5rem 1.5rem 1.3rem;width:100%;max-width:420px;box-shadow:0 24px 60px rgba(0,0,0,.35);text-align:center;}
.guest-prejoin .gp-brand, .guest-left .gp-brand{display:flex;align-items:center;justify-content:center;gap:.5rem;margin-bottom:1rem;}
.guest-prejoin .gp-brand img, .guest-left .gp-brand img{height:34px;width:auto;object-fit:contain;}
.guest-prejoin .gp-brand span, .guest-left .gp-brand span{font-size:1rem;font-weight:600;color:var(--blue);} .guest-prejoin .gp-brand b, .guest-left .gp-brand b{color:var(--blue);}
.guest-left .gp-card h2{margin:.2rem 0 .3rem;font-size:1.2rem;color:var(--ink);}
.guest-prejoin .gp-preview{position:relative;width:100%;aspect-ratio:16/10;border-radius:14px;overflow:hidden;background:#0f172a;display:grid;place-items:center;margin-bottom:.8rem;}
.guest-prejoin .gp-preview video{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;transform:scaleX(-1);}
.guest-prejoin .gp-avatar{width:76px;height:76px;border-radius:50%;display:grid;place-items:center;font-weight:700;font-size:1.5rem;color:#334155;z-index:1;}
.guest-prejoin .gp-camoff{position:absolute;bottom:8px;left:0;right:0;color:rgba(255,255,255,.72);font-size:.75rem;z-index:1;}
.guest-prejoin .gp-toggles{display:flex;gap:.55rem;justify-content:center;margin-bottom:1rem;}
.guest-prejoin .gp-tg{display:inline-flex;align-items:center;gap:.4rem;border:1px solid var(--line);background:var(--blue-soft);color:var(--blue);border-radius:99px;padding:.45rem .85rem;font:inherit;font-size:.82rem;font-weight:600;cursor:pointer;}
.guest-prejoin .gp-tg.off{background:#fee2e2;border-color:#fecaca;color:#b91c1c;}
.guest-prejoin h2{margin:0 0 .2rem;font-size:1.2rem;color:var(--ink);}
.guest-prejoin .gp-sub, .guest-left .gp-sub{margin:0 0 1rem;font-size:.86rem;color:var(--muted);}
.guest-prejoin input{width:100%;box-sizing:border-box;border:1.5px solid var(--line);border-radius:11px;padding:.7rem .8rem;font-size:.95rem;font-family:inherit;background:#fbfcfe;color:var(--ink);outline:none;}
.guest-prejoin input:focus{border-color:var(--blue);}
.guest-prejoin input.err{border-color:#dc2626;background:#fef2f2;}
.guest-prejoin .gp-join, .guest-left .gp-join{width:100%;margin-top:.75rem;border:none;border-radius:11px;background:var(--blue);color:#fff;font:inherit;font-size:.95rem;font-weight:600;padding:.75rem;cursor:pointer;}
.guest-prejoin .gp-join:hover{filter:brightness(1.08);}
.guest-prejoin .gp-note, .guest-left .gp-note{display:flex;align-items:center;justify-content:center;gap:.3rem;margin-top:.7rem;font-size:.74rem;color:var(--muted);}
/* Guest "left the meeting" — same brand language as the pre-join card. */
.guest-left{position:fixed;inset:0;z-index:9900;display:flex;align-items:center;justify-content:center;padding:1rem;
background:radial-gradient(1200px 600px at 50% -10%, #2b4f96 0%, #1F3B73 55%, #16294f 100%);}
.guest-left .gl-tick{width:64px;height:64px;margin:.4rem auto .8rem;border-radius:50%;background:#dcfce7;color:#15803d;display:grid;place-items:center;}
.modal-ov{position:fixed;inset:0;background:rgba(15,23,42,.45);display:flex;align-items:center;justify-content:center;z-index:9800;padding:1rem;}
.modal{background:#fff;border-radius:16px;padding:1.4rem;max-width:380px;width:100%;box-shadow:0 18px 44px rgba(0,0,0,.3);max-height:calc(100vh - 2rem);max-height:calc(100dvh - 2rem);overflow-y:auto;}
.modal h3{margin:0 0 .8rem;color:var(--blue);}
.modal input#grpName,.modal input#giName{width:100%;padding:.6rem .7rem;border:2px solid var(--line);border-radius:10px;background:#fbfcfe;font-size:.95rem;margin-bottom:.8rem;}
.modal input#grpName:focus,.modal input#giName:focus{outline:none;border-color:var(--brand);}
/* Branded confirm dialog (replaces the OS/Electron window.confirm). */
.bz-confirm .bzc-msg{margin:.2rem 0 1.1rem;color:var(--ink);font-size:.92rem;line-height:1.5;}
/* overflow:hidden on the CARD so it never becomes a 2nd scroll container — .modal defaults to
overflow-y:auto, which nested against .seen-scroll and made the wheel/drag feel "stuck". */
.seen-modal{max-width:360px;display:flex;flex-direction:column;max-height:80vh;overflow:hidden;}
.seen-modal>h3{flex:0 0 auto;}
/* THE single scroll region for the whole list; the two section headers stick as you scroll */
.seen-modal .seen-scroll{flex:1 1 auto;overflow-y:auto;overscroll-behavior:contain;margin:.2rem -.4rem 0;padding:0 .4rem;min-height:0;}
.seen-modal .seen-div{height:1px;background:var(--line,#e3e8f2);margin:.5rem .2rem;}
.seen-modal .seen-row{display:flex;align-items:center;gap:.6rem;padding:.34rem .2rem;}
/* dark lettering on the pastel avatar (white was invisible on the light background), unless a real DP covers it */
.seen-modal .seen-row .mini-av{width:34px;height:34px;flex:0 0 auto;border-radius:50%;display:grid;place-items:center;color:#334155;font-weight:700;font-size:.76rem;overflow:hidden;position:relative;}
.seen-modal .seen-row .mini-av .av-img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;}
.seen-modal .seen-row .sn{font-size:.92rem;color:var(--ink);}
.seen-modal .seen-sec{position:sticky;top:0;z-index:1;background:var(--card,#fff);display:flex;align-items:center;gap:.4rem;font-size:.72rem;font-weight:700;letter-spacing:.02em;text-transform:uppercase;color:var(--blue);padding:.5rem .2rem .3rem;}
.seen-modal .seen-sec.dim{color:var(--muted);}
.seen-modal .seen-sec .dot{width:7px;height:7px;border-radius:50%;background:#cbd5e1;}
.seen-modal .seen-sec .dot.ok{background:#22c55e;}
.seen-modal .bzc-actions{flex:0 0 auto;margin-top:.7rem;}
.bzc-actions{display:flex;justify-content:flex-end;gap:.5rem;}
.bzc-actions button{border:none;border-radius:10px;padding:.55rem 1rem;font-size:.9rem;font-weight:600;cursor:pointer;font-family:inherit;}
.bzc-cancel{background:var(--blue-soft);color:var(--blue);}
.bzc-cancel:hover{background:#dbe6fa;}
.bzc-ok{background:var(--blue);color:#fff;}
.bzc-ok:hover{background:var(--blue-d);}
.bzc-ok:disabled{opacity:.5;cursor:not-allowed;}
.bzc-ok.danger{background:var(--red);}
.bzc-ok.danger:hover{background:#991b1b;}
/* "typing…" indicator inside the conversation, just above the composer. */
.typing-row{padding:.1rem .95rem .3rem;min-height:0;color:var(--blue);font-size:.78rem;font-style:italic;font-weight:600;display:none;align-items:center;gap:.4rem;}
.typing-row.show{display:flex;}
.typing-row .tdots{display:inline-flex;gap:2px;}
.typing-row .tdots i{width:5px;height:5px;border-radius:50%;background:var(--blue);opacity:.5;animation:tdot 1s infinite;}
.typing-row .tdots i:nth-child(2){animation-delay:.15s;} .typing-row .tdots i:nth-child(3){animation-delay:.3s;}
@keyframes tdot{0%,60%,100%{opacity:.3;transform:translateY(0);}30%{opacity:1;transform:translateY(-2px);}}
.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;}
.convo-call.joinable{background:#dcfce7;color:#15803d;}
.convo-call.joinable:hover{background:#bbf7d0;}
.convo-call span{font-size:.84rem;}
.call-on{display:inline-flex;align-items:center;gap:.3rem;color:#15803d;font-weight:600;}
.call-invite{position:fixed;right:18px;bottom:18px;z-index:6000;display:flex;align-items:center;gap:.7rem;background:#fff;border:1px solid var(--line);border-left:4px solid #15803d;border-radius:14px;padding:.7rem .9rem;box-shadow:0 12px 30px rgba(20,30,60,.25);max-width:340px;}
/* #4 lobby request stacks above call invites, tinted blue; multiple stack upward */
.call-invite.lobby-req{border-left-color:var(--blue);bottom:auto;top:18px;}
.call-invite.lobby-req .ci-ico{background:var(--blue-soft);color:var(--blue);}
/* #5 speaker output menu */
.spk-menu{position:fixed;z-index:9700;background:#fff;border:1px solid var(--line);border-radius:12px;box-shadow:0 14px 34px rgba(20,30,60,.28);padding:.35rem;min-width:220px;max-width:300px;}
.spk-menu .spk-h{display:flex;align-items:center;gap:.35rem;font-size:.72rem;font-weight:700;text-transform:uppercase;letter-spacing:.03em;color:var(--muted);padding:.35rem .5rem;}
.spk-menu .spk-opt{display:block;width:100%;text-align:left;border:none;background:transparent;font:inherit;font-size:.86rem;color:var(--ink);padding:.5rem .6rem;border-radius:8px;cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.spk-menu .spk-opt:hover{background:var(--blue-soft);}
.spk-menu .spk-opt.on{color:var(--blue);font-weight:600;}
.spk-menu .spk-empty{font-size:.8rem;color:var(--muted);padding:.5rem .6rem;line-height:1.4;}
/* radio dot, so the menu reads like the OS/Teams device picker */
.spk-menu .spk-opt{display:flex;align-items:center;gap:.5rem;}
.spk-menu .spk-dot{width:13px;height:13px;border-radius:50%;border:2px solid #cbd5e1;flex:0 0 auto;}
.spk-menu .spk-opt.on .spk-dot{border-color:var(--blue);box-shadow:inset 0 0 0 2.5px var(--blue);}
.spk-menu .spk-h+.spk-h{margin-top:.2rem;}
/* Mic button + ▾ caret grouped into one control (audio devices live behind the caret) */
.meet-bar .mic-grp{position:relative;display:inline-flex;align-items:center;}
.meet-bar .mic-caret{position:absolute;right:-2px;bottom:-2px;width:20px;height:20px;border-radius:50%;border:2px solid var(--card,#fff);background:var(--blue);color:#fff;display:grid;place-items:center;cursor:pointer;padding:0;z-index:2;}
.meet-bar .mic-caret:hover{filter:brightness(1.12);}
.meet-bar .more-btn{display:none;} /* desktop: everything is on the bar */
/* new #4: the bar can be dragged clear of the shared screen */
.draggable{cursor:move;}
.draggable.dragging{opacity:.92;box-shadow:0 18px 40px rgba(0,0,0,.35);z-index:9500;}
.meet-bar.draggable{border-radius:14px;}
/* Desktop already has speaker+mic devices behind the mic ▾ — no separate speaker button (#8d). */
.meet-bar .spk-btn{display:none;}
/* Speaker OFF is a "lite" state; mic/cam OFF stay RED (see .meet-ic.off above — do NOT override it). */
.meet-bar .spk-btn.off{background:#e2e8f0;color:#94a3b8;}
.gi-list .mrow.dm-able{cursor:pointer;border-radius:9px;}
.gi-list .mrow.dm-able:hover{background:var(--blue-soft);}
/* #1 mini profile card */
.bubble .sender.profile-open{cursor:pointer;border-radius:8px;padding:1px 4px;margin-left:-4px;}
.bubble .sender.profile-open:hover{background:rgba(31,59,115,.07);}
.mini-profile{position:fixed;z-index:9750;width:250px;background:var(--card);border:1px solid var(--line);border-radius:14px;box-shadow:0 16px 40px rgba(20,30,60,.28);padding:.8rem;}
.mini-profile .mp-top{display:flex;align-items:center;gap:.6rem;margin-bottom:.7rem;}
.mini-profile .mp-av{position:relative;overflow:hidden;width:44px;height:44px;flex:0 0 44px;border-radius:50%;display:grid;place-items:center;color:#334155;font-weight:700;font-size:.9rem;}
.mini-profile .mp-av img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;}
.mini-profile .mp-id{min-width:0;}
.mini-profile .mp-id b{display:block;font-size:.92rem;color:var(--ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.mini-profile .mp-id span{font-size:.74rem;color:var(--muted);}
.mini-profile .mp-acts{display:flex;gap:.4rem;}
.mini-profile .mp-msg{flex:1;display:inline-flex;align-items:center;justify-content:center;gap:.35rem;border:none;border-radius:9px;background:var(--blue);color:#fff;font:inherit;font-size:.84rem;font-weight:600;padding:.5rem;cursor:pointer;}
.mini-profile .mp-msg:hover{filter:brightness(1.08);}
.mini-profile .mp-dp{flex:0 0 auto;border:1px solid var(--line);background:var(--card);color:var(--blue);border-radius:9px;width:38px;display:grid;place-items:center;cursor:pointer;}
.spk-menu .mm-opt{display:flex;align-items:center;gap:.55rem;}
.spk-menu .mm-opt svg{flex:0 0 auto;color:var(--blue);}
/* ---- Mobile meeting bar (#8c/#8e): only Mic / Camera / Speaker / End; the rest behind ⋮ More ---- */
@media (max-width:640px){
.meet-bar .mic-caret{display:none;} /* #8c: no device dropdown on mobile — plain on/off */
.meet-bar .sec-btn{display:none;} /* screen, record, transcript, chat, participants → ⋮ */
.meet-bar .more-btn{display:inline-flex;}
.meet-bar .spk-btn{display:inline-flex;} /* speaker/earpiece/bluetooth toggle is MOBILE-only */
/* #2: fit the bar to the screen — controls on ONE row, sized to the width, no overflow/cramping. */
.meet-bar{flex-wrap:wrap;justify-content:center;gap:.45rem;padding:.5rem .5rem calc(.5rem + env(safe-area-inset-bottom,0px));}
.meet-bar .code{flex:1 1 100%;order:-1;margin-bottom:.15rem;}
.meet-bar .meet-ic{width:clamp(40px,13vw,52px);height:clamp(40px,13vw,52px);}
.meet-bar .meet-ic svg{width:20px;height:20px;}
}
.call-invite .ci-ico{width:38px;height:38px;border-radius:50%;background:#dcfce7;color:#15803d;display:grid;place-items:center;flex:0 0 auto;}
.call-invite .ci-txt{font-size:.88rem;color:var(--ink);line-height:1.25;}
.call-invite .ci-join{border:none;background:#15803d;color:#fff;border-radius:9px;padding:.45rem .7rem;font-weight:700;cursor:pointer;display:inline-flex;align-items:center;gap:.3rem;flex:0 0 auto;}
.call-invite .ci-join:hover{background:#13703a;}
.call-invite .ci-x{border:none;background:transparent;color:var(--muted);cursor:pointer;display:grid;place-items:center;flex:0 0 auto;}
.call-invite .ci-x:hover{color:var(--red);}
.call-invite .ci-decline{border:none;background:#fee2e2;color:#b91c1c;border-radius:9px;padding:.45rem .7rem;font-weight:700;cursor:pointer;display:inline-flex;align-items:center;gap:.3rem;flex:0 0 auto;}
.call-invite .ci-decline:hover{background:#fecaca;}
.convo-info:hover{background:#dbe6fb;}
/* group info (Slack-style) */
.modal.gi{max-width:400px;}
.gi-head{display:flex;align-items:center;gap:.7rem;margin-bottom:.6rem;}
.gi-name{flex:1;min-width:0;}
.gi-name input{width:100%;border:none;border-bottom:2px solid transparent;font-size:1.1rem;font-weight:700;color:var(--ink);padding:.1rem 0;background:transparent;}
.gi-name input:focus{outline:none;border-bottom-color:var(--brand);}
.gi-sub{font-size:.78rem;color:var(--muted);}
.gi-name-row{display:flex;align-items:center;gap:.4rem;}
.gi-title{font-size:1.1rem;font-weight:700;color:var(--ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.iconbtn.sm{width:24px;height:24px;}
.gi-edit{display:flex;align-items:center;gap:.3rem;}
.gi-edit input{flex:1;min-width:0;border:2px solid var(--line);border-radius:8px;padding:.3rem .5rem;font-size:1rem;font-weight:600;background:#fbfcfe;color:var(--ink);}
.gi-edit input:focus{outline:none;border-color:var(--brand);}
.gi-actions{display:flex;gap:.6rem;margin:.2rem 0 .7rem;}
.gi-search{position:relative;margin-bottom:.5rem;}
.gi-search input{width:100%;border:1px solid var(--line);border-radius:9px;padding:.45rem 2rem .45rem .6rem;font-size:.88rem;background:#fbfcfe;color:var(--ink);box-sizing:border-box;}
.gi-search input:focus{outline:none;border-color:var(--blue);}
.gi-noresult{text-align:center;color:var(--muted);font-size:.84rem;padding:.7rem 0;}
.gi-created{font-size:.78rem;color:var(--muted);margin:0 0 .7rem;line-height:1.45;}
.gi-created svg{vertical-align:-2px;margin-right:.25rem;}
.gi-created b{color:var(--ink);font-weight:600;}
.gi-setting{display:flex;align-items:center;justify-content:space-between;gap:.5rem;font-size:.84rem;color:var(--ink);background:#f6f8fb;border:1px solid var(--line);border-radius:9px;padding:.5rem .7rem;margin:0 0 .7rem;cursor:pointer;}
.gi-setting span{display:inline-flex;align-items:center;gap:.4rem;}
.switch{position:relative;display:inline-block;width:40px;height:22px;flex:0 0 auto;}
.switch input{opacity:0;width:0;height:0;position:absolute;margin:0;}
.switch .slider{position:absolute;inset:0;background:#cbd2dd;border-radius:99px;transition:background .15s;cursor:pointer;}
.switch .slider::before{content:"";position:absolute;width:18px;height:18px;left:2px;top:2px;background:#fff;border-radius:50%;transition:transform .15s;box-shadow:0 1px 2px rgba(0,0,0,.25);}
.switch input:checked + .slider{background:var(--blue);}
.switch input:checked + .slider::before{transform:translateX(18px);}
.seenby{display:block;margin-top:.2rem;border:none;background:transparent;color:var(--muted);font-size:.68rem;cursor:pointer;padding:0;display:inline-flex;align-items:center;gap:.25rem;}
.seenby:hover{color:var(--blue);}
.bubble.mine .seenby{color:rgba(255,255,255,.8);}
.bubble.mine .seenby:hover{color:#fff;}
.gi-act{flex:1;border:1px solid var(--line);background:var(--card);border-radius:10px;padding:.6rem;font-weight:600;color:var(--blue);cursor:pointer;display:flex;align-items:center;justify-content:center;gap:.4rem;}
.gi-act:hover{background:var(--blue-soft);border-color:#c7d6f0;}
.gi-open{cursor:pointer;}
.sched-wrap{width:100%;margin:1.2rem 0 0;text-align:left;}
.sched-sec{margin-bottom:1.4rem;}
.sched-h{display:flex;align-items:center;gap:.6rem;font-size:.74rem;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);margin:0 0 .55rem;font-weight:700;}
/* #7: past-meetings date filter + pager */
/* #4: all controls share one height/baseline so the icon, preset and date fields line up cleanly */
.mtg-filter{display:inline-flex;align-items:center;gap:.4rem;margin-left:auto;text-transform:none;letter-spacing:0;}
.mtg-filter>svg{color:var(--blue);flex:0 0 auto;}
.mtg-filter select,.mtg-date{height:30px;box-sizing:border-box;display:inline-flex;align-items:center;border:1px solid var(--line);border-radius:8px;padding:0 .55rem;font:inherit;font-size:.74rem;font-weight:600;background:#fbfcfe;color:var(--ink);cursor:pointer;line-height:1;}
.mtg-filter select{appearance:auto;}
.mtg-filter select:focus,.mtg-date:focus{outline:none;border-color:var(--blue);box-shadow:0 0 0 2px var(--blue-soft);}
.mtg-date{gap:.35rem;} .mtg-date:hover{border-color:var(--blue);color:var(--blue);}
.mtg-dash{color:var(--muted);font-weight:600;}
.mtg-clear{width:30px;height:30px;box-sizing:border-box;}
.date-pop{position:fixed;z-index:9760;display:block;} /* branded calendar popup (same look as the scheduler) */
.mtg-clear{border:none;background:transparent;color:var(--muted);cursor:pointer;display:grid;place-items:center;padding:.15rem;border-radius:50%;}
.mtg-clear:hover{background:#fee2e2;color:var(--red);}
.mtg-pager{display:flex;align-items:center;justify-content:center;gap:.7rem;margin-top:.7rem;font-size:.76rem;color:var(--muted);}
.mtg-pg{border:1px solid var(--line);background:var(--card);color:var(--blue);border-radius:8px;width:30px;height:28px;display:grid;place-items:center;cursor:pointer;}
.mtg-pg:hover:not(:disabled){background:var(--blue-soft);}
.mtg-pg:disabled{opacity:.4;cursor:default;}
.sched-list{display:grid;grid-template-columns:repeat(auto-fill,minmax(330px,1fr));gap:.7rem;}
.sched-item{display:flex;align-items:flex-start;gap:.6rem;background:var(--card);border:1px solid var(--line);border-radius:12px;padding:.8rem .9rem;}
.sched-item.live{border-color:#34d399;background:#f0fdf4;}
.sched-item.cancelled{opacity:.7;}
.sched-item.cancelled .si-title{text-decoration:line-through;text-decoration-color:var(--muted);}
.cancel-tag{font-size:.62rem;font-weight:700;background:#fee2e2;color:#b91c1c;padding:.05rem .4rem;border-radius:99px;text-decoration:none;}
/* Schedule form: custom date/time pickers, recurring day chips, inline error highlight */
.sch-row{display:flex;gap:.6rem;flex-wrap:wrap;}
.sch-row > div{flex:1 1 140px;min-width:0;}
.picker-field{position:relative;}
.pick-btn{display:flex;align-items:center;gap:.45rem;width:100%;text-align:left;cursor:pointer;color:var(--ink);}
.pick-btn svg{color:var(--blue);flex:0 0 auto;}
.pick-pop{position:absolute;z-index:60;top:100%;left:0;margin-top:.3rem;background:#fff;border:1px solid var(--line);border-radius:14px;box-shadow:0 14px 36px rgba(20,30,60,.22);padding:.6rem;width:280px;max-width:84vw;}
.pick-pop.time-pop{display:grid;grid-template-columns:repeat(3,1fr);gap:.35rem;max-height:240px;overflow:auto;width:240px;}
.pick-pop.hidden{display:none !important;} /* must beat .pick-pop.time-pop's display:grid */
.cal-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:.4rem;font-size:.92rem;color:var(--ink);}
.cal-nav{border:none;background:var(--blue-soft);color:var(--blue);width:30px;height:30px;border-radius:8px;cursor:pointer;display:grid;place-items:center;}
.cal-nav:disabled{opacity:.35;cursor:default;}
.cal-grid{display:grid;grid-template-columns:repeat(7,1fr);gap:2px;}
.cal-dow{text-align:center;font-size:.66rem;color:var(--muted);font-weight:700;padding:.2rem 0;}
.cal-day{border:none;background:transparent;color:var(--ink);height:34px;border-radius:9px;cursor:pointer;font-size:.84rem;}
.cal-day:hover:not(:disabled){background:var(--blue-soft);}
.cal-day.today{outline:1px solid var(--blue);}
.cal-day.sel{background:var(--blue);color:#fff;font-weight:700;}
.cal-day:disabled{color:#cbd2dc;cursor:default;}
.time-chip{border:1px solid var(--line);background:var(--card);color:var(--ink);border-radius:8px;padding:.4rem .2rem;font-size:.78rem;cursor:pointer;}
.time-chip:hover{background:var(--blue-soft);}
.time-chip.sel{background:var(--blue);color:#fff;border-color:var(--blue);font-weight:700;}
.switch-row{display:flex;align-items:center;justify-content:space-between;background:#f6f8fb;border:1px solid var(--line);border-radius:10px;padding:.55rem .8rem;margin:.8rem 0 .4rem;}
.switch-row > span:first-child{display:flex;align-items:center;gap:.45rem;font-size:.88rem;color:var(--ink);}
.sch-days{display:flex;flex-wrap:wrap;align-items:center;gap:.4rem;margin:.3rem 0 .2rem;}
.day-chip{width:34px;height:34px;border:1px solid var(--line);background:var(--card);color:var(--ink);border-radius:50%;font-size:.78rem;font-weight:700;cursor:pointer;display:grid;place-items:center;}
.day-chip.on{background:var(--blue);color:#fff;border-color:var(--blue);}
.day-all{margin-left:auto;border:1px solid var(--line);background:var(--card);color:var(--blue);border-radius:99px;padding:.32rem .7rem;font-size:.78rem;font-weight:700;cursor:pointer;}
.si-actions .iconbtn{width:28px;height:28px;border-radius:7px;}
.si-actions .iconbtn.edit{color:var(--blue);} .si-actions .iconbtn.edit:hover{background:var(--blue-soft);}
.si-actions .iconbtn.cancel-ic{color:#b91c1c;} .si-actions .iconbtn.cancel-ic:hover{background:#fee2e2;}
.finput.field-err,.pick-btn.field-err{border-color:#dc2626 !important;box-shadow:0 0 0 2px rgba(220,38,38,.18);}
.si-main{flex:1;min-width:0;}
.si-title{font-weight:700;color:var(--ink);display:flex;align-items:center;gap:.5rem;}
.si-meta{font-size:.8rem;color:var(--muted);margin-top:.15rem;}
.si-desc{font-size:.84rem;color:var(--ink);margin-top:.35rem;opacity:.85;}
.livedot{color:#059669;font-size:.72rem;font-weight:700;}
.si-actions{display:flex;align-items:center;gap:.25rem;flex:0 0 auto;}
.btn.sm{padding:.28rem .7rem;font-size:.8rem;border-radius:7px;line-height:1.2;}
.btn.join{background:var(--blue);color:#fff;border:none;cursor:pointer;}
.btn.sm.cancel{background:#fee2e2;color:#b91c1c;border:1px solid #fecaca;}
.btn.sm.cancel:hover{background:#fecaca;}
.modal.sched{max-width:440px;}
.flbl{display:block;font-size:.78rem;font-weight:600;color:var(--ink);margin:.6rem 0 .25rem;}
.flbl .opt{color:var(--muted);font-weight:400;}
.finput{width:100%;border:1px solid var(--line);border-radius:9px;padding:.55rem .65rem;font-size:.92rem;font-family:inherit;background:#fbfcfe;color:var(--ink);box-sizing:border-box;}
.finput:focus{outline:none;border-color:var(--blue);}
.email-invite{display:flex;gap:.5rem;align-items:stretch;}
.email-invite .finput{flex:1;}
.email-add{flex:0 0 auto;display:inline-flex;align-items:center;gap:.3rem;border:1px solid var(--line);background:var(--blue-soft);color:var(--blue);border-radius:9px;padding:0 .8rem;font-size:.82rem;font-weight:600;cursor:pointer;white-space:nowrap;}
.email-add:hover{background:var(--blue);color:#fff;}
.email-chips{display:flex;flex-wrap:wrap;gap:.35rem;margin-top:.45rem;}
.email-chips:empty{margin-top:0;}
.echip{display:inline-flex;align-items:center;gap:.3rem;background:#eef2fa;border:1px solid var(--line);color:var(--ink);border-radius:99px;padding:.2rem .3rem .2rem .6rem;font-size:.78rem;}
.echip button{border:none;background:transparent;color:var(--muted);cursor:pointer;display:grid;place-items:center;padding:.1rem;border-radius:50%;}
.echip button:hover{color:#dc2626;}
.iconbtn{border:none;background:transparent;color:var(--muted);cursor:pointer;width:30px;height:30px;border-radius:8px;display:grid;place-items:center;flex:0 0 auto;}
.iconbtn:hover{background:#f1f5f9;color:var(--blue);}
.iconbtn.rm:hover{color:var(--red);background:#fee2e2;}
.gi-save{width:100%;background:var(--brand);color:var(--ink);margin-bottom:.6rem;}
.gi-sec-h{display:flex;align-items:center;justify-content:space-between;font-size:.74rem;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);margin:.5rem 0 .3rem;}
.gobtn{border:none;border-radius:11px;padding:.7rem 1rem;font-weight:700;font-size:.92rem;cursor:pointer;background:var(--blue);color:#fff;transition:filter .12s;}
.gobtn:hover{filter:brightness(1.08);}
.linkbtn{border:1px solid var(--line);background:var(--card);color:var(--blue);cursor:pointer;font-size:.8rem;font-weight:600;display:inline-flex;align-items:center;gap:.35rem;padding:.35rem .7rem;border-radius:8px;text-transform:none;letter-spacing:0;}
.linkbtn:hover{background:var(--blue-soft);border-color:#c7d6f0;}
.gi-list{display:flex;flex-direction:column;gap:.1rem;max-height:240px;overflow-y:auto;}
.mrow{display:flex;align-items:center;gap:.6rem;padding:.35rem .3rem;border-radius:8px;font-size:.92rem;}
.mrow:hover{background:#f6f8fb;}
.mrow .mn{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.mrow .iconbtn{opacity:0;}
.mrow:hover .iconbtn{opacity:1;}
.mini-av{position:relative;overflow:hidden;width:30px;height:30px;flex:0 0 30px;border-radius:50%;display:grid;place-items:center;color:#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;}
.gi-photo-cam{position:absolute;right:-4px;bottom:-4px;width:22px;height:22px;border-radius:50%;background:var(--blue);color:#fff;display:grid;place-items:center;border:2px solid var(--card);box-shadow:0 1px 3px rgba(0,0,0,.22);}
.gi-photo-cam .ic{width:12px;height:12px;}
.gi-photo:hover .gi-photo-cam{filter:brightness(1.12);}
.youtag{font-size:.62rem;background:var(--blue-soft);color:var(--blue);padding:.05rem .35rem;border-radius:99px;margin-left:.3rem;vertical-align:middle;text-transform:uppercase;letter-spacing:.03em;}
.gi-add{margin-top:.4rem;border:1px solid var(--line);border-radius:10px;padding:.5rem;}
.gi-add .chk{display:flex;align-items:center;gap:.5rem;padding:.25rem;border-radius:6px;cursor:pointer;}
.gi-add .chk:hover{background:#f6f8fb;}
.gi-leave{width:100%;margin-top:.9rem;border:1px solid #fecaca;background:#fff;color:var(--red);border-radius:10px;padding:.55rem;font-weight:600;cursor:pointer;display:flex;align-items:center;justify-content:center;gap:.4rem;}
.gi-leave:hover{background:#fee2e2;}
.grp-members{max-height:220px;overflow-y:auto;border:1px solid var(--line);border-radius:10px;padding:.5rem .6rem;display:flex;flex-direction:column;gap:.4rem;}
.modal .chk{display:flex;align-items:center;gap:.5rem;font-size:.9rem;cursor:pointer;}
.modal .chk input{width:16px;height:16px;accent-color:var(--blue);margin:0;}
.modal-actions{display:flex;gap:.6rem;margin-top:1rem;}
.modal-actions .gobtn{flex:1;border:none;border-radius:10px;padding:.6rem;font-weight:700;cursor:pointer;}
/* ---- Login (shown on /home when logged out) ---- */
.authwrap{flex:1 1 auto;display:none;align-items:center;justify-content:center;padding:1.5rem;min-height:0;
background:radial-gradient(1200px 600px at 50% -10%, #24437f 0%, #1F3B73 42%, #16294F 100%);}
.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;}
/* 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);}
.gobtn:disabled{opacity:.9;cursor:default;}
.btn-spin{display:inline-block;width:15px;height:15px;border:2px solid rgba(22,41,79,.3);border-top-color:var(--blue-d);border-radius:50%;animation:btnspin .6s linear infinite;vertical-align:-3px;margin-right:7px;}
@keyframes btnspin{to{transform:rotate(360deg);}}
.authcard .gobtn:hover{filter:brightness(.95);}
.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.active{background:var(--blue);color:#fff;}
.authcard .lbl{display:block;font-size:.74rem;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;margin:.7rem 0 .15rem;}
.authcard input{width:100%;padding:.6rem .7rem;border-radius:10px;border:2px solid var(--line);background:#fbfcfe;color:var(--ink);font-size:.92rem;}
.authcard input:focus{outline:none;border-color:var(--brand);}
.authcard .gobtn{width:100%;margin-top:1rem;padding:.7rem;background:var(--brand);color:var(--ink);border:none;border-radius:10px;font-weight:700;cursor:pointer;font-size:.95rem;}
.authcard .gobtn:hover{background:var(--brand-d);}
.authcard .pwwrap{position:relative;} .authcard .pwwrap input{padding-right:2.6rem;}
.authcard .eye{position:absolute;right:.35rem;top:50%;transform:translateY(-50%);background:none;border:none;padding:.3rem;width:auto;color:var(--muted);display:inline-flex;align-items:center;cursor:pointer;}
.authcard .eye:hover{color:var(--blue);}
.formerr{color:#b91c1c;font-weight:600;font-size:.88rem;margin:.7rem 0 0;min-height:1em;}
.formerr.show{display:flex;align-items:center;gap:.5rem;background:#fee2e2;border:1px solid #fca5a5;border-radius:9px;padding:.6rem .75rem;animation:errShake .35s;}
.formerr.show::before{content:"⚠";font-size:1rem;}
@keyframes errShake{0%,100%{transform:translateX(0)}20%,60%{transform:translateX(-5px)}40%,80%{transform:translateX(5px)}}
.hidden{display:none;}
/* ---- Loading / toast ---- */
.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:.85rem;}
.loading .ld-inner img{width:72px;height:72px;}
.loading .ld-wm{font-size:1.35rem;font-weight:800;color:#fff;letter-spacing:.3px;margin-top:.15rem;}
.loading .ld-wm b{color:var(--brand);font-weight:800;}
.loading .ld-sub{color:rgba(255,255,255,.66);font-size:.82rem;}
/* Chat thread loading (light pane) and call-connecting (dark stage) — branded, centered. */
.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) */
.navtoggle{background:transparent;border:none;color:#fff;cursor:pointer;display:grid;place-items:center;width:38px;height:38px;border-radius:9px;}
.navtoggle:hover{background:rgba(255,255,255,.14);}
/* Desktop: collapse the rail to enlarge content */
body.rail-hidden .rail{display:none;}
.rail-backdrop{display:none;position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:1100;}
@media (max-width:900px){
.chatcol{width:280px;flex:0 0 280px;}
}
/* ---- Mobile / tablet ---- */
@media (max-width:760px){
/* #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;}
/* CRITICAL (keyboard): iOS auto-ZOOMS into any focused <input>/<textarea> whose font-size is < 16px.
That zoom scales & offsets the visual viewport (vvW 428→394, vvTop→74 in the device report), which
scrambled every keyboard/safe-area calculation. Forcing 16px on all form fields stops the zoom, so
the keyboard behaves predictably and the composer sits correctly. This is THE keyboard root cause. */
/* iOS zoom is now disabled via the viewport meta (maximum-scale=1), so inputs can keep their real
size — no font-size override needed. touch-action:manipulation also removes the double-tap-to-zoom
gesture on controls (e.g. the in-chat search up/down arrows). */
button,a,.iconbtn,.ic-btn,.railbtn,.chat-row,[role=button]{touch-action:manipulation;}
/* #3: kill any horizontal drift on phones (long content, over-wide children, safe-area math) — the
page must never pan left/right. Clip every scroll surface and let long words/URLs wrap. */
html,body{overflow-x:hidden;}
/* Stop the page from widening WITHOUT clipping overlays (profile/bell menus, reaction popups): only
cap the chat-list scroller and let long words/URLs wrap instead of pushing width. */
.chatlist{overflow-x:hidden;}
.chat-main,.msg,.msg-body,.bubble,.chat-sub,.chat-preview{min-width:0;max-width:100%;overflow-wrap:break-word;}
/* #1/#5: the top safe-area inset (notch / Dynamic Island) is applied PER SURFACE — the chat-list
column and each content panel — rather than on the whole shell, so nothing hides under the island
and there's no double-padding. Bottom inset is handled by the fixed nav + panel bottom below. */
.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 */
/* iOS: the 60px height had the home-indicator inset padded INSIDE it, so the bar sat under the
indicator and drifted with the URL bar. Use border-box + a height that INCLUDES the inset, pin it
with a promoted layer, and use px-unit env() fallbacks (unitless 0 breaks calc() on Safari). */
.rail{position:fixed;left:0;right:0;bottom:0;top:auto;width:auto;box-sizing:border-box;
height:calc(60px + var(--sab));
padding:0 .3rem var(--sab) .3rem;
flex-direction:row;justify-content:space-around;align-items:center;gap:0;
border-right:none;border-top:1px solid var(--line);box-shadow:0 -3px 14px rgba(20,30,60,.08);
transform:translateZ(0)!important;-webkit-transform:translateZ(0)!important;z-index:1200;background:var(--card);}
.rail .rail-spacer{display:none;}
.railbtn{display:flex;flex-direction:column;justify-content:center;gap:2px;width:62px;height:50px;border-radius:12px;}
.railbtn .rlabel{display:block;font-size:.66rem;font-weight:700;line-height:1;}
.railbtn svg{width:20px;height:20px;}
.railbtn::after,.railbtn::before{display:none;}
.railbtn:not(.active){color:#334155;background:transparent;}
.railbtn.active{background:var(--blue-soft);color:var(--blue);}
/* Meeting room code: always fully visible, full-width, easy to read */
.meet-bar .code{flex:1 1 100%;font-size:.9rem;white-space:normal;word-break:break-word;text-align:center;background:var(--blue-soft);border-radius:8px;padding:.35rem .5rem;}
/* Chat: one pane at a time (list, then the open conversation) */
.chatcol{width:100%;flex:1 1 100%;padding-top:var(--sat);padding-bottom:calc(60px + var(--sab));}
body.chat-open .chatcol{display:none;}
.content{display:none;}
body.chat-open .content, .shell:not(.is-chat) .content{display:block;}
/* Keep the conversation pane on its own GPU layer for the WHOLE time it's open (not just while the
slide animates). The open transition then only changes the transform VALUE 100%→0; when it ends there
is NOTHING to tear down (no contain/box-shadow/will-change toggle) → the ~40ms end-of-slide demote
raster is gone. The layer is demoted only when you leave the chat, which has its own close animation. */
body.chat-open .content{z-index:2;box-shadow:-12px 0 30px rgba(15,23,42,.18);will-change:transform;backface-visibility:hidden;-webkit-backface-visibility:hidden;}
.shell:not(.is-chat) .chatcol{display:none;}
.content .panel{top:var(--sat);bottom:calc(60px + var(--sab));} /* below the notch; clear the bottom nav + home indicator */
/* #2 (mobile keyboard): inside an open conversation, hide the bottom tab bar so the composer sits
directly above the keyboard (no nav sandwiched between the text box and the keyboard). The panel
then only needs to clear the home indicator. */
body.chat-open .rail{display:none;}
/* Composer clears the home indicator; with keyboard resize:none we raise it by --kb (the reported
keyboard height, set by the Keyboard listener) so it sits on the keyboard. No home indicator while
the keyboard is up, so --sab collapses to 0. */
body.chat-open .content .panel{bottom:calc(var(--sab) + var(--kb));transition:bottom .25s cubic-bezier(.17,.59,.4,1);}
body.kb-open{--sab:0px;}
/* iOS: a home-screen PWA IGNORES maximum-scale, so it auto-zooms the page on focus of any input under
16px (the Capacitor WebView respects maximum-scale, which is why the zoom only shows up in the PWA).
Making every focusable text field exactly 16px is the only reliable prevention — and it's belt-and-
suspenders for the native app too. autoGrow's empty-guard keeps the composer at one line despite this. */
.composer-row input, .composer-row textarea, input[type="text"], input[type="search"], input[type="email"], input[type="tel"], input[type="url"], input[type="password"], input[type="number"], input:not([type]), textarea, select{font-size:16px;}
.lightbox .lb-zoom{display:none;} /* mobile: pinch-to-zoom the image directly — the on-screen +/- buttons are redundant clutter */
/* Native-style push/pop: the conversation slides in from the right when opened and slides back out
when you go back, so navigation has a clear direction (like Teams/WhatsApp). */
/* Native-style push/pop (like iOS/Teams): the conversation (.content) slides in from the right while
the chat LIST underneath slides with a subtle parallax. iOS spring-ish easing. */
@keyframes bzContentIn{from{transform:translateX(100%);}to{transform:translateX(0);}}
@keyframes bzListParallaxOut{from{transform:translateX(0);filter:brightness(1);}to{transform:translateX(-22%);filter:brightness(.97);}}
@keyframes bzListParallaxIn{from{transform:translateX(-22%);filter:brightness(.97);}to{transform:translateX(0);filter:brightness(1);}}
/* OPEN: content slides in over the list (list shown beneath, parallax out) */
/* width:100vw makes the sliding pane a DEFINITE width regardless of any ancestor's transient 0-width
during the animation frame — so bubbles never collapse, the composer measures at the right width,
and translateX(100%) travels a real screen width (a true slide, not a no-travel "pop"). */
/* Compositor-only push (workflow-verified). Phase 1 (chat-opening): the pane is pinned OFF-SCREEN on
its own promoted GPU layer while the message DOM renders + scrolls to bottom. Phase 2 (.chat-anim,
added by JS on a double-rAF AFTER the render is laid out & painted): the compositor transitions
transform 100%→0 with the main thread idle → a true 60fps slide, not a jump. Dropped at transitionend. */
/* OPEN = instant position + a quick opacity fade. No transform, no parallax, no list-column display
toggle → there is literally nothing to reflow when it finishes, so the old ~40ms end-of-slide settle
is gone by construction. The list (chatcol) stays display:none throughout; content fades in over the bg. */
body.chat-opening .content{display:block!important;opacity:0;transform:translateX(12%);}
body.chat-opening .content.chat-fadein{opacity:1;transform:translateX(0);transition:opacity .18s ease-out, transform .24s cubic-bezier(.32,.72,0,1);} /* real slide-in from the right + fade. The list column (.chatcol) is NEVER shown during the open — it stays display:none throughout — so there is no display toggle and therefore no reflow when the slide ends. Content is already on its own GPU layer (will-change on body.chat-open), so this is a pure compositor transform. */
/* CLOSE / drag-back: reveal the list underneath (parallax in) while the conversation slides off right
(.content moved by an inline transform in showWelcome()/the edge-drag gesture). */
body.chat-closing .content, body.chat-dragging .content{display:block!important;z-index:2;box-shadow:-12px 0 30px rgba(15,23,42,.18);will-change:transform;}
body.chat-closing .chatcol{display:flex!important;position:absolute;inset:0;z-index:0;animation:bzListParallaxIn .28s cubic-bezier(.32,.72,0,1);}
body.chat-dragging .chatcol{display:flex!important;position:absolute;inset:0;z-index:0;}
/* Bigger touch targets */
.chat-row{padding:.7rem .85rem;}
.convo-head{padding:.7rem .85rem;}
.modal{width:94vw;}
/* Meeting + embedded panels full-width, controls above the nav */
.meet-grid{grid-template-columns:repeat(auto-fit,minmax(150px,1fr));}
.meet-bar{flex-wrap:wrap;gap:.4rem;padding:.5rem .6rem;}
.meet-bar .code{flex:1 1 100%;margin:0 0 .2rem;}
.meet-bar .meet-ic{width:44px;height:44px;}
.meet-panel{width:90vw;max-width:none;right:5vw;top:6px;bottom:74px;}
.md-top{flex-direction:column;}
/* Code+Join on a full-width row; Start & Schedule as two equal halves below */
.md-actions{width:100%;display:grid;grid-template-columns:1fr 1fr;gap:.5rem;}
.md-join{grid-column:1 / -1;}
.md-actions > .btn{width:100%;justify-content:center;}
.md-join{flex:1 1 100%;}
.call-invite{left:8px;right:8px;max-width:none;bottom:70px;}
.bell-menu{position:fixed;left:8px;right:8px;top:calc(56px + env(safe-area-inset-top,0px));width:auto;}
#rail .bell-menu,#rail .profile .pmenu{left:auto;} /* desktop rail rule must not leak to the mobile bottom-nav */
/* Touch devices have no hover: keep member-row actions (make-admin / remove) always visible */
.mrow .iconbtn{opacity:1;}
}
</style>
</head>
<body>
<script src="/icons.js?v=6"></script>
<script>window.__BUILD='2026-07-22-batch157';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD);
// Emoji are rendered with the OS's own (colour) emoji font — instant, zero network.
//
// We used to run Twemoji over every emoji, which swapped each one for an <img> pulled INDIVIDUALLY from
// a CDN. Opening the picker therefore fired hundreds of image requests, and every re-render of a long
// thread fired more — that's why emoji "loaded very slowly" (#6). Modern Windows/macOS/iOS/Android all
// ship full-colour emoji, so the native glyphs look right and cost nothing. Kept as a no-op so the
// existing call sites don't have to change.
function twemojify(_el){ /* native emoji: nothing to do */ }</script>
<script>
// Native shell tag (iOS/Android). Safe-area insets are handled natively by the @capacitor-community/
// safe-area plugin (it makes env(safe-area-inset-*) report real values), so there's no JS status-bar
// juggling here — we only tag <html> so CSS can make small native-only tweaks if needed.
(function(){ try{ var C=window.Capacitor; if(!C||!C.isNativePlatform||!C.isNativePlatform()) return;
var p=(C.getPlatform&&C.getPlatform())||''; var r=document.documentElement; r.classList.add('native-app'); if(p) r.classList.add('native-'+p);
// Keyboard. The device log proved that on our build (resize:none) the WebView does NOT resize and
// VisualViewport does NOT reflect the keyboard (clientHeight & vvHeight both stay full) — so the ONLY
// reliable signal is the Capacitor Keyboard plugin's reported height. Now that the iOS auto-zoom is
// gone (inputs are 16px), that height is clean, so we lift the composer by exactly it. keyboardWillShow
// fires at the START of the animation, so the CSS bottom-transition slides the composer up WITH the
// keyboard (smooth). raw>innerHeight guards the rare device-pixel unit.
function setKb(px){ px=Math.max(0,Math.round(px)); r.style.setProperty('--kb',px+'px'); document.body.classList.toggle('kb-open',px>4); var b=document.getElementById('msgs'); if(b && (b.scrollHeight-b.scrollTop-b.clientHeight)<400) b.scrollTop=b.scrollHeight; } /* #1: only keep pinned to newest when ALREADY near the bottom — replying to an OLD message must not yank to the latest */
window.__bzResetKb=function(){ setKb(0); }; // called on composer blur as a safety net (#1: keyboard closed but lift stayed, leaving empty space after reply-cancel)
var KB=C.Plugins&&C.Plugins.Keyboard;
if(p==='ios' && KB && KB.setAccessoryBarVisible){ try{ KB.setAccessoryBarVisible({ isVisible:false }); }catch(_){} } // hide the grey iOS input accessory bar (up/down chevrons + Done) above the keyboard — iOS-only, no-op elsewhere
if(KB && KB.addListener){
KB.addListener('keyboardWillShow', function(info){ var raw=(info&&info.keyboardHeight)||0; var kb = raw>window.innerHeight ? raw/(window.devicePixelRatio||1) : raw; setKb(kb); });
KB.addListener('keyboardWillHide', function(){ setKb(0); });
KB.addListener('keyboardDidHide', function(){ setKb(0); }); // belt-and-suspenders: some close paths only fire Did*
}
}catch(_){} })();
// Browser / iOS home-screen PWA (NO Capacitor Keyboard plugin): lift the composer above the on-screen
// keyboard using the VisualViewport API, so the PWA behaves like the native app instead of the keyboard
// covering the text box. Native returns early (it uses the plugin path above). The >100px threshold ignores
// the Safari toolbar growing/shrinking (only a real keyboard shrinks the visual viewport that much).
(function(){ try{
var C=window.Capacitor; if(C && C.isNativePlatform && C.isNativePlatform()) return;
var vv=window.visualViewport; if(!vv) return; var r=document.documentElement, b=document.body;
function pinMsgs(){ var m=document.getElementById('msgs'); if(m && (m.scrollHeight-m.scrollTop-m.clientHeight)<400) m.scrollTop=m.scrollHeight; }
function upd(){
var cover = r.clientHeight - vv.height; // how much of the LAYOUT viewport the keyboard covers
if(cover > 100){
// Keyboard open. iOS/Safari does NOT resize the layout viewport (interactive-widget is ignored) and the
// webview SCROLLS to reveal the focused box (device log: scrollY≈413), which pushes the header off the
// top. Fix: pin the whole app to the VISUAL viewport height and undo the scroll, so header + messages +
// composer all fit above the keyboard. On Android, interactive-widget=resizes-content already shrank
// clientHeight to vv.height, so cover≈0 and this branch is skipped (that path is already correct).
b.style.height = vv.height + 'px'; b.style.bottom = 'auto';
r.style.setProperty('--kb','0px'); document.body.classList.add('kb-open');
if(window.scrollY || vv.offsetTop) window.scrollTo(0,0);
pinMsgs();
} else {
b.style.height=''; b.style.bottom='';
r.style.setProperty('--kb','0px'); document.body.classList.remove('kb-open');
}
}
vv.addEventListener('resize', upd); vv.addEventListener('scroll', upd);
window.__bzResetKb=function(){ b.style.height=''; b.style.bottom=''; r.style.setProperty('--kb','0px'); document.body.classList.remove('kb-open'); };
}catch(_){} })();
</script>
<div class="loading" id="loading"><div class="ld-inner"><img src="/loaders/loader-orbit-dark.svg" alt="" width="72" height="72"><div class="ld-wm">Biz <b>Connect</b></div><span class="ld-sub">Loading…</span></div></div>
<header>
<div class="brandrow">
<button class="navtoggle" id="navToggle" title="Toggle menu" aria-label="Toggle menu"><svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg></button>
<img src="/mark-light.png" alt="" style="height:46px;width:auto;max-width:190px;border-radius:8px;object-fit:contain;image-rendering:-webkit-optimize-contrast" onerror="this.replaceWith(Object.assign(document.createElement('div'),{className:'logo',textContent:'B'}))">
<div class="brand">Biz <span class="y">Connect</span></div>
</div>
<div id="hdrRight"></div>
</header>
<div class="shell is-chat">
<div class="rail-backdrop" id="railBackdrop"></div>
<!-- ---------- Icon rail ---------- -->
<nav class="rail" id="rail">
<button class="railbtn active" data-tab="chat" data-tip="Chat" aria-label="Chat">
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="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>
<span class="rdot" id="railUnread" style="display:none">0</span><span class="rlabel">Chat</span>
</button>
<button class="railbtn" data-tab="share" data-tip="Share Screen" aria-label="Share Screen">
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
<span class="livedot"></span><span class="rlabel">Share</span>
</button>
<button class="railbtn" data-tab="connect" data-tip="Connect Screen" aria-label="Connect Screen">
<svg viewBox="0 0 24 24" width="22" height="22" 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>
<span class="livedot"></span><span class="rlabel">Connect</span>
</button>
<button class="railbtn" data-tab="meeting" data-tip="Meeting" aria-label="Meeting">
<svg viewBox="0 0 24 24" width="22" height="22" 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>
<span class="livedot"></span><span class="rlabel">Meet</span>
</button>
<div class="rail-spacer"></div>
</nav>
<!-- ---------- Chat list (Chat tab only) ---------- -->
<aside class="chatcol" id="chatcol">
<div class="side-head">
<div class="side-title">
<h2>Chats</h2>
</div>
<div class="side-search-row">
<div class="search">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input id="chatSearch" placeholder="Search chats" autocomplete="off">
<button class="search-x" id="chatSearchX" title="Clear" aria-label="Clear" style="display:none"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg></button>
</div>
<button class="newchat" id="newChat" title="New group / chat" aria-label="New group">+</button>
</div>
</div>
<div class="chatlist" id="chatlist"></div>
<div class="demo-note">💬 Messages with your BizGaze teammates</div>
</aside>
<!-- ---------- Main content ---------- -->
<main class="content">
<!-- Chat panel: welcome (no selection) OR conversation placeholder -->
<div class="panel center active" data-panel="chat" id="chatPanel"></div>
<!-- Share -->
<div class="panel" data-panel="share" id="sharePanel"></div>
<!-- Connect -->
<div class="panel" data-panel="connect" id="connectPanel"></div>
<!-- Meeting (mesh video; rendered by JS) -->
<div class="panel" data-panel="meeting" id="meetingPanel"></div>
</main>
</div>
<div class="authwrap" id="authwrap"></div>
<script>
// ---------- Helpers ----------
function pEsc(s){return String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));}
function initials(name){const p=String(name||'?').trim().split(/\s+/);return ((p[0]||'?')[0]+(p[1]?p[1][0]:'')).toUpperCase();}
function fmtDateTime(ts){ if(!ts) return ''; const d=new Date(ts); return d.toLocaleDateString([],{year:'numeric',month:'short',day:'numeric'})+' · '+d.toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'}); }
function fmtClock(ts){ if(!ts) return ''; return new Date(ts).toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'}); } // bubble time = clock only; date is the center separator
function autoGrow(el){ if(!el) return; if(!el.value){ el.style.height=''; el.style.overflowY='hidden'; return; } /* empty → 1 line via CSS (never inflate from a mid-layout scrollHeight) */ el.style.height='auto'; const max=140; el.style.height=Math.min(el.scrollHeight,max)+'px'; el.style.overflowY=el.scrollHeight>max?'auto':'hidden'; }
// Mild, light tint for a quoted reply, color-coded by who is being quoted. [bg, bar]
const REPLY_TINTS=[['#eef4ff','#3b6fd4'],['#eafaf2','#1f9d57'],['#fef4e9','#d98324'],['#f6eefe','#8b46c9'],['#fdeef3','#d6457f'],['#e9fafa','#179a9a'],['#fef7e6','#c9a227']];
function replyTint(key){ let h=0; const s=String(key||''); for(let i=0;i<s.length;i++) h=(h*31+s.charCodeAt(i))>>>0; return REPLY_TINTS[h%REPLY_TINTS.length]; }
function firstName(name){return String(name||'').trim().split(/\s+/)[0]||'there';}
// 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];}
// Auto-correct: curated high-confidence common-typo → fix map. Applied as you type (on a word
// boundary), so corrections happen automatically — no right-click menu. Works on web/desktop/mobile.
// Kept conservative (only unambiguous typos) to avoid "fighting" the user. Ctrl+Z reverts a fix.
const AUTOCORRECT={
teh:'the',hte:'the',thier:'their',recieve:'receive',recieved:'received',beleive:'believe',beleived:'believed',
seperate:'separate',definately:'definitely',occured:'occurred',occuring:'occurring',untill:'until',wich:'which',
becuase:'because',becasue:'because',accross:'across',adress:'address',adressed:'addressed',alot:'a lot',
arguement:'argument',calender:'calendar',cemetary:'cemetery',changable:'changeable',collegue:'colleague',
comming:'coming',commited:'committed',concious:'conscious',definetly:'definitely',dissapoint:'disappoint',
embarass:'embarrass',enviroment:'environment',existance:'existence',familier:'familiar',finaly:'finally',
foriegn:'foreign',fourty:'forty',freind:'friend',gaurd:'guard',goverment:'government',grammer:'grammar',
happend:'happened',harrass:'harass',immediatly:'immediately',independant:'independent',infomation:'information',
knowlege:'knowledge',liason:'liaison',libary:'library',lisence:'license',maintainance:'maintenance',
mispell:'misspell',neccessary:'necessary',necesary:'necessary',noticable:'noticeable',occassion:'occasion',
occurance:'occurrence',ocurrence:'occurrence',paralell:'parallel',parliment:'parliament',particurly:'particularly',
perhasp:'perhaps',persistant:'persistent',posession:'possession',prefered:'preferred',privelege:'privilege',
probaly:'probably',probelm:'problem',proffesional:'professional',promiss:'promise',pronounciation:'pronunciation',
publically:'publicly',realy:'really',reccomend:'recommend',recomend:'recommend',refered:'referred',
relevent:'relevant',religous:'religious',rember:'remember',remeber:'remember',resturant:'restaurant',
rythm:'rhythm',secratary:'secretary',sieze:'seize',similiar:'similar',succesful:'successful',succesfully:'successfully',
suprise:'surprise',suprised:'surprised',tommorow:'tomorrow',tommorrow:'tomorrow',tounge:'tongue',truely:'truly',
unfortunatly:'unfortunately',usualy:'usually',vaccum:'vacuum',wierd:'weird',writen:'written',yeild:'yield',
yuo:'you',youre:"you're",cant:"can't",dont:"don't",doesnt:"doesn't",didnt:"didn't",wasnt:"wasn't",werent:"weren't",
isnt:"isn't",wouldnt:"wouldn't",couldnt:"couldn't",shouldnt:"shouldn't",hasnt:"hasn't",havent:"haven't",
ive:"I've",thats:"that's",whats:"what's",lets:"let's",
aer:'are',adn:'and',nad:'and',wnat:'want',waht:'what',knwo:'know',
woudl:'would',shoudl:'should',coudl:'could',funtion:'function',retrun:'return',
};
// Apply an auto-correction if the caret just crossed a word boundary onto a known typo.
function maybeAutocorrect(el){
try{
if(!el||el.selectionStart==null||el.selectionStart!==el.selectionEnd) return;
const pos=el.selectionStart, val=el.value, ch=val[pos-1];
if(!ch || !/[\s.,!?;:]/.test(ch)) return; // only right after a boundary char
const before=val.slice(0,pos-1); const m=before.match(/([A-Za-z']+)$/); if(!m) return;
const word=m[1], lower=word.toLowerCase(); const rep=AUTOCORRECT[lower];
if(!rep) return; // not a known typo (native squiggles still flag others)
const fixed=/^[A-Z]/.test(word)?(rep.charAt(0).toUpperCase()+rep.slice(1)):rep;
if(fixed===word) return;
const start=pos-1-word.length;
el.value=val.slice(0,start)+fixed+val.slice(pos-1);
const np=start+fixed.length+1; el.setSelectionRange(np,np);
}catch(_){}
}
// All in-app toasts route through the branded BZToast (bizconnect-toast.js). We keep the single
// 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) ----------
function profileHTML(u){
const display=u.name||u.email;
const img=u.avatarUrl?'<img src="'+pEsc(u.avatarUrl)+'" alt="" onerror="this.remove()">':'';
const cur=(u&&u.status)||'active';
const lblOf=(st)=>st==='away'?'Away':st==='onleave'?'On leave':'Available';
const opt=(st)=>'<a class="ps-opt'+(cur===st?' on':'')+'" data-st="'+st+'"><span class="st-dot '+st+'"></span> '+lblOf(st)+'<span class="ps-check">'+ic('check',14)+'</span></a>';
return '<div class="profile"><button class="pbtn icon-only" id="pbtn" title="'+pEsc(display)+'">'
+ '<span class="pav">'+pEsc(initials(display))+img+'</span><span class="pav-dot '+statusCls({online:true,status:cur})+'"></span></button>'
+ '<div class="pmenu" id="pmenu">'
+ '<div class="phead"><div class="n">'+pEsc(display)+'</div><div class="e">'+pEsc(u.email)+(u.role?' · '+pEsc(u.role):'')+'</div></div>'
+ '<a class="ps-current" id="psStatusToggle"><span class="st-dot '+cur+'"></span><span class="ps-cur-label">'+lblOf(cur)+'</span><span class="ps-arrow">'+ic('chevronDown',14)+'</span></a>'
+ '<div class="ps-options" id="psOptions" style="display:none">'+opt('active')+opt('away')+opt('onleave')+'</div>'
+ '<div class="ps-div"></div>'
+ '<a href="/dashboard">'+ic('layoutDashboard',16)+' Dashboard</a>'
+ '<a id="psettings">'+ic('settings',16)+' Settings</a>'
+ '<a class="danger" id="plogout">'+ic('logOut',16)+' Logout</a>'
+ '</div></div>';
}
function wireProfile(){
const btn=document.getElementById('pbtn'),menu=document.getElementById('pmenu');
if(!btn)return;
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()=>{ 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
// your messages, new polls, and new chat requests. Stored per user in this browser.
let NOTIFS=[];
function notifKey(){ return 'notifs_'+((ME&&ME.id)||''); }
function loadNotifs(){ try{ NOTIFS=JSON.parse(localStorage.getItem(notifKey())||'[]'); }catch(_){ NOTIFS=[]; } if(!Array.isArray(NOTIFS)) NOTIFS=[]; updateBellBadge(); }
function saveNotifs(){ try{ NOTIFS=NOTIFS.slice(0,50); localStorage.setItem(notifKey(), JSON.stringify(NOTIFS)); }catch(_){} }
function addNotif(n){ NOTIFS.unshift(Object.assign({ id:'n'+Date.now()+'_'+Math.round((window.performance&&performance.now())||Math.random()*1e6), ts:Date.now(), read:false }, n)); saveNotifs(); updateBellBadge(); const menu=document.getElementById('bellMenu'); if(menu&&menu.classList.contains('open')) renderBell(); }
function bellHTML(){ return '<div class="bell" id="bellWrap"><button class="bellbtn" id="bellBtn" title="Notifications" aria-label="Notifications">'+ic('bell',20)+'<span class="bell-dot" id="bellDot" style="display:none">0</span></button><div class="bell-menu" id="bellMenu"></div></div>'; }
function updateBellBadge(){ const d=document.getElementById('bellDot'); if(!d) return; const n=NOTIFS.filter(x=>!x.read).length; if(n>0){ d.textContent=n>99?'99+':n; d.style.display='grid'; } else d.style.display='none'; }
function renderBell(){ const menu=document.getElementById('bellMenu'); if(!menu) return;
const items=NOTIFS.length?NOTIFS.map(n=>'<div class="bell-item'+(n.read?'':' unread')+'" data-id="'+n.id+'"><span class="bell-ico">'+ic(n.icon||'bell',16)+'</span><div class="bell-body"><div class="bell-tx">'+n.text+'</div><div class="bell-tm">'+fmtTime(n.ts)+'</div></div></div>').join(''):'<div class="bell-empty">No notifications yet</div>';
menu.innerHTML='<div class="bell-head"><span>Notifications</span>'+(NOTIFS.length?'<button id="bellClear">Clear all</button>':'')+'</div><div class="bell-list">'+items+'</div>';
const cl=menu.querySelector('#bellClear'); if(cl) cl.onclick=(e)=>{ e.stopPropagation(); NOTIFS=[]; saveNotifs(); updateBellBadge(); renderBell(); };
menu.querySelectorAll('.bell-item').forEach(el=>el.onclick=()=>{ const n=NOTIFS.find(x=>x.id===el.dataset.id); if(n) openNotif(n); });
}
function openNotif(n){ NOTIFS=NOTIFS.filter(x=>x.id!==n.id); saveNotifs(); updateBellBadge(); const menu=document.getElementById('bellMenu'); if(menu) menu.classList.remove('open');
if(n.link){ if(n.link.kind==='meeting'){ switchTab('meeting'); loadScheduledMeetings(); } else if(n.link.kind==='dm'||n.link.kind==='group'){ switchTab('chat'); selectChat(n.link.kind, n.link.id); } } // meeting → just open the tab, don't auto-join
renderBell();
}
function wireBell(){ const b=document.getElementById('bellBtn'), menu=document.getElementById('bellMenu'); if(!b||!menu) return;
b.onclick=(e)=>{ e.stopPropagation(); const 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.
function openSettings(){
if(document.getElementById('setModal')) return;
const ov=document.createElement('div'); ov.className='modal-ov'; ov.id='setModal';
const sw=(id,label,on)=>'<label class="gi-setting"><span>'+label+'</span><span class="switch"><input type="checkbox" id="'+id+'"'+(on?' checked':'')+'><span class="slider"></span></span></label>';
const perm=('Notification' in window) ? Notification.permission : 'unsupported';
const granted=perm==='granted';
const permLabel=granted?'Allowed':(perm==='denied'?'Denied':(perm==='default'?'Not set':'Unsupported'));
ov.innerHTML='<div class="modal sched"><div class="gi-head" style="margin-bottom:.6rem"><div class="avatar grp" style="width:40px;height:40px;flex:0 0 40px;background:var(--blue)">'+ic('settings',20)+'</div>'
+'<div class="gi-name"><div class="gi-title">Settings</div><div class="gi-sub">Notifications</div></div>'
+'<button class="iconbtn" id="setClose" title="Close">'+ic('x',18)+'</button></div>'
+sw('setGroup','Group message notifications', notifOn('group'))
+sw('setDm','Direct message notifications', notifOn('dm'))
+'<label class="gi-setting"><span>Notifications <span class="perm-state '+perm+'">'+permLabel+'</span><div style="font-size:.72rem;color:var(--muted);font-weight:400;margin-top:.15rem">Pop up messages &amp; calls even when this tab isnt open</div></span>'+(granted?'':'<button class="btn sm" id="setPerm">'+(perm==='denied'?'Allow':'Enable')+'</button>')+'</label>'
+(window.bizConnectNative?('<label class="gi-setting"><span>Biz Connect for Desktop<div style="font-size:.72rem;color:var(--muted);font-weight:400;margin-top:.15rem" id="setVer">Version '+pEsc(window.bizConnectNative.version||'—')+(_pendingUpdate?('<span class="set-ver-i" title="Update available">i</span> '+(_pendingUpdate.phase==='ready'?'Update ready':'Update '+pEsc(_pendingUpdate.version||'')+' available')):'')+'</div></span><button class="btn sm'+(_pendingUpdate?' has-update':'')+'" id="setUpd">'+(_pendingUpdate?(_pendingUpdate.phase==='ready'?'Restart now':'Downloading…'):'Check for updates')+'</button></label>'):'')
// NOTE: deliberately NO "web build" row here — users must not have to reason about an app version vs a
// web build. New code applies itself (see checkWebBuild/scheduleAutoRefresh); the only version shown is
// the desktop app's, which is what the auto-updater manages.
+'<div class="hint" style="margin-top:.4rem">These preferences are saved on this device.</div></div>';
document.body.appendChild(ov);
ov.onclick=e=>{ if(e.target===ov) ov.remove(); };
ov.querySelector('#setClose').onclick=()=>ov.remove();
const updBtn=ov.querySelector('#setUpd'); // #12: desktop version + manual update check
if(updBtn) updBtn.onclick=async()=>{
if(_pendingUpdate&&_pendingUpdate.phase==='ready'){ try{ window.bizConnectNative.restartToUpdate&&window.bizConnectNative.restartToUpdate(); }catch(_){} return; } // update already downloaded → restart
if(_pendingUpdate){ toast('Update '+(_pendingUpdate.version||'')+' is downloading…'); return; }
const ver=ov.querySelector('#setVer'); updBtn.disabled=true; updBtn.textContent='Checking…';
let done=false;
try{
const r=(window.bizConnectNative&&window.bizConnectNative.checkForUpdates)?await window.bizConnectNative.checkForUpdates():{status:'unsupported'};
if(r.status==='available'){ updBtn.textContent='Update '+(r.version||'')+' found'; if(ver) ver.textContent='Downloading '+(r.version||'')+'… youll be asked to restart when ready.'; done=true; }
else if(r.status==='current'){ updBtn.textContent='Up to date ✓'; }
else if(r.status==='dev'){ updBtn.textContent='Dev build'; }
else if(r.status==='unsupported'){ updBtn.textContent='Update on newer app'; }
else { updBtn.textContent='Check failed'; }
}catch(_){ updBtn.textContent='Check failed'; }
setTimeout(()=>{ if(!done){ updBtn.disabled=false; updBtn.textContent='Check for updates'; } }, 3500);
};
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 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) ----------
let ME={};
let CONTACTS=[]; // team users (for new DMs / picking group members)
let ROWS=[]; // sidebar items: {kind:'dm'|'group', id, name, online?, members?, last_body, last_at, last_from_me, unread}
let selected=null; // {kind,id} or null = welcome
let convoIsGroup=false; // the open thread is a group (drives per-message sender labels)
let THREAD=[];
let _convoRevealed=false; // #7: gate the open-time hide/reveal so the cache→network double-render doesn't flash/dance
let _forcePinOpen=false; // true from chat-open until the user's first scroll gesture: keep gluing to the newest even as late images grow the thread past the usual 1200px "don't yank" guard (else you land mid-history)
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;
// Mount the indicator on the list's NON-scrolling parent — inside the scroller it scrolled away with
// the content and was effectively invisible, which is why the pull looked like it did nothing (#6).
const host=el.parentElement||el;
if(getComputedStyle(host).position==='static') host.style.position='relative';
const ind=document.createElement('div'); ind.className='ptr-ind';
ind.innerHTML='<img src="/loaders/loader-ring.svg" width="20" height="20" alt="">';
host.appendChild(ind);
let startY=0, pulling=false, dist=0, busy=false;
const TRIGGER=58; // pull this far → refresh
const hide=()=>{ ind.style.opacity='0'; ind.style.transform='translateY(-44px)'; };
const draw=(d)=>{ ind.style.opacity=String(Math.min(d/26,1)); ind.style.transform='translateY('+(Math.min(d,72)-40)+'px)'; };
hide();
el.addEventListener('touchstart',(e)=>{
if(busy || e.touches.length!==1) return;
if(el.scrollTop>2){ pulling=false; return; } // only from the very top
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){ hide(); return; } // moving up → let the list scroll normally
// Claim the gesture IMMEDIATELY. Deferring preventDefault until the pull was already ~10px let
// iOS/Chrome start their own overscroll first, and our handler then never got the gesture (#6).
if(e.cancelable) e.preventDefault();
draw(dist*0.6);
},{passive:false});
const finish=async()=>{
if(!pulling||busy){ hide(); return; }
pulling=false;
if(dist*0.6>=TRIGGER){
busy=true; ind.classList.add('spin'); ind.style.opacity='1'; ind.style.transform='translateY(12px)';
try{ await onRefresh(); }catch(_){}
await new Promise(r=>setTimeout(r,400)); // let the spinner be seen even on a fast refresh
ind.classList.remove('spin'); busy=false;
}
hide(); dist=0;
};
el.addEventListener('touchend', finish);
el.addEventListener('touchcancel', ()=>{ if(!busy){ pulling=false; hide(); 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(_){} }
// ---- Older-history pagination: the thread loads the newest ~500; scrolling to the top fetches the
// previous page (messages older than the oldest one loaded) and prepends them, preserving scroll. ----
const PAGE=40; // load the latest 40 on open (fast render, no 500-node jank); loadOlder() pages in older history on scroll-up. MUST match the server thread limit.
let _hasMoreOlder=false, _loadingOlder=false, _olderCd=0;
function threadUrl(kind,id,before){ return (kind==='group'?'/api/messages/thread?group='+encodeURIComponent(id):'/api/messages/thread?with='+encodeURIComponent(id))+(before?('&before='+before):''); }
async function loadOlder(){
if(_loadingOlder || !_hasMoreOlder || !selected || !THREAD.length) return;
_loadingOlder=true;
const kind=selected.kind, id=selected.id, before=THREAD[0].created_at;
const box=document.getElementById('msgs');
const _esc=(s)=>(window.CSS&&CSS.escape)?CSS.escape(String(s)):String(s);
// Anchor on the CURRENT oldest-loaded message: after we prepend older ones (and as THEIR images load),
// keep this exact message at the same viewport position, so scrolling up never jumps. (The old
// scrollHeight math jumped because unloaded prepended images weren't counted, then shifted on load.)
const anchorId = THREAD[0] && THREAD[0].id;
const _boxTop = box ? box.getBoundingClientRect().top : 0;
const _aEl = (box&&anchorId) ? box.querySelector('.bubble[data-id="'+_esc(anchorId)+'"]') : null;
const _aTop = _aEl ? (_aEl.getBoundingClientRect().top - _boxTop) : 0;
const _restore=()=>{ if(!box||!anchorId) return; const el=box.querySelector('.bubble[data-id="'+_esc(anchorId)+'"]'); if(el){ const cur=el.getBoundingClientRect().top - box.getBoundingClientRect().top; box.scrollTop += (cur - _aTop); } };
let older=null; try{ const r=await fetch(threadUrl(kind,id,before)); if(r.ok) older=await r.json(); }catch(_){}
if(!selected||selected.kind!==kind||selected.id!==id){ _loadingOlder=false; return; }
if(Array.isArray(older) && older.length){
const have=new Set(THREAD.map(m=>m.id)); const add=older.filter(m=>!have.has(m.id));
if(add.length){ THREAD=add.concat(THREAD); THREAD_CACHE.set(kind+':'+id, THREAD.slice()); }
if(older.length<PAGE) _hasMoreOlder=false;
renderThread(true); // keep scroll (don't jump to bottom)
_restore(); // put the anchor message back at its exact viewport position
if(box){ const imgs=[...box.querySelectorAll('.bubble img')].filter(im=>!im.complete); imgs.forEach(im=>im.addEventListener('load', _restore, {once:true})); setTimeout(()=>imgs.forEach(im=>{ try{ im.removeEventListener('load', _restore); }catch(_){} }), 3000); } // re-anchor as prepended images load (they'd shift it down)
} else { _hasMoreOlder=false; }
_loadingOlder=false; _olderCd=Date.now()+500; // brief cooldown so it can't re-fire and fight the scroll
}
// Keep loading older pages until a target timestamp is in the loaded window (used by search jump-to).
async function ensureLoadedBack(at){ let guard=0; while(_hasMoreOlder && THREAD.length && THREAD[0].created_at > at && guard<60){ await loadOlder(); guard++; } }
// #9: in-chat search — highlight every match and jump between them with up/down (no filtering).
let _searchHits=[], _searchIdx=-1, _searchQ='', _searchDebT=null; // _searchHits: server hits [{id,at}] oldest-first
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(_){} }); }
}
function _highlightIn(root, ql){
const hits=[];
const walker=document.createTreeWalker(root, NodeFilter.SHOW_TEXT, { acceptNode(n){ if(!n.nodeValue || n.nodeValue.toLowerCase().indexOf(ql)===-1) return NodeFilter.FILTER_REJECT; const p=n.parentElement; if(!p || p.closest('.reply-btn,.react-btn,.del-btn,.t,.reacts,.react-chip,.seenby,.rcpt,.att-sz,button')) return NodeFilter.FILTER_REJECT; return NodeFilter.FILTER_ACCEPT; } });
const nodes=[]; while(walker.nextNode()) nodes.push(walker.currentNode);
nodes.forEach(n=>{ const txt=n.nodeValue, low=txt.toLowerCase(); let i, last=0; const frag=document.createDocumentFragment();
while((i=low.indexOf(ql,last))!==-1){ if(i>last) frag.appendChild(document.createTextNode(txt.slice(last,i))); const mk=document.createElement('mark'); mk.className='search-hit'; mk.textContent=txt.slice(i,i+ql.length); frag.appendChild(mk); hits.push(mk); last=i+ql.length; }
if(last>0){ if(last<txt.length) frag.appendChild(document.createTextNode(txt.slice(last))); n.parentNode.replaceChild(frag,n); } });
return hits;
}
// Full-thread search: ask the server for ALL matches (not just the loaded window), debounced. Then
// jump to any hit — paging older history in if the match is above what's currently loaded.
function runSearch(q){ clearTimeout(_searchDebT); _searchDebT=setTimeout(()=>doSearch(q), 220); }
async function doSearch(q){
clearSearchHighlights();
const cnt=document.getElementById('convoSearchCount');
q=(q||'').trim(); _searchQ=q; _searchHits=[]; _searchIdx=-1;
if(!q){ if(cnt) cnt.textContent=''; return; }
if(!selected) return;
const kind=selected.kind, id=selected.id;
const url=(kind==='group'?'/api/messages/search?group='+encodeURIComponent(id):'/api/messages/search?with='+encodeURIComponent(id))+'&q='+encodeURIComponent(q);
let hits=[]; try{ const r=await fetch(url); if(r.ok){ const j=await r.json(); hits=j.hits||[]; } }catch(_){}
if(!selected||selected.kind!==kind||selected.id!==id||_searchQ!==q) return; // stale (switched chat / kept typing)
_searchHits=hits;
if(!hits.length){ if(cnt) cnt.textContent='0/0'; return; }
gotoHit(hits.length-1); // jump to the most recent match first
}
function _bubbleEl(id){ const box=document.getElementById('msgs'); if(!box) return null; const esc=(window.CSS&&CSS.escape)?CSS.escape(id):id; return box.querySelector('.bubble[data-id="'+esc+'"]'); }
// #8: jump to a specific message (e.g. tapping a reply quote) — paging older history in if it's
// above the loaded window — then scroll to it and flash it.
async function jumpToMessage(id, at){
if(!id) return;
let el=_bubbleEl(id);
if(!el && at){ await ensureLoadedBack(at); el=_bubbleEl(id); }
if(!el) return;
try{ el.scrollIntoView({block:'center'}); }catch(_){}
el.classList.add('search-flash'); setTimeout(()=>{ try{ el.classList.remove('search-flash'); }catch(_){} }, 1400);
}
// ---- #1 Forward: multi-select messages → forward to existing conversations only ----
let _selMode=false; const _selIds=new Set();
function enterSelect(id){
_selMode=true; _selIds.clear(); if(id) _selIds.add(id);
document.body.classList.add('sel-mode');
const box=document.getElementById('msgs'); if(box) box.querySelectorAll('.bubble').forEach(b=>b.classList.toggle('selected', _selIds.has(b.dataset.id)));
renderSelBar();
}
function toggleSel(bubble){
const id=bubble&&bubble.dataset.id; if(!id) return;
if(_selIds.has(id)){ _selIds.delete(id); bubble.classList.remove('selected'); } else { _selIds.add(id); bubble.classList.add('selected'); }
if(!_selIds.size){ exitSelect(); return; } renderSelBar();
}
function exitSelect(){
_selMode=false; _selIds.clear(); document.body.classList.remove('sel-mode');
const box=document.getElementById('msgs'); if(box) box.querySelectorAll('.bubble.selected').forEach(b=>b.classList.remove('selected'));
const bar=document.getElementById('selBar'); if(bar) bar.remove();
}
function renderSelBar(){
let bar=document.getElementById('selBar');
if(!bar){ bar=document.createElement('div'); bar.id='selBar'; bar.className='sel-bar'; document.body.appendChild(bar); }
bar.innerHTML='<button class="sb-x" title="Cancel">'+ic('x',18)+'</button><span class="sb-n">'+_selIds.size+' selected</span><button class="sb-fwd">'+ic('arrowRight',16)+' Forward</button>';
bar.querySelector('.sb-x').onclick=exitSelect;
bar.querySelector('.sb-fwd').onclick=()=>{ if(_selIds.size) openForwardPicker([..._selIds]); };
}
function fwdRowHTML(r){
const isG=r.kind==='group';
const av='<span class="mini-av" style="background:'+avColor(r.name)+'">'+(isG?ic('users',15):(r.avatar?'<img src="'+pEsc(r.avatar)+'" onerror="this.remove()">':pEsc(initials(r.name))))+'</span>';
return '<div class="fwd-item" data-k="'+r.kind+'" data-id="'+pEsc(r.id)+'" data-name="'+pEsc((r.name||'').toLowerCase())+'">'+av+'<span class="fi-name">'+pEsc(r.name)+'</span><span class="fi-check">'+ic('check',15)+'</span></div>';
}
function openForwardPicker(ids){
if(document.getElementById('fwdModal')) return;
const rows=ROWS.filter(r=>!r.self); // existing conversations only (per spec)
const ov=document.createElement('div'); ov.className='modal-ov'; ov.id='fwdModal';
ov.innerHTML='<div class="modal"><h3>Forward to…</h3>'
+'<input id="fwdSearch" placeholder="Search chats" autocomplete="off" style="width:100%;padding:.55rem .7rem;border:2px solid var(--line);border-radius:10px;background:#fbfcfe;margin-bottom:.6rem;">'
+'<div class="fwd-list" id="fwdList">'+(rows.length?rows.map(fwdRowHTML).join(''):'<div class="gi-noresult">No conversations yet</div>')+'</div>'
+'<div class="bzc-actions" style="margin-top:.8rem"><button type="button" class="bzc-cancel" id="fwdCancel">Cancel</button><button type="button" class="bzc-ok" id="fwdGo" disabled>Forward (0)</button></div></div>';
document.body.appendChild(ov);
const sel=new Set(); const list=ov.querySelector('#fwdList'), go=ov.querySelector('#fwdGo');
const upd=()=>{ go.disabled=!sel.size; go.textContent='Forward ('+sel.size+')'; };
list.addEventListener('click',e=>{ const it=e.target.closest('.fwd-item'); if(!it) return; const key=it.dataset.k+':'+it.dataset.id; if(sel.has(key)){ sel.delete(key); it.classList.remove('sel'); } else { sel.add(key); it.classList.add('sel'); } upd(); });
ov.querySelector('#fwdSearch').oninput=e=>{ const q=e.target.value.trim().toLowerCase(); list.querySelectorAll('.fwd-item').forEach(it=>{ it.style.display=(!q||(it.dataset.name||'').includes(q))?'':'none'; }); };
ov.querySelector('#fwdCancel').onclick=()=>ov.remove();
ov.addEventListener('mousedown',e=>{ if(e.target===ov) ov.remove(); });
go.onclick=async()=>{
const targets=[...sel].map(k=>{ const i=k.indexOf(':'); return { kind:k.slice(0,i), id:k.slice(i+1) }; });
go.disabled=true; go.textContent='Forwarding…';
try{ await postJSON('/api/messages/forward',{ messageIds:ids, targets }); ov.remove(); exitSelect(); toast('Forwarded to '+targets.length+(targets.length===1?' chat':' chats')); }
catch(e){ toast(e.message||'Could not forward'); go.disabled=false; upd(); }
};
}
async function gotoHit(i){
const hits=_searchHits; if(!hits.length) return;
_searchIdx=((i%hits.length)+hits.length)%hits.length;
const hit=hits[_searchIdx];
const cnt=document.getElementById('convoSearchCount'); if(cnt) cnt.textContent=(_searchIdx+1)+'/'+hits.length;
let el=_bubbleEl(hit.id);
if(!el){ await ensureLoadedBack(hit.at); if(_searchIdx!==((i%hits.length)+hits.length)%hits.length) return; el=_bubbleEl(hit.id); } // older than the loaded window → page back to it
clearSearchHighlights();
if(!el) return;
_highlightIn(el, _searchQ.toLowerCase());
const mk=el.querySelector('mark.search-hit');
if(mk){ mk.classList.add('search-current'); try{ mk.scrollIntoView({block:'center'}); }catch(_){} } else { try{ el.scrollIntoView({block:'center'}); }catch(_){} }
el.classList.add('search-flash'); setTimeout(()=>{ try{ el.classList.remove('search-flash'); }catch(_){} }, 1400);
}
function closeSearch(){ clearSearchHighlights(); const h=document.getElementById('convoSearchHead'); if(h) h.style.display='none'; const i=document.getElementById('convoSearchInput'); if(i) i.value=''; }
// #10: "Shared" — Media (images) + Files tabs for a DM or group, opened by tapping the chat name.
// Compact "Media, links & docs" entry (shown in the conversation-info window). Clicking it opens
// the separate full Media/Docs/Links view (openMediaView).
function mediaRowHTML(){ return '<button class="gi-media-row" id="giMediaRow"><span class="gmr-l">Media, links &amp; docs</span><span class="gmr-r" id="giMediaCount"></span></button><div class="gi-media-strip" id="giMediaStrip"></div>'; }
async function wireMediaEntry(root, kind, id, name){
let data={media:[],docs:[],links:[]}; try{ const url=kind==='group'?('/api/messages/media?group='+encodeURIComponent(id)):('/api/messages/media?with='+encodeURIComponent(id)); const r=await fetch(url).then(x=>x.json()); if(r&&r.media) data=r; }catch(_){}
const total=data.media.length+data.docs.length+data.links.length;
const cnt=root.querySelector('#giMediaCount'); if(cnt) cnt.textContent=(total||0)+' ';
const strip=root.querySelector('#giMediaStrip'); if(strip){ strip.innerHTML=data.media.filter(x=>x.isImage).slice(0,4).map(x=>'<img class="gms-thumb att-img" src="/files/'+pEsc(x.id)+'" data-img="/files/'+pEsc(x.id)+'" loading="lazy">').join(''); strip.addEventListener('click',e=>{ const im=e.target.closest('.att-img'); if(im&&im.dataset.img) openLightbox(im.dataset.img); }); }
const row=root.querySelector('#giMediaRow'); if(row) row.onclick=()=>openMediaView(kind,id,name,data);
}
function stripExt(n){ n=String(n||''); const i=n.lastIndexOf('.'); return (i>0?n.slice(0,i):n)||'File'; }
function fmtDur(s){ s=Math.round(s); if(!isFinite(s)||s<0) return ''; const h=Math.floor(s/3600),m=Math.floor((s%3600)/60),ss=s%60,p=(n)=>String(n).padStart(2,'0'); return h>0?(h+':'+p(m)+':'+p(ss)):(m+':'+p(ss)); }
// One cell in the shared-Media grid: image → thumbnail; audio/video → a tile with a centred
// download button plus a headphone/▶ glyph and (lazily-loaded) duration, matching the design.
function mediaCellHTML(x){
const id=pEsc(x.id), nm=pEsc(x.name||'');
if(x.isImage) return '<figure class="sh-cell"><a class="sh-av img att-img" data-img="/files/'+id+'" href="/files/'+id+'"><img class="sh-poster" src="/files/'+id+'" loading="lazy" alt=""></a></figure>';
const isVid=!!x.isVideo, kind=isVid?'vid':'aud';
return '<figure class="sh-cell">'
+'<figcaption class="sh-name" title="'+nm+'">'+pEsc(stripExt(x.name))+'</figcaption>'
+'<a class="sh-av '+kind+'" href="/files/'+id+'" download="'+nm+'" title="Download '+nm+'">'
+(isVid?'<video class="sh-poster" src="/files/'+id+'#t=0.1" preload="metadata" muted playsinline></video>':'')
+'<span class="sh-dl">'+ic('download',20)+'</span>'
+'<span class="sh-dur"><span class="sh-di">'+ic(isVid?'play':'headphones',12)+'</span><i data-dsrc="/files/'+id+'" data-dkind="'+(isVid?'video':'audio')+'">…</i></span>'
+'</a></figure>';
}
// Server doesn't store media duration, so read each audio/video tile's metadata client-side.
function loadMediaDurations(root){ if(!root) return; root.querySelectorAll('i[data-dsrc]').forEach(el=>{ if(el.dataset.done) return; el.dataset.done='1'; try{ const m=document.createElement(el.dataset.dkind==='video'?'video':'audio'); m.preload='metadata'; m.muted=true; m.src=el.dataset.dsrc; m.addEventListener('loadedmetadata',()=>{ el.textContent=fmtDur(m.duration)||''; }); m.addEventListener('error',()=>{ el.textContent=''; }); }catch(_){ el.textContent=''; } }); }
// Separate full view: Media / Docs / Links tabs.
async function openMediaView(kind,id,name,data){
if(document.getElementById('mediaView')) return;
if(!data){ try{ const url=kind==='group'?('/api/messages/media?group='+encodeURIComponent(id)):('/api/messages/media?with='+encodeURIComponent(id)); data=await fetch(url).then(x=>x.json()); }catch(_){} }
data=data&&data.media?data:{media:[],docs:[],links:[]};
const ov=document.createElement('div'); ov.className='modal-ov'; ov.id='mediaView';
ov.innerHTML='<div class="modal gi"><div class="gi-head" style="margin-bottom:.4rem"><button class="iconbtn" id="mvBack" title="Back">'+ic('arrowLeft',18)+'</button><div class="gi-name"><div class="gi-title">Media, links &amp; docs</div><div class="gi-sub">'+pEsc(name||'')+'</div></div></div>'
+'<div class="mp-tabs mv-tabs"><button class="mp-tab on" data-t="media">Media</button><button class="mp-tab" data-t="docs">Docs</button><button class="mp-tab" data-t="links">Links</button></div>'
+'<div class="mv-body" id="mvBody"></div></div>';
document.body.appendChild(ov);
ov.onclick=e=>{ if(e.target===ov) ov.remove(); };
ov.querySelector('#mvBack').onclick=()=>ov.remove();
let tab='media';
const render=()=>{ const b=ov.querySelector('#mvBody'); if(!b) return;
if(tab==='media') b.innerHTML=data.media.length?'<div class="sh-grid">'+data.media.map(mediaCellHTML).join('')+'</div>':'<div class="gi-noresult">No media shared yet</div>';
else if(tab==='docs') b.innerHTML=data.docs.length?data.docs.map(x=>'<a class="sh-file" href="/files/'+pEsc(x.id)+'" download="'+pEsc(x.name||'file')+'">'+ic('fileText',16)+'<span class="sh-fn">'+pEsc(x.name||'file')+'</span><span class="sh-sz">'+fmtSize(x.size)+'</span></a>').join(''):'<div class="gi-noresult">No documents shared yet</div>';
else b.innerHTML=data.links.length?data.links.map(x=>'<a class="sh-file" href="'+pEsc(x.url)+'" target="_blank" rel="noopener">'+ic('link',16)+'<span class="sh-fn">'+pEsc(x.url)+'</span></a>').join(''):'<div class="gi-noresult">No links shared yet</div>';
loadMediaDurations(b);
};
ov.querySelectorAll('.mv-tabs .mp-tab').forEach(btn=>btn.onclick=()=>{ tab=btn.dataset.t; ov.querySelectorAll('.mv-tabs .mp-tab').forEach(x=>x.classList.toggle('on',x===btn)); render(); });
ov.querySelector('#mvBody').addEventListener('click',e=>{ const im=e.target.closest('.att-img'); if(im&&im.dataset.img){ e.preventDefault(); openLightbox(im.dataset.img); } });
render();
}
// DM contact-info window (no members): header + favourite + the Media/links/docs entry.
async function openSharedItems(kind,id,name){
if(document.getElementById('sharedModal')) return;
const r0=rowFor(kind,id); const fav=!!(r0&&r0.favorite); const online=!!(r0&&r0.online);
const ov=document.createElement('div'); ov.className='modal-ov'; ov.id='sharedModal';
ov.innerHTML='<div class="modal gi"><div class="gi-head" style="margin-bottom:.6rem"><button class="gi-photo" id="shPhoto" title="View photo"><span class="avatar" style="width:46px;height:46px;flex:0 0 46px;background:'+avColor(name)+'">'+pEsc(initials(name||'?'))+((r0&&r0.avatar)?'<img class="av-img" src="'+pEsc(r0.avatar)+'" alt="" onerror="this.remove()">':'')+'</span></button><div class="gi-name"><div class="gi-title-row"><span class="gi-title">'+pEsc(name||'')+'</span><button class="fav-star'+(fav?' on':'')+'" id="shFav" title="'+(fav?'Remove from favourites':'Add to favourites')+'">'+ic('star',16)+'</button></div><div class="gi-sub"><span class="st-dot '+statusCls(r0)+'"></span>'+statusLabel(r0)+'</div></div><button class="iconbtn" id="shClose">'+ic('x',18)+'</button></div>'
+mediaRowHTML()+'</div>';
document.body.appendChild(ov);
ov.onclick=e=>{ if(e.target===ov) ov.remove(); };
ov.querySelector('#shClose').onclick=()=>ov.remove();
const sp=ov.querySelector('#shPhoto'); if(sp) sp.onclick=()=>{ const r=rowFor(kind,id)||r0; if(r&&r.avatar) openLightbox(r.avatar); else toast('No profile photo'); }; // #5: click the DP in the info view to preview it
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();
const listEl=document.getElementById('chatlist');
function searchVal(){ const s=document.getElementById('chatSearch'); return s?s.value:''; }
function rowFor(kind,id){ return ROWS.find(r=>r.kind===kind && r.id===id); }
function currentName(){ const it=selected&&rowFor(selected.kind,selected.id); return it?it.name:''; }
function fmtTime(ts){
if(!ts) return '';
const d=new Date(ts), n=new Date();
if(d.toDateString()===n.toDateString()) return d.toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'});
const y=new Date(n); y.setDate(n.getDate()-1);
if(d.toDateString()===y.toDateString()) return 'Yesterday';
return d.toLocaleDateString([], {month:'short',day:'numeric'});
}
// 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':lastSeenLabel(it); }
// #2: when someone is offline, say exactly WHEN they were last around — WhatsApp style, i.e. an actual
// time/date ("last seen today at 1:36 PM"), not a vague "10 minutes ago".
function lastSeenLabel(it){
const ts=it&&it.lastSeen; if(!ts) return 'Offline';
const t=new Date(ts), now=new Date();
const clock=t.toLocaleTimeString([],{hour:'numeric',minute:'2-digit'});
const dayOf=(d)=>new Date(d.getFullYear(),d.getMonth(),d.getDate()).getTime();
const days=Math.round((dayOf(now)-dayOf(t))/86400000);
if(days<=0) return 'last seen today at '+clock;
if(days===1) return 'last seen yesterday at '+clock;
if(days<7) return 'last seen '+t.toLocaleDateString([],{weekday:'long'})+' at '+clock;
return 'last seen '+t.toLocaleDateString([],{day:'2-digit',month:'2-digit',year:'numeric'})+' at '+clock;
}
function avatarHTML(it, big){
const isG=it.kind==='group';
const sz=big?'width:38px;height:38px;flex:0 0 38px;':'';
const corner=it.self?''
:isG?(it.members?'<span class="mcount">'+(it.members>99?'99+':it.members)+'</span>':'')
:'<span class="dot '+statusCls(it)+'"></span>';
const inner=isG?ic('users',big?20:18):pEsc(initials(it.name));
// Photo overlay (BizGaze profile picture, or an uploaded group image). If it fails to
// load it removes itself, revealing the initials/glyph underneath.
const img=it.avatar?'<img class="av-img" src="'+pEsc(it.avatar)+'" alt="" onerror="this.remove()">':'';
return '<div class="avatar'+(isG?' grp':'')+'" style="'+sz+'background:'+avColor(it.name)+'">'+inner+img+corner+'</div>';
}
// The sidebar row's preview line (draft / ongoing call / last message w/ tick). Factored out so a
// live typing indicator can temporarily replace it and then restore the real preview on stop.
function rowPreviewHTML(it){
const active=selected&&selected.kind===it.kind&&selected.id===it.id;
const isG=it.kind==='group';
const draft=getDraft(it.kind,it.id);
if(draft && !active){ return '<span class="draft-lbl">Draft:</span> '+pEsc(draft); } // unsent draft (#1)
if(it.callActive){ return '<span class="call-on">'+ic('phone',12)+' Ongoing call</span>'; }
const prevBody=isGifUrl(it.last_body)?'🎞️ GIF':it.last_body; // #5: don't show the raw GIF URL
if(it.last_from_me && it.last_status && it.last_body){ // my last message → tick (#3)
return '<span class="prev-tick '+it.last_status+'">'+ic(it.last_status==='sent'?'check':'checkCheck',13)+'</span> '+pEsc(prevBody); }
return pEsc(prevBody?((it.last_from_me?'You: ':'')+prevBody):(isG?((it.members||0)+' members'):'No messages yet'));
}
function rowHTML(it){
const active=selected&&selected.kind===it.kind&&selected.id===it.id;
const cls=['chat-row']; if(active)cls.push('active'); if(it.unread>0)cls.push('unread'); if(it.self)cls.push('self');
const tl=typingLabel(it.kind,it.id);
const inner=tl?('<span class="typing-prev">'+pEsc(tl)+'</span>'):rowPreviewHTML(it); // live "typing…" wins over the preview
return '<div class="'+cls.join(' ')+'" data-kind="'+it.kind+'" data-id="'+pEsc(it.id)+'">'
+ avatarHTML(it,false)
+ '<div class="chat-main"><div class="chat-top"><span class="chat-name">'+pEsc(it.name)+'</span><span class="chat-time">'+pEsc(fmtTime(it.last_at))+'</span></div>'
+ '<div class="chat-bottom"><span class="chat-prev">'+inner+'</span>'+(it.unread>0?'<span class="badge">'+(it.unread>99?'99+':it.unread)+'</span>':'')+'</div></div></div>';
}
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;
if(!q){ // "You" pinned at top, then favourites, then the rest
const self=rows.find(r=>r.self), others=rows.filter(r=>!r.self);
const favs=others.filter(r=>r.favorite), rest=others.filter(r=>!r.favorite);
// "You" (note to self) sits in its own pinned slot, set apart from the conversation list.
let h = self ? (rowHTML(self)+(others.length?'<div class="side-div"></div>':'')) : '';
if(favs.length){ h+='<div class="side-sec">'+ic('star',12)+' Favourites</div>'+favs.map(rowHTML).join(''); if(rest.length) h+='<div class="side-sec">All chats</div>'; }
h+=rest.map(rowHTML).join('');
html = (self||others.length) ? h : '<div class="no-results">No conversations yet.</div>';
} else {
html = rows.length ? rows.map(rowHTML).join('') : '<div class="no-results">No chats match “'+pEsc(filter)+'”.</div>';
}
if(q.length>=1){
// Team contacts you haven't messaged yet → start a new DM.
const haveDm=new Set(ROWS.filter(r=>r.kind==='dm').map(r=>r.id));
const cmatch=CONTACTS.filter(c=>!haveDm.has(c.id) && ((c.name||'').toLowerCase().includes(q)||(c.email||'').toLowerCase().includes(q)));
if(cmatch.length) html+='<div class="side-sec">Start a chat</div>'+cmatch.map(c=>'<div class="contact-row" data-id="'+pEsc(c.id)+'"><span class="mini-av" style="background:'+avColor(c.name)+'">'+pEsc(initials(c.name))+'</span><span class="cr-name">'+pEsc(c.name)+'</span></div>').join('');
html+='<div id="dirResults"></div>'; // BizGaze directory (filled async)
}
listEl.innerHTML=html;
listEl.querySelectorAll('.chat-row').forEach(row=>{ row.onclick=()=>selectChat(row.dataset.kind, row.dataset.id); });
listEl.querySelectorAll('.contact-row').forEach(row=>{ row.onclick=()=>selectChat('dm', row.dataset.id); });
if(q.length>=2) queryDirectory(filter.trim());
}
let _dirT=null, _dirSeq=0;
// Search the wider BizGaze directory (cross-tenant) — proxied server-side. Debounced.
function queryDirectory(q){
clearTimeout(_dirT); const seq=++_dirSeq;
_dirT=setTimeout(async()=>{
let list=[]; try{ list=await fetch('/api/directory/search?q='+encodeURIComponent(q)).then(r=>r.json()); }catch(_){ }
if(seq!==_dirSeq) return; // a newer search superseded this one
const box=document.getElementById('dirResults'); if(!box) return;
const haveEmail=new Set(CONTACTS.map(c=>(c.email||'').toLowerCase()).filter(Boolean));
const all=Array.isArray(list)?list:[];
// Only people actually ON Connect can be messaged — show just those (deduped against team contacts).
// Everyone else (on BizGaze but not signed into Connect) is NOT listed as addable; instead we show a
// clear "not on Connect" / "not found" state so the user isn't confused by un-chattable rows.
const onc=all.filter(p=>p.onConnect && p.connectId && !(p.email&&haveEmail.has(p.email.toLowerCase())));
const localHit=box.parentElement && box.parentElement.querySelector('.chat-row, .contact-row');
if(onc.length){
box.innerHTML='<div class="side-sec">On Connect</div>'+onc.map((p,i)=>{ const sub=[p.org,p.phone].filter(Boolean).join(' · ');
return '<div class="dir-row" data-i="'+i+'"><span class="mini-av" style="background:'+avColor(p.name||'?')+'">'+pEsc(initials(p.name||'?'))+'</span><span class="dr-main"><span class="cr-name">'+pEsc(p.name||p.email||'Unknown')+'</span>'+(sub?'<span class="dr-sub">'+pEsc(sub)+'</span>':'')+'</span></div>'; }).join('');
box.querySelectorAll('.dir-row').forEach(row=>{ const p=onc[+row.dataset.i]; row.onclick=()=>selectChat('dm', p.connectId); });
} else if(!localHit){
// Nothing on Connect matched. Distinguish "exists on BizGaze but not on Connect" from "not found".
box.innerHTML = all.length
? '<div class="dir-empty">'+ic('info',16)+'<div><b>Not on Connect</b><span>Matching people are on BizGaze but havent joined Connect yet, so they cant be messaged.</span></div></div>'
: '<div class="dir-empty">'+ic('search',16)+'<div><b>Contact not found</b><span>No Connect user matches “'+pEsc(q)+'”.</span></div></div>';
} else box.innerHTML='';
}, 280);
}
function updateRailUnread(){
let chats=0; ROWS.forEach(it=>{ if((it.unread||0)>0) chats++; }); // number of chats with unread, not total messages
const d=document.getElementById('railUnread');
if(chats>0){ d.textContent=chats>99?'99+':chats; d.style.display='grid'; } else d.style.display='none';
setDesktopBadge(chats); // mirror the count onto the desktop taskbar icon
}
// Desktop taskbar icon badge: draw a small red count badge and hand it to the Electron shell
// (which sets it as a taskbar overlay). No-op in a browser or on mobile.
function setDesktopBadge(count){
try{
if(!(window.bizConnectNative && window.bizConnectNative.setUnread)) return;
let dataUrl=null;
if(count>0){
// Teams-style overlay: a solid red rounded-square (squircle) with a bold white count and a thin
// white outline so it separates cleanly from the app icon on the taskbar. Drawn at 2× for crispness.
const s=64, c=document.createElement('canvas'); c.width=s; c.height=s; const g=c.getContext('2d');
const label=count>99?'99+':String(count);
const pad=3, rad=17, x=pad, y=pad, w=s-pad*2, h=s-pad*2;
const rrect=(rx,ry,rw,rh,r)=>{ g.beginPath(); g.moveTo(rx+r,ry); g.arcTo(rx+rw,ry,rx+rw,ry+rh,r); g.arcTo(rx+rw,ry+rh,rx,ry+rh,r); g.arcTo(rx,ry+rh,rx,ry,r); g.arcTo(rx,ry,rx+rw,ry,r); g.closePath(); };
g.save(); g.shadowColor='rgba(0,0,0,.28)'; g.shadowBlur=4; g.shadowOffsetY=1.5;
g.fillStyle='#e6394f'; rrect(x,y,w,h,rad); g.fill(); g.restore(); // solid notification red
g.lineWidth=3; g.strokeStyle='#fff'; rrect(x,y,w,h,rad); g.stroke(); // crisp white outline
g.fillStyle='#fff'; g.textAlign='center'; g.textBaseline='middle';
g.font='800 '+(label.length>2?26:(label.length>1?33:39))+'px system-ui,Segoe UI,sans-serif';
g.fillText(label, s/2, s/2+2);
dataUrl=c.toDataURL('image/png');
}
window.bizConnectNative.setUnread(count, dataUrl);
}catch(_){}
}
async function loadSidebar(){
let convos=[], contacts=[];
try{ [convos, contacts]=await Promise.all([ fetch('/api/messages/conversations').then(r=>r.json()), fetch('/api/messages/contacts').then(r=>r.json()) ]); }catch(_){}
CONTACTS=(Array.isArray(contacts)?contacts:[]).filter(c=>c.id!==(ME&&ME.id)); // self isn't a normal contact (#4)
const items=Array.isArray(convos)?convos.slice():[];
const dmIds=new Set(items.filter(i=>i.kind==='dm').map(i=>i.id));
// A contact you haven't messaged YET still needs their profile photo/status: this row was being built
// with only name+online, silently dropping `avatar` — so the same person showed their DP in a group
// (which reads /api/groups/members) but fell back to initials in the 1:1. Carry the whole contact.
for(const c of CONTACTS){ if(!dmIds.has(c.id)) items.push({ kind:'dm', id:c.id, name:c.name, email:c.email||'', avatar:c.avatar||null, online:!!c.online, status:c.status||'active', lastSeen:c.lastSeen||null, last_body:'', last_at:0, last_from_me:false, unread:0 }); }
const onlineById={}; CONTACTS.forEach(c=>onlineById[c.id]=!!c.online);
items.forEach(it=>{ if(it.kind==='dm') it.online=!!onlineById[it.id]; });
// Always-available "You" chat (note to self), pinned to the top (#4).
const selfRow=items.find(i=>i.kind==='dm' && i.id===(ME&&ME.id));
if(selfRow){ selfRow.self=true; selfRow.name='You'; selfRow.avatar=ME.avatarUrl||selfRow.avatar||null; }
else if(ME&&ME.id){ items.push({ kind:'dm', id:ME.id, name:'You', self:true, avatar:ME.avatarUrl||null, online:false, last_body:'', last_at:0, last_from_me:false, unread:0 }); }
ROWS=items;
renderChats(searchVal());
updateRailUnread();
prewarmNotifAvatars();
}
// Desktop only: hand the shell every contact/group DP URL so it caches them up front — the first
// notification (chat OR call) from anyone then shows their photo instantly, with no per-toast wait.
let _avatarsWarmed=false;
function prewarmNotifAvatars(){
try{
const n=window.bizConnectNative; if(!n||!n.precacheAvatars||_avatarsWarmed) return;
const urls=[...new Set((ROWS||[]).map(r=>r.avatar).filter(u=>u && /^https?:\/\//i.test(u)))];
if(urls.length){ _avatarsWarmed=true; n.precacheAvatars(urls); }
}catch(_){}
}
// ----- conversation view -----
function welcomeHTML(){
return '<div class="welcome">'
+ '<div class="wave">👋</div>'
+ '<h1>Hi, '+pEsc(firstName(ME.name||ME.email))+'! Welcome to Biz Connect</h1>'
+ '<p>Pick a conversation on the left to start chatting, or jump straight into a session from the sidebar.</p>'
+ '<div class="wcards">'
+ '<div class="wcard" data-go="share"><div class="wi"><svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg></div><h3>Share Screen</h3><p>Show your screen with a 6-digit code</p></div>'
+ '<div class="wcard" data-go="connect"><div class="wi"><svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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><h3>Connect Screen</h3><p>Enter a customer\'s code to help</p></div>'
+ '<div class="wcard" data-go="meeting"><div class="wi"><svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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><h3>Meeting</h3><p>Multi-party video — coming soon</p></div>'
+ '</div></div>';
}
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); }
// The conversation header's default subtitle (used to restore it after a typing indicator clears).
function subtitleFor(it){ if(!it) return ''; return it.self?'Message yourself':(it.kind==='group'?((it.members||0)+' members'):dmSubLabel(it)); }
function convoShellHTML(it){
const isG=it.kind==='group';
const sub=subtitleFor(it);
return '<div class="convo">'
+ '<div class="convo-head">'
+ '<button class="convo-back" id="convoBack" title="Back (Esc)" aria-label="Back">'+ic('arrowLeft',18)+'</button>'
+ avatarHTML(it,true)
+ '<div class="convo-titlewrap gi-open" id="convoTitle" title="Shared media &amp; files"><div class="nm">'+pEsc(it.name)+'</div><div class="st">'+pEsc(sub)+'</div></div>'
+ '<button class="convo-info" id="convoSearch" title="Search messages">'+ic('search',18)+'</button>'
+ '<button class="convo-call'+(it.callActive?' joinable':'')+'" id="convoCall" title="'+(it.callActive?'Join call':'Start call')+'">'+ic(it.callActive?'video':'phone',18)+(it.callActive?'<span>Join</span>':'')+'</button>'
+ (isG?'<button class="convo-info" id="convoInfo" title="Group info">'+ic('info',18)+'</button>':'')
+ '<div class="convo-search-head" id="convoSearchHead" style="display:none">'
+ '<button class="convo-back" id="convoSearchClose" title="Close search">'+ic('arrowLeft',18)+'</button>'
+ '<input id="convoSearchInput" placeholder="Search this chat…" autocomplete="off">'
+ '<span class="csh-count" id="convoSearchCount"></span>'
+ '<button class="csh-nav" id="convoSearchPrev" title="Older match">'+ic('chevronUp',18)+'</button>'
+ '<button class="csh-nav" id="convoSearchNext" title="Newer match">'+ic('chevronDown',18)+'</button>'
+ '</div>'
+ '</div>'
+ '<div class="convo-msgs" id="msgs"></div>'
+ '<div class="float-date" id="floatDate" style="display:none"></div>'
+ '<button class="jump-latest" id="jumpLatest" title="Jump to latest" style="display:none">'+ic('chevronDown',20)+'</button>'
+ '<div class="reply-bar" id="replyBar" style="display:none"></div>'
+ '<div class="typing-row" id="typingRow"></div>'
+ '<form class="composer" id="composer">'
+ '<div class="composer-box">'
+ '<div class="attach-preview" id="attachBar" style="display:none"></div>'
+ '<div class="fmt-bar" id="fmtBar" style="display:none">'
+ '<button type="button" data-fmt="bold" title="Bold (**)">'+ic('bold',16)+'</button>'
+ '<button type="button" data-fmt="italic" title="Italic (*)">'+ic('italic',16)+'</button>'
+ '<button type="button" data-fmt="strike" title="Strikethrough (~~)">'+ic('strikethrough',16)+'</button>'
+ '<button type="button" data-fmt="code" title="Code (`)">'+ic('code',16)+'</button>'
+ '<span class="fmt-sep"></span>'
+ '<button type="button" data-fmt="ul" title="Bulleted list">'+ic('list',16)+'</button>'
+ '<button type="button" data-fmt="ol" title="Numbered list">'+ic('listOrdered',16)+'</button>'
+ '</div>'
+ '<div class="composer-row">'
+ '<button type="button" class="ic-btn" id="attachBtn" title="Attach a file">'+ic('paperclip',20)+'</button>'
+ '<button type="button" class="ic-btn" id="fmtBtn" title="Formatting">'+ic('type',20)+'</button>'
+ '<textarea id="msgInput" placeholder="Type a message…" autocomplete="off" spellcheck="true" autocapitalize="sentences" maxlength="4000" rows="1"></textarea>'
+ (isG?'<button type="button" class="ic-btn" id="pollBtn" title="Create a poll">'+ic('barChart',20)+'</button>':'')
+ '<button type="button" class="ic-btn" id="emojiBtn" title="Emoji">'+ic('smile',20)+'</button>'
+ '<button type="submit" class="sendbtn" title="Send" aria-label="Send">'+ic('send',18)+'</button>'
+ '</div>'
+ '</div>'
+ '<input type="file" id="fileInput" multiple style="display:none">'
+ '<input type="file" id="fileInputImg" accept="image/*" multiple style="display:none">'
+ '<input type="file" id="fileInputCam" accept="image/*" capture="environment" style="display:none">'
+ '</form>'
+ '<div class="emoji-pop" id="emojiPop" style="display:none"></div>'
+ '<div class="mention-pop" id="mentionPop" style="display:none"></div>'
+ '<div class="attach-pop" id="attachPop" style="display:none">'
+ '<button type="button" class="attach-opt" data-src="photo">'+ic('image',18)+'<span>Photos</span></button>'
+ '<button type="button" class="attach-opt" data-src="camera">'+ic('camera',18)+'<span>Camera</span></button>'
+ '<button type="button" class="attach-opt" data-src="file">'+ic('paperclip',18)+'<span>Document</span></button>'
+ '</div>'
+ '</div>';
}
// Small round avatar (photo if the member has one, else colored initials) for a group message sender.
function senderAvatar(id, name){ const mem=convoMembers.find(x=>x.id===id); const av=mem&&mem.avatar; return '<span class="snd-av" style="background:'+avColor(name||'?')+'">'+(av?'<img src="'+pEsc(av)+'" alt="" onerror="this.remove()">':'')+pEsc(initials(name||'?'))+'</span>'; }
function bubbleHTML(m){
if(m.evt==='call-start') return '<div class="sys-msg">📞 '+(m.from===ME.id?'You':pEsc(m.byName||'Someone'))+' started a call</div>';
// Unanswered 1:1: the caller (message sender) sees "Call not answered"; the callee sees "Missed call".
if(m.evt==='call-end' && /Missed call|not answered/i.test(m.body||'')) return '<div class="sys-msg">📞 '+(m.from===ME.id?'Call not answered':'Missed call')+'</div>';
if(m.system||m.from==='__system__') return '<div class="sys-msg">'+pEsc(m.body)+'</div>';
const mine=m.from===ME.id;
if(m.deleted) return '<div class="bubble '+(mine?'mine':'them')+' deleted" data-id="'+pEsc(m.id)+'"><span class="del-msg">'+ic('trash',12)+' This message was deleted</span><span class="t">'+pEsc(fmtClock(m.created_at))+'</span></div>';
// #1: the sender chip is clickable → mini profile card (with a Message button that opens their DM).
const sender=(convoIsGroup && !mine && m.fromName)?'<div class="sender profile-open" data-uid="'+pEsc(m.from)+'" title="View profile">'+senderAvatar(m.from, m.fromName)+'<span>'+pEsc(m.fromName)+'</span></div>':'';
let quote='';
if(m.reply){ const t=replyTint(m.reply.from||m.reply.fromName); const rb=isGifUrl(m.reply.body)?'🎞️ GIF':m.reply.body; quote='<div class="quote" data-jid="'+pEsc(m.reply.id||'')+'" data-jat="'+(m.reply.at||0)+'" title="Go to message" style="background:'+t[0]+';border-left-color:'+t[1]+'"><b style="color:'+t[1]+'">'+pEsc(m.reply.fromName||'')+'</b>: '+pEsc(rb)+'</div>'; }
const reacts=(m.reactions&&m.reactions.length)?'<div class="reacts">'+m.reactions.map(r=>'<button class="react-chip'+(r.mine?' mine':'')+'" data-id="'+pEsc(m.id)+'" data-emoji="'+pEsc(r.emoji)+'" title="'+pEsc((r.who||[]).join(', '))+'">'+pEsc(r.emoji)+' '+r.count+'</button>').join('')+'</div>':'';
const A = m.attachment;
const att = A ? (
A.isImage ? '<img class="att-img" src="/files/'+pEsc(A.id)+'" data-img="/files/'+pEsc(A.id)+'" alt="'+pEsc(A.name)+'" title="Click to view">'
: A.isVideo ? '<video class="att-vid" src="/files/'+pEsc(A.id)+'#t=0.1" poster="/thumbs/'+pEsc(A.id)+'" controls playsinline preload="metadata" title="'+pEsc(A.name)+'"></video>'
: '<a class="att-file" href="/files/'+pEsc(A.id)+'" download="'+pEsc(A.name)+'">'+ic('file',15)+' <span>'+pEsc(A.name)+'</span> <span class="att-sz">'+fmtSize(A.size)+'</span></a>') : '';
const mentionsMe=convoIsGroup && !mine && Array.isArray(m.mentions) && (m.mentions.includes(ME.id)||m.mentions.includes('everyone'));
// DM ticks: sent (1 grey) → delivered (2 grey) → read (2 yellow).
let rcpt='';
if(mine && !convoIsGroup){ const st=m.read_at?'seen':(m.delivered_at?'delivered':'sent'); rcpt='<span class="rcpt '+st+'" title="'+(st==='seen'?'Seen':st==='delivered'?'Delivered':'Sent')+'">'+ic(st==='sent'?'check':'checkCheck',13)+'</span>'; }
// Group ticks: a per-message receipt on MY messages — sent (1 grey) → seen-by-some (2 grey) →
// seen-by-all (2 yellow). Tap to see exactly who ("Seen by X, Y · N of M"). seenBy = other
// members who've read this message (computed server-side, kept live by onGroupRead).
else if(mine && convoIsGroup){
const others=Math.max(0,(convoMembers?convoMembers.length:0)-1);
const ns=Array.isArray(m.seenBy)?m.seenBy:[]; const rc=ns.length;
const all=others>0 && rc>=others;
const ttl=rc?('Seen by '+ns.slice(0,6).join(', ')+(ns.length>6?(' +'+(ns.length-6)):'')+(others>0?(' · '+rc+' of '+others):'')):'Sent';
rcpt='<span class="rcpt grp '+(all?'seen':'')+'" data-seen="'+pEsc(ns.join('|'))+'" title="'+pEsc(ttl)+'">'+ic(rc>0?'checkCheck':'check',13)+'</span>';
}
// #2: under the group's LAST message, show "Seen by X, Y +N more" — visible to everyone, click for names.
let seen='';
if(convoIsGroup && m.id===_lastGroupId && Array.isArray(m.seenBy) && m.seenBy.length){ const ns=m.seenBy, head=ns.slice(0,2).join(', '), more=ns.length>2?(' +'+(ns.length-2)+' more'):''; seen='<button class="seenby" data-seen="'+pEsc(ns.join('|'))+'">'+ic('checkCheck',12)+' Seen by '+pEsc(head)+more+'</button>'; }
return '<div class="bubble '+(mine?'mine':'them')+(mentionsMe?' mention-me':'')+(m.poll?' has-poll':'')+'" data-id="'+pEsc(m.id)+'">'
+ sender + (m.fwd_from?'<div class="fwd-label">'+ic('arrowRight',11)+' Forwarded'+(m.fwd_from?(' from <b>'+pEsc(m.fwd_from)+'</b>'):'')+'</div>':'') + quote + att + renderMsgBody(m) + pollHTML(m)
// #4: hover row = three one-tap reactions + the picker + (own) edit; everything else lives behind ⋮.
+ (m.deleted?'':'<div class="msg-actions">'
+ QUICK_REACTS.map(q=>'<button class="qr-btn" data-id="'+pEsc(m.id)+'" data-emoji="'+q.e+'" title="'+q.t+'">'+q.e+'</button>').join('')
+ '<button class="react-btn" data-id="'+pEsc(m.id)+'" title="More reactions">'+ic('smilePlus',14)+'</button>'
+ ((mine && m.body && !m.poll)?'<button class="edit-btn" data-edit="'+pEsc(m.id)+'" title="Edit message">'+ic('edit',13)+'</button>':'')
+ '<button class="more-btn" data-more="'+pEsc(m.id)+'" title="More">'+ic('moreVertical',14)+'</button>'
+ '</div>')
+ '<span class="t">'+pEsc(fmtClock(m.created_at))+(m.edited_at?'<span class="edited"> · edited</span>':'')+rcpt+'</span>'
+ reacts + seen + '</div>';
}
// #1: mini profile card for a person in a group — photo, name, presence/last-seen, and a Message button
// that drops straight into the 1:1 with them.
function openMiniProfile(uid, anchor){
document.querySelectorAll('.mini-profile').forEach(x=>x.remove());
if(!uid || uid===(ME&&ME.id)) return;
const row=rowFor('dm',uid) || (CONTACTS||[]).find(c=>c.id===uid) || (convoMembers||[]).find(m=>m.id===uid) || {};
const name=row.name || 'Someone';
const card=document.createElement('div'); card.className='mini-profile';
card.innerHTML='<div class="mp-top">'
+'<span class="mp-av" style="background:'+avColor(name)+'">'+(row.avatar?('<img src="'+pEsc(row.avatar)+'" alt="" onerror="this.remove()">'):'')+pEsc(initials(name))+'</span>'
+'<div class="mp-id"><b>'+pEsc(name)+'</b><span>'+pEsc(statusLabel(row))+'</span></div>'
+'</div>'
+'<div class="mp-acts">'
+'<button class="mp-msg" id="mpMsg">'+ic('chat',15)+' Message</button>'
+(row.avatar?'<button class="mp-dp" id="mpDp" title="View photo">'+ic('image',15)+'</button>':'')
+'</div>';
document.body.appendChild(card);
const r=anchor.getBoundingClientRect();
card.style.left=Math.max(8, Math.min(r.left, window.innerWidth-card.offsetWidth-8))+'px';
const below=r.bottom+8, above=r.top-card.offsetHeight-8;
card.style.top=((below+card.offsetHeight<window.innerHeight-8)?below:Math.max(8,above))+'px';
card.querySelector('#mpMsg').onclick=()=>{ card.remove(); switchTab('chat'); selectChat('dm', uid); };
const dp=card.querySelector('#mpDp'); if(dp) dp.onclick=()=>{ card.remove(); openLightbox(row.avatar); };
const close=(e)=>{ if(!card.contains(e.target)){ card.remove(); document.removeEventListener('mousedown',close); } };
setTimeout(()=>document.addEventListener('mousedown',close),0);
}
// #4: the three one-tap reactions shown on hover (Like / Laugh / Surprised).
const QUICK_REACTS=[{e:'👍',t:'Like'},{e:'😂',t:'Laugh'},{e:'😮',t:'Surprised'}];
// Everything that isn't a reaction or an edit now lives behind the ⋮ menu, so hovering a message no
// longer buries it under a wall of icons.
function openMsgMore(msgId, anchor){
document.querySelectorAll('.msg-more-menu').forEach(x=>x.remove());
if(anchor && anchor._open){ anchor._open=false; return; } // clicking ⋮ again closes it
const m=THREAD.find(x=>x.id===msgId); if(!m) return;
const mine=m.from===ME.id;
const items=[];
items.push({ic:'reply', label:'Reply', fn:()=>setReply(m)});
if((m.body||m.attachment) && !m.poll) items.push({ic:'arrowRight', label:'Forward', fn:()=>enterSelect(m.id)});
if(m.body) items.push({ic:'copy', label:'Copy', fn:()=>copyMessageText(m)});
if(mine) items.push({ic:'trash', label:'Delete', danger:true, fn:()=>deleteMessage(m.id)});
const menu=document.createElement('div'); menu.className='spk-menu msg-more-menu';
menu.innerHTML=items.map((it,i)=>'<button class="spk-opt mm-opt'+(it.danger?' danger':'')+'" data-i="'+i+'">'+ic(it.ic,15)+'<span>'+pEsc(it.label)+'</span></button>').join('');
document.body.appendChild(menu);
const r=anchor.getBoundingClientRect();
menu.style.left=Math.max(8,Math.min(r.right-menu.offsetWidth, window.innerWidth-menu.offsetWidth-8))+'px';
const below=r.bottom+6, above=r.top-menu.offsetHeight-6;
menu.style.top=((below+menu.offsetHeight<window.innerHeight-8)?below:Math.max(8,above))+'px';
menu.querySelectorAll('.mm-opt').forEach(b=>b.onclick=()=>{ const it=items[+b.dataset.i]; menu.remove(); try{ it.fn(); }catch(_){} });
const close=(e)=>{ if(!menu.contains(e.target)){ menu.remove(); document.removeEventListener('mousedown',close); } };
setTimeout(()=>document.addEventListener('mousedown',close),0);
}
// Copy a message's text to the clipboard (#4).
function copyMessageText(m){
const txt=String(m&&m.body||''); if(!txt){ toast('Nothing to copy'); return; }
const done=()=>toast('Message copied');
try{ if(navigator.clipboard&&navigator.clipboard.writeText) return void navigator.clipboard.writeText(txt).then(done).catch(()=>fallbackCopy(txt,done)); }catch(_){}
fallbackCopy(txt,done);
}
// reply + emoji state/helpers
let replyTarget=null;
// Categorized emoji set (covers the common ones used across Slack/Teams/WhatsApp).
const EMOJI_CATS=[
{ icon:'😀', list:'😀 😃 😄 😁 😆 😅 🤣 😂 🙂 🙃 🫠 😉 😊 😇 🥰 😍 🤩 😘 😗 😚 😙 🥲 😋 😛 😜 🤪 😝 🤑 🤗 🤭 🫢 🫣 🤫 🤔 🫡 🤐 🤨 😐 😑 😶 🫥 😏 😒 🙄 😬 🤥 😌 😔 😪 🤤 😴 😷 🤒 🤕 🤢 🤮 🤧 🥵 🥶 🥴 😵 🤯 🤠 🥳 🥸 😎 🤓 🧐 😕 🫤 😟 🙁 ☹️ 😮 😯 😲 😳 🥺 🥹 😦 😧 😨 😰 😥 😢 😭 😱 😖 😣 😞 😓 😩 😫 🥱 😤 😡 😠 🤬 😈 👿 💀 ☠️ 💩 🤡 👹 👺 👻 👽 👾 🤖 😺 😸 😹 😻 😼 😽 🙀 😿 😾' },
{ icon:'👍', list:'👋 🤚 🖐️ ✋ 🖖 🫱 🫲 🫳 🫴 👌 🤌 🤏 ✌️ 🤞 🫰 🤟 🤘 🤙 👈 👉 👆 🖕 👇 ☝️ 🫵 👍 👎 ✊ 👊 🤛 🤜 👏 🙌 🫶 👐 🤲 🤝 🙏 ✍️ 💅 🤳 💪 🦾 🦵 🦶 👣 👀 👁️ 🫦 👄 🫀 🫁 🧠 🦷 🦴 👶 🧒 👦 👧 🧑 👨 👩 🧔 👱 🧓 👴 👵 🙇 💁 🙅 🙆 🙋 🤦 🤷 👮 🕵️ 💂 👷 🤴 👸 👳 🧕 🤵 👰 🤰 🤱 👼 🎅 🤶 🦸 🦹 🧙 🧚 🧛 🧜 🧝 🧞 🧟 💆 💇 🚶 🧍 🧎 🏃 💃 🕺 👯 🧘' },
{ icon:'❤️', list:'❤️ 🧡 💛 💚 💙 💜 🖤 🤍 🤎 💔 ❣️ 💕 💞 💓 💗 💖 💘 💝 💟 ❤️‍🔥 ❤️‍🩹 💋 💯 💢 💥 💫 💦 💨 💬 🗨️ 🗯️ 💭 💤 ♥️ ✨ ⭐ 🌟 ⚡ 🔥 🌈 ☀️ ⛅ ☁️ 🌧️ ⛈️ 🌩️ ❄️ ☃️ ⛄ 💧 🌊' },
{ icon:'🐻', list:'🐶 🐱 🐭 🐹 🐰 🦊 🐻 🐼 🐻‍❄️ 🐨 🐯 🦁 🐮 🐷 🐽 🐸 🐵 🙈 🙉 🙊 🐒 🐔 🐧 🐦 🐤 🐣 🐥 🦆 🦅 🦉 🦇 🐺 🐗 🐴 🦄 🐝 🐛 🦋 🐌 🐞 🐜 🪰 🪲 🦗 🕷️ 🦂 🐢 🐍 🦎 🦖 🦕 🐙 🦑 🦐 🦞 🦀 🐡 🐠 🐟 🐬 🐳 🐋 🦈 🐊 🐅 🐆 🦓 🦍 🦧 🐘 🦛 🦏 🐪 🐫 🦒 🦘 🐃 🐄 🐎 🐖 🐏 🐑 🐐 🦌 🐕 🐩 🦮 🐈 🐓 🦃 🦚 🦜 🦢 🕊️ 🐇 🦝 🦨 🦦 🦥 🐁 🐀 🐿️ 🦔 🐾 🐉 🌵 🎄 🌲 🌳 🌴 🌱 🌿 ☘️ 🍀 🍃 🍂 🍁 🍄 🐚 🌾 💐 🌷 🌹 🥀 🌺 🌸 🌼 🌻' },
{ icon:'🍔', list:'🍏 🍎 🍐 🍊 🍋 🍌 🍉 🍇 🍓 🫐 🍈 🍒 🍑 🥭 🍍 🥥 🥝 🍅 🍆 🥑 🥦 🥬 🥒 🌶️ 🫑 🌽 🥕 🫒 🧄 🧅 🥔 🍠 🥐 🥯 🍞 🥖 🥨 🧀 🥚 🍳 🧈 🥞 🧇 🥓 🥩 🍗 🍖 🌭 🍔 🍟 🍕 🫓 🥪 🥙 🧆 🌮 🌯 🫔 🥗 🥘 🫕 🥫 🍝 🍜 🍲 🍛 🍣 🍱 🥟 🦪 🍤 🍙 🍚 🍘 🍥 🥠 🥮 🍢 🍡 🍧 🍨 🍦 🥧 🧁 🍰 🎂 🍮 🍭 🍬 🍫 🍿 🍩 🍪 🌰 🥜 🍯 🥛 🍼 ☕ 🍵 🧃 🥤 🧋 🍶 🍺 🍻 🥂 🍷 🥃 🍸 🍹 🧉 🍾' },
{ icon:'⚽', list:'⚽ 🏀 🏈 ⚾ 🥎 🎾 🏐 🏉 🥏 🎱 🪀 🏓 🏸 🏒 🏑 🥍 🏏 🥅 ⛳ 🪁 🏹 🎣 🤿 🥊 🥋 🎽 🛹 🛼 🛷 ⛸️ 🥌 🎿 ⛷️ 🏂 🪂 🏋️ 🤼 🤸 ⛹️ 🤺 🤾 🏌️ 🏇 🧘 🏄 🏊 🤽 🚣 🧗 🚵 🚴 🏆 🥇 🥈 🥉 🏅 🎖️ 🏵️ 🎗️ 🎫 🎟️ 🎪 🤹 🎭 🩰 🎨 🎬 🎤 🎧 🎼 🎹 🥁 🪘 🎷 🎺 🪗 🎸 🪕 🎻 🎲 ♟️ 🎯 🎳 🎮 🎰 🧩' },
{ icon:'🚗', list:'🚗 🚕 🚙 🚌 🚎 🏎️ 🚓 🚑 🚒 🚐 🛻 🚚 🚛 🚜 🛴 🚲 🛵 🏍️ 🛺 🚨 🚔 🚍 🚘 🚖 🚡 🚠 🚟 🚃 🚋 🚞 🚝 🚄 🚅 🚈 🚂 🚆 🚇 🚊 🚉 ✈️ 🛫 🛬 🛩️ 💺 🛰️ 🚀 🛸 🚁 🛶 ⛵ 🚤 🛥️ 🛳️ ⛴️ 🚢 ⚓ ⛽ 🚧 🚦 🚥 🗺️ 🗿 🗽 🗼 🏰 🏯 🏟️ 🎡 🎢 🎠 ⛲ ⛱️ 🏖️ 🏝️ 🏜️ 🌋 ⛰️ 🏔️ 🗻 🏕️ ⛺ 🏠 🏡 🏘️ 🏗️ 🏭 🏢 🏬 🏣 🏥 🏦 🏨 🏪 🏫 ⛪ 🕌 🛕 🕋 🌁 🌃 🏙️ 🌅 🌆 🌉 🌌 🎇 🎆 🌠' },
{ icon:'💡', list:'⌚ 📱 📲 💻 ⌨️ 🖥️ 🖨️ 🖱️ 🕹️ 💽 💾 💿 📀 📼 📷 📸 📹 🎥 📽️ 🎞️ 📞 ☎️ 📟 📠 📺 📻 🎙️ 🎚️ 🎛️ 🧭 ⏱️ ⏲️ ⏰ 🕰️ ⌛ ⏳ 📡 🔋 🔌 💡 🔦 🕯️ 🪔 🧯 💸 💵 💴 💶 💷 🪙 💰 💳 💎 ⚖️ 🪜 🧰 🪛 🔧 🔨 ⚒️ 🛠️ ⛏️ 🔩 ⚙️ 🧱 ⛓️ 🧲 🔫 💣 🧨 🪓 🔪 🗡️ ⚔️ 🛡️ 🚬 ⚰️ ⚱️ 🏺 🔮 📿 🧿 💈 ⚗️ 🔭 🔬 💊 💉 🩸 🧬 🦠 🧫 🧪 🌡️ 🧹 🧺 🧻 🚽 🚿 🛁 🧼 🪥 🧽 🪣 🧴 🛎️ 🔑 🗝️ 🚪 🪑 🛋️ 🛏️ 🧸 🖼️ 🛍️ 🛒 🎁 🎈 🎏 🎀 🪄 🎊 🎉 🏮 🎐 🧧 ✉️ 📩 📨 📧 💌 📦 🏷️ 📜 📄 📊 📈 📉 📅 📋 📁 📂 🗂️ 📰 📓 📔 📕 📗 📘 📙 📚 📖 🔖 🔗 📎 📐 📏 🧮 📌 📍 ✂️ 🖊️ 🖋️ ✒️ 🖌️ 🖍️ 📝 ✏️ 🔍 🔎 🔒 🔓' },
{ icon:'✅', list:'✅ ❌ ⭕ 🚫 ❓ ❔ ❗ ❕ ‼️ ⁉️ 💯 🔅 🔆 ⚠️ 🚸 ☢️ ☣️ ⬆️ ↗️ ➡️ ↘️ ⬇️ ↙️ ⬅️ ↖️ ↕️ ↔️ ↩️ ↪️ ⤴️ ⤵️ 🔃 🔄 🔙 🔚 🔛 🔜 🔝 🛐 ⚛️ 🕉️ ✡️ ☸️ ☯️ ✝️ ☦️ ☪️ ☮️ 🕎 🔯 ♈ ♉ ♊ ♋ ♌ ♍ ♎ ♏ ♐ ♑ ♒ ♓ ⛎ 🔀 🔁 🔂 ▶️ ⏩ ⏭️ ◀️ ⏪ ⏮️ 🔼 ⏫ 🔽 ⏬ ⏸️ ⏹️ ⏺️ ⏏️ ♀️ ♂️ ⚧️ ✖️ ➕ ➖ ➗ ♾️ 💲 💱 ™️ ©️ ®️ 〰️ ➰ ➿ ✔️ ☑️ 🔘 🔴 🟠 🟡 🟢 🔵 🟣 🟤 ⚫ ⚪ 🟥 🟧 🟨 🟩 🟦 🟪 🟫 ⬛ ⬜ 🔶 🔷 🔸 🔹 🔺 🔻 💠 🔳 🔲' },
{ icon:'🏁', list:'🏁 🚩 🎌 🏴 🏳️ 🏳️‍🌈 🏳️‍⚧️ 🏴‍☠️ 🇮🇳 🇺🇸 🇬🇧 🇨🇦 🇦🇺 🇩🇪 🇫🇷 🇮🇹 🇪🇸 🇯🇵 🇰🇷 🇨🇳 🇧🇷 🇷🇺 🇿🇦 🇦🇪 🇸🇬 🇲🇾 🇮🇩 🇵🇭 🇹🇭 🇳🇿 🇮🇪 🇳🇱 🇸🇪 🇨🇭' },
];
let emojiCat=0, emojiMode='compose';
function setReply(m){
replyTarget=m;
const bar=document.getElementById('replyBar'); if(!bar) return;
const who=m.from===ME.id?'yourself':(m.fromName||currentName()||'them');
const rprev=isGifUrl(m.body)?'🎞️ GIF':(m.body.length>80?m.body.slice(0,80)+'…':m.body);
bar.innerHTML='<span>'+ic('reply',13)+' Replying to <b>'+pEsc(who)+'</b>: '+pEsc(rprev)+'</span><span class="rx" id="replyCancel">'+ic('x',15)+'</span>';
bar.style.display='flex';
document.getElementById('replyCancel').onclick=clearReply;
const inp=document.getElementById('msgInput'); if(inp) inp.focus();
}
function clearReply(){ replyTarget=null; const bar=document.getElementById('replyBar'); if(bar){ bar.style.display='none'; bar.innerHTML=''; } }
// Edit an existing message (sender only): load its text into the composer, show an "Editing" bar;
// the next send saves the edit instead of posting a new message.
let editTarget=null;
function startEdit(m){
if(!m) return; clearReply(); editTarget=m;
const bar=document.getElementById('replyBar'); if(bar){ bar.innerHTML='<span>'+ic('edit',13)+' Editing message</span><span class="rx" id="editCancel">'+ic('x',15)+'</span>'; bar.style.display='flex'; const x=document.getElementById('editCancel'); if(x) x.onclick=cancelEdit; }
const inp=document.getElementById('msgInput'); if(inp){ inp.value=m.body||''; autoGrow(inp); inp.focus(); }
}
function cancelEdit(){ editTarget=null; const bar=document.getElementById('replyBar'); if(bar){ bar.style.display='none'; bar.innerHTML=''; } const inp=document.getElementById('msgInput'); if(inp){ inp.value=''; autoGrow(inp); } }
async function saveEdit(text){
const m=editTarget; if(!m) return;
if(!text){ cancelEdit(); return; } // empty → treat as cancel (delete is a separate action)
if(text===(m.body||'')){ cancelEdit(); return; } // no change
editTarget=null; const bar=document.getElementById('replyBar'); if(bar){ bar.style.display='none'; bar.innerHTML=''; }
const inp=document.getElementById('msgInput'); if(inp){ inp.value=''; autoGrow(inp); }
try{
await postJSON('/api/messages/edit',{ id:m.id, body:text });
const t=THREAD.find(x=>x.id===m.id); if(t){ t.body=text; t.edited_at=Date.now(); updateBubble(t); }
THREAD_CACHE.forEach(arr=>arr.forEach(x=>{ if(x.id===m.id){ x.body=text; x.edited_at=Date.now(); } }));
// keep the chat-list preview in sync if this was the last message
const it=rowFor(selected.kind, selected.id); if(it && it.last_from_me){ const last=THREAD[THREAD.length-1]; if(last && last.id===m.id){ it.last_body=text; renderChats(searchVal()); } }
}catch(e){ toast(e.message||'Could not edit'); }
}
// A message I can see was edited by its sender → update it in place (thread + cache + list preview).
function onChatEdited(d){
if(!d||!d.id) return;
const t=THREAD.find(x=>x.id===d.id); if(t){ t.body=d.body; t.edited_at=d.edited_at||Date.now(); updateBubble(t); }
THREAD_CACHE.forEach(arr=>arr.forEach(x=>{ if(x.id===d.id){ x.body=d.body; x.edited_at=d.edited_at||Date.now(); } }));
try{ loadSidebar(); }catch(_){} // refresh last-message previews
}
// attachments
let pendingAttachs=[]; // #5: multiple files can be queued + sent together (each item: {id,name,mime,uploading})
function fmtSize(b){ b=+b||0; if(b<1024) return b+' B'; if(b<1048576) return Math.round(b/1024)+' KB'; return (b/1048576).toFixed(1)+' MB'; }
// #8: real upload PROGRESS. fetch() can't report upload progress at all, so a big file just said
// "uploading…" with no idea how long to wait. XHR exposes upload.onprogress, so we can show a real bar
// (and a %), making it obvious when it's safe to hit Send.
function uploadFile(file){
if(!file) return;
if(file.size>1024*1024*1024){ toast('“'+file.name+'” is too large (max 1 GB)'); return; }
const ph={ id:'up-'+Math.random().toString(36).slice(2), name:file.name, mime:file.type||'', uploading:true, pct:0, size:file.size };
pendingAttachs.push(ph); renderAttachBar();
const fail=(msg)=>{ const i=pendingAttachs.indexOf(ph); if(i>=0) pendingAttachs.splice(i,1); renderAttachBar(); toast(msg||'Upload failed'); };
try{
const xhr=new XMLHttpRequest();
ph.xhr=xhr; // so the user can cancel an in-flight upload
xhr.open('POST','/api/messages/upload',true);
xhr.setRequestHeader('Content-Type', file.type||'application/octet-stream');
xhr.setRequestHeader('X-Filename', encodeURIComponent(file.name));
xhr.upload.onprogress=(e)=>{
if(!e.lengthComputable) return;
const pct=Math.max(1, Math.min(99, Math.round((e.loaded/e.total)*100)));
if(pct!==ph.pct){ ph.pct=pct; updateAttachProgress(ph); } // cheap in-place update, no full re-render
};
xhr.onload=()=>{
let d={}; try{ d=JSON.parse(xhr.responseText||'{}'); }catch(_){}
if(xhr.status<200||xhr.status>=300){ return fail(d.error||'Upload failed'); }
const i=pendingAttachs.indexOf(ph); const item={ ...d, uploading:false };
if(i>=0) pendingAttachs[i]=item; else pendingAttachs.push(item);
renderAttachBar();
const inp=document.getElementById('msgInput'); if(inp) inp.focus();
};
xhr.onerror=()=>fail('Upload failed');
xhr.onabort=()=>{ const i=pendingAttachs.indexOf(ph); if(i>=0) pendingAttachs.splice(i,1); renderAttachBar(); };
xhr.send(file);
}catch(e){ fail(e.message); }
}
// Update just the progress bar/label of one pending attachment (called on every progress tick).
function updateAttachProgress(a){
const bar=document.getElementById('attachBar'); if(!bar) return;
const el=bar.querySelector('.ap-item[data-up="'+a.id+'"]'); if(!el) return;
const fill=el.querySelector('.ap-fill'); if(fill) fill.style.width=(a.pct||0)+'%';
const pc=el.querySelector('.ap-pct'); if(pc) pc.textContent=(a.pct||0)+'%';
}
function renderAttachBar(){
const bar=document.getElementById('attachBar'); if(!bar) return;
if(!pendingAttachs.length){ bar.style.display='none'; bar.innerHTML=''; return; }
bar.innerHTML=pendingAttachs.map((a,idx)=>{
const isImg=/^image\//.test(a.mime||'');
const lead=(!a.uploading && isImg)?'<img class="ap-thumb" src="/files/'+pEsc(a.id)+'" alt="">':'<span class="ap-ic">'+ic(a.uploading?'paperclip':(isImg?'camera':'file'),18)+'</span>';
// #8: while uploading, show a real progress bar + % so it's clear when Send is safe.
const prog=a.uploading
? '<span class="ap-prog"><span class="ap-track"><span class="ap-fill" style="width:'+(a.pct||0)+'%"></span></span><span class="ap-pct">'+(a.pct||0)+'%</span></span>'
: '';
return '<div class="ap-item'+(a.uploading?' uploading':'')+'" data-up="'+pEsc(a.id)+'">'+lead
+'<span class="ap-main"><span class="ap-name">'+pEsc(a.name)+'</span>'+prog+'</span>'
+'<button type="button" class="ap-x" data-ai="'+idx+'" title="'+(a.uploading?'Cancel upload':'Remove')+'">'+ic('x',15)+'</button></div>';
}).join('');
bar.style.display='flex';
bar.querySelectorAll('.ap-x').forEach(b=>b.onclick=()=>{
const i=+b.dataset.ai; if(!(i>=0&&i<pendingAttachs.length)) return;
const a=pendingAttachs[i];
if(a.uploading && a.xhr){ try{ a.xhr.abort(); }catch(_){} return; } // abort in-flight (onabort cleans up)
pendingAttachs.splice(i,1); renderAttachBar();
});
}
function hideAttach(){ pendingAttachs=[]; const bar=document.getElementById('attachBar'); if(bar){ bar.style.display='none'; bar.innerHTML=''; } ['fileInput','fileInputImg','fileInputCam'].forEach(id=>{ const fi=document.getElementById(id); if(fi) fi.value=''; }); const ap=document.getElementById('attachPop'); if(ap) ap.style.display='none'; }
// Paste an image from the clipboard (e.g. a screenshot) straight into the composer.
function onPaste(e){
const cd=e.clipboardData; if(!cd) return;
// Excel / Google Sheets put BOTH a bitmap AND the cell text on the clipboard. If there's real text,
// let the textarea paste it normally (don't upload the screenshot). Only upload the image when the
// clipboard is image-ONLY (a genuine screenshot / copied picture with no accompanying text).
const plain=(cd.getData&&cd.getData('text/plain'))||'';
if(plain&&plain.trim()) return; // → default paste of the text
const items=cd.items||[];
for(const it of items){
if(it.type&&it.type.indexOf('image')===0){
const blob=it.getAsFile();
if(blob){ e.preventDefault(); const ext=(blob.type.split('/')[1]||'png'); uploadFile(new File([blob],'screenshot-'+Date.now()+'.'+ext,{type:blob.type})); return; }
}
}
}
// Close the emoji picker when clicking anywhere outside it (or its button).
document.addEventListener('click',(e)=>{
if(!emojiOpen) return;
const pop=document.getElementById('emojiPop'), btn=document.getElementById('emojiBtn');
if(pop && !pop.contains(e.target) && !(btn&&btn.contains(e.target)) && !e.target.closest('.react-btn')) closeEmoji();
});
document.addEventListener('click',(e)=>{ const ap=document.getElementById('attachPop'); if(!ap||ap.style.display==='none') return; const abtn=document.getElementById('attachBtn'); if(!ap.contains(e.target) && !(abtn&&abtn.contains(e.target))) ap.style.display='none'; }); // close the attach menu on an outside tap
let emojiOpen=false;
function openEmoji(mode, anchorEl){ emojiMode=mode||'compose'; if(emojiMode&&emojiMode.react&&emojiCat==='gif') emojiCat=0; /* GIFs can't be a reaction */ const pop=document.getElementById('emojiPop'); if(!pop) return; emojiOpen=true; renderEmojiPop(pop); pop.style.display='flex'; positionEmojiPop(pop, anchorEl); }
// Anchor the picker at its trigger: above the composer emoji icon, or at the message you reacted to.
function positionEmojiPop(pop, anchorEl){
const parent=pop.offsetParent || pop.parentElement; if(!parent){ return; }
const pr=parent.getBoundingClientRect(); const popW=pop.offsetWidth||330, popH=pop.offsetHeight||300;
let left, top;
if(anchorEl && anchorEl.getBoundingClientRect){ const r=anchorEl.getBoundingClientRect(); left=r.left-pr.left; top=r.top-pr.top-popH-8; }
else { left=12; top=pr.height-popH-64; }
left=Math.max(8, Math.min(left, pr.width-popW-8));
top=Math.max(8, Math.min(top, pr.height-popH-8));
pop.style.left=left+'px'; pop.style.top=top+'px'; pop.style.bottom='auto';
}
function closeEmoji(){ const pop=document.getElementById('emojiPop'); if(pop) pop.style.display='none'; emojiOpen=false; }
let _gifOn=null; // null=unknown, true/false once probed
function renderEmojiPop(pop){
// Tabs: emoji categories + (when configured) a GIF tab, visually separated (#5).
const showGif=(_gifOn!==false) && !(emojiMode&&emojiMode.react); // GIFs make sense only when composing, not reacting
const gifTab='<span class="et-sep"></span><button type="button" data-gif="1" class="'+(emojiCat==='gif'?'active':'')+'" title="GIFs">GIF</button>';
pop.innerHTML='<div class="emoji-tabs">'+EMOJI_CATS.map((c,i)=>'<button type="button" data-i="'+i+'" class="'+(emojiCat===i?'active':'')+'">'+c.icon+'</button>').join('')+(showGif?gifTab:'')+'</div>'
+(emojiCat==='gif'
? '<div class="gif-wrap"><div class="gif-search"><input id="gifQ" placeholder="Search GIFs…" autocomplete="off">'+ic('search',14)+'</div><div class="gif-grid" id="gifGrid"></div><div class="gif-attr">Powered by GIPHY</div></div>'
: '<div class="emoji-grid" id="emojiGrid"></div>');
// stopPropagation: switching tabs re-renders the pop (detaching the clicked button), which would
// make the outside-click handler think the click was outside and close the picker.
pop.querySelectorAll('.emoji-tabs button[data-i]').forEach(b=>b.onclick=(ev)=>{ ev.stopPropagation(); emojiCat=+b.dataset.i; renderEmojiPop(pop); });
const gt=pop.querySelector('.emoji-tabs button[data-gif]'); if(gt) gt.onclick=(ev)=>{ ev.stopPropagation(); emojiCat='gif'; renderEmojiPop(pop); };
if(emojiCat==='gif'){
const q=pop.querySelector('#gifQ');
if(q){ q.oninput=()=>{ clearTimeout(_gifT); _gifT=setTimeout(()=>loadGifs(q.value.trim()),300); }; q.onclick=(e)=>e.stopPropagation(); setTimeout(()=>q.focus(),0); }
loadGifs('');
return;
}
const grid=pop.querySelector('#emojiGrid');
grid.innerHTML=EMOJI_CATS[emojiCat].list.trim().split(/\s+/).map(e=>'<button type="button" data-emoji="'+e+'">'+e+'</button>').join('');
grid.querySelectorAll('button').forEach(b=>b.onclick=(ev)=>{ ev.stopPropagation(); onEmojiPick(b.dataset.emoji); }); // data-emoji survives twemoji's <img> swap
}
let _gifT=null, _gifSeq=0;
async function loadGifs(q){
const grid=document.getElementById('gifGrid'); if(!grid) return;
const seq=++_gifSeq; grid.innerHTML='<div class="gif-load"><img src="/loaders/loader-ring.svg" width="26" height="26" alt=""></div>';
let d; try{ d=await fetch('/api/gifs?q='+encodeURIComponent(q||'')).then(r=>r.json()); }catch(_){ d=null; }
if(seq!==_gifSeq) return;
if(d && d.enabled===false){ _gifOn=false; grid.innerHTML='<div class="gif-empty">GIFs arent configured.</div>'; return; }
_gifOn=true;
const gifs=(d&&d.gifs)||[];
if(!gifs.length){ grid.innerHTML='<div class="gif-empty">'+(q?'No GIFs for “'+pEsc(q)+'”.':'Couldnt load GIFs.')+'</div>'; return; }
grid.innerHTML=gifs.map(g=>'<button type="button" class="gif-cell" data-url="'+pEsc(g.url)+'" title="'+pEsc(g.title||'GIF')+'"><img src="'+pEsc(g.preview||g.url)+'" loading="lazy" alt=""></button>').join('');
grid.querySelectorAll('.gif-cell').forEach(b=>b.onclick=(ev)=>{ ev.stopPropagation(); sendGif(b.dataset.url); });
}
// GIFs send immediately (like a sticker), hotlinked to GIPHY's CDN per their terms. If a reply is armed,
// the GIF is sent AS that reply (it was going out as a plain message before).
async function sendGif(url){
if(!url || !selected) return;
closeEmoji();
const kind=selected.kind, id=selected.id;
const replyTo=replyTarget?replyTarget.id:null;
clearReply();
try{ await postJSON('/api/messages', kind==='group'?{group:id, body:url, replyTo}:{to:id, body:url, replyTo}); }
catch(e){ toast(e.message||'Could not send GIF'); }
}
function onEmojiPick(e){ if(emojiMode && emojiMode.react){ reactToMessage(emojiMode.react, e); closeEmoji(); } else { insertEmoji(e); } }
function openEmojiForReact(messageId, anchorEl){ openEmoji({ react: messageId }, anchorEl); }
function insertEmoji(e){
const inp=document.getElementById('msgInput'); if(!inp) return;
const s=inp.selectionStart??inp.value.length, en=inp.selectionEnd??inp.value.length;
inp.value=inp.value.slice(0,s)+e+inp.value.slice(en);
const np=s+e.length; inp.focus(); try{ inp.setSelectionRange(np,np); }catch(_){}
}
// reactions
function applyReaction(m, emoji, userId, added){
m.reactions=m.reactions||[];
let r=m.reactions.find(x=>x.emoji===emoji);
if(added){ if(!r){ r={emoji,count:0,mine:false}; m.reactions.push(r); } r.count++; if(userId===ME.id) r.mine=true; }
else if(r){ r.count--; if(userId===ME.id) r.mine=false; if(r.count<=0) m.reactions=m.reactions.filter(x=>x.emoji!==emoji); }
}
async function reactToMessage(messageId, emoji){
const m=THREAD.find(x=>x.id===messageId);
try{ const r=await postJSON('/api/messages/react',{ messageId, emoji }); if(m){ m.reactions=r.reactions||[]; updateBubble(m); } }catch(_){}
}
function showMeetingCancelled(mtg){ if(!mtg) return; try{ playPing(); }catch(_){}
const w=mtg.when?(' on '+mtg.when):'';
addNotif({icon:'calendarX', text:pEsc(mtg.by||'Someone')+' cancelled “'+pEsc(mtg.title||'a meeting')+'”'+pEsc(w), link:{kind:'meeting'}});
try{ notify('❌ Meeting cancelled', (mtg.by||'Someone')+' cancelled '+(mtg.title||'a meeting')+w); }catch(_){}
toast('❌ “'+(mtg.title||'Meeting')+'”'+w+' was cancelled'); if(currentTab()==='meeting') loadScheduledMeetings(); }
function onGroupRole(d){ if(!d||!d.group) return; toast(d.admin?'You are now an admin of this group':'You are no longer an admin'); const gi=document.getElementById('groupInfo'); if(gi){ gi.remove(); if(selected&&selected.kind==='group'&&selected.id===d.group) openGroupInfo(d.group); } }
// A group's membership changed → refresh the sidebar (member count) live; refresh open group-info.
function onGroupUpdate(d){ if(!d||!d.group) return;
loadSidebar().then(()=>{ if(selected&&selected.kind==='group'&&selected.id===d.group){ if(d.removed){ showWelcome(); toast('You were removed from the group'); } else openConvo('group', d.group); } });
const gi=document.getElementById('groupInfo'); if(gi){ gi.remove(); if(!d.removed && selected&&selected.kind==='group'&&selected.id===d.group) openGroupInfo(d.group); } // refresh open group-info
}
function onChatReaction(d){ const m=THREAD.find(x=>x.id===d.messageId); if(m && d.reactions){ m.reactions=d.reactions; updateBubble(m); }
if(d.added && d.owner===ME.id && d.byId && d.byId!==ME.id){ addNotif({icon:'smilePlus', text:pEsc(d.by||'Someone')+' reacted '+(d.emoji||'')+' to your message', link:d.convId?{kind:'group',id:d.convId}:{kind:'dm',id:d.byId}}); }
}
// Read receipts (DM): the other party read my messages → mark mine as seen.
function onChatRead(d){ if(!d||!d.by) return;
if(selected && selected.kind==='dm' && selected.id===d.by){ let changed=false; THREAD.forEach(m=>{ if(m.from===ME.id && !m.read_at){ m.read_at=Date.now(); changed=true; } }); if(changed) renderThread(); }
// #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()); }
}
// Branded confirm dialog — replaces window.confirm (which shows the OS/Electron default dialog on
// desktop). Returns a Promise<boolean>. Reuses .modal-ov so the global Esc handler dismisses it;
// a MutationObserver resolves false whenever the overlay is removed (Esc / backdrop / cancel).
// #2: "Seen by" as an on-screen popup listing the readers (not a flash toast).
function showSeenByModal(msg){
if(document.getElementById('seenModal')) return;
// Accept either a message (rich: DPs + "not seen yet") or a bare names[] (fallback).
const seenNames = Array.isArray(msg) ? msg : (Array.isArray(msg&&msg.seenBy) ? msg.seenBy.slice() : []);
const senderId = (msg && !Array.isArray(msg)) ? msg.from : null;
const members = (convoIsGroup && Array.isArray(convoMembers)) ? convoMembers : [];
// name → real DP (match member by name, since seenBy carries names).
const avByName={}; members.forEach(m=>{ avByName[(m.name||'').trim().toLowerCase()] = m.avatar||null; });
// Who hasn't seen it yet = members minus the sender, minus me (I'm reading it), minus those who have.
const seenSet=new Set(seenNames.map(n=>String(n).trim().toLowerCase()));
const notSeen = members.filter(m=> m.id!==senderId && m.id!==(ME&&ME.id) && !seenSet.has((m.name||'').trim().toLowerCase()));
const chip=(name,avatar)=>'<span class="mini-av" style="background:'+avColor(name)+'">'+(avatar?('<img class="av-img" src="'+pEsc(avatar)+'" onerror="this.remove()">'):'')+pEsc(initials(name))+'</span>';
const rowsFromNames=(arr)=>arr.map(n=>'<div class="seen-row">'+chip(n, avByName[String(n).trim().toLowerCase()]||null)+'<span class="sn">'+pEsc(n)+'</span></div>').join('');
const rowsFromMembers=(arr)=>arr.map(m=>'<div class="seen-row">'+chip(m.name, m.avatar||null)+'<span class="sn">'+pEsc(m.name)+'</span></div>').join('');
let inner='';
if(seenNames.length) inner+='<div class="seen-sec"><span class="dot ok"></span>Seen · '+seenNames.length+'</div>'+rowsFromNames(seenNames);
else inner+='<div class="gi-noresult" style="padding:.6rem .2rem">No one has seen this yet.</div>';
if(convoIsGroup && notSeen.length) inner+='<div class="seen-div"></div><div class="seen-sec dim"><span class="dot"></span>Not seen yet · '+notSeen.length+'</div>'+rowsFromMembers(notSeen);
// ONE scroll container for the whole list (section headers stick as you scroll) — not a scrollbar
// per section, which split the popup confusingly.
const body='<div class="seen-scroll">'+inner+'</div>';
const ov=document.createElement('div'); ov.className='modal-ov'; ov.id='seenModal';
ov.innerHTML='<div class="modal seen-modal"><h3>'+ic('checkCheck',16)+' Message info</h3>'+body+'<div class="bzc-actions"><button type="button" class="bzc-ok" id="seenClose">Close</button></div></div>';
document.body.appendChild(ov);
ov.addEventListener('mousedown',e=>{ if(e.target===ov) ov.remove(); });
ov.querySelector('#seenClose').onclick=()=>ov.remove();
}
function bzConfirm(message, opts){
opts=opts||{};
return new Promise((resolve)=>{
const ov=document.createElement('div'); ov.className='modal-ov';
ov.innerHTML='<div class="modal bz-confirm"><h3>'+pEsc(opts.title||'Please confirm')+'</h3>'
+'<p class="bzc-msg">'+pEsc(message||'Are you sure?')+'</p>'
+'<div class="bzc-actions"><button type="button" class="bzc-cancel">'+pEsc(opts.cancelText||'Cancel')+'</button>'
+'<button type="button" class="bzc-ok'+(opts.danger?' danger':'')+'">'+pEsc(opts.okText||'Confirm')+'</button></div></div>';
document.body.appendChild(ov);
let done=false;
const finish=(v)=>{ if(done) return; done=true; obs.disconnect(); if(document.body.contains(ov)) ov.remove(); resolve(v); };
const obs=new MutationObserver(()=>{ if(!document.body.contains(ov)) finish(false); }); // Esc/global removal → cancel
obs.observe(document.body,{childList:true});
ov.querySelector('.bzc-ok').onclick=()=>finish(true);
ov.querySelector('.bzc-cancel').onclick=()=>finish(false);
ov.addEventListener('mousedown',(e)=>{ if(e.target===ov) finish(false); }); // backdrop click
setTimeout(()=>{ const b=ov.querySelector('.bzc-ok'); if(b) b.focus(); },0);
});
}
// 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(!(await bzConfirm('This message will be removed for everyone in the chat.', {title:'Delete message?', okText:'Delete', danger:true}))) 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); }
// #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!=='read' && it.last_status!=='delivered'){ it.last_status='delivered'; renderChats(searchVal()); }
}
// #1: opening a DM loads the true delivered/read state into the thread; make the sidebar tick match
// (it can lag when a delivered/read event arrived before the row reflected my latest message).
function syncDmRowTick(id){
const it=rowFor('dm', id); if(!it || !THREAD.length) return;
const last=[...THREAD].reverse().find(m=>!m.system && !m.deleted); if(!last) return;
if(last.from===ME.id){ const st=last.read_at?'read':(last.delivered_at?'delivered':'sent'); if(it.last_status!==st || it.last_msg_id!==last.id){ it.last_status=st; it.last_from_me=true; it.last_msg_id=last.id; renderChats(searchVal()); } }
}
// Group read: a member opened the group → add them to "Seen by" on my messages up to that time.
function onGroupRead(d){ if(!d||!d.group) return; if(selected && selected.kind==='group' && selected.id===d.group){ THREAD.forEach(m=>{ if(m.created_at<=d.at && m.from!==d.by && !m.system && !m.deleted){ m.seenBy=m.seenBy||[]; if(d.byName && !m.seenBy.includes(d.byName)){ m.seenBy.push(d.byName); if(m.id===_lastGroupId||m.from===ME.id) updateBubble(m); } } }); refreshGroupRowTick(d.group); } } // #2: track everyone's reads on every message
// Live-refresh a group's sidebar tick from the open thread (read-by-all → yellow double tick).
function refreshGroupRowTick(gid){
const row=rowFor('group',gid); if(!row||!row.last_from_me||!(selected&&selected.kind==='group'&&selected.id===gid)) return;
const mine=THREAD.filter(m=>m.from===ME.id && !m.system && !m.deleted); const last=mine[mine.length-1]; if(!last) return;
const others=Math.max(0,(convoMembers?convoMembers.length:0)-1); const rc=Array.isArray(last.seenBy)?last.seenBy.length:0;
row.last_status=(others>0&&rc>=others)?'read':(rc>0?'delivered':'sent');
if(!typingLabel('group',gid)){ const el=listEl&&listEl.querySelector('.chat-row[data-kind="group"][data-id="'+_cssEsc(gid)+'"] .chat-prev'); if(el) el.innerHTML=rowPreviewHTML(row); }
}
// Shared group call: start it (or join the live one — the server returns the existing room).
async function startOrJoinGroupCall(group){
try{ const r=await postJSON('/api/groups/call/start',{ group }); if(r&&r.room){ meetReturn={kind:'group',id:group}; switchTab('meeting'); enterMeeting(r.room); } }
catch(e){ toast(e.message||'Could not start the call'); }
}
function updateCallBtn(active){ const cc=document.getElementById('convoCall'); if(!cc) return; cc.classList.toggle('joinable',active); cc.title=active?'Join call':'Start call'; cc.innerHTML=ic(active?'video':'phone',18)+(active?'<span>Join</span>':''); }
function onGroupCall(d){
if(!d||!d.group) return; const it=rowFor('group',d.group); if(it){ it.callActive=!!d.active; it.callRoom=d.room||null;
if(d.active && d.by && d.by!==ME.id){ it.incomingRoom=d.room; it.callByName=d.startedByName; } else if(!d.active){ it.incomingRoom=null; it.callByName=null; } }
if(selected&&selected.kind==='group'&&selected.id===d.group) updateCallBtn(!!d.active);
if(d.active && d.by && d.by!==ME.id && meetRoom!==d.room) showCallInvite(d.room, d.startedByName, {kind:'group',id:d.group}, d.groupName||(it&&it.name)||'Group'); // ring members in
if(!d.active) dismissCallInvite(d.room); // call ended — stop ringing
renderChats(searchVal());
}
function dismissCallInvite(room){ if(!room) return; const el=document.getElementById('ci-'+room); if(el){ try{ el.remove(); }catch(_){} stopRing(); } }
// 1:1 call: start/join from the DM header; live state updates the button + shows an incoming invite.
async function startOrJoinDmCall(otherId){
try{ const r=await postJSON('/api/calls/dm/start',{ to:otherId }); if(r&&r.room){ const _r=rowFor('dm',otherId); _dmCallWaiting={ name:(_r&&_r.name)||'Contact', avatar:(_r&&_r.avatar)||null }; meetReturn={kind:'dm',id:otherId}; switchTab('meeting'); enterMeeting(r.room); startRingback(); /* #17: ring until they answer */ } }
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'; if(d.lastSeen) it.lastSeen=d.lastSeen; renderChats(searchVal()); } // #2: keep last-seen fresh
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){
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(d.active && d.by && d.by!==ME.id){ it.incomingRoom=d.room; it.callByName=d.byName; } else if(!d.active){ it.incomingRoom=null; it.callByName=null; } } // remember an incoming call so opening the chat can re-show Join
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) dismissCallInvite(d.room); // call ended/declined — stop ringing
renderChats(searchVal());
}
// Incoming-call banner (1:1 call or an add-participant invite) with Join / Dismiss.
// ret: where to land when the call ends (the originating chat), or null for the meetings tab.
// sub: a second line under the caller — e.g. the group name for a group call.
function showCallInvite(room, byName, ret, sub, quiet){
if(!room || document.getElementById('ci-'+room)) return;
startRing();
const who=byName||'Someone';
const line2 = sub ? ('is calling · '+pEsc(sub)) : 'is calling you';
const el=document.createElement('div'); el.className='call-invite'; el.id='ci-'+room;
el.innerHTML='<span class="ci-ico">'+ic('phone',18)+'</span><span class="ci-txt"><b>'+pEsc(who)+'</b><br>'+line2+'</span>'
+'<button class="ci-join">'+ic('video',16)+' Join</button>'
+'<button class="ci-decline" title="Decline">'+ic('callEnd',16)+' Decline</button>';
document.body.appendChild(el);
let closed=false;
const close=()=>{ if(closed) return; closed=true; try{ el.remove(); }catch(_){} stopRing(); };
// Wire Join/Decline BEFORE firing the OS notification so the buttons are live the instant the popup
// shows (previously notify() ran first — the click did nothing until the toast plumbing settled).
el.querySelector('.ci-join').onclick=()=>{ close(); meetReturn=ret||null; switchTab('meeting'); enterMeeting(room); };
el.querySelector('.ci-decline').onclick=()=>{ close(); if(ret&&ret.kind==='dm'){ postJSON('/api/calls/decline',{room}).catch(()=>{}); } }; // 1:1 → notify caller; group → silent
if(!quiet){ try{ notify('📞 '+who, (sub?('Group call · '+sub):'is calling you'), ret&&ret.kind, ret&&ret.id, {persistent:true}); }catch(_){} } // OS notification too (skip when merely re-showing on chat open)
setTimeout(close, 45000);
}
// Scheduled-meeting invitation (toast + the meeting shows up in their list).
function showMeetingInvite(mtg){ if(!mtg) return; try{ playPing(); }catch(_){} toast('📅 '+(mtg.by||'Someone')+' invited you to “'+mtg.title+'”'+(mtg.whenText?(' · '+mtg.whenText):''));
addNotif({icon:'calendar', text:pEsc(mtg.by||'Someone')+' invited you to “'+pEsc(mtg.title||'a meeting')+'”'+(mtg.whenText?(' · '+pEsc(mtg.whenText)):''), link:{kind:'meeting', code:mtg.room}});
try{ notify('📅 Meeting invite', (mtg.by||'Someone')+' invited you to '+(mtg.title||'a meeting')); }catch(_){}
if(currentTab()==='meeting') loadScheduledMeetings(); }
// 10-minute reminder before a scheduled meeting — prominent banner with Join.
function showMeetingReminder(mtg){ if(!mtg||!mtg.room) return; try{ playPing(); }catch(_){}
addNotif({icon:'bell', text:'“'+pEsc(mtg.title||'A meeting')+'” starts in ~10 minutes', link:{kind:'meeting', code:mtg.room}});
try{ notify('⏰ Meeting reminder', (mtg.title||'A meeting')+' starts in ~10 minutes'); }catch(_){}
if(document.getElementById('mr-'+mtg.id)) return;
const el=document.createElement('div'); el.className='call-invite'; el.id='mr-'+mtg.id;
el.innerHTML='<span class="ci-ico">'+ic('calendar',18)+'</span><span class="ci-txt"><b>'+pEsc(mtg.title)+'</b><br>starts in ~10 minutes</span><button class="ci-join">'+ic('video',16)+' Join</button><button class="ci-x" title="Dismiss">'+ic('x',16)+'</button>';
document.body.appendChild(el);
const close=()=>{ try{ el.remove(); }catch(_){} };
el.querySelector('.ci-join').onclick=()=>{ close(); switchTab('meeting'); enterMeeting(mtg.room); };
el.querySelector('.ci-x').onclick=close;
}
// In-call participants panel (everyone) + host controls (mute all, transfer host).
function meetParticipantsList(){ const a=[{id:'__local', name:((ME&&ME.name)||(ME&&ME.email)||'You')+' (you)'}]; meetPeers.forEach((p,pid)=>a.push({id:pid, name:meetNames.get(pid)||p.name||'Guest'})); return a; }
// ---- In-meeting chat: ephemeral (lives only for the call). Message EVERYONE or DIRECT to one person. ----
let meetChatLog=[], meetChatUnread=0, meetChatTo='';
function resetMeetChat(){ meetChatLog=[]; meetChatUnread=0; meetChatTo=''; const c=document.getElementById('meetChat'); if(c) c.remove(); updateMeetChatBadge(); }
function meetChatRecipients(){ const a=[]; meetPeers.forEach((pp,pid)=>a.push({id:pid, name:meetNames.get(pid)||pp.name||'Guest'})); return a; }
function toggleMeetChat(){ const ex=document.getElementById('meetChat'); if(ex){ ex.remove(); return; } const meet=document.querySelector('.meet'); if(!meet) return; const p=document.createElement('div'); p.id='meetChat'; p.className='meet-panel meet-chat'; meet.appendChild(p); meetChatUnread=0; updateMeetChatBadge(); renderMeetChat(); }
function updateMeetChatBadge(){ const b=document.getElementById('meetChatBadge'); if(!b) return; if(meetChatUnread>0){ b.textContent=meetChatUnread>9?'9+':meetChatUnread; b.style.display='grid'; } else b.style.display='none'; }
function renderMeetChat(){
const p=document.getElementById('meetChat'); if(!p) return;
const recips=meetChatRecipients();
if(meetChatTo && !recips.some(r=>r.id===meetChatTo)) meetChatTo=''; // recipient left → fall back to Everyone
const head='<div class="mp-head"><b>'+ic('chat',15)+' Meeting chat</b><span style="flex:1"></span><button class="mp-x" title="Close">'+ic('x',16)+'</button></div>';
const body='<div class="mc-log" id="mcLog">'+(meetChatLog.length?meetChatLog.map(mcRowHTML).join(''):'<div class="mc-empty">No messages yet — say hello 👋</div>')+'</div>';
// The recipient picker lives at the BOTTOM, right where you type (new #3).
const foot='<div class="mc-compose'+(meetChatTo?' private':'')+'">'
+'<div class="mc-to"><span>To</span><select id="mcTo"><option value=""'+(!meetChatTo?' selected':'')+'>Everyone</option>'
+recips.map(r=>'<option value="'+pEsc(r.id)+'"'+(meetChatTo===r.id?' selected':'')+'>'+pEsc(r.name)+' (private)</option>').join('')+'</select></div>'
+'<form class="mc-foot" id="mcForm"><input id="mcInput" placeholder="'+(meetChatTo?'Private message…':'Message everyone…')+'" autocomplete="off" maxlength="2000"><button type="submit" class="mc-send" title="Send">'+ic('send',16)+'</button></form>'
+'</div>';
p.innerHTML=head+body+foot;
p.querySelector('.mp-x').onclick=()=>p.remove();
const sel=p.querySelector('#mcTo'); if(sel) sel.onchange=()=>{ meetChatTo=sel.value; renderMeetChat(); };
const form=p.querySelector('#mcForm'); if(form) form.onsubmit=(e)=>{ e.preventDefault(); meetChatSend(); };
const log=p.querySelector('#mcLog'); if(log) log.scrollTop=log.scrollHeight;
const inp=p.querySelector('#mcInput'); if(inp) setTimeout(()=>inp.focus(),0);
}
function mcRowHTML(m){
const mine=m.from==='__self';
const priv=m.direct?('<span class="mc-priv">'+(mine?('to '+pEsc(m.toName||'someone')):'private')+'</span>'):'';
const head=mine?(m.direct?('<div class="mc-nm">You '+priv+'</div>'):''):('<div class="mc-nm">'+pEsc(m.name||'Guest')+priv+'</div>');
return '<div class="mc-row'+(mine?' mine':'')+(m.direct?' priv':'')+'"><div class="mc-bub">'+head+'<div class="mc-tx">'+fmtInline(m.text||'')+'</div><div class="mc-at">'+fmtClock(m.at||Date.now())+'</div></div></div>';
}
function meetChatSend(){
const inp=document.getElementById('mcInput'); if(!inp) return; const text=(inp.value||'').trim(); if(!text) return;
let toName=''; if(meetChatTo){ const r=meetChatRecipients().find(x=>x.id===meetChatTo); toName=r?r.name:''; }
meetSend({type:'meeting-chat', text, to:meetChatTo||undefined});
meetChatAppend({from:'__self', name:'You', text, direct:!!meetChatTo, toName, at:Date.now()});
inp.value=''; inp.focus();
}
function meetChatAppend(m){
meetChatLog.push(m); if(meetChatLog.length>500) meetChatLog.shift();
// A PRIVATE message to me → my reply defaults back to that person (new #3), so you don't accidentally
// answer a private note in front of everyone.
const privFrom=(m.direct && m.from!=='__self' && meetPeers.has(m.from)) ? m.from : null;
if(privFrom) meetChatTo=privFrom;
const panel=document.getElementById('meetChat');
if(panel){
if(privFrom){ renderMeetChat(); return; } // re-render so the "To" picker + compose tint follow
const log=document.getElementById('mcLog');
if(log){ const empty=log.querySelector('.mc-empty'); if(empty) empty.remove(); log.insertAdjacentHTML('beforeend', mcRowHTML(m)); log.scrollTop=log.scrollHeight; }
}
else if(m.from!=='__self'){ meetChatUnread++; updateMeetChatBadge(); try{ playPing(); }catch(_){} }
}
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(); }
// Guest invite link for the current meeting — anyone with it can join (guest token), even without a
// Connect account. Shared from the meeting's Add-people panel and emailed to invitees (#4).
function meetingLink(){ return location.origin+'/home?meet='+encodeURIComponent(meetRoom||''); }
function copyMeetingLink(){
if(!meetRoom){ toast('No active meeting'); return; }
const url=meetingLink(), done=()=>toast('Invite link copied');
try{ if(navigator.clipboard&&navigator.clipboard.writeText) return void navigator.clipboard.writeText(url).then(done).catch(()=>fallbackCopy(url,done)); }catch(_){}
fallbackCopy(url,done);
}
function fallbackCopy(text,cb){ try{ const ta=document.createElement('textarea'); ta.value=text; ta.style.position='fixed'; ta.style.opacity='0'; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove(); cb&&cb(); }catch(_){ prompt('Copy this link:', text); } }
let meetPanelTab='people'; // 'people' | 'add'
let _addPool=null; // cached candidates for "Add people" (group members for a group call)
function toggleMeetPanel(){ const ex=document.getElementById('meetPanel'); if(ex){ ex.remove(); return; } const meet=document.querySelector('.meet'); if(!meet) return; meetPanelTab='people'; const p=document.createElement('div'); p.id='meetPanel'; p.className='meet-panel'; meet.appendChild(p); renderMeetPanel(); }
function renderMeetPanel(){
const p=document.getElementById('meetPanel'); if(!p) return;
const list=meetParticipantsList();
const isHostRow=pp=> pp.id==='__local' ? meetIsHost : pp.id===meetHostId; // YOUR row uses meetIsHost
const head='<div class="mp-head"><b>Participants</b><span style="flex:1"></span>'+((meetIsHost&&meetPanelTab==='people')?'<button class="mp-muteall" title="Mute everyone">'+ic('micOff',14)+' Mute all</button>':'')+'<button class="mp-x" title="Close">'+ic('x',16)+'</button></div>';
// Guests can't add participants (#9) — show only the "In call" list, no Add-people tab.
const isGuest=!!(ME&&ME.guest); if(isGuest && meetPanelTab==='add') meetPanelTab='people';
const tabs='<div class="mp-tabs"><button class="mp-tab'+(meetPanelTab==='people'?' on':'')+'" data-tab="people">In call ('+list.length+')</button>'+(isGuest?'':'<button class="mp-tab'+(meetPanelTab==='add'?' on':'')+'" data-tab="add">'+ic('userPlus',13)+' Add people</button>')+'</div>';
let body;
if(meetPanelTab==='add'){
// Group call → only that group's members may be added. 1:1/ad-hoc → all team contacts.
const inGroup = meetReturn && meetReturn.kind==='group';
if(inGroup && _addPool===null){ _addPool=[]; fetch('/api/groups/members?group='+encodeURIComponent(meetReturn.id)).then(r=>r.json()).then(ms=>{ _addPool=(Array.isArray(ms)?ms:[]).map(x=>({id:x.id,name:x.name})); if(document.getElementById('meetPanel')&&meetPanelTab==='add') renderMeetPanel(); }).catch(()=>{ _addPool=[]; }); }
// #4: 1:1/ad-hoc → only people you've MESSAGED (existing DMs), not the whole team.
const pool = inGroup ? (_addPool||[]) : (ROWS||[]).filter(r=>r.kind==='dm' && !r.self).map(r=>({id:r.id, name:r.name}));
const hereUids=new Set(); meetPeerUids.forEach(uid=>hereUids.add(uid)); if(ME&&ME.id) hereUids.add(ME.id);
const here=new Set(list.map(pp=>(pp.name||'').replace(/\s*\(you\)$/,'').trim().toLowerCase()));
const myName=((ME&&ME.name)||(ME&&ME.email)||'').trim().toLowerCase();
const avail=pool.filter(c=>c.id!==(ME&&ME.id) && !hereUids.has(c.id) && (c.name||'').trim().toLowerCase()!==myName && !here.has((c.name||'').trim().toLowerCase()) && !meetInvited.has(c.id));
body='<div class="mp-invite-link">'+ic('link',14)+'<div class="mil-txt"><b>Invite by link</b><span>Anyone with the link can join — even without a Connect account.</span></div><button class="mp-copylink" id="mpCopyLink">'+ic('copy',13)+' Copy</button></div>'
+(avail.length>5?'<div class="gi-search" style="margin:.15rem .5rem .35rem"><input id="mpSearch" placeholder="Search people…" autocomplete="off"></div>':'') // #5: find people fast
+'<div class="mp-list">'+(avail.length?avail.map(c=>'<label class="chk" data-nm="'+pEsc((c.name||'').toLowerCase())+'"><input type="checkbox" value="'+pEsc(c.id)+'"><span class="mini-av" style="background:'+avColor(c.name)+'">'+pEsc(initials(c.name))+'</span><span class="mn">'+pEsc(c.name)+'</span></label>').join(''):'<div class="gi-noresult">'+(inGroup&&_addPool===null?'Loading…':'No one left to add')+'</div>')+'</div>'+(avail.length?'<button class="gobtn mp-invite-sticky" id="mpInvite">Invite to call</button>':'');
} else {
body='<div class="mp-list">'+list.map(pp=>'<div class="mp-row"><span class="mini-av" style="background:'+avColor(pp.name)+'">'+pEsc(initials(pp.name))+'</span><span class="mn">'+pEsc(pp.name)+'</span>'+(isHostRow(pp)?'<span class="host-tag">'+ic('crown',11)+' Host</span>':'')+((pp.id==='__local'?meetScreen:meetSharers.has(pp.id))?'<span class="pp-screen" title="Sharing screen">'+ic('monitor',13)+'</span>':'')+(meetMuted.get(pp.id)?'<span class="pp-mute">'+ic('micOff',13)+'</span>':'')+((meetIsHost&&pp.id!=='__local'&&!isHostRow(pp))?'<button class="mp-makehost" data-id="'+pEsc(pp.id)+'" title="Make host">'+ic('crown',14)+'</button>':'')+'</div>').join('')+'</div>'
+(meetInvited.size?'<div class="mp-sec">Not joined yet</div><div class="mp-list">'+[...meetInvited.entries()].map(([uid,e])=>'<div class="mp-row waiting"><span class="mini-av" style="background:'+avColor(e.name)+'">'+pEsc(initials(e.name))+'</span><span class="mn">'+pEsc(e.name)+'</span><span class="pp-wait">'+ic('calendarClock',12)+' waiting…</span></div>').join('')+'</div>':'')
+(meetIsHost?'<label class="gi-setting mp-setting"><span>'+ic('monitor',15)+' Allow multiple people to share</span><span class="switch"><input type="checkbox" id="mpMulti"'+(meetMultiShare?' checked':'')+'><span class="slider"></span></span></label>':'');
}
p.innerHTML=head+tabs+'<div class="mp-scroll">'+body+'</div>';
const x=p.querySelector('.mp-x'); if(x) x.onclick=()=>p.remove();
p.querySelectorAll('.mp-tab').forEach(b=>b.onclick=()=>{ meetPanelTab=b.dataset.tab; renderMeetPanel(); });
const ma=p.querySelector('.mp-muteall'); if(ma) ma.onclick=()=>{ meetSend({type:'meeting-muteall'}); toast('Muted everyone'); };
const mm=p.querySelector('#mpMulti'); if(mm) mm.onchange=()=>{ meetMultiShare=mm.checked; meetSend({type:'meeting-sharemode', multi:meetMultiShare}); };
p.querySelectorAll('.mp-makehost').forEach(b=>b.onclick=()=>{ meetSend({type:'meeting-host', to:b.dataset.id}); meetHostId=b.dataset.id; meetIsHost=false; renderMeetPanel(); });
const cpl=p.querySelector('#mpCopyLink'); if(cpl) cpl.onclick=()=>copyMeetingLink();
{ const ms=p.querySelector('#mpSearch'); if(ms) ms.addEventListener('input',()=>{ const q=ms.value.trim().toLowerCase(); p.querySelectorAll('.mp-list .chk').forEach(l=>{ l.style.display=(!q||(l.dataset.nm||'').includes(q))?'':'none'; }); }); } // #5
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){
if(!room || document.getElementById('invModal')) return;
const ov=document.createElement('div'); ov.className='modal-ov'; ov.id='invModal';
ov.innerHTML='<div class="modal sched"><div class="gi-head" style="margin-bottom:.6rem"><div class="avatar grp" style="width:40px;height:40px;flex:0 0 40px;background:var(--blue)">'+ic('userPlus',20)+'</div><div class="gi-name"><div class="gi-title">Add people to the call</div><div class="gi-sub">They get an incoming-call invite</div></div><button class="iconbtn" id="invClose">'+ic('x',18)+'</button></div>'
+(()=>{ const hereUids=new Set(); meetPeerUids.forEach(uid=>hereUids.add(uid)); if(ME&&ME.id) hereUids.add(ME.id); const cand=(ROWS||[]).filter(r=>r.kind==='dm' && !r.self && !hereUids.has(r.id) && !meetInvited.has(r.id)); return '<div class="gi-list" style="max-height:46vh;overflow:auto">'+(cand.length?cand.map(c=>'<label class="chk"><input type="checkbox" value="'+pEsc(c.id)+'"><span class="mini-av" style="background:'+avColor(c.name)+'">'+pEsc(initials(c.name))+'</span><span class="mn">'+pEsc(c.name)+'</span></label>').join(''):'<div class="gi-noresult">No one left to add</div>')+'</div>'; })()
+'<button class="gobtn mp-invite-sticky" id="invBtn" style="width:100%;margin-top:.7rem;position:sticky;bottom:0">Invite</button></div>';
document.body.appendChild(ov);
ov.onclick=e=>{ if(e.target===ov) ov.remove(); };
ov.querySelector('#invClose').onclick=()=>ov.remove();
ov.querySelector('#invBtn').onclick=async()=>{ const ids=[...ov.querySelectorAll('input:checked')].map(i=>i.value); if(!ids.length){ toast('Pick people to invite'); return; } try{ const r=await postJSON('/api/calls/invite',{ room, userIds:ids }); ov.remove(); toast('Invited '+r.invited+(r.invited===1?' person':' people')); }catch(e){ toast(e.message||'Could not invite'); } };
}
// Native app (iOS/Android WKWebView): an <a href="/files/.." download> NAVIGATES the single WebView away to
// the raw file — no back button → the app looks frozen (must force-quit). Routing it to Safari fails too,
// because Safari doesn't carry the app's login cookie ("unauthorized"). Correct fix: fetch the file IN the
// WebView (cookie present) and hand the bytes to the OS save/share sheet — a real inline download. That needs
// the Filesystem+Share plugins (added to the mobile app; ships in the next TestFlight build). Until then, fall
// back to iOS long-press "Save to Photos" for images (works today) so nothing breaks. Desktop/web untouched.
async function nativeSaveFile(url, name){
const C=window.Capacitor, P=C&&C.Plugins; const Fs=P&&P.Filesystem, Sh=P&&P.Share;
if(!Fs||!Sh) return false;
try{
const res=await fetch(url, {credentials:'include'}); if(!res.ok) throw new Error('http '+res.status);
const blob=await res.blob();
const b64=await new Promise((ok,no)=>{ const r=new FileReader(); r.onloadend=()=>ok(String(r.result).split(',')[1]||''); r.onerror=no; r.readAsDataURL(blob); });
const fname=(name||url.split('/').pop()||'file').replace(/[^\w.\- ]+/g,'_');
const w=await Fs.writeFile({ path:fname, data:b64, directory:'CACHE' });
await Sh.share({ url:w.uri, title:fname }); // iOS sheet: Save to Files / Save Image / share
}catch(e){ toast("Couldn't save the file"); }
return true; // handled either way — never fall through to navigation
}
(function(){ var C=window.Capacitor; var p=(C&&C.getPlatform)?C.getPlatform():''; if(p!=='ios'&&p!=='android') return;
document.addEventListener('click', function(e){
var a=e.target&&e.target.closest&&e.target.closest('a[download]'); if(!a) return;
var href=a.getAttribute('href'); if(!href) return;
e.preventDefault(); e.stopPropagation(); // never let the WebView navigate to the file
var P=C.Plugins;
if(P&&P.Filesystem&&P.Share){ nativeSaveFile(href, a.getAttribute('download')||''); return; } // real inline save
var isImg = /\.(png|jpe?g|gif|webp|heic|heif|bmp|svg)(\?|#|$)/i.test(href) || !!a.closest('.lightbox');
toast(isImg ? 'Press and hold the image, then tap "Save to Photos".' : 'Saving files needs the latest app update.');
}, true); // capture phase so it wins over the lightbox / bubble handlers
})();
// In-chat image viewer (lightbox): open on click, close on ✕ / backdrop / Esc.
function openLightbox(src){
if(document.getElementById('lightbox')) return;
// #3: build a gallery from the loaded thread's images so ← / → flip through them.
const gallery=THREAD.filter(m=>m.attachment && m.attachment.isImage && !m.deleted).map(m=>'/files/'+m.attachment.id);
let idx=gallery.indexOf(src); const multi=idx>=0 && gallery.length>1; if(idx<0) idx=0;
const ov=document.createElement('div'); ov.className='lightbox'; ov.id='lightbox';
ov.innerHTML='<button class="lb-close" title="Close (Esc)">'+ic('x',22)+'</button>'
+(multi?'<button class="lb-nav lb-prev" title="Previous (←)">'+ic('chevronLeft',18)+'</button>':'')
+'<img src="'+pEsc(src)+'" alt="">'
+(multi?'<button class="lb-nav lb-next" title="Next (→)">'+ic('chevronRight',18)+'</button>':'')
+'<a class="lb-dl" href="'+pEsc(src)+'" download title="Download">'+ic('download',20)+'</a>'
+'<div class="lb-zoom"><button class="lb-zout" title="Zoom out ()">'+ic('search',15)+'<b></b></button>'
+'<button class="lb-zin" title="Zoom in (+)">'+ic('search',15)+'<b>+</b></button></div>'; // #3
document.body.appendChild(ov);
const img=ov.querySelector('img'), dl=ov.querySelector('.lb-dl');
// ---- #3: zoom & pan. Wheel / pinch to zoom, double-click or +/ to step, drag to pan when zoomed. ----
let z=1, tx=0, ty=0;
const MINZ=1, MAXZ=6;
const apply=()=>{
if(z<=1){ z=1; tx=0; ty=0; }
img.style.transform='translate('+tx+'px,'+ty+'px) scale('+z+')';
img.classList.toggle('zoomed', z>1);
ov.classList.toggle('zooming', z>1); // hide the ←/→ arrows while zoomed so panning isn't hijacked
};
const zoomAt=(factor, cx, cy)=>{
const prev=z; z=Math.min(MAXZ, Math.max(MINZ, z*factor));
if(z===prev) return;
const r=img.getBoundingClientRect();
const ox=(cx===undefined?r.left+r.width/2:cx)-(r.left+r.width/2);
const oy=(cy===undefined?r.top+r.height/2:cy)-(r.top+r.height/2);
const k=z/prev;
tx=(tx-ox)*k+ox; ty=(ty-oy)*k+oy; // keep the point under the cursor anchored
apply();
};
const resetZoom=()=>{ z=1; tx=0; ty=0; apply(); };
ov.addEventListener('wheel',(e)=>{ e.preventDefault(); zoomAt(e.deltaY<0?1.18:1/1.18, e.clientX, e.clientY); },{passive:false});
img.addEventListener('dblclick',(e)=>{ e.preventDefault(); if(z>1) resetZoom(); else zoomAt(2.5, e.clientX, e.clientY); });
// drag to pan (only meaningful when zoomed in)
let dragging=false, sx=0, sy=0, stx=0, sty=0;
img.addEventListener('mousedown',(e)=>{ if(z<=1) return; e.preventDefault(); dragging=true; sx=e.clientX; sy=e.clientY; stx=tx; sty=ty; img.classList.add('grabbing'); });
window.addEventListener('mousemove',(e)=>{ if(!dragging) return; tx=stx+(e.clientX-sx); ty=sty+(e.clientY-sy); apply(); });
window.addEventListener('mouseup',()=>{ dragging=false; img.classList.remove('grabbing'); });
// touch: pinch to zoom, one-finger drag to pan when zoomed
let pinchD=0, pz=1, ptx=0, pty=0, tsx=0, tsy=0;
const dist=(t)=>Math.hypot(t[0].clientX-t[1].clientX, t[0].clientY-t[1].clientY);
img.addEventListener('touchstart',(e)=>{
if(e.touches.length===2){ pinchD=dist(e.touches); pz=z; }
else if(e.touches.length===1 && z>1){ tsx=e.touches[0].clientX; tsy=e.touches[0].clientY; ptx=tx; pty=ty; }
},{passive:true});
img.addEventListener('touchmove',(e)=>{
if(e.touches.length===2 && pinchD){ e.preventDefault(); const d=dist(e.touches); z=Math.min(MAXZ,Math.max(MINZ, pz*(d/pinchD))); apply(); }
else if(e.touches.length===1 && z>1){ e.preventDefault(); tx=ptx+(e.touches[0].clientX-tsx); ty=pty+(e.touches[0].clientY-tsy); apply(); }
},{passive:false});
img.addEventListener('touchend',()=>{ pinchD=0; });
const syncArrows=()=>{ if(!multi) return; const pB=ov.querySelector('.lb-prev'), nB=ov.querySelector('.lb-next'); if(pB) pB.style.visibility=idx<=0?'hidden':'visible'; if(nB) nB.style.visibility=idx>=gallery.length-1?'hidden':'visible'; };
const show=(i)=>{ if(!multi) return; idx=Math.max(0, Math.min(gallery.length-1, i)); const u=gallery[idx]; img.src=u; if(dl) dl.setAttribute('href',u); resetZoom(); syncArrows(); }; // clamp (no wrap → no blank slide past the last) + hide the end arrow
const close=()=>{ ov.remove(); document.removeEventListener('keydown', onKey); };
// #8: animated pager — the current image slides fully out in the swipe direction, then the next/prev
// image slides in from the OPPOSITE side. Stops at the first/last.
const pagerGo=(dir)=>{ // dir:+1 next (current exits left, next enters from right), -1 prev (mirror)
const target = idx + (dir>0?1:-1);
if(!multi || target<0 || target>=gallery.length){ img.style.transition='transform .18s'; img.style.transform=''; return; } // at an edge → spring back
const W=(window.innerWidth||400); let done=false;
const finish=()=>{ if(done) return; done=true; img.removeEventListener('transitionend', finish);
show(target); // load the target image; resetZoom clears its transform
img.style.transition='none'; img.style.transform='translateX('+(dir>0?W:-W)+'px)'; // stage off the OPPOSITE edge (next=right, prev=left)
void img.offsetWidth; // force that staged position to PAINT, else the browser animates from the exit side (wrong direction)
img.style.transition='transform .22s cubic-bezier(.3,.72,.3,1)'; img.style.transform='translateX(0)';
setTimeout(()=>{ if(img){ img.style.transition=''; img.style.transform=''; } ov.style.background=''; }, 240);
};
img.style.transition='transform .16s ease-out'; img.style.transform='translateX('+(dir>0?-W:W)+'px)'; // current slides out (next→left, prev→right)
img.addEventListener('transitionend', finish); setTimeout(finish, 180); // fallback if transitionend misses
};
syncArrows(); // initial: hide prev at first / next at last
// #8: swipe DOWN (when not zoomed) to dismiss, like native photo viewers — drag follows the finger and
// fades the backdrop; release past a threshold closes, otherwise it springs back.
let dsy=0, dsx=0, dDrag=false;
img.addEventListener('touchstart',(e)=>{ if(z<=1 && e.touches.length===1){ dDrag=true; dsy=e.touches[0].clientY; dsx=e.touches[0].clientX; } },{passive:true});
img.addEventListener('touchmove',(e)=>{ if(!dDrag||z>1||e.touches.length!==1) return; const dy=e.touches[0].clientY-dsy, dx=e.touches[0].clientX-dsx;
if(dy>0 && dy>Math.abs(dx)){ img.style.transition='none'; img.style.transform='translateY('+dy+'px)'; ov.style.background='rgba(0,0,0,'+Math.max(.15,0.92-dy/500)+')'; } // #8: follow finger down to dismiss
else if(multi && Math.abs(dx)>Math.abs(dy)){ img.style.transition='none'; img.style.transform='translateX('+dx+'px)'; } // #8: follow finger sideways to flip
},{passive:true});
img.addEventListener('touchend',(e)=>{ if(!dDrag) return; dDrag=false; const t=e.changedTouches[0]; const dy=t?(t.clientY-dsy):0, dx=t?(t.clientX-dsx):0;
if(z<=1 && dy>100 && dy>Math.abs(dx)){ close(); return; } // #8: swipe down = dismiss
if(z<=1 && multi && Math.abs(dx)>50 && Math.abs(dx)>Math.abs(dy)){ pagerGo(dx<0?1:-1); return; } // #8: swipe left/right = animated next/prev
img.style.transition='transform .18s'; img.style.transform=''; ov.style.background=''; setTimeout(()=>{ if(img) img.style.transition=''; },200); // no nav → spring back
},{passive:true});
const onKey=(e)=>{
if(e.key==='Escape'){ e.preventDefault(); if(z>1){ resetZoom(); return; } close(); }
else if(e.key==='ArrowLeft'){ e.preventDefault(); show(idx-1); }
else if(e.key==='ArrowRight'){ e.preventDefault(); show(idx+1); }
else if(e.key==='+'||e.key==='='){ e.preventDefault(); zoomAt(1.3); }
else if(e.key==='-'||e.key==='_'){ e.preventDefault(); zoomAt(1/1.3); }
else if(e.key==='0'){ e.preventDefault(); resetZoom(); }
};
ov.addEventListener('click',(e)=>{
if(e.target.closest('.lb-zin')){ zoomAt(1.3); return; }
if(e.target.closest('.lb-zout')){ zoomAt(1/1.3); return; }
if(e.target.closest('.lb-prev')){ show(idx-1); return; }
if(e.target.closest('.lb-next')){ show(idx+1); return; }
if(e.target.closest('.lb-close')) return close();
if(e.target===ov){ if(z>1){ resetZoom(); return; } close(); } // clicking the backdrop un-zooms first
});
document.addEventListener('keydown', onKey);
}
function updateBubble(m){
const sel=(window.CSS&&CSS.escape)?CSS.escape(m.id):m.id;
const el=document.querySelector('#msgs .bubble[data-id="'+sel+'"]');
if(el){ el.outerHTML=bubbleHTML(m); const n=document.querySelector('#msgs .bubble[data-id="'+sel+'"]'); twemojify(n); }
}
// Floating date pill (updates to the day at the top of the viewport) + jump-to-latest button.
function onMsgsScroll(){
const box=document.getElementById('msgs'); if(!box) return;
if(box.scrollTop < 700 && box.scrollTop >= 0 && _hasMoreOlder && !_loadingOlder && Date.now()>_olderCd) loadOlder(); // pull older history EARLY (while still scrolling down, before the rubber-band top) so the prepend+anchor never interrupts momentum; skip negative (rubber-band) scrollTop
const nearBottom=(box.scrollHeight - box.scrollTop - box.clientHeight) < 120;
const jl=document.getElementById('jumpLatest'); if(jl) jl.style.display=nearBottom?'none':'grid';
const fd=document.getElementById('floatDate'); if(!fd) return;
if(nearBottom){ fd.style.display='none'; return; }
const boxTop=box.getBoundingClientRect().top; let label='';
box.querySelectorAll('.day-sep').forEach(s=>{ if(s.getBoundingClientRect().top - boxTop <= 10) label=s.textContent; });
if(label){ fd.style.top=(box.offsetTop+6)+'px'; fd.textContent=label; fd.style.display='block'; } else fd.style.display='none';
}
function dayKey(ts){ return new Date(ts||Date.now()).toDateString(); }
function dayLabel(ts){ const d=new Date(ts||Date.now()), n=new Date(); if(d.toDateString()===n.toDateString()) return 'Today'; const y=new Date(n); y.setDate(n.getDate()-1); if(d.toDateString()===y.toDateString()) return 'Yesterday'; return d.toLocaleDateString([], {weekday:'long', month:'long', day:'numeric', year:'numeric'}); }
let _lastDay='', _lastMineId='', _lastGroupId=''; // _lastGroupId: the group's newest message shows the shared "Seen by"
function lastMineId(){ for(let i=THREAD.length-1;i>=0;i--){ if(THREAD[i].from===ME.id && !THREAD[i].system) return THREAD[i].id; } return ''; }
function lastGroupMsgId(){ for(let i=THREAD.length-1;i>=0;i--){ const m=THREAD[i]; if(!m.system && !m.deleted) return m.id; } return ''; } // #2: last real message in the group (any sender)
function renderThread(keepScroll){
const box=document.getElementById('msgs'); if(!box) return;
rendered.clear(); _lastDay=''; _lastMineId=lastMineId(); _lastGroupId=lastGroupMsgId();
if(!THREAD.length){ box.innerHTML='<div class="empty-thread">No messages yet — say hello 👋</div>'; return; }
let html='';
for(const m of THREAD){ const dk=dayKey(m.created_at); if(dk!==_lastDay){ html+='<div class="day-sep"><span>'+pEsc(dayLabel(m.created_at))+'</span></div>'; _lastDay=dk; } rendered.add(m.id); html+=bubbleHTML(m); }
box.innerHTML=html; twemojify(box);
if(!keepScroll){
if(_convoRevealed){
// #7: a later render during the SAME open (network arriving after the cache render) — just stay at
// the newest message, no hide/reveal, so there's no second flash/"dance".
box.scrollTop=box.scrollHeight; requestAnimationFrame(()=>{ const b=document.getElementById('msgs'); if(b) b.scrollTop=b.scrollHeight; });
} else {
// #7: FIRST render — show the newest message IMMEDIATELY (visible), so the slide-in transition
// reveals real content rather than an empty pane that then pops (that was the "jumping" feel). The
// pin schedule + image-load pins in openConvo keep it glued to the bottom as late images load, so it
// still doesn't dance.
_convoRevealed=true; box.style.visibility='visible'; box.scrollTop=box.scrollHeight;
requestAnimationFrame(()=>{ const b=document.getElementById('msgs'); if(b && !b.querySelector('.new-sep')) b.scrollTop=b.scrollHeight; });
}
}
if(_selMode) box.querySelectorAll('.bubble').forEach(b=>{ if(_selIds.has(b.dataset.id)) b.classList.add('selected'); }); // #1: keep selection across re-render
}
function appendBubble(m){
// (Open is now a compositor opacity/transform fade — a DOM write during it doesn't stall the animation and is
// masked by the low opacity, so we no longer defer appends to the end. That's what removed the settle-on-landing.)
if(rendered.has(m.id)) return; rendered.add(m.id);
const box=document.getElementById('msgs'); if(!box) return;
// Was the user already at the bottom? If they've scrolled UP to read history, an incoming message
// must NOT yank them back down (that made the thread feel "stuck" — you couldn't stay scrolled up,
// especially now that background messages arrive live). Keep their position + flag "jump to latest".
const atBottom=(box.scrollHeight - box.scrollTop - box.clientHeight) < 160;
const empty=box.querySelector('.empty-thread'); if(empty) empty.remove();
// A new message becomes the newest: move the group "Seen by" off the previous last message.
if(!m.system && !m.deleted){ if(_lastGroupId){ const pe=box.querySelector('.bubble[data-id="'+((window.CSS&&CSS.escape)?CSS.escape(_lastGroupId):_lastGroupId)+'"] .seenby'); if(pe) pe.remove(); } _lastGroupId=m.id; if(m.from===ME.id) _lastMineId=m.id; }
const dk=dayKey(m.created_at); if(dk!==_lastDay){ box.insertAdjacentHTML('beforeend', '<div class="day-sep"><span>'+pEsc(dayLabel(m.created_at))+'</span></div>'); _lastDay=dk; }
box.insertAdjacentHTML('beforeend', bubbleHTML(m));
twemojify(box.lastElementChild);
// Auto-scroll only if they were at the bottom, or it's MY own message (sending always jumps down).
if(atBottom || m.from===ME.id){ box.scrollTop=box.scrollHeight; }
else { const jl=document.getElementById('jumpLatest'); if(jl) jl.style.display='grid'; } // let them jump when ready
}
// Slide-end update WITHOUT rebuilding the DOM: the cache render already painted the latest page, and the
// network result is almost always that same page ± a couple of brand-new tail messages. Append only those
// (0-2 nodes) instead of re-running innerHTML over all 40 — that full rebuild's layout+paint was the residual
// slide-end cost. Returns false (→ caller does a full renderThread) only if an already-shown message was
// removed/edited/deleted since the cache render, which is rare and not worth a targeted DOM patch.
function reconcileOpen(prev){
const box=document.getElementById('msgs'); if(!box || !prev || !prev.length) return false;
const nextById=new Map(THREAD.map(m=>[m.id,m]));
for(const m of prev){ const n=nextById.get(m.id); if(!n || (m.edited_at||0)!==(n.edited_at||0) || !!m.deleted!==!!n.deleted) return false; }
for(const m of THREAD){ if(!rendered.has(m.id)) appendBubble(m); } // we're at the bottom on open → appendBubble keeps us pinned to the newest
return true;
}
let _openUnread=0; // set by selectChat (the unread count before it's reset) so we can open at the first unread
// Per-conversation drafts: unsent text is kept (across chat switches AND reloads) until sent.
function draftKey(kind,id){ return 'draft_'+((ME&&ME.id)||'')+'_'+kind+':'+id; }
function getDraft(kind,id){ try{ return localStorage.getItem(draftKey(kind,id))||''; }catch(_){ return ''; } }
function setDraft(kind,id,val){ try{ if(val&&val.trim()) localStorage.setItem(draftKey(kind,id), val); else localStorage.removeItem(draftKey(kind,id)); }catch(_){} }
// Compositor-only open slide (workflow-verified). armOpenSlide() runs AFTER openConvo's synchronous
// render: it pins the pane off-screen (chat-opening), lets a frame paint it, then adds .chat-anim on a
// double-rAF so the compositor runs the 300ms transform with the main thread idle. All scrollTop/innerHTML
// work (network re-render + pin schedule + live appends) is queued via afterOpenSlide and flushed only at
// transitionend. A token guards rapid chat-to-chat re-taps; a single stored handler avoids listener leaks.
let _slideQueue=[], _slideEndHandler=null;
function afterOpenSlide(fn){ if(!document.body.classList.contains('chat-opening')){ fn(); return; } _slideQueue.push(fn); }
function flushSlideQueue(){ const q=_slideQueue; _slideQueue=[]; for(const fn of q){ try{ fn(); }catch(_){} } }
function armOpenSlide(){
// OPEN = instant + a quick opacity fade (user-chosen). The cache-rendered conversation is already in place;
// we just fade it from 0→1. No transform, no off-screen pane, no list-column display toggle → nothing to
// reflow when it ends, so the old end-of-slide settle cannot occur. Queued network reconcile / pins / live
// appends still flush at fade-end via the same afterOpenSlide mechanism. Token guards rapid chat re-taps.
const b=document.body, c=document.querySelector('.shell > .content');
const token=(window.__slideTok=(window.__slideTok||0)+1);
if(c && _slideEndHandler){ c.removeEventListener('transitionend', _slideEndHandler); _slideEndHandler=null; }
clearTimeout(window.__chatOpenT);
if(c) c.classList.remove('chat-fadein');
b.classList.add('chat-opening'); // content -> opacity:0 (still laid out, just invisible)
const done=()=>{ if(window.__slideTok!==token) return;
if(c && _slideEndHandler){ c.removeEventListener('transitionend', _slideEndHandler); _slideEndHandler=null; }
clearTimeout(window.__chatOpenT);
b.classList.remove('chat-opening'); // opacity returns to the resting 1 — no transform/list toggle to reflow
if(c) c.classList.remove('chat-fadein');
flushSlideQueue(); // network reconcile / pins / queued appends run now
};
_slideEndHandler=function(e){ if(e.target===c && e.propertyName==='transform') done(); }; // transform is the longer of the two transitions → fires after both finish
if(c) c.addEventListener('transitionend', _slideEndHandler);
requestAnimationFrame(()=>{
if(window.__slideTok!==token) return;
if(c) c.classList.add('chat-fadein'); // slide translateX(12%)->0 + opacity 0->1 (pure compositor)
window.__chatOpenT=setTimeout(done, 320); // fallback if transitionend is missed (240ms + margin)
});
}
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)
_convoRevealed=false; // #7: this open hasn't revealed yet
_forcePinOpen=true; // glue to newest until the user actually scrolls (cleared on the first touch/wheel below)
let _openScrolled=false, _openPinTimers=[]; // the moment the user scrolls we STOP the open-pin schedule so it can't yank them back off older history they're trying to reach
convoIsGroup=(kind==='group');
const el=document.getElementById('chatPanel'); el.classList.remove('center');
el.innerHTML=convoShellHTML(it);
// (The open slide/parallax is driven by the body.chat-opening class added in selectChat — animating the
// whole .content pane over the list, not the inner panel, so it reads as a native push.)
// #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(); cancelEdit(); hideAttach();
const ab2=document.getElementById('attachBtn'); const apop=document.getElementById('attachPop');
if(ab2&&apop) ab2.onclick=(e)=>{ e.stopPropagation(); apop.style.display = apop.style.display==='none' ? 'flex' : 'none'; }; // show a composer-anchored menu instead of firing the generic mid-screen OS chooser straight away
if(apop) apop.querySelectorAll('.attach-opt').forEach(b=>b.onclick=(e)=>{ e.stopPropagation(); apop.style.display='none'; const src=b.dataset.src; const inp = src==='photo'?document.getElementById('fileInputImg') : src==='camera'?document.getElementById('fileInputCam') : document.getElementById('fileInput'); if(inp) inp.click(); }); // Photos → gallery, Camera → capture, Document → any file
['fileInput','fileInputImg','fileInputCam'].forEach(fid=>{ const fi=document.getElementById(fid); if(fi) fi.onchange=()=>{ Array.from(fi.files||[]).forEach(uploadFile); fi.value=''; }; }); // #5: queue every selected file from whichever scoped input fired
const eb=document.getElementById('emojiBtn'); if(eb) eb.onclick=(e)=>{ e.stopPropagation(); emojiOpen?closeEmoji():openEmoji('compose', eb); };
const fb=document.getElementById('fmtBtn'); if(fb) fb.onclick=()=>{ const bar=document.getElementById('fmtBar'); if(bar) bar.style.display=bar.style.display==='none'?'flex':'none'; };
const fbar=document.getElementById('fmtBar'); if(fbar) fbar.querySelectorAll('button[data-fmt]').forEach(b=>b.onclick=()=>applyFmt(b.dataset.fmt));
const pb=document.getElementById('pollBtn'); if(pb) pb.onclick=()=>openPollModal(id);
const inpEl=document.getElementById('msgInput');
if(inpEl){
const _d=getDraft(kind,id); if(_d){ inpEl.value=_d; autoGrow(inpEl); } // restore unsent draft
inpEl.addEventListener('paste', onPaste);
inpEl.addEventListener('input', ()=>{ maybeAutocorrect(inpEl); autoGrow(inpEl); setDraft(kind,id,inpEl.value); noteTyping(); }); // auto-correct common typos, keep the draft in sync + emit "typing…"
inpEl.addEventListener('blur', ()=>{ stopTyping(); setTimeout(()=>{ const a=document.activeElement; const stillTyping=a&&(a.tagName==='TEXTAREA'||a.tagName==='INPUT'); if(!stillTyping && window.__bzResetKb) window.__bzResetKb(); }, 120); }); // #1: composer lost focus → keyboard is closing → drop the --kb lift so no empty gap remains
inpEl.addEventListener('keydown', (e)=>{ if(e.key==='Enter' && !e.shiftKey && !isMobileUA()){ if(mentionItems && mentionItems.length) return; e.preventDefault(); sendMessage(); } }); // Desktop: Enter sends, Shift+Enter = newline. Mobile: the Return key inserts a newline (default) — you send with the send button, like every native chat app.
// Desktop: LEFT-click a red-squiggled word → native spelling suggestions right there (the shell
// pops a suggestions menu only if the clicked word is actually misspelled). No right-click needed.
if(window.bizConnectNative && window.bizConnectNative.spellSuggestAt){
inpEl.addEventListener('click',(e)=>{ if(inpEl.selectionStart!==inpEl.selectionEnd) return; try{ window.bizConnectNative.spellSuggestAt(e.clientX, e.clientY); }catch(_){} });
}
}
refreshTyping(kind,id); // if someone's already typing in this conversation, show it right away
// #5: tapping the header avatar previews the DP / group photo full-size.
const hav=document.querySelector('#chatPanel .convo-head > .avatar'); if(hav){ hav.style.cursor='pointer'; hav.title='View photo'; hav.onclick=(e)=>{ e.stopPropagation(); const r=rowFor(kind,id)||it; if(r&&r.avatar) openLightbox(r.avatar); else toast('No profile photo'); }; }
const ci=document.getElementById('convoInfo'); if(ci) ci.onclick=()=>openGroupInfo(id);
const ct=document.getElementById('convoTitle'); if(ct) ct.onclick=()=>{ if(kind==='group') openGroupInfo(id); else { const r=rowFor(kind,id); openSharedItems('dm', id, (r&&r.name)||it.name||''); } }; // name → group: info+media; DM: media/files
const cc=document.getElementById('convoCall'); if(cc) cc.onclick=()=>(kind==='group'?startOrJoinGroupCall(id):startOrJoinDmCall(id));
// If this conversation has an active INCOMING call, (re)show the Join/Decline invite. When the chat
// is opened from a call notification, the original transient invite popup may already have closed —
// this guarantees a landing Join button. `quiet` avoids firing a duplicate OS notification.
if(it.callActive && it.incomingRoom && meetRoom!==it.incomingRoom && !document.getElementById('ci-'+it.incomingRoom)){
showCallInvite(it.incomingRoom, it.callByName||it.name, kind==='group'?{kind:'group',id}:{kind:'dm',id}, kind==='group'?it.name:undefined, true);
}
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)=>{
if(_selMode){ const bb=e.target.closest('.bubble'); if(bb) toggleSel(bb); return; } // #1: selection mode — tap toggles
const fw=e.target.closest('.fwd-btn'); if(fw){ enterSelect(fw.dataset.fwd); return; } // #1: enter forward-selection
const qz=e.target.closest('.quote'); if(qz && qz.dataset.jid){ jumpToMessage(qz.dataset.jid, +qz.dataset.jat||0); return; } // #8: tap a reply → go to the original
// #5: a Biz Connect meeting link posted in chat should JOIN IN PLACE — not open a new tab/window.
const ml=e.target.closest('a.msg-link');
if(ml && ml.href){ try{ const u=new URL(ml.href, location.origin); const code=u.searchParams.get('meet');
if(u.origin===location.origin && code && /^\d{6}$/.test(code)){ e.preventDefault(); switchTab('meeting'); enterMeeting(code); return; } }catch(_){} }
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 ed=e.target.closest('.edit-btn'); if(ed){ const mm=THREAD.find(x=>x.id===ed.dataset.edit); if(mm) startEdit(mm); return; }
const dl=e.target.closest('.del-btn'); if(dl){ deleteMessage(dl.dataset.del); return; }
// #9: a phone number. On mobile the tel: link lets the OS ask "Call this number?" — just let it through.
// On desktop there's no dialer, so offer to copy it instead of doing nothing.
const tel=e.target.closest('.msg-tel');
if(tel){
if(isMobileUA()) return; // OS handles the call prompt
e.preventDefault();
const num=tel.dataset.tel||'';
bzConfirm(num, {title:'Phone number', okText:'Copy number'}).then(ok=>{ if(ok) fallbackCopy(num, ()=>toast('Number copied')); });
return;
}
const pf=e.target.closest('.profile-open'); if(pf){ e.stopPropagation(); openMiniProfile(pf.dataset.uid, pf); return; } // #1: sender → mini profile
const qr=e.target.closest('.qr-btn'); if(qr){ reactToMessage(qr.dataset.id, qr.dataset.emoji); return; } // #4: one-tap reaction
const mb=e.target.closest('.more-btn'); if(mb){ e.stopPropagation(); openMsgMore(mb.dataset.more, mb); return; } // #4: reply/forward/copy/delete
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,.rcpt.grp'); if(sb){ const bub=sb.closest('.bubble'); const mid=bub&&bub.dataset.id; const msg=(mid&&THREAD.find(x=>x.id===mid))||{ seenBy:(sb.dataset.seen||'').split('|').filter(Boolean) }; showSeenByModal(msg); 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){ const _rel=()=>{ _forcePinOpen=false; _openScrolled=true; _openPinTimers.forEach(clearTimeout); _openPinTimers.length=0; }; box.addEventListener('touchmove', _rel, {passive:true}); box.addEventListener('wheel', _rel, {passive:true}); } // the user's first real scroll gesture hands control back: stop force-gluing to the newest AND cancel the pending open-pins so scrolling up for older history isn't yanked back down
if(box) enablePullRefresh(box, loadOlder); // pull down at the top loads OLDER history (or no-ops); never jumps to the newest/bottom
const jl=document.getElementById('jumpLatest'); if(jl) jl.onclick=()=>{ const b=document.getElementById('msgs'); if(b) b.scrollTop=b.scrollHeight; };
composeMentions=new Map(); convoMembers=[];
// Synchronous render from cache (within the click's activation → paints immediately, even
// when opened from a notification; an async-only render would defer the paint until a click).
const ckey=kind+':'+id;
let _renderedOpen=null; // the exact array we painted from cache, to diff against the network result below
if(THREAD_CACHE.has(ckey)){ const _c=THREAD_CACHE.get(ckey); THREAD = _c.length>PAGE ? _c.slice(-PAGE) : _c.slice(); _hasMoreOlder = _c.length>PAGE; _renderedOpen=THREAD; renderThread(); } // open at the LATEST page only: a short DOM pins to the newest reliably even as images load. Re-opening a thread that was scrolled up no longer re-renders 100+ msgs and strands you mid-history — older pages back in on scroll-up.
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
// Synchronous render done (shell + cache thread, already pinned to bottom) → arm the compositor slide now,
// so the transform animates a fully-laid-out, off-screen layer with the main thread idle. Fresh push only.
if(window.__freshOpen){ window.__freshOpen=false; armOpenSlide(); }
if(kind==='group'){ try{ convoMembers=await fetch('/api/groups/members?group='+encodeURIComponent(id)).then(r=>r.json())||[]; }catch(_){ convoMembers=[]; } }
if(!selected||selected.kind!==kind||selected.id!==id) return;
wireMentions();
const url=kind==='group'?('/api/messages/thread?group='+encodeURIComponent(id)):('/api/messages/thread?with='+encodeURIComponent(id));
// msgs stays null on a failed/non-OK fetch (distinct from a genuinely empty [] thread), so a flaky
// desktop network can't be mistaken for "no messages".
let msgs=null; try{ const r=await fetch(url); if(r.ok) msgs=await r.json(); }catch(_){}
if(!selected||selected.kind!==kind||selected.id!==id) return; // switched away while loading
if(!Array.isArray(msgs)){
// Fetch failed / errored — DO NOT blank the chat or overwrite the cache (that wiped messages that
// are still safely on the server → the "messages disappear after reopen" bug). Keep the cached
// render; only show empty if we truly have nothing cached to fall back to.
if(!THREAD_CACHE.has(ckey)){ THREAD=[]; renderThread(); }
const inp0=document.getElementById('msgInput'); if(inp0 && !isMobileUA()) inp0.focus(); // #2: don't auto-open the phone keyboard on chat open
return;
}
// Did the network result EXACTLY match what the cache render already painted? Compare per-message by
// id + edited/deleted (NOT the old length+lastId signature — the cache count drifts every time a live
// message arrives, so that check almost never held and the full innerHTML rebuild ran at slide-end =
// the 40-128ms hitch). An exact match → skip the rebuild entirely and just stay pinned.
const _same = _renderedOpen && _renderedOpen.length===msgs.length && _renderedOpen.every((m,i)=>m.id===msgs[i].id && (m.edited_at||0)===(msgs[i].edited_at||0) && !!m.deleted===!!msgs[i].deleted);
THREAD=msgs;
THREAD_CACHE.set(ckey, THREAD.slice());
_hasMoreOlder = msgs.length >= PAGE; _loadingOlder=false; // a full page back means there may be older history
// Apply the network result NOW (during the fade), not at the landing frame. The fade is compositor-driven,
// so this main-thread work runs smoothly under it and is masked by the low opacity — by the time the pane is
// fully opaque the content is already settled, so there's no "adjust after it lands" hitch.
if(_same){
if(selected&&selected.kind===kind&&selected.id===id){ const b=document.getElementById('msgs'); if(b && !b.querySelector('.new-sep')) b.scrollTop=b.scrollHeight; } // identical to the cache render → just glue to the newest
} else {
if(selected&&selected.kind===kind&&selected.id===id){ if(!reconcileOpen(_renderedOpen)) renderThread(); } // content changed → append the new tail cheaply; full re-render only if the page structure diverged
}
if(kind==='dm') syncDmRowTick(id); // #1: keep the sidebar tick consistent with the loaded thread
// #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.
// #7 (mobile): phones open at the LATEST message (no auto-scroll up to the first unread), which is what
// users expect from a chat app; the divider/jump-up is a desktop convenience only.
if(_unreadN>0 && THREAD.length>=_unreadN && !isMobileUA()){
const fu=THREAD[THREAD.length-_unreadN]; const esc=(window.CSS&&CSS.escape)?CSS.escape(fu.id):fu.id;
const el=document.querySelector('#msgs .bubble[data-id="'+esc+'"]') || document.querySelector('#msgs [data-id="'+esc+'"]');
if(el){ const sep=document.createElement('div'); sep.className='new-sep'; sep.innerHTML='<span>New messages</span>'; el.parentNode.insertBefore(sep, el); el.scrollIntoView({block:'start'}); el.parentNode.scrollTop-=40; const jl=document.getElementById('jumpLatest'); if(jl) jl.style.display='grid'; }
}
const inp=document.getElementById('msgInput'); if(inp && !isMobileUA()) inp.focus(); // #2: on phones, opening a chat should NOT pop the keyboard (user taps the box to type)
// #7: guarantee the newest message is in view on open — force the scroll to the bottom a few times as
// late content (avatars, images, day separators) settles, unless we deliberately stopped at a "New
// messages" divider. This is the safety net over renderThread()'s own pin.
// #7: land on the newest message AND stay there while late content (images/avatars in long threads)
// keeps growing the thread after the first render. Re-pin on a schedule and on every image load, but
// only while still near the bottom, so a user who deliberately scrolled up isn't yanked back down.
const _pinNewest=()=>{ if(_openScrolled) return; if(!(selected&&selected.kind===kind&&selected.id===id)) return; const b=document.getElementById('msgs'); if(!b||b.querySelector('.new-sep')) return; if(_forcePinOpen || (b.scrollHeight-b.scrollTop-b.clientHeight)<1200) b.scrollTop=b.scrollHeight; }; // stops the instant the user scrolls (_openScrolled); until then _forcePinOpen forces the newest into view as late images load
// Re-glue to the newest as late images/day-separators grow the thread. Spread over time (the compositor fade
// is unaffected by these main-thread pins), so "open at latest" holds while images finish loading.
if(!_openScrolled) _openPinTimers=[0,120,320,600,1000,1600,2400,3400].map((d)=>setTimeout(_pinNewest, d)); // cancellable: the user's first scroll clears these so an early scroll-up for older history isn't yanked back
{ const _pinBox=document.getElementById('msgs'); if(_pinBox){ _pinBox.querySelectorAll('img').forEach((im)=>{ if(!im.complete) im.addEventListener('load', _pinNewest, {once:true}); }); } } // _pinNewest self-guards on _openScrolled, so these late image-load pins also stop once the user scrolls
}
// ---------- @mentions (group chat) ----------
let mentionItems=[], mentionIdx=0, mentionStart=-1;
function wireMentions(){
const inp=document.getElementById('msgInput'); if(!inp) return;
inp.addEventListener('input', onMentionInput);
inp.addEventListener('keydown', onMentionKey);
inp.addEventListener('blur', ()=>setTimeout(closeMention,150));
}
function onMentionInput(e){
const inp=e.target; if(!convoIsGroup){ closeMention(); return; }
const pos=inp.selectionStart;
const m=inp.value.slice(0,pos).match(/(?:^|\s)@([\p{L}\p{N}_]*)$/u);
if(!m){ closeMention(); return; }
mentionStart=pos-m[1].length-1;
const q=m[1].toLowerCase();
const opts=[];
if(!q||'everyone'.startsWith(q)||'all'.startsWith(q)) opts.push({id:'everyone',name:'everyone',sub:'Notify the whole group'});
for(const mem of convoMembers){ if(mem.id===ME.id) continue; if(!q||(mem.name||'').toLowerCase().includes(q)) opts.push({id:mem.id,name:mem.name,avatar:mem.avatar}); }
if(!opts.length){ closeMention(); return; }
mentionItems=opts.slice(0,8); mentionIdx=0; renderMentionPop();
}
function renderMentionPop(){
const pop=document.getElementById('mentionPop'); if(!pop) return;
pop.innerHTML=mentionItems.map((o,i)=>'<div class="mrow'+(i===mentionIdx?' sel':'')+'" data-i="'+i+'">'
+(o.id==='everyone'?'<span class="mini-av" style="background:var(--blue)">'+ic('users',15)+'</span>':'<span class="mini-av" style="background:'+avColor(o.name)+'">'+pEsc(initials(o.name))+(o.avatar?'<img class="av-img" src="'+pEsc(o.avatar)+'" alt="" onerror="this.remove()">':'')+'</span>')
+'<span class="mn">'+(o.id==='everyone'?'@everyone':pEsc(o.name))+'</span>'+(o.sub?'<span class="sub">'+pEsc(o.sub)+'</span>':'')+'</div>').join('');
pop.style.display='block';
pop.querySelectorAll('.mrow').forEach(r=>{ r.onmousedown=(ev)=>{ ev.preventDefault(); chooseMention(+r.dataset.i); }; });
}
function closeMention(){ const pop=document.getElementById('mentionPop'); if(pop){ pop.style.display='none'; pop.innerHTML=''; } mentionItems=[]; mentionStart=-1; }
function onMentionKey(e){
if(!mentionItems.length) return;
if(e.key==='ArrowDown'){ e.preventDefault(); mentionIdx=(mentionIdx+1)%mentionItems.length; renderMentionPop(); }
else if(e.key==='ArrowUp'){ e.preventDefault(); mentionIdx=(mentionIdx-1+mentionItems.length)%mentionItems.length; renderMentionPop(); }
else if(e.key==='Enter'||e.key==='Tab'){ e.preventDefault(); chooseMention(mentionIdx); }
else if(e.key==='Escape'){ closeMention(); }
}
function chooseMention(i){
const o=mentionItems[i]; if(!o) return;
const inp=document.getElementById('msgInput'); if(!inp||mentionStart<0) return;
const pos=inp.selectionStart;
const before=inp.value.slice(0,mentionStart), after=inp.value.slice(pos);
const token=(o.id==='everyone')?'@everyone':('@'+o.name);
inp.value=before+token+' '+after;
const np=(before+token+' ').length; inp.setSelectionRange(np,np);
composeMentions.set(o.id==='everyone'?'everyone':token, o.id);
closeMention(); inp.focus();
}
// Resolve the draft's mentions to ids (only those still present in the text) + literal @everyone/@all.
function collectMentions(text){
const out=[];
for(const [tok,idv] of composeMentions){ if(tok==='everyone') continue; if(text.includes(tok)) out.push(idv); }
if(/(?:^|\s)@(everyone|all)\b/i.test(text)) out.push('everyone');
return [...new Set(out)];
}
// ---- Composer formatting toolbar (Markdown-style) ----
function applyFmt(kind){
const inp=document.getElementById('msgInput'); if(!inp) return;
const wrap=(pre,suf)=>{ const s=inp.selectionStart, e=inp.selectionEnd, sel=inp.value.slice(s,e)||'text'; inp.value=inp.value.slice(0,s)+pre+sel+suf+inp.value.slice(e); inp.focus(); inp.setSelectionRange(s+pre.length, s+pre.length+sel.length); autoGrow(inp); };
const prefixLines=(ol)=>{ const v=inp.value; let s=inp.selectionStart, e=inp.selectionEnd; let ls=v.lastIndexOf('\n', s-1)+1, le=v.indexOf('\n', e); if(le===-1) le=v.length; const lines=v.slice(ls,le).split('\n'); const out=lines.map((ln,i)=>(ol?((i+1)+'. '):'- ')+ln).join('\n'); inp.value=v.slice(0,ls)+out+v.slice(le); inp.focus(); inp.setSelectionRange(ls, ls+out.length); autoGrow(inp); };
if(kind==='bold') wrap('**','**'); else if(kind==='italic') wrap('*','*'); else if(kind==='strike') wrap('~~','~~'); else if(kind==='code') wrap('`','`'); else if(kind==='ul') prefixLines(false); else if(kind==='ol') prefixLines(true);
}
// Inline Markdown on an already-HTML-escaped line (code/bold/strike/italic).
function fmtInline(s){
// #1: make URLs clickable (on the already-escaped text). Trailing punctuation is left outside the link.
s=s.replace(/(https?:\/\/[^\s<]+|www\.[^\s<]+)/g,(u)=>{ let tail=''; const mt=u.match(/[.,;:!?)\]}'"]+$/); if(mt){ tail=mt[0]; u=u.slice(0,-tail.length); } const href=/^https?:\/\//i.test(u)?u:('https://'+u); return '<a href="'+href+'" target="_blank" rel="noopener noreferrer" class="msg-link">'+u+'</a>'+tail; });
// #9: phone numbers → tap to call. Deliberately conservative so we don't linkify amounts, dates, IDs or
// 6-digit meeting codes: require 1015 digits, and either a leading + / 0 or grouping separators. Skip
// anything already inside a link (e.g. a tel:/http URL we just wrapped).
s=s.replace(/(?<!["'>=\/\w])((?:\+\d{1,3}[\s-]?)?(?:\(\d{2,4}\)[\s-]?)?\d[\d\s-]{8,16}\d)(?![\w<])/g,(raw)=>{
const digits=raw.replace(/\D/g,'');
if(digits.length<10 || digits.length>15) return raw; // too short/long to be a phone
if(!/^\+|[\s-]/.test(raw.trim()) && digits.length!==10) return raw; // a bare digit run must look like a 10-digit number
return '<a href="tel:'+digits+'" class="msg-tel" data-tel="'+digits+'">'+raw+'</a>';
});
s=s.replace(/`([^`\n]+)`/g,'<code>$1</code>');
s=s.replace(/\*\*([^*\n]+)\*\*/g,'<b>$1</b>');
s=s.replace(/~~([^~\n]+)~~/g,'<s>$1</s>');
s=s.replace(/(^|[^\w*])\*([^*\n]+)\*(?=[^\w*]|$)/g,'$1<i>$2</i>');
return s;
}
function fmtMentions(s){ // s already HTML-escaped
s=s.replace(/(^|\s)@(everyone|all)\b/gi,(mm,pre)=>pre+'<span class="mention all">@everyone</span>');
if(convoIsGroup){ for(const mem of convoMembers){ if(!mem.name) continue; const esc=pEsc('@'+mem.name); if(esc&&s.includes(esc)) s=s.split(esc).join('<span class="mention">'+esc+'</span>'); } }
return s;
}
const fmtSeg=(line)=>fmtInline(fmtMentions(line));
// Render a message body: lists (-, *, •, 1.) + inline Markdown + mentions, newlines as <br>.
// #5: a message whose whole body is a GIF media URL (from our GIF picker, or pasted) renders as the GIF.
function isGifUrl(s){ return /^https?:\/\/(media\d*\.giphy\.com\/media\/|.*\.giphy\.com\/.*\/giphy\.gif|c\.tenor\.com\/|media\.tenor\.com\/)/i.test(String(s||'').trim()) || /^https?:\/\/\S+\.gif(\?\S*)?$/i.test(String(s||'').trim()); }
function renderMsgBody(m){
const body=(m.body||'').trim();
if(isGifUrl(body)){ return '<img class="att-img msg-gif" src="'+pEsc(body)+'" data-img="'+pEsc(body)+'" loading="lazy" alt="GIF">'; }
const lines=pEsc(m.body||'').split('\n');
let out='', list=null; const buf=[], para=[];
const flushList=()=>{ if(list){ out+='<'+list+' class="msg-list">'+buf.map(x=>'<li>'+x+'</li>').join('')+'</'+list+'>'; buf.length=0; list=null; } };
const flushPara=()=>{ if(para.length){ out+=para.join('<br>'); para.length=0; } };
for(const ln of lines){
const ul=ln.match(/^\s*(?:[-*•])\s+(.+)$/), ol=ln.match(/^\s*\d+[.)]\s+(.+)$/);
if(ul){ flushPara(); if(list!=='ul'){ flushList(); list='ul'; } buf.push(fmtSeg(ul[1])); }
else if(ol){ flushPara(); if(list!=='ol'){ flushList(); list='ol'; } buf.push(fmtSeg(ol[1])); }
else { flushList(); para.push(fmtSeg(ln)); }
}
flushList(); flushPara();
return out;
}
// ---------- Polls (group chat) ----------
function pollHTML(m){
const p=m.poll; if(!p) return '';
const max=Math.max(1, ...p.options.map(o=>o.votes||0));
const opts=p.options.map((o,i)=>{
const pct=p.totalVotes?Math.round(o.votes/p.totalVotes*100):0;
const w=Math.round((o.votes||0)/max*100);
return '<button class="poll-opt'+(o.mine?' mine':'')+'" data-poll="'+pEsc(p.id)+'" data-idx="'+i+'"'+(p.closed?' disabled':'')+'>'
+'<span class="po-bar" style="width:'+w+'%"></span>'
+'<span class="po-txt">'+(o.mine?ic('check',13):'')+' '+pEsc(o.text)+'</span>'
+'<span class="po-pct">'+pct+'% · '+(o.votes||0)+'</span></button>';
}).join('');
const foot='<div class="poll-foot">'+p.voters+' voter'+(p.voters===1?'':'s')+(p.multi?' · choose multiple':'')+(p.closed?' · <b>Closed</b>':'')
+((p.isOwner&&!p.closed)?' · <a class="poll-close" data-poll="'+pEsc(p.id)+'">Close poll</a>':'')+'</div>';
return '<div class="poll" data-msg="'+pEsc(m.id)+'"><div class="poll-q">'+ic('barChart',14)+' Poll'+(p.multi?' · multiple':'')+'</div>'+opts+foot+'</div>';
}
function updatePollInThread(messageId, poll){
const m=THREAD.find(x=>x.id===messageId); if(m) m.poll=poll;
document.querySelectorAll('.poll').forEach(el=>{ if(el.getAttribute('data-msg')===messageId && m){ el.outerHTML=pollHTML(m); } });
}
async function votePoll(pollId, idx){
try{ const poll=await postJSON('/api/polls/vote',{ pollId, optionIdx:idx }); const m=THREAD.find(x=>x.poll&&x.poll.id===pollId); if(m) updatePollInThread(m.id, poll); }
catch(e){ toast(e.message||'Could not vote'); }
}
async function closePoll(pollId){
if(!confirm('Close this poll? No more votes can be cast.')) return;
try{ const poll=await postJSON('/api/polls/close',{ pollId }); const m=THREAD.find(x=>x.poll&&x.poll.id===pollId); if(m) updatePollInThread(m.id, poll); }
catch(e){ toast(e.message||'Could not close poll'); }
}
function onPollUpdate(d){ const m=THREAD.find(x=>x.poll&&x.poll.id===d.poll.id); if(m) updatePollInThread(m.id, d.poll); }
function openPollModal(gid){
if(document.getElementById('pollModal')) return;
const ov=document.createElement('div'); ov.className='modal-ov'; ov.id='pollModal';
ov.innerHTML='<div class="modal sched">'
+'<div class="gi-head" style="margin-bottom:.6rem"><div class="avatar grp" style="width:40px;height:40px;flex:0 0 40px;background:var(--blue)">'+ic('barChart',20)+'</div><div class="gi-name"><div class="gi-title">Create a poll</div><div class="gi-sub">Members vote in the group</div></div><button class="iconbtn" id="pollClose">'+ic('x',18)+'</button></div>'
+'<label class="flbl">Question</label><input id="pollQ" class="finput" placeholder="What should we decide?" maxlength="300">'
+'<label class="flbl">Options</label><div id="pollOpts"></div>'
+'<button class="linkbtn" id="pollAdd" style="margin-top:.4rem">'+ic('plus',15)+' Add option</button>'
+'<label class="chk" style="margin-top:.7rem"><input type="checkbox" id="pollMulti"> Allow multiple choices</label>'
+'<button class="gobtn" id="pollCreate" style="width:100%;margin-top:.9rem;background:var(--blue);color:#fff">Create poll</button>'
+'<div class="hint" id="pollErr"></div></div>';
document.body.appendChild(ov);
const optsWrap=ov.querySelector('#pollOpts');
const addOpt=()=>{ if(optsWrap.children.length>=10) return; const row=document.createElement('div'); row.className='poll-opt-row'; row.innerHTML='<input class="finput" maxlength="120" placeholder="Option '+(optsWrap.children.length+1)+'"><button class="iconbtn rm" title="Remove">'+ic('x',15)+'</button>'; optsWrap.appendChild(row); row.querySelector('.rm').onclick=()=>{ if(optsWrap.children.length>2) row.remove(); }; };
addOpt(); addOpt();
ov.onclick=e=>{ if(e.target===ov) ov.remove(); };
ov.querySelector('#pollClose').onclick=()=>ov.remove();
ov.querySelector('#pollAdd').onclick=addOpt;
setTimeout(()=>{ const q=ov.querySelector('#pollQ'); if(q) q.focus(); },0);
ov.querySelector('#pollCreate').onclick=async()=>{
const q=ov.querySelector('#pollQ').value.trim();
const options=[...optsWrap.querySelectorAll('input')].map(i=>i.value.trim()).filter(Boolean);
const multi=ov.querySelector('#pollMulti').checked;
const err=ov.querySelector('#pollErr');
if(!q){ err.textContent='Add a question.'; return; }
if(options.length<2){ err.textContent='Add at least two options.'; return; }
try{ await postJSON('/api/polls',{ group:gid, question:q, options, multi }); ov.remove(); }
catch(e){ err.textContent=e.message||'Could not create poll'; }
};
}
async function selectChat(kind,id){
ensureNotifyPermission();
stopTyping(); // leaving the previous conversation → tell that peer we stopped
if(_selMode) exitSelect(); // #1: leave forward-selection when changing chats
selected={kind,id};
document.body.classList.add('chat-open'); // mobile: show the conversation pane
// DON'T start the slide here — openConvo builds the shell + renders the thread FIRST, then arms the
// compositor slide (armOpenSlide) once the pane is laid out & painted off-screen, so the transform runs
// with the main thread idle (smooth). Only for a fresh push on mobile.
window.__freshOpen = window.matchMedia('(max-width:760px)').matches;
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()); // lay out the LIST (the parallax layer) BEFORE the slide is armed
updateRailUnread();
await openConvo(kind,id);
}
async function sendMessage(){
const inp=document.getElementById('msgInput'); if(!inp) return;
const text=inp.value.trim();
if(editTarget){ saveEdit(text); return; } // in edit mode → save the edit instead of sending new
const atts=pendingAttachs.slice();
if(atts.some(a=>a.uploading)){ toast('Please wait — files are still uploading…'); return; }
if((!text&&!atts.length)||!selected) return;
stopTyping(); // sending → we're no longer "typing"
inp.value=''; inp.style.height='auto'; setDraft(selected.kind, selected.id, ''); // sent → clear the draft
const replyTo=replyTarget?replyTarget.id:null; const isG=selected.kind==='group';
hideAttach(); clearReply(); cancelEdit();
// #5: one message per attachment (the first carries the typed text as its caption), else a single
// text message. Sent in order so they appear chronologically.
const jobs = atts.length ? atts.map((a,i)=>({ body:i===0?text:'', replyTo:i===0?replyTo:null, attachmentId:a.id })) : [{ body:text, replyTo, attachmentId:null }];
for(const j of jobs){
const payload = isG ? { group:selected.id, body:j.body, replyTo:j.replyTo, attachmentId:j.attachmentId, mentions:collectMentions(j.body) } : { to:selected.id, body:j.body, replyTo:j.replyTo, attachmentId:j.attachmentId };
try{
const m=await postJSON('/api/messages', payload);
// Dedup by id: the server echoes our message over WS (to sync other tabs) and that echo can
// arrive BEFORE this POST resolves, so onChatMessage may have already appended it.
if(!THREAD.some(x=>x.id===m.id)){ THREAD.push(m); appendBubble(m); }
{ const ck=selected.kind+':'+selected.id; if(THREAD_CACHE.has(ck)){ const a=THREAD_CACHE.get(ck); if(!a.some(x=>x.id===m.id)) a.push(m); } }
const it=rowFor(selected.kind,selected.id);
if(it){ it.last_body=m.body||(m.attachment?'📎 '+(m.attachment.name||'Attachment'):''); it.last_at=m.created_at; it.last_from_me=true; it.last_status='sent'; it.last_msg_id=m.id; it.unread=0; }
}catch(e){ toast(e.message||'Could not send'); if(j.body && !inp.value){ inp.value=j.body; try{ autoGrow(inp); }catch(_){} } }
}
composeMentions=new Map();
renderChats(searchVal());
}
// 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{ const p=nativePlatform(); if(p==='ios'||p==='android') return; 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; } }
// 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{
const p=nativePlatform(); if(p==='ios'||p==='android') return; // native app handles its own permission flow
if(!('Notification' in window) || Notification.permission!=='default') return;
if(sessionStorage.getItem('notifPromptDismissed')==='1' || document.getElementById('notifPrompt')) return;
const b=document.createElement('div'); b.id='notifPrompt'; b.className='notif-prompt';
b.innerHTML='<span>'+ic('bell',16)+' Turn on notifications so you dont miss messages &amp; calls.</span><span class="np-actions"><button class="btn sm" id="npEnable">Enable</button><button class="np-x" id="npX" aria-label="Dismiss">'+ic('x',16)+'</button></span>';
document.body.appendChild(b);
document.getElementById('npEnable').onclick=async()=>{ try{ const r=await Notification.requestPermission(); if(r==='granted'){ toast('Notifications enabled'); await subscribePush(); } else { toast('Notifications blocked — allow them in your browser/site settings'); } }catch(_){ toast('Notifications need HTTPS'); } b.remove(); };
document.getElementById('npX').onclick=()=>{ try{ sessionStorage.setItem('notifPromptDismissed','1'); }catch(_){} b.remove(); };
}catch(_){}
}
// ----- Web Push: reliable notifications when the tab is backgrounded / frozen / closed -----
// A notification-only service worker (sw.js, no caching) shows OS popups via the push service.
// pushActive => the page can leave hidden-tab popups to push (avoids double-notifying).
let pushActive=false, _swReg=null;
function urlB64ToUint8(base64){ const pad='='.repeat((4-base64.length%4)%4); const b=(base64+pad).replace(/-/g,'+').replace(/_/g,'/'); const raw=atob(b); const arr=new Uint8Array(raw.length); for(let i=0;i<raw.length;i++) arr[i]=raw.charCodeAt(i); return arr; }
// ----- Native push (Capacitor app: FCM on Android, APNs on iOS) -----
// In a native shell, Capacitor injects window.Capacitor and exposes the PushNotifications
// plugin. We register the OS device token with /api/v1/devices so the server can deliver via
// FCM/APNs (see server/push.js). No bundler needed — plugins live on Capacitor.Plugins.
function capPlugin(name){ const C=window.Capacitor; return (C && C.isNativePlatform && C.isNativePlatform() && C.Plugins && C.Plugins[name]) ? C.Plugins[name] : null; }
function nativePlatform(){ const C=window.Capacitor; if(C && C.getPlatform){ const p=C.getPlatform(); if(p==='ios'||p==='android') return p; } if(window.__NATIVE__==='desktop' || (window.bizConnectNative && window.bizConnectNative.platform==='desktop')) return 'desktop'; return ''; } // also detect older desktop builds that expose bizConnectNative but not __NATIVE__ (#3: those wrongly subscribed Web Push → SW openWindow reloaded on notification click)
// Record this install against the signed-in user (desktop shell exposes a stable installId).
// Fires post-login; the request carries the session so admins can see who installed the app.
function reportInstall(){
try{ const n=window.bizConnectNative; if(!n||!n.installId) return;
postJSON('/api/v1/telemetry/install',{ installId:n.installId, platform:n.platform||'desktop', appVersion:n.version||'', os:n.os||'' }).catch(()=>{});
}catch(_){}
}
let _nativeTok=null;
async function setupNativePush(){
const PN=capPlugin('PushNotifications'); const plat=nativePlatform();
if(!PN || (plat!=='ios' && plat!=='android')) return false; // not a mobile native app
try{
PN.addListener('registration', async (t)=>{ const token=t&&t.value; if(!token) return; _nativeTok=token;
try{ await postJSON('/api/v1/devices',{ platform:plat, token }); pushActive=true; console.log('[push] native device registered'); }
catch(e){ console.warn('[push] device register failed:', e); } });
PN.addListener('registrationError', (e)=>console.warn('[push] native registration error:', e));
// Tapping an OS notification opens the relevant chat (payload carries kind+id).
PN.addListener('pushNotificationActionPerformed', (a)=>{ try{ const d=(a&&a.notification&&a.notification.data)||{}; if(d.id) selectChat(d.kind||'dm', d.id); }catch(_){} });
let perm=await PN.checkPermissions();
if(perm.receive!=='granted') perm=await PN.requestPermissions();
if(perm.receive!=='granted'){ console.log('[push] native permission not granted'); return true; }
await PN.register();
console.log('[push] native push registered');
}catch(e){ console.warn('[push] native push setup failed:', e); }
return true; // handled the native path (skip Web Push regardless of outcome)
}
async function setupPush(){
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; }
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()
console.log('[push] service worker ready');
// Clicking an OS notification asks us (if a tab is already open) to open that chat in place.
try{ navigator.serviceWorker.addEventListener('message',(e)=>{ const d=e.data||{}; if(d.type==='open-chat' && d.id){ try{ selectChat(d.kind||'dm', d.id); }catch(_){} } }); }catch(_){}
await subscribePush();
}
async function subscribePush(){
try{
if(!_swReg){ try{ _swReg=await navigator.serviceWorker.ready; }catch(_){ return; } }
if(!('Notification' in window) || Notification.permission!=='granted'){ console.log('[push] permission not granted ('+(window.Notification?Notification.permission:'n/a')+') — skipping subscribe'); return; }
let cfg; try{ cfg=await fetch('/api/push/vapid').then(r=>r.json()); }catch(e){ console.warn('[push] /api/push/vapid failed:', e); return; }
if(!cfg || !cfg.enabled || !cfg.key){ console.warn('[push] server push disabled (VAPID not set):', cfg); return; }
let sub=await _swReg.pushManager.getSubscription();
if(!sub){ sub=await _swReg.pushManager.subscribe({ userVisibleOnly:true, applicationServerKey:urlB64ToUint8(cfg.key) }); }
await postJSON('/api/push/subscribe', sub.toJSON());
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(){
// Native app: drop this device's FCM/APNs token so push stops for the logged-out user.
try{ if(_nativeTok){ await postJSON('/api/v1/devices/remove',{ token:_nativeTok }); _nativeTok=null; pushActive=false; return; } }catch(_){}
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.
function openFromNotif(kind,id){
try{ if(window.bizConnectNative&&window.bizConnectNative.focusApp) window.bizConnectNative.focusApp(); }catch(_){}
try{ window.focus(); }catch(_){}
// The app is already running (the notification came from it) → open the chat IN-PLACE. A full-page
// reload was slow (#19) and dropped live state: the sidebar hadn't reloaded so the header showed
// "Conversation" with no DP (#7), and an incoming call's Join/invite popup was lost (#2).
// NB: use bare `ME`, NOT `window.ME` — ME is declared with `let`, which does NOT create a global
// property, so `window.ME` was always undefined and EVERY click hit the reload fallback below.
if(ME && ME.id && typeof selectChat==='function'){
if(id){ try{ switchTab('chat'); }catch(_){} try{ selectChat(kind||'dm', id); }catch(_){} }
return;
}
// Fallback: app not initialised yet → navigate (boot reads openKind/openId and opens it).
location.assign(id ? ('/home?openKind='+encodeURIComponent(kind||'')+'&openId='+encodeURIComponent(id||'')) : '/home');
}
// Draw the conversation's avatar (DM = sender pic, group = group DP; else colored initials) to
// a round PNG data URL for the desktop toast image.
function notifAvatarDataUrl(kind,id,fallbackName){
return new Promise((resolve)=>{
try{
const row=rowFor(kind,id)||{}; const src=row.avatar||''; const nm=row.name||fallbackName||'?';
const s=96, c=document.createElement('canvas'); c.width=s; c.height=s; const g=c.getContext('2d');
const initialsPng=()=>{ try{ g.clearRect(0,0,s,s); g.fillStyle=avColor(nm); g.beginPath(); g.arc(s/2,s/2,s/2,0,2*Math.PI); g.fill(); g.fillStyle='#334155'; g.font='bold 40px system-ui,Segoe UI,sans-serif'; g.textAlign='center'; g.textBaseline='middle'; g.fillText(initials(nm), s/2, s/2+2); resolve(c.toDataURL('image/png')); }catch(_){ resolve(null); } };
if(src){ const img=new Image(); img.crossOrigin='anonymous'; let settled=false; const done=(fn)=>{ if(!settled){ settled=true; fn(); } };
img.onload=()=>done(()=>{ try{ g.save(); g.beginPath(); g.arc(s/2,s/2,s/2,0,2*Math.PI); g.clip(); g.drawImage(img,0,0,s,s); g.restore(); resolve(c.toDataURL('image/png')); }catch(_){ initialsPng(); } });
img.onerror=()=>done(initialsPng); img.src=src; setTimeout(()=>done(initialsPng), 1500);
} else initialsPng();
}catch(_){ resolve(null); }
});
}
// Reply to a conversation without opening it (used by the notification quick-reply).
async function sendReplyTo(kind,id,text){ try{ await postJSON('/api/messages', kind==='group'?{group:id,body:text}:{to:id,body:text}); }catch(_){} }
// #3: desktop auto-update — a progress banner (branded restart) + an update indicator that cascades
// from the profile → Settings → version, so the user always knows a newer version is available.
let _pendingUpdate=null; // {phase, version}
function wireUpdateBanner(){
const n=window.bizConnectNative; if(!(n&&n.onUpdateEvent)) return;
n.onUpdateEvent((d)=>{
if(!d||!d.phase) return;
if(d.phase==='available'||d.phase==='downloading'||d.phase==='ready') _pendingUpdate={ phase:d.phase, version:d.version||(_pendingUpdate&&_pendingUpdate.version)||'' };
else if(d.phase==='current') _pendingUpdate=null;
applyUpdateBadge();
let el=document.getElementById('updBanner');
const ensure=()=>{ if(!el){ el=document.createElement('div'); el.id='updBanner'; el.className='upd-banner'; document.body.appendChild(el); } return el; };
if(d.phase==='available'){ ensure().innerHTML=ic('download',15)+' <span>Downloading update'+(d.version?(' '+pEsc(d.version)):'')+'…</span>'; el.classList.add('show'); el.classList.remove('ready'); }
else if(d.phase==='downloading'){ ensure().innerHTML=ic('download',15)+' <span>Downloading update… '+(d.percent||0)+'%</span>'; el.classList.add('show'); el.classList.remove('ready'); }
else if(d.phase==='ready'){ ensure().innerHTML=ic('check',15)+' <span>Update ready</span> <button id="updRestart">Restart now</button>'; el.classList.add('show','ready'); const b=el.querySelector('#updRestart'); if(b) b.onclick=()=>{ try{ n.restartToUpdate&&n.restartToUpdate(); }catch(_){} }; }
else if(d.phase==='current'||d.phase==='error'){ if(el){ el.classList.remove('show'); setTimeout(()=>{ if(el&&!el.classList.contains('show')) el.remove(); }, 400); } }
});
// Desktop: a download finished (saved straight to the Downloads folder, no dialog) → confirm with a toast
// so it never feels like "nothing happened". The native app also shows a click-to-reveal OS notification
// when its window isn't focused.
if(n.onDownloadDone) n.onDownloadDone((d)=>{ if(!d) return; toast(d.ok ? ('Saved “'+(d.name||'file')+'” to your Downloads folder') : ('Couldnt download '+(d.name||'the file'))); });
}
// ---- New-web-build detection ----------------------------------------------------------------
// The desktop app closes to TRAY, so it can stay open for weeks and keep running the page it loaded on
// day one — a deploy would never reach it (that's why fixes "worked on mobile but not on desktop").
// Poll the server's build marker; when it changes, offer a Refresh. Also exposed in Settings.
// The user should NEVER have to think about "web build" vs "app version" — a new deploy applies ITSELF.
// We poll the server's build marker and, as soon as it's SAFE, silently reload onto the new code. Safe =
// not in a call, no live screen session, nothing half-typed, no dialog open. If the user is busy we simply
// keep waiting and apply it the moment they're free (or when the window is next hidden).
let _newBuild=null, _autoRefreshTimer=null, _lastBuildCheck=0, _buildChecking=false;
// Throttled + single-flight. `focus` and `visibilitychange` both fire on a single alt-tab back into the
// app, so an unthrottled check fired TWICE every time the window was refocused. One check per minute is
// plenty (the payload is ~35 bytes and the server answers from memory), and it never overlaps itself.
const BUILD_CHECK_MIN_MS=60*1000;
async function checkWebBuild(force){
if(_newBuild) return; // already know: the retry loop owns it now
const now=Date.now();
if(_buildChecking) return;
if(!force && now-_lastBuildCheck < BUILD_CHECK_MIN_MS) return;
_buildChecking=true; _lastBuildCheck=now;
try{
const r=await fetch('/api/build',{cache:'no-store'}); if(!r.ok) return;
const d=await r.json();
if(d && d.build && window.__BUILD && d.build!==window.__BUILD){
_newBuild=d.build;
console.log('[build] new version on server:', d.build, '(running', window.__BUILD+')');
scheduleAutoRefresh();
}
}catch(_){}
finally{ _buildChecking=false; }
}
// Never yank the page out from under someone mid-task.
function safeToRefresh(){
try{
if(typeof meetState!=='undefined' && meetState==='call') return false; // in a meeting/call
if(document.querySelector('.railbtn.live')) return false; // live share/connect session
if(document.querySelector('.modal-ov, .call-invite, .guest-prejoin, .lightbox')) return false; // a dialog is open
const inp=document.getElementById('msgInput'); if(inp && inp.value.trim()) return false; // half-typed message
const mc=document.getElementById('mcInput'); if(mc && mc.value.trim()) return false;
}catch(_){}
return true;
}
function scheduleAutoRefresh(){
if(_autoRefreshTimer) return;
const tryNow=()=>{
if(!_newBuild) return;
if(!safeToRefresh()) return; // busy → try again shortly
clearInterval(_autoRefreshTimer); _autoRefreshTimer=null;
hardReloadApp(); // silent: no banner, no toast
};
_autoRefreshTimer=setInterval(tryNow, 15000);
setTimeout(tryNow, 1200); // usually applies right away
}
// Hard refresh: on desktop this clears the shell's HTTP cache and reloads ignoring cache. In a browser we
// also drop any service-worker + CacheStorage entries, then reload — so there's no stale-code dead end.
async function hardReloadApp(){
try{ const n=window.bizConnectNative; if(n && n.hardReload){ n.hardReload(); return; } }catch(_){}
try{ if('serviceWorker' in navigator){ const rs=await navigator.serviceWorker.getRegistrations(); await Promise.all(rs.map(r=>r.unregister().catch(()=>{}))); } }catch(_){}
try{ if(window.caches && caches.keys){ const ks=await caches.keys(); await Promise.all(ks.map(k=>caches.delete(k).catch(()=>{}))); } }catch(_){}
try{ location.replace(location.pathname+location.search); }catch(_){ location.reload(); }
}
// Cheap by design: one ~35-byte request, answered from a variable the server read at startup. The
// throttle above means refocusing the app repeatedly costs nothing.
setInterval(()=>checkWebBuild(true), 5*60*1000); // every 5 min (the interval IS the schedule)
window.addEventListener('focus', ()=>checkWebBuild()); // coming back to the app (throttled)
document.addEventListener('visibilitychange', ()=>{ if(!document.hidden) checkWebBuild(); });
setTimeout(()=>checkWebBuild(true), 3000); // shortly after boot
// Deliberately NO banner and NO toast: a web build is an implementation detail. Surfacing it would make
// users think about "web build vs app version". It just updates.
// Small 'i' badge on the profile button when an update is pending; clicking opens Settings.
function applyUpdateBadge(){
document.querySelectorAll('.upd-dot').forEach(x=>x.remove());
if(!_pendingUpdate) return;
const host=document.getElementById('pbtn'); if(!host) return;
const dot=document.createElement('span'); dot.className='upd-dot'; dot.title='Update available — open Settings'; dot.textContent='i';
host.appendChild(dot);
}
// #13: track shown page-notifications by conversation tag so reading elsewhere can dismiss them.
const _shownNotifs={};
function onNotifClear(d){
if(!d) return; const tag=(d.kind||'')+':'+(d.id||'');
try{ const n=_shownNotifs[tag]; if(n){ n.close(); } delete _shownNotifs[tag]; }catch(_){}
// Also close any Service-Worker (Web Push) notifications for this chat.
try{ if('serviceWorker' in navigator && navigator.serviceWorker.getRegistration) navigator.serviceWorker.getRegistration().then(reg=>{ if(reg&&reg.getNotifications) reg.getNotifications({tag}).then(ns=>ns.forEach(n=>{ try{ n.close(); }catch(_){} })); }).catch(()=>{}); }catch(_){}
// Drop matching activity-center entries so the bell badge stays in sync.
try{ if(Array.isArray(NOTIFS)){ const before=NOTIFS.length; NOTIFS=NOTIFS.filter(x=>!(x.link&&x.link.kind===d.kind&&x.link.id===d.id)); if(NOTIFS.length!==before){ saveNotifs&&saveNotifs(); updateBellBadge&&updateBellBadge(); } } }catch(_){}
}
// Draw a round initials avatar to a PNG data URL (synchronous — no external image, so it never taints
// the canvas). Used as the desktop toast icon when a chat/group has no photo, so every toast shows one.
function initialsAvatarDataUrl(name, isGroup){
try{
const s=96, c=document.createElement('canvas'); c.width=s; c.height=s; const g=c.getContext('2d');
g.beginPath(); g.arc(s/2,s/2,s/2,0,2*Math.PI); g.fillStyle=isGroup?'#1F3B73':avColor(name||'?'); g.fill();
g.fillStyle=isGroup?'#fff':'#334155'; g.font='bold 40px system-ui,Segoe UI,sans-serif'; g.textAlign='center'; g.textBaseline='middle';
g.fillText(initials(name||'?'), s/2, s/2+2);
return c.toDataURL('image/png');
}catch(_){ return null; }
}
function notify(title, body, kind, id, opts){
const persistent=!!(opts&&opts.persistent);
try{
// Desktop app: native toast with the sender/group DP. Pass the DP URL directly — the shell
// downloads it for the icon (drawing an external DP to a canvas here tainted it → initials).
if(window.bizConnectNative && window.bizConnectNative.replyNotify){
const _row=rowFor(kind,id)||{};
// Groups (and contacts) with no photo URL had NO toast icon at all. Draw a generated icon
// (initials on a brand-tinted disc — no external image, so no canvas taint) so every toast,
// especially group messages, carries a recognizable avatar.
let _av=_row.avatar||null;
if(!_av){ try{ _av=initialsAvatarDataUrl(_row.name||title, kind==='group'); }catch(_){ _av=null; } }
Promise.resolve(window.bizConnectNative.replyNotify({title, body, kind, id, avatar:_av, persistent}))
.then(r=>{ if(!r) return;
if(r.text) sendReplyTo(kind, id, r.text); // replied from the toast
else if(r.open) openFromNotif(kind, id); // clicked the toast
}).catch(()=>{});
return;
}
// Web / PWA: standard Notification. Use the sender's/group's DP URL DIRECTLY as the icon —
// the browser fetches notification icons cross-origin fine, whereas drawing an external DP to a
// canvas taints it and silently fell back to initials (the bug). Brand icon only if no DP.
if(!('Notification' in window) || Notification.permission!=='granted') return;
const _row=rowFor(kind,id)||{};
const tag=(kind||'')+':'+(id||''); // #13: tag by conversation so it can be dismissed cross-device
const n=new Notification(title, { body, icon: _row.avatar || '/icon-192.png', badge:'/icon-192.png', tag });
_shownNotifs[tag]=n;
n.onclick=()=>{ n.close(); delete _shownNotifs[tag]; openFromNotif(kind, id); };
n.onclose=()=>{ if(_shownNotifs[tag]===n) delete _shownNotifs[tag]; };
setTimeout(()=>{ try{ n.close(); }catch(_){} }, 8000);
}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;
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
// (C major) of pure sine tones with an octave shimmer and a long smooth decay, so it rings
// rather than buzzes. Ref-counted: several pending invites share one ring; it stops on the last close.
let _ringTimer=null, _ringRefs=0;
function ringTone(t, freq, dur, peak){
const g=_audioCtx.createGain(); g.connect(_audioCtx.destination);
g.gain.setValueAtTime(0.0001,t);
g.gain.exponentialRampToValueAtTime(peak,t+0.03); // gentle attack (no click)
g.gain.exponentialRampToValueAtTime(0.0001,t+dur); // long, smooth bell-like decay
const o=_audioCtx.createOscillator(); o.type='sine'; o.frequency.value=freq; o.connect(g); o.start(t); o.stop(t+dur);
const o2=_audioCtx.createOscillator(); o2.type='sine'; o2.frequency.value=freq*2; // octave shimmer
const g2=_audioCtx.createGain(); g2.gain.value=0.3; o2.connect(g2); g2.connect(g); o2.start(t); o2.stop(t+dur);
}
function ringOnce(){
try{
_audioCtx=_audioCtx||new (window.AudioContext||window.webkitAudioContext)();
if(_audioCtx.state==='suspended') _audioCtx.resume();
const t=_audioCtx.currentTime;
ringTone(t, 523.25, 1.2, 0.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); }
function stopRing(force){ _ringRefs = force ? 0 : Math.max(0, _ringRefs-1); if(_ringRefs>0) return; if(_ringTimer){ clearInterval(_ringTimer); _ringTimer=null; } }
// #17 Outgoing ringback — the CALLER hears a gentle periodic ring while waiting for the other side to
// pick up. Stops as soon as a peer joins, or the call ends.
let _ringbackTimer=null, _ringbackStop=null;
function ringbackOnce(){ try{ _audioCtx=_audioCtx||new (window.AudioContext||window.webkitAudioContext)(); if(_audioCtx.state==='suspended') _audioCtx.resume(); const t=_audioCtx.currentTime; ringTone(t, 440, 1.1, 0.11); ringTone(t+0.16, 480, 1.1, 0.11); }catch(_){} }
function startRingback(){ if(_ringbackTimer) return; ringbackOnce(); _ringbackTimer=setInterval(ringbackOnce, 3000); _ringbackStop=setTimeout(stopRingback, 45000); /* never ring forever if unanswered/declined */ }
function stopRingback(){ if(_ringbackTimer){ clearInterval(_ringbackTimer); _ringbackTimer=null; } if(_ringbackStop){ clearTimeout(_ringbackStop); _ringbackStop=null; } }
function onChatMessage(m){
const isGroupMsg=!!m.conversation_id;
const kind=isGroupMsg?'group':'dm';
const rid=isGroupMsg?m.conversation_id:(m.from===ME.id?m.to:m.from);
if(m.from&&m.from!==ME.id) clearTypingFrom(kind,rid,m.from); // their message landed → drop their "typing…"
let it=rowFor(kind,rid); const wasNew=!it;
if(!it){ loadSidebar(); } // first DM / a new group we were added to — refresh the list
// Keep the thread cache warm so a notification click can render this chat synchronously.
const ckey=kind+':'+rid;
if(THREAD_CACHE.has(ckey)){ const arr=THREAD_CACHE.get(ckey); if(!arr.some(x=>x.id===m.id)) arr.push(m); }
else { const pu=(kind==='group'?'/api/messages/thread?group='+encodeURIComponent(rid):'/api/messages/thread?with='+encodeURIComponent(rid))+'&peek=1'; fetch(pu).then(r=>r.json()).then(a=>{ if(Array.isArray(a)) THREAD_CACHE.set(ckey,a); }).catch(()=>{}); }
const isOpen=selected&&selected.kind===kind&&selected.id===rid&&currentTab()==='chat';
const isSys=!!m.system || m.from==='__system__'; // activity lines: show in chat, but no ping/notify/unread
if(m.from!==ME.id && !isSys){
if(!isGroupMsg && chatWs && chatWs.readyState===1){ try{ chatWs.send(JSON.stringify({type:'chat-delivered', id:m.id})); }catch(_){} } // ack DM delivery
// Popup rule: ping always. In-page popup only when the tab is VISIBLE but you're on another
// chat. When the tab is HIDDEN, let Web Push show it (the SW). If push isn't active, fall
// back to an in-page popup so hidden-tab users still get alerted.
if(notifOn(kind)){ playPing(); const wantPopup=!(isOpen && !document.hidden);
if(wantPopup && !(document.hidden && pushActive)){
const prev=isGifUrl(m.body)?'🎞️ GIF':(m.body?(m.body.length>80?m.body.slice(0,80)+'…':m.body):'Sent an attachment');
// Group: title = GROUP name, body = "Sender: message" (so you know which group it's from).
if(kind==='group') notify((it&&it.name)||m.groupName||'Group', (m.fromName?m.fromName+': ':'')+prev, kind, rid);
else notify((m.fromName||'New message'), prev, kind, rid);
}
}
// Activity-center entries for things easy to miss.
if(m.poll) addNotif({icon:'barChart', text:pEsc(m.fromName||'Someone')+' created a poll'+(m.poll.question?': '+pEsc(m.poll.question):''), link:{kind, id:rid}});
else if(kind==='dm' && wasNew) addNotif({icon:'chat', text:'New chat from '+pEsc(m.fromName||'someone'), link:{kind:'dm', id:rid}});
}
if(it){
it.last_body=isSys?m.body:(m.body||(m.attachment?'📎 '+(m.attachment.name||'Attachment'):'')); it.last_at=m.created_at; it.last_from_me=(m.from===ME.id);
// #6: the server echoes my own sent message back — don't let that echo DOWNGRADE a delivered/read
// tick that already arrived for this same message (it would flip the list back to a single tick).
if(m.from===ME.id){ if(!(it.last_msg_id===m.id && (it.last_status==='delivered'||it.last_status==='read'))) it.last_status='sent'; it.last_msg_id=m.id; }
else it.last_status=null;
if(m.from!==ME.id && !isOpen && !isSys) it.unread=(it.unread||0)+1;
}
if(isOpen){
// Dedup by id: the server echoes our own sent message back (multi-tab/device sync), and
// sendMessage already appended it optimistically — so skip if it's already in the thread.
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(_){} }
} else if(m.from!==ME.id && !isSys && notifOn(kind)){
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();
}
let chatWs=null, _chatConnectedOnce=false;
// After a socket DROP + reconnect, this client may have missed messages/ticks while offline — and
// the desktop/mobile apps can't just be refreshed to recover. So on every RE-connect we re-pull the
// sidebar and re-open the current conversation (fresh thread + receipts). The first connect is
// handled by boot's loadSidebar, so we skip the resync then.
function resyncChat(){
try{ loadSidebar(); }catch(_){}
refreshOpenThread(); // NON-destructive catch-up (never wipes the open thread — see below)
}
// Re-fetch the open conversation and MERGE: add messages missed while disconnected + refresh
// read/delivered/seen/edited/deleted flags, but NEVER remove messages already shown. openConvo
// replaced THREAD wholesale, which could momentarily blank a chat (e.g. a just-sent message) if the
// reload raced or returned stale — the cause of the "messages disappear after sending" report.
async function refreshOpenThread(){
if(!selected || currentTab()!=='chat') return;
const kind=selected.kind, id=selected.id;
const url=kind==='group'?('/api/messages/thread?group='+encodeURIComponent(id)):('/api/messages/thread?with='+encodeURIComponent(id));
let msgs; try{ msgs=await fetch(url).then(r=>r.json()); }catch(_){ return; }
if(!Array.isArray(msgs)) return;
if(!selected||selected.kind!==kind||selected.id!==id) return; // switched away while fetching
const byId=new Map(THREAD.map(m=>[m.id,m])); let changed=false;
msgs.forEach(nm=>{ const ex=byId.get(nm.id);
if(!ex){ THREAD.push(nm); changed=true; return; }
if(nm.read_at&&!ex.read_at){ ex.read_at=nm.read_at; changed=true; }
if(nm.delivered_at&&!ex.delivered_at){ ex.delivered_at=nm.delivered_at; changed=true; }
if(nm.deleted&&!ex.deleted){ ex.deleted=true; ex.body=''; ex.attachment=null; ex.reactions=[]; changed=true; }
if(nm.edited_at&&nm.edited_at!==ex.edited_at){ ex.edited_at=nm.edited_at; ex.body=nm.body; changed=true; }
if(JSON.stringify(nm.seenBy||[])!==JSON.stringify(ex.seenBy||[])){ ex.seenBy=nm.seenBy||[]; changed=true; }
});
if(changed){ THREAD.sort((a,b)=>(a.created_at||0)-(b.created_at||0)); THREAD_CACHE.set(kind+':'+id, THREAD.slice()); renderThread(); }
}
let _chatReconnectT=null;
function connectChatWs(){
try{
// Defensive: drop any prior socket first so a flaky reconnect can't leave TWO live sockets —
// which would deliver every event (and notification) twice. Detach its handlers before closing
// so its onclose doesn't schedule yet another reconnect.
if(chatWs){ try{ chatWs.onopen=chatWs.onmessage=chatWs.onclose=chatWs.onerror=null; chatWs.close(); }catch(_){} chatWs=null; }
if(_chatReconnectT){ clearTimeout(_chatReconnectT); _chatReconnectT=null; }
chatWs=new WebSocket((location.protocol==='https:'?'wss://':'ws://')+location.host+'/ws');
chatWs.onopen=()=>{ try{ chatWs.send(JSON.stringify({type:'chat-hello'})); }catch(_){} if(_chatConnectedOnce) resyncChat(); _chatConnectedOnce=true; };
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-edited') onChatEdited(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==='chat-typing') onTyping(d); else if(d.type==='notif-clear') onNotifClear(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=()=>{ if(_chatReconnectT) clearTimeout(_chatReconnectT); _chatReconnectT=setTimeout(connectChatWs, 3000); }; // auto-reconnect (single pending timer)
}catch(_){}
}
// ---- Typing indicators (ephemeral, over the chat socket) ----------------------------------
// Outgoing: while you type in the open conversation we send `chat-typing on` (throttled to once
// per ~2.5s), and `off` after 4s idle / on send / on leaving the chat.
let _typingOn=false, _typingIdleT=null, _lastTypingSent=0;
function _cssEsc(s){ try{ return CSS.escape(String(s)); }catch(_){ return String(s).replace(/[^\w-]/g,'\\$&'); } }
function _typingSend(on){
if(!chatWs||chatWs.readyState!==1||!selected||selected.self) return;
const t=selected.kind==='group'?{group:selected.id}:{to:selected.id};
try{ chatWs.send(JSON.stringify(Object.assign({type:'chat-typing',on:!!on},t))); }catch(_){}
}
function noteTyping(){ // call on every composer keystroke
if(!selected||selected.self) return;
const now=Date.now();
if(!_typingOn || now-_lastTypingSent>2500){ _typingOn=true; _lastTypingSent=now; _typingSend(true); }
clearTimeout(_typingIdleT); _typingIdleT=setTimeout(stopTyping, 4000);
}
function stopTyping(){ clearTimeout(_typingIdleT); if(_typingOn){ _typingOn=false; _typingSend(false); } }
// Incoming: per-conversation set of who's currently typing, each with an auto-expiry so a dropped
// 'off' can't leave the indicator stuck. Keyed 'dm:<userId>' or 'group:<groupId>'.
const TYPING={};
function _tKey(kind,id){ return kind+':'+id; }
function onTyping(d){
if(!d||!d.from||d.from===ME.id) return; // ignore our own echo (shouldn't happen, but be safe)
const kind=d.group?'group':'dm', id=d.group||d.from, key=_tKey(kind,id);
let m=TYPING[key]; if(!m){ m=new Map(); TYPING[key]=m; }
const cur=m.get(d.from); if(cur&&cur.t) clearTimeout(cur.t);
if(d.on){ const t=setTimeout(()=>{ m.delete(d.from); if(!m.size) delete TYPING[key]; refreshTyping(kind,id); }, 6000); m.set(d.from,{name:d.name||'',t}); }
else { m.delete(d.from); if(!m.size) delete TYPING[key]; }
refreshTyping(kind,id);
}
function clearTypingFrom(kind,id,userId){ // a real message arrived → they stopped typing
const key=_tKey(kind,id), m=TYPING[key]; if(!m) return;
const cur=m.get(userId); if(cur&&cur.t) clearTimeout(cur.t);
m.delete(userId); if(!m.size) delete TYPING[key]; refreshTyping(kind,id);
}
function typingLabel(kind,id){
const m=TYPING[_tKey(kind,id)]; if(!m||!m.size) return '';
if(kind==='dm') return 'typing…';
const names=[...m.values()].map(v=>firstName(v.name||'Someone'));
if(names.length===1) return names[0]+' is typing…';
if(names.length===2) return names[0]+' and '+names[1]+' are typing…';
return names.length+' people are typing…';
}
function refreshTyping(kind,id){
const it=rowFor(kind,id), lbl=typingLabel(kind,id);
if(selected && selected.kind===kind && selected.id===id){ // open conversation → header subtitle + in-panel row
const st=document.querySelector('#convoTitle .st');
if(st){ if(lbl){ st.textContent=lbl; st.classList.add('typing'); } else { st.classList.remove('typing'); st.textContent=subtitleFor(it||selected); } }
const tr=document.getElementById('typingRow');
if(tr){ if(lbl){ tr.innerHTML='<span class="tdots"><i></i><i></i><i></i></span> '+pEsc(lbl); tr.classList.add('show'); } else { tr.classList.remove('show'); tr.innerHTML=''; } }
}
if(listEl){ // sidebar row preview
const row=listEl.querySelector('.chat-row[data-kind="'+kind+'"][data-id="'+_cssEsc(id)+'"]');
const prev=row&&row.querySelector('.chat-prev');
if(prev){ if(lbl){ prev.innerHTML='<span class="typing-prev">'+pEsc(lbl)+'</span>'; } else if(it){ prev.innerHTML=rowPreviewHTML(it); } }
}
}
function renderChatPanel(){
const el=document.getElementById('chatPanel');
if(!selected){ el.classList.add('center'); el.innerHTML=welcomeHTML(); wireWelcome(); }
else { el.classList.remove('center'); openConvo(selected.kind, selected.id); }
}
// ----- New group modal -----
function openNewGroup(){
if(document.getElementById('groupModal')) return;
const ov=document.createElement('div'); ov.className='modal-ov'; ov.id='groupModal';
ov.innerHTML='<div class="modal"><h3>New group</h3>'
+ '<input id="grpName" placeholder="Group name" maxlength="80">'
+ (CONTACTS.length?'<div class="gi-search" style="margin:.55rem 0 .35rem"><input id="grpSearch" placeholder="Search people…" autocomplete="off"></div>':'')
+ '<div class="grp-members">'+(CONTACTS.length?CONTACTS.map(c=>'<label class="chk" data-name="'+pEsc((c.name||'').toLowerCase())+'"><input type="checkbox" value="'+pEsc(c.id)+'"> '+pEsc(c.name)+'</label>').join(''):'<div class="muted" style="padding:.5rem">No teammates to add yet.</div>')+'</div>'
+ '<div class="gi-noresult" id="grpNoResult" style="display:none">No people found</div>'
+ '<div class="modal-actions"><button class="gobtn" id="grpCancel" style="background:#eef1f6;color:var(--blue)">Cancel</button><button class="gobtn" id="grpCreate">Create group</button></div></div>';
document.body.appendChild(ov);
ov.onclick=(e)=>{ if(e.target===ov) ov.remove(); };
document.getElementById('grpCancel').onclick=()=>ov.remove();
document.getElementById('grpCreate').onclick=createGroup;
const gs=document.getElementById('grpSearch');
if(gs) gs.oninput=()=>{ const q=gs.value.trim().toLowerCase(); let shown=0; ov.querySelectorAll('.grp-members .chk').forEach(l=>{ const vis=!q||(l.dataset.name||'').includes(q); l.style.display=vis?'':'none'; if(vis) shown++; }); const nr=document.getElementById('grpNoResult'); if(nr) nr.style.display=shown?'none':'block'; };
const n=document.getElementById('grpName'); if(n) n.focus();
}
async function createGroup(){
const name=document.getElementById('grpName').value.trim();
const ids=[...document.querySelectorAll('#groupModal .grp-members input:checked')].map(i=>i.value);
if(!name){ toast('Enter a group name'); return; }
if(!ids.length){ toast('Pick at least one member'); return; }
try{
const g=await postJSON('/api/groups',{ name, memberIds:ids });
const ov=document.getElementById('groupModal'); if(ov) ov.remove();
await loadSidebar();
selectChat('group', g.id);
}catch(e){ toast(e.message||'Could not create group'); }
}
async function openGroupInfo(gid){
let info; try{ info=await fetch('/api/groups/info?group='+encodeURIComponent(gid)).then(r=>r.json()); }catch(_){ return; }
if(!info||!info.id) return;
const inSet=new Set(info.members.map(m=>m.id));
const addable=CONTACTS.filter(c=>!inSet.has(c.id));
const canManage=info.isAdmin || !info.adminOnly; // who may add/remove members
const miniAv=(name,avatar)=>'<div class="mini-av" style="background:'+avColor(name)+'">'+pEsc(initials(name))+(avatar?'<img class="av-img" src="'+pEsc(avatar)+'" alt="" onerror="this.remove()">':'')+'</div>';
const ov=document.createElement('div'); ov.className='modal-ov'; ov.id='groupInfo';
ov.innerHTML='<div class="modal gi">'
+ '<input type="file" id="giPhotoInput" accept="image/*" style="display:none">'
+ '<div class="gi-head"><button class="gi-photo" id="giPhoto" title="Change group photo"><span class="avatar grp" style="width:46px;height:46px;background:'+avColor(info.name)+'">'+ic('users',24)+(info.avatar?'<img class="av-img" src="'+pEsc(info.avatar)+'" alt="" onerror="this.remove()">':'')+'</span><span class="gi-photo-cam">'+ic('camera',13)+'</span></button>'
+ '<div class="gi-name">'
+ '<div class="gi-name-row" id="giView"><span class="gi-title">'+pEsc(info.name)+'</span><button class="fav-star'+(((rowFor('group',gid)||{}).favorite)?' on':'')+'" id="giFav" title="Favourite">'+ic('star',15)+'</button><button class="iconbtn sm" id="giEdit" title="Rename group">'+ic('pencil',15)+'</button></div>'
+ '<div class="gi-edit hidden" id="giEditRow"><input id="giName" value="'+pEsc(info.name)+'" maxlength="80"><button class="iconbtn" id="giSave" title="Save">'+ic('check',18)+'</button><button class="iconbtn" id="giCancelEdit" title="Cancel">'+ic('x',16)+'</button></div>'
+ '<div class="gi-sub">'+info.members.length+' member'+(info.members.length===1?'':'s')+'</div>'
+ '</div>'
+ '<button class="iconbtn" id="giClose" title="Close">'+ic('x',18)+'</button></div>'
+ '<div class="gi-actions"><button class="gi-act" id="giCall">'+ic('phone',18)+'<span>Call</span></button><button class="gi-act" id="giSchedule">'+ic('calendar',18)+'<span>Schedule</span></button></div>'
+ (info.createdByName?'<div class="gi-created">'+ic('info',13)+' Created by <b>'+pEsc(info.createdByName)+'</b> on '+pEsc(fmtDateTime(info.createdAt))+'</div>':'')
+ (info.isAdmin?'<label class="gi-setting"><span>'+ic('lock',15)+' Only admins can add or remove people</span><span class="switch"><input type="checkbox" id="giAdminOnly"'+(info.adminOnly?' checked':'')+'><span class="slider"></span></span></label>':'')
+ '<div class="gi-sec-h"><span>Shared</span></div>' + mediaRowHTML()
+ '<div class="gi-sec-h"><span>Members</span>'+(canManage?'<button class="linkbtn" id="giAddToggle">'+ic('userPlus',15)+' Add people</button>':'')+'</div>'
+ '<div class="gi-list">'+info.members.map(m=>'<div class="mrow'+(m.isMe?'':' dm-able')+'"'+(m.isMe?'':' data-dm="'+pEsc(m.id)+'" title="Message '+pEsc(m.name)+'"')+'>'+miniAv(m.name,m.avatar)+'<span class="mn">'+pEsc(m.name)+(m.isMe?' <span class="youtag">you</span>':'')+(m.admin?' <span class="admin-tag">'+ic('crown',11)+' Admin</span>':'')+'</span>'+((info.isAdmin&&!m.isMe)?'<button class="iconbtn role'+(m.admin?' is-admin':'')+'" data-role="'+pEsc(m.id)+'" data-val="'+(m.admin?'0':'1')+'" title="'+(m.admin?'Remove admin':'Make admin')+'">'+ic('crown',15)+'</button>':'')+((canManage&&!m.isMe)?'<button class="iconbtn rm" data-rm="'+pEsc(m.id)+'" title="Remove">'+ic('trash',15)+'</button>':'')+'</div>').join('')+'</div>'
+ '<div class="gi-add hidden" id="giAddWrap">'
+ (addable.length
? ('<div class="gi-search"><input id="giAddSearch" placeholder="Search people…" autocomplete="off"><button class="search-x" id="giAddSearchX" title="Clear" style="display:none">'+ic('x',14)+'</button></div>'
+ '<div class="gi-list" id="giAddList">'+addable.map(c=>'<label class="chk" data-name="'+pEsc((c.name||'').toLowerCase())+'"><input type="checkbox" value="'+pEsc(c.id)+'">'+miniAv(c.name,c.avatar)+'<span class="mn">'+pEsc(c.name)+'</span></label>').join('')+'</div>'
+ '<div class="gi-noresult" id="giAddEmpty" style="display:none">No results found</div>'
+ '<button class="gobtn" id="giAddBtn" style="width:100%;margin-top:.5rem">Add selected</button>')
: '<div class="gi-noresult">Everyone in your workspace is already in this group.</div>')
+ '</div>'
+ '<button class="gi-leave" id="giLeave">'+ic('logOut',16)+' Leave group</button>'
+ '</div>';
const _old=document.getElementById('groupInfo'); if(_old) _old.remove(); // never stack group-info modals (stale close buttons)
document.body.appendChild(ov);
wireMediaEntry(ov, 'group', gid, info.name); // "Media, links & docs" entry above the members
ov.onclick=(e)=>{ if(e.target===ov) ov.remove(); };
ov.querySelector('#giClose').onclick=()=>ov.remove();
{ const gf=ov.querySelector('#giFav'); if(gf) gf.onclick=async()=>{ const r=rowFor('group',gid); const on=!(r&&r.favorite); if(r) r.favorite=on; gf.classList.toggle('on',on); gf.title=on?'Remove from favourites':'Add to favourites'; try{ await postJSON('/api/favorites',{kind:'group',id:gid,on}); }catch(_){} renderChats(searchVal()); }; }
const refresh=async()=>{ await loadSidebar(); if(selected&&selected.kind==='group'&&selected.id===gid) openConvo('group',gid); };
const view=ov.querySelector('#giView'), editRow=ov.querySelector('#giEditRow'), nameInp=ov.querySelector('#giName'), closeBtn=ov.querySelector('#giClose');
document.getElementById('giEdit').onclick=()=>{ view.classList.add('hidden'); editRow.classList.remove('hidden'); closeBtn.style.display='none'; nameInp.focus(); nameInp.select(); };
document.getElementById('giCancelEdit').onclick=()=>{ nameInp.value=info.name; editRow.classList.add('hidden'); view.classList.remove('hidden'); closeBtn.style.display=''; };
const doRename=async()=>{ const nm=nameInp.value.trim(); if(!nm){ toast('Name required'); return; } try{ await postJSON('/api/groups/rename',{group:gid,name:nm}); ov.remove(); await refresh(); toast('Group renamed'); }catch(e){ toast(e.message); } };
document.getElementById('giSave').onclick=doRename;
nameInp.addEventListener('keydown',e=>{ if(e.key==='Enter'){ e.preventDefault(); doRename(); } });
document.getElementById('giCall').onclick=()=>startGroupCall(gid);
document.getElementById('giSchedule').onclick=()=>scheduleGroupCall(gid);
const adminOnlyCb=document.getElementById('giAdminOnly'); if(adminOnlyCb) adminOnlyCb.onchange=async()=>{ try{ await postJSON('/api/groups/admin-only',{ group:gid, value:adminOnlyCb.checked }); ov.remove(); await refresh(); openGroupInfo(gid); }catch(e){ adminOnlyCb.checked=!adminOnlyCb.checked; toast(e.message||'Could not update'); } };
const addTgl=document.getElementById('giAddToggle'); if(addTgl) addTgl.onclick=()=>{ const w=document.getElementById('giAddWrap'); if(w){ w.classList.toggle('hidden'); if(!w.classList.contains('hidden')){ const s=document.getElementById('giAddSearch'); if(s) setTimeout(()=>s.focus(),0); } } };
const addSearch=document.getElementById('giAddSearch');
if(addSearch){
const filter=()=>{ const q=addSearch.value.trim().toLowerCase(); const xb=document.getElementById('giAddSearchX'); if(xb) xb.style.display=q?'grid':'none'; let shown=0; ov.querySelectorAll('#giAddList .chk').forEach(l=>{ const vis=(!q||(l.dataset.name||'').includes(q)); l.style.display=vis?'':'none'; if(vis) shown++; }); const empty=document.getElementById('giAddEmpty'); if(empty) empty.style.display=shown?'none':'block'; };
addSearch.addEventListener('input', filter);
const xb=document.getElementById('giAddSearchX'); if(xb) xb.onclick=()=>{ addSearch.value=''; filter(); addSearch.focus(); };
}
const addBtn=document.getElementById('giAddBtn'); if(addBtn) addBtn.onclick=async()=>{ const ids=[...ov.querySelectorAll('#giAddWrap input:checked')].map(i=>i.value); if(!ids.length){ toast('Pick members to add'); return; } try{ await postJSON('/api/groups/add',{group:gid,memberIds:ids}); ov.remove(); await refresh(); openGroupInfo(gid); }catch(e){ toast(e.message); } };
const nameOf=(uid)=>{ const mm=info.members.find(x=>x.id===uid); return mm?mm.name:'this person'; };
// #9: tapping a member (not their admin/remove buttons) opens the private conversation with them.
ov.querySelectorAll('.mrow[data-dm]').forEach(row=>row.addEventListener('click',(e)=>{
if(e.target.closest('button')) return; // let the admin / remove buttons do their thing
const id=row.dataset.dm; if(!id) return;
ov.remove(); switchTab('chat'); selectChat('dm', id);
}));
ov.querySelectorAll('[data-rm]').forEach(b=>b.onclick=async()=>{ const nm=nameOf(b.dataset.rm); if(!confirm('Remove '+nm+' from the group?\n\nThey will lose access to this conversation.')) return; try{ await postJSON('/api/groups/remove',{group:gid,userId:b.dataset.rm}); ov.remove(); await refresh(); openGroupInfo(gid); }catch(e){ toast(e.message); } });
ov.querySelectorAll('[data-role]').forEach(b=>b.onclick=async()=>{ const mk=b.dataset.val==='1'; const nm=nameOf(b.dataset.role); if(!confirm(mk?('Make '+nm+' an admin?\n\nThey will be able to add or remove members and manage this group.'):('Remove '+nm+' as an admin?'))) return; try{ await postJSON('/api/groups/admin',{group:gid,userId:b.dataset.role,value:mk}); ov.remove(); await refresh(); openGroupInfo(gid); }catch(e){ toast(e.message); } });
document.getElementById('giLeave').onclick=async()=>{
const admins=info.members.filter(m=>m.admin), others=info.members.filter(m=>!m.isMe);
// #10: last admin must pick a successor before leaving.
if(info.isAdmin && others.length && admins.length===1){ ov.remove(); pickSuccessorThenLeave(gid, others); return; }
if(!confirm('Leave this group?')) return;
try{ await postJSON('/api/groups/remove',{group:gid}); ov.remove(); selected=null; await loadSidebar(); renderChatPanel(); }catch(e){ toast(e.message); }
};
// Group photo: click avatar -> pick image -> upload -> set as group image.
const photoBtn=document.getElementById('giPhoto'), photoInput=document.getElementById('giPhotoInput');
if(photoBtn&&photoInput){
photoBtn.onclick=()=>photoInput.click();
photoInput.onchange=async()=>{
const file=photoInput.files&&photoInput.files[0]; if(!file) return;
if(!/^image\//.test(file.type||'')){ toast('Please choose an image file'); return; }
try{
const up=await fetch('/api/messages/upload',{ method:'POST', headers:{ 'Content-Type':file.type||'application/octet-stream', 'X-Filename':encodeURIComponent(file.name) }, body:file }).then(r=>r.json());
if(!up||!up.id) throw new Error(up&&up.error||'Upload failed');
await postJSON('/api/groups/avatar',{ group:gid, attachmentId:up.id });
ov.remove(); await refresh(); openGroupInfo(gid); toast('Group photo updated');
}catch(e){ toast(e.message||'Could not update photo'); }
};
}
}
// Start an audio call with the group: spin up an audio-only meeting and post the join code to the group chat.
// Group-info "Call" button → start (or join) the SHARED group call so every member is notified/rung.
function startGroupCall(gid){ const ov=document.getElementById('groupInfo'); if(ov) ov.remove(); startOrJoinGroupCall(gid); }
// Schedule a call tied to this group: close group info, open the schedule modal.
function scheduleGroupCall(gid){ const ov=document.getElementById('groupInfo'); if(ov) ov.remove(); openScheduleModal(gid); }
// ---------- Meetings (mesh P2P video) ----------
let meetWs=null, meetLocalStream=null, meetRoom=null, meetMyId=null, meetState='idle';
let meetAnnounceGroup=null; // when set, the new meeting's code is posted to this group chat
let meetAudioOnly=false; // audio call (no camera) — tiles show avatars instead of video
const meetPeers=new Map(); // peerId -> { pc, name }
const meetMuted=new Map(); // peerId|'__local' -> muted (for the tile mic-off badge)
const meetNames=new Map(); // peerId -> name (peers that arrive before their offer)
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' }] };
let meetMic=true, meetCam=true;
let meetIsHost=false, meetHostId=null; // host = the meeting creator (transferable by the host only)
let meetScreen=false, meetScreenStream=null; // am I sharing my screen + the display stream
const meetSharers=new Set(); // peerIds of OTHERS currently sharing their screen
let meetMultiShare=false; // host setting: allow several people to share at once (default: one at a time)
let meetRec=null; // active composite recording (host) {rec, stop()}
let meetTranscribe=false; // am I subscribed to a transcript copy
let meetRoomTx=false; // is the room transcription active (≥1 subscriber → all mics transcribe)
let meetSR=null; // my SpeechRecognition instance
let meetStageId=null; // which shared screen is currently on the stage (peerId|'__local')
function meetSend(o){ try{ if(meetWs && meetWs.readyState===1) meetWs.send(JSON.stringify(o)); }catch(_){} }
// ================= LiveKit SFU media plane (feature-flagged) =================
// When the server reports sfu:true, meeting MEDIA flows through LiveKit instead of the P2P mesh:
// each client uploads once and the SFU forwards to everyone, so rooms scale past ~5. Everything
// else — the meeting WebSocket control plane (join/host/mute/screen/recording/transcript), the tile
// UI, recording and transcript — is UNCHANGED. Tiles stay keyed by the WS peerId; LiveKit tracks map
// back to a peer via its uid (LiveKit identity = app user id = the peer's uid in meetPeerUids).
let SFU={ on:false, url:'', room:null, lib:null, peers:new Map() }; // peers: pid -> {audio,cam,screen}
async function sfuInit(){ try{ const c=await fetch('/api/meetings/config').then(r=>r.json()); SFU.on=!!(c&&c.sfu); SFU.url=(c&&c.url)||''; }catch(_){ SFU.on=false; } }
function sfuLoadLib(){ return new Promise((res,rej)=>{ if(window.LivekitClient) return res(window.LivekitClient); const s=document.createElement('script'); s.src='/vendor/livekit-client.umd.min.js'; s.onload=()=>res(window.LivekitClient); s.onerror=()=>rej(new Error('livekit sdk failed to load')); document.head.appendChild(s); }); }
function peerIdForUid(uid){ for(const [pid,u] of meetPeerUids){ if(u===uid) return pid; } return null; }
async function sfuConnect(){
const LK=await sfuLoadLib(); SFU.lib=LK;
// Guests (external link joiners, not signed in) get an unauthenticated guest token for this room.
const tk=(ME&&ME.guest)
? await postJSON('/api/meetings/guest-token',{ room:meetRoom, name:ME.name, identity:ME.id }) // identity must match the guestId sent over signaling
: await postJSON('/api/meetings/token',{ room:meetRoom }); // per-user, per-room join credential
// adaptiveStream + dynacast BOTH off: we attach media via manual srcObject (not track.attach), so
// LiveKit can't observe tile visibility. With dynacast on, a screen-share layer was paused unless a
// camera track was also flowing → the screen showed blank / only when the camera was on, and was
// slow to appear (#9). Off = every published track always flows at full quality.
const room=new LK.Room({ adaptiveStream:false, dynacast:false }); SFU.room=room;
room.on(LK.RoomEvent.TrackSubscribed, (track,pub,participant)=>sfuAttach(pub,track,participant));
room.on(LK.RoomEvent.TrackUnsubscribed, (track,pub,participant)=>sfuDetach(pub,track,participant));
await room.connect(SFU.url, tk.token);
// Re-apply the user's saved mic/speaker choice to this room (#3: a picked headset/Bluetooth device
// must actually be used, not just remembered).
try{ if(meetMicId && room.switchActiveDevice) await room.switchActiveDevice('audioinput', meetMicId); }catch(_){}
try{ if(meetSinkId && room.switchActiveDevice) await room.switchActiveDevice('audiooutput', meetSinkId); }catch(_){}
}
// Per-peer track bookkeeping → one tile stream that prefers the screen track over the camera,
// mirroring the mesh's single-video-per-tile model and driving the existing sharing/stage UI.
function sfuRebuild(pid){
const p=SFU.peers.get(pid)||{}; const st=new MediaStream();
const vid=p.screen||p.cam; if(vid) st.addTrack(vid); if(p.audio) st.addTrack(p.audio);
addTile(pid, st, meetNames.get(pid)||'Guest', false); setTileScreen(pid, !!p.screen); meetWatchStream(pid, st);
}
function sfuAttach(pub, track, participant, _try){
const pid=peerIdForUid(participant.identity);
if(!pid){ // #9: uid→peerId map (from the mesh join) may lag the LiveKit track — retry a few times
if((_try||0)<10){ setTimeout(()=>sfuAttach(pub,track,participant,(_try||0)+1), 400); }
return;
}
const p=SFU.peers.get(pid)||{}; SFU.peers.set(pid,p); const mt=track.mediaStreamTrack;
if(track.kind==='audio') p.audio=mt;
else if(pub.source===SFU.lib.Track.Source.ScreenShare){ p.screen=mt; meetSharers.add(pid); }
else p.cam=mt;
sfuRebuild(pid); updateShareMode();
}
function sfuDetach(pub, track, participant){
const pid=peerIdForUid(participant.identity); if(!pid) return; const p=SFU.peers.get(pid); if(!p) return; const mt=track.mediaStreamTrack;
if(p.audio===mt) p.audio=null; else if(p.screen===mt){ p.screen=null; meetSharers.delete(pid); setTileScreen(pid,false); } else if(p.cam===mt) p.cam=null;
sfuRebuild(pid); updateShareMode();
}
function sfuDropPeer(pid){ SFU.peers.delete(pid); }
// Reflect my LiveKit local mic/cam tracks into meetLocalStream so the self tile + active-speaker
// meter + canvas recording (which read tile <video> srcObjects) keep working unchanged.
function sfuSyncLocal(){
const lp=SFU.room&&SFU.room.localParticipant; if(!lp) return; const LK=SFU.lib;
const mic=lp.getTrackPublication(LK.Track.Source.Microphone), cam=lp.getTrackPublication(LK.Track.Source.Camera);
const mt=mic&&mic.track&&mic.track.mediaStreamTrack, ct=cam&&cam.track&&cam.track.mediaStreamTrack;
meetLocalStream.getTracks().forEach(t=>{ if(t!==mt&&t!==ct) meetLocalStream.removeTrack(t); });
if(mt&&meetLocalStream.getTracks().indexOf(mt)<0) meetLocalStream.addTrack(mt);
if(ct&&meetLocalStream.getTracks().indexOf(ct)<0) meetLocalStream.addTrack(ct);
addTile('__local', meetLocalStream, (ME&&ME.name)?ME.name:'You', true); meetWatchStream('__local', meetLocalStream);
}
async function sfuSetMic(on){ const lp=SFU.room&&SFU.room.localParticipant; if(!lp) return; await lp.setMicrophoneEnabled(on); sfuSyncLocal(); }
async function sfuSetCam(on){ const lp=SFU.room&&SFU.room.localParticipant; if(!lp) return; await lp.setCameraEnabled(on); sfuSyncLocal(); }
function sfuDisconnect(){ if(SFU.room){ try{ SFU.room.disconnect(); }catch(_){} SFU.room=null; } SFU.peers.clear(); }
// #15: turn a getUserMedia / LiveKit media failure into a SPECIFIC, actionable message (and log the
// raw error) instead of always blaming "permission" — which hid the real cause on the desktop app.
function mediaErrMsg(e, what){
const n=((e&&e.name)||'')+''; try{ console.error('[media] '+what+' failed:', n, e&&e.message, e); }catch(_){}
const isDesk=(typeof nativePlatform==='function' && nativePlatform()==='desktop');
const Cap=what.charAt(0).toUpperCase()+what.slice(1);
if(n==='NotAllowedError'||n==='PermissionDeniedError'||n==='SecurityError') return isDesk
? Cap+' access is blocked. Enable it in Windows Settings → Privacy & security → '+(what==='camera'?'Camera':'Microphone')+' (turn on "Let desktop apps access…"), then retry.'
: Cap+' permission was blocked — allow it from your browsers address-bar icon and retry.';
if(n==='NotFoundError'||n==='DevicesNotFoundError') return 'No '+what+' was found on this device.';
if(n==='NotReadableError'||n==='TrackStartError'||n==='AbortError') return 'Your '+what+' is in use by another app — close it and retry.';
if(n==='OverconstrainedError') return 'No '+what+' matches the requested settings.';
return 'Couldnt start '+what+(e&&e.message?(' ('+e.message+')'):'')+'.';
}
// ================= end LiveKit SFU =================
function meetRailLive(on){ const b=document.querySelector('.railbtn[data-tab="meeting"]'); if(b) b.classList.toggle('live', !!on); }
function renderMeetingLobby(){
meetState='idle';
const el=document.getElementById('meetingPanel'); if(!el) return;
el.innerHTML='<div class="meet-dash">'
+ '<div class="md-top">'
+ '<div class="md-title"><h1>Meetings</h1><p>Start or join a video meeting, or schedule one for later. Small group (mesh) for now — larger rooms coming with the SFU.</p></div>'
+ '<div class="md-actions">'
+ '<div class="md-join"><input id="meetCode" placeholder="Enter code" inputmode="numeric" maxlength="6"><button class="btn primary" id="meetJoinBtn">Join</button></div>'
+ '<button class="btn" id="meetStart">'+ic('video',16)+' Start a meeting</button>'
+ '<button class="btn primary" id="meetSchedule">'+ic('calendar',16)+' Schedule</button>'
+ '</div>'
+ '</div>'
+ '<div class="hint" id="meetErr"></div>'
+ '<div class="sched-wrap" id="schedWrap"><div class="sched-empty">Loading meetings…</div></div>'
+ '</div>';
document.getElementById('meetStart').onclick=()=>enterMeeting(null);
document.getElementById('meetSchedule').onclick=()=>openScheduleModal(null);
const doJoin=()=>{ const c=document.getElementById('meetCode').value.trim(); if(/^\d{6}$/.test(c)) enterMeeting(c); else document.getElementById('meetErr').textContent='Enter a valid 6-digit code.'; };
document.getElementById('meetJoinBtn').onclick=doJoin;
document.getElementById('meetCode').addEventListener('keydown', e=>{ if(e.key==='Enter') doJoin(); });
loadScheduledMeetings();
}
// Fetch + render scheduled meetings, bucketed into Running / Upcoming / Past.
// #7: Past meetings are paged + date-filterable on the SERVER (the list used to be hard-capped at 12).
// This is a report, so it gets report-style presets plus a custom range using the SAME branded calendar
// as the scheduler (a raw <input type=date> looked out of place and behaved differently per browser).
let _mtgPage=1, _mtgFrom=null, _mtgTo=null, _mtgPreset='all', _mtgTotal=0, _mtgPageSize=10;
const MTG_PRESETS=[
{k:'all', label:'All time'},
{k:'today', label:'Today'},
{k:'yesterday',label:'Yesterday'},
{k:'7d', label:'Last 7 days'},
{k:'30d', label:'Last 30 days'},
{k:'month', label:'This month'},
{k:'ytd', label:'Year to date'},
{k:'custom', label:'Custom range…'},
];
function applyMtgPreset(k){
_mtgPreset=k;
const now=new Date();
const sod=(d)=>{ const x=new Date(d); x.setHours(0,0,0,0); return x.getTime(); };
const eod=(d)=>{ const x=new Date(d); x.setHours(23,59,59,999); return x.getTime(); };
const daysAgo=(n)=>{ const x=new Date(now); x.setDate(x.getDate()-n); return x; };
if(k==='all'){ _mtgFrom=null; _mtgTo=null; }
else if(k==='today'){ _mtgFrom=sod(now); _mtgTo=eod(now); }
else if(k==='yesterday'){ const y=daysAgo(1); _mtgFrom=sod(y); _mtgTo=eod(y); }
else if(k==='7d'){ _mtgFrom=sod(daysAgo(6)); _mtgTo=eod(now); }
else if(k==='30d'){ _mtgFrom=sod(daysAgo(29)); _mtgTo=eod(now); }
else if(k==='month'){ _mtgFrom=sod(new Date(now.getFullYear(),now.getMonth(),1)); _mtgTo=eod(now); }
else if(k==='ytd'){ _mtgFrom=sod(new Date(now.getFullYear(),0,1)); _mtgTo=eod(now); }
// 'custom' keeps whatever from/to are already set
_mtgPage=1; loadScheduledMeetings();
}
const mtgDateLbl=(ts)=>ts?new Date(ts).toLocaleDateString([],{month:'short',day:'numeric',year:'numeric'}):'Any';
// A compact branded calendar popup — same look/behaviour as the scheduler's picker. opts.min/opts.max
// (ms) disable out-of-range days so an invalid range (from > to) can't be picked (#4).
function openDatePop(anchor, current, onPick, opts){
opts=opts||{};
const minDay=opts.min?new Date(opts.min).setHours(0,0,0,0):null;
const maxDay=opts.max?new Date(opts.max).setHours(0,0,0,0):null;
document.querySelectorAll('.date-pop').forEach(x=>x.remove());
const base=current?new Date(current):new Date();
let viewY=base.getFullYear(), viewM=base.getMonth();
const pop=document.createElement('div'); pop.className='pick-pop date-pop';
const D1=['S','M','T','W','T','F','S'];
const draw=()=>{
const first=new Date(viewY,viewM,1), startDow=first.getDay(), dim=new Date(viewY,viewM+1,0).getDate();
const selTs=current?new Date(current).setHours(0,0,0,0):0;
let h='<div class="cal-head"><button type="button" class="cal-nav" data-nav="-1">'+ic('chevronLeft',16)+'</button>'
+'<b>'+first.toLocaleDateString([],{month:'long',year:'numeric'})+'</b>'
+'<button type="button" class="cal-nav" data-nav="1">'+ic('chevronRight',16)+'</button></div><div class="cal-grid">'
+D1.map(d=>'<span class="cal-dow">'+d+'</span>').join('');
for(let i=0;i<startDow;i++) h+='<span></span>';
for(let d=1;d<=dim;d++){ const ts=new Date(viewY,viewM,d).setHours(0,0,0,0);
const disabled=(minDay!==null&&ts<minDay)||(maxDay!==null&&ts>maxDay);
h+='<button type="button" class="cal-day'+(ts===selTs?' sel':'')+'"'+(disabled?' disabled':'')+' data-ts="'+ts+'">'+d+'</button>'; }
pop.innerHTML=h+'</div>';
pop.querySelectorAll('.cal-nav').forEach(b=>b.onclick=(e)=>{ e.stopPropagation(); viewM+=(+b.dataset.nav); if(viewM<0){viewM=11;viewY--;} if(viewM>11){viewM=0;viewY++;} draw(); });
pop.querySelectorAll('.cal-day:not([disabled])').forEach(b=>b.onclick=()=>{ pop.remove(); onPick(+b.dataset.ts); });
};
document.body.appendChild(pop); draw();
const r=anchor.getBoundingClientRect();
pop.style.left=Math.max(8,Math.min(r.left, window.innerWidth-pop.offsetWidth-8))+'px';
pop.style.top=Math.min(r.bottom+6, window.innerHeight-pop.offsetHeight-8)+'px';
const close=(e)=>{ if(!pop.contains(e.target) && e.target!==anchor){ pop.remove(); document.removeEventListener('mousedown',close); } };
setTimeout(()=>document.addEventListener('mousedown',close),0);
}
async function loadScheduledMeetings(){
const wrap=document.getElementById('schedWrap'); if(!wrap) return;
const qs=new URLSearchParams();
qs.set('page', String(_mtgPage)); qs.set('pageSize', String(_mtgPageSize));
if(_mtgFrom) qs.set('from', String(_mtgFrom));
if(_mtgTo) qs.set('to', String(_mtgTo));
let res; try{ res=await fetch('/api/meetings?'+qs.toString()).then(r=>r.json()); }catch(_){ return; }
const list=Array.isArray(res)?res:(res&&Array.isArray(res.list)?res.list:[]); // tolerate the old array shape
_mtgTotal=(res&&res.pastTotal)||0; _mtgPageSize=(res&&res.pageSize)||_mtgPageSize;
const filtering=!!(_mtgFrom||_mtgTo);
if(!list.length && !filtering && !_mtgTotal){ wrap.innerHTML='<div class="sched-empty">No meetings yet. Start one now or schedule it for later.</div>'; return; }
const running=list.filter(m=>m.status==='running');
const upcoming=list.filter(m=>m.status==='upcoming').sort((a,b)=>a.scheduledAt-b.scheduledAt);
const past=list.filter(m=>m.status==='past'||m.status==='cancelled'); // server already sorted+paged
const fmt=ts=>new Date(ts).toLocaleString([],{weekday:'short',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'});
const card=m=>{
const cancelled=m.status==='cancelled';
const meta=[]; if(m.groupName) meta.push(pEsc(m.groupName)); meta.push(fmt(m.scheduledAt));
if(m.participantCount) meta.push(m.participantCount+' participants'); // #7: from the call log
if(m.durationMins) meta.push(m.durationMins+' min');
if(m.recurrenceLabel) meta.push('🔁 '+pEsc(m.recurrenceLabel));
if(m.status==='running') meta.push(m.inCall+' in call');
if(m.isHost) meta.push('You\'re the host'); else if(m.createdByName) meta.push('Host: '+pEsc(m.createdByName)); // skip "Host: —" for logged calls
if(m.invited&&m.invited.length) meta.push(m.invited.length+' invited');
// Cancel only on a FUTURE upcoming meeting (#13: not once the time has passed).
const canCancel=m.canManage && m.status==='upcoming' && m.scheduledAt>Date.now();
// Start any time BEFORE the meeting's end; once the window passes it's 'past' and can't start (#3).
const canStart=m.status==='running' || m.status==='upcoming';
return '<div class="sched-item'+(m.status==='running'?' live':'')+(cancelled?' cancelled':'')+'">'
+'<div class="si-main"><div class="si-title">'+pEsc(m.title)+(m.status==='running'?'<span class="livedot">● Live</span>':'')+(cancelled?'<span class="cancel-tag">Cancelled</span>':'')+(m.isHost?'<span class="host-tag">'+ic('crown',11)+' Host</span>':'')+'</div>'
+'<div class="si-meta">'+meta.join(' · ')+'</div>'
+(m.description?'<div class="si-desc">'+pEsc(m.description)+'</div>':'')
+(m.invited&&m.invited.length?'<div class="si-invited" title="'+pEsc(m.invited.join(', '))+'">'+ic('users',12)+' '+pEsc(m.invited.slice(0,3).join(', '))+(m.invited.length>3?(' +'+(m.invited.length-3)):'')+'</div>':'')
+(m.recordings&&m.recordings.length?'<div class="si-recs">'+m.recordings.map(r=>'<a class="rec-dl '+(r.kind==='video'?'vid':'txt')+'" href="'+pEsc(r.url)+'" title="Download '+(r.kind==='video'?'recording':'transcript')+'">'+ic('download',14)+'<span>'+(r.kind==='video'?'Recording':'Transcript')+'</span>'+(r.kind==='video'&&r.durationMs?'<span class="rd-dur">'+fmtElapsed(r.durationMs)+'</span>':'')+'</a>').join('')+'</div>':'')+'</div>'
+'<div class="si-actions">'
+((m.status!=='past'&&!cancelled)?'<button class="iconbtn copylink" data-link="'+pEsc(m.link||'')+'" title="Copy invite link" aria-label="Copy invite link">'+ic('link',14)+'</button>':'')
+((m.status!=='past'&&!cancelled&&canStart)?'<button class="btn sm join" data-code="'+pEsc(m.roomCode)+'">'+(m.status==='running'?'Join':'Start')+'</button>':'')
+(canCancel?'<button class="iconbtn edit" data-edit="'+pEsc(m.id)+'" title="Edit meeting" aria-label="Edit meeting">'+ic('pencil',14)+'</button>':'')
+(canCancel?'<button class="iconbtn cancel-ic" data-cancel="'+pEsc(m.id)+'" title="Cancel meeting" aria-label="Cancel meeting">'+ic('calendarX',14)+'</button>':'')
+'</div></div>';
};
const byId={}; list.forEach(m=>byId[m.id]=m);
const sec=(title,arr)=>arr.length?('<div class="sched-sec"><div class="sched-h">'+title+'</div><div class="sched-list">'+arr.map(card).join('')+'</div></div>'):'';
// Past section: date range + pager. Always rendered (even with 0 results) so the filter stays reachable.
const pages=Math.max(1, Math.ceil(_mtgTotal/_mtgPageSize));
const custom=_mtgPreset==='custom';
const pastSec='<div class="sched-sec"><div class="sched-h">Past meetings'
+'<span class="mtg-filter">'+ic('calendar',13)
+'<select id="mtgPreset">'+MTG_PRESETS.map(p=>'<option value="'+p.k+'"'+(_mtgPreset===p.k?' selected':'')+'>'+pEsc(p.label)+'</option>').join('')+'</select>'
+(custom?('<button type="button" class="mtg-date" id="mtgFromBtn">'+pEsc(mtgDateLbl(_mtgFrom))+'</button>'
+'<span class="mtg-dash"></span>'
+'<button type="button" class="mtg-date" id="mtgToBtn">'+pEsc(mtgDateLbl(_mtgTo))+'</button>'):'')
+((_mtgFrom||_mtgTo)?'<button class="mtg-clear" id="mtgClear" title="Clear filter">'+ic('x',13)+'</button>':'')
+'</span></div>'
+(past.length?('<div class="sched-list">'+past.map(card).join('')+'</div>')
:('<div class="sched-empty" style="margin:0">'+((_mtgFrom||_mtgTo)?'No meetings in that date range.':'No past meetings yet.')+'</div>'))
+(_mtgTotal>_mtgPageSize?('<div class="mtg-pager">'
+'<button class="mtg-pg" id="mtgPrev"'+(_mtgPage<=1?' disabled':'')+'>'+ic('chevronLeft',14)+'</button>'
+'<span>Page '+_mtgPage+' of '+pages+' · '+_mtgTotal+' total</span>'
+'<button class="mtg-pg" id="mtgNext"'+(_mtgPage>=pages?' disabled':'')+'>'+ic('chevronRight',14)+'</button>'
+'</div>'):'')
+'</div>';
wrap.innerHTML=sec('Ongoing now',running)+sec('Upcoming meetings',upcoming)+pastSec;
{ const ps=wrap.querySelector('#mtgPreset');
if(ps) ps.onchange=()=>{ if(ps.value==='custom'){ _mtgPreset='custom'; loadScheduledMeetings(); } else applyMtgPreset(ps.value); };
const fb=wrap.querySelector('#mtgFromBtn'), tb=wrap.querySelector('#mtgToBtn');
if(fb) fb.onclick=(e)=>{ e.stopPropagation(); openDatePop(fb, _mtgFrom, (ts)=>{ _mtgFrom=ts; _mtgPage=1; loadScheduledMeetings(); }, {max:_mtgTo}); }; // #4: from ≤ to
if(tb) tb.onclick=(e)=>{ e.stopPropagation(); openDatePop(tb, _mtgTo, (ts)=>{ const end=new Date(ts); end.setHours(23,59,59,999); _mtgTo=end.getTime(); _mtgPage=1; loadScheduledMeetings(); }, {min:_mtgFrom}); };
const c=wrap.querySelector('#mtgClear'); if(c) c.onclick=()=>applyMtgPreset('all');
const pv=wrap.querySelector('#mtgPrev'), nx=wrap.querySelector('#mtgNext');
if(pv) pv.onclick=()=>{ if(_mtgPage>1){ _mtgPage--; loadScheduledMeetings(); } };
if(nx) nx.onclick=()=>{ if(_mtgPage<pages){ _mtgPage++; loadScheduledMeetings(); } };
}
wrap.querySelectorAll('[data-code]').forEach(b=>b.onclick=()=>enterMeeting(b.dataset.code));
wrap.querySelectorAll('[data-link]').forEach(b=>b.onclick=()=>{ const u=b.dataset.link; if(!u){ toast('No link'); return; } const done=()=>toast('Invite link copied'); try{ if(navigator.clipboard&&navigator.clipboard.writeText) return void navigator.clipboard.writeText(u).then(done).catch(()=>fallbackCopy(u,done)); }catch(_){} fallbackCopy(u,done); });
wrap.querySelectorAll('[data-edit]').forEach(b=>b.onclick=()=>{ const m=byId[b.dataset.edit]; if(m) openScheduleModal(m.groupId||null, m); });
wrap.querySelectorAll('[data-cancel]').forEach(b=>b.onclick=()=>cancelMeeting(byId[b.dataset.cancel]));
}
// Last admin leaving must hand off: pick who becomes the new admin, then leave (#10, selective).
function pickSuccessorThenLeave(gid, others){
const ov=document.createElement('div'); ov.className='modal-ov';
ov.innerHTML='<div class="modal" style="max-width:380px"><div class="gi-title" style="margin-bottom:.2rem">Choose a new admin</div><p style="color:var(--muted);font-size:.88rem;margin:.3rem 0 .8rem">You\'re the only admin. Pick someone to take over before you leave.</p><div class="gi-list" style="max-height:40vh;overflow:auto">'+others.map(m=>'<label class="chk"><input type="radio" name="heir" value="'+pEsc(m.id)+'"><span class="mini-av" style="background:'+avColor(m.name)+'">'+pEsc(initials(m.name))+'</span><span class="mn">'+pEsc(m.name)+'</span></label>').join('')+'</div><button class="gobtn" id="heirGo" style="width:100%;margin-top:.8rem;background:var(--blue);color:#fff">Make admin & leave</button><div class="hint" id="heirErr"></div></div>';
document.body.appendChild(ov); ov.onclick=e=>{ if(e.target===ov) ov.remove(); };
ov.querySelector('#heirGo').onclick=async()=>{ const sel=ov.querySelector('input[name=heir]:checked'); if(!sel){ ov.querySelector('#heirErr').textContent='Please pick a member.'; return; }
try{ await postJSON('/api/groups/remove',{group:gid, newAdmin:sel.value}); ov.remove(); selected=null; await loadSidebar(); renderChatPanel(); toast('Left the group'); }catch(e){ ov.querySelector('#heirErr').textContent=e.message||'Could not leave'; } };
}
// Cancel a meeting. Recurring → ask whether to cancel just this occurrence or all future (#recurring).
function cancelMeeting(m){
if(!m) return;
const doCancel=async(scope)=>{ try{ await postJSON('/api/meetings/cancel',{id:m.id, scope}); loadScheduledMeetings(); }catch(e){ toast(e.message); } };
if(m.recurrence&&m.recurrence.length){
const dlabel=new Date(m.scheduledAt).toLocaleString([],{weekday:'short',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'});
const ov=document.createElement('div'); ov.className='modal-ov';
ov.innerHTML='<div class="modal" style="max-width:390px"><div class="gi-title" style="margin-bottom:.2rem">Cancel recurring meeting</div><p style="color:var(--muted);font-size:.9rem;margin:.3rem 0 1rem">“'+pEsc(m.title)+'” repeats '+pEsc(m.recurrenceLabel||'weekly')+'. What do you want to cancel?</p><div style="display:flex;flex-direction:column;gap:.5rem"><button class="btn" id="cmOne" style="background:var(--blue);color:#fff">Only this occurrence ('+pEsc(dlabel)+')</button><button class="btn" id="cmAll" style="background:#fee2e2;color:#b91c1c">Cancel all future</button><button class="linkbtn" id="cmNo" style="align-self:center;border:none">Keep meeting</button></div></div>';
document.body.appendChild(ov); ov.onclick=e=>{ if(e.target===ov) ov.remove(); };
ov.querySelector('#cmOne').onclick=()=>{ ov.remove(); doCancel('one'); };
ov.querySelector('#cmAll').onclick=()=>{ ov.remove(); doCancel('all'); };
ov.querySelector('#cmNo').onclick=()=>ov.remove();
} else { if(confirm('Cancel this meeting? Participants will be notified.')) doCancel('all'); }
}
// Schedule / edit modal with a custom calendar + time picker. gid ties it to a group; editMtg edits.
function openScheduleModal(gid, editMtg){
const pad=n=>String(n).padStart(2,'0'); const now=new Date();
const startOfDay=d=>{ const x=new Date(d); x.setHours(0,0,0,0); return x; };
const editing=!!editMtg;
const base=editing?new Date(editMtg.scheduledAt):new Date(now.getTime()+30*60000);
let selDate=startOfDay(base);
let selMin=Math.round((base.getHours()*60+base.getMinutes())/15)*15;
let viewY=selDate.getFullYear(), viewM=selDate.getMonth();
let recur=(editing&&Array.isArray(editMtg.recurrence))?editMtg.recurrence.slice():[];
const DAYW=['Sun','Mon','Tue','Wed','Thu','Fri','Sat'], DAY1=['S','M','T','W','T','F','S'];
const todayMid=startOfDay(now).getTime();
const label12=m=>{ let h=Math.floor(m/60),mm=m%60; const ap=h<12?'AM':'PM'; let h12=h%12||12; return h12+':'+pad(mm)+' '+ap; };
const dateLabel=d=>d.toLocaleDateString([],{weekday:'short',month:'short',day:'numeric'});
const invitedIds=new Set(editing&&Array.isArray(editMtg.invitedIds)?editMtg.invitedIds:[]);
const ov=document.createElement('div'); ov.className='modal-ov'; ov.id='schedModal';
ov.innerHTML='<div class="modal sched">'
+'<div class="gi-head" style="margin-bottom:.6rem"><div class="avatar grp" style="width:40px;height:40px;flex:0 0 40px;background:var(--blue)">'+ic('calendarClock',20)+'</div>'
+'<div class="gi-name"><div class="gi-title">'+(editing?'Edit meeting':'Schedule a call')+'</div><div class="gi-sub">Pick a date & time and add details</div></div>'
+'<button class="iconbtn" id="schClose" title="Close">'+ic('x',18)+'</button></div>'
+'<label class="flbl">Title</label><input id="schTitle" class="finput" placeholder="e.g. Weekly sync" maxlength="120">'
+'<div class="sch-row">'
+ '<div class="picker-field"><label class="flbl">Date</label><button type="button" class="finput pick-btn" id="schDateBtn">'+ic('calendar',16)+'<span id="schDateLbl"></span></button><div class="pick-pop hidden" id="schCal"></div></div>'
+ '<div class="picker-field"><label class="flbl">Time</label><button type="button" class="finput pick-btn" id="schTimeBtn">'+ic('calendarClock',16)+'<span id="schTimeLbl"></span></button><div class="pick-pop time-pop hidden" id="schTimePop"></div></div>'
+ '<div><label class="flbl">Duration</label><select id="schDur" class="finput"><option value="15">15 min</option><option value="30">30 min</option><option value="45">45 min</option><option value="60">1 hour</option><option value="90">1.5 hours</option><option value="120">2 hours</option></select></div>'
+'</div>'
+'<label class="chk2 switch-row"><span>'+ic('calendarClock',15)+' Repeat weekly</span><span class="switch"><input type="checkbox" id="schRepeat"><span class="slider"></span></span></label>'
+'<div class="sch-days hidden" id="schDays">'+DAY1.map((d,i)=>'<button type="button" class="day-chip" data-d="'+i+'" title="'+DAYW[i]+'">'+d+'</button>').join('')+'<button type="button" class="day-all" data-all="1">Everyday</button></div>'
+'<label class="flbl">Description <span class="opt">(optional)</span></label><textarea id="schDesc" class="finput" rows="2" placeholder="What\'s this call about?"></textarea>'
+'<label class="flbl">Invite participants</label>'
+(CONTACTS.length>6?'<div class="gi-search" style="margin:.15rem 0 .35rem"><input id="schPplSearch" placeholder="Search people…" autocomplete="off"></div>':'')
+'<div class="gi-list" id="schPeople" style="max-height:20vh;overflow:auto">'+(CONTACTS.length?CONTACTS.map(c=>'<label class="chk" data-nm="'+pEsc((c.name||'').toLowerCase())+'"><input type="checkbox" value="'+pEsc(c.id)+'"'+(invitedIds.has(c.id)?' checked':'')+'><span class="mini-av" style="background:'+avColor(c.name)+'">'+pEsc(initials(c.name))+'</span><span class="mn">'+pEsc(c.name)+'</span></label>').join(''):'<div class="gi-noresult">No contacts to invite</div>')+'</div>'
+'<label class="flbl" style="margin-top:.7rem">Invite by email <span class="opt">(guests — no Connect account needed)</span></label>'
+'<div class="email-invite"><input id="schEmail" class="finput" type="email" placeholder="name@example.com" autocomplete="off"><button type="button" class="email-add" id="schEmailAdd">'+ic('userPlus',15)+' Add</button></div>'
+'<div class="email-chips" id="schEmailChips"></div>'
+'<label class="chk2 switch-row" style="margin-top:.6rem"><span>'+ic('users',15)+' Guests must be admitted by the host</span><span class="switch"><input type="checkbox" id="schLobby" checked><span class="slider"></span></span></label>'
+'<button class="gobtn" id="schSave" style="width:100%;margin-top:.9rem;background:var(--blue);color:#fff">'+(editing?'Save changes':'Schedule & invite')+'</button>'
+'<div class="hint" id="schErr"></div></div>';
document.body.appendChild(ov);
ov.onclick=e=>{ if(e.target===ov) ov.remove(); };
document.getElementById('schClose').onclick=()=>ov.remove();
const $=id=>document.getElementById(id);
if(editing){ $('schTitle').value=editMtg.title||''; $('schDesc').value=editMtg.description||''; if(editMtg.durationMins) $('schDur').value=String(editMtg.durationMins); { const lb=$('schLobby'); if(lb) lb.checked=(editMtg.lobby!==false); } }
else $('schDur').value='30';
const err=$('schErr');
const dateBtn=$('schDateBtn'), timeBtn=$('schTimeBtn'), cal=$('schCal'), timePop=$('schTimePop');
const clearErrAll=()=>{ err.textContent=''; [dateBtn,timeBtn,$('schTitle')].forEach(e=>e&&e.classList.remove('field-err')); };
function syncLabels(){ $('schDateLbl').textContent=dateLabel(selDate); $('schTimeLbl').textContent=label12(selMin); }
function renderCal(){
const first=new Date(viewY,viewM,1), startDow=first.getDay(), dim=new Date(viewY,viewM+1,0).getDate();
const canPrev=!(viewY===now.getFullYear()&&viewM===now.getMonth());
let h='<div class="cal-head"><button type="button" class="cal-nav" id="calPrev"'+(canPrev?'':' disabled')+'>'+ic('arrowLeft',16)+'</button><b>'+first.toLocaleDateString([],{month:'long',year:'numeric'})+'</b><button type="button" class="cal-nav" id="calNext">'+ic('arrowRight',16)+'</button></div><div class="cal-grid">'+DAY1.map(d=>'<span class="cal-dow">'+d+'</span>').join('');
for(let i=0;i<startDow;i++) h+='<span></span>';
for(let day=1;day<=dim;day++){ const dt=new Date(viewY,viewM,day).getTime(); const past=dt<todayMid; h+='<button type="button" class="cal-day'+(dt===selDate.getTime()?' sel':'')+(dt===todayMid?' today':'')+'"'+(past?' disabled':'')+' data-day="'+day+'">'+day+'</button>'; }
cal.innerHTML=h+'</div>';
const pv=cal.querySelector('#calPrev'); if(pv&&canPrev) pv.onclick=()=>{ if(--viewM<0){viewM=11;viewY--;} renderCal(); };
cal.querySelector('#calNext').onclick=()=>{ if(++viewM>11){viewM=0;viewY++;} renderCal(); };
cal.querySelectorAll('.cal-day:not([disabled])').forEach(b=>b.onclick=()=>{ selDate=new Date(viewY,viewM,+b.dataset.day); cal.classList.add('hidden'); clearErrAll(); renderTimes(); syncLabels(); });
}
function renderTimes(){
const isToday=selDate.getTime()===todayMid; const minM=isToday?(now.getHours()*60+now.getMinutes()):-1;
if(selMin<=minM){ selMin=Math.ceil((minM+1)/15)*15; }
let h='',any=false;
for(let m=0;m<24*60;m+=15){ if(m<=minM) continue; any=true; h+='<button type="button" class="time-chip'+(m===selMin?' sel':'')+'" data-m="'+m+'">'+label12(m)+'</button>'; }
timePop.innerHTML=any?h:'<div class="gi-noresult" style="padding:.7rem">No times left today</div>';
timePop.querySelectorAll('.time-chip').forEach(b=>b.onclick=()=>{ selMin=+b.dataset.m; timePop.classList.add('hidden'); clearErrAll(); syncLabels(); });
syncLabels();
}
dateBtn.onclick=e=>{ e.stopPropagation(); timePop.classList.add('hidden'); cal.classList.toggle('hidden'); if(!cal.classList.contains('hidden')){ viewY=selDate.getFullYear(); viewM=selDate.getMonth(); renderCal(); } };
timeBtn.onclick=e=>{ e.stopPropagation(); cal.classList.add('hidden'); timePop.classList.toggle('hidden'); if(!timePop.classList.contains('hidden')){ renderTimes(); const s=timePop.querySelector('.time-chip.sel'); if(s) s.scrollIntoView({block:'center'}); } };
ov.addEventListener('click',e=>{ if(!e.target.closest('.picker-field')){ cal.classList.add('hidden'); timePop.classList.add('hidden'); } });
renderTimes(); syncLabels();
// Repeat (iOS switch) + circular day chips
const repeat=$('schRepeat'), daysWrap=$('schDays');
if(editing&&recur.length){ repeat.checked=true; daysWrap.classList.remove('hidden'); recur.forEach(d=>{ const b=daysWrap.querySelector('.day-chip[data-d="'+d+'"]'); if(b) b.classList.add('on'); }); }
repeat.onchange=()=>{ daysWrap.classList.toggle('hidden', !repeat.checked); };
daysWrap.querySelectorAll('.day-chip').forEach(b=>b.onclick=()=>b.classList.toggle('on'));
daysWrap.querySelector('.day-all').onclick=()=>{ const allOn=daysWrap.querySelectorAll('.day-chip.on').length===7; daysWrap.querySelectorAll('.day-chip').forEach(x=>x.classList.toggle('on', !allOn)); };
$('schTitle').addEventListener('input',clearErrAll);
setTimeout(()=>$('schTitle').focus(),0);
// External (guest) email invitees — chips you can add/remove; each gets an emailed join link (#4).
const EMAIL_RE=/^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const emailList=(editing&&Array.isArray(editMtg.guestEmails))?editMtg.guestEmails.slice():[];
function renderEmailChips(){ const w=$('schEmailChips'); if(!w) return; w.innerHTML=emailList.map((e,i)=>'<span class="echip">'+pEsc(e)+'<button type="button" data-i="'+i+'" title="Remove">'+ic('x',12)+'</button></span>').join(''); w.querySelectorAll('.echip button').forEach(b=>b.onclick=()=>{ emailList.splice(+b.dataset.i,1); renderEmailChips(); }); }
function addEmail(){ const inp=$('schEmail'); const v=(inp.value||'').trim().toLowerCase(); if(!v) return; if(!EMAIL_RE.test(v)){ err.textContent='Enter a valid email address.'; return; } if(!emailList.includes(v)) emailList.push(v); inp.value=''; err.textContent=''; renderEmailChips(); }
$('schEmailAdd').onclick=addEmail;
$('schEmail').addEventListener('keydown',e=>{ if(e.key==='Enter'){ e.preventDefault(); addEmail(); } });
renderEmailChips();
// #10: filter the participant list as you type.
{ const ps=$('schPplSearch'); if(ps) ps.addEventListener('input',()=>{ const q=ps.value.trim().toLowerCase(); ov.querySelectorAll('#schPeople .chk').forEach(l=>{ l.style.display=(!q||(l.dataset.nm||'').includes(q))?'':'none'; }); }); }
$('schSave').onclick=async()=>{
const title=$('schTitle').value.trim();
const desc=$('schDesc').value.trim();
const durationMins=parseInt($('schDur').value,10)||30;
const participants=[...ov.querySelectorAll('#schPeople input:checked')].map(i=>i.value);
{ const pend=($('schEmail').value||'').trim().toLowerCase(); if(pend && EMAIL_RE.test(pend) && !emailList.includes(pend)) emailList.push(pend); } // include a typed-but-not-added email
clearErrAll();
if(!title){ err.textContent='Please add a title.'; $('schTitle').classList.add('field-err'); $('schTitle').focus(); return; }
const ts=new Date(selDate.getFullYear(),selDate.getMonth(),selDate.getDate(),Math.floor(selMin/60),selMin%60,0,0).getTime();
if(ts < Date.now()+60000){ err.textContent='That time has already passed — pick a future time.'; timeBtn.classList.add('field-err'); return; }
let recurrence=[]; if(repeat.checked){ recurrence=[...daysWrap.querySelectorAll('.day-chip.on')].map(b=>+b.dataset.d); if(!recurrence.length) recurrence=[new Date(ts).getDay()]; }
const whenText=new Date(ts).toLocaleString([],{weekday:'short',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'});
try{
const lobby=!!($('schLobby')&&$('schLobby').checked);
if(editing){ await postJSON('/api/meetings/update',{ id:editMtg.id, title, description:desc, scheduledAt:ts, durationMins, participants, participantEmails:emailList, recurrence, lobby }); toast('Meeting updated'); }
else { const invn=participants.length+emailList.length; await postJSON('/api/meetings/schedule',{ group:gid||undefined, title, description:desc, scheduledAt:ts, whenText, participants, participantEmails:emailList, durationMins, recurrence, lobby }); toast('Meeting scheduled'+(invn?' · '+invn+' invited':'')); }
ov.remove(); switchTab('meeting'); loadScheduledMeetings();
}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>';
}
// #4 Lobby — guest side: waiting for the host to admit them. _inLobby lets meeting-joined know it must
// rebuild the call UI (the waiting screen replaced it, so without this the guest stayed on "waiting…"
// forever even after being admitted).
let _inLobby=false;
function renderLobbyWait(){
_inLobby=true;
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">Waiting for the host to let you in…</div><div style="color:var(--muted);font-size:.85rem;margin-top:.3rem">Youll join automatically once they admit you.</div></div>';
}
// #4 Lobby — host side: a guest is asking to join. Stacks like the call-invite banners.
function showLobbyRequest(peerId, name){
if(!peerId || document.getElementById('lob-'+peerId)) return;
try{ playPing(); }catch(_){}
const el=document.createElement('div'); el.className='call-invite lobby-req'; el.id='lob-'+peerId;
el.innerHTML='<span class="ci-ico">'+ic('users',18)+'</span><span class="ci-txt"><b>'+pEsc(name||'A guest')+'</b><br>wants to join the meeting</span>'
+'<button class="ci-join">'+ic('check',16)+' Admit</button>'
+'<button class="ci-decline" title="Deny">'+ic('x',16)+' Deny</button>';
document.body.appendChild(el);
el.querySelector('.ci-join').onclick=()=>{ meetSend({type:'meeting-admit', peerId}); el.remove(); };
el.querySelector('.ci-decline').onclick=()=>{ meetSend({type:'meeting-reject', peerId}); el.remove(); };
}
function dismissLobbyRequest(peerId){ const el=document.getElementById('lob-'+peerId); if(el){ try{ el.remove(); }catch(_){} } }
function renderCall(){
const el=document.getElementById('meetingPanel'); if(!el) return;
el.innerHTML='<div class="meet"><div class="meet-grid" id="meetGrid"></div>'
+ '<div class="meet-bar"><span class="code" id="meetCodeChip">Room <b>'+pEsc(meetRoom||'')+'</b> · share to invite</span>'
+ '<span class="mic-grp"><button class="meet-ic'+(meetMic?'':' off')+'" id="meetMicBtn" title="'+(meetMic?'Mute':'Unmute')+'">'+ic(meetMic?'mic':'micOff',20)+'</button>'
+ '<button class="mic-caret" id="meetAudioBtn" title="Audio devices (mic & speaker)">'+ic('chevronUp',12)+'</button></span>'
+ '<button class="meet-ic'+(meetCam?'':' off')+'" id="meetCamBtn" title="'+(meetCam?'Turn camera off':'Turn camera on')+'">'+ic(meetCam?'video':'videoOff',20)+'</button>'
// Mobile keeps only Mic / Camera / Speaker / End on the bar (#8e); everything else moves behind ⋮ More.
+ '<button class="meet-ic sec-btn" id="meetScreenBtn" title="Share screen">'+ic('monitor',20)+'</button>'
+ '<button class="meet-ic host-only sec-btn" id="meetRecBtn" title="Record meeting" style="display:none">'+ic('record',20)+'</button>'
+ ((ME&&ME.guest)?'':'<button class="meet-ic sec-btn" id="meetTransBtn" title="Live transcript">'+ic('fileText',20)+'</button>') // #12: transcript is a signed-in feature (guests can't download it)
+ '<button class="meet-ic sec-btn" id="meetChatBtn" title="Chat"><span class="mc-badge" id="meetChatBadge" style="display:none">0</span>'+ic('chat',20)+'</button>'
+ (canRouteAudio()?('<button class="meet-ic spk-btn'+(meetRoute==='speaker'?'':' off')+'" id="meetSpkBtn" title="'+pEsc(routeLabel())+'">'+ic(spkIcon(),20)+'</button>'):'') // #1: only where output switching actually works
+ '<button class="meet-ic sec-btn" id="meetPplBtn" title="Participants">'+ic('users',20)+'</button>'
+ '<button class="meet-ic more-btn" id="meetMoreBtn" title="More">'+ic('moreVertical',20)+'</button>'
+ '<button class="meet-ic leave" id="meetLeaveBtn" title="Leave">'+ic('callEnd',20)+'</button></div></div>';
document.getElementById('meetMicBtn').onclick=toggleMic;
document.getElementById('meetCamBtn').onclick=toggleCam;
document.getElementById('meetScreenBtn').onclick=toggleScreen;
document.getElementById('meetRecBtn').onclick=toggleRecord;
{ const tb=document.getElementById('meetTransBtn'); if(tb) tb.onclick=toggleTranscribe; }
document.getElementById('meetPplBtn').onclick=toggleMeetPanel;
{ const cb=document.getElementById('meetChatBtn'); if(cb) cb.onclick=toggleMeetChat; }
{ const ab=document.getElementById('meetAudioBtn'); if(ab) ab.onclick=(e)=>{ e.stopPropagation(); openAudioMenu(ab); }; } // desktop: mic ▾ → devices
{ const sb=document.getElementById('meetSpkBtn'); if(sb) sb.onclick=toggleSpeakerphone; } // speaker / headset toggle
{ const mb=document.getElementById('meetMoreBtn'); if(mb) mb.onclick=(e)=>{ e.stopPropagation(); openMeetMore(mb); }; } // mobile: ⋮ → the rest
document.getElementById('meetLeaveBtn').onclick=leaveMeeting;
updateHostControls();
// Click another shared screen (in the side column) to bring it onto the stage.
const grid=document.getElementById('meetGrid'); if(grid) grid.addEventListener('click', e=>{ if(!grid.classList.contains('sharing-mode')) return; const t=e.target.closest('.meet-tile.sharing'); if(t && !t.classList.contains('stage')){ setStage(t.id.replace('meet-tile-','')); } });
addTile('__local', meetLocalStream, (ME&&ME.name)?ME.name:'You', true);
setTileMute('__local', !meetMic);
detectBtOutput(); // #8d: pick the right speaker/bluetooth glyph for the current route
makeDraggable(document.querySelector('.meet-bar'), 'bzc_meetbar_pos'); // new #4: bar covers the shared screen → let them move it
}
// ---- Audio devices (#3): one menu behind the MIC's ▾ caret, listing Speaker + Microphone (like Teams),
// instead of a separate headphones button. Choosing a speaker applies setSinkId to every meeting media
// element; choosing a mic switches the live input device.
function isMobileUA(){ return /Android|iPhone|iPad|iPod|Mobile/i.test(navigator.userAgent||''); }
// #1: choosing the audio OUTPUT from the web needs setSinkId, which Android Chrome and iOS Safari do NOT
// implement — the OS owns the route there (and iOS forces loudspeaker while a mic track is live). Rather
// than ship a button that silently does nothing, we only show the speaker control where it really works.
// Real speaker/earpiece/Bluetooth switching on phones needs the native (Capacitor) build's audio plugin.
// Native iOS AudioRoute plugin (injected into the app via ios-patch.sh) — switches earpiece<->speaker at the
// AVAudioSession level, which the web setSinkId API can't do on iOS. Null in the browser/PWA/Android.
function nativeAudioRoute(){ try{ var C=window.Capacitor; return (C && C.getPlatform && C.getPlatform()==='ios' && C.Plugins && C.Plugins.AudioRoute) ? C.Plugins.AudioRoute : null; }catch(_){ return null; } }
function bzApplyRoute(route){
var ar=nativeAudioRoute();
// iOS: the WKWebView owns call audio and auto-routes it — a connected Bluetooth/wired headset, else the
// loudspeaker. PROVEN on device: the app can force NEITHER the earpiece NOR the loudspeaker-over-active-BT.
// CRITICAL: do NOT call setSpeaker / reconfigure the AVAudioSession here. This runs on EVERY tap during a
// call (via bzUnlockAudio), and setCategory/setActive mid-call interrupts WebKit's audio unit and DROPS the
// call audio (esp. on Bluetooth). We only wire up the route listener so the button icon tracks the real
// output; the route itself is left entirely to iOS. Native-grade routing (earpiece, hold speaker over BT,
// CallKit) would require a native WebRTC stack (e.g. the LiveKit iOS SDK), not the webview.
if(ar){ bzInitNativeRoute(); }
}
// The speaker toggle is usable if the browser supports output switching (setSinkId) OR the native iOS route
// plugin is present.
function canRouteAudio(){ try{ if(nativeAudioRoute()) return true; return typeof HTMLMediaElement!=='undefined' && typeof HTMLMediaElement.prototype.setSinkId==='function'; }catch(_){ return false; } }
// new #4: let the user drag a floating bar out of the way — it otherwise covers part of the shared
// screen. Drag from anywhere on the bar except a control. Position is remembered per bar.
function makeDraggable(el, key){
if(!el || el._drag) return; el._drag=true;
let sx=0, sy=0, ox=0, oy=0, dragging=false;
const clamp=()=>{ const w=el.offsetWidth, h=el.offsetHeight;
let x=parseFloat(el.style.left||'0'), y=parseFloat(el.style.top||'0');
x=Math.max(4, Math.min(x, window.innerWidth-w-4)); y=Math.max(4, Math.min(y, window.innerHeight-h-4));
el.style.left=x+'px'; el.style.top=y+'px'; };
const pin=(r)=>{ el.style.position='fixed'; el.style.left=r.left+'px'; el.style.top=r.top+'px'; el.style.right='auto'; el.style.bottom='auto'; el.style.margin='0'; el.style.width=r.width+'px'; };
try{ const s=JSON.parse(localStorage.getItem(key)||'null'); if(s&&typeof s.x==='number'){ const r=el.getBoundingClientRect(); pin({left:s.x, top:s.y, width:r.width}); clamp(); } }catch(_){}
const pt=(e)=>e.touches?e.touches[0]:e;
const move=(e)=>{ if(!dragging) return; const p=pt(e);
el.style.left=(ox+(p.clientX-sx))+'px'; el.style.top=(oy+(p.clientY-sy))+'px'; clamp();
if(e.cancelable) e.preventDefault(); };
const up=()=>{ if(!dragging) return; dragging=false; el.classList.remove('dragging');
document.removeEventListener('mousemove',move); document.removeEventListener('mouseup',up);
document.removeEventListener('touchmove',move); document.removeEventListener('touchend',up);
try{ localStorage.setItem(key, JSON.stringify({x:parseFloat(el.style.left), y:parseFloat(el.style.top)})); }catch(_){} };
const down=(e)=>{
if(e.target.closest('button,select,input,a')) return; // don't hijack the controls
const r=el.getBoundingClientRect(); pin(r);
const p=pt(e); sx=p.clientX; sy=p.clientY; ox=r.left; oy=r.top; dragging=true; el.classList.add('dragging');
document.addEventListener('mousemove',move); document.addEventListener('mouseup',up);
document.addEventListener('touchmove',move,{passive:false}); document.addEventListener('touchend',up);
if(e.cancelable) e.preventDefault();
};
el.addEventListener('mousedown',down);
el.addEventListener('touchstart',down,{passive:false});
el.classList.add('draggable');
}
let meetSinkId=(()=>{ try{ return localStorage.getItem('bzc_sink')||''; }catch(_){ return ''; } })();
let meetMicId=(()=>{ try{ return localStorage.getItem('bzc_micdev')||''; }catch(_){ return ''; } })();
let meetSpeakerOn=(()=>{ try{ return localStorage.getItem('bzc_spk')!=='0'; }catch(_){ return true; } })(); // mobile speakerphone
function applySink(el){ try{ if(el && meetSinkId && typeof el.setSinkId==='function') el.setSinkId(meetSinkId).catch(()=>{}); }catch(_){} }
function applySinkAll(){ document.querySelectorAll('#meetGrid video').forEach(applySink); }
// Bluetooth/headset devices report themselves in the label — used to pick a sensible icon on mobile.
function isBtLabel(l){ return /bluetooth|airpod|buds|headset|headphone|wh-|wf-/i.test(l||''); }
// #8c/#8e MOBILE audio route: ONE button that cycles Speaker → Earpiece → Bluetooth (only when a headset
// is connected) and back. The icon always shows the route in use, so no separate "Audio devices" entry is
// needed on mobile. meetRoute is the source of truth; meetSpeakerOn stays in sync for the lite/off styling.
let _btConnected=false;
let meetRoute=(()=>{ try{ return localStorage.getItem('bzc_route')||'speaker'; }catch(_){ return 'speaker'; } })(); // 'speaker'|'earpiece'|'bt'
// iOS only: the real active output as reported by the native plugin ('speaker'|'bluetooth'|'wired'|'receiver'|
// 'airplay'). The web layer can't see audio outputs on iOS, so the icon is driven by this instead of meetRoute.
let _nativeOutput=null;
function iosRoute(){ return _nativeOutput; } // truthy only on iOS native once the plugin has reported
function spkIcon(){
if(iosRoute()){ return _nativeOutput==='bluetooth' ? 'bluetooth' : (_nativeOutput==='wired' ? 'headphones' : 'speaker'); }
return meetRoute==='bt' ? 'bluetooth' : (meetRoute==='speaker' ? 'speaker' : 'speakerOff');
}
function routeLabel(){
if(iosRoute()){ return _nativeOutput==='bluetooth' ? 'Bluetooth' : (_nativeOutput==='wired' ? 'Headset' : 'Speaker'); }
return meetRoute==='bt' ? 'Bluetooth / headset' : (meetRoute==='speaker' ? 'Speaker' : 'Earpiece');
}
function refreshSpkBtn(){
const b=document.getElementById('meetSpkBtn'); if(!b) return;
if(meetRoute==='earpiece' && nativeAudioRoute()) meetRoute='speaker'; // iOS: earpiece is unreachable in the WebView; coerce stale prefs
if(iosRoute()){
// On iOS the button is a live OUTPUT indicator (speaker/bluetooth/headphones), not a speaker on/off — so
// never dim it (a dimmed icon read as "disabled" when audio was simply on Bluetooth).
meetSpeakerOn=(_nativeOutput==='speaker');
b.classList.remove('off');
} else {
meetSpeakerOn=(meetRoute==='speaker');
b.classList.toggle('off', meetRoute!=='speaker');
}
b.innerHTML=ic(spkIcon(),20);
b.title=routeLabel()+' — tap to switch';
}
// iOS: subscribe to the native plugin's real output route so the button icon reflects reality (BT/headset/speaker).
let _bzNativeRouteReady=false;
function bzInitNativeRoute(){
const ar=nativeAudioRoute(); if(!ar) return;
if(!_bzNativeRouteReady && ar.addListener){ _bzNativeRouteReady=true;
try{ ar.addListener('routeChange', function(e){ bzOnNativeRoute(e && e.output); }); }catch(_){}
}
if(ar.getRoute){ try{ ar.getRoute().then(function(r){ bzOnNativeRoute(r && r.output); }).catch(function(){}); }catch(_){} }
}
function bzOnNativeRoute(output){
if(!output) return;
_nativeOutput=output;
meetRoute = (output==='bluetooth'||output==='wired') ? 'bt' : (output==='speaker' ? 'speaker' : meetRoute);
refreshSpkBtn();
}
// Detect a connected BT/headset output so the cycle can include it (and the icon can show it).
async function detectBtOutput(){
try{ const d=await navigator.mediaDevices.enumerateDevices(); _btConnected=d.some(x=>x.kind==='audiooutput' && isBtLabel(x.label)); }catch(_){ _btConnected=false; }
if(meetRoute==='bt' && !_btConnected) meetRoute='speaker'; // headset unplugged → fall back
refreshSpkBtn();
}
try{ if(navigator.mediaDevices && navigator.mediaDevices.addEventListener) navigator.mediaDevices.addEventListener('devicechange', ()=>{ detectBtOutput(); }); }catch(_){}
// #8e: on mobile the bar keeps only Mic/Camera/Speaker/End — the rest live behind this ⋮ menu.
function openMeetMore(anchor){
// #4: tapping ⋮ again must CLOSE it. Previously we removed the menu then rebuilt it immediately, so it
// looked like nothing happened.
const open=document.querySelector('.spk-menu.mm-menu');
document.querySelectorAll('.spk-menu').forEach(x=>x.remove());
if(open) return;
const items=[];
items.push({ic:'monitor', label:'Share screen', fn:toggleScreen});
if(meetIsHost) items.push({ic:'record', label:(meetRec?'Stop recording':'Record meeting'), fn:toggleRecord});
if(!(ME&&ME.guest)) items.push({ic:'fileText', label:(meetTranscribe?'Stop transcript':'Live transcript'), fn:toggleTranscribe});
items.push({ic:'chat', label:'Chat'+(meetChatUnread?(' ('+meetChatUnread+')'):''), fn:toggleMeetChat});
items.push({ic:'users', label:'Participants', fn:toggleMeetPanel});
// No "Audio devices" here (#8e): on mobile the speaker button itself cycles Speaker/Earpiece/Bluetooth.
const menu=document.createElement('div'); menu.className='spk-menu mm-menu';
menu.innerHTML=items.map((it,i)=>'<button class="spk-opt mm-opt" data-i="'+i+'">'+ic(it.ic,15)+'<span>'+pEsc(it.label)+'</span></button>').join('');
document.body.appendChild(menu);
const r=anchor.getBoundingClientRect();
menu.style.left=Math.max(8,Math.min(r.left-100, window.innerWidth-menu.offsetWidth-8))+'px';
menu.style.top=Math.max(8,(r.top-menu.offsetHeight-10))+'px';
menu.querySelectorAll('.mm-opt').forEach(b=>b.onclick=()=>{ const it=items[+b.dataset.i]; menu.remove(); try{ it.fn(); }catch(_){} });
const close=(e)=>{ if(!menu.contains(e.target) && !anchor.contains(e.target)){ menu.remove(); document.removeEventListener('mousedown',close); document.removeEventListener('touchstart',close); } };
setTimeout(()=>{ document.addEventListener('mousedown',close); document.addEventListener('touchstart',close); },0);
}
async function setSpeakerDevice(id){
meetSinkId=id||''; try{ localStorage.setItem('bzc_sink', meetSinkId); }catch(_){}
try{ if(SFU.room && SFU.room.switchActiveDevice) await SFU.room.switchActiveDevice('audiooutput', meetSinkId); }catch(_){}
applySinkAll();
}
async function setMicDevice(id){
meetMicId=id||''; try{ localStorage.setItem('bzc_micdev', meetMicId); }catch(_){}
try{ if(SFU.room && SFU.room.switchActiveDevice){ await SFU.room.switchActiveDevice('audioinput', meetMicId); return; } }catch(_){}
// Mesh fallback: re-acquire the mic on the chosen device and swap the outgoing track.
try{
if(!meetMic || !meetLocalStream) return;
const s=await navigator.mediaDevices.getUserMedia({audio:{deviceId:{exact:meetMicId}}});
const nt=s.getAudioTracks()[0]; if(!nt) return;
const old=meetLocalStream.getAudioTracks()[0];
if(old){ meetLocalStream.removeTrack(old); try{ old.stop(); }catch(_){} }
meetLocalStream.addTrack(nt);
meetPeers.forEach(p=>{ try{ const snd=p.pc.getSenders().find(x=>x.track&&x.track.kind==='audio'); if(snd) snd.replaceTrack(nt); }catch(_){} });
}catch(e){ toast(mediaErrMsg(e,'microphone')); }
}
// Windows lists the same physical device three times ("Default - X", "Communications - X", "X"). Collapse
// that into one clean list: a single "System default" entry plus each real device once (#6).
function cleanDevices(list){
const out=[]; const seen=new Set(); let hasDefault=false;
for(const d of (list||[])){
if(d.deviceId==='communications') continue; // Windows' duplicate "comms" alias
if(d.deviceId==='default'){ hasDefault=true; continue; } // folded into the single entry below
const label=String(d.label||'').replace(/^\s*(Default|Communications)\s*-\s*/i,'').trim();
const key=(d.groupId||d.deviceId||'')+'|'+label.toLowerCase();
if(seen.has(key)) continue; seen.add(key);
out.push({ deviceId:d.deviceId, label: label||'Device' });
}
if(hasDefault) out.unshift({ deviceId:'default', label:'System default' });
return out;
}
async function openAudioMenu(anchor){
document.querySelectorAll('.spk-menu').forEach(x=>x.remove());
let outs=[], ins=[];
try{ const d=await navigator.mediaDevices.enumerateDevices(); outs=cleanDevices(d.filter(x=>x.kind==='audiooutput')); ins=cleanDevices(d.filter(x=>x.kind==='audioinput')); }catch(_){}
const menu=document.createElement('div'); menu.className='spk-menu';
const sel=(id, cur)=> (id===cur || (!cur && (id==='default'||id===''))) ? ' on' : '';
let html='';
html+='<div class="spk-h">'+ic('headphones',13)+' Speaker</div>';
html+= outs.length ? outs.map((d,i)=>'<button class="spk-opt'+sel(d.deviceId,meetSinkId)+'" data-k="out" data-id="'+pEsc(d.deviceId)+'"><span class="spk-dot"></span>'+pEsc(d.label||('Output '+(i+1)))+'</button>').join('')
: '<div class="spk-empty">Output is controlled by your system.</div>';
html+='<div class="spk-h">'+ic('mic',13)+' Microphone</div>';
html+= ins.length ? ins.map((d,i)=>'<button class="spk-opt'+sel(d.deviceId,meetMicId)+'" data-k="in" data-id="'+pEsc(d.deviceId)+'"><span class="spk-dot"></span>'+pEsc(d.label||('Microphone '+(i+1)))+'</button>').join('')
: '<div class="spk-empty">Allow microphone access to list devices.</div>';
menu.innerHTML=html;
document.body.appendChild(menu);
const r=anchor.getBoundingClientRect();
menu.style.left=Math.max(8,Math.min(r.left-60, window.innerWidth-menu.offsetWidth-8))+'px';
menu.style.top=Math.max(8,(r.top-menu.offsetHeight-10))+'px';
menu.querySelectorAll('.spk-opt').forEach(b=>b.onclick=async()=>{
if(b.dataset.k==='out'){ await setSpeakerDevice(b.dataset.id); toast('Speaker set'); }
else { await setMicDevice(b.dataset.id); toast('Microphone set'); }
menu.remove();
});
const close=(e)=>{ if(!menu.contains(e.target) && e.target!==anchor && !anchor.contains(e.target)){ menu.remove(); document.removeEventListener('mousedown',close); } };
setTimeout(()=>document.addEventListener('mousedown',close),0);
}
// Mobile: speakerphone on/off. When a Bluetooth/headset output is connected we route to it and show a
// headset icon; otherwise we toggle between the loudspeaker and the default (earpiece) route. NOTE: true
// earpiece routing is an OS capability — on mobile WEB the browser owns it, so this picks the best
// available output device. In the native mobile build this maps to the OS audio route.
// Tap = advance to the next available route. Bluetooth is only in the cycle while a headset is connected.
async function toggleSpeakerphone(){
const ios = !!nativeAudioRoute();
// iOS: the OS owns call-audio routing (BT/wired if connected, else loudspeaker) and the app can't override it
// (earpiece and loudspeaker-over-active-BT both proven impossible in a WKWebView). So the button is a live
// INDICATOR, not a switch — tapping just explains the current output / how to change it.
if(ios){
const o=_nativeOutput;
toast(o==='bluetooth' ? 'On Bluetooth — disconnect the headset to use the speaker'
: o==='wired' ? 'On headset — unplug to use the speaker'
: 'On speaker — connect a Bluetooth/wired headset to use it');
return;
}
let outs=[]; try{ outs=(await navigator.mediaDevices.enumerateDevices()).filter(x=>x.kind==='audiooutput'); }catch(_){}
const bt=outs.find(d=>isBtLabel(d.label)); _btConnected=!!bt;
const routes = ['speaker','earpiece'].concat(bt?['bt']:[]);
const i=routes.indexOf(meetRoute);
meetRoute=routes[(i<0?0:(i+1)%routes.length)];
try{ localStorage.setItem('bzc_route', meetRoute); }catch(_){}
bzApplyRoute(meetRoute);
{
if(meetRoute==='bt' && bt) await setSpeakerDevice(bt.deviceId);
else if(meetRoute==='speaker'){
const spk=outs.find(d=>/speaker/i.test(d.label)) || outs.find(d=>d.deviceId==='default') || (outs[0]&&outs[0].deviceId);
await setSpeakerDevice(typeof spk==='string'?spk:(spk&&spk.deviceId)||'');
} else { await setSpeakerDevice(''); } // earpiece → system default route
}
refreshSpkBtn();
toast(routeLabel());
}
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;
const av=(id==='__local')?((ME&&ME.avatarUrl)||null):(meetAvatars.get(id)||null); // profile pic on the tile
tile.innerHTML='<video autoplay playsinline'+(muted?' muted':'')+'></video><div class="meet-av" style="background:'+avColor(label||'?')+'">'+pEsc(initials(label||'?'))+(av?'<img src="'+pEsc(av)+'" alt="" onerror="this.remove()">':'')+'</div><div class="meet-mute" style="display:none">'+ic('micOff',14)+'</div><span class="nm">'+pEsc(label||'')+'</span>'; grid.appendChild(tile); }
const v=tile.querySelector('video'); if(v && stream && v.srcObject!==stream){ v.srcObject=stream; applySink(v); try{ v.play().catch(function(){}); }catch(_){} } // #5: route audio to the chosen speaker + kick playback (iOS won't autostart WebRTC audio)
const hasVid=!!(stream && stream.getVideoTracks && stream.getVideoTracks().some(t=>t.enabled && t.readyState!=='ended'));
tile.classList.toggle('novid', !hasVid || (meetCamOff.get(id)===true && !meetSharers.has(id))); // camOff → avatar, UNLESS they're sharing a screen (#9: screen must show even with camera off)
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); meetCamOff.delete(id); meetUnwatch(id); }
// #7: on an outgoing 1:1 call, show the person you're calling (DP + name + "Ringing…") so it's clear
// who the call is to, instead of only your own tile. Removed as soon as they answer.
let _dmCallWaiting=null;
function addWaitingTile(name, avatar){
const grid=document.getElementById('meetGrid'); if(!grid || document.getElementById('meet-tile-__waiting')) return;
const tile=document.createElement('div'); tile.className='meet-tile novid waiting'; tile.id='meet-tile-__waiting';
tile.innerHTML='<div class="meet-av" style="background:'+avColor(name||'?')+'">'+pEsc(initials(name||'?'))+(avatar?'<img src="'+pEsc(avatar)+'" alt="" onerror="this.remove()">':'')+'</div><span class="nm">'+pEsc(name||'')+'</span><div class="ringing">'+ic('phone',22)+'<span>Ringing…</span></div>';
grid.appendChild(tile);
}
function removeWaitingTile(){ const t=document.getElementById('meet-tile-__waiting'); if(t) t.remove(); }
// ---- Active-speaker meter: highlight a tile while its audio is above a threshold ----
function meetWatchStream(id, stream){
if(!stream || !stream.getAudioTracks || !stream.getAudioTracks().length) return; // no audio yet
meetUnwatch(id);
let ctx; try{ ctx=new (window.AudioContext||window.webkitAudioContext)(); }catch(_){ return; }
try{ ctx.resume(); }catch(_){}
let src; try{ src=ctx.createMediaStreamSource(stream); }catch(_){ try{ctx.close();}catch(e){} return; }
const an=ctx.createAnalyser(); an.fftSize=512; an.smoothingTimeConstant=0.6; src.connect(an);
const data=new Uint8Array(an.frequencyBinCount); const rec={ctx,an,data,src,raf:0,on:false};
meetVU.set(id, rec);
const tick=()=>{
an.getByteFrequencyData(data); let sum=0; for(let i=0;i<data.length;i++) sum+=data[i];
const speaking=(sum/data.length)>16 && !meetMuted.get(id);
if(speaking!==rec.on){ rec.on=speaking; const t=document.getElementById('meet-tile-'+id); if(t) t.classList.toggle('speaking', speaking); }
rec.raf=requestAnimationFrame(tick);
};
rec.raf=requestAnimationFrame(tick);
}
function meetUnwatch(id){ const r=meetVU.get(id); if(!r) return; try{cancelAnimationFrame(r.raf);}catch(_){} try{r.src.disconnect();}catch(_){} try{r.ctx.close();}catch(_){} meetVU.delete(id); const t=document.getElementById('meet-tile-'+id); if(t) t.classList.remove('speaking'); }
function meetUnwatchAll(){ for(const id of Array.from(meetVU.keys())) meetUnwatch(id); }
// #5: the same person showing up as several tiles. Each browser tab / reload / reconnect gets a NEW
// peerId, so a stale session left a ghost tile behind. One person (uid) = one tile: when a uid reappears
// on a new peerId, drop their older peer entirely.
function dropDupPeers(uid, keepPid){
if(!uid) return;
for(const [pid,u] of [...meetPeerUids]){
if(u!==uid || pid===keepPid) continue;
const p=meetPeers.get(pid); if(p){ try{ p.pc&&p.pc.close(); }catch(_){} meetPeers.delete(pid); }
if(SFU.on){ try{ sfuDropPeer(pid); }catch(_){} }
meetSharers.delete(pid); meetPeerUids.delete(pid); meetNames.delete(pid); meetAvatars.delete(pid);
removeTile(pid);
}
}
function meetMakePeer(peerId, name){
// SFU mode: no P2P peer connection — but we STILL record the peer, otherwise meetPeers stays empty and
// the Participants list only ever shows "you" (and the meeting-chat "To" list is empty). pc is null;
// every p.pc use is already try/catch-guarded.
if(SFU.on){ meetPeers.set(peerId, { pc:null, name, vsender:null }); addTile(peerId, null, meetNames.get(peerId)||name||'Guest', false); return null; }
const pc=new RTCPeerConnection(MEET_ICE);
const entry={ pc, name, vsender:null }; meetPeers.set(peerId, entry);
addTile(peerId, null, meetNames.get(peerId)||name||'Guest', false); // show the tile right away (avatar)
meetLocalStream.getTracks().forEach(t=>{ const s=pc.addTrack(t, meetLocalStream); if(t.kind==='video') entry.vsender=s; });
// If I'm already sharing my screen, send the screen (not the camera) to this new peer.
if(meetScreen && meetScreenStream){ const st=meetScreenStream.getVideoTracks()[0]; if(st){ try{ if(entry.vsender) entry.vsender.replaceTrack(st); else entry.vsender=pc.addTrack(st, meetLocalStream); }catch(_){} } }
pc.onicecandidate=(ev)=>{ if(ev.candidate) meetSend({type:'meeting-signal',to:peerId,data:{candidate:ev.candidate}}); };
pc.ontrack=(ev)=>{ addTile(peerId, ev.streams[0], (meetPeers.get(peerId)||{}).name||name||'Guest', false); meetWatchStream(peerId, ev.streams[0]); };
return pc;
}
// Screen share (mesh): swap the outgoing video track for a display-capture track via replaceTrack
// (no extra tiles, the peer's video just shows the screen). Falls back to addTrack+renegotiate if
// no video sender exists yet (camera never turned on). Stopping restores the camera (or avatar).
async function toggleScreen(){
if(meetScreen){ stopScreen(); return; }
if(!meetMultiShare && meetSharers.size>0){ toast('Someone is already sharing their screen'); return; }
if(SFU.on){
try{ await SFU.room.localParticipant.setScreenShareEnabled(true); }catch(e){ return; } // user cancelled / denied
const pub=SFU.room.localParticipant.getTrackPublication(SFU.lib.Track.Source.ScreenShare);
const st=pub&&pub.track&&pub.track.mediaStreamTrack; if(!st){ return; }
meetScreen=true; meetScreenStream=new MediaStream([st]);
try{ st.addEventListener('ended', ()=>stopScreen()); }catch(_){} // browser's native "Stop sharing" bar
// #9: keep MY tile on camera/avatar — do NOT show my own screen back to me. Sharing the whole
// screen would then capture the tile showing it → infinite "hall of mirrors". Others still see it.
addTile('__local', meetLocalStream, (ME&&ME.name)?ME.name:'You', true); setLocalSharing(true);
updateScreenBtn(); meetSend({ type:'meeting-screen', on:true }); return;
}
let ds; try{ ds=await navigator.mediaDevices.getDisplayMedia({ video:true, audio:false }); }
catch(e){ return; } // user cancelled the picker
const track=ds.getVideoTracks()[0]; if(!track){ try{ ds.getTracks().forEach(t=>t.stop()); }catch(_){} return; }
meetScreenStream=ds; meetScreen=true;
track.onended=()=>stopScreen(); // browser's native "Stop sharing" bar
for(const [pid,p] of meetPeers){
if(p.vsender){ try{ await p.vsender.replaceTrack(track); }catch(_){} }
else { try{ p.vsender=p.pc.addTrack(track, meetLocalStream); const off=await p.pc.createOffer(); await p.pc.setLocalDescription(off); meetSend({type:'meeting-signal',to:pid,data:{sdp:p.pc.localDescription}}); }catch(_){} }
}
addTile('__local', meetLocalStream, (ME&&ME.name)?ME.name:'You', true); setLocalSharing(true); // #9: keep camera/avatar locally, not my own screen
updateScreenBtn(); meetSend({ type:'meeting-screen', on:true });
}
// #9: a badge on MY tile that I'm sharing (instead of showing my own screen, which recurses).
function setLocalSharing(on){
const t=document.getElementById('meet-tile-__local'); if(!t) return;
let b=t.querySelector('.meet-sharing-self');
if(on){ if(!b){ b=document.createElement('div'); b.className='meet-sharing-self'; b.innerHTML=ic('monitor',13)+' Youre sharing your screen'; t.appendChild(b); } }
else if(b){ b.remove(); }
}
function stopScreen(){
if(!meetScreen) return; meetScreen=false;
if(SFU.on){ try{ SFU.room.localParticipant.setScreenShareEnabled(false); }catch(_){} meetScreenStream=null; addTile('__local', meetLocalStream, (ME&&ME.name)?ME.name:'You', true); setLocalSharing(false); updateScreenBtn(); meetSend({ type:'meeting-screen', on:false }); return; }
const cam=meetLocalStream && meetLocalStream.getVideoTracks()[0];
for(const [,p] of meetPeers){ if(p.vsender){ try{ p.vsender.replaceTrack(cam||null); }catch(_){} } }
if(meetScreenStream){ try{ meetScreenStream.getTracks().forEach(t=>t.stop()); }catch(_){} meetScreenStream=null; }
addTile('__local', meetLocalStream, (ME&&ME.name)?ME.name:'You', true); setLocalSharing(false);
updateScreenBtn(); meetSend({ type:'meeting-screen', on:false });
}
function updateScreenBtn(){ const b=document.getElementById('meetScreenBtn'); if(!b) return; b.classList.toggle('on', meetScreen); b.title=meetScreen?'Stop sharing':'Share screen'; b.innerHTML=ic('monitor',20); }
function setTileScreen(id, on){ const t=document.getElementById('meet-tile-'+id); if(!t) return; t.classList.toggle('sharing', !!on); let b=t.querySelector('.meet-screen'); if(on){ if(!b){ b=document.createElement('div'); b.className='meet-screen'; b.innerHTML=ic('monitor',12)+' Screen'; t.appendChild(b); } } else if(b){ b.remove(); } updateShareMode(); }
// Stage mode: one chosen shared screen fills the area; other screens + people are small on the right.
function getSharerIds(){ const a=[]; meetSharers.forEach(id=>a.push(id)); return a; } // #9: '__local' excluded — I don't display my own screen, so it's never the local stage
function updateShareMode(){ const g=document.getElementById('meetGrid'); if(!g) return;
const sharers=getSharerIds(); const on=sharers.length>0;
g.classList.toggle('sharing-mode', on);
if(on){ if(!meetStageId || sharers.indexOf(meetStageId)<0) meetStageId=sharers[0]; } else meetStageId=null;
applyStage();
}
function applyStage(){ const g=document.getElementById('meetGrid'); if(!g) return; g.querySelectorAll('.meet-tile.stage').forEach(t=>t.classList.remove('stage')); if(meetStageId){ const t=document.getElementById('meet-tile-'+meetStageId); if(t) t.classList.add('stage'); } }
function setStage(id){ if(!id) return; meetStageId=id; applyStage(); }
// Record + transcript are host-only; show/hide their buttons when host status changes.
function updateHostControls(){ document.querySelectorAll('.meet-bar .host-only').forEach(b=>{ b.style.display=meetIsHost?'inline-flex':'none'; }); }
// ---- Recording: composite all tiles onto a canvas + mix everyone's audio, then MediaRecorder ----
function drawCover(ctx,v,x,y,w,h){ const vw=v.videoWidth,vh=v.videoHeight; if(!vw||!vh) return; const s=Math.max(w/vw,h/vh),dw=vw*s,dh=vh*s; ctx.save(); ctx.beginPath(); ctx.rect(x,y,w,h); ctx.clip(); ctx.drawImage(v, x+(w-dw)/2, y+(h-dh)/2, dw, dh); ctx.restore(); }
function recMime(){ const c=['video/webm;codecs=vp9,opus','video/webm;codecs=vp8,opus','video/webm']; for(const t of c){ if(window.MediaRecorder&&MediaRecorder.isTypeSupported(t)) return t; } return ''; }
function toggleRecord(){ if(!meetIsHost){ toast('Only the host can record'); return; } if(meetRec){ meetRec.stop(); return; } startRecord(); }
function startRecord(){
if(!window.MediaRecorder){ toast('Recording is not supported in this browser'); return; }
const canvas=document.createElement('canvas'); canvas.width=1280; canvas.height=720; const ctx=canvas.getContext('2d');
let raf=0;
const draw=()=>{
const vids=[...document.querySelectorAll('#meetGrid .meet-tile video')].filter(v=>v.srcObject);
ctx.fillStyle='#0b1220'; ctx.fillRect(0,0,canvas.width,canvas.height);
const n=Math.max(1,vids.length), cols=Math.ceil(Math.sqrt(n)), rows=Math.ceil(n/cols), cw=canvas.width/cols, ch=canvas.height/rows;
vids.forEach((v,i)=>{ const r=Math.floor(i/cols), c=i%cols; drawCover(ctx,v,c*cw,r*ch,cw-4,ch-4); });
raf=requestAnimationFrame(draw);
};
const cstream=canvas.captureStream(25);
let actx=null, dest=null;
try{ actx=new (window.AudioContext||window.webkitAudioContext)(); dest=actx.createMediaStreamDestination(); const seen=new Set();
document.querySelectorAll('#meetGrid .meet-tile video').forEach(v=>{ const s=v.srcObject; if(s&&!seen.has(s)&&s.getAudioTracks&&s.getAudioTracks().length){ seen.add(s); try{ actx.createMediaStreamSource(s).connect(dest); }catch(_){} } });
}catch(_){}
const tracks=[...cstream.getVideoTracks()]; if(dest) tracks.push(...dest.stream.getAudioTracks());
let rec; try{ rec=new MediaRecorder(new MediaStream(tracks), recMime()?{mimeType:recMime()}:undefined); }
catch(e){ toast('Recording is not supported in this browser'); try{actx&&actx.close();}catch(_){} return; }
const chunks=[]; const startedAt=Date.now(); let stopped=false;
rec.ondataavailable=e=>{ if(e.data&&e.data.size) chunks.push(e.data); };
rec.onstop=()=>{ try{ cancelAnimationFrame(raf); }catch(_){} try{ actx&&actx.close(); }catch(_){} const blob=new Blob(chunks,{type:(chunks[0]&&chunks[0].type)||'video/webm'}); uploadRecording(blob, Date.now()-startedAt); };
const stop=()=>{ if(stopped) return; stopped=true; try{ rec.stop(); }catch(_){} meetRec=null; updateRecBtn(); meetSend({type:'meeting-recording', on:false}); recNotice(false); toast('Recording saved to Past meetings'); };
meetRec={ rec, stop }; draw(); rec.start(2000); updateRecBtn();
meetSend({type:'meeting-recording', on:true}); recNotice(true, 'You'); // notify everyone (visual + voice)
}
function speak(text){ try{ if(window.speechSynthesis){ const u=new SpeechSynthesisUtterance(text); u.rate=1; speechSynthesis.cancel(); speechSynthesis.speak(u); } }catch(_){} }
function fmtElapsed(ms){ const s=Math.max(0,Math.floor(ms/1000)); const m=Math.floor(s/60); return String(m).padStart(2,'0')+':'+String(s%60).padStart(2,'0'); }
let _recTimer=null, _recStart=0;
function recNotice(on, by){
let el=document.getElementById('recNotice');
if(on){
if(_recTimer) return; // already showing
if(!el){ el=document.createElement('div'); el.id='recNotice'; el.className='rec-notice'; document.body.appendChild(el); }
_recStart=Date.now();
const tick=()=>{ const e=document.getElementById('recNotice'); if(e) e.innerHTML='<span class="rec-dot"></span> Recording · '+fmtElapsed(Date.now()-_recStart); };
tick(); _recTimer=setInterval(tick, 1000);
speak('This meeting is now being recorded');
} else {
if(_recTimer){ clearInterval(_recTimer); _recTimer=null; }
if(el) el.remove();
speak('Recording stopped');
}
}
function updateRecBtn(){ const b=document.getElementById('meetRecBtn'); if(!b) return; b.classList.toggle('on', !!meetRec); b.title=meetRec?'Stop recording':'Record meeting'; }
async function uploadRecording(blob, durMs){
if(!blob||!blob.size) return;
try{ const gid=(meetReturn&&meetReturn.kind==='group')?meetReturn.id:'';
const q='/api/meetings/recording?room='+encodeURIComponent(meetRoom||'')+(gid?('&group='+encodeURIComponent(gid)):'')+'&dur='+Math.round(durMs||0);
const r=await fetch(q,{method:'POST',headers:{'Content-Type':'application/octet-stream'},body:blob});
if(!r.ok) throw 0;
}catch(_){ toast('Could not upload the recording'); }
}
// ---- Live transcript: each participant transcribes their own mic; the server assembles it ----
// Transcript: subscribe to get your OWN private copy of the FULL conversation. While anyone is
// subscribed, every client transcribes its own mic into one shared transcript (merged with speaker
// names); each subscriber gets a private copy. Unsubscribing only drops YOUR copy, not others'.
function toggleTranscribe(){ meetTranscribe=!meetTranscribe; meetSend({type:'meeting-transcribe', on:meetTranscribe}); updateTransBtn(); toast(meetTranscribe?'Transcript on — your private copy is saved to Past meetings after the call':'You left the transcript — your copy is being saved'); }
function applyRoomTx(active){ if(active===meetRoomTx) return; meetRoomTx=active; if(active) startSR(); else stopSR(); transcribeNotice(active); }
function startSR(){ if(meetSR) return; const SR=window.SpeechRecognition||window.webkitSpeechRecognition; if(!SR){ if(meetTranscribe) toast('Live transcript needs Chrome or Edge'); return; }
try{ meetSR=new SR(); }catch(_){ return; }
meetSR.continuous=true; meetSR.interimResults=false; meetSR.lang='en-US';
meetSR.onresult=(e)=>{ for(let i=e.resultIndex;i<e.results.length;i++){ const r=e.results[i]; if(r.isFinal){ const text=((r[0]&&r[0].transcript)||'').trim(); if(text) meetSend({type:'meeting-transcript', text}); } } };
meetSR.onerror=()=>{}; meetSR.onend=()=>{ if(meetRoomTx){ try{ meetSR.start(); }catch(_){} } };
try{ meetSR.start(); }catch(_){}
}
function stopSR(){ if(meetSR){ try{ meetSR.onend=null; meetSR.stop(); }catch(_){} meetSR=null; } }
function updateTransBtn(){ const b=document.getElementById('meetTransBtn'); if(!b) return; b.classList.toggle('on', meetTranscribe); b.title=meetTranscribe?'Stop my transcript':'Transcribe (your private copy)'; }
function transcribeNotice(on){ let el=document.getElementById('txNotice'); if(on){ if(!el){ el=document.createElement('div'); el.id='txNotice'; el.className='tx-notice'; el.innerHTML=ic('fileText',12)+' Transcribing'; document.body.appendChild(el); } } else if(el){ el.remove(); } }
// iOS blocks media/WebRTC audio playback until a user gesture, so remote call audio stays SILENT until you
// tap something (e.g. mute/unmute — that tap is the gesture). Unlock it: within the Join tap we play a short
// silent clip (grants the page media-playback activation) and (re)start every meeting <video>. A tap fallback
// re-runs it in case the activation lapsed before a remote stream arrived. Web fix — helps native app + PWA.
let _bzSilent=null;
function bzUnlockAudio(){
try{ if(_audioCtx && _audioCtx.state==='suspended') _audioCtx.resume(); }catch(_){}
try{ if(!_bzSilent){ _bzSilent=new Audio('data:audio/wav;base64,UklGRjIAAABXQVZFZm10IBAAAAABAAEAgLsAAAB3AQACABAAZGF0YQ4AAAAAAAAAAAAAAAAAAAAAAAA='); _bzSilent.setAttribute('playsinline',''); } _bzSilent.play().catch(function(){}); }catch(_){}
try{ document.querySelectorAll('#meetGrid video').forEach(function(v){ if(v && v.paused) v.play().catch(function(){}); }); }catch(_){}
try{ bzApplyRoute(meetRoute); }catch(_){} // re-assert the chosen earpiece/speaker route (iOS WebRTC re-grabs the session when audio starts)
}
document.addEventListener('touchend', function(){ if(meetState==='call') bzUnlockAudio(); }, {passive:true}); // fallback: any tap during a call restarts silent remote audio
document.addEventListener('click', function(){ if(meetState==='call') bzUnlockAudio(); }, {passive:true});
async function enterMeeting(code, audioOnly){
bzUnlockAudio(); // runs inside the Join tap → unlock playback so remote audio isn't silent until you tap
if(meetState==='call'){ switchTab('meeting'); return; } // already in a call — ignore double-join
// Joining this room → clear any lingering incoming-call invite popup for it (and stop its ring).
// Fixes: joining via the header "Join" button left the Join/Decline popup on screen. Belt-and-braces
// we clear ALL open invites, since you can only be in one call at a time.
if(code) dismissCallInvite(code);
document.querySelectorAll('.call-invite').forEach(el=>{ try{ el.remove(); }catch(_){} }); stopRing(true);
meetAudioOnly=!!audioOnly;
// Start with NO media — mic & cam OFF by default (no permission prompt until the user
// turns one on). Tracks are acquired on demand by toggleMic / toggleCam.
meetLocalStream=new MediaStream();
meetMic=false; meetCam=false; meetIsHost=false; meetHostId=null;
meetScreen=false; meetScreenStream=null; meetSharers.clear(); meetMultiShare=false;
meetRec=null; meetTranscribe=false; meetRoomTx=false; meetSR=null; _addPool=null; meetStageId=null;
try{ const c=await fetch('/api/ice').then(r=>r.json()); if(c&&c.iceServers) MEET_ICE=c; }catch(_){}
meetState='call'; meetRailLive(true);
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.onmessage=onMeetMsg;
meetWs.onopen=()=>{ if(code){ meetRoom=code; renderCall(); meetSend({type:'meeting-join', room:code, name:(ME.name||ME.email||'Guest'), guestId:(ME&&ME.guest)?ME.id:undefined}); } else { meetSend({type:'meeting-create'}); } };
}
async function onMeetMsg(e){
let m; try{ m=JSON.parse(e.data); }catch(_){ return; }
if(m.type==='meeting-created'){ meetRoom=m.room; renderCall(); meetSend({type:'meeting-join', room:m.room, name:(ME.name||ME.email||'Guest'), guestId:(ME&&ME.guest)?ME.id:undefined}); if(meetAnnounceGroup){ const g=meetAnnounceGroup; meetAnnounceGroup=null; try{ postJSON('/api/messages',{group:g, body:'📹 Started a group call — join with code '+m.room}); }catch(_){} } return; }
if(m.type==='meeting-joined'){
meetMyId=m.peerId;
if(_inLobby){ _inLobby=false; renderCall(); } // admitted from the lobby → replace the "waiting…" screen with the call
if(m.isHost){ meetIsHost=true; meetHostId=meetMyId; } // host = the meeting creator (server-decided)
meetWatchStream('__local', meetLocalStream); // active-speaker detection on my own mic
// Existing peers OFFER to me (their offers carry their tracks incl. any active screen share);
// I just set up the connections and wait. Avoids the "newcomer can't receive screen" bug.
for(const p of (m.peers||[])){ meetNames.set(p.peerId,p.name); if(p.avatar) meetAvatars.set(p.peerId,p.avatar); if(p.uid){ meetPeerUids.set(p.peerId,p.uid); meetInviteJoined(p.uid); dropDupPeers(p.uid, p.peerId); } meetMakePeer(p.peerId,p.name); }
if(_dmCallWaiting && !(m.peers&&m.peers.length)) addWaitingTile(_dmCallWaiting.name, _dmCallWaiting.avatar); // #7: show who we're calling while it rings
// SFU: connect to LiveKit for media once (peer uid→peerId map is populated above). Mic/cam are
// off at join, so nothing publishes yet — toggleMic/toggleCam publish on demand.
if(SFU.on){ try{ await sfuConnect(); }catch(err){ if(ME&&ME.guest){ toast((err&&err.message)||'This meeting link has expired or isnt active.'); leaveMeeting(true); return; } const e2=document.getElementById('meetErr'); if(e2) e2.textContent='Could not connect meeting media'; } }
meetSend({type:'meeting-state', muted:!meetMic, camOff:!meetCam}); // tell existing peers my state
if(meetIsHost) meetSend({type:'meeting-host', to:meetMyId}); // announce host so others know
refreshMeetPanel(); updateHostControls();
return;
}
// The room is gone (host ended it / code expired). Say so plainly instead of hanging on "Connecting…"
// — and for a guest, don't offer a Rejoin that can never work (#7).
if(m.type==='error'){ const gone=/not found/i.test(m.message||''); if(ME&&ME.guest){ _guestRoom=gone?null:_guestRoom; toast(gone?'This meeting has ended.':(m.message||'Could not join')); leaveMeeting(true); return; } toast(m.message||'Could not join the meeting'); leaveMeeting(true); return; }
if(m.type==='meeting-lobby-wait'){ renderLobbyWait(); return; } // #4: guest waits for host to admit
if(m.type==='meeting-rejected'){ toast('The host didnt let you in.'); leaveMeeting(true); return; }
if(m.type==='meeting-lobby-request'){ showLobbyRequest(m.peerId, m.name); return; } // host: someone wants in
if(m.type==='meeting-lobby-cancel'){ dismissLobbyRequest(m.peerId); return; } // that guest left the lobby
if(m.type==='meeting-chat'){ meetChatAppend({ from:m.from, name:m.name, text:m.text, direct:!!m.direct, at:m.at }); return; } // in-meeting chat
if(m.type==='meeting-ended'){ toast(m.reason==='unanswered'?'No answer':'Call ended'); leaveMeeting(true); return; } // 1:1 hangup / host ended / unanswered
if(m.type==='meeting-peer-joined'){
stopRingback(); removeWaitingTile(); _dmCallWaiting=null; // #17/#7: they answered → stop ring + drop the ringing tile
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); dropDupPeers(m.uid, m.peerId); }
const pc=meetMakePeer(m.peerId,m.name); // I'm an existing peer → I OFFER to the newcomer (carries my screen)
if(pc){ try{ const offer=await pc.createOffer(); await pc.setLocalDescription(offer); meetSend({type:'meeting-signal',to:m.peerId,data:{sdp:pc.localDescription}}); }catch(_){} } // (SFU: LiveKit handles media, no offer)
meetSend({type:'meeting-state', muted:!meetMic, camOff:!meetCam});
if(meetIsHost){ meetSend({type:'meeting-host', to:meetHostId}); meetSend({type:'meeting-sharemode', multi:meetMultiShare}); if(meetRec) meetSend({type:'meeting-recording', on:true}); }
if(meetScreen) meetSend({type:'meeting-screen', on:true});
refreshMeetPanel(); return;
}
if(m.type==='meeting-peer-state'){ setTileMute(m.peerId, !!m.muted); 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 && !meetSharers.has(m.peerId))); } refreshMeetPanel(); return; } // camOff -> avatar, unless sharing a screen (#9)
if(m.type==='meeting-host'){ const was=meetIsHost; meetHostId=m.hostPeerId; meetIsHost=(m.hostPeerId===meetMyId); if(meetIsHost&&!was){ toast('You are now the host'); try{ speak('You are now the host'); }catch(_){} } refreshMeetPanel(); updateHostControls(); return; }
if(m.type==='meeting-transcribe-state'){ applyRoomTx(!!m.active); return; } // ≥1 subscriber → transcribe my mic
if(m.type==='meeting-recording'){ recNotice(!!m.on, m.by); return; } // someone is recording — show + announce
if(m.type==='meeting-peer-screen'){ if(m.on) meetSharers.add(m.from); else meetSharers.delete(m.from); setTileScreen(m.from, !!m.on); refreshMeetPanel(); return; }
if(m.type==='meeting-sharemode'){ meetMultiShare=!!m.multi; refreshMeetPanel(); return; }
if(m.type==='meeting-muteall'){ if(meetMic && meetLocalStream){ meetMic=false; meetLocalStream.getAudioTracks().forEach(t=>t.enabled=false); updateMicBtn(); setTileMute('__local', true); meetSend({type:'meeting-state', muted:true, camOff:!meetCam}); } toast('You were muted by the host'); return; }
if(m.type==='meeting-peer-left'){ const p=meetPeers.get(m.peerId); if(p){ try{p.pc.close();}catch(_){} meetPeers.delete(m.peerId);} if(SFU.on) sfuDropPeer(m.peerId); meetSharers.delete(m.peerId); removeTile(m.peerId); refreshMeetPanel(); return; }
if(m.type==='meeting-signal'){
const from=m.from, d=m.data||{};
if(d.sdp){
let p=meetPeers.get(from), pc=p&&p.pc;
if(d.sdp.type==='offer'){
if(!pc) pc=meetMakePeer(from, meetNames.get(from)||'Guest');
try{ await pc.setRemoteDescription(d.sdp); const ans=await pc.createAnswer(); await pc.setLocalDescription(ans); meetSend({type:'meeting-signal',to:from,data:{sdp:pc.localDescription}}); }catch(_){}
} else if(d.sdp.type==='answer'){ if(pc){ try{ await pc.setRemoteDescription(d.sdp); }catch(_){} } }
} else if(d.candidate){ const p=meetPeers.get(from); if(p&&p.pc){ try{ await p.pc.addIceCandidate(d.candidate); }catch(_){} } }
return;
}
if(m.type==='error'){ const msg=m.message; leaveMeeting(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 updateCamBtn(){ const b=document.getElementById('meetCamBtn'); if(b){ b.classList.toggle('off',!meetCam); b.title=meetCam?'Turn camera off':'Turn camera on'; b.innerHTML=ic(meetCam?'video':'videoOff',20); } }
// Unmute acquires the mic on demand (no prompt until then) and renegotiates with peers.
async function toggleMic(){
if(!meetLocalStream) return;
if(SFU.on){ const next=!meetMic; try{ await sfuSetMic(next); meetMic=next; }catch(e){ toast(mediaErrMsg(e,'microphone')); return; } updateMicBtn(); setTileMute('__local', !meetMic); meetSend({type:'meeting-state', muted:!meetMic, camOff:!meetCam}); return; }
const hasTrack=meetLocalStream.getAudioTracks().length>0;
if(!hasTrack){
let astream; try{ astream=await navigator.mediaDevices.getUserMedia({ audio:true }); }
catch(e){ toast(mediaErrMsg(e,'microphone')); return; }
const track=astream.getAudioTracks()[0]; if(!track) return;
meetLocalStream.addTrack(track); meetMic=true; meetWatchStream('__local', meetLocalStream);
for(const [pid,p] of meetPeers){ try{ p.pc.addTrack(track, meetLocalStream); const off=await p.pc.createOffer(); await p.pc.setLocalDescription(off); meetSend({type:'meeting-signal',to:pid,data:{sdp:p.pc.localDescription}}); }catch(_){} }
} else {
meetMic=!meetMic; meetLocalStream.getAudioTracks().forEach(t=>t.enabled=meetMic);
}
updateMicBtn(); setTileMute('__local', !meetMic); meetSend({type:'meeting-state', muted:!meetMic, camOff:!meetCam});
}
// Toggle the camera. In an audio call (no video track yet) this acquires the camera and
// renegotiates with every peer, so you can always turn video on once a meeting has started.
async function toggleCam(){
if(!meetLocalStream) return;
if(SFU.on){ const next=!meetCam; try{ await sfuSetCam(next); meetCam=next; meetAudioOnly=false; }catch(e){ toast(mediaErrMsg(e,'camera')); return; } updateCamBtn(); addTile('__local', meetLocalStream, (ME&&ME.name)?ME.name:'You', true); setTileMute('__local', !meetMic); meetSend({type:'meeting-state', muted:!meetMic, camOff:!meetCam}); return; }
const hasTrack=meetLocalStream.getVideoTracks().length>0;
if(!hasTrack){
let vstream; try{ vstream=await navigator.mediaDevices.getUserMedia({ video:true }); }
catch(e){ toast(mediaErrMsg(e,'camera')); return; }
const track=vstream.getVideoTracks()[0]; if(!track) return;
meetLocalStream.addTrack(track); meetCam=true; meetAudioOnly=false;
for(const [pid,p] of meetPeers){ try{ const s=p.pc.addTrack(track, meetLocalStream); if(!p.vsender) p.vsender=s; const off=await p.pc.createOffer(); await p.pc.setLocalDescription(off); meetSend({type:'meeting-signal',to:pid,data:{sdp:p.pc.localDescription}}); }catch(_){} }
const chip=document.getElementById('meetCodeChip'); if(chip) chip.innerHTML='Room <b>'+pEsc(meetRoom||'')+'</b> · share to invite';
} else {
meetCam=!meetCam; meetLocalStream.getVideoTracks().forEach(t=>t.enabled=meetCam);
}
updateCamBtn();
addTile('__local', meetLocalStream, (ME&&ME.name)?ME.name:'You', true);
setTileMute('__local', !meetMic); meetSend({type:'meeting-state', muted:!meetMic, camOff:!meetCam});
}
let meetLeaving=false;
// 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;
stopRingback(); removeWaitingTile(); _dmCallWaiting=null; // #17/#7: any call exit stops the ring + ringing tile
const isDm=!!(meetReturn && meetReturn.kind==='dm');
if(!forced && !isDm && meetIsHost && meetPeers.size>0){
const next=meetPeers.keys().next().value; // first remaining participant
if(next){ meetSend({type:'meeting-host', to:next}); toast('Host handed to '+(meetNames.get(next)||'a participant')); }
}
if(meetRec) meetRec.stop(); // saves the recording
if(meetTranscribe) meetSend({type:'meeting-transcribe', on:false}); // server saves my copy
meetTranscribe=false; meetRoomTx=false; stopSR(); transcribeNotice(false);
if(meetScreen) stopScreen();
if(_recTimer){ clearInterval(_recTimer); _recTimer=null; } const _rn=document.getElementById('recNotice'); if(_rn) _rn.remove(); // clear any "Recording" badge
meetSend({type:'meeting-leave'});
if(SFU.on) sfuDisconnect(); // tear down the LiveKit room
meetUnwatchAll(); meetSharers.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); resetMeetChat(); _inLobby=false;
const ret=meetReturn; meetReturn=null; meetLeaving=false;
if(ME&&ME.guest){ renderGuestLeft(); return; } // guests have no chat to return to — show a leave screen
if(ret){ switchTab('chat'); selectChat(ret.kind, ret.id); } // land back on the originating chat
else renderMeetingLobby();
}
// Guest post-call screen: they can't drop back into a chat/sidebar (there is none), so show a simple
// "you've left" card with a one-click Rejoin.
let _guestRoom=null;
function renderGuestLeft(){
document.querySelectorAll('.guest-left').forEach(x=>x.remove());
const el=document.createElement('div'); el.className='guest-left';
el.innerHTML='<div class="gp-card">'
+'<div class="gp-brand"><img src="/mark-light.png" alt="" onerror="this.remove()"><span>Biz <b>Connect</b></span></div>'
+'<div class="gl-tick">'+ic('check',30)+'</div>'
+'<h2>'+(_guestRoom?'Youve left the meeting':'This meeting has ended')+'</h2>'
+'<p class="gp-sub">'+(_guestRoom?('Thanks for joining'+((ME&&ME.name)?(', '+pEsc(firstName(ME.name))):'')+'.'):'Thanks for joining.')+'</p>'
+(_guestRoom?'<button class="gp-join" id="glRejoin">Rejoin meeting</button>':'')
+'<div class="gp-note">'+ic('info',13)+' You can close this tab safely.</div>'
+'</div>';
document.body.appendChild(el);
const rj=el.querySelector('#glRejoin'); if(rj) rj.onclick=()=>{ location.href='/home?meet='+encodeURIComponent(_guestRoom); };
}
// ---------- Tabs (icon rail) ----------
// Chat and Meeting are in-shell panels; Share and Connect load in the center panel via
// a single, same-origin, lazily-loaded iframe (cheap isolation, no page navigation).
const railBtns=document.querySelectorAll('.railbtn');
const panels=document.querySelectorAll('.panel');
const chatcol=document.getElementById('chatcol');
let loaded={share:false,connect:false};
function currentTab(){ const b=document.querySelector('.railbtn.active'); return b?b.dataset.tab:'chat'; }
function switchTab(tab){
railBtns.forEach(b=>b.classList.toggle('active',b.dataset.tab===tab));
panels.forEach(p=>p.classList.toggle('active',p.dataset.panel===tab));
chatcol.classList.toggle('hidden', tab!=='chat');
document.querySelector('.shell').classList.toggle('is-chat', tab==='chat'); // mobile one-pane layout
if(tab!=='chat') document.body.classList.remove('chat-open');
document.body.classList.remove('rail-open'); // close the mobile drawer after picking a tab
// Lazy-load the embedded flows on first open; keep them mounted afterwards so a
// live session survives tab switches.
if(tab==='share' && !loaded.share){ document.getElementById('sharePanel').innerHTML='<iframe src="/share?embed=1" allow="camera; microphone; display-capture; clipboard-read; clipboard-write"></iframe>'; loaded.share=true; }
if(tab==='connect' && !loaded.connect){ document.getElementById('connectPanel').innerHTML='<iframe src="/connect?embed=1" allow="camera; microphone; display-capture; clipboard-read; clipboard-write"></iframe>'; loaded.connect=true; }
if(tab==='meeting' && meetState==='idle'){ renderMeetingLobby(); }
}
function showWelcome(skipAnim){
const finish=()=>{ selected=null; document.body.classList.remove('chat-open','chat-closing','chat-dragging'); renderChats(searchVal()); renderChatPanel(); updateRailUnread(); };
// Mobile: slide the outgoing conversation off to the right before swapping back to the chat list, so
// "back" has a clear push/pop direction (like Teams/WhatsApp). Desktop just closes instantly.
// skipAnim=true when the interactive swipe already dragged the pane off-screen — just do the state swap.
if(!skipAnim && document.body.classList.contains('chat-open') && !document.body.classList.contains('chat-closing') && window.matchMedia('(max-width:760px)').matches){
const content=document.querySelector('.shell > .content');
// Render the list NOW so it's ready underneath before we start sliding the conversation away.
renderChats(searchVal());
document.body.classList.add('chat-closing');
if(content){ content.style.transition='transform .21s cubic-bezier(.4,0,.7,.4)'; requestAnimationFrame(()=>{ content.style.transform='translateX(100%)'; }); }
setTimeout(()=>{ if(content){ content.style.transition=''; content.style.transform=''; } finish(); }, 215);
} else finish();
}
railBtns.forEach(btn=>{ btn.onclick=()=>{
const tab=btn.dataset.tab;
// Re-clicking Chat (while already on it) returns to the welcome screen.
if(tab==='chat' && currentTab()==='chat' && selected!=null){ showWelcome(); }
switchTab(tab);
}; });
// Esc clears the open conversation and brings back the welcome screen.
// Esc closes the topmost popup/search FIRST (capture phase, before the conversation-close below).
document.addEventListener('keydown',(e)=>{
if(e.key!=='Escape') return;
const lb=document.getElementById('lightbox'); if(lb){ lb.remove(); e.preventDefault(); e.stopPropagation(); return; } // #10: close the image preview first (leave the media view underneath)
const ovs=document.querySelectorAll('.modal-ov');
if(ovs.length){ ovs[ovs.length-1].remove(); e.preventDefault(); e.stopPropagation(); return; }
if(_selMode){ exitSelect(); e.preventDefault(); e.stopPropagation(); return; } // #1: leave forward-selection
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') && !document.getElementById('lightbox') && currentTab()==='chat' && selected!=null){ showWelcome(); } }); // #4: an open image preview closes first (its own Esc handler); the conversation only closes once no overlay remains
// #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(_){} } });
// #4: left-edge SWIPE-back — INSTANT (release-triggered). A drag that starts at the very left edge and
// releases clearly rightward runs the back (bzcBack → showWelcome()'s push-slide close). The finger-following
// version is PARKED (dropped for now) — this reliable release version is what ships. Only arms at the edge so
// it can't fight message scrolling.
(function(){
let sx=0, sy=0, edge=false;
window.addEventListener('touchstart',(e)=>{
if(e.touches.length!==1){ edge=false; return; }
const t=e.touches[0]; edge = t.clientX<=24; sx=t.clientX; sy=t.clientY;
}, {passive:true});
window.addEventListener('touchend',(e)=>{
if(!edge) return; edge=false;
const t=e.changedTouches&&e.changedTouches[0]; if(!t) return;
if((t.clientX-sx)>60 && Math.abs(t.clientY-sy)<50) bzcBack(); // rightward, mostly-horizontal swipe = back
}, {passive:true});
})();
// #9: swipe a message bubble to the right to reply to it (mobile), like WhatsApp/Teams. The bubble
// follows the finger a little; releasing past the threshold opens the reply composer for that message.
(function(){
let bx=0, by=0, bEl=null, on=false;
document.addEventListener('touchstart',(e)=>{
if(e.touches.length!==1){ on=false; return; }
const t=e.touches[0];
const b=e.target && e.target.closest && e.target.closest('#msgs .bubble');
if(!b || b.classList.contains('deleted') || t.clientX<32){ on=false; bEl=null; return; } // >32px so it can't clash with edge-swipe-back
on=true; bEl=b; bx=t.clientX; by=t.clientY;
}, {passive:true});
document.addEventListener('touchmove',(e)=>{
if(!on||!bEl||e.touches.length!==1) return;
const t=e.touches[0], dx=t.clientX-bx, dy=t.clientY-by;
if(dx>0 && Math.abs(dx)>Math.abs(dy)){ bEl.style.transition='none'; bEl.style.transform='translateX('+Math.min(dx,72)+'px)'; bEl.style.opacity=String(Math.max(.65,1-dx/300)); }
else if(Math.abs(dy)>Math.abs(dx)){ on=false; if(bEl){ bEl.style.transform=''; bEl.style.opacity=''; } bEl=null; } // it's a vertical scroll — let go
}, {passive:true});
document.addEventListener('touchend',(e)=>{
if(!on||!bEl){ on=false; return; }
const t=e.changedTouches&&e.changedTouches[0], dx=t?(t.clientX-bx):0, dy=t?(t.clientY-by):0;
const el=bEl; on=false; bEl=null;
el.style.transition='transform .18s, opacity .18s'; el.style.transform=''; el.style.opacity='';
if(dx>52 && Math.abs(dy)<46){ const id=el.getAttribute('data-id'); const m=(typeof THREAD!=='undefined'&&THREAD||[]).find(x=>String(x.id)===String(id)); if(m) setReply(m); }
}, {passive:true});
})();
// Hamburger: on mobile it slides the rail drawer in/out; on desktop it collapses the rail.
function toggleNav(){ if(window.innerWidth<=760) document.body.classList.toggle('rail-open'); else document.body.classList.toggle('rail-hidden'); }
const _navT=document.getElementById('navToggle'); if(_navT) _navT.onclick=toggleNav;
const _railBd=document.getElementById('railBackdrop'); if(_railBd) _railBd.onclick=()=>document.body.classList.remove('rail-open');
// Mobile chat: "back" returns from an open conversation to the chat list.
function chatBack(){ document.body.classList.remove('chat-open'); }
// Embedded Share/Connect flows report session start/stop so the rail can show a "live"
// dot — that's how you know a session is still running after switching to Chat.
window.addEventListener('message',(e)=>{
if(e.origin!==location.origin) return;
const d=e.data;
if(!d||d.type!=='bzc-session'||(d.flow!=='share'&&d.flow!=='connect')) return;
const btn=document.querySelector('.railbtn[data-tab="'+d.flow+'"]');
if(!btn) return;
btn.classList.toggle('live', !!d.active);
if(d.active && currentTab()!==d.flow){
toast((d.flow==='share'?'Screen share':'Connection')+' is live — tap the highlighted icon to return');
}
});
// Sidebar + misc wiring
document.getElementById('chatSearch').addEventListener('input',e=>{ const x=document.getElementById('chatSearchX'); if(x) x.style.display=e.target.value?'grid':'none'; renderChats(e.target.value); });
(function(){ const x=document.getElementById('chatSearchX'); if(x) x.onclick=()=>{ const s=document.getElementById('chatSearch'); s.value=''; x.style.display='none'; renderChats(''); s.focus(); }; })();
document.getElementById('newChat').title='New group';
document.getElementById('newChat').innerHTML=ic('userPlus',18);
document.getElementById('newChat').onclick=()=>openNewGroup();
// ---------- Login (shown here on /home when logged out) ----------
const EYE_OFF='<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg>';
const EYE_ON='<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>';
function pwField(id,ph){return '<div class="pwwrap"><input id="'+id+'" type="password" placeholder="'+ph+'"><button type="button" class="eye" data-for="'+id+'" aria-label="Show password"></button></div>';}
function wireEyes(){document.querySelectorAll('.eye').forEach(b=>{if(b._w)return;b._w=1;b.innerHTML=EYE_OFF;b.onclick=()=>{const inp=document.getElementById(b.getAttribute('data-for'));if(!inp)return;const show=inp.type==='password';inp.type=show?'text':'password';b.innerHTML=show?EYE_ON:EYE_OFF;};});}
function onEnter(ids,fn){ids.forEach(id=>{const el=document.getElementById(id);if(el)el.addEventListener('keydown',e=>{if(e.key==='Enter'){e.preventDefault();fn();}});});}
function showErr(id,msg){const el=document.getElementById(id);el.textContent=msg;el.classList.add('show');}
function clearErr(id){const el=document.getElementById(id);el.textContent='';el.classList.remove('show');}
async function postJSON(path,body){const r=await fetch(path,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});const d=await r.json().catch(()=>({}));if(!r.ok)throw new Error(d.error||'request failed');return d;}
async function renderLogin(){
document.querySelector('.shell').style.display='none';
const aw=document.getElementById('authwrap'); aw.style.display='flex';
let regOpen=false; try{ regOpen=(await (await fetch('/api/setup-state')).json()).registrationOpen; }catch(_){}
aw.innerHTML=`<div class="authcard">
<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>
${regOpen?`<div class="authtabs">
<button id="tabLogin" class="active">Sign in</button>
<button id="tabReg">Register team</button>
</div>`:''}
<form id="loginForm">
<span class="lbl">Email or phone</span><input id="li_email" type="text" inputmode="email" placeholder="you@bizgaze.com or mobile number" autocomplete="username">
<span class="lbl">Password</span>${pwField('li_pw','password')}
<label style="display:flex;align-items:center;gap:.5rem;margin:.7rem 0;color:var(--ink);font-size:.9rem;cursor:pointer"><input type="checkbox" id="li_remember" style="width:18px;height:18px;accent-color:var(--blue);margin:0"> Remember me on this device</label>
<button class="gobtn" id="li_btn" type="submit">Sign in</button>
<p id="li_err" class="formerr"></p>
</form>
${regOpen?`<div id="regForm" class="hidden">
<span class="lbl">Team name</span><input id="rg_team" placeholder="e.g. Acme Inc">
<span class="lbl">Email</span><input id="rg_email" type="email" placeholder="you@bizgaze.com">
<span class="lbl">Password</span>${pwField('rg_pw','min 8 characters')}
<button class="gobtn" id="rg_btn">Create team</button>
<p id="rg_err" class="formerr"></p>
</div>`:''}
</div>`;
document.getElementById('loginForm').addEventListener('submit',(e)=>{ e.preventDefault(); doLogin(); }); // Enter or "Sign in" both submit
wireEyes();
if(regOpen){
const lf=document.getElementById('loginForm'), rf=document.getElementById('regForm');
const tl=document.getElementById('tabLogin'), tr=document.getElementById('tabReg');
tl.onclick=()=>{lf.classList.remove('hidden');rf.classList.add('hidden');tl.classList.add('active');tr.classList.remove('active');};
tr.onclick=()=>{rf.classList.remove('hidden');lf.classList.add('hidden');tr.classList.add('active');tl.classList.remove('active');};
document.getElementById('rg_btn').onclick=doRegister;
onEnter(['rg_team','rg_email','rg_pw'],doRegister);
}
}
async function doLogin(){
clearErr('li_err');
const btn=document.getElementById('li_btn'); const orig=btn?btn.innerHTML:'';
if(btn){ btn.disabled=true; btn.innerHTML='<span class="btn-spin"></span>Signing in…'; } // show progress on Enter/click
try{
await postJSON('/api/login',{email:document.getElementById('li_email').value,password:document.getElementById('li_pw').value,remember:document.getElementById('li_remember').checked});
location.reload(); // the branded splash takes over from here
}catch(e){ if(btn){ btn.disabled=false; btn.innerHTML=orig; } showErr('li_err', /invalid credentials/i.test(e.message)?'Incorrect email or password. Please try again.':e.message); }
}
async function doRegister(){
clearErr('rg_err');
try{
await postJSON('/api/register',{email:document.getElementById('rg_email').value,password:document.getElementById('rg_pw').value,teamName:document.getElementById('rg_team').value});
await postJSON('/api/login',{email:document.getElementById('rg_email').value,password:document.getElementById('rg_pw').value});
location.reload();
}catch(e){ showErr('rg_err', e.message); }
}
// Relay remote-control messages from the embedded /share iframe to the native desktop shell for OS
// injection. The iframe (share.html) can't reach the native bridge directly (it lives on this top
// frame), so it postMessages here: we answer the availability handshake and forward arm/input events
// only when running in the desktop app. Injection is still gated by the shell's consent flag.
window.addEventListener('message',(e)=>{
if(e.origin!==location.origin) return; const d=e.data||{}; const n=window.bizConnectNative;
if(d.type==='rc-ping'){ const desktop=!!(n&&n.rcAvailable&&n.rcAvailable()); try{ e.source&&e.source.postMessage({type:'rc-pong', desktop}, location.origin); }catch(_){} return; }
if(!n) return;
if(d.type==='rc-arm'){ try{ n.rcArm&&n.rcArm(!!d.on); }catch(_){} return; }
if(d.type==='rc-input'){ try{ n.rcInput&&n.rcInput(d.evt); }catch(_){} return; }
});
// ---------- Boot: show the app if signed in, otherwise the login ----------
(async function(){
let me=null;
try{ const r=await fetch('/api/me'); if(r.ok) me=await r.json(); }catch(_){}
// Guest meeting join: someone opened a shared meeting link (…/home?meet=CODE or /j/CODE). If they
// aren't signed in, run in GUEST mode — no chat/sidebar, just a name prompt then straight into the
// call. Signed-in users fall through and auto-join after their normal boot (handled below).
let _meet=null; try{ _meet=(new URLSearchParams(location.search)).get('meet'); }catch(_){}
if(_meet && /^\d{6}$/.test(_meet) && !me){ document.getElementById('loading').style.display='none'; return startGuestMeeting(_meet); }
if(!me){ await renderLogin(); document.getElementById('loading').style.display='none'; return; }
ME=me;
document.getElementById('hdrRight').innerHTML=bellHTML()+profileHTML(me);
loadNotifs(); wireBell(); wireProfile();
placeHdrRight(); window.addEventListener('resize', placeHdrRight); // mobile: bell+profile live in the chat-list header (no top bar)
connectChatWs();
sfuInit(); // learn whether meetings use the LiveKit SFU or the P2P mesh (before any call starts)
reportInstall(); // desktop/mobile shell: record this install against the signed-in user
wireUpdateBanner(); // #3: desktop update-progress banner
setupPush(); // register the notification service worker + subscribe to Web Push (if granted)
{ const cl=document.getElementById('chatlist'); if(cl) enablePullRefresh(cl, loadSidebar); } // pull-to-refresh the chat list
setTimeout(maybeNotifPrompt, 1500); // gentle "enable notifications" prompt if still undecided (key for iOS PWA)
// Fast-path: when opened from a notification, show the chat immediately (only needs the
// thread fetch) and load the sidebar in the background — don't make the reload wait on it.
let oid=null, okind='dm';
try{ const q=new URLSearchParams(location.search); oid=q.get('openId'); okind=q.get('openKind')||'dm'; if(oid) history.replaceState(null,'','/home'); }catch(_){}
if(oid){
selectChat(okind, oid); // open the chat right away (only waits on the thread fetch)
document.getElementById('loading').style.display='none'; // the chat pane shows its own thread loader
loadSidebar().then(()=>{ try{ const it=rowFor(okind,oid); const nm=document.querySelector('#chatPanel .convo-head .nm'); if(it&&nm) nm.textContent=it.name; }catch(_){} });
} else {
await loadSidebar(); renderChatPanel();
document.getElementById('loading').style.display='none'; // keep the branded splash up until the list is ready (no blank/stuck gap)
}
// Signed-in user opened a meeting link → jump straight into that meeting.
if(_meet && /^\d{6}$/.test(_meet)){ try{ history.replaceState(null,'','/home'); }catch(_){} switchTab('meeting'); enterMeeting(_meet); }
})();
// GUEST meeting: a lightweight pre-join (name) then join the call with a throwaway guest identity —
// reuses the full meeting client (LiveKit/mesh, tiles, mic/cam) with the chat/sidebar chrome hidden.
async function startGuestMeeting(code){
document.body.classList.add('guest-mode');
try{ history.replaceState(null,'','/home?meet='+code); }catch(_){}
const savedName=(()=>{ try{ return localStorage.getItem('bzc_guest_name')||''; }catch(_){ return ''; } })();
let joinMic=false, joinCam=false; // pre-join choices, applied right after entering the call
const ov=document.createElement('div'); ov.className='guest-prejoin'; ov.id='guestJoin';
ov.innerHTML='<div class="gp-card">'
+'<div class="gp-brand"><img src="/mark-light.png" alt="" onerror="this.remove()"><span>Biz <b>Connect</b></span></div>'
+'<div class="gp-preview" id="gpPrev"><video id="gpVid" autoplay playsinline muted></video>'
+'<div class="gp-avatar" id="gpAv">?</div>'
+'<div class="gp-camoff" id="gpCamOff">Camera is off</div></div>'
+'<div class="gp-toggles">'
+'<button type="button" class="gp-tg off" id="gpMic" title="Microphone">'+ic('micOff',18)+'<span>Mic off</span></button>'
+'<button type="button" class="gp-tg off" id="gpCam" title="Camera">'+ic('videoOff',18)+'<span>Camera off</span></button>'
+'</div>'
+'<h2>Ready to join?</h2>'
+'<p class="gp-sub">Meeting <b>'+pEsc(code)+'</b> · joining as a guest</p>'
+'<input id="guestName" placeholder="Enter your name" autocomplete="name" maxlength="60" value="'+pEsc(savedName)+'">'
+'<button class="gp-join" id="guestGo">Join meeting</button>'
+'<div class="gp-note">'+ic('info',13)+' The host may need to admit you.</div>'
+'</div>';
document.body.appendChild(ov);
const inp=ov.querySelector('#guestName'), go=ov.querySelector('#guestGo');
const vid=ov.querySelector('#gpVid'), av=ov.querySelector('#gpAv'), camOff=ov.querySelector('#gpCamOff');
const micBtn=ov.querySelector('#gpMic'), camBtn=ov.querySelector('#gpCam');
let prevStream=null;
const syncAvatar=()=>{ const n=(inp.value||'').trim(); av.textContent=n?initials(n):'?'; av.style.background=avColor(n||'?'); };
syncAvatar(); inp.addEventListener('input', syncAvatar);
// Live camera preview so the guest can see themselves before joining (like every other meeting app).
const stopPrev=()=>{ if(prevStream){ try{ prevStream.getTracks().forEach(t=>t.stop()); }catch(_){} prevStream=null; } vid.srcObject=null; };
camBtn.onclick=async()=>{
if(joinCam){ joinCam=false; stopPrev(); camBtn.classList.add('off'); camBtn.innerHTML=ic('videoOff',18)+'<span>Camera off</span>'; camOff.style.display=''; return; }
try{ prevStream=await navigator.mediaDevices.getUserMedia({video:true}); vid.srcObject=prevStream; joinCam=true; camBtn.classList.remove('off'); camBtn.innerHTML=ic('video',18)+'<span>Camera on</span>'; camOff.style.display='none'; }
catch(e){ toast(mediaErrMsg(e,'camera')); }
};
micBtn.onclick=async()=>{
if(joinMic){ joinMic=false; micBtn.classList.add('off'); micBtn.innerHTML=ic('micOff',18)+'<span>Mic off</span>'; return; }
try{ const s=await navigator.mediaDevices.getUserMedia({audio:true}); s.getTracks().forEach(t=>t.stop()); joinMic=true; micBtn.classList.remove('off'); micBtn.innerHTML=ic('mic',18)+'<span>Mic on</span>'; }
catch(e){ toast(mediaErrMsg(e,'microphone')); }
};
setTimeout(()=>{ try{ inp.focus(); }catch(_){} },50);
const join=async()=>{
const nm=(inp.value||'').trim();
if(!nm){ inp.classList.add('err'); inp.focus(); toast('Please enter your name'); return; }
try{ localStorage.setItem('bzc_guest_name', nm); }catch(_){}
ME={ id:'guest-'+Math.random().toString(36).slice(2,10), name:nm, email:'', guest:true, avatarUrl:null };
_guestRoom=code; stopPrev(); ov.remove();
try{ await sfuInit(); }catch(_){}
switchTab('meeting'); await enterMeeting(code);
// Apply their pre-join choices once the call is up.
setTimeout(()=>{ try{ if(joinMic && !meetMic) toggleMic(); if(joinCam && !meetCam) toggleCam(); }catch(_){} }, 900);
};
go.onclick=join;
inp.addEventListener('keydown',e=>{ if(e.key==='Enter'){ e.preventDefault(); join(); } });
inp.addEventListener('input',()=>inp.classList.remove('err'));
}
</script>
</body>
</html>