xgit simple git

nexus

nexus

clone git clone https://kb.hax.al/nexus

messages/chat.php

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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
121                     .replace(/"/g,'&quot;').replace(/'/g,'&#39;');
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'; ?>