nodebb-plugin-admin-chats 3.4.3 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-admin-chats",
3
- "version": "3.4.3",
3
+ "version": "3.5.1",
4
4
  "description": "A plugin that allows the administrator to view and manage user chats.",
5
5
  "main": "library.js",
6
6
  "nbbpm": {
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
- "client.js"
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.accountMenu",
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>