nodebb-plugin-admin-chats 3.6.0 → 3.7.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.
- package/lib/activity.js +88 -0
- package/lib/hooks.js +7 -1
- package/lib/routes.js +18 -20
- package/library.js +3 -0
- package/package.json +1 -1
- package/static/lib/profile-link.js +25 -7
package/lib/activity.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Last-activity ordering for the admin chat list. Core only keeps such
|
|
4
|
+
// ordering per user (`uid:<uid>:chat:rooms`, bumped on every message) — the
|
|
5
|
+
// global `chat:rooms` set is scored once at room creation and never again,
|
|
6
|
+
// so listing from it sorts by creation date and buries active rooms. This
|
|
7
|
+
// module maintains a plugin-owned set scored like the per-user one.
|
|
8
|
+
|
|
9
|
+
const Messaging = nodebb.require('./src/messaging');
|
|
10
|
+
const db = nodebb.require('./src/database');
|
|
11
|
+
|
|
12
|
+
const ACTIVITY_SET = 'admin-chats:rooms:lastpost';
|
|
13
|
+
const BACKFILL_BATCH_SIZE = 100;
|
|
14
|
+
|
|
15
|
+
function overrideForActivityTracking() {
|
|
16
|
+
if (typeof Messaging.addRoomToUsers === 'function' && !Messaging.addRoomToUsers._adminChatsActivityWrapped) {
|
|
17
|
+
const originalAddRoomToUsers = Messaging.addRoomToUsers;
|
|
18
|
+
|
|
19
|
+
// addRoomToUsers is core's own "bump this private room" signal — it runs
|
|
20
|
+
// on private room creation (src/messaging/rooms.js) and on every message
|
|
21
|
+
// (src/messaging/create.js), always with the activity timestamp.
|
|
22
|
+
const wrappedAddRoomToUsers = async function (roomId, uids, timestamp) {
|
|
23
|
+
await db.sortedSetAdd(ACTIVITY_SET, timestamp, roomId);
|
|
24
|
+
return await originalAddRoomToUsers.call(this, roomId, uids, timestamp);
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
wrappedAddRoomToUsers._adminChatsActivityWrapped = true;
|
|
28
|
+
Messaging.addRoomToUsers = wrappedAddRoomToUsers;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (typeof Messaging.deleteRooms === 'function' && !Messaging.deleteRooms._adminChatsActivityWrapped) {
|
|
32
|
+
const originalDeleteRooms = Messaging.deleteRooms;
|
|
33
|
+
|
|
34
|
+
const wrappedDeleteRooms = async function (roomIds) {
|
|
35
|
+
const result = await originalDeleteRooms.call(this, roomIds);
|
|
36
|
+
await db.sortedSetRemove(ACTIVITY_SET, Array.isArray(roomIds) ? roomIds : [roomIds]);
|
|
37
|
+
return result;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
wrappedDeleteRooms._adminChatsActivityWrapped = true;
|
|
41
|
+
Messaging.deleteRooms = wrappedDeleteRooms;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Reconcile on startup: private rooms not yet in the activity set (created
|
|
46
|
+
// before this feature existed, or while the plugin was disabled) get scored
|
|
47
|
+
// by their last message's timestamp, falling back to room creation time.
|
|
48
|
+
async function backfillActivitySet() {
|
|
49
|
+
const allRooms = await db.getSortedSetRangeWithScores('chat:rooms', 0, -1);
|
|
50
|
+
if (!allRooms.length) {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const roomIds = allRooms.map(item => item.value);
|
|
55
|
+
const [inActivitySet, isPublic] = await Promise.all([
|
|
56
|
+
db.isSortedSetMembers(ACTIVITY_SET, roomIds),
|
|
57
|
+
db.isSortedSetMembers('chat:rooms:public', roomIds),
|
|
58
|
+
]);
|
|
59
|
+
|
|
60
|
+
const missing = allRooms.filter((item, index) => !inActivitySet[index] && !isPublic[index]);
|
|
61
|
+
|
|
62
|
+
for (let offset = 0; offset < missing.length; offset += BACKFILL_BATCH_SIZE) {
|
|
63
|
+
const batch = missing.slice(offset, offset + BACKFILL_BATCH_SIZE);
|
|
64
|
+
const lastMessages = await Promise.all(
|
|
65
|
+
batch.map(item => db.getSortedSetRevRangeWithScores(`chat:room:${item.value}:mids`, 0, 0))
|
|
66
|
+
);
|
|
67
|
+
await db.sortedSetAddBulk(batch.map((item, index) => [
|
|
68
|
+
ACTIVITY_SET,
|
|
69
|
+
(lastMessages[index][0] && lastMessages[index][0].score) || item.score,
|
|
70
|
+
item.value,
|
|
71
|
+
]));
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Entries whose room no longer resolves (deleted outside deleteRooms, or
|
|
76
|
+
// before this module wrapped it) are pruned lazily by the room list.
|
|
77
|
+
async function pruneActivityEntries(roomIds) {
|
|
78
|
+
if (roomIds.length) {
|
|
79
|
+
await db.sortedSetRemove(ACTIVITY_SET, roomIds);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
module.exports = {
|
|
84
|
+
ACTIVITY_SET,
|
|
85
|
+
overrideForActivityTracking,
|
|
86
|
+
backfillActivitySet,
|
|
87
|
+
pruneActivityEntries,
|
|
88
|
+
};
|
package/lib/hooks.js
CHANGED
|
@@ -94,13 +94,19 @@ hooks.addProfileLink = async function (data) {
|
|
|
94
94
|
return data;
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
+
// Nothing to moderate on your own profile — /user/<self>/chats is just the
|
|
98
|
+
// regular chat list, already reachable from the main menu.
|
|
99
|
+
if (parseInt(data.uid, 10) === parseInt(viewerUid, 10)) {
|
|
100
|
+
return data;
|
|
101
|
+
}
|
|
102
|
+
|
|
97
103
|
data.links.push({
|
|
98
104
|
id: 'admin-view-chats',
|
|
99
105
|
route: 'chats',
|
|
100
106
|
icon: 'fa-comments',
|
|
101
107
|
name: '[[admin-chats:profile.viewChats]]',
|
|
102
108
|
visibility: {
|
|
103
|
-
self:
|
|
109
|
+
self: false, other: true, moderator: true, globalMod: true, admin: true,
|
|
104
110
|
},
|
|
105
111
|
});
|
|
106
112
|
|
package/lib/routes.js
CHANGED
|
@@ -11,9 +11,9 @@ const sockets = nodebb.require('./src/socket.io');
|
|
|
11
11
|
const { canAccessAdminChats, canManageAdminChats } = require('./access');
|
|
12
12
|
const { getMessageEditHistory, deleteRevision } = require('./history');
|
|
13
13
|
const { roomExists, lockRoom, unlockRoom, getRoomLockData } = require('./lock');
|
|
14
|
+
const { ACTIVITY_SET, pruneActivityEntries } = require('./activity');
|
|
14
15
|
|
|
15
16
|
const ADMIN_CHAT_PAGE_SIZE = 30;
|
|
16
|
-
const ADMIN_CHAT_SCAN_SIZE = 100;
|
|
17
17
|
|
|
18
18
|
async function assertAdminChatsAccess(req, res) {
|
|
19
19
|
if (!req.uid || !await canAccessAdminChats(req.uid)) {
|
|
@@ -132,34 +132,32 @@ async function getPrivateRoomCount() {
|
|
|
132
132
|
}
|
|
133
133
|
|
|
134
134
|
async function getAdminRecentChats(uid, start, limit) {
|
|
135
|
-
|
|
136
|
-
const roomPairs = [];
|
|
137
|
-
|
|
138
|
-
while (roomPairs.length < limit) {
|
|
139
|
-
const roomIds = await db.getSortedSetRevRange('chat:rooms', cursor, cursor + ADMIN_CHAT_SCAN_SIZE - 1);
|
|
140
|
-
if (!roomIds.length) {
|
|
141
|
-
break;
|
|
142
|
-
}
|
|
135
|
+
const cursor = Math.max(0, parseInt(start, 10) || 0);
|
|
143
136
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
});
|
|
137
|
+
// The activity set is scored by each room's last message (see lib/activity.js),
|
|
138
|
+
// so a plain reverse range yields the same most-recent-first ordering the
|
|
139
|
+
// user's own chats page gets from `uid:<uid>:chat:rooms`.
|
|
140
|
+
const allRoomIds = await db.getSortedSetRevRange(ACTIVITY_SET, cursor, cursor + limit - 1);
|
|
141
|
+
const allRooms = await Messaging.getRoomsData(allRoomIds);
|
|
150
142
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
143
|
+
const roomPairs = [];
|
|
144
|
+
const staleRoomIds = [];
|
|
145
|
+
allRooms.forEach((room, index) => {
|
|
146
|
+
if (room && !room.public) {
|
|
147
|
+
roomPairs.push({ roomId: allRoomIds[index], room });
|
|
148
|
+
} else if (!room) {
|
|
149
|
+
staleRoomIds.push(allRoomIds[index]);
|
|
154
150
|
}
|
|
155
|
-
}
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
await pruneActivityEntries(staleRoomIds);
|
|
156
154
|
|
|
157
155
|
const rooms = roomPairs.map(item => item.room);
|
|
158
156
|
const roomIds = roomPairs.map(item => item.roomId);
|
|
159
157
|
|
|
160
158
|
await enrichAdminRecentRooms(uid, roomIds, rooms);
|
|
161
159
|
|
|
162
|
-
return { rooms, nextStart: cursor };
|
|
160
|
+
return { rooms, nextStart: cursor + allRoomIds.length };
|
|
163
161
|
}
|
|
164
162
|
|
|
165
163
|
async function enrichAdminRecentRooms(uid, roomIds, rooms) {
|
package/library.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
const hooks = require('./lib/hooks');
|
|
4
4
|
const { registerRoutes, renderAdminChatsPage } = require('./lib/routes');
|
|
5
5
|
const { overrideMessagingFunctions, overrideChatsApi, overrideCoreChatRedirect } = require('./lib/overrides');
|
|
6
|
+
const { overrideForActivityTracking, backfillActivitySet } = require('./lib/activity');
|
|
6
7
|
|
|
7
8
|
const plugin = Object.assign({}, hooks);
|
|
8
9
|
|
|
@@ -11,6 +12,8 @@ plugin.init = async function (params) {
|
|
|
11
12
|
overrideMessagingFunctions();
|
|
12
13
|
overrideChatsApi();
|
|
13
14
|
overrideCoreChatRedirect(params.controllers, renderAdminChatsPage);
|
|
15
|
+
overrideForActivityTracking();
|
|
16
|
+
await backfillActivitySet();
|
|
14
17
|
};
|
|
15
18
|
|
|
16
19
|
module.exports = plugin;
|
package/package.json
CHANGED
|
@@ -28,7 +28,18 @@ $(document).ready(function () {
|
|
|
28
28
|
}
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
// Drop everything a previous run of this function left behind. Runs can
|
|
32
|
+
// overlap (ajaxify.end may fire more than once per page) and each one
|
|
33
|
+
// awaits, so without this a bail-out — or a second run — leaves an orphan
|
|
34
|
+
// gear button on the page.
|
|
35
|
+
function removePreviousInjection() {
|
|
36
|
+
$('.admin-chats-privileges-menu').remove();
|
|
37
|
+
$('.admin-chats-profile-link, .admin-chats-profile-divider').remove();
|
|
38
|
+
}
|
|
39
|
+
|
|
31
40
|
async function injectProfileChatsLink() {
|
|
41
|
+
removePreviousInjection();
|
|
42
|
+
|
|
32
43
|
const isUserProfile = $('.account').length && window.location.pathname.includes('/user/');
|
|
33
44
|
if (!isUserProfile || !await resolveAdminChatsAccess()) {
|
|
34
45
|
return;
|
|
@@ -39,6 +50,15 @@ $(document).ready(function () {
|
|
|
39
50
|
return;
|
|
40
51
|
}
|
|
41
52
|
|
|
53
|
+
// Own profile: /user/<self>/chats is just the regular chat list, so the
|
|
54
|
+
// moderation entry point has nothing to add there. Harmony doesn't
|
|
55
|
+
// render its own gear menu here either ({{{ if !isSelf }}}), so bailing
|
|
56
|
+
// out has to leave no button behind at all.
|
|
57
|
+
const profileUid = parseInt(ajaxify.data.uid || (ajaxify.data.user && ajaxify.data.user.uid), 10);
|
|
58
|
+
if (profileUid && parseInt(app.user.uid, 10) === profileUid) {
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
42
62
|
const relativePath = config.relative_path || '';
|
|
43
63
|
// Injected via jQuery rather than a template, so [[...]] needs an
|
|
44
64
|
// explicit translator call here — nothing else will resolve it.
|
|
@@ -56,11 +76,13 @@ $(document).ready(function () {
|
|
|
56
76
|
</li>
|
|
57
77
|
`;
|
|
58
78
|
|
|
59
|
-
|
|
79
|
+
const menu = $('.account-sub-links').first();
|
|
60
80
|
if (!menu.length) {
|
|
61
81
|
// No admin/ban/mute menu was rendered for this viewer (they only
|
|
62
82
|
// hold the admin-chats privilege) — build a minimal dropdown of
|
|
63
|
-
// our own next to the other profile action buttons.
|
|
83
|
+
// our own next to the other profile action buttons. The item goes
|
|
84
|
+
// in before the dropdown is attached, so a half-built gear can
|
|
85
|
+
// never end up on the page.
|
|
64
86
|
const container = $('.account .flex-shrink-0.d-flex.gap-1').first();
|
|
65
87
|
const fallbackContainer = container.length ? container : $('.account .flex-shrink-0').first();
|
|
66
88
|
|
|
@@ -70,14 +92,10 @@ $(document).ready(function () {
|
|
|
70
92
|
<button type="button" class="btn btn-light dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
|
71
93
|
<i class="fa fa-gear fa-fw"></i>
|
|
72
94
|
</button>
|
|
73
|
-
<ul class="dropdown-menu dropdown-menu-end p-1 text-sm account-sub-links" role="menu"
|
|
95
|
+
<ul class="dropdown-menu dropdown-menu-end p-1 text-sm account-sub-links" role="menu">${menuItemHtml}</ul>
|
|
74
96
|
</div>
|
|
75
97
|
`);
|
|
76
|
-
menu = fallbackContainer.find('.account-sub-links').last();
|
|
77
98
|
}
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
if (!menu.length) {
|
|
81
99
|
return;
|
|
82
100
|
}
|
|
83
101
|
|