nodebb-plugin-chat-search 1.2.0 → 1.4.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/README.md ADDED
@@ -0,0 +1,77 @@
1
+ # NodeBB Global Chat Search
2
+
3
+ Standard chat search in NodeBB works well when you already know which room the message is in. But sometimes you remember the message — not the chat.
4
+
5
+ **NodeBB Global Chat Search** solves this by adding a global search bar to the chat sidebar, allowing you to search across **all conversations you have ever participated in**.
6
+
7
+ ---
8
+
9
+ ## 🚀 Features
10
+
11
+ **Global Context**
12
+ Searches across all room IDs associated with your user account.
13
+
14
+ **Performance Focused**
15
+ Messages are fetched in batches of 50 to keep the server responsive even during deep searches.
16
+
17
+ **Sticky UI**
18
+ Your search query and results remain visible when navigating between chat rooms.
19
+
20
+ **Smart Navigation**
21
+ Clicking a search result scrolls directly to the message and highlights it with a smooth transition.
22
+
23
+ **Rich Previews**
24
+ Results include room names, sender avatars, and timestamps, matching the native NodeBB interface.
25
+
26
+ ---
27
+
28
+ ## 🛠 Technical Details
29
+
30
+ **Hooks Used**
31
+
32
+ - `static:app.load` — server initialization
33
+ - `filter:scripts.client` — injecting the search interface into the chat UI
34
+
35
+ **DOM Management**
36
+
37
+ Uses a `MutationObserver` to ensure the search bar is injected correctly regardless of how the chat page loads.
38
+
39
+ **State Management**
40
+
41
+ Implements `window.chatSearchState` so search results persist during Ajaxify navigation.
42
+
43
+ **Compatibility**
44
+
45
+ Built for NodeBB **^3.0.0**
46
+
47
+ ---
48
+
49
+ ## 📥 Installation
50
+
51
+ Install the plugin via terminal:
52
+
53
+ ```bash
54
+ npm install nodebb-plugin-chat-search
55
+ ```
56
+ Then:
57
+
58
+ 1. Activate the plugin in the **Admin Control Panel (ACP)**
59
+ 2. **Rebuild** your NodeBB instance
60
+ 3. **Restart** the forum
61
+
62
+ ---
63
+
64
+ ## 🔗 Links
65
+
66
+ **GitHub**
67
+ https://github.com/palmoni5/nodebb-plugin-chat-search
68
+
69
+ **Issues**
70
+ Report bugs or request features via the repository issue tracker.
71
+
72
+ ---
73
+
74
+ ## 💬 Feedback
75
+
76
+ Feedback, suggestions, and feature requests are welcome.
77
+ If this plugin helps you, consider starring the repository ⭐
package/library.js CHANGED
@@ -1,110 +1,140 @@
1
- 'use strict';
2
-
3
- const messaging = require.main.require('./src/messaging');
4
- const user = require.main.require('./src/user');
5
- const db = require.main.require('./src/database');
6
-
7
- const plugin = {};
8
-
9
- plugin.init = async (params) => {
10
- const socketPlugins = require.main.require('./src/socket.io/plugins');
11
- socketPlugins.chatSearch = {};
12
- socketPlugins.chatSearch.searchGlobal = searchGlobal;
13
- };
14
-
15
- plugin.addClientScript = async (scripts) => {
16
- scripts.push('plugins/nodebb-plugin-chat-search/static/lib/main.js');
17
- return scripts;
18
- };
19
-
20
- async function searchGlobal(socket, data) {
21
- if (!socket.uid) throw new Error('Not logged in');
22
-
23
- let targetUid = socket.uid;
24
- if (data.targetUid && parseInt(data.targetUid, 10) !== parseInt(socket.uid, 10)) {
25
- const isAdmin = await user.isAdministrator(socket.uid);
26
- if (!isAdmin) throw new Error('אין הרשאה.');
27
- targetUid = data.targetUid;
28
- }
29
-
30
- const query = data.query;
31
- const roomIds = await db.getSortedSetRevRange('uid:' + targetUid + ':chat:rooms', 0, -1);
32
- let allResults = [];
33
-
34
- for (const roomId of roomIds) {
35
- const inRoom = await messaging.isUserInRoom(targetUid, roomId);
36
- if (!inRoom) continue;
37
-
38
- try {
39
- let start = 0;
40
- const batchSize = 50;
41
- let roomMatches = [];
42
- let continueFetching = true;
43
-
44
- while (continueFetching) {
45
- const messages = await messaging.getMessages({
46
- callerUid: socket.uid,
47
- uid: targetUid,
48
- roomId: roomId,
49
- isNew: false,
50
- start: start,
51
- stop: start + batchSize - 1
52
- });
53
-
54
- if (!messages || !Array.isArray(messages) || messages.length === 0) {
55
- continueFetching = false;
56
- break;
57
- }
58
-
59
- const matches = messages.filter(msg =>
60
- msg.content && msg.content.toLowerCase().includes(query.toLowerCase())
61
- );
62
-
63
- if (matches.length > 0) {
64
- roomMatches = roomMatches.concat(matches);
65
- }
66
-
67
- if (messages.length < batchSize) {
68
- continueFetching = false;
69
- } else {
70
- start += batchSize;
71
- }
72
- }
73
-
74
- if (roomMatches.length > 0) {
75
- const uids = await messaging.getUidsInRoom(roomId, 0, -1);
76
- const usersData = await user.getUsersFields(uids, ['uid', 'username', 'picture', 'icon:text', 'icon:bgColor']);
77
- const otherUsers = usersData.filter(u => parseInt(u.uid, 10) !== parseInt(targetUid, 10));
78
-
79
- let displayName = '';
80
- if (otherUsers.length === 0) displayName = 'צ\'אט עצמי';
81
- else if (otherUsers.length <= 2) displayName = otherUsers.map(u => u.username).join(', ');
82
- else {
83
- const firstTwo = otherUsers.slice(0, 2).map(u => u.username).join(', ');
84
- const remaining = otherUsers.length - 2;
85
- displayName = `${firstTwo} ועוד ${remaining} משתמשים`;
86
- }
87
-
88
- const roomData = await messaging.getRoomData(roomId);
89
- let roomName = (roomData && roomData.roomName) || displayName;
90
-
91
- roomMatches.forEach(m => {
92
- if (!m.user || !m.user.username) {
93
- const sender = usersData.find(u => parseInt(u.uid, 10) === parseInt(m.fromuid, 10));
94
- m.user = sender || { username: 'Unknown', 'icon:bgColor': '#aaa' };
95
- }
96
- m.roomName = roomName;
97
- m.targetUid = targetUid;
98
- m.participants = otherUsers;
99
- });
100
-
101
- allResults = allResults.concat(roomMatches);
102
- }
103
- } catch (err) {
104
- console.error(`[Chat Search] Error in room ${roomId}: ${err.message}`);
105
- }
106
- }
107
- return allResults;
108
- }
109
-
110
- module.exports = plugin;
1
+ 'use strict';
2
+
3
+ const messaging = require.main.require('./src/messaging');
4
+ const user = require.main.require('./src/user');
5
+ const db = require.main.require('./src/database');
6
+
7
+ const plugin = {};
8
+
9
+ plugin.init = async (params) => {
10
+ const socketPlugins = require.main.require('./src/socket.io/plugins');
11
+ socketPlugins.chatSearch = {};
12
+ socketPlugins.chatSearch.searchGlobal = searchGlobal;
13
+ };
14
+
15
+ plugin.addClientScript = async (scripts) => {
16
+ scripts.push('plugins/nodebb-plugin-chat-search/static/lib/main.js');
17
+ return scripts;
18
+ };
19
+
20
+ async function getMessagesForSearch(params) {
21
+ const { callerUid, targetUid, roomId, start, stop, allowFullHistory } = params;
22
+ if (allowFullHistory) {
23
+ const mids = await db.getSortedSetRevRange(`chat:room:${roomId}:mids`, start, stop);
24
+ if (!mids || !mids.length) return [];
25
+ mids.reverse();
26
+ const messages = await messaging.getMessagesData(mids, targetUid, roomId, false);
27
+ messages.forEach((msg) => {
28
+ if (!msg.mid && msg.messageId) msg.mid = msg.messageId;
29
+ });
30
+ return messages;
31
+ }
32
+ const messages = await messaging.getMessages({
33
+ callerUid: callerUid,
34
+ uid: targetUid,
35
+ roomId: roomId,
36
+ isNew: false,
37
+ start: start,
38
+ stop: stop,
39
+ });
40
+ return messages || [];
41
+ }
42
+
43
+ async function searchGlobal(socket, data) {
44
+ if (!socket.uid) throw new Error('Not logged in');
45
+ const isAdmin = await user.isAdministrator(socket.uid);
46
+
47
+ let targetUid = socket.uid;
48
+ if (data.targetUid && parseInt(data.targetUid, 10) !== parseInt(socket.uid, 10)) {
49
+ if (!isAdmin) throw new Error('אין הרשאה.');
50
+ targetUid = data.targetUid;
51
+ }
52
+
53
+ const query = data.query;
54
+ const requestedRoomIds = Array.isArray(data.roomIds) ? data.roomIds : null;
55
+ const roomIds = requestedRoomIds && requestedRoomIds.length
56
+ ? [...new Set(requestedRoomIds.map(rid => parseInt(rid, 10)).filter(rid => Number.isFinite(rid) && rid > 0))]
57
+ : await db.getSortedSetRevRange('uid:' + targetUid + ':chat:rooms', 0, -1);
58
+ let allResults = [];
59
+ const allowFullHistory = isAdmin && requestedRoomIds && requestedRoomIds.length;
60
+
61
+ for (const roomId of roomIds) {
62
+ if (!isAdmin) {
63
+ const inRoom = await messaging.isUserInRoom(targetUid, roomId);
64
+ if (!inRoom) continue;
65
+ }
66
+
67
+ try {
68
+ let start = 0;
69
+ const batchSize = 50;
70
+ let roomMatches = [];
71
+ let continueFetching = true;
72
+
73
+ while (continueFetching) {
74
+ const messages = await getMessagesForSearch({
75
+ callerUid: socket.uid,
76
+ targetUid: targetUid,
77
+ roomId: roomId,
78
+ start: start,
79
+ stop: start + batchSize - 1,
80
+ allowFullHistory: allowFullHistory,
81
+ });
82
+
83
+ if (!messages || !Array.isArray(messages) || messages.length === 0) {
84
+ continueFetching = false;
85
+ break;
86
+ }
87
+
88
+ const matches = messages.filter(msg =>
89
+ msg.content && msg.content.toLowerCase().includes(query.toLowerCase())
90
+ );
91
+
92
+ if (matches.length > 0) {
93
+ roomMatches = roomMatches.concat(matches);
94
+ }
95
+
96
+ if (messages.length < batchSize) {
97
+ continueFetching = false;
98
+ } else {
99
+ start += batchSize;
100
+ }
101
+ }
102
+
103
+ if (roomMatches.length > 0) {
104
+ const uids = await messaging.getUidsInRoom(roomId, 0, -1);
105
+ const usersData = await user.getUsersFields(uids, ['uid', 'username', 'picture', 'icon:text', 'icon:bgColor']);
106
+ const otherUsers = usersData.filter(u => parseInt(u.uid, 10) !== parseInt(targetUid, 10));
107
+
108
+ let displayName = '';
109
+ if (otherUsers.length === 0) displayName = 'צ\'אט עצמי';
110
+ else if (otherUsers.length <= 2) displayName = otherUsers.map(u => u.username).join(', ');
111
+ else {
112
+ const firstTwo = otherUsers.slice(0, 2).map(u => u.username).join(', ');
113
+ const remaining = otherUsers.length - 2;
114
+ displayName = `${firstTwo} ועוד ${remaining} משתמשים`;
115
+ }
116
+
117
+ const roomData = await messaging.getRoomData(roomId);
118
+ let roomName = (roomData && roomData.roomName) || displayName;
119
+
120
+ roomMatches.forEach(m => {
121
+ if (!m.roomId) m.roomId = roomId;
122
+ if (!m.user || !m.user.username) {
123
+ const sender = usersData.find(u => parseInt(u.uid, 10) === parseInt(m.fromuid, 10));
124
+ m.user = sender || { username: 'Unknown', 'icon:bgColor': '#aaa' };
125
+ }
126
+ m.roomName = roomName;
127
+ m.targetUid = targetUid;
128
+ m.participants = otherUsers;
129
+ });
130
+
131
+ allResults = allResults.concat(roomMatches);
132
+ }
133
+ } catch (err) {
134
+ console.error(`[Chat Search] Error in room ${roomId}: ${err.message}`);
135
+ }
136
+ }
137
+ return allResults;
138
+ }
139
+
140
+ module.exports = plugin;
package/package.json CHANGED
@@ -1,18 +1,18 @@
1
- {
2
- "name": "nodebb-plugin-chat-search",
3
- "version": "1.2.0",
4
- "description": "A plugin to search text within NodeBB chats",
5
- "main": "library.js",
6
- "repository": {
7
- "type": "git",
8
- "url": "https://github.com/palmoni5/nodebb-plugin-chat-search.git"
9
- },
10
- "bugs": {
11
- "url": "https://github.com/palmoni5/nodebb-plugin-chat-search/issues"
12
- },
13
- "homepage": "https://github.com/palmoni5/nodebb-plugin-chat-search#readme",
14
- "nbbpm": {
15
- "compatibility": "^3.0.0"
16
- },
17
- "dependencies": {}
1
+ {
2
+ "name": "nodebb-plugin-chat-search",
3
+ "version": "1.4.0",
4
+ "description": "A plugin to search text within NodeBB chats",
5
+ "main": "library.js",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/palmoni5/nodebb-plugin-chat-search.git"
9
+ },
10
+ "bugs": {
11
+ "url": "https://github.com/palmoni5/nodebb-plugin-chat-search/issues"
12
+ },
13
+ "homepage": "https://github.com/palmoni5/nodebb-plugin-chat-search#readme",
14
+ "nbbpm": {
15
+ "compatibility": "^3.0.0"
16
+ },
17
+ "dependencies": {}
18
18
  }
