Support inbox drawer on the topbar, 1:1 with the notification drawer #45

Merged
archvillainette merged 5 commits from feat/support-inbox-header-icon into main 2026-07-24 21:46:03 +00:00
6 changed files with 173 additions and 77 deletions
Showing only changes of commit 4acf32ec09 - Show all commits
+4 -1
View File
@@ -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"
}
+91 -58
View File
@@ -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);
});
}
+1 -6
View File
@@ -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 {
+50 -7
View File
@@ -1,9 +1,52 @@
<a class="nav-link dropdown-toggle d-flex gap-2 align-items-center" href="#" role="button" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false" aria-label="Support inbox">
<i class="fa fa-fw fa-life-ring" aria-hidden="true"></i>
<span component="support/count" class="badge rounded-1 bg-primary {{{ if !support.unreadCount }}}hidden{{{ end }}}">{support.unreadCount}</span>
<a data-bs-toggle="dropdown" data-bs-auto-close="outside" href="#" role="button" class="nav-link dropdown-toggle d-flex align-items-center justify-content-center" aria-haspopup="true" aria-expanded="false" aria-label="[[westgate:support-inbox.title]]">
<span class="position-relative">
<i component="support/icon" class="fa fa-fw fa-life-ring" aria-hidden="true"></i>
<span component="support/count" class="position-absolute top-0 start-100 translate-middle badge rounded-1 bg-primary {{{ if !support.unreadCount }}}hidden{{{ end }}}">{support.unreadCount}</span>
</span>
</a>
<ul class="dropdown-menu wg-topbar__dropdown support-dropdown p-1 shadow" role="menu" data-wg-support-menu>
<li data-wg-support-loading class="dropdown-item disabled"><i class="fa fa-fw fa-spinner fa-spin" aria-hidden="true"></i> Support inbox</li>
<li data-wg-support-empty class="hidden"><span class="dropdown-item disabled">Nothing new</span></li>
<li data-wg-support-footer><hr class="dropdown-divider"><a class="dropdown-item wg-support-item--all" href="{relative_path}/support">Go to Support</a></li>
<ul class="notifications-dropdown dropdown-menu wg-topbar__dropdown p-1 shadow" role="menu">
{{{ if !config.hideReadNotifications }}}
<li class="d-flex gap-1 align-items-center">
<button type="button" class="btn btn-ghost btn-sm ff-secondary active px-3" data-filter="all">[[notifications:all]]</button>
<button type="button" class="btn btn-ghost btn-sm ff-secondary d-flex align-items-center gap-2" data-filter="unread">[[unread:title]] <span component="support/count" class="{{{ if !support.unreadCount }}}hidden{{{ end }}}">{support.unreadCount}</span></button>
</li>
<li class="dropdown-divider"></li>
{{{ end }}}
<li>
<div component="support/list" class="list-container notification-list overscroll-behavior-contain pe-1 ff-base ghost-scrollbar">
<div class="mb-2 p-1">
<div class="d-flex gap-1 justify-content-between">
<div class="d-flex gap-2 flex-grow-1 placeholder-wave">
<div class="placeholder" style="width: 32px; height: 32px;"></div>
<div class="flex-grow-1">
<div class="d-flex flex-column">
<div class="text-sm">
<span class="placeholder placeholder-sm col-4"></span>
<span class="placeholder placeholder-sm col-6"></span>
<span class="placeholder placeholder-sm col-7"></span>
<span class="placeholder placeholder-sm col-2"></span>
<span class="placeholder placeholder-sm col-5"></span>
</div>
<div class="text-xs">
<div class="placeholder placeholder-xs col-6"></div>
</div>
</div>
</div>
</div>
<div>
<button class="mark-read btn btn-ghost btn-sm d-flex align-items-center justify-content-center flex-grow-0 flex-shrink-0 p-1" style="width: 1.5rem; height: 1.5rem;">
<i class="unread fa fa-2xs fa-circle text-primary"></i>
</button>
</div>
</div>
</div>
</div>
</li>
<li class="dropdown-divider"></li>
<li>
<div class="d-flex justify-content-center gap-1 flex-wrap">
<a role="button" href="#" class="btn btn-sm btn-light mark-all-read flex-fill text-nowrap text-truncate ff-secondary"><i class="fa fa-check-double" aria-hidden="true"></i> [[notifications:mark-all-read]]</a>
<a class="btn btn-sm btn-primary flex-fill text-nowrap text-truncate ff-secondary" href="{relative_path}/support"><i class="fa fa-life-ring" aria-hidden="true"></i> [[westgate:support-inbox.view-all]]</a>
</div>
</li>
</ul>
+2 -2
View File
@@ -46,7 +46,7 @@
</li>
{{{ if support.visible }}}
<li component="support" class="nav-item support dropdown" title="Support inbox">
<li component="support" class="nav-item support dropdown" title="[[westgate:support-inbox.title]]">
<!-- IMPORT partials/header/support-drawer.tpl -->
</li>
{{{ end }}}
@@ -122,7 +122,7 @@
<a href="{relative_path}/unread"><i class="fa fa-fw fa-inbox" aria-hidden="true"></i><span>[[unread:title]]</span><span component="unread/count" class="badge rounded-1 bg-primary {{{ if !unreadCount.topic }}}hidden{{{ end }}}">{unreadCount.topic}</span></a>
<a href="{relative_path}/notifications"><i class="fa fa-fw fa-bell" aria-hidden="true"></i><span>[[global:header.notifications]]</span><span component="notifications/count" class="badge rounded-1 bg-primary {{{ if !unreadCount.notification }}}hidden{{{ end }}}">{unreadCount.notification}</span></a>
{{{ if support.visible }}}
<a href="{relative_path}/support"><i class="fa fa-fw fa-life-ring" aria-hidden="true"></i><span>Support inbox</span><span component="support/count" class="badge rounded-1 bg-primary {{{ if !support.unreadCount }}}hidden{{{ end }}}">{support.unreadCount}</span></a>
<a href="{relative_path}/support"><i class="fa fa-fw fa-life-ring" aria-hidden="true"></i><span>[[westgate:support-inbox.title]]</span><span component="support/count" class="badge rounded-1 bg-primary {{{ if !support.unreadCount }}}hidden{{{ end }}}">{support.unreadCount}</span></a>
{{{ end }}}
{{{ if canChat }}}
<a href="{relative_path}/user/{user.userslug}/chats{{{ if user.lastRoomId }}}/{user.lastRoomId}{{{ end }}}"><i class="fa fa-fw fa-comment" aria-hidden="true"></i><span>[[global:header.chats]]</span><span component="chat/count" class="badge rounded-1 bg-primary {{{ if !unreadCount.chat }}}hidden{{{ end }}}">{unreadCount.chat}</span></a>
+25 -3
View File
@@ -203,9 +203,31 @@ assertIncludes(
);
assertIncludes(
supportDrawer,
'data-wg-support-menu',
'component="support/list"',
'Support dropdown menu should expose the JS mount hook'
);
assertIncludes(
supportDrawer,
'notifications-dropdown',
'Support drawer should reuse Harmony\'s notification dropdown styling, not a bespoke class'
);
assertIncludes(
supportDrawer,
'notification-list',
'Support inbox list should reuse Harmony\'s own notification-list styling, not a bespoke class'
);
['data-filter="unread"', 'mark-all-read', 'mark-read'].forEach((marker) => {
assertIncludes(
supportDrawer,
marker,
`Support drawer should offer the same controls as the notification drawer (${marker})`
);
});
assertIncludes(
supportDrawer,
'[[westgate:support-inbox.title]]',
'Support inbox strings should be translation keys, matching every other topbar item'
);
assertMatches(
topbar,
/href="\{relative_path\}\/support"[^>]*>[\s\S]*?component="support\/count"/,
@@ -213,6 +235,6 @@ assertMatches(
);
assertIncludes(
stylesheet,
'.support-dropdown',
'Topbar styles should cover the support inbox dropdown'
'.wg-topbar .notifications-dropdown',
'Topbar styles should cover the notification-style dropdowns the support inbox reuses'
);