Files
BizGaze_Remote/server/public/home.html
T
Sravan 7ae0cacf74 feat(push): wire Capacitor native push into the web UI
home.html: in a Capacitor app shell, setupPush() now uses the native FCM/APNs path
instead of Web Push — requests permission, registers, POSTs the OS device token to
/api/v1/devices, deep-links on notification tap (selectChat), and unregisters the
token on logout. Web Notification prompts are suppressed on native. Fully inert in a
normal browser (Web Push unchanged). build batch15.

CLIENTS.md Phase B push items checked off.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 19:56:59 +05:30

2748 lines
250 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Biz Connect</title>
<!-- PWA: installable on Android & iOS ("Add to Home Screen"); also enables iOS web push -->
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#1F3B73">
<link rel="apple-touch-icon" href="/apple-touch-icon.png?v=2">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Connect">
<style>
:root{ --brand:#FFC708; --brand-d:#E0AC00; --blue:#1F3B73; --blue-d:#16294f; --blue-soft:#EAF0FB; --ink:#1f2430; --muted:#6b7280; --bg:#f6f8fb; --card:#fff; --line:#e6e9ef; --green:#16a34a; --red:#b91c1c; }
*{box-sizing:border-box;}
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 ---- */
/* #4: the top blue bar is removed everywhere — bell+profile live in the chat-list header. */
header{display:none;}
.brandrow{display:flex;align-items:center;gap:.6rem;}
.logo{width:30px;height:30px;border-radius:8px;background:var(--brand);display:grid;place-items:center;font-weight:800;color:var(--blue);}
.brand{font-weight:700;color:#fff;font-size:1.05rem;} .brand span.y{color:var(--brand);font-weight:700;}
/* ---- Header actions: notification bell + profile ---- */
#hdrRight{display:flex;align-items:center;gap:.45rem;}
.bell{position:relative;}
.bellbtn{position:relative;background:var(--blue-soft);border:1px solid var(--line);color:var(--blue);cursor:pointer;width:38px;height:38px;border-radius:10px;display:grid;place-items:center;}
.bellbtn:hover{background:#dbe6fb;}
.bellbtn:hover{background:rgba(255,255,255,.24);}
.bell-dot{position:absolute;top:2px;right:2px;min-width:16px;height:16px;border-radius:99px;background:var(--red);color:#fff;font-size:.6rem;font-weight:800;display:grid;place-items:center;padding:0 .2rem;border:2px solid var(--card);}
.bell-menu{position:absolute;right:0;top:calc(100% + 6px);width:340px;max-width:92vw;background:#fff;border:1px solid #e6e9ef;border-radius:12px;box-shadow:0 12px 30px rgba(0,0,0,.18);z-index:5000;display:none;overflow:hidden;}
/* When bell+profile live in the left rail (desktop), open their menus to the RIGHT of the rail,
bottom-aligned, so they aren't clipped off-screen. */
#rail .bell-menu{top:auto;bottom:0;right:auto;left:calc(100% + 10px);}
#rail .profile .pmenu{top:auto;bottom:0;right:auto;left:calc(100% + 10px);}
.bell-menu.open{display:block;}
.bell-head{display:flex;align-items:center;justify-content:space-between;padding:.6rem .85rem;border-bottom:1px solid #eef1f6;font-weight:700;font-size:.9rem;color:var(--ink);}
.bell-head button{background:none;border:none;color:var(--blue);font-size:.78rem;cursor:pointer;font-weight:600;}
.bell-list{max-height:62vh;overflow:auto;}
.bell-item{display:flex;gap:.6rem;align-items:flex-start;padding:.6rem .85rem;border-bottom:1px solid #f4f6fa;cursor:pointer;}
.bell-item:hover{background:#f6f8fb;}
.bell-item.unread{background:#eef4ff;}
.bell-ico{width:30px;height:30px;border-radius:50%;background:var(--blue-soft);color:var(--blue);display:grid;place-items:center;flex:0 0 30px;}
.bell-body{min-width:0;}
.bell-tx{font-size:.85rem;color:var(--ink);line-height:1.3;}
.bell-tm{font-size:.7rem;color:var(--muted);margin-top:.1rem;}
.bell-empty{padding:1.6rem;text-align:center;color:var(--muted);font-size:.85rem;}
/* ---- Profile dropdown (from console.html) ---- */
.profile{position:relative}
.profile .pbtn.icon-only{padding:0;gap:0;border-radius:50%;background:transparent;border:none;position:relative;}
.pav-dot{position:absolute;right:-1px;bottom:-1px;width:11px;height:11px;border-radius:50%;border:2px solid var(--card);background:#cbd2dd;}
.pav-dot.active{background:#16a34a;} .pav-dot.away{background:#f59e0b;} .pav-dot.onleave{background:#ef4444;} .pav-dot.incall{background:#2563eb;} .pav-dot.offline{background:#cbd2dd;}
.pmenu .ps-current{display:flex;align-items:center;gap:.45rem;}
.pmenu .ps-current .ps-arrow{margin-left:auto;color:var(--muted);display:inline-flex;}
.pmenu .ps-options{background:#f8fafc;border-top:1px solid #eef1f6;border-bottom:1px solid #eef1f6;}
.pmenu .ps-opt{display:flex;align-items:center;gap:.45rem;padding-left:1.3rem;}
.pmenu .ps-opt.on{font-weight:700;}
.pmenu .ps-opt .ps-check{margin-left:auto;display:none;color:#16a34a;align-items:center;}
.pmenu .ps-opt.on .ps-check{display:inline-flex;}
.pmenu .ps-div{height:1px;background:#eef1f6;margin:.3rem 0;}
.profile .pbtn{display:flex;align-items:center;gap:.5rem;background:rgba(255,255,255,.14);color:#fff;border:1px solid #46598c;border-radius:10px;padding:.4rem .85rem .4rem .5rem;font-weight:600;font-size:.88rem;cursor:pointer}
.profile .pbtn:hover{background:rgba(255,255,255,.24)}
.profile .pbtn .pav{position:relative;width:28px;height:28px;border-radius:50%;background:var(--brand);color:var(--blue);display:grid;place-items:center;font-weight:800;font-size:.78rem;overflow:hidden}
.profile .pbtn .pav img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}
.profile .pmenu{position:absolute;right:0;top:calc(100% + 6px);background:#fff;border:1px solid #e6e9ef;border-radius:10px;box-shadow:0 10px 28px rgba(0,0,0,.18);min-width:210px;overflow:hidden;z-index:5000;display:none}
.profile .pmenu.open{display:block}
.profile .pmenu .phead{padding:.7rem .9rem;border-bottom:1px solid #eef1f6}
.profile .pmenu .phead .n{font-weight:700;font-size:.9rem}
.profile .pmenu .phead .e{color:var(--muted);font-size:.78rem}
.profile .pmenu a{display:flex;align-items:center;gap:.55rem;padding:.6rem .9rem;color:#1f2430;text-decoration:none;font-size:.9rem;cursor:pointer}
.profile .pmenu a:hover{background:#f1f5f9}
.profile .pmenu a .ic{color:var(--muted)}
.profile .pmenu a.danger{color:#b91c1c;border-top:1px solid #eef1f6}
.profile .pmenu a.danger .ic{color:#b91c1c}
/* ---- Shell ---- */
.shell{flex:1 1 auto;display:flex;min-height:0;}
/* ---- Icon rail ---- */
.rail{width:74px;flex:0 0 74px;background:var(--card);border-right:1px solid var(--line);display:flex;flex-direction:column;align-items:center;padding:.8rem 0;gap:.4rem;}
/* Bell + profile relocated to the bottom of the rail on desktop. */
#rail #hdrRight{display:flex;flex-direction:column;align-items:center;gap:.55rem;margin-top:auto;padding:.5rem 0 .2rem;}
#rail #hdrRight .pav{width:34px;height:34px;font-size:.85rem;}
.railbtn{position:relative;width:50px;height:50px;border:none;background:transparent;border-radius:14px;color:var(--muted);cursor:pointer;display:grid;place-items:center;transition:background .12s,color .12s;}
.railbtn:hover{background:var(--blue-soft);color:var(--blue);}
.railbtn.active{background:var(--blue);color:#fff;}
.railbtn .rdot{position:absolute;top:8px;right:8px;min-width:16px;height:16px;border-radius:99px;background:var(--brand);color:var(--blue);font-size:.62rem;font-weight:800;display:grid;place-items:center;padding:0 .2rem;border:2px solid var(--card);}
.railbtn.active .rdot{border-color:var(--blue);}
.railbtn .livedot{position:absolute;top:8px;right:8px;width:11px;height:11px;border-radius:50%;background:var(--green);border:2px solid var(--card);display:none;}
.railbtn.active .livedot{border-color:var(--blue);}
.railbtn.live .livedot{display:block;animation:livePulse 1.4s infinite;}
@keyframes livePulse{0%,100%{opacity:1}50%{opacity:.3}}
.railbtn .rlabel{display:none;font-size:.6rem;margin-top:0;}
/* tooltip */
.railbtn::after{content:attr(data-tip);position:absolute;left:calc(100% + 12px);top:50%;transform:translateY(-50%);background:var(--blue-d);color:#fff;padding:.35rem .6rem;border-radius:8px;font-size:.78rem;font-weight:600;white-space:nowrap;opacity:0;pointer-events:none;transition:opacity .14s;z-index:200;box-shadow:0 6px 16px rgba(0,0,0,.25);}
.railbtn::before{content:"";position:absolute;left:calc(100% + 6px);top:50%;transform:translateY(-50%);border:6px solid transparent;border-right-color:var(--blue-d);opacity:0;pointer-events:none;transition:opacity .14s;z-index:200;}
.railbtn:hover::after,.railbtn:hover::before{opacity:1;}
.rail-spacer{flex:1 1 auto;}
.caption{font-size:.58rem;color:var(--muted);text-align:center;line-height:1.2;}
/* ---- Chat list column ---- */
.chatcol{width:312px;flex:0 0 312px;background:var(--card);border-right:1px solid var(--line);display:flex;flex-direction:column;min-height:0;}
.chatcol.hidden{display:none;}
.side-sec{font-size:.68rem;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);font-weight:700;padding:.6rem .9rem .25rem;}
.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;}
/* Chat-list header (now the app's top area — the blue bar is gone). */
.side-head{padding:.85rem 1rem .75rem;border-bottom:1px solid var(--line);background:linear-gradient(180deg,#f3f7fd 0%,var(--card) 100%);}
.side-title{display:flex;align-items:center;justify-content:space-between;gap:.5rem;margin-bottom:.7rem;}
.side-title h2{font-size:1.15rem;margin:0;color:var(--blue);font-weight:800;letter-spacing:-.01em;flex:1;}
.side-title #hdrRight{display:flex;align-items:center;gap:.45rem;flex:0 0 auto;}
.side-search-row{display:flex;align-items:center;gap:.5rem;}
.side-search-row .search{flex:1;min-width:0;}
.newchat{width:38px;height:38px;flex:0 0 auto;border-radius:10px;border:none;background:var(--blue);color:#fff;font-size:1.35rem;line-height:1;cursor:pointer;font-weight:700;display:grid;place-items:center;padding:0;box-shadow:0 2px 8px rgba(31,59,115,.25);}
.newchat:hover{filter:brightness(1.08);}
.search{position:relative;}
.search > svg{position:absolute;left:.65rem;top:50%;transform:translateY(-50%);color:var(--muted);}
.search input{width:100%;padding:.55rem .7rem .55rem 2.1rem;border-radius:10px;border:2px solid var(--line);background:#fbfcfe;color:var(--ink);font-size:.9rem;}
.search input:focus{outline:none;border-color:var(--brand);}
.search input{padding-right:2.1rem;}
.search-x{position:absolute;right:.5rem;top:50%;transform:translateY(-50%);border:none;background:transparent;color:var(--muted);cursor:pointer;padding:.2rem;border-radius:6px;display:grid;place-items:center;}
.search-x:hover{color:var(--blue);background:var(--blue-soft);}
.chatlist{overflow-y:auto;flex:1 1 auto;padding:.4rem;scrollbar-width:thin;scrollbar-color:transparent transparent;}
.chatlist:hover{scrollbar-color:#c7d0dd transparent;}
.chatlist::-webkit-scrollbar{width:7px;}
.chatlist::-webkit-scrollbar-button{display:none;height:0;width:0;}
.chatlist::-webkit-scrollbar-thumb{background:transparent;border-radius:8px;}
.chatlist:hover::-webkit-scrollbar-thumb{background:#c7d0dd;}
/* Pull-to-refresh indicator (mobile) */
.ptr-ind{position:absolute;top:6px;left:50%;width:32px;height:32px;margin-left:-16px;border-radius:50%;background:var(--card);box-shadow:0 2px 10px rgba(20,30,60,.18);display:grid;place-items:center;color:var(--blue);z-index:40;opacity:0;transform:translateY(-48px);transition:opacity .12s;pointer-events:none;}
.ptr-ind .ptr-g{font-size:1.15rem;font-weight:700;line-height:1;display:inline-block;}
.ptr-ind.spin .ptr-g{animation:ptrspin .7s linear infinite;}
@keyframes ptrspin{to{transform:rotate(360deg);}}
/* Enable-notifications prompt (esp. iOS PWA) */
.notif-prompt{position:fixed;left:50%;transform:translateX(-50%);bottom:calc(74px + env(safe-area-inset-bottom,0));z-index:2000;display:flex;align-items:center;gap:.7rem;width:max-content;max-width:min(460px,94vw);background:var(--blue);color:#fff;padding:.6rem .7rem .6rem 1rem;border-radius:12px;box-shadow:0 10px 30px rgba(20,30,60,.32);font-size:.86rem;}
.notif-prompt .np-actions{display:flex;align-items:center;gap:.4rem;margin-left:auto;flex:0 0 auto;}
.notif-prompt .btn.sm{background:#fff;color:var(--blue);}
.notif-prompt .np-x{background:transparent;border:none;color:#fff;opacity:.85;cursor:pointer;display:grid;place-items:center;padding:.2rem;}
@media(min-width:761px){ .notif-prompt{bottom:18px;} }
.perm-state{font-size:.68rem;font-weight:700;padding:.06rem .45rem;border-radius:99px;vertical-align:middle;}
.perm-state.granted{background:#dcfce7;color:#15803d;}
.perm-state.denied{background:#fee2e2;color:#b91c1c;}
.perm-state.default,.perm-state.unsupported{background:#f1f5f9;color:#475569;}
.chat-row{display:flex;gap:.7rem;align-items:center;padding:.6rem .65rem;border-radius:12px;cursor:pointer;position:relative;}
.chat-row:hover{background:#f3f6fb;}
.chat-row.active{background:var(--blue-soft);box-shadow:inset 3px 0 0 var(--brand);}
.chat-row.active::before{content:"";position:absolute;left:0;top:.7rem;bottom:.7rem;width:3px;border-radius:3px;background:var(--blue);}
.avatar{width:42px;height:42px;flex:0 0 42px;border-radius:50%;display:grid;place-items:center;color:#334155;font-weight:700;font-size:.92rem;position:relative;}
.avatar .av-img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;border-radius:inherit;}
.avatar .dot{position:absolute;right:-1px;bottom:-1px;width:11px;height:11px;border-radius:50%;border:2px solid #fff;background:#cbd2dd;z-index:1;}
.avatar .dot.on{background:var(--green);}
/* presence/status dot colors (#7): in-call = solid red; on-leave = circle with a minus. */
.dot.active{background:#16a34a;} .dot.away{background:#f59e0b;} .dot.onleave{background:#dc2626;} .dot.incall{background:#dc2626;} .dot.offline{background:#cbd2dd;}
.st-dot{position:relative;display:inline-block;width:10px;height:10px;border-radius:50%;margin-right:.4rem;vertical-align:middle;background:#cbd2dd;}
.st-dot.active{background:#16a34a;} .st-dot.away{background:#f59e0b;} .st-dot.onleave{background:#dc2626;} .st-dot.incall{background:#dc2626;} .st-dot.offline{background:#cbd2dd;}
/* the "minus in circle" for On leave (on every size of dot) */
.dot.onleave::after,.st-dot.onleave::after,.pav-dot.onleave::after{content:"";position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);width:6px;height:1.6px;background:#fff;border-radius:1px;}
.chat-main{flex:1 1 auto;min-width:0;}
.chat-top{display:flex;justify-content:space-between;align-items:baseline;gap:.5rem;}
.chat-name{font-weight:400;font-size:.92rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.chat-time{color:var(--muted);font-size:.72rem;flex:0 0 auto;}
.chat-bottom{display:flex;justify-content:space-between;align-items:center;gap:.5rem;margin-top:.15rem;}
.chat-prev{color:var(--muted);font-size:.82rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1 1 auto;}
.chat-row.unread .chat-prev{color:var(--ink);font-weight:500;}
.chat-row.unread .chat-name{font-weight:700;}
.badge{flex:0 0 auto;background:var(--brand);color:var(--blue);font-size:.7rem;font-weight:800;min-width:19px;height:19px;border-radius:99px;padding:0 .35rem;display:grid;place-items:center;}
.no-results{padding:2rem 1rem;text-align:center;color:var(--muted);font-size:.85rem;}
.demo-note{padding:.5rem 1rem;border-top:1px solid var(--line);color:var(--muted);font-size:.72rem;text-align:center;background:#fbfcfe;}
/* ---- Main content ---- */
.content{flex:1 1 auto;position:relative;min-width:0;min-height:0;background:var(--bg);}
.panel{position:absolute;inset:0;display:none;}
.panel.active{display:flex;}
.panel.center{align-items:center;justify-content:center;padding:2rem;overflow-y:auto;}
.panel iframe{width:100%;height:100%;border:0;display:block;background:var(--bg);}
/* welcome + feature cards */
.welcome{text-align:center;max-width:560px;}
.welcome .wave{font-size:3rem;line-height:1;margin-bottom:.4rem;}
.welcome h1{font-size:1.8rem;color:var(--blue);margin:.2rem 0 .5rem;}
.welcome p{color:var(--muted);font-size:1rem;line-height:1.6;margin:0 auto 1.8rem;max-width:440px;}
.wcards{display:flex;gap:1rem;flex-wrap:wrap;justify-content:center;}
.wcard{flex:1;min-width:150px;max-width:180px;background:var(--card);border:1px solid var(--line);border-radius:14px;padding:1.2rem 1rem;cursor:pointer;transition:transform .12s,box-shadow .12s,border-color .12s;text-align:center;}
.wcard:hover{transform:translateY(-3px);box-shadow:0 12px 28px rgba(20,30,60,.1);border-color:var(--brand);}
.wcard .wi{width:46px;height:46px;border-radius:12px;display:grid;place-items:center;margin:0 auto .6rem;background:var(--blue-soft);color:var(--blue);}
.wcard h3{margin:0 0 .2rem;font-size:.95rem;color:var(--blue);}
.wcard p{margin:0;font-size:.78rem;color:var(--muted);line-height:1.4;}
.card{background:var(--card);border:1px solid var(--line);border-radius:16px;padding:2.2rem;box-shadow:0 6px 18px rgba(20,30,60,.05);text-align:center;max-width:520px;}
.feat-icon{width:72px;height:72px;border-radius:20px;display:grid;place-items:center;margin:0 auto 1.2rem;}
.feat-icon.yellow{background:#fff6d8;color:var(--brand-d);}
.card h1{font-size:1.45rem;margin:0 0 .5rem;color:var(--blue);}
.card p{color:var(--muted);font-size:.95rem;line-height:1.55;margin:0 auto 1.6rem;max-width:400px;}
.pill-soon{display:inline-block;background:#fff6d8;color:var(--brand-d);font-size:.74rem;font-weight:700;padding:.25rem .7rem;border-radius:99px;letter-spacing:.03em;margin-bottom:1.2rem;}
.btn{display:inline-flex;align-items:center;gap:.5rem;text-decoration:none;padding:.8rem 1.6rem;background:var(--brand);color:var(--ink);border:none;border-radius:11px;font-weight:700;font-size:.95rem;cursor:pointer;}
.btn:hover{background:var(--brand-d);}
.hint{margin-top:1.4rem;font-size:.8rem;color:var(--muted);}
/* conversation placeholder (selected chat, no backend yet) */
.convo{flex-direction:column;display:flex;width:100%;height:100%;}
.convo-head{display:flex;align-items:center;gap:.7rem;padding:.9rem 1.2rem;border-bottom:1px solid var(--line);background:var(--card);position:relative;}
/* NB: #chatPanel is .panel which is already position:absolute (a containing block),
so the drop overlay positions against it. Do NOT add position:relative here — an ID
selector would override .panel{position:absolute;inset:0} and collapse the pane. */
#chatPanel.drag-over::after{content:"Drop to send";position:absolute;inset:10px;border:2.5px dashed var(--blue);border-radius:14px;background:rgba(31,59,115,.07);display:grid;place-items:center;color:var(--blue);font-weight:700;font-size:1.15rem;z-index:60;pointer-events:none;}
.ic{display:inline-block;vertical-align:middle;}
.convo-back{border:none;background:var(--blue-soft);color:var(--blue);width:34px;height:34px;border-radius:9px;cursor:pointer;display:grid;place-items:center;flex:0 0 auto;}
.convo-back:hover{background:#dbe6fb;}
.convo-head .nm{font-weight:700;color:var(--ink);}
.convo-head .st{font-size:.78rem;color:var(--muted);}
/* In-chat search: the header turns into a search bar with count + up/down jump arrows. */
.convo-search-head{position:absolute;inset:0;display:flex;align-items:center;gap:.4rem;padding:0 .8rem;background:var(--card);z-index:6;}
.convo-search-head input{flex:1;min-width:0;border:none;background:transparent;font-size:.95rem;color:var(--ink);}
.convo-search-head input:focus{outline:none;}
.csh-count{font-size:.8rem;color:var(--muted);white-space:nowrap;flex:0 0 auto;min-width:2.5rem;text-align:right;}
.csh-nav{border:none;background:var(--blue-soft);color:var(--blue);width:30px;height:30px;border-radius:8px;cursor:pointer;display:grid;place-items:center;flex:0 0 auto;}
.csh-nav:hover{background:#dbe6fb;}
mark.search-hit{background:#fde68a;color:inherit;border-radius:2px;padding:0 1px;}
mark.search-current{background:#f59e0b;color:#1f2430;}
.gi-media-row{display:flex;align-items:center;justify-content:space-between;width:100%;background:#f6f8fb;border:1px solid var(--line);border-radius:10px;padding:.65rem .8rem;cursor:pointer;color:var(--ink);font-size:.92rem;font-weight:600;}
.gi-media-row:hover{background:#eef2f8;}
.gi-media-row .gmr-l{display:flex;align-items:center;gap:.6rem;}
.gi-media-row .gmr-ic{width:32px;height:32px;flex:0 0 auto;border-radius:9px;background:linear-gradient(135deg,#2563eb,#1F3B73);color:#fff;display:grid;place-items:center;box-shadow:0 2px 6px rgba(31,59,115,.25);}
.gi-media-row .gmr-ic svg{width:17px;height:17px;}
.gi-media-row .gmr-r{color:var(--muted);font-size:.85rem;font-weight:600;}
.gi-media-strip{display:flex;gap:.3rem;margin:.4rem 0 .2rem;overflow:hidden;}
.gms-thumb{width:60px;height:60px;object-fit:cover;border-radius:8px;background:#f1f5f9;cursor:pointer;flex:0 0 auto;}
/* Media / Docs / Links — clean underline tabs (active = green underline, no chip borders) */
.mv-tabs{display:flex;gap:0;border-bottom:1px solid var(--line);margin:.5rem 0 0;}
.mv-tabs .mp-tab{flex:1;border:none;background:transparent;color:var(--muted);font-size:.95rem;font-weight:600;padding:.6rem .3rem .65rem;cursor:pointer;border-bottom:2px solid transparent;margin-bottom:-1px;text-align:center;}
.mv-tabs .mp-tab:hover{color:var(--ink);}
.mv-tabs .mp-tab.on{color:var(--ink);font-weight:700;border-bottom-color:#16a34a;}
.mv-body{max-height:58vh;overflow:auto;margin-top:.5rem;}
/* Shared-media tiles: image thumbnail, or audio/video tile with download + duration */
.sh-cell{margin:0;display:flex;flex-direction:column;gap:.28rem;min-width:0;}
.sh-name{font-size:.7rem;font-weight:700;color:var(--ink);text-transform:uppercase;letter-spacing:.02em;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.sh-av{position:relative;display:block;width:100%;aspect-ratio:1;border-radius:8px;overflow:hidden;text-decoration:none;background:#eef2f8;cursor:pointer;}
.sh-av.aud{background:linear-gradient(135deg,#f7b733,#ee8c1a);}
.sh-av.vid{background:#0b1220;}
.sh-poster{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;pointer-events:none;}
.sh-dl{position:absolute;left:50%;top:46%;transform:translate(-50%,-50%);width:46px;height:46px;border-radius:50%;background:rgba(20,30,60,.28);display:grid;place-items:center;color:#fff;transition:background .12s;}
.sh-av:hover .sh-dl{background:rgba(20,30,60,.45);}
.sh-dur{position:absolute;left:0;right:0;bottom:0;display:flex;align-items:center;gap:.28rem;padding:.28rem .42rem;background:linear-gradient(transparent,rgba(0,0,0,.5));color:#fff;font-size:.74rem;font-weight:600;}
.sh-dur .sh-di{display:inline-flex;} .sh-dur svg{width:13px;height:13px;}
.sh-dur i{font-style:normal;}
.fav-on svg{fill:#f59e0b;color:#f59e0b;}
.sh-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(92px,1fr));gap:.4rem;}
.sh-thumb{width:100%;aspect-ratio:1;object-fit:cover;border-radius:8px;cursor:pointer;background:#f1f5f9;}
.sh-file{display:flex;align-items:center;gap:.55rem;padding:.6rem .55rem;border-bottom:1px solid var(--line);color:var(--ink);text-decoration:none;}
.sh-file:hover{background:#f6f8fb;}
.sh-file .sh-fn{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-size:.9rem;}
.sh-file .sh-sz{font-size:.74rem;color:var(--muted);flex:0 0 auto;}
.sh-file svg{color:var(--blue);flex:0 0 auto;}
.convo-body{flex:1;display:grid;place-items:center;text-align:center;color:var(--muted);padding:2rem;}
.convo-body .big{font-size:2.4rem;margin-bottom:.4rem;}
/* message thread */
.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;}
.new-sep{display:flex;align-items:center;gap:.6rem;margin:.6rem .2rem;color:var(--red);font-size:.72rem;font-weight:700;}
.new-sep::before,.new-sep::after{content:"";flex:1;height:1px;background:#f0c2c2;}
.day-sep span{background:#fde7b0;color:#7a5b05;padding:.22rem .8rem;border-radius:99px;font-weight:600;box-shadow:0 1px 3px rgba(20,30,60,.1);}
.float-date{position:absolute;top:.6rem;left:50%;transform:translateX(-50%);z-index:5;background:#fde7b0;color:#7a5b05;padding:.22rem .85rem;border-radius:99px;font-weight:600;font-size:.72rem;box-shadow:0 3px 10px rgba(20,30,60,.18);pointer-events:none;}
.jump-latest{position:absolute;right:16px;bottom:86px;z-index:5;width:42px;height:42px;border-radius:50%;border:1px solid var(--line);background:var(--card);color:var(--blue);box-shadow:0 4px 14px rgba(20,30,60,.22);cursor:pointer;display:grid;place-items:center;}
.jump-latest:hover{background:var(--brand);color:var(--blue);border-color:var(--brand-d);}
.empty-thread{align-self:center;font-size:.85rem;color:var(--muted);margin:auto;}
.sys-msg{align-self:center;font-size:.76rem;color:var(--muted);background:rgba(0,0,0,.04);padding:.25rem .7rem;border-radius:99px;margin:.2rem 0;max-width:90%;text-align:center;}
.bubble .t{display:flex;align-items:center;justify-content:flex-end;gap:.25rem;font-size:.64rem;opacity:.65;margin-top:.15rem;}
.bubble .rcpt{display:inline-flex;}
.bubble.mine .rcpt{opacity:.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:#334155;font-weight:700;font-size:1.9rem;overflow:hidden;}
.meet-tile .meet-av img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;}
.meet-tile .meet-mute{position:absolute;left:.5rem;top:.5rem;width:26px;height:26px;border-radius:50%;background:#dc2626;color:#fff;place-items:center;box-shadow:0 1px 3px rgba(0,0,0,.4);}
.meet-panel{position:absolute;right:12px;top:12px;bottom:78px;width:280px;max-width:80vw;background:var(--card);border:1px solid var(--line);border-radius:14px;box-shadow:0 14px 40px rgba(0,0,0,.3);z-index:30;display:flex;flex-direction:column;overflow:hidden;}
.meet-panel .mp-head{display:flex;align-items:center;gap:.5rem;padding:.7rem .8rem;border-bottom:1px solid var(--line);font-size:.9rem;}
.meet-panel .mp-muteall{border:1px solid var(--line);background:#fee2e2;color:var(--red);border-radius:8px;padding:.3rem .55rem;font-size:.78rem;font-weight:600;cursor:pointer;display:inline-flex;align-items:center;gap:.25rem;}
.meet-panel .mp-x{border:none;background:transparent;color:var(--muted);cursor:pointer;display:grid;place-items:center;}
.meet-panel .mp-tabs{display:flex;gap:.25rem;padding:.4rem .5rem 0;border-bottom:1px solid var(--line);}
.meet-panel .mp-tab{flex:1;border:none;background:transparent;color:var(--muted);font-size:.8rem;font-weight:600;padding:.45rem .3rem;cursor:pointer;border-bottom:2px solid transparent;display:inline-flex;align-items:center;justify-content:center;gap:.25rem;}
.meet-panel .mp-tab.on{color:var(--blue);border-bottom-color:var(--blue);}
.meet-panel .mp-scroll{flex:1;overflow:auto;}
.meet-panel .mp-list{padding:.4rem;}
.meet-panel .chk{display:flex;align-items:center;gap:.5rem;padding:.4rem .5rem;border-radius:8px;cursor:pointer;font-size:.88rem;}
.meet-panel .chk:hover{background:#f6f8fb;}
.meet-panel .chk input{width:16px;height:16px;flex:0 0 16px;accent-color:var(--blue);margin:0;}
.meet-panel .chk .mn{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.meet-panel .mp-row{display:flex;align-items:center;gap:.5rem;padding:.4rem .5rem;border-radius:8px;}
.meet-panel .mp-row .mn{flex:1;min-width:0;font-size:.88rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.meet-panel .mp-row .pp-mute{color:var(--red);display:inline-flex;}
.meet-panel .mp-sec{font-size:.68rem;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);font-weight:700;margin:.7rem .5rem .15rem;}
.meet-panel .mp-row.waiting{opacity:.7;}
.meet-panel .mp-row .pp-wait{margin-left:auto;font-size:.72rem;color:var(--muted);display:inline-flex;align-items:center;gap:.25rem;white-space:nowrap;}
.meet-panel .mp-makehost{border:none;background:var(--blue-soft);color:var(--blue);border-radius:7px;padding:.2rem .4rem;cursor:pointer;display:grid;place-items:center;}
.host-tag{display:inline-flex;align-items:center;gap:.2rem;font-size:.62rem;font-weight:700;background:#fff3cd;color:#7a5b05;padding:.05rem .35rem;border-radius:99px;}
.admin-tag{display:inline-flex;align-items:center;gap:.2rem;font-size:.6rem;font-weight:700;background:#fef3c7;color:#92600b;padding:.05rem .4rem;border-radius:99px;vertical-align:middle;}
.iconbtn.role{color:var(--muted);} .iconbtn.role.is-admin{color:#d4a106;} .iconbtn.role:hover{color:#d4a106;}
.si-invited{display:inline-flex;align-items:center;gap:.3rem;font-size:.76rem;color:var(--muted);margin-top:.25rem;}
.meet-tile.novid .meet-av{display:flex;}
.meet-tile.novid video{visibility:hidden;}
.meet-bar{display:flex;align-items:center;gap:.6rem;padding:.7rem 1rem;background:var(--card);border-top:1px solid var(--line);}
.meet-bar .code{margin-right:auto;color:var(--muted);font-size:.85rem;}
.meet-bar .code b{color:var(--blue);font-size:1rem;letter-spacing:.06em;}
.meet-bar button{border:none;border-radius:10px;padding:.6rem 1rem;font-weight:600;cursor:pointer;font-size:.9rem;display:inline-flex;align-items:center;gap:.35rem;}
.meet-bar .tgl{background:#eef1f6;color:var(--blue);}
.meet-bar .tgl.off{background:#fee2e2;color:var(--red);}
.meet-bar .leave{background:#dc2626;color:#fff;}
.meet-bar .meet-ic{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;pointer-events:none;transition:opacity .12s;box-shadow:0 1px 3px rgba(0,0,0,.12);}
.bubble:hover .reply-btn,.bubble.show-actions .reply-btn{opacity:1;pointer-events:auto;}
.reply-bar{display:flex;align-items:center;gap:.5rem;padding:.45rem .8rem;border-top:1px solid var(--line);background:#eef3fb;font-size:.82rem;color:var(--muted);}
.reply-bar b{color:var(--ink);}
.reply-bar .rx{margin-left:auto;cursor:pointer;font-size:1rem;}
.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;pointer-events:none;transition:opacity .12s;box-shadow:0 1px 3px rgba(0,0,0,.12);}
.bubble:hover .react-btn,.bubble.show-actions .react-btn{opacity:1;pointer-events:auto;}
.del-btn{position:absolute;top:-9px;right:58px;background:var(--card);color:var(--red);border:1px solid var(--line);border-radius:50%;width:22px;height:22px;line-height:1;cursor:pointer;opacity:0;pointer-events:none;transition:opacity .12s;box-shadow:0 1px 3px rgba(0,0,0,.12);display:grid;place-items:center;}
.bubble:hover .del-btn,.bubble.show-actions .del-btn{opacity:1;pointer-events:auto;}
.bubble.deleted{opacity:.85;}
.bubble.deleted .del-msg{font-style:italic;color:var(--muted);font-size:.9rem;display:inline-flex;align-items:center;gap:.3rem;}
.bubble.mine.deleted .del-msg{color:rgba(255,255,255,.85);}
.reacts{display:flex;flex-wrap:wrap;gap:.2rem;margin-top:.3rem;}
.react-chip{border:1px solid var(--line);background:var(--card);color:var(--ink);border-radius:999px;font-size:.74rem;padding:.05rem .4rem;cursor:pointer;line-height:1.5;}
.react-chip.mine{background:var(--blue-soft);border-color:#c7d6f0;color:var(--blue);}
/* 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:20px;height:20px;flex:0 0 20px;border-radius:50%;display:grid;place-items:center;color:#334155;font-weight:800;font-size:.58rem;overflow:hidden;}
.bubble .sender .snd-av img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;}
.avatar.grp{border-radius:12px;font-size:1.15rem;}
.modal-ov{position:fixed;inset:0;background:rgba(15,23,42,.45);display:flex;align-items:center;justify-content:center;z-index:9800;padding:1rem;}
.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-fav.on{color:#f59e0b;}
.convo-fav.on svg{fill:#f59e0b;}
.gi-title-row{display:flex;align-items:center;gap:.35rem;}
.fav-star{border:none;background:transparent;color:var(--muted);cursor:pointer;padding:.12rem;display:grid;place-items:center;border-radius:6px;flex:0 0 auto;}
.fav-star:hover{background:#f1f5f9;}
.fav-star.on{color:#f59e0b;} .fav-star.on svg{fill:#f59e0b;}
.status-dot{display:inline-block;width:8px;height:8px;border-radius:50%;background:#cbd5e1;margin-right:.35rem;vertical-align:middle;}
.status-dot.on{background:#16a34a;}
.convo-info:hover{background:#dbe4f0;}
.convo-call{border:none;background:var(--blue-soft);color:var(--blue);height:32px;border-radius:9px;cursor:pointer;flex:0 0 auto;display:inline-flex;align-items:center;gap:.3rem;padding:0 .6rem;font-weight:600;}
.convo-call:hover{background:#dbe4f0;}
.convo-call.joinable{background:#dcfce7;color:#15803d;}
.convo-call.joinable:hover{background:#bbf7d0;}
.convo-call span{font-size:.84rem;}
.call-on{display:inline-flex;align-items:center;gap:.3rem;color:#15803d;font-weight:600;}
.call-invite{position:fixed;right:18px;bottom:18px;z-index:6000;display:flex;align-items:center;gap:.7rem;background:#fff;border:1px solid var(--line);border-left:4px solid #15803d;border-radius:14px;padding:.7rem .9rem;box-shadow:0 12px 30px rgba(20,30,60,.25);max-width:340px;}
.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:#334155;font-weight:700;font-size:.72rem;}
.mini-av .av-img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;}
.gi-photo{position:relative;border:none;background:transparent;padding:0;cursor:pointer;flex:0 0 auto;}
.gi-photo .avatar.grp{flex:0 0 46px;}
.gi-photo-cam{position:absolute;right:-4px;bottom:-4px;width:22px;height:22px;border-radius:50%;background:var(--blue);color:#fff;display:grid;place-items:center;border:2px solid var(--card);box-shadow:0 1px 3px rgba(0,0,0,.22);}
.gi-photo-cam .ic{width:12px;height:12px;}
.gi-photo:hover .gi-photo-cam{filter:brightness(1.12);}
.youtag{font-size:.62rem;background:var(--blue-soft);color:var(--blue);padding:.05rem .35rem;border-radius:99px;margin-left:.3rem;vertical-align:middle;text-transform:uppercase;letter-spacing:.03em;}
.gi-add{margin-top:.4rem;border:1px solid var(--line);border-radius:10px;padding:.5rem;}
.gi-add .chk{display:flex;align-items:center;gap:.5rem;padding:.25rem;border-radius:6px;cursor:pointer;}
.gi-add .chk:hover{background:#f6f8fb;}
.gi-leave{width:100%;margin-top:.9rem;border:1px solid #fecaca;background:#fff;color:var(--red);border-radius:10px;padding:.55rem;font-weight:600;cursor:pointer;display:flex;align-items:center;justify-content:center;gap:.4rem;}
.gi-leave:hover{background:#fee2e2;}
.grp-members{max-height:220px;overflow-y:auto;border:1px solid var(--line);border-radius:10px;padding:.5rem .6rem;display:flex;flex-direction:column;gap:.4rem;}
.modal .chk{display:flex;align-items:center;gap:.5rem;font-size:.9rem;cursor:pointer;}
.modal .chk input{width:16px;height:16px;accent-color:var(--blue);margin:0;}
.modal-actions{display:flex;gap:.6rem;margin-top:1rem;}
.modal-actions .gobtn{flex:1;border:none;border-radius:10px;padding:.6rem;font-weight:700;cursor:pointer;}
/* ---- Login (shown on /home when logged out) ---- */
.authwrap{flex:1 1 auto;display:none;align-items:center;justify-content:center;padding:1.5rem;min-height:0;}
.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){
/* #7: drop the top blue bar entirely on mobile to use the full screen. bell+profile are
relocated into the chat-list header (see placeHdrRight). The shell is pushed below the
device status bar so no view overlaps it. */
header{display:none;}
.shell{padding-top:env(safe-area-inset-top,0);}
.side-title{gap:.5rem;}
.side-title h2{flex:1;}
.side-title #hdrRight{display:flex;align-items:center;gap:.4rem;flex:0 0 auto;}
.brand{font-size:.98rem;}
.navtoggle{display:none;} .rail-backdrop{display:none!important;} /* bottom nav replaces the drawer */
/* App-style bottom navigation bar */
.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:calc(60px + env(safe-area-inset-bottom,0));}
body.chat-open .chatcol{display:none;}
.content{display:none;}
body.chat-open .content, .shell:not(.is-chat) .content{display:block;}
.shell:not(.is-chat) .chatcol{display:none;}
.content .panel{bottom:calc(60px + env(safe-area-inset-bottom,0));} /* clear the bottom nav + home indicator */
/* Bigger touch targets */
.chat-row{padding:.7rem .85rem;}
.convo-head{padding:.7rem .85rem;}
.modal{width:94vw;}
/* Meeting + embedded panels full-width, controls above the nav */
.meet-grid{grid-template-columns:repeat(auto-fit,minmax(150px,1fr));}
.meet-bar{flex-wrap:wrap;gap:.4rem;padding:.5rem .6rem;}
.meet-bar .code{flex:1 1 100%;margin:0 0 .2rem;}
.meet-bar .meet-ic{width:44px;height:44px;}
.meet-panel{width:90vw;max-width:none;right:5vw;top:6px;bottom:74px;}
.md-top{flex-direction:column;}
/* Code+Join on a full-width row; Start & Schedule as two equal halves below */
.md-actions{width:100%;display:grid;grid-template-columns:1fr 1fr;gap:.5rem;}
.md-join{grid-column:1 / -1;}
.md-actions > .btn{width:100%;justify-content:center;}
.md-join{flex:1 1 100%;}
.call-invite{left:8px;right:8px;max-width:none;bottom:70px;}
.bell-menu{position:fixed;left:8px;right:8px;top:calc(56px + env(safe-area-inset-top,0));width:auto;}
#rail .bell-menu,#rail .profile .pmenu{left:auto;} /* desktop rail rule must not leak to the mobile bottom-nav */
/* Touch devices have no hover: keep member-row actions (make-admin / remove) always visible */
.mrow .iconbtn{opacity:1;}
}
</style>
</head>
<body>
<script src="/icons.js?v=4"></script>
<script>window.__BUILD='2026-06-30-batch15';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD);</script>
<div class="loading" id="loading">Loading…</div>
<header>
<div class="brandrow">
<button class="navtoggle" id="navToggle" title="Toggle menu" aria-label="Toggle menu"><svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg></button>
<img src="/logo.png" alt="" style="height:46px;width:auto;max-width:190px;border-radius:8px;object-fit:contain;background:#fff;padding:5px 12px;image-rendering:-webkit-optimize-contrast" onerror="this.replaceWith(Object.assign(document.createElement('div'),{className:'logo',textContent:'B'}))">
<div class="brand">Biz <span class="y">Connect</span></div>
</div>
<div id="hdrRight"></div>
</header>
<div class="shell is-chat">
<div class="rail-backdrop" id="railBackdrop"></div>
<!-- ---------- Icon rail ---------- -->
<nav class="rail" id="rail">
<button class="railbtn active" data-tab="chat" data-tip="Chat" aria-label="Chat">
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
<span class="rdot" id="railUnread" style="display:none">0</span><span class="rlabel">Chat</span>
</button>
<button class="railbtn" data-tab="share" data-tip="Share Screen" aria-label="Share Screen">
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
<span class="livedot"></span><span class="rlabel">Share</span>
</button>
<button class="railbtn" data-tab="connect" data-tip="Connect Screen" aria-label="Connect Screen">
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M1.42 9a16 16 0 0 1 21.16 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><line x1="12" y1="20" x2="12.01" y2="20"/></svg>
<span class="livedot"></span><span class="rlabel">Connect</span>
</button>
<button class="railbtn" data-tab="meeting" data-tip="Meeting" aria-label="Meeting">
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="23 7 16 12 23 17 23 7"/><rect x="1" y="5" width="15" height="14" rx="2" ry="2"/></svg>
<span class="livedot"></span><span class="rlabel">Meet</span>
</button>
<div class="rail-spacer"></div>
</nav>
<!-- ---------- Chat list (Chat tab only) ---------- -->
<aside class="chatcol" id="chatcol">
<div class="side-head">
<div class="side-title">
<h2>Chats</h2>
</div>
<div class="side-search-row">
<div class="search">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input id="chatSearch" placeholder="Search chats" autocomplete="off">
<button class="search-x" id="chatSearchX" title="Clear" aria-label="Clear" style="display:none"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg></button>
</div>
<button class="newchat" id="newChat" title="New group / chat" aria-label="New group">+</button>
</div>
</div>
<div class="chatlist" id="chatlist"></div>
<div class="demo-note">💬 Messages with your BizGaze teammates</div>
</aside>
<!-- ---------- Main content ---------- -->
<main class="content">
<!-- Chat panel: welcome (no selection) OR conversation placeholder -->
<div class="panel center active" data-panel="chat" id="chatPanel"></div>
<!-- Share -->
<div class="panel" data-panel="share" id="sharePanel"></div>
<!-- Connect -->
<div class="panel" data-panel="connect" id="connectPanel"></div>
<!-- Meeting (mesh video; rendered by JS) -->
<div class="panel" data-panel="meeting" id="meetingPanel"></div>
</main>
</div>
<div class="authwrap" id="authwrap"></div>
<div class="toast" id="toast"></div>
<script>
// ---------- Helpers ----------
function pEsc(s){return String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));}
function initials(name){const p=String(name||'?').trim().split(/\s+/);return ((p[0]||'?')[0]+(p[1]?p[1][0]:'')).toUpperCase();}
function fmtDateTime(ts){ if(!ts) return ''; const d=new Date(ts); return d.toLocaleDateString([],{year:'numeric',month:'short',day:'numeric'})+' · '+d.toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'}); }
function fmtClock(ts){ if(!ts) return ''; return new Date(ts).toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'}); } // bubble time = clock only; date is the center separator
function autoGrow(el){ if(!el) return; el.style.height='auto'; const max=140; el.style.height=Math.min(el.scrollHeight,max)+'px'; el.style.overflowY=el.scrollHeight>max?'auto':'hidden'; }
// Mild, light tint for a quoted reply, color-coded by who is being quoted. [bg, bar]
const REPLY_TINTS=[['#eef4ff','#3b6fd4'],['#eafaf2','#1f9d57'],['#fef4e9','#d98324'],['#f6eefe','#8b46c9'],['#fdeef3','#d6457f'],['#e9fafa','#179a9a'],['#fef7e6','#c9a227']];
function replyTint(key){ let h=0; const s=String(key||''); for(let i=0;i<s.length;i++) h=(h*31+s.charCodeAt(i))>>>0; return REPLY_TINTS[h%REPLY_TINTS.length]; }
function firstName(name){return String(name||'').trim().split(/\s+/)[0]||'there';}
// Soft pastel avatar backgrounds (with dark lettering) for a light, professional look.
const AV_COLORS=['#dbeafe','#e0e7ff','#dcfce7','#cffafe','#fae8ff','#fce7f3','#fee2e2','#fef3c7','#ecfccb','#fde4cf'];
function avColor(name){let h=0;for(const c of String(name))h=(h*31+c.charCodeAt(0))>>>0;return AV_COLORS[h%AV_COLORS.length];}
let toastTimer=null;
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()">':'';
const cur=(u&&u.status)||'active';
const lblOf=(st)=>st==='away'?'Away':st==='onleave'?'On leave':'Available';
const opt=(st)=>'<a class="ps-opt'+(cur===st?' on':'')+'" data-st="'+st+'"><span class="st-dot '+st+'"></span> '+lblOf(st)+'<span class="ps-check">'+ic('check',14)+'</span></a>';
return '<div class="profile"><button class="pbtn icon-only" id="pbtn" title="'+pEsc(display)+'">'
+ '<span class="pav">'+pEsc(initials(display))+img+'</span><span class="pav-dot '+statusCls({online:true,status:cur})+'"></span></button>'
+ '<div class="pmenu" id="pmenu">'
+ '<div class="phead"><div class="n">'+pEsc(display)+'</div><div class="e">'+pEsc(u.email)+(u.role?' · '+pEsc(u.role):'')+'</div></div>'
+ '<a class="ps-current" id="psStatusToggle"><span class="st-dot '+cur+'"></span><span class="ps-cur-label">'+lblOf(cur)+'</span><span class="ps-arrow">'+ic('chevronDown',14)+'</span></a>'
+ '<div class="ps-options" id="psOptions" style="display:none">'+opt('active')+opt('away')+opt('onleave')+'</div>'
+ '<div class="ps-div"></div>'
+ '<a href="/dashboard">'+ic('layoutDashboard',16)+' Dashboard</a>'
+ '<a id="psettings">'+ic('settings',16)+' Settings</a>'
+ '<a class="danger" id="plogout">'+ic('logOut',16)+' Logout</a>'
+ '</div></div>';
}
function wireProfile(){
const btn=document.getElementById('pbtn'),menu=document.getElementById('pmenu');
if(!btn)return;
btn.onclick=(e)=>{e.stopPropagation();const bm=document.getElementById('bellMenu');if(bm)bm.classList.remove('open');menu.classList.toggle('open');};
document.addEventListener('click',()=>menu.classList.remove('open'));
const lo=document.getElementById('plogout');
if(lo)lo.onclick=async()=>{ await unsubscribePush(); try{await fetch('/api/logout',{method:'POST'});}catch(_){} location.href='/';};
const ps=document.getElementById('psettings');
if(ps)ps.onclick=()=>{ menu.classList.remove('open'); openSettings(); };
const stog=menu.querySelector('#psStatusToggle'); if(stog) stog.onclick=(e)=>{ e.stopPropagation(); const o=menu.querySelector('#psOptions'); if(o) o.style.display=(o.style.display==='none'?'block':'none'); };
menu.querySelectorAll('.ps-opt').forEach(a=>a.onclick=async(e)=>{ e.stopPropagation(); const st=a.dataset.st; ME.status=st;
const cd=menu.querySelector('.ps-current .st-dot'); if(cd) cd.className='st-dot '+st;
const cl=menu.querySelector('.ps-cur-label'); if(cl) cl.textContent=(st==='away'?'Away':st==='onleave'?'On leave':'Available');
menu.querySelectorAll('.ps-opt').forEach(o=>{ o.classList.toggle('on', o===a); }); const opts=menu.querySelector('#psOptions'); if(opts) opts.style.display='none';
const pd=document.querySelector('.profile .pav-dot'); if(pd) pd.className='pav-dot '+st;
try{ await postJSON('/api/me/status',{status:st}); }catch(_){}
});
}
// ---------- Notification bell (activity center) ----------
// Captures things that otherwise have no home: scheduled-meeting invites, reminders, reactions to
// your messages, new polls, and new chat requests. Stored per user in this browser.
let NOTIFS=[];
function notifKey(){ return 'notifs_'+((ME&&ME.id)||''); }
function loadNotifs(){ try{ NOTIFS=JSON.parse(localStorage.getItem(notifKey())||'[]'); }catch(_){ NOTIFS=[]; } if(!Array.isArray(NOTIFS)) NOTIFS=[]; updateBellBadge(); }
function saveNotifs(){ try{ NOTIFS=NOTIFS.slice(0,50); localStorage.setItem(notifKey(), JSON.stringify(NOTIFS)); }catch(_){} }
function addNotif(n){ NOTIFS.unshift(Object.assign({ id:'n'+Date.now()+'_'+Math.round((window.performance&&performance.now())||Math.random()*1e6), ts:Date.now(), read:false }, n)); saveNotifs(); updateBellBadge(); const menu=document.getElementById('bellMenu'); if(menu&&menu.classList.contains('open')) renderBell(); }
function bellHTML(){ return '<div class="bell" id="bellWrap"><button class="bellbtn" id="bellBtn" title="Notifications" aria-label="Notifications">'+ic('bell',20)+'<span class="bell-dot" id="bellDot" style="display:none">0</span></button><div class="bell-menu" id="bellMenu"></div></div>'; }
function updateBellBadge(){ const d=document.getElementById('bellDot'); if(!d) return; const n=NOTIFS.filter(x=>!x.read).length; if(n>0){ d.textContent=n>99?'99+':n; d.style.display='grid'; } else d.style.display='none'; }
function renderBell(){ const menu=document.getElementById('bellMenu'); if(!menu) return;
const items=NOTIFS.length?NOTIFS.map(n=>'<div class="bell-item'+(n.read?'':' unread')+'" data-id="'+n.id+'"><span class="bell-ico">'+ic(n.icon||'bell',16)+'</span><div class="bell-body"><div class="bell-tx">'+n.text+'</div><div class="bell-tm">'+fmtTime(n.ts)+'</div></div></div>').join(''):'<div class="bell-empty">No notifications yet</div>';
menu.innerHTML='<div class="bell-head"><span>Notifications</span>'+(NOTIFS.length?'<button id="bellClear">Clear all</button>':'')+'</div><div class="bell-list">'+items+'</div>';
const cl=menu.querySelector('#bellClear'); if(cl) cl.onclick=(e)=>{ e.stopPropagation(); NOTIFS=[]; saveNotifs(); updateBellBadge(); renderBell(); };
menu.querySelectorAll('.bell-item').forEach(el=>el.onclick=()=>{ const n=NOTIFS.find(x=>x.id===el.dataset.id); if(n) openNotif(n); });
}
function openNotif(n){ NOTIFS=NOTIFS.filter(x=>x.id!==n.id); saveNotifs(); updateBellBadge(); const menu=document.getElementById('bellMenu'); if(menu) menu.classList.remove('open');
if(n.link){ if(n.link.kind==='meeting'){ switchTab('meeting'); loadScheduledMeetings(); } else if(n.link.kind==='dm'||n.link.kind==='group'){ switchTab('chat'); selectChat(n.link.kind, n.link.id); } } // meeting → just open the tab, don't auto-join
renderBell();
}
function wireBell(){ const b=document.getElementById('bellBtn'), menu=document.getElementById('bellMenu'); if(!b||!menu) return;
b.onclick=(e)=>{ e.stopPropagation(); const pm=document.getElementById('pmenu'); if(pm) pm.classList.remove('open'); const open=menu.classList.toggle('open'); if(open){ renderBell(); NOTIFS.forEach(x=>x.read=true); saveNotifs(); updateBellBadge(); } };
document.addEventListener('click',()=>menu.classList.remove('open')); menu.onclick=(e)=>e.stopPropagation(); updateBellBadge();
}
// Settings: notification preferences (browser permission + per-type), stored per browser.
function openSettings(){
if(document.getElementById('setModal')) return;
const ov=document.createElement('div'); ov.className='modal-ov'; ov.id='setModal';
const sw=(id,label,on)=>'<label class="gi-setting"><span>'+label+'</span><span class="switch"><input type="checkbox" id="'+id+'"'+(on?' checked':'')+'><span class="slider"></span></span></label>';
const perm=('Notification' in window) ? Notification.permission : 'unsupported';
const granted=perm==='granted';
const permLabel=granted?'Allowed':(perm==='denied'?'Denied':(perm==='default'?'Not set':'Unsupported'));
ov.innerHTML='<div class="modal sched"><div class="gi-head" style="margin-bottom:.6rem"><div class="avatar grp" style="width:40px;height:40px;flex:0 0 40px;background:var(--blue)">'+ic('settings',20)+'</div>'
+'<div class="gi-name"><div class="gi-title">Settings</div><div class="gi-sub">Notifications</div></div>'
+'<button class="iconbtn" id="setClose" title="Close">'+ic('x',18)+'</button></div>'
+sw('setGroup','Group message notifications', notifOn('group'))
+sw('setDm','Direct message notifications', notifOn('dm'))
+'<label class="gi-setting"><span>Notifications <span class="perm-state '+perm+'">'+permLabel+'</span><div style="font-size:.72rem;color:var(--muted);font-weight:400;margin-top:.15rem">Pop up messages &amp; calls even when this tab isnt open</div></span>'+(granted?'':'<button class="btn sm" id="setPerm">'+(perm==='denied'?'Allow':'Enable')+'</button>')+'</label>'
+'<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 permBtn=ov.querySelector('#setPerm'); if(permBtn) permBtn.onclick=async()=>{ try{ const r=await Notification.requestPermission(); if(r==='granted'){ try{ await subscribePush(); }catch(_){} } }catch(_){} ov.remove(); openSettings(); }; // just trigger the browser prompt + refresh the state (no toast)
}
// ---------- Chat (1:1 + groups) ----------
let ME={};
let CONTACTS=[]; // team users (for new DMs / picking group members)
let ROWS=[]; // sidebar items: {kind:'dm'|'group', id, name, online?, members?, last_body, last_at, last_from_me, unread}
let selected=null; // {kind,id} or null = welcome
let convoIsGroup=false; // the open thread is a group (drives per-message sender labels)
let THREAD=[];
const THREAD_CACHE=new Map(); // key 'kind:id' -> messages[] ; lets a notification click render synchronously (paints immediately)
// Pull-to-refresh (mobile): drag down at the top of a scroll area to refresh. Touch-only, so it's
// a no-op on desktop. onRefresh is an async function.
function enablePullRefresh(el, onRefresh){
if(!el || el.__ptr) return; el.__ptr=true;
if(getComputedStyle(el).position==='static') el.style.position='relative';
const ind=document.createElement('div'); ind.className='ptr-ind'; ind.innerHTML='<span class="ptr-g">↻</span>'; el.appendChild(ind);
let startY=0, pulling=false, dist=0, busy=false; const TRIGGER=70;
el.addEventListener('touchstart',(e)=>{ if(busy||el.scrollTop>0||e.touches.length!==1) return; startY=e.touches[0].clientY; pulling=true; dist=0; },{passive:true});
el.addEventListener('touchmove',(e)=>{ if(!pulling||busy) return; dist=e.touches[0].clientY-startY; if(dist<=0||el.scrollTop>0){ pulling=false; ind.style.opacity='0'; ind.style.transform='translateY(-48px)'; return; } const d=Math.min(dist*0.5,84); ind.style.opacity=String(Math.min(d/56,1)); ind.style.transform='translateY('+(d-48)+'px)'; if(dist>10) e.preventDefault(); },{passive:false});
el.addEventListener('touchend',async()=>{ if(!pulling) return; pulling=false; if(dist>=TRIGGER){ busy=true; ind.classList.add('spin'); ind.style.opacity='1'; ind.style.transform='translateY(14px)'; try{ await onRefresh(); }catch(_){} await new Promise(r=>setTimeout(r,250)); ind.classList.remove('spin'); busy=false; } ind.style.opacity='0'; ind.style.transform='translateY(-48px)'; dist=0; });
}
async function reloadThread(){ if(!selected) return; const kind=selected.kind, id=selected.id; const url=kind==='group'?('/api/messages/thread?group='+encodeURIComponent(id)):('/api/messages/thread?with='+encodeURIComponent(id)); try{ const msgs=await fetch(url).then(r=>r.json()); if(selected&&selected.kind===kind&&selected.id===id&&Array.isArray(msgs)){ THREAD=msgs; THREAD_CACHE.set(kind+':'+id, THREAD.slice()); renderThread(); } }catch(_){} }
// #9: in-chat search — highlight every match and jump between them with up/down (no filtering).
let _searchHits=[], _searchIdx=-1;
function clearSearchHighlights(){
const box=document.getElementById('msgs'); if(box){ box.querySelectorAll('mark.search-hit').forEach(m=>{ const t=document.createTextNode(m.textContent); m.replaceWith(t); }); box.querySelectorAll('.bubble').forEach(b=>{ try{ b.normalize(); }catch(_){} }); }
_searchHits=[]; _searchIdx=-1;
}
function _highlightIn(root, ql){
const hits=[];
const walker=document.createTreeWalker(root, NodeFilter.SHOW_TEXT, { acceptNode(n){ if(!n.nodeValue || n.nodeValue.toLowerCase().indexOf(ql)===-1) return NodeFilter.FILTER_REJECT; const p=n.parentElement; if(!p || p.closest('.reply-btn,.react-btn,.del-btn,.t,.reacts,.react-chip,.seenby,.rcpt,.att-sz,button')) return NodeFilter.FILTER_REJECT; return NodeFilter.FILTER_ACCEPT; } });
const nodes=[]; while(walker.nextNode()) nodes.push(walker.currentNode);
nodes.forEach(n=>{ const txt=n.nodeValue, low=txt.toLowerCase(); let i, last=0; const frag=document.createDocumentFragment();
while((i=low.indexOf(ql,last))!==-1){ if(i>last) frag.appendChild(document.createTextNode(txt.slice(last,i))); const mk=document.createElement('mark'); mk.className='search-hit'; mk.textContent=txt.slice(i,i+ql.length); frag.appendChild(mk); hits.push(mk); last=i+ql.length; }
if(last>0){ if(last<txt.length) frag.appendChild(document.createTextNode(txt.slice(last))); n.parentNode.replaceChild(frag,n); } });
return hits;
}
function runSearch(q){
clearSearchHighlights();
const box=document.getElementById('msgs'), cnt=document.getElementById('convoSearchCount'); if(!box) return;
q=(q||'').trim(); if(!q){ if(cnt) cnt.textContent=''; return; }
_searchHits=_highlightIn(box, q.toLowerCase());
if(!_searchHits.length){ if(cnt) cnt.textContent='0/0'; return; }
gotoHit(_searchHits.length-1); // start at the most recent match
}
function gotoHit(i){
if(!_searchHits.length) return;
_searchIdx=((i%_searchHits.length)+_searchHits.length)%_searchHits.length;
_searchHits.forEach(m=>m.classList.remove('search-current'));
const m=_searchHits[_searchIdx]; m.classList.add('search-current'); try{ m.scrollIntoView({block:'center'}); }catch(_){}
const cnt=document.getElementById('convoSearchCount'); if(cnt) cnt.textContent=(_searchIdx+1)+'/'+_searchHits.length;
}
function closeSearch(){ clearSearchHighlights(); const h=document.getElementById('convoSearchHead'); if(h) h.style.display='none'; const i=document.getElementById('convoSearchInput'); if(i) i.value=''; }
// #10: "Shared" — Media (images) + Files tabs for a DM or group, opened by tapping the chat name.
// Compact "Media, links & docs" entry (shown in the conversation-info window). Clicking it opens
// the separate full Media/Docs/Links view (openMediaView).
function mediaRowHTML(){ return '<button class="gi-media-row" id="giMediaRow"><span class="gmr-l">Media, links &amp; docs</span><span class="gmr-r" id="giMediaCount"></span></button><div class="gi-media-strip" id="giMediaStrip"></div>'; }
async function wireMediaEntry(root, kind, id, name){
let data={media:[],docs:[],links:[]}; try{ const url=kind==='group'?('/api/messages/media?group='+encodeURIComponent(id)):('/api/messages/media?with='+encodeURIComponent(id)); const r=await fetch(url).then(x=>x.json()); if(r&&r.media) data=r; }catch(_){}
const total=data.media.length+data.docs.length+data.links.length;
const cnt=root.querySelector('#giMediaCount'); if(cnt) cnt.textContent=(total||0)+' ';
const strip=root.querySelector('#giMediaStrip'); if(strip){ strip.innerHTML=data.media.filter(x=>x.isImage).slice(0,4).map(x=>'<img class="gms-thumb att-img" src="/files/'+pEsc(x.id)+'" data-img="/files/'+pEsc(x.id)+'" loading="lazy">').join(''); strip.addEventListener('click',e=>{ const im=e.target.closest('.att-img'); if(im&&im.dataset.img) openLightbox(im.dataset.img); }); }
const row=root.querySelector('#giMediaRow'); if(row) row.onclick=()=>openMediaView(kind,id,name,data);
}
function stripExt(n){ n=String(n||''); const i=n.lastIndexOf('.'); return (i>0?n.slice(0,i):n)||'File'; }
function fmtDur(s){ s=Math.round(s); if(!isFinite(s)||s<0) return ''; const h=Math.floor(s/3600),m=Math.floor((s%3600)/60),ss=s%60,p=(n)=>String(n).padStart(2,'0'); return h>0?(h+':'+p(m)+':'+p(ss)):(m+':'+p(ss)); }
// One cell in the shared-Media grid: image → thumbnail; audio/video → a tile with a centred
// download button plus a headphone/▶ glyph and (lazily-loaded) duration, matching the design.
function mediaCellHTML(x){
const id=pEsc(x.id), nm=pEsc(x.name||'');
if(x.isImage) return '<figure class="sh-cell"><a class="sh-av img att-img" data-img="/files/'+id+'" href="/files/'+id+'"><img class="sh-poster" src="/files/'+id+'" loading="lazy" alt=""></a></figure>';
const isVid=!!x.isVideo, kind=isVid?'vid':'aud';
return '<figure class="sh-cell">'
+'<figcaption class="sh-name" title="'+nm+'">'+pEsc(stripExt(x.name))+'</figcaption>'
+'<a class="sh-av '+kind+'" href="/files/'+id+'" download="'+nm+'" title="Download '+nm+'">'
+(isVid?'<video class="sh-poster" src="/files/'+id+'#t=0.1" preload="metadata" muted playsinline></video>':'')
+'<span class="sh-dl">'+ic('download',20)+'</span>'
+'<span class="sh-dur"><span class="sh-di">'+ic(isVid?'play':'headphones',12)+'</span><i data-dsrc="/files/'+id+'" data-dkind="'+(isVid?'video':'audio')+'">…</i></span>'
+'</a></figure>';
}
// Server doesn't store media duration, so read each audio/video tile's metadata client-side.
function loadMediaDurations(root){ if(!root) return; root.querySelectorAll('i[data-dsrc]').forEach(el=>{ if(el.dataset.done) return; el.dataset.done='1'; try{ const m=document.createElement(el.dataset.dkind==='video'?'video':'audio'); m.preload='metadata'; m.muted=true; m.src=el.dataset.dsrc; m.addEventListener('loadedmetadata',()=>{ el.textContent=fmtDur(m.duration)||''; }); m.addEventListener('error',()=>{ el.textContent=''; }); }catch(_){ el.textContent=''; } }); }
// Separate full view: Media / Docs / Links tabs.
async function openMediaView(kind,id,name,data){
if(document.getElementById('mediaView')) return;
if(!data){ try{ const url=kind==='group'?('/api/messages/media?group='+encodeURIComponent(id)):('/api/messages/media?with='+encodeURIComponent(id)); data=await fetch(url).then(x=>x.json()); }catch(_){} }
data=data&&data.media?data:{media:[],docs:[],links:[]};
const ov=document.createElement('div'); ov.className='modal-ov'; ov.id='mediaView';
ov.innerHTML='<div class="modal gi"><div class="gi-head" style="margin-bottom:.4rem"><button class="iconbtn" id="mvBack" title="Back">'+ic('arrowLeft',18)+'</button><div class="gi-name"><div class="gi-title">Media, links &amp; docs</div><div class="gi-sub">'+pEsc(name||'')+'</div></div></div>'
+'<div class="mp-tabs mv-tabs"><button class="mp-tab on" data-t="media">Media</button><button class="mp-tab" data-t="docs">Docs</button><button class="mp-tab" data-t="links">Links</button></div>'
+'<div class="mv-body" id="mvBody"></div></div>';
document.body.appendChild(ov);
ov.onclick=e=>{ if(e.target===ov) ov.remove(); };
ov.querySelector('#mvBack').onclick=()=>ov.remove();
let tab='media';
const render=()=>{ const b=ov.querySelector('#mvBody'); if(!b) return;
if(tab==='media') b.innerHTML=data.media.length?'<div class="sh-grid">'+data.media.map(mediaCellHTML).join('')+'</div>':'<div class="gi-noresult">No media shared yet</div>';
else if(tab==='docs') b.innerHTML=data.docs.length?data.docs.map(x=>'<a class="sh-file" href="/files/'+pEsc(x.id)+'" download="'+pEsc(x.name||'file')+'">'+ic('fileText',16)+'<span class="sh-fn">'+pEsc(x.name||'file')+'</span><span class="sh-sz">'+fmtSize(x.size)+'</span></a>').join(''):'<div class="gi-noresult">No documents shared yet</div>';
else b.innerHTML=data.links.length?data.links.map(x=>'<a class="sh-file" href="'+pEsc(x.url)+'" target="_blank" rel="noopener">'+ic('link',16)+'<span class="sh-fn">'+pEsc(x.url)+'</span></a>').join(''):'<div class="gi-noresult">No links shared yet</div>';
loadMediaDurations(b);
};
ov.querySelectorAll('.mv-tabs .mp-tab').forEach(btn=>btn.onclick=()=>{ tab=btn.dataset.t; ov.querySelectorAll('.mv-tabs .mp-tab').forEach(x=>x.classList.toggle('on',x===btn)); render(); });
ov.querySelector('#mvBody').addEventListener('click',e=>{ const im=e.target.closest('.att-img'); if(im&&im.dataset.img){ e.preventDefault(); openLightbox(im.dataset.img); } });
render();
}
// DM contact-info window (no members): header + favourite + the Media/links/docs entry.
async function openSharedItems(kind,id,name){
if(document.getElementById('sharedModal')) return;
const r0=rowFor(kind,id); const fav=!!(r0&&r0.favorite); const online=!!(r0&&r0.online);
const ov=document.createElement('div'); ov.className='modal-ov'; ov.id='sharedModal';
ov.innerHTML='<div class="modal gi"><div class="gi-head" style="margin-bottom:.6rem"><span class="avatar" style="width:46px;height:46px;flex:0 0 46px;background:'+avColor(name)+'">'+pEsc(initials(name||'?'))+'</span><div class="gi-name"><div class="gi-title-row"><span class="gi-title">'+pEsc(name||'')+'</span><button class="fav-star'+(fav?' on':'')+'" id="shFav" title="'+(fav?'Remove from favourites':'Add to favourites')+'">'+ic('star',16)+'</button></div><div class="gi-sub"><span class="st-dot '+statusCls(r0)+'"></span>'+statusLabel(r0)+'</div></div><button class="iconbtn" id="shClose">'+ic('x',18)+'</button></div>'
+mediaRowHTML()+'</div>';
document.body.appendChild(ov);
ov.onclick=e=>{ if(e.target===ov) ov.remove(); };
ov.querySelector('#shClose').onclick=()=>ov.remove();
const fb=ov.querySelector('#shFav'); if(fb) fb.onclick=async()=>{ const r=rowFor(kind,id); const on=!(r&&r.favorite); if(r) r.favorite=on; fb.classList.toggle('on',on); fb.title=on?'Remove from favourites':'Add to favourites'; try{ await postJSON('/api/favorites',{kind,id,on}); }catch(_){} renderChats(searchVal()); };
wireMediaEntry(ov, kind, id, name);
}
let convoMembers=[]; // members of the currently open group (for @mentions + highlight)
let composeMentions=new Map();// token ('@Name' | 'everyone') -> userId | 'everyone' for the draft
const rendered=new Set();
const listEl=document.getElementById('chatlist');
function searchVal(){ const s=document.getElementById('chatSearch'); return s?s.value:''; }
function rowFor(kind,id){ return ROWS.find(r=>r.kind===kind && r.id===id); }
function currentName(){ const it=selected&&rowFor(selected.kind,selected.id); return it?it.name:''; }
function fmtTime(ts){
if(!ts) return '';
const d=new Date(ts), n=new Date();
if(d.toDateString()===n.toDateString()) return d.toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'});
const y=new Date(n); y.setDate(n.getDate()-1);
if(d.toDateString()===y.toDateString()) return 'Yesterday';
return d.toLocaleDateString([], {month:'short',day:'numeric'});
}
// Presence/status helpers (#7): 'incall' (auto) overrides; offline if not connected.
function statusCls(it){ const s=it&&it.status; if(s==='incall') return 'incall'; if(!it||!it.online) return 'offline'; if(s==='away') return 'away'; if(s==='onleave') return 'onleave'; return 'active'; }
function statusLabel(it){ const c=statusCls(it); return c==='incall'?'In a call':c==='away'?'Away':c==='onleave'?'On leave':c==='active'?'Available':'Offline'; }
function avatarHTML(it, big){
const isG=it.kind==='group';
const sz=big?'width:38px;height:38px;flex:0 0 38px;':'';
const corner=isG?(it.members?'<span class="mcount">'+(it.members>99?'99+':it.members)+'</span>':'')
:'<span class="dot '+statusCls(it)+'"></span>';
const inner=isG?ic('users',big?20:18):pEsc(initials(it.name));
// Photo overlay (BizGaze profile picture, or an uploaded group image). If it fails to
// load it removes itself, revealing the initials/glyph underneath.
const img=it.avatar?'<img class="av-img" src="'+pEsc(it.avatar)+'" alt="" onerror="this.remove()">':'';
return '<div class="avatar'+(isG?' grp':'')+'" style="'+sz+'background:'+avColor(it.name)+'">'+inner+img+corner+'</div>';
}
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;
if(!q){ // group favourites at the top when not searching
const favs=rows.filter(r=>r.favorite), rest=rows.filter(r=>!r.favorite);
html = rows.length ? ((favs.length?'<div class="side-sec">'+ic('star',12)+' Favourites</div>'+favs.map(rowHTML).join('')+(rest.length?'<div class="side-sec">All chats</div>':''):'')+rest.map(rowHTML).join('')) : '<div class="no-results">No conversations yet.</div>';
} else {
html = rows.length ? rows.map(rowHTML).join('') : '<div class="no-results">No chats match “'+pEsc(filter)+'”.</div>';
}
if(q.length>=1){
// Team contacts you haven't messaged yet → start a new DM.
const haveDm=new Set(ROWS.filter(r=>r.kind==='dm').map(r=>r.id));
const cmatch=CONTACTS.filter(c=>!haveDm.has(c.id) && ((c.name||'').toLowerCase().includes(q)||(c.email||'').toLowerCase().includes(q)));
if(cmatch.length) html+='<div class="side-sec">Start a chat</div>'+cmatch.map(c=>'<div class="contact-row" data-id="'+pEsc(c.id)+'"><span class="mini-av" style="background:'+avColor(c.name)+'">'+pEsc(initials(c.name))+'</span><span class="cr-name">'+pEsc(c.name)+'</span></div>').join('');
html+='<div id="dirResults"></div>'; // BizGaze directory (filled async)
}
listEl.innerHTML=html;
listEl.querySelectorAll('.chat-row').forEach(row=>{ row.onclick=()=>selectChat(row.dataset.kind, row.dataset.id); });
listEl.querySelectorAll('.contact-row').forEach(row=>{ row.onclick=()=>selectChat('dm', row.dataset.id); });
if(q.length>=2) queryDirectory(filter.trim());
}
let _dirT=null, _dirSeq=0;
// Search the wider BizGaze directory (cross-tenant) — proxied server-side. Debounced.
function queryDirectory(q){
clearTimeout(_dirT); const seq=++_dirSeq;
_dirT=setTimeout(async()=>{
let list=[]; try{ list=await fetch('/api/directory/search?q='+encodeURIComponent(q)).then(r=>r.json()); }catch(_){ }
if(seq!==_dirSeq) return; // a newer search superseded this one
const box=document.getElementById('dirResults'); if(!box) return;
const haveEmail=new Set(CONTACTS.map(c=>(c.email||'').toLowerCase()).filter(Boolean));
const ext=(Array.isArray(list)?list:[]).filter(p=>!(p.email&&haveEmail.has(p.email.toLowerCase()))); // hide dups of team contacts
if(!ext.length){ box.innerHTML=''; return; }
box.innerHTML='<div class="side-sec">On BizGaze</div>'+ext.map((p,i)=>{ const sub=[p.org,p.phone].filter(Boolean).join(' · ');
return '<div class="dir-row'+(p.onConnect?'':' ext')+'" data-i="'+i+'"><span class="mini-av" style="background:'+avColor(p.name||'?')+'">'+pEsc(initials(p.name||'?'))+'</span><span class="dr-main"><span class="cr-name">'+pEsc(p.name||p.email||'Unknown')+'</span>'+(sub?'<span class="dr-sub">'+pEsc(sub)+'</span>':'')+'</span>'+(p.onConnect?'':'<span class="dr-tag">Not on Connect</span>')+'</div>'; }).join('');
box.querySelectorAll('.dir-row').forEach(row=>{ const p=ext[+row.dataset.i]; row.onclick=()=>{ if(p.onConnect&&p.connectId) selectChat('dm', p.connectId); else toast(pEsc(p.name||'This person')+' is on BizGaze but hasnt joined Connect yet — theyll be reachable once they sign in.'); }; });
}, 280);
}
function updateRailUnread(){
let chats=0; ROWS.forEach(it=>{ if((it.unread||0)>0) chats++; }); // number of chats with unread, not total messages
const d=document.getElementById('railUnread');
if(chats>0){ d.textContent=chats>99?'99+':chats; d.style.display='grid'; } else d.style.display='none';
}
async function loadSidebar(){
let convos=[], contacts=[];
try{ [convos, contacts]=await Promise.all([ fetch('/api/messages/conversations').then(r=>r.json()), fetch('/api/messages/contacts').then(r=>r.json()) ]); }catch(_){}
CONTACTS=Array.isArray(contacts)?contacts:[];
const items=Array.isArray(convos)?convos.slice():[];
const dmIds=new Set(items.filter(i=>i.kind==='dm').map(i=>i.id));
for(const c of CONTACTS){ if(!dmIds.has(c.id)) items.push({ kind:'dm', id:c.id, name:c.name, online:!!c.online, last_body:'', last_at:0, last_from_me:false, unread:0 }); }
const onlineById={}; CONTACTS.forEach(c=>onlineById[c.id]=!!c.online);
items.forEach(it=>{ if(it.kind==='dm') it.online=!!onlineById[it.id]; });
ROWS=items;
renderChats(searchVal());
updateRailUnread();
}
// ----- conversation view -----
function welcomeHTML(){
return '<div class="welcome">'
+ '<div class="wave">👋</div>'
+ '<h1>Hi, '+pEsc(firstName(ME.name||ME.email))+'! Welcome to Biz Connect</h1>'
+ '<p>Pick a conversation on the left to start chatting, or jump straight into a session from the sidebar.</p>'
+ '<div class="wcards">'
+ '<div class="wcard" data-go="share"><div class="wi"><svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg></div><h3>Share Screen</h3><p>Show your screen with a 6-digit code</p></div>'
+ '<div class="wcard" data-go="connect"><div class="wi"><svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M1.42 9a16 16 0 0 1 21.16 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><line x1="12" y1="20" x2="12.01" y2="20"/></svg></div><h3>Connect Screen</h3><p>Enter a customer\'s code to help</p></div>'
+ '<div class="wcard" data-go="meeting"><div class="wi"><svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="23 7 16 12 23 17 23 7"/><rect x="1" y="5" width="15" height="14" rx="2" ry="2"/></svg></div><h3>Meeting</h3><p>Multi-party video — coming soon</p></div>'
+ '</div></div>';
}
function wireWelcome(){ document.querySelectorAll('#chatPanel .wcard').forEach(card=>{ card.onclick=()=>switchTab(card.dataset.go); }); }
function convoShellHTML(it){
const isG=it.kind==='group';
const sub=isG?((it.members||0)+' members'):statusLabel(it);
return '<div class="convo">'
+ '<div class="convo-head">'
+ '<button class="convo-back" id="convoBack" title="Back (Esc)" aria-label="Back">'+ic('arrowLeft',18)+'</button>'
+ avatarHTML(it,true)
+ '<div class="convo-titlewrap gi-open" id="convoTitle" title="Shared media &amp; files"><div class="nm">'+pEsc(it.name)+'</div><div class="st">'+pEsc(sub)+'</div></div>'
+ '<button class="convo-info" id="convoSearch" title="Search messages">'+ic('search',18)+'</button>'
+ '<button class="convo-call'+(it.callActive?' joinable':'')+'" id="convoCall" title="'+(it.callActive?'Join call':'Start call')+'">'+ic(it.callActive?'video':'phone',18)+(it.callActive?'<span>Join</span>':'')+'</button>'
+ (isG?'<button class="convo-info" id="convoInfo" title="Group info">'+ic('info',18)+'</button>':'')
+ '<div class="convo-search-head" id="convoSearchHead" style="display:none">'
+ '<button class="convo-back" id="convoSearchClose" title="Close search">'+ic('arrowLeft',18)+'</button>'
+ '<input id="convoSearchInput" placeholder="Search this chat…" autocomplete="off">'
+ '<span class="csh-count" id="convoSearchCount"></span>'
+ '<button class="csh-nav" id="convoSearchPrev" title="Older match">'+ic('chevronUp',18)+'</button>'
+ '<button class="csh-nav" id="convoSearchNext" title="Newer match">'+ic('chevronDown',18)+'</button>'
+ '</div>'
+ '</div>'
+ '<div class="convo-msgs" id="msgs"></div>'
+ '<div class="float-date" id="floatDate" style="display:none"></div>'
+ '<button class="jump-latest" id="jumpLatest" title="Jump to latest" style="display:none">'+ic('chevronDown',20)+'</button>'
+ '<div class="reply-bar" id="replyBar" style="display:none"></div>'
+ '<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;
if(m.deleted) return '<div class="bubble '+(mine?'mine':'them')+' deleted" data-id="'+pEsc(m.id)+'"><span class="del-msg">'+ic('trash',12)+' This message was deleted</span><span class="t">'+pEsc(fmtClock(m.created_at))+'</span></div>';
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>'
+ (mine?'<button class="del-btn" data-del="'+pEsc(m.id)+'" title="Delete message">'+ic('trash',13)+'</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(); } }
// Delete-for-everyone: confirm, tell the server, then blank the message locally (the server also
// broadcasts chat-deleted to the other side / other tabs).
async function deleteMessage(id){ if(!confirm('Delete this message for everyone?')) return; try{ await postJSON('/api/messages/delete',{id}); markMsgDeleted(id); }catch(e){ toast(e.message||'Could not delete'); } }
function markMsgDeleted(id){
let changed=false;
THREAD.forEach(m=>{ if(m.id===id && !m.deleted){ m.deleted=true; m.body=''; m.attachment=null; m.reactions=[]; m.reply=null; m.poll=null; changed=true; } });
THREAD_CACHE.forEach(arr=>arr.forEach(m=>{ if(m.id===id){ m.deleted=true; m.body=''; m.attachment=null; } }));
if(changed) renderThread();
}
function onChatDeleted(d){ if(!d||!d.id) return; markMsgDeleted(d.id); try{ loadSidebar(); }catch(_){} } // refresh last-message previews
// DM delivered: recipient's client acked → second (grey) tick.
function onChatDelivered(d){ if(!d||!d.id) return; const m=THREAD.find(x=>x.id===d.id); if(m && !m.delivered_at){ m.delivered_at=Date.now(); updateBubble(m); } }
// Group read: a member opened the group → add them to "Seen by" on my messages up to that time.
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(); }
// #6: track invitees who haven't joined yet. They show under "Not joined yet"; after 30s the
// invite is re-enabled for them. When they actually join (matched by user id), they're cleared.
function meetInviteJoined(uid){ const e=meetInvited.get(uid); if(e){ if(e.timer) clearTimeout(e.timer); meetInvited.delete(uid); refreshMeetPanel(); } }
function meetAddInvited(ids){ (ids||[]).forEach(uid=>{ const c=(CONTACTS||[]).find(x=>x.id===uid); const name=(c&&c.name)||'Someone'; const prev=meetInvited.get(uid); if(prev&&prev.timer) clearTimeout(prev.timer); const timer=setTimeout(()=>{ meetInvited.delete(uid); refreshMeetPanel(); }, 30000); meetInvited.set(uid,{name,timer}); }); refreshMeetPanel(); }
let meetPanelTab='people'; // 'people' | 'add'
let _addPool=null; // cached candidates for "Add people" (group members for a group call)
function toggleMeetPanel(){ const ex=document.getElementById('meetPanel'); if(ex){ ex.remove(); return; } const meet=document.querySelector('.meet'); if(!meet) return; meetPanelTab='people'; const p=document.createElement('div'); p.id='meetPanel'; p.className='meet-panel'; meet.appendChild(p); renderMeetPanel(); }
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()) && !meetInvited.has(c.id));
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>'
+(meetInvited.size?'<div class="mp-sec">Not joined yet</div><div class="mp-list">'+[...meetInvited.entries()].map(([uid,e])=>'<div class="mp-row waiting"><span class="mini-av" style="background:'+avColor(e.name)+'">'+pEsc(initials(e.name))+'</span><span class="mn">'+pEsc(e.name)+'</span><span class="pp-wait">'+ic('calendarClock',12)+' waiting…</span></div>').join('')+'</div>':'')
+(meetIsHost?'<label class="gi-setting mp-setting"><span>'+ic('monitor',15)+' Allow multiple people to share</span><span class="switch"><input type="checkbox" id="mpMulti"'+(meetMultiShare?' checked':'')+'><span class="slider"></span></span></label>':'');
}
p.innerHTML=head+tabs+'<div class="mp-scroll">'+body+'</div>';
const x=p.querySelector('.mp-x'); if(x) x.onclick=()=>p.remove();
p.querySelectorAll('.mp-tab').forEach(b=>b.onclick=()=>{ meetPanelTab=b.dataset.tab; renderMeetPanel(); });
const ma=p.querySelector('.mp-muteall'); if(ma) ma.onclick=()=>{ meetSend({type:'meeting-muteall'}); toast('Muted everyone'); };
const mm=p.querySelector('#mpMulti'); if(mm) mm.onchange=()=>{ meetMultiShare=mm.checked; meetSend({type:'meeting-sharemode', multi:meetMultiShare}); };
p.querySelectorAll('.mp-makehost').forEach(b=>b.onclick=()=>{ meetSend({type:'meeting-host', to:b.dataset.id}); meetHostId=b.dataset.id; meetIsHost=false; renderMeetPanel(); });
const inv=p.querySelector('#mpInvite'); if(inv) inv.onclick=async()=>{ const ids=[...p.querySelectorAll('input:checked')].map(i=>i.value); if(!ids.length){ toast('Pick people to invite'); return; } try{ await postJSON('/api/calls/invite',{ room:meetRoom, userIds:ids }); meetAddInvited(ids); meetPanelTab='people'; toast(ids.length===1?'Waiting for them to join…':'Waiting for '+ids.length+' people to join…'); renderMeetPanel(); }catch(e){ toast(e.message||'Could not invite'); } };
}
// Add people to the current call (from the in-call bar).
function openInvitePicker(room){
if(!room || document.getElementById('invModal')) return;
const ov=document.createElement('div'); ov.className='modal-ov'; ov.id='invModal';
ov.innerHTML='<div class="modal sched"><div class="gi-head" style="margin-bottom:.6rem"><div class="avatar grp" style="width:40px;height:40px;flex:0 0 40px;background:var(--blue)">'+ic('userPlus',20)+'</div><div class="gi-name"><div class="gi-title">Add people to the call</div><div class="gi-sub">They get an incoming-call invite</div></div><button class="iconbtn" id="invClose">'+ic('x',18)+'</button></div>'
+'<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;
}
let _openUnread=0; // set by selectChat (the unread count before it's reset) so we can open at the first unread
async function openConvo(kind,id){
const it=rowFor(kind,id)||{kind,id,name:'Conversation'};
const _unreadN=_openUnread; _openUnread=0; // consume the captured unread count (#3)
convoIsGroup=(kind==='group');
const el=document.getElementById('chatPanel'); el.classList.remove('center');
el.innerHTML=convoShellHTML(it);
// #1: drag-and-drop a file/video/image anywhere on the conversation to send it.
el.ondragover=(e)=>{ if(e.dataTransfer&&Array.from(e.dataTransfer.types||[]).includes('Files')){ e.preventDefault(); el.classList.add('drag-over'); } };
el.ondragleave=(e)=>{ if(e.relatedTarget===null||!el.contains(e.relatedTarget)) el.classList.remove('drag-over'); };
el.ondrop=(e)=>{ const f=e.dataTransfer&&e.dataTransfer.files&&e.dataTransfer.files[0]; if(f){ e.preventDefault(); el.classList.remove('drag-over'); uploadFile(f); } };
const back=document.getElementById('convoBack'); if(back) back.onclick=showWelcome;
const form=document.getElementById('composer'); if(form) form.addEventListener('submit',(e)=>{ e.preventDefault(); sendMessage(); });
clearReply(); pendingAttach=null; hideAttach();
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=()=>{ if(kind==='group') openGroupInfo(id); else { const r=rowFor(kind,id); openSharedItems('dm', id, (r&&r.name)||it.name||''); } }; // name → group: info+media; DM: media/files
const cc=document.getElementById('convoCall'); if(cc) cc.onclick=()=>(kind==='group'?startOrJoinGroupCall(id):startOrJoinDmCall(id));
const csb=document.getElementById('convoSearch'); const cshead=document.getElementById('convoSearchHead'); const csin=document.getElementById('convoSearchInput');
if(csb) csb.onclick=()=>{ if(!cshead) return; cshead.style.display='flex'; if(csin){ csin.value=''; setTimeout(()=>csin.focus(),0); } };
if(csin) csin.oninput=()=>runSearch(csin.value);
if(csin) csin.addEventListener('keydown',e=>{ if(e.key==='Enter'){ e.preventDefault(); gotoHit(_searchIdx + (e.shiftKey?-1:1)); } if(e.key==='Escape'){ closeSearch(); } });
const csClose=document.getElementById('convoSearchClose'); if(csClose) csClose.onclick=closeSearch;
const csPrev=document.getElementById('convoSearchPrev'); if(csPrev) csPrev.onclick=()=>gotoHit(_searchIdx-1);
const csNext=document.getElementById('convoSearchNext'); if(csNext) csNext.onclick=()=>gotoHit(_searchIdx+1);
const box=document.getElementById('msgs'); if(box) box.addEventListener('click',(e)=>{
const im=e.target.closest('.att-img'); if(im && im.dataset.img){ openLightbox(im.dataset.img); return; }
const po=e.target.closest('.poll-opt'); if(po){ if(!po.disabled) votePoll(po.dataset.poll, +po.dataset.idx); return; }
const pcl=e.target.closest('.poll-close'); if(pcl){ closePoll(pcl.dataset.poll); return; }
const rb=e.target.closest('.reply-btn'); if(rb){ const mm=THREAD.find(x=>x.id===rb.dataset.id); if(mm) setReply(mm); return; }
const dl=e.target.closest('.del-btn'); if(dl){ deleteMessage(dl.dataset.del); return; }
const ab=e.target.closest('.react-btn'); if(ab){ openEmojiForReact(ab.dataset.id, ab); return; }
const ch=e.target.closest('.react-chip'); if(ch){ reactToMessage(ch.dataset.id, ch.dataset.emoji); return; }
const sb=e.target.closest('.seenby'); if(sb){ const ns=(sb.dataset.seen||'').split('|').filter(Boolean); toast('Seen by: '+ns.join(', ')); return; }
// Tap-to-reveal (mobile): a tap on the bubble body (not an action) reveals its reply/react/delete
// icons; the action only fires on a SECOND tap once they're shown (icons are pointer-events:none
// until revealed). Tapping elsewhere hides them.
const bub=e.target.closest('.bubble'); const already=bub&&bub.classList.contains('show-actions');
box.querySelectorAll('.bubble.show-actions').forEach(b=>b.classList.remove('show-actions'));
if(bub && !already && !bub.classList.contains('deleted')) bub.classList.add('show-actions');
});
if(box) box.addEventListener('scroll', onMsgsScroll);
if(box) enablePullRefresh(box, reloadThread); // pull down at the top to refresh the conversation
const jl=document.getElementById('jumpLatest'); if(jl) jl.onclick=()=>{ const b=document.getElementById('msgs'); if(b) b.scrollTop=b.scrollHeight; };
composeMentions=new Map(); convoMembers=[];
// Synchronous render from cache (within the click's activation → paints immediately, even
// 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();
// #3: if there were unread messages, drop a "New messages" divider, scroll to the first unread,
// and show the "jump to newest" arrow so the user can return to the bottom.
if(_unreadN>0 && THREAD.length>=_unreadN){
const fu=THREAD[THREAD.length-_unreadN]; const esc=(window.CSS&&CSS.escape)?CSS.escape(fu.id):fu.id;
const el=document.querySelector('#msgs .bubble[data-id="'+esc+'"]') || document.querySelector('#msgs [data-id="'+esc+'"]');
if(el){ const sep=document.createElement('div'); sep.className='new-sep'; sep.innerHTML='<span>New messages</span>'; el.parentNode.insertBefore(sep, el); el.scrollIntoView({block:'start'}); el.parentNode.scrollTop-=40; const jl=document.getElementById('jumpLatest'); if(jl) jl.style.display='grid'; }
}
const inp=document.getElementById('msgInput'); if(inp) 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); _openUnread=(it&&it.unread)||0; if(it) it.unread=0; // capture before reset (#3: open at first unread)
renderChats(searchVal());
updateRailUnread();
await openConvo(kind,id);
}
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();
// Dedup by id: the server echoes our message over WS (to sync other tabs) and that echo
// can arrive BEFORE this POST resolves, so onChatMessage may have already appended it.
if(!THREAD.some(x=>x.id===m.id)){ THREAD.push(m); appendBubble(m); }
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'); }
}
// Request permission from a user gesture (e.g. opening a chat) AND subscribe on grant — the
// subscribe-on-grant is essential on iOS, where permission is granted in-session and push
// won't work until a subscription exists.
function ensureNotifyPermission(){ try{ const p=nativePlatform(); if(p==='ios'||p==='android') return; if('Notification' in window && Notification.permission==='default') Notification.requestPermission().then(r=>{ if(r==='granted'){ try{ subscribePush(); }catch(_){} } }).catch(()=>{}); }catch(_){} }
// Notification preferences (set in Settings; stored per browser). Default ON.
function notifOn(kind){ try{ return localStorage.getItem('notif_'+kind)!=='off'; }catch(_){ return true; } }
// Mobile has no top bar (#7): keep bell+profile in the chat-list header; desktop keeps them in
// the top header. Re-placed on load and on resize. Moving the node preserves its wiring.
function placeHdrRight(){
const hr=document.getElementById('hdrRight'); if(!hr) return;
// Desktop: bell+profile sit at the BOTTOM of the left rail (a clear, persistent spot).
// Mobile: top-right of the chat-list header (the main screen).
if(window.matchMedia('(max-width:760px)').matches){ const st=document.querySelector('#chatcol .side-title'); if(st && hr.parentElement!==st) st.appendChild(hr); }
else { const rail=document.getElementById('rail'); if(rail && hr.parentElement!==rail) rail.appendChild(hr); }
}
// Explicit, dismissible prompt when permission is still 'default'. This is the reliable path on
// iOS (permission must be requested from a tap inside the installed PWA).
function maybeNotifPrompt(){
try{
const p=nativePlatform(); if(p==='ios'||p==='android') return; // native app handles its own permission flow
if(!('Notification' in window) || Notification.permission!=='default') return;
if(sessionStorage.getItem('notifPromptDismissed')==='1' || document.getElementById('notifPrompt')) return;
const b=document.createElement('div'); b.id='notifPrompt'; b.className='notif-prompt';
b.innerHTML='<span>'+ic('bell',16)+' Turn on notifications so you dont miss messages &amp; calls.</span><span class="np-actions"><button class="btn sm" id="npEnable">Enable</button><button class="np-x" id="npX" aria-label="Dismiss">'+ic('x',16)+'</button></span>';
document.body.appendChild(b);
document.getElementById('npEnable').onclick=async()=>{ try{ const r=await Notification.requestPermission(); if(r==='granted'){ toast('Notifications enabled'); await subscribePush(); } else { toast('Notifications blocked — allow them in your browser/site settings'); } }catch(_){ toast('Notifications need HTTPS'); } b.remove(); };
document.getElementById('npX').onclick=()=>{ try{ sessionStorage.setItem('notifPromptDismissed','1'); }catch(_){} b.remove(); };
}catch(_){}
}
// ----- Web Push: reliable notifications when the tab is backgrounded / frozen / closed -----
// A notification-only service worker (sw.js, no caching) shows OS popups via the push service.
// pushActive => the page can leave hidden-tab popups to push (avoids double-notifying).
let pushActive=false, _swReg=null;
function urlB64ToUint8(base64){ const pad='='.repeat((4-base64.length%4)%4); const b=(base64+pad).replace(/-/g,'+').replace(/_/g,'/'); const raw=atob(b); const arr=new Uint8Array(raw.length); for(let i=0;i<raw.length;i++) arr[i]=raw.charCodeAt(i); return arr; }
// ----- Native push (Capacitor app: FCM on Android, APNs on iOS) -----
// In a native shell, Capacitor injects window.Capacitor and exposes the PushNotifications
// plugin. We register the OS device token with /api/v1/devices so the server can deliver via
// FCM/APNs (see server/push.js). No bundler needed — plugins live on Capacitor.Plugins.
function capPlugin(name){ const C=window.Capacitor; return (C && C.isNativePlatform && C.isNativePlatform() && C.Plugins && C.Plugins[name]) ? C.Plugins[name] : null; }
function nativePlatform(){ const C=window.Capacitor; if(C && C.getPlatform){ const p=C.getPlatform(); if(p==='ios'||p==='android') return p; } if(window.__NATIVE__==='desktop') return 'desktop'; return ''; }
let _nativeTok=null;
async function setupNativePush(){
const PN=capPlugin('PushNotifications'); const plat=nativePlatform();
if(!PN || (plat!=='ios' && plat!=='android')) return false; // not a mobile native app
try{
PN.addListener('registration', async (t)=>{ const token=t&&t.value; if(!token) return; _nativeTok=token;
try{ await postJSON('/api/v1/devices',{ platform:plat, token }); pushActive=true; console.log('[push] native device registered'); }
catch(e){ console.warn('[push] device register failed:', e); } });
PN.addListener('registrationError', (e)=>console.warn('[push] native registration error:', e));
// Tapping an OS notification opens the relevant chat (payload carries kind+id).
PN.addListener('pushNotificationActionPerformed', (a)=>{ try{ const d=(a&&a.notification&&a.notification.data)||{}; if(d.id) selectChat(d.kind||'dm', d.id); }catch(_){} });
let perm=await PN.checkPermissions();
if(perm.receive!=='granted') perm=await PN.requestPermissions();
if(perm.receive!=='granted'){ console.log('[push] native permission not granted'); return true; }
await PN.register();
console.log('[push] native push registered');
}catch(e){ console.warn('[push] native push setup failed:', e); }
return true; // handled the native path (skip Web Push regardless of outcome)
}
async function setupPush(){
if(await setupNativePush()) return; // native app → FCM/APNs, not Web Push
if(!('serviceWorker' in navigator) || !('PushManager' in window)){ console.warn('[push] not supported by this browser'); return; }
try{ await navigator.serviceWorker.register('/sw.js'); }catch(e){ console.warn('[push] SW register failed:', e); return; }
try{ _swReg=await navigator.serviceWorker.ready; }catch(e){ console.warn('[push] SW never became ready:', e); return; } // ensure an ACTIVE worker before subscribe()
console.log('[push] service worker ready');
// Clicking an OS notification asks us (if a tab is already open) to open that chat in place.
try{ navigator.serviceWorker.addEventListener('message',(e)=>{ const d=e.data||{}; if(d.type==='open-chat' && d.id){ try{ selectChat(d.kind||'dm', d.id); }catch(_){} } }); }catch(_){}
await subscribePush();
}
async function subscribePush(){
try{
if(!_swReg){ try{ _swReg=await navigator.serviceWorker.ready; }catch(_){ return; } }
if(!('Notification' in window) || Notification.permission!=='granted'){ console.log('[push] permission not granted ('+(window.Notification?Notification.permission:'n/a')+') — skipping subscribe'); return; }
let cfg; try{ cfg=await fetch('/api/push/vapid').then(r=>r.json()); }catch(e){ console.warn('[push] /api/push/vapid failed:', e); return; }
if(!cfg || !cfg.enabled || !cfg.key){ console.warn('[push] server push disabled (VAPID not set):', cfg); return; }
let sub=await _swReg.pushManager.getSubscription();
if(!sub){ sub=await _swReg.pushManager.subscribe({ userVisibleOnly:true, applicationServerKey:urlB64ToUint8(cfg.key) }); }
await postJSON('/api/push/subscribe', sub.toJSON());
pushActive=true; console.log('[push] subscribed OK');
}catch(e){ console.warn('[push] subscribe failed:', e); } // surfaced (not swallowed) so we can see the real reason
}
// On logout, remove this device's push subscription so notifications STOP for the logged-out user.
async function unsubscribePush(){
// Native app: drop this device's FCM/APNs token so push stops for the logged-out user.
try{ if(_nativeTok){ await postJSON('/api/v1/devices/remove',{ token:_nativeTok }); _nativeTok=null; pushActive=false; return; } }catch(_){}
try{
if(!_swReg){ try{ _swReg=await navigator.serviceWorker.getRegistration(); }catch(_){} }
if(!_swReg) return;
const sub=await _swReg.pushManager.getSubscription();
if(sub){ try{ await postJSON('/api/push/unsubscribe',{endpoint:sub.endpoint}); }catch(_){} try{ await sub.unsubscribe(); }catch(_){} }
pushActive=false; console.log('[push] unsubscribed (logout)');
}catch(_){}
}
// Open the chat from an in-page notification. Navigation reliably repaints across browsers (a
// notification click is not an in-page gesture, so an in-place open won't paint until you
// tap). The reload is made fast by HTTP caching + a boot fast-path that opens the chat first.
function 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;
// Message chime: a crisp, recognizable rising two-note ("ti-doo") — louder + brighter than the
// old single beep so it's catchy and noticed.
function playPing(){
try{
_audioCtx=_audioCtx||new (window.AudioContext||window.webkitAudioContext)();
if(_audioCtx.state==='suspended') _audioCtx.resume();
const t=_audioCtx.currentTime;
const tone=(f,start,dur,peak)=>{ const o=_audioCtx.createOscillator(), g=_audioCtx.createGain(); o.type='triangle'; o.frequency.value=f; g.gain.setValueAtTime(0.0001,t+start); g.gain.exponentialRampToValueAtTime(peak,t+start+0.015); g.gain.exponentialRampToValueAtTime(0.0001,t+start+dur); o.connect(g); g.connect(_audioCtx.destination); o.start(t+start); o.stop(t+start+dur+0.03); };
tone(784.0, 0.00, 0.16, 0.34); // G5
tone(1174.7, 0.11, 0.40, 0.34); // D6 — rises, rings out -> "ti-doo"
}catch(_){}
}
// Continuous incoming-call ring. A soft, soothing bell chime — a gentle ascending arpeggio
// (C major) of pure sine tones with an octave shimmer and a long smooth decay, so it rings
// rather than buzzes. Ref-counted: several pending invites share one ring; it stops on the last close.
let _ringTimer=null, _ringRefs=0;
function ringTone(t, freq, dur, peak){
const g=_audioCtx.createGain(); g.connect(_audioCtx.destination);
g.gain.setValueAtTime(0.0001,t);
g.gain.exponentialRampToValueAtTime(peak,t+0.03); // gentle attack (no click)
g.gain.exponentialRampToValueAtTime(0.0001,t+dur); // long, smooth bell-like decay
const o=_audioCtx.createOscillator(); o.type='sine'; o.frequency.value=freq; o.connect(g); o.start(t); o.stop(t+dur);
const o2=_audioCtx.createOscillator(); o2.type='sine'; o2.frequency.value=freq*2; // octave shimmer
const g2=_audioCtx.createGain(); g2.gain.value=0.3; o2.connect(g2); g2.connect(g); o2.start(t); o2.stop(t+dur);
}
function ringOnce(){
try{
_audioCtx=_audioCtx||new (window.AudioContext||window.webkitAudioContext)();
if(_audioCtx.state==='suspended') _audioCtx.resume();
const t=_audioCtx.currentTime;
ringTone(t, 523.25, 1.2, 0.24); // C5 (louder so an incoming call is clearly heard)
ringTone(t+0.20, 659.25, 1.2, 0.22); // E5
ringTone(t+0.40, 783.99, 1.5, 0.24); // G5
ringTone(t+0.60, 1046.5, 1.9, 0.20); // C6 — rings out
}catch(_){}
}
function startRing(){ _ringRefs++; if(_ringTimer) return; ringOnce(); _ringTimer=setInterval(ringOnce, 3500); }
function stopRing(force){ _ringRefs = force ? 0 : Math.max(0, _ringRefs-1); if(_ringRefs>0) return; if(_ringTimer){ clearInterval(_ringTimer); _ringTimer=null; } }
function onChatMessage(m){
const isGroupMsg=!!m.conversation_id;
const kind=isGroupMsg?'group':'dm';
const rid=isGroupMsg?m.conversation_id:(m.from===ME.id?m.to:m.from);
let it=rowFor(kind,rid); const wasNew=!it;
if(!it){ loadSidebar(); } // first DM / a new group we were added to — refresh the list
// Keep the thread cache warm so a notification click can render this chat synchronously.
const ckey=kind+':'+rid;
if(THREAD_CACHE.has(ckey)){ const arr=THREAD_CACHE.get(ckey); if(!arr.some(x=>x.id===m.id)) arr.push(m); }
else { const pu=(kind==='group'?'/api/messages/thread?group='+encodeURIComponent(rid):'/api/messages/thread?with='+encodeURIComponent(rid))+'&peek=1'; fetch(pu).then(r=>r.json()).then(a=>{ if(Array.isArray(a)) THREAD_CACHE.set(ckey,a); }).catch(()=>{}); }
const isOpen=selected&&selected.kind===kind&&selected.id===rid&&currentTab()==='chat';
const isSys=!!m.system || m.from==='__system__'; // activity lines: show in chat, but no ping/notify/unread
if(m.from!==ME.id && !isSys){
if(!isGroupMsg && chatWs && chatWs.readyState===1){ try{ chatWs.send(JSON.stringify({type:'chat-delivered', id:m.id})); }catch(_){} } // ack DM delivery
// Popup rule: ping always. In-page popup only when the tab is VISIBLE but you're on another
// chat. When the tab is HIDDEN, let Web Push show it (the SW). If push isn't active, fall
// back to an in-page popup so hidden-tab users still get alerted.
if(notifOn(kind)){ playPing(); const wantPopup=!(isOpen && !document.hidden); if(wantPopup && !(document.hidden && pushActive)) notify((m.fromName||'New message'), m.body?(m.body.length>80?m.body.slice(0,80)+'…':m.body):'Sent an attachment', kind, rid); }
// Activity-center entries for things easy to miss.
if(m.poll) addNotif({icon:'barChart', text:pEsc(m.fromName||'Someone')+' created a poll'+(m.poll.question?': '+pEsc(m.poll.question):''), link:{kind, id:rid}});
else if(kind==='dm' && wasNew) addNotif({icon:'chat', text:'New chat from '+pEsc(m.fromName||'someone'), link:{kind:'dm', id:rid}});
}
if(it){
it.last_body=isSys?m.body:(m.body||(m.attachment?'📎 '+(m.attachment.name||'Attachment'):'')); it.last_at=m.created_at; it.last_from_me=(m.from===ME.id);
if(m.from!==ME.id && !isOpen && !isSys) it.unread=(it.unread||0)+1;
}
if(isOpen){
// Dedup by id: the server echoes our own sent message back (multi-tab/device sync), and
// sendMessage already appended it optimistically — so skip if it's already in the thread.
if(!THREAD.some(x=>x.id===m.id)){ THREAD.push(m); appendBubble(m); }
if(m.from!==ME.id && !isSys){ if(it) it.unread=0; const body=JSON.stringify(kind==='group'?{group:rid}:{with:rid}); try{ fetch('/api/messages/read',{method:'POST',headers:{'Content-Type':'application/json'},body}); }catch(_){} }
} else if(m.from!==ME.id && !isSys && notifOn(kind)){
toast((m.fromName||'New message')+': '+(m.body?(m.body.length>60?m.body.slice(0,60)+'…':m.body):'📎 Attachment'));
}
renderChats(searchVal()); updateRailUnread();
}
let chatWs=null;
function connectChatWs(){
try{
chatWs=new WebSocket((location.protocol==='https:'?'wss://':'ws://')+location.host+'/ws');
chatWs.onopen=()=>{ try{ chatWs.send(JSON.stringify({type:'chat-hello'})); }catch(_){} };
chatWs.onmessage=(e)=>{ let d; try{ d=JSON.parse(e.data); }catch(_){ return; } if(d.type==='chat-message' && d.message) onChatMessage(d.message); else if(d.type==='chat-deleted') onChatDeleted(d); else if(d.type==='chat-reaction') onChatReaction(d); else if(d.type==='poll-update' && d.poll) onPollUpdate(d); else if(d.type==='chat-read') onChatRead(d); else if(d.type==='chat-delivered') onChatDelivered(d); else if(d.type==='group-read') onGroupRead(d); else if(d.type==='group-call') onGroupCall(d); else if(d.type==='dm-call') onDmCall(d); else if(d.type==='group-update') onGroupUpdate(d); else if(d.type==='call-invite') showCallInvite(d.room, d.byName); else if(d.type==='meeting-invite') showMeetingInvite(d.meeting); else if(d.type==='meeting-reminder') showMeetingReminder(d.meeting); else if(d.type==='meeting-cancelled') showMeetingCancelled(d.meeting); else if(d.type==='group-role') onGroupRole(d); };
chatWs.onclose=()=>{ setTimeout(connectChatWs, 3000); }; // auto-reconnect
}catch(_){}
}
function renderChatPanel(){
const el=document.getElementById('chatPanel');
if(!selected){ el.classList.add('center'); el.innerHTML=welcomeHTML(); wireWelcome(); }
else { el.classList.remove('center'); openConvo(selected.kind, selected.id); }
}
// ----- New group modal -----
function openNewGroup(){
if(document.getElementById('groupModal')) return;
const ov=document.createElement('div'); ov.className='modal-ov'; ov.id='groupModal';
ov.innerHTML='<div class="modal"><h3>New group</h3>'
+ '<input id="grpName" placeholder="Group name" maxlength="80">'
+ (CONTACTS.length?'<div class="gi-search" style="margin:.55rem 0 .35rem"><input id="grpSearch" placeholder="Search people…" autocomplete="off"></div>':'')
+ '<div class="grp-members">'+(CONTACTS.length?CONTACTS.map(c=>'<label class="chk" data-name="'+pEsc((c.name||'').toLowerCase())+'"><input type="checkbox" value="'+pEsc(c.id)+'"> '+pEsc(c.name)+'</label>').join(''):'<div class="muted" style="padding:.5rem">No teammates to add yet.</div>')+'</div>'
+ '<div class="gi-noresult" id="grpNoResult" style="display:none">No people found</div>'
+ '<div class="modal-actions"><button class="gobtn" id="grpCancel" style="background:#eef1f6;color:var(--blue)">Cancel</button><button class="gobtn" id="grpCreate">Create group</button></div></div>';
document.body.appendChild(ov);
ov.onclick=(e)=>{ if(e.target===ov) ov.remove(); };
document.getElementById('grpCancel').onclick=()=>ov.remove();
document.getElementById('grpCreate').onclick=createGroup;
const gs=document.getElementById('grpSearch');
if(gs) gs.oninput=()=>{ const q=gs.value.trim().toLowerCase(); let shown=0; ov.querySelectorAll('.grp-members .chk').forEach(l=>{ const vis=!q||(l.dataset.name||'').includes(q); l.style.display=vis?'':'none'; if(vis) shown++; }); const nr=document.getElementById('grpNoResult'); if(nr) nr.style.display=shown?'none':'block'; };
const n=document.getElementById('grpName'); if(n) n.focus();
}
async function createGroup(){
const name=document.getElementById('grpName').value.trim();
const ids=[...document.querySelectorAll('#groupModal .grp-members input:checked')].map(i=>i.value);
if(!name){ toast('Enter a group name'); return; }
if(!ids.length){ toast('Pick at least one member'); return; }
try{
const g=await postJSON('/api/groups',{ name, memberIds:ids });
const ov=document.getElementById('groupModal'); if(ov) ov.remove();
await loadSidebar();
selectChat('group', g.id);
}catch(e){ toast(e.message||'Could not create group'); }
}
async function openGroupInfo(gid){
let info; try{ info=await fetch('/api/groups/info?group='+encodeURIComponent(gid)).then(r=>r.json()); }catch(_){ return; }
if(!info||!info.id) return;
const inSet=new Set(info.members.map(m=>m.id));
const addable=CONTACTS.filter(c=>!inSet.has(c.id));
const canManage=info.isAdmin || !info.adminOnly; // who may add/remove members
const miniAv=(name,avatar)=>'<div class="mini-av" style="background:'+avColor(name)+'">'+pEsc(initials(name))+(avatar?'<img class="av-img" src="'+pEsc(avatar)+'" alt="" onerror="this.remove()">':'')+'</div>';
const ov=document.createElement('div'); ov.className='modal-ov'; ov.id='groupInfo';
ov.innerHTML='<div class="modal gi">'
+ '<input type="file" id="giPhotoInput" accept="image/*" style="display:none">'
+ '<div class="gi-head"><button class="gi-photo" id="giPhoto" title="Change group photo"><span class="avatar grp" style="width:46px;height:46px;background:'+avColor(info.name)+'">'+ic('users',24)+(info.avatar?'<img class="av-img" src="'+pEsc(info.avatar)+'" alt="" onerror="this.remove()">':'')+'</span><span class="gi-photo-cam">'+ic('camera',13)+'</span></button>'
+ '<div class="gi-name">'
+ '<div class="gi-name-row" id="giView"><span class="gi-title">'+pEsc(info.name)+'</span><button class="fav-star'+(((rowFor('group',gid)||{}).favorite)?' on':'')+'" id="giFav" title="Favourite">'+ic('star',15)+'</button><button class="iconbtn sm" id="giEdit" title="Rename group">'+ic('pencil',15)+'</button></div>'
+ '<div class="gi-edit hidden" id="giEditRow"><input id="giName" value="'+pEsc(info.name)+'" maxlength="80"><button class="iconbtn" id="giSave" title="Save">'+ic('check',18)+'</button><button class="iconbtn" id="giCancelEdit" title="Cancel">'+ic('x',16)+'</button></div>'
+ '<div class="gi-sub">'+info.members.length+' member'+(info.members.length===1?'':'s')+'</div>'
+ '</div>'
+ '<button class="iconbtn" id="giClose" title="Close">'+ic('x',18)+'</button></div>'
+ '<div class="gi-actions"><button class="gi-act" id="giCall">'+ic('phone',18)+'<span>Call</span></button><button class="gi-act" id="giSchedule">'+ic('calendar',18)+'<span>Schedule</span></button></div>'
+ (info.createdByName?'<div class="gi-created">'+ic('info',13)+' Created by <b>'+pEsc(info.createdByName)+'</b> on '+pEsc(fmtDateTime(info.createdAt))+'</div>':'')
+ (info.isAdmin?'<label class="gi-setting"><span>'+ic('lock',15)+' Only admins can add or remove people</span><span class="switch"><input type="checkbox" id="giAdminOnly"'+(info.adminOnly?' checked':'')+'><span class="slider"></span></span></label>':'')
+ '<div class="gi-sec-h"><span>Shared</span></div>' + mediaRowHTML()
+ '<div class="gi-sec-h"><span>Members</span>'+(canManage?'<button class="linkbtn" id="giAddToggle">'+ic('userPlus',15)+' Add people</button>':'')+'</div>'
+ '<div class="gi-list">'+info.members.map(m=>'<div class="mrow">'+miniAv(m.name,m.avatar)+'<span class="mn">'+pEsc(m.name)+(m.isMe?' <span class="youtag">you</span>':'')+(m.admin?' <span class="admin-tag">'+ic('crown',11)+' Admin</span>':'')+'</span>'+((info.isAdmin&&!m.isMe)?'<button class="iconbtn role'+(m.admin?' is-admin':'')+'" data-role="'+pEsc(m.id)+'" data-val="'+(m.admin?'0':'1')+'" title="'+(m.admin?'Remove admin':'Make admin')+'">'+ic('crown',15)+'</button>':'')+((canManage&&!m.isMe)?'<button class="iconbtn rm" data-rm="'+pEsc(m.id)+'" title="Remove">'+ic('trash',15)+'</button>':'')+'</div>').join('')+'</div>'
+ '<div class="gi-add hidden" id="giAddWrap">'
+ (addable.length
? ('<div class="gi-search"><input id="giAddSearch" placeholder="Search people…" autocomplete="off"><button class="search-x" id="giAddSearchX" title="Clear" style="display:none">'+ic('x',14)+'</button></div>'
+ '<div class="gi-list" id="giAddList">'+addable.map(c=>'<label class="chk" data-name="'+pEsc((c.name||'').toLowerCase())+'"><input type="checkbox" value="'+pEsc(c.id)+'">'+miniAv(c.name,c.avatar)+'<span class="mn">'+pEsc(c.name)+'</span></label>').join('')+'</div>'
+ '<div class="gi-noresult" id="giAddEmpty" style="display:none">No results found</div>'
+ '<button class="gobtn" id="giAddBtn" style="width:100%;margin-top:.5rem">Add selected</button>')
: '<div class="gi-noresult">Everyone in your workspace is already in this group.</div>')
+ '</div>'
+ '<button class="gi-leave" id="giLeave">'+ic('logOut',16)+' Leave group</button>'
+ '</div>';
const _old=document.getElementById('groupInfo'); if(_old) _old.remove(); // never stack group-info modals (stale close buttons)
document.body.appendChild(ov);
wireMediaEntry(ov, 'group', gid, info.name); // "Media, links & docs" entry above the members
ov.onclick=(e)=>{ if(e.target===ov) ov.remove(); };
ov.querySelector('#giClose').onclick=()=>ov.remove();
{ const gf=ov.querySelector('#giFav'); if(gf) gf.onclick=async()=>{ const r=rowFor('group',gid); const on=!(r&&r.favorite); if(r) r.favorite=on; gf.classList.toggle('on',on); gf.title=on?'Remove from favourites':'Add to favourites'; try{ await postJSON('/api/favorites',{kind:'group',id:gid,on}); }catch(_){} renderChats(searchVal()); }; }
const refresh=async()=>{ await loadSidebar(); if(selected&&selected.kind==='group'&&selected.id===gid) openConvo('group',gid); };
const view=ov.querySelector('#giView'), editRow=ov.querySelector('#giEditRow'), nameInp=ov.querySelector('#giName'), closeBtn=ov.querySelector('#giClose');
document.getElementById('giEdit').onclick=()=>{ view.classList.add('hidden'); editRow.classList.remove('hidden'); closeBtn.style.display='none'; nameInp.focus(); nameInp.select(); };
document.getElementById('giCancelEdit').onclick=()=>{ nameInp.value=info.name; editRow.classList.add('hidden'); view.classList.remove('hidden'); closeBtn.style.display=''; };
const doRename=async()=>{ const nm=nameInp.value.trim(); if(!nm){ toast('Name required'); return; } try{ await postJSON('/api/groups/rename',{group:gid,name:nm}); ov.remove(); await refresh(); toast('Group renamed'); }catch(e){ toast(e.message); } };
document.getElementById('giSave').onclick=doRename;
nameInp.addEventListener('keydown',e=>{ if(e.key==='Enter'){ e.preventDefault(); doRename(); } });
document.getElementById('giCall').onclick=()=>startGroupCall(gid);
document.getElementById('giSchedule').onclick=()=>scheduleGroupCall(gid);
const adminOnlyCb=document.getElementById('giAdminOnly'); if(adminOnlyCb) adminOnlyCb.onchange=async()=>{ try{ await postJSON('/api/groups/admin-only',{ group:gid, value:adminOnlyCb.checked }); ov.remove(); await refresh(); openGroupInfo(gid); }catch(e){ adminOnlyCb.checked=!adminOnlyCb.checked; toast(e.message||'Could not update'); } };
const addTgl=document.getElementById('giAddToggle'); if(addTgl) addTgl.onclick=()=>{ const w=document.getElementById('giAddWrap'); if(w){ w.classList.toggle('hidden'); if(!w.classList.contains('hidden')){ const s=document.getElementById('giAddSearch'); if(s) setTimeout(()=>s.focus(),0); } } };
const addSearch=document.getElementById('giAddSearch');
if(addSearch){
const filter=()=>{ const q=addSearch.value.trim().toLowerCase(); const xb=document.getElementById('giAddSearchX'); if(xb) xb.style.display=q?'grid':'none'; let shown=0; ov.querySelectorAll('#giAddList .chk').forEach(l=>{ const vis=(!q||(l.dataset.name||'').includes(q)); l.style.display=vis?'':'none'; if(vis) shown++; }); const empty=document.getElementById('giAddEmpty'); if(empty) empty.style.display=shown?'none':'block'; };
addSearch.addEventListener('input', filter);
const xb=document.getElementById('giAddSearchX'); if(xb) xb.onclick=()=>{ addSearch.value=''; filter(); addSearch.focus(); };
}
const addBtn=document.getElementById('giAddBtn'); if(addBtn) addBtn.onclick=async()=>{ const ids=[...ov.querySelectorAll('#giAddWrap input:checked')].map(i=>i.value); if(!ids.length){ toast('Pick members to add'); return; } try{ await postJSON('/api/groups/add',{group:gid,memberIds:ids}); ov.remove(); await refresh(); openGroupInfo(gid); }catch(e){ toast(e.message); } };
const nameOf=(uid)=>{ const mm=info.members.find(x=>x.id===uid); return mm?mm.name:'this person'; };
ov.querySelectorAll('[data-rm]').forEach(b=>b.onclick=async()=>{ const nm=nameOf(b.dataset.rm); if(!confirm('Remove '+nm+' from the group?\n\nThey will lose access to this conversation.')) return; try{ await postJSON('/api/groups/remove',{group:gid,userId:b.dataset.rm}); ov.remove(); await refresh(); openGroupInfo(gid); }catch(e){ toast(e.message); } });
ov.querySelectorAll('[data-role]').forEach(b=>b.onclick=async()=>{ const mk=b.dataset.val==='1'; const nm=nameOf(b.dataset.role); if(!confirm(mk?('Make '+nm+' an admin?\n\nThey will be able to add or remove members and manage this group.'):('Remove '+nm+' as an admin?'))) return; try{ await postJSON('/api/groups/admin',{group:gid,userId:b.dataset.role,value:mk}); ov.remove(); await refresh(); openGroupInfo(gid); }catch(e){ toast(e.message); } });
document.getElementById('giLeave').onclick=async()=>{
const admins=info.members.filter(m=>m.admin), others=info.members.filter(m=>!m.isMe);
// #10: last admin must pick a successor before leaving.
if(info.isAdmin && others.length && admins.length===1){ ov.remove(); pickSuccessorThenLeave(gid, others); return; }
if(!confirm('Leave this group?')) return;
try{ await postJSON('/api/groups/remove',{group:gid}); ov.remove(); selected=null; await loadSidebar(); renderChatPanel(); }catch(e){ toast(e.message); }
};
// Group photo: click avatar -> pick image -> upload -> set as group image.
const photoBtn=document.getElementById('giPhoto'), photoInput=document.getElementById('giPhotoInput');
if(photoBtn&&photoInput){
photoBtn.onclick=()=>photoInput.click();
photoInput.onchange=async()=>{
const file=photoInput.files&&photoInput.files[0]; if(!file) return;
if(!/^image\//.test(file.type||'')){ toast('Please choose an image file'); return; }
try{
const up=await fetch('/api/messages/upload',{ method:'POST', headers:{ 'Content-Type':file.type||'application/octet-stream', 'X-Filename':encodeURIComponent(file.name) }, body:file }).then(r=>r.json());
if(!up||!up.id) throw new Error(up&&up.error||'Upload failed');
await postJSON('/api/groups/avatar',{ group:gid, attachmentId:up.id });
ov.remove(); await refresh(); openGroupInfo(gid); toast('Group photo updated');
}catch(e){ toast(e.message||'Could not update photo'); }
};
}
}
// Start an audio call with the group: spin up an audio-only meeting and post the join code to the group chat.
// Group-info "Call" button → start (or join) the SHARED group call so every member is notified/rung.
function startGroupCall(gid){ const ov=document.getElementById('groupInfo'); if(ov) ov.remove(); startOrJoinGroupCall(gid); }
// Schedule a call tied to this group: close group info, open the schedule modal.
function scheduleGroupCall(gid){ const ov=document.getElementById('groupInfo'); if(ov) ov.remove(); openScheduleModal(gid); }
// ---------- Meetings (mesh P2P video) ----------
let meetWs=null, meetLocalStream=null, meetRoom=null, meetMyId=null, meetState='idle';
let meetAnnounceGroup=null; // when set, the new meeting's code is posted to this group chat
let meetAudioOnly=false; // audio call (no camera) — tiles show avatars instead of video
const meetPeers=new Map(); // peerId -> { pc, name }
const meetMuted=new Map(); // peerId|'__local' -> muted (for the tile mic-off badge)
const meetNames=new Map(); // peerId -> name (peers that arrive before their offer)
const meetAvatars=new Map(); // peerId -> avatar URL (for participant-tile profile pics)
const meetPeerUids=new Map();// peerId -> user id (to tell when an invitee has joined)
const meetCamOff=new Map(); // peerId -> camera off? (a disabled remote track still arrives, so show the avatar)
const meetInvited=new Map(); // user id -> {name, timer} for invitees who haven't joined yet (#6)
let meetReturn=null; // {kind:'dm'|'group', id} — chat to land on when the call ends (null = meetings tab)
const meetVU=new Map(); // peerId|'__local' -> {ctx,analyser,data,raf} active-speaker meters
let MEET_ICE={ iceServers:[{ urls:'stun:stun.l.google.com:19302' }] };
let meetMic=true, meetCam=true;
let meetIsHost=false, meetHostId=null; // host = the meeting creator (transferable by the host only)
let meetScreen=false, meetScreenStream=null; // am I sharing my screen + the display stream
const meetSharers=new Set(); // peerIds of OTHERS currently sharing their screen
let meetMultiShare=false; // host setting: allow several people to share at once (default: one at a time)
let meetRec=null; // active composite recording (host) {rec, stop()}
let meetTranscribe=false; // am I subscribed to a transcript copy
let meetRoomTx=false; // is the room transcription active (≥1 subscriber → all mics transcribe)
let meetSR=null; // my SpeechRecognition instance
let meetStageId=null; // which shared screen is currently on the stage (peerId|'__local')
function meetSend(o){ try{ if(meetWs && meetWs.readyState===1) meetWs.send(JSON.stringify(o)); }catch(_){} }
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;
const av=(id==='__local')?((ME&&ME.avatarUrl)||null):(meetAvatars.get(id)||null); // profile pic on the tile
tile.innerHTML='<video autoplay playsinline'+(muted?' muted':'')+'></video><div class="meet-av" style="background:'+avColor(label||'?')+'">'+pEsc(initials(label||'?'))+(av?'<img src="'+pEsc(av)+'" alt="" onerror="this.remove()">':'')+'</div><div class="meet-mute" style="display:none">'+ic('micOff',14)+'</div><span class="nm">'+pEsc(label||'')+'</span>'; grid.appendChild(tile); }
const v=tile.querySelector('video'); if(v && stream && v.srcObject!==stream) v.srcObject=stream;
const hasVid=!!(stream && stream.getVideoTracks && stream.getVideoTracks().some(t=>t.enabled && t.readyState!=='ended'));
tile.classList.toggle('novid', !hasVid || meetCamOff.get(id)===true); // camOff peer → avatar even if a (disabled) track arrives
if(meetMuted.has(id)) setTileMute(id, meetMuted.get(id)); // apply any known mute state
}
function setTileMute(id, muted){ meetMuted.set(id, !!muted); const t=document.getElementById('meet-tile-'+id); if(t){ const b=t.querySelector('.meet-mute'); if(b) b.style.display=muted?'grid':'none'; if(muted) t.classList.remove('speaking'); } }
function removeTile(id){ const t=document.getElementById('meet-tile-'+id); if(t) t.remove(); meetMuted.delete(id); meetCamOff.delete(id); meetUnwatch(id); }
// ---- Active-speaker meter: highlight a tile while its audio is above a threshold ----
function meetWatchStream(id, stream){
if(!stream || !stream.getAudioTracks || !stream.getAudioTracks().length) return; // no audio yet
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); if(p.avatar) meetAvatars.set(p.peerId,p.avatar); if(p.uid){ meetPeerUids.set(p.peerId,p.uid); meetInviteJoined(p.uid); } meetMakePeer(p.peerId,p.name); }
meetSend({type:'meeting-state', muted:!meetMic, camOff:!meetCam}); // tell existing peers my state
if(meetIsHost) meetSend({type:'meeting-host', to:meetMyId}); // announce host so others know
refreshMeetPanel(); updateHostControls();
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); if(m.avatar) meetAvatars.set(m.peerId,m.avatar); if(m.uid){ meetPeerUids.set(m.peerId,m.uid); meetInviteJoined(m.uid); }
const pc=meetMakePeer(m.peerId,m.name); // I'm an existing peer → I OFFER to the newcomer (carries my screen)
try{ const offer=await pc.createOffer(); await pc.setLocalDescription(offer); meetSend({type:'meeting-signal',to:m.peerId,data:{sdp:pc.localDescription}}); }catch(_){}
meetSend({type:'meeting-state', muted:!meetMic, camOff:!meetCam});
if(meetIsHost){ meetSend({type:'meeting-host', to:meetHostId}); meetSend({type:'meeting-sharemode', multi:meetMultiShare}); if(meetRec) meetSend({type:'meeting-recording', on:true}); }
if(meetScreen) meetSend({type:'meeting-screen', on:true});
refreshMeetPanel(); return;
}
if(m.type==='meeting-peer-state'){ setTileMute(m.peerId, !!m.muted); meetCamOff.set(m.peerId, !!m.camOff); const _t=document.getElementById('meet-tile-'+m.peerId); if(_t){ const v=_t.querySelector('video'), s=v&&v.srcObject; const hv=!!(s&&s.getVideoTracks&&s.getVideoTracks().some(tr=>tr.enabled&&tr.readyState!=='ended')); _t.classList.toggle('novid', !hv || !!m.camOff); } refreshMeetPanel(); return; } // camOff -> show avatar, not a black tile
if(m.type==='meeting-host'){ const was=meetIsHost; meetHostId=m.hostPeerId; meetIsHost=(m.hostPeerId===meetMyId); if(meetIsHost&&!was){ toast('You are now the host'); try{ speak('You are now the host'); }catch(_){} } refreshMeetPanel(); updateHostControls(); return; }
if(m.type==='meeting-transcribe-state'){ applyRoomTx(!!m.active); return; } // ≥1 subscriber → transcribe my mic
if(m.type==='meeting-recording'){ recNotice(!!m.on, m.by); return; } // someone is recording — show + announce
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(); meetAvatars.clear(); meetPeerUids.clear(); meetCamOff.clear(); meetInvited.forEach(e=>{ if(e.timer) clearTimeout(e.timer); }); meetInvited.clear(); meetMuted.clear();
if(meetLocalStream){ try{ meetLocalStream.getTracks().forEach(t=>t.stop()); }catch(_){} meetLocalStream=null; }
if(meetWs){ try{ meetWs.close(); }catch(_){} meetWs=null; }
meetRoom=null; meetMyId=null; meetState='idle'; meetIsHost=false; meetHostId=null; meetRailLive(false);
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.
// Esc closes the topmost popup/search FIRST (capture phase, before the conversation-close below).
document.addEventListener('keydown',(e)=>{
if(e.key!=='Escape') return;
const ovs=document.querySelectorAll('.modal-ov');
if(ovs.length){ ovs[ovs.length-1].remove(); e.preventDefault(); e.stopPropagation(); return; }
const sh=document.getElementById('convoSearchHead'); if(sh && sh.style.display!=='none'){ closeSearch(); e.preventDefault(); e.stopPropagation(); return; }
}, true);
document.addEventListener('keydown',(e)=>{ if(e.key==='Escape' && !document.querySelector('.modal-ov') && currentTab()==='chat' && selected!=null){ showWelcome(); } });
// #10: the device/browser Back button closes the topmost layer (popup → search → open chat on
// mobile) instead of leaving the app. We seed a history entry and re-seed after each handled back.
function bzcBack(){
const ovs=document.querySelectorAll('.modal-ov'); if(ovs.length){ ovs[ovs.length-1].remove(); return true; }
const sh=document.getElementById('convoSearchHead'); if(sh && sh.style.display!=='none'){ closeSearch(); return true; }
if(window.matchMedia('(max-width:760px)').matches && selected){ showWelcome(); return true; }
return false;
}
try{ history.pushState(null,'',location.href); }catch(_){}
window.addEventListener('popstate', ()=>{ if(bzcBack()){ try{ history.pushState(null,'',location.href); }catch(_){} } });
// Hamburger: on mobile it slides the rail drawer in/out; on desktop it collapses the rail.
function toggleNav(){ if(window.innerWidth<=760) document.body.classList.toggle('rail-open'); else document.body.classList.toggle('rail-hidden'); }
const _navT=document.getElementById('navToggle'); if(_navT) _navT.onclick=toggleNav;
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 Biz 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. Acme Inc">
<span class="lbl">Email</span><input id="rg_email" type="email" placeholder="you@bizgaze.com">
<span class="lbl">Password</span>${pwField('rg_pw','min 8 characters')}
<button class="gobtn" id="rg_btn">Create team</button>
<p id="rg_err" class="formerr"></p>
</div>`:''}
</div>`;
document.getElementById('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();
placeHdrRight(); window.addEventListener('resize', placeHdrRight); // mobile: bell+profile live in the chat-list header (no top bar)
connectChatWs();
setupPush(); // register the notification service worker + subscribe to Web Push (if granted)
{ const cl=document.getElementById('chatlist'); if(cl) enablePullRefresh(cl, loadSidebar); } // pull-to-refresh the chat list
setTimeout(maybeNotifPrompt, 1500); // gentle "enable notifications" prompt if still undecided (key for iOS PWA)
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>