nodebb-plugin-admin-chats 3.5.0 → 3.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -7
- package/lib/access.js +89 -0
- package/lib/history.js +144 -0
- package/lib/hooks.js +310 -0
- package/lib/lock.js +145 -0
- package/lib/overrides.js +186 -0
- package/lib/routes.js +432 -0
- package/library.js +5 -1501
- package/package.json +1 -1
- package/plugin.json +6 -6
- package/static/lib/live-updates.js +42 -0
- package/static/lib/lock-toggle.js +57 -0
- package/static/lib/profile-link.js +98 -0
- package/static/lib/switch-chat.js +234 -0
- package/templates/partials/chats/message-window.tpl +60 -0
- package/templates/partials/chats/message.tpl +78 -0
- package/templates/partials/chats/options.tpl +111 -0
- package/templates/partials/chats/recent_room.tpl +60 -0
- package/.github/workflows/npm-publish.yml +0 -62
- package/.vscode/settings.json +0 -3
- package/client.js +0 -917
package/package.json
CHANGED
package/plugin.json
CHANGED
|
@@ -4,12 +4,16 @@
|
|
|
4
4
|
"description": "A plugin that allows the administrator to view and manage user chats",
|
|
5
5
|
"url": "https://github.com/palmoni5/nodebb-plugin-admin-chats",
|
|
6
6
|
"library": "./library.js",
|
|
7
|
+
"templates": "templates",
|
|
7
8
|
"scripts": [
|
|
8
|
-
"
|
|
9
|
+
"static/lib/switch-chat.js",
|
|
10
|
+
"static/lib/lock-toggle.js",
|
|
11
|
+
"static/lib/live-updates.js",
|
|
12
|
+
"static/lib/profile-link.js"
|
|
9
13
|
],
|
|
10
14
|
"hooks": [
|
|
11
15
|
{
|
|
12
|
-
"hook": "filter:user.
|
|
16
|
+
"hook": "filter:user.profileMenu",
|
|
13
17
|
"method": "addProfileLink"
|
|
14
18
|
},
|
|
15
19
|
{
|
|
@@ -60,10 +64,6 @@
|
|
|
60
64
|
"hook": "filter:chat.renameRoom",
|
|
61
65
|
"method": "filterRenameRoom"
|
|
62
66
|
},
|
|
63
|
-
{
|
|
64
|
-
"hook": "filter:middleware.render",
|
|
65
|
-
"method": "addTemplateData"
|
|
66
|
-
},
|
|
67
67
|
{
|
|
68
68
|
"hook": "filter:notifications.create",
|
|
69
69
|
"method": "filterNotificationCreate"
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Pushes room-lock changes to anyone with that room open, instead of the
|
|
4
|
+
// previous approach of polling every open chat window on a timer. The
|
|
5
|
+
// server emits event:admin-chats.roomLockChanged to chat_room_<roomId> from
|
|
6
|
+
// lib/routes.js's lock endpoint (mirrors core's own room broadcast pattern
|
|
7
|
+
// in src/messaging/notifications.js).
|
|
8
|
+
$(document).ready(function () {
|
|
9
|
+
function refreshIfOpen(roomId) {
|
|
10
|
+
const activeRoomId = parseInt($('[component="chat/main-wrapper"]').attr('data-roomid'), 10) || 0;
|
|
11
|
+
if (activeRoomId !== parseInt(roomId, 10)) {
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
require(['forum/chats'], function (Chats) {
|
|
16
|
+
if (Chats && Chats.switchChat) {
|
|
17
|
+
Chats.switchChat(roomId);
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (window.socket) {
|
|
23
|
+
socket.removeListener('event:admin-chats.roomLockChanged', refreshIfOpen);
|
|
24
|
+
socket.on('event:admin-chats.roomLockChanged', function (data) {
|
|
25
|
+
refreshIfOpen(data && data.roomId);
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Core toggles the `deleted` class on a message when it's deleted/restored,
|
|
30
|
+
// but doesn't itself swap which of the delete/restore actions is visible.
|
|
31
|
+
$(document).on('click', '[data-action="delete"], [data-action="restore"]', function () {
|
|
32
|
+
const $message = $(this).closest('[component="chat/message"]');
|
|
33
|
+
if (!$message.length) {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
setTimeout(function () {
|
|
37
|
+
const isDeleted = $message.hasClass('deleted');
|
|
38
|
+
$message.find('[data-action="delete"]').toggleClass('hidden', isDeleted).toggle(!isDeleted);
|
|
39
|
+
$message.find('[data-action="restore"]').toggleClass('hidden', !isDeleted).toggle(isDeleted);
|
|
40
|
+
}, 50);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// The lock/unlock menu item itself is rendered server-side
|
|
4
|
+
// (templates/partials/chats/options.tpl, gated on isAdminOrGlobalMod) — this
|
|
5
|
+
// only wires the click. The resulting UI state (composer, per-message
|
|
6
|
+
// actions, banner) all comes from the server re-render below, not from
|
|
7
|
+
// client-side DOM patching.
|
|
8
|
+
$(document).ready(function () {
|
|
9
|
+
async function toggleRoomLock(roomId, nextState) {
|
|
10
|
+
const response = await fetch(`${config.relative_path || ''}/api/admin-chats/${roomId}/lock`, {
|
|
11
|
+
method: 'POST',
|
|
12
|
+
headers: {
|
|
13
|
+
'Content-Type': 'application/json',
|
|
14
|
+
'x-csrf-token': config.csrf_token,
|
|
15
|
+
},
|
|
16
|
+
body: JSON.stringify({ locked: nextState }),
|
|
17
|
+
credentials: 'same-origin',
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
if (!response.ok) {
|
|
21
|
+
throw new Error('Unable to update room lock');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return await response.json();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
$(document).on('click', '.admin-chat-lock-toggle-item', async function (ev) {
|
|
28
|
+
ev.preventDefault();
|
|
29
|
+
|
|
30
|
+
const $button = $(this);
|
|
31
|
+
const roomId = parseInt($button.attr('data-room-id'), 10);
|
|
32
|
+
const isLocked = $button.attr('data-locked') === 'true';
|
|
33
|
+
|
|
34
|
+
if (!roomId) {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
$button.addClass('disabled').attr('aria-disabled', 'true');
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
await toggleRoomLock(roomId, !isLocked);
|
|
42
|
+
// The lock/unlock menu item, composer visibility and per-message
|
|
43
|
+
// action buttons are all rendered server-side from the fresh
|
|
44
|
+
// room data, so re-switch to this room to pick it up. Other
|
|
45
|
+
// clients with the room open get the same refresh via the
|
|
46
|
+
// event:admin-chats.roomLockChanged broadcast (static/lib/live-updates.js).
|
|
47
|
+
require(['forum/chats'], function (Chats) {
|
|
48
|
+
if (Chats && Chats.switchChat) {
|
|
49
|
+
Chats.switchChat(roomId);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
} catch (err) {
|
|
53
|
+
app.alertError('[[admin-chats:errors.update]]');
|
|
54
|
+
$button.removeClass('disabled').removeAttr('aria-disabled');
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// The "View Chats" profile link has no server-side extension point: the
|
|
4
|
+
// account-menu dropdown (.account-sub-links) is a theme template
|
|
5
|
+
// (nodebb-theme-harmony: templates/partials/account/admin-menu.tpl) with a
|
|
6
|
+
// hardcoded item list and no hook firing over it, and it's only rendered at
|
|
7
|
+
// all when the viewer is a full admin or has ban/mute rights — not for a
|
|
8
|
+
// user who only holds the admin-chats privilege. So this has to be a
|
|
9
|
+
// client-side injection, unlike the chat UI (which does have real template
|
|
10
|
+
// override points — see templates/partials/chats/*.tpl).
|
|
11
|
+
$(document).ready(function () {
|
|
12
|
+
function isAdminChatsAccessible() {
|
|
13
|
+
return !!(ajaxify && ajaxify.data && ajaxify.data.adminChatsAccess);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async function resolveAdminChatsAccess() {
|
|
17
|
+
if (isAdminChatsAccessible()) {
|
|
18
|
+
return true;
|
|
19
|
+
}
|
|
20
|
+
try {
|
|
21
|
+
const response = await fetch(`${config.relative_path || ''}/api/admin-chats?start=0`, {
|
|
22
|
+
headers: { 'x-csrf-token': config.csrf_token },
|
|
23
|
+
credentials: 'same-origin',
|
|
24
|
+
});
|
|
25
|
+
return response.ok;
|
|
26
|
+
} catch (err) {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function injectProfileChatsLink() {
|
|
32
|
+
const isUserProfile = $('.account').length && window.location.pathname.includes('/user/');
|
|
33
|
+
if (!isUserProfile || !await resolveAdminChatsAccess()) {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const userSlug = ajaxify && ajaxify.data && (ajaxify.data.userslug || (ajaxify.data.user && ajaxify.data.user.userslug));
|
|
38
|
+
if (!userSlug) {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const relativePath = config.relative_path || '';
|
|
43
|
+
// Injected via jQuery rather than a template, so [[...]] needs an
|
|
44
|
+
// explicit translator call here — nothing else will resolve it.
|
|
45
|
+
const label = await new Promise((resolve) => {
|
|
46
|
+
require(['translator'], function (translator) {
|
|
47
|
+
translator.translate('[[admin-chats:profile.viewChats]]', resolve);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
const menuItemHtml = `
|
|
51
|
+
<li role="presentation" class="admin-chats-profile-link">
|
|
52
|
+
<a class="dropdown-item rounded-1 d-flex align-items-center gap-2" href="${relativePath}/user/${userSlug}/chats" role="menuitem">
|
|
53
|
+
<i class="far fa-fw fa-comments"></i>
|
|
54
|
+
<span>${label}</span>
|
|
55
|
+
</a>
|
|
56
|
+
</li>
|
|
57
|
+
`;
|
|
58
|
+
|
|
59
|
+
let menu = $('.account-sub-links');
|
|
60
|
+
if (!menu.length) {
|
|
61
|
+
// No admin/ban/mute menu was rendered for this viewer (they only
|
|
62
|
+
// hold the admin-chats privilege) — build a minimal dropdown of
|
|
63
|
+
// our own next to the other profile action buttons.
|
|
64
|
+
const container = $('.account .flex-shrink-0.d-flex.gap-1').first();
|
|
65
|
+
const fallbackContainer = container.length ? container : $('.account .flex-shrink-0').first();
|
|
66
|
+
|
|
67
|
+
if (fallbackContainer.length) {
|
|
68
|
+
fallbackContainer.append(`
|
|
69
|
+
<div class="btn-group bottom-sheet admin-chats-privileges-menu">
|
|
70
|
+
<button type="button" class="btn btn-light dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
|
71
|
+
<i class="fa fa-gear fa-fw"></i>
|
|
72
|
+
</button>
|
|
73
|
+
<ul class="dropdown-menu dropdown-menu-end p-1 text-sm account-sub-links" role="menu"></ul>
|
|
74
|
+
</div>
|
|
75
|
+
`);
|
|
76
|
+
menu = fallbackContainer.find('.account-sub-links').last();
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (!menu.length) {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
menu.find('.admin-chats-profile-link, .admin-chats-profile-divider').remove();
|
|
85
|
+
menu.prepend(menuItemHtml);
|
|
86
|
+
|
|
87
|
+
if (menu.find('li').not('.admin-chats-profile-link').length > 0) {
|
|
88
|
+
menu.find('.admin-chats-profile-link').after('<li role="presentation" class="dropdown-divider admin-chats-profile-divider"></li>');
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
$(window).on('action:ajaxify.end', function (ev, data) {
|
|
93
|
+
const templateName = ajaxify && ajaxify.data && ajaxify.data.template ? ajaxify.data.template.name : '';
|
|
94
|
+
if (templateName.startsWith('account/')) {
|
|
95
|
+
injectProfileChatsLink();
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Admin-all-chats room routing. Core has no hook for intercepting
|
|
4
|
+
// Chats.switchChat, and the admin page uses a different URL scheme
|
|
5
|
+
// (/chats/:roomId vs /user/:slug/chats/:roomId) than a regular user's chat
|
|
6
|
+
// page, so wrapping the whole function is still the only way to route room
|
|
7
|
+
// switches through the admin API endpoints instead of core's.
|
|
8
|
+
$(document).ready(function () {
|
|
9
|
+
function isAdminAllChatsPage() {
|
|
10
|
+
return !!(ajaxify && ajaxify.data && ajaxify.data.adminAllChats);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function getAdminChatsPageUrl(roomId) {
|
|
14
|
+
return roomId ? `chats/${roomId}` : 'chats';
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function getAdminChatsDataUrl(roomId) {
|
|
18
|
+
const params = new URL(document.location).searchParams;
|
|
19
|
+
params.set('switch', 1);
|
|
20
|
+
const query = params.toString();
|
|
21
|
+
return `${config.relative_path || ''}/api/admin-chats/page${roomId ? `/${roomId}` : ''}${query ? `?${query}` : ''}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function loadMoreAdminChats() {
|
|
25
|
+
const recentChats = $('[component="chat/recent"]');
|
|
26
|
+
if (!isAdminAllChatsPage() || !recentChats.length || recentChats.attr('loading')) {
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
recentChats.attr('loading', 1);
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
const start = parseInt(recentChats.attr('data-nextstart'), 10) || 0;
|
|
34
|
+
const response = await fetch(`${config.relative_path || ''}/api/admin-chats?start=${start}`, {
|
|
35
|
+
headers: { 'x-csrf-token': config.csrf_token },
|
|
36
|
+
credentials: 'same-origin',
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
if (!response.ok) {
|
|
40
|
+
throw new Error('Unable to load chats');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const payload = await response.json();
|
|
44
|
+
if (payload.rooms && payload.rooms.length) {
|
|
45
|
+
payload.loadingMore = true;
|
|
46
|
+
const html = await app.parseAndTranslate('chats', 'rooms', payload);
|
|
47
|
+
recentChats.append(html);
|
|
48
|
+
html.find('.timeago').timeago();
|
|
49
|
+
recentChats.attr('data-nextstart', payload.nextStart);
|
|
50
|
+
}
|
|
51
|
+
} catch (err) {
|
|
52
|
+
app.alertError(err);
|
|
53
|
+
} finally {
|
|
54
|
+
recentChats.removeAttr('loading');
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function bindAdminRecentChatsInfiniteScroll() {
|
|
59
|
+
if (!isAdminAllChatsPage()) {
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
$('[component="chat/recent"]').off('scroll').on('scroll', utils.debounce(function () {
|
|
64
|
+
const $this = $(this);
|
|
65
|
+
const bottom = ($this[0].scrollHeight - $this.height()) * 0.9;
|
|
66
|
+
if ($this.scrollTop() > bottom) {
|
|
67
|
+
loadMoreAdminChats();
|
|
68
|
+
}
|
|
69
|
+
}, 100));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Keep the mobile layout state (body.chat-loaded / nav data-loaded) in sync with
|
|
73
|
+
// the room that is actually rendered, without re-fetching it
|
|
74
|
+
function syncChatLoadedState() {
|
|
75
|
+
const activeChatId = parseInt($('[component="chat/main-wrapper"]').attr('data-roomid'), 10) || 0;
|
|
76
|
+
$('body').toggleClass('chat-loaded', !!activeChatId);
|
|
77
|
+
$('[component="chat/nav-wrapper"]').attr('data-loaded', activeChatId ? '1' : '0');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function openRoomIfUrlDiffers(delay) {
|
|
81
|
+
const currentRoomId = ajaxify && ajaxify.data && ajaxify.data.roomId;
|
|
82
|
+
const activeChatId = $('[component="chat/main-wrapper"]').attr('data-roomid') || '';
|
|
83
|
+
if (currentRoomId && String(currentRoomId) !== String(activeChatId)) {
|
|
84
|
+
setTimeout(function () {
|
|
85
|
+
require(['forum/chats'], function (Chats) {
|
|
86
|
+
if (Chats && Chats.switchChat) {
|
|
87
|
+
Chats.switchChat(currentRoomId);
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
}, delay);
|
|
91
|
+
} else {
|
|
92
|
+
syncChatLoadedState();
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function patchForumChatsForAdminAll() {
|
|
97
|
+
if (!isAdminAllChatsPage()) {
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
require(['forum/chats', 'forum/chats/messages'], function (Chats, ChatsMessages) {
|
|
102
|
+
if (!Chats || Chats._adminAllChatsPatched) {
|
|
103
|
+
if (Chats) {
|
|
104
|
+
openRoomIfUrlDiffers(100);
|
|
105
|
+
bindAdminRecentChatsInfiniteScroll();
|
|
106
|
+
}
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const originalSwitchChat = Chats.switchChat;
|
|
111
|
+
|
|
112
|
+
Chats.switchChat = function (roomId) {
|
|
113
|
+
if (!isAdminAllChatsPage()) {
|
|
114
|
+
return originalSwitchChat.call(this, roomId);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
roomId = roomId || '';
|
|
118
|
+
// NodeBB 4.14.9+ (56c6115a61): destroyAutoComplete takes the input element, not a roomId
|
|
119
|
+
Chats.destroyAutoComplete($('[component="chat/main-wrapper"]').find('[component="chat/input"]'));
|
|
120
|
+
if (ajaxify.data.roomId) {
|
|
121
|
+
socket.emit('modules.chats.leave', ajaxify.data.roomId);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const url = getAdminChatsPageUrl(roomId);
|
|
125
|
+
|
|
126
|
+
async function renderAdminChatWindow(payload) {
|
|
127
|
+
const html = await app.parseAndTranslate('partials/chats/message-window', payload);
|
|
128
|
+
const mainWrapper = $('[component="chat/main-wrapper"]');
|
|
129
|
+
mainWrapper.html(html);
|
|
130
|
+
mainWrapper.attr('data-roomid', roomId);
|
|
131
|
+
html.find('.timeago').timeago();
|
|
132
|
+
ajaxify.data = { ...ajaxify.data, ...payload, roomId: roomId };
|
|
133
|
+
ajaxify.updateTitle(ajaxify.data.title);
|
|
134
|
+
$('body').toggleClass('chat-loaded', !!roomId);
|
|
135
|
+
$('[component="chat/nav-wrapper"]').attr('data-loaded', roomId ? '1' : '0');
|
|
136
|
+
mainWrapper.find('[data-bs-toggle="tooltip"]').tooltip({ trigger: 'hover', container: '#content' });
|
|
137
|
+
Chats.setActive(roomId);
|
|
138
|
+
Chats.addEventListeners();
|
|
139
|
+
// Fire through the hooks module like core does — a raw jQuery
|
|
140
|
+
// trigger never reaches hooks.on() listeners (e.g. the reactions
|
|
141
|
+
// plugin), only legacy $(window).on() ones (e.g. the emoji plugin).
|
|
142
|
+
require(['hooks'], function (hooks) {
|
|
143
|
+
hooks.fire('action:chat.loaded', $('.chats-full'));
|
|
144
|
+
});
|
|
145
|
+
if (roomId) {
|
|
146
|
+
ChatsMessages.scrollToBottomAfterImageLoad(mainWrapper.find('[component="chat/message/content"]'));
|
|
147
|
+
// Read-state is cleared server-side when the room payload is
|
|
148
|
+
// built; just keep the list UI in sync
|
|
149
|
+
$('[component="chat/nav-wrapper"]').find(`[data-roomid="${roomId}"]`).removeClass('unread');
|
|
150
|
+
}
|
|
151
|
+
if (history.pushState) {
|
|
152
|
+
const fullUrl = `${window.location.protocol}//${window.location.host}${config.relative_path || ''}/${url}`;
|
|
153
|
+
// When the address bar already shows the target (e.g. closing a chat
|
|
154
|
+
// after a back-gesture), replace instead of stacking a duplicate entry
|
|
155
|
+
const sameUrl = window.location.href.split('?')[0] === fullUrl.split('?')[0];
|
|
156
|
+
history[sameUrl ? 'replaceState' : 'pushState']({ url: url }, null, fullUrl);
|
|
157
|
+
}
|
|
158
|
+
bindAdminRecentChatsInfiniteScroll();
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (!roomId) {
|
|
162
|
+
// Closing a chat needs no server round-trip — render the empty
|
|
163
|
+
// window from the data we already have (instant back-gesture)
|
|
164
|
+
renderAdminChatWindow({ ...ajaxify.data, roomId: 0, title: '[[pages:chats]]' });
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
fetch(getAdminChatsDataUrl(roomId), { credentials: 'include' })
|
|
169
|
+
.then(async function (response) {
|
|
170
|
+
if (!response.ok) {
|
|
171
|
+
throw new Error(`Received ${response.status}`);
|
|
172
|
+
}
|
|
173
|
+
await renderAdminChatWindow(await response.json());
|
|
174
|
+
})
|
|
175
|
+
.catch(function () {
|
|
176
|
+
// Never leave the user stuck on the list — fall back to a
|
|
177
|
+
// regular ajaxify navigation to the room
|
|
178
|
+
ajaxify.go(url);
|
|
179
|
+
});
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
Chats._adminAllChatsPatched = true;
|
|
183
|
+
|
|
184
|
+
openRoomIfUrlDiffers(100);
|
|
185
|
+
bindAdminRecentChatsInfiniteScroll();
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Handle browser back/forward navigation. The URL is the source of truth
|
|
190
|
+
// here — ajaxify.data.roomId is stale after popstate.
|
|
191
|
+
function handleChatNavigation() {
|
|
192
|
+
if (!isAdminAllChatsPage()) {
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const urlMatch = window.location.pathname.match(/\/chats\/(\d+)/);
|
|
197
|
+
const urlRoomId = urlMatch ? parseInt(urlMatch[1], 10) : 0;
|
|
198
|
+
const activeChatId = parseInt($('[component="chat/main-wrapper"]').attr('data-roomid'), 10) || 0;
|
|
199
|
+
|
|
200
|
+
if (urlRoomId === activeChatId) {
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
require(['forum/chats'], function (Chats) {
|
|
205
|
+
if (Chats && Chats.switchChat) {
|
|
206
|
+
// urlRoomId 0 -> URL is the chat list: close the open chat and show the list
|
|
207
|
+
Chats.switchChat(urlRoomId || '');
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
$(window).on('action:ajaxify.end', function (ev, data) {
|
|
213
|
+
if (isAdminAllChatsPage()) {
|
|
214
|
+
patchForumChatsForAdminAll();
|
|
215
|
+
|
|
216
|
+
const url = data && data.url ? data.url : '';
|
|
217
|
+
if (url.match(/^chats\/\d+/)) {
|
|
218
|
+
setTimeout(handleChatNavigation, 300);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
$(window).on('action:chat.loaded', function () {
|
|
224
|
+
if (isAdminAllChatsPage()) {
|
|
225
|
+
patchForumChatsForAdminAll();
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
$(window).on('popstate', function () {
|
|
230
|
+
if (isAdminAllChatsPage()) {
|
|
231
|
+
setTimeout(handleChatNavigation, 100);
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
<div component="chat/message/window" class="d-flex flex-column h-100">
|
|
2
|
+
{{{ if widgets.header.length }}}
|
|
3
|
+
<div data-widget-area="header">
|
|
4
|
+
{{{each widgets.header}}}
|
|
5
|
+
{{widgets.header.html}}
|
|
6
|
+
{{{end}}}
|
|
7
|
+
</div>
|
|
8
|
+
{{{ end }}}
|
|
9
|
+
{{{ if !roomId }}}
|
|
10
|
+
<div class="d-flex flex-column align-items-center gap-3 my-auto">
|
|
11
|
+
<i class="fa-solid fa-wind fs-2 text-muted"></i>
|
|
12
|
+
<span class="text-muted text-sm">{{{ if adminAllChats }}}{{tx("admin-chats:empty.selectChat")}}{{{ else }}}{{tx("modules:chat.no-active")}}{{{ end }}}</span>
|
|
13
|
+
</div>
|
|
14
|
+
{{{ else }}}
|
|
15
|
+
<div component="chat/header" class="d-flex align-items-center px-lg-3 gap-3">
|
|
16
|
+
<a href="#" data-action="close" role="button" class="flex-shrink-0 d-flex d-lg-none btn btn-ghost border align-text-top"><i class="fa fa-chevron-left"></i></a>
|
|
17
|
+
<h5 component="chat/header/title" class="members flex-grow-1 fw-semibold tracking-tight mb-0 text-truncate text-nowrap" style="line-height: initial;">
|
|
18
|
+
{{{ if ./roomName }}}<i class="fa {icon} text-muted"></i> {roomName}{{{ else }}}{{tx(./chatWithMessage) }}{{{ end}}}
|
|
19
|
+
</h5>
|
|
20
|
+
|
|
21
|
+
<!-- IMPORT partials/chats/options.tpl -->
|
|
22
|
+
</div>
|
|
23
|
+
<!-- IMPORT partials/chats/scroll-up-alert.tpl -->
|
|
24
|
+
<hr class="my-1"/>
|
|
25
|
+
<div class="d-flex flex-grow-1 gap-1 overflow-auto" style="min-width: 0px;">
|
|
26
|
+
<div component="chat/messages" class="expanded-chat d-flex flex-column flex-grow-1" data-roomid="{roomId}" style="min-width: 0px;">
|
|
27
|
+
<ul component="chat/message/content" data-roomid="{roomId}" class="chat-content p-0 m-0 list-unstyled overflow-auto flex-grow-1 ghost-scrollbar invisible">
|
|
28
|
+
<!-- IMPORT partials/chats/messages.tpl -->
|
|
29
|
+
</ul>
|
|
30
|
+
<script>
|
|
31
|
+
(function () {
|
|
32
|
+
const el = document.querySelector('[component="chat/main-wrapper"] [component="chat/message/content"]');
|
|
33
|
+
if (el) {
|
|
34
|
+
requestAnimationFrame(() => {
|
|
35
|
+
el.scrollTop = el.scrollHeight;
|
|
36
|
+
el.classList.remove('invisible');
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
})();
|
|
40
|
+
</script>
|
|
41
|
+
<ul component="chat/message/search/results" class="chat-content p-0 m-0 list-unstyled overflow-auto flex-grow-1 hidden">
|
|
42
|
+
<div component="chat/message/search/no-results" class="text-center p-4 d-flex flex-column">
|
|
43
|
+
<div class="p-4"><i class="fa-solid fa-wind fs-2 text-muted"></i></div>
|
|
44
|
+
<div class="text-xs fw-semibold text-muted">{{tx("search:no-matches")}}</div>
|
|
45
|
+
</div>
|
|
46
|
+
</ul>
|
|
47
|
+
{{{ if showUserInput }}}
|
|
48
|
+
<!-- IMPORT partials/chats/composer.tpl -->
|
|
49
|
+
{{{ else }}}
|
|
50
|
+
{{{ if adminChatLockedForViewer }}}
|
|
51
|
+
<div class="admin-chat-lock-banner alert alert-warning mb-2 text-center">{{tx("admin-chats:lock.banner")}}</div>
|
|
52
|
+
{{{ end }}}
|
|
53
|
+
{{{ end }}}
|
|
54
|
+
</div>
|
|
55
|
+
|
|
56
|
+
<!-- IMPORT partials/chats/user-list.tpl -->
|
|
57
|
+
<!-- IMPORT partials/chats/pinned-messages.tpl -->
|
|
58
|
+
</div>
|
|
59
|
+
{{{ end }}}
|
|
60
|
+
</div>
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
<li component="chat/message" class="chat-message mx-2 pe-2 {{{ if messages.deleted }}} deleted{{{ end }}} {{{ if messages.pinned}}} pinned{{{ end }}} {{{ if messages.newSet }}}border-top pt-3{{{ end }}}" data-mid="{messages.messageId}" data-uid="{messages.fromuid}" data-index="{messages.index}" data-self="{messages.self}" data-break="{messages.newSet}" data-timestamp="{messages.timestamp}" data-username="{messages.fromUser.username}" data-displayname="{{txDisplayname(messages.fromUser)}}">
|
|
2
|
+
|
|
3
|
+
{{{ if messages.parent }}}
|
|
4
|
+
<!-- IMPORT partials/chats/parent.tpl -->
|
|
5
|
+
{{{ end }}}
|
|
6
|
+
|
|
7
|
+
<div class="message-header lh-1 d-flex align-items-center gap-2 text-sm {{{ if !messages.newSet }}}hidden{{{ end }}} pb-2">
|
|
8
|
+
<a href="{config.relative_path}/user/{messages.fromUser.userslug}" class="text-decoration-none">{{buildAvatar(messages.fromUser, "18px", true, "not-responsive")}}</a>
|
|
9
|
+
<span class="chat-user fw-semibold"><a href="{config.relative_path}/user/{messages.fromUser.userslug}">{{txDisplayname(messages.fromUser)}}</a></span>
|
|
10
|
+
{{{ if messages.fromUser.banned }}}
|
|
11
|
+
<span class="badge bg-danger">{{tx("user:banned")}}</span>
|
|
12
|
+
{{{ end }}}
|
|
13
|
+
{{{ if messages.fromUser.deleted }}}
|
|
14
|
+
<span class="badge bg-danger">{{tx("user:deleted")}}</span>
|
|
15
|
+
{{{ end }}}
|
|
16
|
+
<span class="chat-timestamp text-muted timeago" title="{messages.timestampISO}"></span>
|
|
17
|
+
|
|
18
|
+
<div component="chat/message/edited" class="text-muted ms-auto {{{ if !messages.edited }}}hidden{{{ end }}}" title="{{tx("global:edited-timestamp", isoTimeToLocaleString(messages.editedISO, config.userLang))}}"><i class="fa fa-edit"></i></span></div>
|
|
19
|
+
</div>
|
|
20
|
+
<div class="message-body-wrapper">
|
|
21
|
+
<div component="chat/message/body" class="message-body ps-0 py-0 text-break mb-last-0 mb-3">
|
|
22
|
+
{{{ if messages.txContent }}}{{tx(messages.content)}}{{{ else }}}{{txEscape(messages.content)}}{{{ end }}}
|
|
23
|
+
</div>
|
|
24
|
+
<!-- IMPORT partials/chats/reactions.tpl -->
|
|
25
|
+
<div component="chat/message/controls" class="position-relative">
|
|
26
|
+
<div class="btn-group border shadow-sm controls position-absolute bg-body end-0" style="bottom:1rem;">
|
|
27
|
+
<!-- IMPORT partials/chats/add-reaction.tpl -->
|
|
28
|
+
{{{ if !messages.adminChatLocked }}}
|
|
29
|
+
<button class="btn btn-sm btn-link" data-action="reply" title="{{tx("topic:reply")}}"><i class="fa fa-reply"></i></button>
|
|
30
|
+
{{{ end }}}
|
|
31
|
+
|
|
32
|
+
<div class="btn-group d-inline-block">
|
|
33
|
+
<button class="btn btn-sm btn-link dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false"><i class="fa fa-ellipsis" type="button"></i></button>
|
|
34
|
+
<ul class="dropdown-menu dropdown-menu-end p-1 text-sm list-unstyled" role="menu">
|
|
35
|
+
{{{ if (isAdminOrGlobalMod || (!config.disableChatMessageEditing && (messages.self && !messages.adminChatLocked))) }}}
|
|
36
|
+
<li>
|
|
37
|
+
<a href="#" class="dropdown-item rounded-1" data-action="edit" role="menuitem"><span class="d-inline-flex align-items-center gap-2"><i class="fa fa-fw fa-pencil text-muted"></i> {{tx("topic:edit")}}</span></a>
|
|
38
|
+
</li>
|
|
39
|
+
<li>
|
|
40
|
+
<a href="#" class="dropdown-item rounded-1" data-action="delete" role="menuitem"><span class="d-inline-flex align-items-center gap-2"><i class="fa fa-fw fa-trash text-muted"></i> {{tx("topic:delete")}}</span></a>
|
|
41
|
+
</li>
|
|
42
|
+
<li>
|
|
43
|
+
<a href="#" class="dropdown-item rounded-1" data-action="restore" role="menuitem"><span class="d-inline-flex align-items-center gap-2"><i class="fa fa-fw fa-repeat text-muted"></i> {{tx("topic:restore")}}</span></a>
|
|
44
|
+
</li>
|
|
45
|
+
{{{ end }}}
|
|
46
|
+
|
|
47
|
+
{{{ if (isAdminOrGlobalMod || isOwner )}}}
|
|
48
|
+
<li>
|
|
49
|
+
<a href="#" class="dropdown-item rounded-1" data-action="pin" role="menuitem"><span class="d-inline-flex align-items-center gap-2"><i class="fa fa-fw fa-thumbtack text-muted"></i> {{tx("modules:chat.pin-message")}}</span></a>
|
|
50
|
+
</li>
|
|
51
|
+
<li>
|
|
52
|
+
<a href="#" class="dropdown-item rounded-1" data-action="unpin" role="menuitem"><span class="d-inline-flex align-items-center gap-2"><i class="fa fa-fw fa-thumbtack fa-rotate-90 text-muted"></i> {{tx("modules:chat.unpin-message")}}</span></a>
|
|
53
|
+
</li>
|
|
54
|
+
<li class="dropdown-divider"></li>
|
|
55
|
+
{{{ end }}}
|
|
56
|
+
|
|
57
|
+
{{{ if isAdminOrGlobalMod }}}
|
|
58
|
+
<li>
|
|
59
|
+
<a href="#" class="dropdown-item rounded-1 chat-ip-button" role="menuitem">
|
|
60
|
+
<span class="d-inline-flex align-items-center gap-2 show"><i class="fa fa-fw fa-info-circle text-muted"></i> {{tx("modules:chat.show-ip")}}</span>
|
|
61
|
+
<span class="d-inline-flex align-items-center gap-2 copy hidden"><i class="fa fa-fw fa-copy text-muted"></i> <span class="copy-ip-text"></span></span>
|
|
62
|
+
</a>
|
|
63
|
+
</li>
|
|
64
|
+
{{{ end }}}
|
|
65
|
+
|
|
66
|
+
<li>
|
|
67
|
+
<a href="#" class="dropdown-item rounded-1" data-action="copy-text" data-mid="{messages.mid}" role="menuitem"><span class="d-inline-flex align-items-center gap-2"><i class="fa fa-fw fa-copy text-muted"></i> {{tx("modules:chat.copy-text")}}</span></a>
|
|
68
|
+
</li>
|
|
69
|
+
|
|
70
|
+
<li>
|
|
71
|
+
<a href="#" class="dropdown-item rounded-1" data-action="copy-link" data-mid="{messages.mid}" role="menuitem"><span class="d-inline-flex align-items-center gap-2"><i class="fa fa-fw fa-link text-muted"></i> {{tx("modules:chat.copy-link")}}</span></a>
|
|
72
|
+
</li>
|
|
73
|
+
</ul>
|
|
74
|
+
</div>
|
|
75
|
+
</div>
|
|
76
|
+
</div>
|
|
77
|
+
</div>
|
|
78
|
+
</li>
|