1272b81cee
Page-level Notifications can't fire when a tab is frozen/closed (and never on mobile), which is why recipients on another tab/app got nothing. Adds a notification-only service worker (sw.js, no caching) + Web Push: - push.js: optional web-push wrapper (no-op unless web-push installed AND VAPID_PUBLIC_KEY/VAPID_PRIVATE_KEY set -> app unaffected if unconfigured). - push_subscriptions table + R.pushSubs repo (upsert by endpoint, prune dead). - /api/push/vapid|subscribe|unsubscribe; DM + group message routes also send a Web Push to recipients. - Client registers /sw.js, subscribes when permission granted; hidden-tab popups are left to push to avoid double-notifying (pushActive flag); SW suppresses the OS popup when a tab is visible. Removes the old code that unregistered SWs. Requires (prod, once): npm install + VAPID_PUBLIC_KEY/VAPID_PRIVATE_KEY/VAPID_SUBJECT env. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2322 lines
205 KiB
HTML
2322 lines
205 KiB
HTML
<!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>
|
||
<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=>({'&':'&','<':'<','>':'>','"':'"'}[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 hasn’t joined Connect yet — they’ll 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&¤tTab()==='chat';
|
||
const isSys=!!m.system || m.from==='__system__'; // activity lines: show in chat, but no ping/notify/unread
|
||
if(m.from!==ME.id && !isSys){
|
||
if(!isGroupMsg && chatWs && chatWs.readyState===1){ try{ chatWs.send(JSON.stringify({type:'chat-delivered', id:m.id})); }catch(_){} } // ack DM delivery
|
||
// 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>
|