From 4acf32ec0920323a1a2d94944085ec69b888465c Mon Sep 17 00:00:00 2001 From: vickydotbat Date: Fri, 24 Jul 2026 22:20:04 +0200 Subject: [PATCH] Make the support drawer core's notification drawer (sow-nodebb-plugin-support#10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first version of this drawer built its own rows in JS. This one renders core's `partials/notifications_list` partial with entries the support plugin now returns in core's notification shape, so the markup is literally the notification drawer's: avatar block, stretched link, muted timeago, per-entry read dot, All/Unread filter row, and the same two footer buttons. The handlers mirror core's too — only the API paths differ, because the list is plugin-owned (core's unread count is one aggregate with no hook to carve a type out of it). Opening the box no longer marks everything read: like the bell, an entry is read when you click it or use "Mark all read", and the dot toggles both ways. Deletes the bespoke row builder and its hand-rolled relative-time helper; `$.timeago` is what core uses for JS-inserted rows. The dropdown reuses the `.notifications-dropdown` class this theme already styles, so it needs no CSS of its own. Hardcoded English is gone (the code-review finding on the first version): every string is a translation key. Co-Authored-By: Claude Opus 5 (1M context) --- languages/en-GB/westgate.json | 5 +- public/client.js | 149 +++++++++++-------- scss/westgate/_topbar.scss | 7 +- templates/partials/header/support-drawer.tpl | 57 ++++++- templates/partials/header/topbar.tpl | 4 +- tests/global-topbar-contract.test.js | 28 +++- 6 files changed, 173 insertions(+), 77 deletions(-) diff --git a/languages/en-GB/westgate.json b/languages/en-GB/westgate.json index a23dbe8..10ff7cf 100644 --- a/languages/en-GB/westgate.json +++ b/languages/en-GB/westgate.json @@ -20,5 +20,8 @@ "footer.how-to-join": "How to Join", "footer.copyright": "© %1 Shadows Over Westgate · A NWN:EE Persistent World", "footer.setting": "Forgotten Realms · Westgate · Sea of Fallen Stars", - "footer.powered-by": "Powered by" + "footer.powered-by": "Powered by", + "support-inbox.title": "Support inbox", + "support-inbox.empty": "Nothing new", + "support-inbox.view-all": "Go to Support" } diff --git a/public/client.js b/public/client.js index f7eed1e..329611a 100644 --- a/public/client.js +++ b/public/client.js @@ -208,56 +208,77 @@ }); } - function clearSupportBadge() { + /* + * Support inbox drawer. The support plugin owns its own notification list + * (core's unread count is one aggregate with no hook to carve a type out + * of it), but everything the user sees is core's: the rows are rendered + * with core's own `partials/notifications_list` partial and driven by the + * same handlers as the bell — filter buttons, per-entry read dot, mark all + * read. Only the API paths differ. + */ + + function setSupportBadge(count) { toArray(document.querySelectorAll('[component="support/count"]')).forEach((badge) => { - badge.textContent = '0'; - badge.classList.add('hidden'); + badge.textContent = count > 99 ? '99+' : String(count); + badge.classList.toggle('hidden', count <= 0); }); } - function renderSupportMenu(menuEl, entries) { - if (!menuEl) { - return; - } + function adjustSupportBadge(delta) { + const badge = document.querySelector('[component="support/count"]'); + const current = parseInt(badge && badge.textContent, 10) || 0; + setSupportBadge(Math.max(0, current + delta)); + } - const relativePath = (window.config && window.config.relative_path) || ''; - - // remove the loading row and any previously injected entries; the - // empty-state and footer/divider rows are server-rendered and stay put - const loading = menuEl.querySelector('[data-wg-support-loading]'); - if (loading) { - loading.remove(); - } - toArray(menuEl.querySelectorAll('.wg-support-item')).forEach((li) => { - li.remove(); + // Same shape as core's markNotification: PUT marks read, DELETE marks unread. + function markSupportEntry(entryEl, read) { + const nid = encodeURIComponent(entryEl.attr('data-nid')); + const path = '/plugins/support/notifications/' + nid + '/read'; + require(['api'], function (api) { + (read ? api.put(path) : api.del(path)).then(function () { + entryEl.toggleClass('unread', !read); + entryEl.find('.mark-read .unread').toggleClass('hidden', read); + entryEl.find('.mark-read .read').toggleClass('hidden', !read); + adjustSupportBadge(read ? -1 : 1); + }); }); + } - const emptyEl = menuEl.querySelector('[data-wg-support-empty]'); - const footerEl = menuEl.querySelector('[data-wg-support-footer]'); + function renderSupportMenu(triggerEl) { + const dropdownEl = triggerEl.parent().find('.dropdown-menu'); + const listEl = dropdownEl.find('[component="support/list"]'); + dropdownEl.find('[data-filter="all"]').addClass('active'); + dropdownEl.find('[data-filter="unread"]').removeClass('active'); - const list = (entries || []).slice(0, 10); - if (emptyEl) { - emptyEl.classList.toggle('hidden', list.length > 0); - } - - list.forEach((entry) => { - const link = document.createElement('a'); - link.className = 'dropdown-item wg-support-item text-truncate'; - link.href = relativePath + entry.path; - link.textContent = entry.bodyShort; - if (!entry.read) { - link.classList.add('wg-support-item--unread'); - } - - const li = document.createElement('li'); - li.className = 'wg-support-item'; - li.appendChild(link); - - if (footerEl) { - menuEl.insertBefore(li, footerEl); - } else { - menuEl.appendChild(li); - } + require(['api', 'translator'], function (api, translator) { + api.get('/plugins/support/notifications', {}).then(function (data) { + const notifications = (data && data.notifications) || []; + const relativePath = (window.config && window.config.relative_path) || ''; + notifications.forEach((notification) => { + notification.path = relativePath + notification.path; + notification.timeagoLong = $.timeago(new Date(notification.datetime)); + }); + app.parseAndTranslate('partials/notifications_list', { notifications }, function (html) { + listEl.html(html); + setSupportBadge((data && data.unreadCount) || 0); + // core's partial says "no notifications"; this box only ever holds + // support activity, so say that instead. + translator.translate('[[westgate:support-inbox.empty]]', function (text) { + listEl.find('.no-notifs .fw-semibold').text(text); + }); + listEl.off('click').on('click', '[component="notifications/item/link"]', function () { + const entryEl = $(this).parents('[data-nid]'); + if (entryEl.hasClass('unread')) { + markSupportEntry(entryEl, true); + } + triggerEl.dropdown('toggle'); + }); + listEl.on('click', '.mark-read', function () { + const entryEl = $(this).parents('[data-nid]'); + markSupportEntry(entryEl, entryEl.hasClass('unread')); + }); + }); + }); }); } @@ -347,27 +368,39 @@ }); }); + // ponytail: unlike core's bell, this badge gets no socket push, so it + // only catches up when the drawer is opened or the page reloads. Add a + // socket event in the support plugin if that gap ever matters. $(document) .off('show.bs.dropdown.westgateSupport') - .on('show.bs.dropdown.westgateSupport', '.wg-topbar [component="support"]', function () { - const menuEl = this.querySelector('[data-wg-support-menu]'); + .on('show.bs.dropdown.westgateSupport', '.wg-topbar [component="support"] [data-bs-toggle="dropdown"]', function () { + renderSupportMenu($(this)); + }); + + $(document) + .off('click.westgateSupport') + .on('click.westgateSupport', '.wg-topbar [component="support"] .mark-all-read', function (event) { + event.preventDefault(); + const entries = $(this).closest('.dropdown-menu').find('[data-nid]'); require(['api'], function (api) { - api.get('/plugins/support/notifications', {}, function (err, data) { - if (err) { - renderSupportMenu(menuEl, []); - return; - } - renderSupportMenu(menuEl, data && data.notifications); - // ponytail: unlike core's unread/notification badges, this one - // gets no live socket push, so it only catches up on the next - // full page load after the box is opened here. Add a socket - // event if that gap matters. - if (data && data.unreadCount) { - api.post('/plugins/support/notifications/read', {}, function () {}); - clearSupportBadge(); - } + api.post('/plugins/support/notifications/read', {}).then(function () { + setSupportBadge(0); + entries.removeClass('unread'); + entries.find('.mark-read .unread').addClass('hidden'); + entries.find('.mark-read .read').removeClass('hidden'); }); }); + }) + // Core's own filter behaviour: hide read rows client-side, nothing refetched. + .on('click.westgateSupport', '.wg-topbar [component="support"] [data-filter]', function () { + const dropdownEl = $(this).closest('.dropdown-menu'); + dropdownEl.find('[data-filter]').removeClass('active'); + this.classList.add('active'); + const unreadOnly = this.getAttribute('data-filter') === 'unread'; + dropdownEl.find('[data-nid]').each(function () { + this.classList.toggle('hidden', unreadOnly && !this.classList.contains('unread')); + }); + dropdownEl.find('.no-notifs').toggleClass('hidden', dropdownEl.find('[data-nid]:not(.hidden)').length !== 0); }); } diff --git a/scss/westgate/_topbar.scss b/scss/westgate/_topbar.scss index 0e6b461..ff33ead 100644 --- a/scss/westgate/_topbar.scss +++ b/scss/westgate/_topbar.scss @@ -173,8 +173,7 @@ $wg-topbar-panel-bg: rgba(21, 17, 26, 0.98); .wg-topbar .notifications-dropdown, .wg-topbar .chats-dropdown, .wg-topbar .drafts-dropdown, -.wg-topbar .unread-dropdown, -.wg-topbar .support-dropdown { +.wg-topbar .unread-dropdown { width: min(24rem, calc(100vw - 1.5rem)); } @@ -190,10 +189,6 @@ $wg-topbar-panel-bg: rgba(21, 17, 26, 0.98); opacity: 0.7; } -.wg-topbar .wg-support-item--unread { - font-weight: 600; -} - .wg-topbar .dropdown-item, .wg-topbar .btn.btn-ghost, .wg-topbar .btn.btn-light { diff --git a/templates/partials/header/support-drawer.tpl b/templates/partials/header/support-drawer.tpl index 02f0842..6597613 100644 --- a/templates/partials/header/support-drawer.tpl +++ b/templates/partials/header/support-drawer.tpl @@ -1,9 +1,52 @@ - -