1 <?php 2 /** 3 * Live Chat β conversation view with real-time polling 4 */ 5 require_once __DIR__ . '/../includes/bootstrap.php'; 6 must_login(); 7 8 $withUser = get('with'); 9 $other = null; 10 11 if ($withUser) { 12 $other = DB::row('SELECT id,username,avatar,role,karma,last_seen FROM users WHERE username=?', [$withUser]); 13 if (!$other) render_404(); 14 if ((int)$other['id'] === (int)$USER['id']) go('messages/chat.php'); 15 } 16 17 $PAGE_TITLE = $other ? 'Chat with @' . $other['username'] : 'Messages'; 18 include __DIR__ . '/../views/partials/layout.php'; 19 ?> 20 <div class="chat-shell"> 21 22 <!-- Sidebar: conversation list --> 23 <aside class="chat-aside" id="chatAside"> 24 <div class="chat-aside-head"> 25 <h2>π¬ Messages</h2> 26 <a href="<?= u('messages/chat.php') ?>" class="chat-new-btn" title="New conversation"> 27 <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 28 <path d="M12 5v14M5 12h14"/> 29 </svg> 30 </a> 31 </div> 32 <div class="chat-aside-search"> 33 <input type="text" id="convSearch" class="chat-search-inp" placeholder="Search peopleβ¦" autocomplete="off"> 34 <div id="convSearchResults" class="chat-search-results"></div> 35 </div> 36 <div class="chat-conv-list" id="convList"> 37 <div class="chat-loading">Loadingβ¦</div> 38 </div> 39 </aside> 40 41 <!-- Main chat area --> 42 <div class="chat-main" id="chatMain"> 43 <?php if ($other): ?> 44 <!-- Chat header --> 45 <div class="chat-head"> 46 <div class="chat-head-left"> 47 <button class="chat-back-btn" onclick="history.back()">β</button> 48 <?php if ($other['avatar']): ?> 49 <img src="<?= e($other['avatar']) ?>" class="av-md" alt=""> 50 <?php else: ?> 51 <span class="av-md av-init"><?= strtoupper($other['username'][0]) ?></span> 52 <?php endif; ?> 53 <div> 54 <a href="<?= u('users/profile.php?u=' . urlencode($other['username'])) ?>" class="chat-head-name"> 55 @<?= e($other['username']) ?> 56 </a> 57 <div class="chat-head-status" id="chatStatus"> 58 <span class="status-dot" id="statusDot"></span> 59 <span id="statusTxt">Loadingβ¦</span> 60 </div> 61 </div> 62 </div> 63 <div class="chat-head-right"> 64 <a href="<?= u('users/profile.php?u=' . urlencode($other['username'])) ?>" class="btn-ghost btn-sm">View Profile</a> 65 </div> 66 </div> 67 68 <!-- Messages area --> 69 <div class="chat-messages" id="chatMessages"> 70 <div class="chat-loading-msgs"> 71 <div class="chat-spinner"></div> 72 Loading messagesβ¦ 73 </div> 74 </div> 75 76 <!-- Typing + input --> 77 <div class="chat-input-area"> 78 <div class="chat-input-wrap"> 79 <textarea id="chatInput" class="chat-input" placeholder="Write a messageβ¦ (Enter to send, Shift+Enter for newline)" 80 rows="1" maxlength="2000"></textarea> 81 <button id="chatSendBtn" class="chat-send-btn" title="Send"> 82 <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"> 83 <path d="M22 2L11 13M22 2L15 22l-4-9-9-4 20-7z"/> 84 </svg> 85 </button> 86 </div> 87 <div class="chat-input-meta"> 88 <span id="chatCharCount" class="chat-char">0 / 2000</span> 89 <span class="chat-hint">Enter β΅ to send Β· Shift+Enter for new line</span> 90 </div> 91 </div> 92 93 <?php else: ?> 94 <!-- No conversation selected --> 95 <div class="chat-empty-state"> 96 <div class="chat-empty-icon">π¬</div> 97 <h2>Your Messages</h2> 98 <p>Select a conversation or start a new one</p> 99 <div class="chat-new-search"> 100 <input type="text" id="newChatSearch" class="fi" placeholder="Search for a user to messageβ¦" autocomplete="off"> 101 <div id="newChatResults" class="chat-search-results chat-search-results-lg"></div> 102 </div> 103 </div> 104 <?php endif; ?> 105 </div> 106 107 </div> 108 109 <script> 110 (function() { 111 var OTHER_ID = <?= $other ? (int)$other['id'] : 'null' ?>; 112 var OTHER_NAME = <?= $other ? json_encode($other['username']) : 'null' ?>; 113 var MY_ID = NX.user ? NX.user.id : null; 114 var lastMsgId = 0; 115 var pollTimer = null; 116 var sending = false; 117 118 /* ββ Helpers βββββββββββββββββββββββββββββββββββββββββββ */ 119 function esc(s) { 120 return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>') 121 .replace(/"/g,'"').replace(/'/g,'''); 122 } 123 124 function timeStr(ts) { 125 if (!ts) return ''; 126 var d = new Date(ts.replace(' ','T') + 'Z'); 127 var now = new Date(); 128 var diff = (now - d) / 1000; 129 if (diff < 60) return 'just now'; 130 if (diff < 3600) return Math.floor(diff/60) + 'm ago'; 131 if (diff < 86400) return Math.floor(diff/3600) + 'h ago'; 132 return d.toLocaleDateString(); 133 } 134 135 function avatarHtml(name, av, size) { 136 size = size || 'av-sm'; 137 if (av) return '<img src="'+esc(av)+'" class="'+size+'" alt="">'; 138 return '<span class="'+size+' av-init">'+esc(name[0].toUpperCase())+'</span>'; 139 } 140 141 /* ββ Render a single message bubble ββββββββββββββββββ */ 142 function renderBubble(m) { 143 var mine = (parseInt(m.sender_id) === MY_ID); 144 var ts = timeStr(m.created_at); 145 return '<div class="chat-bubble-row '+(mine?'mine':'theirs')+'" data-id="'+m.id+'">' 146 + (mine ? '' : '<div class="chat-av">'+avatarHtml(m.sender_name, m.sender_av)+'</div>') 147 + '<div class="chat-bubble-wrap">' 148 + '<div class="chat-bubble">' 149 + '<div class="chat-bubble-body">'+esc(m.body).replace(/\n/g,'<br>')+'</div>' 150 + '<div class="chat-bubble-time">'+ts+'</div>' 151 + '</div>' 152 + (mine ? '<button class="chat-del-btn" onclick="deleteMsg('+m.id+',this)" title="Delete">β</button>' : '') 153 + '</div>' 154 + (mine ? '<div class="chat-av">'+avatarHtml(m.sender_name, m.sender_av)+'</div>' : '') 155 + '</div>'; 156 } 157 158 /* ββ Load conversation βββββββββββββββββββββββββββββββ */ 159 function loadConversation() { 160 if (!OTHER_ID) return; 161 var fd = new FormData(); 162 fd.append('action','load'); fd.append('other_id',OTHER_ID); fd.append('csrf',NX.csrf); 163 fetch(NX.base+'/api/chat.php', {method:'POST', body:fd}) 164 .then(function(r){return r.json();}) 165 .then(function(d){ 166 if (!d.ok) return; 167 var box = document.getElementById('chatMessages'); 168 if (d.messages.length === 0) { 169 box.innerHTML = '<div class="chat-no-msgs">No messages yet. Say hello! π</div>'; 170 } else { 171 box.innerHTML = d.messages.map(renderBubble).join(''); 172 lastMsgId = d.messages[d.messages.length-1].id; 173 scrollToBottom(true); 174 } 175 updateStatus(d.other); 176 startPolling(); 177 }); 178 } 179 180 /* ββ Update online status βββββββββββββββββββββββββββ */ 181 function updateStatus(other) { 182 if (!other) return; 183 var dot = document.getElementById('statusDot'); 184 var txt = document.getElementById('statusTxt'); 185 if (!dot || !txt) return; 186 var ls = new Date((other.last_seen||'').replace(' ','T')+'Z'); 187 var diff = (Date.now() - ls) / 1000; 188 if (diff < 300) { 189 dot.className = 'status-dot online'; 190 txt.textContent = 'Online'; 191 } else { 192 dot.className = 'status-dot offline'; 193 txt.textContent = 'Last seen ' + timeStr(other.last_seen); 194 } 195 } 196 197 /* ββ Scroll to bottom βββββββββββββββββββββββββββββββ */ 198 function scrollToBottom(force) { 199 var box = document.getElementById('chatMessages'); 200 if (!box) return; 201 var atBottom = box.scrollHeight - box.scrollTop - box.clientHeight < 80; 202 if (force || atBottom) box.scrollTop = box.scrollHeight; 203 } 204 205 /* ββ Poll for new messages ββββββββββββββββββββββββββ */ 206 function poll() { 207 if (!OTHER_ID) return; 208 var fd = new FormData(); 209 fd.append('action','poll'); fd.append('other_id',OTHER_ID); 210 fd.append('since_id',lastMsgId); fd.append('csrf',NX.csrf); 211 fetch(NX.base+'/api/chat.php', {method:'POST', body:fd}) 212 .then(function(r){return r.json();}) 213 .then(function(d){ 214 if (!d.ok || !d.messages.length) return; 215 var box = document.getElementById('chatMessages'); 216 var noMsg = box.querySelector('.chat-no-msgs'); 217 if (noMsg) noMsg.remove(); 218 d.messages.forEach(function(m){ 219 if (!box.querySelector('[data-id="'+m.id+'"]')) { 220 box.insertAdjacentHTML('beforeend', renderBubble(m)); 221 lastMsgId = Math.max(lastMsgId, m.id); 222 } 223 }); 224 scrollToBottom(); 225 }); 226 } 227 228 function startPolling() { 229 clearInterval(pollTimer); 230 pollTimer = setInterval(poll, 2500); 231 } 232 233 /* ββ Send message ββββββββββββββββββββββββββββββββββ */ 234 function sendMessage() { 235 if (sending) return; 236 var inp = document.getElementById('chatInput'); 237 var body = inp.value.trim(); 238 if (!body || !OTHER_ID) return; 239 sending = true; 240 var btn = document.getElementById('chatSendBtn'); 241 btn.disabled = true; 242 inp.disabled = true; 243 244 var fd = new FormData(); 245 fd.append('action','send'); fd.append('to_id',OTHER_ID); 246 fd.append('body',body); fd.append('csrf',NX.csrf); 247 fetch(NX.base+'/api/chat.php', {method:'POST', body:fd}) 248 .then(function(r){return r.json();}) 249 .then(function(d){ 250 sending = false; btn.disabled = false; inp.disabled = false; inp.focus(); 251 if (d.ok && d.message) { 252 var box = document.getElementById('chatMessages'); 253 var noMsg = box.querySelector('.chat-no-msgs'); 254 if (noMsg) noMsg.remove(); 255 box.insertAdjacentHTML('beforeend', renderBubble(d.message)); 256 lastMsgId = Math.max(lastMsgId, d.message.id); 257 scrollToBottom(true); 258 inp.value = ''; autoResize(inp); 259 updateCharCount(); 260 } else { 261 toast(d.error || 'Failed to send', 'err'); 262 } 263 }) 264 .catch(function(){ sending=false; btn.disabled=false; inp.disabled=false; toast('Network error','err'); }); 265 } 266 267 /* ββ Delete message ββββββββββββββββββββββββββββββββ */ 268 window.deleteMsg = function(id, btn) { 269 if (!confirm('Delete this message?')) return; 270 var fd = new FormData(); 271 fd.append('action','delete'); fd.append('msg_id',id); fd.append('csrf',NX.csrf); 272 fetch(NX.base+'/api/chat.php', {method:'POST', body:fd}) 273 .then(function(r){return r.json();}) 274 .then(function(d){ 275 if (d.ok) btn.closest('.chat-bubble-row').remove(); 276 else toast(d.error,'err'); 277 }); 278 }; 279 280 /* ββ Input auto-resize βββββββββββββββββββββββββββββ */ 281 function autoResize(ta) { 282 ta.style.height = 'auto'; 283 ta.style.height = Math.min(ta.scrollHeight, 160) + 'px'; 284 } 285 function updateCharCount() { 286 var inp = document.getElementById('chatInput'); 287 var cnt = document.getElementById('chatCharCount'); 288 if (inp && cnt) { 289 var n = inp.value.length; 290 cnt.textContent = n + ' / 2000'; 291 cnt.style.color = n > 1800 ? '#ef4444' : ''; 292 } 293 } 294 295 /* ββ Load conversation list ββββββββββββββββββββββββ */ 296 function loadConvList() { 297 var fd = new FormData(); 298 fd.append('action','conversations'); fd.append('csrf',NX.csrf); 299 fetch(NX.base+'/api/chat.php', {method:'POST', body:fd}) 300 .then(function(r){return r.json();}) 301 .then(function(d){ 302 var list = document.getElementById('convList'); 303 if (!list) return; 304 if (!d.ok || !d.conversations.length) { 305 list.innerHTML = '<div class="chat-empty-conv">No conversations yet</div>'; 306 return; 307 } 308 list.innerHTML = d.conversations.map(function(c){ 309 var isOther = parseInt(c.sender_id) === MY_ID ? false : true; 310 var name = c.other_name; 311 var av = c.other_av; 312 var active = OTHER_NAME && OTHER_NAME === name ? ' active' : ''; 313 var unread = parseInt(c.unread_count) > 0; 314 return '<a href="'+NX.base+'/messages/chat.php?with='+encodeURIComponent(name)+'" class="conv-item'+active+'">' 315 + avatarHtml(name, av) 316 + '<div class="conv-body">' 317 + '<div class="conv-name">@'+esc(name)+(unread?'<span class="conv-unread">'+c.unread_count+'</span>':'')+'</div>' 318 + '<div class="conv-preview">'+(parseInt(c.sender_id)===MY_ID?'You: ':'')+esc(c.body.substring(0,50))+(c.body.length>50?'β¦':'')+'</div>' 319 + '</div>' 320 + '<div class="conv-time">'+timeStr(c.created_at)+'</div>' 321 + '</a>'; 322 }).join(''); 323 }); 324 } 325 326 /* ββ Conversation search (sidebar) ββββββββββββββββ */ 327 function setupConvSearch() { 328 var inp = document.getElementById('convSearch'); 329 if (!inp) return; 330 var res = document.getElementById('convSearchResults'); 331 var timer; 332 inp.addEventListener('input', function(){ 333 clearTimeout(timer); 334 var q = this.value.trim(); 335 if (q.length < 1) { res.style.display='none'; return; } 336 timer = setTimeout(function(){ 337 fetch(NX.base+'/api/search_users.php?q='+encodeURIComponent(q)) 338 .then(function(r){return r.json();}) 339 .then(function(rows){ 340 if (!rows.length) { res.style.display='none'; return; } 341 res.innerHTML = rows.map(function(u){ 342 return '<a class="chat-sug-item" href="'+NX.base+'/messages/chat.php?with='+encodeURIComponent(u.username)+'">' 343 + avatarHtml(u.username, u.avatar) 344 + '<span>@'+esc(u.username)+'</span></a>'; 345 }).join(''); 346 res.style.display='block'; 347 }); 348 }, 200); 349 }); 350 document.addEventListener('click', function(e){ 351 if (!inp.contains(e.target)) res.style.display='none'; 352 }); 353 } 354 355 /* ββ New chat search (empty state) ββββββββββββββββ */ 356 function setupNewChatSearch() { 357 var inp = document.getElementById('newChatSearch'); 358 if (!inp) return; 359 var res = document.getElementById('newChatResults'); 360 var timer; 361 inp.addEventListener('input', function(){ 362 clearTimeout(timer); 363 var q = this.value.trim(); 364 if (q.length < 1) { res.style.display='none'; return; } 365 timer = setTimeout(function(){ 366 fetch(NX.base+'/api/search_users.php?q='+encodeURIComponent(q)) 367 .then(function(r){return r.json();}) 368 .then(function(rows){ 369 if (!rows.length) { res.style.display='none'; return; } 370 res.innerHTML = rows.map(function(u){ 371 return '<a class="chat-sug-item" href="'+NX.base+'/messages/chat.php?with='+encodeURIComponent(u.username)+'">' 372 + avatarHtml(u.username, u.avatar, 'av-md') 373 + '<div><div style="font-weight:600">@'+esc(u.username)+'</div></div></a>'; 374 }).join(''); 375 res.style.display='block'; 376 }); 377 }, 200); 378 }); 379 } 380 381 /* ββ Wire up input βββββββββββββββββββββββββββββββββ */ 382 var inp = document.getElementById('chatInput'); 383 if (inp) { 384 inp.addEventListener('keydown', function(e){ 385 if (e.key==='Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); } 386 }); 387 inp.addEventListener('input', function(){ autoResize(this); updateCharCount(); }); 388 document.getElementById('chatSendBtn').addEventListener('click', sendMessage); 389 } 390 391 /* ββ Init βββββββββββββββββββββββββββββββββββββββββββ */ 392 loadConvList(); 393 setupConvSearch(); 394 setupNewChatSearch(); 395 if (OTHER_ID) loadConversation(); 396 397 // Refresh conv list every 10s 398 setInterval(loadConvList, 10000); 399 400 })(); 401 </script> 402 <?php include __DIR__ . '/../views/partials/layout_end.php'; ?>