// Student · Messages — messenger-style support thread with the coaching team. // Exposes window.StudentChat({ client, session, name }). (function(){ const { useState, useEffect, useRef } = React; const { fmtTime, dayLabel, fmtFull, startPresenceLoop, isOnline, relTime } = window.ChatCommon; const { ConfirmModal, Ticks, ReactionBar, ReactPicker, TypingDots } = window.ChatUI; function StudentChat({ client, session, name }){ const meEmail=(session.user.email||'').toLowerCase(); const thread='support:'+meEmail; const [msgs,setMsgs]=useState([]); const [reactions,setReactions]=useState({}); const [reads,setReads]=useState([]); const [unread,setUnread]=useState(0); const [coachOnline,setCoachOnline]=useState(false); const [otherTyping,setOtherTyping]=useState(false); const [body,setBody]=useState(''); const [err,setErr]=useState(''); const [sending,setSending]=useState(false); const [open,setOpen]=useState(false); const [staffOnline,setStaffOnline]=useState(false); const [editId,setEditId]=useState(null); const [editText,setEditText]=useState(''); const [replyTo,setReplyTo]=useState(null); const [pickerFor,setPickerFor]=useState(null); const [confirm,setConfirm]=useState(null); const scroller=useRef(null); const lastTypingSent=useRef(0); const myName=(name&&name.split(' ')[0])||meEmail.split('@')[0]; useEffect(()=>{ if(document.getElementById('cg-chat-css')) return; const s=document.createElement('style'); s.id='cg-chat-css'; s.textContent='.cg-typedot{width:5px;height:5px;border-radius:50%;background:rgba(26,19,3,.55);animation:cgTyping 1s infinite ease-in-out}.cg-typedot:nth-child(2){animation-delay:.15s}.cg-typedot:nth-child(3){animation-delay:.3s}@keyframes cgTyping{0%,60%,100%{opacity:.35;transform:translateY(0)}30%{opacity:1;transform:translateY(-2px)}}.cg-msg-row:hover .cg-msg-actions{opacity:1}.cg-msg-actions{opacity:0;transition:opacity .15s}@media(max-width:600px){.cg-msg-actions{opacity:1}}'; document.head.appendChild(s); },[]); useEffect(()=>{ const iv=startPresenceLoop(client, meEmail); return ()=>clearInterval(iv); },[]); async function load(){ 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||[]).filter(m=>!m.staff_only)); const { data:rx } = await client.from('chat_reactions').select('*').eq('thread_key',thread); const rmap={}; (rx||[]).forEach(r=>{ (rmap[r.message_id]=rmap[r.message_id]||[]).push(r); }); setReactions(rmap); const { data:rd } = await client.from('chat_reads').select('*').eq('thread_key',thread); setReads(rd||[]); } async function markRead(){ await client.from('chat_reads').upsert({thread_key:thread,reader_email:meEmail,last_read_at:new Date().toISOString()}); } useEffect(()=>{ load(); const iv=setInterval(load, open?3500:15000); return ()=>clearInterval(iv); },[open]); useEffect(()=>{ if(open){ markRead(); } },[open,msgs.length]); useEffect(()=>{ if(open&&scroller.current) scroller.current.scrollTop=scroller.current.scrollHeight; },[msgs,open,otherTyping]); // unread badge on the collapsed header useEffect(()=>{ let stop=false; async function chk(){ if(stop||open) return; const { data:rd } = await client.from('chat_reads').select('*').eq('thread_key',thread).eq('reader_email',meEmail); const last = rd&&rd[0]?rd[0].last_read_at:null; const n=(msgs||[]).filter(m=>!m.deleted && m.sender_email.toLowerCase()!==meEmail && (!last||new Date(m.created_at)>new Date(last))).length; setUnread(n); } chk(); const iv=setInterval(chk,8000); return ()=>{ stop=true; clearInterval(iv); }; },[msgs,open]); // staff presence (any staff online) + typing useEffect(()=>{ let stop=false; async function poll(){ if(stop) return; const { data:t } = await client.from('chat_typing').select('*').eq('thread_key',thread); const now=Date.now(); setOtherTyping((t||[]).some(x=>x.sender_email.toLowerCase()!==meEmail && (now-new Date(x.updated_at).getTime())<6000)); } poll(); const iv=setInterval(poll,2500); return ()=>{ stop=true; clearInterval(iv); }; },[open]); function pingTyping(){ const now=Date.now(); if(now-lastTypingSent.current<2500) return; lastTypingSent.current=now; client.from('chat_typing').upsert({thread_key:thread,sender_email:meEmail,updated_at:new Date().toISOString()}).then(()=>{}); } // staff presence (any coach/admin who has messaged you or is generally online) useEffect(()=>{ let stop=false; async function poll(){ if(stop) return; const { data } = await client.from('chat_presence').select('*'); const now=Date.now(); setStaffOnline((data||[]).some(p=>p.email.toLowerCase()!==meEmail && (now-new Date(p.last_seen).getTime())<45000)); } poll(); const iv=setInterval(poll,15000); return ()=>{ stop=true; clearInterval(iv); }; },[]); async function send(){ const text=body.trim(); if(!text||sending) return; setSending(true); setErr(''); const row={ thread_key:thread, sender_email:meEmail, sender_name:myName, sender_role:'student', body:text, reply_to_id:replyTo?replyTo.id:null }; setBody(''); setReplyTo(null); const { error } = await client.from('chat_messages').insert(row); if(error){ setErr(error.message); setBody(text); } else { load(); markRead(); } setSending(false); } async function toggleReaction(m,emoji){ const mine=(reactions[m.id]||[]).find(r=>r.reactor_email.toLowerCase()===meEmail); if(mine && mine.emoji===emoji){ await client.from('chat_reactions').delete().eq('message_id',m.id).eq('reactor_email',meEmail); } else { await client.from('chat_reactions').upsert({message_id:m.id,thread_key:thread,reactor_email:meEmail,emoji}); } setPickerFor(null); load(); } function del(m){ setConfirm({ title:'Delete this message?', body:'It will show as "message deleted" for your coach too.', run: async()=>{ const { error } = await client.from('chat_messages').update({deleted:true}).eq('id',m.id); if(error) setErr(error.message); else load(); setConfirm(null); } }); } function startEdit(m){ setEditId(m.id); setEditText(m.body||''); } async function saveEdit(m){ const t=editText.trim(); if(!t){ setEditId(null); return; } const { error } = await client.from('chat_messages').update({body:t, edited_at:new Date().toISOString()}).eq('id',m.id); if(error){ setErr(error.message); } else { setEditId(null); setEditText(''); load(); } } const otherReadAt = (()=>{ const others=reads.filter(r=>r.reader_email.toLowerCase()!==meEmail); if(!others.length) return null; return others.map(r=>new Date(r.last_read_at).getTime()).sort((a,b)=>b-a)[0]; })(); let lastDay=null; return
{confirm && setConfirm(null)} onConfirm={confirm.run}/>} {open &&
{msgs.length===0 &&
No messages yet.
Ask your coach anything 👋
} {msgs.map(m=>{ const mine=(m.sender_email||'').toLowerCase()===meEmail; const editing=editId===m.id; const thisDay=dayLabel(m.created_at); const showDay=thisDay!==lastDay; lastDay=thisDay; const seen = mine && !!otherReadAt && otherReadAt>=new Date(m.created_at).getTime(); const quoted = m.reply_to_id ? msgs.find(x=>x.id===m.reply_to_id) : null; const rx = reactions[m.id]||[]; const counts={}; rx.forEach(r=>counts[r.emoji]=(counts[r.emoji]||0)+1); const mineReact=rx.find(r=>r.reactor_email.toLowerCase()===meEmail); return {showDay &&
{thisDay}
}
{!mine &&
{m.sender_name||'Coach'}{m.sender_role&&m.sender_role!=='student'?' · '+m.sender_role:''}
} {editing ?