nodebb-plugin-admin-chats 3.3.0 → 3.4.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/.github/workflows/npm-publish.yml +62 -0
- package/client.js +52 -29
- package/library.js +258 -38
- package/package.json +1 -1
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
name: Publish to npm on version bump
|
|
2
|
+
|
|
3
|
+
# Runs on every push to the default branch. If the version in package.json is
|
|
4
|
+
# not yet on the npm registry, it publishes the package. Bump the version in a
|
|
5
|
+
# commit and this workflow does the rest. Requires an NPM_TOKEN repository (or
|
|
6
|
+
# organization) secret; without it the publish step is skipped, not failed.
|
|
7
|
+
|
|
8
|
+
on:
|
|
9
|
+
push:
|
|
10
|
+
branches: [main, master]
|
|
11
|
+
|
|
12
|
+
permissions:
|
|
13
|
+
contents: read
|
|
14
|
+
|
|
15
|
+
concurrency:
|
|
16
|
+
group: npm-publish-${{ github.ref }}
|
|
17
|
+
cancel-in-progress: false
|
|
18
|
+
|
|
19
|
+
jobs:
|
|
20
|
+
publish:
|
|
21
|
+
runs-on: ubuntu-latest
|
|
22
|
+
steps:
|
|
23
|
+
- name: Checkout
|
|
24
|
+
uses: actions/checkout@v4
|
|
25
|
+
|
|
26
|
+
- name: Setup Node.js
|
|
27
|
+
uses: actions/setup-node@v4
|
|
28
|
+
with:
|
|
29
|
+
node-version: 20
|
|
30
|
+
registry-url: https://registry.npmjs.org
|
|
31
|
+
|
|
32
|
+
- name: Determine if this version needs publishing
|
|
33
|
+
id: check
|
|
34
|
+
run: |
|
|
35
|
+
NAME=$(node -p "require('./package.json').name")
|
|
36
|
+
VERSION=$(node -p "require('./package.json').version")
|
|
37
|
+
echo "Package: $NAME@$VERSION"
|
|
38
|
+
if npm view "$NAME@$VERSION" version >/dev/null 2>&1; then
|
|
39
|
+
echo "Version already on npm - nothing to do."
|
|
40
|
+
echo "publish=false" >> "$GITHUB_OUTPUT"
|
|
41
|
+
else
|
|
42
|
+
echo "Version not on npm - will publish."
|
|
43
|
+
echo "publish=true" >> "$GITHUB_OUTPUT"
|
|
44
|
+
fi
|
|
45
|
+
|
|
46
|
+
- name: Verify NPM_TOKEN is configured
|
|
47
|
+
id: token
|
|
48
|
+
env:
|
|
49
|
+
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
|
50
|
+
run: |
|
|
51
|
+
if [ -n "$NPM_TOKEN" ]; then
|
|
52
|
+
echo "set=true" >> "$GITHUB_OUTPUT"
|
|
53
|
+
else
|
|
54
|
+
echo "::warning::NPM_TOKEN secret is not set - skipping publish."
|
|
55
|
+
echo "set=false" >> "$GITHUB_OUTPUT"
|
|
56
|
+
fi
|
|
57
|
+
|
|
58
|
+
- name: Publish to npm
|
|
59
|
+
if: steps.check.outputs.publish == 'true' && steps.token.outputs.set == 'true'
|
|
60
|
+
run: npm publish --access public
|
|
61
|
+
env:
|
|
62
|
+
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
package/client.js
CHANGED
|
@@ -223,8 +223,11 @@ $(document).ready(function() {
|
|
|
223
223
|
}
|
|
224
224
|
|
|
225
225
|
roomId = roomId || '';
|
|
226
|
-
|
|
227
|
-
|
|
226
|
+
// NodeBB 4.14.9+ (56c6115a61): destroyAutoComplete takes the input element, not a roomId
|
|
227
|
+
Chats.destroyAutoComplete($('[component="chat/main-wrapper"]').find('[component="chat/input"]'));
|
|
228
|
+
if (ajaxify.data.roomId) {
|
|
229
|
+
socket.emit('modules.chats.leave', ajaxify.data.roomId);
|
|
230
|
+
}
|
|
228
231
|
|
|
229
232
|
const url = getAdminChatsPageUrl(roomId);
|
|
230
233
|
fetch(getAdminChatsDataUrl(roomId), { credentials: 'include' })
|
|
@@ -245,12 +248,18 @@ $(document).ready(function() {
|
|
|
245
248
|
mainWrapper.find('[data-bs-toggle="tooltip"]').tooltip({ trigger: 'hover', container: '#content' });
|
|
246
249
|
Chats.setActive(roomId);
|
|
247
250
|
Chats.addEventListeners();
|
|
248
|
-
|
|
251
|
+
// Pass the container like core does — listeners (e.g. the emoji
|
|
252
|
+
// plugin's chat button) resolve elements relative to it
|
|
253
|
+
$(window).trigger('action:chat.loaded', $('.chats-full'));
|
|
249
254
|
if (roomId) {
|
|
250
255
|
ChatsMessages.scrollToBottomAfterImageLoad(mainWrapper.find('[component="chat/message/content"]'));
|
|
251
256
|
}
|
|
252
257
|
if (history.pushState) {
|
|
253
|
-
|
|
258
|
+
const fullUrl = `${window.location.protocol}//${window.location.host}${config.relative_path || ''}/${url}`;
|
|
259
|
+
// When the address bar already shows the target (e.g. closing a chat
|
|
260
|
+
// after a back-gesture), replace instead of stacking a duplicate entry
|
|
261
|
+
const sameUrl = window.location.href.split('?')[0] === fullUrl.split('?')[0];
|
|
262
|
+
history[sameUrl ? 'replaceState' : 'pushState']({ url: url }, null, fullUrl);
|
|
254
263
|
}
|
|
255
264
|
bindAdminRecentChatsInfiniteScroll();
|
|
256
265
|
});
|
|
@@ -259,18 +268,31 @@ $(document).ready(function() {
|
|
|
259
268
|
Chats._adminAllChatsPatched = true;
|
|
260
269
|
}
|
|
261
270
|
|
|
262
|
-
// Auto-open chat if roomId is specified in URL
|
|
271
|
+
// Auto-open chat if roomId is specified in URL and the room is not
|
|
272
|
+
// already rendered (server-side render sets data-roomid on the wrapper —
|
|
273
|
+
// re-switching in that case caused a visible list->chat flash on mobile)
|
|
263
274
|
const currentRoomId = ajaxify && ajaxify.data && ajaxify.data.roomId;
|
|
264
|
-
|
|
275
|
+
const activeChatId = $('[component="chat/main-wrapper"]').attr('data-roomid') || '';
|
|
276
|
+
if (currentRoomId && String(currentRoomId) !== String(activeChatId)) {
|
|
265
277
|
setTimeout(function() {
|
|
266
278
|
Chats.switchChat(currentRoomId);
|
|
267
279
|
}, 100);
|
|
280
|
+
} else {
|
|
281
|
+
syncChatLoadedState();
|
|
268
282
|
}
|
|
269
283
|
|
|
270
284
|
bindAdminRecentChatsInfiniteScroll();
|
|
271
285
|
});
|
|
272
286
|
}
|
|
273
287
|
|
|
288
|
+
// Keep the mobile layout state (body.chat-loaded / nav data-loaded) in sync with
|
|
289
|
+
// the room that is actually rendered, without re-fetching it
|
|
290
|
+
function syncChatLoadedState() {
|
|
291
|
+
const activeChatId = parseInt($('[component="chat/main-wrapper"]').attr('data-roomid'), 10) || 0;
|
|
292
|
+
$('body').toggleClass('chat-loaded', !!activeChatId);
|
|
293
|
+
$('[component="chat/nav-wrapper"]').attr('data-loaded', activeChatId ? '1' : '0');
|
|
294
|
+
}
|
|
295
|
+
|
|
274
296
|
function replaceAdminEmptyStateText() {
|
|
275
297
|
if (!hasAdminChatsAccess()) {
|
|
276
298
|
return;
|
|
@@ -675,9 +697,10 @@ $(document).ready(function() {
|
|
|
675
697
|
patchForumChatsForAdminAll();
|
|
676
698
|
bindAdminRecentChatsInfiniteScroll();
|
|
677
699
|
|
|
678
|
-
// Auto-open chat if roomId is specified in URL and
|
|
700
|
+
// Auto-open chat if roomId is specified in URL and that room is not already rendered
|
|
679
701
|
const currentRoomId = ajaxify && ajaxify.data && ajaxify.data.roomId;
|
|
680
|
-
|
|
702
|
+
const activeChatId = $('[component="chat/main-wrapper"]').attr('data-roomid') || '';
|
|
703
|
+
if (currentRoomId && String(currentRoomId) !== String(activeChatId)) {
|
|
681
704
|
setTimeout(function() {
|
|
682
705
|
require(['forum/chats'], function(Chats) {
|
|
683
706
|
if (Chats && Chats.switchChat) {
|
|
@@ -685,6 +708,8 @@ $(document).ready(function() {
|
|
|
685
708
|
}
|
|
686
709
|
});
|
|
687
710
|
}, 200);
|
|
711
|
+
} else {
|
|
712
|
+
syncChatLoadedState();
|
|
688
713
|
}
|
|
689
714
|
}
|
|
690
715
|
|
|
@@ -718,6 +743,8 @@ $(document).ready(function() {
|
|
|
718
743
|
}
|
|
719
744
|
});
|
|
720
745
|
}, 100);
|
|
746
|
+
} else {
|
|
747
|
+
syncChatLoadedState();
|
|
721
748
|
}
|
|
722
749
|
}
|
|
723
750
|
refreshChatUi();
|
|
@@ -776,32 +803,28 @@ $(document).ready(function() {
|
|
|
776
803
|
}
|
|
777
804
|
});
|
|
778
805
|
|
|
779
|
-
// Handle URL changes for internal navigation
|
|
806
|
+
// Handle URL changes for internal navigation (including back/forward gestures).
|
|
807
|
+
// The URL is the source of truth here — ajaxify.data.roomId is stale after popstate.
|
|
780
808
|
function handleChatNavigation() {
|
|
781
809
|
if (!isAdminAllChatsPage()) {
|
|
782
810
|
return;
|
|
783
811
|
}
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
if (
|
|
790
|
-
|
|
791
|
-
if (urlMatch) {
|
|
792
|
-
currentRoomId = parseInt(urlMatch[1], 10);
|
|
793
|
-
}
|
|
794
|
-
}
|
|
795
|
-
|
|
796
|
-
const activeChatId = $('[component="chat/main-wrapper"]').attr('data-roomid');
|
|
797
|
-
|
|
798
|
-
if (currentRoomId && (!activeChatId || activeChatId !== String(currentRoomId))) {
|
|
799
|
-
require(['forum/chats'], function(Chats) {
|
|
800
|
-
if (Chats && Chats.switchChat) {
|
|
801
|
-
Chats.switchChat(currentRoomId);
|
|
802
|
-
}
|
|
803
|
-
});
|
|
812
|
+
|
|
813
|
+
const urlMatch = window.location.pathname.match(/\/chats\/(\d+)/);
|
|
814
|
+
const urlRoomId = urlMatch ? parseInt(urlMatch[1], 10) : 0;
|
|
815
|
+
const activeChatId = parseInt($('[component="chat/main-wrapper"]').attr('data-roomid'), 10) || 0;
|
|
816
|
+
|
|
817
|
+
if (urlRoomId === activeChatId) {
|
|
818
|
+
return;
|
|
804
819
|
}
|
|
820
|
+
|
|
821
|
+
require(['forum/chats'], function(Chats) {
|
|
822
|
+
if (Chats && Chats.switchChat) {
|
|
823
|
+
// urlRoomId 0 -> URL is the chat list: close the open chat and show the list
|
|
824
|
+
// (previously a back-gesture out of a chat left the chat stuck on screen)
|
|
825
|
+
Chats.switchChat(urlRoomId || '');
|
|
826
|
+
}
|
|
827
|
+
});
|
|
805
828
|
}
|
|
806
829
|
|
|
807
830
|
$(document).on('click', '[data-action="delete"], [data-action="restore"]', function() {
|
package/library.js
CHANGED
|
@@ -7,6 +7,7 @@ const db = require.main.require('./src/database');
|
|
|
7
7
|
const meta = require.main.require('./src/meta');
|
|
8
8
|
const privileges = require.main.require('./src/privileges');
|
|
9
9
|
const helpers = require.main.require('./src/controllers/helpers');
|
|
10
|
+
const validator = require.main.require('validator');
|
|
10
11
|
const fs = require('fs');
|
|
11
12
|
const path = require('path');
|
|
12
13
|
|
|
@@ -16,6 +17,15 @@ const LOCKED_MESSAGE_TEXT_KEY = 'lock.banner';
|
|
|
16
17
|
const ADMIN_CHATS_PRIVILEGE = 'admin-chats:view';
|
|
17
18
|
const ADMIN_CHATS_MANAGE_PRIVILEGE = 'admin-chats:manage';
|
|
18
19
|
|
|
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
|
+
|
|
19
29
|
// Load translations from language files
|
|
20
30
|
const translations = {};
|
|
21
31
|
function loadTranslations() {
|
|
@@ -113,6 +123,7 @@ async function getLockedActionMessage(uid) {
|
|
|
113
123
|
}
|
|
114
124
|
}
|
|
115
125
|
const ADMIN_CHAT_PAGE_SIZE = 30;
|
|
126
|
+
const ADMIN_CHAT_SCAN_SIZE = 100;
|
|
116
127
|
|
|
117
128
|
async function canAccessAdminChats(uid) {
|
|
118
129
|
if (!uid) {
|
|
@@ -639,6 +650,51 @@ function overrideMessagingFunctions() {
|
|
|
639
650
|
const originalCanViewMessage = Messaging.canViewMessage;
|
|
640
651
|
const originalCanReply = Messaging.canReply;
|
|
641
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
|
+
|
|
642
698
|
Messaging.canEdit = async function (messageId, uid) {
|
|
643
699
|
if (await User.isAdministrator(uid)) {
|
|
644
700
|
return true;
|
|
@@ -852,6 +908,185 @@ function registerRoutes(app, router, middleware) {
|
|
|
852
908
|
return res.status(500).json({ status: { code: 'error', message: err.message } });
|
|
853
909
|
}
|
|
854
910
|
});
|
|
911
|
+
|
|
912
|
+
// --- Hidden chat edit-history viewer ---------------------------------
|
|
913
|
+
// These two routes are intentionally NOT referenced from any template,
|
|
914
|
+
// client script, menu or API payload. They do not appear in the admin
|
|
915
|
+
// chats UI, so a "regular" administrator who has not read this source
|
|
916
|
+
// will never encounter them. They are still protected by
|
|
917
|
+
// assertAdminChatsAccess(), so only privileged accounts can read data
|
|
918
|
+
// even if the URL is discovered. Paths:
|
|
919
|
+
// GET /api/admin-chats/history/:mid -> JSON
|
|
920
|
+
// GET /admin-chats/history/:mid -> standalone HTML page
|
|
921
|
+
router.get('/api/admin-chats/history/:mid', ...routeMiddleware, async (req, res) => {
|
|
922
|
+
try {
|
|
923
|
+
if (!await assertAdminChatsAccess(req, res)) {
|
|
924
|
+
return;
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
const mid = parseInt(req.params.mid, 10);
|
|
928
|
+
if (Number.isNaN(mid)) {
|
|
929
|
+
return res.status(400).json({ status: { code: 'bad-request', message: 'Invalid message id' } });
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
const history = await getMessageEditHistory(mid);
|
|
933
|
+
return res.json({
|
|
934
|
+
status: { code: 'ok', message: 'Edit history retrieved' },
|
|
935
|
+
...history,
|
|
936
|
+
});
|
|
937
|
+
} catch (err) {
|
|
938
|
+
return res.status(500).json({ status: { code: 'error', message: err.message } });
|
|
939
|
+
}
|
|
940
|
+
});
|
|
941
|
+
|
|
942
|
+
router.get('/admin-chats/history/:mid', ...routeMiddleware, async (req, res) => {
|
|
943
|
+
try {
|
|
944
|
+
if (!await assertAdminChatsAccess(req, res)) {
|
|
945
|
+
return;
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
const mid = parseInt(req.params.mid, 10);
|
|
949
|
+
if (Number.isNaN(mid)) {
|
|
950
|
+
return res.status(400).type('html').send('<h1>400</h1><p>Invalid message id</p>');
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
const history = await getMessageEditHistory(mid);
|
|
954
|
+
return res.type('html').send(renderMessageHistoryHtml(history));
|
|
955
|
+
} catch (err) {
|
|
956
|
+
return res.status(500).type('html').send(`<h1>500</h1><pre>${validator.escape(String(err.message))}</pre>`);
|
|
957
|
+
}
|
|
958
|
+
});
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
async function getMessageEditHistory(mid) {
|
|
962
|
+
const [exists, current, rawHistory] = await Promise.all([
|
|
963
|
+
Messaging.messageExists(mid),
|
|
964
|
+
Messaging.getMessageFields(mid, ['content', 'fromuid', 'timestamp', 'edited', 'roomId', 'deleted']),
|
|
965
|
+
db.getListRange(editHistoryKey(mid), 0, -1),
|
|
966
|
+
]);
|
|
967
|
+
|
|
968
|
+
const revisions = (rawHistory || []).map((item) => {
|
|
969
|
+
try {
|
|
970
|
+
return JSON.parse(item);
|
|
971
|
+
} catch (err) {
|
|
972
|
+
return null;
|
|
973
|
+
}
|
|
974
|
+
}).filter(Boolean);
|
|
975
|
+
|
|
976
|
+
// Resolve usernames for the author and every editor in one lookup.
|
|
977
|
+
const uidSet = new Set();
|
|
978
|
+
const authorUid = parseInt(current && current.fromuid, 10) || 0;
|
|
979
|
+
if (authorUid) {
|
|
980
|
+
uidSet.add(authorUid);
|
|
981
|
+
}
|
|
982
|
+
revisions.forEach((rev) => {
|
|
983
|
+
if (rev.editedByUid) {
|
|
984
|
+
uidSet.add(parseInt(rev.editedByUid, 10) || 0);
|
|
985
|
+
}
|
|
986
|
+
if (rev.fromuid) {
|
|
987
|
+
uidSet.add(parseInt(rev.fromuid, 10) || 0);
|
|
988
|
+
}
|
|
989
|
+
});
|
|
990
|
+
uidSet.delete(0);
|
|
991
|
+
|
|
992
|
+
const uids = [...uidSet];
|
|
993
|
+
const userMap = new Map();
|
|
994
|
+
if (uids.length) {
|
|
995
|
+
const users = await User.getUsersFields(uids, ['uid', 'username', 'userslug']);
|
|
996
|
+
uids.forEach((uid, index) => {
|
|
997
|
+
if (users[index]) {
|
|
998
|
+
userMap.set(uid, users[index]);
|
|
999
|
+
}
|
|
1000
|
+
});
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
const decorate = (uid) => {
|
|
1004
|
+
const numeric = parseInt(uid, 10) || 0;
|
|
1005
|
+
const user = userMap.get(numeric);
|
|
1006
|
+
return {
|
|
1007
|
+
uid: numeric,
|
|
1008
|
+
username: user ? user.username : (numeric ? `uid ${numeric}` : '—'),
|
|
1009
|
+
};
|
|
1010
|
+
};
|
|
1011
|
+
|
|
1012
|
+
return {
|
|
1013
|
+
mid,
|
|
1014
|
+
exists: !!exists,
|
|
1015
|
+
roomId: parseInt(current && current.roomId, 10) || 0,
|
|
1016
|
+
author: decorate(authorUid),
|
|
1017
|
+
current: {
|
|
1018
|
+
content: current ? String(current.content || '') : '',
|
|
1019
|
+
edited: parseInt(current && current.edited, 10) || 0,
|
|
1020
|
+
timestamp: parseInt(current && current.timestamp, 10) || 0,
|
|
1021
|
+
deleted: parseInt(current && current.deleted, 10) === 1,
|
|
1022
|
+
},
|
|
1023
|
+
revisionCount: revisions.length,
|
|
1024
|
+
revisions: revisions.map((rev) => ({
|
|
1025
|
+
content: String(rev.content || ''),
|
|
1026
|
+
editor: decorate(rev.editedByUid),
|
|
1027
|
+
replacedAt: parseInt(rev.replacedAt, 10) || 0,
|
|
1028
|
+
previousEdited: parseInt(rev.previousEdited, 10) || 0,
|
|
1029
|
+
})),
|
|
1030
|
+
};
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
function renderMessageHistoryHtml(history) {
|
|
1034
|
+
const esc = (value) => validator.escape(String(value == null ? '' : value));
|
|
1035
|
+
const fmt = (ts) => {
|
|
1036
|
+
const numeric = parseInt(ts, 10) || 0;
|
|
1037
|
+
return numeric ? new Date(numeric).toISOString().replace('T', ' ').replace(/\..+$/, ' UTC') : '—';
|
|
1038
|
+
};
|
|
1039
|
+
|
|
1040
|
+
if (!history.exists) {
|
|
1041
|
+
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;">
|
|
1042
|
+
<h1>הודעה ${esc(history.mid)} לא נמצאה</h1></body></html>`;
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
// Oldest version first (revisions are stored oldest→newest), then the
|
|
1046
|
+
// current live version last, so the page reads top-to-bottom in time order.
|
|
1047
|
+
const timeline = history.revisions.map((rev, index) => `
|
|
1048
|
+
<li class="rev">
|
|
1049
|
+
<div class="meta">גרסה ${index + 1} · הוחלפה ב-${esc(fmt(rev.replacedAt))} · עורך: ${esc(rev.editor.username)}</div>
|
|
1050
|
+
<pre class="content">${esc(rev.content)}</pre>
|
|
1051
|
+
</li>`).join('');
|
|
1052
|
+
|
|
1053
|
+
const currentBlock = `
|
|
1054
|
+
<li class="rev current">
|
|
1055
|
+
<div class="meta">גרסה נוכחית${history.current.edited ? ` · נערכה לאחרונה ${esc(fmt(history.current.edited))}` : ''}${history.current.deleted ? ' · מחוקה' : ''}</div>
|
|
1056
|
+
<pre class="content">${esc(history.current.content)}</pre>
|
|
1057
|
+
</li>`;
|
|
1058
|
+
|
|
1059
|
+
return `<!doctype html>
|
|
1060
|
+
<html lang="he" dir="rtl">
|
|
1061
|
+
<head>
|
|
1062
|
+
<meta charset="utf-8">
|
|
1063
|
+
<meta name="robots" content="noindex,nofollow">
|
|
1064
|
+
<title>היסטוריית עריכות · הודעה ${esc(history.mid)}</title>
|
|
1065
|
+
<style>
|
|
1066
|
+
body { font-family: system-ui, Segoe UI, Arial, sans-serif; margin: 0; padding: 2rem; background: #f5f6f8; color: #1c1e21; }
|
|
1067
|
+
h1 { font-size: 1.25rem; margin: 0 0 .25rem; }
|
|
1068
|
+
.sub { color: #65676b; font-size: .875rem; margin-bottom: 1.5rem; }
|
|
1069
|
+
ul { list-style: none; margin: 0; padding: 0; max-width: 780px; }
|
|
1070
|
+
.rev { background: #fff; border: 1px solid #dcdfe3; border-radius: 8px; padding: .75rem 1rem; margin-bottom: 1rem; }
|
|
1071
|
+
.rev.current { border-color: #2d6cdf; box-shadow: 0 0 0 1px #2d6cdf33; }
|
|
1072
|
+
.meta { font-size: .8rem; color: #65676b; margin-bottom: .5rem; }
|
|
1073
|
+
.content { white-space: pre-wrap; word-break: break-word; margin: 0; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .9rem; }
|
|
1074
|
+
@media (prefers-color-scheme: dark) {
|
|
1075
|
+
body { background: #18191a; color: #e4e6eb; }
|
|
1076
|
+
.rev { background: #242526; border-color: #3a3b3c; }
|
|
1077
|
+
.meta, .sub { color: #b0b3b8; }
|
|
1078
|
+
}
|
|
1079
|
+
</style>
|
|
1080
|
+
</head>
|
|
1081
|
+
<body>
|
|
1082
|
+
<h1>היסטוריית עריכות · הודעה ${esc(history.mid)}</h1>
|
|
1083
|
+
<div class="sub">מחבר: ${esc(history.author.username)} · חדר: ${esc(history.roomId)} · ${esc(history.revisionCount)} עריכות קודמות</div>
|
|
1084
|
+
<ul>
|
|
1085
|
+
${history.revisionCount ? timeline : '<li class="rev"><div class="meta">אין עריכות קודמות מתועדות</div></li>'}
|
|
1086
|
+
${currentBlock}
|
|
1087
|
+
</ul>
|
|
1088
|
+
</body>
|
|
1089
|
+
</html>`;
|
|
855
1090
|
}
|
|
856
1091
|
|
|
857
1092
|
async function renderAdminChatsPage(req, res, next) {
|
|
@@ -969,57 +1204,42 @@ async function getPrivateRoomCount() {
|
|
|
969
1204
|
}
|
|
970
1205
|
|
|
971
1206
|
async function getAdminRecentChats(uid, start, limit) {
|
|
972
|
-
|
|
973
|
-
const
|
|
974
|
-
const allRoomIds = await db.getSortedSetRevRange('chat:rooms', 0, -1);
|
|
1207
|
+
let cursor = Math.max(0, parseInt(start, 10) || 0);
|
|
1208
|
+
const roomPairs = [];
|
|
975
1209
|
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
};
|
|
981
|
-
}
|
|
982
|
-
|
|
983
|
-
const allRooms = await Messaging.getRoomsData(allRoomIds);
|
|
984
|
-
const roomPairs = (await Promise.all(allRooms.map(async (room, index) => {
|
|
985
|
-
const roomId = parseInt(allRoomIds[index], 10) || 0;
|
|
986
|
-
if (!room || room.public || !roomId) {
|
|
987
|
-
return null;
|
|
1210
|
+
while (roomPairs.length < limit) {
|
|
1211
|
+
const roomIds = await db.getSortedSetRevRange('chat:rooms', cursor, cursor + ADMIN_CHAT_SCAN_SIZE - 1);
|
|
1212
|
+
if (!roomIds.length) {
|
|
1213
|
+
break;
|
|
988
1214
|
}
|
|
989
1215
|
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
room
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
1216
|
+
const rooms = await Messaging.getRoomsData(roomIds);
|
|
1217
|
+
rooms.forEach((room, index) => {
|
|
1218
|
+
if (room && !room.public && roomPairs.length < limit) {
|
|
1219
|
+
roomPairs.push({
|
|
1220
|
+
roomId: roomIds[index],
|
|
1221
|
+
room,
|
|
1222
|
+
});
|
|
1223
|
+
}
|
|
1224
|
+
});
|
|
996
1225
|
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1226
|
+
cursor += roomIds.length;
|
|
1227
|
+
if (roomIds.length < ADMIN_CHAT_SCAN_SIZE) {
|
|
1228
|
+
break;
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1001
1231
|
|
|
1002
|
-
const
|
|
1003
|
-
const
|
|
1004
|
-
const roomIds = pagedRooms.map(item => item.roomId);
|
|
1232
|
+
const rooms = roomPairs.map(item => item.room);
|
|
1233
|
+
const roomIds = roomPairs.map(item => item.roomId);
|
|
1005
1234
|
|
|
1006
1235
|
await enrichAdminRecentRooms(uid, roomIds, rooms);
|
|
1007
1236
|
|
|
1008
1237
|
return {
|
|
1009
1238
|
rooms,
|
|
1010
|
-
nextStart:
|
|
1239
|
+
nextStart: cursor,
|
|
1011
1240
|
};
|
|
1012
1241
|
}
|
|
1013
1242
|
|
|
1014
|
-
async function getAdminRoomLastActivity(roomId, room) {
|
|
1015
|
-
const lastMessage = await db.getSortedSetRevRangeWithScores(`chat:room:${roomId}:mids`, 0, 0);
|
|
1016
|
-
if (lastMessage.length) {
|
|
1017
|
-
return parseInt(lastMessage[0].score, 10) || 0;
|
|
1018
|
-
}
|
|
1019
|
-
|
|
1020
|
-
return parseInt(room && room.timestamp, 10) || 0;
|
|
1021
|
-
}
|
|
1022
|
-
|
|
1023
1243
|
async function enrichAdminRecentRooms(uid, roomIds, rooms) {
|
|
1024
1244
|
if (!roomIds.length) {
|
|
1025
1245
|
return;
|