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
package/library.js CHANGED
@@ -1,1512 +1,16 @@
1
1
  'use strict';
2
2
 
3
- const User = require.main.require('./src/user');
4
- const Messaging = require.main.require('./src/messaging');
5
- const ChatsAPI = require.main.require('./src/api/chats');
6
- const db = require.main.require('./src/database');
7
- const meta = require.main.require('./src/meta');
8
- const privileges = require.main.require('./src/privileges');
9
- const helpers = require.main.require('./src/controllers/helpers');
10
- const validator = require.main.require('validator');
11
- const fs = require('fs');
12
- const path = require('path');
3
+ const hooks = require('./lib/hooks');
4
+ const { registerRoutes, renderAdminChatsPage } = require('./lib/routes');
5
+ const { overrideMessagingFunctions, overrideChatsApi, overrideCoreChatRedirect } = require('./lib/overrides');
13
6
 
14
- const plugin = {};
15
- const LOCK_PREFIX = '[admin-chat-lock]';
16
- const LOCKED_MESSAGE_TEXT_KEY = 'lock.banner';
17
- const ADMIN_CHATS_PRIVILEGE = 'admin-chats:view';
18
- const ADMIN_CHATS_MANAGE_PRIVILEGE = 'admin-chats:manage';
7
+ const plugin = Object.assign({}, hooks);
19
8
 
20
- // Per-message edit-history storage. Each edit appends the *previous* (pre-edit)
21
- // version as a JSON string, oldest first. Deliberately kept out of every payload,
22
- // hook and template the plugin exposes — the only way to read it back is the
23
- // hidden routes registered in registerRoutes(), whose paths are not linked
24
- // anywhere in the UI. Access is still gated by admin-chats privileges.
25
- function editHistoryKey(mid) {
26
- return `message:${mid}:editHistory`;
27
- }
28
-
29
- // Load translations from language files
30
- const translations = {};
31
- function loadTranslations() {
32
- // Try multiple possible paths
33
- const possiblePaths = [
34
- path.join(__dirname, 'public', 'languages'),
35
- path.join(__dirname, '..', '..', 'public', 'languages'),
36
- path.resolve(__dirname, 'public', 'languages'),
37
- ];
38
-
39
- let languagesDir = null;
40
- for (const dir of possiblePaths) {
41
- if (fs.existsSync(dir)) {
42
- languagesDir = dir;
43
- break;
44
- }
45
- }
46
-
47
- if (!languagesDir) {
48
- return;
49
- }
50
-
51
- try {
52
- const langs = fs.readdirSync(languagesDir);
53
- langs.forEach(lang => {
54
- const langPath = path.join(languagesDir, lang, 'admin-chats.json');
55
- try {
56
- if (fs.existsSync(langPath)) {
57
- const content = fs.readFileSync(langPath, 'utf8');
58
- translations[lang] = JSON.parse(content);
59
- }
60
- } catch (err) {
61
- console.error(`[admin-chats] Error loading translations for ${lang}:`, err.message);
62
- }
63
- });
64
- } catch (err) {
65
- console.error(`[admin-chats] Error reading languages directory:`, err.message);
66
- }
67
- }
68
- loadTranslations();
69
- function getTranslationForLang(lang, key) {
70
- if (!key) {
71
- return '';
72
- }
73
- if (lang && translations[lang] && translations[lang][key]) {
74
- return translations[lang][key];
75
- }
76
- const langCode = lang ? lang.split('-')[0] : '';
77
- if (langCode && translations[langCode] && translations[langCode][key]) {
78
- return translations[langCode][key];
79
- }
80
- if (translations.en && translations.en[key]) {
81
- return translations.en[key];
82
- }
83
- if (translations['en-GB'] && translations['en-GB'][key]) {
84
- return translations['en-GB'][key];
85
- }
86
- return '';
87
- }
88
-
89
- function getDefaultLockMessage() {
90
- const defaultLang = meta.config.defaultLang || 'en-GB';
91
- return getTranslationForLang(defaultLang, LOCKED_MESSAGE_TEXT_KEY)
92
- || getTranslationForLang('en-GB', LOCKED_MESSAGE_TEXT_KEY)
93
- || getTranslationForLang('en', LOCKED_MESSAGE_TEXT_KEY)
94
- || 'This room was locked by the administrators.';
95
- }
96
-
97
- async function getLockedActionMessage(uid) {
98
- try {
99
- const userData = await User.getUserData(uid);
100
- let userLang = userData && userData.settings && userData.userLang;
101
-
102
- // If user doesn't have specific language setting, use forum default
103
- if (!userLang) {
104
- userLang = meta.config.defaultLang || 'en-GB';
105
- }
106
-
107
- // Extract language code (e.g., 'he' from 'he-IL', 'fr' from 'fr-FR')
108
- const langCode = userLang.split('-')[0];
109
-
110
- // Try to get translation from loaded files
111
- if (translations[langCode] && translations[langCode]['errors.lockedAction']) {
112
- return translations[langCode]['errors.lockedAction'];
113
- }
114
- if (translations[userLang] && translations[userLang]['errors.lockedAction']) {
115
- return translations[userLang]['errors.lockedAction'];
116
- }
117
-
118
- // Fallback to English only
119
- return 'This action cannot be performed in a locked room.';
120
- } catch (err) {
121
- // Default to English if we can't determine language
122
- return 'This action cannot be performed in a locked room.';
123
- }
124
- }
125
- const ADMIN_CHAT_PAGE_SIZE = 30;
126
- const ADMIN_CHAT_SCAN_SIZE = 100;
127
-
128
- async function canAccessAdminChats(uid) {
129
- if (!uid) {
130
- return false;
131
- }
132
-
133
- const [isAdmin, canView, canManage] = await Promise.all([
134
- User.isAdministrator(uid),
135
- privileges.global.can(ADMIN_CHATS_PRIVILEGE, uid),
136
- privileges.global.can(ADMIN_CHATS_MANAGE_PRIVILEGE, uid),
137
- ]);
138
-
139
- return isAdmin || canView || canManage;
140
- }
141
-
142
- async function canManageAdminChats(uid) {
143
- if (!uid) {
144
- return false;
145
- }
146
-
147
- const [isAdmin, canManage] = await Promise.all([
148
- User.isAdministrator(uid),
149
- privileges.global.can(ADMIN_CHATS_MANAGE_PRIVILEGE, uid),
150
- ]);
151
-
152
- return isAdmin || canManage;
153
- }
154
-
155
- async function isUserMemberOfRoom(uid, roomId) {
156
- if (!uid || !roomId) {
157
- return false;
158
- }
159
-
160
- return await db.isSortedSetMember(`chat:room:${roomId}:uids`, uid);
161
- }
162
9
  plugin.init = async function (params) {
163
10
  registerRoutes(params.app, params.router, params.middleware);
164
11
  overrideMessagingFunctions();
165
12
  overrideChatsApi();
166
- overrideCoreChatRedirect(params.controllers);
167
-
168
- // Add middleware to inject translations into every page
169
- params.app.use((req, res, next) => {
170
- // Store translations in res.locals so they can be accessed in templates
171
- res.locals.adminChatsTranslations = translations;
172
- next();
173
- });
174
- };
175
- plugin.registerPrivileges = async function (data) {
176
- if (!data || !data.privileges || typeof data.privileges.set !== 'function') {
177
- return data;
178
- }
179
-
180
- data.privileges.set(ADMIN_CHATS_PRIVILEGE, {
181
- label: '[[admin-chats:admin-chats-view]]',
182
- type: 'moderation',
183
- });
184
-
185
- data.privileges.set(ADMIN_CHATS_MANAGE_PRIVILEGE, {
186
- label: '[[admin-chats:admin-chats-manage]]',
187
- type: 'moderation',
188
- });
189
-
190
- return data;
13
+ overrideCoreChatRedirect(params.controllers, renderAdminChatsPage);
191
14
  };
