Files
BizGaze_Remote/server/public/home.html
T

2323 lines
205 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">
<title>BizGaze Connect</title>
<style>
:root{ --brand:#FFC708; --brand-d:#E0AC00; --blue:#1F3B73; --blue-d:#16294f; --blue-soft:#EAF0FB; --ink:#1f2430; --muted:#6b7280; --bg:#f6f8fb; --card:#fff; --line:#e6e9ef; --green:#16a34a; --red:#b91c1c; }
*{box-sizing:border-box;}
html,body{height:100%;}
body{font-family:'Segoe UI',system-ui,sans-serif;background:var(--bg);color:var(--ink);margin:0;display:flex;flex-direction:column;height:100vh;height:100dvh;overflow:hidden;}
/* ---- Top bar ---- */
header{background:var(--blue);padding:.7rem 1.4rem;display:flex;justify-content:space-between;align-items:center;flex:0 0 auto;position:relative;z-index:1300;}
.brandrow{display:flex;align-items:center;gap:.6rem;}
.logo{width:30px;height:30px;border-radius:8px;background:var(--brand);display:grid;place-items:center;font-weight:800;color:var(--blue);}
.brand{font-weight:700;color:#fff;font-size:1.05rem;} .brand span.y{color:var(--brand);font-weight:700;}
/* ---- Header actions: notification bell + profile ---- */
#hdrRight{display:flex;align-items:center;gap:.45rem;}
.bell{position:relative;}
.bellbtn{position:relative;background:rgba(255,255,255,.14);border:1px solid #46598c;color:#fff;cursor:pointer;width:40px;height:40px;border-radius:10px;display:grid;place-items:center;}
.bellbtn:hover{background:rgba(255,255,255,.24);}
.bell-dot{position:absolute;top:4px;right:4px;min-width:16px;height:16px;border-radius:99px;background:var(--brand);color:var(--blue);font-size:.6rem;font-weight:800;display:grid;place-items:center;padding:0 .2rem;border:2px solid var(--blue);}
.bell-menu{position:absolute;right:0;top:calc(100% + 6px);width:340px;max-width:92vw;background:#fff;border:1px solid #e6e9ef;border-radius:12px;box-shadow:0 12px 30px rgba(0,0,0,.18);z-index:5000;display:none;overflow:hidden;}
.bell-menu.open{display:block;}
.bell-head{display:flex;align-items:center;justify-content:space-between;padding:.6rem .85rem;border-bottom:1px solid #eef1f6;font-weight:700;font-size:.9rem;color:var(--ink);}
.bell-head button{background:none;border:none;color:var(--blue);font-size:.78rem;cursor:pointer;font-weight:600;}
.bell-list{max-height:62vh;overflow:auto;}
.bell-item{display:flex;gap:.6rem;align-items:flex-start;padding:.6rem .85rem;border-bottom:1px solid #f4f6fa;cursor:pointer;}
.bell-item:hover{background:#f6f8fb;}
.bell-item.unread{background:#eef4ff;}
.bell-ico{width:30px;height:30px;border-radius:50%;background:var(--blue-soft);color:var(--blue);display:grid;place-items:center;flex:0 0 30px;}
.bell-body{min-width:0;}
.bell-tx{font-size:.85rem;color:var(--ink);line-height:1.3;}
.bell-tm{font-size:.7rem;color:var(--muted);margin-top:.1rem;}
.bell-empty{padding:1.6rem;text-align:center;color:var(--muted);font-size:.85rem;}
/* ---- Profile dropdown (from console.html) ---- */
.profile{position:relative}
.profile .pbtn.icon-only{padding:.3rem;gap:0;border-radius:50%;}
.profile .pbtn{display:flex;align-items:center;gap:.5rem;background:rgba(255,255,255,.14);color:#fff;border:1px solid #46598c;border-radius:10px;padding:.4rem .85rem .4rem .5rem;font-weight:600;font-size:.88rem;cursor:pointer}
.profile .pbtn:hover{background:rgba(255,255,255,.24)}
.profile .pbtn .pav{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;}
.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;}
.contact-row,.dir-row{display:flex;align-items:center;gap:.6rem;padding:.5rem .9rem;cursor:pointer;}
.contact-row:hover,.dir-row:hover{background:var(--blue-soft);}
.contact-row .cr-name,.dir-row .cr-name{font-size:.9rem;color:var(--ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.dir-row .dr-main{display:flex;flex-direction:column;min-width:0;flex:1;}
.dir-row .dr-sub{font-size:.74rem;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.dir-row .dr-tag{font-size:.62rem;font-weight:700;color:#92600b;background:#fff3cd;border-radius:99px;padding:.1rem .4rem;flex:0 0 auto;}
.side-head{padding:1rem 1rem .75rem;border-bottom:1px solid var(--line);}
.side-title{display:flex;align-items:center;justify-content:space-between;margin-bottom:.7rem;}
.side-title h2{font-size:.95rem;margin:0;color:var(--blue);}
.newchat{width:30px;height:30px;border-radius:9px;border:none;background:var(--blue-soft);color:var(--blue);font-size:1.2rem;line-height:1;cursor:pointer;font-weight:700;display:grid;place-items:center;padding:0;}
.newchat:hover{background:#dbe6fb;}
.search{position:relative;}
.search > svg{position:absolute;left:.65rem;top:50%;transform:translateY(-50%);color:var(--muted);}
.search input{width:100%;padding:.55rem .7rem .55rem 2.1rem;border-radius:10px;border:2px solid var(--line);background:#fbfcfe;color:var(--ink);font-size:.9rem;}
.search input:focus{outline:none;border-color:var(--brand);}
.search input{padding-right:2.1rem;}
.search-x{position:absolute;right:.5rem;top:50%;transform:translateY(-50%);border:none;background:transparent;color:var(--muted);cursor:pointer;padding:.2rem;border-radius:6px;display:grid;place-items:center;}
.search-x:hover{color:var(--blue);background:var(--blue-soft);}
.chatlist{overflow-y:auto;flex:1 1 auto;padding:.4rem;}
.chat-row{display:flex;gap:.7rem;align-items:center;padding:.6rem .65rem;border-radius:12px;cursor:pointer;position:relative;}
.chat-row:hover{background:#f3f6fb;}
.chat-row.active{background:var(--blue-soft);box-shadow:inset 3px 0 0 var(--brand);}
.chat-row.active::before{content:"";position:absolute;left:0;top:.7rem;bottom:.7rem;width:3px;border-radius:3px;background:var(--blue);}
.avatar{width:42px;height:42px;flex:0 0 42px;border-radius:50%;display:grid;place-items:center;color:#fff;font-weight:700;font-size:.92rem;position:relative;}
.avatar .av-img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;border-radius:inherit;}
.avatar .dot{position:absolute;right:-1px;bottom:-1px;width:11px;height:11px;border-radius:50%;border:2px solid #fff;background:#cbd2dd;z-index:1;}
.avatar .dot.on{background:var(--green);}
.chat-main{flex:1 1 auto;min-width:0;}
.chat-top{display:flex;justify-content:space-between;align-items:baseline;gap:.5rem;}
.chat-name{font-weight:400;font-size:.92rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.chat-time{color:var(--muted);font-size:.72rem;flex:0 0 auto;}
.chat-bottom{display:flex;justify-content:space-between;align-items:center;gap:.5rem;margin-top:.15rem;}
.chat-prev{color:var(--muted);font-size:.82rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1 1 auto;}
.chat-row.unread .chat-prev{color:var(--ink);font-weight:500;}
.chat-row.unread .chat-name{font-weight:700;}
.badge{flex:0 0 auto;background:var(--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);}
.ic{display:inline-block;vertical-align:middle;}
.convo-back{border:none;background:var(--blue-soft);color:var(--blue);width:34px;height:34px;border-radius:9px;cursor:pointer;display:grid;place-items:center;flex:0 0 auto;}
.convo-back:hover{background:#dbe6fb;}
.convo-head .nm{font-weight:700;color:var(--ink);}
.convo-head .st{font-size:.78rem;color:var(--muted);}
.convo-body{flex:1;display:grid;place-items:center;text-align:center;color:var(--muted);padding:2rem;}
.convo-body .big{font-size:2.4rem;margin-bottom:.4rem;}
/* message thread */
.convo-msgs{flex:1;overflow-y:auto;padding:1rem 1.2rem;display:flex;flex-direction:column;gap:.35rem;background:var(--bg);}
.bubble{max-width:72%;padding:.5rem .75rem;border-radius:14px;font-size:.9rem;line-height:1.4;white-space:pre-wrap;word-break:break-word;box-shadow:0 1px 2px rgba(20,30,60,.06);}
.bubble.them{align-self:flex-start;background:#fff;border:1px solid var(--line);color:var(--ink);border-bottom-left-radius:4px;}
.bubble.mine{align-self:flex-end;background:var(--blue);color:#fff;border-bottom-right-radius:4px;}
.bubble .t{display:block;font-size:.64rem;opacity:.65;margin-top:.15rem;text-align:right;}
.day-sep{align-self:center;font-size:.72rem;margin:.7rem 0 .3rem;text-align:center;}
.day-sep span{background:#fde7b0;color:#7a5b05;padding:.22rem .8rem;border-radius:99px;font-weight:600;box-shadow:0 1px 3px rgba(20,30,60,.1);}
.float-date{position:absolute;top:.6rem;left:50%;transform:translateX(-50%);z-index:5;background:#fde7b0;color:#7a5b05;padding:.22rem .85rem;border-radius:99px;font-weight:600;font-size:.72rem;box-shadow:0 3px 10px rgba(20,30,60,.18);pointer-events:none;}
.jump-latest{position:absolute;right:16px;bottom:86px;z-index:5;width:42px;height:42px;border-radius:50%;border:1px solid var(--line);background:var(--card);color:var(--blue);box-shadow:0 4px 14px rgba(20,30,60,.22);cursor:pointer;display:grid;place-items:center;}
.jump-latest:hover{background:var(--brand);color:var(--blue);border-color:var(--brand-d);}
.empty-thread{align-self:center;font-size:.85rem;color:var(--muted);margin:auto;}
.sys-msg{align-self:center;font-size:.76rem;color:var(--muted);background:rgba(0,0,0,.04);padding:.25rem .7rem;border-radius:99px;margin:.2rem 0;max-width:90%;text-align:center;}
.bubble .t{display:flex;align-items:center;justify-content:flex-end;gap:.25rem;font-size:.64rem;opacity:.65;margin-top:.15rem;}
.bubble .rcpt{display:inline-flex;}
.bubble.mine .rcpt{opacity:.8;}
.bubble.mine .rcpt.seen{color:#8fd3ff;opacity:1;}
.att-img{cursor:zoom-in;}
.fmt-bar{display:flex;align-items:center;gap:.05rem;padding:.3rem .4rem .1rem;flex-wrap:wrap;border-bottom:1px dashed var(--line);}
.fmt-bar button{border:none;background:transparent;color:var(--muted);cursor:pointer;width:30px;height:30px;border-radius:7px;display:grid;place-items:center;}
.fmt-bar button:hover{color:var(--blue);background:var(--blue-soft);}
.fmt-sep{width:1px;height:18px;background:var(--line);margin:0 .3rem;}
.bubble .msg-list{margin:.15rem 0;padding-left:1.25rem;}
.bubble .msg-list li{margin:.05rem 0;}
.bubble code{background:rgba(0,0,0,.08);padding:.05rem .3rem;border-radius:5px;font-size:.86em;font-family:ui-monospace,Consolas,monospace;}
.bubble.mine code{background:rgba(255,255,255,.2);}
.lightbox{position:fixed;inset:0;z-index:6000;background:rgba(8,12,22,.88);display:flex;align-items:center;justify-content:center;}
.lightbox img{max-width:92vw;max-height:88vh;border-radius:10px;box-shadow:0 16px 50px rgba(0,0,0,.5);}
.lightbox .lb-close,.lightbox .lb-dl{position:absolute;top:18px;border:none;background:rgba(255,255,255,.14);color:#fff;width:44px;height:44px;border-radius:50%;display:grid;place-items:center;cursor:pointer;text-decoration:none;}
.lightbox .lb-close{right:18px;}
.lightbox .lb-dl{right:74px;}
.lightbox .lb-close:hover,.lightbox .lb-dl:hover{background:rgba(255,255,255,.28);}
.composer{display:flex;align-items:flex-end;gap:.5rem;padding:.6rem .8rem;border-top:1px solid var(--line);background:var(--card);}
.composer-box{flex:1;min-width:0;border:1.5px solid var(--line);border-radius:16px;background:#fbfcfe;display:flex;flex-direction:column;overflow:hidden;}
.composer-box:focus-within{border-color:var(--blue);}
.composer-row{display:flex;align-items:flex-end;gap:.15rem;padding:.15rem .3rem;}
.composer-row input,.composer-row textarea{flex:1;min-width:0;box-sizing:border-box;border:none;background:transparent;padding:.5rem .4rem;margin:0;font-size:.92rem;color:var(--ink);font-family:inherit;resize:none;line-height:1.45;max-height:140px;overflow-y:hidden;display:block;scrollbar-width:thin;}
.composer-row input:focus,.composer-row textarea:focus{outline:none;}
.ic-btn{border:none;background:transparent;color:var(--muted);cursor:pointer;width:36px;height:36px;border-radius:10px;display:grid;place-items:center;flex:0 0 auto;}
.ic-btn:hover{color:var(--blue);background:var(--blue-soft);}
.attach-preview{padding:.55rem .6rem .15rem;}
.ap-item{display:inline-flex;align-items:center;gap:.55rem;background:#fff;border:1px solid var(--line);border-radius:11px;padding:.35rem .5rem;max-width:100%;}
.ap-thumb{width:42px;height:42px;border-radius:8px;object-fit:cover;flex:0 0 auto;}
.ap-ic{display:grid;place-items:center;color:var(--blue);flex:0 0 auto;}
.ap-name{font-size:.82rem;color:var(--ink);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:240px;}
.ap-x{border:none;background:transparent;color:var(--muted);cursor:pointer;display:grid;place-items:center;padding:.15rem;border-radius:6px;flex:0 0 auto;}
.ap-x:hover{color:var(--red);background:#fee2e2;}
/* meetings */
.meet{display:flex;flex-direction:column;width:100%;height:100%;}
.meet-grid{flex:1;display:grid;gap:.6rem;padding:.8rem;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));align-content:start;overflow:auto;background:#0b1220;}
.meet-tile{position:relative;background:#0b1220;border-radius:12px;overflow:hidden;aspect-ratio:4/3;border:1px solid #1e293b;transition:box-shadow .12s,border-color .12s;}
.meet-tile.speaking{border-color:#22c55e;box-shadow:0 0 0 2px #22c55e, 0 0 14px rgba(34,197,94,.5);}
.meet-tile .meet-screen{position:absolute;right:.5rem;top:.5rem;display:inline-flex;align-items:center;gap:.25rem;background:rgba(37,99,235,.92);color:#fff;font-size:.66rem;font-weight:700;padding:.14rem .42rem;border-radius:6px;}
.meet-tile.sharing{grid-column:span 2;border-color:#2563eb;}
/* Screen-share "stage": the chosen shared screen (.stage) fills a FIXED area on the left; every
other tile — participants AND any other shared screens — sits in a persistent small column on the
right. Click another shared screen there to switch which one is on the stage. Screen never shrinks. */
.meet-grid.sharing-mode{display:flex;flex-flow:column wrap;align-content:flex-start;height:100%;min-height:0;gap:.5rem;}
.meet-grid.sharing-mode .meet-tile{aspect-ratio:auto;}
.meet-grid.sharing-mode .meet-tile.stage{order:-1;height:100%;width:calc(100% - 176px);min-width:0;border-color:#2563eb;}
.meet-grid.sharing-mode .meet-tile.stage video{object-fit:contain;background:#000;}
.meet-grid.sharing-mode .meet-tile:not(.stage){width:160px;height:94px;flex:0 0 auto;}
.meet-grid.sharing-mode .meet-tile.sharing:not(.stage){cursor:pointer;}
.meet-grid.sharing-mode .meet-tile.sharing:not(.stage)::after{content:"Click to view";position:absolute;left:0;right:0;bottom:0;font-size:.58rem;text-align:center;background:rgba(37,99,235,.88);color:#fff;padding:1px 0;}
@media (max-width:760px){
.meet-grid.sharing-mode{flex-flow:row wrap;height:auto;}
.meet-grid.sharing-mode .meet-tile.stage{width:100%;height:auto;flex:1 1 100%;min-height:40vh;}
.meet-grid.sharing-mode .meet-tile:not(.stage){width:46%;height:84px;}
}
.meet-bar .meet-ic.on{background:var(--blue);color:#fff;}
.meet-bar #meetRecBtn.on{background:#dc2626;color:#fff;animation:livePulse 1.6s infinite;}
.rec-notice{position:fixed;top:14px;left:50%;transform:translateX(-50%);z-index:6500;display:inline-flex;align-items:center;gap:.4rem;background:rgba(220,38,38,.95);color:#fff;font-size:.82rem;font-weight:700;padding:.32rem .7rem;border-radius:99px;box-shadow:0 6px 18px rgba(220,38,38,.4);}
.rec-notice .rec-dot{width:9px;height:9px;border-radius:50%;background:#fff;animation:livePulse 1.4s infinite;}
.tx-notice{position:fixed;top:14px;left:14px;z-index:6500;display:inline-flex;align-items:center;gap:.35rem;background:rgba(37,99,235,.95);color:#fff;font-size:.78rem;font-weight:700;padding:.3rem .65rem;border-radius:99px;box-shadow:0 6px 18px rgba(37,99,235,.4);}
.si-recs{display:flex;flex-wrap:wrap;gap:.5rem;margin-top:.55rem;}
.rec-dl{display:inline-flex;align-items:center;gap:.35rem;font-size:.78rem;font-weight:700;text-decoration:none;border-radius:9px;padding:.36rem .7rem;border:1px solid transparent;transition:filter .12s;}
.rec-dl:hover{filter:brightness(.96);}
.rec-dl.vid{background:#eef2ff;color:#4338ca;border-color:#c7d2fe;}
.rec-dl.txt{background:#ecfdf5;color:#047857;border-color:#a7f3d0;}
.rec-dl .rd-dur{background:rgba(0,0,0,.09);border-radius:6px;padding:.04rem .32rem;font-size:.72rem;font-weight:700;}
.meet-panel .pp-screen{color:var(--blue);display:inline-flex;}
.meet-panel .mp-setting{margin:.3rem;font-size:.8rem;}
.meet-tile video{width:100%;height:100%;object-fit:cover;background:#0b1220;}
.meet-tile .nm{position:absolute;left:.5rem;bottom:.5rem;background:rgba(0,0,0,.55);color:#fff;font-size:.75rem;padding:.15rem .5rem;border-radius:6px;}
.meet-tile .meet-av{position:absolute;inset:0;margin:auto;width:84px;height:84px;border-radius:50%;display:none;align-items:center;justify-content:center;color:#fff;font-weight:700;font-size:1.9rem;}
.meet-tile .meet-mute{position:absolute;left:.5rem;top:.5rem;width:26px;height:26px;border-radius:50%;background:#dc2626;color:#fff;place-items:center;box-shadow:0 1px 3px rgba(0,0,0,.4);}
.meet-panel{position:absolute;right:12px;top:12px;bottom:78px;width:280px;max-width:80vw;background:var(--card);border:1px solid var(--line);border-radius:14px;box-shadow:0 14px 40px rgba(0,0,0,.3);z-index:30;display:flex;flex-direction:column;overflow:hidden;}
.meet-panel .mp-head{display:flex;align-items:center;gap:.5rem;padding:.7rem .8rem;border-bottom:1px solid var(--line);font-size:.9rem;}
.meet-panel .mp-muteall{border:1px solid var(--line);background:#fee2e2;color:var(--red);border-radius:8px;padding:.3rem .55rem;font-size:.78rem;font-weight:600;cursor:pointer;display:inline-flex;align-items:center;gap:.25rem;}
.meet-panel .mp-x{border:none;background:transparent;color:var(--muted);cursor:pointer;display:grid;place-items:center;}
.meet-panel .mp-tabs{display:flex;gap:.25rem;padding:.4rem .5rem 0;border-bottom:1px solid var(--line);}
.meet-panel .mp-tab{flex:1;border:none;background:transparent;color:var(--muted);font-size:.8rem;font-weight:600;padding:.45rem .3rem;cursor:pointer;border-bottom:2px solid transparent;display:inline-flex;align-items:center;justify-content:center;gap:.25rem;}
.meet-panel .mp-tab.on{color:var(--blue);border-bottom-color:var(--blue);}
.meet-panel .mp-list{flex:1;overflow:auto;padding:.4rem;}
.meet-panel .chk{display:flex;align-items:center;gap:.5rem;padding:.4rem .5rem;border-radius:8px;cursor:pointer;font-size:.88rem;}
.meet-panel .chk:hover{background:#f6f8fb;}
.meet-panel .chk input{width:16px;height:16px;flex:0 0 16px;accent-color:var(--blue);margin:0;}
.meet-panel .chk .mn{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.meet-panel .mp-row{display:flex;align-items:center;gap:.5rem;padding:.4rem .5rem;border-radius:8px;}
.meet-panel .mp-row .mn{flex:1;min-width:0;font-size:.88rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.meet-panel .mp-row .pp-mute{color:var(--red);display:inline-flex;}
.meet-panel .mp-makehost{border:none;background:var(--blue-soft);color:var(--blue);border-radius:7px;padding:.2rem .4rem;cursor:pointer;display:grid;place-items:center;}
.host-tag{display:inline-flex;align-items:center;gap:.2rem;font-size:.62rem;font-weight:700;background:#fff3cd;color:#7a5b05;padding:.05rem .35rem;border-radius:99px;}
.admin-tag{display:inline-flex;align-items:center;gap:.2rem;font-size:.6rem;font-weight:700;background:#fef3c7;color:#92600b;padding:.05rem .4rem;border-radius:99px;vertical-align:middle;}
.iconbtn.role{color:var(--muted);} .iconbtn.role.is-admin{color:#d4a106;} .iconbtn.role:hover{color:#d4a106;}
.si-invited{display:inline-flex;align-items:center;gap:.3rem;font-size:.76rem;color:var(--muted);margin-top:.25rem;}
.meet-tile.novid .meet-av{display:flex;}
.meet-tile.novid video{visibility:hidden;}
.meet-bar{display:flex;align-items:center;gap:.6rem;padding:.7rem 1rem;background:var(--card);border-top:1px solid var(--line);}
.meet-bar .code{margin-right:auto;color:var(--muted);font-size:.85rem;}
.meet-bar .code b{color:var(--blue);font-size:1rem;letter-spacing:.06em;}
.meet-bar button{border:none;border-radius:10px;padding:.6rem 1rem;font-weight:600;cursor:pointer;font-size:.9rem;display:inline-flex;align-items:center;gap:.35rem;}
.meet-bar .tgl{background:#eef1f6;color:var(--blue);}
.meet-bar .tgl.off{background:#fee2e2;color:var(--red);}
.meet-bar .leave{background:#dc2626;color:#fff;}
.meet-bar .meet-ic{width:46px;height:46px;padding:0;border-radius:50%;background:#e8edf5;color:var(--blue);display:inline-flex;align-items:center;justify-content:center;}
.meet-bar .meet-ic:hover{background:#dbe4f0;}
.meet-bar .meet-ic.off{background:#fee2e2;color:var(--red);}
.meet-bar .meet-ic.leave,.meet-bar .meet-ic.leave.off{background:#dc2626;color:#fff;}
.meet-bar .meet-ic.leave:hover{background:#b91c1c;}
/* Meetings dashboard (full-width, scrollable) */
.meet-dash{width:100%;height:100%;overflow-y:auto;padding:1.4rem clamp(1rem,4vw,2.4rem) 2rem;background:var(--bg);}
.md-top{display:flex;align-items:flex-start;justify-content:space-between;gap:1rem;flex-wrap:wrap;margin-bottom:.5rem;}
.md-title h1{margin:0;font-size:1.5rem;color:var(--ink);}
.md-title p{margin:.3rem 0 0;color:var(--muted);font-size:.9rem;max-width:520px;}
.md-actions{display:flex;align-items:stretch;gap:.5rem;flex-wrap:wrap;}
.md-join{display:flex;gap:.4rem;flex:1 1 240px;min-width:0;}
.md-join input{flex:1 1 auto;min-width:0;text-align:center;letter-spacing:.18rem;font-size:1.05rem;font-weight:600;border:1px solid var(--line);border-radius:11px;padding:.7rem .6rem;background:var(--card);color:var(--ink);}
.md-join .btn{flex:0 0 auto;padding:.55rem 1.1rem;font-size:.9rem;}
.md-actions > .btn{white-space:nowrap;padding:.7rem 1.05rem;}
.btn.primary{background:var(--blue);color:#fff;}
.btn.primary:hover{filter:brightness(1.06);}
.meet-dash .hint{min-height:0;color:var(--red);font-size:.82rem;margin:.2rem 0;}
.sched-empty{color:var(--muted);font-size:.92rem;background:var(--card);border:1px dashed var(--line);border-radius:12px;padding:1.6rem;text-align:center;margin-top:1rem;}
/* chat: reply + emoji */
.convo{position:relative;}
.bubble{position:relative;}
.bubble .quote{border-left:3px solid var(--line);padding:.22rem .5rem;margin-bottom:.3rem;font-size:.78rem;border-radius:6px;color:#33384a;}
.reply-btn{position:absolute;top:-9px;right:6px;background:var(--card);color:var(--blue);border:1px solid var(--line);border-radius:50%;width:22px;height:22px;font-size:.8rem;line-height:1;cursor:pointer;opacity:0;transition:opacity .12s;box-shadow:0 1px 3px rgba(0,0,0,.12);}
.bubble:hover .reply-btn{opacity:1;}
.reply-bar{display:flex;align-items:center;gap:.5rem;padding:.45rem .8rem;border-top:1px solid var(--line);background:#eef3fb;font-size:.82rem;color:var(--muted);}
.reply-bar b{color:var(--ink);}
.reply-bar .rx{margin-left:auto;cursor:pointer;font-size:1rem;}
.reply-bar .rx:hover{color:var(--red);}
.reply-btn,.react-btn{display:grid;place-items:center;}
.emoji-pop{position:absolute;bottom:64px;left:12px;width:330px;height:300px;background:#fff;border:1px solid var(--line);border-radius:12px;box-shadow:0 10px 28px rgba(0,0,0,.18);z-index:50;display:flex;flex-direction:column;overflow:hidden;}
.mention-pop{position:absolute;left:12px;right:12px;bottom:64px;max-height:240px;overflow:auto;background:var(--card);border:1px solid var(--line);border-radius:12px;box-shadow:0 10px 28px rgba(0,0,0,.18);z-index:60;padding:.3rem;}
.mention-pop .mrow{display:flex;align-items:center;gap:.55rem;padding:.4rem .55rem;border-radius:8px;cursor:pointer;}
.mention-pop .mrow.sel{background:var(--blue-soft);}
.mention-pop .mn{font-weight:600;color:var(--ink);}
.mention-pop .sub{color:var(--muted);font-size:.78rem;margin-left:auto;}
.mention{color:var(--blue);background:var(--blue-soft);border-radius:5px;padding:0 .18rem;font-weight:600;}
.bubble.mine .mention{color:#fff;background:rgba(255,255,255,.22);}
.bubble.mention-me{box-shadow:0 1px 2px rgba(20,30,60,.06),inset 3px 0 0 var(--brand);}
.bubble.has-poll{max-width:88%;}
.poll{margin-top:.5rem;display:flex;flex-direction:column;gap:.35rem;min-width:240px;}
.poll-q{font-size:.72rem;font-weight:700;letter-spacing:.03em;text-transform:uppercase;opacity:.7;display:flex;align-items:center;gap:.3rem;}
.poll-opt{position:relative;overflow:hidden;display:flex;align-items:center;justify-content:space-between;gap:.5rem;border:1px solid var(--line);background:var(--card);color:var(--ink);border-radius:9px;padding:.45rem .6rem;font-size:.86rem;cursor:pointer;text-align:left;}
.poll-opt:hover:not([disabled]){border-color:var(--blue);}
.poll-opt[disabled]{cursor:default;}
.poll-opt.mine{border-color:var(--blue);background:var(--blue-soft);}
.poll-opt .po-bar{position:absolute;left:0;top:0;bottom:0;background:var(--blue-soft);z-index:0;transition:width .25s;}
.poll-opt.mine .po-bar{background:#d8e4f8;}
.poll-opt .po-txt{position:relative;z-index:1;font-weight:600;display:flex;align-items:center;gap:.3rem;}
.poll-opt .po-pct{position:relative;z-index:1;color:var(--muted);font-size:.78rem;flex:0 0 auto;}
.poll-foot{font-size:.76rem;color:var(--muted);margin-top:.1rem;}
.poll-foot .poll-close{color:var(--red);cursor:pointer;font-weight:600;}
.bubble.mine .poll-opt{color:var(--ink);}
.poll-opt-row{display:flex;align-items:center;gap:.4rem;margin-bottom:.4rem;}
.emoji-tabs{display:flex;border-bottom:1px solid var(--line);flex:0 0 auto;}
.emoji-tabs button{flex:1;border:none;background:transparent;font-size:1.05rem;padding:.35rem 0;cursor:pointer;opacity:.55;}
.emoji-tabs button.active{opacity:1;background:var(--blue-soft);}
.emoji-grid{flex:1;overflow-y:auto;display:grid;grid-template-columns:repeat(8,1fr);gap:.1rem;padding:.4rem;align-content:start;}
.emoji-grid button{border:none;background:transparent;font-size:1.25rem;cursor:pointer;padding:.2rem;border-radius:6px;line-height:1.15;}
.emoji-grid button:hover{background:var(--blue-soft);}
.react-btn{position:absolute;top:-9px;right:32px;background:var(--card);color:var(--blue);border:1px solid var(--line);border-radius:50%;width:22px;height:22px;font-size:.8rem;line-height:1;cursor:pointer;opacity:0;transition:opacity .12s;box-shadow:0 1px 3px rgba(0,0,0,.12);}
.bubble:hover .react-btn{opacity:1;}
.reacts{display:flex;flex-wrap:wrap;gap:.2rem;margin-top:.3rem;}
.react-chip{border:1px solid var(--line);background:var(--card);color:var(--ink);border-radius:999px;font-size:.74rem;padding:.05rem .4rem;cursor:pointer;line-height:1.5;}
.react-chip.mine{background:var(--blue-soft);border-color:#c7d6f0;color:var(--blue);}
/* attachments */
.attach-btn,.emoji-btn{border:none;background:transparent;cursor:pointer;padding:.35rem;color:var(--muted);display:grid;place-items:center;border-radius:8px;}
.attach-btn:hover,.emoji-btn:hover{color:var(--blue);background:var(--blue-soft);}
.composer-row .sendbtn{flex:0 0 auto;align-self:flex-end;width:34px;height:34px;margin:1px;border:none;background:var(--brand);color:var(--ink);cursor:pointer;border-radius:50%;display:grid;place-items:center;}
.composer-row .sendbtn:hover{background:var(--brand-d);}
.att-img{max-width:240px;max-height:240px;border-radius:8px;display:block;margin:.15rem 0;}
.att-file{display:inline-flex;align-items:center;gap:.4rem;background:rgba(0,0,0,.06);border:1px solid var(--line);border-radius:8px;padding:.4rem .6rem;color:inherit;text-decoration:none;font-size:.85rem;margin:.15rem 0;max-width:240px;}
.att-file span{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.bubble.mine .att-file{background:rgba(255,255,255,.18);border-color:rgba(255,255,255,.3);}
.att-file .att-sz{opacity:.65;font-size:.75rem;flex:0 0 auto;}
/* groups */
.bubble .sender{font-size:.7rem;color:var(--blue);font-weight:700;margin-bottom:.12rem;display:flex;align-items:center;gap:.35rem;}
.bubble .sender .snd-av{position:relative;width:18px;height:18px;flex:0 0 18px;border-radius:50%;display:grid;place-items:center;color:#fff;font-weight:700;font-size:.55rem;overflow:hidden;}
.bubble .sender .snd-av img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;}
.avatar.grp{border-radius:12px;font-size:1.15rem;}
.modal-ov{position:fixed;inset:0;background:rgba(15,23,42,.45);display:flex;align-items:center;justify-content:center;z-index:9800;padding:1rem;}
.modal{background:#fff;border-radius:16px;padding:1.4rem;max-width:380px;width:100%;box-shadow:0 18px 44px rgba(0,0,0,.3);max-height:calc(100vh - 2rem);max-height:calc(100dvh - 2rem);overflow-y:auto;}
.modal h3{margin:0 0 .8rem;color:var(--blue);}
.modal input#grpName,.modal input#giName{width:100%;padding:.6rem .7rem;border:2px solid var(--line);border-radius:10px;background:#fbfcfe;font-size:.95rem;margin-bottom:.8rem;}
.modal input#grpName:focus,.modal input#giName:focus{outline:none;border-color:var(--brand);}
.avatar .mcount{position:absolute;right:-3px;bottom:-3px;min-width:16px;height:16px;border-radius:99px;background:var(--blue);color:#fff;font-size:.6rem;font-weight:800;display:grid;place-items:center;border:2px solid var(--card);padding:0 .15rem;z-index:1;}
.convo-titlewrap{flex:1;min-width:0;}
.convo-info{border:none;background:var(--blue-soft);color:var(--blue);width:32px;height:32px;border-radius:9px;font-size:1rem;cursor:pointer;flex:0 0 auto;display:grid;place-items:center;}
.convo-info:hover{background:#dbe4f0;}
.convo-call{border:none;background:var(--blue-soft);color:var(--blue);height:32px;border-radius:9px;cursor:pointer;flex:0 0 auto;display:inline-flex;align-items:center;gap:.3rem;padding:0 .6rem;font-weight:600;}
.convo-call:hover{background:#dbe4f0;}
.convo-call.joinable{background:#dcfce7;color:#15803d;}
.convo-call.joinable:hover{background:#bbf7d0;}
.convo-call span{font-size:.84rem;}
.call-on{display:inline-flex;align-items:center;gap:.3rem;color:#15803d;font-weight:600;}
.call-invite{position:fixed;right:18px;bottom:18px;z-index:6000;display:flex;align-items:center;gap:.7rem;background:#fff;border:1px solid var(--line);border-left:4px solid #15803d;border-radius:14px;padding:.7rem .9rem;box-shadow:0 12px 30px rgba(20,30,60,.25);max-width:340px;}
.call-invite .ci-ico{width:38px;height:38px;border-radius:50%;background:#dcfce7;color:#15803d;display:grid;place-items:center;flex:0 0 auto;}
.call-invite .ci-txt{font-size:.88rem;color:var(--ink);line-height:1.25;}
.call-invite .ci-join{border:none;background:#15803d;color:#fff;border-radius:9px;padding:.45rem .7rem;font-weight:700;cursor:pointer;display:inline-flex;align-items:center;gap:.3rem;flex:0 0 auto;}
.call-invite .ci-join:hover{background:#13703a;}
.call-invite .ci-x{border:none;background:transparent;color:var(--muted);cursor:pointer;display:grid;place-items:center;flex:0 0 auto;}
.call-invite .ci-x:hover{color:var(--red);}
.call-invite .ci-decline{border:none;background:#fee2e2;color:#b91c1c;border-radius:9px;padding:.45rem .7rem;font-weight:700;cursor:pointer;display:inline-flex;align-items:center;gap:.3rem;flex:0 0 auto;}
.call-invite .ci-decline:hover{background:#fecaca;}
.convo-info:hover{background:#dbe6fb;}
/* group info (Slack-style) */
.modal.gi{max-width:400px;}
.gi-head{display:flex;align-items:center;gap:.7rem;margin-bottom:.6rem;}
.gi-name{flex:1;min-width:0;}
.gi-name input{width:100%;border:none;border-bottom:2px solid transparent;font-size:1.1rem;font-weight:700;color:var(--ink);padding:.1rem 0;background:transparent;}
.gi-name input:focus{outline:none;border-bottom-color:var(--brand);}
.gi-sub{font-size:.78rem;color:var(--muted);}
.gi-name-row{display:flex;align-items:center;gap:.4rem;}
.gi-title{font-size:1.1rem;font-weight:700;color:var(--ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.iconbtn.sm{width:24px;height:24px;}
.gi-edit{display:flex;align-items:center;gap:.3rem;}
.gi-edit input{flex:1;min-width:0;border:2px solid var(--line);border-radius:8px;padding:.3rem .5rem;font-size:1rem;font-weight:600;background:#fbfcfe;color:var(--ink);}
.gi-edit input:focus{outline:none;border-color:var(--brand);}
.gi-actions{display:flex;gap:.6rem;margin:.2rem 0 .7rem;}
.gi-search{position:relative;margin-bottom:.5rem;}
.gi-search input{width:100%;border:1px solid var(--line);border-radius:9px;padding:.45rem 2rem .45rem .6rem;font-size:.88rem;background:#fbfcfe;color:var(--ink);box-sizing:border-box;}
.gi-search input:focus{outline:none;border-color:var(--blue);}
.gi-noresult{text-align:center;color:var(--muted);font-size:.84rem;padding:.7rem 0;}
.gi-created{font-size:.78rem;color:var(--muted);margin:0 0 .7rem;line-height:1.45;}
.gi-created svg{vertical-align:-2px;margin-right:.25rem;}
.gi-created b{color:var(--ink);font-weight:600;}
.gi-setting{display:flex;align-items:center;justify-content:space-between;gap:.5rem;font-size:.84rem;color:var(--ink);background:#f6f8fb;border:1px solid var(--line);border-radius:9px;padding:.5rem .7rem;margin:0 0 .7rem;cursor:pointer;}
.gi-setting span{display:inline-flex;align-items:center;gap:.4rem;}
.switch{position:relative;display:inline-block;width:40px;height:22px;flex:0 0 auto;}
.switch input{opacity:0;width:0;height:0;position:absolute;margin:0;}
.switch .slider{position:absolute;inset:0;background:#cbd2dd;border-radius:99px;transition:background .15s;cursor:pointer;}
.switch .slider::before{content:"";position:absolute;width:18px;height:18px;left:2px;top:2px;background:#fff;border-radius:50%;transition:transform .15s;box-shadow:0 1px 2px rgba(0,0,0,.25);}
.switch input:checked + .slider{background:var(--blue);}
.switch input:checked + .slider::before{transform:translateX(18px);}
.seenby{display:block;margin-top:.2rem;border:none;background:transparent;color:var(--muted);font-size:.68rem;cursor:pointer;padding:0;display:inline-flex;align-items:center;gap:.25rem;}
.seenby:hover{color:var(--blue);}
.bubble.mine .seenby{color:rgba(255,255,255,.8);}
.bubble.mine .seenby:hover{color:#fff;}
.gi-act{flex:1;border:1px solid var(--line);background:var(--card);border-radius:10px;padding:.6rem;font-weight:600;color:var(--blue);cursor:pointer;display:flex;align-items:center;justify-content:center;gap:.4rem;}
.gi-act:hover{background:var(--blue-soft);border-color:#c7d6f0;}
.gi-open{cursor:pointer;}
.sched-wrap{width:100%;margin:1.2rem 0 0;text-align:left;}
.sched-sec{margin-bottom:1.4rem;}
.sched-h{font-size:.74rem;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);margin:0 0 .55rem;font-weight:700;}
.sched-list{display:grid;grid-template-columns:repeat(auto-fill,minmax(330px,1fr));gap:.7rem;}
.sched-item{display:flex;align-items:flex-start;gap:.6rem;background:var(--card);border:1px solid var(--line);border-radius:12px;padding:.8rem .9rem;}
.sched-item.live{border-color:#34d399;background:#f0fdf4;}
.sched-item.cancelled{opacity:.7;}
.sched-item.cancelled .si-title{text-decoration:line-through;text-decoration-color:var(--muted);}
.cancel-tag{font-size:.62rem;font-weight:700;background:#fee2e2;color:#b91c1c;padding:.05rem .4rem;border-radius:99px;text-decoration:none;}
/* Schedule form: custom date/time pickers, recurring day chips, inline error highlight */
.sch-row{display:flex;gap:.6rem;flex-wrap:wrap;}
.sch-row > div{flex:1 1 140px;min-width:0;}
.picker-field{position:relative;}
.pick-btn{display:flex;align-items:center;gap:.45rem;width:100%;text-align:left;cursor:pointer;color:var(--ink);}
.pick-btn svg{color:var(--blue);flex:0 0 auto;}
.pick-pop{position:absolute;z-index:60;top:100%;left:0;margin-top:.3rem;background:#fff;border:1px solid var(--line);border-radius:14px;box-shadow:0 14px 36px rgba(20,30,60,.22);padding:.6rem;width:280px;max-width:84vw;}
.pick-pop.time-pop{display:grid;grid-template-columns:repeat(3,1fr);gap:.35rem;max-height:240px;overflow:auto;width:240px;}
.pick-pop.hidden{display:none !important;} /* must beat .pick-pop.time-pop's display:grid */
.cal-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:.4rem;font-size:.92rem;color:var(--ink);}
.cal-nav{border:none;background:var(--blue-soft);color:var(--blue);width:30px;height:30px;border-radius:8px;cursor:pointer;display:grid;place-items:center;}
.cal-nav:disabled{opacity:.35;cursor:default;}
.cal-grid{display:grid;grid-template-columns:repeat(7,1fr);gap:2px;}
.cal-dow{text-align:center;font-size:.66rem;color:var(--muted);font-weight:700;padding:.2rem 0;}
.cal-day{border:none;background:transparent;color:var(--ink);height:34px;border-radius:9px;cursor:pointer;font-size:.84rem;}
.cal-day:hover:not(:disabled){background:var(--blue-soft);}
.cal-day.today{outline:1px solid var(--blue);}
.cal-day.sel{background:var(--blue);color:#fff;font-weight:700;}
.cal-day:disabled{color:#cbd2dc;cursor:default;}
.time-chip{border:1px solid var(--line);background:var(--card);color:var(--ink);border-radius:8px;padding:.4rem .2rem;font-size:.78rem;cursor:pointer;}
.time-chip:hover{background:var(--blue-soft);}
.time-chip.sel{background:var(--blue);color:#fff;border-color:var(--blue);font-weight:700;}
.switch-row{display:flex;align-items:center;justify-content:space-between;background:#f6f8fb;border:1px solid var(--line);border-radius:10px;padding:.55rem .8rem;margin:.8rem 0 .4rem;}
.switch-row > span:first-child{display:flex;align-items:center;gap:.45rem;font-size:.88rem;color:var(--ink);}
.sch-days{display:flex;flex-wrap:wrap;align-items:center;gap:.4rem;margin:.3rem 0 .2rem;}
.day-chip{width:34px;height:34px;border:1px solid var(--line);background:var(--card);color:var(--ink);border-radius:50%;font-size:.78rem;font-weight:700;cursor:pointer;display:grid;place-items:center;}
.day-chip.on{background:var(--blue);color:#fff;border-color:var(--blue);}
.day-all{margin-left:auto;border:1px solid var(--line);background:var(--card);color:var(--blue);border-radius:99px;padding:.32rem .7rem;font-size:.78rem;font-weight:700;cursor:pointer;}
.si-actions .iconbtn{width:28px;height:28px;border-radius:7px;}
.si-actions .iconbtn.edit{color:var(--blue);} .si-actions .iconbtn.edit:hover{background:var(--blue-soft);}
.si-actions .iconbtn.cancel-ic{color:#b91c1c;} .si-actions .iconbtn.cancel-ic:hover{background:#fee2e2;}
.finput.field-err,.pick-btn.field-err{border-color:#dc2626 !important;box-shadow:0 0 0 2px rgba(220,38,38,.18);}
.si-main{flex:1;min-width:0;}
.si-title{font-weight:700;color:var(--ink);display:flex;align-items:center;gap:.5rem;}
.si-meta{font-size:.8rem;color:var(--muted);margin-top:.15rem;}
.si-desc{font-size:.84rem;color:var(--ink);margin-top:.35rem;opacity:.85;}
.livedot{color:#059669;font-size:.72rem;font-weight:700;}
.si-actions{display:flex;align-items:center;gap:.25rem;flex:0 0 auto;}
.btn.sm{padding:.28rem .7rem;font-size:.8rem;border-radius:7px;line-height:1.2;}
.btn.join{background:var(--blue);color:#fff;border:none;cursor:pointer;}
.btn.sm.cancel{background:#fee2e2;color:#b91c1c;border:1px solid #fecaca;}
.btn.sm.cancel:hover{background:#fecaca;}
.modal.sched{max-width:440px;}
.flbl{display:block;font-size:.78rem;font-weight:600;color:var(--ink);margin:.6rem 0 .25rem;}
.flbl .opt{color:var(--muted);font-weight:400;}
.finput{width:100%;border:1px solid var(--line);border-radius:9px;padding:.55rem .65rem;font-size:.92rem;font-family:inherit;background:#fbfcfe;color:var(--ink);box-sizing:border-box;}
.finput:focus{outline:none;border-color:var(--blue);}
.iconbtn{border:none;background:transparent;color:var(--muted);cursor:pointer;width:30px;height:30px;border-radius:8px;display:grid;place-items:center;flex:0 0 auto;}
.iconbtn:hover{background:#f1f5f9;color:var(--blue);}
.iconbtn.rm:hover{color:var(--red);background:#fee2e2;}
.gi-save{width:100%;background:var(--brand);color:var(--ink);margin-bottom:.6rem;}
.gi-sec-h{display:flex;align-items:center;justify-content:space-between;font-size:.74rem;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);margin:.5rem 0 .3rem;}
.gobtn{border:none;border-radius:11px;padding:.7rem 1rem;font-weight:700;font-size:.92rem;cursor:pointer;background:var(--blue);color:#fff;transition:filter .12s;}
.gobtn:hover{filter:brightness(1.08);}
.linkbtn{border:1px solid var(--line);background:var(--card);color:var(--blue);cursor:pointer;font-size:.8rem;font-weight:600;display:inline-flex;align-items:center;gap:.35rem;padding:.35rem .7rem;border-radius:8px;text-transform:none;letter-spacing:0;}
.linkbtn:hover{background:var(--blue-soft);border-color:#c7d6f0;}
.gi-list{display:flex;flex-direction:column;gap:.1rem;max-height:240px;overflow-y:auto;}
.mrow{display:flex;align-items:center;gap:.6rem;padding:.35rem .3rem;border-radius:8px;font-size:.92rem;}
.mrow:hover{background:#f6f8fb;}
.mrow .mn{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.mrow .iconbtn{opacity:0;}
.mrow:hover .iconbtn{opacity:1;}
.mini-av{position:relative;overflow:hidden;width:30px;height:30px;flex:0 0 30px;border-radius:50%;display:grid;place-items:center;color:#fff;font-weight:700;font-size:.72rem;}
.mini-av .av-img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;}
.gi-photo{position:relative;border:none;background:transparent;padding:0;cursor:pointer;flex:0 0 auto;}
.gi-photo .avatar.grp{flex:0 0 46px;}
.gi-photo-cam{position:absolute;right:-4px;bottom:-4px;width:22px;height:22px;border-radius:50%;background:var(--blue);color:#fff;display:grid;place-items:center;border:2px solid var(--card);box-shadow:0 1px 3px rgba(0,0,0,.22);}
.gi-photo-cam .ic{width:12px;height:12px;}
.gi-photo:hover .gi-photo-cam{filter:brightness(1.12);}
.youtag{font-size:.62rem;background:var(--blue-soft);color:var(--blue);padding:.05rem .35rem;border-radius:99px;margin-left:.3rem;vertical-align:middle;text-transform:uppercase;letter-spacing:.03em;}
.gi-add{margin-top:.4rem;border:1px solid var(--line);border-radius:10px;padding:.5rem;}
.gi-add .chk{display:flex;align-items:center;gap:.5rem;padding:.25rem;border-radius:6px;cursor:pointer;}
.gi-add .chk:hover{background:#f6f8fb;}
.gi-leave{width:100%;margin-top:.9rem;border:1px solid #fecaca;background:#fff;color:var(--red);border-radius:10px;padding:.55rem;font-weight:600;cursor:pointer;display:flex;align-items:center;justify-content:center;gap:.4rem;}
.gi-leave:hover{background:#fee2e2;}
.grp-members{max-height:220px;overflow-y:auto;border:1px solid var(--line);border-radius:10px;padding:.5rem .6rem;display:flex;flex-direction:column;gap:.4rem;}
.modal .chk{display:flex;align-items:center;gap:.5rem;font-size:.9rem;cursor:pointer;}
.modal .chk input{width:16px;height:16px;accent-color:var(--blue);margin:0;}
.modal-actions{display:flex;gap:.6rem;margin-top:1rem;}
.modal-actions .gobtn{flex:1;border:none;border-radius:10px;padding:.6rem;font-weight:700;cursor:pointer;}
/* ---- Login (shown on /home when logged out) ---- */
.authwrap{flex:1 1 auto;display:none;align-items:center;justify-content:center;padding:1.5rem;min-height:0;}
.authcard{background:var(--card);border:1px solid var(--line);border-radius:16px;padding:2rem;max-width:400px;width:100%;box-shadow:0 10px 30px rgba(20,30,60,.08);}
.authcard h1{font-size:1.3rem;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;}
.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:var(--bg);z-index:9000;color:var(--muted);font-size:.9rem;}
.toast{position:fixed;left:50%;bottom:1.6rem;transform:translateX(-50%) translateY(1rem);background:var(--blue);color:#fff;padding:.7rem 1.2rem;border-radius:10px;font-size:.88rem;box-shadow:0 10px 28px rgba(0,0,0,.22);opacity:0;pointer-events:none;transition:opacity .2s,transform .2s;z-index:9500;}
.toast.show{opacity:1;transform:translateX(-50%) translateY(0);}
/* Hamburger menu button (header) */
.navtoggle{background:transparent;border:none;color:#fff;cursor:pointer;display:grid;place-items:center;width:38px;height:38px;border-radius:9px;}
.navtoggle:hover{background:rgba(255,255,255,.14);}
/* Desktop: collapse the rail to enlarge content */
body.rail-hidden .rail{display:none;}
.rail-backdrop{display:none;position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:1100;}
@media (max-width:900px){
.chatcol{width:280px;flex:0 0 280px;}
}
/* ---- Mobile / tablet ---- */
@media (max-width:760px){
header{padding:.5rem .8rem;}
.brand{font-size:.98rem;}
.navtoggle{display:none;} .rail-backdrop{display:none!important;} /* bottom nav replaces the drawer */
/* App-style bottom navigation bar */
.rail{position:fixed;left:0;right:0;bottom:0;top:auto;width:auto;height:60px;flex-direction:row;justify-content:space-around;align-items:center;gap:0;padding:0 .3rem env(safe-area-inset-bottom,0) .3rem;border-right:none;border-top:1px solid var(--line);box-shadow:0 -3px 14px rgba(20,30,60,.08);transform:none!important;z-index:1200;}
.rail .rail-spacer{display:none;}
.railbtn{display:flex;flex-direction:column;justify-content:center;gap:2px;width:62px;height:50px;border-radius:12px;}
.railbtn .rlabel{display:block;font-size:.66rem;font-weight:700;line-height:1;}
.railbtn svg{width:20px;height:20px;}
.railbtn::after,.railbtn::before{display:none;}
.railbtn:not(.active){color:#334155;background:transparent;}
.railbtn.active{background:var(--blue-soft);color:var(--blue);}
/* Meeting room code: always fully visible, full-width, easy to read */
.meet-bar .code{flex:1 1 100%;font-size:.9rem;white-space:normal;word-break:break-word;text-align:center;background:var(--blue-soft);border-radius:8px;padding:.35rem .5rem;}
/* Chat: one pane at a time (list, then the open conversation) */
.chatcol{width:100%;flex:1 1 100%;padding-bottom:60px;}
body.chat-open .chatcol{display:none;}
.content{display:none;}
body.chat-open .content, .shell:not(.is-chat) .content{display:block;}
.shell:not(.is-chat) .chatcol{display:none;}
.content .panel{bottom:60px;} /* keep panel content above the bottom nav */
/* Bigger touch targets */
.chat-row{padding:.7rem .85rem;}
.convo-head{padding:.7rem .85rem;}
.modal{width:94vw;}
/* Meeting + embedded panels full-width, controls above the nav */
.meet-grid{grid-template-columns:repeat(auto-fit,minmax(150px,1fr));}
.meet-bar{flex-wrap:wrap;gap:.4rem;padding:.5rem .6rem;}
.meet-bar .code{flex:1 1 100%;margin:0 0 .2rem;}
.meet-bar .meet-ic{width:44px;height:44px;}
.meet-panel{width:90vw;max-width:none;right:5vw;top:6px;bottom:74px;}
.md-top{flex-direction:column;}
/* Code+Join on a full-width row; Start & Schedule as two equal halves below */
.md-actions{width:100%;display:grid;grid-template-columns:1fr 1fr;gap:.5rem;}
.md-join{grid-column:1 / -1;}
.md-actions > .btn{width:100%;justify-content:center;}
.md-join{flex:1 1 100%;}
.call-invite{left:8px;right:8px;max-width:none;bottom:70px;}
.bell-menu{position:fixed;left:8px;right:8px;top:58px;width:auto;}
/* Touch devices have no hover: keep member-row actions (make-admin / remove) always visible */
.mrow .iconbtn{opacity:1;}
}
</style>
</head>
<body>
<script src="/icons.js?v=3"></script>
<script>window.__BUILD='2026-06-24-push1';console.log('%cBizGaze Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD);</script>
<div class="loading" id="loading">Loading…</div>
<header>
<div class="brandrow">
<button class="navtoggle" id="navToggle" title="Toggle menu" aria-label="Toggle menu"><svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg></button>
<img src="/logo.png" alt="" style="height:46px;width:auto;max-width:190px;border-radius:8px;object-fit:contain;background:#fff;padding:5px 12px;image-rendering:-webkit-optimize-contrast" onerror="this.replaceWith(Object.assign(document.createElement('div'),{className:'logo',textContent:'B'}))">
<div class="brand">BizGaze <span class="y">Connect</span></div>
</div>
<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>
<button class="newchat" id="newChat" title="New chat" aria-label="New chat">+</button>
</div>
<div class="search">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input id="chatSearch" placeholder="Search chats" autocomplete="off">
<button class="search-x" id="chatSearchX" title="Clear" aria-label="Clear" style="display:none"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg></button>
</div>
</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>
<div class="toast" id="toast"></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; 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';}
const AV_COLORS=['#1F3B73','#2563eb','#0e7490','#7c3aed','#be185d','#b45309','#15803d','#9d174d'];
function avColor(name){let h=0;for(const c of String(name))h=(h*31+c.charCodeAt(0))>>>0;return AV_COLORS[h%AV_COLORS.length];}
let toastTimer=null;
function toast(msg){const t=document.getElementById('toast');t.textContent=msg;t.classList.add('show');clearTimeout(toastTimer);toastTimer=setTimeout(()=>t.classList.remove('show'),2600);}
// ---------- 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()">':'';
return '<div class="profile"><button class="pbtn icon-only" id="pbtn" title="'+pEsc(display)+'">'
+ '<span class="pav">'+pEsc(initials(display))+img+'</span></button>'
+ '<div class="pmenu" id="pmenu">'
+ '<div class="phead"><div class="n">'+pEsc(display)+'</div><div class="e">'+pEsc(u.email)+(u.role?' · '+pEsc(u.role):'')+'</div></div>'
+ '<a href="/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();menu.classList.toggle('open');};
document.addEventListener('click',()=>menu.classList.remove('open'));
const lo=document.getElementById('plogout');
if(lo)lo.onclick=async()=>{try{await fetch('/api/logout',{method:'POST'});}catch(_){}location.href='/';};
const ps=document.getElementById('psettings');
if(ps)ps.onclick=()=>{ menu.classList.remove('open'); openSettings(); };
}
// ---------- Notification bell (activity center) ----------
// Captures things that otherwise have no home: scheduled-meeting invites, reminders, reactions to
// your messages, new polls, and new chat requests. Stored per user in this browser.
let NOTIFS=[];
function notifKey(){ return 'notifs_'+((ME&&ME.id)||''); }
function loadNotifs(){ try{ NOTIFS=JSON.parse(localStorage.getItem(notifKey())||'[]'); }catch(_){ NOTIFS=[]; } if(!Array.isArray(NOTIFS)) NOTIFS=[]; updateBellBadge(); }
function saveNotifs(){ try{ NOTIFS=NOTIFS.slice(0,50); localStorage.setItem(notifKey(), JSON.stringify(NOTIFS)); }catch(_){} }
function addNotif(n){ NOTIFS.unshift(Object.assign({ id:'n'+Date.now()+'_'+Math.round((window.performance&&performance.now())||Math.random()*1e6), ts:Date.now(), read:false }, n)); saveNotifs(); updateBellBadge(); const menu=document.getElementById('bellMenu'); if(menu&&menu.classList.contains('open')) renderBell(); }
function bellHTML(){ return '<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 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 granted=('Notification' in window) && Notification.permission==='granted';
ov.innerHTML='<div class="modal sched"><div class="gi-head" style="margin-bottom:.6rem"><div class="avatar grp" style="width:40px;height:40px;flex:0 0 40px;background:var(--blue)">'+ic('settings',20)+'</div>'
+'<div class="gi-name"><div class="gi-title">Settings</div><div class="gi-sub">Notifications</div></div>'
+'<button class="iconbtn" id="setClose" title="Close">'+ic('x',18)+'</button></div>'
+sw('setGroup','Group message notifications', notifOn('group'))
+sw('setDm','Direct message notifications', notifOn('dm'))
+'<label class="gi-setting"><span>Browser/desktop pop-ups</span><button class="btn sm" id="setPerm"'+(granted?' disabled':'')+'>'+(granted?'Enabled':'Enable')+'</button></label>'
+'<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 setPref=(k,v)=>{ try{ localStorage.setItem('notif_'+k, v?'on':'off'); }catch(_){} };
ov.querySelector('#setGroup').onchange=e=>setPref('group', e.target.checked);
ov.querySelector('#setDm').onchange=e=>setPref('dm', e.target.checked);
const perm=ov.querySelector('#setPerm'); if(perm) perm.onclick=async()=>{ try{ const r=await Notification.requestPermission(); if(r==='granted'){ perm.textContent='Enabled'; perm.disabled=true; toast('Desktop notifications enabled'); try{ await subscribePush(); }catch(_){} } else toast('Notifications blocked — allow them in your browser site settings'); }catch(_){ toast('Notifications need HTTPS or localhost'); } };
}
// ---------- Chat (1:1 + groups) ----------
let ME={};
let CONTACTS=[]; // team users (for new DMs / picking group members)
let ROWS=[]; // sidebar items: {kind:'dm'|'group', id, name, online?, members?, last_body, last_at, last_from_me, unread}
let selected=null; // {kind,id} or null = welcome
let convoIsGroup=false; // the open thread is a group (drives per-message sender labels)
let THREAD=[];
const THREAD_CACHE=new Map(); // key 'kind:id' -> messages[] ; lets a notification click render synchronously (paints immediately)
let convoMembers=[]; // members of the currently open group (for @mentions + highlight)
let composeMentions=new Map();// token ('@Name' | 'everyone') -> userId | 'everyone' for the draft
const rendered=new Set();
const listEl=document.getElementById('chatlist');
function searchVal(){ const s=document.getElementById('chatSearch'); return s?s.value:''; }
function rowFor(kind,id){ return ROWS.find(r=>r.kind===kind && r.id===id); }
function currentName(){ const it=selected&&rowFor(selected.kind,selected.id); return it?it.name:''; }
function fmtTime(ts){
if(!ts) return '';
const d=new Date(ts), n=new Date();
if(d.toDateString()===n.toDateString()) return d.toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'});
const y=new Date(n); y.setDate(n.getDate()-1);
if(d.toDateString()===y.toDateString()) return 'Yesterday';
return d.toLocaleDateString([], {month:'short',day:'numeric'});
}
function avatarHTML(it, big){
const isG=it.kind==='group';
const sz=big?'width:38px;height:38px;flex:0 0 38px;':'';
const corner=isG?(it.members?'<span class="mcount">'+(it.members>99?'99+':it.members)+'</span>':'')
:'<span class="dot'+(it.online?' on':'')+'"></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>';
}
function rowHTML(it){
const active=selected&&selected.kind===it.kind&&selected.id===it.id;
const cls=['chat-row']; if(active)cls.push('active'); if(it.unread>0)cls.push('unread');
const isG=it.kind==='group';
const preview=it.last_body?((it.last_from_me?'You: ':'')+it.last_body):(isG?((it.members||0)+' members'):'No messages yet');
return '<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">'+(it.callActive?'<span class="call-on">'+ic('phone',12)+' Ongoing call</span>':pEsc(preview))+'</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 = rows.length ? rows.map(rowHTML).join('')
: '<div class="no-results">'+(ROWS.length?('No chats match “'+pEsc(filter)+'”.'):'No conversations yet.')+'</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 ext=(Array.isArray(list)?list:[]).filter(p=>!(p.email&&haveEmail.has(p.email.toLowerCase()))); // hide dups of team contacts
if(!ext.length){ box.innerHTML=''; return; }
box.innerHTML='<div class="side-sec">On BizGaze</div>'+ext.map((p,i)=>{ const sub=[p.org,p.phone].filter(Boolean).join(' · ');
return '<div class="dir-row'+(p.onConnect?'':' ext')+'" 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>'+(p.onConnect?'':'<span class="dr-tag">Not on Connect</span>')+'</div>'; }).join('');
box.querySelectorAll('.dir-row').forEach(row=>{ const p=ext[+row.dataset.i]; row.onclick=()=>{ if(p.onConnect&&p.connectId) selectChat('dm', p.connectId); else toast(pEsc(p.name||'This person')+' is on BizGaze but hasnt joined Connect yet — theyll be reachable once they sign in.'); }; });
}, 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';
}
async function loadSidebar(){
let convos=[], contacts=[];
try{ [convos, contacts]=await Promise.all([ fetch('/api/messages/conversations').then(r=>r.json()), fetch('/api/messages/contacts').then(r=>r.json()) ]); }catch(_){}
CONTACTS=Array.isArray(contacts)?contacts:[];
const items=Array.isArray(convos)?convos.slice():[];
const dmIds=new Set(items.filter(i=>i.kind==='dm').map(i=>i.id));
for(const c of CONTACTS){ if(!dmIds.has(c.id)) items.push({ kind:'dm', id:c.id, name:c.name, online:!!c.online, last_body:'', last_at:0, last_from_me:false, unread:0 }); }
const onlineById={}; CONTACTS.forEach(c=>onlineById[c.id]=!!c.online);
items.forEach(it=>{ if(it.kind==='dm') it.online=!!onlineById[it.id]; });
ROWS=items;
renderChats(searchVal());
updateRailUnread();
}
// ----- conversation view -----
function welcomeHTML(){
return '<div class="welcome">'
+ '<div class="wave">👋</div>'
+ '<h1>Hi, '+pEsc(firstName(ME.name||ME.email))+'! Welcome to BizGaze 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); }); }
function convoShellHTML(it){
const isG=it.kind==='group';
const sub=isG?((it.members||0)+' members'):(it.online?'Online':'Offline');
return '<div class="convo">'
+ '<div class="convo-head">'
+ '<button class="convo-back" id="convoBack" title="Back (Esc)" aria-label="Back">'+ic('arrowLeft',18)+'</button>'
+ avatarHTML(it,true)
+ '<div class="convo-titlewrap'+(isG?' gi-open" id="convoTitle" title="Group info"':'"')+'><div class="nm">'+pEsc(it.name)+'</div><div class="st">'+pEsc(sub)+'</div></div>'
+ '<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>'
+ '<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>'
+ '<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" 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" 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>';
}
// 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>';
if(m.system||m.from==='__system__') return '<div class="sys-msg">'+pEsc(m.body)+'</div>';
const mine=m.from===ME.id;
const sender=(convoIsGroup && !mine && m.fromName)?'<div class="sender">'+senderAvatar(m.from, m.fromName)+'<span>'+pEsc(m.fromName)+'</span></div>':'';
let quote='';
if(m.reply){ const t=replyTint(m.reply.from||m.reply.fromName); quote='<div class="quote" style="background:'+t[0]+';border-left-color:'+t[1]+'"><b style="color:'+t[1]+'">'+pEsc(m.reply.fromName||'')+'</b>: '+pEsc(m.reply.body)+'</div>'; }
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 att = m.attachment ? (m.attachment.isImage
? '<img class="att-img" src="/files/'+pEsc(m.attachment.id)+'" data-img="/files/'+pEsc(m.attachment.id)+'" alt="'+pEsc(m.attachment.name)+'" title="Click to view">'
: '<a class="att-file" href="/files/'+pEsc(m.attachment.id)+'" download="'+pEsc(m.attachment.name)+'">'+ic('file',15)+' <span>'+pEsc(m.attachment.name)+'</span> <span class="att-sz">'+fmtSize(m.attachment.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 blue).
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 "Seen by …" on my own messages.
let seen='';
if(mine && convoIsGroup && m.id===_lastMineId && Array.isArray(m.seenBy) && m.seenBy.length){ const ns=m.seenBy, head=ns.slice(0,3).join(', '), more=ns.length>3?(' +'+(ns.length-3)+' more'):''; seen='<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 + quote + att + renderMsgBody(m) + pollHTML(m)
+ '<button class="reply-btn" data-id="'+pEsc(m.id)+'" title="Reply">'+ic('reply',14)+'</button>'
+ '<button class="react-btn" data-id="'+pEsc(m.id)+'" title="React">'+ic('smilePlus',14)+'</button>'
+ '<span class="t">'+pEsc(fmtClock(m.created_at))+rcpt+'</span>'
+ reacts + seen + '</div>';
}
// reply + emoji state/helpers
let replyTarget=null;
// Categorized emoji set (covers the common ones used across Slack/Teams/WhatsApp).
const EMOJI_CATS=[
{ icon:'😀', list:'😀 😃 😄 😁 😆 😅 🤣 😂 🙂 🙃 🫠 😉 😊 😇 🥰 😍 🤩 😘 😗 😚 😙 🥲 😋 😛 😜 🤪 😝 🤑 🤗 🤭 🫢 🫣 🤫 🤔 🫡 🤐 🤨 😐 😑 😶 🫥 😏 😒 🙄 😬 🤥 😌 😔 😪 🤤 😴 😷 🤒 🤕 🤢 🤮 🤧 🥵 🥶 🥴 😵 🤯 🤠 🥳 🥸 😎 🤓 🧐 😕 🫤 😟 🙁 ☹️ 😮 😯 😲 😳 🥺 🥹 😦 😧 😨 😰 😥 😢 😭 😱 😖 😣 😞 😓 😩 😫 🥱 😤 😡 😠 🤬 😈 👿 💀 ☠️ 💩 🤡 👹 👺 👻 👽 👾 🤖 😺 😸 😹 😻 😼 😽 🙀 😿 😾' },
{ icon:'👍', list:'👋 🤚 🖐️ ✋ 🖖 🫱 🫲 🫳 🫴 👌 🤌 🤏 ✌️ 🤞 🫰 🤟 🤘 🤙 👈 👉 👆 🖕 👇 ☝️ 🫵 👍 👎 ✊ 👊 🤛 🤜 👏 🙌 🫶 👐 🤲 🤝 🙏 ✍️ 💅 🤳 💪 🦾 🦵 🦶 👣 👀 👁️ 🫦 👄 🫀 🫁 🧠 🦷 🦴 👶 🧒 👦 👧 🧑 👨 👩 🧔 👱 🧓 👴 👵 🙇 💁 🙅 🙆 🙋 🤦 🤷 👮 🕵️ 💂 👷 🤴 👸 👳 🧕 🤵 👰 🤰 🤱 👼 🎅 🤶 🦸 🦹 🧙 🧚 🧛 🧜 🧝 🧞 🧟 💆 💇 🚶 🧍 🧎 🏃 💃 🕺 👯 🧘' },
{ icon:'❤️', list:'❤️ 🧡 💛 💚 💙 💜 🖤 🤍 🤎 💔 ❣️ 💕 💞 💓 💗 💖 💘 💝 💟 ❤️‍🔥 ❤️‍🩹 💋 💯 💢 💥 💫 💦 💨 💬 🗨️ 🗯️ 💭 💤 ♥️ ✨ ⭐ 🌟 ⚡ 🔥 🌈 ☀️ ⛅ ☁️ 🌧️ ⛈️ 🌩️ ❄️ ☃️ ⛄ 💧 🌊' },
{ icon:'🐻', list:'🐶 🐱 🐭 🐹 🐰 🦊 🐻 🐼 🐻‍❄️ 🐨 🐯 🦁 🐮 🐷 🐽 🐸 🐵 🙈 🙉 🙊 🐒 🐔 🐧 🐦 🐤 🐣 🐥 🦆 🦅 🦉 🦇 🐺 🐗 🐴 🦄 🐝 🐛 🦋 🐌 🐞 🐜 🪰 🪲 🦗 🕷️ 🦂 🐢 🐍 🦎 🦖 🦕 🐙 🦑 🦐 🦞 🦀 🐡 🐠 🐟 🐬 🐳 🐋 🦈 🐊 🐅 🐆 🦓 🦍 🦧 🐘 🦛 🦏 🐪 🐫 🦒 🦘 🐃 🐄 🐎 🐖 🐏 🐑 🐐 🦌 🐕 🐩 🦮 🐈 🐓 🦃 🦚 🦜 🦢 🕊️ 🐇 🦝 🦨 🦦 🦥 🐁 🐀 🐿️ 🦔 🐾 🐉 🌵 🎄 🌲 🌳 🌴 🌱 🌿 ☘️ 🍀 🍃 🍂 🍁 🍄 🐚 🌾 💐 🌷 🌹 🥀 🌺 🌸 🌼 🌻' },
{ icon:'🍔', list:'🍏 🍎 🍐 🍊 🍋 🍌 🍉 🍇 🍓 🫐 🍈 🍒 🍑 🥭 🍍 🥥 🥝 🍅 🍆 🥑 🥦 🥬 🥒 🌶️ 🫑 🌽 🥕 🫒 🧄 🧅 🥔 🍠 🥐 🥯 🍞 🥖 🥨 🧀 🥚 🍳 🧈 🥞 🧇 🥓 🥩 🍗 🍖 🌭 🍔 🍟 🍕 🫓 🥪 🥙 🧆 🌮 🌯 🫔 🥗 🥘 🫕 🥫 🍝 🍜 🍲 🍛 🍣 🍱 🥟 🦪 🍤 🍙 🍚 🍘 🍥 🥠 🥮 🍢 🍡 🍧 🍨 🍦 🥧 🧁 🍰 🎂 🍮 🍭 🍬 🍫 🍿 🍩 🍪 🌰 🥜 🍯 🥛 🍼 ☕ 🍵 🧃 🥤 🧋 🍶 🍺 🍻 🥂 🍷 🥃 🍸 🍹 🧉 🍾' },
{ icon:'⚽', list:'⚽ 🏀 🏈 ⚾ 🥎 🎾 🏐 🏉 🥏 🎱 🪀 🏓 🏸 🏒 🏑 🥍 🏏 🥅 ⛳ 🪁 🏹 🎣 🤿 🥊 🥋 🎽 🛹 🛼 🛷 ⛸️ 🥌 🎿 ⛷️ 🏂 🪂 🏋️ 🤼 🤸 ⛹️ 🤺 🤾 🏌️ 🏇 🧘 🏄 🏊 🤽 🚣 🧗 🚵 🚴 🏆 🥇 🥈 🥉 🏅 🎖️ 🏵️ 🎗️ 🎫 🎟️ 🎪 🤹 🎭 🩰 🎨 🎬 🎤 🎧 🎼 🎹 🥁 🪘 🎷 🎺 🪗 🎸 🪕 🎻 🎲 ♟️ 🎯 🎳 🎮 🎰 🧩' },
{ icon:'🚗', list:'🚗 🚕 🚙 🚌 🚎 🏎️ 🚓 🚑 🚒 🚐 🛻 🚚 🚛 🚜 🛴 🚲 🛵 🏍️ 🛺 🚨 🚔 🚍 🚘 🚖 🚡 🚠 🚟 🚃 🚋 🚞 🚝 🚄 🚅 🚈 🚂 🚆 🚇 🚊 🚉 ✈️ 🛫 🛬 🛩️ 💺 🛰️ 🚀 🛸 🚁 🛶 ⛵ 🚤 🛥️ 🛳️ ⛴️ 🚢 ⚓ ⛽ 🚧 🚦 🚥 🗺️ 🗿 🗽 🗼 🏰 🏯 🏟️ 🎡 🎢 🎠 ⛲ ⛱️ 🏖️ 🏝️ 🏜️ 🌋 ⛰️ 🏔️ 🗻 🏕️ ⛺ 🏠 🏡 🏘️ 🏗️ 🏭 🏢 🏬 🏣 🏥 🏦 🏨 🏪 🏫 ⛪ 🕌 🛕 🕋 🌁 🌃 🏙️ 🌅 🌆 🌉 🌌 🎇 🎆 🌠' },
{ icon:'💡', list:'⌚ 📱 📲 💻 ⌨️ 🖥️ 🖨️ 🖱️ 🕹️ 💽 💾 💿 📀 📼 📷 📸 📹 🎥 📽️ 🎞️ 📞 ☎️ 📟 📠 📺 📻 🎙️ 🎚️ 🎛️ 🧭 ⏱️ ⏲️ ⏰ 🕰️ ⌛ ⏳ 📡 🔋 🔌 💡 🔦 🕯️ 🪔 🧯 💸 💵 💴 💶 💷 🪙 💰 💳 💎 ⚖️ 🪜 🧰 🪛 🔧 🔨 ⚒️ 🛠️ ⛏️ 🔩 ⚙️ 🧱 ⛓️ 🧲 🔫 💣 🧨 🪓 🔪 🗡️ ⚔️ 🛡️ 🚬 ⚰️ ⚱️ 🏺 🔮 📿 🧿 💈 ⚗️ 🔭 🔬 💊 💉 🩸 🧬 🦠 🧫 🧪 🌡️ 🧹 🧺 🧻 🚽 🚿 🛁 🧼 🪥 🧽 🪣 🧴 🛎️ 🔑 🗝️ 🚪 🪑 🛋️ 🛏️ 🧸 🖼️ 🛍️ 🛒 🎁 🎈 🎏 🎀 🪄 🎊 🎉 🏮 🎐 🧧 ✉️ 📩 📨 📧 💌 📦 🏷️ 📜 📄 📊 📈 📉 📅 📋 📁 📂 🗂️ 📰 📓 📔 📕 📗 📘 📙 📚 📖 🔖 🔗 📎 📐 📏 🧮 📌 📍 ✂️ 🖊️ 🖋️ ✒️ 🖌️ 🖍️ 📝 ✏️ 🔍 🔎 🔒 🔓' },
{ icon:'✅', list:'✅ ❌ ⭕ 🚫 ❓ ❔ ❗ ❕ ‼️ ⁉️ 💯 🔅 🔆 ⚠️ 🚸 ☢️ ☣️ ⬆️ ↗️ ➡️ ↘️ ⬇️ ↙️ ⬅️ ↖️ ↕️ ↔️ ↩️ ↪️ ⤴️ ⤵️ 🔃 🔄 🔙 🔚 🔛 🔜 🔝 🛐 ⚛️ 🕉️ ✡️ ☸️ ☯️ ✝️ ☦️ ☪️ ☮️ 🕎 🔯 ♈ ♉ ♊ ♋ ♌ ♍ ♎ ♏ ♐ ♑ ♒ ♓ ⛎ 🔀 🔁 🔂 ▶️ ⏩ ⏭️ ◀️ ⏪ ⏮️ 🔼 ⏫ 🔽 ⏬ ⏸️ ⏹️ ⏺️ ⏏️ ♀️ ♂️ ⚧️ ✖️ ➕ ➖ ➗ ♾️ 💲 💱 ™️ ©️ ®️ 〰️ ➰ ➿ ✔️ ☑️ 🔘 🔴 🟠 🟡 🟢 🔵 🟣 🟤 ⚫ ⚪ 🟥 🟧 🟨 🟩 🟦 🟪 🟫 ⬛ ⬜ 🔶 🔷 🔸 🔹 🔺 🔻 💠 🔳 🔲' },
{ icon:'🏁', list:'🏁 🚩 🎌 🏴 🏳️ 🏳️‍🌈 🏳️‍⚧️ 🏴‍☠️ 🇮🇳 🇺🇸 🇬🇧 🇨🇦 🇦🇺 🇩🇪 🇫🇷 🇮🇹 🇪🇸 🇯🇵 🇰🇷 🇨🇳 🇧🇷 🇷🇺 🇿🇦 🇦🇪 🇸🇬 🇲🇾 🇮🇩 🇵🇭 🇹🇭 🇳🇿 🇮🇪 🇳🇱 🇸🇪 🇨🇭' },
];
let emojiCat=0, emojiMode='compose';
function setReply(m){
replyTarget=m;
const bar=document.getElementById('replyBar'); if(!bar) return;
const who=m.from===ME.id?'yourself':(m.fromName||currentName()||'them');
bar.innerHTML='<span>'+ic('reply',13)+' Replying to <b>'+pEsc(who)+'</b>: '+pEsc(m.body.length>80?m.body.slice(0,80)+'…':m.body)+'</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=''; } }
// attachments
let pendingAttach=null;
function fmtSize(b){ b=+b||0; if(b<1024) return b+' B'; if(b<1048576) return Math.round(b/1024)+' KB'; return (b/1048576).toFixed(1)+' MB'; }
async function uploadFile(file){
if(!file) return;
if(file.size>25*1024*1024){ toast('File too large (max 25 MB)'); return; }
showAttachPending(file.name, true);
try{
const r=await fetch('/api/messages/upload',{ method:'POST', headers:{ 'Content-Type':file.type||'application/octet-stream', 'X-Filename':encodeURIComponent(file.name) }, body:file });
const d=await r.json(); if(!r.ok) throw new Error(d.error||'upload failed');
pendingAttach=d; showAttachPending(d.name,false);
const inp=document.getElementById('msgInput'); if(inp) inp.focus();
}catch(e){ pendingAttach=null; hideAttach(); toast(e.message||'Upload failed'); }
}
function showAttachPending(name, uploading){
const bar=document.getElementById('attachBar'); if(!bar) return;
const a=pendingAttach;
const isImg=a && /^image\//.test(a.mime||'');
const lead=(!uploading && isImg)
? '<img class="ap-thumb" src="/files/'+pEsc(a.id)+'" alt="">'
: '<span class="ap-ic">'+ic(uploading?'paperclip':(isImg?'camera':'file'),18)+'</span>';
bar.innerHTML='<div class="ap-item">'+lead+'<span class="ap-name">'+pEsc(name)+(uploading?' · uploading…':'')+'</span>'+(uploading?'':'<button type="button" class="ap-x" id="attachCancel" title="Remove">'+ic('x',15)+'</button>')+'</div>';
bar.style.display='block';
const x=document.getElementById('attachCancel'); if(x) x.onclick=()=>{ pendingAttach=null; hideAttach(); };
}
function hideAttach(){ const bar=document.getElementById('attachBar'); if(bar){ bar.style.display='none'; bar.innerHTML=''; } const fi=document.getElementById('fileInput'); if(fi) fi.value=''; }
// Paste an image from the clipboard (e.g. a screenshot) straight into the composer.
function onPaste(e){
const items=(e.clipboardData&&e.clipboardData.items)||[];
for(const it of items){
if(it.type&&it.type.indexOf('image')===0){
const blob=it.getAsFile();
if(blob){ e.preventDefault(); const ext=(blob.type.split('/')[1]||'png'); uploadFile(new File([blob],'screenshot-'+Date.now()+'.'+ext,{type:blob.type})); return; }
}
}
}
// Close the emoji picker when clicking anywhere outside it (or its button).
document.addEventListener('click',(e)=>{
if(!emojiOpen) return;
const pop=document.getElementById('emojiPop'), btn=document.getElementById('emojiBtn');
if(pop && !pop.contains(e.target) && !(btn&&btn.contains(e.target)) && !e.target.closest('.react-btn')) closeEmoji();
});
let emojiOpen=false;
function openEmoji(mode, anchorEl){ emojiMode=mode||'compose'; const pop=document.getElementById('emojiPop'); if(!pop) return; emojiOpen=true; renderEmojiPop(pop); pop.style.display='flex'; positionEmojiPop(pop, anchorEl); }
// Anchor the picker at its trigger: above the composer emoji icon, or at the message you reacted to.
function positionEmojiPop(pop, anchorEl){
const parent=pop.offsetParent || pop.parentElement; if(!parent){ return; }
const pr=parent.getBoundingClientRect(); const popW=pop.offsetWidth||330, popH=pop.offsetHeight||300;
let left, top;
if(anchorEl && anchorEl.getBoundingClientRect){ const r=anchorEl.getBoundingClientRect(); left=r.left-pr.left; top=r.top-pr.top-popH-8; }
else { left=12; top=pr.height-popH-64; }
left=Math.max(8, Math.min(left, pr.width-popW-8));
top=Math.max(8, Math.min(top, pr.height-popH-8));
pop.style.left=left+'px'; pop.style.top=top+'px'; pop.style.bottom='auto';
}
function closeEmoji(){ const pop=document.getElementById('emojiPop'); if(pop) pop.style.display='none'; emojiOpen=false; }
function renderEmojiPop(pop){
pop.innerHTML='<div class="emoji-tabs">'+EMOJI_CATS.map((c,i)=>'<button type="button" data-i="'+i+'" class="'+(i===emojiCat?'active':'')+'">'+c.icon+'</button>').join('')+'</div><div class="emoji-grid" id="emojiGrid"></div>';
pop.querySelectorAll('.emoji-tabs button').forEach(b=>b.onclick=()=>{ emojiCat=+b.dataset.i; renderEmojiPop(pop); });
const grid=pop.querySelector('#emojiGrid');
grid.innerHTML=EMOJI_CATS[emojiCat].list.trim().split(/\s+/).map(e=>'<button type="button">'+e+'</button>').join('');
grid.querySelectorAll('button').forEach(b=>b.onclick=()=>onEmojiPick(b.textContent));
}
function onEmojiPick(e){ if(emojiMode && emojiMode.react){ reactToMessage(emojiMode.react, e); closeEmoji(); } else { insertEmoji(e); } }
function openEmojiForReact(messageId, anchorEl){ openEmoji({ react: messageId }, anchorEl); }
function insertEmoji(e){
const inp=document.getElementById('msgInput'); if(!inp) return;
const s=inp.selectionStart??inp.value.length, en=inp.selectionEnd??inp.value.length;
inp.value=inp.value.slice(0,s)+e+inp.value.slice(en);
const np=s+e.length; inp.focus(); try{ inp.setSelectionRange(np,np); }catch(_){}
}
// reactions
function applyReaction(m, emoji, userId, added){
m.reactions=m.reactions||[];
let r=m.reactions.find(x=>x.emoji===emoji);
if(added){ if(!r){ r={emoji,count:0,mine:false}; m.reactions.push(r); } r.count++; if(userId===ME.id) r.mine=true; }
else if(r){ r.count--; if(userId===ME.id) r.mine=false; if(r.count<=0) m.reactions=m.reactions.filter(x=>x.emoji!==emoji); }
}
async function reactToMessage(messageId, emoji){
const m=THREAD.find(x=>x.id===messageId);
try{ const r=await postJSON('/api/messages/react',{ messageId, emoji }); if(m){ m.reactions=r.reactions||[]; updateBubble(m); } }catch(_){}
}
function showMeetingCancelled(mtg){ if(!mtg) return; try{ playPing(); }catch(_){}
const w=mtg.when?(' on '+mtg.when):'';
addNotif({icon:'calendarX', text:pEsc(mtg.by||'Someone')+' cancelled “'+pEsc(mtg.title||'a meeting')+'”'+pEsc(w), link:{kind:'meeting'}});
try{ notify('❌ Meeting cancelled', (mtg.by||'Someone')+' cancelled '+(mtg.title||'a meeting')+w); }catch(_){}
toast('❌ “'+(mtg.title||'Meeting')+'”'+w+' was cancelled'); if(currentTab()==='meeting') loadScheduledMeetings(); }
function onGroupRole(d){ if(!d||!d.group) return; toast(d.admin?'You are now an admin of this group':'You are no longer an admin'); const gi=document.getElementById('groupInfo'); if(gi){ gi.remove(); if(selected&&selected.kind==='group'&&selected.id===d.group) openGroupInfo(d.group); } }
// A group's membership changed → refresh the sidebar (member count) live; refresh open group-info.
function onGroupUpdate(d){ if(!d||!d.group) return;
loadSidebar().then(()=>{ if(selected&&selected.kind==='group'&&selected.id===d.group){ if(d.removed){ showWelcome(); toast('You were removed from the group'); } else openConvo('group', d.group); } });
const gi=document.getElementById('groupInfo'); if(gi){ gi.remove(); if(!d.removed && selected&&selected.kind==='group'&&selected.id===d.group) openGroupInfo(d.group); } // refresh open group-info
}
function onChatReaction(d){ const m=THREAD.find(x=>x.id===d.messageId); if(m && d.reactions){ m.reactions=d.reactions; updateBubble(m); }
if(d.added && d.owner===ME.id && d.byId && d.byId!==ME.id){ addNotif({icon:'smilePlus', text:pEsc(d.by||'Someone')+' reacted '+(d.emoji||'')+' to your message', link:d.convId?{kind:'group',id:d.convId}:{kind:'dm',id:d.byId}}); }
}
// Read receipts (DM): the other party read my messages → mark mine as seen.
function onChatRead(d){ if(!d||!d.by) return; if(selected && selected.kind==='dm' && selected.id===d.by){ let changed=false; THREAD.forEach(m=>{ if(m.from===ME.id && !m.read_at){ m.read_at=Date.now(); changed=true; } }); if(changed) renderThread(); } }
// DM delivered: recipient's client acked → second (grey) tick.
function onChatDelivered(d){ if(!d||!d.id) return; const m=THREAD.find(x=>x.id===d.id); if(m && !m.delivered_at){ m.delivered_at=Date.now(); updateBubble(m); } }
// Group read: a member opened the group → add them to "Seen by" on my messages up to that time.
function onGroupRead(d){ if(!d||!d.group) return; if(selected && selected.kind==='group' && selected.id===d.group){ THREAD.forEach(m=>{ if(m.from===ME.id && m.created_at<=d.at){ m.seenBy=m.seenBy||[]; if(d.byName && !m.seenBy.includes(d.byName)){ m.seenBy.push(d.byName); updateBubble(m); } } }); } }
// Shared group call: start it (or join the live one — the server returns the existing room).
async function startOrJoinGroupCall(group){
try{ const r=await postJSON('/api/groups/call/start',{ group }); if(r&&r.room){ meetReturn={kind:'group',id:group}; switchTab('meeting'); enterMeeting(r.room); } }
catch(e){ toast(e.message||'Could not start the call'); }
}
function updateCallBtn(active){ const cc=document.getElementById('convoCall'); if(!cc) return; cc.classList.toggle('joinable',active); cc.title=active?'Join call':'Start call'; cc.innerHTML=ic(active?'video':'phone',18)+(active?'<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(selected&&selected.kind==='group'&&selected.id===d.group) updateCallBtn(!!d.active);
if(d.active && d.by && d.by!==ME.id && meetRoom!==d.room) showCallInvite(d.room, d.startedByName, {kind:'group',id:d.group}, d.groupName||(it&&it.name)||'Group'); // ring members in
if(!d.active) dismissCallInvite(d.room); // call ended — stop ringing
renderChats(searchVal());
}
function dismissCallInvite(room){ if(!room) return; const el=document.getElementById('ci-'+room); if(el){ try{ el.remove(); }catch(_){} stopRing(); } }
// 1:1 call: start/join from the DM header; live state updates the button + shows an incoming invite.
async function startOrJoinDmCall(otherId){
try{ const r=await postJSON('/api/calls/dm/start',{ to:otherId }); if(r&&r.room){ meetReturn={kind:'dm',id:otherId}; switchTab('meeting'); enterMeeting(r.room); } }
catch(e){ toast(e.message||'Could not start the call'); }
}
function onDmCall(d){
if(!d) return; const it=rowFor('dm', d.with); if(it){ it.callActive=!!d.active; it.callRoom=d.room||null; }
if(selected&&selected.kind==='dm'&&selected.id===d.with) updateCallBtn(!!d.active);
if(d.active && d.by && d.by!==ME.id) showCallInvite(d.room, d.byName, {kind:'dm',id:d.with}); // incoming 1:1 call
if(!d.active) dismissCallInvite(d.room); // call ended/declined — stop ringing
renderChats(searchVal());
}
// Incoming-call banner (1:1 call or an add-participant invite) with Join / Dismiss.
// ret: where to land when the call ends (the originating chat), or null for the meetings tab.
// sub: a second line under the caller — e.g. the group name for a group call.
function showCallInvite(room, byName, ret, sub){
if(!room || document.getElementById('ci-'+room)) return;
startRing();
const who=byName||'Someone';
const line2 = sub ? ('is calling · '+pEsc(sub)) : 'is calling you';
const el=document.createElement('div'); el.className='call-invite'; el.id='ci-'+room;
el.innerHTML='<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);
try{ notify('📞 '+who, (sub?('Group call · '+sub):'is calling you'), ret&&ret.kind, ret&&ret.id); }catch(_){} // OS notification too
let closed=false;
const close=()=>{ if(closed) return; closed=true; try{ el.remove(); }catch(_){} stopRing(); };
el.querySelector('.ci-join').onclick=()=>{ close(); meetReturn=ret||null; switchTab('meeting'); enterMeeting(room); };
el.querySelector('.ci-decline').onclick=()=>{ close(); if(ret&&ret.kind==='dm'){ postJSON('/api/calls/decline',{room}).catch(()=>{}); } }; // 1:1 → notify caller; group → silent
setTimeout(close, 45000);
}
// Scheduled-meeting invitation (toast + the meeting shows up in their list).
function showMeetingInvite(mtg){ if(!mtg) return; try{ playPing(); }catch(_){} toast('📅 '+(mtg.by||'Someone')+' invited you to “'+mtg.title+'”'+(mtg.whenText?(' · '+mtg.whenText):''));
addNotif({icon:'calendar', text:pEsc(mtg.by||'Someone')+' invited you to “'+pEsc(mtg.title||'a meeting')+'”'+(mtg.whenText?(' · '+pEsc(mtg.whenText)):''), link:{kind:'meeting', code:mtg.room}});
try{ notify('📅 Meeting invite', (mtg.by||'Someone')+' invited you to '+(mtg.title||'a meeting')); }catch(_){}
if(currentTab()==='meeting') loadScheduledMeetings(); }
// 10-minute reminder before a scheduled meeting — prominent banner with Join.
function showMeetingReminder(mtg){ if(!mtg||!mtg.room) return; try{ playPing(); }catch(_){}
addNotif({icon:'bell', text:'“'+pEsc(mtg.title||'A meeting')+'” starts in ~10 minutes', link:{kind:'meeting', code:mtg.room}});
try{ notify('⏰ Meeting reminder', (mtg.title||'A meeting')+' starts in ~10 minutes'); }catch(_){}
if(document.getElementById('mr-'+mtg.id)) return;
const el=document.createElement('div'); el.className='call-invite'; el.id='mr-'+mtg.id;
el.innerHTML='<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; }
function refreshMeetPanel(){ if(document.getElementById('meetPanel')) renderMeetPanel(); }
let meetPanelTab='people'; // 'people' | 'add'
let _addPool=null; // cached candidates for "Add people" (group members for a group call)
function toggleMeetPanel(){ const ex=document.getElementById('meetPanel'); if(ex){ ex.remove(); return; } const meet=document.querySelector('.meet'); if(!meet) return; meetPanelTab='people'; const p=document.createElement('div'); p.id='meetPanel'; p.className='meet-panel'; meet.appendChild(p); renderMeetPanel(); }
function renderMeetPanel(){
const p=document.getElementById('meetPanel'); if(!p) return;
const list=meetParticipantsList();
const isHostRow=pp=> pp.id==='__local' ? meetIsHost : pp.id===meetHostId; // YOUR row uses meetIsHost
const head='<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>';
const tabs='<div class="mp-tabs"><button class="mp-tab'+(meetPanelTab==='people'?' on':'')+'" data-tab="people">In call ('+list.length+')</button><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=[]; }); }
const pool = inGroup ? (_addPool||[]) : (CONTACTS||[]);
const here=new Set(list.map(pp=>(pp.name||'').replace(/\s*\(you\)$/,'').trim().toLowerCase()));
const myName=((ME&&ME.name)||(ME&&ME.email)||'').trim().toLowerCase();
const avail=pool.filter(c=>c.id!==(ME&&ME.id) && (c.name||'').trim().toLowerCase()!==myName && !here.has((c.name||'').trim().toLowerCase()));
body='<div class="mp-list">'+(avail.length?avail.map(c=>'<label class="chk"><input type="checkbox" value="'+pEsc(c.id)+'"><span class="mini-av" style="background:'+avColor(c.name)+'">'+pEsc(initials(c.name))+'</span><span class="mn">'+pEsc(c.name)+'</span></label>').join(''):'<div class="gi-noresult">'+(inGroup&&_addPool===null?'Loading…':'Everyone\'s already here')+'</div>')+'</div><button class="gobtn" id="mpInvite" style="margin:.5rem">Invite to call</button>';
} else {
body='<div class="mp-list">'+list.map(pp=>'<div class="mp-row"><span class="mini-av" style="background:'+avColor(pp.name)+'">'+pEsc(initials(pp.name))+'</span><span class="mn">'+pEsc(pp.name)+'</span>'+(isHostRow(pp)?'<span class="host-tag">'+ic('crown',11)+' Host</span>':'')+((pp.id==='__local'?meetScreen:meetSharers.has(pp.id))?'<span class="pp-screen" title="Sharing screen">'+ic('monitor',13)+'</span>':'')+(meetMuted.get(pp.id)?'<span class="pp-mute">'+ic('micOff',13)+'</span>':'')+((meetIsHost&&pp.id!=='__local'&&!isHostRow(pp))?'<button class="mp-makehost" data-id="'+pEsc(pp.id)+'" title="Make host">'+ic('crown',14)+'</button>':'')+'</div>').join('')+'</div>'
+(meetIsHost?'<label class="gi-setting mp-setting"><span>'+ic('monitor',15)+' Allow multiple people to share</span><span class="switch"><input type="checkbox" id="mpMulti"'+(meetMultiShare?' checked':'')+'><span class="slider"></span></span></label>':'');
}
p.innerHTML=head+tabs+body;
const x=p.querySelector('.mp-x'); if(x) x.onclick=()=>p.remove();
p.querySelectorAll('.mp-tab').forEach(b=>b.onclick=()=>{ meetPanelTab=b.dataset.tab; renderMeetPanel(); });
const ma=p.querySelector('.mp-muteall'); if(ma) ma.onclick=()=>{ meetSend({type:'meeting-muteall'}); toast('Muted everyone'); };
const mm=p.querySelector('#mpMulti'); if(mm) mm.onchange=()=>{ meetMultiShare=mm.checked; meetSend({type:'meeting-sharemode', multi:meetMultiShare}); };
p.querySelectorAll('.mp-makehost').forEach(b=>b.onclick=()=>{ meetSend({type:'meeting-host', to:b.dataset.id}); meetHostId=b.dataset.id; meetIsHost=false; renderMeetPanel(); });
const inv=p.querySelector('#mpInvite'); if(inv) inv.onclick=async()=>{ const ids=[...p.querySelectorAll('input:checked')].map(i=>i.value); if(!ids.length){ toast('Pick people to invite'); return; } try{ const r=await postJSON('/api/calls/invite',{ room:meetRoom, userIds:ids }); toast('Invited '+r.invited+(r.invited===1?' person':' people')); meetPanelTab='people'; renderMeetPanel(); }catch(e){ toast(e.message||'Could not invite'); } };
}
// Add people to the current call (from the in-call bar).
function openInvitePicker(room){
if(!room || document.getElementById('invModal')) return;
const ov=document.createElement('div'); ov.className='modal-ov'; ov.id='invModal';
ov.innerHTML='<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>'
+'<div class="gi-list" style="max-height:46vh;overflow:auto">'+(CONTACTS.length?CONTACTS.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 contacts available</div>')+'</div>'
+'<button class="gobtn" id="invBtn" style="width:100%;margin-top:.7rem">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'); } };
}
// In-chat image viewer (lightbox): open on click, close on ✕ / backdrop / Esc.
function openLightbox(src){
if(document.getElementById('lightbox')) return;
const ov=document.createElement('div'); ov.className='lightbox'; ov.id='lightbox';
ov.innerHTML='<button class="lb-close" title="Close (Esc)">'+ic('x',22)+'</button><img src="'+pEsc(src)+'" alt=""><a class="lb-dl" href="'+pEsc(src)+'" download title="Download">'+ic('download',20)+'</a>';
document.body.appendChild(ov);
const close=()=>{ ov.remove(); document.removeEventListener('keydown', onKey); };
const onKey=(e)=>{ if(e.key==='Escape'){ e.preventDefault(); close(); } };
ov.addEventListener('click',(e)=>{ if(e.target===ov || e.target.closest('.lb-close')) close(); });
document.addEventListener('keydown', onKey);
}
function updateBubble(m){
const sel=(window.CSS&&CSS.escape)?CSS.escape(m.id):m.id;
const el=document.querySelector('#msgs .bubble[data-id="'+sel+'"]');
if(el) el.outerHTML=bubbleHTML(m);
}
// Floating date pill (updates to the day at the top of the viewport) + jump-to-latest button.
function onMsgsScroll(){
const box=document.getElementById('msgs'); if(!box) return;
const nearBottom=(box.scrollHeight - box.scrollTop - box.clientHeight) < 120;
const jl=document.getElementById('jumpLatest'); if(jl) jl.style.display=nearBottom?'none':'grid';
const fd=document.getElementById('floatDate'); if(!fd) return;
if(nearBottom){ fd.style.display='none'; return; }
const boxTop=box.getBoundingClientRect().top; let label='';
box.querySelectorAll('.day-sep').forEach(s=>{ if(s.getBoundingClientRect().top - boxTop <= 10) label=s.textContent; });
if(label){ fd.style.top=(box.offsetTop+6)+'px'; fd.textContent=label; fd.style.display='block'; } else fd.style.display='none';
}
function dayKey(ts){ return new Date(ts||Date.now()).toDateString(); }
function dayLabel(ts){ const d=new Date(ts||Date.now()), n=new Date(); if(d.toDateString()===n.toDateString()) return 'Today'; const y=new Date(n); y.setDate(n.getDate()-1); if(d.toDateString()===y.toDateString()) return 'Yesterday'; return d.toLocaleDateString([], {weekday:'long', month:'long', day:'numeric', year:'numeric'}); }
let _lastDay='', _lastMineId=''; // _lastMineId: only my newest message shows the group "Seen by"
function lastMineId(){ for(let i=THREAD.length-1;i>=0;i--){ if(THREAD[i].from===ME.id && !THREAD[i].system) return THREAD[i].id; } return ''; }
function renderThread(){
const box=document.getElementById('msgs'); if(!box) return;
rendered.clear(); _lastDay=''; _lastMineId=lastMineId();
if(!THREAD.length){ box.innerHTML='<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; box.scrollTop=box.scrollHeight;
}
function appendBubble(m){
if(rendered.has(m.id)) return; rendered.add(m.id);
const box=document.getElementById('msgs'); if(!box) return;
const empty=box.querySelector('.empty-thread'); if(empty) empty.remove();
// A new message of mine becomes the newest: drop the "Seen by" from the previous one.
if(m.from===ME.id && !m.system){ if(_lastMineId){ const pe=box.querySelector('.bubble[data-id="'+((window.CSS&&CSS.escape)?CSS.escape(_lastMineId):_lastMineId)+'"] .seenby'); if(pe) pe.remove(); } _lastMineId=m.id; }
const dk=dayKey(m.created_at); if(dk!==_lastDay){ box.insertAdjacentHTML('beforeend', '<div class="day-sep"><span>'+pEsc(dayLabel(m.created_at))+'</span></div>'); _lastDay=dk; }
box.insertAdjacentHTML('beforeend', bubbleHTML(m));
box.scrollTop=box.scrollHeight;
}
async function openConvo(kind,id){
const it=rowFor(kind,id)||{kind,id,name:'Conversation'};
convoIsGroup=(kind==='group');
const el=document.getElementById('chatPanel'); el.classList.remove('center');
el.innerHTML=convoShellHTML(it);
const back=document.getElementById('convoBack'); if(back) back.onclick=showWelcome;
const form=document.getElementById('composer'); if(form) form.addEventListener('submit',(e)=>{ e.preventDefault(); sendMessage(); });
clearReply(); pendingAttach=null; hideAttach();
const ab2=document.getElementById('attachBtn'); if(ab2) ab2.onclick=()=>{ const fi=document.getElementById('fileInput'); if(fi) fi.click(); };
const fi=document.getElementById('fileInput'); if(fi) fi.onchange=()=>{ if(fi.files&&fi.files[0]) uploadFile(fi.files[0]); };
const eb=document.getElementById('emojiBtn'); if(eb) eb.onclick=(e)=>{ e.stopPropagation(); emojiOpen?closeEmoji():openEmoji('compose', eb); };
const fb=document.getElementById('fmtBtn'); if(fb) fb.onclick=()=>{ const bar=document.getElementById('fmtBar'); if(bar) bar.style.display=bar.style.display==='none'?'flex':'none'; };
const fbar=document.getElementById('fmtBar'); if(fbar) fbar.querySelectorAll('button[data-fmt]').forEach(b=>b.onclick=()=>applyFmt(b.dataset.fmt));
const pb=document.getElementById('pollBtn'); if(pb) pb.onclick=()=>openPollModal(id);
const inpEl=document.getElementById('msgInput');
if(inpEl){
inpEl.addEventListener('paste', onPaste);
inpEl.addEventListener('input', ()=>autoGrow(inpEl));
inpEl.addEventListener('keydown', (e)=>{ if(e.key==='Enter' && !e.shiftKey){ if(mentionItems && mentionItems.length) return; e.preventDefault(); sendMessage(); } }); // Enter sends, Shift+Enter = newline
}
const ci=document.getElementById('convoInfo'); if(ci) ci.onclick=()=>openGroupInfo(id);
const ct=document.getElementById('convoTitle'); if(ct) ct.onclick=()=>openGroupInfo(id);
const cc=document.getElementById('convoCall'); if(cc) cc.onclick=()=>(kind==='group'?startOrJoinGroupCall(id):startOrJoinDmCall(id));
const box=document.getElementById('msgs'); if(box) box.addEventListener('click',(e)=>{
const im=e.target.closest('.att-img'); if(im && im.dataset.img){ openLightbox(im.dataset.img); return; }
const po=e.target.closest('.poll-opt'); if(po){ if(!po.disabled) votePoll(po.dataset.poll, +po.dataset.idx); return; }
const pcl=e.target.closest('.poll-close'); if(pcl){ closePoll(pcl.dataset.poll); return; }
const rb=e.target.closest('.reply-btn'); if(rb){ const mm=THREAD.find(x=>x.id===rb.dataset.id); if(mm) setReply(mm); return; }
const ab=e.target.closest('.react-btn'); if(ab){ openEmojiForReact(ab.dataset.id, ab); return; }
const ch=e.target.closest('.react-chip'); if(ch){ reactToMessage(ch.dataset.id, ch.dataset.emoji); return; }
const sb=e.target.closest('.seenby'); if(sb){ const ns=(sb.dataset.seen||'').split('|').filter(Boolean); toast('Seen by: '+ns.join(', ')); return; }
});
if(box) box.addEventListener('scroll', onMsgsScroll);
const jl=document.getElementById('jumpLatest'); if(jl) jl.onclick=()=>{ const b=document.getElementById('msgs'); if(b) b.scrollTop=b.scrollHeight; };
composeMentions=new Map(); convoMembers=[];
// Synchronous render from cache (within the click's activation → paints immediately, even
// when opened from a notification; an async-only render would defer the paint until a click).
const ckey=kind+':'+id;
if(THREAD_CACHE.has(ckey)){ THREAD=THREAD_CACHE.get(ckey).slice(); renderThread(); }
if(kind==='group'){ try{ convoMembers=await fetch('/api/groups/members?group='+encodeURIComponent(id)).then(r=>r.json())||[]; }catch(_){ convoMembers=[]; } }
if(!selected||selected.kind!==kind||selected.id!==id) return;
wireMentions();
const url=kind==='group'?('/api/messages/thread?group='+encodeURIComponent(id)):('/api/messages/thread?with='+encodeURIComponent(id));
let msgs=[]; try{ msgs=await fetch(url).then(r=>r.json()); }catch(_){}
if(!selected||selected.kind!==kind||selected.id!==id) return; // switched away while loading
THREAD=Array.isArray(msgs)?msgs:[];
THREAD_CACHE.set(ckey, THREAD.slice());
renderThread();
const inp=document.getElementById('msgInput'); if(inp) inp.focus();
}
// ---------- @mentions (group chat) ----------
let mentionItems=[], mentionIdx=0, mentionStart=-1;
function wireMentions(){
const inp=document.getElementById('msgInput'); if(!inp) return;
inp.addEventListener('input', onMentionInput);
inp.addEventListener('keydown', onMentionKey);
inp.addEventListener('blur', ()=>setTimeout(closeMention,150));
}
function onMentionInput(e){
const inp=e.target; if(!convoIsGroup){ closeMention(); return; }
const pos=inp.selectionStart;
const m=inp.value.slice(0,pos).match(/(?:^|\s)@([\p{L}\p{N}_]*)$/u);
if(!m){ closeMention(); return; }
mentionStart=pos-m[1].length-1;
const q=m[1].toLowerCase();
const opts=[];
if(!q||'everyone'.startsWith(q)||'all'.startsWith(q)) opts.push({id:'everyone',name:'everyone',sub:'Notify the whole group'});
for(const mem of convoMembers){ if(mem.id===ME.id) continue; if(!q||(mem.name||'').toLowerCase().includes(q)) opts.push({id:mem.id,name:mem.name,avatar:mem.avatar}); }
if(!opts.length){ closeMention(); return; }
mentionItems=opts.slice(0,8); mentionIdx=0; renderMentionPop();
}
function renderMentionPop(){
const pop=document.getElementById('mentionPop'); if(!pop) return;
pop.innerHTML=mentionItems.map((o,i)=>'<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){
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>.
function renderMsgBody(m){
const lines=pEsc(m.body||'').split('\n');
let out='', list=null; const buf=[], para=[];
const flushList=()=>{ if(list){ out+='<'+list+' class="msg-list">'+buf.map(x=>'<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();
selected={kind,id};
document.body.classList.add('chat-open'); // mobile: show the conversation pane
const it=rowFor(kind,id); if(it) it.unread=0;
renderChats(searchVal());
updateRailUnread();
await openConvo(kind,id);
}
async function sendMessage(){
const inp=document.getElementById('msgInput'); if(!inp) return;
const text=inp.value.trim(); if((!text&&!pendingAttach)||!selected) return;
inp.value=''; inp.style.height='auto';
const replyTo=replyTarget?replyTarget.id:null;
const attachmentId=pendingAttach?pendingAttach.id:null;
const payload = selected.kind==='group' ? { group:selected.id, body:text, replyTo, attachmentId, mentions:collectMentions(text) } : { to:selected.id, body:text, replyTo, attachmentId };
try{
const m=await postJSON('/api/messages', payload);
composeMentions=new Map();
THREAD.push(m); appendBubble(m); clearReply(); pendingAttach=null; hideAttach();
{ const ck=selected.kind+':'+selected.id; if(THREAD_CACHE.has(ck)){ const a=THREAD_CACHE.get(ck); if(!a.some(x=>x.id===m.id)) a.push(m); } }
const it=rowFor(selected.kind,selected.id);
if(it){ it.last_body=m.body||(m.attachment?'📎 '+(m.attachment.name||'Attachment'):''); it.last_at=m.created_at; it.last_from_me=true; it.unread=0; }
renderChats(searchVal());
}catch(e){ inp.value=text; toast(e.message||'Could not send'); }
}
function ensureNotifyPermission(){ try{ if('Notification' in window && Notification.permission==='default') Notification.requestPermission(); }catch(_){} }
// Notification preferences (set in Dashboard → Settings; stored per browser). Default ON.
function notifOn(kind){ try{ return localStorage.getItem('notif_'+kind)!=='off'; }catch(_){ return true; } }
document.addEventListener('click', ensureNotifyPermission, { once:true });
// ----- 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; }
async function setupPush(){
if(!('serviceWorker' in navigator)) return;
try{ _swReg=await navigator.serviceWorker.register('/sw.js'); }catch(_){ return; }
// 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(_){}
try{ await subscribePush(); }catch(_){}
}
async function subscribePush(){
if(!_swReg || !('PushManager' in window)) return;
if(!('Notification' in window) || Notification.permission!=='granted') return; // only once allowed
let cfg; try{ cfg=await fetch('/api/push/vapid').then(r=>r.json()); }catch(_){ return; }
if(!cfg || !cfg.enabled || !cfg.key) return; // server hasn't configured VAPID -> stay on in-page notifs
let sub=null; try{ sub=await _swReg.pushManager.getSubscription(); }catch(_){}
if(!sub){ try{ sub=await _swReg.pushManager.subscribe({ userVisibleOnly:true, applicationServerKey:urlB64ToUint8(cfg.key) }); }catch(_){ return; } }
try{ await postJSON('/api/push/subscribe', sub.toJSON()); pushActive=true; }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 notify(title, body, kind, id){
try{
if(!('Notification' in window) || Notification.permission!=='granted') return;
const n=new Notification(title, { body, icon:'/logo.png' });
n.onclick=()=>{ try{ window.focus(); }catch(_){} const u='/home?openKind='+encodeURIComponent(kind||'')+'&openId='+encodeURIComponent(id||''); n.close(); location.assign(u); };
setTimeout(()=>{ try{ n.close(); }catch(_){} }, 8000);
}catch(_){}
}
let _audioCtx=null;
function playPing(){
try{
_audioCtx=_audioCtx||new (window.AudioContext||window.webkitAudioContext)();
if(_audioCtx.state==='suspended') _audioCtx.resume();
const t=_audioCtx.currentTime, o=_audioCtx.createOscillator(), g=_audioCtx.createGain();
o.type='sine'; o.frequency.setValueAtTime(880,t); o.frequency.setValueAtTime(660,t+0.09);
g.gain.setValueAtTime(0.0001,t); g.gain.exponentialRampToValueAtTime(0.14,t+0.012); g.gain.exponentialRampToValueAtTime(0.0001,t+0.35);
o.connect(g); g.connect(_audioCtx.destination); o.start(t); o.stop(t+0.36);
}catch(_){}
}
// Continuous incoming-call ring. A soft, soothing bell chime — a gentle ascending arpeggio
// (C major) of pure sine tones with an octave shimmer and a long smooth decay, so it rings
// rather than buzzes. Ref-counted: several pending invites share one ring; it stops on the last close.
let _ringTimer=null, _ringRefs=0;
function ringTone(t, freq, dur, peak){
const g=_audioCtx.createGain(); g.connect(_audioCtx.destination);
g.gain.setValueAtTime(0.0001,t);
g.gain.exponentialRampToValueAtTime(peak,t+0.03); // gentle attack (no click)
g.gain.exponentialRampToValueAtTime(0.0001,t+dur); // long, smooth bell-like decay
const o=_audioCtx.createOscillator(); o.type='sine'; o.frequency.value=freq; o.connect(g); o.start(t); o.stop(t+dur);
const o2=_audioCtx.createOscillator(); o2.type='sine'; o2.frequency.value=freq*2; // octave shimmer
const g2=_audioCtx.createGain(); g2.gain.value=0.3; o2.connect(g2); g2.connect(g); o2.start(t); o2.stop(t+dur);
}
function ringOnce(){
try{
_audioCtx=_audioCtx||new (window.AudioContext||window.webkitAudioContext)();
if(_audioCtx.state==='suspended') _audioCtx.resume();
const t=_audioCtx.currentTime;
ringTone(t, 523.25, 1.2, 0.11); // C5
ringTone(t+0.20, 659.25, 1.2, 0.10); // E5
ringTone(t+0.40, 783.99, 1.5, 0.11); // G5
ringTone(t+0.60, 1046.5, 1.9, 0.09); // C6 — rings out softly
}catch(_){}
}
function startRing(){ _ringRefs++; if(_ringTimer) return; ringOnce(); _ringTimer=setInterval(ringOnce, 3500); }
function stopRing(force){ _ringRefs = force ? 0 : Math.max(0, _ringRefs-1); if(_ringRefs>0) return; if(_ringTimer){ clearInterval(_ringTimer); _ringTimer=null; } }
function onChatMessage(m){
const isGroupMsg=!!m.conversation_id;
const kind=isGroupMsg?'group':'dm';
const rid=isGroupMsg?m.conversation_id:(m.from===ME.id?m.to:m.from);
let it=rowFor(kind,rid); const wasNew=!it;
if(!it){ loadSidebar(); } // first DM / a new group we were added to — refresh the list
// Keep the thread cache warm so a notification click can render this chat synchronously.
const ckey=kind+':'+rid;
if(THREAD_CACHE.has(ckey)){ const arr=THREAD_CACHE.get(ckey); if(!arr.some(x=>x.id===m.id)) arr.push(m); }
else { const pu=(kind==='group'?'/api/messages/thread?group='+encodeURIComponent(rid):'/api/messages/thread?with='+encodeURIComponent(rid))+'&peek=1'; fetch(pu).then(r=>r.json()).then(a=>{ if(Array.isArray(a)) THREAD_CACHE.set(ckey,a); }).catch(()=>{}); }
const isOpen=selected&&selected.kind===kind&&selected.id===rid&&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)) notify((m.fromName||'New message'), m.body?(m.body.length>80?m.body.slice(0,80)+'…':m.body):'Sent an attachment', kind, rid); }
// Activity-center entries for things easy to miss.
if(m.poll) addNotif({icon:'barChart', text:pEsc(m.fromName||'Someone')+' created a poll'+(m.poll.question?': '+pEsc(m.poll.question):''), link:{kind, id:rid}});
else if(kind==='dm' && wasNew) addNotif({icon:'chat', text:'New chat from '+pEsc(m.fromName||'someone'), link:{kind:'dm', id:rid}});
}
if(it){
it.last_body=isSys?m.body:(m.body||(m.attachment?'📎 '+(m.attachment.name||'Attachment'):'')); it.last_at=m.created_at; it.last_from_me=(m.from===ME.id);
if(m.from!==ME.id && !isOpen && !isSys) it.unread=(it.unread||0)+1;
}
if(isOpen){
// 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)){
toast((m.fromName||'New message')+': '+(m.body?(m.body.length>60?m.body.slice(0,60)+'…':m.body):'📎 Attachment'));
}
renderChats(searchVal()); updateRailUnread();
}
let chatWs=null;
function connectChatWs(){
try{
chatWs=new WebSocket((location.protocol==='https:'?'wss://':'ws://')+location.host+'/ws');
chatWs.onopen=()=>{ try{ chatWs.send(JSON.stringify({type:'chat-hello'})); }catch(_){} };
chatWs.onmessage=(e)=>{ let d; try{ d=JSON.parse(e.data); }catch(_){ return; } if(d.type==='chat-message' && d.message) onChatMessage(d.message); else if(d.type==='chat-reaction') onChatReaction(d); else if(d.type==='poll-update' && d.poll) onPollUpdate(d); else if(d.type==='chat-read') onChatRead(d); else if(d.type==='chat-delivered') onChatDelivered(d); else if(d.type==='group-read') onGroupRead(d); else if(d.type==='group-call') onGroupCall(d); else if(d.type==='dm-call') onDmCall(d); else if(d.type==='group-update') onGroupUpdate(d); else if(d.type==='call-invite') showCallInvite(d.room, d.byName); else if(d.type==='meeting-invite') showMeetingInvite(d.meeting); else if(d.type==='meeting-reminder') showMeetingReminder(d.meeting); else if(d.type==='meeting-cancelled') showMeetingCancelled(d.meeting); else if(d.type==='group-role') onGroupRole(d); };
chatWs.onclose=()=>{ setTimeout(connectChatWs, 3000); }; // auto-reconnect
}catch(_){}
}
function renderChatPanel(){
const el=document.getElementById('chatPanel');
if(!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">'
+ '<div class="grp-members">'+(CONTACTS.length?CONTACTS.map(c=>'<label class="chk"><input type="checkbox" value="'+pEsc(c.id)+'"> '+pEsc(c.name)+'</label>').join(''):'<div class="muted" style="padding:.5rem">No teammates to add yet.</div>')+'</div>'
+ '<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 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="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>Members</span>'+(canManage?'<button class="linkbtn" id="giAddToggle">'+ic('userPlus',15)+' Add people</button>':'')+'</div>'
+ '<div class="gi-list">'+info.members.map(m=>'<div class="mrow">'+miniAv(m.name,m.avatar)+'<span class="mn">'+pEsc(m.name)+(m.isMe?' <span class="youtag">you</span>':'')+(m.admin?' <span class="admin-tag">'+ic('crown',11)+' Admin</span>':'')+'</span>'+((info.isAdmin&&!m.isMe)?'<button class="iconbtn role'+(m.admin?' is-admin':'')+'" data-role="'+pEsc(m.id)+'" data-val="'+(m.admin?'0':'1')+'" title="'+(m.admin?'Remove admin':'Make admin')+'">'+ic('crown',15)+'</button>':'')+((canManage&&!m.isMe)?'<button class="iconbtn rm" data-rm="'+pEsc(m.id)+'" title="Remove">'+ic('trash',15)+'</button>':'')+'</div>').join('')+'</div>'
+ '<div class="gi-add hidden" id="giAddWrap">'
+ (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);
ov.onclick=(e)=>{ if(e.target===ov) ov.remove(); };
ov.querySelector('#giClose').onclick=()=>ov.remove();
const refresh=async()=>{ await loadSidebar(); if(selected&&selected.kind==='group'&&selected.id===gid) openConvo('group',gid); };
const view=ov.querySelector('#giView'), editRow=ov.querySelector('#giEditRow'), nameInp=ov.querySelector('#giName'), closeBtn=ov.querySelector('#giClose');
document.getElementById('giEdit').onclick=()=>{ view.classList.add('hidden'); editRow.classList.remove('hidden'); closeBtn.style.display='none'; nameInp.focus(); nameInp.select(); };
document.getElementById('giCancelEdit').onclick=()=>{ nameInp.value=info.name; editRow.classList.add('hidden'); view.classList.remove('hidden'); closeBtn.style.display=''; };
const doRename=async()=>{ const nm=nameInp.value.trim(); if(!nm){ toast('Name required'); return; } try{ await postJSON('/api/groups/rename',{group:gid,name:nm}); ov.remove(); await refresh(); toast('Group renamed'); }catch(e){ toast(e.message); } };
document.getElementById('giSave').onclick=doRename;
nameInp.addEventListener('keydown',e=>{ if(e.key==='Enter'){ e.preventDefault(); doRename(); } });
document.getElementById('giCall').onclick=()=>startGroupCall(gid);
document.getElementById('giSchedule').onclick=()=>scheduleGroupCall(gid);
const adminOnlyCb=document.getElementById('giAdminOnly'); if(adminOnlyCb) adminOnlyCb.onchange=async()=>{ try{ await postJSON('/api/groups/admin-only',{ group:gid, value:adminOnlyCb.checked }); ov.remove(); await refresh(); openGroupInfo(gid); }catch(e){ adminOnlyCb.checked=!adminOnlyCb.checked; toast(e.message||'Could not update'); } };
const addTgl=document.getElementById('giAddToggle'); if(addTgl) addTgl.onclick=()=>{ const w=document.getElementById('giAddWrap'); if(w){ w.classList.toggle('hidden'); if(!w.classList.contains('hidden')){ const s=document.getElementById('giAddSearch'); if(s) setTimeout(()=>s.focus(),0); } } };
const addSearch=document.getElementById('giAddSearch');
if(addSearch){
const filter=()=>{ const q=addSearch.value.trim().toLowerCase(); const xb=document.getElementById('giAddSearchX'); if(xb) xb.style.display=q?'grid':'none'; let shown=0; ov.querySelectorAll('#giAddList .chk').forEach(l=>{ const vis=(!q||(l.dataset.name||'').includes(q)); l.style.display=vis?'':'none'; if(vis) shown++; }); const empty=document.getElementById('giAddEmpty'); if(empty) empty.style.display=shown?'none':'block'; };
addSearch.addEventListener('input', filter);
const xb=document.getElementById('giAddSearchX'); if(xb) xb.onclick=()=>{ addSearch.value=''; filter(); addSearch.focus(); };
}
const addBtn=document.getElementById('giAddBtn'); if(addBtn) addBtn.onclick=async()=>{ const ids=[...ov.querySelectorAll('#giAddWrap input:checked')].map(i=>i.value); if(!ids.length){ toast('Pick members to add'); return; } try{ await postJSON('/api/groups/add',{group:gid,memberIds:ids}); ov.remove(); await refresh(); openGroupInfo(gid); }catch(e){ toast(e.message); } };
const nameOf=(uid)=>{ const mm=info.members.find(x=>x.id===uid); return mm?mm.name:'this person'; };
ov.querySelectorAll('[data-rm]').forEach(b=>b.onclick=async()=>{ const nm=nameOf(b.dataset.rm); if(!confirm('Remove '+nm+' from the group?\n\nThey will lose access to this conversation.')) return; try{ await postJSON('/api/groups/remove',{group:gid,userId:b.dataset.rm}); ov.remove(); await refresh(); openGroupInfo(gid); }catch(e){ toast(e.message); } });
ov.querySelectorAll('[data-role]').forEach(b=>b.onclick=async()=>{ const mk=b.dataset.val==='1'; const nm=nameOf(b.dataset.role); if(!confirm(mk?('Make '+nm+' an admin?\n\nThey will be able to add or remove members and manage this group.'):('Remove '+nm+' as an admin?'))) return; try{ await postJSON('/api/groups/admin',{group:gid,userId:b.dataset.role,value:mk}); ov.remove(); await refresh(); openGroupInfo(gid); }catch(e){ toast(e.message); } });
document.getElementById('giLeave').onclick=async()=>{
const admins=info.members.filter(m=>m.admin), others=info.members.filter(m=>!m.isMe);
// #10: last admin must pick a successor before leaving.
if(info.isAdmin && others.length && admins.length===1){ ov.remove(); pickSuccessorThenLeave(gid, others); return; }
if(!confirm('Leave this group?')) return;
try{ await postJSON('/api/groups/remove',{group:gid}); ov.remove(); selected=null; await loadSidebar(); renderChatPanel(); }catch(e){ toast(e.message); }
};
// Group photo: click avatar -> pick image -> upload -> set as group image.
const photoBtn=document.getElementById('giPhoto'), photoInput=document.getElementById('giPhotoInput');
if(photoBtn&&photoInput){
photoBtn.onclick=()=>photoInput.click();
photoInput.onchange=async()=>{
const file=photoInput.files&&photoInput.files[0]; if(!file) return;
if(!/^image\//.test(file.type||'')){ toast('Please choose an image file'); return; }
try{
const up=await fetch('/api/messages/upload',{ method:'POST', headers:{ 'Content-Type':file.type||'application/octet-stream', 'X-Filename':encodeURIComponent(file.name) }, body:file }).then(r=>r.json());
if(!up||!up.id) throw new Error(up&&up.error||'Upload failed');
await postJSON('/api/groups/avatar',{ group:gid, attachmentId:up.id });
ov.remove(); await refresh(); openGroupInfo(gid); toast('Group photo updated');
}catch(e){ toast(e.message||'Could not update photo'); }
};
}
}
// Start an audio call with the group: spin up an audio-only meeting and post the join code to the group chat.
// Group-info "Call" button → start (or join) the SHARED group call so every member is notified/rung.
function startGroupCall(gid){ const ov=document.getElementById('groupInfo'); if(ov) ov.remove(); startOrJoinGroupCall(gid); }
// Schedule a call tied to this group: close group info, open the schedule modal.
function scheduleGroupCall(gid){ const ov=document.getElementById('groupInfo'); if(ov) ov.remove(); openScheduleModal(gid); }
// ---------- Meetings (mesh P2P video) ----------
let meetWs=null, meetLocalStream=null, meetRoom=null, meetMyId=null, meetState='idle';
let meetAnnounceGroup=null; // when set, the new meeting's code is posted to this group chat
let meetAudioOnly=false; // audio call (no camera) — tiles show avatars instead of video
const meetPeers=new Map(); // peerId -> { pc, name }
const meetMuted=new Map(); // peerId|'__local' -> muted (for the tile mic-off badge)
const meetNames=new Map(); // peerId -> name (peers that arrive before their offer)
let meetReturn=null; // {kind:'dm'|'group', id} — chat to land on when the call ends (null = meetings tab)
const meetVU=new Map(); // peerId|'__local' -> {ctx,analyser,data,raf} active-speaker meters
let MEET_ICE={ iceServers:[{ urls:'stun:stun.l.google.com:19302' }] };
let meetMic=true, meetCam=true;
let meetIsHost=false, meetHostId=null; // host = the meeting creator (transferable by the host only)
let meetScreen=false, meetScreenStream=null; // am I sharing my screen + the display stream
const meetSharers=new Set(); // peerIds of OTHERS currently sharing their screen
let meetMultiShare=false; // host setting: allow several people to share at once (default: one at a time)
let meetRec=null; // active composite recording (host) {rec, stop()}
let meetTranscribe=false; // am I subscribed to a transcript copy
let meetRoomTx=false; // is the room transcription active (≥1 subscriber → all mics transcribe)
let meetSR=null; // my SpeechRecognition instance
let meetStageId=null; // which shared screen is currently on the stage (peerId|'__local')
function meetSend(o){ try{ if(meetWs && meetWs.readyState===1) meetWs.send(JSON.stringify(o)); }catch(_){} }
function meetRailLive(on){ const b=document.querySelector('.railbtn[data-tab="meeting"]'); if(b) b.classList.toggle('live', !!on); }
function renderMeetingLobby(){
meetState='idle';
const el=document.getElementById('meetingPanel'); if(!el) return;
el.innerHTML='<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.
async function loadScheduledMeetings(){
const wrap=document.getElementById('schedWrap'); if(!wrap) return;
let list; try{ list=await fetch('/api/meetings').then(r=>r.json()); }catch(_){ return; }
if(!Array.isArray(list)||!list.length){ wrap.innerHTML='<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').sort((a,b)=>b.scheduledAt-a.scheduledAt).slice(0,12);
const fmt=ts=>new Date(ts).toLocaleString([],{weekday:'short',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'});
const card=m=>{
const cancelled=m.status==='cancelled';
const meta=[]; if(m.groupName) meta.push(pEsc(m.groupName)); meta.push(fmt(m.scheduledAt));
if(m.durationMins) meta.push(m.durationMins+' min');
if(m.recurrenceLabel) meta.push('🔁 '+pEsc(m.recurrenceLabel));
if(m.status==='running') meta.push(m.inCall+' in call');
meta.push(m.isHost?'You\'re the host':('Host: '+pEsc(m.createdByName||'—')));
if(m.invited&&m.invited.length) meta.push(m.invited.length+' invited');
// Cancel only on a FUTURE upcoming meeting (#13: not once the time has passed).
const canCancel=m.canManage && m.status==='upcoming' && m.scheduledAt>Date.now();
// Start any time BEFORE the meeting's end; once the window passes it's 'past' and can't start (#3).
const canStart=m.status==='running' || m.status==='upcoming';
return '<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&&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>'):'';
wrap.innerHTML=sec('Ongoing now',running)+sec('Upcoming meetings',upcoming)+sec('Past meetings',past);
wrap.querySelectorAll('[data-code]').forEach(b=>b.onclick=()=>enterMeeting(b.dataset.code));
wrap.querySelectorAll('[data-edit]').forEach(b=>b.onclick=()=>{ const m=byId[b.dataset.edit]; if(m) openScheduleModal(m.groupId||null, m); });
wrap.querySelectorAll('[data-cancel]').forEach(b=>b.onclick=()=>cancelMeeting(byId[b.dataset.cancel]));
}
// Last admin leaving must hand off: pick who becomes the new admin, then leave (#10, selective).
function pickSuccessorThenLeave(gid, others){
const ov=document.createElement('div'); ov.className='modal-ov';
ov.innerHTML='<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>'
+'<div class="gi-list" id="schPeople" style="max-height:24vh;overflow:auto">'+(CONTACTS.length?CONTACTS.map(c=>'<label class="chk"><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>'
+'<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); }
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);
$('schSave').onclick=async()=>{
const title=$('schTitle').value.trim();
const desc=$('schDesc').value.trim();
const durationMins=parseInt($('schDur').value,10)||30;
const participants=[...ov.querySelectorAll('#schPeople input:checked')].map(i=>i.value);
clearErrAll();
if(!title){ err.textContent='Please add a title.'; $('schTitle').classList.add('field-err'); $('schTitle').focus(); return; }
const ts=new Date(selDate.getFullYear(),selDate.getMonth(),selDate.getDate(),Math.floor(selMin/60),selMin%60,0,0).getTime();
if(ts < Date.now()+60000){ err.textContent='That time has already passed — pick a future time.'; timeBtn.classList.add('field-err'); return; }
let recurrence=[]; if(repeat.checked){ recurrence=[...daysWrap.querySelectorAll('.day-chip.on')].map(b=>+b.dataset.d); if(!recurrence.length) recurrence=[new Date(ts).getDay()]; }
const whenText=new Date(ts).toLocaleString([],{weekday:'short',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'});
try{
if(editing){ await postJSON('/api/meetings/update',{ id:editMtg.id, title, description:desc, scheduledAt:ts, durationMins, participants, recurrence }); toast('Meeting updated'); }
else { await postJSON('/api/meetings/schedule',{ group:gid||undefined, title, description:desc, scheduledAt:ts, whenText, participants, durationMins, recurrence }); toast('Meeting scheduled'+(participants.length?' · '+participants.length+' invited':'')); }
ov.remove(); switchTab('meeting'); loadScheduledMeetings();
}catch(e){ err.textContent=e.message||'Could not save'; }
};
}
function renderCall(){
const el=document.getElementById('meetingPanel'); if(!el) return;
el.innerHTML='<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>'
+ '<button class="meet-ic'+(meetMic?'':' off')+'" id="meetMicBtn" title="'+(meetMic?'Mute':'Unmute')+'">'+ic(meetMic?'mic':'micOff',20)+'</button>'
+ '<button class="meet-ic'+(meetCam?'':' off')+'" id="meetCamBtn" title="'+(meetCam?'Turn camera off':'Turn camera on')+'">'+ic(meetCam?'video':'videoOff',20)+'</button>'
+ '<button class="meet-ic" id="meetScreenBtn" title="Share screen">'+ic('monitor',20)+'</button>'
+ '<button class="meet-ic host-only" id="meetRecBtn" title="Record meeting" style="display:none">'+ic('record',20)+'</button>'
+ '<button class="meet-ic" id="meetTransBtn" title="Live transcript">'+ic('fileText',20)+'</button>'
+ '<button class="meet-ic" id="meetPplBtn" title="Participants">'+ic('users',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;
document.getElementById('meetTransBtn').onclick=toggleTranscribe;
document.getElementById('meetPplBtn').onclick=toggleMeetPanel;
document.getElementById('meetLeaveBtn').onclick=leaveMeeting;
updateHostControls();
// Click another shared screen (in the side column) to bring it onto the stage.
const grid=document.getElementById('meetGrid'); if(grid) grid.addEventListener('click', e=>{ if(!grid.classList.contains('sharing-mode')) return; const t=e.target.closest('.meet-tile.sharing'); if(t && !t.classList.contains('stage')){ setStage(t.id.replace('meet-tile-','')); } });
addTile('__local', meetLocalStream, (ME&&ME.name)?ME.name:'You', true);
setTileMute('__local', !meetMic);
}
function addTile(id, stream, label, muted){
const grid=document.getElementById('meetGrid'); if(!grid) return;
let tile=document.getElementById('meet-tile-'+id);
if(!tile){ tile=document.createElement('div'); tile.className='meet-tile'; tile.id='meet-tile-'+id;
tile.innerHTML='<video autoplay playsinline'+(muted?' muted':'')+'></video><div class="meet-av" style="background:'+avColor(label||'?')+'">'+pEsc(initials(label||'?'))+'</div><div class="meet-mute" style="display:none">'+ic('micOff',14)+'</div><span class="nm">'+pEsc(label||'')+'</span>'; grid.appendChild(tile); }
const v=tile.querySelector('video'); if(v && stream && v.srcObject!==stream) v.srcObject=stream;
const hasVid=!!(stream && stream.getVideoTracks && stream.getVideoTracks().some(t=>t.enabled && t.readyState!=='ended'));
tile.classList.toggle('novid', !hasVid);
if(meetMuted.has(id)) setTileMute(id, meetMuted.get(id)); // apply any known mute state
}
function setTileMute(id, muted){ meetMuted.set(id, !!muted); const t=document.getElementById('meet-tile-'+id); if(t){ const b=t.querySelector('.meet-mute'); if(b) b.style.display=muted?'grid':'none'; if(muted) t.classList.remove('speaking'); } }
function removeTile(id){ const t=document.getElementById('meet-tile-'+id); if(t) t.remove(); meetMuted.delete(id); meetUnwatch(id); }
// ---- Active-speaker meter: highlight a tile while its audio is above a threshold ----
function meetWatchStream(id, stream){
if(!stream || !stream.getAudioTracks || !stream.getAudioTracks().length) return; // no audio yet
meetUnwatch(id);
let ctx; try{ ctx=new (window.AudioContext||window.webkitAudioContext)(); }catch(_){ return; }
try{ ctx.resume(); }catch(_){}
let src; try{ src=ctx.createMediaStreamSource(stream); }catch(_){ try{ctx.close();}catch(e){} return; }
const an=ctx.createAnalyser(); an.fftSize=512; an.smoothingTimeConstant=0.6; src.connect(an);
const data=new Uint8Array(an.frequencyBinCount); const rec={ctx,an,data,src,raf:0,on:false};
meetVU.set(id, rec);
const tick=()=>{
an.getByteFrequencyData(data); let sum=0; for(let i=0;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); }
function meetMakePeer(peerId, name){
const pc=new RTCPeerConnection(MEET_ICE);
const entry={ pc, name, vsender:null }; meetPeers.set(peerId, entry);
addTile(peerId, null, meetNames.get(peerId)||name||'Guest', false); // show the tile right away (avatar)
meetLocalStream.getTracks().forEach(t=>{ const s=pc.addTrack(t, meetLocalStream); if(t.kind==='video') entry.vsender=s; });
// If I'm already sharing my screen, send the screen (not the camera) to this new peer.
if(meetScreen && meetScreenStream){ const st=meetScreenStream.getVideoTracks()[0]; if(st){ try{ if(entry.vsender) entry.vsender.replaceTrack(st); else entry.vsender=pc.addTrack(st, meetLocalStream); }catch(_){} } }
pc.onicecandidate=(ev)=>{ if(ev.candidate) meetSend({type:'meeting-signal',to:peerId,data:{candidate:ev.candidate}}); };
pc.ontrack=(ev)=>{ addTile(peerId, ev.streams[0], (meetPeers.get(peerId)||{}).name||name||'Guest', false); meetWatchStream(peerId, ev.streams[0]); };
return pc;
}
// Screen share (mesh): swap the outgoing video track for a display-capture track via replaceTrack
// (no extra tiles, the peer's video just shows the screen). Falls back to addTrack+renegotiate if
// no video sender exists yet (camera never turned on). Stopping restores the camera (or avatar).
async function toggleScreen(){
if(meetScreen){ stopScreen(); return; }
if(!meetMultiShare && meetSharers.size>0){ toast('Someone is already sharing their screen'); return; }
let ds; try{ ds=await navigator.mediaDevices.getDisplayMedia({ video:true, audio:false }); }
catch(e){ return; } // user cancelled the picker
const track=ds.getVideoTracks()[0]; if(!track){ try{ ds.getTracks().forEach(t=>t.stop()); }catch(_){} return; }
meetScreenStream=ds; meetScreen=true;
track.onended=()=>stopScreen(); // browser's native "Stop sharing" bar
for(const [pid,p] of meetPeers){
if(p.vsender){ try{ await p.vsender.replaceTrack(track); }catch(_){} }
else { try{ p.vsender=p.pc.addTrack(track, meetLocalStream); const off=await p.pc.createOffer(); await p.pc.setLocalDescription(off); meetSend({type:'meeting-signal',to:pid,data:{sdp:p.pc.localDescription}}); }catch(_){} }
}
addTile('__local', ds, ((ME&&ME.name)?ME.name:'You')+' (screen)', true); setTileScreen('__local', true);
updateScreenBtn(); meetSend({ type:'meeting-screen', on:true });
}
function stopScreen(){
if(!meetScreen) return; meetScreen=false;
const cam=meetLocalStream && meetLocalStream.getVideoTracks()[0];
for(const [,p] of meetPeers){ if(p.vsender){ try{ p.vsender.replaceTrack(cam||null); }catch(_){} } }
if(meetScreenStream){ try{ meetScreenStream.getTracks().forEach(t=>t.stop()); }catch(_){} meetScreenStream=null; }
addTile('__local', meetLocalStream, (ME&&ME.name)?ME.name:'You', true); setTileScreen('__local', false);
updateScreenBtn(); meetSend({ type:'meeting-screen', on:false });
}
function updateScreenBtn(){ const b=document.getElementById('meetScreenBtn'); if(!b) return; b.classList.toggle('on', meetScreen); b.title=meetScreen?'Stop sharing':'Share screen'; b.innerHTML=ic('monitor',20); }
function setTileScreen(id, on){ const t=document.getElementById('meet-tile-'+id); if(!t) return; t.classList.toggle('sharing', !!on); let b=t.querySelector('.meet-screen'); if(on){ if(!b){ b=document.createElement('div'); b.className='meet-screen'; b.innerHTML=ic('monitor',12)+' Screen'; t.appendChild(b); } } else if(b){ b.remove(); } updateShareMode(); }
// Stage mode: one chosen shared screen fills the area; other screens + people are small on the right.
function getSharerIds(){ const a=[]; if(meetScreen) a.push('__local'); meetSharers.forEach(id=>a.push(id)); return a; }
function updateShareMode(){ const g=document.getElementById('meetGrid'); if(!g) return;
const sharers=getSharerIds(); const on=sharers.length>0;
g.classList.toggle('sharing-mode', on);
if(on){ if(!meetStageId || sharers.indexOf(meetStageId)<0) meetStageId=sharers[0]; } else meetStageId=null;
applyStage();
}
function applyStage(){ const g=document.getElementById('meetGrid'); if(!g) return; g.querySelectorAll('.meet-tile.stage').forEach(t=>t.classList.remove('stage')); if(meetStageId){ const t=document.getElementById('meet-tile-'+meetStageId); if(t) t.classList.add('stage'); } }
function setStage(id){ if(!id) return; meetStageId=id; applyStage(); }
// Record + transcript are host-only; show/hide their buttons when host status changes.
function updateHostControls(){ document.querySelectorAll('.meet-bar .host-only').forEach(b=>{ b.style.display=meetIsHost?'inline-flex':'none'; }); }
// ---- Recording: composite all tiles onto a canvas + mix everyone's audio, then MediaRecorder ----
function drawCover(ctx,v,x,y,w,h){ const vw=v.videoWidth,vh=v.videoHeight; if(!vw||!vh) return; const s=Math.max(w/vw,h/vh),dw=vw*s,dh=vh*s; ctx.save(); ctx.beginPath(); ctx.rect(x,y,w,h); ctx.clip(); ctx.drawImage(v, x+(w-dw)/2, y+(h-dh)/2, dw, dh); ctx.restore(); }
function recMime(){ const c=['video/webm;codecs=vp9,opus','video/webm;codecs=vp8,opus','video/webm']; for(const t of c){ if(window.MediaRecorder&&MediaRecorder.isTypeSupported(t)) return t; } return ''; }
function toggleRecord(){ if(!meetIsHost){ toast('Only the host can record'); return; } if(meetRec){ meetRec.stop(); return; } startRecord(); }
function startRecord(){
if(!window.MediaRecorder){ toast('Recording is not supported in this browser'); return; }
const canvas=document.createElement('canvas'); canvas.width=1280; canvas.height=720; const ctx=canvas.getContext('2d');
let raf=0;
const draw=()=>{
const vids=[...document.querySelectorAll('#meetGrid .meet-tile video')].filter(v=>v.srcObject);
ctx.fillStyle='#0b1220'; ctx.fillRect(0,0,canvas.width,canvas.height);
const n=Math.max(1,vids.length), cols=Math.ceil(Math.sqrt(n)), rows=Math.ceil(n/cols), cw=canvas.width/cols, ch=canvas.height/rows;
vids.forEach((v,i)=>{ const r=Math.floor(i/cols), c=i%cols; drawCover(ctx,v,c*cw,r*ch,cw-4,ch-4); });
raf=requestAnimationFrame(draw);
};
const cstream=canvas.captureStream(25);
let actx=null, dest=null;
try{ actx=new (window.AudioContext||window.webkitAudioContext)(); dest=actx.createMediaStreamDestination(); const seen=new Set();
document.querySelectorAll('#meetGrid .meet-tile video').forEach(v=>{ const s=v.srcObject; if(s&&!seen.has(s)&&s.getAudioTracks&&s.getAudioTracks().length){ seen.add(s); try{ actx.createMediaStreamSource(s).connect(dest); }catch(_){} } });
}catch(_){}
const tracks=[...cstream.getVideoTracks()]; if(dest) tracks.push(...dest.stream.getAudioTracks());
let rec; try{ rec=new MediaRecorder(new MediaStream(tracks), recMime()?{mimeType:recMime()}:undefined); }
catch(e){ toast('Recording is not supported in this browser'); try{actx&&actx.close();}catch(_){} return; }
const chunks=[]; const startedAt=Date.now(); let stopped=false;
rec.ondataavailable=e=>{ if(e.data&&e.data.size) chunks.push(e.data); };
rec.onstop=()=>{ try{ cancelAnimationFrame(raf); }catch(_){} try{ actx&&actx.close(); }catch(_){} const blob=new Blob(chunks,{type:(chunks[0]&&chunks[0].type)||'video/webm'}); uploadRecording(blob, Date.now()-startedAt); };
const stop=()=>{ if(stopped) return; stopped=true; try{ rec.stop(); }catch(_){} meetRec=null; updateRecBtn(); meetSend({type:'meeting-recording', on:false}); recNotice(false); toast('Recording saved to Past meetings'); };
meetRec={ rec, stop }; draw(); rec.start(2000); updateRecBtn();
meetSend({type:'meeting-recording', on:true}); recNotice(true, 'You'); // notify everyone (visual + voice)
}
function speak(text){ try{ if(window.speechSynthesis){ const u=new SpeechSynthesisUtterance(text); u.rate=1; speechSynthesis.cancel(); speechSynthesis.speak(u); } }catch(_){} }
function fmtElapsed(ms){ const s=Math.max(0,Math.floor(ms/1000)); const m=Math.floor(s/60); return String(m).padStart(2,'0')+':'+String(s%60).padStart(2,'0'); }
let _recTimer=null, _recStart=0;
function recNotice(on, by){
let el=document.getElementById('recNotice');
if(on){
if(_recTimer) return; // already showing
if(!el){ el=document.createElement('div'); el.id='recNotice'; el.className='rec-notice'; document.body.appendChild(el); }
_recStart=Date.now();
const tick=()=>{ const e=document.getElementById('recNotice'); if(e) e.innerHTML='<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(); } }
async function enterMeeting(code, audioOnly){
if(meetState==='call'){ switchTab('meeting'); return; } // already in a call — ignore double-join
meetAudioOnly=!!audioOnly;
// Start with NO media — mic & cam OFF by default (no permission prompt until the user
// turns one on). Tracks are acquired on demand by toggleMic / toggleCam.
meetLocalStream=new MediaStream();
meetMic=false; meetCam=false; meetIsHost=false; meetHostId=null;
meetScreen=false; meetScreenStream=null; meetSharers.clear(); meetMultiShare=false;
meetRec=null; meetTranscribe=false; meetRoomTx=false; meetSR=null; _addPool=null; meetStageId=null;
try{ const c=await fetch('/api/ice').then(r=>r.json()); if(c&&c.iceServers) MEET_ICE=c; }catch(_){}
meetState='call'; meetRailLive(true);
meetWs=new WebSocket((location.protocol==='https:'?'wss://':'ws://')+location.host+'/ws');
meetWs.onmessage=onMeetMsg;
meetWs.onopen=()=>{ if(code){ meetRoom=code; renderCall(); meetSend({type:'meeting-join', room:code, name:(ME.name||ME.email||'Guest')}); } else { meetSend({type:'meeting-create'}); } };
}
async function onMeetMsg(e){
let m; try{ m=JSON.parse(e.data); }catch(_){ return; }
if(m.type==='meeting-created'){ meetRoom=m.room; renderCall(); meetSend({type:'meeting-join', room:m.room, name:(ME.name||ME.email||'Guest')}); if(meetAnnounceGroup){ const g=meetAnnounceGroup; meetAnnounceGroup=null; try{ postJSON('/api/messages',{group:g, body:'📹 Started a group call — join with code '+m.room}); }catch(_){} } return; }
if(m.type==='meeting-joined'){
meetMyId=m.peerId;
if(m.isHost){ meetIsHost=true; meetHostId=meetMyId; } // host = the meeting creator (server-decided)
meetWatchStream('__local', meetLocalStream); // active-speaker detection on my own mic
// Existing peers OFFER to me (their offers carry their tracks incl. any active screen share);
// I just set up the connections and wait. Avoids the "newcomer can't receive screen" bug.
for(const p of (m.peers||[])){ meetNames.set(p.peerId,p.name); meetMakePeer(p.peerId,p.name); }
meetSend({type:'meeting-state', muted:!meetMic, camOff:!meetCam}); // tell existing peers my state
if(meetIsHost) meetSend({type:'meeting-host', to:meetMyId}); // announce host so others know
refreshMeetPanel(); updateHostControls();
return;
}
if(m.type==='meeting-ended'){ toast('Call ended'); leaveMeeting(); return; } // 1:1 hangup, or host ended
if(m.type==='meeting-peer-joined'){
meetNames.set(m.peerId,m.name);
const pc=meetMakePeer(m.peerId,m.name); // I'm an existing peer → I OFFER to the newcomer (carries my screen)
try{ const offer=await pc.createOffer(); await pc.setLocalDescription(offer); meetSend({type:'meeting-signal',to:m.peerId,data:{sdp:pc.localDescription}}); }catch(_){}
meetSend({type:'meeting-state', muted:!meetMic, camOff:!meetCam});
if(meetIsHost){ meetSend({type:'meeting-host', to:meetHostId}); meetSend({type:'meeting-sharemode', multi:meetMultiShare}); if(meetRec) meetSend({type:'meeting-recording', on:true}); }
if(meetScreen) meetSend({type:'meeting-screen', on:true});
refreshMeetPanel(); return;
}
if(m.type==='meeting-peer-state'){ setTileMute(m.peerId, !!m.muted); refreshMeetPanel(); return; }
if(m.type==='meeting-host'){ const was=meetIsHost; meetHostId=m.hostPeerId; meetIsHost=(m.hostPeerId===meetMyId); if(meetIsHost&&!was){ toast('You are now the host'); try{ speak('You are now the host'); }catch(_){} } refreshMeetPanel(); updateHostControls(); return; }
if(m.type==='meeting-transcribe-state'){ applyRoomTx(!!m.active); return; } // ≥1 subscriber → transcribe my mic
if(m.type==='meeting-recording'){ recNotice(!!m.on, m.by); return; } // someone is recording — show + announce
if(m.type==='meeting-peer-screen'){ if(m.on) meetSharers.add(m.from); else meetSharers.delete(m.from); setTileScreen(m.from, !!m.on); refreshMeetPanel(); return; }
if(m.type==='meeting-sharemode'){ meetMultiShare=!!m.multi; refreshMeetPanel(); return; }
if(m.type==='meeting-muteall'){ if(meetMic && meetLocalStream){ meetMic=false; meetLocalStream.getAudioTracks().forEach(t=>t.enabled=false); updateMicBtn(); setTileMute('__local', true); meetSend({type:'meeting-state', muted:true, camOff:!meetCam}); } toast('You were muted by the host'); return; }
if(m.type==='meeting-peer-left'){ const p=meetPeers.get(m.peerId); if(p){ try{p.pc.close();}catch(_){} meetPeers.delete(m.peerId);} meetSharers.delete(m.peerId); removeTile(m.peerId); refreshMeetPanel(); return; }
if(m.type==='meeting-signal'){
const from=m.from, d=m.data||{};
if(d.sdp){
let p=meetPeers.get(from), pc=p&&p.pc;
if(d.sdp.type==='offer'){
if(!pc) pc=meetMakePeer(from, meetNames.get(from)||'Guest');
try{ await pc.setRemoteDescription(d.sdp); const ans=await pc.createAnswer(); await pc.setLocalDescription(ans); meetSend({type:'meeting-signal',to:from,data:{sdp:pc.localDescription}}); }catch(_){}
} else if(d.sdp.type==='answer'){ if(pc){ try{ await pc.setRemoteDescription(d.sdp); }catch(_){} } }
} else if(d.candidate){ const p=meetPeers.get(from); if(p&&p.pc){ try{ await p.pc.addIceCandidate(d.candidate); }catch(_){} } }
return;
}
if(m.type==='error'){ const msg=m.message; leaveMeeting(); const e2=document.getElementById('meetErr'); if(e2) e2.textContent=msg||'Meeting error'; return; }
}
function updateMicBtn(){ const b=document.getElementById('meetMicBtn'); if(b){ b.classList.toggle('off',!meetMic); b.title=meetMic?'Mute':'Unmute'; b.innerHTML=ic(meetMic?'mic':'micOff',20); } }
function updateCamBtn(){ const b=document.getElementById('meetCamBtn'); if(b){ b.classList.toggle('off',!meetCam); b.title=meetCam?'Turn camera off':'Turn camera on'; b.innerHTML=ic(meetCam?'video':'videoOff',20); } }
// Unmute acquires the mic on demand (no prompt until then) and renegotiates with peers.
async function toggleMic(){
if(!meetLocalStream) return;
const hasTrack=meetLocalStream.getAudioTracks().length>0;
if(!hasTrack){
let astream; try{ astream=await navigator.mediaDevices.getUserMedia({ audio:true }); }
catch(e){ toast('Microphone permission is required to unmute'); return; }
const track=astream.getAudioTracks()[0]; if(!track) return;
meetLocalStream.addTrack(track); meetMic=true; meetWatchStream('__local', meetLocalStream);
for(const [pid,p] of meetPeers){ try{ p.pc.addTrack(track, meetLocalStream); const off=await p.pc.createOffer(); await p.pc.setLocalDescription(off); meetSend({type:'meeting-signal',to:pid,data:{sdp:p.pc.localDescription}}); }catch(_){} }
} else {
meetMic=!meetMic; meetLocalStream.getAudioTracks().forEach(t=>t.enabled=meetMic);
}
updateMicBtn(); setTileMute('__local', !meetMic); meetSend({type:'meeting-state', muted:!meetMic, camOff:!meetCam});
}
// Toggle the camera. In an audio call (no video track yet) this acquires the camera and
// renegotiates with every peer, so you can always turn video on once a meeting has started.
async function toggleCam(){
if(!meetLocalStream) return;
const hasTrack=meetLocalStream.getVideoTracks().length>0;
if(!hasTrack){
let vstream; try{ vstream=await navigator.mediaDevices.getUserMedia({ video:true }); }
catch(e){ toast('Camera permission is required to turn on video'); return; }
const track=vstream.getVideoTracks()[0]; if(!track) return;
meetLocalStream.addTrack(track); meetCam=true; meetAudioOnly=false;
for(const [pid,p] of meetPeers){ try{ const s=p.pc.addTrack(track, meetLocalStream); if(!p.vsender) p.vsender=s; const off=await p.pc.createOffer(); await p.pc.setLocalDescription(off); meetSend({type:'meeting-signal',to:pid,data:{sdp:p.pc.localDescription}}); }catch(_){} }
const chip=document.getElementById('meetCodeChip'); if(chip) chip.innerHTML='Room <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;
function leaveMeeting(){
if(meetLeaving) return; meetLeaving=true;
// Host leaving voluntarily must hand off so the meeting isn't left host-less.
if(meetIsHost && meetPeers.size>0){
const next=meetPeers.keys().next().value; // first remaining participant
if(next){ meetSend({type:'meeting-host', to:next}); toast('Host handed to '+(meetNames.get(next)||'a participant')); }
}
if(meetRec) meetRec.stop(); // saves the recording
if(meetTranscribe) meetSend({type:'meeting-transcribe', on:false}); // server saves my copy
meetTranscribe=false; meetRoomTx=false; stopSR(); transcribeNotice(false);
if(meetScreen) stopScreen();
if(_recTimer){ clearInterval(_recTimer); _recTimer=null; } const _rn=document.getElementById('recNotice'); if(_rn) _rn.remove(); // clear any "Recording" badge
meetSend({type:'meeting-leave'});
meetUnwatchAll(); meetSharers.clear();
meetPeers.forEach(p=>{ try{p.pc.close();}catch(_){} }); meetPeers.clear(); meetNames.clear(); meetMuted.clear();
if(meetLocalStream){ try{ meetLocalStream.getTracks().forEach(t=>t.stop()); }catch(_){} meetLocalStream=null; }
if(meetWs){ try{ meetWs.close(); }catch(_){} meetWs=null; }
meetRoom=null; meetMyId=null; meetState='idle'; meetIsHost=false; meetHostId=null; meetRailLive(false);
const ret=meetReturn; meetReturn=null; meetLeaving=false;
if(ret){ switchTab('chat'); selectChat(ret.kind, ret.id); } // land back on the originating chat
else renderMeetingLobby();
}
// ---------- Tabs (icon rail) ----------
// 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(){ selected=null; document.body.classList.remove('chat-open'); renderChats(searchVal()); renderChatPanel(); updateRailUnread(); }
railBtns.forEach(btn=>{ btn.onclick=()=>{
const tab=btn.dataset.tab;
// Re-clicking Chat (while already on it) returns to the welcome screen.
if(tab==='chat' && currentTab()==='chat' && selected!=null){ showWelcome(); }
switchTab(tab);
}; });
// Esc clears the open conversation and brings back the welcome screen.
document.addEventListener('keydown',(e)=>{ if(e.key==='Escape' && currentTab()==='chat' && selected!=null){ showWelcome(); } });
// Hamburger: on mobile it slides the rail drawer in/out; on desktop it collapses the rail.
function toggleNav(){ if(window.innerWidth<=760) document.body.classList.toggle('rail-open'); else document.body.classList.toggle('rail-hidden'); }
const _navT=document.getElementById('navToggle'); if(_navT) _navT.onclick=toggleNav;
const _railBd=document.getElementById('railBackdrop'); if(_railBd) _railBd.onclick=()=>document.body.classList.remove('rail-open');
// Mobile chat: "back" returns from an open conversation to the chat list.
function chatBack(){ document.body.classList.remove('chat-open'); }
// Embedded Share/Connect flows report session start/stop so the rail can show a "live"
// dot — that's how you know a session is still running after switching to Chat.
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">
<h1>Welcome to BizGaze Connect</h1>
<div class="sub">Sign in to access chats, screen share and connect.</div>
${regOpen?`<div class="authtabs">
<button id="tabLogin" class="active">Sign in</button>
<button id="tabReg">Register team</button>
</div>`:''}
<div id="loginForm">
<span class="lbl">Email</span><input id="li_email" type="email" placeholder="you@bizgaze.com">
<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">Sign in</button>
<p id="li_err" class="formerr"></p>
</div>
${regOpen?`<div id="regForm" class="hidden">
<span class="lbl">Team name</span><input id="rg_team" placeholder="e.g. BizGaze Support">
<span class="lbl">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('li_btn').onclick=doLogin;
wireEyes();
onEnter(['li_email','li_pw'],doLogin);
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');
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();
}catch(e){ 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); }
}
// ---------- 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(_){}
if(!me){ await renderLogin(); document.getElementById('loading').style.display='none'; return; }
ME=me;
document.getElementById('hdrRight').innerHTML=bellHTML()+profileHTML(me);
loadNotifs(); wireBell(); wireProfile();
connectChatWs();
setupPush(); // register the notification service worker + subscribe to Web Push (if granted)
document.getElementById('loading').style.display='none';
// Fast-path: when opened from a notification, show the chat immediately (only needs the
// thread fetch) and load the sidebar in the background — don't make the reload wait on it.
let oid=null, okind='dm';
try{ const q=new URLSearchParams(location.search); oid=q.get('openId'); okind=q.get('openKind')||'dm'; if(oid) history.replaceState(null,'','/home'); }catch(_){}
if(oid){
selectChat(okind, oid); // open the chat right away (only waits on the thread fetch)
loadSidebar().then(()=>{ try{ const it=rowFor(okind,oid); const nm=document.querySelector('#chatPanel .convo-head .nm'); if(it&&nm) nm.textContent=it.name; }catch(_){} });
} else { await loadSidebar(); renderChatPanel(); }
})();
</script>
</body>
</html>