فهرست منبع

Show newest messages first and flag unread ones

Messages now render newest-first instead of oldest-first, and
incoming messages from polling mark their thread with an unread
badge/page-title count until the user actually clicks into it,
since the auto-scrolled box alone was easy to miss.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Bernn 1 ماه پیش
والد
کامیت
092a52cfae
3فایلهای تغییر یافته به همراه69 افزوده شده و 7 حذف شده
  1. 15 0
      assets/app.css
  2. 52 5
      assets/app.js
  3. 2 2
      lib/render.php

+ 15 - 0
assets/app.css

@@ -155,6 +155,21 @@ input.inline-edit {
     background: var(--c-accent);
     border-bottom: 1px solid #ddd;
     font-size: 1rem;
+    display: flex;
+    align-items: center;
+    gap: 0.5rem;
+}
+.thread-unread {
+    border-color: var(--c-warn);
+    box-shadow: 0 0 0 1px var(--c-warn);
+}
+.unread-badge {
+    background: var(--c-warn);
+    color: #000;
+    font-weight: 700;
+    font-size: 0.75rem;
+    border-radius: 999px;
+    padding: 0.1rem 0.5rem;
 }
 .thread-list {
     max-height: 280px;

+ 52 - 5
assets/app.js

@@ -194,6 +194,8 @@
 
   // -------- messages --------
 
+  const baseTitle = document.title;
+
   function reloadAllThreads() {
     return Promise.all($$('.thread').map(reloadThread));
   }
@@ -202,6 +204,7 @@
     const slug = thread.dataset.vendor;
     const list = thread.querySelector('.thread-list');
     const since = list.dataset.maxId || 0;
+    const isInitialLoad = (since === '0' || since === 0);
     return get('bin/messages_list.php', { vendor: slug, since })
       .then(r => r.text().then(html => ({
         html,
@@ -209,9 +212,14 @@
       })))
       .then(({ html, maxId }) => {
         if (html.trim()) {
-          if (since === '0' || since === 0) list.innerHTML = '';
-          list.insertAdjacentHTML('beforeend', html);
-          list.scrollTop = list.scrollHeight;
+          if (isInitialLoad) list.innerHTML = '';
+          const incomingClass = PDQ.audience === 'ICG' ? 'msg-vendor' : 'msg-icg';
+          const counter = document.createElement('div');
+          counter.innerHTML = html;
+          const incomingCount = counter.querySelectorAll('.' + incomingClass).length;
+          list.insertAdjacentHTML('afterbegin', html);
+          list.scrollTop = 0;
+          if (!isInitialLoad && incomingCount > 0) markUnread(thread, incomingCount);
         }
         list.dataset.maxId = maxId;
         const empty = list.querySelector('.msg-empty');
@@ -219,6 +227,44 @@
       });
   }
 
+  // Flag a thread as having unread messages: a badge on its header and a
+  // count in the page title, since the auto-scrolled message box alone is
+  // easy to miss if the user isn't already looking at it.
+  function markUnread(thread, count) {
+    const current = parseInt(thread.dataset.unread || '0', 10);
+    thread.dataset.unread = current + count;
+    thread.classList.add('thread-unread');
+    let badge = thread.querySelector('.unread-badge');
+    if (!badge) {
+      badge = document.createElement('span');
+      badge.className = 'unread-badge';
+      thread.querySelector('h2').appendChild(badge);
+    }
+    badge.textContent = thread.dataset.unread;
+    updateTitle();
+  }
+
+  function clearUnread(thread) {
+    if (!thread.dataset.unread || thread.dataset.unread === '0') return;
+    thread.dataset.unread = '0';
+    thread.classList.remove('thread-unread');
+    const badge = thread.querySelector('.unread-badge');
+    if (badge) badge.remove();
+    updateTitle();
+  }
+
+  function updateTitle() {
+    const total = $$('.thread').reduce((sum, t) => sum + parseInt(t.dataset.unread || '0', 10), 0);
+    document.title = total > 0 ? '(' + total + ') ' + baseTitle : baseTitle;
+  }
+
+  function wireUnreadClearing() {
+    $$('.thread').forEach(thread => {
+      thread.addEventListener('click', () => clearUnread(thread));
+      thread.addEventListener('focusin', () => clearUnread(thread));
+    });
+  }
+
   function wireCompose() {
     $$('.thread').forEach(thread => {
       const form = thread.querySelector('.thread-compose');
@@ -240,9 +286,9 @@
             const list = thread.querySelector('.thread-list');
             const empty = list.querySelector('.msg-empty');
             if (empty) empty.remove();
-            list.insertAdjacentHTML('beforeend', res.body);
+            list.insertAdjacentHTML('afterbegin', res.body);
             list.dataset.maxId = res.maxId || list.dataset.maxId;
-            list.scrollTop = list.scrollHeight;
+            list.scrollTop = 0;
           })
           .catch(err => {
             input.disabled = false;
@@ -403,6 +449,7 @@
   document.addEventListener('DOMContentLoaded', () => {
     wireAddJob();
     wireCompose();
+    wireUnreadClearing();
     reloadTable();
     reloadAllThreads();
     const interval = PDQ.pollMs || 180000;

+ 2 - 2
lib/render.php

@@ -171,10 +171,10 @@ function render_actions(array $r, string $audience): string {
 function render_messages(int $vendor_id, ?int $since_id = null): string {
     $pdo = db();
     if ($since_id !== null) {
-        $stmt = $pdo->prepare('SELECT * FROM messages WHERE vendor_id = ? AND id > ? ORDER BY id ASC');
+        $stmt = $pdo->prepare('SELECT * FROM messages WHERE vendor_id = ? AND id > ? ORDER BY id DESC');
         $stmt->execute([$vendor_id, $since_id]);
     } else {
-        $stmt = $pdo->prepare('SELECT * FROM messages WHERE vendor_id = ? ORDER BY id ASC');
+        $stmt = $pdo->prepare('SELECT * FROM messages WHERE vendor_id = ? ORDER BY id DESC');
         $stmt->execute([$vendor_id]);
     }
     $rows = $stmt->fetchAll();