192
15
 
193
- plugin.filterNotificationCreate = async function (data) {
194
- if (!data || !data.data) {
195
- return data;
196
- }
197
-
198
- const notification = data.data;
199
-
200
- // Check if this is a chat notification with path like /chats/123
201
- if (notification.path && notification.path.match(/^\/chats\/\d+/)) {
202
- const chatMatch = notification.path.match(/^\/chats\/(\d+)(?:\/(\d+))?$/);
203
- if (chatMatch && notification && notification.uid) {
204
- const roomId = chatMatch[1];
205
- const index = chatMatch[2];
206
-
207
- // Get the user's slug to redirect to their personal chat page
208
- const userSlug = await User.getUserField(notification.uid, 'userslug');
209
- if (userSlug) {
210
- notification.path = `/user/${userSlug}/chats/${roomId}${index ? `/${index}` : ''}`;
211
- }
212
- }
213
- }
214
-
215
- return data;
216
- };
217
-
218
- plugin.filterUserNotificationsGetNotifications = async function (data) {
219
- if (!data || !data.uid || !Array.isArray(data.notifications) || !data.notifications.length) {
220
- return data;
221
- }
222
-
223
- const userSlug = await User.getUserField(data.uid, 'userslug');
224
- if (!userSlug) {
225
- return data;
226
- }
227
-
228
- const userPathRegex = /\/user\/[^/]+\/chats\//;
229
- const chatPathRegex = /\/chats\/(\d+)(?:\/(\d+))?(?=$|[/?#])/;
230
-
231
- data.notifications.forEach((notification) => {
232
- if (!notification || !notification.path) {
233
- return;
234
- }
235
- if (userPathRegex.test(notification.path)) {
236
- return;
237
- }
238
- const match = notification.path.match(chatPathRegex);
239
- if (!match) {
240
- return;
241
- }
242
- const roomId = match[1];
243
- const index = match[2];
244
- const replacement = `/user/${userSlug}/chats/${roomId}${index ? `/${index}` : ''}`;
245
- notification.path = notification.path.replace(chatPathRegex, replacement);
246
- });
247
-
248
- return data;
249
- };
250
- async function buildAdminChatsClientContext(req) {
251
- const uid = req && req.uid;
252
-
253
- let userLang = 'en-GB';
254
-
255
- if (uid) {
256
- try {
257
- const userData = await User.getUserData(uid);
258
-
259
- // Try different ways to get user language
260
- userLang = userData && userData.userLang;
261
- if (!userLang && userData && userData.settings) {
262
- userLang = userData.settings.userLang;
263
- }
264
-
265
- // If user doesn't have specific language setting, use forum default
266
- if (!userLang) {
267
- userLang = meta.config.defaultLang || 'en-GB';
268
- }
269
- } catch (err) {
270
- console.error(`[admin-chats] Error getting user language:`, err.message);
271
- userLang = meta.config.defaultLang || 'en-GB';
272
- }
273
- } else {
274
- // No uid, use forum default language
275
- userLang = meta.config.defaultLang || 'en-GB';
276
- }
277
-
278
- // Build translations based on user language from loaded files
279
- let clientTranslations = {};
280
- const langCode = userLang.split('-')[0];
281
-
282
-
283
- // Try to get translations from loaded files
284
- // First try exact match (e.g., 'he-IL')
285
- if (translations[userLang]) {
286
- clientTranslations = translations[userLang];
287
- }
288
- // Then try language code (e.g., 'he' from 'he-IL')
289
- else if (translations[langCode]) {
290
- clientTranslations = translations[langCode];
291
- }
292
- // Then try full locale (e.g., 'en-GB')
293
- else if (translations[userLang.replace('-', '-')]) {
294
- clientTranslations = translations[userLang];
295
- }
296
- // Fallback to English
297
- else {
298
- clientTranslations = translations['en'] || translations['en-GB'] || {};
299
- }
300
-
301
-
302
- const isAdmin = uid ? await User.isAdministrator(uid) : false;
303
- const adminChatsManage = uid ? (isAdmin || await privileges.global.can(ADMIN_CHATS_MANAGE_PRIVILEGE, uid)) : false;
304
- const adminChatsAccess = uid ? (isAdmin || adminChatsManage || await privileges.global.can(ADMIN_CHATS_PRIVILEGE, uid)) : false;
305
-
306
- return {
307
- clientTranslations,
308
- userLang,
309
- adminChatsAccess,
310
- adminChatsIsAdmin: isAdmin,
311
- adminChatsCanManage: adminChatsManage,
312
- };
313
- }
314
-
315
- plugin.addTemplateData = async function (data) {
316
- const templateData = data && data.templateData ? data.templateData : null;
317
- if (!templateData) {
318
- return data;
319
- }
320
-
321
- const context = await buildAdminChatsClientContext(data.req);
322
- templateData.adminChatsTranslations = context.clientTranslations;
323
- templateData.adminChatsLanguage = context.userLang;
324
- templateData.adminChatsAccess = context.adminChatsAccess;
325
- templateData.adminChatsIsAdmin = context.adminChatsIsAdmin;
326
- templateData.adminChatsCanManage = context.adminChatsCanManage;
327
-
328
- return data;
329
- };
330
-
331
- plugin.addScripts = async function (data) {
332
- const context = await buildAdminChatsClientContext(data.req);
333
-
334
- // Add translations directly to data object
335
- data.adminChatsTranslations = context.clientTranslations;
336
- data.adminChatsLanguage = context.userLang;
337
- data.adminChatsAccess = context.adminChatsAccess;
338
- data.adminChatsIsAdmin = context.adminChatsIsAdmin;
339
- data.adminChatsCanManage = context.adminChatsCanManage;
340
-
341
- // Also add as inline script to ensure it's available before client.js runs
342
- data.scripts = data.scripts || [];
343
- const scriptContent = `window.adminChatsTranslations = ${JSON.stringify(context.clientTranslations)}; window.adminChatsLanguage = '${context.userLang}'; window.adminChatsAccess = ${context.adminChatsAccess}; window.adminChatsIsAdmin = ${context.adminChatsIsAdmin}; window.adminChatsCanManage = ${context.adminChatsCanManage};`;
344
- data.scripts.push({
345
- src: false,
346
- script: scriptContent
347
- });
348
-
349
-
350
- return data;
351
- };
352
-
353
- plugin.addProfileLink = async function (data) {
354
- try {
355
- const uid = data && (data.uid || (data.req && data.req.uid));
356
- if (!uid || !await canAccessAdminChats(uid)) {
357
- return data;
358
- }
359
-
360
- const userSlug = data.user ? data.user.userslug : (data.userData ? data.userData.userslug : null);
361
-
362
- if (userSlug) {
363
- data.links.push({
364
- id: 'admin-view-chats',
365
- route: 'user/' + userSlug + '/chats',
366
- icon: 'fa-comments',
367
- name: '[[admin-chats:profile.viewChats]]',
368
- visibility: {
369
- self: true,
370
- other: true,
371
- moderator: true,
372
- globalMod: true,
373
- admin: true,
374
- }
375
- });
376
- }
377
- } catch (e) {
378
- // Silent error handling
379
- }
380
- return data;
381
- };
382
-
383
- plugin.isRoomOwner = async function (payload) {
384
- const [isAdmin, canManage] = await Promise.all([
385
- User.isAdministrator(payload.uid),
386
- canManageAdminChats(payload.uid),
387
- ]);
388
- if (isAdmin || canManage) {
389
- payload.isOwner = true;
390
- }
391
- return payload;
392
- };
393
-
394
- plugin.isUserInRoom = async function (payload) {
395
- const uid = getCallerUid(payload);
396
- if (await canAccessAdminChats(uid)) {
397
- payload.inRoom = true;
398
- }
399
- return payload;
400
- };
401
-
402
- plugin.canReply = async function (payload) {
403
- const uid = getCallerUid(payload);
404
- const roomId = getRoomId(payload);
405
-
406
- const [isAdmin, canView, canManage] = await Promise.all([
407
- User.isAdministrator(uid),
408
- privileges.global.can(ADMIN_CHATS_PRIVILEGE, uid),
409
- privileges.global.can(ADMIN_CHATS_MANAGE_PRIVILEGE, uid),
410
- ]);
411
- const isManager = isAdmin || canManage;
412
- const hasAccess = isAdmin || canView || canManage;
413
-
414
- if (isManager) {
415
- payload.canReply = true;
416
- return payload;
417
- }
418
-
419
- if (roomId && hasAccess) {
420
- const isMember = await isUserMemberOfRoom(uid, roomId);
421
- if (!isMember) {
422
- payload.canReply = false;
423
- return payload;
424
- }
425
- }
426
-
427
- if (roomId && await isRoomLocked(roomId)) {
428
- payload.canReply = false;
429
- }
430
-
431
- return payload;
432
- };
433
-
434
- plugin.filterMessagingSend = async function (payload) {
435
- const uid = getCallerUid(payload);
436
- const roomId = getRoomId(payload);
437
-
438
- if (!uid || !roomId) {
439
- return payload;
440
- }
441
-
442
- if (await User.isAdministrator(uid)) {
443
- return payload;
444
- }
445
-
446
- if (await canManageAdminChats(uid)) {
447
- return payload;
448
- }
449
-
450
- if (await canAccessAdminChats(uid)) {
451
- const isMember = await isUserMemberOfRoom(uid, roomId);
452
- if (!isMember) {
453
- throw new Error('[[error:no-privileges]]');
454
- }
455
- }
456
-
457
- if (await isRoomLocked(roomId)) {
458
- const message = await getLockedActionMessage(uid);
459
- throw new Error(message);
460
- }
461
-
462
- return payload;
463
- };
464
- plugin.filterAddUsersToRoom = async function (payload) {
465
- if (await canManageAdminChats(payload.uid)) {
466
- return payload;
467
- }
468
- if (await canNonAdminModifyLockedRoom(payload.uid, payload.roomId)) {
469
- const message = await getLockedActionMessage(payload.uid);
470
- throw new Error(message);
471
- }
472
- return payload;
473
- };
474
-
475
- plugin.filterRemoveUsersFromRoom = async function (payload) {
476
- if (await canManageAdminChats(payload.uid)) {
477
- return payload;
478
- }
479
- if (await canNonAdminModifyLockedRoom(payload.uid, payload.roomId)) {
480
- const message = await getLockedActionMessage(payload.uid);
481
- throw new Error(message);
482
- }
483
- return payload;
484
- };
485
-
486
- plugin.filterRenameRoom = async function (payload) {
487
- if (await canManageAdminChats(payload.uid)) {
488
- return payload;
489
- }
490
- if (await canNonAdminModifyLockedRoom(payload.uid, payload.roomId)) {
491
- const message = await getLockedActionMessage(payload.uid);
492
- throw new Error(message);
493
- }
494
- return payload;
495
- };
496
-
497
- plugin.canGetMessages = async function (payload) {
498
- if (await canAccessAdminChats(payload.callerUid)) {
499
- payload.canGet = true;
500
- }
501
- return payload;
502
- };
503
-
504
- plugin.canGetRecentChats = async function (payload) {
505
- if (await canAccessAdminChats(payload.callerUid)) {
506
- payload.canGet = true;
507
- }
508
- return payload;
509
- };
510
-
511
- plugin.canGetPublicChats = async function (payload) {
512
- if (await canAccessAdminChats(payload.callerUid)) {
513
- payload.canGet = true;
514
- }
515
- return payload;
516
- };
517
-
518
- plugin.onLoadRoom = async function (payload) {
519
- const { uid, room } = payload;
520
- if (!room) {
521
- return payload;
522
- }
523
-
524
- const [isAdmin, canView, canManage] = await Promise.all([
525
- User.isAdministrator(uid),
526
- privileges.global.can(ADMIN_CHATS_PRIVILEGE, uid),
527
- privileges.global.can(ADMIN_CHATS_MANAGE_PRIVILEGE, uid),
528
- ]);
529
- const isManager = isAdmin || canManage;
530
- const hasAccess = isAdmin || canView || canManage;
531
-
532
- const lockData = await getRoomLockData(room.roomId);
533
- const isLockedForUser = lockData.isLocked && !isManager;
534
-
535
- room.adminChatLock = lockData;
536
- room.messages = ensureFirstVisibleMessageHeader(buildRoomMessagesWithLockNotice(room.messages, lockData));
537
- room.canReply = room.canReply && !isLockedForUser;
538
- room.showUserInput = room.showUserInput && !isLockedForUser;
539
-
540
- if (!hasAccess) {
541
- return payload;
542
- }
543
-
544
- const isOfficialMember = await isUserMemberOfRoom(uid, room.roomId);
545
-
546
- if (!isOfficialMember) {
547
- await db.sortedSetRemove(`chat:room:${room.roomId}:uids:online`, uid);
548
-
549
- if (Array.isArray(room.users)) {
550
- room.users = room.users.filter(user => user && parseInt(user.uid, 10) !== parseInt(uid, 10));
551
- }
552
- if (room.userCount > 0) {
553
- room.userCount -= 1;
554
- }
555
- room.groupChat = room.userCount > 2;
556
-
557
- const allMids = await db.getSortedSetRevRange(`chat:room:${room.roomId}:mids`, 0, 49);
558
-
559
- if (allMids.length > 0) {
560
- const messages = await Messaging.getMessagesData(allMids, uid, room.roomId, false);
561
- const messageCount = await db.getObjectField(`chat:room:${room.roomId}`, 'messageCount');
562
- const count = parseInt(messageCount, 10) || 0;
563
-
564
- messages.forEach((msg, index) => {
565
- msg.index = count - index - 1;
566
- });
567
-
568
- room.messages = ensureFirstVisibleMessageHeader(buildRoomMessagesWithLockNotice(messages.reverse(), lockData));
569
-
570
- room.messages.forEach((msg, index) => {
571
- if (index === 0) {
572
- msg.newSet = true;
573
- } else {
574
- const prevMsg = room.messages[index - 1];
575
- const prevTime = parseInt(prevMsg && prevMsg.timestamp, 10) || 0;
576
- const currentTime = parseInt(msg && msg.timestamp, 10) || 0;
577
-
578
- msg.newSet = !!(
579
- currentTime > prevTime + Messaging.newMessageCutoff ||
580
- parseInt(msg.fromuid, 10) !== parseInt(prevMsg.fromuid, 10) ||
581
- prevMsg.system ||
582
- msg.toMid
583
- );
584
- }
585
- });
586
-
587
- room.messages = ensureFirstVisibleMessageHeader(room.messages);
588
- }
589
- }
590
-
591
- if (!isManager && !isOfficialMember) {
592
- room.canReply = false;
593
- room.showUserInput = false;
594
- }
595
-
596
- room.isAdmin = isManager;
597
- room.isAdminOrGlobalMod = isManager;
598
- payload.isAdminOrGlobalMod = isManager;
599
- if (isManager) {
600
- room.isOwner = true;
601
- }
602
- room.adminChatsAccess = hasAccess;
603
- payload.adminChatsAccess = hasAccess;
604
- payload.adminChatsCanManage = isManager;
605
- payload.adminChatsIsAdmin = isAdmin;
606
-
607
- return payload;
608
- };
609
-
610
- function overrideCoreChatRedirect(controllers) {
611
- const chatsController = controllers && controllers.accounts && controllers.accounts.chats;
612
- if (!chatsController || typeof chatsController.redirectToChat !== 'function' || chatsController.redirectToChat._adminChatsWrapped) {
613
- return;
614
- }
615
-
616
- const originalRedirectToChat = chatsController.redirectToChat;
617
- const wrappedRedirectToChat = async function (req, res, next) {
618
- if (req && req.uid && await canAccessAdminChats(req.uid)) {
619
- return await renderAdminChatsPage(req, res, next);
620
- }
621
-
622
- return await originalRedirectToChat.call(this, req, res, next);
623
- };
624
-
625
- wrappedRedirectToChat._adminChatsWrapped = true;
626
- chatsController.redirectToChat = wrappedRedirectToChat;
627
- }
628
-
629
- function overrideChatsApi() {
630
- if (!ChatsAPI || typeof ChatsAPI.kick !== 'function' || ChatsAPI.kick._adminChatLockWrapped) {
631
- return;
632
- }
633
-
634
- const originalKick = ChatsAPI.kick;
635
- const wrappedKick = async function (caller, data) {
636
- if (caller && !await User.isAdministrator(caller.uid) && !await canManageAdminChats(caller.uid) && data && data.roomId && Array.isArray(data.uids) && data.uids.length === 1 && parseInt(data.uids[0], 10) === parseInt(caller.uid, 10) && await isRoomLocked(data.roomId)) {
637
- const message = await getLockedActionMessage(caller.uid);
638
- throw new Error(message);
639
- }
640
-
641
- return await originalKick.call(this, caller, data);
642
- };
643
-
644
- wrappedKick._adminChatLockWrapped = true;
645
- ChatsAPI.kick = wrappedKick;
646
- }
647
- function overrideMessagingFunctions() {
648
- const originalCanEdit = Messaging.canEdit;
649
- const originalCanDelete = Messaging.canDelete;
650
- const originalCanViewMessage = Messaging.canViewMessage;
651
- const originalCanReply = Messaging.canReply;
652
-
653
- if (typeof Messaging.editMessage === 'function' && !Messaging.editMessage._adminChatsHistoryWrapped) {
654
- const originalEditMessage = Messaging.editMessage;
655
-
656
- const wrappedEditMessage = async function (uid, mid, roomId, content) {
657
- let previous = null;
658
- try {
659
- const [oldContent, fields] = await Promise.all([
660
- Messaging.getMessageField(mid, 'content'),
661
- Messaging.getMessageFields(mid, ['fromuid', 'timestamp', 'edited']),
662
- ]);
663
- // Only snapshot when the content actually changes, mirroring core's
664
- // no-op short-circuit so history stays in step with real edits.
665
- if (typeof oldContent === 'string' && oldContent !== content) {
666
- previous = {
667
- content: oldContent,
668
- fromuid: parseInt(fields && fields.fromuid, 10) || 0,
669
- editedByUid: parseInt(uid, 10) || 0,
670
- // When this version stopped being current (i.e. now).
671
- replacedAt: Date.now(),
672
- // The edit stamp this version carried before being replaced.
673
- previousEdited: parseInt(fields && fields.edited, 10) ||
674
- parseInt(fields && fields.timestamp, 10) || 0,
675
- };
676
- }
677
- } catch (err) {
678
- previous = null;
679
- }
680
-
681
- const result = await originalEditMessage.call(this, uid, mid, roomId, content);
682
-
683
- if (previous) {
684
- try {
685
- await db.listAppend(editHistoryKey(mid), JSON.stringify(previous));
686
- } catch (err) {
687
- // History is best-effort; never break an edit because of it.
688
- }
689
- }
690
-
691
- return result;
692
- };
693
-
694
- wrappedEditMessage._adminChatsHistoryWrapped = true;
695
- Messaging.editMessage = wrappedEditMessage;
696
- }
697
-
698
- Messaging.canEdit = async function (messageId, uid) {
699
- if (await User.isAdministrator(uid)) {
700
- return true;
701
- }
702
-
703
- if (await canManageAdminChats(uid)) {
704
- return true;
705
- }
706
-
707
- const roomId = await getMessageRoomId(messageId);
708
- if (roomId && await isRoomLocked(roomId)) {
709
- const message = await getLockedActionMessage(uid);
710
- throw new Error(message);
711
- }
712
-
713
- return await originalCanEdit(messageId, uid);
714
- };
715
-
716
- Messaging.canDelete = async function (messageId, uid) {
717
- if (await User.isAdministrator(uid)) {
718
- return true;
719
- }
720
-
721
- if (await canManageAdminChats(uid)) {
722
- return true;
723
- }
724
-
725
- const roomId = await getMessageRoomId(messageId);
726
- if (roomId && await isRoomLocked(roomId)) {
727
- const message = await getLockedActionMessage(uid);
728
- throw new Error(message);
729
- }
730
-
731
- return await originalCanDelete(messageId, uid);
732
- };
733
-
734
- Messaging.canViewMessage = async function (mids, roomId, uid) {
735
- if (await canAccessAdminChats(uid)) {
736
- return Array.isArray(mids) ? mids.map(() => true) : true;
737
- }
738
- return await originalCanViewMessage(mids, roomId, uid);
739
- };
740
-
741
- if (typeof originalCanReply === 'function') {
742
- Messaging.canReply = async function (...args) {
743
- const guessed = guessRoomIdUidFromArgs(args);
744
- const [isAdmin, canView, canManage] = await Promise.all([
745
- User.isAdministrator(guessed.uid),
746
- privileges.global.can(ADMIN_CHATS_PRIVILEGE, guessed.uid),
747
- privileges.global.can(ADMIN_CHATS_MANAGE_PRIVILEGE, guessed.uid),
748
- ]);
749
- const isManager = isAdmin || canManage;
750
- const hasAccess = isAdmin || canView || canManage;
751
-
752
- if (isManager) {
753
- return true;
754
- }
755
- if (guessed.roomId && hasAccess) {
756
- const isMember = await isUserMemberOfRoom(guessed.uid, guessed.roomId);
757
- if (!isMember) {
758
- return false;
759
- }
760
- }
761
- if (guessed.roomId && await isRoomLocked(guessed.roomId)) {
762
- return false;
763
- }
764
- return await originalCanReply.apply(this, args);
765
- };
766
- }
767
- }
768
-
769
- function registerRoutes(app, router, middleware) {
770
- if (!router) {
771
- return;
772
- }
773
-
774
- const routeMiddleware = [];
775
- if (middleware && typeof middleware.ensureLoggedIn === 'function') {
776
- routeMiddleware.push(middleware.ensureLoggedIn);
777
- }
778
-
779
- const pageMiddlewares = [
780
- middleware.autoLocale,
781
- middleware.applyBlacklist,
782
- middleware.authenticateRequest,
783
- middleware.redirectToHomeIfBanned,
784
- middleware.maintenanceMode,
785
- middleware.registrationComplete,
786
- middleware.pluginHooks,
787
- ...routeMiddleware,
788
- middleware.pageView,
789
- ].filter(Boolean);
790
-
791
- const pageController = async (req, res, next) => {
792
- try {
793
- // Users without admin-chats access get core's behaviour: redirect to
794
- // their own chats page (helpers.redirect also handles API/ajaxify
795
- // requests via X-Redirect, so navigation works in one hop)
796
- if (req.uid && !await canAccessAdminChats(req.uid)) {
797
- const userSlug = await User.getUserField(req.uid, 'userslug');
798
- if (userSlug) {
799
- const suffix = req.params.roomId ? `/${req.params.roomId}${req.params.index ? `/${req.params.index}` : ''}` : '';
800
- return helpers.redirect(res, `/user/${userSlug}/chats${suffix}`);
801
- }
802
- }
803
-
804
- await renderAdminChatsPage(req, res, next);
805
- } catch (err) {
806
- next(err);
807
- }
808
- };
809
-
810
- const pageRouter = app || router;
811
- pageRouter.get('/chats/:roomId?/:index?', middleware.busyCheck, pageMiddlewares, middleware.buildHeader, pageController);
812
- // Also own the page-API route. Without it, ajaxify entry into /chats/<roomId>
813
- // (e.g. picking a room from the mobile chat dropdown) hit core's redirect
814
- // route and bounced through an extra hop before the room finally rendered —
815
- // showing the chat list in between. Registered on the core router before
816
- // core's own routes, and covered by middleware.prepareAPI (mounted earlier),
817
- // so res.render answers with ajaxify JSON in a single round-trip.
818
- router.get('/api/chats/:roomId?/:index?', pageMiddlewares, pageController);
819
-
820
- router.get('/api/admin-chats', ...routeMiddleware, async (req, res) => {
821
- try {
822
- if (!await assertAdminChatsAccess(req, res)) {
823
- return;
824
- }
825
-
826
- const start = Math.max(0, parseInt(req.query.start, 10) || 0);
827
- const data = await getAdminRecentChats(req.uid, start, ADMIN_CHAT_PAGE_SIZE);
828
- res.json(data);
829
- } catch (err) {
830
- res.status(500).json({ status: { code: 'error', message: err.message } });
831
- }
832
- });
833
-
834
-
835
- router.get('/api/admin-chats/privileges', ...routeMiddleware, async (req, res) => {
836
- try {
837
- if (!await assertAdminChatsAccess(req, res)) {
838
- return;
839
- }
840
-
841
- const isAdmin = await User.isAdministrator(req.uid);
842
- const canManage = isAdmin || await privileges.global.can(ADMIN_CHATS_MANAGE_PRIVILEGE, req.uid);
843
- res.json({
844
- adminChatsAccess: true,
845
- adminChatsCanManage: canManage,
846
- adminChatsIsAdmin: isAdmin,
847
- });
848
- } catch (err) {
849
- res.status(500).json({ status: { code: 'error', message: err.message } });
850
- }
851
- });
852
-
853
- router.get('/api/admin-chats/page/:roomId?/:index?', ...routeMiddleware, async (req, res) => {
854
- try {
855
- if (!await assertAdminChatsAccess(req, res)) {
856
- return;
857
- }
858
-
859
- const payload = await buildAdminChatsPayload(req, {
860
- isSwitch: parseInt(req.query.switch, 10) === 1,
861
- });
862
- res.json(payload);
863
- } catch (err) {
864
- res.status(500).json({ status: { code: 'error', message: err.message } });
865
- }
866
- });
867
-
868
- router.post('/api/admin-chats/:roomId/lock', ...routeMiddleware, async (req, res) => {
869
- try {
870
- const actingUid = req.uid;
871
- const roomId = parseInt(req.params.roomId, 10);
872
-
873
- if (!actingUid || Number.isNaN(roomId)) {
874
- return res.status(400).json({ status: { code: 'bad-request', message: 'Invalid room id' } });
875
- }
876
-
877
- if (!await User.isAdministrator(actingUid) && !await canManageAdminChats(actingUid)) {
878
- return res.status(403).json({ status: { code: 'forbidden', message: 'Admin or manage privilege required' } });
879
- }
880
-
881
- if (!await roomExists(roomId)) {
882
- return res.status(404).json({ status: { code: 'not-found', message: 'Room not found' } });
883
- }
884
-
885
- const shouldLock = req.body && typeof req.body.locked === 'boolean' ? req.body.locked : true;
886
- const lockData = shouldLock ?
887
- await lockRoom(roomId, actingUid) :
888
- await unlockRoom(roomId, actingUid);
889
-
890
- return res.json({
891
- status: { code: 'ok', message: 'Room updated' },
892
- roomId,
893
- lockData,
894
- });
895
- } catch (err) {
896
- return res.status(500).json({ status: { code: 'error', message: err.message } });
897
- }
898
- });
899
-
900
- router.get('/api/admin-chats/:roomId/lock', ...routeMiddleware, async (req, res) => {
901
- try {
902
- const roomId = parseInt(req.params.roomId, 10);
903
-
904
- if (Number.isNaN(roomId)) {
905
- return res.status(400).json({ status: { code: 'bad-request', message: 'Invalid room id' } });
906
- }
907
-
908
- if (!await roomExists(roomId)) {
909
- return res.status(404).json({ status: { code: 'not-found', message: 'Room not found' } });
910
- }
911
-
912
- const lockData = await getRoomLockData(roomId);
913
- return res.json({
914
- status: { code: 'ok', message: 'Lock data retrieved' },
915
- roomId,
916
- lockData,
917
- });
918
- } catch (err) {
919
- return res.status(500).json({ status: { code: 'error', message: err.message } });
920
- }
921
- });
922
-
923
- // --- Hidden chat edit-history viewer ---------------------------------
924
- // These two routes are intentionally NOT referenced from any template,
925
- // client script, menu or API payload. They do not appear in the admin
926
- // chats UI, so a "regular" administrator who has not read this source
927
- // will never encounter them. They are still protected by
928
- // assertAdminChatsAccess(), so only privileged accounts can read data
929
- // even if the URL is discovered. Paths:
930
- // GET /api/admin-chats/history/:mid -> JSON
931
- // GET /admin-chats/history/:mid -> standalone HTML page
932
- router.get('/api/admin-chats/history/:mid', ...routeMiddleware, async (req, res) => {
933
- try {
934
- if (!await assertAdminChatsAccess(req, res)) {
935
- return;
936
- }
937
-
938
- const mid = parseInt(req.params.mid, 10);
939
- if (Number.isNaN(mid)) {
940
- return res.status(400).json({ status: { code: 'bad-request', message: 'Invalid message id' } });
941
- }
942
-
943
- const history = await getMessageEditHistory(mid);
944
- return res.json({
945
- status: { code: 'ok', message: 'Edit history retrieved' },
946
- ...history,
947
- });
948
- } catch (err) {
949
- return res.status(500).json({ status: { code: 'error', message: err.message } });
950
- }
951
- });
952
-
953
- router.get('/admin-chats/history/:mid', ...routeMiddleware, async (req, res) => {
954
- try {
955
- if (!await assertAdminChatsAccess(req, res)) {
956
- return;
957
- }
958
-
959
- const mid = parseInt(req.params.mid, 10);
960
- if (Number.isNaN(mid)) {
961
- return res.status(400).type('html').send('<h1>400</h1><p>Invalid message id</p>');
962
- }
963
-
964
- const history = await getMessageEditHistory(mid);
965
- return res.type('html').send(renderMessageHistoryHtml(history));
966
- } catch (err) {
967
- return res.status(500).type('html').send(`<h1>500</h1><pre>${validator.escape(String(err.message))}</pre>`);
968
- }
969
- });
970
- }
971
-
972
- async function getMessageEditHistory(mid) {
973
- const [exists, current, rawHistory] = await Promise.all([
974
- Messaging.messageExists(mid),
975
- Messaging.getMessageFields(mid, ['content', 'fromuid', 'timestamp', 'edited', 'roomId', 'deleted']),
976
- db.getListRange(editHistoryKey(mid), 0, -1),
977
- ]);
978
-
979
- const revisions = (rawHistory || []).map((item) => {
980
- try {
981
- return JSON.parse(item);
982
- } catch (err) {
983
- return null;
984
- }
985
- }).filter(Boolean);
986
-
987
- // Resolve usernames for the author and every editor in one lookup.
988
- const uidSet = new Set();
989
- const authorUid = parseInt(current && current.fromuid, 10) || 0;
990
- if (authorUid) {
991
- uidSet.add(authorUid);
992
- }
993
- revisions.forEach((rev) => {
994
- if (rev.editedByUid) {
995
- uidSet.add(parseInt(rev.editedByUid, 10) || 0);
996
- }
997
- if (rev.fromuid) {
998
- uidSet.add(parseInt(rev.fromuid, 10) || 0);
999
- }
1000
- });
1001
- uidSet.delete(0);
1002
-
1003
- const uids = [...uidSet];
1004
- const userMap = new Map();
1005
- if (uids.length) {
1006
- const users = await User.getUsersFields(uids, ['uid', 'username', 'userslug']);
1007
- uids.forEach((uid, index) => {
1008
- if (users[index]) {
1009
- userMap.set(uid, users[index]);
1010
- }
1011
- });
1012
- }
1013
-
1014
- const decorate = (uid) => {
1015
- const numeric = parseInt(uid, 10) || 0;
1016
- const user = userMap.get(numeric);
1017
- return {
1018
- uid: numeric,
1019
- username: user ? user.username : (numeric ? `uid ${numeric}` : '—'),
1020
- };
1021
- };
1022
-
1023
- return {
1024
- mid,
1025
- exists: !!exists,
1026
- roomId: parseInt(current && current.roomId, 10) || 0,
1027
- author: decorate(authorUid),
1028
- current: {
1029
- content: current ? String(current.content || '') : '',
1030
- edited: parseInt(current && current.edited, 10) || 0,
1031
- timestamp: parseInt(current && current.timestamp, 10) || 0,
1032
- deleted: parseInt(current && current.deleted, 10) === 1,
1033
- },
1034
- revisionCount: revisions.length,
1035
- revisions: revisions.map((rev) => ({
1036
- content: String(rev.content || ''),
1037
- editor: decorate(rev.editedByUid),
1038
- replacedAt: parseInt(rev.replacedAt, 10) || 0,
1039
- previousEdited: parseInt(rev.previousEdited, 10) || 0,
1040
- })),
1041
- };
1042
- }
1043
-
1044
- function renderMessageHistoryHtml(history) {
1045
- const esc = (value) => validator.escape(String(value == null ? '' : value));
1046
- const fmt = (ts) => {
1047
- const numeric = parseInt(ts, 10) || 0;
1048
- return numeric ? new Date(numeric).toISOString().replace('T', ' ').replace(/\..+$/, ' UTC') : '—';
1049
- };
1050
-
1051
- if (!history.exists) {
1052
- return `<!doctype html><html lang="he" dir="rtl"><meta charset="utf-8"><title>היסטוריית עריכות</title><body style="font-family:system-ui,Arial,sans-serif;padding:2rem;">
1053
- <h1>הודעה ${esc(history.mid)} לא נמצאה</h1></body></html>`;
1054
- }
1055
-
1056
- // Oldest version first (revisions are stored oldest→newest), then the
1057
- // current live version last, so the page reads top-to-bottom in time order.
1058
- const timeline = history.revisions.map((rev, index) => `
1059
- <li class="rev">
1060
- <div class="meta">גרסה ${index + 1} · הוחלפה ב-${esc(fmt(rev.replacedAt))} · עורך: ${esc(rev.editor.username)}</div>
1061
- <pre class="content">${esc(rev.content)}</pre>
1062
- </li>`).join('');
1063
-
1064
- const currentBlock = `
1065
- <li class="rev current">
1066
- <div class="meta">גרסה נוכחית${history.current.edited ? ` · נערכה לאחרונה ${esc(fmt(history.current.edited))}` : ''}${history.current.deleted ? ' · מחוקה' : ''}</div>
1067
- <pre class="content">${esc(history.current.content)}</pre>
1068
- </li>`;
1069
-
1070
- return `<!doctype html>
1071
- <html lang="he" dir="rtl">
1072
- <head>
1073
- <meta charset="utf-8">
1074
- <meta name="robots" content="noindex,nofollow">
1075
- <title>היסטוריית עריכות · הודעה ${esc(history.mid)}</title>
1076
- <style>
1077
- body { font-family: system-ui, Segoe UI, Arial, sans-serif; margin: 0; padding: 2rem; background: #f5f6f8; color: #1c1e21; }
1078
- h1 { font-size: 1.25rem; margin: 0 0 .25rem; }
1079
- .sub { color: #65676b; font-size: .875rem; margin-bottom: 1.5rem; }
1080
- ul { list-style: none; margin: 0; padding: 0; max-width: 780px; }
1081
- .rev { background: #fff; border: 1px solid #dcdfe3; border-radius: 8px; padding: .75rem 1rem; margin-bottom: 1rem; }
1082
- .rev.current { border-color: #2d6cdf; box-shadow: 0 0 0 1px #2d6cdf33; }
1083
- .meta { font-size: .8rem; color: #65676b; margin-bottom: .5rem; }
1084
- .content { white-space: pre-wrap; word-break: break-word; margin: 0; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .9rem; }
1085
- @media (prefers-color-scheme: dark) {
1086
- body { background: #18191a; color: #e4e6eb; }
1087
- .rev { background: #242526; border-color: #3a3b3c; }
1088
- .meta, .sub { color: #b0b3b8; }
1089
- }
1090
- </style>
1091
- </head>
1092
- <body>
1093
- <h1>היסטוריית עריכות · הודעה ${esc(history.mid)}</h1>
1094
- <div class="sub">מחבר: ${esc(history.author.username)} · חדר: ${esc(history.roomId)} · ${esc(history.revisionCount)} עריכות קודמות</div>
1095
- <ul>
1096
- ${history.revisionCount ? timeline : '<li class="rev"><div class="meta">אין עריכות קודמות מתועדות</div></li>'}
1097
- ${currentBlock}
1098
- </ul>
1099
- </body>
1100
- </html>`;
1101
- }
1102
-
1103
- async function renderAdminChatsPage(req, res, next) {
1104
- if (meta.config.disableChat) {
1105
- return next();
1106
- }
1107
-
1108
- if (!await assertAdminChatsAccess(req, res)) {
1109
- return;
1110
- }
1111
-
1112
- const payload = await buildAdminChatsPayload(req);
1113
- if (req.params.roomId && !payload.roomId) {
1114
- return next();
1115
- }
1116
-
1117
- res.render('chats', payload);
1118
- }
1119
-
1120
- async function assertAdminChatsAccess(req, res) {
1121
- if (!req.uid || !await canAccessAdminChats(req.uid)) {
1122
- helpers.notAllowed(req, res);
1123
- return false;
1124
- }
1125
-
1126
- return true;
1127
- }
1128
-
1129
- async function buildAdminChatsPayload(req, options = {}) {
1130
- // Like core (controllers/accounts/chats.js): when the client only switches
1131
- // rooms (?switch=1 on the JSON endpoint), skip rebuilding the room list —
1132
- // scanning every chat room on each switch made room-opening slow on mobile
1133
- const isSwitch = !!options.isSwitch;
1134
- const userslug = await User.getUserField(req.uid, 'userslug');
1135
- const [isAdmin, canView, canManage] = await Promise.all([
1136
- User.isAdministrator(req.uid),
1137
- privileges.global.can(ADMIN_CHATS_PRIVILEGE, req.uid),
1138
- privileges.global.can(ADMIN_CHATS_MANAGE_PRIVILEGE, req.uid),
1139
- ]);
1140
- const adminChatsManage = isAdmin || canManage;
1141
- const adminChatsAccess = isAdmin || canView || canManage;
1142
-
1143
- const payload = {
1144
- title: '[[pages:chats]]',
1145
- uid: req.uid,
1146
- userslug,
1147
- adminAllChats: true,
1148
- adminChatsAccess,
1149
- adminChatsIsAdmin: isAdmin,
1150
- adminChatsCanManage: adminChatsManage,
1151
- bodyClasses: ['page-user-chats'],
1152
- };
1153
-
1154
- if (!isSwitch) {
1155
- const [recentChats, publicRooms, privateRoomCount] = await Promise.all([
1156
- getAdminRecentChats(req.uid, 0, ADMIN_CHAT_PAGE_SIZE),
1157
- Messaging.getPublicRooms(req.uid, req.uid),
1158
- getPrivateRoomCount(),
1159
- ]);
1160
- payload.rooms = recentChats.rooms;
1161
- payload.nextStart = recentChats.nextStart;
1162
- payload.publicRooms = publicRooms || [];
1163
- payload.privateRoomCount = privateRoomCount;
1164
- }
1165
-
1166
- const roomId = parseInt(req.params.roomId, 10) || 0;
1167
- if (!roomId) {
1168
- return payload;
1169
- }
1170
-
1171
- const roomPayload = await buildAdminChatRoomPayload(req.uid, roomId, req.params.index);
1172
- if (!roomPayload) {
1173
- return payload;
1174
- }
1175
-
1176
- return {
1177
- ...payload,
1178
- ...roomPayload,
1179
- };
1180
- }
1181
-
1182
- async function buildAdminChatRoomPayload(uid, roomId, indexParam) {
1183
- let start = 0;
1184
- let scrollToIndex = null;
1185
-
1186
- if (indexParam) {
1187
- const msgCount = await db.getObjectField(`chat:room:${roomId}`, 'messageCount');
1188
- start = Math.max(0, parseInt(msgCount, 10) - parseInt(indexParam, 10) - 49);
1189
- scrollToIndex = Math.min(msgCount, Math.max(0, parseInt(indexParam, 10) || 1));
1190
- }
1191
-
1192
- const room = await Messaging.loadRoom(uid, {
1193
- uid,
1194
- roomId,
1195
- start,
1196
- });
1197
-
1198
- if (!room) {
1199
- return null;
1200
- }
1201
-
1202
- // Root fix for "message stays unread": core only clears unread state from the
1203
- // client when the room's nav element carries the `unread` class, which the
1204
- // admin list never sets. Mark the room read for the viewer on every load.
1205
- try {
1206
- await Messaging.markRead(uid, roomId);
1207
- await Messaging.pushUnreadCount(uid);
1208
- } catch (err) {
1209
- // never let read-state bookkeeping break room rendering
1210
- }
1211
-
1212
- const [canViewInfo, canUploadImage, canUploadFile] = await privileges.global.can([
1213
- 'view:users:info', 'upload:post:image', 'upload:post:file',
1214
- ], uid);
1215
-
1216
- room.title = room.roomName || room.usernames || '[[pages:chats]]';
1217
- room.bodyClasses = ['page-user-chats', 'chat-loaded'];
1218
- room.canViewInfo = canViewInfo;
1219
- room.canUpload = (canUploadImage || canUploadFile) && (meta.config.maximumFileSize > 0 || room.isAdmin);
1220
- room.scrollToIndex = scrollToIndex;
1221
-
1222
- return room;
1223
- }
1224
-
1225
- async function getPrivateRoomCount() {
1226
- const [totalCount, publicCount] = await Promise.all([
1227
- db.sortedSetCard('chat:rooms'),
1228
- db.sortedSetCard('chat:rooms:public'),
1229
- ]);
1230
-
1231
- return Math.max(0, (parseInt(totalCount, 10) || 0) - (parseInt(publicCount, 10) || 0));
1232
- }
1233
-
1234
- async function getAdminRecentChats(uid, start, limit) {
1235
- let cursor = Math.max(0, parseInt(start, 10) || 0);
1236
- const roomPairs = [];
1237
-
1238
- while (roomPairs.length < limit) {
1239
- const roomIds = await db.getSortedSetRevRange('chat:rooms', cursor, cursor + ADMIN_CHAT_SCAN_SIZE - 1);
1240
- if (!roomIds.length) {
1241
- break;
1242
- }
1243
-
1244
- const rooms = await Messaging.getRoomsData(roomIds);
1245
- rooms.forEach((room, index) => {
1246
- if (room && !room.public && roomPairs.length < limit) {
1247
- roomPairs.push({
1248
- roomId: roomIds[index],
1249
- room,
1250
- });
1251
- }
1252
- });
1253
-
1254
- cursor += roomIds.length;
1255
- if (roomIds.length < ADMIN_CHAT_SCAN_SIZE) {
1256
- break;
1257
- }
1258
- }
1259
-
1260
- const rooms = roomPairs.map(item => item.room);
1261
- const roomIds = roomPairs.map(item => item.roomId);
1262
-
1263
- await enrichAdminRecentRooms(uid, roomIds, rooms);
1264
-
1265
- return {
1266
- rooms,
1267
- nextStart: cursor,
1268
- };
1269
- }
1270
-
1271
- async function enrichAdminRecentRooms(uid, roomIds, rooms) {
1272
- if (!roomIds.length) {
1273
- return;
1274
- }
1275
-
1276
- const roomUsers = await Promise.all(roomIds.map(roomId => Messaging.getUidsInRoom(roomId, 0, -1)));
1277
- const uniqueUids = [...new Set(roomUsers.flat().filter(Boolean))];
1278
- const userMap = new Map();
1279
-
1280
- if (uniqueUids.length) {
1281
- const users = await User.getUsersFields(uniqueUids, [
1282
- 'uid', 'username', 'userslug', 'displayname', 'picture', 'status', 'lastonline',
1283
- ]);
1284
- uniqueUids.forEach((memberUid, index) => {
1285
- const userData = users[index];
1286
- if (userData) {
1287
- userData.status = User.getStatus(userData);
1288
- userMap.set(String(memberUid), userData);
1289
- }
1290
- });
1291
- }
1292
-
1293
- const teasers = await Promise.all(roomIds.map(roomId => getAdminRoomTeaser(roomId)));
1294
-
1295
- rooms.forEach((room, index) => {
1296
- if (!room) {
1297
- return;
1298
- }
1299
-
1300
- room.users = (roomUsers[index] || [])
1301
- .map(memberUid => userMap.get(String(memberUid)))
1302
- .filter(Boolean);
1303
- room.groupChat = room.userCount > 2;
1304
- room.unread = false;
1305
- room.teaser = teasers[index];
1306
- room.lastUser = room.users[0];
1307
- room.usernames = Messaging.generateUsernames(room, uid);
1308
- room.participantsLabel = buildParticipantsLabel(room.users);
1309
- room.icon = Messaging.getRoomIcon(room);
1310
- });
1311
- }
1312
-
1313
- function buildParticipantsLabel(users) {
1314
- const list = Array.isArray(users) ? users.filter(Boolean) : [];
1315
- if (!list.length) {
1316
- return '';
1317
- }
1318
-
1319
- const names = list.map(user => user.displayname || user.username).filter(Boolean);
1320
- if (names.length <= 5) {
1321
- return names.join(', ');
1322
- }
1323
-
1324
- return `${names.slice(0, 5).join(', ')} +${names.length - 5}`;
1325
- }
1326
-
1327
- async function getAdminRoomTeaser(roomId) {
1328
- const mids = await db.getSortedSetRevRange(`chat:room:${roomId}:mids`, 0, 19);
1329
- if (!mids.length) {
1330
- return null;
1331
- }
1332
-
1333
- const teaser = (await Messaging.getMessagesFields(mids, ['fromuid', 'content', 'timestamp', 'deleted', 'system']))
1334
- .find(message => message && !message.deleted && !message.system && message.fromuid);
1335
-
1336
- if (!teaser) {
1337
- return null;
1338
- }
1339
-
1340
- const teaserUser = await User.getUserFields(teaser.fromuid, [
1341
- 'uid', 'username', 'userslug', 'displayname', 'picture', 'status', 'lastonline',
1342
- ]);
1343
-
1344
- if (teaserUser) {
1345
- teaser.user = teaserUser;
1346
- }
1347
-
1348
- teaser.content = String(teaser.content || '')
1349
- .replace(/<[^>]*>/g, '')
1350
- .trim();
1351
- teaser.roomId = roomId;
1352
-
1353
- return teaser;
1354
- }
1355
-
1356
- async function canNonAdminModifyLockedRoom(uid, roomId) {
1357
- if (!uid || !roomId) {
1358
- return false;
1359
- }
1360
-
1361
- if (await User.isAdministrator(uid)) {
1362
- return false;
1363
- }
1364
-
1365
- if (await canManageAdminChats(uid)) {
1366
- return false;
1367
- }
1368
-
1369
- return await isRoomLocked(roomId);
1370
- }
1371
- async function roomExists(roomId) {
1372
- const roomData = await db.getObject(`chat:room:${roomId}`);
1373
- return !!roomData;
1374
- }
1375
-
1376
- async function lockRoom(roomId, actingUid) {
1377
- const now = Date.now();
1378
- await db.setObject(`chat:room:${roomId}:adminLock`, {
1379
- isLocked: 1,
1380
- lockedBy: actingUid,
1381
- lockedAt: now,
1382
- });
1383
-
1384
- return await getRoomLockData(roomId);
1385
- }
1386
-
1387
- async function unlockRoom(roomId, actingUid) {
1388
- await db.setObject(`chat:room:${roomId}:adminLock`, {
1389
- isLocked: 0,
1390
- lockedBy: actingUid,
1391
- lockedAt: 0,
1392
- });
1393
-
1394
- return await getRoomLockData(roomId);
1395
- }
1396
-
1397
- async function isRoomLocked(roomId) {
1398
- const field = await db.getObjectField(`chat:room:${roomId}:adminLock`, 'isLocked');
1399
- return field === '1' || field === 1 || field === true;
1400
- }
1401
-
1402
- async function getMessageRoomId(messageId) {
1403
- const roomId = await db.getObjectField(`message:${messageId}`, 'roomId');
1404
- return parseInt(roomId, 10) || 0;
1405
- }
1406
-
1407
- async function getRoomLockData(roomId) {
1408
- const data = await db.getObject(`chat:room:${roomId}:adminLock`) || {};
1409
- const lockedBy = parseInt(data.lockedBy, 10) || 0;
1410
- const lockedAt = parseInt(data.lockedAt, 10) || 0;
1411
- const isLockedValue = data.isLocked === '1' || data.isLocked === 1 || data.isLocked === true;
1412
-
1413
- return {
1414
- isLocked: isLockedValue,
1415
- lockedBy,
1416
- lockedAt,
1417
- };
1418
- }
1419
-
1420
- function ensureFirstVisibleMessageHeader(messages) {
1421
- const list = Array.isArray(messages) ? messages.slice() : [];
1422
- const firstUserMessageIndex = list.findIndex(msg => msg && !msg.system);
1423
-
1424
- if (firstUserMessageIndex !== -1) {
1425
- list[firstUserMessageIndex].newSet = true;
1426
- }
1427
-
1428
- return list;
1429
- }
1430
-
1431
- function buildRoomMessagesWithLockNotice(messages, lockData) {
1432
- const cleanedMessages = stripAdminLockMessages(messages);
1433
- if (!lockData || !lockData.isLocked || !lockData.lockedAt) {
1434
- return cleanedMessages;
1435
- }
1436
-
1437
- cleanedMessages.push({
1438
- content: `${LOCK_PREFIX} ${getDefaultLockMessage()}`,
1439
- fromuid: lockData.lockedBy,
1440
- uid: lockData.lockedBy,
1441
- timestamp: lockData.lockedAt,
1442
- datetime: lockData.lockedAt,
1443
- system: true,
1444
- newSet: true,
1445
- });
1446
-
1447
- cleanedMessages.sort((left, right) => {
1448
- const leftTime = parseInt(left && (left.timestamp || left.datetime), 10) || 0;
1449
- const rightTime = parseInt(right && (right.timestamp || right.datetime), 10) || 0;
1450
- return leftTime - rightTime;
1451
- });
1452
-
1453
- return cleanedMessages;
1454
- }
1455
-
1456
- function stripAdminLockMessages(messages) {
1457
- const list = Array.isArray(messages) ? messages.slice() : [];
1458
- return list.filter(msg => !isAdminLockMessage(msg));
1459
- }
1460
-
1461
- function isAdminLockMessage(msg) {
1462
- const content = String(msg && msg.content || '');
1463
- return content.includes('admin-chat-lock') ||
1464
- content.includes(getDefaultLockMessage()) ||
1465
- content.includes('נפתח מחדש ע"י המנהלים') ||
1466
- content.includes('modules:chat.system.[admin-chat-lock]');
1467
- }
1468
-
1469
- function guessRoomIdUidFromArgs(args) {
1470
- if (!Array.isArray(args) || !args.length) {
1471
- return { roomId: null, uid: null };
1472
- }
1473
-
1474
- if (args.length === 1 && args[0] && typeof args[0] === 'object') {
1475
- return {
1476
- roomId: getRoomId(args[0]),
1477
- uid: getCallerUid(args[0]),
1478
- };
1479
- }
1480
-
1481
- return {
1482
- roomId: args[0],
1483
- uid: args[1],
1484
- };
1485
- }
1486
-
1487
- function getCallerUid(payload) {
1488
- return payload && (
1489
- payload.uid ||
1490
- payload.callerUid ||
1491
- (payload.data && payload.data.uid) ||
1492
- (payload.message && payload.message.uid)
1493
- );
1494
- }
1495
-
1496
- function getRoomId(payload) {
1497
- return payload && (
1498
- payload.roomId ||
1499
- (payload.data && payload.data.roomId) ||
1500
- (payload.message && payload.message.roomId)
1501
- );
1502
- }
1503
-
1504
16
  module.exports = plugin;
1505
-
1506
-
1507
-
1508
-
1509
-
1510
-
1511
-
1512
-