Support inbox drawer on the topbar, 1:1 with the notification drawer #45
@@ -20,8 +20,5 @@
|
||||
"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",
|
||||
"support-inbox.title": "Support inbox",
|
||||
"support-inbox.empty": "Nothing new",
|
||||
"support-inbox.view-all": "Go to Support"
|
||||
"footer.powered-by": "Powered by"
|
||||
}
|
||||
|
||||
@@ -208,80 +208,6 @@
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
* 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 = count > 99 ? '99+' : String(count);
|
||||
badge.classList.toggle('hidden', count <= 0);
|
||||
});
|
||||
}
|
||||
|
||||
function adjustSupportBadge(delta) {
|
||||
const badge = document.querySelector('[component="support/count"]');
|
||||
const current = parseInt(badge && badge.textContent, 10) || 0;
|
||||
setSupportBadge(Math.max(0, current + delta));
|
||||
}
|
||||
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
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');
|
||||
|
||||
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'));
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function getWestgateTopbar() {
|
||||
if (!document || typeof document.querySelector !== 'function') {
|
||||
return null;
|
||||
@@ -367,48 +293,12 @@
|
||||
renderUnreadMenu(menuEl, []);
|
||||
});
|
||||
});
|
||||
|
||||
// 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"] [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.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);
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
<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="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>
|
||||
@@ -45,12 +45,6 @@
|
||||
<!-- IMPORT partials/sidebar/notifications.tpl -->
|
||||
</li>
|
||||
|
||||
{{{ if support.visible }}}
|
||||
<li component="support" class="nav-item support dropdown" title="[[westgate:support-inbox.title]]">
|
||||
<!-- IMPORT partials/header/support-drawer.tpl -->
|
||||
</li>
|
||||
{{{ end }}}
|
||||
|
||||
{{{ if canChat }}}
|
||||
<li class="nav-item chats dropdown" title="[[global:header.chats]]">
|
||||
<!-- IMPORT partials/sidebar/chats.tpl -->
|
||||
@@ -121,9 +115,6 @@
|
||||
<div class="wg-topbar__drawer-actions">
|
||||
<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>[[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>
|
||||
{{{ end }}}
|
||||
|
||||
@@ -181,60 +181,16 @@ assertIncludes(
|
||||
'Topbar styles should cover the unread dropdown'
|
||||
);
|
||||
|
||||
const supportDrawer = read('templates/partials/header/support-drawer.tpl');
|
||||
assertIncludes(
|
||||
topbar,
|
||||
'<!-- IMPORT partials/header/support-drawer.tpl -->',
|
||||
'Topbar should mount the support inbox drawer'
|
||||
);
|
||||
assertIncludes(
|
||||
topbar,
|
||||
'{{{ if support.visible }}}',
|
||||
'Support inbox drawer should be gated on the viewer having any support access'
|
||||
);
|
||||
// Support case notifications live in the notification bell, added by
|
||||
// nodebb-plugin-support on core's own hooks. This theme owns nothing about
|
||||
// them, which is the point — see that plugin's ADR-0002.
|
||||
assert(
|
||||
topbar.indexOf('partials/sidebar/notifications.tpl') < topbar.indexOf('partials/header/support-drawer.tpl'),
|
||||
'Support inbox should sit next to (after) the notifications bell'
|
||||
);
|
||||
assertIncludes(
|
||||
supportDrawer,
|
||||
'component="support/count"',
|
||||
'Support toggle should carry its own unread count badge'
|
||||
);
|
||||
assertIncludes(
|
||||
supportDrawer,
|
||||
'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"/,
|
||||
'Mobile drawer actions should include a Support inbox link with count badge'
|
||||
!/support/i.test(topbar),
|
||||
'Topbar should carry no support-specific markup'
|
||||
);
|
||||
|
||||
assertIncludes(
|
||||
stylesheet,
|
||||
'.wg-topbar .notifications-dropdown',
|
||||
'Topbar styles should cover the notification-style dropdowns the support inbox reuses'
|
||||
'Topbar styles should cover the notification dropdown'
|
||||
);
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const client = fs.readFileSync(path.join(__dirname, '..', 'public', 'client.js'), 'utf8');
|
||||
|
||||
assert(
|
||||
client.includes("'show.bs.dropdown.westgateSupport'"),
|
||||
'client.js should fetch the support inbox when its dropdown opens'
|
||||
);
|
||||
assert(
|
||||
client.includes("'/plugins/support/notifications'"),
|
||||
'client.js should load the support inbox from the plugin API via the api module (for CSRF-safe writes)'
|
||||
);
|
||||
assert(
|
||||
client.includes("'/plugins/support/notifications/read'"),
|
||||
'client.js should mark the support inbox read when it is opened with unread entries'
|
||||
);
|
||||
assert(
|
||||
client.includes('renderSupportMenu'),
|
||||
'client.js should expose the support inbox menu renderer'
|
||||
);
|
||||
assert(
|
||||
/textContent/.test(client) && !/innerHTML\s*=[^=]*bodyShort/.test(client),
|
||||
'Support inbox entry text must be inserted via textContent, never innerHTML'
|
||||
);
|
||||
Reference in New Issue
Block a user