nodebb-plugin-admin-chats 3.5.0 → 3.6.0

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.
Files changed (44) hide show
  1. package/README.md +12 -7
  2. package/lib/access.js +89 -0
  3. package/lib/history.js +110 -0
  4. package/lib/hooks.js +310 -0
  5. package/lib/lock.js +145 -0
  6. package/lib/overrides.js +186 -0
  7. package/lib/routes.js +469 -0
  8. package/library.js +5 -1501
  9. package/package.json +1 -1
  10. package/plugin.json +7 -6
  11. package/public/languages/ar/admin-chats.json +11 -1
  12. package/public/languages/de/admin-chats.json +11 -1
  13. package/public/languages/de-CH/admin-chats.json +11 -1
  14. package/public/languages/el/admin-chats.json +11 -1
  15. package/public/languages/en/admin-chats.json +11 -1
  16. package/public/languages/en-GB/admin-chats.json +11 -1
  17. package/public/languages/es/admin-chats.json +11 -1
  18. package/public/languages/fr/admin-chats.json +11 -1
  19. package/public/languages/fr-CH/admin-chats.json +11 -1
  20. package/public/languages/he/admin-chats.json +11 -1
  21. package/public/languages/hi/admin-chats.json +11 -1
  22. package/public/languages/it/admin-chats.json +11 -1
  23. package/public/languages/it-CH/admin-chats.json +11 -1
  24. package/public/languages/ja/admin-chats.json +11 -1
  25. package/public/languages/nl/admin-chats.json +11 -1
  26. package/public/languages/pl/admin-chats.json +11 -1
  27. package/public/languages/pt-BR/admin-chats.json +11 -1
  28. package/public/languages/rm-CH/admin-chats.json +11 -1
  29. package/public/languages/ru/admin-chats.json +11 -1
  30. package/public/languages/uk/admin-chats.json +11 -1
  31. package/public/languages/zh-CN/admin-chats.json +11 -1
  32. package/public/languages/zh-TW/admin-chats.json +11 -1
  33. package/static/lib/live-updates.js +42 -0
  34. package/static/lib/lock-toggle.js +57 -0
  35. package/static/lib/message-history.js +221 -0
  36. package/static/lib/profile-link.js +98 -0
  37. package/static/lib/switch-chat.js +234 -0
  38. package/templates/partials/chats/message-window.tpl +60 -0
  39. package/templates/partials/chats/message.tpl +83 -0
  40. package/templates/partials/chats/options.tpl +111 -0
  41. package/templates/partials/chats/recent_room.tpl +60 -0
  42. package/.github/workflows/npm-publish.yml +0 -62
  43. package/.vscode/settings.json +0 -3
  44. package/client.js +0 -917
