Add a support inbox icon to the topbar (sow-nodebb-plugin-support#10)

Mirrors the existing unread-drawer pattern: a new dropdown next to the
notification bell, gated on templateData.support.visible (injected by
the support plugin's filter:middleware.renderHeader hook, on every page
render, for viewers with any support access at all). Opening it fetches
GET /plugins/support/notifications via the api module, renders up to
10 entries by textContent (never innerHTML), and marks everything read
via POST .../notifications/read, clearing the badge client-side.

Also present in the mobile burger drawer, same gating.

ponytail: no live socket push for this badge (core's own unread/notification
counts get one); it only catches up on the next full page load after the
box is opened. Noted in client.js as the upgrade path if that gap matters.

Verified live against sow-nodebb-plugin-support's dev NodeBB stack (theme +
plugin both mounted): icon hidden for a user with no support access, badge
count and dropdown contents correct for a reviewer, badge clears on open.
Full plain-node test suite passes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-24 21:29:57 +02:00
co-authored by Claude Sonnet 5
parent 0bdb59aaa0
commit ca2aefa156
6 changed files with 165 additions and 1 deletions
+77
View File
@@ -208,6 +208,59 @@
});
}
function clearSupportBadge() {
toArray(document.querySelectorAll('[component="support/count"]')).forEach((badge) => {
badge.textContent = '0';
badge.classList.add('hidden');
});
}
function renderSupportMenu(menuEl, entries) {
if (!menuEl) {
return;
}
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();
});
const emptyEl = menuEl.querySelector('[data-wg-support-empty]');
const footerEl = menuEl.querySelector('[data-wg-support-footer]');
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);
}
});
}
function getWestgateTopbar() {
if (!document || typeof document.querySelector !== 'function') {
return null;
@@ -293,12 +346,36 @@
renderUnreadMenu(menuEl, []);
});
});
$(document)
.off('show.bs.dropdown.westgateSupport')
.on('show.bs.dropdown.westgateSupport', '.wg-topbar [component="support"]', function () {
const menuEl = this.querySelector('[data-wg-support-menu]');
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();
}
});
});
});
}
window.westgateTheme = window.westgateTheme || {};
window.westgateTheme.wrapWikiTables = wrapWestgateWikiTables;
window.westgateTheme.initTopbar = initWestgateTopbar;
window.westgateTheme.renderUnreadMenu = renderUnreadMenu;
window.westgateTheme.renderSupportMenu = renderSupportMenu;
$(document).ready(function () {
wrapWestgateWikiTables(document);