nodebb-plugin-admin-chats 3.8.2 → 3.8.3

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/lib/overrides.js CHANGED
@@ -16,6 +16,7 @@ const {
16
16
  const { isRoomLocked } = require('./lock');
17
17
  const { editHistoryKey } = require('./history');
18
18
  const privileges = nodebb.require('./src/privileges');
19
+ const helpers = nodebb.require('./src/controllers/helpers');
19
20
 
20
21
  // NodeBB translates `[[namespace:key]]` error messages itself (both in
21
22
  // app.alertError on the client and anywhere else the message surfaces) —
@@ -46,6 +47,45 @@ function overrideCoreChatRedirect(controllers, renderAdminChatsPage) {
46
47
  chatsController.redirectToChat = wrappedRedirectToChat;
47
48
  }
48
49
 
50
+ // /user/<slug>/chats/<roomId> renders the room as seen by <slug>: core loads
51
+ // its messages from that user's join timestamp, so for a private room the
52
+ // user is not in it comes out empty (title only). Send the viewer to a URL
53
+ // that can actually show the room — their own chats page if they are in it,
54
+ // otherwise the all-chats page.
55
+ function overrideCoreChatsPage(controllers) {
56
+ const chatsController = controllers && controllers.accounts && controllers.accounts.chats;
57
+ if (!chatsController || typeof chatsController.get !== 'function' || chatsController.get._adminChatsWrapped) {
58
+ return;
59
+ }
60
+
61
+ const originalGet = chatsController.get;
62
+ const wrappedGet = async function (req, res, next) {
63
+ const roomId = parseInt(req.params.roomid, 10);
64
+ const isSwitch = res.locals.isAPI && parseInt(req.query.switch, 10) === 1;
65
+ if (roomId && !isSwitch && req.uid && await canAccessAdminChats(req.uid)) {
66
+ const uid = await User.getUidByUserslug(req.params.userslug);
67
+ if (uid && parseInt(uid, 10) !== parseInt(req.uid, 10)) {
68
+ const [room, isOwnerInRoom, isCallerInRoom] = await Promise.all([
69
+ Messaging.getRoomData(roomId),
70
+ isUserMemberOfRoom(uid, roomId),
71
+ isUserMemberOfRoom(req.uid, roomId),
72
+ ]);
73
+ if (room && !room.public && !isOwnerInRoom) {
74
+ const index = parseInt(req.params.index, 10);
75
+ const suffix = `${roomId}${index ? `/${index}` : ''}`;
76
+ const callerSlug = isCallerInRoom && await User.getUserField(req.uid, 'userslug');
77
+ return helpers.redirect(res, callerSlug ? `/user/${callerSlug}/chats/${suffix}` : `/chats/${suffix}`);
78
+ }
79
+ }
80
+ }
81
+
82
+ return await originalGet.call(this, req, res, next);
83
+ };
84
+
85
+ wrappedGet._adminChatsWrapped = true;
86
+ chatsController.get = wrappedGet;
87
+ }
88
+
49
89
  function overrideChatsApi() {
50
90
  if (!ChatsAPI || typeof ChatsAPI.kick !== 'function' || ChatsAPI.kick._adminChatLockWrapped) {
51
91
  return;
@@ -181,6 +221,7 @@ function overrideMessagingFunctions() {
181
221
  module.exports = {
182
222
  getMessageRoomId,
183
223
  overrideCoreChatRedirect,
224
+ overrideCoreChatsPage,
184
225
  overrideChatsApi,
185
226
  overrideMessagingFunctions,
186
227
  };
package/lib/routes.js CHANGED
@@ -14,6 +14,7 @@ const { roomExists, lockRoom, unlockRoom, getRoomLockData } = require('./lock');
14
14
  const { ACTIVITY_SET, pruneActivityEntries } = require('./activity');
15
15
 
16
16
  const ADMIN_CHAT_PAGE_SIZE = 30;
17
+ const ADMIN_CHAT_SEARCH_LIMIT = 100;
17
18
 
18
19
  async function assertAdminChatsAccess(req, res) {
19
20
  if (!req.uid || !await canAccessAdminChats(req.uid)) {
@@ -202,6 +203,41 @@ async function enrichAdminRecentRooms(uid, roomIds, rooms) {
202
203
  });
203
204
  }
204
205
 
206
+ async function searchAdminChats(uid, query) {
207
+ const lowerQuery = String(query).toLowerCase();
208
+ const allRoomIds = await db.getSortedSetRevRange(ACTIVITY_SET, 0, -1);
209
+ const [allRooms, roomUids] = await Promise.all([
210
+ Messaging.getRoomsData(allRoomIds, ['roomName', 'public']),
211
+ db.getSortedSetsMembers(allRoomIds.map(roomId => `chat:room:${roomId}:uids`)),
212
+ ]);
213
+
214
+ const uniqueUids = [...new Set(roomUids.flat().filter(Boolean))];
215
+ const users = await User.getUsersFields(uniqueUids, ['username', 'displayname']);
216
+ const matchingUids = new Set();
217
+ users.forEach((user, index) => {
218
+ if (user && (
219
+ (user.displayname && user.displayname.toLowerCase().includes(lowerQuery)) ||
220
+ (user.username && user.username.toLowerCase().includes(lowerQuery))
221
+ )) {
222
+ matchingUids.add(String(uniqueUids[index]));
223
+ }
224
+ });
225
+
226
+ const roomIds = allRoomIds.filter((roomId, index) => {
227
+ const room = allRooms[index];
228
+ if (!room || room.public) {
229
+ return false;
230
+ }
231
+ return (room.roomName && room.roomName.toLowerCase().includes(lowerQuery)) ||
232
+ (roomUids[index] || []).some(memberUid => matchingUids.has(String(memberUid)));
233
+ }).slice(0, ADMIN_CHAT_SEARCH_LIMIT);
234
+
235
+ const rooms = await Messaging.getRoomsData(roomIds);
236
+ await enrichAdminRecentRooms(uid, roomIds, rooms);
237
+
238
+ return { rooms: rooms.filter(Boolean) };
239
+ }
240
+
205
241
  function buildParticipantsLabel(users) {
206
242
  const list = Array.isArray(users) ? users.filter(Boolean) : [];
207
243
  if (!list.length) {
@@ -308,6 +344,33 @@ function registerRoutes(app, router, middleware) {
308
344
  }
309
345
  });
310
346
 
347
+ // Core's chat list search (GET /api/v3/chats/search) always searches the
348
+ // caller's own rooms, so on the all-chats page and on another user's chats
349
+ // page it would list the viewer's chats instead of the ones being viewed.
350
+ router.get('/api/admin-chats/search', ...routeMiddleware, async (req, res) => {
351
+ try {
352
+ if (!await assertAdminChatsAccess(req, res)) {
353
+ return;
354
+ }
355
+
356
+ const query = String(req.query.query || '').trim();
357
+ const uid = parseInt(req.query.uid, 10) || 0;
358
+ let data;
359
+ if (uid) {
360
+ data = query ?
361
+ await Messaging.searchRecentChats(req.uid, uid, query) :
362
+ await Messaging.getRecentChats(req.uid, uid, 0, ADMIN_CHAT_PAGE_SIZE - 1);
363
+ } else {
364
+ data = query ?
365
+ await searchAdminChats(req.uid, query) :
366
+ await getAdminRecentChats(req.uid, 0, ADMIN_CHAT_PAGE_SIZE);
367
+ }
368
+ res.json(data);
369
+ } catch (err) {
370
+ res.status(500).json({ status: { code: 'error', message: err.message } });
371
+ }
372
+ });
373
+
311
374
  router.get('/api/admin-chats/page/:roomId?/:index?', ...routeMiddleware, async (req, res) => {
312
375
  try {
313
376
  if (!await assertAdminChatsAccess(req, res)) {
package/library.js CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  const hooks = require('./lib/hooks');
4
4
  const { registerRoutes, renderAdminChatsPage } = require('./lib/routes');
5
- const { overrideMessagingFunctions, overrideChatsApi, overrideCoreChatRedirect } = require('./lib/overrides');
5
+ const { overrideMessagingFunctions, overrideChatsApi, overrideCoreChatRedirect, overrideCoreChatsPage } = require('./lib/overrides');
6
6
  const { overrideForActivityTracking, backfillActivitySet } = require('./lib/activity');
7
7
 
8
8
  const plugin = Object.assign({}, hooks);
@@ -12,6 +12,7 @@ plugin.init = async function (params) {
12
12
  overrideMessagingFunctions();
13
13
  overrideChatsApi();
14
14
  overrideCoreChatRedirect(params.controllers, renderAdminChatsPage);
15
+ overrideCoreChatsPage(params.controllers);
15
16
  overrideForActivityTracking();
16
17
  await backfillActivitySet();
17
18
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-admin-chats",
3
- "version": "3.8.2",
3
+ "version": "3.8.3",
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
@@ -11,7 +11,8 @@
11
11
  "static/lib/delete-room.js",
12
12
  "static/lib/live-updates.js",
13
13
  "static/lib/profile-link.js",
14
- "static/lib/message-history.js"
14
+ "static/lib/message-history.js",
15
+ "static/lib/chat-search.js"
15
16
  ],
16
17
  "hooks": [
17
18
  {
@@ -0,0 +1,80 @@
1
+ 'use strict';
2
+
3
+ // Core's chat list search (public/src/client/chats/search.js) always searches
4
+ // the caller's own rooms. On the admin all-chats page and on another user's
5
+ // chats page that lists the viewer's chats instead of the ones on screen, and
6
+ // opening one of them lands on /user/<other>/chats/<roomId> for a room that
7
+ // user is not in. Core binds its listener with no handle to remove it, so the
8
+ // input is swapped for a clone and searched through the plugin's endpoint.
9
+ $(document).ready(function () {
10
+ function getSearchUid() {
11
+ const data = ajaxify && ajaxify.data;
12
+ if (!data) {
13
+ return null;
14
+ }
15
+ if (data.adminAllChats) {
16
+ return 0;
17
+ }
18
+ const uid = parseInt(data.uid, 10) || 0;
19
+ if (!uid || uid === parseInt(app.user.uid, 10)) {
20
+ return null;
21
+ }
22
+ return uid;
23
+ }
24
+
25
+ async function search(query, uid) {
26
+ const params = new URLSearchParams({ query });
27
+ if (uid) {
28
+ params.set('uid', uid);
29
+ }
30
+ const response = await fetch(`${config.relative_path || ''}/api/admin-chats/search?${params.toString()}`, {
31
+ credentials: 'same-origin',
32
+ });
33
+ if (!response.ok) {
34
+ throw new Error(`Received ${response.status}`);
35
+ }
36
+ return await response.json();
37
+ }
38
+
39
+ function takeOverSearch() {
40
+ const uid = getSearchUid();
41
+ const searchEl = document.getElementById('search-chats');
42
+ const listEl = document.getElementById('private-rooms');
43
+ if (uid === null || !searchEl || !listEl || searchEl.hasAttribute('data-admin-chats-search')) {
44
+ return;
45
+ }
46
+
47
+ const replacement = searchEl.cloneNode(true);
48
+ replacement.value = searchEl.value;
49
+ replacement.setAttribute('data-admin-chats-search', '1');
50
+ searchEl.replaceWith(replacement);
51
+
52
+ let lastQuery = replacement.value.trim();
53
+ replacement.addEventListener('keyup', utils.debounce(async function () {
54
+ const query = replacement.value.trim();
55
+ if (query === lastQuery) {
56
+ return;
57
+ }
58
+ lastQuery = query;
59
+ try {
60
+ const data = await search(query, uid);
61
+ if (query !== lastQuery) {
62
+ return;
63
+ }
64
+ const html = await app.parseAndTranslate('chats', 'rooms', data);
65
+ $(listEl).html(html);
66
+ html.find('.timeago').timeago();
67
+ listEl.setAttribute('data-nextstart', data.nextStart || 0);
68
+ listEl.setAttribute('data-prevstart', 0);
69
+ // Both infinite-scroll loaders (core's and switch-chat.js) bail out
70
+ // while `loading` is set; keep them from appending the unfiltered
71
+ // list under the search results.
72
+ listEl.toggleAttribute('loading', !!query);
73
+ } catch (err) {
74
+ app.alertError(err);
75
+ }
76
+ }, 300));
77
+ }
78
+
79
+ $(window).on('action:chat.loaded', takeOverSearch);
80
+ });