@@ -0,0 +1,221 @@
1
+ 'use strict';
2
+
3
+ // "Edit history" item in the chat message options dropdown (rendered
4
+ // server-side in templates/partials/chats/message.tpl, gated on manage
5
+ // privilege via isAdminOrGlobalMod). Opens a modal modelled on core's post
6
+ // diffs modal (public/src/client/topic/diffs.js): a revision selector, the
7
+ // selected revision's content, and a delete-revision button. The API routes
8
+ // it calls are manage-only as well, so the client gating is cosmetic.
9
+ $(document).ready(function () {
10
+ const localeStringOpts = { year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: 'numeric' };
11
+
12
+ function prettyDate(timestamp) {
13
+ const numeric = parseInt(timestamp, 10) || 0;
14
+ if (!numeric) {
15
+ return '—';
16
+ }
17
+ return new Date(numeric).toLocaleString((config.userLang || 'en-GB').replace('_', '-'), localeStringOpts);
18
+ }
19
+
20
+ async function requestHistory(mid, method, revisionIndex) {
21
+ const suffix = (method === 'DELETE' || method === 'PUT') ? `/${revisionIndex}` : '';
22
+ const response = await fetch(`${config.relative_path || ''}/api/admin-chats/history/${mid}${suffix}`, {
23
+ method: method || 'GET',
24
+ headers: { 'x-csrf-token': config.csrf_token },
25
+ credentials: 'same-origin',
26
+ });
27
+
28
+ const data = await response.json();
29
+ if (!response.ok) {
30
+ throw new Error((data && data.status && data.status.message) || 'Request failed');
31
+ }
32
+
33
+ return data;
34
+ }
35
+
36
+ // The selector lists the current version first, then prior revisions
37
+ // newest-first. Option values keep the revision's index in the stored
38
+ // (oldest-first) list so deletes address the right entry.
39
+ function buildEntries(history, labels) {
40
+ const currentEditor = (history.current.editor && history.current.editor.username) ||
41
+ (history.author && history.author.username) || '';
42
+ const entries = [{
43
+ value: 'current',
44
+ label: `${prettyDate(history.current.edited || history.current.timestamp)} [${currentEditor}] · ${labels.current}`,
45
+ content: history.current.content,
46
+ }];
47
+
48
+ history.revisions.forEach((revision, index) => {
49
+ entries.push({
50
+ value: String(index),
51
+ label: `${prettyDate(revision.replacedAt)} [${revision.editor.username}]`,
52
+ content: revision.content,
53
+ });
54
+ });
55
+
56
+ return [entries[0]].concat(entries.slice(1).reverse());
57
+ }
58
+
59
+ function renderState($modal, history, labels) {
60
+ const $select = $modal.find('select');
61
+ const $content = $modal.find('.admin-chat-history-content');
62
+ const $deleteBtn = $modal.find('[data-action="delete-revision"]');
63
+ const $restoreBtn = $modal.find('[data-action="restore-revision"]');
64
+ const $count = $modal.find('.admin-chat-history-count');
65
+
66
+ const entries = buildEntries(history, labels);
67
+ const contentByValue = new Map(entries.map(entry => [entry.value, entry.content]));
68
+
69
+ $select.empty();
70
+ entries.forEach((entry) => {
71
+ const option = document.createElement('option');
72
+ option.value = entry.value;
73
+ option.textContent = entry.label;
74
+ $select.append(option);
75
+ });
76
+
77
+ $count.text(labels.count.replace('%1', String(history.revisionCount)));
78
+
79
+ const showSelection = () => {
80
+ const value = $select.val();
81
+ $content.text(contentByValue.get(value) || '');
82
+ $deleteBtn.prop('disabled', value === 'current');
83
+ $restoreBtn.prop('disabled', value === 'current');
84
+ };
85
+
86
+ $select.off('change').on('change', showSelection);
87
+ showSelection();
88
+ }
89
+
90
+ async function openHistoryModal(mid, modals, alerts, translator) {
91
+ const history = await requestHistory(mid);
92
+
93
+ const labels = {
94
+ current: await translator.translate('[[admin-chats:history.current]]'),
95
+ count: await translator.translate('[[admin-chats:history.revisionCount, %1]]'),
96
+ };
97
+
98
+ // modals.dialog runs the message through the translator itself
99
+ const $modal = await modals.dialog({
100
+ title: '[[admin-chats:history.title]]',
101
+ message: '<p class="admin-chat-history-count text-muted"></p>' +
102
+ '<div class="mb-3">' +
103
+ '<select class="form-control"></select>' +
104
+ '<hr />' +
105
+ '<pre class="admin-chat-history-content text-break mb-3" style="white-space: pre-wrap;"></pre>' +
106
+ '<button class="btn btn-primary" data-action="restore-revision">[[admin-chats:history.restore]]</button> ' +
107
+ '<button class="btn btn-danger" data-action="delete-revision">[[admin-chats:history.deleteRevision]]</button>' +
108
+ '<p class="form-text">[[admin-chats:history.restoreDescription]]</p>' +
109
+ '</div>',
110
+ size: 'large',
111
+ onEscape: true,
112
+ backdrop: true,
113
+ });
114
+
115
+ renderState($modal, history, labels);
116
+
117
+ $modal.find('[data-action="restore-revision"]').on('click', async function () {
118
+ const value = $modal.find('select').val();
119
+ if (value === 'current') {
120
+ return;
121
+ }
122
+
123
+ try {
124
+ await requestHistory(mid, 'PUT', value);
125
+ $modal.modal('hide');
126
+ alerts.success('[[admin-chats:history.restored]]');
127
+ } catch (err) {
128
+ alerts.error('[[admin-chats:errors.history]]');
129
+ }
130
+ });
131
+
132
+ $modal.find('[data-action="delete-revision"]').on('click', function () {
133
+ const value = $modal.find('select').val();
134
+ if (value === 'current') {
135
+ return;
136
+ }
137
+
138
+ modals.confirm('[[admin-chats:history.confirmDelete]]', async (confirmed) => {
139
+ if (!confirmed) {
140
+ return;
141
+ }
142
+
143
+ try {
144
+ const fresh = await requestHistory(mid, 'DELETE', value);
145
+ renderState($modal, fresh, labels);
146
+ alerts.success('[[admin-chats:history.deleted]]');
147
+ } catch (err) {
148
+ alerts.error('[[admin-chats:errors.history]]');
149
+ }
150
+ });
151
+ });
152
+ }
153
+
154
+ // Messages rendered live on the client (incoming messages, and a message
155
+ // re-parsed right after an edit) go through core's parseMessage, which
156
+ // sets isAdminOrGlobalMod = app.user.isAdmin || app.user.isGlobalMod —
157
+ // ignoring the plugin's manage privilege that gates the server-rendered
158
+ // item. Re-add the item on dropdown open when the room payload grants
159
+ // management and the item is missing.
160
+ $(document).on('show.bs.dropdown', '[component="chat/message"] .btn-group', function () {
161
+ if (!ajaxify.data || !ajaxify.data.isAdminOrGlobalMod) {
162
+ return;
163
+ }
164
+
165
+ const $menu = $(this).find('.dropdown-menu');
166
+ if (!$menu.length || $menu.find('.admin-chat-history-item').length) {
167
+ return;
168
+ }
169
+
170
+ const $message = $(this).closest('[component="chat/message"]');
171
+ const mid = parseInt($message.attr('data-mid'), 10);
172
+ if (!mid) {
173
+ return;
174
+ }
175
+
176
+ // Same gate as the server-rendered item: only edited messages have a
177
+ // history to show. The edited indicator element exists on every
178
+ // message and carries `hidden` while the message is unedited.
179
+ const $edited = $message.find('[component="chat/message/edited"]');
180
+ if (!$edited.length || $edited.hasClass('hidden')) {
181
+ return;
182
+ }
183
+
184
+ require(['translator'], function (translator) {
185
+ translator.translate('[[admin-chats:history.title]]', function (title) {
186
+ if ($menu.find('.admin-chat-history-item').length) {
187
+ return;
188
+ }
189
+
190
+ const $item = $(
191
+ '<li><a href="#" class="dropdown-item rounded-1 admin-chat-history-item" role="menuitem">' +
192
+ '<span class="d-inline-flex align-items-center gap-2"><i class="fa fa-fw fa-history text-muted"></i> </span></a></li>'
193
+ );
194
+ $item.find('a').attr('data-mid', String(mid));
195
+ $item.find('span').append(document.createTextNode(title));
196
+
197
+ const $copyText = $menu.find('[data-action="copy-text"]').closest('li');
198
+ if ($copyText.length) {
199
+ $item.insertBefore($copyText);
200
+ } else {
201
+ $menu.append($item);
202
+ }
203
+ });
204
+ });
205
+ });
206
+
207
+ $(document).on('click', '.admin-chat-history-item', function (ev) {
208
+ ev.preventDefault();
209
+
210
+ const mid = parseInt($(this).attr('data-mid'), 10);
211
+ if (!mid) {
212
+ return;
213
+ }
214
+
215
+ require(['modals', 'alerts', 'translator'], function (modals, alerts, translator) {
216
+ openHistoryModal(mid, modals, alerts, translator).catch(() => {
217
+ alerts.error('[[admin-chats:errors.history]]');
218
+ });
219
+ });
220
+ });
221
+ });
@@ -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>