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 */
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  /* ── 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'; ?>