// Chart Growth Hub โ€” floating chat dock (v2, two-pane). // // One component for both portals. Staff get the full room list (team room, // boardroom, broadcasts, signal view, and a support thread per student); a // student gets their own coach thread plus whichever channels their tier lets // them read. // // v2 changes: two-pane layout (list stays on the left, the room fills the // right), mono message type at 16px for legibility, brighter bubbles, a // "new messages" divider, reactions, staff-only read receipts, pinned // messages, per-room mute (per device), a drag-resizable panel, and simple // link cards. // // Exposes window.ChatDock({client, email, role, name, mode, tier, isVIP, // openThread, onThreadOpened}). // mode 'staff' โ€” full room list, needs role // mode 'student' โ€” own coach thread + eligible channels (function(){ const { useState, useEffect, useRef, useMemo, useCallback } = React; const C = window.ChatCommon || {}; const fmtTime = C.fmtTime || (d => new Date(d).toLocaleTimeString(undefined,{hour:'numeric',minute:'2-digit'})); const dayKey = C.dayKey || (d => String(d).slice(0,10)); const dayLabel = C.dayLabel || (d => new Date(d).toLocaleDateString(undefined,{month:'short',day:'numeric'})); const EMOJI = { 'Smileys': ['๐Ÿ˜€','๐Ÿ˜„','๐Ÿ˜','๐Ÿ˜Š','๐Ÿ™‚','๐Ÿ˜‰','๐Ÿ˜','๐Ÿฅฐ','๐Ÿ˜˜','๐Ÿ˜Ž','๐Ÿคฉ','๐Ÿค—','๐Ÿค”','๐Ÿ˜','๐Ÿ˜‘','๐Ÿ™„','๐Ÿ˜ด','๐Ÿ˜ฎ','๐Ÿ˜ข','๐Ÿ˜ญ','๐Ÿ˜ค','๐Ÿ˜ก','๐Ÿฅณ','๐Ÿ˜‡','๐Ÿค','๐Ÿ™'], 'Gestures':['๐Ÿ‘','๐Ÿ‘Ž','๐Ÿ‘Œ','โœŒ๏ธ','๐Ÿคž','๐Ÿ‘','๐Ÿ™Œ','๐Ÿ’ช','๐Ÿ‘‹','โœ‹','๐Ÿซก','๐Ÿค™','โ˜๏ธ','๐Ÿ‘‡','๐Ÿ‘‰','๐Ÿ‘ˆ'], 'Trading': ['๐Ÿ“ˆ','๐Ÿ“‰','๐Ÿ’น','๐Ÿ’ฐ','๐Ÿ’ต','๐Ÿ’ธ','๐Ÿฆ','โš–๏ธ','๐ŸŽฏ','๐Ÿ””','โฐ','โšก','๐Ÿ”ฅ','๐Ÿ’Ž','๐Ÿš€','๐Ÿง ','๐Ÿ“Š','๐Ÿ“Œ','โœ…','โŒ','โš ๏ธ','๐Ÿ›‘'], 'Hearts': ['โค๏ธ','๐Ÿงก','๐Ÿ’›','๐Ÿ’š','๐Ÿ’™','๐Ÿ’œ','๐Ÿ–ค','๐Ÿค','๐Ÿ’ฏ','โญ','๐ŸŒŸ','โœจ','๐ŸŽ‰','๐ŸŽŠ','๐Ÿ†','๐Ÿ‘‘'], }; // Reactions people actually reach for โ€” the full picker is one tap further in. const QUICK_REACTIONS = ['๐Ÿ‘','๐Ÿ”ฅ','โœ…','๐Ÿ™','๐Ÿ˜‚','โค๏ธ']; /* โ”€โ”€ per-device room mute โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ Kept in localStorage rather than a table: muting is a personal, per-device preference, and this way it needs no migration to work today. */ const MUTE_KEY = 'cg_chat_muted'; function mutedSet(){ try{ return new Set(JSON.parse(localStorage.getItem(MUTE_KEY)||'[]')); }catch(e){ return new Set(); } } function isMuted(key){ return mutedSet().has(key); } function toggleMuted(key){ const s = mutedSet(); if(s.has(key)) s.delete(key); else s.add(key); try{ localStorage.setItem(MUTE_KEY, JSON.stringify([...s])); }catch(e){} return s.has(key); } // chat-notify.js reads this so a muted room stays silent everywhere. window.cgChatMutedThreads = () => [...mutedSet()]; const SIZE_KEY = 'cg_chat_dock_size'; function savedSize(){ try{ return JSON.parse(localStorage.getItem(SIZE_KEY)||'null'); }catch(e){ return null; } } /* โ”€โ”€ styles โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ (function css(){ if(document.getElementById('cg-chatdock-css')) document.getElementById('cg-chatdock-css').remove(); const s = document.createElement('style'); s.id = 'cg-chatdock-css'; s.textContent = ` .cd-launch{position:fixed;left:24px;bottom:24px;z-index:250;width:68px;height:68px;border-radius:50%; border:1px solid var(--border-gold);cursor:pointer;display:grid;place-items:center; background:radial-gradient(circle at 34% 24%,rgba(255,255,255,.55),rgba(255,255,255,0) 46%), linear-gradient(155deg,var(--gold-2),var(--gold) 62%,var(--gold-2)); box-shadow:0 14px 30px rgba(0,0,0,.5),0 0 0 6px rgba(212,175,55,.10), inset 0 2px 1px rgba(255,255,255,.6),inset 0 -8px 14px rgba(90,60,0,.35); transition:transform .18s cubic-bezier(.2,.8,.2,1),box-shadow .18s} .cd-launch::after{content:'';position:absolute;inset:5px;border-radius:50%;pointer-events:none; background:linear-gradient(180deg,rgba(255,255,255,.42),rgba(255,255,255,0) 52%)} .cd-launch:hover{transform:translateY(-3px) scale(1.05);box-shadow:0 20px 42px rgba(0,0,0,.55),0 0 0 9px rgba(212,175,55,.14),inset 0 2px 1px rgba(255,255,255,.65),inset 0 -8px 14px rgba(90,60,0,.35)} .cd-launch:active{transform:translateY(1px) scale(.99);box-shadow:0 6px 14px rgba(0,0,0,.5),inset 0 3px 8px rgba(90,60,0,.5)} .cd-launch svg{width:30px;height:30px;fill:none;stroke:#1a1303;stroke-width:2;stroke-linecap:round;stroke-linejoin:round; filter:drop-shadow(0 1px 0 rgba(255,255,255,.45));position:relative;z-index:1} .cd-launch .cd-badge{position:absolute;top:-3px;right:-3px;font-size:.7rem;padding:4px 7px;min-width:21px} .cd-launch.has{animation:cdPulse 2.4s ease-out infinite} @keyframes cdPulse{ 0%{box-shadow:0 10px 30px rgba(0,0,0,.45),0 0 0 0 rgba(212,175,55,.42)} 70%{box-shadow:0 10px 30px rgba(0,0,0,.45),0 0 0 16px rgba(212,175,55,0)} 100%{box-shadow:0 10px 30px rgba(0,0,0,.45),0 0 0 0 rgba(212,175,55,0)}} @media(prefers-reduced-motion:reduce){ .cd-launch.has{animation:none} } .cd-badge{font-family:var(--font-mono);font-size:.68rem;font-weight:800;line-height:1; background:radial-gradient(circle at 34% 26%,rgba(255,255,255,.5),rgba(255,255,255,0) 55%),var(--red); color:#fff;padding:4px 7px;border-radius:999px;min-width:19px;text-align:center; box-shadow:0 3px 7px rgba(0,0,0,.5),inset 0 1px 0 rgba(255,255,255,.45),0 0 0 2px var(--navy-950)} /* shell โ€” two panes: rooms on the left, the conversation on the right */ .cd-panel{position:fixed;left:22px;bottom:22px;z-index:251;display:flex;border-radius:18px;overflow:hidden; border:1px solid var(--border-gold);background:var(--navy-950); box-shadow:0 26px 70px rgba(0,0,0,.62);animation:cdIn .22s cubic-bezier(.2,.8,.2,1) both} @keyframes cdIn{from{opacity:0;transform:translateY(14px) scale(.985)}to{opacity:1;transform:none}} .cd-side{width:266px;flex-shrink:0;display:flex;flex-direction:column;border-right:1px solid var(--border); background:var(--navy-900)} .cd-main{flex:1;min-width:0;display:flex;flex-direction:column} .cd-resize{position:absolute;top:0;right:0;width:22px;height:22px;cursor:nesw-resize;z-index:6} .cd-resize::after{content:'';position:absolute;top:7px;right:7px;width:9px;height:9px; border-top:2px solid var(--border-gold);border-right:2px solid var(--border-gold);border-radius:2px 3px 0 0} .cd-head{display:flex;align-items:center;gap:10px;padding:13px 14px;flex-shrink:0;min-height:60px; border-bottom:1px solid var(--border); background:linear-gradient(135deg,rgba(212,175,55,.16),transparent 70%),var(--navy-900)} /* Chat heads: lit glass orbs, not flat circles. A radial key light top-left, a rim light, an inner floor shadow, and a specular sheen layer. */ .cd-avatar,.cd-av{position:relative;border-radius:50%;flex-shrink:0;display:grid;place-items:center; font-family:var(--font-mono);font-weight:800;color:var(--gold-2); border:1px solid var(--border-gold);isolation:isolate; background:radial-gradient(circle at 32% 24%,rgba(255,255,255,.34),rgba(255,255,255,0) 46%), linear-gradient(158deg,var(--navy-700),var(--navy-950) 78%); box-shadow:0 5px 12px rgba(0,0,0,.5),inset 0 1px 0 rgba(255,255,255,.30), inset 0 -7px 13px rgba(0,0,0,.45)} .cd-avatar::after,.cd-av::after{content:'';position:absolute;inset:2px;border-radius:50%;pointer-events:none; background:linear-gradient(180deg,rgba(255,255,255,.22),rgba(255,255,255,0) 48%)} .cd-avatar{width:38px;height:38px;font-size:.78rem;overflow:hidden} .cd-avatar img{width:100%;height:100%;object-fit:contain;padding:6px;border-radius:50%; filter:drop-shadow(0 2px 3px rgba(0,0,0,.5))} .cd-av{width:31px;height:31px;font-size:.66rem} .cd-av.hide{visibility:hidden} .cd-avatar svg,.cd-av svg{position:relative;z-index:1;fill:none;stroke:var(--gold-2);stroke-width:1.9; stroke-linecap:round;stroke-linejoin:round;filter:drop-shadow(0 1px 2px rgba(0,0,0,.6))} .cd-avatar svg{width:20px;height:20px} .cd-av svg{width:17px;height:17px} /* one hue per room type, so a channel is recognisable at a glance */ .cd-av.h-gold,.cd-avatar.h-gold{background:radial-gradient(circle at 32% 24%,rgba(255,255,255,.5),rgba(255,255,255,0) 46%),linear-gradient(158deg,var(--gold-2),var(--gold) 80%);color:#1a1303} .cd-av.h-gold svg,.cd-avatar.h-gold svg{stroke:#1a1303} .cd-av.h-em,.cd-avatar.h-em{background:radial-gradient(circle at 32% 24%,rgba(255,255,255,.42),rgba(255,255,255,0) 46%),linear-gradient(158deg,#3ddba0,#0b6647 82%);color:#04150f} .cd-av.h-em svg{stroke:#062018} .cd-av.h-sky,.cd-avatar.h-sky{background:radial-gradient(circle at 32% 24%,rgba(255,255,255,.42),rgba(255,255,255,0) 46%),linear-gradient(158deg,#57c7f7,#0a5687 82%);color:#04141f} .cd-av.h-sky svg{stroke:#04202f} .cd-av.h-ind,.cd-avatar.h-ind{background:radial-gradient(circle at 32% 24%,rgba(255,255,255,.42),rgba(255,255,255,0) 46%),linear-gradient(158deg,#a9b6ff,#3b34b8 82%);color:#0d0f2b} .cd-av.h-ind svg{stroke:#0d0f2b} .cd-av.h-slate,.cd-avatar.h-slate{background:radial-gradient(circle at 32% 24%,rgba(255,255,255,.36),rgba(255,255,255,0) 46%),linear-gradient(158deg,#cbd5e1,#55637a 82%);color:#10151f} .cd-av.h-slate svg{stroke:#141b26} .cd-head h4{margin:0;font-size:.98rem;font-weight:700;line-height:1.2;color:var(--ink); white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .cd-sub{font-size:.75rem;color:var(--ink-dim);margin-top:2px;font-family:var(--font-mono); white-space:nowrap;overflow:hidden;text-overflow:ellipsis} /* Glass 3D control buttons โ€” a lit top edge, a soft floor shadow, and a real press. Flat squares read as unfinished at this size. */ .cd-icobtn{border:1px solid var(--border);padding:7px;border-radius:11px;cursor:pointer;color:var(--ink-dim); display:grid;place-items:center;flex-shrink:0;position:relative; background:linear-gradient(165deg,rgba(255,255,255,.12),rgba(255,255,255,.02)); -webkit-backdrop-filter:blur(9px);backdrop-filter:blur(9px); box-shadow:0 3px 8px rgba(0,0,0,.36),inset 0 1px 0 rgba(255,255,255,.20); transition:transform .13s,box-shadow .13s,color .13s,border-color .13s} .cd-icobtn:hover{color:var(--gold-2);border-color:var(--border-gold);transform:translateY(-1px); background:linear-gradient(165deg,rgba(255,255,255,.18),rgba(212,175,55,.07)); box-shadow:0 6px 14px rgba(0,0,0,.42),inset 0 1px 0 rgba(255,255,255,.28)} .cd-icobtn:active{transform:translateY(1px); box-shadow:inset 0 3px 8px rgba(0,0,0,.5),inset 0 1px 0 rgba(255,255,255,.06)} .cd-icobtn.on{color:var(--gold-2)} .cd-icobtn.off{opacity:.55} .cd-icobtn svg{width:17px;height:17px;fill:none;stroke:currentColor;stroke-width:2; stroke-linecap:round;stroke-linejoin:round;filter:drop-shadow(0 1px 1px rgba(0,0,0,.45))} .cd-threads{flex:1;overflow-y:auto;padding:6px} .cd-thread{display:flex;gap:10px;align-items:center;width:100%;padding:11px 11px;border-radius:11px; border:0;background:none;cursor:pointer;text-align:left;color:var(--ink);font-family:inherit} .cd-thread:hover{background:var(--surface)} .cd-thread.on{background:rgba(212,175,55,.13);box-shadow:inset 3px 0 0 var(--gold-2)} .cd-thread .t{flex:1;min-width:0} .cd-thread .t b{display:block;font-size:.9rem;font-weight:700;color:var(--ink); white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .cd-thread .t span{display:block;font-size:.74rem;color:var(--ink-dim);white-space:nowrap; overflow:hidden;text-overflow:ellipsis;margin-top:1px} .cd-thread .cd-av{width:36px;height:36px} .cd-grouphead{font-size:.66rem;letter-spacing:.11em;text-transform:uppercase;color:var(--ink-dim); padding:13px 12px 6px;font-family:var(--font-mono);font-weight:700} .cd-body{flex:1;overflow-y:auto;padding:16px 16px 8px;display:flex;flex-direction:column;gap:2px; background:radial-gradient(1100px 420px at 20% -10%,rgba(212,175,55,.05),transparent 60%)} .cd-day{align-self:center;margin:14px 0 10px;font-size:.7rem;font-family:var(--font-mono); letter-spacing:.07em;text-transform:uppercase;color:var(--ink-dim);font-weight:700; background:var(--surface);border:1px solid var(--border);border-radius:999px;padding:4px 13px} .cd-newline{display:flex;align-items:center;gap:10px;margin:14px 2px 8px; font-family:var(--font-mono);font-size:.68rem;font-weight:800;letter-spacing:.1em; text-transform:uppercase;color:var(--gold-2)} .cd-newline::before,.cd-newline::after{content:'';flex:1;height:1px;background:var(--border-gold)} .cd-row{display:flex;gap:9px;align-items:flex-end;margin-top:3px;position:relative} .cd-row.me{flex-direction:row-reverse} .cd-row.first{margin-top:13px} .cd-row.first .cd-bub{border-bottom-left-radius:5px} .cd-row.me.first .cd-bub{border-bottom-left-radius:16px;border-bottom-right-radius:5px} /* Bubbles: brighter fills, real borders and a lit top edge so they sit above the ground rather than melting into it. */ .cd-bub{max-width:78%;padding:10px 14px 8px;border-radius:16px;position:relative; background:linear-gradient(168deg,var(--navy-850),var(--navy-900));border:1px solid var(--border); min-width:80px;box-shadow:0 4px 12px rgba(0,0,0,.34),inset 0 1px 0 rgba(255,255,255,.07)} .cd-row.me .cd-bub{background:linear-gradient(155deg,rgba(212,175,55,.34),rgba(212,175,55,.16)); border-color:var(--border-gold);box-shadow:0 5px 14px rgba(0,0,0,.38),inset 0 1px 0 rgba(255,255,255,.22)} .cd-name{font-size:.76rem;font-weight:800;color:var(--gold-2);margin-bottom:3px; font-family:var(--font-mono);letter-spacing:.02em} /* mono at 16px โ€” the readability fix */ .cd-text{font-family:var(--font-mono);font-size:16px;line-height:1.62;color:var(--ink); white-space:pre-wrap;word-break:break-word;font-weight:500} .cd-text.gone{color:var(--ink-dim);font-style:italic;font-weight:400} .cd-meta{display:flex;align-items:center;gap:6px;justify-content:flex-end;margin-top:4px; font-family:var(--font-mono);font-size:.7rem;color:var(--ink-dim);line-height:1} .cd-meta svg{width:14px;height:14px;fill:none;stroke:currentColor;stroke-width:2.2; stroke-linecap:round;stroke-linejoin:round} .cd-meta .seen{color:var(--gold-2)} .cd-quote{border-left:3px solid var(--gold-2);padding:5px 9px;margin-bottom:7px;border-radius:5px; background:rgba(212,175,55,.09);cursor:pointer} .cd-quote b{display:block;font-size:.72rem;color:var(--gold-2);font-family:var(--font-mono)} .cd-quote span{display:block;font-size:.8rem;color:var(--ink-dim);white-space:nowrap; overflow:hidden;text-overflow:ellipsis;max-width:230px} .cd-link{display:flex;align-items:center;gap:9px;margin-top:8px;padding:9px 11px;border-radius:11px; border:1px solid var(--border-gold);background:rgba(212,175,55,.07);text-decoration:none; color:var(--ink);max-width:100%} .cd-link:hover{background:rgba(212,175,55,.13)} .cd-link svg{width:17px;height:17px;flex-shrink:0;fill:none;stroke:var(--gold-2);stroke-width:2; stroke-linecap:round;stroke-linejoin:round} .cd-link .l{min-width:0} .cd-link b{display:block;font-size:.8rem;font-family:var(--font-mono);color:var(--gold-2)} .cd-link span{display:block;font-size:.74rem;color:var(--ink-dim);white-space:nowrap; overflow:hidden;text-overflow:ellipsis;max-width:240px} .cd-rx{display:flex;flex-wrap:wrap;gap:5px;margin-top:7px} .cd-rx button{display:inline-flex;align-items:center;gap:4px;padding:3px 9px;border-radius:999px; border:1px solid var(--border);cursor:pointer;font-size:.86rem;line-height:1.3; color:var(--ink-dim);font-family:var(--font-mono);font-weight:700; background:linear-gradient(165deg,rgba(255,255,255,.12),rgba(255,255,255,.02)); -webkit-backdrop-filter:blur(7px);backdrop-filter:blur(7px); box-shadow:0 2px 5px rgba(0,0,0,.3),inset 0 1px 0 rgba(255,255,255,.2)} .cd-rx button:active{transform:translateY(1px)} .cd-rx button.mine{border-color:var(--border-gold);background:var(--gold-soft);color:var(--gold-2)} .cd-rxpick{position:absolute;top:-42px;display:flex;gap:2px;padding:4px;border-radius:12px; background:var(--navy-900);border:1px solid var(--border-gold);box-shadow:0 8px 22px rgba(0,0,0,.5);z-index:6} .cd-row.me .cd-rxpick{right:8px} .cd-row:not(.me) .cd-rxpick{left:8px} .cd-rxpick button{background:none;border:0;font-size:1.1rem;padding:4px 6px;border-radius:8px;cursor:pointer} .cd-rxpick button:hover{background:var(--surface)} .cd-acts{position:absolute;top:-13px;display:flex;gap:1px;padding:3px;border-radius:10px; background:var(--navy-900);border:1px solid var(--border);box-shadow:0 6px 18px rgba(0,0,0,.45); opacity:0;pointer-events:none;transition:opacity .12s;z-index:3} .cd-row:hover .cd-acts,.cd-acts.open{opacity:1;pointer-events:auto} .cd-row.me .cd-acts{left:8px} .cd-row:not(.me) .cd-acts{right:8px} .cd-acts button{background:none;border:0;padding:6px;border-radius:7px;cursor:pointer; color:var(--ink-dim);display:grid;place-items:center} .cd-acts button:hover{background:var(--surface);color:var(--gold-2)} .cd-acts button.danger:hover{color:var(--red);background:rgba(248,113,113,.1)} .cd-acts svg{width:15px;height:15px;fill:none;stroke:currentColor;stroke-width:2; stroke-linecap:round;stroke-linejoin:round} .cd-pins{flex-shrink:0;border-bottom:1px solid var(--border);background:rgba(212,175,55,.08); padding:8px 13px;display:flex;flex-direction:column;gap:5px;max-height:104px;overflow-y:auto} .cd-pin{display:flex;align-items:center;gap:8px;font-size:.82rem;color:var(--ink);cursor:pointer} .cd-pin svg{width:14px;height:14px;flex-shrink:0;fill:none;stroke:var(--gold-2);stroke-width:2; stroke-linecap:round;stroke-linejoin:round} .cd-pin span{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis; font-family:var(--font-mono)} .cd-pin button{background:none;border:0;color:var(--ink-dim);cursor:pointer;font-size:.72rem; font-family:var(--font-mono);flex-shrink:0} .cd-pin button:hover{color:var(--red)} .cd-foot{flex-shrink:0;border-top:1px solid var(--border);background:var(--navy-900)} .cd-reply{display:flex;align-items:center;gap:8px;padding:9px 13px;border-bottom:1px solid var(--border); background:rgba(212,175,55,.07)} .cd-reply .r{flex:1;min-width:0;border-left:3px solid var(--gold-2);padding-left:9px} .cd-reply b{display:block;font-size:.72rem;color:var(--gold-2);font-family:var(--font-mono)} .cd-reply span{display:block;font-size:.82rem;color:var(--ink-dim);white-space:nowrap; overflow:hidden;text-overflow:ellipsis} .cd-compose{display:flex;align-items:flex-end;gap:8px;padding:10px 11px} .cd-compose textarea{flex:1;resize:none;max-height:132px;min-height:44px;padding:11px 13px; border-radius:13px;border:1px solid var(--border);background:var(--navy-950);color:var(--ink); font-family:var(--font-mono);font-size:15px;line-height:1.5} .cd-compose textarea::placeholder{color:var(--ink-faint)} .cd-compose textarea:focus{outline:none;border-color:var(--gold);box-shadow:0 0 0 3px var(--gold-soft)} .cd-send{width:42px;height:42px;flex-shrink:0;border-radius:50%;border:1px solid var(--border-gold); cursor:pointer;display:grid;place-items:center;position:relative; background:radial-gradient(circle at 34% 24%,rgba(255,255,255,.55),rgba(255,255,255,0) 48%), linear-gradient(155deg,var(--gold-2),var(--gold) 70%); box-shadow:0 6px 14px rgba(0,0,0,.45),inset 0 1px 0 rgba(255,255,255,.55), inset 0 -6px 11px rgba(90,60,0,.32); transition:filter .15s,opacity .15s,transform .12s,box-shadow .12s} .cd-send:disabled{opacity:.4;cursor:default} .cd-send:not(:disabled):hover{filter:brightness(1.07);transform:translateY(-1px)} .cd-send:not(:disabled):active{transform:translateY(1px); box-shadow:0 2px 6px rgba(0,0,0,.45),inset 0 3px 8px rgba(90,60,0,.5)} .cd-send svg{width:18px;height:18px;fill:none;stroke:#1a1303;stroke-width:2.2; stroke-linecap:round;stroke-linejoin:round;filter:drop-shadow(0 1px 0 rgba(255,255,255,.4))} .cd-readonly{padding:13px 15px;font-size:.82rem;color:var(--ink-dim);text-align:center; font-family:var(--font-mono)} .cd-emoji{position:absolute;bottom:60px;left:10px;right:10px;max-height:230px;overflow-y:auto; background:var(--navy-900);border:1px solid var(--border-gold);border-radius:13px;padding:8px; box-shadow:0 -8px 28px rgba(0,0,0,.45);z-index:5} .cd-emoji h6{margin:6px 4px 4px;font-size:.66rem;font-family:var(--font-mono);letter-spacing:.09em; text-transform:uppercase;color:var(--ink-dim);font-weight:700} .cd-emoji .g{display:grid;grid-template-columns:repeat(8,1fr);gap:2px} .cd-emoji button{background:none;border:0;font-size:1.2rem;line-height:1;padding:6px;border-radius:7px;cursor:pointer} .cd-emoji button:hover{background:var(--surface)} .cd-ask{position:absolute;inset:0;z-index:8;display:grid;place-items:center;padding:18px; background:rgba(4,7,14,.74);backdrop-filter:blur(3px)} .cd-askcard{background:var(--navy-900);border:1px solid var(--border-gold);border-radius:14px; padding:17px 19px;max-width:320px;box-shadow:0 18px 44px rgba(0,0,0,.55)} .cd-askcard b{display:block;font-size:.96rem;margin-bottom:7px;color:var(--ink)} .cd-askcard p{font-size:.83rem;line-height:1.55;color:var(--ink-dim);margin:0 0 15px} .cd-askbtns{display:flex;gap:8px;justify-content:flex-end} .cd-askbtns button{padding:8px 15px;border-radius:9px;font-size:.83rem;font-weight:700; cursor:pointer;font-family:inherit;border:1px solid var(--border);background:var(--surface); color:var(--ink-dim)} .cd-askno:hover{border-color:var(--border-gold);color:var(--gold-2)} .cd-askyes{border-color:var(--border-gold) !important;color:var(--gold-2) !important; background:var(--gold-soft) !important} .cd-askyes.danger{border-color:rgba(248,113,113,.45) !important;color:var(--red) !important; background:rgba(248,113,113,.12) !important} .cd-sig{border:1px solid var(--border);border-left-width:3px;border-radius:10px;padding:10px 12px; background:var(--surface);margin-bottom:9px} .cd-sig-top{display:flex;align-items:center;gap:8px;flex-wrap:wrap;font-size:.86rem} .cd-sig-top strong{font-family:var(--font-mono);letter-spacing:.03em;color:var(--ink)} .cd-sig-top span{font-family:var(--font-mono);font-size:.78rem;font-weight:800} .cd-sig-top em{font-style:normal;font-size:.74rem;color:var(--ink-dim);font-family:var(--font-mono)} .cd-sig-when{margin-left:auto;color:var(--ink-dim);font-weight:400 !important} .cd-sig-lv{font-family:var(--font-mono);font-size:.8rem;color:var(--ink-dim);margin-top:6px} .cd-sig-msg{font-size:.87rem;color:var(--ink);margin-top:7px;white-space:pre-wrap;line-height:1.5; font-family:var(--font-mono)} .cd-sig-by{font-family:var(--font-mono);font-size:.7rem;color:var(--ink-dim);margin-top:7px} .cd-typing{padding:3px 15px 7px;font-size:.76rem;color:var(--gold-2);font-family:var(--font-mono)} .cd-empty{flex:1;display:grid;place-items:center;padding:34px 22px;text-align:center; color:var(--ink-dim);font-size:.88rem} .cd-err{padding:8px 14px;font-size:.78rem;color:var(--red);font-family:var(--font-mono)} /* one pane at a time on narrow screens, full screen on phones */ /* light theme: glass has to invert or it disappears on a white ground */ html[data-cg-theme="light"] .cd-panel{background:var(--navy-900)} html[data-cg-theme="light"] .cd-side{background:var(--navy-850)} html[data-cg-theme="light"] .cd-icobtn{background:linear-gradient(165deg,rgba(255,255,255,.92),rgba(20,30,60,.05)); box-shadow:0 3px 8px rgba(20,30,60,.14),inset 0 1px 0 rgba(255,255,255,.95)} html[data-cg-theme="light"] .cd-icobtn svg{filter:none} html[data-cg-theme="light"] .cd-bub{background:linear-gradient(168deg,var(--navy-900),var(--navy-850)); box-shadow:0 3px 10px rgba(20,30,60,.12),inset 0 1px 0 rgba(255,255,255,.9)} html[data-cg-theme="light"] .cd-avatar,html[data-cg-theme="light"] .cd-av{ box-shadow:0 4px 10px rgba(20,30,60,.22),inset 0 1px 0 rgba(255,255,255,.55),inset 0 -6px 12px rgba(20,30,60,.16)} html[data-cg-theme="light"] .cd-rx button{background:linear-gradient(165deg,rgba(255,255,255,.92),rgba(20,30,60,.05))} @media(max-width:900px){ .cd-side{width:100%;border-right:0} .cd-resize{display:none} .cd-panel{width:min(440px,calc(100vw - 32px));height:min(620px,calc(100vh - 44px))} } @media(max-width:640px){ .cd-panel{left:0 !important;right:0;bottom:0;top:0;width:100% !important;height:100% !important; border-radius:0;border:0} .cd-launch{left:16px;bottom:16px} .cd-bub{max-width:86%} }`; document.head.appendChild(s); })(); /* โ”€โ”€ icons โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ const I = { chat: , close: , back: , reply: , edit: , trash: , copy: , smile: , react: , pin: , send: , link: , check: , checks:, bellOn: , bellOff: , }; // One mark per room type โ€” a channel is not the same kind of thing as a person. const ROOM_ICON = { staff: , boardroom: , all: , trial: , vip: , signal: , }; function roomIcon(key){ if(key==='staff') return ROOM_ICON.staff; if(key==='boardroom') return ROOM_ICON.boardroom; if(key==='broadcast:all') return ROOM_ICON.all; if(key==='broadcast:trial') return ROOM_ICON.trial; if(key==='broadcast:vip') return ROOM_ICON.vip; if(key==='signalview') return ROOM_ICON.signal; return null; // a person โ€” initials read better } function hueClass(key){ if(key==='staff') return 'h-gold'; if(key==='boardroom') return 'h-em'; if(key==='signalview') return 'h-sky'; if(key==='broadcast:all') return 'h-ind'; if(key==='broadcast:trial') return 'h-slate'; if(key==='broadcast:vip') return 'h-gold'; return ''; // a person keeps the neutral navy orb } const initials = s => (String(s||'?').trim().split(/[\s@._-]+/).filter(Boolean).slice(0,2) .map(w=>w[0]).join('') || '?').toUpperCase(); let DOCK_SEQ = 0; /* โ”€โ”€ thread naming โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ function threadTitle(key){ if(key==='staff') return 'Team room'; if(key==='boardroom') return 'Boardroom'; if(key==='broadcast:all') return 'All members'; if(key==='broadcast:trial') return 'Free trial tier'; if(key==='broadcast:vip') return 'VIP members'; if(key==='signalview') return 'Signal View'; if(key && key.indexOf('support:')===0) return key.slice(8); return key || ''; } function threadSub(key){ if(key==='staff') return 'Coaches and admins'; if(key==='boardroom') return 'Admin-only'; if(key && key.indexOf('broadcast:')===0) return 'Broadcast channel'; if(key==='signalview') return 'Live signal feed ยท read-only'; if(key && key.indexOf('support:')===0) return 'Direct support'; return ''; } // Students read broadcast channels, they do not post in them. function readOnlyFor(key, staffMode){ if(key==='signalview') return true; if(!staffMode && String(key).indexOf('broadcast:')===0) return true; return false; } const URL_RE = /(https?:\/\/[^\s]+)/i; function firstUrl(text){ const m = URL_RE.exec(String(text||'')); return m ? m[1].replace(/[.,)]+$/,'') : null; } function hostOf(u){ try{ return new URL(u).hostname.replace(/^www\./,''); }catch(e){ return u; } } /* The dock's own mark โ€” the brand logo as a lit orb, with the chat glyph as a fallback if the image is missing. */ function BrandOrb(){ const [bad,setBad] = useState(false); return {bad ? I.chat : Chart Growth HubsetBad(true)}/>} ; } /* โ”€โ”€ one message โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ function Bubble({m, mine, first, canPurge, canPin, quoted, reactions, meEmail, seen, showReceipts, onReply, onEdit, onDelete, onPurge, onPin, onJumpTo, onReact}){ const [picker,setPicker] = useState(false); const gone = !!m.deleted; const url = gone ? null : firstUrl(m.body); const counts = {}; (reactions||[]).forEach(r=>{ counts[r.emoji] = (counts[r.emoji]||0)+1; }); const mineRx = (reactions||[]).filter(r=>String(r.reactor_email).toLowerCase()===meEmail).map(r=>r.emoji); return
{initials(m.sender_name||m.sender_email)}
{first && !mine &&
{m.sender_name||m.sender_email}
} {quoted &&
onJumpTo && onJumpTo(quoted.id)} title="Go to the message being replied to"> {quoted.sender_name||quoted.sender_email} {quoted.deleted ? 'Deleted message' : quoted.body}
}
{gone ? 'This message was deleted' : m.body}
{url && {I.link}{hostOf(url)}{url} } {Object.keys(counts).length>0 &&
{Object.keys(counts).map(e=> )}
}
{m.pinned && !gone && {I.pin}} {m.edited_at && !gone && edited} {fmtTime(m.created_at)} {mine && !gone && showReceipts && {seen ? I.checks : I.check}}
{picker && !gone &&
{QUICK_REACTIONS.map(e=>)}
} {!gone &&
{canPin && } {mine && } {(mine || canPurge) && }
} {gone && canPurge &&
}
; } /* โ”€โ”€ sound toggle (global) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ function SoundToggle(){ const api = window.cgChatNotify; const [on,setOn] = useState(()=> api && api.soundOn ? !!api.soundOn() : true); if(!api || !api.setSound) return null; return ; } /* โ”€โ”€ emoji picker โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ function EmojiPicker({onPick, onClose}){ const ref = useRef(null); useEffect(()=>{ const h = e => { if(ref.current && !ref.current.contains(e.target)) onClose(); }; const t = setTimeout(()=>document.addEventListener('mousedown',h),0); return ()=>{ clearTimeout(t); document.removeEventListener('mousedown',h); }; },[onClose]); return
{Object.keys(EMOJI).map(g=>
{g}
{EMOJI[g].map(e=> )}
)}
; } /* โ”€โ”€ read-only signal feed โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ function SignalFeed({client}){ const [rows,setRows] = useState(null); useEffect(()=>{ let stop=false; async function load(){ if(stop) return; const { data } = await client.from('signals').select('*') .order('created_at',{ascending:false}).limit(40); if(!stop) setRows(data||[]); } load(); const t=setInterval(load,15000); return ()=>{ stop=true; clearInterval(t); }; },[client]); if(rows===null) return
; if(!rows.length) return
No signals published yet.
; return {rows.map(r=>{ const buy = r.direction==='buy'; return
{r.pair} {buy?'โ–ฒ BUY':'โ–ผ SELL'} {r.timeframe && {r.timeframe}} {fmtTime(r.created_at)}
{['Entry '+(r.entry||'โ€”'),'SL '+(r.stop_loss||'โ€”'), 'TP '+(r.take_profit_1||r.take_profit||'โ€”')].join(' ยท ')}
{r.message &&
{r.message}
}
{r.coach_name||'Coach'}{r.status?' ยท '+r.status.replace(/_/g,' '):''}
; })}
; } /* โ”€โ”€ the dock โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ function ChatDock({client, email, role, name, mode, tier, isVIP, openThread, onThreadOpened}){ const meEmail = (email||'').toLowerCase(); const isMain = role === 'main'; const staffMode = mode !== 'student'; const isStaff = staffMode; const [open,setOpen] = useState(false); const [thread,setThread] = useState(null); const [msgs,setMsgs] = useState(null); const [threads,setThreads] = useState([]); const [unread,setUnread] = useState({}); const [reactions,setReactions] = useState([]); const [otherReadAt,setOtherReadAt] = useState(null); const [firstUnreadAt,setFirstUnreadAt] = useState(null); const [body,setBody] = useState(''); const [replyTo,setReplyTo] = useState(null); const [editing,setEditing] = useState(null); const [emoji,setEmoji] = useState(false); const [sending,setSending] = useState(false); const [err,setErr] = useState(''); const [typing,setTyping] = useState([]); const [muted,setMuted] = useState(false); const [wide,setWide] = useState(typeof window!=='undefined' && window.innerWidth>900); const [size,setSize] = useState(()=> savedSize() || {w:780,h:640}); const [ask,setAsk] = useState(null); // {title, note, danger, run} const bodyRef = useRef(null); const taRef = useRef(null); const lastTyped = useRef(0); const uid = useRef(null); if(uid.current === null) uid.current = 'cd' + (++DOCK_SEQ); const anchorId = id => uid.current + '-m-' + id; const myName = name || (email||'').split('@')[0]; useEffect(()=>{ const r=()=>setWide(window.innerWidth>900); window.addEventListener('resize',r); return ()=>window.removeEventListener('resize',r); },[]); useEffect(()=>{ setMuted(thread ? isMuted(thread) : false); },[thread]); // A student always starts in their own thread; staff start on the list. const didInit = useRef(false); useEffect(()=>{ if(!staffMode && !didInit.current){ didInit.current = true; setThread('support:'+meEmail); } },[staffMode, meEmail]); useEffect(()=>{ if(!openThread) return; setThread(openThread); setOpen(true); setReplyTo(null); setEditing(null); if(onThreadOpened) onThreadOpened(); /* eslint-disable-next-line */ },[openThread]); useEffect(()=>{ window.cgOpenChat = () => setOpen(true); return ()=>{ if(window.cgOpenChat) delete window.cgOpenChat; }; },[]); const totalUnread = useMemo( ()=>Object.keys(unread).reduce((s,k)=>s+(unread[k]||0),0), [unread]); /* unread counts across every thread this account can see */ const loadUnread = useCallback(async ()=>{ const { data:rd } = await client.from('chat_reads').select('*').eq('reader_email',meEmail); const readMap = {}; (rd||[]).forEach(r=>{ readMap[r.thread_key] = r.last_read_at; }); const { data:all } = await client.from('chat_messages') .select('thread_key,created_at,sender_email,deleted').neq('sender_email',meEmail); const counts = {}; (all||[]).forEach(m=>{ if(m.deleted) return; const last = readMap[m.thread_key]; if(!last || new Date(m.created_at) > new Date(last)) counts[m.thread_key] = (counts[m.thread_key]||0)+1; }); setUnread(counts); if(staffMode){ const keys = {}; (all||[]).forEach(m=>{ if(String(m.thread_key).indexOf('support:')===0) keys[m.thread_key]=true; }); setThreads(Object.keys(keys).sort()); } },[client, meEmail, staffMode]); useEffect(()=>{ loadUnread(); const t=setInterval(loadUnread,10000); return ()=>clearInterval(t); },[loadUnread]); /* messages + reactions + read state for the open thread */ const loadMsgs = useCallback(async ()=>{ if(!thread) return; const { data, error } = await client.from('chat_messages').select('*') .eq('thread_key',thread).order('created_at',{ascending:true}); if(error){ setErr(error.message); return; } setErr(''); setMsgs(data||[]); const { data:rx } = await client.from('chat_reactions').select('*').eq('thread_key',thread); setReactions(rx||[]); if(isStaff){ const { data:rd } = await client.from('chat_reads').select('*').eq('thread_key',thread); const others = (rd||[]).filter(r=>String(r.reader_email).toLowerCase()!==meEmail); setOtherReadAt(others.length ? Math.max(...others.map(r=>new Date(r.last_read_at).getTime())) : null); } },[client, thread, isStaff, meEmail]); // Where the "new messages" line goes: the read mark as it stood when the // room was opened, captured before we mark it read. useEffect(()=>{ if(!open || !thread){ return; } let stop = false; (async ()=>{ const { data:rd } = await client.from('chat_reads').select('last_read_at') .eq('thread_key',thread).eq('reader_email',meEmail).maybeSingle(); if(!stop) setFirstUnreadAt(rd && rd.last_read_at ? new Date(rd.last_read_at).getTime() : null); })(); setMsgs(null); loadMsgs(); const t = setInterval(loadMsgs, 6000); return ()=>{ stop = true; clearInterval(t); }; },[open, thread, loadMsgs, client, meEmail]); /* mark read whenever the thread is open and messages change */ useEffect(()=>{ if(!open || !thread || !msgs) return; client.from('chat_reads').upsert({thread_key:thread, reader_email:meEmail, last_read_at:new Date().toISOString()}).then(()=>{ setUnread(u => { const n = {...u}; delete n[thread]; return n; }); }); },[open, thread, msgs, client, meEmail]); /* who else is typing */ useEffect(()=>{ if(!open || !thread) return; let stop = false; async function poll(){ if(stop) return; const { data } = await client.from('chat_typing').select('*').eq('thread_key',thread); const fresh = (data||[]).filter(r => String(r.sender_email).toLowerCase() !== meEmail && Date.now() - new Date(r.updated_at).getTime() < 6000); setTyping(fresh.map(r=>r.sender_email)); } poll(); const t = setInterval(poll, 3000); return ()=>{ stop = true; clearInterval(t); }; },[open, thread, client, meEmail]); function pingTyping(){ const now = Date.now(); if(now - lastTyped.current < 2500) return; lastTyped.current = now; client.from('chat_typing').upsert({thread_key:thread, sender_email:meEmail, updated_at:new Date().toISOString()}).then(()=>{}, ()=>{}); } useEffect(()=>{ const el = bodyRef.current; if(el) el.scrollTop = el.scrollHeight; },[msgs, thread]); const byId = useMemo(()=>{ const m = {}; (msgs||[]).forEach(x=>{ m[x.id]=x; }); return m; },[msgs]); const rxFor = useCallback(id => reactions.filter(r=>String(r.message_id)===String(id)), [reactions]); const pinned = useMemo(()=> (msgs||[]).filter(m=>m.pinned && !m.deleted), [msgs]); const readOnly = readOnlyFor(thread, staffMode); /* drag to resize โ€” the panel is anchored bottom-left, so it grows up/right */ function startResize(e){ e.preventDefault(); const sx=e.clientX, sy=e.clientY, sw=size.w, sh=size.h; function move(ev){ const w = Math.max(560, Math.min(1180, sw + (ev.clientX-sx))); const h = Math.max(420, Math.min(window.innerHeight-60, sh - (ev.clientY-sy))); setSize({w,h}); } function up(){ document.removeEventListener('mousemove',move); document.removeEventListener('mouseup',up); try{ localStorage.setItem(SIZE_KEY, JSON.stringify(size)); }catch(err){} } document.addEventListener('mousemove',move); document.addEventListener('mouseup',up); } useEffect(()=>{ try{ localStorage.setItem(SIZE_KEY, JSON.stringify(size)); }catch(e){} },[size]); async function send(){ const text = body.trim(); if(!text || sending || !thread || readOnly) return; setSending(true); setErr(''); if(editing){ const { error } = await client.from('chat_messages') .update({body:text, edited_at:new Date().toISOString()}).eq('id',editing.id); setSending(false); if(error){ setErr(error.message); return; } setEditing(null); setBody(''); loadMsgs(); return; } const { error } = await client.from('chat_messages').insert({ thread_key: thread, sender_email: meEmail, sender_name: myName, sender_role: role || (staffMode?'staff':'student'), body: text, reply_to_id: replyTo ? replyTo.id : null, }); setSending(false); if(error){ setErr(error.message); return; } setBody(''); setReplyTo(null); setFirstUnreadAt(null); loadMsgs(); loadUnread(); } async function react(m, e){ const mine = reactions.find(r=>String(r.message_id)===String(m.id) && String(r.reactor_email).toLowerCase()===meEmail && r.emoji===e); if(mine){ await client.from('chat_reactions').delete().eq('message_id',m.id) .eq('reactor_email',meEmail).eq('emoji',e); } else { await client.from('chat_reactions').insert({message_id:m.id, thread_key:thread, reactor_email:meEmail, emoji:e}); } loadMsgs(); } async function togglePin(m){ const { error } = await client.from('chat_messages').update({pinned:!m.pinned}).eq('id',m.id); if(error) setErr(error.message); else loadMsgs(); } function softDelete(m){ setAsk({ title:'Delete this message?', note:'It will read โ€œThis message was deletedโ€ for everyone in this conversation.', run: async ()=>{ const { error } = await client.from('chat_messages').update({deleted:true}).eq('id',m.id); if(error) setErr(error.message); else loadMsgs(); }}); } function purge(m){ setAsk({ title:'Remove permanently?', danger:true, note:'The record is erased. Any reply pointing at it loses its quote. This cannot be undone.', run: async ()=>{ const { error } = await client.from('chat_messages').delete().eq('id',m.id); if(error) setErr(error.message); else { if(window.cgLog) window.cgLog(client,'chat_purge','message '+m.id+' in '+thread); loadMsgs(); } }}); } function startEdit(m){ setEditing(m); setReplyTo(null); setBody(m.body||''); setTimeout(()=>{ if(taRef.current){ taRef.current.focus(); taRef.current.setSelectionRange(taRef.current.value.length, taRef.current.value.length); } },0); } function startReply(m){ setEditing(null); setReplyTo(m); setTimeout(()=>taRef.current && taRef.current.focus(), 0); } function insertEmoji(e){ const ta = taRef.current; if(!ta){ setBody(b=>b+e); return; } const a = ta.selectionStart, b2 = ta.selectionEnd; setBody(v => v.slice(0,a) + e + v.slice(b2)); setTimeout(()=>{ ta.focus(); ta.setSelectionRange(a+e.length, a+e.length); },0); } function jumpTo(id){ const el = document.getElementById(anchorId(id)); if(el){ el.scrollIntoView({block:'center', behavior:'smooth'}); el.style.transition='background .4s'; el.style.background='rgba(212,175,55,.16)'; setTimeout(()=>{ el.style.background=''; }, 900); } } function onKey(e){ if(e.key === 'Enter' && !e.shiftKey){ e.preventDefault(); send(); } if(e.key === 'Escape'){ setEditing(null); setReplyTo(null); setEmoji(false); } } /* โ”€โ”€ launcher โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ if(!open) return ; /* โ”€โ”€ room list (left pane) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ const staffRooms = ['staff','boardroom']; const staffCasts = ['broadcast:all','broadcast:trial','broadcast:vip','signalview']; const studentRooms = ['support:'+meEmail]; const studentCasts = ['broadcast:all'] .concat(tier==='trial' ? ['broadcast:trial'] : []) .concat(isVIP ? ['broadcast:vip','signalview'] : []); const supp = threads.slice().sort((a,b)=>(unread[b]||0)-(unread[a]||0) || a.localeCompare(b)); function ThreadBtn({k, label, sub}){ const ico = roomIcon(k); return ; } const Group = ({label, keys, labelFor}) => keys.length ?
{label}
{keys.map(k=>)}
: null; const side =

