1<?php
2/**
3 * Live Chat β conversation view with real-time polling
4 */
5require_once __DIR__ . '/../includes/bootstrap.php';
6must_login();
7
8$withUser = get('with');
9$other = null;
10
11if ($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';
18include __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
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
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
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
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
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
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
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
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
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
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
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
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
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
392 loadConvList();
393 setupConvSearch();
394 setupNewChatSearch();
395 if (OTHER_ID) loadConversation();
396
397
398 setInterval(loadConvList, 10000);
399
400})();
401</script>
402<?php include __DIR__ . '/../views/partials/layout_end.php'; ?>