package/plugin.json CHANGED
@@ -1,14 +1,14 @@
1
- {
2
- "id": "nodebb-plugin-chat-search",
3
- "url": "https://github.com/palmoni5/nodebb-plugin-chat-search",
4
- "name": "Chat Search",
5
- "description": "Allows searching in chat windows",
6
- "library": "./library.js",
7
- "hooks": [
8
- { "hook": "static:app.load", "method": "init" },
9
- { "hook": "filter:scripts.client", "method": "addClientScript" }
10
- ],
11
- "scripts": [
12
- "static/lib/main.js"
13
- ]
1
+ {
2
+ "id": "nodebb-plugin-chat-search",
3
+ "url": "https://github.com/palmoni5/nodebb-plugin-chat-search",
4
+ "name": "Chat Search",
5
+ "description": "Allows searching in chat windows",
6
+ "library": "./library.js",
7
+ "hooks": [
8
+ { "hook": "static:app.load", "method": "init" },
9
+ { "hook": "filter:scripts.client", "method": "addClientScript" }
10
+ ],
11
+ "scripts": [
12
+ "static/lib/main.js"
13
+ ]
14
14
  }
@@ -1,268 +1,318 @@
1
- 'use strict';
2
-
3
- window.chatSearchState = window.chatSearchState || {
4
- query: '',
5
- resultsHtml: '',
6
- isOpen: false,
7
- lastScroll: 0
8
- };
9
-
10
- $(document).ready(function () {
11
- const isHebrew = (document.documentElement.lang || 'en').startsWith('he');
12
-
13
- const txt = {
14
- placeholder: isHebrew ? 'חפש הודעה...' : 'Search messages...',
15
- searching: isHebrew ? 'מחפש...' : 'Searching...',
16
- error: isHebrew ? 'שגיאה' : 'Error',
17
- noResults: isHebrew ? 'לא נמצאו תוצאות.' : 'No results found.',
18
- unknownUser: isHebrew ? 'לא ידוע' : 'Unknown'
19
- };
20
-
21
- let observer = null;
22
-
23
- $(window).on('action:ajaxify.end', function (ev, data) {
24
- if (observer) observer.disconnect();
25
- const isChatUrl = data.url.match(/^(user\/[^\/]+\/)?chats/);
26
- const isChatTemplate = data.template && (data.template.name === 'chats' || data.template === 'chats');
27
-
28
- if (isChatUrl || isChatTemplate) {
29
- initSearchInjection();
30
- } else {
31
- window.chatSearchState = { query: '', resultsHtml: '', isOpen: false, lastScroll: 0 };
32
- }
33
- });
34
-
35
- $(window).on('action:chat.loaded', function (ev, data) {
36
- highlightActiveChat();
37
- handleScrollToMessage();
38
- });
39
-
40
- if (ajaxify.data.template && (ajaxify.data.template.name === 'chats' || ajaxify.data.template === 'chats')) {
41
- initSearchInjection();
42
- }
43
-
44
- function initSearchInjection() {
45
- if (!injectSearchBar()) {
46
- const targetNode = document.body;
47
- const config = { childList: true, subtree: true };
48
- observer = new MutationObserver(function(mutationsList) {
49
- const container = findContainer();
50
- if (container.length > 0) {
51
- injectSearchBar(container);
52
- observer.disconnect();
53
- }
54
- });
55
- observer.observe(targetNode, config);
56
- }
57
- }
58
-
59
- function findContainer() {
60
- let container = $('[component="chat/nav-wrapper"]');
61
- if (container.length === 0) container = $('.chats-page').find('.col-md-4').first();
62
- if (container.length === 0) container = $('[component="chat/list"]').parent();
63
- return container;
64
- }
65
-
66
- function injectSearchBar(containerElement) {
67
- const container = containerElement || findContainer();
68
- if (container.length === 0) return false;
69
- if ($('#global-chat-search-container').length > 0) return true;
70
-
71
- const searchHtml = `
72
- <div id="global-chat-search-container" style="padding: 10px; background: #fff; border-bottom: 1px solid #ddd; margin-bottom: 5px;">
73
- <div class="input-group">
74
- <input type="text" id="global-chat-search" class="form-control" placeholder="${txt.placeholder}" style="font-size: 14px; height: 34px;">
75
- <span class="input-group-btn">
76
- <button class="btn btn-primary" id="btn-chat-search" type="button" style="height: 34px;"><i class="fa fa-search"></i></button>
77
- </span>
78
- </div>
79
- <div id="global-search-results" class="chats-list overflow-auto ghost-scrollbar" style="margin-top: 5px; max-height: 400px; display:none;"></div>
80
- </div>
81
- `;
82
-
83
- container.prepend(searchHtml);
84
- restoreState();
85
- attachEvents();
86
- return true;
87
- }
88
-
89
- function attachEvents() {
90
- $('#btn-chat-search').off('click').on('click', executeSearch);
91
- const input = $('#global-chat-search');
92
- input.off('keypress').on('keypress', function (e) {
93
- if (e.which === 13) executeSearch();
94
- });
95
- input.on('input', function() {
96
- window.chatSearchState.query = $(this).val();
97
- });
98
- $('#global-search-results').on('scroll', function() {
99
- window.chatSearchState.lastScroll = $(this).scrollTop();
100
- });
101
- }
102
-
103
- function restoreState() {
104
- const input = $('#global-chat-search');
105
- const results = $('#global-search-results');
106
- if (window.chatSearchState.query) input.val(window.chatSearchState.query);
107
- if (window.chatSearchState.isOpen && window.chatSearchState.resultsHtml) {
108
- results.html(window.chatSearchState.resultsHtml).show();
109
- if ($.fn.timeago) results.find('.timeago').timeago();
110
- if (window.chatSearchState.lastScroll > 0) results.scrollTop(window.chatSearchState.lastScroll);
111
- highlightActiveChat();
112
- }
113
- }
114
-
115
- function buildAvatarHtml(user, sizePx, extraStyle = '', extraClasses = '') {
116
- const sizeVal = sizePx + 'px';
117
- const bgStyle = `background-color: ${user['icon:bgColor'] || '#5c5c5c'};`;
118
- const commonStyle = `style="--avatar-size: ${sizeVal}; width: ${sizeVal}; height: ${sizeVal}; line-height: ${sizeVal}; ${bgStyle} ${extraStyle}"`;
119
- const classes = `avatar avatar-rounded ${extraClasses}`;
120
-
121
- if (user.picture) {
122
- return `<span title="${user.username}" class="${classes}" component="avatar/picture" ${commonStyle}><img src="${user.picture}" alt="${user.username}" class="avatar avatar-rounded"></span>`;
123
- }
124
-
125
- const text = user['icon:text'] || (user.username ? user.username[0].toUpperCase() : '?');
126
- return `<span title="${user.username}" class="${classes}" component="avatar/icon" ${commonStyle}>${text}</span>`;
127
- }
128
-
129
- function renderMainAvatars(participants) {
130
- if (!participants || participants.length === 0) {
131
- return `<div class="main-avatar">
132
- <span class="avatar avatar-rounded" style="--avatar-size: 32px; width:32px; height:32px; background-color: #ccc">?</span>
133
- </div>`;
134
- }
135
-
136
- return `<div class="main-avatar">
137
- ${buildAvatarHtml(participants[0], 32)}
138
- </div>`;
139
- }
140
-
141
- function cleanContent(content) {
142
- if (!content) return '';
143
- return content.replace(/<\/?p[^>]*>/g, ' ').trim();
144
- }
145
-
146
- function executeSearch() {
147
- const query = $('#global-chat-search').val();
148
- const resultsContainer = $('#global-search-results');
149
-
150
- if (!query) {
151
- resultsContainer.hide();
152
- window.chatSearchState.isOpen = false;
153
- window.chatSearchState.resultsHtml = '';
154
- return;
155
- }
156
-
157
- let targetUid = ajaxify.data.uid || app.user.uid;
158
-
159
- resultsContainer.show().html(`<div class="text-center" style="padding:10px;"><i class="fa fa-spinner fa-spin"></i> ${txt.searching}</div>`);
160
- window.chatSearchState.isOpen = true;
161
-
162
- socket.emit('plugins.chatSearch.searchGlobal', { query: query, targetUid: targetUid }, function (err, messages) {
163
- if (err) {
164
- resultsContainer.html(`<div class="alert alert-danger" style="margin:5px;">${txt.error}</div>`);
165
- return;
166
- }
167
- if (!messages || messages.length === 0) {
168
- const noRes = `<div class="text-center" style="padding:10px; color:#777;">${txt.noResults}</div>`;
169
- resultsContainer.html(noRes);
170
- window.chatSearchState.resultsHtml = noRes;
171
- return;
172
- }
173
-
174
- let html = '<div class="d-flex flex-column">';
175
- messages.forEach(msg => {
176
- const isoTime = new Date(msg.timestamp).toISOString();
177
-
178
- let baseUrl = window.location.pathname.replace(/\/chats\/.*$/, '/chats');
179
- if (baseUrl.endsWith('/')) baseUrl = baseUrl.slice(0, -1);
180
-
181
- const chatLink = baseUrl + '/' + msg.roomId + '?mid=' + msg.mid;
182
- const senderName = (msg.user && msg.user.username) ? msg.user.username : txt.unknownUser;
183
-
184
- const mainAvatarHtml = renderMainAvatars(msg.participants);
185
- const senderSmallAvatar = buildAvatarHtml(msg.user, 14, 'vertical-align: text-bottom;', 'align-middle');
186
-
187
- const cleanedContent = cleanContent(msg.content);
188
-
189
- html += `
190
- <div component="chat/recent/room" class="rounded-1 search-result" data-roomid="${msg.roomId}">
191
- <div class="d-flex gap-1 justify-content-between">
192
- <a href="#" onclick="ajaxify.go('${chatLink}'); return false;" class="chat-room-btn position-relative d-flex flex-grow-1 gap-2 justify-content-start align-items-start btn btn-ghost btn-sm ff-sans text-start" style="padding: 0.5rem;">
193
-
194
- ${mainAvatarHtml}
195
-
196
- <div class="d-flex flex-grow-1 flex-column w-100" style="min-width:0;">
197
- <div component="chat/room/title" class="room-name fw-semibold text-xs text-break">
198
- ${msg.roomName}
199
- </div>
200
- <div component="chat/room/teaser">
201
-
202
- <div class="teaser-content text-sm line-clamp-3 text-break mb-0">
203
- ${senderSmallAvatar}
204
- <strong class="text-xs fw-semibold teaser-username">${senderName}:</strong>
205
- ${cleanedContent}
206
- </div>
207
-
208
- <div class="teaser-timestamp text-muted text-xs" style="margin-top: 2px; line-height: 1;">
209
- <span class="timeago" title="${isoTime}"></span>
210
- </div>
211
-
212
- </div>
213
- </div>
214
- </a>
215
- </div>
216
- </div>
217
- <hr class="my-1">
218
- `;
219
- });
220
- html += '</div>';
221
-
222
- resultsContainer.html(html);
223
-
224
- if ($.fn.timeago) {
225
- resultsContainer.find('.timeago').timeago();
226
- }
227
-
228
- window.chatSearchState.resultsHtml = html;
229
- window.chatSearchState.lastScroll = 0;
230
- highlightActiveChat();
231
- });
232
- }
233
-
234
- function highlightActiveChat() {
235
- let currentRoomId = ajaxify.data.roomId;
236
- if (!currentRoomId) {
237
- const match = window.location.pathname.match(/chats\/(\d+)/);
238
- if (match) currentRoomId = match[1];
239
- }
240
- if (!currentRoomId) return;
241
- $('.search-result').removeClass('active');
242
- const activeItem = $('.search-result[data-roomid="' + currentRoomId + '"]');
243
- activeItem.addClass('active');
244
- }
245
-
246
- function handleScrollToMessage() {
247
- const params = new URLSearchParams(window.location.search);
248
- const mid = params.get('mid');
249
- if (!mid) return;
250
- scrollToId(mid);
251
- let attempts = 0;
252
- const scrollInt = setInterval(() => {
253
- attempts++;
254
- if (scrollToId(mid) || attempts > 15) clearInterval(scrollInt);
255
- }, 300);
256
- }
257
-
258
- function scrollToId(mid) {
259
- const el = $('[data-mid="' + mid + '"]');
260
- if (el.length > 0) {
261
- el[0].scrollIntoView({ behavior: 'smooth', block: 'center' });
262
- el.css('background', '#fffeca').css('transition', 'background 1s');
263
- setTimeout(() => el.css('background', ''), 2000);
264
- return true;
265
- }
266
- return false;
267
- }
268
- });
1
+ 'use strict';
2
+
3
+ window.chatSearchState = window.chatSearchState || {
4
+ query: '',
5
+ resultsHtml: '',
6
+ isOpen: false,
7
+ lastScroll: 0
8
+ };
9
+
10
+ $(document).ready(function () {
11
+ const isHebrew = (document.documentElement.lang || 'en').startsWith('he');
12
+
13
+ const txt = {
14
+ placeholder: isHebrew ? 'חפש הודעה...' : 'Search messages...',
15
+ searching: isHebrew ? 'מחפש...' : 'Searching...',
16
+ error: isHebrew ? 'שגיאה' : 'Error',
17
+ noResults: isHebrew ? 'לא נמצאו תוצאות.' : 'No results found.',
18
+ unknownUser: isHebrew ? 'לא ידוע' : 'Unknown'
19
+ };
20
+
21
+ let observer = null;
22
+
23
+ $(window).on('action:ajaxify.end', function (ev, data) {
24
+ if (observer) observer.disconnect();
25
+ const isChatUrl = data.url.match(/^(user\/[^\/]+\/)?chats/);
26
+ const isChatTemplate = data.template && (data.template.name === 'chats' || data.template === 'chats');
27
+
28
+ if (isChatUrl || isChatTemplate) {
29
+ initSearchInjection();
30
+ } else {
31
+ window.chatSearchState = { query: '', resultsHtml: '', isOpen: false, lastScroll: 0 };
32
+ }
33
+ });
34
+
35
+ $(window).on('action:chat.loaded', function (ev, data) {
36
+ highlightActiveChat();
37
+ handleScrollToMessage();
38
+ });
39
+
40
+ if (ajaxify.data.template && (ajaxify.data.template.name === 'chats' || ajaxify.data.template === 'chats')) {
41
+ initSearchInjection();
42
+ }
43
+
44
+ function initSearchInjection() {
45
+ if (!injectSearchBar()) {
46
+ const targetNode = document.body;
47
+ const config = { childList: true, subtree: true };
48
+ observer = new MutationObserver(function(mutationsList) {
49
+ const container = findContainer();
50
+ if (container.length > 0) {
51
+ injectSearchBar(container);
52
+ observer.disconnect();
53
+ }
54
+ });
55
+ observer.observe(targetNode, config);
56
+ }
57
+ }
58
+
59
+ function findContainer() {
60
+ let container = $('[component="chat/nav-wrapper"]');
61
+ if (container.length === 0) container = $('.chats-page').find('.col-md-4').first();
62
+ if (container.length === 0) container = $('[component="chat/list"]').parent();
63
+ return container;
64
+ }
65
+
66
+ function injectSearchBar(containerElement) {
67
+ const container = containerElement || findContainer();
68
+ if (container.length === 0) return false;
69
+ if ($('#global-chat-search-container').length > 0) return true;
70
+
71
+ const searchHtml = `
72
+ <div id="global-chat-search-container" style="padding: 10px; background: #fff; border-bottom: 1px solid #ddd; margin-bottom: 5px;">
73
+ <div class="input-group">
74
+ <input type="text" id="global-chat-search" class="form-control" placeholder="${txt.placeholder}" style="font-size: 14px; height: 34px;">
75
+ <span class="input-group-btn">
76
+ <button class="btn btn-primary" id="btn-chat-search" type="button" style="height: 34px;"><i class="fa fa-search"></i></button>
77
+ </span>
78
+ </div>
79
+ <div id="global-search-results" class="chats-list overflow-auto ghost-scrollbar" style="margin-top: 5px; max-height: 400px; display:none;"></div>
80
+ </div>
81
+ `;
82
+
83
+ container.prepend(searchHtml);
84
+ restoreState();
85
+ attachEvents();
86
+ return true;
87
+ }
88
+
89
+ function attachEvents() {
90
+ $('#btn-chat-search').off('click').on('click', executeSearch);
91
+ const input = $('#global-chat-search');
92
+ input.off('keypress').on('keypress', function (e) {
93
+ if (e.which === 13) executeSearch();
94
+ });
95
+ input.on('input', function() {
96
+ window.chatSearchState.query = $(this).val();
97
+ });
98
+ $('#global-search-results').on('scroll', function() {
99
+ window.chatSearchState.lastScroll = $(this).scrollTop();
100
+ });
101
+ }
102
+
103
+ function restoreState() {
104
+ const input = $('#global-chat-search');
105
+ const results = $('#global-search-results');
106
+ if (window.chatSearchState.query) input.val(window.chatSearchState.query);
107
+ if (window.chatSearchState.isOpen && window.chatSearchState.resultsHtml) {
108
+ results.html(window.chatSearchState.resultsHtml).show();
109
+ if ($.fn.timeago) results.find('.timeago').timeago();
110
+ if (window.chatSearchState.lastScroll > 0) results.scrollTop(window.chatSearchState.lastScroll);
111
+ highlightActiveChat();
112
+ }
113
+ }
114
+
115
+ function buildAvatarHtml(user, sizePx, extraStyle = '', extraClasses = '') {
116
+ const sizeVal = sizePx + 'px';
117
+ const bgStyle = `background-color: ${user['icon:bgColor'] || '#5c5c5c'};`;
118
+ const commonStyle = `style="--avatar-size: ${sizeVal}; width: ${sizeVal}; height: ${sizeVal}; line-height: ${sizeVal}; ${bgStyle} ${extraStyle}"`;
119
+ const classes = `avatar avatar-rounded ${extraClasses}`;
120
+
121
+ if (user.picture) {
122
+ return `<span title="${user.username}" class="${classes}" component="avatar/picture" ${commonStyle}><img src="${user.picture}" alt="${user.username}" class="avatar avatar-rounded"></span>`;
123
+ }
124
+
125
+ const text = user['icon:text'] || (user.username ? user.username[0].toUpperCase() : '?');
126
+ return `<span title="${user.username}" class="${classes}" component="avatar/icon" ${commonStyle}>${text}</span>`;
127
+ }
128
+
129
+ function renderMainAvatars(participants) {
130
+ if (!participants || participants.length === 0) {
131
+ return `<div class="main-avatar">
132
+ <span class="avatar avatar-rounded" style="--avatar-size: 32px; width:32px; height:32px; background-color: #ccc">?</span>
133
+ </div>`;
134
+ }
135
+
136
+ return `<div class="main-avatar">
137
+ ${buildAvatarHtml(participants[0], 32)}
138
+ </div>`;
139
+ }
140
+
141
+ function cleanContent(content) {
142
+ if (!content) return '';
143
+ return content.replace(/<\/?p[^>]*>/g, ' ').trim();
144
+ }
145
+
146
+ function isAdminAllChatsPage() {
147
+ return !!(ajaxify && ajaxify.data && ajaxify.data.adminAllChats);
148
+ }
149
+
150
+ function getDisplayedRoomIds() {
151
+ const ids = [];
152
+ const seen = {};
153
+
154
+ function addRoomId(roomId) {
155
+ const rid = parseInt(roomId, 10);
156
+ if (rid && !seen[rid]) {
157
+ ids.push(rid);
158
+ seen[rid] = true;
159
+ }
160
+ }
161
+
162
+ // Prefer server payload when available (admin-chats all-chats page)
163
+ if (ajaxify && ajaxify.data) {
164
+ if (Array.isArray(ajaxify.data.rooms)) {
165
+ ajaxify.data.rooms.forEach(r => addRoomId(r && (r.roomId || r.roomid)));
166
+ }
167
+ if (Array.isArray(ajaxify.data.publicRooms)) {
168
+ ajaxify.data.publicRooms.forEach(r => addRoomId(r && (r.roomId || r.roomid)));
169
+ }
170
+ }
171
+
172
+ // Also scan DOM (covers infinite scroll appended rooms)
173
+ const $recent = $("[component=\"chat/recent\"]");
174
+ const $scope = $recent.length ? $recent : $("#content");
175
+
176
+ $scope.find("[data-roomid], [data-room-id], [data-roomId]").each(function () {
177
+ addRoomId($(this).attr("data-roomid") || $(this).attr("data-room-id") || $(this).attr("data-roomId"));
178
+ });
179
+
180
+ // Fallback: parse chat links (e.g. /chats/123 or chats/123)
181
+ $scope.find("a[href]").each(function () {
182
+ const href = $(this).attr("href") || "";
183
+ const m = href.match(/(?:^|\/)(?:user\/[^\/]+\/)?chats\/(\d+)/);
184
+ if (m && m[1]) addRoomId(m[1]);
185
+ });
186
+
187
+ return ids;
188
+ }
189
+
190
+ function executeSearch() {
191
+ const query = $('#global-chat-search').val();
192
+ const resultsContainer = $('#global-search-results');
193
+
194
+ if (!query) {
195
+ resultsContainer.hide();
196
+ window.chatSearchState.isOpen = false;
197
+ window.chatSearchState.resultsHtml = '';
198
+ return;
199
+ }
200
+
201
+ let targetUid = ajaxify.data.uid || app.user.uid;
202
+
203
+ resultsContainer.show().html(`<div class="text-center" style="padding:10px;"><i class="fa fa-spinner fa-spin"></i> ${txt.searching}</div>`);
204
+ window.chatSearchState.isOpen = true;
205
+
206
+ const payload = { query: query, targetUid: targetUid };
207
+ if (isAdminAllChatsPage()) {
208
+ const roomIds = getDisplayedRoomIds();
209
+ if (roomIds.length) payload.roomIds = roomIds;
210
+ }
211
+
212
+ socket.emit('plugins.chatSearch.searchGlobal', payload, function (err, messages) {
213
+ if (err) {
214
+ resultsContainer.html(`<div class="alert alert-danger" style="margin:5px;">${txt.error}</div>`);
215
+ return;
216
+ }
217
+ if (!messages || messages.length === 0) {
218
+ const noRes = `<div class="text-center" style="padding:10px; color:#777;">${txt.noResults}</div>`;
219
+ resultsContainer.html(noRes);
220
+ window.chatSearchState.resultsHtml = noRes;
221
+ return;
222
+ }
223
+
224
+ let html = '<div class="d-flex flex-column">';
225
+ messages.forEach(msg => {
226
+ const isoTime = new Date(msg.timestamp).toISOString();
227
+
228
+ let baseUrl = window.location.pathname.replace(/\/chats\/.*$/, '/chats');
229
+ if (baseUrl.endsWith('/')) baseUrl = baseUrl.slice(0, -1);
230
+
231
+ const chatLink = baseUrl + '/' + msg.roomId + '?mid=' + msg.mid;
232
+ const senderName = (msg.user && msg.user.username) ? msg.user.username : txt.unknownUser;
233
+
234
+ const mainAvatarHtml = renderMainAvatars(msg.participants);
235
+ const senderSmallAvatar = buildAvatarHtml(msg.user, 14, 'vertical-align: text-bottom;', 'align-middle');
236
+
237
+ const cleanedContent = cleanContent(msg.content);
238
+
239
+ html += `
240
+ <div component="chat/recent/room" class="rounded-1 search-result" data-roomid="${msg.roomId}">
241
+ <div class="d-flex gap-1 justify-content-between">
242
+ <a href="#" onclick="ajaxify.go('${chatLink}'); return false;" class="chat-room-btn position-relative d-flex flex-grow-1 gap-2 justify-content-start align-items-start btn btn-ghost btn-sm ff-sans text-start" style="padding: 0.5rem;">
243
+
244
+ ${mainAvatarHtml}
245
+
246
+ <div class="d-flex flex-grow-1 flex-column w-100" style="min-width:0;">
247
+ <div component="chat/room/title" class="room-name fw-semibold text-xs text-break">
248
+ ${msg.roomName}
249
+ </div>
250
+ <div component="chat/room/teaser">
251
+
252
+ <div class="teaser-content text-sm line-clamp-3 text-break mb-0">
253
+ ${senderSmallAvatar}
254
+ <strong class="text-xs fw-semibold teaser-username">${senderName}:</strong>
255
+ ${cleanedContent}
256
+ </div>
257
+
258
+ <div class="teaser-timestamp text-muted text-xs" style="margin-top: 2px; line-height: 1;">
259
+ <span class="timeago" title="${isoTime}"></span>
260
+ </div>
261
+
262
+ </div>
263
+ </div>
264
+ </a>
265
+ </div>
266
+ </div>
267
+ <hr class="my-1">
268
+ `;
269
+ });
270
+ html += '</div>';
271
+
272
+ resultsContainer.html(html);
273
+
274
+ if ($.fn.timeago) {
275
+ resultsContainer.find('.timeago').timeago();
276
+ }
277
+
278
+ window.chatSearchState.resultsHtml = html;
279
+ window.chatSearchState.lastScroll = 0;
280
+ highlightActiveChat();
281
+ });
282
+ }
283
+
284
+ function highlightActiveChat() {
285
+ let currentRoomId = ajaxify.data.roomId;
286
+ if (!currentRoomId) {
287
+ const match = window.location.pathname.match(/chats\/(\d+)/);
288
+ if (match) currentRoomId = match[1];
289
+ }
290
+ if (!currentRoomId) return;
291
+ $('.search-result').removeClass('active');
292
+ const activeItem = $('.search-result[data-roomid="' + currentRoomId + '"]');
293
+ activeItem.addClass('active');
294
+ }
295
+
296
+ function handleScrollToMessage() {
297
+ const params = new URLSearchParams(window.location.search);
298
+ const mid = params.get('mid');
299
+ if (!mid) return;
300
+ scrollToId(mid);
301
+ let attempts = 0;
302
+ const scrollInt = setInterval(() => {
303
+ attempts++;
304
+ if (scrollToId(mid) || attempts > 15) clearInterval(scrollInt);
305
+ }, 300);
306
+ }
307
+
308
+ function scrollToId(mid) {
309
+ const el = $('[data-mid="' + mid + '"]');
310
+ if (el.length > 0) {
311
+ el[0].scrollIntoView({ behavior: 'smooth', block: 'center' });
312
+ el.css('background', '#fffeca').css('transition', 'background 1s');
313
+ setTimeout(() => el.css('background', ''), 2000);
314
+ return true;
315
+ }
316
+ return false;
317
+ }
318
+ });