2c1e1c7ca5
Photos/Files bug: the lightbox download handed nativeSaveFile a bare "/files/<id>" with no name, extension, or mime. Empty mime made bzSaveToPhotos short-circuit as "notmedia" — so saveToAlbum (and its permission prompt) never ran, and the image landed in the generic Files folder as an extension-less blob. Now nativeSaveFile trusts the server's Content-Type when the mime is unknown and appends a real extension (bzExtForMime/bzEnsureExt), so images reach the Images folder AND Photos. File preview: replace the @capacitor/share "share sheet" open with a new native FileOpener plugin (QLPreviewController). bzOpenFile now prefers a real Quick Look preview and only falls back to the share sheet if the plugin isn't in the build. Wired file-opener into mobile/package.json and the codemagic SPM diagnostics loop. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
5942 lines
517 KiB
HTML
5942 lines
517 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover, interactive-widget=resizes-content">
|
||
<title>Biz Connect</title>
|
||
<!-- PWA: installable on Android & iOS ("Add to Home Screen"); also enables iOS web push -->
|
||
<link rel="manifest" href="/manifest.json">
|
||
<meta name="theme-color" content="#1F3B73">
|
||
<link rel="icon" href="/favicon.ico?v=3" sizes="any">
|
||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png?v=3">
|
||
<link rel="apple-touch-icon" href="/apple-touch-icon-180.png?v=3">
|
||
<link rel="stylesheet" href="/bizconnect-toast.css">
|
||
<script src="/bizconnect-toast.js"></script>
|
||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||
<meta name="mobile-web-app-capable" content="yes">
|
||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||
<meta name="apple-mobile-web-app-title" content="Connect">
|
||
<style>
|
||
:root{ --brand:#FFC708; --brand-d:#E0AC00; --blue:#1F3B73; --blue-d:#16294f; --blue-soft:#EAF0FB; --ink:#1f2430; --muted:#6b7280; --bg:#f6f8fb; --card:#fff; --line:#e6e9ef; --green:#16a34a; --red:#b91c1c; }
|
||
*{box-sizing:border-box;}
|
||
/* #8a: iOS rubber-band scrolling bounced the whole page, exposing the background BELOW the fixed
|
||
bottom rail (the "gap"). overscroll-behavior:none pins the page; the rail's own height already
|
||
includes the home-indicator inset. */
|
||
html,body{height:100%;overscroll-behavior:none;}
|
||
/* Safe-area insets. The native app enables edge-to-edge via the @capacitor-community/safe-area plugin,
|
||
which populates env(safe-area-inset-*) with real values on every device (notch, Dynamic Island, home
|
||
indicator). On the web these resolve to 0. All layout below pads with var(--sat)/var(--sab). */
|
||
:root{--sat:env(safe-area-inset-top,0px);--sab:env(safe-area-inset-bottom,0px);--kb:0px;}
|
||
body{font-family:'Segoe UI',system-ui,sans-serif;background:var(--bg);color:var(--ink);margin:0;display:flex;flex-direction:column;height:100vh;height:100dvh;overflow:hidden;position:fixed;inset:0;width:100%;}
|
||
|
||
/* ---- Top bar ---- */
|
||
/* #4: the top blue bar is removed everywhere — bell+profile live in the chat-list header. */
|
||
header{display:none;}
|
||
.brandrow{display:flex;align-items:center;gap:.6rem;}
|
||
.logo{width:30px;height:30px;border-radius:8px;background:var(--brand);display:grid;place-items:center;font-weight:800;color:var(--blue);}
|
||
.brand{font-weight:700;color:#fff;font-size:1.05rem;} .brand span.y{color:var(--brand);font-weight:700;}
|
||
|
||
/* ---- Header actions: notification bell + profile ---- */
|
||
#hdrRight{display:flex;align-items:center;gap:.45rem;}
|
||
.bell{position:relative;}
|
||
.bellbtn{position:relative;background:var(--blue-soft);border:1px solid var(--line);color:var(--blue);cursor:pointer;width:38px;height:38px;border-radius:10px;display:grid;place-items:center;}
|
||
.bellbtn:hover{background:#dbe6fb;}
|
||
.bellbtn:hover{background:rgba(255,255,255,.24);}
|
||
.bell-dot{position:absolute;top:2px;right:2px;min-width:16px;height:16px;border-radius:99px;background:var(--red);color:#fff;font-size:.6rem;font-weight:800;display:grid;place-items:center;padding:0 .2rem;border:2px solid var(--card);}
|
||
.bell-menu{position:absolute;right:0;top:calc(100% + 6px);width:340px;max-width:92vw;background:#fff;border:1px solid #e6e9ef;border-radius:12px;box-shadow:0 12px 30px rgba(0,0,0,.18);z-index:5000;display:none;overflow:hidden;}
|
||
/* When bell+profile live in the left rail (desktop), open their menus to the RIGHT of the rail,
|
||
bottom-aligned, so they aren't clipped off-screen. */
|
||
#rail .bell-menu{top:auto;bottom:0;right:auto;left:calc(100% + 10px);}
|
||
#rail .profile .pmenu{top:auto;bottom:0;right:auto;left:calc(100% + 10px);}
|
||
.bell-menu.open{display:block;}
|
||
.bell-head{display:flex;align-items:center;justify-content:space-between;padding:.6rem .85rem;border-bottom:1px solid #eef1f6;font-weight:700;font-size:.9rem;color:var(--ink);}
|
||
.bell-head button{background:none;border:none;color:var(--blue);font-size:.78rem;cursor:pointer;font-weight:600;}
|
||
.bell-list{max-height:62vh;overflow:auto;}
|
||
.bell-item{display:flex;gap:.6rem;align-items:flex-start;padding:.6rem .85rem;border-bottom:1px solid #f4f6fa;cursor:pointer;}
|
||
.bell-item:hover{background:#f6f8fb;}
|
||
.bell-item.unread{background:#eef4ff;}
|
||
.bell-ico{width:30px;height:30px;border-radius:50%;background:var(--blue-soft);color:var(--blue);display:grid;place-items:center;flex:0 0 30px;}
|
||
.bell-body{min-width:0;}
|
||
.bell-tx{font-size:.85rem;color:var(--ink);line-height:1.3;}
|
||
.bell-tm{font-size:.7rem;color:var(--muted);margin-top:.1rem;}
|
||
.bell-empty{padding:1.6rem;text-align:center;color:var(--muted);font-size:.85rem;}
|
||
/* ---- Profile dropdown (from console.html) ---- */
|
||
.profile{position:relative}
|
||
.profile .pbtn.icon-only{padding:0;gap:0;border-radius:50%;background:transparent;border:none;position:relative;}
|
||
.pav-dot{position:absolute;right:-1px;bottom:-1px;width:11px;height:11px;border-radius:50%;border:2px solid var(--card);background:#cbd2dd;}
|
||
.pav-dot.active{background:#16a34a;} .pav-dot.away{background:#f59e0b;} .pav-dot.onleave{background:#ef4444;} .pav-dot.incall{background:#2563eb;} .pav-dot.offline{background:#cbd2dd;}
|
||
.pmenu .ps-current{display:flex;align-items:center;gap:.45rem;}
|
||
.pmenu .ps-current .ps-arrow{margin-left:auto;color:var(--muted);display:inline-flex;}
|
||
.pmenu .ps-options{background:#f8fafc;border-top:1px solid #eef1f6;border-bottom:1px solid #eef1f6;}
|
||
.pmenu .ps-opt{display:flex;align-items:center;gap:.45rem;padding-left:1.3rem;}
|
||
.pmenu .ps-opt.on{font-weight:700;}
|
||
.pmenu .ps-opt .ps-check{margin-left:auto;display:none;color:#16a34a;align-items:center;}
|
||
.pmenu .ps-opt.on .ps-check{display:inline-flex;}
|
||
.pmenu .ps-div{height:1px;background:#eef1f6;margin:.3rem 0;}
|
||
.profile .pbtn{display:flex;align-items:center;gap:.5rem;background:rgba(255,255,255,.14);color:#fff;border:1px solid #46598c;border-radius:10px;padding:.4rem .85rem .4rem .5rem;font-weight:600;font-size:.88rem;cursor:pointer}
|
||
.profile .pbtn:hover{background:rgba(255,255,255,.24)}
|
||
.profile .pbtn .pav{position:relative;width:28px;height:28px;border-radius:50%;background:var(--brand);color:var(--blue);display:grid;place-items:center;font-weight:800;font-size:.78rem;overflow:hidden}
|
||
.profile .pbtn .pav img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}
|
||
.profile .pmenu{position:absolute;right:0;top:calc(100% + 6px);background:#fff;border:1px solid #e6e9ef;border-radius:10px;box-shadow:0 10px 28px rgba(0,0,0,.18);min-width:210px;overflow:hidden;z-index:5000;display:none}
|
||
.profile .pmenu.open{display:block}
|
||
.profile .pmenu .phead{padding:.7rem .9rem;border-bottom:1px solid #eef1f6}
|
||
.profile .pmenu .phead .n{font-weight:700;font-size:.9rem}
|
||
.profile .pmenu .phead .e{color:var(--muted);font-size:.78rem}
|
||
.profile .pmenu a{display:flex;align-items:center;gap:.55rem;padding:.6rem .9rem;color:#1f2430;text-decoration:none;font-size:.9rem;cursor:pointer}
|
||
.profile .pmenu a:hover{background:#f1f5f9}
|
||
.profile .pmenu a .ic{color:var(--muted)}
|
||
.profile .pmenu a.danger{color:#b91c1c;border-top:1px solid #eef1f6}
|
||
.profile .pmenu a.danger .ic{color:#b91c1c}
|
||
|
||
/* ---- Shell ---- */
|
||
.shell{flex:1 1 auto;display:flex;min-height:0;}
|
||
|
||
/* ---- Icon rail ---- */
|
||
.rail{width:74px;flex:0 0 74px;background:var(--card);border-right:1px solid var(--line);display:flex;flex-direction:column;align-items:center;padding:.8rem 0;gap:.4rem;}
|
||
/* Bell + profile relocated to the bottom of the rail on desktop. */
|
||
#rail #hdrRight{display:flex;flex-direction:column;align-items:center;gap:.55rem;margin-top:auto;padding:.5rem 0 .2rem;}
|
||
#rail #hdrRight .pav{width:34px;height:34px;font-size:.85rem;}
|
||
.railbtn{position:relative;width:50px;height:50px;border:none;background:transparent;border-radius:14px;color:var(--muted);cursor:pointer;display:grid;place-items:center;transition:background .12s,color .12s;}
|
||
.railbtn:hover{background:var(--blue-soft);color:var(--blue);}
|
||
.railbtn.active{background:var(--blue);color:#fff;}
|
||
.railbtn .rdot{position:absolute;top:8px;right:8px;min-width:16px;height:16px;border-radius:99px;background:var(--brand);color:var(--blue);font-size:.62rem;font-weight:800;display:grid;place-items:center;padding:0 .2rem;border:2px solid var(--card);}
|
||
.railbtn.active .rdot{border-color:var(--blue);}
|
||
.railbtn .livedot{position:absolute;top:8px;right:8px;width:11px;height:11px;border-radius:50%;background:var(--green);border:2px solid var(--card);display:none;}
|
||
.railbtn.active .livedot{border-color:var(--blue);}
|
||
.railbtn.live .livedot{display:block;animation:livePulse 1.4s infinite;}
|
||
@keyframes livePulse{0%,100%{opacity:1}50%{opacity:.3}}
|
||
.railbtn .rlabel{display:none;font-size:.6rem;margin-top:0;}
|
||
/* tooltip */
|
||
.railbtn::after{content:attr(data-tip);position:absolute;left:calc(100% + 12px);top:50%;transform:translateY(-50%);background:var(--blue-d);color:#fff;padding:.35rem .6rem;border-radius:8px;font-size:.78rem;font-weight:600;white-space:nowrap;opacity:0;pointer-events:none;transition:opacity .14s;z-index:200;box-shadow:0 6px 16px rgba(0,0,0,.25);}
|
||
.railbtn::before{content:"";position:absolute;left:calc(100% + 6px);top:50%;transform:translateY(-50%);border:6px solid transparent;border-right-color:var(--blue-d);opacity:0;pointer-events:none;transition:opacity .14s;z-index:200;}
|
||
.railbtn:hover::after,.railbtn:hover::before{opacity:1;}
|
||
.rail-spacer{flex:1 1 auto;}
|
||
.caption{font-size:.58rem;color:var(--muted);text-align:center;line-height:1.2;}
|
||
|
||
/* ---- Chat list column ---- */
|
||
.chatcol{width:312px;flex:0 0 312px;background:var(--card);border-right:1px solid var(--line);display:flex;flex-direction:column;min-height:0;}
|
||
.chatcol.hidden{display:none;}
|
||
.side-sec{font-size:.68rem;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);font-weight:700;padding:.6rem .9rem .25rem;}
|
||
/* Pinned "You" (note-to-self) set apart from the conversation list (#3): tinted + a divider. */
|
||
.side-div{height:1px;background:var(--line);margin:.4rem .7rem .3rem;}
|
||
.chat-row.self{background:var(--blue-soft);}
|
||
.chat-row.self .chat-name::after{content:"Note to self";font-size:.58rem;font-weight:700;color:var(--blue);background:#fff;border:1px solid var(--line);border-radius:99px;padding:.03rem .35rem;margin-left:.4rem;vertical-align:middle;text-transform:none;}
|
||
.contact-row,.dir-row{display:flex;align-items:center;gap:.6rem;padding:.5rem .9rem;cursor:pointer;}
|
||
.contact-row:hover,.dir-row:hover{background:var(--blue-soft);}
|
||
.contact-row .cr-name,.dir-row .cr-name{font-size:.9rem;color:var(--ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
|
||
.dir-row .dr-main{display:flex;flex-direction:column;min-width:0;flex:1;}
|
||
.dir-row .dr-sub{font-size:.74rem;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
|
||
.dir-row .dr-tag{font-size:.62rem;font-weight:700;color:#92600b;background:#fff3cd;border-radius:99px;padding:.1rem .4rem;flex:0 0 auto;}
|
||
.dir-empty{display:flex;gap:.55rem;align-items:flex-start;margin:.5rem .9rem;padding:.6rem .7rem;background:#f6f8fc;border:1px solid var(--line);border-radius:10px;color:var(--muted);}
|
||
.dir-empty svg{flex:0 0 auto;margin-top:.05rem;color:var(--blue);}
|
||
.dir-empty b{display:block;font-size:.82rem;color:var(--ink);margin-bottom:.1rem;}
|
||
.dir-empty span{font-size:.74rem;line-height:1.35;}
|
||
/* Chat-list header (now the app's top area — the blue bar is gone). */
|
||
.side-head{padding:.85rem 1rem .75rem;border-bottom:1px solid var(--line);background:linear-gradient(180deg,#f3f7fd 0%,var(--card) 100%);}
|
||
.side-title{display:flex;align-items:center;justify-content:space-between;gap:.5rem;margin-bottom:.7rem;}
|
||
.side-title h2{font-size:1.15rem;margin:0;color:var(--blue);font-weight:800;letter-spacing:-.01em;flex:1;}
|
||
.side-title #hdrRight{display:flex;align-items:center;gap:.45rem;flex:0 0 auto;}
|
||
.side-search-row{display:flex;align-items:center;gap:.5rem;}
|
||
.side-search-row .search{flex:1;min-width:0;}
|
||
.newchat{width:38px;height:38px;flex:0 0 auto;border-radius:10px;border:none;background:var(--blue);color:#fff;font-size:1.35rem;line-height:1;cursor:pointer;font-weight:700;display:grid;place-items:center;padding:0;box-shadow:0 2px 8px rgba(31,59,115,.25);}
|
||
.newchat:hover{filter:brightness(1.08);}
|
||
.search{position:relative;}
|
||
.search > svg{position:absolute;left:.65rem;top:50%;transform:translateY(-50%);color:var(--muted);}
|
||
.search input{width:100%;padding:.55rem .7rem .55rem 2.1rem;border-radius:10px;border:2px solid var(--line);background:#fbfcfe;color:var(--ink);font-size:.9rem;}
|
||
.search input:focus{outline:none;border-color:var(--brand);}
|
||
.search input{padding-right:2.1rem;}
|
||
.search-x{position:absolute;right:.5rem;top:50%;transform:translateY(-50%);border:none;background:transparent;color:var(--muted);cursor:pointer;padding:.2rem;border-radius:6px;display:grid;place-items:center;}
|
||
.search-x:hover{color:var(--blue);background:var(--blue-soft);}
|
||
|
||
/* Modern thin scrollbars for every scroll area (chat, modals, lists, …). Without this,
|
||
Electron on Windows falls back to the classic scrollbar with up/down stepper arrows. */
|
||
::-webkit-scrollbar{width:11px;height:11px;}
|
||
::-webkit-scrollbar-button,::-webkit-scrollbar-button:start:decrement,::-webkit-scrollbar-button:end:increment,::-webkit-scrollbar-button:vertical:start,::-webkit-scrollbar-button:vertical:end,::-webkit-scrollbar-button:horizontal:start,::-webkit-scrollbar-button:horizontal:end{display:none!important;width:0!important;height:0!important;}
|
||
::-webkit-scrollbar-track{background:transparent;}
|
||
/* Was 6px with a 1px inset border (~4px grabbable) and no min height → the thumb was tiny and felt
|
||
"stuck" when dragged. Wider, solid, with a min length so it's easy to grab and drags smoothly. */
|
||
::-webkit-scrollbar-thumb{background:#b9c4d4;border-radius:8px;min-height:44px;}
|
||
::-webkit-scrollbar-thumb:hover{background:#9fadc2;}
|
||
::-webkit-scrollbar-corner{background:transparent;}
|
||
*{scrollbar-width:thin;scrollbar-color:#c7d0dd transparent;}
|
||
|
||
/* #8b: overscroll-behavior:contain stops Safari/Chrome's own rubber-band + page pull-to-refresh from
|
||
swallowing the gesture, so OUR pull-to-refresh fires on iOS as well as Android. */
|
||
.chatlist{overflow-y:auto;flex:1 1 auto;padding:.4rem;scrollbar-width:thin;scrollbar-color:transparent transparent;overscroll-behavior-y:contain;-webkit-overflow-scrolling:touch;}
|
||
.convo-msgs{overscroll-behavior-y:contain;-webkit-overflow-scrolling:touch;}
|
||
.chatlist:hover{scrollbar-color:#c7d0dd transparent;}
|
||
.chatlist::-webkit-scrollbar{width:6px;}
|
||
.chatlist::-webkit-scrollbar-button,.chatlist::-webkit-scrollbar-button:start:decrement,.chatlist::-webkit-scrollbar-button:end:increment{display:none!important;height:0!important;width:0!important;}
|
||
.convo-msgs::-webkit-scrollbar{width:10px;} .convo-msgs::-webkit-scrollbar-button{display:none!important;height:0!important;width:0!important;}
|
||
.convo-msgs::-webkit-scrollbar-thumb{min-height:44px;}
|
||
.chatlist::-webkit-scrollbar-thumb{background:transparent;border-radius:8px;}
|
||
.chatlist:hover::-webkit-scrollbar-thumb{background:#c7d0dd;}
|
||
/* Pull-to-refresh indicator (mobile) */
|
||
/* Lives on the list's NON-scrolling parent, so it stays pinned at the top and is actually seen (#6). */
|
||
.ptr-ind{position:absolute;top:calc(var(--sat) + 8px);left:50%;width:34px;height:34px;margin-left:-17px;border-radius:50%;background:var(--card);box-shadow:0 3px 12px rgba(20,30,60,.22);display:grid;place-items:center;color:var(--blue);z-index:40;opacity:0;transform:translateY(-44px);transition:opacity .12s;pointer-events:none;} /* top offset clears the notch/Dynamic Island (var(--sat)) so the spinner isn't hidden behind it */
|
||
.ptr-ind .ptr-g{font-size:1.15rem;font-weight:700;line-height:1;display:inline-block;}
|
||
.ptr-ind.spin .ptr-g{animation:ptrspin .7s linear infinite;}
|
||
.ptr-ind img{display:block;} .ptr-ind.spin img{animation:ptrspin .8s linear infinite;}
|
||
@keyframes ptrspin{to{transform:rotate(360deg);}}
|
||
/* Enable-notifications prompt (esp. iOS PWA) */
|
||
.notif-prompt{position:fixed;left:50%;transform:translateX(-50%);bottom:calc(74px + env(safe-area-inset-bottom,0px));z-index:2000;display:flex;align-items:center;gap:.7rem;width:max-content;max-width:min(460px,94vw);background:var(--blue);color:#fff;padding:.6rem .7rem .6rem 1rem;border-radius:12px;box-shadow:0 10px 30px rgba(20,30,60,.32);font-size:.86rem;}
|
||
.notif-prompt .np-actions{display:flex;align-items:center;gap:.4rem;margin-left:auto;flex:0 0 auto;}
|
||
.notif-prompt .btn.sm{background:#fff;color:var(--blue);}
|
||
.notif-prompt .np-x{background:transparent;border:none;color:#fff;opacity:.85;cursor:pointer;display:grid;place-items:center;padding:.2rem;}
|
||
@media(min-width:761px){ .notif-prompt{bottom:18px;} }
|
||
.perm-state{font-size:.68rem;font-weight:700;padding:.06rem .45rem;border-radius:99px;vertical-align:middle;}
|
||
.perm-state.granted{background:#dcfce7;color:#15803d;}
|
||
.perm-state.denied{background:#fee2e2;color:#b91c1c;}
|
||
.perm-state.default,.perm-state.unsupported{background:#f1f5f9;color:#475569;}
|
||
.chat-row{display:flex;gap:.7rem;align-items:center;padding:.6rem .65rem;border-radius:12px;cursor:pointer;position:relative;}
|
||
.chat-row:hover{background:#f3f6fb;}
|
||
.chat-row.active{background:var(--blue-soft);box-shadow:inset 0 0 0 1.5px var(--brand);} /* #6: thin yellow boundary around the selected chat */
|
||
.avatar{width:42px;height:42px;flex:0 0 42px;border-radius:50%;display:grid;place-items:center;color:#334155;font-weight:700;font-size:.92rem;position:relative;}
|
||
.avatar .av-img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;border-radius:inherit;}
|
||
.avatar .dot{position:absolute;right:-1px;bottom:-1px;width:11px;height:11px;border-radius:50%;border:2px solid #fff;background:#cbd2dd;z-index:1;}
|
||
.avatar .dot.on{background:var(--green);}
|
||
/* presence/status dot colors (#7): in-call = solid red; on-leave = circle with a minus. */
|
||
.dot.active{background:#16a34a;} .dot.away{background:#f59e0b;} .dot.onleave{background:#dc2626;} .dot.incall{background:#dc2626;} .dot.offline{background:#cbd2dd;}
|
||
.st-dot{position:relative;display:inline-block;width:10px;height:10px;border-radius:50%;margin-right:.4rem;vertical-align:middle;background:#cbd2dd;}
|
||
.st-dot.active{background:#16a34a;} .st-dot.away{background:#f59e0b;} .st-dot.onleave{background:#dc2626;} .st-dot.incall{background:#dc2626;} .st-dot.offline{background:#cbd2dd;}
|
||
/* the "minus in circle" for On leave (on every size of dot) */
|
||
.dot.onleave::after,.st-dot.onleave::after,.pav-dot.onleave::after{content:"";position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);width:6px;height:1.6px;background:#fff;border-radius:1px;}
|
||
.chat-main{flex:1 1 auto;min-width:0;}
|
||
.chat-top{display:flex;justify-content:space-between;align-items:baseline;gap:.5rem;}
|
||
.chat-name{font-weight:400;font-size:.92rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
|
||
.chat-time{color:var(--muted);font-size:.72rem;flex:0 0 auto;}
|
||
.chat-bottom{display:flex;justify-content:space-between;align-items:center;gap:.5rem;margin-top:.15rem;}
|
||
.chat-prev{color:var(--muted);font-size:.82rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1 1 auto;}
|
||
.chat-row.unread .chat-prev{color:var(--ink);font-weight:500;}
|
||
.prev-tick{display:inline-flex;vertical-align:middle;color:var(--muted);} .prev-tick.read{color:#2563eb;} .prev-tick svg{width:14px;height:14px;}
|
||
.draft-lbl{color:#dc2626;font-weight:600;}
|
||
.chat-row.unread .chat-name{font-weight:700;}
|
||
.badge{flex:0 0 auto;background:var(--brand);color:var(--blue);font-size:.7rem;font-weight:800;min-width:19px;height:19px;border-radius:99px;padding:0 .35rem;display:grid;place-items:center;}
|
||
.no-results{padding:2rem 1rem;text-align:center;color:var(--muted);font-size:.85rem;}
|
||
.demo-note{padding:.5rem 1rem;border-top:1px solid var(--line);color:var(--muted);font-size:.72rem;text-align:center;background:#fbfcfe;}
|
||
|
||
/* ---- Main content ---- */
|
||
.content{flex:1 1 auto;position:relative;min-width:0;min-height:0;background:var(--bg);}
|
||
.panel{position:absolute;inset:0;display:none;}
|
||
.panel.active{display:flex;}
|
||
.panel.center{align-items:center;justify-content:center;padding:2rem;overflow-y:auto;}
|
||
.panel iframe{width:100%;height:100%;border:0;display:block;background:var(--bg);}
|
||
|
||
/* welcome + feature cards */
|
||
.welcome{text-align:center;max-width:560px;}
|
||
.welcome .wave{font-size:3rem;line-height:1;margin-bottom:.4rem;}
|
||
.welcome h1{font-size:1.8rem;color:var(--blue);margin:.2rem 0 .5rem;}
|
||
.welcome p{color:var(--muted);font-size:1rem;line-height:1.6;margin:0 auto 1.8rem;max-width:440px;}
|
||
.wcards{display:flex;gap:1rem;flex-wrap:wrap;justify-content:center;}
|
||
.wcard{flex:1;min-width:150px;max-width:180px;background:var(--card);border:1px solid var(--line);border-radius:14px;padding:1.2rem 1rem;cursor:pointer;transition:transform .12s,box-shadow .12s,border-color .12s;text-align:center;}
|
||
.wcard:hover{transform:translateY(-3px);box-shadow:0 12px 28px rgba(20,30,60,.1);border-color:var(--brand);}
|
||
.wcard .wi{width:46px;height:46px;border-radius:12px;display:grid;place-items:center;margin:0 auto .6rem;background:var(--blue-soft);color:var(--blue);}
|
||
.wcard h3{margin:0 0 .2rem;font-size:.95rem;color:var(--blue);}
|
||
.wcard p{margin:0;font-size:.78rem;color:var(--muted);line-height:1.4;}
|
||
|
||
.card{background:var(--card);border:1px solid var(--line);border-radius:16px;padding:2.2rem;box-shadow:0 6px 18px rgba(20,30,60,.05);text-align:center;max-width:520px;}
|
||
.feat-icon{width:72px;height:72px;border-radius:20px;display:grid;place-items:center;margin:0 auto 1.2rem;}
|
||
.feat-icon.yellow{background:#fff6d8;color:var(--brand-d);}
|
||
.card h1{font-size:1.45rem;margin:0 0 .5rem;color:var(--blue);}
|
||
.card p{color:var(--muted);font-size:.95rem;line-height:1.55;margin:0 auto 1.6rem;max-width:400px;}
|
||
.pill-soon{display:inline-block;background:#fff6d8;color:var(--brand-d);font-size:.74rem;font-weight:700;padding:.25rem .7rem;border-radius:99px;letter-spacing:.03em;margin-bottom:1.2rem;}
|
||
.btn{display:inline-flex;align-items:center;gap:.5rem;text-decoration:none;padding:.8rem 1.6rem;background:var(--brand);color:var(--ink);border:none;border-radius:11px;font-weight:700;font-size:.95rem;cursor:pointer;}
|
||
.btn:hover{background:var(--brand-d);}
|
||
.hint{margin-top:1.4rem;font-size:.8rem;color:var(--muted);}
|
||
|
||
/* conversation placeholder (selected chat, no backend yet) */
|
||
.convo{flex-direction:column;display:flex;width:100%;height:100%;}
|
||
.convo-head{display:flex;align-items:center;gap:.7rem;padding:.9rem 1.2rem;border-bottom:1px solid var(--line);background:var(--card);position:relative;}
|
||
/* NB: #chatPanel is .panel which is already position:absolute (a containing block),
|
||
so the drop overlay positions against it. Do NOT add position:relative here — an ID
|
||
selector would override .panel{position:absolute;inset:0} and collapse the pane. */
|
||
#chatPanel.drag-over::after{content:"Drop to send";position:absolute;inset:10px;border:2.5px dashed var(--blue);border-radius:14px;background:rgba(31,59,115,.07);display:grid;place-items:center;color:var(--blue);font-weight:700;font-size:1.15rem;z-index:60;pointer-events:none;}
|
||
.ic{display:inline-block;vertical-align:middle;}
|
||
img.emoji{height:1.15em;width:1.15em;margin:0 .05em;vertical-align:-0.15em;display:inline-block;}
|
||
.emoji-grid img.emoji,.emoji-tabs img.emoji{height:1.4em;width:1.4em;}
|
||
.convo-back{border:none;background:var(--blue-soft);color:var(--blue);width:34px;height:34px;border-radius:9px;cursor:pointer;display:grid;place-items:center;flex:0 0 auto;}
|
||
.convo-back:hover{background:#dbe6fb;}
|
||
.convo-head .nm{font-weight:700;color:var(--ink);}
|
||
.convo-head .st{font-size:.78rem;color:var(--muted);}
|
||
/* Live "typing…" — brand blue + italic, in both the header subtitle and the sidebar preview. */
|
||
.convo-head .st.typing{color:var(--blue);font-weight:600;font-style:italic;}
|
||
.typing-prev{color:var(--blue);font-weight:600;font-style:italic;}
|
||
/* In-chat search: the header turns into a search bar with count + up/down jump arrows. */
|
||
.convo-search-head{position:absolute;inset:0;display:flex;align-items:center;gap:.4rem;padding:0 .8rem;background:var(--card);z-index:6;}
|
||
.convo-search-head input{flex:1;min-width:0;border:none;background:transparent;font-size:.95rem;color:var(--ink);}
|
||
.convo-search-head input:focus{outline:none;}
|
||
.csh-count{font-size:.8rem;color:var(--muted);white-space:nowrap;flex:0 0 auto;min-width:2.5rem;text-align:right;}
|
||
.csh-nav{border:none;background:var(--blue-soft);color:var(--blue);width:30px;height:30px;border-radius:8px;cursor:pointer;display:grid;place-items:center;flex:0 0 auto;}
|
||
.csh-nav:hover{background:#dbe6fb;}
|
||
mark.search-hit{background:#fde68a;color:inherit;border-radius:2px;padding:0 1px;}
|
||
mark.search-current{background:#f59e0b;color:#1f2430;}
|
||
.bubble.search-flash{animation:searchFlash 1.4s ease;}
|
||
@keyframes searchFlash{0%,20%{box-shadow:0 0 0 3px var(--brand);}100%{box-shadow:0 0 0 0 transparent;}}
|
||
.gi-media-row{display:flex;align-items:center;justify-content:space-between;width:100%;background:#f6f8fb;border:1px solid var(--line);border-radius:10px;padding:.65rem .8rem;cursor:pointer;color:var(--ink);font-size:.92rem;font-weight:600;}
|
||
.gi-media-row:hover{background:#eef2f8;}
|
||
.gi-media-row .gmr-l{display:flex;align-items:center;gap:.6rem;}
|
||
.gi-media-row .gmr-ic{width:32px;height:32px;flex:0 0 auto;border-radius:9px;background:linear-gradient(135deg,#2563eb,#1F3B73);color:#fff;display:grid;place-items:center;box-shadow:0 2px 6px rgba(31,59,115,.25);}
|
||
.gi-media-row .gmr-ic svg{width:17px;height:17px;}
|
||
.gi-media-row .gmr-r{color:var(--muted);font-size:.85rem;font-weight:600;}
|
||
.gi-media-strip{display:flex;gap:.3rem;margin:.4rem 0 .2rem;overflow:hidden;}
|
||
.gms-thumb{width:60px;height:60px;object-fit:cover;border-radius:8px;background:#f1f5f9;cursor:pointer;flex:0 0 auto;}
|
||
/* Media / Docs / Links — clean underline tabs (active = brand-blue underline, no chip borders) */
|
||
.mv-tabs{display:flex;gap:0;border-bottom:1px solid var(--line);margin:.5rem 0 0;}
|
||
.mv-tabs .mp-tab{flex:1;border:none;background:transparent;color:var(--muted);font-size:.95rem;font-weight:600;padding:.6rem .3rem .65rem;cursor:pointer;border-bottom:2px solid transparent;margin-bottom:-1px;text-align:center;}
|
||
.mv-tabs .mp-tab:hover{color:var(--ink);}
|
||
.mv-tabs .mp-tab.on{color:var(--blue);font-weight:700;border-bottom-color:var(--blue);}
|
||
.mv-body{max-height:58vh;overflow:auto;margin-top:.5rem;}
|
||
/* Shared-media tiles: image thumbnail, or audio/video tile with download + duration */
|
||
.sh-cell{margin:0;display:flex;flex-direction:column;gap:.28rem;min-width:0;}
|
||
.sh-name{font-size:.7rem;font-weight:700;color:var(--ink);text-transform:uppercase;letter-spacing:.02em;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
|
||
.sh-av{position:relative;display:block;width:100%;aspect-ratio:1;border-radius:8px;overflow:hidden;text-decoration:none;background:#eef2f8;cursor:pointer;}
|
||
.sh-av.aud{background:linear-gradient(135deg,#f7b733,#ee8c1a);}
|
||
.sh-av.vid{background:#0b1220;}
|
||
.sh-poster{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;pointer-events:none;}
|
||
.sh-dl{position:absolute;left:50%;top:46%;transform:translate(-50%,-50%);width:46px;height:46px;border-radius:50%;background:rgba(20,30,60,.28);display:grid;place-items:center;color:#fff;transition:background .12s;}
|
||
.sh-av:hover .sh-dl{background:rgba(20,30,60,.45);}
|
||
.sh-dur{position:absolute;left:0;right:0;bottom:0;display:flex;align-items:center;gap:.28rem;padding:.28rem .42rem;background:linear-gradient(transparent,rgba(0,0,0,.5));color:#fff;font-size:.74rem;font-weight:600;}
|
||
.sh-dur .sh-di{display:inline-flex;} .sh-dur svg{width:13px;height:13px;}
|
||
.sh-dur i{font-style:normal;}
|
||
.fav-on svg{fill:#f59e0b;color:#f59e0b;}
|
||
.sh-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(92px,1fr));gap:.4rem;}
|
||
.sh-thumb{width:100%;aspect-ratio:1;object-fit:cover;border-radius:8px;cursor:pointer;background:#f1f5f9;}
|
||
.sh-file{display:flex;align-items:center;gap:.55rem;padding:.6rem .55rem;border-bottom:1px solid var(--line);color:var(--ink);text-decoration:none;}
|
||
.sh-file:hover{background:#f6f8fb;}
|
||
.sh-file .sh-fn{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-size:.9rem;}
|
||
.sh-file .sh-sz{font-size:.74rem;color:var(--muted);flex:0 0 auto;}
|
||
.sh-file svg{color:var(--blue);flex:0 0 auto;}
|
||
.convo-body{flex:1;display:grid;place-items:center;text-align:center;color:var(--muted);padding:2rem;}
|
||
.convo-body .big{font-size:2.4rem;margin-bottom:.4rem;}
|
||
/* message thread */
|
||
.convo-msgs{flex:1;overflow-y:auto;overflow-x:clip;padding:1rem 1.2rem;display:flex;flex-direction:column;gap:.35rem;background:var(--bg);}
|
||
/* word-break:normal + overflow-wrap:break-word: still emergency-breaks an over-long word/URL to fit,
|
||
but keeps min-content = longest WORD (not 1 glyph) so a bubble can never collapse to ~0 width and
|
||
wrap text into hundreds of lines during a transient 0-width layout frame (the "stretch" balloon). */
|
||
.bubble{max-width:72%;padding:.5rem .75rem;border-radius:14px;font-size:.9rem;line-height:1.4;white-space:pre-wrap;word-break:normal;overflow-wrap:break-word;box-shadow:0 1px 2px rgba(20,30,60,.06);}
|
||
.bubble.them{align-self:flex-start;background:#fff;border:1px solid var(--line);color:var(--ink);border-bottom-left-radius:4px;}
|
||
.bubble.mine{align-self:flex-end;background:var(--blue);color:#fff;border-bottom-right-radius:4px;}
|
||
.bubble .t{display:block;font-size:.64rem;opacity:.65;margin-top:.15rem;text-align:right;}
|
||
.day-sep{align-self:center;font-size:.72rem;margin:.7rem 0 .3rem;text-align:center;}
|
||
.new-sep{display:flex;align-items:center;gap:.6rem;margin:.6rem .2rem;color:var(--red);font-size:.72rem;font-weight:700;}
|
||
.new-sep::before,.new-sep::after{content:"";flex:1;height:1px;background:#f0c2c2;}
|
||
.day-sep span{background:#fde7b0;color:#7a5b05;padding:.22rem .8rem;border-radius:99px;font-weight:600;box-shadow:0 1px 3px rgba(20,30,60,.1);}
|
||
.float-date{position:absolute;top:.6rem;left:50%;transform:translateX(-50%);z-index:5;background:#fde7b0;color:#7a5b05;padding:.22rem .85rem;border-radius:99px;font-weight:600;font-size:.72rem;box-shadow:0 3px 10px rgba(20,30,60,.18);pointer-events:none;}
|
||
.jump-latest{position:absolute;right:16px;bottom:86px;z-index:5;width:42px;height:42px;border-radius:50%;border:1px solid var(--line);background:var(--card);color:var(--blue);box-shadow:0 4px 14px rgba(20,30,60,.22);cursor:pointer;display:grid;place-items:center;}
|
||
.jump-latest:hover{background:var(--brand);color:var(--blue);border-color:var(--brand-d);}
|
||
.empty-thread{align-self:center;font-size:.85rem;color:var(--muted);margin:auto;}
|
||
.sys-msg{align-self:center;font-size:.76rem;color:var(--muted);background:rgba(0,0,0,.04);padding:.25rem .7rem;border-radius:99px;margin:.2rem 0;max-width:90%;text-align:center;}
|
||
.bubble .t{display:flex;align-items:center;justify-content:flex-end;gap:.25rem;font-size:.64rem;opacity:.65;margin-top:.15rem;}
|
||
.bubble .rcpt{display:inline-flex;}
|
||
.bubble.mine .rcpt{opacity:.85;}
|
||
/* Read receipt on my own (blue) bubble: brand yellow so the double tick actually stands out. */
|
||
.bubble.mine .rcpt.seen{color:var(--brand);opacity:1;}
|
||
.bubble.mine .rcpt.grp{cursor:pointer;} /* tap a group tick to see who's read it */
|
||
.att-img{cursor:zoom-in;}
|
||
.fmt-bar{display:flex;align-items:center;gap:.05rem;padding:.3rem .4rem .1rem;flex-wrap:wrap;border-bottom:1px dashed var(--line);}
|
||
.fmt-bar button{border:none;background:transparent;color:var(--muted);cursor:pointer;width:30px;height:30px;border-radius:7px;display:grid;place-items:center;}
|
||
.fmt-bar button:hover{color:var(--blue);background:var(--blue-soft);}
|
||
.fmt-sep{width:1px;height:18px;background:var(--line);margin:0 .3rem;}
|
||
.bubble .msg-list{margin:.15rem 0;padding-left:1.25rem;}
|
||
.bubble .msg-list li{margin:.05rem 0;}
|
||
.bubble code{background:rgba(0,0,0,.08);padding:.05rem .3rem;border-radius:5px;font-size:.86em;font-family:ui-monospace,Consolas,monospace;}
|
||
.bubble.mine code{background:rgba(255,255,255,.2);}
|
||
.lightbox{position:fixed;inset:0;z-index:9900;background:rgba(8,12,22,.88);display:flex;align-items:center;justify-content:center;} /* above modals (9800) so the media→image preview opens on top */
|
||
.lightbox img{max-width:92vw;max-height:88vh;border-radius:10px;box-shadow:0 16px 50px rgba(0,0,0,.5);}
|
||
/* new #2: on iOS these sat UNDER the Dynamic Island / notch and couldn't be tapped. Push them below
|
||
the safe area (and clear of the right-side inset in landscape). */
|
||
.lightbox .lb-close,.lightbox .lb-dl{position:absolute;top:calc(18px + env(safe-area-inset-top,0px));border:1px solid rgba(255,255,255,.55);background:rgba(0,0,0,.58);color:#fff;width:46px;height:46px;border-radius:50%;display:grid;place-items:center;cursor:pointer;text-decoration:none;z-index:2;box-shadow:0 2px 12px rgba(0,0,0,.55);backdrop-filter:blur(4px);-webkit-backdrop-filter:blur(4px);} /* solid dark chip + border so the ✕ / download stay clearly visible over bright images */
|
||
.lightbox .lb-close{right:calc(18px + env(safe-area-inset-right,0px));}
|
||
.lightbox .lb-dl{right:calc(74px + env(safe-area-inset-right,0px));}
|
||
.lightbox .lb-close:hover,.lightbox .lb-dl:hover{background:rgba(0,0,0,.78);}
|
||
.lightbox img{max-width:82vw;}
|
||
/* #3 zoom: transform-based, anchored at the cursor; arrows hide while zoomed so panning isn't hijacked */
|
||
.lightbox img{transition:transform .08s linear;transform-origin:center center;will-change:transform;}
|
||
.lightbox img.zoomed{cursor:grab;} .lightbox img.grabbing{cursor:grabbing;transition:none;}
|
||
.lightbox.zooming .lb-nav{display:none;}
|
||
.lightbox .lb-zoom{position:absolute;left:50%;transform:translateX(-50%);bottom:calc(18px + env(safe-area-inset-bottom,0px));display:flex;gap:.4rem;z-index:2;}
|
||
.lightbox .lb-zoom button{position:relative;border:none;background:rgba(255,255,255,.14);color:#fff;width:44px;height:36px;border-radius:10px;display:grid;place-items:center;cursor:pointer;}
|
||
.lightbox .lb-zoom button:hover{background:rgba(255,255,255,.28);}
|
||
.lightbox .lb-zoom button b{position:absolute;right:7px;bottom:4px;font-size:.72rem;line-height:1;}
|
||
.lightbox .lb-nav{position:absolute;top:50%;transform:translateY(-50%);border:none;background:rgba(255,255,255,.16);color:#fff;width:32px;height:32px;border-radius:50%;display:grid;place-items:center;cursor:pointer;}
|
||
.lightbox .lb-nav:hover{background:rgba(255,255,255,.34);}
|
||
.lightbox .lb-prev{left:14px;} .lightbox .lb-next{right:14px;}
|
||
.composer{display:flex;align-items:flex-end;gap:.5rem;padding:.6rem .8rem;border-top:1px solid var(--line);background:var(--card);}
|
||
.composer-box{flex:1;min-width:0;border:1.5px solid var(--line);border-radius:16px;background:#fbfcfe;display:flex;flex-direction:column;overflow:hidden;}
|
||
.composer-box:focus-within{border-color:var(--blue);}
|
||
.composer-row{display:flex;align-items:flex-end;gap:.15rem;padding:.15rem .3rem;}
|
||
.composer-row input,.composer-row textarea{flex:1;min-width:0;box-sizing:border-box;border:none;background:transparent;padding:.5rem .4rem;margin:0;font-size:16px;color:var(--ink);font-family:inherit;resize:none;line-height:1.4;max-height:140px;overflow-y:hidden;display:block;scrollbar-width:thin;} /* 16px UNCONDITIONALLY (not just in the mobile @media) — anything under 16px makes iOS Safari/PWA zoom the page on focus, and the mobile media query doesn't match on iPad / landscape. */
|
||
.composer-row input:focus,.composer-row textarea:focus{outline:none;}
|
||
.ic-btn{border:none;background:transparent;color:var(--muted);cursor:pointer;width:36px;height:36px;border-radius:10px;display:grid;place-items:center;flex:0 0 auto;}
|
||
.ic-btn:hover{color:var(--blue);background:var(--blue-soft);}
|
||
.attach-preview{padding:.55rem .6rem .15rem;gap:.4rem;flex-wrap:wrap;max-height:118px;overflow-y:auto;}
|
||
.ap-item{display:inline-flex;align-items:center;gap:.55rem;background:#fff;border:1px solid var(--line);border-radius:11px;padding:.35rem .5rem;max-width:100%;}
|
||
.ap-thumb{width:42px;height:42px;border-radius:8px;object-fit:cover;flex:0 0 auto;}
|
||
.ap-ic{display:grid;place-items:center;color:var(--blue);flex:0 0 auto;}
|
||
.ap-name{font-size:.82rem;color:var(--ink);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:240px;}
|
||
/* #8: upload progress — a real bar + % so the user knows when Send is safe */
|
||
.ap-main{display:flex;flex-direction:column;gap:.2rem;min-width:0;}
|
||
.ap-prog{display:flex;align-items:center;gap:.4rem;}
|
||
.ap-track{flex:1;min-width:90px;height:4px;border-radius:99px;background:#e2e8f0;overflow:hidden;}
|
||
.ap-fill{display:block;height:100%;width:0;border-radius:99px;background:var(--blue);transition:width .15s linear;}
|
||
.ap-pct{font-size:.68rem;color:var(--muted);font-weight:600;min-width:30px;text-align:right;}
|
||
.ap-item.uploading{border-color:var(--blue);background:var(--blue-soft);}
|
||
.ap-x{border:none;background:transparent;color:var(--muted);cursor:pointer;display:grid;place-items:center;padding:.15rem;border-radius:6px;flex:0 0 auto;}
|
||
.ap-x:hover{color:var(--red);background:#fee2e2;}
|
||
/* meetings */
|
||
.meet{display:flex;flex-direction:column;width:100%;height:100%;}
|
||
.meet-grid{flex:1;min-height:0;display:grid;gap:.6rem;padding:.8rem;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));align-content:center;overflow:hidden;background:#0b1220;} /* #16: center tiles + no stray scrollbar on 1:1/small calls */
|
||
.meet-tile{position:relative;background:#0b1220;border-radius:12px;overflow:hidden;aspect-ratio:4/3;border:1px solid #1e293b;transition:box-shadow .12s,border-color .12s;}
|
||
.meet-tile .meet-sharing-self{position:absolute;left:0;right:0;bottom:0;display:flex;align-items:center;justify-content:center;gap:.3rem;font-size:.72rem;color:#fff;background:rgba(37,99,235,.9);padding:.25rem;}
|
||
/* #3: "Ringing…" sits in the MIDDLE of the tile over a dimmed avatar/name, so it reads as the tile's
|
||
state instead of a caption tucked under the name. */
|
||
.meet-tile.waiting{border-color:#334155;}
|
||
.meet-tile.waiting .meet-av,.meet-tile.waiting .nm{opacity:.32;filter:saturate(.6);}
|
||
.meet-tile.waiting .ringing{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:.4rem;
|
||
font-size:.95rem;font-weight:700;letter-spacing:.02em;color:#fff;background:rgba(8,12,22,.45);z-index:3;
|
||
animation:ringPulse 1.4s ease-in-out infinite;}
|
||
.meet-tile.waiting .ringing svg{width:22px;height:22px;color:var(--brand,#FFC708);}
|
||
@keyframes ringPulse{0%,100%{opacity:.6;}50%{opacity:1;}}
|
||
.meet-tile.speaking{border-color:#22c55e;box-shadow:0 0 0 2px #22c55e, 0 0 14px rgba(34,197,94,.5);}
|
||
.meet-tile .meet-screen{position:absolute;right:.5rem;top:.5rem;display:inline-flex;align-items:center;gap:.25rem;background:rgba(37,99,235,.92);color:#fff;font-size:.66rem;font-weight:700;padding:.14rem .42rem;border-radius:6px;}
|
||
.meet-tile.sharing{grid-column:span 2;border-color:#2563eb;}
|
||
/* Screen-share "stage": the chosen shared screen (.stage) fills a FIXED area on the left; every
|
||
other tile — participants AND any other shared screens — sits in a persistent small column on the
|
||
right. Click another shared screen there to switch which one is on the stage. Screen never shrinks. */
|
||
.meet-grid.sharing-mode{display:flex;flex-flow:column wrap;align-content:flex-start;height:100%;min-height:0;gap:.5rem;}
|
||
.meet-grid.sharing-mode .meet-tile{aspect-ratio:auto;}
|
||
.meet-grid.sharing-mode .meet-tile.stage{order:-1;height:100%;width:calc(100% - 176px);min-width:0;border-color:#2563eb;}
|
||
.meet-grid.sharing-mode .meet-tile.stage video{object-fit:contain;background:#000;}
|
||
.meet-grid.sharing-mode .meet-tile:not(.stage){width:160px;height:94px;flex:0 0 auto;}
|
||
.meet-grid.sharing-mode .meet-tile.sharing:not(.stage){cursor:pointer;}
|
||
.meet-grid.sharing-mode .meet-tile.sharing:not(.stage)::after{content:"Click to view";position:absolute;left:0;right:0;bottom:0;font-size:.58rem;text-align:center;background:rgba(37,99,235,.88);color:#fff;padding:1px 0;}
|
||
@media (max-width:760px){
|
||
.meet-grid.sharing-mode{flex-flow:row wrap;height:auto;}
|
||
.meet-grid.sharing-mode .meet-tile.stage{width:100%;height:auto;flex:1 1 100%;min-height:40vh;}
|
||
.meet-grid.sharing-mode .meet-tile:not(.stage){width:46%;height:84px;}
|
||
}
|
||
.meet-bar .meet-ic.on{background:var(--blue);color:#fff;}
|
||
.meet-bar #meetRecBtn.on{background:#dc2626;color:#fff;animation:livePulse 1.6s infinite;}
|
||
.rec-notice{position:fixed;top:14px;left:50%;transform:translateX(-50%);z-index:6500;display:inline-flex;align-items:center;gap:.4rem;background:rgba(220,38,38,.95);color:#fff;font-size:.82rem;font-weight:700;padding:.32rem .7rem;border-radius:99px;box-shadow:0 6px 18px rgba(220,38,38,.4);}
|
||
.rec-notice .rec-dot{width:9px;height:9px;border-radius:50%;background:#fff;animation:livePulse 1.4s infinite;}
|
||
.tx-notice{position:fixed;top:14px;left:14px;z-index:6500;display:inline-flex;align-items:center;gap:.35rem;background:rgba(37,99,235,.95);color:#fff;font-size:.78rem;font-weight:700;padding:.3rem .65rem;border-radius:99px;box-shadow:0 6px 18px rgba(37,99,235,.4);}
|
||
.si-recs{display:flex;flex-wrap:wrap;gap:.5rem;margin-top:.55rem;}
|
||
.rec-dl{display:inline-flex;align-items:center;gap:.35rem;font-size:.78rem;font-weight:700;text-decoration:none;border-radius:9px;padding:.36rem .7rem;border:1px solid transparent;transition:filter .12s;}
|
||
.rec-dl:hover{filter:brightness(.96);}
|
||
.rec-dl.vid{background:#eef2ff;color:#4338ca;border-color:#c7d2fe;}
|
||
.rec-dl.txt{background:#ecfdf5;color:#047857;border-color:#a7f3d0;}
|
||
.rec-dl .rd-dur{background:rgba(0,0,0,.09);border-radius:6px;padding:.04rem .32rem;font-size:.72rem;font-weight:700;}
|
||
.meet-panel .pp-screen{color:var(--blue);display:inline-flex;}
|
||
.meet-panel .mp-setting{margin:.3rem;font-size:.8rem;}
|
||
.meet-tile video{width:100%;height:100%;object-fit:cover;background:#0b1220;}
|
||
.meet-tile .nm{position:absolute;left:.5rem;bottom:.5rem;background:rgba(0,0,0,.55);color:#fff;font-size:.75rem;padding:.15rem .5rem;border-radius:6px;}
|
||
.meet-tile .meet-av{position:absolute;inset:0;margin:auto;width:84px;height:84px;border-radius:50%;display:none;align-items:center;justify-content:center;color:#334155;font-weight:700;font-size:1.9rem;overflow:hidden;}
|
||
.meet-tile .meet-av img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;}
|
||
.meet-tile .meet-mute{position:absolute;left:.5rem;top:.5rem;width:26px;height:26px;border-radius:50%;background:#dc2626;color:#fff;place-items:center;box-shadow:0 1px 3px rgba(0,0,0,.4);}
|
||
.meet-panel{position:absolute;right:12px;top:12px;bottom:78px;width:280px;max-width:80vw;background:var(--card);border:1px solid var(--line);border-radius:14px;box-shadow:0 14px 40px rgba(0,0,0,.3);z-index:30;display:flex;flex-direction:column;overflow:hidden;}
|
||
.meet-panel .mp-head{display:flex;align-items:center;gap:.5rem;padding:.7rem .8rem;border-bottom:1px solid var(--line);font-size:.9rem;}
|
||
.meet-panel .mp-muteall{border:1px solid var(--line);background:#fee2e2;color:var(--red);border-radius:8px;padding:.3rem .55rem;font-size:.78rem;font-weight:600;cursor:pointer;display:inline-flex;align-items:center;gap:.25rem;}
|
||
.meet-panel .mp-x{border:none;background:transparent;color:var(--muted);cursor:pointer;display:grid;place-items:center;}
|
||
.meet-panel .mp-tabs{display:flex;gap:.25rem;padding:.4rem .5rem 0;border-bottom:1px solid var(--line);}
|
||
.meet-panel .mp-tab{flex:1;border:none;background:transparent;color:var(--muted);font-size:.8rem;font-weight:600;padding:.45rem .3rem;cursor:pointer;border-bottom:2px solid transparent;display:inline-flex;align-items:center;justify-content:center;gap:.25rem;}
|
||
.meet-panel .mp-tab.on{color:var(--blue);border-bottom-color:var(--blue);}
|
||
.meet-panel .mp-scroll{flex:1;overflow:auto;}
|
||
.meet-panel .mp-invite-sticky{position:sticky;bottom:.4rem;width:calc(100% - 1rem);margin:.5rem;background:var(--blue);color:#fff;box-shadow:0 -6px 14px rgba(255,255,255,.06);} /* #4: keep Invite reachable while the list scrolls */
|
||
.meet-panel .mp-list{padding:.4rem;}
|
||
.meet-panel .mp-invite-link{display:flex;align-items:center;gap:.55rem;margin:.5rem;padding:.6rem .7rem;background:var(--blue-soft);border:1px solid var(--line);border-radius:10px;}
|
||
.meet-panel .mp-invite-link svg{flex:0 0 auto;color:var(--blue);}
|
||
.meet-panel .mp-invite-link .mil-txt{flex:1;min-width:0;}
|
||
.meet-panel .mp-invite-link .mil-txt b{display:block;font-size:.82rem;color:var(--ink);}
|
||
.meet-panel .mp-invite-link .mil-txt span{font-size:.7rem;color:var(--muted);line-height:1.3;display:block;}
|
||
.meet-panel .mp-copylink{flex:0 0 auto;display:inline-flex;align-items:center;gap:.3rem;background:var(--blue);color:#fff;border:none;border-radius:8px;padding:.4rem .7rem;font-size:.78rem;font-weight:600;cursor:pointer;}
|
||
.meet-panel .mp-copylink:hover{filter:brightness(1.05);}
|
||
/* In-meeting chat panel (brand-styled) */
|
||
.meet-chat{width:320px;}
|
||
/* Compose block at the BOTTOM: recipient picker sits right above the input (new #3). */
|
||
.meet-chat .mc-compose{border-top:1px solid var(--line);background:var(--card);}
|
||
.meet-chat .mc-compose.private{background:#fffbeb;} /* private mode is visibly different */
|
||
.meet-chat .mc-to{display:flex;align-items:center;gap:.5rem;padding:.45rem .7rem .1rem;font-size:.75rem;color:var(--muted);}
|
||
.meet-chat .mc-to select{flex:1;border:1px solid var(--line);border-radius:8px;padding:.3rem .45rem;font:inherit;font-size:.8rem;background:#fbfcfe;color:var(--ink);}
|
||
.meet-chat .mc-compose.private .mc-to select{border-color:#f5c518;background:#fffdf5;color:#7a5c00;font-weight:600;}
|
||
.meet-chat .mc-log{flex:1;overflow-y:auto;padding:.6rem .7rem;display:flex;flex-direction:column;gap:.4rem;}
|
||
.meet-chat .mc-empty{color:var(--muted);font-size:.82rem;text-align:center;margin:auto;padding:1rem;}
|
||
.meet-chat .mc-row{display:flex;}
|
||
.meet-chat .mc-row.mine{justify-content:flex-end;}
|
||
/* Everyone = neutral/blue. Private = brand amber, clearly a different conversation (new #3). */
|
||
.meet-chat .mc-bub{max-width:82%;background:#f1f5f9;border:1px solid transparent;border-radius:12px;padding:.4rem .6rem;}
|
||
.meet-chat .mc-row.mine .mc-bub{background:var(--blue);color:#fff;}
|
||
.meet-chat .mc-row.priv .mc-bub{background:#fef3c7;border-color:#fcd34d;color:#1f2430;}
|
||
.meet-chat .mc-row.mine.priv .mc-bub{background:#7a5c00;border-color:var(--brand,#FFC708);color:#fff;}
|
||
.meet-chat .mc-nm{font-size:.7rem;font-weight:700;color:var(--blue);margin-bottom:.1rem;display:flex;gap:.35rem;align-items:center;}
|
||
.meet-chat .mc-row.mine .mc-nm{color:rgba(255,255,255,.9);}
|
||
.meet-chat .mc-priv{font-weight:600;font-size:.62rem;text-transform:uppercase;letter-spacing:.03em;background:rgba(31,59,115,.1);color:var(--blue);border-radius:99px;padding:.02rem .35rem;}
|
||
.meet-chat .mc-row.mine .mc-priv{background:rgba(255,255,255,.2);color:#fff;}
|
||
.meet-chat .mc-tx{font-size:.88rem;line-height:1.35;word-wrap:break-word;overflow-wrap:anywhere;}
|
||
.meet-chat .mc-at{font-size:.62rem;opacity:.6;margin-top:.15rem;text-align:right;}
|
||
.meet-chat .mc-foot{display:flex;gap:.4rem;padding:.4rem .6rem .55rem;}
|
||
.meet-chat .mc-foot input{flex:1;border:1px solid var(--line);border-radius:9px;padding:.5rem .6rem;font:inherit;font-size:.86rem;background:#fbfcfe;color:var(--ink);outline:none;}
|
||
.meet-chat .mc-foot input:focus{border-color:var(--blue);}
|
||
.meet-chat .mc-send{flex:0 0 auto;width:38px;border:none;border-radius:9px;background:var(--brand,#FFC708);color:#1f2430;display:grid;place-items:center;cursor:pointer;}
|
||
.meet-ic .mc-badge{position:absolute;top:-3px;right:-3px;min-width:16px;height:16px;padding:0 3px;border-radius:99px;background:#e6394f;color:#fff;font-size:.62rem;font-weight:700;display:grid;place-items:center;box-shadow:0 0 0 2px var(--card,#fff);}
|
||
.meet-panel .chk{display:flex;align-items:center;gap:.5rem;padding:.4rem .5rem;border-radius:8px;cursor:pointer;font-size:.88rem;}
|
||
.meet-panel .chk:hover{background:#f6f8fb;}
|
||
.meet-panel .chk input{width:16px;height:16px;flex:0 0 16px;accent-color:var(--blue);margin:0;}
|
||
.meet-panel .chk .mn{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
|
||
.meet-panel .mp-row{display:flex;align-items:center;gap:.5rem;padding:.4rem .5rem;border-radius:8px;}
|
||
.meet-panel .mp-row .mn{flex:1;min-width:0;font-size:.88rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
|
||
.meet-panel .mp-row .pp-mute{color:var(--red);display:inline-flex;}
|
||
.meet-panel .mp-sec{font-size:.68rem;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);font-weight:700;margin:.7rem .5rem .15rem;}
|
||
.meet-panel .mp-row.waiting{opacity:.7;}
|
||
.meet-panel .mp-row .pp-wait{margin-left:auto;font-size:.72rem;color:var(--muted);display:inline-flex;align-items:center;gap:.25rem;white-space:nowrap;}
|
||
.meet-panel .mp-makehost{border:none;background:var(--blue-soft);color:var(--blue);border-radius:7px;padding:.2rem .4rem;cursor:pointer;display:grid;place-items:center;}
|
||
.host-tag{display:inline-flex;align-items:center;gap:.2rem;font-size:.62rem;font-weight:700;background:#fff3cd;color:#7a5b05;padding:.05rem .35rem;border-radius:99px;}
|
||
.admin-tag{display:inline-flex;align-items:center;gap:.2rem;font-size:.6rem;font-weight:700;background:#fef3c7;color:#92600b;padding:.05rem .4rem;border-radius:99px;vertical-align:middle;}
|
||
.iconbtn.role{color:var(--muted);} .iconbtn.role.is-admin{color:#d4a106;} .iconbtn.role:hover{color:#d4a106;}
|
||
.si-invited{display:inline-flex;align-items:center;gap:.3rem;font-size:.76rem;color:var(--muted);margin-top:.25rem;}
|
||
.meet-tile.novid .meet-av{display:flex;}
|
||
.meet-tile.novid video{visibility:hidden;}
|
||
.meet-bar{display:flex;align-items:center;gap:.6rem;padding:.7rem 1rem;background:var(--card);border-top:1px solid var(--line);}
|
||
.meet-bar .code{margin-right:auto;color:var(--muted);font-size:.85rem;}
|
||
.meet-bar .code b{color:var(--blue);font-size:1rem;letter-spacing:.06em;}
|
||
.meet-bar button{border:none;border-radius:10px;padding:.6rem 1rem;font-weight:600;cursor:pointer;font-size:.9rem;display:inline-flex;align-items:center;gap:.35rem;}
|
||
.meet-bar .tgl{background:#eef1f6;color:var(--blue);}
|
||
.meet-bar .tgl.off{background:#fee2e2;color:var(--red);}
|
||
.meet-bar .leave{background:#dc2626;color:#fff;}
|
||
.meet-bar .meet-ic{position:relative;width:46px;height:46px;padding:0;border-radius:50%;background:#e8edf5;color:var(--blue);display:inline-flex;align-items:center;justify-content:center;}
|
||
.meet-bar .meet-ic:hover{background:#dbe4f0;}
|
||
.meet-bar .meet-ic.off{background:#fee2e2;color:var(--red);}
|
||
.meet-bar .meet-ic.on{background:var(--blue);color:#fff;} /* #9: screen-share highlighted while active (record keeps its red pulse via #meetRecBtn.on) */
|
||
.meet-bar .meet-ic.leave,.meet-bar .meet-ic.leave.off{background:#dc2626;color:#fff;}
|
||
.meet-bar .meet-ic.leave:hover{background:#b91c1c;}
|
||
/* Meetings dashboard (full-width, scrollable) */
|
||
.meet-dash{width:100%;height:100%;overflow-y:auto;padding:1.4rem clamp(1rem,4vw,2.4rem) 2rem;background:var(--bg);}
|
||
.md-top{display:flex;align-items:flex-start;justify-content:space-between;gap:1rem;flex-wrap:wrap;margin-bottom:.5rem;}
|
||
.md-title h1{margin:0;font-size:1.5rem;color:var(--ink);}
|
||
.md-title p{margin:.3rem 0 0;color:var(--muted);font-size:.9rem;max-width:520px;}
|
||
.md-actions{display:flex;align-items:stretch;gap:.5rem;flex-wrap:wrap;}
|
||
.md-join{display:flex;gap:.4rem;flex:1 1 240px;min-width:0;}
|
||
.md-join input{flex:1 1 auto;min-width:0;text-align:center;letter-spacing:.18rem;font-size:1.05rem;font-weight:600;border:1px solid var(--line);border-radius:11px;padding:.7rem .6rem;background:var(--card);color:var(--ink);}
|
||
.md-join .btn{flex:0 0 auto;padding:.55rem 1.1rem;font-size:.9rem;}
|
||
.md-actions > .btn{white-space:nowrap;padding:.7rem 1.05rem;}
|
||
.btn.primary{background:var(--blue);color:#fff;}
|
||
.btn.primary:hover{filter:brightness(1.06);}
|
||
.meet-dash .hint{min-height:0;color:var(--red);font-size:.82rem;margin:.2rem 0;}
|
||
.sched-empty{color:var(--muted);font-size:.92rem;background:var(--card);border:1px dashed var(--line);border-radius:12px;padding:1.6rem;text-align:center;margin-top:1rem;}
|
||
/* chat: reply + emoji */
|
||
.convo{position:relative;}
|
||
.bubble{position:relative;}
|
||
/* Clamp the reply preview to 2 lines — a long quoted message must not stretch the bubble to 1000s of px
|
||
(device log: DIV.quote rendered 1571px). white-space:normal collapses the quoted text's newlines. */
|
||
.bubble .quote{border-left:3px solid var(--line);padding:.22rem .5rem;margin-bottom:.3rem;font-size:.78rem;border-radius:6px;color:#33384a;cursor:pointer;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;max-height:3.2em;white-space:normal;word-break:normal;overflow-wrap:break-word;}
|
||
.upd-banner{position:fixed;left:50%;bottom:16px;transform:translateX(-50%) translateY(70px);z-index:9500;display:none;align-items:center;gap:.5rem;background:var(--blue);color:#fff;border-radius:999px;padding:.5rem .5rem .5rem .9rem;box-shadow:0 10px 30px rgba(20,30,60,.32);font-size:.85rem;font-weight:600;transition:transform .25s ease;}
|
||
.upd-banner.show{display:flex;transform:translateX(-50%) translateY(0);}
|
||
.upd-banner button{border:none;background:var(--brand);color:var(--blue-d);border-radius:999px;padding:.35rem .8rem;font-weight:700;cursor:pointer;}
|
||
.upd-dot{position:absolute;top:-3px;right:-3px;width:15px;height:15px;border-radius:50%;background:var(--brand);color:var(--blue-d);font-size:.66rem;font-weight:800;font-style:italic;display:grid;place-items:center;border:2px solid var(--blue);pointer-events:none;}
|
||
#setUpd.has-update{background:var(--brand);color:var(--blue-d);}
|
||
.set-ver-i{display:inline-grid;place-items:center;width:15px;height:15px;border-radius:50%;background:var(--brand);color:var(--blue-d);font-size:.66rem;font-weight:800;font-style:italic;margin-left:.35rem;vertical-align:middle;}
|
||
.bubble .msg-link{color:inherit;text-decoration:underline;text-underline-offset:2px;overflow-wrap:break-word;}
|
||
.bubble .msg-tel{color:inherit;text-decoration:underline;text-underline-offset:2px;white-space:nowrap;cursor:pointer;} /* #9 */
|
||
.bubble.them .msg-link{color:var(--blue);} .bubble.mine .msg-link{color:#dbe9ff;}
|
||
.bubble .fwd-label{display:flex;align-items:center;gap:.2rem;font-size:.72rem;font-style:italic;opacity:.7;margin-bottom:.2rem;}
|
||
.bubble .fwd-label svg{transform:scaleX(-1) rotate(0deg);}
|
||
.bubble.mine .fwd-label{color:#e6edfb;} .bubble.them .fwd-label{color:var(--muted);}
|
||
/* All message actions live in ONE hover pill anchored to the bubble's top-right corner. It
|
||
overlaps the bubble slightly so there's no dead gap — on a short message the icons can't float
|
||
off into empty space where the hover (and the buttons) would vanish before you can click. */
|
||
/* #4: sit FULLY above the bubble (was top:-14px, which overlapped the first line of text). */
|
||
.msg-actions{position:absolute;bottom:calc(100% - 2px);right:6px;z-index:6;display:flex;align-items:center;gap:1px;white-space:nowrap;background:var(--card);border:1px solid var(--line);border-radius:999px;padding:2px 3px;box-shadow:0 4px 12px rgba(20,30,60,.2);opacity:0;pointer-events:none;transition:opacity .12s;}
|
||
/* The row is wider than a SHORT bubble. Anchored right, a received ("them") bubble pushed it off the
|
||
left edge of the panel. Anchor received messages from the LEFT so it grows into the empty space. */
|
||
.bubble.them .msg-actions{left:0;right:auto;}
|
||
.bubble.mine .msg-actions{right:0;left:auto;}
|
||
.bubble:hover .msg-actions,.bubble.show-actions .msg-actions{opacity:1;pointer-events:auto;}
|
||
.msg-actions button{position:static;width:26px;height:26px;border:none;background:none;border-radius:50%;display:grid;place-items:center;cursor:pointer;color:var(--blue);opacity:1;pointer-events:auto;box-shadow:none;padding:0;transition:background .12s;}
|
||
.msg-actions button:hover{background:var(--blue-soft);}
|
||
/* one-tap reactions render as the emoji glyph itself */
|
||
.msg-actions .qr-btn{font-size:15px;line-height:1;}
|
||
.msg-actions .qr-btn:hover{background:var(--blue-soft);transform:scale(1.15);}
|
||
.msg-more-menu .spk-opt.danger{color:var(--red);} .msg-more-menu .spk-opt.danger svg{color:var(--red);}
|
||
.msg-more-menu .spk-opt.danger:hover{background:#fee2e2;}
|
||
.msg-actions .del-btn{color:var(--red);} .msg-actions .del-btn:hover{background:#fee2e2;}
|
||
/* #1 Forward: message selection mode + target picker */
|
||
/* Selection mode: a checkbox appears on every message; selected ones highlight in green (distinct
|
||
from the blue message bubbles) with a filled tick. */
|
||
body.sel-mode .bubble{cursor:pointer;} body.sel-mode .msg-actions{display:none!important;}
|
||
body.sel-mode .bubble::after{content:"";position:absolute;top:-7px;width:20px;height:20px;border-radius:50%;border:2px solid var(--muted);background:var(--card);z-index:7;}
|
||
body.sel-mode .bubble.them::after{left:-8px;} body.sel-mode .bubble.mine::after{right:-8px;}
|
||
body.sel-mode .bubble.selected::after{border-color:#16a34a;background:#16a34a url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='3.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'/%3E%3C/svg%3E") center/12px no-repeat;}
|
||
.bubble.selected{outline:2px solid #16a34a;outline-offset:2px;}
|
||
body.sel-mode .bubble.selected{background:#eafaf0;}
|
||
body.sel-mode .bubble.mine.selected{background:#16a34a;}
|
||
.sel-bar{position:fixed;left:50%;bottom:18px;transform:translateX(-50%);z-index:9000;display:flex;align-items:center;gap:.7rem;background:var(--card);border:1px solid var(--line);border-radius:999px;padding:.4rem .5rem .4rem .4rem;box-shadow:0 10px 30px rgba(20,30,60,.25);}
|
||
.sel-bar .sb-x{border:none;background:var(--blue-soft);color:var(--blue);width:34px;height:34px;border-radius:50%;display:grid;place-items:center;cursor:pointer;}
|
||
.sel-bar .sb-n{font-size:.9rem;font-weight:600;color:var(--ink);}
|
||
.sel-bar .sb-fwd{border:none;background:var(--blue);color:#fff;border-radius:999px;padding:.5rem .9rem;font-weight:700;cursor:pointer;display:inline-flex;align-items:center;gap:.3rem;}
|
||
.sel-bar .sb-fwd:hover{background:var(--blue-d);}
|
||
.fwd-list{max-height:44vh;overflow-y:auto;display:flex;flex-direction:column;gap:2px;}
|
||
.fwd-item{display:flex;align-items:center;gap:.6rem;padding:.45rem .5rem;border-radius:10px;cursor:pointer;border:1px solid transparent;}
|
||
.fwd-item:hover{background:#f2f6fc;}
|
||
.fwd-item.sel{background:var(--blue-soft);border-color:var(--blue);}
|
||
.fwd-item .fi-name{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-size:.92rem;}
|
||
.fwd-item .fi-check{width:20px;height:20px;flex:0 0 auto;border:2px solid var(--line);border-radius:6px;display:grid;place-items:center;color:transparent;}
|
||
.fwd-item.sel .fi-check{background:var(--blue);border-color:var(--blue);color:#fff;}
|
||
.fwd-item .mini-av{width:34px;height:34px;flex:0 0 auto;border-radius:50%;display:grid;place-items:center;color:#fff;font-weight:700;font-size:.8rem;overflow:hidden;}
|
||
.fwd-item .mini-av img{width:100%;height:100%;object-fit:cover;}
|
||
.reply-bar{display:flex;align-items:center;gap:.5rem;padding:.45rem .8rem;border-top:1px solid var(--line);background:#eef3fb;font-size:.82rem;color:var(--muted);}
|
||
.reply-bar b{color:var(--ink);}
|
||
.reply-bar .rx{margin-left:auto;cursor:pointer;font-size:1rem;}
|
||
.reply-bar .rx:hover{color:var(--red);}
|
||
/* (message action button styling is under .msg-actions) */
|
||
.emoji-pop{position:absolute;bottom:64px;left:12px;width:330px;height:300px;background:#fff;border:1px solid var(--line);border-radius:12px;box-shadow:0 10px 28px rgba(0,0,0,.18);z-index:50;display:flex;flex-direction:column;overflow:hidden;}
|
||
.mention-pop{position:absolute;left:12px;right:12px;bottom:64px;max-height:240px;overflow:auto;background:var(--card);border:1px solid var(--line);border-radius:12px;box-shadow:0 10px 28px rgba(0,0,0,.18);z-index:60;padding:.3rem;}
|
||
/* Attach menu: anchored to the composer (like the emoji/mention popups) so options appear right above the
|
||
paperclip instead of the OS chooser jumping to mid-screen. */
|
||
.attach-pop{position:absolute;left:12px;bottom:64px;z-index:60;background:var(--card);border:1px solid var(--line);border-radius:12px;box-shadow:0 10px 28px rgba(0,0,0,.18);padding:.3rem;display:flex;flex-direction:column;min-width:190px;}
|
||
.attach-pop .attach-opt{display:flex;align-items:center;gap:.6rem;padding:.65rem .7rem;border:none;background:transparent;color:var(--ink);cursor:pointer;border-radius:8px;font-size:.95rem;text-align:left;width:100%;}
|
||
.attach-pop .attach-opt:hover{background:var(--blue-soft);color:var(--blue);}
|
||
.attach-pop .attach-opt svg{flex:0 0 auto;}
|
||
.mention-pop .mrow{display:flex;align-items:center;gap:.55rem;padding:.4rem .55rem;border-radius:8px;cursor:pointer;}
|
||
.mention-pop .mrow.sel{background:var(--blue-soft);}
|
||
.mention-pop .mn{font-weight:600;color:var(--ink);}
|
||
.mention-pop .sub{color:var(--muted);font-size:.78rem;margin-left:auto;}
|
||
.mention{color:var(--blue);background:var(--blue-soft);border-radius:5px;padding:0 .18rem;font-weight:600;}
|
||
.bubble.mine .mention{color:#fff;background:rgba(255,255,255,.22);}
|
||
.bubble.mention-me{box-shadow:0 1px 2px rgba(20,30,60,.06),inset 3px 0 0 var(--brand);}
|
||
.bubble.has-poll{max-width:88%;}
|
||
.poll{margin-top:.5rem;display:flex;flex-direction:column;gap:.35rem;min-width:240px;}
|
||
.poll-q{font-size:.72rem;font-weight:700;letter-spacing:.03em;text-transform:uppercase;opacity:.7;display:flex;align-items:center;gap:.3rem;}
|
||
.poll-opt{position:relative;overflow:hidden;display:flex;align-items:center;justify-content:space-between;gap:.5rem;border:1px solid var(--line);background:var(--card);color:var(--ink);border-radius:9px;padding:.45rem .6rem;font-size:.86rem;cursor:pointer;text-align:left;}
|
||
.poll-opt:hover:not([disabled]){border-color:var(--blue);}
|
||
.poll-opt[disabled]{cursor:default;}
|
||
.poll-opt.mine{border-color:var(--blue);background:var(--blue-soft);}
|
||
.poll-opt .po-bar{position:absolute;left:0;top:0;bottom:0;background:var(--blue-soft);z-index:0;transition:width .25s;}
|
||
.poll-opt.mine .po-bar{background:#d8e4f8;}
|
||
.poll-opt .po-txt{position:relative;z-index:1;font-weight:600;display:flex;align-items:center;gap:.3rem;}
|
||
.poll-opt .po-pct{position:relative;z-index:1;color:var(--muted);font-size:.78rem;flex:0 0 auto;}
|
||
.poll-foot{font-size:.76rem;color:var(--muted);margin-top:.1rem;}
|
||
.poll-foot .poll-close{color:var(--red);cursor:pointer;font-weight:600;}
|
||
.bubble.mine .poll-opt{color:var(--ink);}
|
||
.poll-opt-row{display:flex;align-items:center;gap:.4rem;margin-bottom:.4rem;}
|
||
.emoji-tabs{display:flex;border-bottom:1px solid var(--line);flex:0 0 auto;}
|
||
.emoji-tabs button{flex:1;border:none;background:transparent;font-size:1.05rem;padding:.35rem 0;cursor:pointer;opacity:.55;}
|
||
.emoji-tabs button.active{opacity:1;background:var(--blue-soft);}
|
||
/* #5 GIF tab: separated from the emoji category tabs */
|
||
.emoji-tabs .et-sep{width:1px;align-self:stretch;margin:.3rem .15rem;background:var(--line);flex:0 0 auto;}
|
||
.emoji-tabs button[data-gif]{flex:0 0 auto;font-size:.7rem;font-weight:800;letter-spacing:.02em;padding:.35rem .5rem;color:var(--blue);opacity:.7;}
|
||
.emoji-tabs button[data-gif].active{opacity:1;}
|
||
.gif-wrap{display:flex;flex-direction:column;min-height:0;flex:1 1 auto;}
|
||
.gif-search{position:relative;padding:.5rem;flex:0 0 auto;}
|
||
.gif-search input{width:100%;box-sizing:border-box;border:1px solid var(--line);border-radius:9px;padding:.5rem 2rem .5rem .6rem;font:inherit;font-size:.85rem;background:#fbfcfe;color:var(--ink);}
|
||
.gif-search input:focus{outline:none;border-color:var(--blue);}
|
||
.gif-search svg{position:absolute;right:1.1rem;top:50%;transform:translateY(-50%);color:var(--muted);pointer-events:none;}
|
||
/* fixed row height (not aspect-ratio, which collapsed to ~0 in the grid → images stacked over each
|
||
other). Uniform 92px rows keep the grid tidy. */
|
||
.gif-grid{flex:1 1 auto;overflow-y:auto;display:grid;grid-template-columns:1fr 1fr;grid-auto-rows:92px;gap:.35rem;padding:0 .5rem .3rem;align-content:start;}
|
||
.gif-cell{border:none;background:#f1f5f9;border-radius:9px;overflow:hidden;cursor:pointer;padding:0;height:92px;display:block;}
|
||
.gif-cell img{width:100%;height:100%;object-fit:cover;display:block;}
|
||
.gif-cell:hover{outline:2px solid var(--blue);}
|
||
.gif-load{grid-column:1/-1;display:grid;place-items:center;padding:1.4rem;}
|
||
.gif-load img{animation:ptrspin .8s linear infinite;}
|
||
.gif-empty{grid-column:1/-1;text-align:center;color:var(--muted);font-size:.82rem;padding:1.4rem .5rem;}
|
||
.gif-attr{flex:0 0 auto;text-align:center;font-size:.6rem;color:var(--muted);letter-spacing:.03em;padding:.15rem 0 .35rem;}
|
||
.bubble .msg-gif{max-width:230px;max-height:230px;border-radius:12px;cursor:pointer;}
|
||
.emoji-grid{flex:1;overflow-y:auto;display:grid;grid-template-columns:repeat(8,1fr);gap:.1rem;padding:.4rem;align-content:start;}
|
||
.emoji-grid button{border:none;background:transparent;font-size:1.25rem;cursor:pointer;padding:.2rem;border-radius:6px;line-height:1.15;}
|
||
.emoji-grid button:hover{background:var(--blue-soft);}
|
||
.bubble .t .edited{font-style:italic;opacity:.8;}
|
||
.bubble.deleted{opacity:.85;}
|
||
.bubble.deleted .del-msg{font-style:italic;color:var(--muted);font-size:.9rem;display:inline-flex;align-items:center;gap:.3rem;}
|
||
.bubble.mine.deleted .del-msg{color:rgba(255,255,255,.85);}
|
||
.reacts{display:flex;flex-wrap:wrap;gap:.2rem;margin-top:.3rem;}
|
||
.react-chip{border:1px solid var(--line);background:var(--card);color:var(--ink);border-radius:999px;font-size:.74rem;padding:.05rem .4rem;cursor:pointer;line-height:1.5;}
|
||
.react-chip.mine{background:var(--blue-soft);border-color:#c7d6f0;color:var(--blue);}
|
||
/* attachments */
|
||
.attach-btn,.emoji-btn{border:none;background:transparent;cursor:pointer;padding:.35rem;color:var(--muted);display:grid;place-items:center;border-radius:8px;}
|
||
.attach-btn:hover,.emoji-btn:hover{color:var(--blue);background:var(--blue-soft);}
|
||
.composer-row .sendbtn{flex:0 0 auto;align-self:flex-end;width:34px;height:34px;margin:1px;border:none;background:var(--brand);color:var(--ink);cursor:pointer;border-radius:50%;display:grid;place-items:center;}
|
||
.composer-row .sendbtn:hover{background:var(--brand-d);}
|
||
.att-img{max-width:240px;max-height:240px;border-radius:8px;display:block;margin:.15rem 0;}
|
||
/* Belt-and-suspenders: cap ANY image inside a message bubble so a message can never momentarily balloon
|
||
the thread to thousands of px while a large picture loads at its natural size (the "stretch" glitch). */
|
||
.convo-msgs img,.bubble img{max-height:340px;max-width:100%;height:auto;}
|
||
.att-file{display:inline-flex;align-items:center;gap:.4rem;background:rgba(0,0,0,.06);border:1px solid var(--line);border-radius:8px;padding:.4rem .6rem;color:inherit;text-decoration:none;font-size:.85rem;margin:.15rem 0;max-width:240px;}
|
||
.att-file span{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
|
||
.att-file-native{cursor:pointer;-webkit-user-select:none;user-select:none;}
|
||
.att-file-name{flex:1;min-width:0;}
|
||
.att-file-act{margin-left:.15rem;flex:0 0 auto;display:inline-flex;align-items:center;opacity:.75;font-variant-numeric:tabular-nums;}
|
||
.att-file.saved .att-file-act{opacity:.95;}
|
||
/* In-chat video: native <video controls> (poster from /thumbs, plays inline on one tap, HTTP-Range streamed).
|
||
Wrapped so we can overlay a clear CENTER spinner while it buffers — the OS controls' own buffering hint is
|
||
small/unclear. The spinner is display-only (pointer-events:none) so it never blocks the controls. */
|
||
.att-vid{position:relative;width:min(260px,74vw);min-height:146px;max-height:340px;border-radius:10px;overflow:hidden;display:block;margin:.15rem 0;background:#000;line-height:0;} /* min-height: the tile keeps its size before the poster lands, so the thread never jumps */
|
||
.att-vid-v{display:block;width:100%;max-height:340px;background:#000;object-fit:contain;}
|
||
.att-vid-spin{position:absolute;top:50%;left:50%;width:46px;height:46px;margin:-23px 0 0 -23px;border-radius:50%;border:3px solid rgba(255,255,255,.35);border-top-color:#fff;background:rgba(0,0,0,.45);opacity:0;pointer-events:none;z-index:3;}
|
||
.att-vid.buffering .att-vid-spin{opacity:1;animation:ptrspin .8s linear infinite;}
|
||
/* Dim the frame while buffering so the centre spinner reads instantly against a bright poster. */
|
||
.att-vid.buffering .att-vid-v{filter:brightness(.72);}
|
||
/* Download progress chip. The native save path fetches the bytes itself, so there is no browser download
|
||
UI to lean on — this mirrors the upload bar's bar + %, so a download reads the same as an upload. */
|
||
.dl-prog{position:fixed;left:50%;transform:translateX(-50%);bottom:calc(env(safe-area-inset-bottom,0px) + 84px);z-index:120;background:rgba(17,24,39,.94);color:#fff;border-radius:12px;padding:.55rem .75rem;width:min(320px,84vw);box-shadow:0 6px 24px rgba(0,0,0,.38);font-size:.82rem;display:grid;gap:.4rem;}
|
||
.dl-prog .dp-top{display:flex;align-items:center;gap:.45rem;}
|
||
.dl-prog .dp-name{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
|
||
.dl-prog .dp-pct{font-weight:700;flex:0 0 auto;font-variant-numeric:tabular-nums;}
|
||
.dl-prog .dp-bar{height:4px;border-radius:3px;background:rgba(255,255,255,.22);overflow:hidden;}
|
||
.dl-prog .dp-fill{display:block;height:100%;width:0;background:#fff;transition:width .15s linear;}
|
||
/* One CENTRED control over the masked poster: download → % → play. A corner button collided with the
|
||
native control bar, and the bar itself is only turned on once playback starts, so nothing overlaps. */
|
||
.att-vid-ov{position:absolute;inset:0;z-index:4;width:100%;height:100%;border:0;padding:0;margin:0;background:rgba(0,0,0,.38);display:grid;place-items:center;cursor:pointer;}
|
||
.att-vid.playing .att-vid-ov{display:none;}
|
||
.att-vid-btn{width:62px;height:62px;border-radius:50%;background:rgba(0,0,0,.62);border:2px solid rgba(255,255,255,.85);color:#fff;display:grid;place-items:center;font-weight:700;font-size:.92rem;font-variant-numeric:tabular-nums;-webkit-backdrop-filter:blur(3px);backdrop-filter:blur(3px);}
|
||
.att-vid-ov:active .att-vid-btn{transform:scale(.94);}
|
||
.att-vid.saved .att-vid-btn svg{margin-left:3px;} /* optically centre the play triangle */
|
||
/* Manage storage */
|
||
.stor-tot{display:flex;align-items:center;gap:.45rem;margin:.1rem 0 .5rem;}
|
||
.stor-tot b{font-size:1.3rem;}
|
||
.stor-grp{font-size:.7rem;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;margin:.65rem 0 .25rem;}
|
||
.stor-list{max-height:52vh;overflow:auto;}
|
||
.stor-row{display:flex;align-items:center;gap:.55rem;padding:.4rem .1rem;border-bottom:1px solid var(--line);}
|
||
.stor-row .sr-ic{width:34px;height:34px;flex:0 0 34px;border-radius:8px;background:rgba(0,0,0,.06);display:grid;place-items:center;overflow:hidden;color:var(--muted);}
|
||
.stor-row .sr-ic img{width:100%;height:100%;object-fit:cover;}
|
||
.stor-row .sr-m{flex:1;min-width:0;}
|
||
.stor-row .sr-n{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-size:.85rem;}
|
||
.stor-row .sr-s{display:block;font-size:.72rem;color:var(--muted);}
|
||
.stor-row .sr-b{border:0;background:transparent;color:var(--muted);cursor:pointer;padding:.35rem;border-radius:6px;display:grid;place-items:center;}
|
||
.stor-row .sr-b:hover{background:rgba(0,0,0,.06);color:var(--blue);}
|
||
.stor-row .sr-b.del:hover{color:#dc2626;}
|
||
.stor-empty{color:var(--muted);font-size:.85rem;padding:1.1rem .2rem;text-align:center;line-height:1.5;}
|
||
.bubble.mine .att-file{background:rgba(255,255,255,.18);border-color:rgba(255,255,255,.3);}
|
||
.att-file .att-sz{opacity:.65;font-size:.75rem;flex:0 0 auto;}
|
||
/* groups */
|
||
.bubble .sender{font-size:.7rem;color:var(--blue);font-weight:700;margin-bottom:.12rem;display:flex;align-items:center;gap:.35rem;}
|
||
.bubble .sender .snd-av{position:relative;width:20px;height:20px;flex:0 0 20px;border-radius:50%;display:grid;place-items:center;color:#334155;font-weight:800;font-size:.58rem;overflow:hidden;}
|
||
.bubble .sender .snd-av img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;}
|
||
.avatar.grp{border-radius:12px;font-size:1.15rem;}
|
||
/* Guest meeting mode: an external link-joiner sees ONLY the meeting — no rail, sidebar, or profile. */
|
||
body.guest-mode .rail, body.guest-mode #chatcol, body.guest-mode #hdrRight, body.guest-mode #navToggle, body.guest-mode .demo-note{display:none!important;}
|
||
body.guest-mode .content{width:100%;flex:1 1 auto;}
|
||
/* Guest pre-join: brand backdrop, camera preview, mic/cam toggles, name. */
|
||
.guest-prejoin{position:fixed;inset:0;z-index:9900;display:flex;align-items:center;justify-content:center;padding:1rem;overflow:auto;
|
||
background:radial-gradient(1200px 600px at 50% -10%, #2b4f96 0%, #1F3B73 55%, #16294f 100%);}
|
||
.guest-prejoin .gp-card, .guest-left .gp-card{background:#fff;border-radius:20px;padding:1.5rem 1.5rem 1.3rem;width:100%;max-width:420px;box-shadow:0 24px 60px rgba(0,0,0,.35);text-align:center;}
|
||
.guest-prejoin .gp-brand, .guest-left .gp-brand{display:flex;align-items:center;justify-content:center;gap:.5rem;margin-bottom:1rem;}
|
||
.guest-prejoin .gp-brand img, .guest-left .gp-brand img{height:34px;width:auto;object-fit:contain;}
|
||
.guest-prejoin .gp-brand span, .guest-left .gp-brand span{font-size:1rem;font-weight:600;color:var(--blue);} .guest-prejoin .gp-brand b, .guest-left .gp-brand b{color:var(--blue);}
|
||
.guest-left .gp-card h2{margin:.2rem 0 .3rem;font-size:1.2rem;color:var(--ink);}
|
||
.guest-prejoin .gp-preview{position:relative;width:100%;aspect-ratio:16/10;border-radius:14px;overflow:hidden;background:#0f172a;display:grid;place-items:center;margin-bottom:.8rem;}
|
||
.guest-prejoin .gp-preview video{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;transform:scaleX(-1);}
|
||
.guest-prejoin .gp-avatar{width:76px;height:76px;border-radius:50%;display:grid;place-items:center;font-weight:700;font-size:1.5rem;color:#334155;z-index:1;}
|
||
.guest-prejoin .gp-camoff{position:absolute;bottom:8px;left:0;right:0;color:rgba(255,255,255,.72);font-size:.75rem;z-index:1;}
|
||
.guest-prejoin .gp-toggles{display:flex;gap:.55rem;justify-content:center;margin-bottom:1rem;}
|
||
.guest-prejoin .gp-tg{display:inline-flex;align-items:center;gap:.4rem;border:1px solid var(--line);background:var(--blue-soft);color:var(--blue);border-radius:99px;padding:.45rem .85rem;font:inherit;font-size:.82rem;font-weight:600;cursor:pointer;}
|
||
.guest-prejoin .gp-tg.off{background:#fee2e2;border-color:#fecaca;color:#b91c1c;}
|
||
.guest-prejoin h2{margin:0 0 .2rem;font-size:1.2rem;color:var(--ink);}
|
||
.guest-prejoin .gp-sub, .guest-left .gp-sub{margin:0 0 1rem;font-size:.86rem;color:var(--muted);}
|
||
.guest-prejoin input{width:100%;box-sizing:border-box;border:1.5px solid var(--line);border-radius:11px;padding:.7rem .8rem;font-size:.95rem;font-family:inherit;background:#fbfcfe;color:var(--ink);outline:none;}
|
||
.guest-prejoin input:focus{border-color:var(--blue);}
|
||
.guest-prejoin input.err{border-color:#dc2626;background:#fef2f2;}
|
||
.guest-prejoin .gp-join, .guest-left .gp-join{width:100%;margin-top:.75rem;border:none;border-radius:11px;background:var(--blue);color:#fff;font:inherit;font-size:.95rem;font-weight:600;padding:.75rem;cursor:pointer;}
|
||
.guest-prejoin .gp-join:hover{filter:brightness(1.08);}
|
||
.guest-prejoin .gp-note, .guest-left .gp-note{display:flex;align-items:center;justify-content:center;gap:.3rem;margin-top:.7rem;font-size:.74rem;color:var(--muted);}
|
||
/* Guest "left the meeting" — same brand language as the pre-join card. */
|
||
.guest-left{position:fixed;inset:0;z-index:9900;display:flex;align-items:center;justify-content:center;padding:1rem;
|
||
background:radial-gradient(1200px 600px at 50% -10%, #2b4f96 0%, #1F3B73 55%, #16294f 100%);}
|
||
.guest-left .gl-tick{width:64px;height:64px;margin:.4rem auto .8rem;border-radius:50%;background:#dcfce7;color:#15803d;display:grid;place-items:center;}
|
||
.modal-ov{position:fixed;inset:0;background:rgba(15,23,42,.45);display:flex;align-items:center;justify-content:center;z-index:9800;padding:1rem;}
|
||
.modal{background:#fff;border-radius:16px;padding:1.4rem;max-width:380px;width:100%;box-shadow:0 18px 44px rgba(0,0,0,.3);max-height:calc(100vh - 2rem);max-height:calc(100dvh - 2rem);overflow-y:auto;}
|
||
.modal h3{margin:0 0 .8rem;color:var(--blue);}
|
||
.modal input#grpName,.modal input#giName{width:100%;padding:.6rem .7rem;border:2px solid var(--line);border-radius:10px;background:#fbfcfe;font-size:.95rem;margin-bottom:.8rem;}
|
||
.modal input#grpName:focus,.modal input#giName:focus{outline:none;border-color:var(--brand);}
|
||
/* Branded confirm dialog (replaces the OS/Electron window.confirm). */
|
||
.bz-confirm .bzc-msg{margin:.2rem 0 1.1rem;color:var(--ink);font-size:.92rem;line-height:1.5;}
|
||
/* overflow:hidden on the CARD so it never becomes a 2nd scroll container — .modal defaults to
|
||
overflow-y:auto, which nested against .seen-scroll and made the wheel/drag feel "stuck". */
|
||
.seen-modal{max-width:360px;display:flex;flex-direction:column;max-height:80vh;overflow:hidden;}
|
||
.seen-modal>h3{flex:0 0 auto;}
|
||
/* THE single scroll region for the whole list; the two section headers stick as you scroll */
|
||
.seen-modal .seen-scroll{flex:1 1 auto;overflow-y:auto;overscroll-behavior:contain;margin:.2rem -.4rem 0;padding:0 .4rem;min-height:0;}
|
||
.seen-modal .seen-div{height:1px;background:var(--line,#e3e8f2);margin:.5rem .2rem;}
|
||
.seen-modal .seen-row{display:flex;align-items:center;gap:.6rem;padding:.34rem .2rem;}
|
||
/* dark lettering on the pastel avatar (white was invisible on the light background), unless a real DP covers it */
|
||
.seen-modal .seen-row .mini-av{width:34px;height:34px;flex:0 0 auto;border-radius:50%;display:grid;place-items:center;color:#334155;font-weight:700;font-size:.76rem;overflow:hidden;position:relative;}
|
||
.seen-modal .seen-row .mini-av .av-img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;}
|
||
.seen-modal .seen-row .sn{font-size:.92rem;color:var(--ink);}
|
||
.seen-modal .seen-sec{position:sticky;top:0;z-index:1;background:var(--card,#fff);display:flex;align-items:center;gap:.4rem;font-size:.72rem;font-weight:700;letter-spacing:.02em;text-transform:uppercase;color:var(--blue);padding:.5rem .2rem .3rem;}
|
||
.seen-modal .seen-sec.dim{color:var(--muted);}
|
||
.seen-modal .seen-sec .dot{width:7px;height:7px;border-radius:50%;background:#cbd5e1;}
|
||
.seen-modal .seen-sec .dot.ok{background:#22c55e;}
|
||
.seen-modal .bzc-actions{flex:0 0 auto;margin-top:.7rem;}
|
||
.bzc-actions{display:flex;justify-content:flex-end;gap:.5rem;}
|
||
.bzc-actions button{border:none;border-radius:10px;padding:.55rem 1rem;font-size:.9rem;font-weight:600;cursor:pointer;font-family:inherit;}
|
||
.bzc-cancel{background:var(--blue-soft);color:var(--blue);}
|
||
.bzc-cancel:hover{background:#dbe6fa;}
|
||
.bzc-ok{background:var(--blue);color:#fff;}
|
||
.bzc-ok:hover{background:var(--blue-d);}
|
||
.bzc-ok:disabled{opacity:.5;cursor:not-allowed;}
|
||
.bzc-ok.danger{background:var(--red);}
|
||
.bzc-ok.danger:hover{background:#991b1b;}
|
||
/* "typing…" indicator inside the conversation, just above the composer. */
|
||
.typing-row{padding:.1rem .95rem .3rem;min-height:0;color:var(--blue);font-size:.78rem;font-style:italic;font-weight:600;display:none;align-items:center;gap:.4rem;}
|
||
.typing-row.show{display:flex;}
|
||
.typing-row .tdots{display:inline-flex;gap:2px;}
|
||
.typing-row .tdots i{width:5px;height:5px;border-radius:50%;background:var(--blue);opacity:.5;animation:tdot 1s infinite;}
|
||
.typing-row .tdots i:nth-child(2){animation-delay:.15s;} .typing-row .tdots i:nth-child(3){animation-delay:.3s;}
|
||
@keyframes tdot{0%,60%,100%{opacity:.3;transform:translateY(0);}30%{opacity:1;transform:translateY(-2px);}}
|
||
.avatar .mcount{position:absolute;right:-3px;bottom:-3px;min-width:16px;height:16px;border-radius:99px;background:var(--blue);color:#fff;font-size:.6rem;font-weight:800;display:grid;place-items:center;border:2px solid var(--card);padding:0 .15rem;z-index:1;}
|
||
.convo-titlewrap{flex:1;min-width:0;}
|
||
.convo-info{border:none;background:var(--blue-soft);color:var(--blue);width:32px;height:32px;border-radius:9px;font-size:1rem;cursor:pointer;flex:0 0 auto;display:grid;place-items:center;}
|
||
.convo-fav.on{color:#f59e0b;}
|
||
.convo-fav.on svg{fill:#f59e0b;}
|
||
.gi-title-row{display:flex;align-items:center;gap:.35rem;}
|
||
.fav-star{border:none;background:transparent;color:var(--muted);cursor:pointer;padding:.12rem;display:grid;place-items:center;border-radius:6px;flex:0 0 auto;}
|
||
.fav-star:hover{background:#f1f5f9;}
|
||
.fav-star.on{color:#f59e0b;} .fav-star.on svg{fill:#f59e0b;}
|
||
.status-dot{display:inline-block;width:8px;height:8px;border-radius:50%;background:#cbd5e1;margin-right:.35rem;vertical-align:middle;}
|
||
.status-dot.on{background:#16a34a;}
|
||
.convo-info:hover{background:#dbe4f0;}
|
||
.convo-call{border:none;background:var(--blue-soft);color:var(--blue);height:32px;border-radius:9px;cursor:pointer;flex:0 0 auto;display:inline-flex;align-items:center;gap:.3rem;padding:0 .6rem;font-weight:600;}
|
||
.convo-call:hover{background:#dbe4f0;}
|
||
.convo-call.joinable{background:#dcfce7;color:#15803d;}
|
||
.convo-call.joinable:hover{background:#bbf7d0;}
|
||
.convo-call span{font-size:.84rem;}
|
||
.call-on{display:inline-flex;align-items:center;gap:.3rem;color:#15803d;font-weight:600;}
|
||
.call-invite{position:fixed;right:18px;bottom:18px;z-index:6000;display:flex;align-items:center;gap:.7rem;background:#fff;border:1px solid var(--line);border-left:4px solid #15803d;border-radius:14px;padding:.7rem .9rem;box-shadow:0 12px 30px rgba(20,30,60,.25);max-width:340px;}
|
||
/* #4 lobby request stacks above call invites, tinted blue; multiple stack upward */
|
||
.call-invite.lobby-req{border-left-color:var(--blue);bottom:auto;top:18px;}
|
||
.call-invite.lobby-req .ci-ico{background:var(--blue-soft);color:var(--blue);}
|
||
/* #5 speaker output menu */
|
||
.spk-menu{position:fixed;z-index:9700;background:#fff;border:1px solid var(--line);border-radius:12px;box-shadow:0 14px 34px rgba(20,30,60,.28);padding:.35rem;min-width:220px;max-width:300px;}
|
||
.spk-menu .spk-h{display:flex;align-items:center;gap:.35rem;font-size:.72rem;font-weight:700;text-transform:uppercase;letter-spacing:.03em;color:var(--muted);padding:.35rem .5rem;}
|
||
.spk-menu .spk-opt{display:block;width:100%;text-align:left;border:none;background:transparent;font:inherit;font-size:.86rem;color:var(--ink);padding:.5rem .6rem;border-radius:8px;cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
|
||
.spk-menu .spk-opt:hover{background:var(--blue-soft);}
|
||
.spk-menu .spk-opt.on{color:var(--blue);font-weight:600;}
|
||
.spk-menu .spk-empty{font-size:.8rem;color:var(--muted);padding:.5rem .6rem;line-height:1.4;}
|
||
/* radio dot, so the menu reads like the OS/Teams device picker */
|
||
.spk-menu .spk-opt{display:flex;align-items:center;gap:.5rem;}
|
||
.spk-menu .spk-dot{width:13px;height:13px;border-radius:50%;border:2px solid #cbd5e1;flex:0 0 auto;}
|
||
.spk-menu .spk-opt.on .spk-dot{border-color:var(--blue);box-shadow:inset 0 0 0 2.5px var(--blue);}
|
||
.spk-menu .spk-h+.spk-h{margin-top:.2rem;}
|
||
/* Mic button + ▾ caret grouped into one control (audio devices live behind the caret) */
|
||
.meet-bar .mic-grp{position:relative;display:inline-flex;align-items:center;}
|
||
.meet-bar .mic-caret{position:absolute;right:-2px;bottom:-2px;width:20px;height:20px;border-radius:50%;border:2px solid var(--card,#fff);background:var(--blue);color:#fff;display:grid;place-items:center;cursor:pointer;padding:0;z-index:2;}
|
||
.meet-bar .mic-caret:hover{filter:brightness(1.12);}
|
||
.meet-bar .more-btn{display:none;} /* desktop: everything is on the bar */
|
||
/* new #4: the bar can be dragged clear of the shared screen */
|
||
.draggable{cursor:move;}
|
||
.draggable.dragging{opacity:.92;box-shadow:0 18px 40px rgba(0,0,0,.35);z-index:9500;}
|
||
.meet-bar.draggable{border-radius:14px;}
|
||
/* Desktop already has speaker+mic devices behind the mic ▾ — no separate speaker button (#8d). */
|
||
.meet-bar .spk-btn{display:none;}
|
||
/* Speaker OFF is a "lite" state; mic/cam OFF stay RED (see .meet-ic.off above — do NOT override it). */
|
||
.meet-bar .spk-btn.off{background:#e2e8f0;color:#94a3b8;}
|
||
.gi-list .mrow.dm-able{cursor:pointer;border-radius:9px;}
|
||
.gi-list .mrow.dm-able:hover{background:var(--blue-soft);}
|
||
/* #1 mini profile card */
|
||
.bubble .sender.profile-open{cursor:pointer;border-radius:8px;padding:1px 4px;margin-left:-4px;}
|
||
.bubble .sender.profile-open:hover{background:rgba(31,59,115,.07);}
|
||
.mini-profile{position:fixed;z-index:9750;width:250px;background:var(--card);border:1px solid var(--line);border-radius:14px;box-shadow:0 16px 40px rgba(20,30,60,.28);padding:.8rem;}
|
||
.mini-profile .mp-top{display:flex;align-items:center;gap:.6rem;margin-bottom:.7rem;}
|
||
.mini-profile .mp-av{position:relative;overflow:hidden;width:44px;height:44px;flex:0 0 44px;border-radius:50%;display:grid;place-items:center;color:#334155;font-weight:700;font-size:.9rem;}
|
||
.mini-profile .mp-av img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;}
|
||
.mini-profile .mp-id{min-width:0;}
|
||
.mini-profile .mp-id b{display:block;font-size:.92rem;color:var(--ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
|
||
.mini-profile .mp-id span{font-size:.74rem;color:var(--muted);}
|
||
.mini-profile .mp-acts{display:flex;gap:.4rem;}
|
||
.mini-profile .mp-msg{flex:1;display:inline-flex;align-items:center;justify-content:center;gap:.35rem;border:none;border-radius:9px;background:var(--blue);color:#fff;font:inherit;font-size:.84rem;font-weight:600;padding:.5rem;cursor:pointer;}
|
||
.mini-profile .mp-msg:hover{filter:brightness(1.08);}
|
||
.mini-profile .mp-dp{flex:0 0 auto;border:1px solid var(--line);background:var(--card);color:var(--blue);border-radius:9px;width:38px;display:grid;place-items:center;cursor:pointer;}
|
||
.spk-menu .mm-opt{display:flex;align-items:center;gap:.55rem;}
|
||
.spk-menu .mm-opt svg{flex:0 0 auto;color:var(--blue);}
|
||
/* ---- Mobile meeting bar (#8c/#8e): only Mic / Camera / Speaker / End; the rest behind ⋮ More ---- */
|
||
@media (max-width:640px){
|
||
.meet-bar .mic-caret{display:none;} /* #8c: no device dropdown on mobile — plain on/off */
|
||
.meet-bar .sec-btn{display:none;} /* screen, record, transcript, chat, participants → ⋮ */
|
||
.meet-bar .more-btn{display:inline-flex;}
|
||
.meet-bar .spk-btn{display:inline-flex;} /* speaker/earpiece/bluetooth toggle is MOBILE-only */
|
||
/* #2: fit the bar to the screen — controls on ONE row, sized to the width, no overflow/cramping. */
|
||
.meet-bar{flex-wrap:wrap;justify-content:center;gap:.45rem;padding:.5rem .5rem calc(.5rem + env(safe-area-inset-bottom,0px));}
|
||
.meet-bar .code{flex:1 1 100%;order:-1;margin-bottom:.15rem;}
|
||
.meet-bar .meet-ic{width:clamp(40px,13vw,52px);height:clamp(40px,13vw,52px);}
|
||
.meet-bar .meet-ic svg{width:20px;height:20px;}
|
||
}
|
||
.call-invite .ci-ico{width:38px;height:38px;border-radius:50%;background:#dcfce7;color:#15803d;display:grid;place-items:center;flex:0 0 auto;}
|
||
.call-invite .ci-txt{font-size:.88rem;color:var(--ink);line-height:1.25;}
|
||
.call-invite .ci-join{border:none;background:#15803d;color:#fff;border-radius:9px;padding:.45rem .7rem;font-weight:700;cursor:pointer;display:inline-flex;align-items:center;gap:.3rem;flex:0 0 auto;}
|
||
.call-invite .ci-join:hover{background:#13703a;}
|
||
.call-invite .ci-x{border:none;background:transparent;color:var(--muted);cursor:pointer;display:grid;place-items:center;flex:0 0 auto;}
|
||
.call-invite .ci-x:hover{color:var(--red);}
|
||
.call-invite .ci-decline{border:none;background:#fee2e2;color:#b91c1c;border-radius:9px;padding:.45rem .7rem;font-weight:700;cursor:pointer;display:inline-flex;align-items:center;gap:.3rem;flex:0 0 auto;}
|
||
.call-invite .ci-decline:hover{background:#fecaca;}
|
||
.convo-info:hover{background:#dbe6fb;}
|
||
/* group info (Slack-style) */
|
||
.modal.gi{max-width:400px;}
|
||
.gi-head{display:flex;align-items:center;gap:.7rem;margin-bottom:.6rem;}
|
||
.gi-name{flex:1;min-width:0;}
|
||
.gi-name input{width:100%;border:none;border-bottom:2px solid transparent;font-size:1.1rem;font-weight:700;color:var(--ink);padding:.1rem 0;background:transparent;}
|
||
.gi-name input:focus{outline:none;border-bottom-color:var(--brand);}
|
||
.gi-sub{font-size:.78rem;color:var(--muted);}
|
||
.gi-name-row{display:flex;align-items:center;gap:.4rem;}
|
||
.gi-title{font-size:1.1rem;font-weight:700;color:var(--ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
|
||
.iconbtn.sm{width:24px;height:24px;}
|
||
.gi-edit{display:flex;align-items:center;gap:.3rem;}
|
||
.gi-edit input{flex:1;min-width:0;border:2px solid var(--line);border-radius:8px;padding:.3rem .5rem;font-size:1rem;font-weight:600;background:#fbfcfe;color:var(--ink);}
|
||
.gi-edit input:focus{outline:none;border-color:var(--brand);}
|
||
.gi-actions{display:flex;gap:.6rem;margin:.2rem 0 .7rem;}
|
||
.gi-search{position:relative;margin-bottom:.5rem;}
|
||
.gi-search input{width:100%;border:1px solid var(--line);border-radius:9px;padding:.45rem 2rem .45rem .6rem;font-size:.88rem;background:#fbfcfe;color:var(--ink);box-sizing:border-box;}
|
||
.gi-search input:focus{outline:none;border-color:var(--blue);}
|
||
.gi-noresult{text-align:center;color:var(--muted);font-size:.84rem;padding:.7rem 0;}
|
||
.gi-created{font-size:.78rem;color:var(--muted);margin:0 0 .7rem;line-height:1.45;}
|
||
.gi-created svg{vertical-align:-2px;margin-right:.25rem;}
|
||
.gi-created b{color:var(--ink);font-weight:600;}
|
||
.gi-setting{display:flex;align-items:center;justify-content:space-between;gap:.5rem;font-size:.84rem;color:var(--ink);background:#f6f8fb;border:1px solid var(--line);border-radius:9px;padding:.5rem .7rem;margin:0 0 .7rem;cursor:pointer;}
|
||
.gi-setting span{display:inline-flex;align-items:center;gap:.4rem;}
|
||
.switch{position:relative;display:inline-block;width:40px;height:22px;flex:0 0 auto;}
|
||
.switch input{opacity:0;width:0;height:0;position:absolute;margin:0;}
|
||
.switch .slider{position:absolute;inset:0;background:#cbd2dd;border-radius:99px;transition:background .15s;cursor:pointer;}
|
||
.switch .slider::before{content:"";position:absolute;width:18px;height:18px;left:2px;top:2px;background:#fff;border-radius:50%;transition:transform .15s;box-shadow:0 1px 2px rgba(0,0,0,.25);}
|
||
.switch input:checked + .slider{background:var(--blue);}
|
||
.switch input:checked + .slider::before{transform:translateX(18px);}
|
||
.seenby{display:block;margin-top:.2rem;border:none;background:transparent;color:var(--muted);font-size:.68rem;cursor:pointer;padding:0;display:inline-flex;align-items:center;gap:.25rem;}
|
||
.seenby:hover{color:var(--blue);}
|
||
.bubble.mine .seenby{color:rgba(255,255,255,.8);}
|
||
.bubble.mine .seenby:hover{color:#fff;}
|
||
.gi-act{flex:1;border:1px solid var(--line);background:var(--card);border-radius:10px;padding:.6rem;font-weight:600;color:var(--blue);cursor:pointer;display:flex;align-items:center;justify-content:center;gap:.4rem;}
|
||
.gi-act:hover{background:var(--blue-soft);border-color:#c7d6f0;}
|
||
.gi-open{cursor:pointer;}
|
||
.sched-wrap{width:100%;margin:1.2rem 0 0;text-align:left;}
|
||
.sched-sec{margin-bottom:1.4rem;}
|
||
.sched-h{display:flex;align-items:center;gap:.6rem;font-size:.74rem;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);margin:0 0 .55rem;font-weight:700;}
|
||
/* #7: past-meetings date filter + pager */
|
||
/* #4: all controls share one height/baseline so the icon, preset and date fields line up cleanly */
|
||
.mtg-filter{display:inline-flex;align-items:center;gap:.4rem;margin-left:auto;text-transform:none;letter-spacing:0;}
|
||
.mtg-filter>svg{color:var(--blue);flex:0 0 auto;}
|
||
.mtg-filter select,.mtg-date{height:30px;box-sizing:border-box;display:inline-flex;align-items:center;border:1px solid var(--line);border-radius:8px;padding:0 .55rem;font:inherit;font-size:.74rem;font-weight:600;background:#fbfcfe;color:var(--ink);cursor:pointer;line-height:1;}
|
||
.mtg-filter select{appearance:auto;}
|
||
.mtg-filter select:focus,.mtg-date:focus{outline:none;border-color:var(--blue);box-shadow:0 0 0 2px var(--blue-soft);}
|
||
.mtg-date{gap:.35rem;} .mtg-date:hover{border-color:var(--blue);color:var(--blue);}
|
||
.mtg-dash{color:var(--muted);font-weight:600;}
|
||
.mtg-clear{width:30px;height:30px;box-sizing:border-box;}
|
||
.date-pop{position:fixed;z-index:9760;display:block;} /* branded calendar popup (same look as the scheduler) */
|
||
.mtg-clear{border:none;background:transparent;color:var(--muted);cursor:pointer;display:grid;place-items:center;padding:.15rem;border-radius:50%;}
|
||
.mtg-clear:hover{background:#fee2e2;color:var(--red);}
|
||
.mtg-pager{display:flex;align-items:center;justify-content:center;gap:.7rem;margin-top:.7rem;font-size:.76rem;color:var(--muted);}
|
||
.mtg-pg{border:1px solid var(--line);background:var(--card);color:var(--blue);border-radius:8px;width:30px;height:28px;display:grid;place-items:center;cursor:pointer;}
|
||
.mtg-pg:hover:not(:disabled){background:var(--blue-soft);}
|
||
.mtg-pg:disabled{opacity:.4;cursor:default;}
|
||
.sched-list{display:grid;grid-template-columns:repeat(auto-fill,minmax(330px,1fr));gap:.7rem;}
|
||
.sched-item{display:flex;align-items:flex-start;gap:.6rem;background:var(--card);border:1px solid var(--line);border-radius:12px;padding:.8rem .9rem;}
|
||
.sched-item.live{border-color:#34d399;background:#f0fdf4;}
|
||
.sched-item.cancelled{opacity:.7;}
|
||
.sched-item.cancelled .si-title{text-decoration:line-through;text-decoration-color:var(--muted);}
|
||
.cancel-tag{font-size:.62rem;font-weight:700;background:#fee2e2;color:#b91c1c;padding:.05rem .4rem;border-radius:99px;text-decoration:none;}
|
||
/* Schedule form: custom date/time pickers, recurring day chips, inline error highlight */
|
||
.sch-row{display:flex;gap:.6rem;flex-wrap:wrap;}
|
||
.sch-row > div{flex:1 1 140px;min-width:0;}
|
||
.picker-field{position:relative;}
|
||
.pick-btn{display:flex;align-items:center;gap:.45rem;width:100%;text-align:left;cursor:pointer;color:var(--ink);}
|
||
.pick-btn svg{color:var(--blue);flex:0 0 auto;}
|
||
.pick-pop{position:absolute;z-index:60;top:100%;left:0;margin-top:.3rem;background:#fff;border:1px solid var(--line);border-radius:14px;box-shadow:0 14px 36px rgba(20,30,60,.22);padding:.6rem;width:280px;max-width:84vw;}
|
||
.pick-pop.time-pop{display:grid;grid-template-columns:repeat(3,1fr);gap:.35rem;max-height:240px;overflow:auto;width:240px;}
|
||
.pick-pop.hidden{display:none !important;} /* must beat .pick-pop.time-pop's display:grid */
|
||
.cal-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:.4rem;font-size:.92rem;color:var(--ink);}
|
||
.cal-nav{border:none;background:var(--blue-soft);color:var(--blue);width:30px;height:30px;border-radius:8px;cursor:pointer;display:grid;place-items:center;}
|
||
.cal-nav:disabled{opacity:.35;cursor:default;}
|
||
.cal-grid{display:grid;grid-template-columns:repeat(7,1fr);gap:2px;}
|
||
.cal-dow{text-align:center;font-size:.66rem;color:var(--muted);font-weight:700;padding:.2rem 0;}
|
||
.cal-day{border:none;background:transparent;color:var(--ink);height:34px;border-radius:9px;cursor:pointer;font-size:.84rem;}
|
||
.cal-day:hover:not(:disabled){background:var(--blue-soft);}
|
||
.cal-day.today{outline:1px solid var(--blue);}
|
||
.cal-day.sel{background:var(--blue);color:#fff;font-weight:700;}
|
||
.cal-day:disabled{color:#cbd2dc;cursor:default;}
|
||
.time-chip{border:1px solid var(--line);background:var(--card);color:var(--ink);border-radius:8px;padding:.4rem .2rem;font-size:.78rem;cursor:pointer;}
|
||
.time-chip:hover{background:var(--blue-soft);}
|
||
.time-chip.sel{background:var(--blue);color:#fff;border-color:var(--blue);font-weight:700;}
|
||
.switch-row{display:flex;align-items:center;justify-content:space-between;background:#f6f8fb;border:1px solid var(--line);border-radius:10px;padding:.55rem .8rem;margin:.8rem 0 .4rem;}
|
||
.switch-row > span:first-child{display:flex;align-items:center;gap:.45rem;font-size:.88rem;color:var(--ink);}
|
||
.sch-days{display:flex;flex-wrap:wrap;align-items:center;gap:.4rem;margin:.3rem 0 .2rem;}
|
||
.day-chip{width:34px;height:34px;border:1px solid var(--line);background:var(--card);color:var(--ink);border-radius:50%;font-size:.78rem;font-weight:700;cursor:pointer;display:grid;place-items:center;}
|
||
.day-chip.on{background:var(--blue);color:#fff;border-color:var(--blue);}
|
||
.day-all{margin-left:auto;border:1px solid var(--line);background:var(--card);color:var(--blue);border-radius:99px;padding:.32rem .7rem;font-size:.78rem;font-weight:700;cursor:pointer;}
|
||
.si-actions .iconbtn{width:28px;height:28px;border-radius:7px;}
|
||
.si-actions .iconbtn.edit{color:var(--blue);} .si-actions .iconbtn.edit:hover{background:var(--blue-soft);}
|
||
.si-actions .iconbtn.cancel-ic{color:#b91c1c;} .si-actions .iconbtn.cancel-ic:hover{background:#fee2e2;}
|
||
.finput.field-err,.pick-btn.field-err{border-color:#dc2626 !important;box-shadow:0 0 0 2px rgba(220,38,38,.18);}
|
||
.si-main{flex:1;min-width:0;}
|
||
.si-title{font-weight:700;color:var(--ink);display:flex;align-items:center;gap:.5rem;}
|
||
.si-meta{font-size:.8rem;color:var(--muted);margin-top:.15rem;}
|
||
.si-desc{font-size:.84rem;color:var(--ink);margin-top:.35rem;opacity:.85;}
|
||
.livedot{color:#059669;font-size:.72rem;font-weight:700;}
|
||
.si-actions{display:flex;align-items:center;gap:.25rem;flex:0 0 auto;}
|
||
.btn.sm{padding:.28rem .7rem;font-size:.8rem;border-radius:7px;line-height:1.2;}
|
||
.btn.join{background:var(--blue);color:#fff;border:none;cursor:pointer;}
|
||
.btn.sm.cancel{background:#fee2e2;color:#b91c1c;border:1px solid #fecaca;}
|
||
.btn.sm.cancel:hover{background:#fecaca;}
|
||
.modal.sched{max-width:440px;}
|
||
.flbl{display:block;font-size:.78rem;font-weight:600;color:var(--ink);margin:.6rem 0 .25rem;}
|
||
.flbl .opt{color:var(--muted);font-weight:400;}
|
||
.finput{width:100%;border:1px solid var(--line);border-radius:9px;padding:.55rem .65rem;font-size:.92rem;font-family:inherit;background:#fbfcfe;color:var(--ink);box-sizing:border-box;}
|
||
.finput:focus{outline:none;border-color:var(--blue);}
|
||
.email-invite{display:flex;gap:.5rem;align-items:stretch;}
|
||
.email-invite .finput{flex:1;}
|
||
.email-add{flex:0 0 auto;display:inline-flex;align-items:center;gap:.3rem;border:1px solid var(--line);background:var(--blue-soft);color:var(--blue);border-radius:9px;padding:0 .8rem;font-size:.82rem;font-weight:600;cursor:pointer;white-space:nowrap;}
|
||
.email-add:hover{background:var(--blue);color:#fff;}
|
||
.email-chips{display:flex;flex-wrap:wrap;gap:.35rem;margin-top:.45rem;}
|
||
.email-chips:empty{margin-top:0;}
|
||
.echip{display:inline-flex;align-items:center;gap:.3rem;background:#eef2fa;border:1px solid var(--line);color:var(--ink);border-radius:99px;padding:.2rem .3rem .2rem .6rem;font-size:.78rem;}
|
||
.echip button{border:none;background:transparent;color:var(--muted);cursor:pointer;display:grid;place-items:center;padding:.1rem;border-radius:50%;}
|
||
.echip button:hover{color:#dc2626;}
|
||
.iconbtn{border:none;background:transparent;color:var(--muted);cursor:pointer;width:30px;height:30px;border-radius:8px;display:grid;place-items:center;flex:0 0 auto;}
|
||
.iconbtn:hover{background:#f1f5f9;color:var(--blue);}
|
||
.iconbtn.rm:hover{color:var(--red);background:#fee2e2;}
|
||
.gi-save{width:100%;background:var(--brand);color:var(--ink);margin-bottom:.6rem;}
|
||
.gi-sec-h{display:flex;align-items:center;justify-content:space-between;font-size:.74rem;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);margin:.5rem 0 .3rem;}
|
||
.gobtn{border:none;border-radius:11px;padding:.7rem 1rem;font-weight:700;font-size:.92rem;cursor:pointer;background:var(--blue);color:#fff;transition:filter .12s;}
|
||
.gobtn:hover{filter:brightness(1.08);}
|
||
.linkbtn{border:1px solid var(--line);background:var(--card);color:var(--blue);cursor:pointer;font-size:.8rem;font-weight:600;display:inline-flex;align-items:center;gap:.35rem;padding:.35rem .7rem;border-radius:8px;text-transform:none;letter-spacing:0;}
|
||
.linkbtn:hover{background:var(--blue-soft);border-color:#c7d6f0;}
|
||
.gi-list{display:flex;flex-direction:column;gap:.1rem;max-height:240px;overflow-y:auto;}
|
||
.mrow{display:flex;align-items:center;gap:.6rem;padding:.35rem .3rem;border-radius:8px;font-size:.92rem;}
|
||
.mrow:hover{background:#f6f8fb;}
|
||
.mrow .mn{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
|
||
.mrow .iconbtn{opacity:0;}
|
||
.mrow:hover .iconbtn{opacity:1;}
|
||
.mini-av{position:relative;overflow:hidden;width:30px;height:30px;flex:0 0 30px;border-radius:50%;display:grid;place-items:center;color:#334155;font-weight:700;font-size:.72rem;}
|
||
.mini-av .av-img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;}
|
||
.gi-photo{position:relative;border:none;background:transparent;padding:0;cursor:pointer;flex:0 0 auto;}
|
||
.gi-photo .avatar.grp{flex:0 0 46px;}
|
||
.gi-photo-cam{position:absolute;right:-4px;bottom:-4px;width:22px;height:22px;border-radius:50%;background:var(--blue);color:#fff;display:grid;place-items:center;border:2px solid var(--card);box-shadow:0 1px 3px rgba(0,0,0,.22);}
|
||
.gi-photo-cam .ic{width:12px;height:12px;}
|
||
.gi-photo:hover .gi-photo-cam{filter:brightness(1.12);}
|
||
.youtag{font-size:.62rem;background:var(--blue-soft);color:var(--blue);padding:.05rem .35rem;border-radius:99px;margin-left:.3rem;vertical-align:middle;text-transform:uppercase;letter-spacing:.03em;}
|
||
.gi-add{margin-top:.4rem;border:1px solid var(--line);border-radius:10px;padding:.5rem;}
|
||
.gi-add .chk{display:flex;align-items:center;gap:.5rem;padding:.25rem;border-radius:6px;cursor:pointer;}
|
||
.gi-add .chk:hover{background:#f6f8fb;}
|
||
.gi-leave{width:100%;margin-top:.9rem;border:1px solid #fecaca;background:#fff;color:var(--red);border-radius:10px;padding:.55rem;font-weight:600;cursor:pointer;display:flex;align-items:center;justify-content:center;gap:.4rem;}
|
||
.gi-leave:hover{background:#fee2e2;}
|
||
.grp-members{max-height:220px;overflow-y:auto;border:1px solid var(--line);border-radius:10px;padding:.5rem .6rem;display:flex;flex-direction:column;gap:.4rem;}
|
||
.modal .chk{display:flex;align-items:center;gap:.5rem;font-size:.9rem;cursor:pointer;}
|
||
.modal .chk input{width:16px;height:16px;accent-color:var(--blue);margin:0;}
|
||
.modal-actions{display:flex;gap:.6rem;margin-top:1rem;}
|
||
.modal-actions .gobtn{flex:1;border:none;border-radius:10px;padding:.6rem;font-weight:700;cursor:pointer;}
|
||
|
||
/* ---- Login (shown on /home when logged out) ---- */
|
||
.authwrap{flex:1 1 auto;display:none;align-items:center;justify-content:center;padding:1.5rem;min-height:0;
|
||
background:radial-gradient(1200px 600px at 50% -10%, #24437f 0%, #1F3B73 42%, #16294F 100%);}
|
||
.authcard{background:var(--card);border:1px solid var(--line);border-radius:18px;padding:2rem 2rem 1.7rem;max-width:400px;width:100%;box-shadow:0 24px 60px rgba(9,17,38,.38);}
|
||
.authcard .auth-brand{display:flex;flex-direction:column;align-items:center;gap:.55rem;margin-bottom:1rem;}
|
||
.authcard .auth-brand img{width:66px;height:66px;border-radius:17px;box-shadow:0 10px 24px rgba(31,59,115,.30);}
|
||
.authcard .auth-brand .wm{font-size:1.15rem;font-weight:800;color:var(--blue);letter-spacing:.2px;}
|
||
.authcard .auth-brand .wm b{color:var(--brand-d);font-weight:800;}
|
||
.authcard h1{font-size:1.12rem;color:var(--blue);margin:0 0 .3rem;text-align:center;}
|
||
.authcard .sub{color:var(--muted);font-size:.9rem;text-align:center;margin-bottom:1.2rem;}
|
||
/* Blue card + gold CTA so the sign-in screen carries BOTH brand colours (never mono-blue). */
|
||
.authcard .gobtn{background:var(--brand);color:var(--blue-d);}
|
||
.gobtn:disabled{opacity:.9;cursor:default;}
|
||
.btn-spin{display:inline-block;width:15px;height:15px;border:2px solid rgba(22,41,79,.3);border-top-color:var(--blue-d);border-radius:50%;animation:btnspin .6s linear infinite;vertical-align:-3px;margin-right:7px;}
|
||
@keyframes btnspin{to{transform:rotate(360deg);}}
|
||
.authcard .gobtn:hover{filter:brightness(.95);}
|
||
.authtabs{display:flex;gap:.5rem;margin-bottom:1.1rem;}
|
||
.authtabs button{flex:1;background:#eef1f6;color:var(--muted);font-weight:600;border:none;border-radius:9px;padding:.5rem;cursor:pointer;font-size:.9rem;}
|
||
.authtabs button.active{background:var(--blue);color:#fff;}
|
||
.authcard .lbl{display:block;font-size:.74rem;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;margin:.7rem 0 .15rem;}
|
||
.authcard input{width:100%;padding:.6rem .7rem;border-radius:10px;border:2px solid var(--line);background:#fbfcfe;color:var(--ink);font-size:.92rem;}
|
||
.authcard input:focus{outline:none;border-color:var(--brand);}
|
||
.authcard .gobtn{width:100%;margin-top:1rem;padding:.7rem;background:var(--brand);color:var(--ink);border:none;border-radius:10px;font-weight:700;cursor:pointer;font-size:.95rem;}
|
||
.authcard .gobtn:hover{background:var(--brand-d);}
|
||
.authcard .pwwrap{position:relative;} .authcard .pwwrap input{padding-right:2.6rem;}
|
||
.authcard .eye{position:absolute;right:.35rem;top:50%;transform:translateY(-50%);background:none;border:none;padding:.3rem;width:auto;color:var(--muted);display:inline-flex;align-items:center;cursor:pointer;}
|
||
.authcard .eye:hover{color:var(--blue);}
|
||
.formerr{color:#b91c1c;font-weight:600;font-size:.88rem;margin:.7rem 0 0;min-height:1em;}
|
||
.formerr.show{display:flex;align-items:center;gap:.5rem;background:#fee2e2;border:1px solid #fca5a5;border-radius:9px;padding:.6rem .75rem;animation:errShake .35s;}
|
||
.formerr.show::before{content:"⚠";font-size:1rem;}
|
||
@keyframes errShake{0%,100%{transform:translateX(0)}20%,60%{transform:translateX(-5px)}40%,80%{transform:translateX(5px)}}
|
||
.hidden{display:none;}
|
||
|
||
/* ---- Loading / toast ---- */
|
||
.loading{position:fixed;inset:0;display:grid;place-items:center;background:#1F3B73;z-index:9000;color:rgba(255,255,255,.82);font-size:.9rem;}
|
||
.loading .ld-inner{display:flex;flex-direction:column;align-items:center;gap:.85rem;}
|
||
.loading .ld-inner img{width:72px;height:72px;}
|
||
.loading .ld-wm{font-size:1.35rem;font-weight:800;color:#fff;letter-spacing:.3px;margin-top:.15rem;}
|
||
.loading .ld-wm b{color:var(--brand);font-weight:800;}
|
||
.loading .ld-sub{color:rgba(255,255,255,.66);font-size:.82rem;}
|
||
/* Chat thread loading (light pane) and call-connecting (dark stage) — branded, centered. */
|
||
.thread-loading{height:100%;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:.7rem;color:var(--muted);font-size:.86rem;}
|
||
.call-connecting{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:1rem;background:#16294F;color:rgba(255,255,255,.82);font-size:.95rem;}
|
||
.call-connecting .cc-txt{letter-spacing:.2px;}
|
||
/* in-app toasts now render via /bizconnect-toast.css (BZToast) */
|
||
|
||
/* Hamburger menu button (header) */
|
||
.navtoggle{background:transparent;border:none;color:#fff;cursor:pointer;display:grid;place-items:center;width:38px;height:38px;border-radius:9px;}
|
||
.navtoggle:hover{background:rgba(255,255,255,.14);}
|
||
/* Desktop: collapse the rail to enlarge content */
|
||
body.rail-hidden .rail{display:none;}
|
||
.rail-backdrop{display:none;position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:1100;}
|
||
|
||
@media (max-width:900px){
|
||
.chatcol{width:280px;flex:0 0 280px;}
|
||
}
|
||
/* ---- Mobile / tablet ---- */
|
||
@media (max-width:760px){
|
||
/* #7: drop the top blue bar entirely on mobile to use the full screen. bell+profile are
|
||
relocated into the chat-list header (see placeHdrRight). The shell is pushed below the
|
||
device status bar so no view overlaps it. */
|
||
header{display:none;}
|
||
/* CRITICAL (keyboard): iOS auto-ZOOMS into any focused <input>/<textarea> whose font-size is < 16px.
|
||
That zoom scales & offsets the visual viewport (vvW 428→394, vvTop→74 in the device report), which
|
||
scrambled every keyboard/safe-area calculation. Forcing 16px on all form fields stops the zoom, so
|
||
the keyboard behaves predictably and the composer sits correctly. This is THE keyboard root cause. */
|
||
/* iOS zoom is now disabled via the viewport meta (maximum-scale=1), so inputs can keep their real
|
||
size — no font-size override needed. touch-action:manipulation also removes the double-tap-to-zoom
|
||
gesture on controls (e.g. the in-chat search up/down arrows). */
|
||
button,a,.iconbtn,.ic-btn,.railbtn,.chat-row,[role=button]{touch-action:manipulation;}
|
||
/* #3: kill any horizontal drift on phones (long content, over-wide children, safe-area math) — the
|
||
page must never pan left/right. Clip every scroll surface and let long words/URLs wrap. */
|
||
html,body{overflow-x:hidden;}
|
||
/* Stop the page from widening WITHOUT clipping overlays (profile/bell menus, reaction popups): only
|
||
cap the chat-list scroller and let long words/URLs wrap instead of pushing width. */
|
||
.chatlist{overflow-x:hidden;}
|
||
.chat-main,.msg,.msg-body,.bubble,.chat-sub,.chat-preview{min-width:0;max-width:100%;overflow-wrap:break-word;}
|
||
/* #1/#5: the top safe-area inset (notch / Dynamic Island) is applied PER SURFACE — the chat-list
|
||
column and each content panel — rather than on the whole shell, so nothing hides under the island
|
||
and there's no double-padding. Bottom inset is handled by the fixed nav + panel bottom below. */
|
||
.side-title{gap:.5rem;}
|
||
.side-title h2{flex:1;}
|
||
.side-title #hdrRight{display:flex;align-items:center;gap:.4rem;flex:0 0 auto;}
|
||
.brand{font-size:.98rem;}
|
||
.navtoggle{display:none;} .rail-backdrop{display:none!important;} /* bottom nav replaces the drawer */
|
||
/* App-style bottom navigation bar */
|
||
/* iOS: the 60px height had the home-indicator inset padded INSIDE it, so the bar sat under the
|
||
indicator and drifted with the URL bar. Use border-box + a height that INCLUDES the inset, pin it
|
||
with a promoted layer, and use px-unit env() fallbacks (unitless 0 breaks calc() on Safari). */
|
||
.rail{position:fixed;left:0;right:0;bottom:0;top:auto;width:auto;box-sizing:border-box;
|
||
height:calc(60px + var(--sab));
|
||
padding:0 .3rem var(--sab) .3rem;
|
||
flex-direction:row;justify-content:space-around;align-items:center;gap:0;
|
||
border-right:none;border-top:1px solid var(--line);box-shadow:0 -3px 14px rgba(20,30,60,.08);
|
||
transform:translateZ(0)!important;-webkit-transform:translateZ(0)!important;z-index:1200;background:var(--card);}
|
||
.rail .rail-spacer{display:none;}
|
||
.railbtn{display:flex;flex-direction:column;justify-content:center;gap:2px;width:62px;height:50px;border-radius:12px;}
|
||
.railbtn .rlabel{display:block;font-size:.66rem;font-weight:700;line-height:1;}
|
||
.railbtn svg{width:20px;height:20px;}
|
||
.railbtn::after,.railbtn::before{display:none;}
|
||
.railbtn:not(.active){color:#334155;background:transparent;}
|
||
.railbtn.active{background:var(--blue-soft);color:var(--blue);}
|
||
/* Meeting room code: always fully visible, full-width, easy to read */
|
||
.meet-bar .code{flex:1 1 100%;font-size:.9rem;white-space:normal;word-break:break-word;text-align:center;background:var(--blue-soft);border-radius:8px;padding:.35rem .5rem;}
|
||
/* Chat: one pane at a time (list, then the open conversation) */
|
||
.chatcol{width:100%;flex:1 1 100%;padding-top:var(--sat);padding-bottom:calc(60px + var(--sab));}
|
||
body.chat-open .chatcol{display:none;}
|
||
.content{display:none;}
|
||
body.chat-open .content, .shell:not(.is-chat) .content{display:block;}
|
||
/* Keep the conversation pane on its own GPU layer for the WHOLE time it's open (not just while the
|
||
slide animates). The open transition then only changes the transform VALUE 100%→0; when it ends there
|
||
is NOTHING to tear down (no contain/box-shadow/will-change toggle) → the ~40ms end-of-slide demote
|
||
raster is gone. The layer is demoted only when you leave the chat, which has its own close animation. */
|
||
body.chat-open .content{z-index:2;box-shadow:-12px 0 30px rgba(15,23,42,.18);will-change:transform;backface-visibility:hidden;-webkit-backface-visibility:hidden;}
|
||
.shell:not(.is-chat) .chatcol{display:none;}
|
||
.content .panel{top:var(--sat);bottom:calc(60px + var(--sab));} /* below the notch; clear the bottom nav + home indicator */
|
||
/* #2 (mobile keyboard): inside an open conversation, hide the bottom tab bar so the composer sits
|
||
directly above the keyboard (no nav sandwiched between the text box and the keyboard). The panel
|
||
then only needs to clear the home indicator. */
|
||
body.chat-open .rail{display:none;}
|
||
/* Composer clears the home indicator; with keyboard resize:none we raise it by --kb (the reported
|
||
keyboard height, set by the Keyboard listener) so it sits on the keyboard. No home indicator while
|
||
the keyboard is up, so --sab collapses to 0. */
|
||
body.chat-open .content .panel{bottom:calc(var(--sab) + var(--kb));transition:bottom .25s cubic-bezier(.17,.59,.4,1);}
|
||
body.kb-open{--sab:0px;}
|
||
/* iOS: a home-screen PWA IGNORES maximum-scale, so it auto-zooms the page on focus of any input under
|
||
16px (the Capacitor WebView respects maximum-scale, which is why the zoom only shows up in the PWA).
|
||
Making every focusable text field exactly 16px is the only reliable prevention — and it's belt-and-
|
||
suspenders for the native app too. autoGrow's empty-guard keeps the composer at one line despite this. */
|
||
.composer-row input, .composer-row textarea, input[type="text"], input[type="search"], input[type="email"], input[type="tel"], input[type="url"], input[type="password"], input[type="number"], input:not([type]), textarea, select{font-size:16px;}
|
||
.lightbox .lb-zoom{display:none;} /* mobile: pinch-to-zoom the image directly — the on-screen +/- buttons are redundant clutter */
|
||
/* Native-style push/pop: the conversation slides in from the right when opened and slides back out
|
||
when you go back, so navigation has a clear direction (like Teams/WhatsApp). */
|
||
/* Native-style push/pop (like iOS/Teams): the conversation (.content) slides in from the right while
|
||
the chat LIST underneath slides with a subtle parallax. iOS spring-ish easing. */
|
||
@keyframes bzContentIn{from{transform:translateX(100%);}to{transform:translateX(0);}}
|
||
@keyframes bzListParallaxOut{from{transform:translateX(0);filter:brightness(1);}to{transform:translateX(-22%);filter:brightness(.97);}}
|
||
@keyframes bzListParallaxIn{from{transform:translateX(-22%);filter:brightness(.97);}to{transform:translateX(0);filter:brightness(1);}}
|
||
/* OPEN: content slides in over the list (list shown beneath, parallax out) */
|
||
/* width:100vw makes the sliding pane a DEFINITE width regardless of any ancestor's transient 0-width
|
||
during the animation frame — so bubbles never collapse, the composer measures at the right width,
|
||
and translateX(100%) travels a real screen width (a true slide, not a no-travel "pop"). */
|
||
/* Compositor-only push (workflow-verified). Phase 1 (chat-opening): the pane is pinned OFF-SCREEN on
|
||
its own promoted GPU layer while the message DOM renders + scrolls to bottom. Phase 2 (.chat-anim,
|
||
added by JS on a double-rAF AFTER the render is laid out & painted): the compositor transitions
|
||
transform 100%→0 with the main thread idle → a true 60fps slide, not a jump. Dropped at transitionend. */
|
||
/* OPEN = instant position + a quick opacity fade. No transform, no parallax, no list-column display
|
||
toggle → there is literally nothing to reflow when it finishes, so the old ~40ms end-of-slide settle
|
||
is gone by construction. The list (chatcol) stays display:none throughout; content fades in over the bg. */
|
||
body.chat-opening .content{display:block!important;opacity:0;transform:translateX(12%);}
|
||
body.chat-opening .content.chat-fadein{opacity:1;transform:translateX(0);transition:opacity .18s ease-out, transform .24s cubic-bezier(.32,.72,0,1);} /* real slide-in from the right + fade. The list column (.chatcol) is NEVER shown during the open — it stays display:none throughout — so there is no display toggle and therefore no reflow when the slide ends. Content is already on its own GPU layer (will-change on body.chat-open), so this is a pure compositor transform. */
|
||
/* CLOSE / drag-back: reveal the list underneath (parallax in) while the conversation slides off right
|
||
(.content moved by an inline transform in showWelcome()/the edge-drag gesture). */
|
||
body.chat-closing .content, body.chat-dragging .content{display:block!important;z-index:2;box-shadow:-12px 0 30px rgba(15,23,42,.18);will-change:transform;}
|
||
body.chat-closing .chatcol{display:flex!important;position:absolute;inset:0;z-index:0;animation:bzListParallaxIn .28s cubic-bezier(.32,.72,0,1);}
|
||
body.chat-dragging .chatcol{display:flex!important;position:absolute;inset:0;z-index:0;}
|
||
/* Bigger touch targets */
|
||
.chat-row{padding:.7rem .85rem;}
|
||
.convo-head{padding:.7rem .85rem;}
|
||
.modal{width:94vw;}
|
||
/* Meeting + embedded panels full-width, controls above the nav */
|
||
.meet-grid{grid-template-columns:repeat(auto-fit,minmax(150px,1fr));}
|
||
.meet-bar{flex-wrap:wrap;gap:.4rem;padding:.5rem .6rem;}
|
||
.meet-bar .code{flex:1 1 100%;margin:0 0 .2rem;}
|
||
.meet-bar .meet-ic{width:44px;height:44px;}
|
||
.meet-panel{width:90vw;max-width:none;right:5vw;top:6px;bottom:74px;}
|
||
.md-top{flex-direction:column;}
|
||
/* Code+Join on a full-width row; Start & Schedule as two equal halves below */
|
||
.md-actions{width:100%;display:grid;grid-template-columns:1fr 1fr;gap:.5rem;}
|
||
.md-join{grid-column:1 / -1;}
|
||
.md-actions > .btn{width:100%;justify-content:center;}
|
||
.md-join{flex:1 1 100%;}
|
||
.call-invite{left:8px;right:8px;max-width:none;bottom:70px;}
|
||
.bell-menu{position:fixed;left:8px;right:8px;top:calc(56px + env(safe-area-inset-top,0px));width:auto;}
|
||
#rail .bell-menu,#rail .profile .pmenu{left:auto;} /* desktop rail rule must not leak to the mobile bottom-nav */
|
||
/* Touch devices have no hover: keep member-row actions (make-admin / remove) always visible */
|
||
.mrow .iconbtn{opacity:1;}
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<script src="/icons.js?v=6"></script>
|
||
<script>window.__BUILD='2026-07-24-batch168';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD);
|
||
// Emoji are rendered with the OS's own (colour) emoji font — instant, zero network.
|
||
//
|
||
// We used to run Twemoji over every emoji, which swapped each one for an <img> pulled INDIVIDUALLY from
|
||
// a CDN. Opening the picker therefore fired hundreds of image requests, and every re-render of a long
|
||
// thread fired more — that's why emoji "loaded very slowly" (#6). Modern Windows/macOS/iOS/Android all
|
||
// ship full-colour emoji, so the native glyphs look right and cost nothing. Kept as a no-op so the
|
||
// existing call sites don't have to change.
|
||
function twemojify(_el){ /* native emoji: nothing to do */ }</script>
|
||
<script>
|
||
// Native shell tag (iOS/Android). Safe-area insets are handled natively by the @capacitor-community/
|
||
// safe-area plugin (it makes env(safe-area-inset-*) report real values), so there's no JS status-bar
|
||
// juggling here — we only tag <html> so CSS can make small native-only tweaks if needed.
|
||
(function(){ try{ var C=window.Capacitor; if(!C||!C.isNativePlatform||!C.isNativePlatform()) return;
|
||
var p=(C.getPlatform&&C.getPlatform())||''; var r=document.documentElement; r.classList.add('native-app'); if(p) r.classList.add('native-'+p);
|
||
// Keyboard. The device log proved that on our build (resize:none) the WebView does NOT resize and
|
||
// VisualViewport does NOT reflect the keyboard (clientHeight & vvHeight both stay full) — so the ONLY
|
||
// reliable signal is the Capacitor Keyboard plugin's reported height. Now that the iOS auto-zoom is
|
||
// gone (inputs are 16px), that height is clean, so we lift the composer by exactly it. keyboardWillShow
|
||
// fires at the START of the animation, so the CSS bottom-transition slides the composer up WITH the
|
||
// keyboard (smooth). raw>innerHeight guards the rare device-pixel unit.
|
||
function setKb(px){ px=Math.max(0,Math.round(px)); r.style.setProperty('--kb',px+'px'); document.body.classList.toggle('kb-open',px>4); var b=document.getElementById('msgs'); if(b && (b.scrollHeight-b.scrollTop-b.clientHeight)<400) b.scrollTop=b.scrollHeight; } /* #1: only keep pinned to newest when ALREADY near the bottom — replying to an OLD message must not yank to the latest */
|
||
window.__bzResetKb=function(){ setKb(0); }; // called on composer blur as a safety net (#1: keyboard closed but lift stayed, leaving empty space after reply-cancel)
|
||
var KB=C.Plugins&&C.Plugins.Keyboard;
|
||
if(p==='ios' && KB && KB.setAccessoryBarVisible){ try{ KB.setAccessoryBarVisible({ isVisible:false }); }catch(_){} } // hide the grey iOS input accessory bar (up/down chevrons + Done) above the keyboard — iOS-only, no-op elsewhere
|
||
if(KB && KB.addListener){
|
||
KB.addListener('keyboardWillShow', function(info){ var raw=(info&&info.keyboardHeight)||0; var kb = raw>window.innerHeight ? raw/(window.devicePixelRatio||1) : raw; setKb(kb); });
|
||
KB.addListener('keyboardWillHide', function(){ setKb(0); });
|
||
KB.addListener('keyboardDidHide', function(){ setKb(0); }); // belt-and-suspenders: some close paths only fire Did*
|
||
}
|
||
}catch(_){} })();
|
||
// Browser / iOS home-screen PWA (NO Capacitor Keyboard plugin): lift the composer above the on-screen
|
||
// keyboard using the VisualViewport API, so the PWA behaves like the native app instead of the keyboard
|
||
// covering the text box. Native returns early (it uses the plugin path above). The >100px threshold ignores
|
||
// the Safari toolbar growing/shrinking (only a real keyboard shrinks the visual viewport that much).
|
||
(function(){ try{
|
||
var C=window.Capacitor; if(C && C.isNativePlatform && C.isNativePlatform()) return;
|
||
var vv=window.visualViewport; if(!vv) return; var r=document.documentElement, b=document.body;
|
||
function pinMsgs(){ var m=document.getElementById('msgs'); if(m && (m.scrollHeight-m.scrollTop-m.clientHeight)<400) m.scrollTop=m.scrollHeight; }
|
||
function upd(){
|
||
var cover = r.clientHeight - vv.height; // how much of the LAYOUT viewport the keyboard covers
|
||
if(cover > 100){
|
||
// Keyboard open. iOS/Safari does NOT resize the layout viewport (interactive-widget is ignored) and the
|
||
// webview SCROLLS to reveal the focused box (device log: scrollY≈413), which pushes the header off the
|
||
// top. Fix: pin the whole app to the VISUAL viewport height and undo the scroll, so header + messages +
|
||
// composer all fit above the keyboard. On Android, interactive-widget=resizes-content already shrank
|
||
// clientHeight to vv.height, so cover≈0 and this branch is skipped (that path is already correct).
|
||
b.style.height = vv.height + 'px'; b.style.bottom = 'auto';
|
||
r.style.setProperty('--kb','0px'); document.body.classList.add('kb-open');
|
||
if(window.scrollY || vv.offsetTop) window.scrollTo(0,0);
|
||
pinMsgs();
|
||
} else {
|
||
b.style.height=''; b.style.bottom='';
|
||
r.style.setProperty('--kb','0px'); document.body.classList.remove('kb-open');
|
||
}
|
||
}
|
||
vv.addEventListener('resize', upd); vv.addEventListener('scroll', upd);
|
||
window.__bzResetKb=function(){ b.style.height=''; b.style.bottom=''; r.style.setProperty('--kb','0px'); document.body.classList.remove('kb-open'); };
|
||
}catch(_){} })();
|
||
</script>
|
||
<div class="loading" id="loading"><div class="ld-inner"><img src="/loaders/loader-orbit-dark.svg" alt="" width="72" height="72"><div class="ld-wm">Biz <b>Connect</b></div><span class="ld-sub">Loading…</span></div></div>
|
||
|
||
<header>
|
||
<div class="brandrow">
|
||
<button class="navtoggle" id="navToggle" title="Toggle menu" aria-label="Toggle menu"><svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg></button>
|
||
<img src="/mark-light.png" alt="" style="height:46px;width:auto;max-width:190px;border-radius:8px;object-fit:contain;image-rendering:-webkit-optimize-contrast" onerror="this.replaceWith(Object.assign(document.createElement('div'),{className:'logo',textContent:'B'}))">
|
||
<div class="brand">Biz <span class="y">Connect</span></div>
|
||
</div>
|
||
<div id="hdrRight"></div>
|
||
</header>
|
||
|
||
<div class="shell is-chat">
|
||
<div class="rail-backdrop" id="railBackdrop"></div>
|
||
<!-- ---------- Icon rail ---------- -->
|
||
<nav class="rail" id="rail">
|
||
<button class="railbtn active" data-tab="chat" data-tip="Chat" aria-label="Chat">
|
||
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
|
||
<span class="rdot" id="railUnread" style="display:none">0</span><span class="rlabel">Chat</span>
|
||
</button>
|
||
<button class="railbtn" data-tab="share" data-tip="Share Screen" aria-label="Share Screen">
|
||
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
|
||
<span class="livedot"></span><span class="rlabel">Share</span>
|
||
</button>
|
||
<button class="railbtn" data-tab="connect" data-tip="Connect Screen" aria-label="Connect Screen">
|
||
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M1.42 9a16 16 0 0 1 21.16 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><line x1="12" y1="20" x2="12.01" y2="20"/></svg>
|
||
<span class="livedot"></span><span class="rlabel">Connect</span>
|
||
</button>
|
||
<button class="railbtn" data-tab="meeting" data-tip="Meeting" aria-label="Meeting">
|
||
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="23 7 16 12 23 17 23 7"/><rect x="1" y="5" width="15" height="14" rx="2" ry="2"/></svg>
|
||
<span class="livedot"></span><span class="rlabel">Meet</span>
|
||
</button>
|
||
<div class="rail-spacer"></div>
|
||
</nav>
|
||
|
||
<!-- ---------- Chat list (Chat tab only) ---------- -->
|
||
<aside class="chatcol" id="chatcol">
|
||
<div class="side-head">
|
||
<div class="side-title">
|
||
<h2>Chats</h2>
|
||
</div>
|
||
<div class="side-search-row">
|
||
<div class="search">
|
||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||
<input id="chatSearch" placeholder="Search chats" autocomplete="off">
|
||
<button class="search-x" id="chatSearchX" title="Clear" aria-label="Clear" style="display:none"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg></button>
|
||
</div>
|
||
<button class="newchat" id="newChat" title="New group / chat" aria-label="New group">+</button>
|
||
</div>
|
||
</div>
|
||
<div class="chatlist" id="chatlist"></div>
|
||
<div class="demo-note">💬 Messages with your BizGaze teammates</div>
|
||
</aside>
|
||
|
||
<!-- ---------- Main content ---------- -->
|
||
<main class="content">
|
||
<!-- Chat panel: welcome (no selection) OR conversation placeholder -->
|
||
<div class="panel center active" data-panel="chat" id="chatPanel"></div>
|
||
|
||
<!-- Share -->
|
||
<div class="panel" data-panel="share" id="sharePanel"></div>
|
||
|
||
<!-- Connect -->
|
||
<div class="panel" data-panel="connect" id="connectPanel"></div>
|
||
|
||
<!-- Meeting (mesh video; rendered by JS) -->
|
||
<div class="panel" data-panel="meeting" id="meetingPanel"></div>
|
||
</main>
|
||
</div>
|
||
|
||
<div class="authwrap" id="authwrap"></div>
|
||
|
||
|
||
<script>
|
||
// ---------- Helpers ----------
|
||
function pEsc(s){return String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));}
|
||
function initials(name){const p=String(name||'?').trim().split(/\s+/);return ((p[0]||'?')[0]+(p[1]?p[1][0]:'')).toUpperCase();}
|
||
function fmtDateTime(ts){ if(!ts) return ''; const d=new Date(ts); return d.toLocaleDateString([],{year:'numeric',month:'short',day:'numeric'})+' · '+d.toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'}); }
|
||
function fmtClock(ts){ if(!ts) return ''; return new Date(ts).toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'}); } // bubble time = clock only; date is the center separator
|
||
function autoGrow(el){ if(!el) return; if(!el.value){ el.style.height=''; el.style.overflowY='hidden'; return; } /* empty → 1 line via CSS (never inflate from a mid-layout scrollHeight) */ el.style.height='auto'; const max=140; el.style.height=Math.min(el.scrollHeight,max)+'px'; el.style.overflowY=el.scrollHeight>max?'auto':'hidden'; }
|
||
// Mild, light tint for a quoted reply, color-coded by who is being quoted. [bg, bar]
|
||
const REPLY_TINTS=[['#eef4ff','#3b6fd4'],['#eafaf2','#1f9d57'],['#fef4e9','#d98324'],['#f6eefe','#8b46c9'],['#fdeef3','#d6457f'],['#e9fafa','#179a9a'],['#fef7e6','#c9a227']];
|
||
function replyTint(key){ let h=0; const s=String(key||''); for(let i=0;i<s.length;i++) h=(h*31+s.charCodeAt(i))>>>0; return REPLY_TINTS[h%REPLY_TINTS.length]; }
|
||
function firstName(name){return String(name||'').trim().split(/\s+/)[0]||'there';}
|
||
// Soft pastel avatar backgrounds (with dark lettering) for a light, professional look.
|
||
const AV_COLORS=['#dbeafe','#e0e7ff','#dcfce7','#cffafe','#fae8ff','#fce7f3','#fee2e2','#fef3c7','#ecfccb','#fde4cf'];
|
||
function avColor(name){let h=0;for(const c of String(name))h=(h*31+c.charCodeAt(0))>>>0;return AV_COLORS[h%AV_COLORS.length];}
|
||
|
||
// Auto-correct: curated high-confidence common-typo → fix map. Applied as you type (on a word
|
||
// boundary), so corrections happen automatically — no right-click menu. Works on web/desktop/mobile.
|
||
// Kept conservative (only unambiguous typos) to avoid "fighting" the user. Ctrl+Z reverts a fix.
|
||
const AUTOCORRECT={
|
||
teh:'the',hte:'the',thier:'their',recieve:'receive',recieved:'received',beleive:'believe',beleived:'believed',
|
||
seperate:'separate',definately:'definitely',occured:'occurred',occuring:'occurring',untill:'until',wich:'which',
|
||
becuase:'because',becasue:'because',accross:'across',adress:'address',adressed:'addressed',alot:'a lot',
|
||
arguement:'argument',calender:'calendar',cemetary:'cemetery',changable:'changeable',collegue:'colleague',
|
||
comming:'coming',commited:'committed',concious:'conscious',definetly:'definitely',dissapoint:'disappoint',
|
||
embarass:'embarrass',enviroment:'environment',existance:'existence',familier:'familiar',finaly:'finally',
|
||
foriegn:'foreign',fourty:'forty',freind:'friend',gaurd:'guard',goverment:'government',grammer:'grammar',
|
||
happend:'happened',harrass:'harass',immediatly:'immediately',independant:'independent',infomation:'information',
|
||
knowlege:'knowledge',liason:'liaison',libary:'library',lisence:'license',maintainance:'maintenance',
|
||
mispell:'misspell',neccessary:'necessary',necesary:'necessary',noticable:'noticeable',occassion:'occasion',
|
||
occurance:'occurrence',ocurrence:'occurrence',paralell:'parallel',parliment:'parliament',particurly:'particularly',
|
||
perhasp:'perhaps',persistant:'persistent',posession:'possession',prefered:'preferred',privelege:'privilege',
|
||
probaly:'probably',probelm:'problem',proffesional:'professional',promiss:'promise',pronounciation:'pronunciation',
|
||
publically:'publicly',realy:'really',reccomend:'recommend',recomend:'recommend',refered:'referred',
|
||
relevent:'relevant',religous:'religious',rember:'remember',remeber:'remember',resturant:'restaurant',
|
||
rythm:'rhythm',secratary:'secretary',sieze:'seize',similiar:'similar',succesful:'successful',succesfully:'successfully',
|
||
suprise:'surprise',suprised:'surprised',tommorow:'tomorrow',tommorrow:'tomorrow',tounge:'tongue',truely:'truly',
|
||
unfortunatly:'unfortunately',usualy:'usually',vaccum:'vacuum',wierd:'weird',writen:'written',yeild:'yield',
|
||
yuo:'you',youre:"you're",cant:"can't",dont:"don't",doesnt:"doesn't",didnt:"didn't",wasnt:"wasn't",werent:"weren't",
|
||
isnt:"isn't",wouldnt:"wouldn't",couldnt:"couldn't",shouldnt:"shouldn't",hasnt:"hasn't",havent:"haven't",
|
||
ive:"I've",thats:"that's",whats:"what's",lets:"let's",
|
||
aer:'are',adn:'and',nad:'and',wnat:'want',waht:'what',knwo:'know',
|
||
woudl:'would',shoudl:'should',coudl:'could',funtion:'function',retrun:'return',
|
||
};
|
||
// Apply an auto-correction if the caret just crossed a word boundary onto a known typo.
|
||
function maybeAutocorrect(el){
|
||
try{
|
||
if(!el||el.selectionStart==null||el.selectionStart!==el.selectionEnd) return;
|
||
const pos=el.selectionStart, val=el.value, ch=val[pos-1];
|
||
if(!ch || !/[\s.,!?;:]/.test(ch)) return; // only right after a boundary char
|
||
const before=val.slice(0,pos-1); const m=before.match(/([A-Za-z']+)$/); if(!m) return;
|
||
const word=m[1], lower=word.toLowerCase(); const rep=AUTOCORRECT[lower];
|
||
if(!rep) return; // not a known typo (native squiggles still flag others)
|
||
const fixed=/^[A-Z]/.test(word)?(rep.charAt(0).toUpperCase()+rep.slice(1)):rep;
|
||
if(fixed===word) return;
|
||
const start=pos-1-word.length;
|
||
el.value=val.slice(0,start)+fixed+val.slice(pos-1);
|
||
const np=start+fixed.length+1; el.setSelectionRange(np,np);
|
||
}catch(_){}
|
||
}
|
||
|
||
// All in-app toasts route through the branded BZToast (bizconnect-toast.js). We keep the single
|
||
// toast(msg) entry point so every existing call site is unchanged, and classify by wording so
|
||
// errors/successes get the right colour+icon; everything else shows as a neutral message.
|
||
function toast(msg){
|
||
var s=String(msg==null?'':msg);
|
||
if(!window.BZToast) return; // loaded synchronously in <head>
|
||
if(/could ?n.t|cannot|can.t|failed|too large|invalid|error|denied|lost|blocked|not registered|required|need|no result|already/i.test(s)) return BZToast.error(s);
|
||
if(/renamed|updated|enabled|added|saved|created|invited|removed|left the|muted|photo updated|now an admin|no longer|success/i.test(s)) return BZToast.success(s);
|
||
return BZToast.message(s);
|
||
}
|
||
|
||
// ---------- Profile dropdown (mirrors profileHTML()/wireProfile() from console.html) ----------
|
||
function profileHTML(u){
|
||
const display=u.name||u.email;
|
||
const img=u.avatarUrl?'<img src="'+pEsc(u.avatarUrl)+'" alt="" onerror="this.remove()">':'';
|
||
const cur=(u&&u.status)||'active';
|
||
const lblOf=(st)=>st==='away'?'Away':st==='onleave'?'On leave':'Available';
|
||
const opt=(st)=>'<a class="ps-opt'+(cur===st?' on':'')+'" data-st="'+st+'"><span class="st-dot '+st+'"></span> '+lblOf(st)+'<span class="ps-check">'+ic('check',14)+'</span></a>';
|
||
return '<div class="profile"><button class="pbtn icon-only" id="pbtn" title="'+pEsc(display)+'">'
|
||
+ '<span class="pav">'+pEsc(initials(display))+img+'</span><span class="pav-dot '+statusCls({online:true,status:cur})+'"></span></button>'
|
||
+ '<div class="pmenu" id="pmenu">'
|
||
+ '<div class="phead"><div class="n">'+pEsc(display)+'</div><div class="e">'+pEsc(u.email)+(u.role?' · '+pEsc(u.role):'')+'</div></div>'
|
||
+ '<a class="ps-current" id="psStatusToggle"><span class="st-dot '+cur+'"></span><span class="ps-cur-label">'+lblOf(cur)+'</span><span class="ps-arrow">'+ic('chevronDown',14)+'</span></a>'
|
||
+ '<div class="ps-options" id="psOptions" style="display:none">'+opt('active')+opt('away')+opt('onleave')+'</div>'
|
||
+ '<div class="ps-div"></div>'
|
||
+ '<a href="/dashboard">'+ic('layoutDashboard',16)+' Dashboard</a>'
|
||
+ '<a id="psettings">'+ic('settings',16)+' Settings</a>'
|
||
+ '<a class="danger" id="plogout">'+ic('logOut',16)+' Logout</a>'
|
||
+ '</div></div>';
|
||
}
|
||
function wireProfile(){
|
||
const btn=document.getElementById('pbtn'),menu=document.getElementById('pmenu');
|
||
if(!btn)return;
|
||
btn.onclick=(e)=>{e.stopPropagation();const bm=document.getElementById('bellMenu');if(bm)bm.classList.remove('open');menu.classList.toggle('open');};
|
||
document.addEventListener('click',()=>menu.classList.remove('open'));
|
||
const lo=document.getElementById('plogout');
|
||
if(lo)lo.onclick=async()=>{ await unsubscribePush(); try{await fetch('/api/logout',{method:'POST'});}catch(_){} location.href='/';};
|
||
const ps=document.getElementById('psettings');
|
||
if(ps)ps.onclick=()=>{ menu.classList.remove('open'); openSettings(); };
|
||
const stog=menu.querySelector('#psStatusToggle'); if(stog) stog.onclick=(e)=>{ e.stopPropagation(); const o=menu.querySelector('#psOptions'); if(o) o.style.display=(o.style.display==='none'?'block':'none'); };
|
||
menu.querySelectorAll('.ps-opt').forEach(a=>a.onclick=async(e)=>{ e.stopPropagation(); const st=a.dataset.st; ME.status=st;
|
||
const cd=menu.querySelector('.ps-current .st-dot'); if(cd) cd.className='st-dot '+st;
|
||
const cl=menu.querySelector('.ps-cur-label'); if(cl) cl.textContent=(st==='away'?'Away':st==='onleave'?'On leave':'Available');
|
||
menu.querySelectorAll('.ps-opt').forEach(o=>{ o.classList.toggle('on', o===a); }); const opts=menu.querySelector('#psOptions'); if(opts) opts.style.display='none';
|
||
const pd=document.querySelector('.profile .pav-dot'); if(pd) pd.className='pav-dot '+st;
|
||
try{ await postJSON('/api/me/status',{status:st}); }catch(_){}
|
||
});
|
||
}
|
||
// ---------- Notification bell (activity center) ----------
|
||
// Captures things that otherwise have no home: scheduled-meeting invites, reminders, reactions to
|
||
// your messages, new polls, and new chat requests. Stored per user in this browser.
|
||
let NOTIFS=[];
|
||
function notifKey(){ return 'notifs_'+((ME&&ME.id)||''); }
|
||
function loadNotifs(){ try{ NOTIFS=JSON.parse(localStorage.getItem(notifKey())||'[]'); }catch(_){ NOTIFS=[]; } if(!Array.isArray(NOTIFS)) NOTIFS=[]; updateBellBadge(); }
|
||
function saveNotifs(){ try{ NOTIFS=NOTIFS.slice(0,50); localStorage.setItem(notifKey(), JSON.stringify(NOTIFS)); }catch(_){} }
|
||
function addNotif(n){ NOTIFS.unshift(Object.assign({ id:'n'+Date.now()+'_'+Math.round((window.performance&&performance.now())||Math.random()*1e6), ts:Date.now(), read:false }, n)); saveNotifs(); updateBellBadge(); const menu=document.getElementById('bellMenu'); if(menu&&menu.classList.contains('open')) renderBell(); }
|
||
function bellHTML(){ return '<div class="bell" id="bellWrap"><button class="bellbtn" id="bellBtn" title="Notifications" aria-label="Notifications">'+ic('bell',20)+'<span class="bell-dot" id="bellDot" style="display:none">0</span></button><div class="bell-menu" id="bellMenu"></div></div>'; }
|
||
function updateBellBadge(){ const d=document.getElementById('bellDot'); if(!d) return; const n=NOTIFS.filter(x=>!x.read).length; if(n>0){ d.textContent=n>99?'99+':n; d.style.display='grid'; } else d.style.display='none'; }
|
||
function renderBell(){ const menu=document.getElementById('bellMenu'); if(!menu) return;
|
||
const items=NOTIFS.length?NOTIFS.map(n=>'<div class="bell-item'+(n.read?'':' unread')+'" data-id="'+n.id+'"><span class="bell-ico">'+ic(n.icon||'bell',16)+'</span><div class="bell-body"><div class="bell-tx">'+n.text+'</div><div class="bell-tm">'+fmtTime(n.ts)+'</div></div></div>').join(''):'<div class="bell-empty">No notifications yet</div>';
|
||
menu.innerHTML='<div class="bell-head"><span>Notifications</span>'+(NOTIFS.length?'<button id="bellClear">Clear all</button>':'')+'</div><div class="bell-list">'+items+'</div>';
|
||
const cl=menu.querySelector('#bellClear'); if(cl) cl.onclick=(e)=>{ e.stopPropagation(); NOTIFS=[]; saveNotifs(); updateBellBadge(); renderBell(); };
|
||
menu.querySelectorAll('.bell-item').forEach(el=>el.onclick=()=>{ const n=NOTIFS.find(x=>x.id===el.dataset.id); if(n) openNotif(n); });
|
||
}
|
||
function openNotif(n){ NOTIFS=NOTIFS.filter(x=>x.id!==n.id); saveNotifs(); updateBellBadge(); const menu=document.getElementById('bellMenu'); if(menu) menu.classList.remove('open');
|
||
if(n.link){ if(n.link.kind==='meeting'){ switchTab('meeting'); loadScheduledMeetings(); } else if(n.link.kind==='dm'||n.link.kind==='group'){ switchTab('chat'); selectChat(n.link.kind, n.link.id); } } // meeting → just open the tab, don't auto-join
|
||
renderBell();
|
||
}
|
||
function wireBell(){ const b=document.getElementById('bellBtn'), menu=document.getElementById('bellMenu'); if(!b||!menu) return;
|
||
b.onclick=(e)=>{ e.stopPropagation(); const pm=document.getElementById('pmenu'); if(pm) pm.classList.remove('open'); const open=menu.classList.toggle('open'); if(open){ renderBell(); NOTIFS.forEach(x=>x.read=true); saveNotifs(); updateBellBadge(); } };
|
||
document.addEventListener('click',()=>menu.classList.remove('open')); menu.onclick=(e)=>e.stopPropagation(); updateBellBadge();
|
||
}
|
||
// Settings: notification preferences (browser permission + per-type), stored per browser.
|
||
function openSettings(){
|
||
if(document.getElementById('setModal')) return;
|
||
const ov=document.createElement('div'); ov.className='modal-ov'; ov.id='setModal';
|
||
const sw=(id,label,on)=>'<label class="gi-setting"><span>'+label+'</span><span class="switch"><input type="checkbox" id="'+id+'"'+(on?' checked':'')+'><span class="slider"></span></span></label>';
|
||
const perm=('Notification' in window) ? Notification.permission : 'unsupported';
|
||
const granted=perm==='granted';
|
||
const permLabel=granted?'Allowed':(perm==='denied'?'Denied':(perm==='default'?'Not set':'Unsupported'));
|
||
ov.innerHTML='<div class="modal sched"><div class="gi-head" style="margin-bottom:.6rem"><div class="avatar grp" style="width:40px;height:40px;flex:0 0 40px;background:var(--blue)">'+ic('settings',20)+'</div>'
|
||
+'<div class="gi-name"><div class="gi-title">Settings</div><div class="gi-sub">Notifications</div></div>'
|
||
+'<button class="iconbtn" id="setClose" title="Close">'+ic('x',18)+'</button></div>'
|
||
+sw('setGroup','Group message notifications', notifOn('group'))
|
||
+sw('setDm','Direct message notifications', notifOn('dm'))
|
||
+'<label class="gi-setting"><span>Notifications <span class="perm-state '+perm+'">'+permLabel+'</span><div style="font-size:.72rem;color:var(--muted);font-weight:400;margin-top:.15rem">Pop up messages & calls even when this tab isn’t open</div></span>'+(granted?'':'<button class="btn sm" id="setPerm">'+(perm==='denied'?'Allow':'Enable')+'</button>')+'</label>'
|
||
+(window.bizConnectNative?('<label class="gi-setting"><span>Biz Connect for Desktop<div style="font-size:.72rem;color:var(--muted);font-weight:400;margin-top:.15rem" id="setVer">Version '+pEsc(window.bizConnectNative.version||'—')+(_pendingUpdate?('<span class="set-ver-i" title="Update available">i</span> '+(_pendingUpdate.phase==='ready'?'Update ready':'Update '+pEsc(_pendingUpdate.version||'')+' available')):'')+'</div></span><button class="btn sm'+(_pendingUpdate?' has-update':'')+'" id="setUpd">'+(_pendingUpdate?(_pendingUpdate.phase==='ready'?'Restart now':'Downloading…'):'Check for updates')+'</button></label>'):'')
|
||
// NOTE: deliberately NO "web build" row here — users must not have to reason about an app version vs a
|
||
// web build. New code applies itself (see checkWebBuild/scheduleAutoRefresh); the only version shown is
|
||
// the desktop app's, which is what the auto-updater manages.
|
||
+(bzLibOn()?sw('setPhotos','Save photos & videos to the Photos app', bzPhotosOn()):'')
|
||
+(bzLibOn()?('<label class="gi-setting"><span>Storage<div style="font-size:.72rem;color:var(--muted);font-weight:400;margin-top:.15rem">Photos, videos & files you downloaded to this device</div></span><button class="btn sm" id="setStor">Manage</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 updBtn=ov.querySelector('#setUpd'); // #12: desktop version + manual update check
|
||
if(updBtn) updBtn.onclick=async()=>{
|
||
if(_pendingUpdate&&_pendingUpdate.phase==='ready'){ try{ window.bizConnectNative.restartToUpdate&&window.bizConnectNative.restartToUpdate(); }catch(_){} return; } // update already downloaded → restart
|
||
if(_pendingUpdate){ toast('Update '+(_pendingUpdate.version||'')+' is downloading…'); return; }
|
||
const ver=ov.querySelector('#setVer'); updBtn.disabled=true; updBtn.textContent='Checking…';
|
||
let done=false;
|
||
try{
|
||
const r=(window.bizConnectNative&&window.bizConnectNative.checkForUpdates)?await window.bizConnectNative.checkForUpdates():{status:'unsupported'};
|
||
if(r.status==='available'){ updBtn.textContent='Update '+(r.version||'')+' found'; if(ver) ver.textContent='Downloading '+(r.version||'')+'… you’ll be asked to restart when ready.'; done=true; }
|
||
else if(r.status==='current'){ updBtn.textContent='Up to date ✓'; }
|
||
else if(r.status==='dev'){ updBtn.textContent='Dev build'; }
|
||
else if(r.status==='unsupported'){ updBtn.textContent='Update on newer app'; }
|
||
else { updBtn.textContent='Check failed'; }
|
||
}catch(_){ updBtn.textContent='Check failed'; }
|
||
setTimeout(()=>{ if(!done){ updBtn.disabled=false; updBtn.textContent='Check for updates'; } }, 3500);
|
||
};
|
||
const setPref=(k,v)=>{ try{ localStorage.setItem('notif_'+k, v?'on':'off'); }catch(_){} };
|
||
ov.querySelector('#setGroup').onchange=e=>setPref('group', e.target.checked);
|
||
ov.querySelector('#setDm').onchange=e=>setPref('dm', e.target.checked);
|
||
const permBtn=ov.querySelector('#setPerm'); if(permBtn) permBtn.onclick=async()=>{ try{ const r=await Notification.requestPermission(); if(r==='granted'){ try{ await subscribePush(); }catch(_){} } }catch(_){} ov.remove(); openSettings(); }; // just trigger the browser prompt + refresh the state (no toast)
|
||
const storBtn=ov.querySelector('#setStor'); if(storBtn) storBtn.onclick=()=>{ ov.remove(); openStorage(); };
|
||
const phBox=ov.querySelector('#setPhotos'); // second copy in the Photos library — worth being able to turn off
|
||
if(phBox) phBox.onchange=e=>{ try{ localStorage.setItem('bzc.photos', e.target.checked?'on':'off'); }catch(_){} };
|
||
}
|
||
// Manage storage: everything this device has downloaded, with sizes, and a way to remove it. Deleting
|
||
// here only removes the LOCAL copy — the message and its attachment stay on the server, so anything
|
||
// deleted can simply be downloaded again from the chat.
|
||
function openStorage(){
|
||
if(document.getElementById('storModal')) return;
|
||
const plat=(window.Capacitor&&window.Capacitor.getPlatform)?window.Capacitor.getPlatform():'';
|
||
const where = plat==='ios' ? 'Files → On My iPhone → Biz Connect' : 'your Documents folder → Biz Connect';
|
||
const ov=document.createElement('div'); ov.className='modal-ov'; ov.id='storModal';
|
||
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('folder',20)+'</div>'
|
||
+'<div class="gi-name"><div class="gi-title">Manage storage</div><div class="gi-sub">Downloads on this device</div></div>'
|
||
+'<button class="iconbtn" id="storClose" title="Close">'+ic('x',18)+'</button></div>'
|
||
+'<div id="storBody"><div class="stor-empty">Loading…</div></div>'
|
||
+'<div class="hint" style="margin-top:.5rem">Saved to '+pEsc(where)+'. Deleting here removes only the copy on this device — the file stays in the chat and can be downloaded again.</div></div>';
|
||
document.body.appendChild(ov);
|
||
ov.onclick=e=>{ if(e.target===ov) ov.remove(); };
|
||
ov.querySelector('#storClose').onclick=()=>ov.remove();
|
||
const render=async()=>{
|
||
const body=ov.querySelector('#storBody'); if(!body) return;
|
||
const items=await bzLibList(); // verifies each file still exists first
|
||
bzSyncVideoTiles(); // anything deleted outside the app goes back to streaming
|
||
if(!items.length){ body.innerHTML='<div class="stor-empty">Nothing downloaded yet.<br>Downloads appear here and in '+pEsc(where)+'.</div>'; return; }
|
||
const groups={ Images:[], Videos:[], Files:[] };
|
||
items.forEach(i=>{ (groups[bzLibFolder(i.mime)]||groups.Files).push(i); });
|
||
const total=items.reduce((s,i)=>s+(i.size||0),0);
|
||
let h='<div class="stor-tot"><b>'+fmtSize(total)+'</b><span style="color:var(--muted);font-size:.8rem">in '+items.length+' item'+(items.length===1?'':'s')+'</span>'
|
||
+'<button class="btn sm" id="storAll" style="margin-left:auto">Delete all</button></div><div class="stor-list">';
|
||
for(const g of ['Images','Videos','Files']){
|
||
const arr=groups[g]; if(!arr.length) continue;
|
||
h+='<div class="stor-grp">'+g+' · '+fmtSize(arr.reduce((s,i)=>s+(i.size||0),0))+'</div>';
|
||
for(const it of arr){
|
||
const src=bzLibSrc(it);
|
||
const lead=(/^image\//.test(it.mime||'')&&src) ? '<img src="'+pEsc(src)+'" alt="">'
|
||
: ic(/^video\//.test(it.mime||'')?'video':(/^audio\//.test(it.mime||'')?'headphones':'file'),16);
|
||
h+='<div class="stor-row" data-id="'+pEsc(it.id)+'"><span class="sr-ic">'+lead+'</span>'
|
||
+'<span class="sr-m"><span class="sr-n">'+pEsc(it.name)+'</span><span class="sr-s">'+fmtSize(it.size)+'</span></span>'
|
||
+'<button class="sr-b share" title="Share or save elsewhere">'+ic('link',16)+'</button>'
|
||
+'<button class="sr-b del" title="Delete from this device">'+ic('trash',16)+'</button></div>';
|
||
}
|
||
}
|
||
body.innerHTML=h+'</div>';
|
||
const all=body.querySelector('#storAll');
|
||
if(all) all.onclick=async()=>{
|
||
if(!confirm('Delete all '+items.length+' downloaded item'+(items.length===1?'':'s')+' from this device?\n\nThey stay in your chats and can be downloaded again.')) return;
|
||
for(const it of items) await bzLibDelete(it.id);
|
||
bzSyncVideoTiles(); render(); toast('Downloads cleared');
|
||
};
|
||
body.querySelectorAll('.stor-row').forEach(row=>{
|
||
const id=row.getAttribute('data-id');
|
||
const del=row.querySelector('.del');
|
||
if(del) del.onclick=async()=>{ await bzLibDelete(id); bzSyncVideoTiles(); render(); };
|
||
const sh=row.querySelector('.share');
|
||
if(sh) sh.onclick=async()=>{ // hand the local file to the OS sheet (Save to Photos, etc.)
|
||
const r=bzLibGet(id), S=window.Capacitor&&window.Capacitor.Plugins&&window.Capacitor.Plugins.Share;
|
||
if(!r||!S) return toast('Sharing needs the latest app update.');
|
||
try{ await S.share({ url:r.uri, title:r.name }); }catch(_){}
|
||
};
|
||
});
|
||
};
|
||
render();
|
||
}
|
||
|
||
// ---------- Chat (1:1 + groups) ----------
|
||
let ME={};
|
||
let CONTACTS=[]; // team users (for new DMs / picking group members)
|
||
let ROWS=[]; // sidebar items: {kind:'dm'|'group', id, name, online?, members?, last_body, last_at, last_from_me, unread}
|
||
let selected=null; // {kind,id} or null = welcome
|
||
let convoIsGroup=false; // the open thread is a group (drives per-message sender labels)
|
||
let THREAD=[];
|
||
let _convoRevealed=false; // #7: gate the open-time hide/reveal so the cache→network double-render doesn't flash/dance
|
||
let _forcePinOpen=false; // true from chat-open until the user's first scroll gesture: keep gluing to the newest even as late images grow the thread past the usual 1200px "don't yank" guard (else you land mid-history)
|
||
const THREAD_CACHE=new Map(); // key 'kind:id' -> messages[] ; lets a notification click render synchronously (paints immediately)
|
||
// Pull-to-refresh (mobile): drag down at the top of a scroll area to refresh. Touch-only, so it's
|
||
// a no-op on desktop. onRefresh is an async function.
|
||
function enablePullRefresh(el, onRefresh){
|
||
if(!el || el.__ptr) return; el.__ptr=true;
|
||
// Mount the indicator on the list's NON-scrolling parent — inside the scroller it scrolled away with
|
||
// the content and was effectively invisible, which is why the pull looked like it did nothing (#6).
|
||
const host=el.parentElement||el;
|
||
if(getComputedStyle(host).position==='static') host.style.position='relative';
|
||
const ind=document.createElement('div'); ind.className='ptr-ind';
|
||
ind.innerHTML='<img src="/loaders/loader-ring.svg" width="20" height="20" alt="">';
|
||
host.appendChild(ind);
|
||
let startY=0, pulling=false, dist=0, busy=false;
|
||
const TRIGGER=58; // pull this far → refresh
|
||
const hide=()=>{ ind.style.opacity='0'; ind.style.transform='translateY(-44px)'; };
|
||
const draw=(d)=>{ ind.style.opacity=String(Math.min(d/26,1)); ind.style.transform='translateY('+(Math.min(d,72)-40)+'px)'; };
|
||
hide();
|
||
el.addEventListener('touchstart',(e)=>{
|
||
if(busy || e.touches.length!==1) return;
|
||
if(el.scrollTop>2){ pulling=false; return; } // only from the very top
|
||
startY=e.touches[0].clientY; pulling=true; dist=0;
|
||
},{passive:true});
|
||
el.addEventListener('touchmove',(e)=>{
|
||
if(!pulling||busy) return;
|
||
dist=e.touches[0].clientY-startY;
|
||
if(dist<=0){ hide(); return; } // moving up → let the list scroll normally
|
||
// Claim the gesture IMMEDIATELY. Deferring preventDefault until the pull was already ~10px let
|
||
// iOS/Chrome start their own overscroll first, and our handler then never got the gesture (#6).
|
||
if(e.cancelable) e.preventDefault();
|
||
draw(dist*0.6);
|
||
},{passive:false});
|
||
const finish=async()=>{
|
||
if(!pulling||busy){ hide(); return; }
|
||
pulling=false;
|
||
if(dist*0.6>=TRIGGER){
|
||
busy=true; ind.classList.add('spin'); ind.style.opacity='1'; ind.style.transform='translateY(12px)';
|
||
try{ await onRefresh(); }catch(_){}
|
||
await new Promise(r=>setTimeout(r,400)); // let the spinner be seen even on a fast refresh
|
||
ind.classList.remove('spin'); busy=false;
|
||
}
|
||
hide(); dist=0;
|
||
};
|
||
el.addEventListener('touchend', finish);
|
||
el.addEventListener('touchcancel', ()=>{ if(!busy){ pulling=false; hide(); dist=0; } });
|
||
}
|
||
async function reloadThread(){ if(!selected) return; const kind=selected.kind, id=selected.id; const url=kind==='group'?('/api/messages/thread?group='+encodeURIComponent(id)):('/api/messages/thread?with='+encodeURIComponent(id)); try{ const msgs=await fetch(url).then(r=>r.json()); if(selected&&selected.kind===kind&&selected.id===id&&Array.isArray(msgs)){ THREAD=msgs; THREAD_CACHE.set(kind+':'+id, THREAD.slice()); renderThread(); } }catch(_){} }
|
||
// ---- Older-history pagination: the thread loads the newest ~500; scrolling to the top fetches the
|
||
// previous page (messages older than the oldest one loaded) and prepends them, preserving scroll. ----
|
||
const PAGE=40; // load the latest 40 on open (fast render, no 500-node jank); loadOlder() pages in older history on scroll-up. MUST match the server thread limit.
|
||
let _hasMoreOlder=false, _loadingOlder=false, _olderCd=0;
|
||
function threadUrl(kind,id,before){ return (kind==='group'?'/api/messages/thread?group='+encodeURIComponent(id):'/api/messages/thread?with='+encodeURIComponent(id))+(before?('&before='+before):''); }
|
||
async function loadOlder(){
|
||
if(_loadingOlder || !_hasMoreOlder || !selected || !THREAD.length) return;
|
||
_loadingOlder=true;
|
||
const kind=selected.kind, id=selected.id, before=THREAD[0].created_at;
|
||
const box=document.getElementById('msgs');
|
||
const _esc=(s)=>(window.CSS&&CSS.escape)?CSS.escape(String(s)):String(s);
|
||
// Anchor on the CURRENT oldest-loaded message: after we prepend older ones (and as THEIR images load),
|
||
// keep this exact message at the same viewport position, so scrolling up never jumps. (The old
|
||
// scrollHeight math jumped because unloaded prepended images weren't counted, then shifted on load.)
|
||
const anchorId = THREAD[0] && THREAD[0].id;
|
||
const _boxTop = box ? box.getBoundingClientRect().top : 0;
|
||
const _aEl = (box&&anchorId) ? box.querySelector('.bubble[data-id="'+_esc(anchorId)+'"]') : null;
|
||
const _aTop = _aEl ? (_aEl.getBoundingClientRect().top - _boxTop) : 0;
|
||
const _restore=()=>{ if(!box||!anchorId) return; const el=box.querySelector('.bubble[data-id="'+_esc(anchorId)+'"]'); if(el){ const cur=el.getBoundingClientRect().top - box.getBoundingClientRect().top; box.scrollTop += (cur - _aTop); } };
|
||
let older=null; try{ const r=await fetch(threadUrl(kind,id,before)); if(r.ok) older=await r.json(); }catch(_){}
|
||
if(!selected||selected.kind!==kind||selected.id!==id){ _loadingOlder=false; return; }
|
||
if(Array.isArray(older) && older.length){
|
||
const have=new Set(THREAD.map(m=>m.id)); const add=older.filter(m=>!have.has(m.id));
|
||
if(add.length){ THREAD=add.concat(THREAD); THREAD_CACHE.set(kind+':'+id, THREAD.slice()); }
|
||
if(older.length<PAGE) _hasMoreOlder=false;
|
||
renderThread(true); // keep scroll (don't jump to bottom)
|
||
_restore(); // put the anchor message back at its exact viewport position
|
||
if(box){ const imgs=[...box.querySelectorAll('.bubble img')].filter(im=>!im.complete); imgs.forEach(im=>im.addEventListener('load', _restore, {once:true})); setTimeout(()=>imgs.forEach(im=>{ try{ im.removeEventListener('load', _restore); }catch(_){} }), 3000); } // re-anchor as prepended images load (they'd shift it down)
|
||
} else { _hasMoreOlder=false; }
|
||
_loadingOlder=false; _olderCd=Date.now()+500; // brief cooldown so it can't re-fire and fight the scroll
|
||
}
|
||
// Keep loading older pages until a target timestamp is in the loaded window (used by search jump-to).
|
||
async function ensureLoadedBack(at){ let guard=0; while(_hasMoreOlder && THREAD.length && THREAD[0].created_at > at && guard<60){ await loadOlder(); guard++; } }
|
||
// #9: in-chat search — highlight every match and jump between them with up/down (no filtering).
|
||
let _searchHits=[], _searchIdx=-1, _searchQ='', _searchDebT=null; // _searchHits: server hits [{id,at}] oldest-first
|
||
function clearSearchHighlights(){
|
||
const box=document.getElementById('msgs'); if(box){ box.querySelectorAll('mark.search-hit').forEach(m=>{ const t=document.createTextNode(m.textContent); m.replaceWith(t); }); box.querySelectorAll('.bubble').forEach(b=>{ try{ b.normalize(); }catch(_){} }); }
|
||
}
|
||
function _highlightIn(root, ql){
|
||
const hits=[];
|
||
const walker=document.createTreeWalker(root, NodeFilter.SHOW_TEXT, { acceptNode(n){ if(!n.nodeValue || n.nodeValue.toLowerCase().indexOf(ql)===-1) return NodeFilter.FILTER_REJECT; const p=n.parentElement; if(!p || p.closest('.reply-btn,.react-btn,.del-btn,.t,.reacts,.react-chip,.seenby,.rcpt,.att-sz,button')) return NodeFilter.FILTER_REJECT; return NodeFilter.FILTER_ACCEPT; } });
|
||
const nodes=[]; while(walker.nextNode()) nodes.push(walker.currentNode);
|
||
nodes.forEach(n=>{ const txt=n.nodeValue, low=txt.toLowerCase(); let i, last=0; const frag=document.createDocumentFragment();
|
||
while((i=low.indexOf(ql,last))!==-1){ if(i>last) frag.appendChild(document.createTextNode(txt.slice(last,i))); const mk=document.createElement('mark'); mk.className='search-hit'; mk.textContent=txt.slice(i,i+ql.length); frag.appendChild(mk); hits.push(mk); last=i+ql.length; }
|
||
if(last>0){ if(last<txt.length) frag.appendChild(document.createTextNode(txt.slice(last))); n.parentNode.replaceChild(frag,n); } });
|
||
return hits;
|
||
}
|
||
// Full-thread search: ask the server for ALL matches (not just the loaded window), debounced. Then
|
||
// jump to any hit — paging older history in if the match is above what's currently loaded.
|
||
function runSearch(q){ clearTimeout(_searchDebT); _searchDebT=setTimeout(()=>doSearch(q), 220); }
|
||
async function doSearch(q){
|
||
clearSearchHighlights();
|
||
const cnt=document.getElementById('convoSearchCount');
|
||
q=(q||'').trim(); _searchQ=q; _searchHits=[]; _searchIdx=-1;
|
||
if(!q){ if(cnt) cnt.textContent=''; return; }
|
||
if(!selected) return;
|
||
const kind=selected.kind, id=selected.id;
|
||
const url=(kind==='group'?'/api/messages/search?group='+encodeURIComponent(id):'/api/messages/search?with='+encodeURIComponent(id))+'&q='+encodeURIComponent(q);
|
||
let hits=[]; try{ const r=await fetch(url); if(r.ok){ const j=await r.json(); hits=j.hits||[]; } }catch(_){}
|
||
if(!selected||selected.kind!==kind||selected.id!==id||_searchQ!==q) return; // stale (switched chat / kept typing)
|
||
_searchHits=hits;
|
||
if(!hits.length){ if(cnt) cnt.textContent='0/0'; return; }
|
||
gotoHit(hits.length-1); // jump to the most recent match first
|
||
}
|
||
function _bubbleEl(id){ const box=document.getElementById('msgs'); if(!box) return null; const esc=(window.CSS&&CSS.escape)?CSS.escape(id):id; return box.querySelector('.bubble[data-id="'+esc+'"]'); }
|
||
// #8: jump to a specific message (e.g. tapping a reply quote) — paging older history in if it's
|
||
// above the loaded window — then scroll to it and flash it.
|
||
async function jumpToMessage(id, at){
|
||
if(!id) return;
|
||
let el=_bubbleEl(id);
|
||
if(!el && at){ await ensureLoadedBack(at); el=_bubbleEl(id); }
|
||
if(!el) return;
|
||
try{ el.scrollIntoView({block:'center'}); }catch(_){}
|
||
el.classList.add('search-flash'); setTimeout(()=>{ try{ el.classList.remove('search-flash'); }catch(_){} }, 1400);
|
||
}
|
||
// ---- #1 Forward: multi-select messages → forward to existing conversations only ----
|
||
let _selMode=false; const _selIds=new Set();
|
||
function enterSelect(id){
|
||
_selMode=true; _selIds.clear(); if(id) _selIds.add(id);
|
||
document.body.classList.add('sel-mode');
|
||
const box=document.getElementById('msgs'); if(box) box.querySelectorAll('.bubble').forEach(b=>b.classList.toggle('selected', _selIds.has(b.dataset.id)));
|
||
renderSelBar();
|
||
}
|
||
function toggleSel(bubble){
|
||
const id=bubble&&bubble.dataset.id; if(!id) return;
|
||
if(_selIds.has(id)){ _selIds.delete(id); bubble.classList.remove('selected'); } else { _selIds.add(id); bubble.classList.add('selected'); }
|
||
if(!_selIds.size){ exitSelect(); return; } renderSelBar();
|
||
}
|
||
function exitSelect(){
|
||
_selMode=false; _selIds.clear(); document.body.classList.remove('sel-mode');
|
||
const box=document.getElementById('msgs'); if(box) box.querySelectorAll('.bubble.selected').forEach(b=>b.classList.remove('selected'));
|
||
const bar=document.getElementById('selBar'); if(bar) bar.remove();
|
||
}
|
||
function renderSelBar(){
|
||
let bar=document.getElementById('selBar');
|
||
if(!bar){ bar=document.createElement('div'); bar.id='selBar'; bar.className='sel-bar'; document.body.appendChild(bar); }
|
||
bar.innerHTML='<button class="sb-x" title="Cancel">'+ic('x',18)+'</button><span class="sb-n">'+_selIds.size+' selected</span><button class="sb-fwd">'+ic('arrowRight',16)+' Forward</button>';
|
||
bar.querySelector('.sb-x').onclick=exitSelect;
|
||
bar.querySelector('.sb-fwd').onclick=()=>{ if(_selIds.size) openForwardPicker([..._selIds]); };
|
||
}
|
||
function fwdRowHTML(r){
|
||
const isG=r.kind==='group';
|
||
const av='<span class="mini-av" style="background:'+avColor(r.name)+'">'+(isG?ic('users',15):(r.avatar?'<img src="'+pEsc(r.avatar)+'" onerror="this.remove()">':pEsc(initials(r.name))))+'</span>';
|
||
return '<div class="fwd-item" data-k="'+r.kind+'" data-id="'+pEsc(r.id)+'" data-name="'+pEsc((r.name||'').toLowerCase())+'">'+av+'<span class="fi-name">'+pEsc(r.name)+'</span><span class="fi-check">'+ic('check',15)+'</span></div>';
|
||
}
|
||
function openForwardPicker(ids){
|
||
if(document.getElementById('fwdModal')) return;
|
||
const rows=ROWS.filter(r=>!r.self); // existing conversations only (per spec)
|
||
const ov=document.createElement('div'); ov.className='modal-ov'; ov.id='fwdModal';
|
||
ov.innerHTML='<div class="modal"><h3>Forward to…</h3>'
|
||
+'<input id="fwdSearch" placeholder="Search chats" autocomplete="off" style="width:100%;padding:.55rem .7rem;border:2px solid var(--line);border-radius:10px;background:#fbfcfe;margin-bottom:.6rem;">'
|
||
+'<div class="fwd-list" id="fwdList">'+(rows.length?rows.map(fwdRowHTML).join(''):'<div class="gi-noresult">No conversations yet</div>')+'</div>'
|
||
+'<div class="bzc-actions" style="margin-top:.8rem"><button type="button" class="bzc-cancel" id="fwdCancel">Cancel</button><button type="button" class="bzc-ok" id="fwdGo" disabled>Forward (0)</button></div></div>';
|
||
document.body.appendChild(ov);
|
||
const sel=new Set(); const list=ov.querySelector('#fwdList'), go=ov.querySelector('#fwdGo');
|
||
const upd=()=>{ go.disabled=!sel.size; go.textContent='Forward ('+sel.size+')'; };
|
||
list.addEventListener('click',e=>{ const it=e.target.closest('.fwd-item'); if(!it) return; const key=it.dataset.k+':'+it.dataset.id; if(sel.has(key)){ sel.delete(key); it.classList.remove('sel'); } else { sel.add(key); it.classList.add('sel'); } upd(); });
|
||
ov.querySelector('#fwdSearch').oninput=e=>{ const q=e.target.value.trim().toLowerCase(); list.querySelectorAll('.fwd-item').forEach(it=>{ it.style.display=(!q||(it.dataset.name||'').includes(q))?'':'none'; }); };
|
||
ov.querySelector('#fwdCancel').onclick=()=>ov.remove();
|
||
ov.addEventListener('mousedown',e=>{ if(e.target===ov) ov.remove(); });
|
||
go.onclick=async()=>{
|
||
const targets=[...sel].map(k=>{ const i=k.indexOf(':'); return { kind:k.slice(0,i), id:k.slice(i+1) }; });
|
||
go.disabled=true; go.textContent='Forwarding…';
|
||
try{ await postJSON('/api/messages/forward',{ messageIds:ids, targets }); ov.remove(); exitSelect(); toast('Forwarded to '+targets.length+(targets.length===1?' chat':' chats')); }
|
||
catch(e){ toast(e.message||'Could not forward'); go.disabled=false; upd(); }
|
||
};
|
||
}
|
||
async function gotoHit(i){
|
||
const hits=_searchHits; if(!hits.length) return;
|
||
_searchIdx=((i%hits.length)+hits.length)%hits.length;
|
||
const hit=hits[_searchIdx];
|
||
const cnt=document.getElementById('convoSearchCount'); if(cnt) cnt.textContent=(_searchIdx+1)+'/'+hits.length;
|
||
let el=_bubbleEl(hit.id);
|
||
if(!el){ await ensureLoadedBack(hit.at); if(_searchIdx!==((i%hits.length)+hits.length)%hits.length) return; el=_bubbleEl(hit.id); } // older than the loaded window → page back to it
|
||
clearSearchHighlights();
|
||
if(!el) return;
|
||
_highlightIn(el, _searchQ.toLowerCase());
|
||
const mk=el.querySelector('mark.search-hit');
|
||
if(mk){ mk.classList.add('search-current'); try{ mk.scrollIntoView({block:'center'}); }catch(_){} } else { try{ el.scrollIntoView({block:'center'}); }catch(_){} }
|
||
el.classList.add('search-flash'); setTimeout(()=>{ try{ el.classList.remove('search-flash'); }catch(_){} }, 1400);
|
||
}
|
||
function closeSearch(){ clearSearchHighlights(); const h=document.getElementById('convoSearchHead'); if(h) h.style.display='none'; const i=document.getElementById('convoSearchInput'); if(i) i.value=''; }
|
||
// #10: "Shared" — Media (images) + Files tabs for a DM or group, opened by tapping the chat name.
|
||
// Compact "Media, links & docs" entry (shown in the conversation-info window). Clicking it opens
|
||
// the separate full Media/Docs/Links view (openMediaView).
|
||
function mediaRowHTML(){ return '<button class="gi-media-row" id="giMediaRow"><span class="gmr-l">Media, links & 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 & docs</div><div class="gi-sub">'+pEsc(name||'')+'</div></div></div>'
|
||
+'<div class="mp-tabs mv-tabs"><button class="mp-tab on" data-t="media">Media</button><button class="mp-tab" data-t="docs">Docs</button><button class="mp-tab" data-t="links">Links</button></div>'
|
||
+'<div class="mv-body" id="mvBody"></div></div>';
|
||
document.body.appendChild(ov);
|
||
ov.onclick=e=>{ if(e.target===ov) ov.remove(); };
|
||
ov.querySelector('#mvBack').onclick=()=>ov.remove();
|
||
let tab='media';
|
||
const render=()=>{ const b=ov.querySelector('#mvBody'); if(!b) return;
|
||
if(tab==='media') b.innerHTML=data.media.length?'<div class="sh-grid">'+data.media.map(mediaCellHTML).join('')+'</div>':'<div class="gi-noresult">No media shared yet</div>';
|
||
else if(tab==='docs') b.innerHTML=data.docs.length?data.docs.map(x=>'<a class="sh-file" href="/files/'+pEsc(x.id)+'" download="'+pEsc(x.name||'file')+'">'+ic('fileText',16)+'<span class="sh-fn">'+pEsc(x.name||'file')+'</span><span class="sh-sz">'+fmtSize(x.size)+'</span></a>').join(''):'<div class="gi-noresult">No documents shared yet</div>';
|
||
else b.innerHTML=data.links.length?data.links.map(x=>'<a class="sh-file" href="'+pEsc(x.url)+'" target="_blank" rel="noopener">'+ic('link',16)+'<span class="sh-fn">'+pEsc(x.url)+'</span></a>').join(''):'<div class="gi-noresult">No links shared yet</div>';
|
||
loadMediaDurations(b);
|
||
};
|
||
ov.querySelectorAll('.mv-tabs .mp-tab').forEach(btn=>btn.onclick=()=>{ tab=btn.dataset.t; ov.querySelectorAll('.mv-tabs .mp-tab').forEach(x=>x.classList.toggle('on',x===btn)); render(); });
|
||
ov.querySelector('#mvBody').addEventListener('click',e=>{ const im=e.target.closest('.att-img'); if(im&&im.dataset.img){ e.preventDefault(); openLightbox(im.dataset.img); } });
|
||
render();
|
||
}
|
||
// DM contact-info window (no members): header + favourite + the Media/links/docs entry.
|
||
async function openSharedItems(kind,id,name){
|
||
if(document.getElementById('sharedModal')) return;
|
||
const r0=rowFor(kind,id); const fav=!!(r0&&r0.favorite); const online=!!(r0&&r0.online);
|
||
const ov=document.createElement('div'); ov.className='modal-ov'; ov.id='sharedModal';
|
||
ov.innerHTML='<div class="modal gi"><div class="gi-head" style="margin-bottom:.6rem"><button class="gi-photo" id="shPhoto" title="View photo"><span class="avatar" style="width:46px;height:46px;flex:0 0 46px;background:'+avColor(name)+'">'+pEsc(initials(name||'?'))+((r0&&r0.avatar)?'<img class="av-img" src="'+pEsc(r0.avatar)+'" alt="" onerror="this.remove()">':'')+'</span></button><div class="gi-name"><div class="gi-title-row"><span class="gi-title">'+pEsc(name||'')+'</span><button class="fav-star'+(fav?' on':'')+'" id="shFav" title="'+(fav?'Remove from favourites':'Add to favourites')+'">'+ic('star',16)+'</button></div><div class="gi-sub"><span class="st-dot '+statusCls(r0)+'"></span>'+statusLabel(r0)+'</div></div><button class="iconbtn" id="shClose">'+ic('x',18)+'</button></div>'
|
||
+mediaRowHTML()+'</div>';
|
||
document.body.appendChild(ov);
|
||
ov.onclick=e=>{ if(e.target===ov) ov.remove(); };
|
||
ov.querySelector('#shClose').onclick=()=>ov.remove();
|
||
const sp=ov.querySelector('#shPhoto'); if(sp) sp.onclick=()=>{ const r=rowFor(kind,id)||r0; if(r&&r.avatar) openLightbox(r.avatar); else toast('No profile photo'); }; // #5: click the DP in the info view to preview it
|
||
const fb=ov.querySelector('#shFav'); if(fb) fb.onclick=async()=>{ const r=rowFor(kind,id); const on=!(r&&r.favorite); if(r) r.favorite=on; fb.classList.toggle('on',on); fb.title=on?'Remove from favourites':'Add to favourites'; try{ await postJSON('/api/favorites',{kind,id,on}); }catch(_){} renderChats(searchVal()); };
|
||
wireMediaEntry(ov, kind, id, name);
|
||
}
|
||
let convoMembers=[]; // members of the currently open group (for @mentions + highlight)
|
||
let composeMentions=new Map();// token ('@Name' | 'everyone') -> userId | 'everyone' for the draft
|
||
const rendered=new Set();
|
||
const listEl=document.getElementById('chatlist');
|
||
function searchVal(){ const s=document.getElementById('chatSearch'); return s?s.value:''; }
|
||
function rowFor(kind,id){ return ROWS.find(r=>r.kind===kind && r.id===id); }
|
||
function currentName(){ const it=selected&&rowFor(selected.kind,selected.id); return it?it.name:''; }
|
||
|
||
function fmtTime(ts){
|
||
if(!ts) return '';
|
||
const d=new Date(ts), n=new Date();
|
||
if(d.toDateString()===n.toDateString()) return d.toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'});
|
||
const y=new Date(n); y.setDate(n.getDate()-1);
|
||
if(d.toDateString()===y.toDateString()) return 'Yesterday';
|
||
return d.toLocaleDateString([], {month:'short',day:'numeric'});
|
||
}
|
||
// Presence/status helpers (#7): 'incall' (auto) overrides; offline if not connected.
|
||
function statusCls(it){ const s=it&&it.status; if(s==='incall') return 'incall'; if(!it||!it.online) return 'offline'; if(s==='away') return 'away'; if(s==='onleave') return 'onleave'; return 'active'; }
|
||
function statusLabel(it){ const c=statusCls(it); return c==='incall'?'In a call':c==='away'?'Away':c==='onleave'?'On leave':c==='active'?'Available':lastSeenLabel(it); }
|
||
// #2: when someone is offline, say exactly WHEN they were last around — WhatsApp style, i.e. an actual
|
||
// time/date ("last seen today at 1:36 PM"), not a vague "10 minutes ago".
|
||
function lastSeenLabel(it){
|
||
const ts=it&&it.lastSeen; if(!ts) return 'Offline';
|
||
const t=new Date(ts), now=new Date();
|
||
const clock=t.toLocaleTimeString([],{hour:'numeric',minute:'2-digit'});
|
||
const dayOf=(d)=>new Date(d.getFullYear(),d.getMonth(),d.getDate()).getTime();
|
||
const days=Math.round((dayOf(now)-dayOf(t))/86400000);
|
||
if(days<=0) return 'last seen today at '+clock;
|
||
if(days===1) return 'last seen yesterday at '+clock;
|
||
if(days<7) return 'last seen '+t.toLocaleDateString([],{weekday:'long'})+' at '+clock;
|
||
return 'last seen '+t.toLocaleDateString([],{day:'2-digit',month:'2-digit',year:'numeric'})+' at '+clock;
|
||
}
|
||
function avatarHTML(it, big){
|
||
const isG=it.kind==='group';
|
||
const sz=big?'width:38px;height:38px;flex:0 0 38px;':'';
|
||
const corner=it.self?''
|
||
:isG?(it.members?'<span class="mcount">'+(it.members>99?'99+':it.members)+'</span>':'')
|
||
:'<span class="dot '+statusCls(it)+'"></span>';
|
||
const inner=isG?ic('users',big?20:18):pEsc(initials(it.name));
|
||
// Photo overlay (BizGaze profile picture, or an uploaded group image). If it fails to
|
||
// load it removes itself, revealing the initials/glyph underneath.
|
||
const img=it.avatar?'<img class="av-img" src="'+pEsc(it.avatar)+'" alt="" onerror="this.remove()">':'';
|
||
return '<div class="avatar'+(isG?' grp':'')+'" style="'+sz+'background:'+avColor(it.name)+'">'+inner+img+corner+'</div>';
|
||
}
|
||
// The sidebar row's preview line (draft / ongoing call / last message w/ tick). Factored out so a
|
||
// live typing indicator can temporarily replace it and then restore the real preview on stop.
|
||
function rowPreviewHTML(it){
|
||
const active=selected&&selected.kind===it.kind&&selected.id===it.id;
|
||
const isG=it.kind==='group';
|
||
const draft=getDraft(it.kind,it.id);
|
||
if(draft && !active){ return '<span class="draft-lbl">Draft:</span> '+pEsc(draft); } // unsent draft (#1)
|
||
if(it.callActive){ return '<span class="call-on">'+ic('phone',12)+' Ongoing call</span>'; }
|
||
const prevBody=isGifUrl(it.last_body)?'🎞️ GIF':it.last_body; // #5: don't show the raw GIF URL
|
||
if(it.last_from_me && it.last_status && it.last_body){ // my last message → tick (#3)
|
||
return '<span class="prev-tick '+it.last_status+'">'+ic(it.last_status==='sent'?'check':'checkCheck',13)+'</span> '+pEsc(prevBody); }
|
||
return pEsc(prevBody?((it.last_from_me?'You: ':'')+prevBody):(isG?((it.members||0)+' members'):'No messages yet'));
|
||
}
|
||
function rowHTML(it){
|
||
const active=selected&&selected.kind===it.kind&&selected.id===it.id;
|
||
const cls=['chat-row']; if(active)cls.push('active'); if(it.unread>0)cls.push('unread'); if(it.self)cls.push('self');
|
||
const tl=typingLabel(it.kind,it.id);
|
||
const inner=tl?('<span class="typing-prev">'+pEsc(tl)+'</span>'):rowPreviewHTML(it); // live "typing…" wins over the preview
|
||
return '<div class="'+cls.join(' ')+'" data-kind="'+it.kind+'" data-id="'+pEsc(it.id)+'">'
|
||
+ avatarHTML(it,false)
|
||
+ '<div class="chat-main"><div class="chat-top"><span class="chat-name">'+pEsc(it.name)+'</span><span class="chat-time">'+pEsc(fmtTime(it.last_at))+'</span></div>'
|
||
+ '<div class="chat-bottom"><span class="chat-prev">'+inner+'</span>'+(it.unread>0?'<span class="badge">'+(it.unread>99?'99+':it.unread)+'</span>':'')+'</div></div></div>';
|
||
}
|
||
function renderChats(filter){
|
||
const q=(filter||'').trim().toLowerCase();
|
||
const rows=ROWS.filter(it=>!q||it.name.toLowerCase().includes(q)||(it.last_body||'').toLowerCase().includes(q))
|
||
.sort((a,b)=>(b.last_at-a.last_at)||a.name.localeCompare(b.name));
|
||
let html;
|
||
if(!q){ // "You" pinned at top, then favourites, then the rest
|
||
const self=rows.find(r=>r.self), others=rows.filter(r=>!r.self);
|
||
const favs=others.filter(r=>r.favorite), rest=others.filter(r=>!r.favorite);
|
||
// "You" (note to self) sits in its own pinned slot, set apart from the conversation list.
|
||
let h = self ? (rowHTML(self)+(others.length?'<div class="side-div"></div>':'')) : '';
|
||
if(favs.length){ h+='<div class="side-sec">'+ic('star',12)+' Favourites</div>'+favs.map(rowHTML).join(''); if(rest.length) h+='<div class="side-sec">All chats</div>'; }
|
||
h+=rest.map(rowHTML).join('');
|
||
html = (self||others.length) ? h : '<div class="no-results">No conversations yet.</div>';
|
||
} else {
|
||
html = rows.length ? rows.map(rowHTML).join('') : '<div class="no-results">No chats match “'+pEsc(filter)+'”.</div>';
|
||
}
|
||
if(q.length>=1){
|
||
// Team contacts you haven't messaged yet → start a new DM.
|
||
const haveDm=new Set(ROWS.filter(r=>r.kind==='dm').map(r=>r.id));
|
||
const cmatch=CONTACTS.filter(c=>!haveDm.has(c.id) && ((c.name||'').toLowerCase().includes(q)||(c.email||'').toLowerCase().includes(q)));
|
||
if(cmatch.length) html+='<div class="side-sec">Start a chat</div>'+cmatch.map(c=>'<div class="contact-row" data-id="'+pEsc(c.id)+'"><span class="mini-av" style="background:'+avColor(c.name)+'">'+pEsc(initials(c.name))+'</span><span class="cr-name">'+pEsc(c.name)+'</span></div>').join('');
|
||
html+='<div id="dirResults"></div>'; // BizGaze directory (filled async)
|
||
}
|
||
listEl.innerHTML=html;
|
||
listEl.querySelectorAll('.chat-row').forEach(row=>{ row.onclick=()=>selectChat(row.dataset.kind, row.dataset.id); });
|
||
listEl.querySelectorAll('.contact-row').forEach(row=>{ row.onclick=()=>selectChat('dm', row.dataset.id); });
|
||
if(q.length>=2) queryDirectory(filter.trim());
|
||
}
|
||
let _dirT=null, _dirSeq=0;
|
||
// Search the wider BizGaze directory (cross-tenant) — proxied server-side. Debounced.
|
||
function queryDirectory(q){
|
||
clearTimeout(_dirT); const seq=++_dirSeq;
|
||
_dirT=setTimeout(async()=>{
|
||
let list=[]; try{ list=await fetch('/api/directory/search?q='+encodeURIComponent(q)).then(r=>r.json()); }catch(_){ }
|
||
if(seq!==_dirSeq) return; // a newer search superseded this one
|
||
const box=document.getElementById('dirResults'); if(!box) return;
|
||
const haveEmail=new Set(CONTACTS.map(c=>(c.email||'').toLowerCase()).filter(Boolean));
|
||
const all=Array.isArray(list)?list:[];
|
||
// Only people actually ON Connect can be messaged — show just those (deduped against team contacts).
|
||
// Everyone else (on BizGaze but not signed into Connect) is NOT listed as addable; instead we show a
|
||
// clear "not on Connect" / "not found" state so the user isn't confused by un-chattable rows.
|
||
const onc=all.filter(p=>p.onConnect && p.connectId && !(p.email&&haveEmail.has(p.email.toLowerCase())));
|
||
const localHit=box.parentElement && box.parentElement.querySelector('.chat-row, .contact-row');
|
||
if(onc.length){
|
||
box.innerHTML='<div class="side-sec">On Connect</div>'+onc.map((p,i)=>{ const sub=[p.org,p.phone].filter(Boolean).join(' · ');
|
||
return '<div class="dir-row" data-i="'+i+'"><span class="mini-av" style="background:'+avColor(p.name||'?')+'">'+pEsc(initials(p.name||'?'))+'</span><span class="dr-main"><span class="cr-name">'+pEsc(p.name||p.email||'Unknown')+'</span>'+(sub?'<span class="dr-sub">'+pEsc(sub)+'</span>':'')+'</span></div>'; }).join('');
|
||
box.querySelectorAll('.dir-row').forEach(row=>{ const p=onc[+row.dataset.i]; row.onclick=()=>selectChat('dm', p.connectId); });
|
||
} else if(!localHit){
|
||
// Nothing on Connect matched. Distinguish "exists on BizGaze but not on Connect" from "not found".
|
||
box.innerHTML = all.length
|
||
? '<div class="dir-empty">'+ic('info',16)+'<div><b>Not on Connect</b><span>Matching people are on BizGaze but haven’t joined Connect yet, so they can’t be messaged.</span></div></div>'
|
||
: '<div class="dir-empty">'+ic('search',16)+'<div><b>Contact not found</b><span>No Connect user matches “'+pEsc(q)+'”.</span></div></div>';
|
||
} else box.innerHTML='';
|
||
}, 280);
|
||
}
|
||
function updateRailUnread(){
|
||
let chats=0; ROWS.forEach(it=>{ if((it.unread||0)>0) chats++; }); // number of chats with unread, not total messages
|
||
const d=document.getElementById('railUnread');
|
||
if(chats>0){ d.textContent=chats>99?'99+':chats; d.style.display='grid'; } else d.style.display='none';
|
||
setDesktopBadge(chats); // mirror the count onto the desktop taskbar icon
|
||
}
|
||
// Desktop taskbar icon badge: draw a small red count badge and hand it to the Electron shell
|
||
// (which sets it as a taskbar overlay). No-op in a browser or on mobile.
|
||
function setDesktopBadge(count){
|
||
try{
|
||
if(!(window.bizConnectNative && window.bizConnectNative.setUnread)) return;
|
||
let dataUrl=null;
|
||
if(count>0){
|
||
// Teams-style overlay: a solid red rounded-square (squircle) with a bold white count and a thin
|
||
// white outline so it separates cleanly from the app icon on the taskbar. Drawn at 2× for crispness.
|
||
const s=64, c=document.createElement('canvas'); c.width=s; c.height=s; const g=c.getContext('2d');
|
||
const label=count>99?'99+':String(count);
|
||
const pad=3, rad=17, x=pad, y=pad, w=s-pad*2, h=s-pad*2;
|
||
const rrect=(rx,ry,rw,rh,r)=>{ g.beginPath(); g.moveTo(rx+r,ry); g.arcTo(rx+rw,ry,rx+rw,ry+rh,r); g.arcTo(rx+rw,ry+rh,rx,ry+rh,r); g.arcTo(rx,ry+rh,rx,ry,r); g.arcTo(rx,ry,rx+rw,ry,r); g.closePath(); };
|
||
g.save(); g.shadowColor='rgba(0,0,0,.28)'; g.shadowBlur=4; g.shadowOffsetY=1.5;
|
||
g.fillStyle='#e6394f'; rrect(x,y,w,h,rad); g.fill(); g.restore(); // solid notification red
|
||
g.lineWidth=3; g.strokeStyle='#fff'; rrect(x,y,w,h,rad); g.stroke(); // crisp white outline
|
||
g.fillStyle='#fff'; g.textAlign='center'; g.textBaseline='middle';
|
||
g.font='800 '+(label.length>2?26:(label.length>1?33:39))+'px system-ui,Segoe UI,sans-serif';
|
||
g.fillText(label, s/2, s/2+2);
|
||
dataUrl=c.toDataURL('image/png');
|
||
}
|
||
window.bizConnectNative.setUnread(count, dataUrl);
|
||
}catch(_){}
|
||
}
|
||
async function loadSidebar(){
|
||
let convos=[], contacts=[];
|
||
try{ [convos, contacts]=await Promise.all([ fetch('/api/messages/conversations').then(r=>r.json()), fetch('/api/messages/contacts').then(r=>r.json()) ]); }catch(_){}
|
||
CONTACTS=(Array.isArray(contacts)?contacts:[]).filter(c=>c.id!==(ME&&ME.id)); // self isn't a normal contact (#4)
|
||
const items=Array.isArray(convos)?convos.slice():[];
|
||
const dmIds=new Set(items.filter(i=>i.kind==='dm').map(i=>i.id));
|
||
// A contact you haven't messaged YET still needs their profile photo/status: this row was being built
|
||
// with only name+online, silently dropping `avatar` — so the same person showed their DP in a group
|
||
// (which reads /api/groups/members) but fell back to initials in the 1:1. Carry the whole contact.
|
||
for(const c of CONTACTS){ if(!dmIds.has(c.id)) items.push({ kind:'dm', id:c.id, name:c.name, email:c.email||'', avatar:c.avatar||null, online:!!c.online, status:c.status||'active', lastSeen:c.lastSeen||null, last_body:'', last_at:0, last_from_me:false, unread:0 }); }
|
||
const onlineById={}; CONTACTS.forEach(c=>onlineById[c.id]=!!c.online);
|
||
items.forEach(it=>{ if(it.kind==='dm') it.online=!!onlineById[it.id]; });
|
||
// Always-available "You" chat (note to self), pinned to the top (#4).
|
||
const selfRow=items.find(i=>i.kind==='dm' && i.id===(ME&&ME.id));
|
||
if(selfRow){ selfRow.self=true; selfRow.name='You'; selfRow.avatar=ME.avatarUrl||selfRow.avatar||null; }
|
||
else if(ME&&ME.id){ items.push({ kind:'dm', id:ME.id, name:'You', self:true, avatar:ME.avatarUrl||null, online:false, last_body:'', last_at:0, last_from_me:false, unread:0 }); }
|
||
ROWS=items;
|
||
renderChats(searchVal());
|
||
updateRailUnread();
|
||
prewarmNotifAvatars();
|
||
}
|
||
// Desktop only: hand the shell every contact/group DP URL so it caches them up front — the first
|
||
// notification (chat OR call) from anyone then shows their photo instantly, with no per-toast wait.
|
||
let _avatarsWarmed=false;
|
||
function prewarmNotifAvatars(){
|
||
try{
|
||
const n=window.bizConnectNative; if(!n||!n.precacheAvatars||_avatarsWarmed) return;
|
||
const urls=[...new Set((ROWS||[]).map(r=>r.avatar).filter(u=>u && /^https?:\/\//i.test(u)))];
|
||
if(urls.length){ _avatarsWarmed=true; n.precacheAvatars(urls); }
|
||
}catch(_){}
|
||
}
|
||
|
||
// ----- conversation view -----
|
||
function welcomeHTML(){
|
||
return '<div class="welcome">'
|
||
+ '<div class="wave">👋</div>'
|
||
+ '<h1>Hi, '+pEsc(firstName(ME.name||ME.email))+'! Welcome to Biz Connect</h1>'
|
||
+ '<p>Pick a conversation on the left to start chatting, or jump straight into a session from the sidebar.</p>'
|
||
+ '<div class="wcards">'
|
||
+ '<div class="wcard" data-go="share"><div class="wi"><svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg></div><h3>Share Screen</h3><p>Show your screen with a 6-digit code</p></div>'
|
||
+ '<div class="wcard" data-go="connect"><div class="wi"><svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M1.42 9a16 16 0 0 1 21.16 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><line x1="12" y1="20" x2="12.01" y2="20"/></svg></div><h3>Connect Screen</h3><p>Enter a customer\'s code to help</p></div>'
|
||
+ '<div class="wcard" data-go="meeting"><div class="wi"><svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="23 7 16 12 23 17 23 7"/><rect x="1" y="5" width="15" height="14" rx="2" ry="2"/></svg></div><h3>Meeting</h3><p>Multi-party video — coming soon</p></div>'
|
||
+ '</div></div>';
|
||
}
|
||
function wireWelcome(){ document.querySelectorAll('#chatPanel .wcard').forEach(card=>{ card.onclick=()=>switchTab(card.dataset.go); }); }
|
||
// A DM's subtitle tracks the LIVE call state (pushed on call start AND end) so it can't get stuck
|
||
// on "In a call" after the call ends — unlike it.status, which is only derived at conversation load.
|
||
function dmSubLabel(it){ return (it&&it.callActive)?'In a call':statusLabel(it); }
|
||
// The conversation header's default subtitle (used to restore it after a typing indicator clears).
|
||
function subtitleFor(it){ if(!it) return ''; return it.self?'Message yourself':(it.kind==='group'?((it.members||0)+' members'):dmSubLabel(it)); }
|
||
function convoShellHTML(it){
|
||
const isG=it.kind==='group';
|
||
const sub=subtitleFor(it);
|
||
return '<div class="convo">'
|
||
+ '<div class="convo-head">'
|
||
+ '<button class="convo-back" id="convoBack" title="Back (Esc)" aria-label="Back">'+ic('arrowLeft',18)+'</button>'
|
||
+ avatarHTML(it,true)
|
||
+ '<div class="convo-titlewrap gi-open" id="convoTitle" title="Shared media & files"><div class="nm">'+pEsc(it.name)+'</div><div class="st">'+pEsc(sub)+'</div></div>'
|
||
+ '<button class="convo-info" id="convoSearch" title="Search messages">'+ic('search',18)+'</button>'
|
||
+ '<button class="convo-call'+(it.callActive?' joinable':'')+'" id="convoCall" title="'+(it.callActive?'Join call':'Start call')+'">'+ic(it.callActive?'video':'phone',18)+(it.callActive?'<span>Join</span>':'')+'</button>'
|
||
+ (isG?'<button class="convo-info" id="convoInfo" title="Group info">'+ic('info',18)+'</button>':'')
|
||
+ '<div class="convo-search-head" id="convoSearchHead" style="display:none">'
|
||
+ '<button class="convo-back" id="convoSearchClose" title="Close search">'+ic('arrowLeft',18)+'</button>'
|
||
+ '<input id="convoSearchInput" placeholder="Search this chat…" autocomplete="off">'
|
||
+ '<span class="csh-count" id="convoSearchCount"></span>'
|
||
+ '<button class="csh-nav" id="convoSearchPrev" title="Older match">'+ic('chevronUp',18)+'</button>'
|
||
+ '<button class="csh-nav" id="convoSearchNext" title="Newer match">'+ic('chevronDown',18)+'</button>'
|
||
+ '</div>'
|
||
+ '</div>'
|
||
+ '<div class="convo-msgs" id="msgs"></div>'
|
||
+ '<div class="float-date" id="floatDate" style="display:none"></div>'
|
||
+ '<button class="jump-latest" id="jumpLatest" title="Jump to latest" style="display:none">'+ic('chevronDown',20)+'</button>'
|
||
+ '<div class="reply-bar" id="replyBar" style="display:none"></div>'
|
||
+ '<div class="typing-row" id="typingRow"></div>'
|
||
+ '<form class="composer" id="composer">'
|
||
+ '<div class="composer-box">'
|
||
+ '<div class="attach-preview" id="attachBar" style="display:none"></div>'
|
||
+ '<div class="fmt-bar" id="fmtBar" style="display:none">'
|
||
+ '<button type="button" data-fmt="bold" title="Bold (**)">'+ic('bold',16)+'</button>'
|
||
+ '<button type="button" data-fmt="italic" title="Italic (*)">'+ic('italic',16)+'</button>'
|
||
+ '<button type="button" data-fmt="strike" title="Strikethrough (~~)">'+ic('strikethrough',16)+'</button>'
|
||
+ '<button type="button" data-fmt="code" title="Code (`)">'+ic('code',16)+'</button>'
|
||
+ '<span class="fmt-sep"></span>'
|
||
+ '<button type="button" data-fmt="ul" title="Bulleted list">'+ic('list',16)+'</button>'
|
||
+ '<button type="button" data-fmt="ol" title="Numbered list">'+ic('listOrdered',16)+'</button>'
|
||
+ '</div>'
|
||
+ '<div class="composer-row">'
|
||
+ '<button type="button" class="ic-btn" id="attachBtn" title="Attach a file">'+ic('paperclip',20)+'</button>'
|
||
+ '<button type="button" class="ic-btn" id="fmtBtn" title="Formatting">'+ic('type',20)+'</button>'
|
||
+ '<textarea id="msgInput" placeholder="Type a message…" autocomplete="off" spellcheck="true" autocapitalize="sentences" maxlength="4000" rows="1"></textarea>'
|
||
+ (isG?'<button type="button" class="ic-btn" id="pollBtn" title="Create a poll">'+ic('barChart',20)+'</button>':'')
|
||
+ '<button type="button" class="ic-btn" id="emojiBtn" title="Emoji">'+ic('smile',20)+'</button>'
|
||
+ '<button type="submit" class="sendbtn" title="Send" aria-label="Send">'+ic('send',18)+'</button>'
|
||
+ '</div>'
|
||
+ '</div>'
|
||
+ '<input type="file" id="fileInput" multiple style="display:none">'
|
||
+ '<input type="file" id="fileInputImg" accept="image/*" multiple style="display:none">'
|
||
+ '<input type="file" id="fileInputCam" accept="image/*" capture="environment" style="display:none">'
|
||
+ '</form>'
|
||
+ '<div class="emoji-pop" id="emojiPop" style="display:none"></div>'
|
||
+ '<div class="mention-pop" id="mentionPop" style="display:none"></div>'
|
||
+ '<div class="attach-pop" id="attachPop" style="display:none">'
|
||
+ '<button type="button" class="attach-opt" data-src="photo">'+ic('image',18)+'<span>Photos</span></button>'
|
||
+ '<button type="button" class="attach-opt" data-src="camera">'+ic('camera',18)+'<span>Camera</span></button>'
|
||
+ '<button type="button" class="attach-opt" data-src="file">'+ic('paperclip',18)+'<span>Document</span></button>'
|
||
+ '</div>'
|
||
+ '</div>';
|
||
}
|
||
// Small round avatar (photo if the member has one, else colored initials) for a group message sender.
|
||
function senderAvatar(id, name){ const mem=convoMembers.find(x=>x.id===id); const av=mem&&mem.avatar; return '<span class="snd-av" style="background:'+avColor(name||'?')+'">'+(av?'<img src="'+pEsc(av)+'" alt="" onerror="this.remove()">':'')+pEsc(initials(name||'?'))+'</span>'; }
|
||
function bubbleHTML(m){
|
||
if(m.evt==='call-start') return '<div class="sys-msg">📞 '+(m.from===ME.id?'You':pEsc(m.byName||'Someone'))+' started a call</div>';
|
||
// Unanswered 1:1: the caller (message sender) sees "Call not answered"; the callee sees "Missed call".
|
||
if(m.evt==='call-end' && /Missed call|not answered/i.test(m.body||'')) return '<div class="sys-msg">📞 '+(m.from===ME.id?'Call not answered':'Missed call')+'</div>';
|
||
if(m.system||m.from==='__system__') return '<div class="sys-msg">'+pEsc(m.body)+'</div>';
|
||
const mine=m.from===ME.id;
|
||
if(m.deleted) return '<div class="bubble '+(mine?'mine':'them')+' deleted" data-id="'+pEsc(m.id)+'"><span class="del-msg">'+ic('trash',12)+' This message was deleted</span><span class="t">'+pEsc(fmtClock(m.created_at))+'</span></div>';
|
||
// #1: the sender chip is clickable → mini profile card (with a Message button that opens their DM).
|
||
const sender=(convoIsGroup && !mine && m.fromName)?'<div class="sender profile-open" data-uid="'+pEsc(m.from)+'" title="View profile">'+senderAvatar(m.from, m.fromName)+'<span>'+pEsc(m.fromName)+'</span></div>':'';
|
||
let quote='';
|
||
if(m.reply){ const t=replyTint(m.reply.from||m.reply.fromName); const rb=isGifUrl(m.reply.body)?'🎞️ GIF':m.reply.body; quote='<div class="quote" data-jid="'+pEsc(m.reply.id||'')+'" data-jat="'+(m.reply.at||0)+'" title="Go to message" style="background:'+t[0]+';border-left-color:'+t[1]+'"><b style="color:'+t[1]+'">'+pEsc(m.reply.fromName||'')+'</b>: '+pEsc(rb)+'</div>'; }
|
||
const reacts=(m.reactions&&m.reactions.length)?'<div class="reacts">'+m.reactions.map(r=>'<button class="react-chip'+(r.mine?' mine':'')+'" data-id="'+pEsc(m.id)+'" data-emoji="'+pEsc(r.emoji)+'" title="'+pEsc((r.who||[]).join(', '))+'">'+pEsc(r.emoji)+' '+r.count+'</button>').join('')+'</div>':'';
|
||
const A = m.attachment;
|
||
const att = A ? (
|
||
A.isImage ? '<img class="att-img" src="/files/'+pEsc(A.id)+'" data-img="/files/'+pEsc(A.id)+'" alt="'+pEsc(A.name)+'" title="Click to view">'
|
||
: A.isVideo ? (function(){
|
||
// A video has no download button of its own the way an image or file link does, so once it is
|
||
// saved it would keep re-streaming on every play. If we have a local copy, play THAT — no network
|
||
// at all — and swap the download control for a saved tick.
|
||
// Web/PWA: nothing to download to, so stream with the native controls as before.
|
||
if(!bzLibOn()) return '<div class="att-vid" data-aid="'+pEsc(A.id)+'"><video class="att-vid-v" src="/stream/'+pEsc(A.id)+'#t=0.1" poster="/thumbs/'+pEsc(A.id)+'" controls playsinline preload="none" title="'+pEsc(A.name)+'"></video><span class="att-vid-spin" aria-hidden="true"></span></div>';
|
||
// Native app: ONE centred control over the masked poster — download, and once saved it becomes play.
|
||
// Nothing is fetched until it is tapped (no src at all until downloaded), and the native control bar
|
||
// only appears once playback starts, so there is nothing for the control to collide with.
|
||
const rec=bzLibGet(A.id), loc=rec?bzLibSrc(rec):null;
|
||
return '<div class="att-vid'+(loc?' saved':'')+'" data-aid="'+pEsc(A.id)+'" data-name="'+pEsc(A.name)+'" data-mime="'+pEsc(A.mime||'video/mp4')+'">'
|
||
+'<video class="att-vid-v"'+(loc?(' src="'+pEsc(loc)+'"'):'')+' poster="/thumbs/'+pEsc(A.id)+'" playsinline preload="none" title="'+pEsc(A.name)+'"></video>'
|
||
+'<span class="att-vid-spin" aria-hidden="true"></span>'
|
||
+'<button class="att-vid-ov" title="'+(loc?'Play':'Download to this device')+'"><span class="att-vid-btn">'+ic(loc?'play':'download',26)+'</span></button>'
|
||
+'</div>';
|
||
})()
|
||
: (function(){
|
||
// Web/PWA: a normal download link — the browser saves + opens it.
|
||
if(!bzLibOn()) return '<a class="att-file" href="/files/'+pEsc(A.id)+'" download="'+pEsc(A.name)+'">'+ic('file',15)+' <span>'+pEsc(A.name)+'</span> <span class="att-sz">'+fmtSize(A.size)+'</span></a>';
|
||
// Native app: like a video — a download control that becomes "open" once the file is on the device.
|
||
// Tap to download (icon shows %), then tap again to open it. State is driven by the local library.
|
||
const saved=!!bzLibGet(A.id);
|
||
return '<div class="att-file att-file-native'+(saved?' saved':'')+'" data-aid="'+pEsc(A.id)+'" data-name="'+pEsc(A.name)+'" data-mime="'+pEsc(A.mime||'')+'" title="'+(saved?'Open':'Download to this device')+'">'
|
||
+ic('file',15)+' <span class="att-file-name">'+pEsc(A.name)+'</span> <span class="att-sz">'+fmtSize(A.size)+'</span>'
|
||
+'<span class="att-file-act">'+ic(saved?'externalLink':'download',16)+'</span>'
|
||
+'</div>';
|
||
})()) : '';
|
||
const mentionsMe=convoIsGroup && !mine && Array.isArray(m.mentions) && (m.mentions.includes(ME.id)||m.mentions.includes('everyone'));
|
||
// DM ticks: sent (1 grey) → delivered (2 grey) → read (2 yellow).
|
||
let rcpt='';
|
||
if(mine && !convoIsGroup){ const st=m.read_at?'seen':(m.delivered_at?'delivered':'sent'); rcpt='<span class="rcpt '+st+'" title="'+(st==='seen'?'Seen':st==='delivered'?'Delivered':'Sent')+'">'+ic(st==='sent'?'check':'checkCheck',13)+'</span>'; }
|
||
// Group ticks: a per-message receipt on MY messages — sent (1 grey) → seen-by-some (2 grey) →
|
||
// seen-by-all (2 yellow). Tap to see exactly who ("Seen by X, Y · N of M"). seenBy = other
|
||
// members who've read this message (computed server-side, kept live by onGroupRead).
|
||
else if(mine && convoIsGroup){
|
||
const others=Math.max(0,(convoMembers?convoMembers.length:0)-1);
|
||
const ns=Array.isArray(m.seenBy)?m.seenBy:[]; const rc=ns.length;
|
||
const all=others>0 && rc>=others;
|
||
const ttl=rc?('Seen by '+ns.slice(0,6).join(', ')+(ns.length>6?(' +'+(ns.length-6)):'')+(others>0?(' · '+rc+' of '+others):'')):'Sent';
|
||
rcpt='<span class="rcpt grp '+(all?'seen':'')+'" data-seen="'+pEsc(ns.join('|'))+'" title="'+pEsc(ttl)+'">'+ic(rc>0?'checkCheck':'check',13)+'</span>';
|
||
}
|
||
// #2: under the group's LAST message, show "Seen by X, Y +N more" — visible to everyone, click for names.
|
||
let seen='';
|
||
if(convoIsGroup && m.id===_lastGroupId && Array.isArray(m.seenBy) && m.seenBy.length){ const ns=m.seenBy, head=ns.slice(0,2).join(', '), more=ns.length>2?(' +'+(ns.length-2)+' more'):''; seen='<button class="seenby" data-seen="'+pEsc(ns.join('|'))+'">'+ic('checkCheck',12)+' Seen by '+pEsc(head)+more+'</button>'; }
|
||
return '<div class="bubble '+(mine?'mine':'them')+(mentionsMe?' mention-me':'')+(m.poll?' has-poll':'')+'" data-id="'+pEsc(m.id)+'">'
|
||
+ sender + (m.fwd_from?'<div class="fwd-label">'+ic('arrowRight',11)+' Forwarded'+(m.fwd_from?(' from <b>'+pEsc(m.fwd_from)+'</b>'):'')+'</div>':'') + quote + att + renderMsgBody(m) + pollHTML(m)
|
||
// #4: hover row = three one-tap reactions + the picker + (own) edit; everything else lives behind ⋮.
|
||
+ (m.deleted?'':'<div class="msg-actions">'
|
||
+ QUICK_REACTS.map(q=>'<button class="qr-btn" data-id="'+pEsc(m.id)+'" data-emoji="'+q.e+'" title="'+q.t+'">'+q.e+'</button>').join('')
|
||
+ '<button class="react-btn" data-id="'+pEsc(m.id)+'" title="More reactions">'+ic('smilePlus',14)+'</button>'
|
||
+ ((mine && m.body && !m.poll)?'<button class="edit-btn" data-edit="'+pEsc(m.id)+'" title="Edit message">'+ic('edit',13)+'</button>':'')
|
||
+ '<button class="more-btn" data-more="'+pEsc(m.id)+'" title="More">'+ic('moreVertical',14)+'</button>'
|
||
+ '</div>')
|
||
+ '<span class="t">'+pEsc(fmtClock(m.created_at))+(m.edited_at?'<span class="edited"> · edited</span>':'')+rcpt+'</span>'
|
||
+ reacts + seen + '</div>';
|
||
}
|
||
// #1: mini profile card for a person in a group — photo, name, presence/last-seen, and a Message button
|
||
// that drops straight into the 1:1 with them.
|
||
function openMiniProfile(uid, anchor){
|
||
document.querySelectorAll('.mini-profile').forEach(x=>x.remove());
|
||
if(!uid || uid===(ME&&ME.id)) return;
|
||
const row=rowFor('dm',uid) || (CONTACTS||[]).find(c=>c.id===uid) || (convoMembers||[]).find(m=>m.id===uid) || {};
|
||
const name=row.name || 'Someone';
|
||
const card=document.createElement('div'); card.className='mini-profile';
|
||
card.innerHTML='<div class="mp-top">'
|
||
+'<span class="mp-av" style="background:'+avColor(name)+'">'+(row.avatar?('<img src="'+pEsc(row.avatar)+'" alt="" onerror="this.remove()">'):'')+pEsc(initials(name))+'</span>'
|
||
+'<div class="mp-id"><b>'+pEsc(name)+'</b><span>'+pEsc(statusLabel(row))+'</span></div>'
|
||
+'</div>'
|
||
+'<div class="mp-acts">'
|
||
+'<button class="mp-msg" id="mpMsg">'+ic('chat',15)+' Message</button>'
|
||
+(row.avatar?'<button class="mp-dp" id="mpDp" title="View photo">'+ic('image',15)+'</button>':'')
|
||
+'</div>';
|
||
document.body.appendChild(card);
|
||
const r=anchor.getBoundingClientRect();
|
||
card.style.left=Math.max(8, Math.min(r.left, window.innerWidth-card.offsetWidth-8))+'px';
|
||
const below=r.bottom+8, above=r.top-card.offsetHeight-8;
|
||
card.style.top=((below+card.offsetHeight<window.innerHeight-8)?below:Math.max(8,above))+'px';
|
||
card.querySelector('#mpMsg').onclick=()=>{ card.remove(); switchTab('chat'); selectChat('dm', uid); };
|
||
const dp=card.querySelector('#mpDp'); if(dp) dp.onclick=()=>{ card.remove(); openLightbox(row.avatar); };
|
||
const close=(e)=>{ if(!card.contains(e.target)){ card.remove(); document.removeEventListener('mousedown',close); } };
|
||
setTimeout(()=>document.addEventListener('mousedown',close),0);
|
||
}
|
||
// #4: the three one-tap reactions shown on hover (Like / Laugh / Surprised).
|
||
const QUICK_REACTS=[{e:'👍',t:'Like'},{e:'😂',t:'Laugh'},{e:'😮',t:'Surprised'}];
|
||
// Everything that isn't a reaction or an edit now lives behind the ⋮ menu, so hovering a message no
|
||
// longer buries it under a wall of icons.
|
||
function openMsgMore(msgId, anchor){
|
||
document.querySelectorAll('.msg-more-menu').forEach(x=>x.remove());
|
||
if(anchor && anchor._open){ anchor._open=false; return; } // clicking ⋮ again closes it
|
||
const m=THREAD.find(x=>x.id===msgId); if(!m) return;
|
||
const mine=m.from===ME.id;
|
||
const items=[];
|
||
items.push({ic:'reply', label:'Reply', fn:()=>setReply(m)});
|
||
if((m.body||m.attachment) && !m.poll) items.push({ic:'arrowRight', label:'Forward', fn:()=>enterSelect(m.id)});
|
||
if(m.body) items.push({ic:'copy', label:'Copy', fn:()=>copyMessageText(m)});
|
||
if(mine) items.push({ic:'trash', label:'Delete', danger:true, fn:()=>deleteMessage(m.id)});
|
||
const menu=document.createElement('div'); menu.className='spk-menu msg-more-menu';
|
||
menu.innerHTML=items.map((it,i)=>'<button class="spk-opt mm-opt'+(it.danger?' danger':'')+'" data-i="'+i+'">'+ic(it.ic,15)+'<span>'+pEsc(it.label)+'</span></button>').join('');
|
||
document.body.appendChild(menu);
|
||
const r=anchor.getBoundingClientRect();
|
||
menu.style.left=Math.max(8,Math.min(r.right-menu.offsetWidth, window.innerWidth-menu.offsetWidth-8))+'px';
|
||
const below=r.bottom+6, above=r.top-menu.offsetHeight-6;
|
||
menu.style.top=((below+menu.offsetHeight<window.innerHeight-8)?below:Math.max(8,above))+'px';
|
||
menu.querySelectorAll('.mm-opt').forEach(b=>b.onclick=()=>{ const it=items[+b.dataset.i]; menu.remove(); try{ it.fn(); }catch(_){} });
|
||
const close=(e)=>{ if(!menu.contains(e.target)){ menu.remove(); document.removeEventListener('mousedown',close); } };
|
||
setTimeout(()=>document.addEventListener('mousedown',close),0);
|
||
}
|
||
// Copy a message's text to the clipboard (#4).
|
||
function copyMessageText(m){
|
||
const txt=String(m&&m.body||''); if(!txt){ toast('Nothing to copy'); return; }
|
||
const done=()=>toast('Message copied');
|
||
try{ if(navigator.clipboard&&navigator.clipboard.writeText) return void navigator.clipboard.writeText(txt).then(done).catch(()=>fallbackCopy(txt,done)); }catch(_){}
|
||
fallbackCopy(txt,done);
|
||
}
|
||
// reply + emoji state/helpers
|
||
let replyTarget=null;
|
||
// Categorized emoji set (covers the common ones used across Slack/Teams/WhatsApp).
|
||
const EMOJI_CATS=[
|
||
{ icon:'😀', list:'😀 😃 😄 😁 😆 😅 🤣 😂 🙂 🙃 🫠 😉 😊 😇 🥰 😍 🤩 😘 😗 😚 😙 🥲 😋 😛 😜 🤪 😝 🤑 🤗 🤭 🫢 🫣 🤫 🤔 🫡 🤐 🤨 😐 😑 😶 🫥 😏 😒 🙄 😬 🤥 😌 😔 😪 🤤 😴 😷 🤒 🤕 🤢 🤮 🤧 🥵 🥶 🥴 😵 🤯 🤠 🥳 🥸 😎 🤓 🧐 😕 🫤 😟 🙁 ☹️ 😮 😯 😲 😳 🥺 🥹 😦 😧 😨 😰 😥 😢 😭 😱 😖 😣 😞 😓 😩 😫 🥱 😤 😡 😠 🤬 😈 👿 💀 ☠️ 💩 🤡 👹 👺 👻 👽 👾 🤖 😺 😸 😹 😻 😼 😽 🙀 😿 😾' },
|
||
{ icon:'👍', list:'👋 🤚 🖐️ ✋ 🖖 🫱 🫲 🫳 🫴 👌 🤌 🤏 ✌️ 🤞 🫰 🤟 🤘 🤙 👈 👉 👆 🖕 👇 ☝️ 🫵 👍 👎 ✊ 👊 🤛 🤜 👏 🙌 🫶 👐 🤲 🤝 🙏 ✍️ 💅 🤳 💪 🦾 🦵 🦶 👣 👀 👁️ 🫦 👄 🫀 🫁 🧠 🦷 🦴 👶 🧒 👦 👧 🧑 👨 👩 🧔 👱 🧓 👴 👵 🙇 💁 🙅 🙆 🙋 🤦 🤷 👮 🕵️ 💂 👷 🤴 👸 👳 🧕 🤵 👰 🤰 🤱 👼 🎅 🤶 🦸 🦹 🧙 🧚 🧛 🧜 🧝 🧞 🧟 💆 💇 🚶 🧍 🧎 🏃 💃 🕺 👯 🧘' },
|
||
{ icon:'❤️', list:'❤️ 🧡 💛 💚 💙 💜 🖤 🤍 🤎 💔 ❣️ 💕 💞 💓 💗 💖 💘 💝 💟 ❤️🔥 ❤️🩹 💋 💯 💢 💥 💫 💦 💨 💬 🗨️ 🗯️ 💭 💤 ♥️ ✨ ⭐ 🌟 ⚡ 🔥 🌈 ☀️ ⛅ ☁️ 🌧️ ⛈️ 🌩️ ❄️ ☃️ ⛄ 💧 🌊' },
|
||
{ icon:'🐻', list:'🐶 🐱 🐭 🐹 🐰 🦊 🐻 🐼 🐻❄️ 🐨 🐯 🦁 🐮 🐷 🐽 🐸 🐵 🙈 🙉 🙊 🐒 🐔 🐧 🐦 🐤 🐣 🐥 🦆 🦅 🦉 🦇 🐺 🐗 🐴 🦄 🐝 🐛 🦋 🐌 🐞 🐜 🪰 🪲 🦗 🕷️ 🦂 🐢 🐍 🦎 🦖 🦕 🐙 🦑 🦐 🦞 🦀 🐡 🐠 🐟 🐬 🐳 🐋 🦈 🐊 🐅 🐆 🦓 🦍 🦧 🐘 🦛 🦏 🐪 🐫 🦒 🦘 🐃 🐄 🐎 🐖 🐏 🐑 🐐 🦌 🐕 🐩 🦮 🐈 🐓 🦃 🦚 🦜 🦢 🕊️ 🐇 🦝 🦨 🦦 🦥 🐁 🐀 🐿️ 🦔 🐾 🐉 🌵 🎄 🌲 🌳 🌴 🌱 🌿 ☘️ 🍀 🍃 🍂 🍁 🍄 🐚 🌾 💐 🌷 🌹 🥀 🌺 🌸 🌼 🌻' },
|
||
{ icon:'🍔', list:'🍏 🍎 🍐 🍊 🍋 🍌 🍉 🍇 🍓 🫐 🍈 🍒 🍑 🥭 🍍 🥥 🥝 🍅 🍆 🥑 🥦 🥬 🥒 🌶️ 🫑 🌽 🥕 🫒 🧄 🧅 🥔 🍠 🥐 🥯 🍞 🥖 🥨 🧀 🥚 🍳 🧈 🥞 🧇 🥓 🥩 🍗 🍖 🌭 🍔 🍟 🍕 🫓 🥪 🥙 🧆 🌮 🌯 🫔 🥗 🥘 🫕 🥫 🍝 🍜 🍲 🍛 🍣 🍱 🥟 🦪 🍤 🍙 🍚 🍘 🍥 🥠 🥮 🍢 🍡 🍧 🍨 🍦 🥧 🧁 🍰 🎂 🍮 🍭 🍬 🍫 🍿 🍩 🍪 🌰 🥜 🍯 🥛 🍼 ☕ 🍵 🧃 🥤 🧋 🍶 🍺 🍻 🥂 🍷 🥃 🍸 🍹 🧉 🍾' },
|
||
{ icon:'⚽', list:'⚽ 🏀 🏈 ⚾ 🥎 🎾 🏐 🏉 🥏 🎱 🪀 🏓 🏸 🏒 🏑 🥍 🏏 🥅 ⛳ 🪁 🏹 🎣 🤿 🥊 🥋 🎽 🛹 🛼 🛷 ⛸️ 🥌 🎿 ⛷️ 🏂 🪂 🏋️ 🤼 🤸 ⛹️ 🤺 🤾 🏌️ 🏇 🧘 🏄 🏊 🤽 🚣 🧗 🚵 🚴 🏆 🥇 🥈 🥉 🏅 🎖️ 🏵️ 🎗️ 🎫 🎟️ 🎪 🤹 🎭 🩰 🎨 🎬 🎤 🎧 🎼 🎹 🥁 🪘 🎷 🎺 🪗 🎸 🪕 🎻 🎲 ♟️ 🎯 🎳 🎮 🎰 🧩' },
|
||
{ icon:'🚗', list:'🚗 🚕 🚙 🚌 🚎 🏎️ 🚓 🚑 🚒 🚐 🛻 🚚 🚛 🚜 🛴 🚲 🛵 🏍️ 🛺 🚨 🚔 🚍 🚘 🚖 🚡 🚠 🚟 🚃 🚋 🚞 🚝 🚄 🚅 🚈 🚂 🚆 🚇 🚊 🚉 ✈️ 🛫 🛬 🛩️ 💺 🛰️ 🚀 🛸 🚁 🛶 ⛵ 🚤 🛥️ 🛳️ ⛴️ 🚢 ⚓ ⛽ 🚧 🚦 🚥 🗺️ 🗿 🗽 🗼 🏰 🏯 🏟️ 🎡 🎢 🎠 ⛲ ⛱️ 🏖️ 🏝️ 🏜️ 🌋 ⛰️ 🏔️ 🗻 🏕️ ⛺ 🏠 🏡 🏘️ 🏗️ 🏭 🏢 🏬 🏣 🏥 🏦 🏨 🏪 🏫 ⛪ 🕌 🛕 🕋 🌁 🌃 🏙️ 🌅 🌆 🌉 🌌 🎇 🎆 🌠' },
|
||
{ icon:'💡', list:'⌚ 📱 📲 💻 ⌨️ 🖥️ 🖨️ 🖱️ 🕹️ 💽 💾 💿 📀 📼 📷 📸 📹 🎥 📽️ 🎞️ 📞 ☎️ 📟 📠 📺 📻 🎙️ 🎚️ 🎛️ 🧭 ⏱️ ⏲️ ⏰ 🕰️ ⌛ ⏳ 📡 🔋 🔌 💡 🔦 🕯️ 🪔 🧯 💸 💵 💴 💶 💷 🪙 💰 💳 💎 ⚖️ 🪜 🧰 🪛 🔧 🔨 ⚒️ 🛠️ ⛏️ 🔩 ⚙️ 🧱 ⛓️ 🧲 🔫 💣 🧨 🪓 🔪 🗡️ ⚔️ 🛡️ 🚬 ⚰️ ⚱️ 🏺 🔮 📿 🧿 💈 ⚗️ 🔭 🔬 💊 💉 🩸 🧬 🦠 🧫 🧪 🌡️ 🧹 🧺 🧻 🚽 🚿 🛁 🧼 🪥 🧽 🪣 🧴 🛎️ 🔑 🗝️ 🚪 🪑 🛋️ 🛏️ 🧸 🖼️ 🛍️ 🛒 🎁 🎈 🎏 🎀 🪄 🎊 🎉 🏮 🎐 🧧 ✉️ 📩 📨 📧 💌 📦 🏷️ 📜 📄 📊 📈 📉 📅 📋 📁 📂 🗂️ 📰 📓 📔 📕 📗 📘 📙 📚 📖 🔖 🔗 📎 📐 📏 🧮 📌 📍 ✂️ 🖊️ 🖋️ ✒️ 🖌️ 🖍️ 📝 ✏️ 🔍 🔎 🔒 🔓' },
|
||
{ icon:'✅', list:'✅ ❌ ⭕ 🚫 ❓ ❔ ❗ ❕ ‼️ ⁉️ 💯 🔅 🔆 ⚠️ 🚸 ☢️ ☣️ ⬆️ ↗️ ➡️ ↘️ ⬇️ ↙️ ⬅️ ↖️ ↕️ ↔️ ↩️ ↪️ ⤴️ ⤵️ 🔃 🔄 🔙 🔚 🔛 🔜 🔝 🛐 ⚛️ 🕉️ ✡️ ☸️ ☯️ ✝️ ☦️ ☪️ ☮️ 🕎 🔯 ♈ ♉ ♊ ♋ ♌ ♍ ♎ ♏ ♐ ♑ ♒ ♓ ⛎ 🔀 🔁 🔂 ▶️ ⏩ ⏭️ ◀️ ⏪ ⏮️ 🔼 ⏫ 🔽 ⏬ ⏸️ ⏹️ ⏺️ ⏏️ ♀️ ♂️ ⚧️ ✖️ ➕ ➖ ➗ ♾️ 💲 💱 ™️ ©️ ®️ 〰️ ➰ ➿ ✔️ ☑️ 🔘 🔴 🟠 🟡 🟢 🔵 🟣 🟤 ⚫ ⚪ 🟥 🟧 🟨 🟩 🟦 🟪 🟫 ⬛ ⬜ 🔶 🔷 🔸 🔹 🔺 🔻 💠 🔳 🔲' },
|
||
{ icon:'🏁', list:'🏁 🚩 🎌 🏴 🏳️ 🏳️🌈 🏳️⚧️ 🏴☠️ 🇮🇳 🇺🇸 🇬🇧 🇨🇦 🇦🇺 🇩🇪 🇫🇷 🇮🇹 🇪🇸 🇯🇵 🇰🇷 🇨🇳 🇧🇷 🇷🇺 🇿🇦 🇦🇪 🇸🇬 🇲🇾 🇮🇩 🇵🇭 🇹🇭 🇳🇿 🇮🇪 🇳🇱 🇸🇪 🇨🇭' },
|
||
];
|
||
let emojiCat=0, emojiMode='compose';
|
||
function setReply(m){
|
||
replyTarget=m;
|
||
const bar=document.getElementById('replyBar'); if(!bar) return;
|
||
const who=m.from===ME.id?'yourself':(m.fromName||currentName()||'them');
|
||
const rprev=isGifUrl(m.body)?'🎞️ GIF':(m.body.length>80?m.body.slice(0,80)+'…':m.body);
|
||
bar.innerHTML='<span>'+ic('reply',13)+' Replying to <b>'+pEsc(who)+'</b>: '+pEsc(rprev)+'</span><span class="rx" id="replyCancel">'+ic('x',15)+'</span>';
|
||
bar.style.display='flex';
|
||
document.getElementById('replyCancel').onclick=clearReply;
|
||
const inp=document.getElementById('msgInput'); if(inp) inp.focus();
|
||
}
|
||
function clearReply(){ replyTarget=null; const bar=document.getElementById('replyBar'); if(bar){ bar.style.display='none'; bar.innerHTML=''; } }
|
||
// Edit an existing message (sender only): load its text into the composer, show an "Editing" bar;
|
||
// the next send saves the edit instead of posting a new message.
|
||
let editTarget=null;
|
||
function startEdit(m){
|
||
if(!m) return; clearReply(); editTarget=m;
|
||
const bar=document.getElementById('replyBar'); if(bar){ bar.innerHTML='<span>'+ic('edit',13)+' Editing message</span><span class="rx" id="editCancel">'+ic('x',15)+'</span>'; bar.style.display='flex'; const x=document.getElementById('editCancel'); if(x) x.onclick=cancelEdit; }
|
||
const inp=document.getElementById('msgInput'); if(inp){ inp.value=m.body||''; autoGrow(inp); inp.focus(); }
|
||
}
|
||
function cancelEdit(){ editTarget=null; const bar=document.getElementById('replyBar'); if(bar){ bar.style.display='none'; bar.innerHTML=''; } const inp=document.getElementById('msgInput'); if(inp){ inp.value=''; autoGrow(inp); } }
|
||
async function saveEdit(text){
|
||
const m=editTarget; if(!m) return;
|
||
if(!text){ cancelEdit(); return; } // empty → treat as cancel (delete is a separate action)
|
||
if(text===(m.body||'')){ cancelEdit(); return; } // no change
|
||
editTarget=null; const bar=document.getElementById('replyBar'); if(bar){ bar.style.display='none'; bar.innerHTML=''; }
|
||
const inp=document.getElementById('msgInput'); if(inp){ inp.value=''; autoGrow(inp); }
|
||
try{
|
||
await postJSON('/api/messages/edit',{ id:m.id, body:text });
|
||
const t=THREAD.find(x=>x.id===m.id); if(t){ t.body=text; t.edited_at=Date.now(); updateBubble(t); }
|
||
THREAD_CACHE.forEach(arr=>arr.forEach(x=>{ if(x.id===m.id){ x.body=text; x.edited_at=Date.now(); } }));
|
||
// keep the chat-list preview in sync if this was the last message
|
||
const it=rowFor(selected.kind, selected.id); if(it && it.last_from_me){ const last=THREAD[THREAD.length-1]; if(last && last.id===m.id){ it.last_body=text; renderChats(searchVal()); } }
|
||
}catch(e){ toast(e.message||'Could not edit'); }
|
||
}
|
||
// A message I can see was edited by its sender → update it in place (thread + cache + list preview).
|
||
function onChatEdited(d){
|
||
if(!d||!d.id) return;
|
||
const t=THREAD.find(x=>x.id===d.id); if(t){ t.body=d.body; t.edited_at=d.edited_at||Date.now(); updateBubble(t); }
|
||
THREAD_CACHE.forEach(arr=>arr.forEach(x=>{ if(x.id===d.id){ x.body=d.body; x.edited_at=d.edited_at||Date.now(); } }));
|
||
try{ loadSidebar(); }catch(_){} // refresh last-message previews
|
||
}
|
||
// attachments
|
||
let pendingAttachs=[]; // #5: multiple files can be queued + sent together (each item: {id,name,mime,uploading})
|
||
function fmtSize(b){ b=+b||0; if(b<1024) return b+' B'; if(b<1048576) return Math.round(b/1024)+' KB'; return (b/1048576).toFixed(1)+' MB'; }
|
||
// #8: real upload PROGRESS. fetch() can't report upload progress at all, so a big file just said
|
||
// "uploading…" with no idea how long to wait. XHR exposes upload.onprogress, so we can show a real bar
|
||
// (and a %), making it obvious when it's safe to hit Send.
|
||
function uploadFile(file){
|
||
if(!file) return;
|
||
if(file.size>1024*1024*1024){ toast('“'+file.name+'” is too large (max 1 GB)'); return; }
|
||
const ph={ id:'up-'+Math.random().toString(36).slice(2), name:file.name, mime:file.type||'', uploading:true, pct:0, size:file.size };
|
||
pendingAttachs.push(ph); renderAttachBar();
|
||
const fail=(msg)=>{ const i=pendingAttachs.indexOf(ph); if(i>=0) pendingAttachs.splice(i,1); renderAttachBar(); toast(msg||'Upload failed'); };
|
||
try{
|
||
const xhr=new XMLHttpRequest();
|
||
ph.xhr=xhr; // so the user can cancel an in-flight upload
|
||
xhr.open('POST','/api/messages/upload',true);
|
||
xhr.setRequestHeader('Content-Type', file.type||'application/octet-stream');
|
||
xhr.setRequestHeader('X-Filename', encodeURIComponent(file.name));
|
||
xhr.upload.onprogress=(e)=>{
|
||
if(!e.lengthComputable) return;
|
||
const pct=Math.max(1, Math.min(99, Math.round((e.loaded/e.total)*100)));
|
||
if(pct!==ph.pct){ ph.pct=pct; updateAttachProgress(ph); } // cheap in-place update, no full re-render
|
||
};
|
||
xhr.onload=()=>{
|
||
let d={}; try{ d=JSON.parse(xhr.responseText||'{}'); }catch(_){}
|
||
if(xhr.status<200||xhr.status>=300){ return fail(d.error||'Upload failed'); }
|
||
const i=pendingAttachs.indexOf(ph); const item={ ...d, uploading:false };
|
||
if(i>=0) pendingAttachs[i]=item; else pendingAttachs.push(item);
|
||
renderAttachBar();
|
||
const inp=document.getElementById('msgInput'); if(inp) inp.focus();
|
||
};
|
||
xhr.onerror=()=>fail('Upload failed');
|
||
xhr.onabort=()=>{ const i=pendingAttachs.indexOf(ph); if(i>=0) pendingAttachs.splice(i,1); renderAttachBar(); };
|
||
xhr.send(file);
|
||
}catch(e){ fail(e.message); }
|
||
}
|
||
// Update just the progress bar/label of one pending attachment (called on every progress tick).
|
||
function updateAttachProgress(a){
|
||
const bar=document.getElementById('attachBar'); if(!bar) return;
|
||
const el=bar.querySelector('.ap-item[data-up="'+a.id+'"]'); if(!el) return;
|
||
const fill=el.querySelector('.ap-fill'); if(fill) fill.style.width=(a.pct||0)+'%';
|
||
const pc=el.querySelector('.ap-pct'); if(pc) pc.textContent=(a.pct||0)+'%';
|
||
}
|
||
function renderAttachBar(){
|
||
const bar=document.getElementById('attachBar'); if(!bar) return;
|
||
if(!pendingAttachs.length){ bar.style.display='none'; bar.innerHTML=''; return; }
|
||
bar.innerHTML=pendingAttachs.map((a,idx)=>{
|
||
const isImg=/^image\//.test(a.mime||'');
|
||
const lead=(!a.uploading && isImg)?'<img class="ap-thumb" src="/files/'+pEsc(a.id)+'" alt="">':'<span class="ap-ic">'+ic(a.uploading?'paperclip':(isImg?'camera':'file'),18)+'</span>';
|
||
// #8: while uploading, show a real progress bar + % so it's clear when Send is safe.
|
||
const prog=a.uploading
|
||
? '<span class="ap-prog"><span class="ap-track"><span class="ap-fill" style="width:'+(a.pct||0)+'%"></span></span><span class="ap-pct">'+(a.pct||0)+'%</span></span>'
|
||
: '';
|
||
return '<div class="ap-item'+(a.uploading?' uploading':'')+'" data-up="'+pEsc(a.id)+'">'+lead
|
||
+'<span class="ap-main"><span class="ap-name">'+pEsc(a.name)+'</span>'+prog+'</span>'
|
||
+'<button type="button" class="ap-x" data-ai="'+idx+'" title="'+(a.uploading?'Cancel upload':'Remove')+'">'+ic('x',15)+'</button></div>';
|
||
}).join('');
|
||
bar.style.display='flex';
|
||
bar.querySelectorAll('.ap-x').forEach(b=>b.onclick=()=>{
|
||
const i=+b.dataset.ai; if(!(i>=0&&i<pendingAttachs.length)) return;
|
||
const a=pendingAttachs[i];
|
||
if(a.uploading && a.xhr){ try{ a.xhr.abort(); }catch(_){} return; } // abort in-flight (onabort cleans up)
|
||
pendingAttachs.splice(i,1); renderAttachBar();
|
||
});
|
||
}
|
||
function hideAttach(){ pendingAttachs=[]; const bar=document.getElementById('attachBar'); if(bar){ bar.style.display='none'; bar.innerHTML=''; } ['fileInput','fileInputImg','fileInputCam'].forEach(id=>{ const fi=document.getElementById(id); if(fi) fi.value=''; }); const ap=document.getElementById('attachPop'); if(ap) ap.style.display='none'; }
|
||
// Paste an image from the clipboard (e.g. a screenshot) straight into the composer.
|
||
function onPaste(e){
|
||
const cd=e.clipboardData; if(!cd) return;
|
||
// Excel / Google Sheets put BOTH a bitmap AND the cell text on the clipboard. If there's real text,
|
||
// let the textarea paste it normally (don't upload the screenshot). Only upload the image when the
|
||
// clipboard is image-ONLY (a genuine screenshot / copied picture with no accompanying text).
|
||
const plain=(cd.getData&&cd.getData('text/plain'))||'';
|
||
if(plain&&plain.trim()) return; // → default paste of the text
|
||
const items=cd.items||[];
|
||
for(const it of items){
|
||
if(it.type&&it.type.indexOf('image')===0){
|
||
const blob=it.getAsFile();
|
||
if(blob){ e.preventDefault(); const ext=(blob.type.split('/')[1]||'png'); uploadFile(new File([blob],'screenshot-'+Date.now()+'.'+ext,{type:blob.type})); return; }
|
||
}
|
||
}
|
||
}
|
||
// Close the emoji picker when clicking anywhere outside it (or its button).
|
||
document.addEventListener('click',(e)=>{
|
||
if(!emojiOpen) return;
|
||
const pop=document.getElementById('emojiPop'), btn=document.getElementById('emojiBtn');
|
||
if(pop && !pop.contains(e.target) && !(btn&&btn.contains(e.target)) && !e.target.closest('.react-btn')) closeEmoji();
|
||
});
|
||
document.addEventListener('click',(e)=>{ const ap=document.getElementById('attachPop'); if(!ap||ap.style.display==='none') return; const abtn=document.getElementById('attachBtn'); if(!ap.contains(e.target) && !(abtn&&abtn.contains(e.target))) ap.style.display='none'; }); // close the attach menu on an outside tap
|
||
let emojiOpen=false;
|
||
function openEmoji(mode, anchorEl){ emojiMode=mode||'compose'; if(emojiMode&&emojiMode.react&&emojiCat==='gif') emojiCat=0; /* GIFs can't be a reaction */ const pop=document.getElementById('emojiPop'); if(!pop) return; emojiOpen=true; renderEmojiPop(pop); pop.style.display='flex'; positionEmojiPop(pop, anchorEl); }
|
||
// Anchor the picker at its trigger: above the composer emoji icon, or at the message you reacted to.
|
||
function positionEmojiPop(pop, anchorEl){
|
||
const parent=pop.offsetParent || pop.parentElement; if(!parent){ return; }
|
||
const pr=parent.getBoundingClientRect(); const popW=pop.offsetWidth||330, popH=pop.offsetHeight||300;
|
||
let left, top;
|
||
if(anchorEl && anchorEl.getBoundingClientRect){ const r=anchorEl.getBoundingClientRect(); left=r.left-pr.left; top=r.top-pr.top-popH-8; }
|
||
else { left=12; top=pr.height-popH-64; }
|
||
left=Math.max(8, Math.min(left, pr.width-popW-8));
|
||
top=Math.max(8, Math.min(top, pr.height-popH-8));
|
||
pop.style.left=left+'px'; pop.style.top=top+'px'; pop.style.bottom='auto';
|
||
}
|
||
function closeEmoji(){ const pop=document.getElementById('emojiPop'); if(pop) pop.style.display='none'; emojiOpen=false; }
|
||
let _gifOn=null; // null=unknown, true/false once probed
|
||
function renderEmojiPop(pop){
|
||
// Tabs: emoji categories + (when configured) a GIF tab, visually separated (#5).
|
||
const showGif=(_gifOn!==false) && !(emojiMode&&emojiMode.react); // GIFs make sense only when composing, not reacting
|
||
const gifTab='<span class="et-sep"></span><button type="button" data-gif="1" class="'+(emojiCat==='gif'?'active':'')+'" title="GIFs">GIF</button>';
|
||
pop.innerHTML='<div class="emoji-tabs">'+EMOJI_CATS.map((c,i)=>'<button type="button" data-i="'+i+'" class="'+(emojiCat===i?'active':'')+'">'+c.icon+'</button>').join('')+(showGif?gifTab:'')+'</div>'
|
||
+(emojiCat==='gif'
|
||
? '<div class="gif-wrap"><div class="gif-search"><input id="gifQ" placeholder="Search GIFs…" autocomplete="off">'+ic('search',14)+'</div><div class="gif-grid" id="gifGrid"></div><div class="gif-attr">Powered by GIPHY</div></div>'
|
||
: '<div class="emoji-grid" id="emojiGrid"></div>');
|
||
// stopPropagation: switching tabs re-renders the pop (detaching the clicked button), which would
|
||
// make the outside-click handler think the click was outside and close the picker.
|
||
pop.querySelectorAll('.emoji-tabs button[data-i]').forEach(b=>b.onclick=(ev)=>{ ev.stopPropagation(); emojiCat=+b.dataset.i; renderEmojiPop(pop); });
|
||
const gt=pop.querySelector('.emoji-tabs button[data-gif]'); if(gt) gt.onclick=(ev)=>{ ev.stopPropagation(); emojiCat='gif'; renderEmojiPop(pop); };
|
||
if(emojiCat==='gif'){
|
||
const q=pop.querySelector('#gifQ');
|
||
if(q){ q.oninput=()=>{ clearTimeout(_gifT); _gifT=setTimeout(()=>loadGifs(q.value.trim()),300); }; q.onclick=(e)=>e.stopPropagation(); setTimeout(()=>q.focus(),0); }
|
||
loadGifs('');
|
||
return;
|
||
}
|
||
const grid=pop.querySelector('#emojiGrid');
|
||
grid.innerHTML=EMOJI_CATS[emojiCat].list.trim().split(/\s+/).map(e=>'<button type="button" data-emoji="'+e+'">'+e+'</button>').join('');
|
||
grid.querySelectorAll('button').forEach(b=>b.onclick=(ev)=>{ ev.stopPropagation(); onEmojiPick(b.dataset.emoji); }); // data-emoji survives twemoji's <img> swap
|
||
}
|
||
let _gifT=null, _gifSeq=0;
|
||
async function loadGifs(q){
|
||
const grid=document.getElementById('gifGrid'); if(!grid) return;
|
||
const seq=++_gifSeq; grid.innerHTML='<div class="gif-load"><img src="/loaders/loader-ring.svg" width="26" height="26" alt=""></div>';
|
||
let d; try{ d=await fetch('/api/gifs?q='+encodeURIComponent(q||'')).then(r=>r.json()); }catch(_){ d=null; }
|
||
if(seq!==_gifSeq) return;
|
||
if(d && d.enabled===false){ _gifOn=false; grid.innerHTML='<div class="gif-empty">GIFs aren’t configured.</div>'; return; }
|
||
_gifOn=true;
|
||
const gifs=(d&&d.gifs)||[];
|
||
if(!gifs.length){ grid.innerHTML='<div class="gif-empty">'+(q?'No GIFs for “'+pEsc(q)+'”.':'Couldn’t load GIFs.')+'</div>'; return; }
|
||
grid.innerHTML=gifs.map(g=>'<button type="button" class="gif-cell" data-url="'+pEsc(g.url)+'" title="'+pEsc(g.title||'GIF')+'"><img src="'+pEsc(g.preview||g.url)+'" loading="lazy" alt=""></button>').join('');
|
||
grid.querySelectorAll('.gif-cell').forEach(b=>b.onclick=(ev)=>{ ev.stopPropagation(); sendGif(b.dataset.url); });
|
||
}
|
||
// GIFs send immediately (like a sticker), hotlinked to GIPHY's CDN per their terms. If a reply is armed,
|
||
// the GIF is sent AS that reply (it was going out as a plain message before).
|
||
async function sendGif(url){
|
||
if(!url || !selected) return;
|
||
closeEmoji();
|
||
const kind=selected.kind, id=selected.id;
|
||
const replyTo=replyTarget?replyTarget.id:null;
|
||
clearReply();
|
||
try{ await postJSON('/api/messages', kind==='group'?{group:id, body:url, replyTo}:{to:id, body:url, replyTo}); }
|
||
catch(e){ toast(e.message||'Could not send GIF'); }
|
||
}
|
||
function onEmojiPick(e){ if(emojiMode && emojiMode.react){ reactToMessage(emojiMode.react, e); closeEmoji(); } else { insertEmoji(e); } }
|
||
function openEmojiForReact(messageId, anchorEl){ openEmoji({ react: messageId }, anchorEl); }
|
||
function insertEmoji(e){
|
||
const inp=document.getElementById('msgInput'); if(!inp) return;
|
||
const s=inp.selectionStart??inp.value.length, en=inp.selectionEnd??inp.value.length;
|
||
inp.value=inp.value.slice(0,s)+e+inp.value.slice(en);
|
||
const np=s+e.length; inp.focus(); try{ inp.setSelectionRange(np,np); }catch(_){}
|
||
}
|
||
// reactions
|
||
function applyReaction(m, emoji, userId, added){
|
||
m.reactions=m.reactions||[];
|
||
let r=m.reactions.find(x=>x.emoji===emoji);
|
||
if(added){ if(!r){ r={emoji,count:0,mine:false}; m.reactions.push(r); } r.count++; if(userId===ME.id) r.mine=true; }
|
||
else if(r){ r.count--; if(userId===ME.id) r.mine=false; if(r.count<=0) m.reactions=m.reactions.filter(x=>x.emoji!==emoji); }
|
||
}
|
||
async function reactToMessage(messageId, emoji){
|
||
const m=THREAD.find(x=>x.id===messageId);
|
||
try{ const r=await postJSON('/api/messages/react',{ messageId, emoji }); if(m){ m.reactions=r.reactions||[]; updateBubble(m); } }catch(_){}
|
||
}
|
||
function showMeetingCancelled(mtg){ if(!mtg) return; try{ playPing(); }catch(_){}
|
||
const w=mtg.when?(' on '+mtg.when):'';
|
||
addNotif({icon:'calendarX', text:pEsc(mtg.by||'Someone')+' cancelled “'+pEsc(mtg.title||'a meeting')+'”'+pEsc(w), link:{kind:'meeting'}});
|
||
try{ notify('❌ Meeting cancelled', (mtg.by||'Someone')+' cancelled '+(mtg.title||'a meeting')+w); }catch(_){}
|
||
toast('❌ “'+(mtg.title||'Meeting')+'”'+w+' was cancelled'); if(currentTab()==='meeting') loadScheduledMeetings(); }
|
||
function onGroupRole(d){ if(!d||!d.group) return; toast(d.admin?'You are now an admin of this group':'You are no longer an admin'); const gi=document.getElementById('groupInfo'); if(gi){ gi.remove(); if(selected&&selected.kind==='group'&&selected.id===d.group) openGroupInfo(d.group); } }
|
||
// A group's membership changed → refresh the sidebar (member count) live; refresh open group-info.
|
||
function onGroupUpdate(d){ if(!d||!d.group) return;
|
||
loadSidebar().then(()=>{ if(selected&&selected.kind==='group'&&selected.id===d.group){ if(d.removed){ showWelcome(); toast('You were removed from the group'); } else openConvo('group', d.group); } });
|
||
const gi=document.getElementById('groupInfo'); if(gi){ gi.remove(); if(!d.removed && selected&&selected.kind==='group'&&selected.id===d.group) openGroupInfo(d.group); } // refresh open group-info
|
||
}
|
||
function onChatReaction(d){ const m=THREAD.find(x=>x.id===d.messageId); if(m && d.reactions){ m.reactions=d.reactions; updateBubble(m); }
|
||
if(d.added && d.owner===ME.id && d.byId && d.byId!==ME.id){ addNotif({icon:'smilePlus', text:pEsc(d.by||'Someone')+' reacted '+(d.emoji||'')+' to your message', link:d.convId?{kind:'group',id:d.convId}:{kind:'dm',id:d.byId}}); }
|
||
}
|
||
// Read receipts (DM): the other party read my messages → mark mine as seen.
|
||
function onChatRead(d){ if(!d||!d.by) return;
|
||
if(selected && selected.kind==='dm' && selected.id===d.by){ let changed=false; THREAD.forEach(m=>{ if(m.from===ME.id && !m.read_at){ m.read_at=Date.now(); changed=true; } }); if(changed) renderThread(); }
|
||
// #6: keep the chat-list tick in sync with the thread — my last message is now read (blue double).
|
||
const it=rowFor('dm', d.by); if(it && it.last_from_me && it.last_status!=='read'){ it.last_status='read'; renderChats(searchVal()); }
|
||
}
|
||
// Branded confirm dialog — replaces window.confirm (which shows the OS/Electron default dialog on
|
||
// desktop). Returns a Promise<boolean>. Reuses .modal-ov so the global Esc handler dismisses it;
|
||
// a MutationObserver resolves false whenever the overlay is removed (Esc / backdrop / cancel).
|
||
// #2: "Seen by" as an on-screen popup listing the readers (not a flash toast).
|
||
function showSeenByModal(msg){
|
||
if(document.getElementById('seenModal')) return;
|
||
// Accept either a message (rich: DPs + "not seen yet") or a bare names[] (fallback).
|
||
const seenNames = Array.isArray(msg) ? msg : (Array.isArray(msg&&msg.seenBy) ? msg.seenBy.slice() : []);
|
||
const senderId = (msg && !Array.isArray(msg)) ? msg.from : null;
|
||
const members = (convoIsGroup && Array.isArray(convoMembers)) ? convoMembers : [];
|
||
// name → real DP (match member by name, since seenBy carries names).
|
||
const avByName={}; members.forEach(m=>{ avByName[(m.name||'').trim().toLowerCase()] = m.avatar||null; });
|
||
// Who hasn't seen it yet = members minus the sender, minus me (I'm reading it), minus those who have.
|
||
const seenSet=new Set(seenNames.map(n=>String(n).trim().toLowerCase()));
|
||
const notSeen = members.filter(m=> m.id!==senderId && m.id!==(ME&&ME.id) && !seenSet.has((m.name||'').trim().toLowerCase()));
|
||
const chip=(name,avatar)=>'<span class="mini-av" style="background:'+avColor(name)+'">'+(avatar?('<img class="av-img" src="'+pEsc(avatar)+'" onerror="this.remove()">'):'')+pEsc(initials(name))+'</span>';
|
||
const rowsFromNames=(arr)=>arr.map(n=>'<div class="seen-row">'+chip(n, avByName[String(n).trim().toLowerCase()]||null)+'<span class="sn">'+pEsc(n)+'</span></div>').join('');
|
||
const rowsFromMembers=(arr)=>arr.map(m=>'<div class="seen-row">'+chip(m.name, m.avatar||null)+'<span class="sn">'+pEsc(m.name)+'</span></div>').join('');
|
||
let inner='';
|
||
if(seenNames.length) inner+='<div class="seen-sec"><span class="dot ok"></span>Seen · '+seenNames.length+'</div>'+rowsFromNames(seenNames);
|
||
else inner+='<div class="gi-noresult" style="padding:.6rem .2rem">No one has seen this yet.</div>';
|
||
if(convoIsGroup && notSeen.length) inner+='<div class="seen-div"></div><div class="seen-sec dim"><span class="dot"></span>Not seen yet · '+notSeen.length+'</div>'+rowsFromMembers(notSeen);
|
||
// ONE scroll container for the whole list (section headers stick as you scroll) — not a scrollbar
|
||
// per section, which split the popup confusingly.
|
||
const body='<div class="seen-scroll">'+inner+'</div>';
|
||
const ov=document.createElement('div'); ov.className='modal-ov'; ov.id='seenModal';
|
||
ov.innerHTML='<div class="modal seen-modal"><h3>'+ic('checkCheck',16)+' Message info</h3>'+body+'<div class="bzc-actions"><button type="button" class="bzc-ok" id="seenClose">Close</button></div></div>';
|
||
document.body.appendChild(ov);
|
||
ov.addEventListener('mousedown',e=>{ if(e.target===ov) ov.remove(); });
|
||
ov.querySelector('#seenClose').onclick=()=>ov.remove();
|
||
}
|
||
function bzConfirm(message, opts){
|
||
opts=opts||{};
|
||
return new Promise((resolve)=>{
|
||
const ov=document.createElement('div'); ov.className='modal-ov';
|
||
ov.innerHTML='<div class="modal bz-confirm"><h3>'+pEsc(opts.title||'Please confirm')+'</h3>'
|
||
+'<p class="bzc-msg">'+pEsc(message||'Are you sure?')+'</p>'
|
||
+'<div class="bzc-actions"><button type="button" class="bzc-cancel">'+pEsc(opts.cancelText||'Cancel')+'</button>'
|
||
+'<button type="button" class="bzc-ok'+(opts.danger?' danger':'')+'">'+pEsc(opts.okText||'Confirm')+'</button></div></div>';
|
||
document.body.appendChild(ov);
|
||
let done=false;
|
||
const finish=(v)=>{ if(done) return; done=true; obs.disconnect(); if(document.body.contains(ov)) ov.remove(); resolve(v); };
|
||
const obs=new MutationObserver(()=>{ if(!document.body.contains(ov)) finish(false); }); // Esc/global removal → cancel
|
||
obs.observe(document.body,{childList:true});
|
||
ov.querySelector('.bzc-ok').onclick=()=>finish(true);
|
||
ov.querySelector('.bzc-cancel').onclick=()=>finish(false);
|
||
ov.addEventListener('mousedown',(e)=>{ if(e.target===ov) finish(false); }); // backdrop click
|
||
setTimeout(()=>{ const b=ov.querySelector('.bzc-ok'); if(b) b.focus(); },0);
|
||
});
|
||
}
|
||
// Delete-for-everyone: confirm, tell the server, then blank the message locally (the server also
|
||
// broadcasts chat-deleted to the other side / other tabs).
|
||
async function deleteMessage(id){ if(!(await bzConfirm('This message will be removed for everyone in the chat.', {title:'Delete message?', okText:'Delete', danger:true}))) return; try{ await postJSON('/api/messages/delete',{id}); markMsgDeleted(id); }catch(e){ toast(e.message||'Could not delete'); } }
|
||
function markMsgDeleted(id){
|
||
let changed=false;
|
||
THREAD.forEach(m=>{ if(m.id===id && !m.deleted){ m.deleted=true; m.body=''; m.attachment=null; m.reactions=[]; m.reply=null; m.poll=null; changed=true; } });
|
||
THREAD_CACHE.forEach(arr=>arr.forEach(m=>{ if(m.id===id){ m.deleted=true; m.body=''; m.attachment=null; } }));
|
||
if(changed) renderThread();
|
||
}
|
||
function onChatDeleted(d){ if(!d||!d.id) return; markMsgDeleted(d.id); try{ loadSidebar(); }catch(_){} } // refresh last-message previews
|
||
// DM delivered: recipient's client acked → second (grey) tick.
|
||
function onChatDelivered(d){ if(!d||!d.id) return;
|
||
const m=THREAD.find(x=>x.id===d.id); if(m && !m.delivered_at){ m.delivered_at=Date.now(); updateBubble(m); }
|
||
// #6: mirror the delivered state onto the chat-list tick (double grey), unless it's already read.
|
||
const it=d.with?rowFor('dm', d.with):null; if(it && it.last_from_me && it.last_status!=='read' && it.last_status!=='delivered'){ it.last_status='delivered'; renderChats(searchVal()); }
|
||
}
|
||
// #1: opening a DM loads the true delivered/read state into the thread; make the sidebar tick match
|
||
// (it can lag when a delivered/read event arrived before the row reflected my latest message).
|
||
function syncDmRowTick(id){
|
||
const it=rowFor('dm', id); if(!it || !THREAD.length) return;
|
||
const last=[...THREAD].reverse().find(m=>!m.system && !m.deleted); if(!last) return;
|
||
if(last.from===ME.id){ const st=last.read_at?'read':(last.delivered_at?'delivered':'sent'); if(it.last_status!==st || it.last_msg_id!==last.id){ it.last_status=st; it.last_from_me=true; it.last_msg_id=last.id; renderChats(searchVal()); } }
|
||
}
|
||
// Group read: a member opened the group → add them to "Seen by" on my messages up to that time.
|
||
function onGroupRead(d){ if(!d||!d.group) return; if(selected && selected.kind==='group' && selected.id===d.group){ THREAD.forEach(m=>{ if(m.created_at<=d.at && m.from!==d.by && !m.system && !m.deleted){ m.seenBy=m.seenBy||[]; if(d.byName && !m.seenBy.includes(d.byName)){ m.seenBy.push(d.byName); if(m.id===_lastGroupId||m.from===ME.id) updateBubble(m); } } }); refreshGroupRowTick(d.group); } } // #2: track everyone's reads on every message
|
||
// Live-refresh a group's sidebar tick from the open thread (read-by-all → yellow double tick).
|
||
function refreshGroupRowTick(gid){
|
||
const row=rowFor('group',gid); if(!row||!row.last_from_me||!(selected&&selected.kind==='group'&&selected.id===gid)) return;
|
||
const mine=THREAD.filter(m=>m.from===ME.id && !m.system && !m.deleted); const last=mine[mine.length-1]; if(!last) return;
|
||
const others=Math.max(0,(convoMembers?convoMembers.length:0)-1); const rc=Array.isArray(last.seenBy)?last.seenBy.length:0;
|
||
row.last_status=(others>0&&rc>=others)?'read':(rc>0?'delivered':'sent');
|
||
if(!typingLabel('group',gid)){ const el=listEl&&listEl.querySelector('.chat-row[data-kind="group"][data-id="'+_cssEsc(gid)+'"] .chat-prev'); if(el) el.innerHTML=rowPreviewHTML(row); }
|
||
}
|
||
// Shared group call: start it (or join the live one — the server returns the existing room).
|
||
async function startOrJoinGroupCall(group){
|
||
try{ const r=await postJSON('/api/groups/call/start',{ group }); if(r&&r.room){ const _g=rowFor('group',group); meetReturn={kind:'group',id:group}; if(nativeCallOn()){ await callkitReportOutgoing(r.uuid, r.room, 'group', (_g&&_g.name)||'Group call', false); switchTab('meeting'); enterMeeting(r.room, false, { native:true, uuid:r.uuid }); return; } /* native: CallKit ring + native LiveKit + the REAL meeting window (mesh, no 2nd SFU) */ switchTab('meeting'); enterMeeting(r.room); } }
|
||
catch(e){ toast(e.message||'Could not start the call'); }
|
||
}
|
||
function updateCallBtn(active){ const cc=document.getElementById('convoCall'); if(!cc) return; cc.classList.toggle('joinable',active); cc.title=active?'Join call':'Start call'; cc.innerHTML=ic(active?'video':'phone',18)+(active?'<span>Join</span>':''); }
|
||
function onGroupCall(d){
|
||
if(!d||!d.group) return; const it=rowFor('group',d.group); if(it){ it.callActive=!!d.active; it.callRoom=d.room||null;
|
||
if(d.active && d.by && d.by!==ME.id){ it.incomingRoom=d.room; it.callByName=d.startedByName; } else if(!d.active){ it.incomingRoom=null; it.callByName=null; } }
|
||
if(selected&&selected.kind==='group'&&selected.id===d.group) updateCallBtn(!!d.active);
|
||
// Ring members in. On a CallKit device the system rings it (VoIP push) — skip the in-app popup.
|
||
if(d.active && d.by && d.by!==ME.id && meetRoom!==d.room && !nativeCallOn()) showCallInvite(d.room, d.startedByName, {kind:'group',id:d.group}, d.groupName||(it&&it.name)||'Group');
|
||
if(d.active && d.by && d.by!==ME.id && meetRoom!==d.room && nativeCallOn() && d.uuid) nativeReportIncoming({ callUUID:d.uuid, room:d.room, kind:'group', groupId:d.group, callerName:(d.groupName||(it&&it.name)||'Group call') }); // WS path → CallKit
|
||
if(!d.active){ dismissCallInvite(d.room); if(nativeCallOn() && d.uuid) callkitEnd(d.uuid); } // ended → stop ringing + clear CallKit
|
||
renderChats(searchVal());
|
||
}
|
||
function dismissCallInvite(room){ if(!room) return; const el=document.getElementById('ci-'+room); if(el){ try{ el.remove(); }catch(_){} stopRing(); } }
|
||
// 1:1 call: start/join from the DM header; live state updates the button + shows an incoming invite.
|
||
async function startOrJoinDmCall(otherId){
|
||
try{ const r=await postJSON('/api/calls/dm/start',{ to:otherId }); if(r&&r.room){ const _r=rowFor('dm',otherId); _dmCallWaiting={ name:(_r&&_r.name)||'Contact', avatar:(_r&&_r.avatar)||null }; meetReturn={kind:'dm',id:otherId}; if(nativeCallOn()){ await callkitReportOutgoing(r.uuid, r.room, 'dm', (_r&&_r.name)||'Call', false); switchTab('meeting'); enterMeeting(r.room, false, { native:true, uuid:r.uuid }); startRingback(); return; } /* native: CallKit ring + native LiveKit media + the REAL meeting window (mesh, no 2nd SFU) */ switchTab('meeting'); enterMeeting(r.room); startRingback(); /* #17: ring until they answer */ } }
|
||
catch(e){ toast(e.message||'Could not start the call'); }
|
||
}
|
||
// Live presence: a contact came online/offline or entered/left a call — update their dot + the
|
||
// open chat header immediately (no refresh, which the desktop/mobile apps can't do). #6
|
||
function onPresence(d){
|
||
if(!d||!d.userId) return;
|
||
const it=rowFor('dm', d.userId);
|
||
if(it){ it.online=!!d.online; it.status=d.status||'active'; if(d.lastSeen) it.lastSeen=d.lastSeen; renderChats(searchVal()); } // #2: keep last-seen fresh
|
||
if(selected && selected.kind==='dm' && selected.id===d.userId && it){
|
||
const sub=document.querySelector('#convoTitle .st'); if(sub) sub.textContent=dmSubLabel(it);
|
||
const dot=document.querySelector('.convo-head .avatar .dot'); if(dot) dot.className='dot '+statusCls(it);
|
||
}
|
||
}
|
||
function onDmCall(d){
|
||
if(!d) return; const it=rowFor('dm', d.with); if(it){ it.callActive=!!d.active; it.callRoom=d.room||null; if(!d.active && it.status==='incall') it.status='active'; // #4: drop the stuck "in call" status
|
||
if(d.active && d.by && d.by!==ME.id){ it.incomingRoom=d.room; it.callByName=d.byName; } else if(!d.active){ it.incomingRoom=null; it.callByName=null; } } // remember an incoming call so opening the chat can re-show Join
|
||
if(selected&&selected.kind==='dm'&&selected.id===d.with){ updateCallBtn(!!d.active); const s=document.querySelector('#convoTitle .st'); if(s&&it) s.textContent=dmSubLabel(it); } // refresh the header subtitle live
|
||
// Incoming 1:1 call. On a CallKit device the SYSTEM rings it (via the VoIP push) — don't also show the
|
||
// in-app popup, and let CallKit answer drive the join. Off CallKit, show the in-app invite as before.
|
||
if(d.active && d.by && d.by!==ME.id && !nativeCallOn()) showCallInvite(d.room, d.byName, {kind:'dm',id:d.with});
|
||
if(d.active && d.by && d.by!==ME.id && nativeCallOn() && d.uuid) nativeReportIncoming({ callUUID:d.uuid, room:d.room, kind:'dm', callerId:d.with, callerName:d.byName }); // WS path → CallKit (2nd path alongside the VoIP push)
|
||
if(!d.active){ dismissCallInvite(d.room); if(nativeCallOn() && d.uuid) callkitEnd(d.uuid); } // ended/declined → stop ringing + clear CallKit
|
||
renderChats(searchVal());
|
||
}
|
||
// Incoming-call banner (1:1 call or an add-participant invite) with Join / Dismiss.
|
||
// ret: where to land when the call ends (the originating chat), or null for the meetings tab.
|
||
// sub: a second line under the caller — e.g. the group name for a group call.
|
||
function showCallInvite(room, byName, ret, sub, quiet){
|
||
if(!room || document.getElementById('ci-'+room)) return;
|
||
startRing();
|
||
const who=byName||'Someone';
|
||
const line2 = sub ? ('is calling · '+pEsc(sub)) : 'is calling you';
|
||
const el=document.createElement('div'); el.className='call-invite'; el.id='ci-'+room;
|
||
el.innerHTML='<span class="ci-ico">'+ic('phone',18)+'</span><span class="ci-txt"><b>'+pEsc(who)+'</b><br>'+line2+'</span>'
|
||
+'<button class="ci-join">'+ic('video',16)+' Join</button>'
|
||
+'<button class="ci-decline" title="Decline">'+ic('callEnd',16)+' Decline</button>';
|
||
document.body.appendChild(el);
|
||
let closed=false;
|
||
const close=()=>{ if(closed) return; closed=true; try{ el.remove(); }catch(_){} stopRing(); };
|
||
// Wire Join/Decline BEFORE firing the OS notification so the buttons are live the instant the popup
|
||
// shows (previously notify() ran first — the click did nothing until the toast plumbing settled).
|
||
el.querySelector('.ci-join').onclick=()=>{ close(); meetReturn=ret||null; switchTab('meeting'); enterMeeting(room); };
|
||
el.querySelector('.ci-decline').onclick=()=>{ close(); if(ret&&ret.kind==='dm'){ postJSON('/api/calls/decline',{room}).catch(()=>{}); } }; // 1:1 → notify caller; group → silent
|
||
if(!quiet){ try{ notify('📞 '+who, (sub?('Group call · '+sub):'is calling you'), ret&&ret.kind, ret&&ret.id, {persistent:true}); }catch(_){} } // OS notification too (skip when merely re-showing on chat open)
|
||
setTimeout(close, 45000);
|
||
}
|
||
// Scheduled-meeting invitation (toast + the meeting shows up in their list).
|
||
function showMeetingInvite(mtg){ if(!mtg) return; try{ playPing(); }catch(_){} toast('📅 '+(mtg.by||'Someone')+' invited you to “'+mtg.title+'”'+(mtg.whenText?(' · '+mtg.whenText):''));
|
||
addNotif({icon:'calendar', text:pEsc(mtg.by||'Someone')+' invited you to “'+pEsc(mtg.title||'a meeting')+'”'+(mtg.whenText?(' · '+pEsc(mtg.whenText)):''), link:{kind:'meeting', code:mtg.room}});
|
||
try{ notify('📅 Meeting invite', (mtg.by||'Someone')+' invited you to '+(mtg.title||'a meeting')); }catch(_){}
|
||
if(currentTab()==='meeting') loadScheduledMeetings(); }
|
||
// 10-minute reminder before a scheduled meeting — prominent banner with Join.
|
||
function showMeetingReminder(mtg){ if(!mtg||!mtg.room) return; try{ playPing(); }catch(_){}
|
||
addNotif({icon:'bell', text:'“'+pEsc(mtg.title||'A meeting')+'” starts in ~10 minutes', link:{kind:'meeting', code:mtg.room}});
|
||
try{ notify('⏰ Meeting reminder', (mtg.title||'A meeting')+' starts in ~10 minutes'); }catch(_){}
|
||
if(document.getElementById('mr-'+mtg.id)) return;
|
||
const el=document.createElement('div'); el.className='call-invite'; el.id='mr-'+mtg.id;
|
||
el.innerHTML='<span class="ci-ico">'+ic('calendar',18)+'</span><span class="ci-txt"><b>'+pEsc(mtg.title)+'</b><br>starts in ~10 minutes</span><button class="ci-join">'+ic('video',16)+' Join</button><button class="ci-x" title="Dismiss">'+ic('x',16)+'</button>';
|
||
document.body.appendChild(el);
|
||
const close=()=>{ try{ el.remove(); }catch(_){} };
|
||
el.querySelector('.ci-join').onclick=()=>{ close(); switchTab('meeting'); enterMeeting(mtg.room); };
|
||
el.querySelector('.ci-x').onclick=close;
|
||
}
|
||
// In-call participants panel (everyone) + host controls (mute all, transfer host).
|
||
function meetParticipantsList(){ const a=[{id:'__local', name:((ME&&ME.name)||(ME&&ME.email)||'You')+' (you)'}]; meetPeers.forEach((p,pid)=>a.push({id:pid, name:meetNames.get(pid)||p.name||'Guest'})); return a; }
|
||
// ---- In-meeting chat: ephemeral (lives only for the call). Message EVERYONE or DIRECT to one person. ----
|
||
let meetChatLog=[], meetChatUnread=0, meetChatTo='';
|
||
function resetMeetChat(){ meetChatLog=[]; meetChatUnread=0; meetChatTo=''; const c=document.getElementById('meetChat'); if(c) c.remove(); updateMeetChatBadge(); }
|
||
function meetChatRecipients(){ const a=[]; meetPeers.forEach((pp,pid)=>a.push({id:pid, name:meetNames.get(pid)||pp.name||'Guest'})); return a; }
|
||
function toggleMeetChat(){ const ex=document.getElementById('meetChat'); if(ex){ ex.remove(); return; } const meet=document.querySelector('.meet'); if(!meet) return; const p=document.createElement('div'); p.id='meetChat'; p.className='meet-panel meet-chat'; meet.appendChild(p); meetChatUnread=0; updateMeetChatBadge(); renderMeetChat(); }
|
||
function updateMeetChatBadge(){ const b=document.getElementById('meetChatBadge'); if(!b) return; if(meetChatUnread>0){ b.textContent=meetChatUnread>9?'9+':meetChatUnread; b.style.display='grid'; } else b.style.display='none'; }
|
||
function renderMeetChat(){
|
||
const p=document.getElementById('meetChat'); if(!p) return;
|
||
const recips=meetChatRecipients();
|
||
if(meetChatTo && !recips.some(r=>r.id===meetChatTo)) meetChatTo=''; // recipient left → fall back to Everyone
|
||
const head='<div class="mp-head"><b>'+ic('chat',15)+' Meeting chat</b><span style="flex:1"></span><button class="mp-x" title="Close">'+ic('x',16)+'</button></div>';
|
||
const body='<div class="mc-log" id="mcLog">'+(meetChatLog.length?meetChatLog.map(mcRowHTML).join(''):'<div class="mc-empty">No messages yet — say hello 👋</div>')+'</div>';
|
||
// The recipient picker lives at the BOTTOM, right where you type (new #3).
|
||
const foot='<div class="mc-compose'+(meetChatTo?' private':'')+'">'
|
||
+'<div class="mc-to"><span>To</span><select id="mcTo"><option value=""'+(!meetChatTo?' selected':'')+'>Everyone</option>'
|
||
+recips.map(r=>'<option value="'+pEsc(r.id)+'"'+(meetChatTo===r.id?' selected':'')+'>'+pEsc(r.name)+' (private)</option>').join('')+'</select></div>'
|
||
+'<form class="mc-foot" id="mcForm"><input id="mcInput" placeholder="'+(meetChatTo?'Private message…':'Message everyone…')+'" autocomplete="off" maxlength="2000"><button type="submit" class="mc-send" title="Send">'+ic('send',16)+'</button></form>'
|
||
+'</div>';
|
||
p.innerHTML=head+body+foot;
|
||
p.querySelector('.mp-x').onclick=()=>p.remove();
|
||
const sel=p.querySelector('#mcTo'); if(sel) sel.onchange=()=>{ meetChatTo=sel.value; renderMeetChat(); };
|
||
const form=p.querySelector('#mcForm'); if(form) form.onsubmit=(e)=>{ e.preventDefault(); meetChatSend(); };
|
||
const log=p.querySelector('#mcLog'); if(log) log.scrollTop=log.scrollHeight;
|
||
const inp=p.querySelector('#mcInput'); if(inp) setTimeout(()=>inp.focus(),0);
|
||
}
|
||
function mcRowHTML(m){
|
||
const mine=m.from==='__self';
|
||
const priv=m.direct?('<span class="mc-priv">'+(mine?('to '+pEsc(m.toName||'someone')):'private')+'</span>'):'';
|
||
const head=mine?(m.direct?('<div class="mc-nm">You '+priv+'</div>'):''):('<div class="mc-nm">'+pEsc(m.name||'Guest')+priv+'</div>');
|
||
return '<div class="mc-row'+(mine?' mine':'')+(m.direct?' priv':'')+'"><div class="mc-bub">'+head+'<div class="mc-tx">'+fmtInline(m.text||'')+'</div><div class="mc-at">'+fmtClock(m.at||Date.now())+'</div></div></div>';
|
||
}
|
||
function meetChatSend(){
|
||
const inp=document.getElementById('mcInput'); if(!inp) return; const text=(inp.value||'').trim(); if(!text) return;
|
||
let toName=''; if(meetChatTo){ const r=meetChatRecipients().find(x=>x.id===meetChatTo); toName=r?r.name:''; }
|
||
meetSend({type:'meeting-chat', text, to:meetChatTo||undefined});
|
||
meetChatAppend({from:'__self', name:'You', text, direct:!!meetChatTo, toName, at:Date.now()});
|
||
inp.value=''; inp.focus();
|
||
}
|
||
function meetChatAppend(m){
|
||
meetChatLog.push(m); if(meetChatLog.length>500) meetChatLog.shift();
|
||
// A PRIVATE message to me → my reply defaults back to that person (new #3), so you don't accidentally
|
||
// answer a private note in front of everyone.
|
||
const privFrom=(m.direct && m.from!=='__self' && meetPeers.has(m.from)) ? m.from : null;
|
||
if(privFrom) meetChatTo=privFrom;
|
||
const panel=document.getElementById('meetChat');
|
||
if(panel){
|
||
if(privFrom){ renderMeetChat(); return; } // re-render so the "To" picker + compose tint follow
|
||
const log=document.getElementById('mcLog');
|
||
if(log){ const empty=log.querySelector('.mc-empty'); if(empty) empty.remove(); log.insertAdjacentHTML('beforeend', mcRowHTML(m)); log.scrollTop=log.scrollHeight; }
|
||
}
|
||
else if(m.from!=='__self'){ meetChatUnread++; updateMeetChatBadge(); try{ playPing(); }catch(_){} }
|
||
}
|
||
function refreshMeetPanel(){ if(document.getElementById('meetPanel')) renderMeetPanel(); }
|
||
// #6: track invitees who haven't joined yet. They show under "Not joined yet"; after 30s the
|
||
// invite is re-enabled for them. When they actually join (matched by user id), they're cleared.
|
||
function meetInviteJoined(uid){ const e=meetInvited.get(uid); if(e){ if(e.timer) clearTimeout(e.timer); meetInvited.delete(uid); refreshMeetPanel(); } }
|
||
function meetAddInvited(ids){ (ids||[]).forEach(uid=>{ const c=(CONTACTS||[]).find(x=>x.id===uid); const name=(c&&c.name)||'Someone'; const prev=meetInvited.get(uid); if(prev&&prev.timer) clearTimeout(prev.timer); const timer=setTimeout(()=>{ meetInvited.delete(uid); refreshMeetPanel(); }, 30000); meetInvited.set(uid,{name,timer}); }); refreshMeetPanel(); }
|
||
// Guest invite link for the current meeting — anyone with it can join (guest token), even without a
|
||
// Connect account. Shared from the meeting's Add-people panel and emailed to invitees (#4).
|
||
function meetingLink(){ return location.origin+'/home?meet='+encodeURIComponent(meetRoom||''); }
|
||
function copyMeetingLink(){
|
||
if(!meetRoom){ toast('No active meeting'); return; }
|
||
const url=meetingLink(), done=()=>toast('Invite link copied');
|
||
try{ if(navigator.clipboard&&navigator.clipboard.writeText) return void navigator.clipboard.writeText(url).then(done).catch(()=>fallbackCopy(url,done)); }catch(_){}
|
||
fallbackCopy(url,done);
|
||
}
|
||
function fallbackCopy(text,cb){ try{ const ta=document.createElement('textarea'); ta.value=text; ta.style.position='fixed'; ta.style.opacity='0'; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove(); cb&&cb(); }catch(_){ prompt('Copy this link:', text); } }
|
||
let meetPanelTab='people'; // 'people' | 'add'
|
||
let _addPool=null; // cached candidates for "Add people" (group members for a group call)
|
||
function toggleMeetPanel(){ const ex=document.getElementById('meetPanel'); if(ex){ ex.remove(); return; } const meet=document.querySelector('.meet'); if(!meet) return; meetPanelTab='people'; const p=document.createElement('div'); p.id='meetPanel'; p.className='meet-panel'; meet.appendChild(p); renderMeetPanel(); }
|
||
function renderMeetPanel(){
|
||
const p=document.getElementById('meetPanel'); if(!p) return;
|
||
const list=meetParticipantsList();
|
||
const isHostRow=pp=> pp.id==='__local' ? meetIsHost : pp.id===meetHostId; // YOUR row uses meetIsHost
|
||
const head='<div class="mp-head"><b>Participants</b><span style="flex:1"></span>'+((meetIsHost&&meetPanelTab==='people')?'<button class="mp-muteall" title="Mute everyone">'+ic('micOff',14)+' Mute all</button>':'')+'<button class="mp-x" title="Close">'+ic('x',16)+'</button></div>';
|
||
// Guests can't add participants (#9) — show only the "In call" list, no Add-people tab.
|
||
const isGuest=!!(ME&&ME.guest); if(isGuest && meetPanelTab==='add') meetPanelTab='people';
|
||
const tabs='<div class="mp-tabs"><button class="mp-tab'+(meetPanelTab==='people'?' on':'')+'" data-tab="people">In call ('+list.length+')</button>'+(isGuest?'':'<button class="mp-tab'+(meetPanelTab==='add'?' on':'')+'" data-tab="add">'+ic('userPlus',13)+' Add people</button>')+'</div>';
|
||
let body;
|
||
if(meetPanelTab==='add'){
|
||
// Group call → only that group's members may be added. 1:1/ad-hoc → all team contacts.
|
||
const inGroup = meetReturn && meetReturn.kind==='group';
|
||
if(inGroup && _addPool===null){ _addPool=[]; fetch('/api/groups/members?group='+encodeURIComponent(meetReturn.id)).then(r=>r.json()).then(ms=>{ _addPool=(Array.isArray(ms)?ms:[]).map(x=>({id:x.id,name:x.name})); if(document.getElementById('meetPanel')&&meetPanelTab==='add') renderMeetPanel(); }).catch(()=>{ _addPool=[]; }); }
|
||
// #4: 1:1/ad-hoc → only people you've MESSAGED (existing DMs), not the whole team.
|
||
const pool = inGroup ? (_addPool||[]) : (ROWS||[]).filter(r=>r.kind==='dm' && !r.self).map(r=>({id:r.id, name:r.name}));
|
||
const hereUids=new Set(); meetPeerUids.forEach(uid=>hereUids.add(uid)); if(ME&&ME.id) hereUids.add(ME.id);
|
||
const here=new Set(list.map(pp=>(pp.name||'').replace(/\s*\(you\)$/,'').trim().toLowerCase()));
|
||
const myName=((ME&&ME.name)||(ME&&ME.email)||'').trim().toLowerCase();
|
||
const avail=pool.filter(c=>c.id!==(ME&&ME.id) && !hereUids.has(c.id) && (c.name||'').trim().toLowerCase()!==myName && !here.has((c.name||'').trim().toLowerCase()) && !meetInvited.has(c.id));
|
||
body='<div class="mp-invite-link">'+ic('link',14)+'<div class="mil-txt"><b>Invite by link</b><span>Anyone with the link can join — even without a Connect account.</span></div><button class="mp-copylink" id="mpCopyLink">'+ic('copy',13)+' Copy</button></div>'
|
||
+(avail.length>5?'<div class="gi-search" style="margin:.15rem .5rem .35rem"><input id="mpSearch" placeholder="Search people…" autocomplete="off"></div>':'') // #5: find people fast
|
||
+'<div class="mp-list">'+(avail.length?avail.map(c=>'<label class="chk" data-nm="'+pEsc((c.name||'').toLowerCase())+'"><input type="checkbox" value="'+pEsc(c.id)+'"><span class="mini-av" style="background:'+avColor(c.name)+'">'+pEsc(initials(c.name))+'</span><span class="mn">'+pEsc(c.name)+'</span></label>').join(''):'<div class="gi-noresult">'+(inGroup&&_addPool===null?'Loading…':'No one left to add')+'</div>')+'</div>'+(avail.length?'<button class="gobtn mp-invite-sticky" id="mpInvite">Invite to call</button>':'');
|
||
} else {
|
||
body='<div class="mp-list">'+list.map(pp=>'<div class="mp-row"><span class="mini-av" style="background:'+avColor(pp.name)+'">'+pEsc(initials(pp.name))+'</span><span class="mn">'+pEsc(pp.name)+'</span>'+(isHostRow(pp)?'<span class="host-tag">'+ic('crown',11)+' Host</span>':'')+((pp.id==='__local'?meetScreen:meetSharers.has(pp.id))?'<span class="pp-screen" title="Sharing screen">'+ic('monitor',13)+'</span>':'')+(meetMuted.get(pp.id)?'<span class="pp-mute">'+ic('micOff',13)+'</span>':'')+((meetIsHost&&pp.id!=='__local'&&!isHostRow(pp))?'<button class="mp-makehost" data-id="'+pEsc(pp.id)+'" title="Make host">'+ic('crown',14)+'</button>':'')+'</div>').join('')+'</div>'
|
||
+(meetInvited.size?'<div class="mp-sec">Not joined yet</div><div class="mp-list">'+[...meetInvited.entries()].map(([uid,e])=>'<div class="mp-row waiting"><span class="mini-av" style="background:'+avColor(e.name)+'">'+pEsc(initials(e.name))+'</span><span class="mn">'+pEsc(e.name)+'</span><span class="pp-wait">'+ic('calendarClock',12)+' waiting…</span></div>').join('')+'</div>':'')
|
||
+(meetIsHost?'<label class="gi-setting mp-setting"><span>'+ic('monitor',15)+' Allow multiple people to share</span><span class="switch"><input type="checkbox" id="mpMulti"'+(meetMultiShare?' checked':'')+'><span class="slider"></span></span></label>':'');
|
||
}
|
||
p.innerHTML=head+tabs+'<div class="mp-scroll">'+body+'</div>';
|
||
const x=p.querySelector('.mp-x'); if(x) x.onclick=()=>p.remove();
|
||
p.querySelectorAll('.mp-tab').forEach(b=>b.onclick=()=>{ meetPanelTab=b.dataset.tab; renderMeetPanel(); });
|
||
const ma=p.querySelector('.mp-muteall'); if(ma) ma.onclick=()=>{ meetSend({type:'meeting-muteall'}); toast('Muted everyone'); };
|
||
const mm=p.querySelector('#mpMulti'); if(mm) mm.onchange=()=>{ meetMultiShare=mm.checked; meetSend({type:'meeting-sharemode', multi:meetMultiShare}); };
|
||
p.querySelectorAll('.mp-makehost').forEach(b=>b.onclick=()=>{ meetSend({type:'meeting-host', to:b.dataset.id}); meetHostId=b.dataset.id; meetIsHost=false; renderMeetPanel(); });
|
||
const cpl=p.querySelector('#mpCopyLink'); if(cpl) cpl.onclick=()=>copyMeetingLink();
|
||
{ const ms=p.querySelector('#mpSearch'); if(ms) ms.addEventListener('input',()=>{ const q=ms.value.trim().toLowerCase(); p.querySelectorAll('.mp-list .chk').forEach(l=>{ l.style.display=(!q||(l.dataset.nm||'').includes(q))?'':'none'; }); }); } // #5
|
||
const inv=p.querySelector('#mpInvite'); if(inv) inv.onclick=async()=>{ const ids=[...p.querySelectorAll('input:checked')].map(i=>i.value); if(!ids.length){ toast('Pick people to invite'); return; } try{ await postJSON('/api/calls/invite',{ room:meetRoom, userIds:ids }); meetAddInvited(ids); meetPanelTab='people'; toast(ids.length===1?'Waiting for them to join…':'Waiting for '+ids.length+' people to join…'); renderMeetPanel(); }catch(e){ toast(e.message||'Could not invite'); } };
|
||
}
|
||
// Add people to the current call (from the in-call bar).
|
||
function openInvitePicker(room){
|
||
if(!room || document.getElementById('invModal')) return;
|
||
const ov=document.createElement('div'); ov.className='modal-ov'; ov.id='invModal';
|
||
ov.innerHTML='<div class="modal sched"><div class="gi-head" style="margin-bottom:.6rem"><div class="avatar grp" style="width:40px;height:40px;flex:0 0 40px;background:var(--blue)">'+ic('userPlus',20)+'</div><div class="gi-name"><div class="gi-title">Add people to the call</div><div class="gi-sub">They get an incoming-call invite</div></div><button class="iconbtn" id="invClose">'+ic('x',18)+'</button></div>'
|
||
+(()=>{ const hereUids=new Set(); meetPeerUids.forEach(uid=>hereUids.add(uid)); if(ME&&ME.id) hereUids.add(ME.id); const cand=(ROWS||[]).filter(r=>r.kind==='dm' && !r.self && !hereUids.has(r.id) && !meetInvited.has(r.id)); return '<div class="gi-list" style="max-height:46vh;overflow:auto">'+(cand.length?cand.map(c=>'<label class="chk"><input type="checkbox" value="'+pEsc(c.id)+'"><span class="mini-av" style="background:'+avColor(c.name)+'">'+pEsc(initials(c.name))+'</span><span class="mn">'+pEsc(c.name)+'</span></label>').join(''):'<div class="gi-noresult">No one left to add</div>')+'</div>'; })()
|
||
+'<button class="gobtn mp-invite-sticky" id="invBtn" style="width:100%;margin-top:.7rem;position:sticky;bottom:0">Invite</button></div>';
|
||
document.body.appendChild(ov);
|
||
ov.onclick=e=>{ if(e.target===ov) ov.remove(); };
|
||
ov.querySelector('#invClose').onclick=()=>ov.remove();
|
||
ov.querySelector('#invBtn').onclick=async()=>{ const ids=[...ov.querySelectorAll('input:checked')].map(i=>i.value); if(!ids.length){ toast('Pick people to invite'); return; } try{ const r=await postJSON('/api/calls/invite',{ room, userIds:ids }); ov.remove(); toast('Invited '+r.invited+(r.invited===1?' person':' people')); }catch(e){ toast(e.message||'Could not invite'); } };
|
||
}
|
||
// Native app (iOS/Android WKWebView): an <a href="/files/.." download> NAVIGATES the single WebView away to
|
||
// the raw file — no back button → the app looks frozen (must force-quit). Routing it to Safari fails too,
|
||
// because Safari doesn't carry the app's login cookie ("unauthorized"). Correct fix: fetch the file IN the
|
||
// WebView (cookie present) and hand the bytes to the OS save/share sheet — a real inline download. That needs
|
||
// the Filesystem+Share plugins (added to the mobile app; ships in the next TestFlight build). Until then, fall
|
||
// back to iOS long-press "Save to Photos" for images (works today) so nothing breaks. Desktop/web untouched.
|
||
// Download PROGRESS. An upload shows a bar + % (uploadFile below); a native save had none, because it
|
||
// fetches the bytes itself and there is no browser download UI behind it. Same chip, same %.
|
||
function bzDlShow(name){
|
||
let el=document.getElementById('dlProg');
|
||
if(!el){ el=document.createElement('div'); el.id='dlProg'; el.className='dl-prog'; document.body.appendChild(el); }
|
||
el.innerHTML='<div class="dp-top">'+ic('download',15)+'<span class="dp-name"></span><span class="dp-pct">0%</span></div>'
|
||
+'<div class="dp-bar"><span class="dp-fill"></span></div>';
|
||
el.querySelector('.dp-name').textContent=name||'file';
|
||
return el;
|
||
}
|
||
function bzDlSet(el,pct){
|
||
if(!el) return; const f=el.querySelector('.dp-fill'), p=el.querySelector('.dp-pct');
|
||
if(pct<0){ if(p) p.textContent='…'; if(f) f.style.width='100%'; return; } // server sent no Content-Length
|
||
if(f) f.style.width=pct+'%'; if(p) p.textContent=pct+'%';
|
||
}
|
||
function bzDlHide(el){ if(el&&el.parentNode) el.parentNode.removeChild(el); }
|
||
function bzB64(u8){ let s=''; const CH=0x8000; for(let i=0;i<u8.length;i+=CH) s+=String.fromCharCode.apply(null,u8.subarray(i,i+CH)); return btoa(s); }
|
||
|
||
// ---------- Local library (native app only) ----------
|
||
// Downloads land in the app's own Documents folder, which iOS exposes in the Files app as
|
||
// Files → On My iPhone → Biz Connect → Images | Videos | Files
|
||
// (turned on by UIFileSharingEnabled in mobile/scripts/ios-patch.sh). We keep a small index of what
|
||
// was saved: that is what lets an already-downloaded video play from disk instead of streaming again,
|
||
// and what powers Manage storage. The index is only a CACHE of the filesystem — the user can delete
|
||
// anything from the Files app behind our back, so every read is reconciled by bzLibVerify.
|
||
const BZ_LIB_KEY='bzc.lib.v1';
|
||
const BZ_DIR='DOCUMENTS';
|
||
function bzLibOn(){ const C=window.Capacitor; if(!C) return false; const p=C.getPlatform?C.getPlatform():''; return (p==='ios'||p==='android') && !!(C.Plugins&&C.Plugins.Filesystem); }
|
||
function bzLibAll(){ try{ return JSON.parse(localStorage.getItem(BZ_LIB_KEY)||'{}')||{}; }catch(_){ return {}; } }
|
||
function bzLibWrite(m){ try{ localStorage.setItem(BZ_LIB_KEY, JSON.stringify(m)); }catch(_){} }
|
||
function bzLibGet(id){ return bzLibAll()[id]||null; }
|
||
function bzLibPut(id,rec){ const m=bzLibAll(); m[id]=rec; bzLibWrite(m); }
|
||
function bzLibDrop(id){ const m=bzLibAll(); delete m[id]; bzLibWrite(m); }
|
||
function bzLibFolder(mime){ return /^image\//.test(mime||'') ? 'Images' : /^video\//.test(mime||'') ? 'Videos' : 'Files'; }
|
||
function bzMimeFromName(n){
|
||
const e=String(n||'').toLowerCase().split('.').pop();
|
||
if(/^(png|jpg|jpeg|gif|webp|heic|heif|bmp|svg)$/.test(e)) return 'image/'+(e==='jpg'?'jpeg':e);
|
||
if(/^(mp4|mov|m4v|webm|3gp|mkv|avi)$/.test(e)) return 'video/'+(e==='mov'?'quicktime':e);
|
||
if(/^(mp3|wav|m4a|aac|ogg|opus)$/.test(e)) return 'audio/'+e;
|
||
return '';
|
||
}
|
||
// Map a MIME type to a file extension. Needed because some downloads arrive without one — the lightbox
|
||
// hands us a bare "/files/<id>" (no name, no extension) — and a file saved with no extension is one that
|
||
// neither the Photos app will accept nor the Files app / Quick Look can open.
|
||
function bzExtForMime(mime){
|
||
const m=String(mime||'').toLowerCase().split(';')[0].trim();
|
||
const map={'image/jpeg':'jpg','image/jpg':'jpg','image/png':'png','image/gif':'gif','image/webp':'webp','image/heic':'heic','image/heif':'heif','image/bmp':'bmp','image/svg+xml':'svg',
|
||
'video/mp4':'mp4','video/quicktime':'mov','video/webm':'webm','video/3gpp':'3gp','video/x-matroska':'mkv','video/x-msvideo':'avi',
|
||
'audio/mpeg':'mp3','audio/mp4':'m4a','audio/aac':'aac','audio/wav':'wav','audio/x-wav':'wav','audio/ogg':'ogg','audio/opus':'opus',
|
||
'application/pdf':'pdf','application/zip':'zip','text/plain':'txt'};
|
||
if(map[m]) return map[m];
|
||
return (m.split('/')[1]||'').replace(/\+.*$/,'').replace(/[^a-z0-9]+/g,'').slice(0,5); // e.g. "svg+xml"→"svg"
|
||
}
|
||
function bzEnsureExt(name, mime){
|
||
const n=String(name||'file');
|
||
if(/\.[a-z0-9]{1,6}$/i.test(n)) return n; // already carries an extension
|
||
const ext=bzExtForMime(mime); return ext ? n+'.'+ext : n;
|
||
}
|
||
function bzLibSrc(r){ try{ return r&&r.uri ? window.Capacitor.convertFileSrc(r.uri) : null; }catch(_){ return null; } }
|
||
function bzPhotosOn(){ try{ return localStorage.getItem('bzc.photos')!=='off'; }catch(_){ return true; } }
|
||
// Also put saved photos/videos in the Photos app, in a "Connect" album — the way WhatsApp does. The Files
|
||
// folder is the app's own copy (it powers offline playback and Manage storage), but Files is not where
|
||
// people look for media. Only PhotoKit can write to the photo library, hence the media-library plugin.
|
||
// Best-effort throughout: a denied permission or an older app build must never fail a download that has
|
||
// already landed safely in the app folder.
|
||
async function bzSaveToPhotos(uri, mime){
|
||
if(!uri || !bzPhotosOn()) return {ok:false, reason:'off'};
|
||
const isVid=/^video\//.test(mime||'');
|
||
if(!isVid && !/^image\//.test(mime||'')) return {ok:false, reason:'notmedia'}; // documents don't belong in Photos
|
||
const ML=window.Capacitor&&window.Capacitor.Plugins&&window.Capacitor.Plugins.MediaLibrary;
|
||
if(!ML||!ML.saveToAlbum) return {ok:false, reason:'noplugin'}; // plugin not loaded (SPM wiring)
|
||
try{ await ML.saveToAlbum({ path:uri, album:'Connect', kind:isVid?'video':'photo' }); return {ok:true}; }
|
||
catch(e){ const err=String((e&&e.message)||e); return {ok:false, reason:/den|permission|auth/i.test(err)?'denied':'error', err}; }
|
||
}
|
||
// Confirm the file is still on disk. Returns null (and forgets it) if the user deleted it in Files.
|
||
async function bzLibVerify(id){
|
||
const r=bzLibGet(id); if(!r) return null;
|
||
try{
|
||
const st=await window.Capacitor.Plugins.Filesystem.stat({ path:r.path, directory:BZ_DIR });
|
||
if(st && st.size!=null && st.size!==r.size){ r.size=st.size; bzLibPut(id,r); }
|
||
return r;
|
||
}catch(_){ bzLibDrop(id); return null; }
|
||
}
|
||
async function bzLibDelete(id){
|
||
const r=bzLibGet(id); if(!r) return;
|
||
try{ await window.Capacitor.Plugins.Filesystem.deleteFile({ path:r.path, directory:BZ_DIR }); }catch(_){}
|
||
bzLibDrop(id);
|
||
}
|
||
async function bzLibList(){
|
||
const out=[];
|
||
for(const id of Object.keys(bzLibAll())){ const r=await bzLibVerify(id); if(r) out.push(Object.assign({ id }, r)); }
|
||
out.sort((a,b)=>(b.at||0)-(a.at||0));
|
||
return out;
|
||
}
|
||
// Pick a human filename inside the right subfolder, never colliding with something already saved.
|
||
function bzLibPath(name, mime, id){
|
||
const folder=bzLibFolder(mime);
|
||
const safe=(String(name||'file').replace(/[\/\\:*?"<>|\r\n]+/g,'_').trim().slice(0,120))||'file';
|
||
const dot=safe.lastIndexOf('.'); const stem=dot>0?safe.slice(0,dot):safe; const ext=dot>0?safe.slice(dot):'';
|
||
const taken={}; const all=bzLibAll();
|
||
for(const k of Object.keys(all)) if(k!==id && all[k] && all[k].path) taken[all[k].path]=1;
|
||
let p=folder+'/'+safe, i=2;
|
||
while(taken[p]){ p=folder+'/'+stem+' ('+i+')'+ext; i++; }
|
||
return p;
|
||
}
|
||
// Re-sync the video tiles already on screen with the library, in BOTH directions — a fresh download
|
||
// swaps the tile to the local file, and deleting it in Manage storage swaps it back to streaming. Done
|
||
// in place rather than by re-rendering the thread, which would lose scroll position.
|
||
function bzSyncVideoTiles(only){
|
||
if(!bzLibOn()) return;
|
||
document.querySelectorAll('.att-vid[data-aid]').forEach(w=>{
|
||
const id=w.getAttribute('data-aid'); if(only && id!==only) return;
|
||
const v=w.querySelector('.att-vid-v'), ov=w.querySelector('.att-vid-ov'); if(!v||!ov) return;
|
||
const rec=bzLibGet(id), loc=rec?bzLibSrc(rec):null;
|
||
w.classList.toggle('saved', !!loc);
|
||
if(loc){
|
||
if(!w.classList.contains('playing') && v.getAttribute('src')!==loc) v.setAttribute('src', loc);
|
||
ov.title='Play'; ov.innerHTML='<span class="att-vid-btn">'+ic('play',26)+'</span>';
|
||
} else { // local copy gone → back to "not downloaded"
|
||
w.classList.remove('playing'); v.controls=false;
|
||
try{ if(!v.paused) v.pause(); }catch(_){}
|
||
v.removeAttribute('src'); try{ v.load(); }catch(_){}
|
||
ov.title='Download to this device'; ov.innerHTML='<span class="att-vid-btn">'+ic('download',26)+'</span>';
|
||
}
|
||
});
|
||
}
|
||
// Files (native): mirror bzSyncVideoTiles — flip a file tile between "download" and "open" as the local copy
|
||
// appears/disappears. Done in place so the open thread doesn't re-render (scroll position kept).
|
||
function bzSyncFileTiles(only){
|
||
if(!bzLibOn()) return;
|
||
document.querySelectorAll('.att-file-native[data-aid]').forEach(el=>{
|
||
const id=el.getAttribute('data-aid'); if(only && id!==only) return;
|
||
const saved=!!bzLibGet(id);
|
||
el.classList.toggle('saved', saved);
|
||
const act=el.querySelector('.att-file-act'); if(act) act.innerHTML=ic(saved?'externalLink':'download',16);
|
||
el.title = saved ? 'Open' : 'Download to this device';
|
||
});
|
||
}
|
||
// Open an already-downloaded file. Prefer a native Quick Look PREVIEW (the FileOpener plugin) — that's the iOS
|
||
// "look at this file" surface (swipe, pinch, print, its own share button), which is what people expect when
|
||
// they tap a file. Only if the preview plugin isn't in this build do we fall back to the share sheet (which
|
||
// offers "open in another app"), then to a new browser tab.
|
||
async function bzOpenFile(rec, name){
|
||
const uri=rec&&rec.uri; if(!uri) return;
|
||
const P=window.Capacitor&&window.Capacitor.Plugins;
|
||
if(P&&P.FileOpener&&P.FileOpener.preview){ try{ await P.FileOpener.preview({ path:uri }); return; }catch(_){ /* fall through to the share sheet */ } }
|
||
if(P&&P.Share){ try{ await P.Share.share({ title:name||'File', url:uri }); }catch(_){} return; } // catch = user cancelled
|
||
try{ const src=bzLibSrc(rec); if(src) window.open(src, '_blank'); }catch(_){}
|
||
}
|
||
async function nativeSaveFile(url, name, meta){
|
||
const C=window.Capacitor, P=C&&C.Plugins; const Fs=P&&P.Filesystem;
|
||
if(!Fs) return false;
|
||
const id=(meta&&meta.id) || (String(url).split('?')[0].split('/').pop()||'');
|
||
let fname=(String(name||url.split('/').pop()||'file').replace(/[\/\\:*?"<>|\r\n]+/g,'_')).slice(0,120)||'file';
|
||
let mime=(meta&&meta.mime)||bzMimeFromName(fname);
|
||
const onPct=(meta&&meta.onPct)||null;
|
||
const box=onPct?null:bzDlShow(fname); // a video reports % inside its own tile, so no floating chip
|
||
try{
|
||
const res=await fetch(url, {credentials:'include'}); if(!res.ok) throw new Error('http '+res.status);
|
||
// The lightbox / avatar downloads hand us a bare "/files/<id>" — no name, no extension, no mime — so an
|
||
// image was landing in the generic "Files" folder as an extension-less blob and never reaching Photos.
|
||
// Trust the server's Content-Type here, then give the file a real extension, so it goes to the right
|
||
// folder, opens in Quick Look, AND qualifies for the Photos save below.
|
||
if(!mime){ mime=((res.headers.get('Content-Type')||'').split(';')[0]||'').trim().toLowerCase(); }
|
||
fname=bzEnsureExt(fname, mime);
|
||
const lpath=bzLibPath(fname, mime, id);
|
||
const total=+(res.headers.get('Content-Length')||0);
|
||
let uri=null, wrote=0;
|
||
if(res.body && res.body.getReader){
|
||
// Stream to disk instead of buffering. The old path held blob + base64 at once — ~250 MB peak for a
|
||
// 75 MB video, enough to get the WebView killed on a phone. Flush in 3-byte-aligned blocks so each
|
||
// base64 chunk concatenates cleanly onto the file.
|
||
const reader=res.body.getReader(); const BLOCK=3*65536;
|
||
let pend=[], pendLen=0, got=0, first=true, shown=-1;
|
||
const flush=async(u8)=>{
|
||
const data=bzB64(u8);
|
||
// recursive:true creates Documents/<Images|Videos|Files> on first use.
|
||
const w= first ? await Fs.writeFile({ path:lpath, data, directory:BZ_DIR, recursive:true })
|
||
: await Fs.appendFile({ path:lpath, data, directory:BZ_DIR });
|
||
first=false; wrote+=u8.length; if(w&&w.uri) uri=w.uri;
|
||
};
|
||
for(;;){
|
||
const r=await reader.read(); if(r.done) break;
|
||
pend.push(r.value); pendLen+=r.value.length; got+=r.value.length;
|
||
if(pendLen>=BLOCK){
|
||
const all=new Uint8Array(pendLen); let o=0; for(const c of pend){ all.set(c,o); o+=c.length; }
|
||
const n=Math.floor(all.length/3)*3;
|
||
await flush(all.subarray(0,n));
|
||
const rem=all.slice(n); pend=rem.length?[rem]:[]; pendLen=rem.length;
|
||
}
|
||
const pct= total ? Math.max(1, Math.min(99, Math.round(got/total*100))) : -1;
|
||
if(pct!==shown){ shown=pct; bzDlSet(box, pct); if(onPct) onPct(pct); }
|
||
}
|
||
if(pendLen||first){ const all=new Uint8Array(pendLen); let o=0; for(const c of pend){ all.set(c,o); o+=c.length; } await flush(all); }
|
||
if(!uri){ try{ const g=await Fs.getUri({ path:lpath, directory:BZ_DIR }); uri=g&&g.uri; }catch(_){} }
|
||
} else { // no streams (older WebView) — original one-shot path
|
||
const blob=await res.blob();
|
||
const b64=await new Promise((ok,no)=>{ const r=new FileReader(); r.onloadend=()=>ok(String(r.result).split(',')[1]||''); r.onerror=no; r.readAsDataURL(blob); });
|
||
const w=await Fs.writeFile({ path:lpath, data:b64, directory:BZ_DIR, recursive:true });
|
||
uri=w&&w.uri; wrote=blob.size;
|
||
}
|
||
bzDlSet(box,100); if(onPct) onPct(100);
|
||
if(id) bzLibPut(id, { path:lpath, uri:uri||'', name:fname, mime, size:wrote||total||0, at:Date.now() });
|
||
if(id) bzSyncVideoTiles(id);
|
||
const ph=await bzSaveToPhotos(uri, mime);
|
||
const isMedia=/^(image|video)\//.test(mime||'');
|
||
if(ph.ok) toast('Saved to Photos · Connect album');
|
||
else if(isMedia && ph.reason==='denied') toast('Saved to Files. Allow Photos access in Settings to also save to your gallery.');
|
||
else toast('Saved to Biz Connect · '+bzLibFolder(mime));
|
||
if(isMedia && !ph.ok && ph.reason!=='off'){ try{ pdbg('photos-fail', { reason:ph.reason, err:ph.err||'' }); }catch(_){} } // telemetry: why Photos save failed
|
||
}catch(e){ toast("Couldn't save the file"); }
|
||
finally{ bzDlHide(box); }
|
||
return true; // handled either way — never fall through to navigation
|
||
}
|
||
(function(){ var C=window.Capacitor; var p=(C&&C.getPlatform)?C.getPlatform():''; if(p!=='ios'&&p!=='android') return;
|
||
document.addEventListener('click', function(e){
|
||
var a=e.target&&e.target.closest&&e.target.closest('a[download]'); if(!a) return;
|
||
var href=a.getAttribute('href'); if(!href) return;
|
||
e.preventDefault(); e.stopPropagation(); // never let the WebView navigate to the file
|
||
var P=C.Plugins;
|
||
// Real inline save into the app's Files folder (id comes from /files/<id>; mime refines the subfolder).
|
||
if(P&&P.Filesystem){ nativeSaveFile(href, a.getAttribute('download')||'', { mime:a.getAttribute('data-mime')||'' }); return; }
|
||
var isImg = /\.(png|jpe?g|gif|webp|heic|heif|bmp|svg)(\?|#|$)/i.test(href) || !!a.closest('.lightbox');
|
||
toast(isImg ? 'Press and hold the image, then tap "Save to Photos".' : 'Saving files needs the latest app update.');
|
||
}, true); // capture phase so it wins over the lightbox / bubble handlers
|
||
})();
|
||
// In-chat image viewer (lightbox): open on click, close on ✕ / backdrop / Esc.
|
||
function openLightbox(src){
|
||
if(document.getElementById('lightbox')) return;
|
||
// #3: build a gallery from the loaded thread's images so ← / → flip through them.
|
||
const gallery=THREAD.filter(m=>m.attachment && m.attachment.isImage && !m.deleted).map(m=>'/files/'+m.attachment.id);
|
||
let idx=gallery.indexOf(src); const multi=idx>=0 && gallery.length>1; if(idx<0) idx=0;
|
||
const ov=document.createElement('div'); ov.className='lightbox'; ov.id='lightbox';
|
||
ov.innerHTML='<button class="lb-close" title="Close (Esc)">'+ic('x',22)+'</button>'
|
||
+(multi?'<button class="lb-nav lb-prev" title="Previous (←)">'+ic('chevronLeft',18)+'</button>':'')
|
||
+'<img src="'+pEsc(src)+'" alt="">'
|
||
+(multi?'<button class="lb-nav lb-next" title="Next (→)">'+ic('chevronRight',18)+'</button>':'')
|
||
+'<a class="lb-dl" href="'+pEsc(src)+'" download title="Download">'+ic('download',20)+'</a>'
|
||
+'<div class="lb-zoom"><button class="lb-zout" title="Zoom out (−)">'+ic('search',15)+'<b>−</b></button>'
|
||
+'<button class="lb-zin" title="Zoom in (+)">'+ic('search',15)+'<b>+</b></button></div>'; // #3
|
||
document.body.appendChild(ov);
|
||
const img=ov.querySelector('img'), dl=ov.querySelector('.lb-dl');
|
||
// ---- #3: zoom & pan. Wheel / pinch to zoom, double-click or +/− to step, drag to pan when zoomed. ----
|
||
let z=1, tx=0, ty=0;
|
||
const MINZ=1, MAXZ=6;
|
||
const apply=()=>{
|
||
if(z<=1){ z=1; tx=0; ty=0; }
|
||
img.style.transform='translate('+tx+'px,'+ty+'px) scale('+z+')';
|
||
img.classList.toggle('zoomed', z>1);
|
||
ov.classList.toggle('zooming', z>1); // hide the ←/→ arrows while zoomed so panning isn't hijacked
|
||
};
|
||
const zoomAt=(factor, cx, cy)=>{
|
||
const prev=z; z=Math.min(MAXZ, Math.max(MINZ, z*factor));
|
||
if(z===prev) return;
|
||
const r=img.getBoundingClientRect();
|
||
const ox=(cx===undefined?r.left+r.width/2:cx)-(r.left+r.width/2);
|
||
const oy=(cy===undefined?r.top+r.height/2:cy)-(r.top+r.height/2);
|
||
const k=z/prev;
|
||
tx=(tx-ox)*k+ox; ty=(ty-oy)*k+oy; // keep the point under the cursor anchored
|
||
apply();
|
||
};
|
||
const resetZoom=()=>{ z=1; tx=0; ty=0; apply(); };
|
||
ov.addEventListener('wheel',(e)=>{ e.preventDefault(); zoomAt(e.deltaY<0?1.18:1/1.18, e.clientX, e.clientY); },{passive:false});
|
||
img.addEventListener('dblclick',(e)=>{ e.preventDefault(); if(z>1) resetZoom(); else zoomAt(2.5, e.clientX, e.clientY); });
|
||
// drag to pan (only meaningful when zoomed in)
|
||
let dragging=false, sx=0, sy=0, stx=0, sty=0;
|
||
img.addEventListener('mousedown',(e)=>{ if(z<=1) return; e.preventDefault(); dragging=true; sx=e.clientX; sy=e.clientY; stx=tx; sty=ty; img.classList.add('grabbing'); });
|
||
window.addEventListener('mousemove',(e)=>{ if(!dragging) return; tx=stx+(e.clientX-sx); ty=sty+(e.clientY-sy); apply(); });
|
||
window.addEventListener('mouseup',()=>{ dragging=false; img.classList.remove('grabbing'); });
|
||
// touch: pinch to zoom, one-finger drag to pan when zoomed
|
||
let pinchD=0, pz=1, ptx=0, pty=0, tsx=0, tsy=0;
|
||
const dist=(t)=>Math.hypot(t[0].clientX-t[1].clientX, t[0].clientY-t[1].clientY);
|
||
img.addEventListener('touchstart',(e)=>{
|
||
if(e.touches.length===2){ pinchD=dist(e.touches); pz=z; }
|
||
else if(e.touches.length===1 && z>1){ tsx=e.touches[0].clientX; tsy=e.touches[0].clientY; ptx=tx; pty=ty; }
|
||
},{passive:true});
|
||
img.addEventListener('touchmove',(e)=>{
|
||
if(e.touches.length===2 && pinchD){ e.preventDefault(); const d=dist(e.touches); z=Math.min(MAXZ,Math.max(MINZ, pz*(d/pinchD))); apply(); }
|
||
else if(e.touches.length===1 && z>1){ e.preventDefault(); tx=ptx+(e.touches[0].clientX-tsx); ty=pty+(e.touches[0].clientY-tsy); apply(); }
|
||
},{passive:false});
|
||
img.addEventListener('touchend',()=>{ pinchD=0; });
|
||
|
||
const syncArrows=()=>{ if(!multi) return; const pB=ov.querySelector('.lb-prev'), nB=ov.querySelector('.lb-next'); if(pB) pB.style.visibility=idx<=0?'hidden':'visible'; if(nB) nB.style.visibility=idx>=gallery.length-1?'hidden':'visible'; };
|
||
const show=(i)=>{ if(!multi) return; idx=Math.max(0, Math.min(gallery.length-1, i)); const u=gallery[idx]; img.src=u; if(dl) dl.setAttribute('href',u); resetZoom(); syncArrows(); }; // clamp (no wrap → no blank slide past the last) + hide the end arrow
|
||
const close=()=>{ ov.remove(); document.removeEventListener('keydown', onKey); };
|
||
// #8: animated pager — the current image slides fully out in the swipe direction, then the next/prev
|
||
// image slides in from the OPPOSITE side. Stops at the first/last.
|
||
const pagerGo=(dir)=>{ // dir:+1 next (current exits left, next enters from right), -1 prev (mirror)
|
||
const target = idx + (dir>0?1:-1);
|
||
if(!multi || target<0 || target>=gallery.length){ img.style.transition='transform .18s'; img.style.transform=''; return; } // at an edge → spring back
|
||
const W=(window.innerWidth||400); let done=false;
|
||
const finish=()=>{ if(done) return; done=true; img.removeEventListener('transitionend', finish);
|
||
show(target); // load the target image; resetZoom clears its transform
|
||
img.style.transition='none'; img.style.transform='translateX('+(dir>0?W:-W)+'px)'; // stage off the OPPOSITE edge (next=right, prev=left)
|
||
void img.offsetWidth; // force that staged position to PAINT, else the browser animates from the exit side (wrong direction)
|
||
img.style.transition='transform .22s cubic-bezier(.3,.72,.3,1)'; img.style.transform='translateX(0)';
|
||
setTimeout(()=>{ if(img){ img.style.transition=''; img.style.transform=''; } ov.style.background=''; }, 240);
|
||
};
|
||
img.style.transition='transform .16s ease-out'; img.style.transform='translateX('+(dir>0?-W:W)+'px)'; // current slides out (next→left, prev→right)
|
||
img.addEventListener('transitionend', finish); setTimeout(finish, 180); // fallback if transitionend misses
|
||
};
|
||
syncArrows(); // initial: hide prev at first / next at last
|
||
// #8: swipe DOWN (when not zoomed) to dismiss, like native photo viewers — drag follows the finger and
|
||
// fades the backdrop; release past a threshold closes, otherwise it springs back.
|
||
let dsy=0, dsx=0, dDrag=false;
|
||
img.addEventListener('touchstart',(e)=>{ if(z<=1 && e.touches.length===1){ dDrag=true; dsy=e.touches[0].clientY; dsx=e.touches[0].clientX; } },{passive:true});
|
||
img.addEventListener('touchmove',(e)=>{ if(!dDrag||z>1||e.touches.length!==1) return; const dy=e.touches[0].clientY-dsy, dx=e.touches[0].clientX-dsx;
|
||
if(dy>0 && dy>Math.abs(dx)){ img.style.transition='none'; img.style.transform='translateY('+dy+'px)'; ov.style.background='rgba(0,0,0,'+Math.max(.15,0.92-dy/500)+')'; } // #8: follow finger down to dismiss
|
||
else if(multi && Math.abs(dx)>Math.abs(dy)){ img.style.transition='none'; img.style.transform='translateX('+dx+'px)'; } // #8: follow finger sideways to flip
|
||
},{passive:true});
|
||
img.addEventListener('touchend',(e)=>{ if(!dDrag) return; dDrag=false; const t=e.changedTouches[0]; const dy=t?(t.clientY-dsy):0, dx=t?(t.clientX-dsx):0;
|
||
if(z<=1 && dy>100 && dy>Math.abs(dx)){ close(); return; } // #8: swipe down = dismiss
|
||
if(z<=1 && multi && Math.abs(dx)>50 && Math.abs(dx)>Math.abs(dy)){ pagerGo(dx<0?1:-1); return; } // #8: swipe left/right = animated next/prev
|
||
img.style.transition='transform .18s'; img.style.transform=''; ov.style.background=''; setTimeout(()=>{ if(img) img.style.transition=''; },200); // no nav → spring back
|
||
},{passive:true});
|
||
const onKey=(e)=>{
|
||
if(e.key==='Escape'){ e.preventDefault(); if(z>1){ resetZoom(); return; } close(); }
|
||
else if(e.key==='ArrowLeft'){ e.preventDefault(); show(idx-1); }
|
||
else if(e.key==='ArrowRight'){ e.preventDefault(); show(idx+1); }
|
||
else if(e.key==='+'||e.key==='='){ e.preventDefault(); zoomAt(1.3); }
|
||
else if(e.key==='-'||e.key==='_'){ e.preventDefault(); zoomAt(1/1.3); }
|
||
else if(e.key==='0'){ e.preventDefault(); resetZoom(); }
|
||
};
|
||
ov.addEventListener('click',(e)=>{
|
||
if(e.target.closest('.lb-zin')){ zoomAt(1.3); return; }
|
||
if(e.target.closest('.lb-zout')){ zoomAt(1/1.3); return; }
|
||
if(e.target.closest('.lb-prev')){ show(idx-1); return; }
|
||
if(e.target.closest('.lb-next')){ show(idx+1); return; }
|
||
if(e.target.closest('.lb-close')) return close();
|
||
if(e.target===ov){ if(z>1){ resetZoom(); return; } close(); } // clicking the backdrop un-zooms first
|
||
});
|
||
document.addEventListener('keydown', onKey);
|
||
}
|
||
function updateBubble(m){
|
||
const sel=(window.CSS&&CSS.escape)?CSS.escape(m.id):m.id;
|
||
const el=document.querySelector('#msgs .bubble[data-id="'+sel+'"]');
|
||
if(el){ el.outerHTML=bubbleHTML(m); const n=document.querySelector('#msgs .bubble[data-id="'+sel+'"]'); twemojify(n); }
|
||
}
|
||
// Floating date pill (updates to the day at the top of the viewport) + jump-to-latest button.
|
||
function onMsgsScroll(){
|
||
const box=document.getElementById('msgs'); if(!box) return;
|
||
if(box.scrollTop < 700 && box.scrollTop >= 0 && _hasMoreOlder && !_loadingOlder && Date.now()>_olderCd) loadOlder(); // pull older history EARLY (while still scrolling down, before the rubber-band top) so the prepend+anchor never interrupts momentum; skip negative (rubber-band) scrollTop
|
||
const nearBottom=(box.scrollHeight - box.scrollTop - box.clientHeight) < 120;
|
||
const jl=document.getElementById('jumpLatest'); if(jl) jl.style.display=nearBottom?'none':'grid';
|
||
const fd=document.getElementById('floatDate'); if(!fd) return;
|
||
if(nearBottom){ fd.style.display='none'; return; }
|
||
const boxTop=box.getBoundingClientRect().top; let label='';
|
||
box.querySelectorAll('.day-sep').forEach(s=>{ if(s.getBoundingClientRect().top - boxTop <= 10) label=s.textContent; });
|
||
if(label){ fd.style.top=(box.offsetTop+6)+'px'; fd.textContent=label; fd.style.display='block'; } else fd.style.display='none';
|
||
}
|
||
function dayKey(ts){ return new Date(ts||Date.now()).toDateString(); }
|
||
function dayLabel(ts){ const d=new Date(ts||Date.now()), n=new Date(); if(d.toDateString()===n.toDateString()) return 'Today'; const y=new Date(n); y.setDate(n.getDate()-1); if(d.toDateString()===y.toDateString()) return 'Yesterday'; return d.toLocaleDateString([], {weekday:'long', month:'long', day:'numeric', year:'numeric'}); }
|
||
let _lastDay='', _lastMineId='', _lastGroupId=''; // _lastGroupId: the group's newest message shows the shared "Seen by"
|
||
function lastMineId(){ for(let i=THREAD.length-1;i>=0;i--){ if(THREAD[i].from===ME.id && !THREAD[i].system) return THREAD[i].id; } return ''; }
|
||
function lastGroupMsgId(){ for(let i=THREAD.length-1;i>=0;i--){ const m=THREAD[i]; if(!m.system && !m.deleted) return m.id; } return ''; } // #2: last real message in the group (any sender)
|
||
function renderThread(keepScroll){
|
||
const box=document.getElementById('msgs'); if(!box) return;
|
||
rendered.clear(); _lastDay=''; _lastMineId=lastMineId(); _lastGroupId=lastGroupMsgId();
|
||
if(!THREAD.length){ box.innerHTML='<div class="empty-thread">No messages yet — say hello 👋</div>'; return; }
|
||
let html='';
|
||
for(const m of THREAD){ const dk=dayKey(m.created_at); if(dk!==_lastDay){ html+='<div class="day-sep"><span>'+pEsc(dayLabel(m.created_at))+'</span></div>'; _lastDay=dk; } rendered.add(m.id); html+=bubbleHTML(m); }
|
||
box.innerHTML=html; twemojify(box);
|
||
if(!keepScroll){
|
||
if(_convoRevealed){
|
||
// #7: a later render during the SAME open (network arriving after the cache render) — just stay at
|
||
// the newest message, no hide/reveal, so there's no second flash/"dance".
|
||
box.scrollTop=box.scrollHeight; requestAnimationFrame(()=>{ const b=document.getElementById('msgs'); if(b) b.scrollTop=b.scrollHeight; });
|
||
} else {
|
||
// #7: FIRST render — show the newest message IMMEDIATELY (visible), so the slide-in transition
|
||
// reveals real content rather than an empty pane that then pops (that was the "jumping" feel). The
|
||
// pin schedule + image-load pins in openConvo keep it glued to the bottom as late images load, so it
|
||
// still doesn't dance.
|
||
_convoRevealed=true; box.style.visibility='visible'; box.scrollTop=box.scrollHeight;
|
||
requestAnimationFrame(()=>{ const b=document.getElementById('msgs'); if(b && !b.querySelector('.new-sep')) b.scrollTop=b.scrollHeight; });
|
||
}
|
||
}
|
||
if(_selMode) box.querySelectorAll('.bubble').forEach(b=>{ if(_selIds.has(b.dataset.id)) b.classList.add('selected'); }); // #1: keep selection across re-render
|
||
}
|
||
function appendBubble(m){
|
||
// (Open is now a compositor opacity/transform fade — a DOM write during it doesn't stall the animation and is
|
||
// masked by the low opacity, so we no longer defer appends to the end. That's what removed the settle-on-landing.)
|
||
if(rendered.has(m.id)) return; rendered.add(m.id);
|
||
const box=document.getElementById('msgs'); if(!box) return;
|
||
// Was the user already at the bottom? If they've scrolled UP to read history, an incoming message
|
||
// must NOT yank them back down (that made the thread feel "stuck" — you couldn't stay scrolled up,
|
||
// especially now that background messages arrive live). Keep their position + flag "jump to latest".
|
||
const atBottom=(box.scrollHeight - box.scrollTop - box.clientHeight) < 160;
|
||
const empty=box.querySelector('.empty-thread'); if(empty) empty.remove();
|
||
// A new message becomes the newest: move the group "Seen by" off the previous last message.
|
||
if(!m.system && !m.deleted){ if(_lastGroupId){ const pe=box.querySelector('.bubble[data-id="'+((window.CSS&&CSS.escape)?CSS.escape(_lastGroupId):_lastGroupId)+'"] .seenby'); if(pe) pe.remove(); } _lastGroupId=m.id; if(m.from===ME.id) _lastMineId=m.id; }
|
||
const dk=dayKey(m.created_at); if(dk!==_lastDay){ box.insertAdjacentHTML('beforeend', '<div class="day-sep"><span>'+pEsc(dayLabel(m.created_at))+'</span></div>'); _lastDay=dk; }
|
||
box.insertAdjacentHTML('beforeend', bubbleHTML(m));
|
||
twemojify(box.lastElementChild);
|
||
// Auto-scroll only if they were at the bottom, or it's MY own message (sending always jumps down).
|
||
if(atBottom || m.from===ME.id){ box.scrollTop=box.scrollHeight; }
|
||
else { const jl=document.getElementById('jumpLatest'); if(jl) jl.style.display='grid'; } // let them jump when ready
|
||
}
|
||
// Slide-end update WITHOUT rebuilding the DOM: the cache render already painted the latest page, and the
|
||
// network result is almost always that same page ± a couple of brand-new tail messages. Append only those
|
||
// (0-2 nodes) instead of re-running innerHTML over all 40 — that full rebuild's layout+paint was the residual
|
||
// slide-end cost. Returns false (→ caller does a full renderThread) only if an already-shown message was
|
||
// removed/edited/deleted since the cache render, which is rare and not worth a targeted DOM patch.
|
||
function reconcileOpen(prev){
|
||
const box=document.getElementById('msgs'); if(!box || !prev || !prev.length) return false;
|
||
const nextById=new Map(THREAD.map(m=>[m.id,m]));
|
||
for(const m of prev){ const n=nextById.get(m.id); if(!n || (m.edited_at||0)!==(n.edited_at||0) || !!m.deleted!==!!n.deleted) return false; }
|
||
for(const m of THREAD){ if(!rendered.has(m.id)) appendBubble(m); } // we're at the bottom on open → appendBubble keeps us pinned to the newest
|
||
return true;
|
||
}
|
||
let _openUnread=0; // set by selectChat (the unread count before it's reset) so we can open at the first unread
|
||
// Per-conversation drafts: unsent text is kept (across chat switches AND reloads) until sent.
|
||
function draftKey(kind,id){ return 'draft_'+((ME&&ME.id)||'')+'_'+kind+':'+id; }
|
||
function getDraft(kind,id){ try{ return localStorage.getItem(draftKey(kind,id))||''; }catch(_){ return ''; } }
|
||
function setDraft(kind,id,val){ try{ if(val&&val.trim()) localStorage.setItem(draftKey(kind,id), val); else localStorage.removeItem(draftKey(kind,id)); }catch(_){} }
|
||
// Compositor-only open slide (workflow-verified). armOpenSlide() runs AFTER openConvo's synchronous
|
||
// render: it pins the pane off-screen (chat-opening), lets a frame paint it, then adds .chat-anim on a
|
||
// double-rAF so the compositor runs the 300ms transform with the main thread idle. All scrollTop/innerHTML
|
||
// work (network re-render + pin schedule + live appends) is queued via afterOpenSlide and flushed only at
|
||
// transitionend. A token guards rapid chat-to-chat re-taps; a single stored handler avoids listener leaks.
|
||
let _slideQueue=[], _slideEndHandler=null;
|
||
function afterOpenSlide(fn){ if(!document.body.classList.contains('chat-opening')){ fn(); return; } _slideQueue.push(fn); }
|
||
function flushSlideQueue(){ const q=_slideQueue; _slideQueue=[]; for(const fn of q){ try{ fn(); }catch(_){} } }
|
||
function armOpenSlide(){
|
||
// OPEN = instant + a quick opacity fade (user-chosen). The cache-rendered conversation is already in place;
|
||
// we just fade it from 0→1. No transform, no off-screen pane, no list-column display toggle → nothing to
|
||
// reflow when it ends, so the old end-of-slide settle cannot occur. Queued network reconcile / pins / live
|
||
// appends still flush at fade-end via the same afterOpenSlide mechanism. Token guards rapid chat re-taps.
|
||
const b=document.body, c=document.querySelector('.shell > .content');
|
||
const token=(window.__slideTok=(window.__slideTok||0)+1);
|
||
if(c && _slideEndHandler){ c.removeEventListener('transitionend', _slideEndHandler); _slideEndHandler=null; }
|
||
clearTimeout(window.__chatOpenT);
|
||
if(c) c.classList.remove('chat-fadein');
|
||
b.classList.add('chat-opening'); // content -> opacity:0 (still laid out, just invisible)
|
||
const done=()=>{ if(window.__slideTok!==token) return;
|
||
if(c && _slideEndHandler){ c.removeEventListener('transitionend', _slideEndHandler); _slideEndHandler=null; }
|
||
clearTimeout(window.__chatOpenT);
|
||
b.classList.remove('chat-opening'); // opacity returns to the resting 1 — no transform/list toggle to reflow
|
||
if(c) c.classList.remove('chat-fadein');
|
||
flushSlideQueue(); // network reconcile / pins / queued appends run now
|
||
};
|
||
_slideEndHandler=function(e){ if(e.target===c && e.propertyName==='transform') done(); }; // transform is the longer of the two transitions → fires after both finish
|
||
if(c) c.addEventListener('transitionend', _slideEndHandler);
|
||
requestAnimationFrame(()=>{
|
||
if(window.__slideTok!==token) return;
|
||
if(c) c.classList.add('chat-fadein'); // slide translateX(12%)->0 + opacity 0->1 (pure compositor)
|
||
window.__chatOpenT=setTimeout(done, 320); // fallback if transitionend is missed (240ms + margin)
|
||
});
|
||
}
|
||
async function openConvo(kind,id){
|
||
const it=rowFor(kind,id)||{kind,id,name:'Conversation'};
|
||
const _unreadN=_openUnread; _openUnread=0; // consume the captured unread count (#3)
|
||
_convoRevealed=false; // #7: this open hasn't revealed yet
|
||
_forcePinOpen=true; // glue to newest until the user actually scrolls (cleared on the first touch/wheel below)
|
||
let _openScrolled=false, _openPinTimers=[]; // the moment the user scrolls we STOP the open-pin schedule so it can't yank them back off older history they're trying to reach
|
||
convoIsGroup=(kind==='group');
|
||
const el=document.getElementById('chatPanel'); el.classList.remove('center');
|
||
el.innerHTML=convoShellHTML(it);
|
||
// (The open slide/parallax is driven by the body.chat-opening class added in selectChat — animating the
|
||
// whole .content pane over the list, not the inner panel, so it reads as a native push.)
|
||
// #1: drag-and-drop a file/video/image anywhere on the conversation to send it.
|
||
el.ondragover=(e)=>{ if(e.dataTransfer&&Array.from(e.dataTransfer.types||[]).includes('Files')){ e.preventDefault(); el.classList.add('drag-over'); } };
|
||
el.ondragleave=(e)=>{ if(e.relatedTarget===null||!el.contains(e.relatedTarget)) el.classList.remove('drag-over'); };
|
||
el.ondrop=(e)=>{ const f=e.dataTransfer&&e.dataTransfer.files&&e.dataTransfer.files[0]; if(f){ e.preventDefault(); el.classList.remove('drag-over'); uploadFile(f); } };
|
||
const back=document.getElementById('convoBack'); if(back) back.onclick=showWelcome;
|
||
const form=document.getElementById('composer'); if(form) form.addEventListener('submit',(e)=>{ e.preventDefault(); sendMessage(); });
|
||
clearReply(); cancelEdit(); hideAttach();
|
||
const ab2=document.getElementById('attachBtn'); const apop=document.getElementById('attachPop');
|
||
if(ab2&&apop) ab2.onclick=(e)=>{ e.stopPropagation(); apop.style.display = apop.style.display==='none' ? 'flex' : 'none'; }; // show a composer-anchored menu instead of firing the generic mid-screen OS chooser straight away
|
||
if(apop) apop.querySelectorAll('.attach-opt').forEach(b=>b.onclick=(e)=>{ e.stopPropagation(); apop.style.display='none'; const src=b.dataset.src; const inp = src==='photo'?document.getElementById('fileInputImg') : src==='camera'?document.getElementById('fileInputCam') : document.getElementById('fileInput'); if(inp) inp.click(); }); // Photos → gallery, Camera → capture, Document → any file
|
||
['fileInput','fileInputImg','fileInputCam'].forEach(fid=>{ const fi=document.getElementById(fid); if(fi) fi.onchange=()=>{ Array.from(fi.files||[]).forEach(uploadFile); fi.value=''; }; }); // #5: queue every selected file from whichever scoped input fired
|
||
const eb=document.getElementById('emojiBtn'); if(eb) eb.onclick=(e)=>{ e.stopPropagation(); emojiOpen?closeEmoji():openEmoji('compose', eb); };
|
||
const fb=document.getElementById('fmtBtn'); if(fb) fb.onclick=()=>{ const bar=document.getElementById('fmtBar'); if(bar) bar.style.display=bar.style.display==='none'?'flex':'none'; };
|
||
const fbar=document.getElementById('fmtBar'); if(fbar) fbar.querySelectorAll('button[data-fmt]').forEach(b=>b.onclick=()=>applyFmt(b.dataset.fmt));
|
||
const pb=document.getElementById('pollBtn'); if(pb) pb.onclick=()=>openPollModal(id);
|
||
const inpEl=document.getElementById('msgInput');
|
||
if(inpEl){
|
||
const _d=getDraft(kind,id); if(_d){ inpEl.value=_d; autoGrow(inpEl); } // restore unsent draft
|
||
inpEl.addEventListener('paste', onPaste);
|
||
inpEl.addEventListener('input', ()=>{ maybeAutocorrect(inpEl); autoGrow(inpEl); setDraft(kind,id,inpEl.value); noteTyping(); }); // auto-correct common typos, keep the draft in sync + emit "typing…"
|
||
inpEl.addEventListener('blur', ()=>{ stopTyping(); setTimeout(()=>{ const a=document.activeElement; const stillTyping=a&&(a.tagName==='TEXTAREA'||a.tagName==='INPUT'); if(!stillTyping && window.__bzResetKb) window.__bzResetKb(); }, 120); }); // #1: composer lost focus → keyboard is closing → drop the --kb lift so no empty gap remains
|
||
inpEl.addEventListener('keydown', (e)=>{ if(e.key==='Enter' && !e.shiftKey && !isMobileUA()){ if(mentionItems && mentionItems.length) return; e.preventDefault(); sendMessage(); } }); // Desktop: Enter sends, Shift+Enter = newline. Mobile: the Return key inserts a newline (default) — you send with the send button, like every native chat app.
|
||
// Desktop: LEFT-click a red-squiggled word → native spelling suggestions right there (the shell
|
||
// pops a suggestions menu only if the clicked word is actually misspelled). No right-click needed.
|
||
if(window.bizConnectNative && window.bizConnectNative.spellSuggestAt){
|
||
inpEl.addEventListener('click',(e)=>{ if(inpEl.selectionStart!==inpEl.selectionEnd) return; try{ window.bizConnectNative.spellSuggestAt(e.clientX, e.clientY); }catch(_){} });
|
||
}
|
||
}
|
||
refreshTyping(kind,id); // if someone's already typing in this conversation, show it right away
|
||
// #5: tapping the header avatar previews the DP / group photo full-size.
|
||
const hav=document.querySelector('#chatPanel .convo-head > .avatar'); if(hav){ hav.style.cursor='pointer'; hav.title='View photo'; hav.onclick=(e)=>{ e.stopPropagation(); const r=rowFor(kind,id)||it; if(r&&r.avatar) openLightbox(r.avatar); else toast('No profile photo'); }; }
|
||
const ci=document.getElementById('convoInfo'); if(ci) ci.onclick=()=>openGroupInfo(id);
|
||
const ct=document.getElementById('convoTitle'); if(ct) ct.onclick=()=>{ if(kind==='group') openGroupInfo(id); else { const r=rowFor(kind,id); openSharedItems('dm', id, (r&&r.name)||it.name||''); } }; // name → group: info+media; DM: media/files
|
||
const cc=document.getElementById('convoCall'); if(cc) cc.onclick=()=>(kind==='group'?startOrJoinGroupCall(id):startOrJoinDmCall(id));
|
||
// If this conversation has an active INCOMING call, (re)show the Join/Decline invite. When the chat
|
||
// is opened from a call notification, the original transient invite popup may already have closed —
|
||
// this guarantees a landing Join button. `quiet` avoids firing a duplicate OS notification.
|
||
if(it.callActive && it.incomingRoom && meetRoom!==it.incomingRoom && !document.getElementById('ci-'+it.incomingRoom) && !nativeCallOn()){ // CallKit devices ring via the system, not this popup
|
||
showCallInvite(it.incomingRoom, it.callByName||it.name, kind==='group'?{kind:'group',id}:{kind:'dm',id}, kind==='group'?it.name:undefined, true);
|
||
}
|
||
const csb=document.getElementById('convoSearch'); const cshead=document.getElementById('convoSearchHead'); const csin=document.getElementById('convoSearchInput');
|
||
if(csb) csb.onclick=()=>{ if(!cshead) return; cshead.style.display='flex'; if(csin){ csin.value=''; setTimeout(()=>csin.focus(),0); } };
|
||
if(csin) csin.oninput=()=>runSearch(csin.value);
|
||
if(csin) csin.addEventListener('keydown',e=>{ if(e.key==='Enter'){ e.preventDefault(); gotoHit(_searchIdx + (e.shiftKey?-1:1)); } if(e.key==='Escape'){ closeSearch(); } });
|
||
const csClose=document.getElementById('convoSearchClose'); if(csClose) csClose.onclick=closeSearch;
|
||
const csPrev=document.getElementById('convoSearchPrev'); if(csPrev) csPrev.onclick=()=>gotoHit(_searchIdx-1);
|
||
const csNext=document.getElementById('convoSearchNext'); if(csNext) csNext.onclick=()=>gotoHit(_searchIdx+1);
|
||
const box=document.getElementById('msgs'); if(box) box.addEventListener('click',(e)=>{
|
||
if(_selMode){ const bb=e.target.closest('.bubble'); if(bb) toggleSel(bb); return; } // #1: selection mode — tap toggles
|
||
const fw=e.target.closest('.fwd-btn'); if(fw){ enterSelect(fw.dataset.fwd); return; } // #1: enter forward-selection
|
||
const qz=e.target.closest('.quote'); if(qz && qz.dataset.jid){ jumpToMessage(qz.dataset.jid, +qz.dataset.jat||0); return; } // #8: tap a reply → go to the original
|
||
// #5: a Biz Connect meeting link posted in chat should JOIN IN PLACE — not open a new tab/window.
|
||
const ml=e.target.closest('a.msg-link');
|
||
if(ml && ml.href){ try{ const u=new URL(ml.href, location.origin); const code=u.searchParams.get('meet');
|
||
if(u.origin===location.origin && code && /^\d{6}$/.test(code)){ e.preventDefault(); switchTab('meeting'); enterMeeting(code); return; } }catch(_){} }
|
||
const im=e.target.closest('.att-img'); if(im && im.dataset.img){ openLightbox(im.dataset.img); return; }
|
||
const po=e.target.closest('.poll-opt'); if(po){ if(!po.disabled) votePoll(po.dataset.poll, +po.dataset.idx); return; }
|
||
const pcl=e.target.closest('.poll-close'); if(pcl){ closePoll(pcl.dataset.poll); return; }
|
||
const rb=e.target.closest('.reply-btn'); if(rb){ const mm=THREAD.find(x=>x.id===rb.dataset.id); if(mm) setReply(mm); return; }
|
||
const ed=e.target.closest('.edit-btn'); if(ed){ const mm=THREAD.find(x=>x.id===ed.dataset.edit); if(mm) startEdit(mm); return; }
|
||
const dl=e.target.closest('.del-btn'); if(dl){ deleteMessage(dl.dataset.del); return; }
|
||
// #9: a phone number. On mobile the tel: link lets the OS ask "Call this number?" — just let it through.
|
||
// On desktop there's no dialer, so offer to copy it instead of doing nothing.
|
||
const tel=e.target.closest('.msg-tel');
|
||
if(tel){
|
||
if(isMobileUA()) return; // OS handles the call prompt
|
||
e.preventDefault();
|
||
const num=tel.dataset.tel||'';
|
||
bzConfirm(num, {title:'Phone number', okText:'Copy number'}).then(ok=>{ if(ok) fallbackCopy(num, ()=>toast('Number copied')); });
|
||
return;
|
||
}
|
||
const pf=e.target.closest('.profile-open'); if(pf){ e.stopPropagation(); openMiniProfile(pf.dataset.uid, pf); return; } // #1: sender → mini profile
|
||
const qr=e.target.closest('.qr-btn'); if(qr){ reactToMessage(qr.dataset.id, qr.dataset.emoji); return; } // #4: one-tap reaction
|
||
const mb=e.target.closest('.more-btn'); if(mb){ e.stopPropagation(); openMsgMore(mb.dataset.more, mb); return; } // #4: reply/forward/copy/delete
|
||
const ab=e.target.closest('.react-btn'); if(ab){ openEmojiForReact(ab.dataset.id, ab); return; }
|
||
const ch=e.target.closest('.react-chip'); if(ch){ reactToMessage(ch.dataset.id, ch.dataset.emoji); return; }
|
||
const sb=e.target.closest('.seenby,.rcpt.grp'); if(sb){ const bub=sb.closest('.bubble'); const mid=bub&&bub.dataset.id; const msg=(mid&&THREAD.find(x=>x.id===mid))||{ seenBy:(sb.dataset.seen||'').split('|').filter(Boolean) }; showSeenByModal(msg); return; }
|
||
// Tap-to-reveal (mobile): a tap on the bubble body (not an action) reveals its reply/react/delete
|
||
// icons; the action only fires on a SECOND tap once they're shown (icons are pointer-events:none
|
||
// until revealed). Tapping elsewhere hides them.
|
||
const bub=e.target.closest('.bubble'); const already=bub&&bub.classList.contains('show-actions');
|
||
box.querySelectorAll('.bubble.show-actions').forEach(b=>b.classList.remove('show-actions'));
|
||
if(bub && !already && !bub.classList.contains('deleted')) bub.classList.add('show-actions');
|
||
});
|
||
if(box) box.addEventListener('scroll', onMsgsScroll);
|
||
if(box){ const _rel=()=>{ _forcePinOpen=false; _openScrolled=true; _openPinTimers.forEach(clearTimeout); _openPinTimers.length=0; }; box.addEventListener('touchmove', _rel, {passive:true}); box.addEventListener('wheel', _rel, {passive:true}); } // the user's first real scroll gesture hands control back: stop force-gluing to the newest AND cancel the pending open-pins so scrolling up for older history isn't yanked back down
|
||
if(box) enablePullRefresh(box, loadOlder); // pull down at the top loads OLDER history (or no-ops); never jumps to the newest/bottom
|
||
const jl=document.getElementById('jumpLatest'); if(jl) jl.onclick=()=>{ const b=document.getElementById('msgs'); if(b) b.scrollTop=b.scrollHeight; };
|
||
composeMentions=new Map(); convoMembers=[];
|
||
// Synchronous render from cache (within the click's activation → paints immediately, even
|
||
// when opened from a notification; an async-only render would defer the paint until a click).
|
||
const ckey=kind+':'+id;
|
||
let _renderedOpen=null; // the exact array we painted from cache, to diff against the network result below
|
||
if(THREAD_CACHE.has(ckey)){ const _c=THREAD_CACHE.get(ckey); THREAD = _c.length>PAGE ? _c.slice(-PAGE) : _c.slice(); _hasMoreOlder = _c.length>PAGE; _renderedOpen=THREAD; renderThread(); } // open at the LATEST page only: a short DOM pins to the newest reliably even as images load. Re-opening a thread that was scrolled up no longer re-renders 100+ msgs and strands you mid-history — older pages back in on scroll-up.
|
||
else if(box){ box.innerHTML='<div class="thread-loading"><img src="/loaders/loader-ring.svg" width="34" height="34" alt=""><span>Loading messages…</span></div>'; } // branded loader instead of a blank pane on slow links
|
||
// Synchronous render done (shell + cache thread, already pinned to bottom) → arm the compositor slide now,
|
||
// so the transform animates a fully-laid-out, off-screen layer with the main thread idle. Fresh push only.
|
||
if(window.__freshOpen){ window.__freshOpen=false; armOpenSlide(); }
|
||
if(kind==='group'){ try{ convoMembers=await fetch('/api/groups/members?group='+encodeURIComponent(id)).then(r=>r.json())||[]; }catch(_){ convoMembers=[]; } }
|
||
if(!selected||selected.kind!==kind||selected.id!==id) return;
|
||
wireMentions();
|
||
const url=kind==='group'?('/api/messages/thread?group='+encodeURIComponent(id)):('/api/messages/thread?with='+encodeURIComponent(id));
|
||
// msgs stays null on a failed/non-OK fetch (distinct from a genuinely empty [] thread), so a flaky
|
||
// desktop network can't be mistaken for "no messages".
|
||
let msgs=null; try{ const r=await fetch(url); if(r.ok) msgs=await r.json(); }catch(_){}
|
||
if(!selected||selected.kind!==kind||selected.id!==id) return; // switched away while loading
|
||
if(!Array.isArray(msgs)){
|
||
// Fetch failed / errored — DO NOT blank the chat or overwrite the cache (that wiped messages that
|
||
// are still safely on the server → the "messages disappear after reopen" bug). Keep the cached
|
||
// render; only show empty if we truly have nothing cached to fall back to.
|
||
if(!THREAD_CACHE.has(ckey)){ THREAD=[]; renderThread(); }
|
||
const inp0=document.getElementById('msgInput'); if(inp0 && !isMobileUA()) inp0.focus(); // #2: don't auto-open the phone keyboard on chat open
|
||
return;
|
||
}
|
||
// Did the network result EXACTLY match what the cache render already painted? Compare per-message by
|
||
// id + edited/deleted (NOT the old length+lastId signature — the cache count drifts every time a live
|
||
// message arrives, so that check almost never held and the full innerHTML rebuild ran at slide-end =
|
||
// the 40-128ms hitch). An exact match → skip the rebuild entirely and just stay pinned.
|
||
const _same = _renderedOpen && _renderedOpen.length===msgs.length && _renderedOpen.every((m,i)=>m.id===msgs[i].id && (m.edited_at||0)===(msgs[i].edited_at||0) && !!m.deleted===!!msgs[i].deleted);
|
||
THREAD=msgs;
|
||
THREAD_CACHE.set(ckey, THREAD.slice());
|
||
_hasMoreOlder = msgs.length >= PAGE; _loadingOlder=false; // a full page back means there may be older history
|
||
// Apply the network result NOW (during the fade), not at the landing frame. The fade is compositor-driven,
|
||
// so this main-thread work runs smoothly under it and is masked by the low opacity — by the time the pane is
|
||
// fully opaque the content is already settled, so there's no "adjust after it lands" hitch.
|
||
if(_same){
|
||
if(selected&&selected.kind===kind&&selected.id===id){ const b=document.getElementById('msgs'); if(b && !b.querySelector('.new-sep')) b.scrollTop=b.scrollHeight; } // identical to the cache render → just glue to the newest
|
||
} else {
|
||
if(selected&&selected.kind===kind&&selected.id===id){ if(!reconcileOpen(_renderedOpen)) renderThread(); } // content changed → append the new tail cheaply; full re-render only if the page structure diverged
|
||
}
|
||
if(kind==='dm') syncDmRowTick(id); // #1: keep the sidebar tick consistent with the loaded thread
|
||
// #3: if there were unread messages, drop a "New messages" divider, scroll to the first unread,
|
||
// and show the "jump to newest" arrow so the user can return to the bottom.
|
||
// #7 (mobile): phones open at the LATEST message (no auto-scroll up to the first unread), which is what
|
||
// users expect from a chat app; the divider/jump-up is a desktop convenience only.
|
||
if(_unreadN>0 && THREAD.length>=_unreadN && !isMobileUA()){
|
||
const fu=THREAD[THREAD.length-_unreadN]; const esc=(window.CSS&&CSS.escape)?CSS.escape(fu.id):fu.id;
|
||
const el=document.querySelector('#msgs .bubble[data-id="'+esc+'"]') || document.querySelector('#msgs [data-id="'+esc+'"]');
|
||
if(el){ const sep=document.createElement('div'); sep.className='new-sep'; sep.innerHTML='<span>New messages</span>'; el.parentNode.insertBefore(sep, el); el.scrollIntoView({block:'start'}); el.parentNode.scrollTop-=40; const jl=document.getElementById('jumpLatest'); if(jl) jl.style.display='grid'; }
|
||
}
|
||
const inp=document.getElementById('msgInput'); if(inp && !isMobileUA()) inp.focus(); // #2: on phones, opening a chat should NOT pop the keyboard (user taps the box to type)
|
||
// #7: guarantee the newest message is in view on open — force the scroll to the bottom a few times as
|
||
// late content (avatars, images, day separators) settles, unless we deliberately stopped at a "New
|
||
// messages" divider. This is the safety net over renderThread()'s own pin.
|
||
// #7: land on the newest message AND stay there while late content (images/avatars in long threads)
|
||
// keeps growing the thread after the first render. Re-pin on a schedule and on every image load, but
|
||
// only while still near the bottom, so a user who deliberately scrolled up isn't yanked back down.
|
||
const _pinNewest=()=>{ if(_openScrolled) return; if(!(selected&&selected.kind===kind&&selected.id===id)) return; const b=document.getElementById('msgs'); if(!b||b.querySelector('.new-sep')) return; if(_forcePinOpen || (b.scrollHeight-b.scrollTop-b.clientHeight)<1200) b.scrollTop=b.scrollHeight; }; // stops the instant the user scrolls (_openScrolled); until then _forcePinOpen forces the newest into view as late images load
|
||
// Re-glue to the newest as late images/day-separators grow the thread. Spread over time (the compositor fade
|
||
// is unaffected by these main-thread pins), so "open at latest" holds while images finish loading.
|
||
if(!_openScrolled) _openPinTimers=[0,120,320,600,1000,1600,2400,3400].map((d)=>setTimeout(_pinNewest, d)); // cancellable: the user's first scroll clears these so an early scroll-up for older history isn't yanked back
|
||
{ const _pinBox=document.getElementById('msgs'); if(_pinBox){ _pinBox.querySelectorAll('img').forEach((im)=>{ if(!im.complete) im.addEventListener('load', _pinNewest, {once:true}); }); } } // _pinNewest self-guards on _openScrolled, so these late image-load pins also stop once the user scrolls
|
||
}
|
||
// ---------- @mentions (group chat) ----------
|
||
let mentionItems=[], mentionIdx=0, mentionStart=-1;
|
||
function wireMentions(){
|
||
const inp=document.getElementById('msgInput'); if(!inp) return;
|
||
inp.addEventListener('input', onMentionInput);
|
||
inp.addEventListener('keydown', onMentionKey);
|
||
inp.addEventListener('blur', ()=>setTimeout(closeMention,150));
|
||
}
|
||
function onMentionInput(e){
|
||
const inp=e.target; if(!convoIsGroup){ closeMention(); return; }
|
||
const pos=inp.selectionStart;
|
||
const m=inp.value.slice(0,pos).match(/(?:^|\s)@([\p{L}\p{N}_]*)$/u);
|
||
if(!m){ closeMention(); return; }
|
||
mentionStart=pos-m[1].length-1;
|
||
const q=m[1].toLowerCase();
|
||
const opts=[];
|
||
if(!q||'everyone'.startsWith(q)||'all'.startsWith(q)) opts.push({id:'everyone',name:'everyone',sub:'Notify the whole group'});
|
||
for(const mem of convoMembers){ if(mem.id===ME.id) continue; if(!q||(mem.name||'').toLowerCase().includes(q)) opts.push({id:mem.id,name:mem.name,avatar:mem.avatar}); }
|
||
if(!opts.length){ closeMention(); return; }
|
||
mentionItems=opts.slice(0,8); mentionIdx=0; renderMentionPop();
|
||
}
|
||
function renderMentionPop(){
|
||
const pop=document.getElementById('mentionPop'); if(!pop) return;
|
||
pop.innerHTML=mentionItems.map((o,i)=>'<div class="mrow'+(i===mentionIdx?' sel':'')+'" data-i="'+i+'">'
|
||
+(o.id==='everyone'?'<span class="mini-av" style="background:var(--blue)">'+ic('users',15)+'</span>':'<span class="mini-av" style="background:'+avColor(o.name)+'">'+pEsc(initials(o.name))+(o.avatar?'<img class="av-img" src="'+pEsc(o.avatar)+'" alt="" onerror="this.remove()">':'')+'</span>')
|
||
+'<span class="mn">'+(o.id==='everyone'?'@everyone':pEsc(o.name))+'</span>'+(o.sub?'<span class="sub">'+pEsc(o.sub)+'</span>':'')+'</div>').join('');
|
||
pop.style.display='block';
|
||
pop.querySelectorAll('.mrow').forEach(r=>{ r.onmousedown=(ev)=>{ ev.preventDefault(); chooseMention(+r.dataset.i); }; });
|
||
}
|
||
function closeMention(){ const pop=document.getElementById('mentionPop'); if(pop){ pop.style.display='none'; pop.innerHTML=''; } mentionItems=[]; mentionStart=-1; }
|
||
function onMentionKey(e){
|
||
if(!mentionItems.length) return;
|
||
if(e.key==='ArrowDown'){ e.preventDefault(); mentionIdx=(mentionIdx+1)%mentionItems.length; renderMentionPop(); }
|
||
else if(e.key==='ArrowUp'){ e.preventDefault(); mentionIdx=(mentionIdx-1+mentionItems.length)%mentionItems.length; renderMentionPop(); }
|
||
else if(e.key==='Enter'||e.key==='Tab'){ e.preventDefault(); chooseMention(mentionIdx); }
|
||
else if(e.key==='Escape'){ closeMention(); }
|
||
}
|
||
function chooseMention(i){
|
||
const o=mentionItems[i]; if(!o) return;
|
||
const inp=document.getElementById('msgInput'); if(!inp||mentionStart<0) return;
|
||
const pos=inp.selectionStart;
|
||
const before=inp.value.slice(0,mentionStart), after=inp.value.slice(pos);
|
||
const token=(o.id==='everyone')?'@everyone':('@'+o.name);
|
||
inp.value=before+token+' '+after;
|
||
const np=(before+token+' ').length; inp.setSelectionRange(np,np);
|
||
composeMentions.set(o.id==='everyone'?'everyone':token, o.id);
|
||
closeMention(); inp.focus();
|
||
}
|
||
// Resolve the draft's mentions to ids (only those still present in the text) + literal @everyone/@all.
|
||
function collectMentions(text){
|
||
const out=[];
|
||
for(const [tok,idv] of composeMentions){ if(tok==='everyone') continue; if(text.includes(tok)) out.push(idv); }
|
||
if(/(?:^|\s)@(everyone|all)\b/i.test(text)) out.push('everyone');
|
||
return [...new Set(out)];
|
||
}
|
||
// ---- Composer formatting toolbar (Markdown-style) ----
|
||
function applyFmt(kind){
|
||
const inp=document.getElementById('msgInput'); if(!inp) return;
|
||
const wrap=(pre,suf)=>{ const s=inp.selectionStart, e=inp.selectionEnd, sel=inp.value.slice(s,e)||'text'; inp.value=inp.value.slice(0,s)+pre+sel+suf+inp.value.slice(e); inp.focus(); inp.setSelectionRange(s+pre.length, s+pre.length+sel.length); autoGrow(inp); };
|
||
const prefixLines=(ol)=>{ const v=inp.value; let s=inp.selectionStart, e=inp.selectionEnd; let ls=v.lastIndexOf('\n', s-1)+1, le=v.indexOf('\n', e); if(le===-1) le=v.length; const lines=v.slice(ls,le).split('\n'); const out=lines.map((ln,i)=>(ol?((i+1)+'. '):'- ')+ln).join('\n'); inp.value=v.slice(0,ls)+out+v.slice(le); inp.focus(); inp.setSelectionRange(ls, ls+out.length); autoGrow(inp); };
|
||
if(kind==='bold') wrap('**','**'); else if(kind==='italic') wrap('*','*'); else if(kind==='strike') wrap('~~','~~'); else if(kind==='code') wrap('`','`'); else if(kind==='ul') prefixLines(false); else if(kind==='ol') prefixLines(true);
|
||
}
|
||
// Inline Markdown on an already-HTML-escaped line (code/bold/strike/italic).
|
||
function fmtInline(s){
|
||
// #1: make URLs clickable (on the already-escaped text). Trailing punctuation is left outside the link.
|
||
s=s.replace(/(https?:\/\/[^\s<]+|www\.[^\s<]+)/g,(u)=>{ let tail=''; const mt=u.match(/[.,;:!?)\]}'"]+$/); if(mt){ tail=mt[0]; u=u.slice(0,-tail.length); } const href=/^https?:\/\//i.test(u)?u:('https://'+u); return '<a href="'+href+'" target="_blank" rel="noopener noreferrer" class="msg-link">'+u+'</a>'+tail; });
|
||
// #9: phone numbers → tap to call. Deliberately conservative so we don't linkify amounts, dates, IDs or
|
||
// 6-digit meeting codes: require 10–15 digits, and either a leading + / 0 or grouping separators. Skip
|
||
// anything already inside a link (e.g. a tel:/http URL we just wrapped).
|
||
s=s.replace(/(?<!["'>=\/\w])((?:\+\d{1,3}[\s-]?)?(?:\(\d{2,4}\)[\s-]?)?\d[\d\s-]{8,16}\d)(?![\w<])/g,(raw)=>{
|
||
const digits=raw.replace(/\D/g,'');
|
||
if(digits.length<10 || digits.length>15) return raw; // too short/long to be a phone
|
||
if(!/^\+|[\s-]/.test(raw.trim()) && digits.length!==10) return raw; // a bare digit run must look like a 10-digit number
|
||
return '<a href="tel:'+digits+'" class="msg-tel" data-tel="'+digits+'">'+raw+'</a>';
|
||
});
|
||
s=s.replace(/`([^`\n]+)`/g,'<code>$1</code>');
|
||
s=s.replace(/\*\*([^*\n]+)\*\*/g,'<b>$1</b>');
|
||
s=s.replace(/~~([^~\n]+)~~/g,'<s>$1</s>');
|
||
s=s.replace(/(^|[^\w*])\*([^*\n]+)\*(?=[^\w*]|$)/g,'$1<i>$2</i>');
|
||
return s;
|
||
}
|
||
function fmtMentions(s){ // s already HTML-escaped
|
||
s=s.replace(/(^|\s)@(everyone|all)\b/gi,(mm,pre)=>pre+'<span class="mention all">@everyone</span>');
|
||
if(convoIsGroup){ for(const mem of convoMembers){ if(!mem.name) continue; const esc=pEsc('@'+mem.name); if(esc&&s.includes(esc)) s=s.split(esc).join('<span class="mention">'+esc+'</span>'); } }
|
||
return s;
|
||
}
|
||
const fmtSeg=(line)=>fmtInline(fmtMentions(line));
|
||
// Render a message body: lists (-, *, •, 1.) + inline Markdown + mentions, newlines as <br>.
|
||
// #5: a message whose whole body is a GIF media URL (from our GIF picker, or pasted) renders as the GIF.
|
||
function isGifUrl(s){ return /^https?:\/\/(media\d*\.giphy\.com\/media\/|.*\.giphy\.com\/.*\/giphy\.gif|c\.tenor\.com\/|media\.tenor\.com\/)/i.test(String(s||'').trim()) || /^https?:\/\/\S+\.gif(\?\S*)?$/i.test(String(s||'').trim()); }
|
||
function renderMsgBody(m){
|
||
const body=(m.body||'').trim();
|
||
if(isGifUrl(body)){ return '<img class="att-img msg-gif" src="'+pEsc(body)+'" data-img="'+pEsc(body)+'" loading="lazy" alt="GIF">'; }
|
||
const lines=pEsc(m.body||'').split('\n');
|
||
let out='', list=null; const buf=[], para=[];
|
||
const flushList=()=>{ if(list){ out+='<'+list+' class="msg-list">'+buf.map(x=>'<li>'+x+'</li>').join('')+'</'+list+'>'; buf.length=0; list=null; } };
|
||
const flushPara=()=>{ if(para.length){ out+=para.join('<br>'); para.length=0; } };
|
||
for(const ln of lines){
|
||
const ul=ln.match(/^\s*(?:[-*•])\s+(.+)$/), ol=ln.match(/^\s*\d+[.)]\s+(.+)$/);
|
||
if(ul){ flushPara(); if(list!=='ul'){ flushList(); list='ul'; } buf.push(fmtSeg(ul[1])); }
|
||
else if(ol){ flushPara(); if(list!=='ol'){ flushList(); list='ol'; } buf.push(fmtSeg(ol[1])); }
|
||
else { flushList(); para.push(fmtSeg(ln)); }
|
||
}
|
||
flushList(); flushPara();
|
||
return out;
|
||
}
|
||
// ---------- Polls (group chat) ----------
|
||
function pollHTML(m){
|
||
const p=m.poll; if(!p) return '';
|
||
const max=Math.max(1, ...p.options.map(o=>o.votes||0));
|
||
const opts=p.options.map((o,i)=>{
|
||
const pct=p.totalVotes?Math.round(o.votes/p.totalVotes*100):0;
|
||
const w=Math.round((o.votes||0)/max*100);
|
||
return '<button class="poll-opt'+(o.mine?' mine':'')+'" data-poll="'+pEsc(p.id)+'" data-idx="'+i+'"'+(p.closed?' disabled':'')+'>'
|
||
+'<span class="po-bar" style="width:'+w+'%"></span>'
|
||
+'<span class="po-txt">'+(o.mine?ic('check',13):'')+' '+pEsc(o.text)+'</span>'
|
||
+'<span class="po-pct">'+pct+'% · '+(o.votes||0)+'</span></button>';
|
||
}).join('');
|
||
const foot='<div class="poll-foot">'+p.voters+' voter'+(p.voters===1?'':'s')+(p.multi?' · choose multiple':'')+(p.closed?' · <b>Closed</b>':'')
|
||
+((p.isOwner&&!p.closed)?' · <a class="poll-close" data-poll="'+pEsc(p.id)+'">Close poll</a>':'')+'</div>';
|
||
return '<div class="poll" data-msg="'+pEsc(m.id)+'"><div class="poll-q">'+ic('barChart',14)+' Poll'+(p.multi?' · multiple':'')+'</div>'+opts+foot+'</div>';
|
||
}
|
||
function updatePollInThread(messageId, poll){
|
||
const m=THREAD.find(x=>x.id===messageId); if(m) m.poll=poll;
|
||
document.querySelectorAll('.poll').forEach(el=>{ if(el.getAttribute('data-msg')===messageId && m){ el.outerHTML=pollHTML(m); } });
|
||
}
|
||
async function votePoll(pollId, idx){
|
||
try{ const poll=await postJSON('/api/polls/vote',{ pollId, optionIdx:idx }); const m=THREAD.find(x=>x.poll&&x.poll.id===pollId); if(m) updatePollInThread(m.id, poll); }
|
||
catch(e){ toast(e.message||'Could not vote'); }
|
||
}
|
||
async function closePoll(pollId){
|
||
if(!confirm('Close this poll? No more votes can be cast.')) return;
|
||
try{ const poll=await postJSON('/api/polls/close',{ pollId }); const m=THREAD.find(x=>x.poll&&x.poll.id===pollId); if(m) updatePollInThread(m.id, poll); }
|
||
catch(e){ toast(e.message||'Could not close poll'); }
|
||
}
|
||
function onPollUpdate(d){ const m=THREAD.find(x=>x.poll&&x.poll.id===d.poll.id); if(m) updatePollInThread(m.id, d.poll); }
|
||
function openPollModal(gid){
|
||
if(document.getElementById('pollModal')) return;
|
||
const ov=document.createElement('div'); ov.className='modal-ov'; ov.id='pollModal';
|
||
ov.innerHTML='<div class="modal sched">'
|
||
+'<div class="gi-head" style="margin-bottom:.6rem"><div class="avatar grp" style="width:40px;height:40px;flex:0 0 40px;background:var(--blue)">'+ic('barChart',20)+'</div><div class="gi-name"><div class="gi-title">Create a poll</div><div class="gi-sub">Members vote in the group</div></div><button class="iconbtn" id="pollClose">'+ic('x',18)+'</button></div>'
|
||
+'<label class="flbl">Question</label><input id="pollQ" class="finput" placeholder="What should we decide?" maxlength="300">'
|
||
+'<label class="flbl">Options</label><div id="pollOpts"></div>'
|
||
+'<button class="linkbtn" id="pollAdd" style="margin-top:.4rem">'+ic('plus',15)+' Add option</button>'
|
||
+'<label class="chk" style="margin-top:.7rem"><input type="checkbox" id="pollMulti"> Allow multiple choices</label>'
|
||
+'<button class="gobtn" id="pollCreate" style="width:100%;margin-top:.9rem;background:var(--blue);color:#fff">Create poll</button>'
|
||
+'<div class="hint" id="pollErr"></div></div>';
|
||
document.body.appendChild(ov);
|
||
const optsWrap=ov.querySelector('#pollOpts');
|
||
const addOpt=()=>{ if(optsWrap.children.length>=10) return; const row=document.createElement('div'); row.className='poll-opt-row'; row.innerHTML='<input class="finput" maxlength="120" placeholder="Option '+(optsWrap.children.length+1)+'"><button class="iconbtn rm" title="Remove">'+ic('x',15)+'</button>'; optsWrap.appendChild(row); row.querySelector('.rm').onclick=()=>{ if(optsWrap.children.length>2) row.remove(); }; };
|
||
addOpt(); addOpt();
|
||
ov.onclick=e=>{ if(e.target===ov) ov.remove(); };
|
||
ov.querySelector('#pollClose').onclick=()=>ov.remove();
|
||
ov.querySelector('#pollAdd').onclick=addOpt;
|
||
setTimeout(()=>{ const q=ov.querySelector('#pollQ'); if(q) q.focus(); },0);
|
||
ov.querySelector('#pollCreate').onclick=async()=>{
|
||
const q=ov.querySelector('#pollQ').value.trim();
|
||
const options=[...optsWrap.querySelectorAll('input')].map(i=>i.value.trim()).filter(Boolean);
|
||
const multi=ov.querySelector('#pollMulti').checked;
|
||
const err=ov.querySelector('#pollErr');
|
||
if(!q){ err.textContent='Add a question.'; return; }
|
||
if(options.length<2){ err.textContent='Add at least two options.'; return; }
|
||
try{ await postJSON('/api/polls',{ group:gid, question:q, options, multi }); ov.remove(); }
|
||
catch(e){ err.textContent=e.message||'Could not create poll'; }
|
||
};
|
||
}
|
||
async function selectChat(kind,id){
|
||
ensureNotifyPermission();
|
||
stopTyping(); // leaving the previous conversation → tell that peer we stopped
|
||
if(_selMode) exitSelect(); // #1: leave forward-selection when changing chats
|
||
selected={kind,id};
|
||
document.body.classList.add('chat-open'); // mobile: show the conversation pane
|
||
// DON'T start the slide here — openConvo builds the shell + renders the thread FIRST, then arms the
|
||
// compositor slide (armOpenSlide) once the pane is laid out & painted off-screen, so the transform runs
|
||
// with the main thread idle (smooth). Only for a fresh push on mobile.
|
||
window.__freshOpen = window.matchMedia('(max-width:760px)').matches;
|
||
const it=rowFor(kind,id); _openUnread=(it&&it.unread)||0; if(it) it.unread=0; // capture before reset (#3: open at first unread)
|
||
renderChats(searchVal()); // lay out the LIST (the parallax layer) BEFORE the slide is armed
|
||
updateRailUnread();
|
||
await openConvo(kind,id);
|
||
}
|
||
async function sendMessage(){
|
||
const inp=document.getElementById('msgInput'); if(!inp) return;
|
||
const text=inp.value.trim();
|
||
if(editTarget){ saveEdit(text); return; } // in edit mode → save the edit instead of sending new
|
||
const atts=pendingAttachs.slice();
|
||
if(atts.some(a=>a.uploading)){ toast('Please wait — files are still uploading…'); return; }
|
||
if((!text&&!atts.length)||!selected) return;
|
||
stopTyping(); // sending → we're no longer "typing"
|
||
inp.value=''; inp.style.height='auto'; setDraft(selected.kind, selected.id, ''); // sent → clear the draft
|
||
const replyTo=replyTarget?replyTarget.id:null; const isG=selected.kind==='group';
|
||
hideAttach(); clearReply(); cancelEdit();
|
||
// #5: one message per attachment (the first carries the typed text as its caption), else a single
|
||
// text message. Sent in order so they appear chronologically.
|
||
const jobs = atts.length ? atts.map((a,i)=>({ body:i===0?text:'', replyTo:i===0?replyTo:null, attachmentId:a.id })) : [{ body:text, replyTo, attachmentId:null }];
|
||
for(const j of jobs){
|
||
const payload = isG ? { group:selected.id, body:j.body, replyTo:j.replyTo, attachmentId:j.attachmentId, mentions:collectMentions(j.body) } : { to:selected.id, body:j.body, replyTo:j.replyTo, attachmentId:j.attachmentId };
|
||
try{
|
||
const m=await postJSON('/api/messages', payload);
|
||
// Dedup by id: the server echoes our message over WS (to sync other tabs) and that echo can
|
||
// arrive BEFORE this POST resolves, so onChatMessage may have already appended it.
|
||
if(!THREAD.some(x=>x.id===m.id)){ THREAD.push(m); appendBubble(m); }
|
||
{ const ck=selected.kind+':'+selected.id; if(THREAD_CACHE.has(ck)){ const a=THREAD_CACHE.get(ck); if(!a.some(x=>x.id===m.id)) a.push(m); } }
|
||
const it=rowFor(selected.kind,selected.id);
|
||
if(it){ it.last_body=m.body||(m.attachment?'📎 '+(m.attachment.name||'Attachment'):''); it.last_at=m.created_at; it.last_from_me=true; it.last_status='sent'; it.last_msg_id=m.id; it.unread=0; }
|
||
}catch(e){ toast(e.message||'Could not send'); if(j.body && !inp.value){ inp.value=j.body; try{ autoGrow(inp); }catch(_){} } }
|
||
}
|
||
composeMentions=new Map();
|
||
renderChats(searchVal());
|
||
}
|
||
// ---------- Incoming share (iOS share sheet → Biz Connect) ----------
|
||
// The Share Extension stages files into the App Group and opens bizconnect://share; here we read them and
|
||
// let the user pick a conversation to send them to. Reuses the normal upload + /api/messages send, so a
|
||
// shared file behaves exactly like one attached in-app. Best-effort: if the plugin isn't present (older
|
||
// build) or there's nothing pending, this is a no-op.
|
||
let _shareBusy=false;
|
||
function bzShareInbox(){ const P=window.Capacitor&&window.Capacitor.Plugins; return P&&P.ShareInbox||null; }
|
||
// Hand the Share Extension a bearer token + API base so it can list chats, upload and send on its own —
|
||
// the Teams-style in-sheet picker, no app-open needed. The extension can't read our HttpOnly cookie, so
|
||
// the logged-in web app mints a token (/api/share/token) and writes it into the App Group each launch.
|
||
async function bzPushShareAuth(){
|
||
const SI=bzShareInbox(); if(!SI||!SI.setAuth||!ME||!ME.id) return;
|
||
try{
|
||
const r=await fetch('/api/share/token'); if(!r.ok) return;
|
||
const d=await r.json(); if(!d||!d.token) return;
|
||
await SI.setAuth({ token:d.token, base:location.origin });
|
||
}catch(_){}
|
||
}
|
||
async function bzCheckSharedInbox(){
|
||
const SI=bzShareInbox(); if(!SI||_shareBusy) return;
|
||
if(!ME||!ME.id) return; // must be signed in to choose a conversation
|
||
let items=[];
|
||
try{ const r=await SI.getPending(); items=(r&&r.items)||[]; }catch(_){ return; }
|
||
if(!items.length) return;
|
||
_shareBusy=true;
|
||
openSharePicker(items);
|
||
}
|
||
// Upload one Blob/File through the same endpoint as an in-app attachment; resolves to the attachment meta.
|
||
function bzUploadBlob(file, onPct){
|
||
return new Promise((resolve,reject)=>{
|
||
const xhr=new XMLHttpRequest();
|
||
xhr.open('POST','/api/messages/upload',true);
|
||
xhr.setRequestHeader('Content-Type', file.type||'application/octet-stream');
|
||
xhr.setRequestHeader('X-Filename', encodeURIComponent(file.name||'file'));
|
||
xhr.upload.onprogress=(e)=>{ if(e.lengthComputable&&onPct) onPct(Math.round(e.loaded/e.total*100)); };
|
||
xhr.onload=()=>{ let d={}; try{ d=JSON.parse(xhr.responseText||'{}'); }catch(_){}
|
||
if(xhr.status>=200&&xhr.status<300) resolve(d); else reject(new Error(d.error||'Upload failed')); };
|
||
xhr.onerror=()=>reject(new Error('Upload failed'));
|
||
xhr.send(file);
|
||
});
|
||
}
|
||
function bzB64ToBlob(b64, mime){
|
||
const bin=atob(b64||''), len=bin.length, u8=new Uint8Array(len);
|
||
for(let i=0;i<len;i++) u8[i]=bin.charCodeAt(i);
|
||
return new Blob([u8], { type: mime||'application/octet-stream' });
|
||
}
|
||
async function bzShareItemToFile(it){
|
||
// Read the staged file's bytes for upload. We MUST go through the native Filesystem bridge, NOT
|
||
// fetch(convertFileSrc(...)): this app loads its UI from the REMOTE origin (remote.bizgaze.com), so
|
||
// Capacitor's local `_capacitor_file_` serving isn't on this origin — a fetch there is CORS-blocked or
|
||
// hits the remote server (404 "not found"). Filesystem.readFile reads the file in native code and
|
||
// returns base64, bypassing the webview network stack entirely.
|
||
const Fs=window.Capacitor&&window.Capacitor.Plugins&&window.Capacitor.Plugins.Filesystem;
|
||
if(Fs&&Fs.readFile){
|
||
let r=null;
|
||
try{ r=await Fs.readFile({ path: it.uri }); } // full file:// path, no directory
|
||
catch(e){ try{ r=await Fs.readFile({ path: it.path }); }catch(e2){ throw new Error('Could not read the shared file'); } }
|
||
return new File([bzB64ToBlob((r&&r.data)||'', it.mime)], it.name||'file', { type: it.mime||'application/octet-stream' });
|
||
}
|
||
// Fallback (local-asset builds only): the webview file scheme.
|
||
const src=window.Capacitor.convertFileSrc(it.uri);
|
||
const res=await fetch(src); if(!res.ok) throw new Error('read failed');
|
||
return new File([await res.blob()], it.name||'file', { type: it.mime||'application/octet-stream' });
|
||
}
|
||
function openSharePicker(items){
|
||
const files=items.filter(i=>i.kind==='file');
|
||
const texts=items.filter(i=>i.kind==='text').map(i=>i.text).filter(Boolean);
|
||
const done=()=>{ const SI=bzShareInbox(); if(SI){ try{ SI.clear(); }catch(_){} } _shareBusy=false; };
|
||
if(document.getElementById('shareModal')){ done(); return; }
|
||
const rows=(ROWS||[]).slice(0,200);
|
||
const ov=document.createElement('div'); ov.className='modal-ov'; ov.id='shareModal';
|
||
const summary = files.length
|
||
? (files.length+' file'+(files.length>1?'s':'')+(texts.length?' + text':''))
|
||
: (texts.length?'a link or text':'');
|
||
ov.innerHTML='<div class="modal sched"><div class="gi-head" style="margin-bottom:.5rem">'
|
||
+'<div class="avatar grp" style="width:40px;height:40px;flex:0 0 40px;background:var(--blue)">'+ic('send',20)+'</div>'
|
||
+'<div class="gi-name"><div class="gi-title">Send to…</div><div class="gi-sub">Sharing '+pEsc(summary)+'</div></div>'
|
||
+'<button class="iconbtn" id="shareClose" title="Cancel">'+ic('x',18)+'</button></div>'
|
||
+'<div class="gi-search"><input id="shareSearch" placeholder="Search chats…" autocomplete="off"></div>'
|
||
+'<div class="stor-list" id="shareList"></div></div>';
|
||
document.body.appendChild(ov);
|
||
const close=()=>{ ov.remove(); done(); };
|
||
ov.onclick=e=>{ if(e.target===ov) close(); };
|
||
ov.querySelector('#shareClose').onclick=close;
|
||
const listEl=ov.querySelector('#shareList');
|
||
const paint=(q)=>{
|
||
q=(q||'').toLowerCase().trim();
|
||
const shown=rows.filter(r=>!q||String(r.name||'').toLowerCase().includes(q));
|
||
listEl.innerHTML=shown.length?shown.map(r=>{
|
||
// Match the sidebar/forward avatars: real photo if the row has one, else coloured initials.
|
||
const inner = r.kind==='group' ? ic('users',16)
|
||
: (r.avatar ? '<img src="'+pEsc(r.avatar)+'" alt="" onerror="this.remove()">' : pEsc(initials(r.name)));
|
||
const av='<span class="sr-ic" style="background:'+avColor(r.name||'?')+';color:#334155">'+inner+'</span>';
|
||
return '<div class="stor-row share-to" data-kind="'+pEsc(r.kind)+'" data-id="'+pEsc(r.id)+'">'+av
|
||
+'<span class="sr-m"><span class="sr-n">'+pEsc(r.name||'Chat')+'</span>'
|
||
+'<span class="sr-s">'+(r.kind==='group'?'Group':'Direct message')+'</span></span>'
|
||
+ic('chevronRight',16)+'</div>';
|
||
}).join(''):'<div class="stor-empty">No matching chats.</div>';
|
||
listEl.querySelectorAll('.share-to').forEach(row=>{
|
||
row.onclick=()=>sendSharedTo(row.getAttribute('data-kind'), row.getAttribute('data-id'), files, texts, ov, close);
|
||
});
|
||
};
|
||
paint('');
|
||
const s=ov.querySelector('#shareSearch'); if(s) s.oninput=()=>paint(s.value);
|
||
}
|
||
async function sendSharedTo(kind, id, files, texts, ov, close){
|
||
if(ov.dataset.busy) return; ov.dataset.busy='1';
|
||
const target=((rowFor(kind,id)||{}).name)||'chat';
|
||
const sub=ov.querySelector('.gi-sub'); if(sub) sub.textContent='Sending to '+target;
|
||
const body=ov.querySelector('#shareList');
|
||
const prog=document.createElement('div'); prog.className='stor-empty'; prog.textContent='Sending to '+target+'…';
|
||
if(body){ body.innerHTML=''; body.appendChild(prog); }
|
||
const many=files.length>1;
|
||
const post=(payload)=>postJSON('/api/messages', kind==='group'?Object.assign({group:id},payload):Object.assign({to:id},payload));
|
||
try{
|
||
let first=true;
|
||
// Files first (each its own message), then any shared text/link as a final message.
|
||
for(let i=0;i<files.length;i++){
|
||
const of=many?(' ('+(i+1)+' of '+files.length+')'):'';
|
||
prog.textContent='Sending to '+target+of+'…';
|
||
const f=await bzShareItemToFile(files[i]);
|
||
const meta=await bzUploadBlob(f, p=>{ prog.textContent='Uploading to '+target+of+' · '+p+'%'; });
|
||
await post({ body:(first&&texts.length&&files.length===1)?texts.join('\n'):'', attachmentId:meta.id, mentions:[] });
|
||
first=false;
|
||
}
|
||
if(texts.length && !(files.length===1)){ await post({ body:texts.join('\n'), mentions:[] }); }
|
||
close();
|
||
toast('Shared to '+((rowFor(kind,id)||{}).name||'chat'));
|
||
try{ await loadSidebar(); }catch(_){}
|
||
selectChat(kind, id);
|
||
}catch(e){ delete ov.dataset.busy; toast(e.message||'Could not send'); if(body) paintShareError(body, e); }
|
||
}
|
||
function paintShareError(body, e){ body.innerHTML='<div class="stor-empty">Couldn’t send. Please try again.</div>'; }
|
||
// Trigger points: the extension opens bizconnect://share (foreground), and we also sweep on every
|
||
// resume, in case iOS delivered the share while the app was backgrounded.
|
||
(function(){
|
||
const C=window.Capacitor; if(!C||!C.Plugins||!C.Plugins.App) return;
|
||
const App=C.Plugins.App;
|
||
try{ App.addListener('appUrlOpen', (d)=>{ if(d&&/^bizconnect:\/\/share/i.test(d.url||'')) setTimeout(bzCheckSharedInbox, 150); }); }catch(_){}
|
||
try{ App.addListener('appStateChange', (s)=>{ if(s&&s.isActive) setTimeout(bzCheckSharedInbox, 300); }); }catch(_){}
|
||
})();
|
||
// 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 don’t miss messages & calls.</span><span class="np-actions"><button class="btn sm" id="npEnable">Enable</button><button class="np-x" id="npX" aria-label="Dismiss">'+ic('x',16)+'</button></span>';
|
||
document.body.appendChild(b);
|
||
document.getElementById('npEnable').onclick=async()=>{ try{ const r=await Notification.requestPermission(); if(r==='granted'){ toast('Notifications enabled'); await subscribePush(); } else { toast('Notifications blocked — allow them in your browser/site settings'); } }catch(_){ toast('Notifications need HTTPS'); } b.remove(); };
|
||
document.getElementById('npX').onclick=()=>{ try{ sessionStorage.setItem('notifPromptDismissed','1'); }catch(_){} b.remove(); };
|
||
}catch(_){}
|
||
}
|
||
// ----- Web Push: reliable notifications when the tab is backgrounded / frozen / closed -----
|
||
// A notification-only service worker (sw.js, no caching) shows OS popups via the push service.
|
||
// pushActive => the page can leave hidden-tab popups to push (avoids double-notifying).
|
||
let pushActive=false, _swReg=null;
|
||
function urlB64ToUint8(base64){ const pad='='.repeat((4-base64.length%4)%4); const b=(base64+pad).replace(/-/g,'+').replace(/_/g,'/'); const raw=atob(b); const arr=new Uint8Array(raw.length); for(let i=0;i<raw.length;i++) arr[i]=raw.charCodeAt(i); return arr; }
|
||
// ----- Native push (Capacitor app: FCM on Android, APNs on iOS) -----
|
||
// In a native shell, Capacitor injects window.Capacitor and exposes the PushNotifications
|
||
// plugin. We register the OS device token with /api/v1/devices so the server can deliver via
|
||
// FCM/APNs (see server/push.js). No bundler needed — plugins live on Capacitor.Plugins.
|
||
function capPlugin(name){ const C=window.Capacitor; return (C && C.isNativePlatform && C.isNativePlatform() && C.Plugins && C.Plugins[name]) ? C.Plugins[name] : null; }
|
||
function nativePlatform(){ const C=window.Capacitor; if(C && C.getPlatform){ const p=C.getPlatform(); if(p==='ios'||p==='android') return p; } if(window.__NATIVE__==='desktop' || (window.bizConnectNative && window.bizConnectNative.platform==='desktop')) return 'desktop'; return ''; } // also detect older desktop builds that expose bizConnectNative but not __NATIVE__ (#3: those wrongly subscribed Web Push → SW openWindow reloaded on notification click)
|
||
// Record this install against the signed-in user (desktop shell exposes a stable installId).
|
||
// Fires post-login; the request carries the session so admins can see who installed the app.
|
||
function reportInstall(){
|
||
try{ const n=window.bizConnectNative; if(!n||!n.installId) return;
|
||
postJSON('/api/v1/telemetry/install',{ installId:n.installId, platform:n.platform||'desktop', appVersion:n.version||'', os:n.os||'' }).catch(()=>{});
|
||
}catch(_){}
|
||
}
|
||
let _nativeTok=null;
|
||
// TEMP push diagnostics: mirror each step to the server so we can see where iOS registration fails
|
||
// without a Mac/device console. Remove once native push is confirmed working end-to-end.
|
||
function pdbg(step, extra){ try{ postJSON('/api/v1/push-debug', Object.assign({ step, t: Date.now() }, extra||{})).catch(()=>{}); }catch(_){}
|
||
try{ console.log('[push]', step, extra||''); }catch(_){} }
|
||
async function setupNativePush(){
|
||
const PN=capPlugin('PushNotifications'); const plat=nativePlatform();
|
||
const C=window.Capacitor;
|
||
pdbg('native-setup-enter', { plat, hasPlugin: !!PN, isNative: !!(C&&C.isNativePlatform&&C.isNativePlatform()), plugins: (C&&C.Plugins)?Object.keys(C.Plugins).join(','):'' });
|
||
if(!PN || (plat!=='ios' && plat!=='android')){ pdbg('native-setup-skip', { plat, hasPlugin: !!PN }); return false; } // not a mobile native app
|
||
try{
|
||
PN.addListener('registration', async (t)=>{ const token=t&&t.value; pdbg('registration-event', { tokenLen: token?token.length:0 }); if(!token) return; _nativeTok=token;
|
||
try{ await postJSON('/api/v1/devices',{ platform:plat, token }); pushActive=true; pdbg('device-post-ok', { plat }); console.log('[push] native device registered'); }
|
||
catch(e){ pdbg('device-post-fail', { err:String((e&&e.message)||e) }); console.warn('[push] device register failed:', e); } });
|
||
PN.addListener('registrationError', (e)=>{ pdbg('registration-error', { err:(e&&(e.error||e.message))||JSON.stringify(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(); pdbg('perm-check', { receive: perm&&perm.receive });
|
||
if(perm.receive!=='granted'){ perm=await PN.requestPermissions(); pdbg('perm-after-request', { receive: perm&&perm.receive }); }
|
||
if(perm.receive!=='granted'){ pdbg('perm-not-granted', { receive: perm&&perm.receive }); console.log('[push] native permission not granted'); return true; }
|
||
await PN.register(); pdbg('register-called');
|
||
console.log('[push] native push registered');
|
||
}catch(e){ pdbg('native-setup-exception', { err:String((e&&e.message)||e) }); console.warn('[push] native push setup failed:', e); }
|
||
return true; // handled the native path (skip Web Push regardless of outcome)
|
||
}
|
||
async function setupPush(){
|
||
if(await setupNativePush()) return; // mobile native app → FCM/APNs, not Web Push
|
||
// Desktop shell: the in-page bridge already shows a native toast (notify → replyNotify) while the
|
||
// app runs, so ALSO subscribing to Web Push made every message pop TWICE (in-app toast + SW push,
|
||
// with different icons). Drop any Web Push subscription here so only the branded native toast fires.
|
||
if(nativePlatform()==='desktop'){ try{ await unsubscribePush(); }catch(_){} console.log('[push] desktop shell → native toasts only, skipping Web Push'); return; }
|
||
if(!('serviceWorker' in navigator) || !('PushManager' in window)){ console.warn('[push] not supported by this browser'); return; }
|
||
try{ await navigator.serviceWorker.register('/sw.js'); }catch(e){ console.warn('[push] SW register failed:', e); return; }
|
||
try{ _swReg=await navigator.serviceWorker.ready; }catch(e){ console.warn('[push] SW never became ready:', e); return; } // ensure an ACTIVE worker before subscribe()
|
||
console.log('[push] service worker ready');
|
||
// Clicking an OS notification asks us (if a tab is already open) to open that chat in place.
|
||
try{ navigator.serviceWorker.addEventListener('message',(e)=>{ const d=e.data||{}; if(d.type==='open-chat' && d.id){ try{ selectChat(d.kind||'dm', d.id); }catch(_){} } }); }catch(_){}
|
||
await subscribePush();
|
||
}
|
||
async function subscribePush(){
|
||
try{
|
||
if(!_swReg){ try{ _swReg=await navigator.serviceWorker.ready; }catch(_){ return; } }
|
||
if(!('Notification' in window) || Notification.permission!=='granted'){ console.log('[push] permission not granted ('+(window.Notification?Notification.permission:'n/a')+') — skipping subscribe'); return; }
|
||
let cfg; try{ cfg=await fetch('/api/push/vapid').then(r=>r.json()); }catch(e){ console.warn('[push] /api/push/vapid failed:', e); return; }
|
||
if(!cfg || !cfg.enabled || !cfg.key){ console.warn('[push] server push disabled (VAPID not set):', cfg); return; }
|
||
let sub=await _swReg.pushManager.getSubscription();
|
||
if(!sub){ sub=await _swReg.pushManager.subscribe({ userVisibleOnly:true, applicationServerKey:urlB64ToUint8(cfg.key) }); }
|
||
await postJSON('/api/push/subscribe', sub.toJSON());
|
||
pushActive=true; console.log('[push] subscribed OK');
|
||
}catch(e){ console.warn('[push] subscribe failed:', e); } // surfaced (not swallowed) so we can see the real reason
|
||
}
|
||
// On logout, remove this device's push subscription so notifications STOP for the logged-out user.
|
||
async function unsubscribePush(){
|
||
// Native app: drop this device's FCM/APNs token so push stops for the logged-out user.
|
||
try{ if(_nativeTok){ await postJSON('/api/v1/devices/remove',{ token:_nativeTok }); _nativeTok=null; pushActive=false; return; } }catch(_){}
|
||
try{
|
||
if(!_swReg){ try{ _swReg=await navigator.serviceWorker.getRegistration(); }catch(_){} }
|
||
if(!_swReg) return;
|
||
const sub=await _swReg.pushManager.getSubscription();
|
||
if(sub){ try{ await postJSON('/api/push/unsubscribe',{endpoint:sub.endpoint}); }catch(_){} try{ await sub.unsubscribe(); }catch(_){} }
|
||
pushActive=false; console.log('[push] unsubscribed (logout)');
|
||
}catch(_){}
|
||
}
|
||
// ---------- Native calling (iOS CallKit + PushKit VoIP) ----------
|
||
// The native-call plugin rings incoming calls via CallKit (full-screen, works when the app is killed) and
|
||
// reports its VoIP token; on answer/end it fires events we bridge into the existing meeting join/leave. On
|
||
// a CallKit device the SYSTEM owns the incoming ring, so we suppress the in-app call-invite popup
|
||
// (onDmCall / the group invite check nativeCallOn()).
|
||
let _callkitReady=false;
|
||
let _nativeAnswered=new Set(); // rooms this device answered natively (so a "call-taken" from the server doesn't end our own call)
|
||
function nativeCallPlugin(){ const P=window.Capacitor&&window.Capacitor.Plugins; return (P&&P.NativeCall)||null; }
|
||
function nativeCallOn(){ return _callkitReady; }
|
||
async function setupNativeCall(){
|
||
const NC=nativeCallPlugin(); if(!NC) return;
|
||
// Server kill-switch: only take over calls with CallKit when the server enables it (callkit:true). When
|
||
// off, calls use the WebView flow (mic works). Flipped on only once native media carries the audio.
|
||
let cfg={}; try{ cfg=await fetch('/api/meetings/config').then(r=>r.json()); }catch(_){}
|
||
if(!cfg || !cfg.callkit){ console.log('[callkit] disabled by server — using WebView calls'); return; }
|
||
_callkitReady=true;
|
||
// VoIP (PushKit) token → register as an 'ios-voip' device so the server sends CallKit wake pushes.
|
||
NC.addListener('voipToken', (e)=>{ const token=e&&e.token; if(!token) return; postJSON('/api/v1/devices',{ platform:'ios-voip', token }).then(()=>console.log('[callkit] voip token registered')).catch((err)=>console.warn('[callkit] voip register failed', err)); });
|
||
try{ const t=await NC.getToken(); if(t&&t.token) postJSON('/api/v1/devices',{ platform:'ios-voip', token:t.token }).catch(()=>{}); }catch(_){}
|
||
// Native media: the plugin carries the call over its OWN LiveKit room. The WebView must NOT also join
|
||
// (LiveKit = one connection per identity). So on answer we do NOT enterMeeting — we just tell the server
|
||
// the call was answered (native media bypasses our WS, so the server can't learn it otherwise) and clear
|
||
// the in-app invite. Track answered rooms so end vs decline is signalled correctly.
|
||
NC.addListener('answerCall', (d)=>{ try{
|
||
pdbg('nc-answer', { room:(d&&d.room)||'', hasUrl:!!(d&&d.livekitUrl), hasToken:!!(d&&d.livekitToken) });
|
||
if(!d||!d.room) return;
|
||
_nativeAnswered.add(d.room);
|
||
dismissCallInvite(d.room);
|
||
// Open the REAL meeting window. Joining the mesh marks the call answered server-side, shows the caller's
|
||
// tile, and wires the end-both-sides lifecycle. The plugin already holds the LiveKit media, so this joins
|
||
// native (no 2nd SFU connection). meetReturn = the chat to land back on when the call ends.
|
||
const isGroup=(d.kind==='group');
|
||
meetReturn = isGroup ? { kind:'group', id:d.groupId } : { kind:'dm', id:d.callerId };
|
||
switchTab('meeting'); enterMeeting(d.room, false, { native:true, uuid:d.callUUID });
|
||
}catch(e){ pdbg('nc-answer-err', { err:String((e&&e.message)||e) }); } });
|
||
// Enforce the muted-by-default rule against the plugin's actual mic once the room connects (belt-and-braces
|
||
// for builds whose plugin still connects the mic live). meetMic is the source of truth for the mic button.
|
||
NC.addListener('callConnected', ()=>{ pdbg('nc-connected'); if(meetNative){ try{ NC.setMuted({ muted:!meetMic }); }catch(_){} } });
|
||
NC.addListener('audioActivated', (e)=>{ pdbg('nc-audio', { ok:!!(e&&e.ok), err:(e&&e.error)||'', route:(e&&e.route)||'' }); }); // CallKit audio-session activation + output route (debug audio)
|
||
NC.addListener('callError', (e)=>{ pdbg('nc-error', { err:(e&&e.error)||'' }); });
|
||
// Muted from the CallKit system UI → reflect it in the meeting window's mic button.
|
||
NC.addListener('setMuted', (e)=>{ if(!e||meetState!=='call'||!meetNative) return; meetMic=!e.muted; try{ updateMicBtn(); setTileMute('__local', !meetMic); meetSend({type:'meeting-state', muted:!meetMic, camOff:!meetCam}); }catch(_){} });
|
||
// Ended on the CallKit system screen / plugin ended on remote hang-up. Leave the meeting window too (guarded
|
||
// so our own in-app hang-up, which already called the plugin, doesn't loop). Then tell the server.
|
||
NC.addListener('endCall', (d)=>{ try{
|
||
const room=d&&d.room; pdbg('nc-end', { room:room||'', answered:_nativeAnswered.has(room) });
|
||
if(!room) return;
|
||
dismissCallInvite(room);
|
||
if(_ncEnding){ _ncEnding=false; }
|
||
else if(meetState==='call' && meetRoom===room){ leaveMeeting(true); }
|
||
if(_nativeAnswered.has(room)){ _nativeAnswered.delete(room); postJSON('/api/calls/end',{ room }).catch(()=>{}); }
|
||
else { postJSON('/api/calls/decline',{ room }).catch(()=>{}); }
|
||
}catch(_){} });
|
||
console.log('[callkit] native calling ready');
|
||
}
|
||
// Start an OUTGOING native call: fetch a LiveKit join token for the room, then have the plugin start the
|
||
// CallKit call AND connect the LiveKit room natively (the WebView does NOT join — one connection per identity).
|
||
async function callkitReportOutgoing(uuid, room, kind, peerName, hasVideo){
|
||
const NC=nativeCallPlugin(); if(!NC||!uuid) return;
|
||
let tk={}; try{ tk=await postJSON('/api/meetings/token',{ room }); }catch(_){}
|
||
pdbg('nc-outgoing', { room, hasUrl:!!(tk&&tk.url), hasToken:!!(tk&&tk.token) });
|
||
try{ NC.reportOutgoingCall({ callUUID:uuid, room, kind:kind||'dm', peerName:peerName||'Call', hasVideo:!!hasVideo, url:tk.url||'', token:tk.token||'' }); }catch(_){}
|
||
}
|
||
// End the CallKit call (remote hung up / we left / call ended). Safe no-op off-CallKit.
|
||
function callkitEnd(uuid){ const NC=nativeCallPlugin(); if(!NC||!uuid) return; try{ NC.endCall({ callUUID:uuid }); }catch(_){} }
|
||
// Ring CallKit from a WebSocket call event — a 2nd, reliable path alongside the VoIP push for when the app is
|
||
// OPEN (the push can be delayed/dropped). Deduped by UUID in the plugin, so double-firing is harmless.
|
||
async function nativeReportIncoming(o){
|
||
const NC=nativeCallPlugin(); if(!NC||!o||!o.callUUID||!o.room) return;
|
||
let tk={}; try{ tk=await postJSON('/api/meetings/token',{ room:o.room }); }catch(_){}
|
||
// .catch: on older builds without this plugin method, Capacitor REJECTS the promise (not a sync throw) — swallow it.
|
||
try{ const p=NC.reportIncomingCall({ callUUID:o.callUUID, room:o.room, kind:o.kind||'dm', callerId:o.callerId||'', callerName:o.callerName||'Incoming call', groupId:o.groupId||'', groupName:o.groupName||'', hasVideo:false, url:(tk&&tk.url)||'', token:(tk&&tk.token)||'' }); if(p&&p.catch) p.catch(()=>{}); }catch(_){}
|
||
}
|
||
|
||
// NATIVE calls use your REAL meeting window: the plugin owns this user's ONE LiveKit connection (media +
|
||
// CallKit ring/background/lock-screen), and the WebView joins the same mesh room for the UI — so the caller/
|
||
// callee tiles, roster, mute state and the whole answer/end lifecycle are the normal meeting code. The only
|
||
// difference vs a web call is meetNative=true → the WebView does NOT open its own SFU media connection (that
|
||
// would be a 2nd connection for the same identity), and mute/hang-up bridge to the plugin.
|
||
// Another of the callee's devices answered — stop ringing here (never on the device that answered).
|
||
function onCallTaken(d){ if(!d||!d.room) return; if(_nativeAnswered.has(d.room)) return; dismissCallInvite(d.room); if(nativeCallOn() && d.uuid) callkitEnd(d.uuid); }
|
||
// The callee picked up (server signal; the mesh peer-join already clears these on the caller — belt & braces).
|
||
function onCallAnswered(d){ if(d&&d.room && meetRoom===d.room){ stopRingback(); removeWaitingTile(); _dmCallWaiting=null; } }
|
||
|
||
// Open the chat from an in-page notification. Navigation reliably repaints across browsers (a
|
||
// notification click is not an in-page gesture, so an in-place open won't paint until you
|
||
// tap). The reload is made fast by HTTP caching + a boot fast-path that opens the chat first.
|
||
function openFromNotif(kind,id){
|
||
try{ if(window.bizConnectNative&&window.bizConnectNative.focusApp) window.bizConnectNative.focusApp(); }catch(_){}
|
||
try{ window.focus(); }catch(_){}
|
||
// The app is already running (the notification came from it) → open the chat IN-PLACE. A full-page
|
||
// reload was slow (#19) and dropped live state: the sidebar hadn't reloaded so the header showed
|
||
// "Conversation" with no DP (#7), and an incoming call's Join/invite popup was lost (#2).
|
||
// NB: use bare `ME`, NOT `window.ME` — ME is declared with `let`, which does NOT create a global
|
||
// property, so `window.ME` was always undefined and EVERY click hit the reload fallback below.
|
||
if(ME && ME.id && typeof selectChat==='function'){
|
||
if(id){ try{ switchTab('chat'); }catch(_){} try{ selectChat(kind||'dm', id); }catch(_){} }
|
||
return;
|
||
}
|
||
// Fallback: app not initialised yet → navigate (boot reads openKind/openId and opens it).
|
||
location.assign(id ? ('/home?openKind='+encodeURIComponent(kind||'')+'&openId='+encodeURIComponent(id||'')) : '/home');
|
||
}
|
||
// Draw the conversation's avatar (DM = sender pic, group = group DP; else colored initials) to
|
||
// a round PNG data URL for the desktop toast image.
|
||
function notifAvatarDataUrl(kind,id,fallbackName){
|
||
return new Promise((resolve)=>{
|
||
try{
|
||
const row=rowFor(kind,id)||{}; const src=row.avatar||''; const nm=row.name||fallbackName||'?';
|
||
const s=96, c=document.createElement('canvas'); c.width=s; c.height=s; const g=c.getContext('2d');
|
||
const initialsPng=()=>{ try{ g.clearRect(0,0,s,s); g.fillStyle=avColor(nm); g.beginPath(); g.arc(s/2,s/2,s/2,0,2*Math.PI); g.fill(); g.fillStyle='#334155'; g.font='bold 40px system-ui,Segoe UI,sans-serif'; g.textAlign='center'; g.textBaseline='middle'; g.fillText(initials(nm), s/2, s/2+2); resolve(c.toDataURL('image/png')); }catch(_){ resolve(null); } };
|
||
if(src){ const img=new Image(); img.crossOrigin='anonymous'; let settled=false; const done=(fn)=>{ if(!settled){ settled=true; fn(); } };
|
||
img.onload=()=>done(()=>{ try{ g.save(); g.beginPath(); g.arc(s/2,s/2,s/2,0,2*Math.PI); g.clip(); g.drawImage(img,0,0,s,s); g.restore(); resolve(c.toDataURL('image/png')); }catch(_){ initialsPng(); } });
|
||
img.onerror=()=>done(initialsPng); img.src=src; setTimeout(()=>done(initialsPng), 1500);
|
||
} else initialsPng();
|
||
}catch(_){ resolve(null); }
|
||
});
|
||
}
|
||
// Reply to a conversation without opening it (used by the notification quick-reply).
|
||
async function sendReplyTo(kind,id,text){ try{ await postJSON('/api/messages', kind==='group'?{group:id,body:text}:{to:id,body:text}); }catch(_){} }
|
||
// #3: desktop auto-update — a progress banner (branded restart) + an update indicator that cascades
|
||
// from the profile → Settings → version, so the user always knows a newer version is available.
|
||
let _pendingUpdate=null; // {phase, version}
|
||
function wireUpdateBanner(){
|
||
const n=window.bizConnectNative; if(!(n&&n.onUpdateEvent)) return;
|
||
n.onUpdateEvent((d)=>{
|
||
if(!d||!d.phase) return;
|
||
if(d.phase==='available'||d.phase==='downloading'||d.phase==='ready') _pendingUpdate={ phase:d.phase, version:d.version||(_pendingUpdate&&_pendingUpdate.version)||'' };
|
||
else if(d.phase==='current') _pendingUpdate=null;
|
||
applyUpdateBadge();
|
||
let el=document.getElementById('updBanner');
|
||
const ensure=()=>{ if(!el){ el=document.createElement('div'); el.id='updBanner'; el.className='upd-banner'; document.body.appendChild(el); } return el; };
|
||
if(d.phase==='available'){ ensure().innerHTML=ic('download',15)+' <span>Downloading update'+(d.version?(' '+pEsc(d.version)):'')+'…</span>'; el.classList.add('show'); el.classList.remove('ready'); }
|
||
else if(d.phase==='downloading'){ ensure().innerHTML=ic('download',15)+' <span>Downloading update… '+(d.percent||0)+'%</span>'; el.classList.add('show'); el.classList.remove('ready'); }
|
||
else if(d.phase==='ready'){ ensure().innerHTML=ic('check',15)+' <span>Update ready</span> <button id="updRestart">Restart now</button>'; el.classList.add('show','ready'); const b=el.querySelector('#updRestart'); if(b) b.onclick=()=>{ try{ n.restartToUpdate&&n.restartToUpdate(); }catch(_){} }; }
|
||
else if(d.phase==='current'||d.phase==='error'){ if(el){ el.classList.remove('show'); setTimeout(()=>{ if(el&&!el.classList.contains('show')) el.remove(); }, 400); } }
|
||
});
|
||
// Desktop: a download finished (saved straight to the Downloads folder, no dialog) → confirm with a toast
|
||
// so it never feels like "nothing happened". The native app also shows a click-to-reveal OS notification
|
||
// when its window isn't focused.
|
||
if(n.onDownloadDone) n.onDownloadDone((d)=>{ if(!d) return; toast(d.ok ? ('Saved “'+(d.name||'file')+'” to your Downloads folder') : ('Couldn’t download '+(d.name||'the file'))); });
|
||
}
|
||
// ---- New-web-build detection ----------------------------------------------------------------
|
||
// The desktop app closes to TRAY, so it can stay open for weeks and keep running the page it loaded on
|
||
// day one — a deploy would never reach it (that's why fixes "worked on mobile but not on desktop").
|
||
// Poll the server's build marker; when it changes, offer a Refresh. Also exposed in Settings.
|
||
// The user should NEVER have to think about "web build" vs "app version" — a new deploy applies ITSELF.
|
||
// We poll the server's build marker and, as soon as it's SAFE, silently reload onto the new code. Safe =
|
||
// not in a call, no live screen session, nothing half-typed, no dialog open. If the user is busy we simply
|
||
// keep waiting and apply it the moment they're free (or when the window is next hidden).
|
||
let _newBuild=null, _autoRefreshTimer=null, _lastBuildCheck=0, _buildChecking=false;
|
||
// Throttled + single-flight. `focus` and `visibilitychange` both fire on a single alt-tab back into the
|
||
// app, so an unthrottled check fired TWICE every time the window was refocused. One check per minute is
|
||
// plenty (the payload is ~35 bytes and the server answers from memory), and it never overlaps itself.
|
||
const BUILD_CHECK_MIN_MS=60*1000;
|
||
async function checkWebBuild(force){
|
||
if(_newBuild) return; // already know: the retry loop owns it now
|
||
const now=Date.now();
|
||
if(_buildChecking) return;
|
||
if(!force && now-_lastBuildCheck < BUILD_CHECK_MIN_MS) return;
|
||
_buildChecking=true; _lastBuildCheck=now;
|
||
try{
|
||
const r=await fetch('/api/build',{cache:'no-store'}); if(!r.ok) return;
|
||
const d=await r.json();
|
||
if(d && d.build && window.__BUILD && d.build!==window.__BUILD){
|
||
_newBuild=d.build;
|
||
console.log('[build] new version on server:', d.build, '(running', window.__BUILD+')');
|
||
scheduleAutoRefresh();
|
||
}
|
||
}catch(_){}
|
||
finally{ _buildChecking=false; }
|
||
}
|
||
// Never yank the page out from under someone mid-task.
|
||
function safeToRefresh(){
|
||
try{
|
||
if(typeof meetState!=='undefined' && meetState==='call') return false; // in a meeting/call
|
||
if(document.querySelector('.railbtn.live')) return false; // live share/connect session
|
||
if(document.querySelector('.modal-ov, .call-invite, .guest-prejoin, .lightbox')) return false; // a dialog is open
|
||
const inp=document.getElementById('msgInput'); if(inp && inp.value.trim()) return false; // half-typed message
|
||
const mc=document.getElementById('mcInput'); if(mc && mc.value.trim()) return false;
|
||
}catch(_){}
|
||
return true;
|
||
}
|
||
function scheduleAutoRefresh(){
|
||
if(_autoRefreshTimer) return;
|
||
const tryNow=()=>{
|
||
if(!_newBuild) return;
|
||
if(!safeToRefresh()) return; // busy → try again shortly
|
||
clearInterval(_autoRefreshTimer); _autoRefreshTimer=null;
|
||
hardReloadApp(); // silent: no banner, no toast
|
||
};
|
||
_autoRefreshTimer=setInterval(tryNow, 15000);
|
||
setTimeout(tryNow, 1200); // usually applies right away
|
||
}
|
||
// Hard refresh: on desktop this clears the shell's HTTP cache and reloads ignoring cache. In a browser we
|
||
// also drop any service-worker + CacheStorage entries, then reload — so there's no stale-code dead end.
|
||
async function hardReloadApp(){
|
||
try{ const n=window.bizConnectNative; if(n && n.hardReload){ n.hardReload(); return; } }catch(_){}
|
||
try{ if('serviceWorker' in navigator){ const rs=await navigator.serviceWorker.getRegistrations(); await Promise.all(rs.map(r=>r.unregister().catch(()=>{}))); } }catch(_){}
|
||
try{ if(window.caches && caches.keys){ const ks=await caches.keys(); await Promise.all(ks.map(k=>caches.delete(k).catch(()=>{}))); } }catch(_){}
|
||
try{ location.replace(location.pathname+location.search); }catch(_){ location.reload(); }
|
||
}
|
||
// Cheap by design: one ~35-byte request, answered from a variable the server read at startup. The
|
||
// throttle above means refocusing the app repeatedly costs nothing.
|
||
setInterval(()=>checkWebBuild(true), 5*60*1000); // every 5 min (the interval IS the schedule)
|
||
window.addEventListener('focus', ()=>checkWebBuild()); // coming back to the app (throttled)
|
||
document.addEventListener('visibilitychange', ()=>{ if(!document.hidden) checkWebBuild(); });
|
||
setTimeout(()=>checkWebBuild(true), 3000); // shortly after boot
|
||
// Deliberately NO banner and NO toast: a web build is an implementation detail. Surfacing it would make
|
||
// users think about "web build vs app version". It just updates.
|
||
// Small 'i' badge on the profile button when an update is pending; clicking opens Settings.
|
||
function applyUpdateBadge(){
|
||
document.querySelectorAll('.upd-dot').forEach(x=>x.remove());
|
||
if(!_pendingUpdate) return;
|
||
const host=document.getElementById('pbtn'); if(!host) return;
|
||
const dot=document.createElement('span'); dot.className='upd-dot'; dot.title='Update available — open Settings'; dot.textContent='i';
|
||
host.appendChild(dot);
|
||
}
|
||
// #13: track shown page-notifications by conversation tag so reading elsewhere can dismiss them.
|
||
const _shownNotifs={};
|
||
function onNotifClear(d){
|
||
if(!d) return; const tag=(d.kind||'')+':'+(d.id||'');
|
||
try{ const n=_shownNotifs[tag]; if(n){ n.close(); } delete _shownNotifs[tag]; }catch(_){}
|
||
// Also close any Service-Worker (Web Push) notifications for this chat.
|
||
try{ if('serviceWorker' in navigator && navigator.serviceWorker.getRegistration) navigator.serviceWorker.getRegistration().then(reg=>{ if(reg&®.getNotifications) reg.getNotifications({tag}).then(ns=>ns.forEach(n=>{ try{ n.close(); }catch(_){} })); }).catch(()=>{}); }catch(_){}
|
||
// Drop matching activity-center entries so the bell badge stays in sync.
|
||
try{ if(Array.isArray(NOTIFS)){ const before=NOTIFS.length; NOTIFS=NOTIFS.filter(x=>!(x.link&&x.link.kind===d.kind&&x.link.id===d.id)); if(NOTIFS.length!==before){ saveNotifs&&saveNotifs(); updateBellBadge&&updateBellBadge(); } } }catch(_){}
|
||
}
|
||
// Draw a round initials avatar to a PNG data URL (synchronous — no external image, so it never taints
|
||
// the canvas). Used as the desktop toast icon when a chat/group has no photo, so every toast shows one.
|
||
function initialsAvatarDataUrl(name, isGroup){
|
||
try{
|
||
const s=96, c=document.createElement('canvas'); c.width=s; c.height=s; const g=c.getContext('2d');
|
||
g.beginPath(); g.arc(s/2,s/2,s/2,0,2*Math.PI); g.fillStyle=isGroup?'#1F3B73':avColor(name||'?'); g.fill();
|
||
g.fillStyle=isGroup?'#fff':'#334155'; g.font='bold 40px system-ui,Segoe UI,sans-serif'; g.textAlign='center'; g.textBaseline='middle';
|
||
g.fillText(initials(name||'?'), s/2, s/2+2);
|
||
return c.toDataURL('image/png');
|
||
}catch(_){ return null; }
|
||
}
|
||
function notify(title, body, kind, id, opts){
|
||
const persistent=!!(opts&&opts.persistent);
|
||
try{
|
||
// Desktop app: native toast with the sender/group DP. Pass the DP URL directly — the shell
|
||
// downloads it for the icon (drawing an external DP to a canvas here tainted it → initials).
|
||
if(window.bizConnectNative && window.bizConnectNative.replyNotify){
|
||
const _row=rowFor(kind,id)||{};
|
||
// Groups (and contacts) with no photo URL had NO toast icon at all. Draw a generated icon
|
||
// (initials on a brand-tinted disc — no external image, so no canvas taint) so every toast,
|
||
// especially group messages, carries a recognizable avatar.
|
||
let _av=_row.avatar||null;
|
||
if(!_av){ try{ _av=initialsAvatarDataUrl(_row.name||title, kind==='group'); }catch(_){ _av=null; } }
|
||
Promise.resolve(window.bizConnectNative.replyNotify({title, body, kind, id, avatar:_av, persistent}))
|
||
.then(r=>{ if(!r) return;
|
||
if(r.text) sendReplyTo(kind, id, r.text); // replied from the toast
|
||
else if(r.open) openFromNotif(kind, id); // clicked the toast
|
||
}).catch(()=>{});
|
||
return;
|
||
}
|
||
// Web / PWA: standard Notification. Use the sender's/group's DP URL DIRECTLY as the icon —
|
||
// the browser fetches notification icons cross-origin fine, whereas drawing an external DP to a
|
||
// canvas taints it and silently fell back to initials (the bug). Brand icon only if no DP.
|
||
if(!('Notification' in window) || Notification.permission!=='granted') return;
|
||
const _row=rowFor(kind,id)||{};
|
||
const tag=(kind||'')+':'+(id||''); // #13: tag by conversation so it can be dismissed cross-device
|
||
const n=new Notification(title, { body, icon: _row.avatar || '/icon-192.png', badge:'/icon-192.png', tag });
|
||
_shownNotifs[tag]=n;
|
||
n.onclick=()=>{ n.close(); delete _shownNotifs[tag]; openFromNotif(kind, id); };
|
||
n.onclose=()=>{ if(_shownNotifs[tag]===n) delete _shownNotifs[tag]; };
|
||
setTimeout(()=>{ try{ n.close(); }catch(_){} }, 8000);
|
||
}catch(_){}
|
||
}
|
||
let _audioCtx=null;
|
||
// Message chime: a crisp, recognizable rising two-note ("ti-doo") — louder + brighter than the
|
||
// old single beep so it's catchy and noticed.
|
||
function playPing(){
|
||
try{
|
||
_audioCtx=_audioCtx||new (window.AudioContext||window.webkitAudioContext)();
|
||
if(_audioCtx.state==='suspended') _audioCtx.resume();
|
||
const t=_audioCtx.currentTime;
|
||
const tone=(f,start,dur,peak)=>{ const o=_audioCtx.createOscillator(), g=_audioCtx.createGain(); o.type='triangle'; o.frequency.value=f; g.gain.setValueAtTime(0.0001,t+start); g.gain.exponentialRampToValueAtTime(peak,t+start+0.015); g.gain.exponentialRampToValueAtTime(0.0001,t+start+dur); o.connect(g); g.connect(_audioCtx.destination); o.start(t+start); o.stop(t+start+dur+0.03); };
|
||
tone(784.0, 0.00, 0.16, 0.34); // G5
|
||
tone(1174.7, 0.11, 0.40, 0.34); // D6 — rises, rings out -> "ti-doo"
|
||
}catch(_){}
|
||
}
|
||
// Continuous incoming-call ring. A soft, soothing bell chime — a gentle ascending arpeggio
|
||
// (C major) of pure sine tones with an octave shimmer and a long smooth decay, so it rings
|
||
// rather than buzzes. Ref-counted: several pending invites share one ring; it stops on the last close.
|
||
let _ringTimer=null, _ringRefs=0;
|
||
function ringTone(t, freq, dur, peak){
|
||
const g=_audioCtx.createGain(); g.connect(_audioCtx.destination);
|
||
g.gain.setValueAtTime(0.0001,t);
|
||
g.gain.exponentialRampToValueAtTime(peak,t+0.03); // gentle attack (no click)
|
||
g.gain.exponentialRampToValueAtTime(0.0001,t+dur); // long, smooth bell-like decay
|
||
const o=_audioCtx.createOscillator(); o.type='sine'; o.frequency.value=freq; o.connect(g); o.start(t); o.stop(t+dur);
|
||
const o2=_audioCtx.createOscillator(); o2.type='sine'; o2.frequency.value=freq*2; // octave shimmer
|
||
const g2=_audioCtx.createGain(); g2.gain.value=0.3; o2.connect(g2); g2.connect(g); o2.start(t); o2.stop(t+dur);
|
||
}
|
||
function ringOnce(){
|
||
try{
|
||
_audioCtx=_audioCtx||new (window.AudioContext||window.webkitAudioContext)();
|
||
if(_audioCtx.state==='suspended') _audioCtx.resume();
|
||
const t=_audioCtx.currentTime;
|
||
ringTone(t, 523.25, 1.2, 0.24); // C5 (louder so an incoming call is clearly heard)
|
||
ringTone(t+0.20, 659.25, 1.2, 0.22); // E5
|
||
ringTone(t+0.40, 783.99, 1.5, 0.24); // G5
|
||
ringTone(t+0.60, 1046.5, 1.9, 0.20); // C6 — rings out
|
||
}catch(_){}
|
||
}
|
||
function startRing(){ _ringRefs++; if(_ringTimer) return; ringOnce(); _ringTimer=setInterval(ringOnce, 3500); }
|
||
function stopRing(force){ _ringRefs = force ? 0 : Math.max(0, _ringRefs-1); if(_ringRefs>0) return; if(_ringTimer){ clearInterval(_ringTimer); _ringTimer=null; } }
|
||
// #17 Outgoing ringback — the CALLER hears a gentle periodic ring while waiting for the other side to
|
||
// pick up. Stops as soon as a peer joins, or the call ends.
|
||
let _ringbackTimer=null, _ringbackStop=null;
|
||
function ringbackOnce(){ try{ _audioCtx=_audioCtx||new (window.AudioContext||window.webkitAudioContext)(); if(_audioCtx.state==='suspended') _audioCtx.resume(); const t=_audioCtx.currentTime; ringTone(t, 440, 1.1, 0.11); ringTone(t+0.16, 480, 1.1, 0.11); }catch(_){} }
|
||
function startRingback(){ if(_ringbackTimer) return; ringbackOnce(); _ringbackTimer=setInterval(ringbackOnce, 3000); _ringbackStop=setTimeout(stopRingback, 45000); /* never ring forever if unanswered/declined */ }
|
||
function stopRingback(){ if(_ringbackTimer){ clearInterval(_ringbackTimer); _ringbackTimer=null; } if(_ringbackStop){ clearTimeout(_ringbackStop); _ringbackStop=null; } }
|
||
function onChatMessage(m){
|
||
const isGroupMsg=!!m.conversation_id;
|
||
const kind=isGroupMsg?'group':'dm';
|
||
const rid=isGroupMsg?m.conversation_id:(m.from===ME.id?m.to:m.from);
|
||
if(m.from&&m.from!==ME.id) clearTypingFrom(kind,rid,m.from); // their message landed → drop their "typing…"
|
||
let it=rowFor(kind,rid); const wasNew=!it;
|
||
if(!it){ loadSidebar(); } // first DM / a new group we were added to — refresh the list
|
||
// Keep the thread cache warm so a notification click can render this chat synchronously.
|
||
const ckey=kind+':'+rid;
|
||
if(THREAD_CACHE.has(ckey)){ const arr=THREAD_CACHE.get(ckey); if(!arr.some(x=>x.id===m.id)) arr.push(m); }
|
||
else { const pu=(kind==='group'?'/api/messages/thread?group='+encodeURIComponent(rid):'/api/messages/thread?with='+encodeURIComponent(rid))+'&peek=1'; fetch(pu).then(r=>r.json()).then(a=>{ if(Array.isArray(a)) THREAD_CACHE.set(ckey,a); }).catch(()=>{}); }
|
||
const isOpen=selected&&selected.kind===kind&&selected.id===rid&¤tTab()==='chat';
|
||
const isSys=!!m.system || m.from==='__system__'; // activity lines: show in chat, but no ping/notify/unread
|
||
if(m.from!==ME.id && !isSys){
|
||
if(!isGroupMsg && chatWs && chatWs.readyState===1){ try{ chatWs.send(JSON.stringify({type:'chat-delivered', id:m.id})); }catch(_){} } // ack DM delivery
|
||
// Popup rule: ping always. In-page popup only when the tab is VISIBLE but you're on another
|
||
// chat. When the tab is HIDDEN, let Web Push show it (the SW). If push isn't active, fall
|
||
// back to an in-page popup so hidden-tab users still get alerted.
|
||
if(notifOn(kind)){ playPing(); const wantPopup=!(isOpen && !document.hidden);
|
||
if(wantPopup && !(document.hidden && pushActive)){
|
||
const prev=isGifUrl(m.body)?'🎞️ GIF':(m.body?(m.body.length>80?m.body.slice(0,80)+'…':m.body):'Sent an attachment');
|
||
// Group: title = GROUP name, body = "Sender: message" (so you know which group it's from).
|
||
if(kind==='group') notify((it&&it.name)||m.groupName||'Group', (m.fromName?m.fromName+': ':'')+prev, kind, rid);
|
||
else notify((m.fromName||'New message'), prev, kind, rid);
|
||
}
|
||
}
|
||
// Activity-center entries for things easy to miss.
|
||
if(m.poll) addNotif({icon:'barChart', text:pEsc(m.fromName||'Someone')+' created a poll'+(m.poll.question?': '+pEsc(m.poll.question):''), link:{kind, id:rid}});
|
||
else if(kind==='dm' && wasNew) addNotif({icon:'chat', text:'New chat from '+pEsc(m.fromName||'someone'), link:{kind:'dm', id:rid}});
|
||
}
|
||
if(it){
|
||
it.last_body=isSys?m.body:(m.body||(m.attachment?'📎 '+(m.attachment.name||'Attachment'):'')); it.last_at=m.created_at; it.last_from_me=(m.from===ME.id);
|
||
// #6: the server echoes my own sent message back — don't let that echo DOWNGRADE a delivered/read
|
||
// tick that already arrived for this same message (it would flip the list back to a single tick).
|
||
if(m.from===ME.id){ if(!(it.last_msg_id===m.id && (it.last_status==='delivered'||it.last_status==='read'))) it.last_status='sent'; it.last_msg_id=m.id; }
|
||
else it.last_status=null;
|
||
if(m.from!==ME.id && !isOpen && !isSys) it.unread=(it.unread||0)+1;
|
||
}
|
||
if(isOpen){
|
||
// Dedup by id: the server echoes our own sent message back (multi-tab/device sync), and
|
||
// sendMessage already appended it optimistically — so skip if it's already in the thread.
|
||
if(!THREAD.some(x=>x.id===m.id)){ THREAD.push(m); appendBubble(m); }
|
||
if(m.from!==ME.id && !isSys){ if(it) it.unread=0; const body=JSON.stringify(kind==='group'?{group:rid}:{with:rid}); try{ fetch('/api/messages/read',{method:'POST',headers:{'Content-Type':'application/json'},body}); }catch(_){} }
|
||
} else if(m.from!==ME.id && !isSys && notifOn(kind)){
|
||
if(window.BZToast) BZToast.message(m.body?(m.body.length>60?m.body.slice(0,60)+'…':m.body):'📎 Attachment', {title:(m.fromName||'New message')});
|
||
}
|
||
renderChats(searchVal()); updateRailUnread();
|
||
}
|
||
let chatWs=null, _chatConnectedOnce=false;
|
||
// After a socket DROP + reconnect, this client may have missed messages/ticks while offline — and
|
||
// the desktop/mobile apps can't just be refreshed to recover. So on every RE-connect we re-pull the
|
||
// sidebar and re-open the current conversation (fresh thread + receipts). The first connect is
|
||
// handled by boot's loadSidebar, so we skip the resync then.
|
||
function resyncChat(){
|
||
try{ loadSidebar(); }catch(_){}
|
||
refreshOpenThread(); // NON-destructive catch-up (never wipes the open thread — see below)
|
||
}
|
||
// Re-fetch the open conversation and MERGE: add messages missed while disconnected + refresh
|
||
// read/delivered/seen/edited/deleted flags, but NEVER remove messages already shown. openConvo
|
||
// replaced THREAD wholesale, which could momentarily blank a chat (e.g. a just-sent message) if the
|
||
// reload raced or returned stale — the cause of the "messages disappear after sending" report.
|
||
async function refreshOpenThread(){
|
||
if(!selected || currentTab()!=='chat') return;
|
||
const kind=selected.kind, id=selected.id;
|
||
const url=kind==='group'?('/api/messages/thread?group='+encodeURIComponent(id)):('/api/messages/thread?with='+encodeURIComponent(id));
|
||
let msgs; try{ msgs=await fetch(url).then(r=>r.json()); }catch(_){ return; }
|
||
if(!Array.isArray(msgs)) return;
|
||
if(!selected||selected.kind!==kind||selected.id!==id) return; // switched away while fetching
|
||
const byId=new Map(THREAD.map(m=>[m.id,m])); let changed=false;
|
||
msgs.forEach(nm=>{ const ex=byId.get(nm.id);
|
||
if(!ex){ THREAD.push(nm); changed=true; return; }
|
||
if(nm.read_at&&!ex.read_at){ ex.read_at=nm.read_at; changed=true; }
|
||
if(nm.delivered_at&&!ex.delivered_at){ ex.delivered_at=nm.delivered_at; changed=true; }
|
||
if(nm.deleted&&!ex.deleted){ ex.deleted=true; ex.body=''; ex.attachment=null; ex.reactions=[]; changed=true; }
|
||
if(nm.edited_at&&nm.edited_at!==ex.edited_at){ ex.edited_at=nm.edited_at; ex.body=nm.body; changed=true; }
|
||
if(JSON.stringify(nm.seenBy||[])!==JSON.stringify(ex.seenBy||[])){ ex.seenBy=nm.seenBy||[]; changed=true; }
|
||
});
|
||
if(changed){ THREAD.sort((a,b)=>(a.created_at||0)-(b.created_at||0)); THREAD_CACHE.set(kind+':'+id, THREAD.slice()); renderThread(); }
|
||
}
|
||
let _chatReconnectT=null;
|
||
function connectChatWs(){
|
||
try{
|
||
// Defensive: drop any prior socket first so a flaky reconnect can't leave TWO live sockets —
|
||
// which would deliver every event (and notification) twice. Detach its handlers before closing
|
||
// so its onclose doesn't schedule yet another reconnect.
|
||
if(chatWs){ try{ chatWs.onopen=chatWs.onmessage=chatWs.onclose=chatWs.onerror=null; chatWs.close(); }catch(_){} chatWs=null; }
|
||
if(_chatReconnectT){ clearTimeout(_chatReconnectT); _chatReconnectT=null; }
|
||
chatWs=new WebSocket((location.protocol==='https:'?'wss://':'ws://')+location.host+'/ws');
|
||
chatWs.onopen=()=>{ try{ chatWs.send(JSON.stringify({type:'chat-hello'})); }catch(_){} if(_chatConnectedOnce) resyncChat(); _chatConnectedOnce=true; };
|
||
chatWs.onmessage=(e)=>{ let d; try{ d=JSON.parse(e.data); }catch(_){ return; } if(d.type==='chat-message' && d.message) onChatMessage(d.message); else if(d.type==='chat-deleted') onChatDeleted(d); else if(d.type==='chat-edited') onChatEdited(d); else if(d.type==='chat-reaction') onChatReaction(d); else if(d.type==='poll-update' && d.poll) onPollUpdate(d); else if(d.type==='chat-read') onChatRead(d); else if(d.type==='chat-delivered') onChatDelivered(d); else if(d.type==='group-read') onGroupRead(d); else if(d.type==='group-call') onGroupCall(d); else if(d.type==='dm-call') onDmCall(d); else if(d.type==='call-taken') onCallTaken(d); else if(d.type==='call-answered') onCallAnswered(d); else if(d.type==='presence') onPresence(d); else if(d.type==='chat-typing') onTyping(d); else if(d.type==='notif-clear') onNotifClear(d); else if(d.type==='group-update') onGroupUpdate(d); else if(d.type==='call-invite') showCallInvite(d.room, d.byName); else if(d.type==='meeting-invite') showMeetingInvite(d.meeting); else if(d.type==='meeting-reminder') showMeetingReminder(d.meeting); else if(d.type==='meeting-cancelled') showMeetingCancelled(d.meeting); else if(d.type==='group-role') onGroupRole(d); };
|
||
chatWs.onclose=()=>{ if(_chatReconnectT) clearTimeout(_chatReconnectT); _chatReconnectT=setTimeout(connectChatWs, 3000); }; // auto-reconnect (single pending timer)
|
||
}catch(_){}
|
||
}
|
||
|
||
// ---- Typing indicators (ephemeral, over the chat socket) ----------------------------------
|
||
// Outgoing: while you type in the open conversation we send `chat-typing on` (throttled to once
|
||
// per ~2.5s), and `off` after 4s idle / on send / on leaving the chat.
|
||
let _typingOn=false, _typingIdleT=null, _lastTypingSent=0;
|
||
function _cssEsc(s){ try{ return CSS.escape(String(s)); }catch(_){ return String(s).replace(/[^\w-]/g,'\\$&'); } }
|
||
function _typingSend(on){
|
||
if(!chatWs||chatWs.readyState!==1||!selected||selected.self) return;
|
||
const t=selected.kind==='group'?{group:selected.id}:{to:selected.id};
|
||
try{ chatWs.send(JSON.stringify(Object.assign({type:'chat-typing',on:!!on},t))); }catch(_){}
|
||
}
|
||
function noteTyping(){ // call on every composer keystroke
|
||
if(!selected||selected.self) return;
|
||
const now=Date.now();
|
||
if(!_typingOn || now-_lastTypingSent>2500){ _typingOn=true; _lastTypingSent=now; _typingSend(true); }
|
||
clearTimeout(_typingIdleT); _typingIdleT=setTimeout(stopTyping, 4000);
|
||
}
|
||
function stopTyping(){ clearTimeout(_typingIdleT); if(_typingOn){ _typingOn=false; _typingSend(false); } }
|
||
|
||
// Incoming: per-conversation set of who's currently typing, each with an auto-expiry so a dropped
|
||
// 'off' can't leave the indicator stuck. Keyed 'dm:<userId>' or 'group:<groupId>'.
|
||
const TYPING={};
|
||
function _tKey(kind,id){ return kind+':'+id; }
|
||
function onTyping(d){
|
||
if(!d||!d.from||d.from===ME.id) return; // ignore our own echo (shouldn't happen, but be safe)
|
||
const kind=d.group?'group':'dm', id=d.group||d.from, key=_tKey(kind,id);
|
||
let m=TYPING[key]; if(!m){ m=new Map(); TYPING[key]=m; }
|
||
const cur=m.get(d.from); if(cur&&cur.t) clearTimeout(cur.t);
|
||
if(d.on){ const t=setTimeout(()=>{ m.delete(d.from); if(!m.size) delete TYPING[key]; refreshTyping(kind,id); }, 6000); m.set(d.from,{name:d.name||'',t}); }
|
||
else { m.delete(d.from); if(!m.size) delete TYPING[key]; }
|
||
refreshTyping(kind,id);
|
||
}
|
||
function clearTypingFrom(kind,id,userId){ // a real message arrived → they stopped typing
|
||
const key=_tKey(kind,id), m=TYPING[key]; if(!m) return;
|
||
const cur=m.get(userId); if(cur&&cur.t) clearTimeout(cur.t);
|
||
m.delete(userId); if(!m.size) delete TYPING[key]; refreshTyping(kind,id);
|
||
}
|
||
function typingLabel(kind,id){
|
||
const m=TYPING[_tKey(kind,id)]; if(!m||!m.size) return '';
|
||
if(kind==='dm') return 'typing…';
|
||
const names=[...m.values()].map(v=>firstName(v.name||'Someone'));
|
||
if(names.length===1) return names[0]+' is typing…';
|
||
if(names.length===2) return names[0]+' and '+names[1]+' are typing…';
|
||
return names.length+' people are typing…';
|
||
}
|
||
function refreshTyping(kind,id){
|
||
const it=rowFor(kind,id), lbl=typingLabel(kind,id);
|
||
if(selected && selected.kind===kind && selected.id===id){ // open conversation → header subtitle + in-panel row
|
||
const st=document.querySelector('#convoTitle .st');
|
||
if(st){ if(lbl){ st.textContent=lbl; st.classList.add('typing'); } else { st.classList.remove('typing'); st.textContent=subtitleFor(it||selected); } }
|
||
const tr=document.getElementById('typingRow');
|
||
if(tr){ if(lbl){ tr.innerHTML='<span class="tdots"><i></i><i></i><i></i></span> '+pEsc(lbl); tr.classList.add('show'); } else { tr.classList.remove('show'); tr.innerHTML=''; } }
|
||
}
|
||
if(listEl){ // sidebar row preview
|
||
const row=listEl.querySelector('.chat-row[data-kind="'+kind+'"][data-id="'+_cssEsc(id)+'"]');
|
||
const prev=row&&row.querySelector('.chat-prev');
|
||
if(prev){ if(lbl){ prev.innerHTML='<span class="typing-prev">'+pEsc(lbl)+'</span>'; } else if(it){ prev.innerHTML=rowPreviewHTML(it); } }
|
||
}
|
||
}
|
||
function renderChatPanel(){
|
||
const el=document.getElementById('chatPanel');
|
||
if(!selected){ el.classList.add('center'); el.innerHTML=welcomeHTML(); wireWelcome(); }
|
||
else { el.classList.remove('center'); openConvo(selected.kind, selected.id); }
|
||
}
|
||
// ----- New group modal -----
|
||
function openNewGroup(){
|
||
if(document.getElementById('groupModal')) return;
|
||
const ov=document.createElement('div'); ov.className='modal-ov'; ov.id='groupModal';
|
||
ov.innerHTML='<div class="modal"><h3>New group</h3>'
|
||
+ '<input id="grpName" placeholder="Group name" maxlength="80">'
|
||
+ (CONTACTS.length?'<div class="gi-search" style="margin:.55rem 0 .35rem"><input id="grpSearch" placeholder="Search people…" autocomplete="off"></div>':'')
|
||
+ '<div class="grp-members">'+(CONTACTS.length?CONTACTS.map(c=>'<label class="chk" data-name="'+pEsc((c.name||'').toLowerCase())+'"><input type="checkbox" value="'+pEsc(c.id)+'"> '+pEsc(c.name)+'</label>').join(''):'<div class="muted" style="padding:.5rem">No teammates to add yet.</div>')+'</div>'
|
||
+ '<div class="gi-noresult" id="grpNoResult" style="display:none">No people found</div>'
|
||
+ '<div class="modal-actions"><button class="gobtn" id="grpCancel" style="background:#eef1f6;color:var(--blue)">Cancel</button><button class="gobtn" id="grpCreate">Create group</button></div></div>';
|
||
document.body.appendChild(ov);
|
||
ov.onclick=(e)=>{ if(e.target===ov) ov.remove(); };
|
||
document.getElementById('grpCancel').onclick=()=>ov.remove();
|
||
document.getElementById('grpCreate').onclick=createGroup;
|
||
const gs=document.getElementById('grpSearch');
|
||
if(gs) gs.oninput=()=>{ const q=gs.value.trim().toLowerCase(); let shown=0; ov.querySelectorAll('.grp-members .chk').forEach(l=>{ const vis=!q||(l.dataset.name||'').includes(q); l.style.display=vis?'':'none'; if(vis) shown++; }); const nr=document.getElementById('grpNoResult'); if(nr) nr.style.display=shown?'none':'block'; };
|
||
const n=document.getElementById('grpName'); if(n) n.focus();
|
||
}
|
||
async function createGroup(){
|
||
const name=document.getElementById('grpName').value.trim();
|
||
const ids=[...document.querySelectorAll('#groupModal .grp-members input:checked')].map(i=>i.value);
|
||
if(!name){ toast('Enter a group name'); return; }
|
||
if(!ids.length){ toast('Pick at least one member'); return; }
|
||
try{
|
||
const g=await postJSON('/api/groups',{ name, memberIds:ids });
|
||
const ov=document.getElementById('groupModal'); if(ov) ov.remove();
|
||
await loadSidebar();
|
||
selectChat('group', g.id);
|
||
}catch(e){ toast(e.message||'Could not create group'); }
|
||
}
|
||
async function openGroupInfo(gid){
|
||
let info; try{ info=await fetch('/api/groups/info?group='+encodeURIComponent(gid)).then(r=>r.json()); }catch(_){ return; }
|
||
if(!info||!info.id) return;
|
||
const inSet=new Set(info.members.map(m=>m.id));
|
||
const addable=CONTACTS.filter(c=>!inSet.has(c.id));
|
||
const canManage=info.isAdmin || !info.adminOnly; // who may add/remove members
|
||
const miniAv=(name,avatar)=>'<div class="mini-av" style="background:'+avColor(name)+'">'+pEsc(initials(name))+(avatar?'<img class="av-img" src="'+pEsc(avatar)+'" alt="" onerror="this.remove()">':'')+'</div>';
|
||
const ov=document.createElement('div'); ov.className='modal-ov'; ov.id='groupInfo';
|
||
ov.innerHTML='<div class="modal gi">'
|
||
+ '<input type="file" id="giPhotoInput" accept="image/*" style="display:none">'
|
||
+ '<div class="gi-head"><button class="gi-photo" id="giPhoto" title="Change group photo"><span class="avatar grp" style="width:46px;height:46px;background:'+avColor(info.name)+'">'+ic('users',24)+(info.avatar?'<img class="av-img" src="'+pEsc(info.avatar)+'" alt="" onerror="this.remove()">':'')+'</span><span class="gi-photo-cam">'+ic('camera',13)+'</span></button>'
|
||
+ '<div class="gi-name">'
|
||
+ '<div class="gi-name-row" id="giView"><span class="gi-title">'+pEsc(info.name)+'</span><button class="fav-star'+(((rowFor('group',gid)||{}).favorite)?' on':'')+'" id="giFav" title="Favourite">'+ic('star',15)+'</button><button class="iconbtn sm" id="giEdit" title="Rename group">'+ic('pencil',15)+'</button></div>'
|
||
+ '<div class="gi-edit hidden" id="giEditRow"><input id="giName" value="'+pEsc(info.name)+'" maxlength="80"><button class="iconbtn" id="giSave" title="Save">'+ic('check',18)+'</button><button class="iconbtn" id="giCancelEdit" title="Cancel">'+ic('x',16)+'</button></div>'
|
||
+ '<div class="gi-sub">'+info.members.length+' member'+(info.members.length===1?'':'s')+'</div>'
|
||
+ '</div>'
|
||
+ '<button class="iconbtn" id="giClose" title="Close">'+ic('x',18)+'</button></div>'
|
||
+ '<div class="gi-actions"><button class="gi-act" id="giCall">'+ic('phone',18)+'<span>Call</span></button><button class="gi-act" id="giSchedule">'+ic('calendar',18)+'<span>Schedule</span></button></div>'
|
||
+ (info.createdByName?'<div class="gi-created">'+ic('info',13)+' Created by <b>'+pEsc(info.createdByName)+'</b> on '+pEsc(fmtDateTime(info.createdAt))+'</div>':'')
|
||
+ (info.isAdmin?'<label class="gi-setting"><span>'+ic('lock',15)+' Only admins can add or remove people</span><span class="switch"><input type="checkbox" id="giAdminOnly"'+(info.adminOnly?' checked':'')+'><span class="slider"></span></span></label>':'')
|
||
+ '<div class="gi-sec-h"><span>Shared</span></div>' + mediaRowHTML()
|
||
+ '<div class="gi-sec-h"><span>Members</span>'+(canManage?'<button class="linkbtn" id="giAddToggle">'+ic('userPlus',15)+' Add people</button>':'')+'</div>'
|
||
+ '<div class="gi-list">'+info.members.map(m=>'<div class="mrow'+(m.isMe?'':' dm-able')+'"'+(m.isMe?'':' data-dm="'+pEsc(m.id)+'" title="Message '+pEsc(m.name)+'"')+'>'+miniAv(m.name,m.avatar)+'<span class="mn">'+pEsc(m.name)+(m.isMe?' <span class="youtag">you</span>':'')+(m.admin?' <span class="admin-tag">'+ic('crown',11)+' Admin</span>':'')+'</span>'+((info.isAdmin&&!m.isMe)?'<button class="iconbtn role'+(m.admin?' is-admin':'')+'" data-role="'+pEsc(m.id)+'" data-val="'+(m.admin?'0':'1')+'" title="'+(m.admin?'Remove admin':'Make admin')+'">'+ic('crown',15)+'</button>':'')+((canManage&&!m.isMe)?'<button class="iconbtn rm" data-rm="'+pEsc(m.id)+'" title="Remove">'+ic('trash',15)+'</button>':'')+'</div>').join('')+'</div>'
|
||
+ '<div class="gi-add hidden" id="giAddWrap">'
|
||
+ (addable.length
|
||
? ('<div class="gi-search"><input id="giAddSearch" placeholder="Search people…" autocomplete="off"><button class="search-x" id="giAddSearchX" title="Clear" style="display:none">'+ic('x',14)+'</button></div>'
|
||
+ '<div class="gi-list" id="giAddList">'+addable.map(c=>'<label class="chk" data-name="'+pEsc((c.name||'').toLowerCase())+'"><input type="checkbox" value="'+pEsc(c.id)+'">'+miniAv(c.name,c.avatar)+'<span class="mn">'+pEsc(c.name)+'</span></label>').join('')+'</div>'
|
||
+ '<div class="gi-noresult" id="giAddEmpty" style="display:none">No results found</div>'
|
||
+ '<button class="gobtn" id="giAddBtn" style="width:100%;margin-top:.5rem">Add selected</button>')
|
||
: '<div class="gi-noresult">Everyone in your workspace is already in this group.</div>')
|
||
+ '</div>'
|
||
+ '<button class="gi-leave" id="giLeave">'+ic('logOut',16)+' Leave group</button>'
|
||
+ '</div>';
|
||
const _old=document.getElementById('groupInfo'); if(_old) _old.remove(); // never stack group-info modals (stale close buttons)
|
||
document.body.appendChild(ov);
|
||
wireMediaEntry(ov, 'group', gid, info.name); // "Media, links & docs" entry above the members
|
||
ov.onclick=(e)=>{ if(e.target===ov) ov.remove(); };
|
||
ov.querySelector('#giClose').onclick=()=>ov.remove();
|
||
{ const gf=ov.querySelector('#giFav'); if(gf) gf.onclick=async()=>{ const r=rowFor('group',gid); const on=!(r&&r.favorite); if(r) r.favorite=on; gf.classList.toggle('on',on); gf.title=on?'Remove from favourites':'Add to favourites'; try{ await postJSON('/api/favorites',{kind:'group',id:gid,on}); }catch(_){} renderChats(searchVal()); }; }
|
||
const refresh=async()=>{ await loadSidebar(); if(selected&&selected.kind==='group'&&selected.id===gid) openConvo('group',gid); };
|
||
const view=ov.querySelector('#giView'), editRow=ov.querySelector('#giEditRow'), nameInp=ov.querySelector('#giName'), closeBtn=ov.querySelector('#giClose');
|
||
document.getElementById('giEdit').onclick=()=>{ view.classList.add('hidden'); editRow.classList.remove('hidden'); closeBtn.style.display='none'; nameInp.focus(); nameInp.select(); };
|
||
document.getElementById('giCancelEdit').onclick=()=>{ nameInp.value=info.name; editRow.classList.add('hidden'); view.classList.remove('hidden'); closeBtn.style.display=''; };
|
||
const doRename=async()=>{ const nm=nameInp.value.trim(); if(!nm){ toast('Name required'); return; } try{ await postJSON('/api/groups/rename',{group:gid,name:nm}); ov.remove(); await refresh(); toast('Group renamed'); }catch(e){ toast(e.message); } };
|
||
document.getElementById('giSave').onclick=doRename;
|
||
nameInp.addEventListener('keydown',e=>{ if(e.key==='Enter'){ e.preventDefault(); doRename(); } });
|
||
document.getElementById('giCall').onclick=()=>startGroupCall(gid);
|
||
document.getElementById('giSchedule').onclick=()=>scheduleGroupCall(gid);
|
||
const adminOnlyCb=document.getElementById('giAdminOnly'); if(adminOnlyCb) adminOnlyCb.onchange=async()=>{ try{ await postJSON('/api/groups/admin-only',{ group:gid, value:adminOnlyCb.checked }); ov.remove(); await refresh(); openGroupInfo(gid); }catch(e){ adminOnlyCb.checked=!adminOnlyCb.checked; toast(e.message||'Could not update'); } };
|
||
const addTgl=document.getElementById('giAddToggle'); if(addTgl) addTgl.onclick=()=>{ const w=document.getElementById('giAddWrap'); if(w){ w.classList.toggle('hidden'); if(!w.classList.contains('hidden')){ const s=document.getElementById('giAddSearch'); if(s) setTimeout(()=>s.focus(),0); } } };
|
||
const addSearch=document.getElementById('giAddSearch');
|
||
if(addSearch){
|
||
const filter=()=>{ const q=addSearch.value.trim().toLowerCase(); const xb=document.getElementById('giAddSearchX'); if(xb) xb.style.display=q?'grid':'none'; let shown=0; ov.querySelectorAll('#giAddList .chk').forEach(l=>{ const vis=(!q||(l.dataset.name||'').includes(q)); l.style.display=vis?'':'none'; if(vis) shown++; }); const empty=document.getElementById('giAddEmpty'); if(empty) empty.style.display=shown?'none':'block'; };
|
||
addSearch.addEventListener('input', filter);
|
||
const xb=document.getElementById('giAddSearchX'); if(xb) xb.onclick=()=>{ addSearch.value=''; filter(); addSearch.focus(); };
|
||
}
|
||
const addBtn=document.getElementById('giAddBtn'); if(addBtn) addBtn.onclick=async()=>{ const ids=[...ov.querySelectorAll('#giAddWrap input:checked')].map(i=>i.value); if(!ids.length){ toast('Pick members to add'); return; } try{ await postJSON('/api/groups/add',{group:gid,memberIds:ids}); ov.remove(); await refresh(); openGroupInfo(gid); }catch(e){ toast(e.message); } };
|
||
const nameOf=(uid)=>{ const mm=info.members.find(x=>x.id===uid); return mm?mm.name:'this person'; };
|
||
// #9: tapping a member (not their admin/remove buttons) opens the private conversation with them.
|
||
ov.querySelectorAll('.mrow[data-dm]').forEach(row=>row.addEventListener('click',(e)=>{
|
||
if(e.target.closest('button')) return; // let the admin / remove buttons do their thing
|
||
const id=row.dataset.dm; if(!id) return;
|
||
ov.remove(); switchTab('chat'); selectChat('dm', id);
|
||
}));
|
||
ov.querySelectorAll('[data-rm]').forEach(b=>b.onclick=async()=>{ const nm=nameOf(b.dataset.rm); if(!confirm('Remove '+nm+' from the group?\n\nThey will lose access to this conversation.')) return; try{ await postJSON('/api/groups/remove',{group:gid,userId:b.dataset.rm}); ov.remove(); await refresh(); openGroupInfo(gid); }catch(e){ toast(e.message); } });
|
||
ov.querySelectorAll('[data-role]').forEach(b=>b.onclick=async()=>{ const mk=b.dataset.val==='1'; const nm=nameOf(b.dataset.role); if(!confirm(mk?('Make '+nm+' an admin?\n\nThey will be able to add or remove members and manage this group.'):('Remove '+nm+' as an admin?'))) return; try{ await postJSON('/api/groups/admin',{group:gid,userId:b.dataset.role,value:mk}); ov.remove(); await refresh(); openGroupInfo(gid); }catch(e){ toast(e.message); } });
|
||
document.getElementById('giLeave').onclick=async()=>{
|
||
const admins=info.members.filter(m=>m.admin), others=info.members.filter(m=>!m.isMe);
|
||
// #10: last admin must pick a successor before leaving.
|
||
if(info.isAdmin && others.length && admins.length===1){ ov.remove(); pickSuccessorThenLeave(gid, others); return; }
|
||
if(!confirm('Leave this group?')) return;
|
||
try{ await postJSON('/api/groups/remove',{group:gid}); ov.remove(); selected=null; await loadSidebar(); renderChatPanel(); }catch(e){ toast(e.message); }
|
||
};
|
||
// Group photo: click avatar -> pick image -> upload -> set as group image.
|
||
const photoBtn=document.getElementById('giPhoto'), photoInput=document.getElementById('giPhotoInput');
|
||
if(photoBtn&&photoInput){
|
||
photoBtn.onclick=()=>photoInput.click();
|
||
photoInput.onchange=async()=>{
|
||
const file=photoInput.files&&photoInput.files[0]; if(!file) return;
|
||
if(!/^image\//.test(file.type||'')){ toast('Please choose an image file'); return; }
|
||
try{
|
||
const up=await fetch('/api/messages/upload',{ method:'POST', headers:{ 'Content-Type':file.type||'application/octet-stream', 'X-Filename':encodeURIComponent(file.name) }, body:file }).then(r=>r.json());
|
||
if(!up||!up.id) throw new Error(up&&up.error||'Upload failed');
|
||
await postJSON('/api/groups/avatar',{ group:gid, attachmentId:up.id });
|
||
ov.remove(); await refresh(); openGroupInfo(gid); toast('Group photo updated');
|
||
}catch(e){ toast(e.message||'Could not update photo'); }
|
||
};
|
||
}
|
||
}
|
||
// Start an audio call with the group: spin up an audio-only meeting and post the join code to the group chat.
|
||
// Group-info "Call" button → start (or join) the SHARED group call so every member is notified/rung.
|
||
function startGroupCall(gid){ const ov=document.getElementById('groupInfo'); if(ov) ov.remove(); startOrJoinGroupCall(gid); }
|
||
// Schedule a call tied to this group: close group info, open the schedule modal.
|
||
function scheduleGroupCall(gid){ const ov=document.getElementById('groupInfo'); if(ov) ov.remove(); openScheduleModal(gid); }
|
||
|
||
// ---------- Meetings (mesh P2P video) ----------
|
||
let meetWs=null, meetLocalStream=null, meetRoom=null, meetMyId=null, meetState='idle';
|
||
let meetAnnounceGroup=null; // when set, the new meeting's code is posted to this group chat
|
||
let meetAudioOnly=false; // audio call (no camera) — tiles show avatars instead of video
|
||
const meetPeers=new Map(); // peerId -> { pc, name }
|
||
const meetMuted=new Map(); // peerId|'__local' -> muted (for the tile mic-off badge)
|
||
const meetNames=new Map(); // peerId -> name (peers that arrive before their offer)
|
||
const meetAvatars=new Map(); // peerId -> avatar URL (for participant-tile profile pics)
|
||
const meetPeerUids=new Map();// peerId -> user id (to tell when an invitee has joined)
|
||
const meetCamOff=new Map(); // peerId -> camera off? (a disabled remote track still arrives, so show the avatar)
|
||
const meetInvited=new Map(); // user id -> {name, timer} for invitees who haven't joined yet (#6)
|
||
let meetReturn=null; // {kind:'dm'|'group', id} — chat to land on when the call ends (null = meetings tab)
|
||
const meetVU=new Map(); // peerId|'__local' -> {ctx,analyser,data,raf} active-speaker meters
|
||
let MEET_ICE={ iceServers:[{ urls:'stun:stun.l.google.com:19302' }] };
|
||
let meetMic=true, meetCam=true;
|
||
let meetIsHost=false, meetHostId=null; // host = the meeting creator (transferable by the host only)
|
||
let meetScreen=false, meetScreenStream=null; // am I sharing my screen + the display stream
|
||
const meetSharers=new Set(); // peerIds of OTHERS currently sharing their screen
|
||
let meetMultiShare=false; // host setting: allow several people to share at once (default: one at a time)
|
||
let meetRec=null; // active composite recording (host) {rec, stop()}
|
||
let meetTranscribe=false; // am I subscribed to a transcript copy
|
||
let meetRoomTx=false; // is the room transcription active (≥1 subscriber → all mics transcribe)
|
||
let meetSR=null; // my SpeechRecognition instance
|
||
let meetStageId=null; // which shared screen is currently on the stage (peerId|'__local')
|
||
function meetSend(o){ try{ if(meetWs && meetWs.readyState===1) meetWs.send(JSON.stringify(o)); }catch(_){} }
|
||
|
||
// ================= LiveKit SFU media plane (feature-flagged) =================
|
||
// When the server reports sfu:true, meeting MEDIA flows through LiveKit instead of the P2P mesh:
|
||
// each client uploads once and the SFU forwards to everyone, so rooms scale past ~5. Everything
|
||
// else — the meeting WebSocket control plane (join/host/mute/screen/recording/transcript), the tile
|
||
// UI, recording and transcript — is UNCHANGED. Tiles stay keyed by the WS peerId; LiveKit tracks map
|
||
// back to a peer via its uid (LiveKit identity = app user id = the peer's uid in meetPeerUids).
|
||
let SFU={ on:false, url:'', room:null, lib:null, peers:new Map() }; // peers: pid -> {audio,cam,screen}
|
||
async function sfuInit(){ try{ const c=await fetch('/api/meetings/config').then(r=>r.json()); SFU.on=!!(c&&c.sfu); SFU.url=(c&&c.url)||''; }catch(_){ SFU.on=false; } }
|
||
function sfuLoadLib(){ return new Promise((res,rej)=>{ if(window.LivekitClient) return res(window.LivekitClient); const s=document.createElement('script'); s.src='/vendor/livekit-client.umd.min.js'; s.onload=()=>res(window.LivekitClient); s.onerror=()=>rej(new Error('livekit sdk failed to load')); document.head.appendChild(s); }); }
|
||
function peerIdForUid(uid){ for(const [pid,u] of meetPeerUids){ if(u===uid) return pid; } return null; }
|
||
async function sfuConnect(){
|
||
const LK=await sfuLoadLib(); SFU.lib=LK;
|
||
// Guests (external link joiners, not signed in) get an unauthenticated guest token for this room.
|
||
const tk=(ME&&ME.guest)
|
||
? await postJSON('/api/meetings/guest-token',{ room:meetRoom, name:ME.name, identity:ME.id }) // identity must match the guestId sent over signaling
|
||
: await postJSON('/api/meetings/token',{ room:meetRoom }); // per-user, per-room join credential
|
||
// adaptiveStream + dynacast BOTH off: we attach media via manual srcObject (not track.attach), so
|
||
// LiveKit can't observe tile visibility. With dynacast on, a screen-share layer was paused unless a
|
||
// camera track was also flowing → the screen showed blank / only when the camera was on, and was
|
||
// slow to appear (#9). Off = every published track always flows at full quality.
|
||
const room=new LK.Room({ adaptiveStream:false, dynacast:false }); SFU.room=room;
|
||
room.on(LK.RoomEvent.TrackSubscribed, (track,pub,participant)=>sfuAttach(pub,track,participant));
|
||
room.on(LK.RoomEvent.TrackUnsubscribed, (track,pub,participant)=>sfuDetach(pub,track,participant));
|
||
await room.connect(SFU.url, tk.token);
|
||
// Re-apply the user's saved mic/speaker choice to this room (#3: a picked headset/Bluetooth device
|
||
// must actually be used, not just remembered).
|
||
try{ if(meetMicId && room.switchActiveDevice) await room.switchActiveDevice('audioinput', meetMicId); }catch(_){}
|
||
try{ if(meetSinkId && room.switchActiveDevice) await room.switchActiveDevice('audiooutput', meetSinkId); }catch(_){}
|
||
}
|
||
// Per-peer track bookkeeping → one tile stream that prefers the screen track over the camera,
|
||
// mirroring the mesh's single-video-per-tile model and driving the existing sharing/stage UI.
|
||
function sfuRebuild(pid){
|
||
const p=SFU.peers.get(pid)||{}; const st=new MediaStream();
|
||
const vid=p.screen||p.cam; if(vid) st.addTrack(vid); if(p.audio) st.addTrack(p.audio);
|
||
addTile(pid, st, meetNames.get(pid)||'Guest', false); setTileScreen(pid, !!p.screen); meetWatchStream(pid, st);
|
||
}
|
||
function sfuAttach(pub, track, participant, _try){
|
||
let pid=peerIdForUid(participant.identity);
|
||
if(!pid){ // uid→peerId map (from the mesh join) may lag the LiveKit track — retry a few times…
|
||
if((_try||0)<6){ setTimeout(()=>sfuAttach(pub,track,participant,(_try||0)+1), 400); return; }
|
||
// …then fall back: a NATIVE (CallKit + LiveKit) participant joins LiveKit but NOT our WS mesh, so it has
|
||
// no mesh peer id. Key its tile + audio by the LiveKit identity so it still shows and is heard. The
|
||
// outgoing "Ringing…" placeholder is cleared here (its mesh 'answered' signal never fires for native),
|
||
// and we label the tile from the contact roster so it shows a NAME + DP, not a raw user id.
|
||
pid='lk:'+participant.identity;
|
||
removeWaitingTile(); stopRingback();
|
||
if(!meetNames.get(pid)){
|
||
const c=(typeof CONTACTS!=='undefined'?CONTACTS:[]).find(x=>x&&x.id===participant.identity);
|
||
meetNames.set(pid, (c&&c.name) || (_dmCallWaiting&&_dmCallWaiting.name) || participant.name || 'Caller');
|
||
if(c&&c.avatar) meetAvatars.set(pid, c.avatar);
|
||
}
|
||
}
|
||
const p=SFU.peers.get(pid)||{}; SFU.peers.set(pid,p); const mt=track.mediaStreamTrack;
|
||
if(track.kind==='audio') p.audio=mt;
|
||
else if(pub.source===SFU.lib.Track.Source.ScreenShare){ p.screen=mt; meetSharers.add(pid); }
|
||
else p.cam=mt;
|
||
sfuRebuild(pid); updateShareMode();
|
||
}
|
||
function sfuDetach(pub, track, participant){
|
||
let pid=peerIdForUid(participant.identity); if(!pid) pid='lk:'+participant.identity; const p=SFU.peers.get(pid); if(!p) return; const mt=track.mediaStreamTrack;
|
||
if(p.audio===mt) p.audio=null; else if(p.screen===mt){ p.screen=null; meetSharers.delete(pid); setTileScreen(pid,false); } else if(p.cam===mt) p.cam=null;
|
||
sfuRebuild(pid); updateShareMode();
|
||
}
|
||
function sfuDropPeer(pid){ SFU.peers.delete(pid); }
|
||
// Reflect my LiveKit local mic/cam tracks into meetLocalStream so the self tile + active-speaker
|
||
// meter + canvas recording (which read tile <video> srcObjects) keep working unchanged.
|
||
function sfuSyncLocal(){
|
||
const lp=SFU.room&&SFU.room.localParticipant; if(!lp) return; const LK=SFU.lib;
|
||
const mic=lp.getTrackPublication(LK.Track.Source.Microphone), cam=lp.getTrackPublication(LK.Track.Source.Camera);
|
||
const mt=mic&&mic.track&&mic.track.mediaStreamTrack, ct=cam&&cam.track&&cam.track.mediaStreamTrack;
|
||
meetLocalStream.getTracks().forEach(t=>{ if(t!==mt&&t!==ct) meetLocalStream.removeTrack(t); });
|
||
if(mt&&meetLocalStream.getTracks().indexOf(mt)<0) meetLocalStream.addTrack(mt);
|
||
if(ct&&meetLocalStream.getTracks().indexOf(ct)<0) meetLocalStream.addTrack(ct);
|
||
addTile('__local', meetLocalStream, (ME&&ME.name)?ME.name:'You', true); meetWatchStream('__local', meetLocalStream);
|
||
}
|
||
async function sfuSetMic(on){ const lp=SFU.room&&SFU.room.localParticipant; if(!lp) return; await lp.setMicrophoneEnabled(on); sfuSyncLocal(); }
|
||
async function sfuSetCam(on){ const lp=SFU.room&&SFU.room.localParticipant; if(!lp) return; await lp.setCameraEnabled(on); sfuSyncLocal(); }
|
||
function sfuDisconnect(){ if(SFU.room){ try{ SFU.room.disconnect(); }catch(_){} SFU.room=null; } SFU.peers.clear(); }
|
||
// #15: turn a getUserMedia / LiveKit media failure into a SPECIFIC, actionable message (and log the
|
||
// raw error) instead of always blaming "permission" — which hid the real cause on the desktop app.
|
||
function mediaErrMsg(e, what){
|
||
const n=((e&&e.name)||'')+''; try{ console.error('[media] '+what+' failed:', n, e&&e.message, e); }catch(_){}
|
||
const isDesk=(typeof nativePlatform==='function' && nativePlatform()==='desktop');
|
||
const Cap=what.charAt(0).toUpperCase()+what.slice(1);
|
||
if(n==='NotAllowedError'||n==='PermissionDeniedError'||n==='SecurityError') return isDesk
|
||
? Cap+' access is blocked. Enable it in Windows Settings → Privacy & security → '+(what==='camera'?'Camera':'Microphone')+' (turn on "Let desktop apps access…"), then retry.'
|
||
: Cap+' permission was blocked — allow it from your browser’s address-bar icon and retry.';
|
||
if(n==='NotFoundError'||n==='DevicesNotFoundError') return 'No '+what+' was found on this device.';
|
||
if(n==='NotReadableError'||n==='TrackStartError'||n==='AbortError') return 'Your '+what+' is in use by another app — close it and retry.';
|
||
if(n==='OverconstrainedError') return 'No '+what+' matches the requested settings.';
|
||
return 'Couldn’t start '+what+(e&&e.message?(' ('+e.message+')'):'')+'.';
|
||
}
|
||
// ================= end LiveKit SFU =================
|
||
function meetRailLive(on){ const b=document.querySelector('.railbtn[data-tab="meeting"]'); if(b) b.classList.toggle('live', !!on); }
|
||
|
||
function renderMeetingLobby(){
|
||
meetState='idle';
|
||
const el=document.getElementById('meetingPanel'); if(!el) return;
|
||
el.innerHTML='<div class="meet-dash">'
|
||
+ '<div class="md-top">'
|
||
+ '<div class="md-title"><h1>Meetings</h1><p>Start or join a video meeting, or schedule one for later. Small group (mesh) for now — larger rooms coming with the SFU.</p></div>'
|
||
+ '<div class="md-actions">'
|
||
+ '<div class="md-join"><input id="meetCode" placeholder="Enter code" inputmode="numeric" maxlength="6"><button class="btn primary" id="meetJoinBtn">Join</button></div>'
|
||
+ '<button class="btn" id="meetStart">'+ic('video',16)+' Start a meeting</button>'
|
||
+ '<button class="btn primary" id="meetSchedule">'+ic('calendar',16)+' Schedule</button>'
|
||
+ '</div>'
|
||
+ '</div>'
|
||
+ '<div class="hint" id="meetErr"></div>'
|
||
+ '<div class="sched-wrap" id="schedWrap"><div class="sched-empty">Loading meetings…</div></div>'
|
||
+ '</div>';
|
||
document.getElementById('meetStart').onclick=()=>enterMeeting(null);
|
||
document.getElementById('meetSchedule').onclick=()=>openScheduleModal(null);
|
||
const doJoin=()=>{ const c=document.getElementById('meetCode').value.trim(); if(/^\d{6}$/.test(c)) enterMeeting(c); else document.getElementById('meetErr').textContent='Enter a valid 6-digit code.'; };
|
||
document.getElementById('meetJoinBtn').onclick=doJoin;
|
||
document.getElementById('meetCode').addEventListener('keydown', e=>{ if(e.key==='Enter') doJoin(); });
|
||
loadScheduledMeetings();
|
||
}
|
||
// Fetch + render scheduled meetings, bucketed into Running / Upcoming / Past.
|
||
// #7: Past meetings are paged + date-filterable on the SERVER (the list used to be hard-capped at 12).
|
||
// This is a report, so it gets report-style presets plus a custom range using the SAME branded calendar
|
||
// as the scheduler (a raw <input type=date> looked out of place and behaved differently per browser).
|
||
let _mtgPage=1, _mtgFrom=null, _mtgTo=null, _mtgPreset='all', _mtgTotal=0, _mtgPageSize=10;
|
||
const MTG_PRESETS=[
|
||
{k:'all', label:'All time'},
|
||
{k:'today', label:'Today'},
|
||
{k:'yesterday',label:'Yesterday'},
|
||
{k:'7d', label:'Last 7 days'},
|
||
{k:'30d', label:'Last 30 days'},
|
||
{k:'month', label:'This month'},
|
||
{k:'ytd', label:'Year to date'},
|
||
{k:'custom', label:'Custom range…'},
|
||
];
|
||
function applyMtgPreset(k){
|
||
_mtgPreset=k;
|
||
const now=new Date();
|
||
const sod=(d)=>{ const x=new Date(d); x.setHours(0,0,0,0); return x.getTime(); };
|
||
const eod=(d)=>{ const x=new Date(d); x.setHours(23,59,59,999); return x.getTime(); };
|
||
const daysAgo=(n)=>{ const x=new Date(now); x.setDate(x.getDate()-n); return x; };
|
||
if(k==='all'){ _mtgFrom=null; _mtgTo=null; }
|
||
else if(k==='today'){ _mtgFrom=sod(now); _mtgTo=eod(now); }
|
||
else if(k==='yesterday'){ const y=daysAgo(1); _mtgFrom=sod(y); _mtgTo=eod(y); }
|
||
else if(k==='7d'){ _mtgFrom=sod(daysAgo(6)); _mtgTo=eod(now); }
|
||
else if(k==='30d'){ _mtgFrom=sod(daysAgo(29)); _mtgTo=eod(now); }
|
||
else if(k==='month'){ _mtgFrom=sod(new Date(now.getFullYear(),now.getMonth(),1)); _mtgTo=eod(now); }
|
||
else if(k==='ytd'){ _mtgFrom=sod(new Date(now.getFullYear(),0,1)); _mtgTo=eod(now); }
|
||
// 'custom' keeps whatever from/to are already set
|
||
_mtgPage=1; loadScheduledMeetings();
|
||
}
|
||
const mtgDateLbl=(ts)=>ts?new Date(ts).toLocaleDateString([],{month:'short',day:'numeric',year:'numeric'}):'Any';
|
||
// A compact branded calendar popup — same look/behaviour as the scheduler's picker. opts.min/opts.max
|
||
// (ms) disable out-of-range days so an invalid range (from > to) can't be picked (#4).
|
||
function openDatePop(anchor, current, onPick, opts){
|
||
opts=opts||{};
|
||
const minDay=opts.min?new Date(opts.min).setHours(0,0,0,0):null;
|
||
const maxDay=opts.max?new Date(opts.max).setHours(0,0,0,0):null;
|
||
document.querySelectorAll('.date-pop').forEach(x=>x.remove());
|
||
const base=current?new Date(current):new Date();
|
||
let viewY=base.getFullYear(), viewM=base.getMonth();
|
||
const pop=document.createElement('div'); pop.className='pick-pop date-pop';
|
||
const D1=['S','M','T','W','T','F','S'];
|
||
const draw=()=>{
|
||
const first=new Date(viewY,viewM,1), startDow=first.getDay(), dim=new Date(viewY,viewM+1,0).getDate();
|
||
const selTs=current?new Date(current).setHours(0,0,0,0):0;
|
||
let h='<div class="cal-head"><button type="button" class="cal-nav" data-nav="-1">'+ic('chevronLeft',16)+'</button>'
|
||
+'<b>'+first.toLocaleDateString([],{month:'long',year:'numeric'})+'</b>'
|
||
+'<button type="button" class="cal-nav" data-nav="1">'+ic('chevronRight',16)+'</button></div><div class="cal-grid">'
|
||
+D1.map(d=>'<span class="cal-dow">'+d+'</span>').join('');
|
||
for(let i=0;i<startDow;i++) h+='<span></span>';
|
||
for(let d=1;d<=dim;d++){ const ts=new Date(viewY,viewM,d).setHours(0,0,0,0);
|
||
const disabled=(minDay!==null&&ts<minDay)||(maxDay!==null&&ts>maxDay);
|
||
h+='<button type="button" class="cal-day'+(ts===selTs?' sel':'')+'"'+(disabled?' disabled':'')+' data-ts="'+ts+'">'+d+'</button>'; }
|
||
pop.innerHTML=h+'</div>';
|
||
pop.querySelectorAll('.cal-nav').forEach(b=>b.onclick=(e)=>{ e.stopPropagation(); viewM+=(+b.dataset.nav); if(viewM<0){viewM=11;viewY--;} if(viewM>11){viewM=0;viewY++;} draw(); });
|
||
pop.querySelectorAll('.cal-day:not([disabled])').forEach(b=>b.onclick=()=>{ pop.remove(); onPick(+b.dataset.ts); });
|
||
};
|
||
document.body.appendChild(pop); draw();
|
||
const r=anchor.getBoundingClientRect();
|
||
pop.style.left=Math.max(8,Math.min(r.left, window.innerWidth-pop.offsetWidth-8))+'px';
|
||
pop.style.top=Math.min(r.bottom+6, window.innerHeight-pop.offsetHeight-8)+'px';
|
||
const close=(e)=>{ if(!pop.contains(e.target) && e.target!==anchor){ pop.remove(); document.removeEventListener('mousedown',close); } };
|
||
setTimeout(()=>document.addEventListener('mousedown',close),0);
|
||
}
|
||
async function loadScheduledMeetings(){
|
||
const wrap=document.getElementById('schedWrap'); if(!wrap) return;
|
||
const qs=new URLSearchParams();
|
||
qs.set('page', String(_mtgPage)); qs.set('pageSize', String(_mtgPageSize));
|
||
if(_mtgFrom) qs.set('from', String(_mtgFrom));
|
||
if(_mtgTo) qs.set('to', String(_mtgTo));
|
||
let res; try{ res=await fetch('/api/meetings?'+qs.toString()).then(r=>r.json()); }catch(_){ return; }
|
||
const list=Array.isArray(res)?res:(res&&Array.isArray(res.list)?res.list:[]); // tolerate the old array shape
|
||
_mtgTotal=(res&&res.pastTotal)||0; _mtgPageSize=(res&&res.pageSize)||_mtgPageSize;
|
||
const filtering=!!(_mtgFrom||_mtgTo);
|
||
if(!list.length && !filtering && !_mtgTotal){ wrap.innerHTML='<div class="sched-empty">No meetings yet. Start one now or schedule it for later.</div>'; return; }
|
||
const running=list.filter(m=>m.status==='running');
|
||
const upcoming=list.filter(m=>m.status==='upcoming').sort((a,b)=>a.scheduledAt-b.scheduledAt);
|
||
const past=list.filter(m=>m.status==='past'||m.status==='cancelled'); // server already sorted+paged
|
||
const fmt=ts=>new Date(ts).toLocaleString([],{weekday:'short',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'});
|
||
const card=m=>{
|
||
const cancelled=m.status==='cancelled';
|
||
const meta=[]; if(m.groupName) meta.push(pEsc(m.groupName)); meta.push(fmt(m.scheduledAt));
|
||
if(m.participantCount) meta.push(m.participantCount+' participants'); // #7: from the call log
|
||
if(m.durationMins) meta.push(m.durationMins+' min');
|
||
if(m.recurrenceLabel) meta.push('🔁 '+pEsc(m.recurrenceLabel));
|
||
if(m.status==='running') meta.push(m.inCall+' in call');
|
||
if(m.isHost) meta.push('You\'re the host'); else if(m.createdByName) meta.push('Host: '+pEsc(m.createdByName)); // skip "Host: —" for logged calls
|
||
if(m.invited&&m.invited.length) meta.push(m.invited.length+' invited');
|
||
// Cancel only on a FUTURE upcoming meeting (#13: not once the time has passed).
|
||
const canCancel=m.canManage && m.status==='upcoming' && m.scheduledAt>Date.now();
|
||
// Start any time BEFORE the meeting's end; once the window passes it's 'past' and can't start (#3).
|
||
const canStart=m.status==='running' || m.status==='upcoming';
|
||
return '<div class="sched-item'+(m.status==='running'?' live':'')+(cancelled?' cancelled':'')+'">'
|
||
+'<div class="si-main"><div class="si-title">'+pEsc(m.title)+(m.status==='running'?'<span class="livedot">● Live</span>':'')+(cancelled?'<span class="cancel-tag">Cancelled</span>':'')+(m.isHost?'<span class="host-tag">'+ic('crown',11)+' Host</span>':'')+'</div>'
|
||
+'<div class="si-meta">'+meta.join(' · ')+'</div>'
|
||
+(m.description?'<div class="si-desc">'+pEsc(m.description)+'</div>':'')
|
||
+(m.invited&&m.invited.length?'<div class="si-invited" title="'+pEsc(m.invited.join(', '))+'">'+ic('users',12)+' '+pEsc(m.invited.slice(0,3).join(', '))+(m.invited.length>3?(' +'+(m.invited.length-3)):'')+'</div>':'')
|
||
+(m.recordings&&m.recordings.length?'<div class="si-recs">'+m.recordings.map(r=>'<a class="rec-dl '+(r.kind==='video'?'vid':'txt')+'" href="'+pEsc(r.url)+'" title="Download '+(r.kind==='video'?'recording':'transcript')+'">'+ic('download',14)+'<span>'+(r.kind==='video'?'Recording':'Transcript')+'</span>'+(r.kind==='video'&&r.durationMs?'<span class="rd-dur">'+fmtElapsed(r.durationMs)+'</span>':'')+'</a>').join('')+'</div>':'')+'</div>'
|
||
+'<div class="si-actions">'
|
||
+((m.status!=='past'&&!cancelled)?'<button class="iconbtn copylink" data-link="'+pEsc(m.link||'')+'" title="Copy invite link" aria-label="Copy invite link">'+ic('link',14)+'</button>':'')
|
||
+((m.status!=='past'&&!cancelled&&canStart)?'<button class="btn sm join" data-code="'+pEsc(m.roomCode)+'">'+(m.status==='running'?'Join':'Start')+'</button>':'')
|
||
+(canCancel?'<button class="iconbtn edit" data-edit="'+pEsc(m.id)+'" title="Edit meeting" aria-label="Edit meeting">'+ic('pencil',14)+'</button>':'')
|
||
+(canCancel?'<button class="iconbtn cancel-ic" data-cancel="'+pEsc(m.id)+'" title="Cancel meeting" aria-label="Cancel meeting">'+ic('calendarX',14)+'</button>':'')
|
||
+'</div></div>';
|
||
};
|
||
const byId={}; list.forEach(m=>byId[m.id]=m);
|
||
const sec=(title,arr)=>arr.length?('<div class="sched-sec"><div class="sched-h">'+title+'</div><div class="sched-list">'+arr.map(card).join('')+'</div></div>'):'';
|
||
// Past section: date range + pager. Always rendered (even with 0 results) so the filter stays reachable.
|
||
const pages=Math.max(1, Math.ceil(_mtgTotal/_mtgPageSize));
|
||
const custom=_mtgPreset==='custom';
|
||
const pastSec='<div class="sched-sec"><div class="sched-h">Past meetings'
|
||
+'<span class="mtg-filter">'+ic('calendar',13)
|
||
+'<select id="mtgPreset">'+MTG_PRESETS.map(p=>'<option value="'+p.k+'"'+(_mtgPreset===p.k?' selected':'')+'>'+pEsc(p.label)+'</option>').join('')+'</select>'
|
||
+(custom?('<button type="button" class="mtg-date" id="mtgFromBtn">'+pEsc(mtgDateLbl(_mtgFrom))+'</button>'
|
||
+'<span class="mtg-dash">–</span>'
|
||
+'<button type="button" class="mtg-date" id="mtgToBtn">'+pEsc(mtgDateLbl(_mtgTo))+'</button>'):'')
|
||
+((_mtgFrom||_mtgTo)?'<button class="mtg-clear" id="mtgClear" title="Clear filter">'+ic('x',13)+'</button>':'')
|
||
+'</span></div>'
|
||
+(past.length?('<div class="sched-list">'+past.map(card).join('')+'</div>')
|
||
:('<div class="sched-empty" style="margin:0">'+((_mtgFrom||_mtgTo)?'No meetings in that date range.':'No past meetings yet.')+'</div>'))
|
||
+(_mtgTotal>_mtgPageSize?('<div class="mtg-pager">'
|
||
+'<button class="mtg-pg" id="mtgPrev"'+(_mtgPage<=1?' disabled':'')+'>'+ic('chevronLeft',14)+'</button>'
|
||
+'<span>Page '+_mtgPage+' of '+pages+' · '+_mtgTotal+' total</span>'
|
||
+'<button class="mtg-pg" id="mtgNext"'+(_mtgPage>=pages?' disabled':'')+'>'+ic('chevronRight',14)+'</button>'
|
||
+'</div>'):'')
|
||
+'</div>';
|
||
wrap.innerHTML=sec('Ongoing now',running)+sec('Upcoming meetings',upcoming)+pastSec;
|
||
{ const ps=wrap.querySelector('#mtgPreset');
|
||
if(ps) ps.onchange=()=>{ if(ps.value==='custom'){ _mtgPreset='custom'; loadScheduledMeetings(); } else applyMtgPreset(ps.value); };
|
||
const fb=wrap.querySelector('#mtgFromBtn'), tb=wrap.querySelector('#mtgToBtn');
|
||
if(fb) fb.onclick=(e)=>{ e.stopPropagation(); openDatePop(fb, _mtgFrom, (ts)=>{ _mtgFrom=ts; _mtgPage=1; loadScheduledMeetings(); }, {max:_mtgTo}); }; // #4: from ≤ to
|
||
if(tb) tb.onclick=(e)=>{ e.stopPropagation(); openDatePop(tb, _mtgTo, (ts)=>{ const end=new Date(ts); end.setHours(23,59,59,999); _mtgTo=end.getTime(); _mtgPage=1; loadScheduledMeetings(); }, {min:_mtgFrom}); };
|
||
const c=wrap.querySelector('#mtgClear'); if(c) c.onclick=()=>applyMtgPreset('all');
|
||
const pv=wrap.querySelector('#mtgPrev'), nx=wrap.querySelector('#mtgNext');
|
||
if(pv) pv.onclick=()=>{ if(_mtgPage>1){ _mtgPage--; loadScheduledMeetings(); } };
|
||
if(nx) nx.onclick=()=>{ if(_mtgPage<pages){ _mtgPage++; loadScheduledMeetings(); } };
|
||
}
|
||
wrap.querySelectorAll('[data-code]').forEach(b=>b.onclick=()=>enterMeeting(b.dataset.code));
|
||
wrap.querySelectorAll('[data-link]').forEach(b=>b.onclick=()=>{ const u=b.dataset.link; if(!u){ toast('No link'); return; } const done=()=>toast('Invite link copied'); try{ if(navigator.clipboard&&navigator.clipboard.writeText) return void navigator.clipboard.writeText(u).then(done).catch(()=>fallbackCopy(u,done)); }catch(_){} fallbackCopy(u,done); });
|
||
wrap.querySelectorAll('[data-edit]').forEach(b=>b.onclick=()=>{ const m=byId[b.dataset.edit]; if(m) openScheduleModal(m.groupId||null, m); });
|
||
wrap.querySelectorAll('[data-cancel]').forEach(b=>b.onclick=()=>cancelMeeting(byId[b.dataset.cancel]));
|
||
}
|
||
// Last admin leaving must hand off: pick who becomes the new admin, then leave (#10, selective).
|
||
function pickSuccessorThenLeave(gid, others){
|
||
const ov=document.createElement('div'); ov.className='modal-ov';
|
||
ov.innerHTML='<div class="modal" style="max-width:380px"><div class="gi-title" style="margin-bottom:.2rem">Choose a new admin</div><p style="color:var(--muted);font-size:.88rem;margin:.3rem 0 .8rem">You\'re the only admin. Pick someone to take over before you leave.</p><div class="gi-list" style="max-height:40vh;overflow:auto">'+others.map(m=>'<label class="chk"><input type="radio" name="heir" value="'+pEsc(m.id)+'"><span class="mini-av" style="background:'+avColor(m.name)+'">'+pEsc(initials(m.name))+'</span><span class="mn">'+pEsc(m.name)+'</span></label>').join('')+'</div><button class="gobtn" id="heirGo" style="width:100%;margin-top:.8rem;background:var(--blue);color:#fff">Make admin & leave</button><div class="hint" id="heirErr"></div></div>';
|
||
document.body.appendChild(ov); ov.onclick=e=>{ if(e.target===ov) ov.remove(); };
|
||
ov.querySelector('#heirGo').onclick=async()=>{ const sel=ov.querySelector('input[name=heir]:checked'); if(!sel){ ov.querySelector('#heirErr').textContent='Please pick a member.'; return; }
|
||
try{ await postJSON('/api/groups/remove',{group:gid, newAdmin:sel.value}); ov.remove(); selected=null; await loadSidebar(); renderChatPanel(); toast('Left the group'); }catch(e){ ov.querySelector('#heirErr').textContent=e.message||'Could not leave'; } };
|
||
}
|
||
// Cancel a meeting. Recurring → ask whether to cancel just this occurrence or all future (#recurring).
|
||
function cancelMeeting(m){
|
||
if(!m) return;
|
||
const doCancel=async(scope)=>{ try{ await postJSON('/api/meetings/cancel',{id:m.id, scope}); loadScheduledMeetings(); }catch(e){ toast(e.message); } };
|
||
if(m.recurrence&&m.recurrence.length){
|
||
const dlabel=new Date(m.scheduledAt).toLocaleString([],{weekday:'short',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'});
|
||
const ov=document.createElement('div'); ov.className='modal-ov';
|
||
ov.innerHTML='<div class="modal" style="max-width:390px"><div class="gi-title" style="margin-bottom:.2rem">Cancel recurring meeting</div><p style="color:var(--muted);font-size:.9rem;margin:.3rem 0 1rem">“'+pEsc(m.title)+'” repeats '+pEsc(m.recurrenceLabel||'weekly')+'. What do you want to cancel?</p><div style="display:flex;flex-direction:column;gap:.5rem"><button class="btn" id="cmOne" style="background:var(--blue);color:#fff">Only this occurrence ('+pEsc(dlabel)+')</button><button class="btn" id="cmAll" style="background:#fee2e2;color:#b91c1c">Cancel all future</button><button class="linkbtn" id="cmNo" style="align-self:center;border:none">Keep meeting</button></div></div>';
|
||
document.body.appendChild(ov); ov.onclick=e=>{ if(e.target===ov) ov.remove(); };
|
||
ov.querySelector('#cmOne').onclick=()=>{ ov.remove(); doCancel('one'); };
|
||
ov.querySelector('#cmAll').onclick=()=>{ ov.remove(); doCancel('all'); };
|
||
ov.querySelector('#cmNo').onclick=()=>ov.remove();
|
||
} else { if(confirm('Cancel this meeting? Participants will be notified.')) doCancel('all'); }
|
||
}
|
||
// Schedule / edit modal with a custom calendar + time picker. gid ties it to a group; editMtg edits.
|
||
function openScheduleModal(gid, editMtg){
|
||
const pad=n=>String(n).padStart(2,'0'); const now=new Date();
|
||
const startOfDay=d=>{ const x=new Date(d); x.setHours(0,0,0,0); return x; };
|
||
const editing=!!editMtg;
|
||
const base=editing?new Date(editMtg.scheduledAt):new Date(now.getTime()+30*60000);
|
||
let selDate=startOfDay(base);
|
||
let selMin=Math.round((base.getHours()*60+base.getMinutes())/15)*15;
|
||
let viewY=selDate.getFullYear(), viewM=selDate.getMonth();
|
||
let recur=(editing&&Array.isArray(editMtg.recurrence))?editMtg.recurrence.slice():[];
|
||
const DAYW=['Sun','Mon','Tue','Wed','Thu','Fri','Sat'], DAY1=['S','M','T','W','T','F','S'];
|
||
const todayMid=startOfDay(now).getTime();
|
||
const label12=m=>{ let h=Math.floor(m/60),mm=m%60; const ap=h<12?'AM':'PM'; let h12=h%12||12; return h12+':'+pad(mm)+' '+ap; };
|
||
const dateLabel=d=>d.toLocaleDateString([],{weekday:'short',month:'short',day:'numeric'});
|
||
const invitedIds=new Set(editing&&Array.isArray(editMtg.invitedIds)?editMtg.invitedIds:[]);
|
||
const ov=document.createElement('div'); ov.className='modal-ov'; ov.id='schedModal';
|
||
ov.innerHTML='<div class="modal sched">'
|
||
+'<div class="gi-head" style="margin-bottom:.6rem"><div class="avatar grp" style="width:40px;height:40px;flex:0 0 40px;background:var(--blue)">'+ic('calendarClock',20)+'</div>'
|
||
+'<div class="gi-name"><div class="gi-title">'+(editing?'Edit meeting':'Schedule a call')+'</div><div class="gi-sub">Pick a date & time and add details</div></div>'
|
||
+'<button class="iconbtn" id="schClose" title="Close">'+ic('x',18)+'</button></div>'
|
||
+'<label class="flbl">Title</label><input id="schTitle" class="finput" placeholder="e.g. Weekly sync" maxlength="120">'
|
||
+'<div class="sch-row">'
|
||
+ '<div class="picker-field"><label class="flbl">Date</label><button type="button" class="finput pick-btn" id="schDateBtn">'+ic('calendar',16)+'<span id="schDateLbl"></span></button><div class="pick-pop hidden" id="schCal"></div></div>'
|
||
+ '<div class="picker-field"><label class="flbl">Time</label><button type="button" class="finput pick-btn" id="schTimeBtn">'+ic('calendarClock',16)+'<span id="schTimeLbl"></span></button><div class="pick-pop time-pop hidden" id="schTimePop"></div></div>'
|
||
+ '<div><label class="flbl">Duration</label><select id="schDur" class="finput"><option value="15">15 min</option><option value="30">30 min</option><option value="45">45 min</option><option value="60">1 hour</option><option value="90">1.5 hours</option><option value="120">2 hours</option></select></div>'
|
||
+'</div>'
|
||
+'<label class="chk2 switch-row"><span>'+ic('calendarClock',15)+' Repeat weekly</span><span class="switch"><input type="checkbox" id="schRepeat"><span class="slider"></span></span></label>'
|
||
+'<div class="sch-days hidden" id="schDays">'+DAY1.map((d,i)=>'<button type="button" class="day-chip" data-d="'+i+'" title="'+DAYW[i]+'">'+d+'</button>').join('')+'<button type="button" class="day-all" data-all="1">Everyday</button></div>'
|
||
+'<label class="flbl">Description <span class="opt">(optional)</span></label><textarea id="schDesc" class="finput" rows="2" placeholder="What\'s this call about?"></textarea>'
|
||
+'<label class="flbl">Invite participants</label>'
|
||
+(CONTACTS.length>6?'<div class="gi-search" style="margin:.15rem 0 .35rem"><input id="schPplSearch" placeholder="Search people…" autocomplete="off"></div>':'')
|
||
+'<div class="gi-list" id="schPeople" style="max-height:20vh;overflow:auto">'+(CONTACTS.length?CONTACTS.map(c=>'<label class="chk" data-nm="'+pEsc((c.name||'').toLowerCase())+'"><input type="checkbox" value="'+pEsc(c.id)+'"'+(invitedIds.has(c.id)?' checked':'')+'><span class="mini-av" style="background:'+avColor(c.name)+'">'+pEsc(initials(c.name))+'</span><span class="mn">'+pEsc(c.name)+'</span></label>').join(''):'<div class="gi-noresult">No contacts to invite</div>')+'</div>'
|
||
+'<label class="flbl" style="margin-top:.7rem">Invite by email <span class="opt">(guests — no Connect account needed)</span></label>'
|
||
+'<div class="email-invite"><input id="schEmail" class="finput" type="email" placeholder="name@example.com" autocomplete="off"><button type="button" class="email-add" id="schEmailAdd">'+ic('userPlus',15)+' Add</button></div>'
|
||
+'<div class="email-chips" id="schEmailChips"></div>'
|
||
+'<label class="chk2 switch-row" style="margin-top:.6rem"><span>'+ic('users',15)+' Guests must be admitted by the host</span><span class="switch"><input type="checkbox" id="schLobby" checked><span class="slider"></span></span></label>'
|
||
+'<button class="gobtn" id="schSave" style="width:100%;margin-top:.9rem;background:var(--blue);color:#fff">'+(editing?'Save changes':'Schedule & invite')+'</button>'
|
||
+'<div class="hint" id="schErr"></div></div>';
|
||
document.body.appendChild(ov);
|
||
ov.onclick=e=>{ if(e.target===ov) ov.remove(); };
|
||
document.getElementById('schClose').onclick=()=>ov.remove();
|
||
const $=id=>document.getElementById(id);
|
||
if(editing){ $('schTitle').value=editMtg.title||''; $('schDesc').value=editMtg.description||''; if(editMtg.durationMins) $('schDur').value=String(editMtg.durationMins); { const lb=$('schLobby'); if(lb) lb.checked=(editMtg.lobby!==false); } }
|
||
else $('schDur').value='30';
|
||
const err=$('schErr');
|
||
const dateBtn=$('schDateBtn'), timeBtn=$('schTimeBtn'), cal=$('schCal'), timePop=$('schTimePop');
|
||
const clearErrAll=()=>{ err.textContent=''; [dateBtn,timeBtn,$('schTitle')].forEach(e=>e&&e.classList.remove('field-err')); };
|
||
function syncLabels(){ $('schDateLbl').textContent=dateLabel(selDate); $('schTimeLbl').textContent=label12(selMin); }
|
||
function renderCal(){
|
||
const first=new Date(viewY,viewM,1), startDow=first.getDay(), dim=new Date(viewY,viewM+1,0).getDate();
|
||
const canPrev=!(viewY===now.getFullYear()&&viewM===now.getMonth());
|
||
let h='<div class="cal-head"><button type="button" class="cal-nav" id="calPrev"'+(canPrev?'':' disabled')+'>'+ic('arrowLeft',16)+'</button><b>'+first.toLocaleDateString([],{month:'long',year:'numeric'})+'</b><button type="button" class="cal-nav" id="calNext">'+ic('arrowRight',16)+'</button></div><div class="cal-grid">'+DAY1.map(d=>'<span class="cal-dow">'+d+'</span>').join('');
|
||
for(let i=0;i<startDow;i++) h+='<span></span>';
|
||
for(let day=1;day<=dim;day++){ const dt=new Date(viewY,viewM,day).getTime(); const past=dt<todayMid; h+='<button type="button" class="cal-day'+(dt===selDate.getTime()?' sel':'')+(dt===todayMid?' today':'')+'"'+(past?' disabled':'')+' data-day="'+day+'">'+day+'</button>'; }
|
||
cal.innerHTML=h+'</div>';
|
||
const pv=cal.querySelector('#calPrev'); if(pv&&canPrev) pv.onclick=()=>{ if(--viewM<0){viewM=11;viewY--;} renderCal(); };
|
||
cal.querySelector('#calNext').onclick=()=>{ if(++viewM>11){viewM=0;viewY++;} renderCal(); };
|
||
cal.querySelectorAll('.cal-day:not([disabled])').forEach(b=>b.onclick=()=>{ selDate=new Date(viewY,viewM,+b.dataset.day); cal.classList.add('hidden'); clearErrAll(); renderTimes(); syncLabels(); });
|
||
}
|
||
function renderTimes(){
|
||
const isToday=selDate.getTime()===todayMid; const minM=isToday?(now.getHours()*60+now.getMinutes()):-1;
|
||
if(selMin<=minM){ selMin=Math.ceil((minM+1)/15)*15; }
|
||
let h='',any=false;
|
||
for(let m=0;m<24*60;m+=15){ if(m<=minM) continue; any=true; h+='<button type="button" class="time-chip'+(m===selMin?' sel':'')+'" data-m="'+m+'">'+label12(m)+'</button>'; }
|
||
timePop.innerHTML=any?h:'<div class="gi-noresult" style="padding:.7rem">No times left today</div>';
|
||
timePop.querySelectorAll('.time-chip').forEach(b=>b.onclick=()=>{ selMin=+b.dataset.m; timePop.classList.add('hidden'); clearErrAll(); syncLabels(); });
|
||
syncLabels();
|
||
}
|
||
dateBtn.onclick=e=>{ e.stopPropagation(); timePop.classList.add('hidden'); cal.classList.toggle('hidden'); if(!cal.classList.contains('hidden')){ viewY=selDate.getFullYear(); viewM=selDate.getMonth(); renderCal(); } };
|
||
timeBtn.onclick=e=>{ e.stopPropagation(); cal.classList.add('hidden'); timePop.classList.toggle('hidden'); if(!timePop.classList.contains('hidden')){ renderTimes(); const s=timePop.querySelector('.time-chip.sel'); if(s) s.scrollIntoView({block:'center'}); } };
|
||
ov.addEventListener('click',e=>{ if(!e.target.closest('.picker-field')){ cal.classList.add('hidden'); timePop.classList.add('hidden'); } });
|
||
renderTimes(); syncLabels();
|
||
// Repeat (iOS switch) + circular day chips
|
||
const repeat=$('schRepeat'), daysWrap=$('schDays');
|
||
if(editing&&recur.length){ repeat.checked=true; daysWrap.classList.remove('hidden'); recur.forEach(d=>{ const b=daysWrap.querySelector('.day-chip[data-d="'+d+'"]'); if(b) b.classList.add('on'); }); }
|
||
repeat.onchange=()=>{ daysWrap.classList.toggle('hidden', !repeat.checked); };
|
||
daysWrap.querySelectorAll('.day-chip').forEach(b=>b.onclick=()=>b.classList.toggle('on'));
|
||
daysWrap.querySelector('.day-all').onclick=()=>{ const allOn=daysWrap.querySelectorAll('.day-chip.on').length===7; daysWrap.querySelectorAll('.day-chip').forEach(x=>x.classList.toggle('on', !allOn)); };
|
||
$('schTitle').addEventListener('input',clearErrAll);
|
||
setTimeout(()=>$('schTitle').focus(),0);
|
||
// External (guest) email invitees — chips you can add/remove; each gets an emailed join link (#4).
|
||
const EMAIL_RE=/^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||
const emailList=(editing&&Array.isArray(editMtg.guestEmails))?editMtg.guestEmails.slice():[];
|
||
function renderEmailChips(){ const w=$('schEmailChips'); if(!w) return; w.innerHTML=emailList.map((e,i)=>'<span class="echip">'+pEsc(e)+'<button type="button" data-i="'+i+'" title="Remove">'+ic('x',12)+'</button></span>').join(''); w.querySelectorAll('.echip button').forEach(b=>b.onclick=()=>{ emailList.splice(+b.dataset.i,1); renderEmailChips(); }); }
|
||
function addEmail(){ const inp=$('schEmail'); const v=(inp.value||'').trim().toLowerCase(); if(!v) return; if(!EMAIL_RE.test(v)){ err.textContent='Enter a valid email address.'; return; } if(!emailList.includes(v)) emailList.push(v); inp.value=''; err.textContent=''; renderEmailChips(); }
|
||
$('schEmailAdd').onclick=addEmail;
|
||
$('schEmail').addEventListener('keydown',e=>{ if(e.key==='Enter'){ e.preventDefault(); addEmail(); } });
|
||
renderEmailChips();
|
||
// #10: filter the participant list as you type.
|
||
{ const ps=$('schPplSearch'); if(ps) ps.addEventListener('input',()=>{ const q=ps.value.trim().toLowerCase(); ov.querySelectorAll('#schPeople .chk').forEach(l=>{ l.style.display=(!q||(l.dataset.nm||'').includes(q))?'':'none'; }); }); }
|
||
$('schSave').onclick=async()=>{
|
||
const title=$('schTitle').value.trim();
|
||
const desc=$('schDesc').value.trim();
|
||
const durationMins=parseInt($('schDur').value,10)||30;
|
||
const participants=[...ov.querySelectorAll('#schPeople input:checked')].map(i=>i.value);
|
||
{ const pend=($('schEmail').value||'').trim().toLowerCase(); if(pend && EMAIL_RE.test(pend) && !emailList.includes(pend)) emailList.push(pend); } // include a typed-but-not-added email
|
||
clearErrAll();
|
||
if(!title){ err.textContent='Please add a title.'; $('schTitle').classList.add('field-err'); $('schTitle').focus(); return; }
|
||
const ts=new Date(selDate.getFullYear(),selDate.getMonth(),selDate.getDate(),Math.floor(selMin/60),selMin%60,0,0).getTime();
|
||
if(ts < Date.now()+60000){ err.textContent='That time has already passed — pick a future time.'; timeBtn.classList.add('field-err'); return; }
|
||
let recurrence=[]; if(repeat.checked){ recurrence=[...daysWrap.querySelectorAll('.day-chip.on')].map(b=>+b.dataset.d); if(!recurrence.length) recurrence=[new Date(ts).getDay()]; }
|
||
const whenText=new Date(ts).toLocaleString([],{weekday:'short',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'});
|
||
try{
|
||
const lobby=!!($('schLobby')&&$('schLobby').checked);
|
||
if(editing){ await postJSON('/api/meetings/update',{ id:editMtg.id, title, description:desc, scheduledAt:ts, durationMins, participants, participantEmails:emailList, recurrence, lobby }); toast('Meeting updated'); }
|
||
else { const invn=participants.length+emailList.length; await postJSON('/api/meetings/schedule',{ group:gid||undefined, title, description:desc, scheduledAt:ts, whenText, participants, participantEmails:emailList, durationMins, recurrence, lobby }); toast('Meeting scheduled'+(invn?' · '+invn+' invited':'')); }
|
||
ov.remove(); switchTab('meeting'); loadScheduledMeetings();
|
||
}catch(e){ err.textContent=e.message||'Could not save'; }
|
||
};
|
||
}
|
||
// Branded connecting screen shown from the moment a call is entered until the room is ready —
|
||
// so a slow link shows our loader, not a blank/half meeting window.
|
||
function renderCallConnecting(){
|
||
const el=document.getElementById('meetingPanel'); if(!el) return;
|
||
el.innerHTML='<div class="call-connecting"><img src="/loaders/loader-orbit-dark.svg" width="76" height="76" alt=""><div class="cc-txt">Connecting to call…</div></div>';
|
||
}
|
||
// #4 Lobby — guest side: waiting for the host to admit them. _inLobby lets meeting-joined know it must
|
||
// rebuild the call UI (the waiting screen replaced it, so without this the guest stayed on "waiting…"
|
||
// forever even after being admitted).
|
||
let _inLobby=false;
|
||
function renderLobbyWait(){
|
||
_inLobby=true;
|
||
const el=document.getElementById('meetingPanel'); if(!el) return;
|
||
el.innerHTML='<div class="call-connecting"><img src="/loaders/loader-orbit-dark.svg" width="76" height="76" alt=""><div class="cc-txt">Waiting for the host to let you in…</div><div style="color:var(--muted);font-size:.85rem;margin-top:.3rem">You’ll join automatically once they admit you.</div></div>';
|
||
}
|
||
// #4 Lobby — host side: a guest is asking to join. Stacks like the call-invite banners.
|
||
function showLobbyRequest(peerId, name){
|
||
if(!peerId || document.getElementById('lob-'+peerId)) return;
|
||
try{ playPing(); }catch(_){}
|
||
const el=document.createElement('div'); el.className='call-invite lobby-req'; el.id='lob-'+peerId;
|
||
el.innerHTML='<span class="ci-ico">'+ic('users',18)+'</span><span class="ci-txt"><b>'+pEsc(name||'A guest')+'</b><br>wants to join the meeting</span>'
|
||
+'<button class="ci-join">'+ic('check',16)+' Admit</button>'
|
||
+'<button class="ci-decline" title="Deny">'+ic('x',16)+' Deny</button>';
|
||
document.body.appendChild(el);
|
||
el.querySelector('.ci-join').onclick=()=>{ meetSend({type:'meeting-admit', peerId}); el.remove(); };
|
||
el.querySelector('.ci-decline').onclick=()=>{ meetSend({type:'meeting-reject', peerId}); el.remove(); };
|
||
}
|
||
function dismissLobbyRequest(peerId){ const el=document.getElementById('lob-'+peerId); if(el){ try{ el.remove(); }catch(_){} } }
|
||
function renderCall(){
|
||
const el=document.getElementById('meetingPanel'); if(!el) return;
|
||
el.innerHTML='<div class="meet"><div class="meet-grid" id="meetGrid"></div>'
|
||
+ '<div class="meet-bar"><span class="code" id="meetCodeChip">Room <b>'+pEsc(meetRoom||'')+'</b> · share to invite</span>'
|
||
+ '<span class="mic-grp"><button class="meet-ic'+(meetMic?'':' off')+'" id="meetMicBtn" title="'+(meetMic?'Mute':'Unmute')+'">'+ic(meetMic?'mic':'micOff',20)+'</button>'
|
||
+ '<button class="mic-caret" id="meetAudioBtn" title="Audio devices (mic & speaker)">'+ic('chevronUp',12)+'</button></span>'
|
||
+ '<button class="meet-ic'+(meetCam?'':' off')+'" id="meetCamBtn" title="'+(meetCam?'Turn camera off':'Turn camera on')+'">'+ic(meetCam?'video':'videoOff',20)+'</button>'
|
||
// Mobile keeps only Mic / Camera / Speaker / End on the bar (#8e); everything else moves behind ⋮ More.
|
||
+ '<button class="meet-ic sec-btn" id="meetScreenBtn" title="Share screen">'+ic('monitor',20)+'</button>'
|
||
+ '<button class="meet-ic host-only sec-btn" id="meetRecBtn" title="Record meeting" style="display:none">'+ic('record',20)+'</button>'
|
||
+ ((ME&&ME.guest)?'':'<button class="meet-ic sec-btn" id="meetTransBtn" title="Live transcript">'+ic('fileText',20)+'</button>') // #12: transcript is a signed-in feature (guests can't download it)
|
||
+ '<button class="meet-ic sec-btn" id="meetChatBtn" title="Chat"><span class="mc-badge" id="meetChatBadge" style="display:none">0</span>'+ic('chat',20)+'</button>'
|
||
+ (canRouteAudio()?('<button class="meet-ic spk-btn'+(meetRoute==='speaker'?'':' off')+'" id="meetSpkBtn" title="'+pEsc(routeLabel())+'">'+ic(spkIcon(),20)+'</button>'):'') // #1: only where output switching actually works
|
||
+ '<button class="meet-ic sec-btn" id="meetPplBtn" title="Participants">'+ic('users',20)+'</button>'
|
||
+ '<button class="meet-ic more-btn" id="meetMoreBtn" title="More">'+ic('moreVertical',20)+'</button>'
|
||
+ '<button class="meet-ic leave" id="meetLeaveBtn" title="Leave">'+ic('callEnd',20)+'</button></div></div>';
|
||
document.getElementById('meetMicBtn').onclick=toggleMic;
|
||
document.getElementById('meetCamBtn').onclick=toggleCam;
|
||
document.getElementById('meetScreenBtn').onclick=toggleScreen;
|
||
document.getElementById('meetRecBtn').onclick=toggleRecord;
|
||
{ const tb=document.getElementById('meetTransBtn'); if(tb) tb.onclick=toggleTranscribe; }
|
||
document.getElementById('meetPplBtn').onclick=toggleMeetPanel;
|
||
{ const cb=document.getElementById('meetChatBtn'); if(cb) cb.onclick=toggleMeetChat; }
|
||
{ const ab=document.getElementById('meetAudioBtn'); if(ab) ab.onclick=(e)=>{ e.stopPropagation(); openAudioMenu(ab); }; } // desktop: mic ▾ → devices
|
||
{ const sb=document.getElementById('meetSpkBtn'); if(sb) sb.onclick=toggleSpeakerphone; } // speaker / headset toggle
|
||
{ const mb=document.getElementById('meetMoreBtn'); if(mb) mb.onclick=(e)=>{ e.stopPropagation(); openMeetMore(mb); }; } // mobile: ⋮ → the rest
|
||
document.getElementById('meetLeaveBtn').onclick=leaveMeeting;
|
||
updateHostControls();
|
||
// Click another shared screen (in the side column) to bring it onto the stage.
|
||
const grid=document.getElementById('meetGrid'); if(grid) grid.addEventListener('click', e=>{ if(!grid.classList.contains('sharing-mode')) return; const t=e.target.closest('.meet-tile.sharing'); if(t && !t.classList.contains('stage')){ setStage(t.id.replace('meet-tile-','')); } });
|
||
addTile('__local', meetLocalStream, (ME&&ME.name)?ME.name:'You', true);
|
||
setTileMute('__local', !meetMic);
|
||
detectBtOutput(); // #8d: pick the right speaker/bluetooth glyph for the current route
|
||
makeDraggable(document.querySelector('.meet-bar'), 'bzc_meetbar_pos'); // new #4: bar covers the shared screen → let them move it
|
||
}
|
||
// ---- Audio devices (#3): one menu behind the MIC's ▾ caret, listing Speaker + Microphone (like Teams),
|
||
// instead of a separate headphones button. Choosing a speaker applies setSinkId to every meeting media
|
||
// element; choosing a mic switches the live input device.
|
||
function isMobileUA(){ return /Android|iPhone|iPad|iPod|Mobile/i.test(navigator.userAgent||''); }
|
||
// #1: choosing the audio OUTPUT from the web needs setSinkId, which Android Chrome and iOS Safari do NOT
|
||
// implement — the OS owns the route there (and iOS forces loudspeaker while a mic track is live). Rather
|
||
// than ship a button that silently does nothing, we only show the speaker control where it really works.
|
||
// Real speaker/earpiece/Bluetooth switching on phones needs the native (Capacitor) build's audio plugin.
|
||
// Native iOS AudioRoute plugin (injected into the app via ios-patch.sh) — switches earpiece<->speaker at the
|
||
// AVAudioSession level, which the web setSinkId API can't do on iOS. Null in the browser/PWA/Android.
|
||
function nativeAudioRoute(){ try{ var C=window.Capacitor; return (C && C.getPlatform && C.getPlatform()==='ios' && C.Plugins && C.Plugins.AudioRoute) ? C.Plugins.AudioRoute : null; }catch(_){ return null; } }
|
||
function bzApplyRoute(route){
|
||
var ar=nativeAudioRoute();
|
||
// iOS: the WKWebView owns call audio and auto-routes it — a connected Bluetooth/wired headset, else the
|
||
// loudspeaker. PROVEN on device: the app can force NEITHER the earpiece NOR the loudspeaker-over-active-BT.
|
||
// CRITICAL: do NOT call setSpeaker / reconfigure the AVAudioSession here. This runs on EVERY tap during a
|
||
// call (via bzUnlockAudio), and setCategory/setActive mid-call interrupts WebKit's audio unit and DROPS the
|
||
// call audio (esp. on Bluetooth). We only wire up the route listener so the button icon tracks the real
|
||
// output; the route itself is left entirely to iOS. Native-grade routing (earpiece, hold speaker over BT,
|
||
// CallKit) would require a native WebRTC stack (e.g. the LiveKit iOS SDK), not the webview.
|
||
if(ar){ bzInitNativeRoute(); }
|
||
}
|
||
// The speaker toggle is usable if the browser supports output switching (setSinkId) OR the native iOS route
|
||
// plugin is present.
|
||
function canRouteAudio(){ try{ if(nativeAudioRoute()) return true; return typeof HTMLMediaElement!=='undefined' && typeof HTMLMediaElement.prototype.setSinkId==='function'; }catch(_){ return false; } }
|
||
// new #4: let the user drag a floating bar out of the way — it otherwise covers part of the shared
|
||
// screen. Drag from anywhere on the bar except a control. Position is remembered per bar.
|
||
function makeDraggable(el, key){
|
||
if(!el || el._drag) return; el._drag=true;
|
||
let sx=0, sy=0, ox=0, oy=0, dragging=false;
|
||
const clamp=()=>{ const w=el.offsetWidth, h=el.offsetHeight;
|
||
let x=parseFloat(el.style.left||'0'), y=parseFloat(el.style.top||'0');
|
||
x=Math.max(4, Math.min(x, window.innerWidth-w-4)); y=Math.max(4, Math.min(y, window.innerHeight-h-4));
|
||
el.style.left=x+'px'; el.style.top=y+'px'; };
|
||
const pin=(r)=>{ el.style.position='fixed'; el.style.left=r.left+'px'; el.style.top=r.top+'px'; el.style.right='auto'; el.style.bottom='auto'; el.style.margin='0'; el.style.width=r.width+'px'; };
|
||
try{ const s=JSON.parse(localStorage.getItem(key)||'null'); if(s&&typeof s.x==='number'){ const r=el.getBoundingClientRect(); pin({left:s.x, top:s.y, width:r.width}); clamp(); } }catch(_){}
|
||
const pt=(e)=>e.touches?e.touches[0]:e;
|
||
const move=(e)=>{ if(!dragging) return; const p=pt(e);
|
||
el.style.left=(ox+(p.clientX-sx))+'px'; el.style.top=(oy+(p.clientY-sy))+'px'; clamp();
|
||
if(e.cancelable) e.preventDefault(); };
|
||
const up=()=>{ if(!dragging) return; dragging=false; el.classList.remove('dragging');
|
||
document.removeEventListener('mousemove',move); document.removeEventListener('mouseup',up);
|
||
document.removeEventListener('touchmove',move); document.removeEventListener('touchend',up);
|
||
try{ localStorage.setItem(key, JSON.stringify({x:parseFloat(el.style.left), y:parseFloat(el.style.top)})); }catch(_){} };
|
||
const down=(e)=>{
|
||
if(e.target.closest('button,select,input,a')) return; // don't hijack the controls
|
||
const r=el.getBoundingClientRect(); pin(r);
|
||
const p=pt(e); sx=p.clientX; sy=p.clientY; ox=r.left; oy=r.top; dragging=true; el.classList.add('dragging');
|
||
document.addEventListener('mousemove',move); document.addEventListener('mouseup',up);
|
||
document.addEventListener('touchmove',move,{passive:false}); document.addEventListener('touchend',up);
|
||
if(e.cancelable) e.preventDefault();
|
||
};
|
||
el.addEventListener('mousedown',down);
|
||
el.addEventListener('touchstart',down,{passive:false});
|
||
el.classList.add('draggable');
|
||
}
|
||
let meetSinkId=(()=>{ try{ return localStorage.getItem('bzc_sink')||''; }catch(_){ return ''; } })();
|
||
let meetMicId=(()=>{ try{ return localStorage.getItem('bzc_micdev')||''; }catch(_){ return ''; } })();
|
||
let meetSpeakerOn=(()=>{ try{ return localStorage.getItem('bzc_spk')!=='0'; }catch(_){ return true; } })(); // mobile speakerphone
|
||
function applySink(el){ try{ if(el && meetSinkId && typeof el.setSinkId==='function') el.setSinkId(meetSinkId).catch(()=>{}); }catch(_){} }
|
||
function applySinkAll(){ document.querySelectorAll('#meetGrid video').forEach(applySink); }
|
||
// Bluetooth/headset devices report themselves in the label — used to pick a sensible icon on mobile.
|
||
function isBtLabel(l){ return /bluetooth|airpod|buds|headset|headphone|wh-|wf-/i.test(l||''); }
|
||
// #8c/#8e MOBILE audio route: ONE button that cycles Speaker → Earpiece → Bluetooth (only when a headset
|
||
// is connected) and back. The icon always shows the route in use, so no separate "Audio devices" entry is
|
||
// needed on mobile. meetRoute is the source of truth; meetSpeakerOn stays in sync for the lite/off styling.
|
||
let _btConnected=false;
|
||
let meetRoute=(()=>{ try{ return localStorage.getItem('bzc_route')||'speaker'; }catch(_){ return 'speaker'; } })(); // 'speaker'|'earpiece'|'bt'
|
||
// iOS only: the real active output as reported by the native plugin ('speaker'|'bluetooth'|'wired'|'receiver'|
|
||
// 'airplay'). The web layer can't see audio outputs on iOS, so the icon is driven by this instead of meetRoute.
|
||
let _nativeOutput=null;
|
||
function iosRoute(){ return _nativeOutput; } // truthy only on iOS native once the plugin has reported
|
||
function spkIcon(){
|
||
if(iosRoute()){ return _nativeOutput==='bluetooth' ? 'bluetooth' : (_nativeOutput==='wired' ? 'headphones' : 'speaker'); }
|
||
return meetRoute==='bt' ? 'bluetooth' : (meetRoute==='speaker' ? 'speaker' : 'speakerOff');
|
||
}
|
||
function routeLabel(){
|
||
if(iosRoute()){ return _nativeOutput==='bluetooth' ? 'Bluetooth' : (_nativeOutput==='wired' ? 'Headset' : 'Speaker'); }
|
||
return meetRoute==='bt' ? 'Bluetooth / headset' : (meetRoute==='speaker' ? 'Speaker' : 'Earpiece');
|
||
}
|
||
function refreshSpkBtn(){
|
||
const b=document.getElementById('meetSpkBtn'); if(!b) return;
|
||
if(meetRoute==='earpiece' && nativeAudioRoute()) meetRoute='speaker'; // iOS: earpiece is unreachable in the WebView; coerce stale prefs
|
||
if(iosRoute()){
|
||
// On iOS the button is a live OUTPUT indicator (speaker/bluetooth/headphones), not a speaker on/off — so
|
||
// never dim it (a dimmed icon read as "disabled" when audio was simply on Bluetooth).
|
||
meetSpeakerOn=(_nativeOutput==='speaker');
|
||
b.classList.remove('off');
|
||
} else {
|
||
meetSpeakerOn=(meetRoute==='speaker');
|
||
b.classList.toggle('off', meetRoute!=='speaker');
|
||
}
|
||
b.innerHTML=ic(spkIcon(),20);
|
||
b.title=routeLabel()+' — tap to switch';
|
||
}
|
||
// iOS: subscribe to the native plugin's real output route so the button icon reflects reality (BT/headset/speaker).
|
||
let _bzNativeRouteReady=false;
|
||
function bzInitNativeRoute(){
|
||
const ar=nativeAudioRoute(); if(!ar) return;
|
||
if(!_bzNativeRouteReady && ar.addListener){ _bzNativeRouteReady=true;
|
||
try{ ar.addListener('routeChange', function(e){ bzOnNativeRoute(e && e.output); }); }catch(_){}
|
||
}
|
||
if(ar.getRoute){ try{ ar.getRoute().then(function(r){ bzOnNativeRoute(r && r.output); }).catch(function(){}); }catch(_){} }
|
||
}
|
||
function bzOnNativeRoute(output){
|
||
if(!output) return;
|
||
_nativeOutput=output;
|
||
meetRoute = (output==='bluetooth'||output==='wired') ? 'bt' : (output==='speaker' ? 'speaker' : meetRoute);
|
||
refreshSpkBtn();
|
||
}
|
||
// Detect a connected BT/headset output so the cycle can include it (and the icon can show it).
|
||
async function detectBtOutput(){
|
||
try{ const d=await navigator.mediaDevices.enumerateDevices(); _btConnected=d.some(x=>x.kind==='audiooutput' && isBtLabel(x.label)); }catch(_){ _btConnected=false; }
|
||
if(meetRoute==='bt' && !_btConnected) meetRoute='speaker'; // headset unplugged → fall back
|
||
refreshSpkBtn();
|
||
}
|
||
try{ if(navigator.mediaDevices && navigator.mediaDevices.addEventListener) navigator.mediaDevices.addEventListener('devicechange', ()=>{ detectBtOutput(); }); }catch(_){}
|
||
// #8e: on mobile the bar keeps only Mic/Camera/Speaker/End — the rest live behind this ⋮ menu.
|
||
function openMeetMore(anchor){
|
||
// #4: tapping ⋮ again must CLOSE it. Previously we removed the menu then rebuilt it immediately, so it
|
||
// looked like nothing happened.
|
||
const open=document.querySelector('.spk-menu.mm-menu');
|
||
document.querySelectorAll('.spk-menu').forEach(x=>x.remove());
|
||
if(open) return;
|
||
const items=[];
|
||
items.push({ic:'monitor', label:'Share screen', fn:toggleScreen});
|
||
if(meetIsHost) items.push({ic:'record', label:(meetRec?'Stop recording':'Record meeting'), fn:toggleRecord});
|
||
if(!(ME&&ME.guest)) items.push({ic:'fileText', label:(meetTranscribe?'Stop transcript':'Live transcript'), fn:toggleTranscribe});
|
||
items.push({ic:'chat', label:'Chat'+(meetChatUnread?(' ('+meetChatUnread+')'):''), fn:toggleMeetChat});
|
||
items.push({ic:'users', label:'Participants', fn:toggleMeetPanel});
|
||
// No "Audio devices" here (#8e): on mobile the speaker button itself cycles Speaker/Earpiece/Bluetooth.
|
||
const menu=document.createElement('div'); menu.className='spk-menu mm-menu';
|
||
menu.innerHTML=items.map((it,i)=>'<button class="spk-opt mm-opt" data-i="'+i+'">'+ic(it.ic,15)+'<span>'+pEsc(it.label)+'</span></button>').join('');
|
||
document.body.appendChild(menu);
|
||
const r=anchor.getBoundingClientRect();
|
||
menu.style.left=Math.max(8,Math.min(r.left-100, window.innerWidth-menu.offsetWidth-8))+'px';
|
||
menu.style.top=Math.max(8,(r.top-menu.offsetHeight-10))+'px';
|
||
menu.querySelectorAll('.mm-opt').forEach(b=>b.onclick=()=>{ const it=items[+b.dataset.i]; menu.remove(); try{ it.fn(); }catch(_){} });
|
||
const close=(e)=>{ if(!menu.contains(e.target) && !anchor.contains(e.target)){ menu.remove(); document.removeEventListener('mousedown',close); document.removeEventListener('touchstart',close); } };
|
||
setTimeout(()=>{ document.addEventListener('mousedown',close); document.addEventListener('touchstart',close); },0);
|
||
}
|
||
async function setSpeakerDevice(id){
|
||
meetSinkId=id||''; try{ localStorage.setItem('bzc_sink', meetSinkId); }catch(_){}
|
||
try{ if(SFU.room && SFU.room.switchActiveDevice) await SFU.room.switchActiveDevice('audiooutput', meetSinkId); }catch(_){}
|
||
applySinkAll();
|
||
}
|
||
async function setMicDevice(id){
|
||
meetMicId=id||''; try{ localStorage.setItem('bzc_micdev', meetMicId); }catch(_){}
|
||
try{ if(SFU.room && SFU.room.switchActiveDevice){ await SFU.room.switchActiveDevice('audioinput', meetMicId); return; } }catch(_){}
|
||
// Mesh fallback: re-acquire the mic on the chosen device and swap the outgoing track.
|
||
try{
|
||
if(!meetMic || !meetLocalStream) return;
|
||
const s=await navigator.mediaDevices.getUserMedia({audio:{deviceId:{exact:meetMicId}}});
|
||
const nt=s.getAudioTracks()[0]; if(!nt) return;
|
||
const old=meetLocalStream.getAudioTracks()[0];
|
||
if(old){ meetLocalStream.removeTrack(old); try{ old.stop(); }catch(_){} }
|
||
meetLocalStream.addTrack(nt);
|
||
meetPeers.forEach(p=>{ try{ const snd=p.pc.getSenders().find(x=>x.track&&x.track.kind==='audio'); if(snd) snd.replaceTrack(nt); }catch(_){} });
|
||
}catch(e){ toast(mediaErrMsg(e,'microphone')); }
|
||
}
|
||
// Windows lists the same physical device three times ("Default - X", "Communications - X", "X"). Collapse
|
||
// that into one clean list: a single "System default" entry plus each real device once (#6).
|
||
function cleanDevices(list){
|
||
const out=[]; const seen=new Set(); let hasDefault=false;
|
||
for(const d of (list||[])){
|
||
if(d.deviceId==='communications') continue; // Windows' duplicate "comms" alias
|
||
if(d.deviceId==='default'){ hasDefault=true; continue; } // folded into the single entry below
|
||
const label=String(d.label||'').replace(/^\s*(Default|Communications)\s*-\s*/i,'').trim();
|
||
const key=(d.groupId||d.deviceId||'')+'|'+label.toLowerCase();
|
||
if(seen.has(key)) continue; seen.add(key);
|
||
out.push({ deviceId:d.deviceId, label: label||'Device' });
|
||
}
|
||
if(hasDefault) out.unshift({ deviceId:'default', label:'System default' });
|
||
return out;
|
||
}
|
||
async function openAudioMenu(anchor){
|
||
document.querySelectorAll('.spk-menu').forEach(x=>x.remove());
|
||
let outs=[], ins=[];
|
||
try{ const d=await navigator.mediaDevices.enumerateDevices(); outs=cleanDevices(d.filter(x=>x.kind==='audiooutput')); ins=cleanDevices(d.filter(x=>x.kind==='audioinput')); }catch(_){}
|
||
const menu=document.createElement('div'); menu.className='spk-menu';
|
||
const sel=(id, cur)=> (id===cur || (!cur && (id==='default'||id===''))) ? ' on' : '';
|
||
let html='';
|
||
html+='<div class="spk-h">'+ic('headphones',13)+' Speaker</div>';
|
||
html+= outs.length ? outs.map((d,i)=>'<button class="spk-opt'+sel(d.deviceId,meetSinkId)+'" data-k="out" data-id="'+pEsc(d.deviceId)+'"><span class="spk-dot"></span>'+pEsc(d.label||('Output '+(i+1)))+'</button>').join('')
|
||
: '<div class="spk-empty">Output is controlled by your system.</div>';
|
||
html+='<div class="spk-h">'+ic('mic',13)+' Microphone</div>';
|
||
html+= ins.length ? ins.map((d,i)=>'<button class="spk-opt'+sel(d.deviceId,meetMicId)+'" data-k="in" data-id="'+pEsc(d.deviceId)+'"><span class="spk-dot"></span>'+pEsc(d.label||('Microphone '+(i+1)))+'</button>').join('')
|
||
: '<div class="spk-empty">Allow microphone access to list devices.</div>';
|
||
menu.innerHTML=html;
|
||
document.body.appendChild(menu);
|
||
const r=anchor.getBoundingClientRect();
|
||
menu.style.left=Math.max(8,Math.min(r.left-60, window.innerWidth-menu.offsetWidth-8))+'px';
|
||
menu.style.top=Math.max(8,(r.top-menu.offsetHeight-10))+'px';
|
||
menu.querySelectorAll('.spk-opt').forEach(b=>b.onclick=async()=>{
|
||
if(b.dataset.k==='out'){ await setSpeakerDevice(b.dataset.id); toast('Speaker set'); }
|
||
else { await setMicDevice(b.dataset.id); toast('Microphone set'); }
|
||
menu.remove();
|
||
});
|
||
const close=(e)=>{ if(!menu.contains(e.target) && e.target!==anchor && !anchor.contains(e.target)){ menu.remove(); document.removeEventListener('mousedown',close); } };
|
||
setTimeout(()=>document.addEventListener('mousedown',close),0);
|
||
}
|
||
// Mobile: speakerphone on/off. When a Bluetooth/headset output is connected we route to it and show a
|
||
// headset icon; otherwise we toggle between the loudspeaker and the default (earpiece) route. NOTE: true
|
||
// earpiece routing is an OS capability — on mobile WEB the browser owns it, so this picks the best
|
||
// available output device. In the native mobile build this maps to the OS audio route.
|
||
// Tap = advance to the next available route. Bluetooth is only in the cycle while a headset is connected.
|
||
async function toggleSpeakerphone(){
|
||
const ios = !!nativeAudioRoute();
|
||
// iOS: the OS owns call-audio routing (BT/wired if connected, else loudspeaker) and the app can't override it
|
||
// (earpiece and loudspeaker-over-active-BT both proven impossible in a WKWebView). So the button is a live
|
||
// INDICATOR, not a switch — tapping just explains the current output / how to change it.
|
||
if(ios){
|
||
const o=_nativeOutput;
|
||
toast(o==='bluetooth' ? 'On Bluetooth — disconnect the headset to use the speaker'
|
||
: o==='wired' ? 'On headset — unplug to use the speaker'
|
||
: 'On speaker — connect a Bluetooth/wired headset to use it');
|
||
return;
|
||
}
|
||
let outs=[]; try{ outs=(await navigator.mediaDevices.enumerateDevices()).filter(x=>x.kind==='audiooutput'); }catch(_){}
|
||
const bt=outs.find(d=>isBtLabel(d.label)); _btConnected=!!bt;
|
||
const routes = ['speaker','earpiece'].concat(bt?['bt']:[]);
|
||
const i=routes.indexOf(meetRoute);
|
||
meetRoute=routes[(i<0?0:(i+1)%routes.length)];
|
||
try{ localStorage.setItem('bzc_route', meetRoute); }catch(_){}
|
||
bzApplyRoute(meetRoute);
|
||
{
|
||
if(meetRoute==='bt' && bt) await setSpeakerDevice(bt.deviceId);
|
||
else if(meetRoute==='speaker'){
|
||
const spk=outs.find(d=>/speaker/i.test(d.label)) || outs.find(d=>d.deviceId==='default') || (outs[0]&&outs[0].deviceId);
|
||
await setSpeakerDevice(typeof spk==='string'?spk:(spk&&spk.deviceId)||'');
|
||
} else { await setSpeakerDevice(''); } // earpiece → system default route
|
||
}
|
||
refreshSpkBtn();
|
||
toast(routeLabel());
|
||
}
|
||
function addTile(id, stream, label, muted){
|
||
const grid=document.getElementById('meetGrid'); if(!grid) return;
|
||
let tile=document.getElementById('meet-tile-'+id);
|
||
if(!tile){ tile=document.createElement('div'); tile.className='meet-tile'; tile.id='meet-tile-'+id;
|
||
const av=(id==='__local')?((ME&&ME.avatarUrl)||null):(meetAvatars.get(id)||null); // profile pic on the tile
|
||
tile.innerHTML='<video autoplay playsinline'+(muted?' muted':'')+'></video><div class="meet-av" style="background:'+avColor(label||'?')+'">'+pEsc(initials(label||'?'))+(av?'<img src="'+pEsc(av)+'" alt="" onerror="this.remove()">':'')+'</div><div class="meet-mute" style="display:none">'+ic('micOff',14)+'</div><span class="nm">'+pEsc(label||'')+'</span>'; grid.appendChild(tile); }
|
||
const v=tile.querySelector('video'); if(v && stream && v.srcObject!==stream){ v.srcObject=stream; applySink(v); try{ v.play().catch(function(){}); }catch(_){} } // #5: route audio to the chosen speaker + kick playback (iOS won't autostart WebRTC audio)
|
||
const hasVid=!!(stream && stream.getVideoTracks && stream.getVideoTracks().some(t=>t.enabled && t.readyState!=='ended'));
|
||
tile.classList.toggle('novid', !hasVid || (meetCamOff.get(id)===true && !meetSharers.has(id))); // camOff → avatar, UNLESS they're sharing a screen (#9: screen must show even with camera off)
|
||
if(meetMuted.has(id)) setTileMute(id, meetMuted.get(id)); // apply any known mute state
|
||
}
|
||
function setTileMute(id, muted){ meetMuted.set(id, !!muted); const t=document.getElementById('meet-tile-'+id); if(t){ const b=t.querySelector('.meet-mute'); if(b) b.style.display=muted?'grid':'none'; if(muted) t.classList.remove('speaking'); } }
|
||
function removeTile(id){ const t=document.getElementById('meet-tile-'+id); if(t) t.remove(); meetMuted.delete(id); meetCamOff.delete(id); meetUnwatch(id); }
|
||
// #7: on an outgoing 1:1 call, show the person you're calling (DP + name + "Ringing…") so it's clear
|
||
// who the call is to, instead of only your own tile. Removed as soon as they answer.
|
||
let _dmCallWaiting=null;
|
||
function addWaitingTile(name, avatar){
|
||
const grid=document.getElementById('meetGrid'); if(!grid || document.getElementById('meet-tile-__waiting')) return;
|
||
const tile=document.createElement('div'); tile.className='meet-tile novid waiting'; tile.id='meet-tile-__waiting';
|
||
tile.innerHTML='<div class="meet-av" style="background:'+avColor(name||'?')+'">'+pEsc(initials(name||'?'))+(avatar?'<img src="'+pEsc(avatar)+'" alt="" onerror="this.remove()">':'')+'</div><span class="nm">'+pEsc(name||'')+'</span><div class="ringing">'+ic('phone',22)+'<span>Ringing…</span></div>';
|
||
grid.appendChild(tile);
|
||
}
|
||
function removeWaitingTile(){ const t=document.getElementById('meet-tile-__waiting'); if(t) t.remove(); }
|
||
// ---- Active-speaker meter: highlight a tile while its audio is above a threshold ----
|
||
function meetWatchStream(id, stream){
|
||
if(!stream || !stream.getAudioTracks || !stream.getAudioTracks().length) return; // no audio yet
|
||
meetUnwatch(id);
|
||
let ctx; try{ ctx=new (window.AudioContext||window.webkitAudioContext)(); }catch(_){ return; }
|
||
try{ ctx.resume(); }catch(_){}
|
||
let src; try{ src=ctx.createMediaStreamSource(stream); }catch(_){ try{ctx.close();}catch(e){} return; }
|
||
const an=ctx.createAnalyser(); an.fftSize=512; an.smoothingTimeConstant=0.6; src.connect(an);
|
||
const data=new Uint8Array(an.frequencyBinCount); const rec={ctx,an,data,src,raf:0,on:false};
|
||
meetVU.set(id, rec);
|
||
const tick=()=>{
|
||
an.getByteFrequencyData(data); let sum=0; for(let i=0;i<data.length;i++) sum+=data[i];
|
||
const speaking=(sum/data.length)>16 && !meetMuted.get(id);
|
||
if(speaking!==rec.on){ rec.on=speaking; const t=document.getElementById('meet-tile-'+id); if(t) t.classList.toggle('speaking', speaking); }
|
||
rec.raf=requestAnimationFrame(tick);
|
||
};
|
||
rec.raf=requestAnimationFrame(tick);
|
||
}
|
||
function meetUnwatch(id){ const r=meetVU.get(id); if(!r) return; try{cancelAnimationFrame(r.raf);}catch(_){} try{r.src.disconnect();}catch(_){} try{r.ctx.close();}catch(_){} meetVU.delete(id); const t=document.getElementById('meet-tile-'+id); if(t) t.classList.remove('speaking'); }
|
||
function meetUnwatchAll(){ for(const id of Array.from(meetVU.keys())) meetUnwatch(id); }
|
||
// #5: the same person showing up as several tiles. Each browser tab / reload / reconnect gets a NEW
|
||
// peerId, so a stale session left a ghost tile behind. One person (uid) = one tile: when a uid reappears
|
||
// on a new peerId, drop their older peer entirely.
|
||
function dropDupPeers(uid, keepPid){
|
||
if(!uid) return;
|
||
for(const [pid,u] of [...meetPeerUids]){
|
||
if(u!==uid || pid===keepPid) continue;
|
||
const p=meetPeers.get(pid); if(p){ try{ p.pc&&p.pc.close(); }catch(_){} meetPeers.delete(pid); }
|
||
if(SFU.on){ try{ sfuDropPeer(pid); }catch(_){} }
|
||
meetSharers.delete(pid); meetPeerUids.delete(pid); meetNames.delete(pid); meetAvatars.delete(pid);
|
||
removeTile(pid);
|
||
}
|
||
}
|
||
function meetMakePeer(peerId, name){
|
||
// SFU mode: no P2P peer connection — but we STILL record the peer, otherwise meetPeers stays empty and
|
||
// the Participants list only ever shows "you" (and the meeting-chat "To" list is empty). pc is null;
|
||
// every p.pc use is already try/catch-guarded.
|
||
if(SFU.on){ meetPeers.set(peerId, { pc:null, name, vsender:null }); addTile(peerId, null, meetNames.get(peerId)||name||'Guest', false); return null; }
|
||
const pc=new RTCPeerConnection(MEET_ICE);
|
||
const entry={ pc, name, vsender:null }; meetPeers.set(peerId, entry);
|
||
addTile(peerId, null, meetNames.get(peerId)||name||'Guest', false); // show the tile right away (avatar)
|
||
meetLocalStream.getTracks().forEach(t=>{ const s=pc.addTrack(t, meetLocalStream); if(t.kind==='video') entry.vsender=s; });
|
||
// If I'm already sharing my screen, send the screen (not the camera) to this new peer.
|
||
if(meetScreen && meetScreenStream){ const st=meetScreenStream.getVideoTracks()[0]; if(st){ try{ if(entry.vsender) entry.vsender.replaceTrack(st); else entry.vsender=pc.addTrack(st, meetLocalStream); }catch(_){} } }
|
||
pc.onicecandidate=(ev)=>{ if(ev.candidate) meetSend({type:'meeting-signal',to:peerId,data:{candidate:ev.candidate}}); };
|
||
pc.ontrack=(ev)=>{ addTile(peerId, ev.streams[0], (meetPeers.get(peerId)||{}).name||name||'Guest', false); meetWatchStream(peerId, ev.streams[0]); };
|
||
return pc;
|
||
}
|
||
// Screen share (mesh): swap the outgoing video track for a display-capture track via replaceTrack
|
||
// (no extra tiles, the peer's video just shows the screen). Falls back to addTrack+renegotiate if
|
||
// no video sender exists yet (camera never turned on). Stopping restores the camera (or avatar).
|
||
async function toggleScreen(){
|
||
if(meetScreen){ stopScreen(); return; }
|
||
if(!meetMultiShare && meetSharers.size>0){ toast('Someone is already sharing their screen'); return; }
|
||
if(SFU.on){
|
||
try{ await SFU.room.localParticipant.setScreenShareEnabled(true); }catch(e){ return; } // user cancelled / denied
|
||
const pub=SFU.room.localParticipant.getTrackPublication(SFU.lib.Track.Source.ScreenShare);
|
||
const st=pub&&pub.track&&pub.track.mediaStreamTrack; if(!st){ return; }
|
||
meetScreen=true; meetScreenStream=new MediaStream([st]);
|
||
try{ st.addEventListener('ended', ()=>stopScreen()); }catch(_){} // browser's native "Stop sharing" bar
|
||
// #9: keep MY tile on camera/avatar — do NOT show my own screen back to me. Sharing the whole
|
||
// screen would then capture the tile showing it → infinite "hall of mirrors". Others still see it.
|
||
addTile('__local', meetLocalStream, (ME&&ME.name)?ME.name:'You', true); setLocalSharing(true);
|
||
updateScreenBtn(); meetSend({ type:'meeting-screen', on:true }); return;
|
||
}
|
||
let ds; try{ ds=await navigator.mediaDevices.getDisplayMedia({ video:true, audio:false }); }
|
||
catch(e){ return; } // user cancelled the picker
|
||
const track=ds.getVideoTracks()[0]; if(!track){ try{ ds.getTracks().forEach(t=>t.stop()); }catch(_){} return; }
|
||
meetScreenStream=ds; meetScreen=true;
|
||
track.onended=()=>stopScreen(); // browser's native "Stop sharing" bar
|
||
for(const [pid,p] of meetPeers){
|
||
if(p.vsender){ try{ await p.vsender.replaceTrack(track); }catch(_){} }
|
||
else { try{ p.vsender=p.pc.addTrack(track, meetLocalStream); const off=await p.pc.createOffer(); await p.pc.setLocalDescription(off); meetSend({type:'meeting-signal',to:pid,data:{sdp:p.pc.localDescription}}); }catch(_){} }
|
||
}
|
||
addTile('__local', meetLocalStream, (ME&&ME.name)?ME.name:'You', true); setLocalSharing(true); // #9: keep camera/avatar locally, not my own screen
|
||
updateScreenBtn(); meetSend({ type:'meeting-screen', on:true });
|
||
}
|
||
// #9: a badge on MY tile that I'm sharing (instead of showing my own screen, which recurses).
|
||
function setLocalSharing(on){
|
||
const t=document.getElementById('meet-tile-__local'); if(!t) return;
|
||
let b=t.querySelector('.meet-sharing-self');
|
||
if(on){ if(!b){ b=document.createElement('div'); b.className='meet-sharing-self'; b.innerHTML=ic('monitor',13)+' You’re sharing your screen'; t.appendChild(b); } }
|
||
else if(b){ b.remove(); }
|
||
}
|
||
function stopScreen(){
|
||
if(!meetScreen) return; meetScreen=false;
|
||
if(SFU.on){ try{ SFU.room.localParticipant.setScreenShareEnabled(false); }catch(_){} meetScreenStream=null; addTile('__local', meetLocalStream, (ME&&ME.name)?ME.name:'You', true); setLocalSharing(false); updateScreenBtn(); meetSend({ type:'meeting-screen', on:false }); return; }
|
||
const cam=meetLocalStream && meetLocalStream.getVideoTracks()[0];
|
||
for(const [,p] of meetPeers){ if(p.vsender){ try{ p.vsender.replaceTrack(cam||null); }catch(_){} } }
|
||
if(meetScreenStream){ try{ meetScreenStream.getTracks().forEach(t=>t.stop()); }catch(_){} meetScreenStream=null; }
|
||
addTile('__local', meetLocalStream, (ME&&ME.name)?ME.name:'You', true); setLocalSharing(false);
|
||
updateScreenBtn(); meetSend({ type:'meeting-screen', on:false });
|
||
}
|
||
function updateScreenBtn(){ const b=document.getElementById('meetScreenBtn'); if(!b) return; b.classList.toggle('on', meetScreen); b.title=meetScreen?'Stop sharing':'Share screen'; b.innerHTML=ic('monitor',20); }
|
||
function setTileScreen(id, on){ const t=document.getElementById('meet-tile-'+id); if(!t) return; t.classList.toggle('sharing', !!on); let b=t.querySelector('.meet-screen'); if(on){ if(!b){ b=document.createElement('div'); b.className='meet-screen'; b.innerHTML=ic('monitor',12)+' Screen'; t.appendChild(b); } } else if(b){ b.remove(); } updateShareMode(); }
|
||
// Stage mode: one chosen shared screen fills the area; other screens + people are small on the right.
|
||
function getSharerIds(){ const a=[]; meetSharers.forEach(id=>a.push(id)); return a; } // #9: '__local' excluded — I don't display my own screen, so it's never the local stage
|
||
function updateShareMode(){ const g=document.getElementById('meetGrid'); if(!g) return;
|
||
const sharers=getSharerIds(); const on=sharers.length>0;
|
||
g.classList.toggle('sharing-mode', on);
|
||
if(on){ if(!meetStageId || sharers.indexOf(meetStageId)<0) meetStageId=sharers[0]; } else meetStageId=null;
|
||
applyStage();
|
||
}
|
||
function applyStage(){ const g=document.getElementById('meetGrid'); if(!g) return; g.querySelectorAll('.meet-tile.stage').forEach(t=>t.classList.remove('stage')); if(meetStageId){ const t=document.getElementById('meet-tile-'+meetStageId); if(t) t.classList.add('stage'); } }
|
||
function setStage(id){ if(!id) return; meetStageId=id; applyStage(); }
|
||
// Record + transcript are host-only; show/hide their buttons when host status changes.
|
||
function updateHostControls(){ document.querySelectorAll('.meet-bar .host-only').forEach(b=>{ b.style.display=meetIsHost?'inline-flex':'none'; }); }
|
||
// ---- Recording: composite all tiles onto a canvas + mix everyone's audio, then MediaRecorder ----
|
||
function drawCover(ctx,v,x,y,w,h){ const vw=v.videoWidth,vh=v.videoHeight; if(!vw||!vh) return; const s=Math.max(w/vw,h/vh),dw=vw*s,dh=vh*s; ctx.save(); ctx.beginPath(); ctx.rect(x,y,w,h); ctx.clip(); ctx.drawImage(v, x+(w-dw)/2, y+(h-dh)/2, dw, dh); ctx.restore(); }
|
||
function recMime(){ const c=['video/webm;codecs=vp9,opus','video/webm;codecs=vp8,opus','video/webm']; for(const t of c){ if(window.MediaRecorder&&MediaRecorder.isTypeSupported(t)) return t; } return ''; }
|
||
function toggleRecord(){ if(!meetIsHost){ toast('Only the host can record'); return; } if(meetRec){ meetRec.stop(); return; } startRecord(); }
|
||
function startRecord(){
|
||
if(!window.MediaRecorder){ toast('Recording is not supported in this browser'); return; }
|
||
const canvas=document.createElement('canvas'); canvas.width=1280; canvas.height=720; const ctx=canvas.getContext('2d');
|
||
let raf=0;
|
||
const draw=()=>{
|
||
const vids=[...document.querySelectorAll('#meetGrid .meet-tile video')].filter(v=>v.srcObject);
|
||
ctx.fillStyle='#0b1220'; ctx.fillRect(0,0,canvas.width,canvas.height);
|
||
const n=Math.max(1,vids.length), cols=Math.ceil(Math.sqrt(n)), rows=Math.ceil(n/cols), cw=canvas.width/cols, ch=canvas.height/rows;
|
||
vids.forEach((v,i)=>{ const r=Math.floor(i/cols), c=i%cols; drawCover(ctx,v,c*cw,r*ch,cw-4,ch-4); });
|
||
raf=requestAnimationFrame(draw);
|
||
};
|
||
const cstream=canvas.captureStream(25);
|
||
let actx=null, dest=null;
|
||
try{ actx=new (window.AudioContext||window.webkitAudioContext)(); dest=actx.createMediaStreamDestination(); const seen=new Set();
|
||
document.querySelectorAll('#meetGrid .meet-tile video').forEach(v=>{ const s=v.srcObject; if(s&&!seen.has(s)&&s.getAudioTracks&&s.getAudioTracks().length){ seen.add(s); try{ actx.createMediaStreamSource(s).connect(dest); }catch(_){} } });
|
||
}catch(_){}
|
||
const tracks=[...cstream.getVideoTracks()]; if(dest) tracks.push(...dest.stream.getAudioTracks());
|
||
let rec; try{ rec=new MediaRecorder(new MediaStream(tracks), recMime()?{mimeType:recMime()}:undefined); }
|
||
catch(e){ toast('Recording is not supported in this browser'); try{actx&&actx.close();}catch(_){} return; }
|
||
const chunks=[]; const startedAt=Date.now(); let stopped=false;
|
||
rec.ondataavailable=e=>{ if(e.data&&e.data.size) chunks.push(e.data); };
|
||
rec.onstop=()=>{ try{ cancelAnimationFrame(raf); }catch(_){} try{ actx&&actx.close(); }catch(_){} const blob=new Blob(chunks,{type:(chunks[0]&&chunks[0].type)||'video/webm'}); uploadRecording(blob, Date.now()-startedAt); };
|
||
const stop=()=>{ if(stopped) return; stopped=true; try{ rec.stop(); }catch(_){} meetRec=null; updateRecBtn(); meetSend({type:'meeting-recording', on:false}); recNotice(false); toast('Recording saved to Past meetings'); };
|
||
meetRec={ rec, stop }; draw(); rec.start(2000); updateRecBtn();
|
||
meetSend({type:'meeting-recording', on:true}); recNotice(true, 'You'); // notify everyone (visual + voice)
|
||
}
|
||
function speak(text){ try{ if(window.speechSynthesis){ const u=new SpeechSynthesisUtterance(text); u.rate=1; speechSynthesis.cancel(); speechSynthesis.speak(u); } }catch(_){} }
|
||
function fmtElapsed(ms){ const s=Math.max(0,Math.floor(ms/1000)); const m=Math.floor(s/60); return String(m).padStart(2,'0')+':'+String(s%60).padStart(2,'0'); }
|
||
let _recTimer=null, _recStart=0;
|
||
function recNotice(on, by){
|
||
let el=document.getElementById('recNotice');
|
||
if(on){
|
||
if(_recTimer) return; // already showing
|
||
if(!el){ el=document.createElement('div'); el.id='recNotice'; el.className='rec-notice'; document.body.appendChild(el); }
|
||
_recStart=Date.now();
|
||
const tick=()=>{ const e=document.getElementById('recNotice'); if(e) e.innerHTML='<span class="rec-dot"></span> Recording · '+fmtElapsed(Date.now()-_recStart); };
|
||
tick(); _recTimer=setInterval(tick, 1000);
|
||
speak('This meeting is now being recorded');
|
||
} else {
|
||
if(_recTimer){ clearInterval(_recTimer); _recTimer=null; }
|
||
if(el) el.remove();
|
||
speak('Recording stopped');
|
||
}
|
||
}
|
||
function updateRecBtn(){ const b=document.getElementById('meetRecBtn'); if(!b) return; b.classList.toggle('on', !!meetRec); b.title=meetRec?'Stop recording':'Record meeting'; }
|
||
async function uploadRecording(blob, durMs){
|
||
if(!blob||!blob.size) return;
|
||
try{ const gid=(meetReturn&&meetReturn.kind==='group')?meetReturn.id:'';
|
||
const q='/api/meetings/recording?room='+encodeURIComponent(meetRoom||'')+(gid?('&group='+encodeURIComponent(gid)):'')+'&dur='+Math.round(durMs||0);
|
||
const r=await fetch(q,{method:'POST',headers:{'Content-Type':'application/octet-stream'},body:blob});
|
||
if(!r.ok) throw 0;
|
||
}catch(_){ toast('Could not upload the recording'); }
|
||
}
|
||
// ---- Live transcript: each participant transcribes their own mic; the server assembles it ----
|
||
// Transcript: subscribe to get your OWN private copy of the FULL conversation. While anyone is
|
||
// subscribed, every client transcribes its own mic into one shared transcript (merged with speaker
|
||
// names); each subscriber gets a private copy. Unsubscribing only drops YOUR copy, not others'.
|
||
function toggleTranscribe(){ meetTranscribe=!meetTranscribe; meetSend({type:'meeting-transcribe', on:meetTranscribe}); updateTransBtn(); toast(meetTranscribe?'Transcript on — your private copy is saved to Past meetings after the call':'You left the transcript — your copy is being saved'); }
|
||
function applyRoomTx(active){ if(active===meetRoomTx) return; meetRoomTx=active; if(active) startSR(); else stopSR(); transcribeNotice(active); }
|
||
function startSR(){ if(meetSR) return; const SR=window.SpeechRecognition||window.webkitSpeechRecognition; if(!SR){ if(meetTranscribe) toast('Live transcript needs Chrome or Edge'); return; }
|
||
try{ meetSR=new SR(); }catch(_){ return; }
|
||
meetSR.continuous=true; meetSR.interimResults=false; meetSR.lang='en-US';
|
||
meetSR.onresult=(e)=>{ for(let i=e.resultIndex;i<e.results.length;i++){ const r=e.results[i]; if(r.isFinal){ const text=((r[0]&&r[0].transcript)||'').trim(); if(text) meetSend({type:'meeting-transcript', text}); } } };
|
||
meetSR.onerror=()=>{}; meetSR.onend=()=>{ if(meetRoomTx){ try{ meetSR.start(); }catch(_){} } };
|
||
try{ meetSR.start(); }catch(_){}
|
||
}
|
||
function stopSR(){ if(meetSR){ try{ meetSR.onend=null; meetSR.stop(); }catch(_){} meetSR=null; } }
|
||
function updateTransBtn(){ const b=document.getElementById('meetTransBtn'); if(!b) return; b.classList.toggle('on', meetTranscribe); b.title=meetTranscribe?'Stop my transcript':'Transcribe (your private copy)'; }
|
||
function transcribeNotice(on){ let el=document.getElementById('txNotice'); if(on){ if(!el){ el=document.createElement('div'); el.id='txNotice'; el.className='tx-notice'; el.innerHTML=ic('fileText',12)+' Transcribing'; document.body.appendChild(el); } } else if(el){ el.remove(); } }
|
||
// iOS blocks media/WebRTC audio playback until a user gesture, so remote call audio stays SILENT until you
|
||
// tap something (e.g. mute/unmute — that tap is the gesture). Unlock it: within the Join tap we play a short
|
||
// silent clip (grants the page media-playback activation) and (re)start every meeting <video>. A tap fallback
|
||
// re-runs it in case the activation lapsed before a remote stream arrived. Web fix — helps native app + PWA.
|
||
let _bzSilent=null;
|
||
function bzUnlockAudio(){
|
||
try{ if(_audioCtx && _audioCtx.state==='suspended') _audioCtx.resume(); }catch(_){}
|
||
try{ if(!_bzSilent){ _bzSilent=new Audio('data:audio/wav;base64,UklGRjIAAABXQVZFZm10IBAAAAABAAEAgLsAAAB3AQACABAAZGF0YQ4AAAAAAAAAAAAAAAAAAAAAAAA='); _bzSilent.setAttribute('playsinline',''); } _bzSilent.play().catch(function(){}); }catch(_){}
|
||
try{ document.querySelectorAll('#meetGrid video').forEach(function(v){ if(v && v.paused) v.play().catch(function(){}); }); }catch(_){}
|
||
try{ bzApplyRoute(meetRoute); }catch(_){} // re-assert the chosen earpiece/speaker route (iOS WebRTC re-grabs the session when audio starts)
|
||
}
|
||
document.addEventListener('touchend', function(){ if(meetState==='call') bzUnlockAudio(); }, {passive:true}); // fallback: any tap during a call restarts silent remote audio
|
||
document.addEventListener('click', function(){ if(meetState==='call') bzUnlockAudio(); }, {passive:true});
|
||
let meetNative=false, meetNativeUuid=null, _ncEnding=false; // native call: WebView joins the mesh for UI; the plugin owns media/CallKit
|
||
async function enterMeeting(code, audioOnly, opts){
|
||
bzUnlockAudio(); // runs inside the Join tap → unlock playback so remote audio isn't silent until you tap
|
||
if(meetState==='call'){ switchTab('meeting'); return; } // already in a call — ignore double-join
|
||
meetNative=!!(opts&&opts.native); meetNativeUuid=(opts&&opts.uuid)||null; // native → skip our own SFU media; mute/end bridge to the plugin
|
||
// Joining this room → clear any lingering incoming-call invite popup for it (and stop its ring).
|
||
// Fixes: joining via the header "Join" button left the Join/Decline popup on screen. Belt-and-braces
|
||
// we clear ALL open invites, since you can only be in one call at a time.
|
||
if(code) dismissCallInvite(code);
|
||
document.querySelectorAll('.call-invite').forEach(el=>{ try{ el.remove(); }catch(_){} }); stopRing(true);
|
||
meetAudioOnly=!!audioOnly;
|
||
// Start with NO media — mic & cam OFF by default (no permission prompt until the user
|
||
// turns one on). Tracks are acquired on demand by toggleMic / toggleCam.
|
||
meetLocalStream=new MediaStream();
|
||
meetMic=false; meetCam=false; meetIsHost=false; meetHostId=null; // answer MUTED by default (house rule) — tap Mic to speak
|
||
meetScreen=false; meetScreenStream=null; meetSharers.clear(); meetMultiShare=false;
|
||
meetRec=null; meetTranscribe=false; meetRoomTx=false; meetSR=null; _addPool=null; meetStageId=null;
|
||
try{ const c=await fetch('/api/ice').then(r=>r.json()); if(c&&c.iceServers) MEET_ICE=c; }catch(_){}
|
||
meetState='call'; meetRailLive(true);
|
||
renderCallConnecting(); // branded "Connecting…" until the room is created/joined (esp. on slow links)
|
||
meetWs=new WebSocket((location.protocol==='https:'?'wss://':'ws://')+location.host+'/ws');
|
||
meetWs.onmessage=onMeetMsg;
|
||
meetWs.onopen=()=>{ if(code){ meetRoom=code; renderCall(); meetSend({type:'meeting-join', room:code, name:(ME.name||ME.email||'Guest'), guestId:(ME&&ME.guest)?ME.id:undefined}); } else { meetSend({type:'meeting-create'}); } };
|
||
}
|
||
async function onMeetMsg(e){
|
||
let m; try{ m=JSON.parse(e.data); }catch(_){ return; }
|
||
if(m.type==='meeting-created'){ meetRoom=m.room; renderCall(); meetSend({type:'meeting-join', room:m.room, name:(ME.name||ME.email||'Guest'), guestId:(ME&&ME.guest)?ME.id:undefined}); if(meetAnnounceGroup){ const g=meetAnnounceGroup; meetAnnounceGroup=null; try{ postJSON('/api/messages',{group:g, body:'📹 Started a group call — join with code '+m.room}); }catch(_){} } return; }
|
||
if(m.type==='meeting-joined'){
|
||
meetMyId=m.peerId;
|
||
if(_inLobby){ _inLobby=false; renderCall(); } // admitted from the lobby → replace the "waiting…" screen with the call
|
||
if(m.isHost){ meetIsHost=true; meetHostId=meetMyId; } // host = the meeting creator (server-decided)
|
||
meetWatchStream('__local', meetLocalStream); // active-speaker detection on my own mic
|
||
// Existing peers OFFER to me (their offers carry their tracks incl. any active screen share);
|
||
// I just set up the connections and wait. Avoids the "newcomer can't receive screen" bug.
|
||
for(const p of (m.peers||[])){ meetNames.set(p.peerId,p.name); if(p.avatar) meetAvatars.set(p.peerId,p.avatar); if(p.uid){ meetPeerUids.set(p.peerId,p.uid); meetInviteJoined(p.uid); dropDupPeers(p.uid, p.peerId); } meetMakePeer(p.peerId,p.name); }
|
||
if(_dmCallWaiting && !(m.peers&&m.peers.length)) addWaitingTile(_dmCallWaiting.name, _dmCallWaiting.avatar); // #7: show who we're calling while it rings
|
||
// SFU: connect to LiveKit for media once (peer uid→peerId map is populated above). Mic/cam are
|
||
// off at join, so nothing publishes yet — toggleMic/toggleCam publish on demand.
|
||
if(SFU.on && !meetNative){ try{ await sfuConnect(); }catch(err){ if(ME&&ME.guest){ toast((err&&err.message)||'This meeting link has expired or isn’t active.'); leaveMeeting(true); return; } const e2=document.getElementById('meetErr'); if(e2) e2.textContent='Could not connect meeting media'; } }
|
||
// native: the plugin already holds the LiveKit media (one connection per identity) — we joined the mesh
|
||
// for the UI only. Media is native; tell peers our mic is live.
|
||
meetSend({type:'meeting-state', muted:!meetMic, camOff:!meetCam}); // tell existing peers my state
|
||
if(meetIsHost) meetSend({type:'meeting-host', to:meetMyId}); // announce host so others know
|
||
refreshMeetPanel(); updateHostControls();
|
||
return;
|
||
}
|
||
// The room is gone (host ended it / code expired). Say so plainly instead of hanging on "Connecting…"
|
||
// — and for a guest, don't offer a Rejoin that can never work (#7).
|
||
if(m.type==='error'){ const gone=/not found/i.test(m.message||''); if(ME&&ME.guest){ _guestRoom=gone?null:_guestRoom; toast(gone?'This meeting has ended.':(m.message||'Could not join')); leaveMeeting(true); return; } toast(m.message||'Could not join the meeting'); leaveMeeting(true); return; }
|
||
if(m.type==='meeting-lobby-wait'){ renderLobbyWait(); return; } // #4: guest waits for host to admit
|
||
if(m.type==='meeting-rejected'){ toast('The host didn’t let you in.'); leaveMeeting(true); return; }
|
||
if(m.type==='meeting-lobby-request'){ showLobbyRequest(m.peerId, m.name); return; } // host: someone wants in
|
||
if(m.type==='meeting-lobby-cancel'){ dismissLobbyRequest(m.peerId); return; } // that guest left the lobby
|
||
if(m.type==='meeting-chat'){ meetChatAppend({ from:m.from, name:m.name, text:m.text, direct:!!m.direct, at:m.at }); return; } // in-meeting chat
|
||
if(m.type==='meeting-ended'){ toast(m.reason==='unanswered'?'No answer':'Call ended'); leaveMeeting(true); return; } // 1:1 hangup / host ended / unanswered
|
||
if(m.type==='meeting-peer-joined'){
|
||
stopRingback(); removeWaitingTile(); _dmCallWaiting=null; // #17/#7: they answered → stop ring + drop the ringing tile
|
||
meetNames.set(m.peerId,m.name); if(m.avatar) meetAvatars.set(m.peerId,m.avatar); if(m.uid){ meetPeerUids.set(m.peerId,m.uid); meetInviteJoined(m.uid); dropDupPeers(m.uid, m.peerId); }
|
||
const pc=meetMakePeer(m.peerId,m.name); // I'm an existing peer → I OFFER to the newcomer (carries my screen)
|
||
if(pc){ try{ const offer=await pc.createOffer(); await pc.setLocalDescription(offer); meetSend({type:'meeting-signal',to:m.peerId,data:{sdp:pc.localDescription}}); }catch(_){} } // (SFU: LiveKit handles media, no offer)
|
||
meetSend({type:'meeting-state', muted:!meetMic, camOff:!meetCam});
|
||
if(meetIsHost){ meetSend({type:'meeting-host', to:meetHostId}); meetSend({type:'meeting-sharemode', multi:meetMultiShare}); if(meetRec) meetSend({type:'meeting-recording', on:true}); }
|
||
if(meetScreen) meetSend({type:'meeting-screen', on:true});
|
||
refreshMeetPanel(); return;
|
||
}
|
||
if(m.type==='meeting-peer-state'){ setTileMute(m.peerId, !!m.muted); meetCamOff.set(m.peerId, !!m.camOff); const _t=document.getElementById('meet-tile-'+m.peerId); if(_t){ const v=_t.querySelector('video'), s=v&&v.srcObject; const hv=!!(s&&s.getVideoTracks&&s.getVideoTracks().some(tr=>tr.enabled&&tr.readyState!=='ended')); _t.classList.toggle('novid', !hv || (!!m.camOff && !meetSharers.has(m.peerId))); } refreshMeetPanel(); return; } // camOff -> avatar, unless sharing a screen (#9)
|
||
if(m.type==='meeting-host'){ const was=meetIsHost; meetHostId=m.hostPeerId; meetIsHost=(m.hostPeerId===meetMyId); if(meetIsHost&&!was){ toast('You are now the host'); try{ speak('You are now the host'); }catch(_){} } refreshMeetPanel(); updateHostControls(); return; }
|
||
if(m.type==='meeting-transcribe-state'){ applyRoomTx(!!m.active); return; } // ≥1 subscriber → transcribe my mic
|
||
if(m.type==='meeting-recording'){ recNotice(!!m.on, m.by); return; } // someone is recording — show + announce
|
||
if(m.type==='meeting-peer-screen'){ if(m.on) meetSharers.add(m.from); else meetSharers.delete(m.from); setTileScreen(m.from, !!m.on); refreshMeetPanel(); return; }
|
||
if(m.type==='meeting-sharemode'){ meetMultiShare=!!m.multi; refreshMeetPanel(); return; }
|
||
if(m.type==='meeting-muteall'){ if(meetMic && meetLocalStream){ meetMic=false; meetLocalStream.getAudioTracks().forEach(t=>t.enabled=false); updateMicBtn(); setTileMute('__local', true); meetSend({type:'meeting-state', muted:true, camOff:!meetCam}); } toast('You were muted by the host'); return; }
|
||
if(m.type==='meeting-peer-left'){ const p=meetPeers.get(m.peerId); if(p){ try{p.pc.close();}catch(_){} meetPeers.delete(m.peerId);} if(SFU.on) sfuDropPeer(m.peerId); meetSharers.delete(m.peerId); removeTile(m.peerId); refreshMeetPanel(); return; }
|
||
if(m.type==='meeting-signal'){
|
||
const from=m.from, d=m.data||{};
|
||
if(d.sdp){
|
||
let p=meetPeers.get(from), pc=p&&p.pc;
|
||
if(d.sdp.type==='offer'){
|
||
if(!pc) pc=meetMakePeer(from, meetNames.get(from)||'Guest');
|
||
try{ await pc.setRemoteDescription(d.sdp); const ans=await pc.createAnswer(); await pc.setLocalDescription(ans); meetSend({type:'meeting-signal',to:from,data:{sdp:pc.localDescription}}); }catch(_){}
|
||
} else if(d.sdp.type==='answer'){ if(pc){ try{ await pc.setRemoteDescription(d.sdp); }catch(_){} } }
|
||
} else if(d.candidate){ const p=meetPeers.get(from); if(p&&p.pc){ try{ await p.pc.addIceCandidate(d.candidate); }catch(_){} } }
|
||
return;
|
||
}
|
||
if(m.type==='error'){ const msg=m.message; leaveMeeting(true); const e2=document.getElementById('meetErr'); if(e2) e2.textContent=msg||'Meeting error'; return; }
|
||
}
|
||
function updateMicBtn(){ const b=document.getElementById('meetMicBtn'); if(b){ b.classList.toggle('off',!meetMic); b.title=meetMic?'Mute':'Unmute'; b.innerHTML=ic(meetMic?'mic':'micOff',20); } }
|
||
function updateCamBtn(){ const b=document.getElementById('meetCamBtn'); if(b){ b.classList.toggle('off',!meetCam); b.title=meetCam?'Turn camera off':'Turn camera on'; b.innerHTML=ic(meetCam?'video':'videoOff',20); } }
|
||
// Unmute acquires the mic on demand (no prompt until then) and renegotiates with peers.
|
||
async function toggleMic(){
|
||
if(!meetLocalStream) return;
|
||
if(meetNative){ const next=!meetMic; const NC=nativeCallPlugin(); if(NC){ try{ NC.setMuted({ muted:!next }); }catch(_){} } meetMic=next; updateMicBtn(); setTileMute('__local', !meetMic); meetSend({type:'meeting-state', muted:!meetMic, camOff:!meetCam}); return; } // native: mute the plugin's mic
|
||
if(SFU.on){ const next=!meetMic; try{ await sfuSetMic(next); meetMic=next; }catch(e){ toast(mediaErrMsg(e,'microphone')); return; } updateMicBtn(); setTileMute('__local', !meetMic); meetSend({type:'meeting-state', muted:!meetMic, camOff:!meetCam}); return; }
|
||
const hasTrack=meetLocalStream.getAudioTracks().length>0;
|
||
if(!hasTrack){
|
||
let astream; try{ astream=await navigator.mediaDevices.getUserMedia({ audio:true }); }
|
||
catch(e){ toast(mediaErrMsg(e,'microphone')); return; }
|
||
const track=astream.getAudioTracks()[0]; if(!track) return;
|
||
meetLocalStream.addTrack(track); meetMic=true; meetWatchStream('__local', meetLocalStream);
|
||
for(const [pid,p] of meetPeers){ try{ p.pc.addTrack(track, meetLocalStream); const off=await p.pc.createOffer(); await p.pc.setLocalDescription(off); meetSend({type:'meeting-signal',to:pid,data:{sdp:p.pc.localDescription}}); }catch(_){} }
|
||
} else {
|
||
meetMic=!meetMic; meetLocalStream.getAudioTracks().forEach(t=>t.enabled=meetMic);
|
||
}
|
||
updateMicBtn(); setTileMute('__local', !meetMic); meetSend({type:'meeting-state', muted:!meetMic, camOff:!meetCam});
|
||
}
|
||
// Toggle the camera. In an audio call (no video track yet) this acquires the camera and
|
||
// renegotiates with every peer, so you can always turn video on once a meeting has started.
|
||
async function toggleCam(){
|
||
if(!meetLocalStream) return;
|
||
if(meetNative){ toast('Video isn’t available on native calls yet'); return; } // native path is audio-only for now (video rendering is the next phase)
|
||
if(SFU.on){ const next=!meetCam; try{ await sfuSetCam(next); meetCam=next; meetAudioOnly=false; }catch(e){ toast(mediaErrMsg(e,'camera')); return; } updateCamBtn(); addTile('__local', meetLocalStream, (ME&&ME.name)?ME.name:'You', true); setTileMute('__local', !meetMic); meetSend({type:'meeting-state', muted:!meetMic, camOff:!meetCam}); return; }
|
||
const hasTrack=meetLocalStream.getVideoTracks().length>0;
|
||
if(!hasTrack){
|
||
let vstream; try{ vstream=await navigator.mediaDevices.getUserMedia({ video:true }); }
|
||
catch(e){ toast(mediaErrMsg(e,'camera')); return; }
|
||
const track=vstream.getVideoTracks()[0]; if(!track) return;
|
||
meetLocalStream.addTrack(track); meetCam=true; meetAudioOnly=false;
|
||
for(const [pid,p] of meetPeers){ try{ const s=p.pc.addTrack(track, meetLocalStream); if(!p.vsender) p.vsender=s; const off=await p.pc.createOffer(); await p.pc.setLocalDescription(off); meetSend({type:'meeting-signal',to:pid,data:{sdp:p.pc.localDescription}}); }catch(_){} }
|
||
const chip=document.getElementById('meetCodeChip'); if(chip) chip.innerHTML='Room <b>'+pEsc(meetRoom||'')+'</b> · share to invite';
|
||
} else {
|
||
meetCam=!meetCam; meetLocalStream.getVideoTracks().forEach(t=>t.enabled=meetCam);
|
||
}
|
||
updateCamBtn();
|
||
addTile('__local', meetLocalStream, (ME&&ME.name)?ME.name:'You', true);
|
||
setTileMute('__local', !meetMic); meetSend({type:'meeting-state', muted:!meetMic, camOff:!meetCam});
|
||
}
|
||
let meetLeaving=false;
|
||
// forced = the call is ending for us (1:1 hangup, host ended, room closed/error) rather than us
|
||
// voluntarily stepping out of a group others are still in. Only a VOLUNTARY exit from a GROUP
|
||
// call hands the host role on; a 1:1 call (or a forced end) has nobody to hand to — handing off
|
||
// there wrongly announced "Host handed to <the person who just left>" (bug).
|
||
function leaveMeeting(forced){
|
||
if(meetLeaving) return; meetLeaving=true;
|
||
// Native call: also end the CallKit/plugin call. _ncEnding tells the plugin's endCall listener this leave
|
||
// originated here, so it doesn't call leaveMeeting again (the plugin ending the call re-fires endCall).
|
||
if(meetNative && meetNativeUuid){ _ncEnding=true; callkitEnd(meetNativeUuid); }
|
||
meetNative=false; meetNativeUuid=null;
|
||
stopRingback(); removeWaitingTile(); _dmCallWaiting=null; // #17/#7: any call exit stops the ring + ringing tile
|
||
const isDm=!!(meetReturn && meetReturn.kind==='dm');
|
||
if(!forced && !isDm && meetIsHost && meetPeers.size>0){
|
||
const next=meetPeers.keys().next().value; // first remaining participant
|
||
if(next){ meetSend({type:'meeting-host', to:next}); toast('Host handed to '+(meetNames.get(next)||'a participant')); }
|
||
}
|
||
if(meetRec) meetRec.stop(); // saves the recording
|
||
if(meetTranscribe) meetSend({type:'meeting-transcribe', on:false}); // server saves my copy
|
||
meetTranscribe=false; meetRoomTx=false; stopSR(); transcribeNotice(false);
|
||
if(meetScreen) stopScreen();
|
||
if(_recTimer){ clearInterval(_recTimer); _recTimer=null; } const _rn=document.getElementById('recNotice'); if(_rn) _rn.remove(); // clear any "Recording" badge
|
||
meetSend({type:'meeting-leave'});
|
||
if(SFU.on) sfuDisconnect(); // tear down the LiveKit room
|
||
meetUnwatchAll(); meetSharers.clear();
|
||
meetPeers.forEach(p=>{ try{p.pc.close();}catch(_){} }); meetPeers.clear(); meetNames.clear(); meetAvatars.clear(); meetPeerUids.clear(); meetCamOff.clear(); meetInvited.forEach(e=>{ if(e.timer) clearTimeout(e.timer); }); meetInvited.clear(); meetMuted.clear();
|
||
if(meetLocalStream){ try{ meetLocalStream.getTracks().forEach(t=>t.stop()); }catch(_){} meetLocalStream=null; }
|
||
if(meetWs){ try{ meetWs.close(); }catch(_){} meetWs=null; }
|
||
meetRoom=null; meetMyId=null; meetState='idle'; meetIsHost=false; meetHostId=null; meetRailLive(false); resetMeetChat(); _inLobby=false;
|
||
const ret=meetReturn; meetReturn=null; meetLeaving=false;
|
||
if(ME&&ME.guest){ renderGuestLeft(); return; } // guests have no chat to return to — show a leave screen
|
||
if(ret){ switchTab('chat'); selectChat(ret.kind, ret.id); } // land back on the originating chat
|
||
else renderMeetingLobby();
|
||
}
|
||
// Guest post-call screen: they can't drop back into a chat/sidebar (there is none), so show a simple
|
||
// "you've left" card with a one-click Rejoin.
|
||
let _guestRoom=null;
|
||
function renderGuestLeft(){
|
||
document.querySelectorAll('.guest-left').forEach(x=>x.remove());
|
||
const el=document.createElement('div'); el.className='guest-left';
|
||
el.innerHTML='<div class="gp-card">'
|
||
+'<div class="gp-brand"><img src="/mark-light.png" alt="" onerror="this.remove()"><span>Biz <b>Connect</b></span></div>'
|
||
+'<div class="gl-tick">'+ic('check',30)+'</div>'
|
||
+'<h2>'+(_guestRoom?'You’ve left the meeting':'This meeting has ended')+'</h2>'
|
||
+'<p class="gp-sub">'+(_guestRoom?('Thanks for joining'+((ME&&ME.name)?(', '+pEsc(firstName(ME.name))):'')+'.'):'Thanks for joining.')+'</p>'
|
||
+(_guestRoom?'<button class="gp-join" id="glRejoin">Rejoin meeting</button>':'')
|
||
+'<div class="gp-note">'+ic('info',13)+' You can close this tab safely.</div>'
|
||
+'</div>';
|
||
document.body.appendChild(el);
|
||
const rj=el.querySelector('#glRejoin'); if(rj) rj.onclick=()=>{ location.href='/home?meet='+encodeURIComponent(_guestRoom); };
|
||
}
|
||
|
||
// ---------- Tabs (icon rail) ----------
|
||
// Chat and Meeting are in-shell panels; Share and Connect load in the center panel via
|
||
// a single, same-origin, lazily-loaded iframe (cheap isolation, no page navigation).
|
||
const railBtns=document.querySelectorAll('.railbtn');
|
||
const panels=document.querySelectorAll('.panel');
|
||
const chatcol=document.getElementById('chatcol');
|
||
let loaded={share:false,connect:false};
|
||
function currentTab(){ const b=document.querySelector('.railbtn.active'); return b?b.dataset.tab:'chat'; }
|
||
function switchTab(tab){
|
||
railBtns.forEach(b=>b.classList.toggle('active',b.dataset.tab===tab));
|
||
panels.forEach(p=>p.classList.toggle('active',p.dataset.panel===tab));
|
||
chatcol.classList.toggle('hidden', tab!=='chat');
|
||
document.querySelector('.shell').classList.toggle('is-chat', tab==='chat'); // mobile one-pane layout
|
||
if(tab!=='chat') document.body.classList.remove('chat-open');
|
||
document.body.classList.remove('rail-open'); // close the mobile drawer after picking a tab
|
||
// Lazy-load the embedded flows on first open; keep them mounted afterwards so a
|
||
// live session survives tab switches.
|
||
if(tab==='share' && !loaded.share){ document.getElementById('sharePanel').innerHTML='<iframe src="/share?embed=1" allow="camera; microphone; display-capture; clipboard-read; clipboard-write"></iframe>'; loaded.share=true; }
|
||
if(tab==='connect' && !loaded.connect){ document.getElementById('connectPanel').innerHTML='<iframe src="/connect?embed=1" allow="camera; microphone; display-capture; clipboard-read; clipboard-write"></iframe>'; loaded.connect=true; }
|
||
if(tab==='meeting' && meetState==='idle'){ renderMeetingLobby(); }
|
||
}
|
||
function showWelcome(skipAnim){
|
||
const finish=()=>{ selected=null; document.body.classList.remove('chat-open','chat-closing','chat-dragging'); renderChats(searchVal()); renderChatPanel(); updateRailUnread(); };
|
||
// Mobile: slide the outgoing conversation off to the right before swapping back to the chat list, so
|
||
// "back" has a clear push/pop direction (like Teams/WhatsApp). Desktop just closes instantly.
|
||
// skipAnim=true when the interactive swipe already dragged the pane off-screen — just do the state swap.
|
||
if(!skipAnim && document.body.classList.contains('chat-open') && !document.body.classList.contains('chat-closing') && window.matchMedia('(max-width:760px)').matches){
|
||
const content=document.querySelector('.shell > .content');
|
||
// Render the list NOW so it's ready underneath before we start sliding the conversation away.
|
||
renderChats(searchVal());
|
||
document.body.classList.add('chat-closing');
|
||
if(content){ content.style.transition='transform .21s cubic-bezier(.4,0,.7,.4)'; requestAnimationFrame(()=>{ content.style.transform='translateX(100%)'; }); }
|
||
setTimeout(()=>{ if(content){ content.style.transition=''; content.style.transform=''; } finish(); }, 215);
|
||
} else finish();
|
||
}
|
||
railBtns.forEach(btn=>{ btn.onclick=()=>{
|
||
const tab=btn.dataset.tab;
|
||
// Re-clicking Chat (while already on it) returns to the welcome screen.
|
||
if(tab==='chat' && currentTab()==='chat' && selected!=null){ showWelcome(); }
|
||
switchTab(tab);
|
||
}; });
|
||
// Esc clears the open conversation and brings back the welcome screen.
|
||
// Esc closes the topmost popup/search FIRST (capture phase, before the conversation-close below).
|
||
document.addEventListener('keydown',(e)=>{
|
||
if(e.key!=='Escape') return;
|
||
const lb=document.getElementById('lightbox'); if(lb){ lb.remove(); e.preventDefault(); e.stopPropagation(); return; } // #10: close the image preview first (leave the media view underneath)
|
||
const ovs=document.querySelectorAll('.modal-ov');
|
||
if(ovs.length){ ovs[ovs.length-1].remove(); e.preventDefault(); e.stopPropagation(); return; }
|
||
if(_selMode){ exitSelect(); e.preventDefault(); e.stopPropagation(); return; } // #1: leave forward-selection
|
||
const sh=document.getElementById('convoSearchHead'); if(sh && sh.style.display!=='none'){ closeSearch(); e.preventDefault(); e.stopPropagation(); return; }
|
||
}, true);
|
||
document.addEventListener('keydown',(e)=>{ if(e.key==='Escape' && !document.querySelector('.modal-ov') && !document.getElementById('lightbox') && currentTab()==='chat' && selected!=null){ showWelcome(); } }); // #4: an open image preview closes first (its own Esc handler); the conversation only closes once no overlay remains
|
||
// #10: the device/browser Back button closes the topmost layer (popup → search → open chat on
|
||
// mobile) instead of leaving the app. We seed a history entry and re-seed after each handled back.
|
||
function bzcBack(){
|
||
const ovs=document.querySelectorAll('.modal-ov'); if(ovs.length){ ovs[ovs.length-1].remove(); return true; }
|
||
const sh=document.getElementById('convoSearchHead'); if(sh && sh.style.display!=='none'){ closeSearch(); return true; }
|
||
if(window.matchMedia('(max-width:760px)').matches && selected){ showWelcome(); return true; }
|
||
return false;
|
||
}
|
||
try{ history.pushState(null,'',location.href); }catch(_){}
|
||
window.addEventListener('popstate', ()=>{ if(bzcBack()){ try{ history.pushState(null,'',location.href); }catch(_){} } });
|
||
// #4: left-edge SWIPE-back — INSTANT (release-triggered). A drag that starts at the very left edge and
|
||
// releases clearly rightward runs the back (bzcBack → showWelcome()'s push-slide close). The finger-following
|
||
// version is PARKED (dropped for now) — this reliable release version is what ships. Only arms at the edge so
|
||
// it can't fight message scrolling.
|
||
(function(){
|
||
let sx=0, sy=0, edge=false;
|
||
window.addEventListener('touchstart',(e)=>{
|
||
if(e.touches.length!==1){ edge=false; return; }
|
||
const t=e.touches[0]; edge = t.clientX<=24; sx=t.clientX; sy=t.clientY;
|
||
}, {passive:true});
|
||
window.addEventListener('touchend',(e)=>{
|
||
if(!edge) return; edge=false;
|
||
const t=e.changedTouches&&e.changedTouches[0]; if(!t) return;
|
||
if((t.clientX-sx)>60 && Math.abs(t.clientY-sy)<50) bzcBack(); // rightward, mostly-horizontal swipe = back
|
||
}, {passive:true});
|
||
})();
|
||
// In-chat video buffering: show a clear CENTER spinner while a video buffers (the OS controls' own indicator
|
||
// is small/unclear). Media events (waiting/playing/…) do NOT bubble, so we listen in the CAPTURE phase. This
|
||
// only toggles a class — it never intercepts taps, so the native controls keep working.
|
||
(function(){
|
||
const HAVE_FUTURE_DATA=3; // below this the element cannot keep playing = it is buffering
|
||
// Derive the spinner from the element's REAL state rather than a hand-picked event list. A cold start
|
||
// from preload="none" never fires `waiting` (it runs loadstart→loadedmetadata→canplay→playing straight
|
||
// through), so an event-list approach missed the one stall the user actually sees — the wait after tapping
|
||
// play — and left only the OS controls' own tiny indicator next to the timer.
|
||
function sync(v){
|
||
if(!v || !v.classList || !v.classList.contains('att-vid-v')) return;
|
||
const w=v.parentElement; if(!w || !w.classList.contains('att-vid')) return;
|
||
const busy = v.seeking || (!v.paused && !v.ended && v.readyState < HAVE_FUTURE_DATA);
|
||
w.classList.toggle('buffering', !!busy);
|
||
}
|
||
const onEv=(e)=>sync(e.target);
|
||
['loadstart','loadedmetadata','loadeddata','canplay','canplaythrough','play','playing','waiting','stalled',
|
||
'suspend','progress','timeupdate','seeking','seeked','pause','ended','error','emptied']
|
||
.forEach(ev=>document.addEventListener(ev,onEv,true)); // media events don't bubble → capture phase
|
||
})();
|
||
// Download a video to this device. Images and files have their own download link; a video's tile is the
|
||
// player, so the control is an overlay on it. Capture phase so it never reaches the video's own controls.
|
||
(function(){
|
||
document.addEventListener('click', function(e){
|
||
const ov=e.target&&e.target.closest&&e.target.closest('.att-vid-ov'); if(!ov) return;
|
||
e.preventDefault(); e.stopPropagation();
|
||
const w=ov.closest('.att-vid'); if(!w || ov.dataset.busy) return;
|
||
const id=w.getAttribute('data-aid'); if(!id) return;
|
||
const v=w.querySelector('.att-vid-v'); if(!v) return;
|
||
const rec=bzLibGet(id);
|
||
if(rec){ // already on the device → play it, from disk
|
||
const src=bzLibSrc(rec);
|
||
if(src && v.getAttribute('src')!==src) v.setAttribute('src', src);
|
||
v.controls=true; w.classList.add('playing'); // native bar appears only now, so nothing overlaps
|
||
const go=()=>v.play().catch(()=>{});
|
||
v.play().catch(()=>{ v.addEventListener('canplay', go, { once:true }); });
|
||
return;
|
||
}
|
||
ov.dataset.busy='1';
|
||
const btn=ov.querySelector('.att-vid-btn');
|
||
const setPct=(p)=>{ if(btn) btn.textContent = p<0 ? '…' : (p+'%'); };
|
||
setPct(0);
|
||
Promise.resolve(nativeSaveFile('/files/'+encodeURIComponent(id), w.getAttribute('data-name')||'video.mp4',
|
||
{ id, mime:w.getAttribute('data-mime')||'video/mp4', onPct:setPct }))
|
||
.catch(()=>{})
|
||
.then(()=>{ delete ov.dataset.busy; bzSyncVideoTiles(id); }); // → play icon, or back to download if it failed
|
||
}, true);
|
||
if(!bzLibOn()) return;
|
||
// The user can delete these from the Files app at any time, so the index is never trusted blindly:
|
||
// reconcile once at startup, and if a local file has vanished by the time it is played, fall straight
|
||
// back to streaming instead of showing a broken player.
|
||
setTimeout(()=>{ bzLibList().then(()=>{ bzSyncVideoTiles(); bzSyncFileTiles(); }).catch(()=>{}); }, 2500);
|
||
document.addEventListener('error', function(e){
|
||
const v=e.target; if(!v||!v.classList||!v.classList.contains('att-vid-v')) return;
|
||
const w=v.parentElement; if(!w||!w.getAttribute) return;
|
||
const id=w.getAttribute('data-aid'); if(!id) return;
|
||
if((v.getAttribute('src')||'').indexOf('/stream/')>=0) return; // already streaming — nothing to recover to
|
||
bzLibDrop(id); bzSyncVideoTiles(id);
|
||
}, true);
|
||
})();
|
||
// Native file attachments: tap to download (the trailing icon shows %), tap again to open. Mirrors the video
|
||
// control. Capture phase so it wins over the generic a[download] handler / bubble clicks.
|
||
(function(){
|
||
document.addEventListener('click', function(e){
|
||
const el=e.target&&e.target.closest&&e.target.closest('.att-file-native'); if(!el) return;
|
||
e.preventDefault(); e.stopPropagation();
|
||
if(el.dataset.busy) return;
|
||
const id=el.getAttribute('data-aid'); if(!id) return;
|
||
const name=el.getAttribute('data-name')||'file', mime=el.getAttribute('data-mime')||'';
|
||
const rec=bzLibGet(id);
|
||
if(rec){ bzOpenFile(rec, name); return; } // already on device → open it
|
||
el.dataset.busy='1';
|
||
const act=el.querySelector('.att-file-act'); const orig=act?act.innerHTML:'';
|
||
const setPct=(p)=>{ if(act) act.textContent = p<0?'…':(p+'%'); };
|
||
setPct(0);
|
||
Promise.resolve(nativeSaveFile('/files/'+encodeURIComponent(id), name, { id, mime, onPct:setPct }))
|
||
.catch(()=>{})
|
||
.then(()=>{ delete el.dataset.busy; if(!bzLibGet(id)&&act) act.innerHTML=orig; bzSyncFileTiles(id); }); // saved → "open", else restore download
|
||
}, true);
|
||
})();
|
||
// #9: swipe a message bubble to the right to reply to it (mobile), like WhatsApp/Teams. The bubble
|
||
// follows the finger a little; releasing past the threshold opens the reply composer for that message.
|
||
(function(){
|
||
let bx=0, by=0, bEl=null, on=false;
|
||
document.addEventListener('touchstart',(e)=>{
|
||
if(e.touches.length!==1){ on=false; return; }
|
||
const t=e.touches[0];
|
||
const b=e.target && e.target.closest && e.target.closest('#msgs .bubble');
|
||
if(!b || b.classList.contains('deleted') || t.clientX<32){ on=false; bEl=null; return; } // >32px so it can't clash with edge-swipe-back
|
||
on=true; bEl=b; bx=t.clientX; by=t.clientY;
|
||
}, {passive:true});
|
||
document.addEventListener('touchmove',(e)=>{
|
||
if(!on||!bEl||e.touches.length!==1) return;
|
||
const t=e.touches[0], dx=t.clientX-bx, dy=t.clientY-by;
|
||
if(dx>0 && Math.abs(dx)>Math.abs(dy)){ bEl.style.transition='none'; bEl.style.transform='translateX('+Math.min(dx,72)+'px)'; bEl.style.opacity=String(Math.max(.65,1-dx/300)); }
|
||
else if(Math.abs(dy)>Math.abs(dx)){ on=false; if(bEl){ bEl.style.transform=''; bEl.style.opacity=''; } bEl=null; } // it's a vertical scroll — let go
|
||
}, {passive:true});
|
||
document.addEventListener('touchend',(e)=>{
|
||
if(!on||!bEl){ on=false; return; }
|
||
const t=e.changedTouches&&e.changedTouches[0], dx=t?(t.clientX-bx):0, dy=t?(t.clientY-by):0;
|
||
const el=bEl; on=false; bEl=null;
|
||
el.style.transition='transform .18s, opacity .18s'; el.style.transform=''; el.style.opacity='';
|
||
if(dx>52 && Math.abs(dy)<46){ const id=el.getAttribute('data-id'); const m=(typeof THREAD!=='undefined'&&THREAD||[]).find(x=>String(x.id)===String(id)); if(m) setReply(m); }
|
||
}, {passive:true});
|
||
})();
|
||
// Hamburger: on mobile it slides the rail drawer in/out; on desktop it collapses the rail.
|
||
function toggleNav(){ if(window.innerWidth<=760) document.body.classList.toggle('rail-open'); else document.body.classList.toggle('rail-hidden'); }
|
||
const _navT=document.getElementById('navToggle'); if(_navT) _navT.onclick=toggleNav;
|
||
const _railBd=document.getElementById('railBackdrop'); if(_railBd) _railBd.onclick=()=>document.body.classList.remove('rail-open');
|
||
// Mobile chat: "back" returns from an open conversation to the chat list.
|
||
function chatBack(){ document.body.classList.remove('chat-open'); }
|
||
|
||
// Embedded Share/Connect flows report session start/stop so the rail can show a "live"
|
||
// dot — that's how you know a session is still running after switching to Chat.
|
||
window.addEventListener('message',(e)=>{
|
||
if(e.origin!==location.origin) return;
|
||
const d=e.data;
|
||
if(!d||d.type!=='bzc-session'||(d.flow!=='share'&&d.flow!=='connect')) return;
|
||
const btn=document.querySelector('.railbtn[data-tab="'+d.flow+'"]');
|
||
if(!btn) return;
|
||
btn.classList.toggle('live', !!d.active);
|
||
if(d.active && currentTab()!==d.flow){
|
||
toast((d.flow==='share'?'Screen share':'Connection')+' is live — tap the highlighted icon to return');
|
||
}
|
||
});
|
||
|
||
// Sidebar + misc wiring
|
||
document.getElementById('chatSearch').addEventListener('input',e=>{ const x=document.getElementById('chatSearchX'); if(x) x.style.display=e.target.value?'grid':'none'; renderChats(e.target.value); });
|
||
(function(){ const x=document.getElementById('chatSearchX'); if(x) x.onclick=()=>{ const s=document.getElementById('chatSearch'); s.value=''; x.style.display='none'; renderChats(''); s.focus(); }; })();
|
||
document.getElementById('newChat').title='New group';
|
||
document.getElementById('newChat').innerHTML=ic('userPlus',18);
|
||
document.getElementById('newChat').onclick=()=>openNewGroup();
|
||
|
||
// ---------- Login (shown here on /home when logged out) ----------
|
||
const EYE_OFF='<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg>';
|
||
const EYE_ON='<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>';
|
||
function pwField(id,ph){return '<div class="pwwrap"><input id="'+id+'" type="password" placeholder="'+ph+'"><button type="button" class="eye" data-for="'+id+'" aria-label="Show password"></button></div>';}
|
||
function wireEyes(){document.querySelectorAll('.eye').forEach(b=>{if(b._w)return;b._w=1;b.innerHTML=EYE_OFF;b.onclick=()=>{const inp=document.getElementById(b.getAttribute('data-for'));if(!inp)return;const show=inp.type==='password';inp.type=show?'text':'password';b.innerHTML=show?EYE_ON:EYE_OFF;};});}
|
||
function onEnter(ids,fn){ids.forEach(id=>{const el=document.getElementById(id);if(el)el.addEventListener('keydown',e=>{if(e.key==='Enter'){e.preventDefault();fn();}});});}
|
||
function showErr(id,msg){const el=document.getElementById(id);el.textContent=msg;el.classList.add('show');}
|
||
function clearErr(id){const el=document.getElementById(id);el.textContent='';el.classList.remove('show');}
|
||
async function postJSON(path,body){const r=await fetch(path,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});const d=await r.json().catch(()=>({}));if(!r.ok)throw new Error(d.error||'request failed');return d;}
|
||
async function renderLogin(){
|
||
document.querySelector('.shell').style.display='none';
|
||
const aw=document.getElementById('authwrap'); aw.style.display='flex';
|
||
let regOpen=false; try{ regOpen=(await (await fetch('/api/setup-state')).json()).registrationOpen; }catch(_){}
|
||
aw.innerHTML=`<div class="authcard">
|
||
<div class="auth-brand"><img src="/icon-192.png" alt="Biz Connect"><div class="wm">Biz <b>Connect</b></div></div>
|
||
<h1>Welcome back</h1>
|
||
<div class="sub">Sign in to access chats, screen share and connect.</div>
|
||
${regOpen?`<div class="authtabs">
|
||
<button id="tabLogin" class="active">Sign in</button>
|
||
<button id="tabReg">Register team</button>
|
||
</div>`:''}
|
||
<form id="loginForm">
|
||
<span class="lbl">Email or phone</span><input id="li_email" type="text" inputmode="email" placeholder="you@bizgaze.com or mobile number" autocomplete="username">
|
||
<span class="lbl">Password</span>${pwField('li_pw','password')}
|
||
<label style="display:flex;align-items:center;gap:.5rem;margin:.7rem 0;color:var(--ink);font-size:.9rem;cursor:pointer"><input type="checkbox" id="li_remember" style="width:18px;height:18px;accent-color:var(--blue);margin:0"> Remember me on this device</label>
|
||
<button class="gobtn" id="li_btn" type="submit">Sign in</button>
|
||
<p id="li_err" class="formerr"></p>
|
||
</form>
|
||
${regOpen?`<div id="regForm" class="hidden">
|
||
<span class="lbl">Team name</span><input id="rg_team" placeholder="e.g. Acme Inc">
|
||
<span class="lbl">Email</span><input id="rg_email" type="email" placeholder="you@bizgaze.com">
|
||
<span class="lbl">Password</span>${pwField('rg_pw','min 8 characters')}
|
||
<button class="gobtn" id="rg_btn">Create team</button>
|
||
<p id="rg_err" class="formerr"></p>
|
||
</div>`:''}
|
||
</div>`;
|
||
document.getElementById('loginForm').addEventListener('submit',(e)=>{ e.preventDefault(); doLogin(); }); // Enter or "Sign in" both submit
|
||
wireEyes();
|
||
if(regOpen){
|
||
const lf=document.getElementById('loginForm'), rf=document.getElementById('regForm');
|
||
const tl=document.getElementById('tabLogin'), tr=document.getElementById('tabReg');
|
||
tl.onclick=()=>{lf.classList.remove('hidden');rf.classList.add('hidden');tl.classList.add('active');tr.classList.remove('active');};
|
||
tr.onclick=()=>{rf.classList.remove('hidden');lf.classList.add('hidden');tr.classList.add('active');tl.classList.remove('active');};
|
||
document.getElementById('rg_btn').onclick=doRegister;
|
||
onEnter(['rg_team','rg_email','rg_pw'],doRegister);
|
||
}
|
||
}
|
||
async function doLogin(){
|
||
clearErr('li_err');
|
||
const btn=document.getElementById('li_btn'); const orig=btn?btn.innerHTML:'';
|
||
if(btn){ btn.disabled=true; btn.innerHTML='<span class="btn-spin"></span>Signing in…'; } // show progress on Enter/click
|
||
try{
|
||
await postJSON('/api/login',{email:document.getElementById('li_email').value,password:document.getElementById('li_pw').value,remember:document.getElementById('li_remember').checked});
|
||
location.reload(); // the branded splash takes over from here
|
||
}catch(e){ if(btn){ btn.disabled=false; btn.innerHTML=orig; } showErr('li_err', /invalid credentials/i.test(e.message)?'Incorrect email or password. Please try again.':e.message); }
|
||
}
|
||
async function doRegister(){
|
||
clearErr('rg_err');
|
||
try{
|
||
await postJSON('/api/register',{email:document.getElementById('rg_email').value,password:document.getElementById('rg_pw').value,teamName:document.getElementById('rg_team').value});
|
||
await postJSON('/api/login',{email:document.getElementById('rg_email').value,password:document.getElementById('rg_pw').value});
|
||
location.reload();
|
||
}catch(e){ showErr('rg_err', e.message); }
|
||
}
|
||
|
||
// Relay remote-control messages from the embedded /share iframe to the native desktop shell for OS
|
||
// injection. The iframe (share.html) can't reach the native bridge directly (it lives on this top
|
||
// frame), so it postMessages here: we answer the availability handshake and forward arm/input events
|
||
// only when running in the desktop app. Injection is still gated by the shell's consent flag.
|
||
window.addEventListener('message',(e)=>{
|
||
if(e.origin!==location.origin) return; const d=e.data||{}; const n=window.bizConnectNative;
|
||
if(d.type==='rc-ping'){ const desktop=!!(n&&n.rcAvailable&&n.rcAvailable()); try{ e.source&&e.source.postMessage({type:'rc-pong', desktop}, location.origin); }catch(_){} return; }
|
||
if(!n) return;
|
||
if(d.type==='rc-arm'){ try{ n.rcArm&&n.rcArm(!!d.on); }catch(_){} return; }
|
||
if(d.type==='rc-input'){ try{ n.rcInput&&n.rcInput(d.evt); }catch(_){} return; }
|
||
});
|
||
// ---------- Boot: show the app if signed in, otherwise the login ----------
|
||
(async function(){
|
||
let me=null;
|
||
try{ const r=await fetch('/api/me'); if(r.ok) me=await r.json(); }catch(_){}
|
||
// Guest meeting join: someone opened a shared meeting link (…/home?meet=CODE or /j/CODE). If they
|
||
// aren't signed in, run in GUEST mode — no chat/sidebar, just a name prompt then straight into the
|
||
// call. Signed-in users fall through and auto-join after their normal boot (handled below).
|
||
let _meet=null; try{ _meet=(new URLSearchParams(location.search)).get('meet'); }catch(_){}
|
||
if(_meet && /^\d{6}$/.test(_meet) && !me){ document.getElementById('loading').style.display='none'; return startGuestMeeting(_meet); }
|
||
if(!me){ await renderLogin(); document.getElementById('loading').style.display='none'; return; }
|
||
ME=me;
|
||
document.getElementById('hdrRight').innerHTML=bellHTML()+profileHTML(me);
|
||
loadNotifs(); wireBell(); wireProfile();
|
||
placeHdrRight(); window.addEventListener('resize', placeHdrRight); // mobile: bell+profile live in the chat-list header (no top bar)
|
||
connectChatWs();
|
||
sfuInit(); // learn whether meetings use the LiveKit SFU or the P2P mesh (before any call starts)
|
||
reportInstall(); // desktop/mobile shell: record this install against the signed-in user
|
||
wireUpdateBanner(); // #3: desktop update-progress banner
|
||
setupPush(); // register the notification service worker + subscribe to Web Push (if granted)
|
||
setupNativeCall(); // iOS CallKit: register VoIP token + bridge answer/end to the meeting join/leave
|
||
{ const cl=document.getElementById('chatlist'); if(cl) enablePullRefresh(cl, loadSidebar); } // pull-to-refresh the chat list
|
||
setTimeout(maybeNotifPrompt, 1500); // gentle "enable notifications" prompt if still undecided (key for iOS PWA)
|
||
// Fast-path: when opened from a notification, show the chat immediately (only needs the
|
||
// thread fetch) and load the sidebar in the background — don't make the reload wait on it.
|
||
let oid=null, okind='dm';
|
||
try{ const q=new URLSearchParams(location.search); oid=q.get('openId'); okind=q.get('openKind')||'dm'; if(oid) history.replaceState(null,'','/home'); }catch(_){}
|
||
if(oid){
|
||
selectChat(okind, oid); // open the chat right away (only waits on the thread fetch)
|
||
document.getElementById('loading').style.display='none'; // the chat pane shows its own thread loader
|
||
loadSidebar().then(()=>{ try{ const it=rowFor(okind,oid); const nm=document.querySelector('#chatPanel .convo-head .nm'); if(it&&nm) nm.textContent=it.name; }catch(_){} });
|
||
} else {
|
||
await loadSidebar(); renderChatPanel();
|
||
document.getElementById('loading').style.display='none'; // keep the branded splash up until the list is ready (no blank/stuck gap)
|
||
}
|
||
// Signed-in user opened a meeting link → jump straight into that meeting.
|
||
if(_meet && /^\d{6}$/.test(_meet)){ try{ history.replaceState(null,'','/home'); }catch(_){} switchTab('meeting'); enterMeeting(_meet); }
|
||
// Cold launch FROM the share sheet: the extension staged files before the app was even running, so
|
||
// the appUrlOpen listener may have missed it. Sweep once now that ME + the chat list are ready.
|
||
setTimeout(bzCheckSharedInbox, 600);
|
||
// Refresh the token the Share Extension uses to send on its own (Teams-style in-sheet picker).
|
||
bzPushShareAuth();
|
||
})();
|
||
|
||
// GUEST meeting: a lightweight pre-join (name) then join the call with a throwaway guest identity —
|
||
// reuses the full meeting client (LiveKit/mesh, tiles, mic/cam) with the chat/sidebar chrome hidden.
|
||
async function startGuestMeeting(code){
|
||
document.body.classList.add('guest-mode');
|
||
try{ history.replaceState(null,'','/home?meet='+code); }catch(_){}
|
||
const savedName=(()=>{ try{ return localStorage.getItem('bzc_guest_name')||''; }catch(_){ return ''; } })();
|
||
let joinMic=false, joinCam=false; // pre-join choices, applied right after entering the call
|
||
const ov=document.createElement('div'); ov.className='guest-prejoin'; ov.id='guestJoin';
|
||
ov.innerHTML='<div class="gp-card">'
|
||
+'<div class="gp-brand"><img src="/mark-light.png" alt="" onerror="this.remove()"><span>Biz <b>Connect</b></span></div>'
|
||
+'<div class="gp-preview" id="gpPrev"><video id="gpVid" autoplay playsinline muted></video>'
|
||
+'<div class="gp-avatar" id="gpAv">?</div>'
|
||
+'<div class="gp-camoff" id="gpCamOff">Camera is off</div></div>'
|
||
+'<div class="gp-toggles">'
|
||
+'<button type="button" class="gp-tg off" id="gpMic" title="Microphone">'+ic('micOff',18)+'<span>Mic off</span></button>'
|
||
+'<button type="button" class="gp-tg off" id="gpCam" title="Camera">'+ic('videoOff',18)+'<span>Camera off</span></button>'
|
||
+'</div>'
|
||
+'<h2>Ready to join?</h2>'
|
||
+'<p class="gp-sub">Meeting <b>'+pEsc(code)+'</b> · joining as a guest</p>'
|
||
+'<input id="guestName" placeholder="Enter your name" autocomplete="name" maxlength="60" value="'+pEsc(savedName)+'">'
|
||
+'<button class="gp-join" id="guestGo">Join meeting</button>'
|
||
+'<div class="gp-note">'+ic('info',13)+' The host may need to admit you.</div>'
|
||
+'</div>';
|
||
document.body.appendChild(ov);
|
||
const inp=ov.querySelector('#guestName'), go=ov.querySelector('#guestGo');
|
||
const vid=ov.querySelector('#gpVid'), av=ov.querySelector('#gpAv'), camOff=ov.querySelector('#gpCamOff');
|
||
const micBtn=ov.querySelector('#gpMic'), camBtn=ov.querySelector('#gpCam');
|
||
let prevStream=null;
|
||
const syncAvatar=()=>{ const n=(inp.value||'').trim(); av.textContent=n?initials(n):'?'; av.style.background=avColor(n||'?'); };
|
||
syncAvatar(); inp.addEventListener('input', syncAvatar);
|
||
// Live camera preview so the guest can see themselves before joining (like every other meeting app).
|
||
const stopPrev=()=>{ if(prevStream){ try{ prevStream.getTracks().forEach(t=>t.stop()); }catch(_){} prevStream=null; } vid.srcObject=null; };
|
||
camBtn.onclick=async()=>{
|
||
if(joinCam){ joinCam=false; stopPrev(); camBtn.classList.add('off'); camBtn.innerHTML=ic('videoOff',18)+'<span>Camera off</span>'; camOff.style.display=''; return; }
|
||
try{ prevStream=await navigator.mediaDevices.getUserMedia({video:true}); vid.srcObject=prevStream; joinCam=true; camBtn.classList.remove('off'); camBtn.innerHTML=ic('video',18)+'<span>Camera on</span>'; camOff.style.display='none'; }
|
||
catch(e){ toast(mediaErrMsg(e,'camera')); }
|
||
};
|
||
micBtn.onclick=async()=>{
|
||
if(joinMic){ joinMic=false; micBtn.classList.add('off'); micBtn.innerHTML=ic('micOff',18)+'<span>Mic off</span>'; return; }
|
||
try{ const s=await navigator.mediaDevices.getUserMedia({audio:true}); s.getTracks().forEach(t=>t.stop()); joinMic=true; micBtn.classList.remove('off'); micBtn.innerHTML=ic('mic',18)+'<span>Mic on</span>'; }
|
||
catch(e){ toast(mediaErrMsg(e,'microphone')); }
|
||
};
|
||
setTimeout(()=>{ try{ inp.focus(); }catch(_){} },50);
|
||
const join=async()=>{
|
||
const nm=(inp.value||'').trim();
|
||
if(!nm){ inp.classList.add('err'); inp.focus(); toast('Please enter your name'); return; }
|
||
try{ localStorage.setItem('bzc_guest_name', nm); }catch(_){}
|
||
ME={ id:'guest-'+Math.random().toString(36).slice(2,10), name:nm, email:'', guest:true, avatarUrl:null };
|
||
_guestRoom=code; stopPrev(); ov.remove();
|
||
try{ await sfuInit(); }catch(_){}
|
||
switchTab('meeting'); await enterMeeting(code);
|
||
// Apply their pre-join choices once the call is up.
|
||
setTimeout(()=>{ try{ if(joinMic && !meetMic) toggleMic(); if(joinCam && !meetCam) toggleCam(); }catch(_){} }, 900);
|
||
};
|
||
go.onclick=join;
|
||
inp.addEventListener('keydown',e=>{ if(e.key==='Enter'){ e.preventDefault(); join(); } });
|
||
inp.addEventListener('input',()=>inp.classList.remove('err'));
|
||
}
|
||
</script>
|
||
</body>
|
||
</html>
|