Messages

{totalUnread ? totalUnread+' unread' : 'All caught up'}
{!wide && }
{staffMode ? {supp.length===0 &&
No student conversations yet.
}
: 'Coaching team'}/> {!isVIP &&
VIP members also get the VIP channel and the live Signal View.
}
}
; /* โ”€โ”€ conversation (right pane) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ let lastSender = null, lastDay = null, dividerDone = false; const conversation =
{!wide && } {roomIcon(thread) || initials(staffMode ? threadTitle(thread) : 'Coaching team')}

{staffMode ? threadTitle(thread) : (String(thread).indexOf('support:')===0 ? 'Coaching team' : threadTitle(thread))}

{typing.length ? 'typingโ€ฆ' : (staffMode ? threadSub(thread) : (String(thread).indexOf('support:')===0 ? 'Ask anything โ€” we reply here' : threadSub(thread)))}
{pinned.length>0 &&
{pinned.map(p=>
jumpTo(p.id)}> {I.pin}{p.body} {isStaff && }
)}
}
{thread==='signalview' && } {thread!=='signalview' && msgs === null &&
} {thread!=='signalview' && msgs && msgs.length===0 &&
No messages yet โ€” start the conversation.
} {thread!=='signalview' && (msgs||[]).map(m=>{ const mine = String(m.sender_email).toLowerCase() === meEmail; const dk = dayKey(m.created_at); const newDay = dk !== lastDay; const first = newDay || m.sender_email !== lastSender; lastDay = dk; lastSender = m.sender_email; const showDivider = !dividerDone && !mine && firstUnreadAt && new Date(m.created_at).getTime() > firstUnreadAt; if(showDivider) dividerDone = true; const seen = !!otherReadAt && otherReadAt >= new Date(m.created_at).getTime(); return {newDay &&
{dayLabel(m.created_at)}
} {showDivider &&
New messages
}
; })}
{typing.length>0 &&
{typing.length===1 ? typing[0].split('@')[0]+' is typingโ€ฆ' : 'Several people are typingโ€ฆ'}
} {err &&
{err}
} {readOnly ?
{thread==='signalview' ? 'Read-only feed ยท published from Signals/Strategy' : 'Read-only channel ยท your coaching team posts here'}
:
{(replyTo || editing) &&
{editing ? 'Editing your message' : 'Replying to '+(replyTo.sender_name||replyTo.sender_email)} {(editing||replyTo).body}
} {emoji && setEmoji(false)}/>} {ask &&
{ask.title}

{ask.note}

}