@nodebb/nodebb-plugin-reactions 2.0.1 → 2.1.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/README.md CHANGED
@@ -4,9 +4,7 @@ Reactions plugin for NodeBB
4
4
  # Screenshots
5
5
 
6
6
  ## Reactions:
7
- ![CleanShot 2023-05-11 at 08 53 48@2x](https://github.com/ShlomoCode/nodebb-plugin-reactions/assets/78599753/613cb49e-994a-4869-a721-c1eeb00959ff)
8
-
7
+ ![demo](./assets/demo.png)
9
8
 
10
9
  ## ACP:
11
- ![CleanShot 2023-05-11 at 04 09 11@2x](https://github.com/ShlomoCode/nodebb-plugin-reactions/assets/78599753/f4a4e5cb-f060-4415-a337-222a157a6e59)
12
-
10
+ ![acp](./assets/acp.png)
package/assets/acp.png ADDED
Binary file
Binary file
@@ -5,9 +5,14 @@
5
5
  "error.maximum-reached": "Maximum reactions reached",
6
6
  "error.maximum-per-user-per-post-reached": "Maximum reactions per user per post reached",
7
7
  "settings.title": "Reactions plugin settings",
8
+ "settings.enable-post-reactions": "Enable post reactions",
8
9
  "settings.max-reactions-per-post": "Maximum unique reactions per post (0 for unlimited, default: 4)",
9
10
  "settings.max-reactions-per-user-per-post": "Maximum reactions per user per post (0 for unlimited)",
10
11
  "settings.max-reactions-per-user-per-post-help": "The limit is enforced only when adding a new reaction and not when joining an existing reaction.",
12
+ "settings.enable-message-reactions": "Enable message reactions",
13
+ "settings.max-reactions-per-message": "Maximum unique reactions per message (0 for unlimited, default: 4)",
14
+ "settings.max-reactions-per-user-per-message": "Maximum reactions per user per message (0 for unlimited)",
15
+ "settings.max-reactions-per-user-per-message-help": "The limit is enforced only when adding a new reaction and not when joining an existing reaction.",
11
16
  "settings.reaction-reputations": "Reaction Reputations (Optional)",
12
17
  "settings.reaction-reputations-help": "You can assign a reputation to individual reactions. When a reaction is applied to a post, the owner of that post will get this reputation.",
13
18
  "settings.reaction-reputations.add": "Add Rule",
package/library.js CHANGED
@@ -1,9 +1,10 @@
1
1
  'use strict';
2
2
 
3
- const _ = require.main.require('lodash');
4
3
  const meta = require.main.require('./src/meta');
5
4
  const user = require.main.require('./src/user');
6
5
  const posts = require.main.require('./src/posts');
6
+ const messaging = require.main.require('./src/messaging');
7
+ const privileges = require.main.require('./src/privileges');
7
8
  const db = require.main.require('./src/database');
8
9
  const routesHelpers = require.main.require('./src/routes/helpers');
9
10
  const websockets = require.main.require('./src/socket.io/index');
@@ -19,13 +20,14 @@ function parse(name) {
19
20
  return emojiParser.buildEmoji(emojiTable[name] || emojiTable[emojiAliases[name]], '');
20
21
  }
21
22
 
22
- const ReactionsPlugin = {};
23
+ const ReactionsPlugin = module.exports;
23
24
 
24
25
  ReactionsPlugin.init = async function (params) {
25
- function renderAdmin(_, res) {
26
- res.render('admin/plugins/reactions', {});
27
- }
28
- routesHelpers.setupAdminPageRoute(params.router, '/admin/plugins/reactions', params.middleware, [], renderAdmin);
26
+ routesHelpers.setupAdminPageRoute(params.router, '/admin/plugins/reactions', (req, res) => {
27
+ res.render('admin/plugins/reactions', {
28
+ title: '[[reactions:reactions]]',
29
+ });
30
+ });
29
31
  };
30
32
 
31
33
  ReactionsPlugin.addAdminNavigation = async function (header) {
@@ -41,13 +43,30 @@ ReactionsPlugin.getPluginConfig = async function (config) {
41
43
  try {
42
44
  const settings = await meta.settings.get('reactions');
43
45
  config.maximumReactions = settings.maximumReactions ? parseInt(settings.maximumReactions, 10) : DEFAULT_MAX_EMOTES;
46
+ config.maximumReactionsPerMessage = settings.maximumReactionsPerMessage ?
47
+ parseInt(settings.maximumReactionsPerMessage, 10) : DEFAULT_MAX_EMOTES;
48
+ config.enablePostReactions = settings.enablePostReactions === 'on';
49
+ config.enableMessageReactions = settings.enableMessageReactions === 'on';
44
50
  } catch (e) {
45
51
  console.error(e);
46
52
  }
47
53
  return config;
48
54
  };
49
55
 
50
- ReactionsPlugin.getReactions = async function (data) {
56
+ ReactionsPlugin.filterSettingsGet = async function (hookData) {
57
+ if (hookData.plugin === 'reactions') {
58
+ const { values } = hookData;
59
+ if (!values.hasOwnProperty('enablePostReactions')) {
60
+ values.enablePostReactions = 'on';
61
+ }
62
+ if (!values.hasOwnProperty('enableMessageReactions')) {
63
+ values.enableMessageReactions = 'on';
64
+ }
65
+ }
66
+ return hookData;
67
+ };
68
+
69
+ ReactionsPlugin.getPostReactions = async function (data) {
51
70
  if (data.uid === 0) {
52
71
  return data;
53
72
  }
@@ -79,42 +98,80 @@ ReactionsPlugin.getReactions = async function (data) {
79
98
  }
80
99
  }
81
100
 
82
- const reactionSetToUsersMap = new Map(); // reactionSet -> { uid, username }
83
- if (reactionSets.length > 0) {
84
- const uidsForReactions = await db.getSetsMembers(reactionSets);
85
- const allUids = _.union(...uidsForReactions).filter(Boolean);
86
- const usersData = await user.getUsersFields(allUids, ['uid', 'username']);
87
- const uidToUserdataMap = _.keyBy(usersData, 'uid');
88
-
89
- for (let i = 0, len = reactionSets.length; i < len; i++) {
90
- const uidsForReaction = uidsForReactions[i];
91
- if (uidsForReaction && uidsForReaction.length > 0) {
92
- const usersData = uidsForReaction.map(uid => uidToUserdataMap[uid]).filter(Boolean);
93
- reactionSetToUsersMap.set(reactionSets[i], usersData);
94
- }
95
- }
96
- }
101
+ const reactionSetToUsersMap = await getReactionSetsUidsMap(reactionSets);
97
102
 
98
103
  for (const post of data.posts) {
99
104
  post.maxReactionsReached = pidToIsMaxReactionsReachedMap.get(post.pid);
100
105
  post.reactions = [];
101
106
 
102
- if (pidToReactionsMap.has(post.pid)) {
103
- for (const reaction of pidToReactionsMap.get(post.pid)) {
107
+ const reactions = pidToReactionsMap.get(post.pid);
108
+ if (reactions) {
109
+ for (const reaction of reactions) {
104
110
  const reactionSet = `pid:${post.pid}:reaction:${reaction}`;
105
- if (reactionSetToUsersMap.has(reactionSet)) {
106
- const usersData = reactionSetToUsersMap.get(reactionSet);
107
- const reactionCount = usersData.length;
108
- const reactedUsernames = usersData.map(userData => userData.username).join(', ');
109
- const reactedUids = usersData.map(userData => userData.uid);
110
-
111
+ const uids = reactionSetToUsersMap.get(reactionSet);
112
+ if (Array.isArray(uids)) {
111
113
  post.reactions.push({
112
114
  pid: post.pid,
113
- reacted: reactedUids.includes(data.uid),
115
+ reacted: uids.includes(String(data.uid)),
116
+ reaction,
117
+ reactionImage: parse(reaction),
118
+ reactionCount: uids.length,
119
+ });
120
+ }
121
+ }
122
+ }
123
+ }
124
+ } catch (e) {
125
+ console.error(e);
126
+ }
127
+ return data;
128
+ };
129
+
130
+ ReactionsPlugin.getMessageReactions = async function (data) {
131
+ if (data.uid === 0) {
132
+ return data;
133
+ }
134
+
135
+ try {
136
+ const settings = await meta.settings.get('reactions');
137
+ const maximumReactionsPerMessage = settings.maximumReactionsPerMessage || DEFAULT_MAX_EMOTES;
138
+
139
+ const mids = data.messages.map(message => message && parseInt(message.mid, 10));
140
+ const allReactionsForMids = await db.getSetsMembers(mids.map(pid => `mid:${pid}:reactions`));
141
+
142
+ const midToIsMaxReactionsReachedMap = new Map(); // mid -> IsMaxReactionsReached (boolean)
143
+ const midToReactionsMap = new Map(); // mid -> reactions (string[])
144
+ let reactionSets = [];
145
+
146
+ for (let i = 0, len = mids.length; i < len; i++) {
147
+ const mid = mids[i];
148
+ const reactionsList = allReactionsForMids[i];
149
+ const reactionsCount = reactionsList.length;
150
+
151
+ if (reactionsList && reactionsList.length > 0) {
152
+ midToReactionsMap.set(mid, reactionsList);
153
+ midToIsMaxReactionsReachedMap.set(mid, reactionsCount > maximumReactionsPerMessage);
154
+ reactionSets = reactionSets.concat(reactionsList.map(reaction => `mid:${mid}:reaction:${reaction}`));
155
+ }
156
+ }
157
+
158
+ const reactionSetToUsersMap = await getReactionSetsUidsMap(reactionSets);
159
+
160
+ for (const msg of data.messages) {
161
+ msg.maxReactionsReached = midToIsMaxReactionsReachedMap.get(msg.mid);
162
+ msg.reactions = [];
163
+ const reactions = midToReactionsMap.get(msg.mid);
164
+ if (reactions) {
165
+ for (const reaction of reactions) {
166
+ const reactionSet = `mid:${msg.mid}:reaction:${reaction}`;
167
+ const uids = reactionSetToUsersMap.get(reactionSet);
168
+ if (Array.isArray(uids)) {
169
+ msg.reactions.push({
170
+ mid: msg.mid,
171
+ reacted: uids.includes(String(data.uid)),
114
172
  reaction,
115
- usernames: reactedUsernames,
116
173
  reactionImage: parse(reaction),
117
- reactionCount,
174
+ reactionCount: uids.length,
118
175
  });
119
176
  }
120
177
  }
@@ -126,6 +183,22 @@ ReactionsPlugin.getReactions = async function (data) {
126
183
  return data;
127
184
  };
128
185
 
186
+
187
+ async function getReactionSetsUidsMap(reactionSets) {
188
+ const reactionSetToUsersMap = new Map(); // reactionSet -> uids
189
+ if (reactionSets.length > 0) {
190
+ const uidsForReactions = await db.getSetsMembers(reactionSets);
191
+
192
+ for (let i = 0, len = reactionSets.length; i < len; i++) {
193
+ const uidsForReaction = uidsForReactions[i];
194
+ if (uidsForReaction && uidsForReaction.length > 0) {
195
+ reactionSetToUsersMap.set(reactionSets[i], uidsForReaction);
196
+ }
197
+ }
198
+ }
199
+ return reactionSetToUsersMap;
200
+ }
201
+
129
202
  ReactionsPlugin.onReply = async function (data) {
130
203
  if (data.uid !== 0) {
131
204
  data.reactions = [];
@@ -148,17 +221,13 @@ ReactionsPlugin.deleteReactions = async function (hookData) {
148
221
  await db.deleteAll(keys);
149
222
  };
150
223
 
151
- async function sendEvent(data, eventName) {
224
+ async function sendPostEvent(data, eventName) {
152
225
  try {
153
- const [reactionCount, totalReactions, uids] = await Promise.all([
226
+ const [reactionCount, totalReactions] = await Promise.all([
154
227
  db.setCount(`pid:${data.pid}:reaction:${data.reaction}`),
155
228
  db.setCount(`pid:${data.pid}:reactions`),
156
- db.getSetMembers(`pid:${data.pid}:reaction:${data.reaction}`),
157
229
  ]);
158
230
 
159
- const userdata = await user.getUsersFields(uids, ['uid', 'username']);
160
- const usernames = userdata.map(user => user.username).join(', ');
161
-
162
231
  if (parseInt(reactionCount, 10) === 0) {
163
232
  await db.setRemove(`pid:${data.pid}:reactions`, data.reaction);
164
233
  }
@@ -169,7 +238,30 @@ async function sendEvent(data, eventName) {
169
238
  reaction: data.reaction,
170
239
  reactionCount,
171
240
  totalReactions,
172
- usernames,
241
+ reactionImage: parse(data.reaction),
242
+ });
243
+ } catch (e) {
244
+ console.error(e);
245
+ }
246
+ }
247
+
248
+ async function sendMessageEvent(data, eventName) {
249
+ try {
250
+ const [reactionCount, totalReactions] = await Promise.all([
251
+ db.setCount(`mid:${data.mid}:reaction:${data.reaction}`),
252
+ db.setCount(`mid:${data.mid}:reactions`),
253
+ ]);
254
+
255
+ if (parseInt(reactionCount, 10) === 0) {
256
+ await db.setRemove(`mid:${data.mid}:reactions`, data.reaction);
257
+ }
258
+
259
+ await websockets.in(`chat_room_${data.roomId}`).emit(eventName, {
260
+ mid: data.mid,
261
+ uid: data.uid,
262
+ reaction: data.reaction,
263
+ reactionCount,
264
+ totalReactions,
173
265
  reactionImage: parse(data.reaction),
174
266
  });
175
267
  } catch (e) {
@@ -201,23 +293,30 @@ SocketPlugins.reactions = {
201
293
  throw new Error('[[reactions:error.invalid-reaction]]');
202
294
  }
203
295
 
204
- data.uid = socket.uid;
205
-
206
296
  const settings = await meta.settings.get('reactions');
297
+ if (settings.enablePostReactions === 'off') {
298
+ throw new Error('[[error:post-reactions-disabled]]');
299
+ }
207
300
  const maximumReactions = settings.maximumReactions || DEFAULT_MAX_EMOTES;
208
- const [totalReactions, emojiIsAlreadyExist, alreadyReacted, reactionReputation] = await Promise.all([
301
+ const [tid, totalReactions, emojiIsAlreadyExist, alreadyReacted, reactionReputation] = await Promise.all([
302
+ posts.getPostField(data.pid, 'tid'),
209
303
  db.setCount(`pid:${data.pid}:reactions`),
210
304
  db.isSetMember(`pid:${data.pid}:reactions`, data.reaction),
211
305
  db.isSetMember(`pid:${data.pid}:reaction:${data.reaction}`, socket.uid),
212
306
  getReactionReputation(data.reaction),
213
307
  ]);
214
-
308
+ if (!tid) {
309
+ throw new Error('[[error:no-post]]');
310
+ }
311
+ data.uid = socket.uid;
312
+ data.tid = tid;
215
313
  if (!emojiIsAlreadyExist) {
216
314
  if (totalReactions > maximumReactions) {
217
315
  throw new Error(`[[reactions:error.maximum-reached]] (${maximumReactions})`);
218
316
  }
219
-
220
- const maximumReactionsPerUserPerPost = settings.maximumReactionsPerUserPerPost ? parseInt(settings.maximumReactionsPerUserPerPost, 10) : 0;
317
+
318
+ const maximumReactionsPerUserPerPost = settings.maximumReactionsPerUserPerPost ?
319
+ parseInt(settings.maximumReactionsPerUserPerPost, 10) : 0;
221
320
  if (maximumReactionsPerUserPerPost > 0) {
222
321
  const emojiesInPost = await db.getSetMembers(`pid:${data.pid}:reactions`);
223
322
  const userPostReactions = await db.isMemberOfSets(emojiesInPost.map(emojiName => `pid:${data.pid}:reaction:${emojiName}`), socket.uid);
@@ -227,7 +326,6 @@ SocketPlugins.reactions = {
227
326
  }
228
327
  }
229
328
  }
230
-
231
329
 
232
330
  await Promise.all([
233
331
  db.setAdd(`pid:${data.pid}:reactions`, data.reaction),
@@ -238,7 +336,7 @@ SocketPlugins.reactions = {
238
336
  await giveOwnerReactionReputation(reactionReputation, data.pid);
239
337
  }
240
338
 
241
- await sendEvent(data, 'event:reactions.addPostReaction');
339
+ await sendPostEvent(data, 'event:reactions.addPostReaction');
242
340
  },
243
341
  removePostReaction: async function (socket, data) {
244
342
  if (!socket.uid) {
@@ -249,30 +347,156 @@ SocketPlugins.reactions = {
249
347
  throw new Error('[[reactions:error.invalid-reaction]]');
250
348
  }
251
349
 
350
+ const [settings, tid, hasReacted, reactionReputation] = await Promise.all([
351
+ meta.settings.get('reactions'),
352
+ posts.getPostField(data.pid, 'tid'),
353
+ db.isSetMember(`pid:${data.pid}:reaction:${data.reaction}`, socket.uid),
354
+ getReactionReputation(data.reaction),
355
+ ]);
356
+ if (settings.enablePostReactions === 'off') {
357
+ throw new Error('[[error:post-reactions-disabled]]');
358
+ }
359
+ if (!tid) {
360
+ throw new Error('[[error:no-post]]');
361
+ }
252
362
  data.uid = socket.uid;
363
+ data.tid = tid;
364
+
365
+ if (hasReacted) {
366
+ await db.setRemove(`pid:${data.pid}:reaction:${data.reaction}`, socket.uid);
367
+ }
368
+
369
+ const reactionCount = await db.setCount(`pid:${data.pid}:reaction:${data.reaction}`);
370
+ if (reactionCount === 0) {
371
+ await db.setRemove(`pid:${data.pid}:reactions`, data.reaction);
372
+ }
373
+ if (hasReacted && reactionReputation > 0) {
374
+ await giveOwnerReactionReputation(-reactionReputation, data.pid);
375
+ }
253
376
 
254
- try {
255
- const [hasReacted, reactionReputation] = await Promise.all([
256
- db.isSetMember(`pid:${data.pid}:reaction:${data.reaction}`, socket.uid),
257
- getReactionReputation(data.reaction),
258
- ]);
259
- if (hasReacted) {
260
- await db.setRemove(`pid:${data.pid}:reaction:${data.reaction}`, socket.uid);
377
+ await sendPostEvent(data, 'event:reactions.removePostReaction');
378
+ },
379
+ addMessageReaction: async function (socket, data) {
380
+ if (!socket.uid) {
381
+ throw new Error('[[error:not-logged-in]]');
382
+ }
383
+
384
+ if (!emojiTable[data.reaction]) {
385
+ throw new Error('[[reactions:error.invalid-reaction]]');
386
+ }
387
+
388
+ const settings = await meta.settings.get('reactions');
389
+ if (settings.enableMessageReactions === 'off') {
390
+ throw new Error('[[error:post-reactions-disabled]]');
391
+ }
392
+ const maximumReactionsPerMessage = settings.maximumReactionsPerMessage || DEFAULT_MAX_EMOTES;
393
+ const [roomId, totalReactions, emojiIsAlreadyExist] = await Promise.all([
394
+ messaging.getMessageField(data.mid, 'roomId'),
395
+ db.setCount(`mid:${data.mid}:reactions`),
396
+ db.isSetMember(`mid:${data.mid}:reactions`, data.reaction),
397
+ ]);
398
+
399
+ if (!roomId) {
400
+ throw new Error('[[error:no-message]]');
401
+ }
402
+
403
+ data.uid = socket.uid;
404
+ data.roomId = roomId;
405
+
406
+ if (!emojiIsAlreadyExist) {
407
+ if (totalReactions > maximumReactionsPerMessage) {
408
+ throw new Error(`[[reactions:error.maximum-reached]] (${maximumReactionsPerMessage})`);
261
409
  }
262
410
 
263
- const reactionCount = await db.setCount(`pid:${data.pid}:reaction:${data.reaction}`);
264
- if (reactionCount === 0) {
265
- await db.setRemove(`pid:${data.pid}:reactions`, data.reaction);
411
+ const maximumReactionsPerUserPerMessage = settings.maximumReactionsPerUserPerMessage ?
412
+ parseInt(settings.maximumReactionsPerUserPerMessage, 10) : 0;
413
+ if (maximumReactionsPerUserPerMessage > 0) {
414
+ const emojiesInMessage = await db.getSetMembers(`mid:${data.mid}:reactions`);
415
+ const userPostReactions = await db.isMemberOfSets(emojiesInMessage.map(emojiName => `mid:${data.mid}:reaction:${emojiName}`), socket.uid);
416
+ const userPostReactionCount = userPostReactions.filter(Boolean).length;
417
+ if (userPostReactionCount > maximumReactionsPerUserPerMessage) {
418
+ throw new Error(`[[reactions:error.maximum-per-user-per-post-reached]] (${maximumReactionsPerUserPerMessage})`);
419
+ }
266
420
  }
267
- if (hasReacted && reactionReputation > 0) {
268
- await giveOwnerReactionReputation(-reactionReputation, data.pid);
421
+ }
422
+
423
+ await Promise.all([
424
+ db.setAdd(`mid:${data.mid}:reactions`, data.reaction),
425
+ db.setAdd(`mid:${data.mid}:reaction:${data.reaction}`, socket.uid),
426
+ ]);
427
+
428
+ await sendMessageEvent(data, 'event:reactions.addMessageReaction');
429
+ },
430
+ removeMessageReaction: async function (socket, data) {
431
+ if (!socket.uid) {
432
+ throw new Error('[[error:not-logged-in]]');
433
+ }
434
+
435
+ if (!emojiTable[data.reaction]) {
436
+ throw new Error('[[reactions:error.invalid-reaction]]');
437
+ }
438
+
439
+ const [settings, roomId, hasReacted] = await Promise.all([
440
+ meta.settings.get('reactions'),
441
+ messaging.getMessageField(data.mid, 'roomId'),
442
+ db.isSetMember(`mid:${data.mid}:reaction:${data.reaction}`, socket.uid),
443
+ ]);
444
+ if (settings.enableMessageReactions === 'off') {
445
+ throw new Error('[[error:post-reactions-disabled]]');
446
+ }
447
+ if (!roomId) {
448
+ throw new Error('[[error:no-message]]');
449
+ }
450
+ data.uid = socket.uid;
451
+ data.roomId = roomId;
452
+ if (hasReacted) {
453
+ await db.setRemove(`mid:${data.mid}:reaction:${data.reaction}`, socket.uid);
454
+ }
455
+
456
+ const reactionCount = await db.setCount(`mid:${data.mid}:reaction:${data.reaction}`);
457
+ if (reactionCount === 0) {
458
+ await db.setRemove(`mid:${data.mid}:reactions`, data.reaction);
459
+ }
460
+
461
+ await sendMessageEvent(data, 'event:reactions.removeMessageReaction');
462
+ },
463
+ getReactionUsernames: async function (socket, data) {
464
+ if (!socket.uid) {
465
+ throw new Error('[[error:not-logged-in]]');
466
+ }
467
+ if (!emojiTable[data.reaction]) {
468
+ throw new Error('[[reactions:error.invalid-reaction]]');
469
+ }
470
+ let set = '';
471
+ if (data.type === 'post') {
472
+ if (!await privileges.posts.can('topics:read', data.pid, socket.uid)) {
473
+ throw new Error('[[error:not-allowed]]');
269
474
  }
270
- await sendEvent(data, 'event:reactions.removePostReaction');
271
- } catch (e) {
272
- console.error(e);
475
+ set = `pid:${data.pid}:reaction:${data.reaction}`;
476
+ } else if (data.type === 'message') {
477
+ const roomId = await messaging.getMessageField(data.mid, 'roomId');
478
+ if (!await messaging.canViewMessage(data.mid, roomId, socket.uid)) {
479
+ throw new Error('[[error:not-allowed]]');
480
+ }
481
+ set = `mid:${data.mid}:reaction:${data.reaction}`;
482
+ } else {
483
+ throw new Error('[[error:invalid-data]]');
484
+ }
485
+ let uids = await db.getSetMembers(set);
486
+ const cutoff = 6;
487
+
488
+ let otherCount = 0;
489
+ if (uids.length > cutoff) {
490
+ otherCount = uids.length - (cutoff - 1);
491
+ uids = uids.slice(0, cutoff - 1);
273
492
  }
493
+
494
+ const usernames = await user.getUsernamesByUids(uids);
495
+ return {
496
+ cutoff: cutoff,
497
+ otherCount,
498
+ usernames,
499
+ };
274
500
  },
275
501
  };
276
502
 
277
-
278
- module.exports = ReactionsPlugin;
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@nodebb/nodebb-plugin-reactions",
3
- "version": "2.0.1",
3
+ "version": "2.1.1",
4
4
  "nbbpm": {
5
- "compatibility": "^3.0.0"
5
+ "compatibility": "^3.3.0"
6
6
  },
7
7
  "description": "Reactions plugin for NodeBB",
8
8
  "main": "library.js",
package/plugin.json CHANGED
@@ -26,7 +26,13 @@
26
26
  "hook": "filter:config.get", "method": "getPluginConfig"
27
27
  },
28
28
  {
29
- "hook": "filter:post.getPosts", "method": "getReactions"
29
+ "hook": "filter:settings.get", "method": "filterSettingsGet"
30
+ },
31
+ {
32
+ "hook": "filter:post.getPosts", "method": "getPostReactions"
33
+ },
34
+ {
35
+ "hook": "filter:messaging.getMessages", "method": "getMessageReactions"
30
36
  },
31
37
  {
32
38
  "hook": "filter:post.get", "method": "onReply"
package/public/client.js CHANGED
@@ -1,55 +1,23 @@
1
1
  'use strict';
2
2
 
3
3
  $(document).ready(function () {
4
- setupPostReactions();
5
-
6
- function setupPostReactions() {
7
- require(['hooks', 'alerts'], function (hooks, alerts) {
4
+ setupReactions();
5
+ let alerts;
6
+ let mouseOverReactionEl;
7
+ let tooltipTimeoutId = 0;
8
+ function setupReactions() {
9
+ createReactionTooltips();
10
+ require(['hooks', 'alerts'], function (hooks, _alerts) {
11
+ alerts = _alerts;
8
12
  hooks.on('action:ajaxify.end', function () {
9
13
  if (ajaxify.data.template.topic) {
10
- setupReactionSockets();
11
- createReactionTooltips();
12
- $('[component="topic"]').on('click', '[component="post/reaction"]', function () {
13
- var tid = $('[component="topic"]').attr('data-tid');
14
- var reactionElement = $(this);
15
- var pid = reactionElement.attr('data-pid');
16
- var reaction = reactionElement.attr('data-reaction');
17
- var reacted = reactionElement.hasClass('reacted');
18
- var event = 'plugins.reactions.' + (reacted ? 'removePostReaction' : 'addPostReaction');
19
- socket.emit(event, {
20
- tid: tid,
21
- pid: pid,
22
- reaction: reaction,
23
- }, function (err) {
24
- if (err) {
25
- alerts.error(err.message);
26
- }
27
- });
28
- });
29
-
30
- $('[component="topic"]').on('click', '[component="post/reaction/add"]', function () {
31
- var reactionAddEl = $(this);
32
- var tid = $('[component="topic"]').attr('data-tid');
33
- var pid = reactionAddEl.attr('data-pid');
34
- require(['emoji-dialog'], function (emojiDialog) {
35
- emojiDialog.toggle(reactionAddEl[0], function (_, name, dialog) {
36
- emojiDialog.dialogActions.close(dialog);
37
-
38
- socket.emit('plugins.reactions.addPostReaction', {
39
- tid: tid,
40
- pid: pid,
41
- reaction: name,
42
- }, function (err) {
43
- if (err) {
44
- alerts.error(err.message);
45
- throw err;
46
- }
47
-
48
- $('[component="post/reaction"][data-pid="' + pid + '"][data-reaction="' + name + '"]').addClass('reacted');
49
- });
50
- });
51
- });
52
- });
14
+ setupPostReactions();
15
+ }
16
+ });
17
+ // switchChat uses action:chat.loaded and not action:ajaxify.end
18
+ hooks.on('action:chat.loaded', function () {
19
+ if (ajaxify.data.template.chats && ajaxify.data.roomId) {
20
+ setupMessageReactions();
53
21
  }
54
22
  });
55
23
  });
@@ -61,19 +29,122 @@ $(document).ready(function () {
61
29
  socket.on('event:post_restored', function (data) {
62
30
  $('[component="post/reactions"][data-pid="' + data.pid + '"]').removeClass('hidden');
63
31
  });
32
+
33
+ socket.on('event:chats.delete', function (mid) {
34
+ $('[component="message/reactions"][data-mid="' + mid + '"]').addClass('hidden');
35
+ });
36
+
37
+ socket.on('event:chats.restore', function (msg) {
38
+ $('[component="message/reactions"][data-mid="' + msg.mid + '"]').removeClass('hidden');
39
+ });
40
+ }
41
+
42
+ function setupPostReactions() {
43
+ setupPostReactionSockets();
44
+
45
+ $('[component="topic"]').on('click', '[component="post/reaction"]', function () {
46
+ var reactionElement = $(this);
47
+ var pid = reactionElement.attr('data-pid');
48
+ var reaction = reactionElement.attr('data-reaction');
49
+ var reacted = reactionElement.hasClass('reacted');
50
+ var event = 'plugins.reactions.' + (reacted ? 'removePostReaction' : 'addPostReaction');
51
+ socket.emit(event, {
52
+ pid: pid,
53
+ reaction: reaction,
54
+ }, function (err) {
55
+ if (err) {
56
+ alerts.error(err.message);
57
+ }
58
+ });
59
+ });
60
+
61
+ $('[component="topic"]').on('click', '[component="post/reaction/add"]', function () {
62
+ var reactionAddEl = $(this);
63
+ var pid = reactionAddEl.attr('data-pid');
64
+ require(['emoji-dialog'], function (emojiDialog) {
65
+ emojiDialog.toggle(reactionAddEl[0], function (_, name, dialog) {
66
+ emojiDialog.dialogActions.close(dialog);
67
+
68
+ socket.emit('plugins.reactions.addPostReaction', {
69
+ pid: pid,
70
+ reaction: name,
71
+ }, function (err) {
72
+ if (err) {
73
+ alerts.error(err.message);
74
+ throw err;
75
+ }
76
+
77
+ $('[component="post/reaction"][data-pid="' + pid + '"][data-reaction="' + name + '"]').addClass('reacted');
78
+ });
79
+ });
80
+ });
81
+ });
82
+ }
83
+
84
+ function setupMessageReactions() {
85
+ setupMessageReactionSockets();
86
+
87
+ const messageContent = $('[component="chat/message/content"]');
88
+ messageContent.on('click', '[component="message/reaction"]', function () {
89
+ var reactionElement = $(this);
90
+ var mid = reactionElement.attr('data-mid');
91
+ var reaction = reactionElement.attr('data-reaction');
92
+ var reacted = reactionElement.hasClass('reacted');
93
+ var event = 'plugins.reactions.' + (reacted ? 'removeMessageReaction' : 'addMessageReaction');
94
+ socket.emit(event, {
95
+ mid: mid,
96
+ reaction: reaction,
97
+ }, function (err) {
98
+ if (err) {
99
+ alerts.error(err.message);
100
+ }
101
+ });
102
+ });
103
+
104
+ messageContent.on('click', '[component="message/reaction/add"]', function () {
105
+ const reactionAddEl = $(this);
106
+ const mid = reactionAddEl.attr('data-mid');
107
+
108
+ require(['emoji-dialog'], function (emojiDialog) {
109
+ emojiDialog.toggle(reactionAddEl[0], function (_, name, dialog) {
110
+ emojiDialog.dialogActions.close(dialog);
111
+
112
+ socket.emit('plugins.reactions.addMessageReaction', {
113
+ mid: mid,
114
+ reaction: name,
115
+ }, function (err) {
116
+ if (err) {
117
+ return alerts.error(err.message);
118
+ }
119
+
120
+ $('[component="message/reaction"][data-mid="' + mid + '"][data-reaction="' + name + '"]').addClass('reacted');
121
+ });
122
+ });
123
+ });
124
+ });
64
125
  }
65
126
 
66
- function setupReactionSockets() {
127
+ function setupPostReactionSockets() {
67
128
  socket.off('event:reactions.addPostReaction').on('event:reactions.addPostReaction', function (data) {
68
- updateReactionCount(data);
129
+ updatePostReactionCount(data, 'add');
69
130
  });
70
131
 
71
132
  socket.off('event:reactions.removePostReaction').on('event:reactions.removePostReaction', function (data) {
72
- updateReactionCount(data);
133
+ updatePostReactionCount(data, 'remove');
134
+ });
135
+ }
136
+
137
+ function setupMessageReactionSockets() {
138
+ socket.off('event:reactions.addMessageReaction').on('event:reactions.addMessageReaction', function (data) {
139
+ updateMessageReactionCount(data, 'add');
140
+ });
141
+
142
+ socket.off('event:reactions.removeMessageReaction').on('event:reactions.removeMessageReaction', function (data) {
143
+ updateMessageReactionCount(data, 'remove');
73
144
  });
74
145
  }
75
146
 
76
- function updateReactionCount(data) {
147
+ function updatePostReactionCount(data, type) {
77
148
  var maxReactionsReached = parseInt(data.totalReactions, 10) > config.maximumReactions;
78
149
  $('[component="post/reaction/add"][data-pid="' + data.pid + '"]').toggleClass('max-reactions', maxReactionsReached);
79
150
 
@@ -83,14 +154,14 @@ $(document).ready(function () {
83
154
  reactionEl.tooltip('dispose');
84
155
  reactionEl.remove();
85
156
  }
86
-
157
+ const isSelf = parseInt(data.uid, 10) === app.user.uid;
87
158
  if (reactionEl.length === 0) {
88
159
  app.parseAndTranslate('partials/topic/reaction', {
89
160
  pid: data.pid,
90
161
  reaction: data.reaction,
91
162
  reactionCount: data.reactionCount,
92
163
  usernames: data.usernames,
93
- reacted: (parseInt(data.uid, 10) === app.user.uid),
164
+ reacted: isSelf && type === 'add',
94
165
  reactionImage: data.reactionImage,
95
166
  }, function (html) {
96
167
  $('[component="post/reactions"][data-pid="' + data.pid + '"]').append(html);
@@ -99,18 +170,109 @@ $(document).ready(function () {
99
170
  reactionEl.find('.reaction-emoji-count').attr('data-count', data.reactionCount);
100
171
  reactionEl.attr('data-bs-original-title', data.usernames);
101
172
  reactionEl.attr('aria-label', data.usernames);
102
- reactionEl.toggleClass('reacted', !(parseInt(data.uid, 10) === app.user.uid));
173
+ if (isSelf) {
174
+ reactionEl.toggleClass('reacted', type === 'add');
175
+ }
176
+ }
177
+ }
178
+
179
+ function updateMessageReactionCount(data, type) {
180
+ var maxReactionsReached = parseInt(data.totalReactions, 10) > config.maximumReactionsPerMessage;
181
+ $('[component="message/reaction/add"][data-mid="' + data.mid + '"]').toggleClass('max-reactions', maxReactionsReached);
182
+
183
+ var reactionEl = $(`[component="message/reaction"][data-mid="${data.mid}"][data-reaction="${data.reaction}"]`);
184
+
185
+ if (parseInt(data.reactionCount, 10) === 0) {
186
+ reactionEl.tooltip('dispose');
187
+ reactionEl.remove();
188
+ }
189
+ const isSelf = (parseInt(data.uid, 10) === app.user.uid);
190
+ if (reactionEl.length === 0) {
191
+ app.parseAndTranslate('partials/chats/reaction', {
192
+ mid: data.mid,
193
+ reaction: data.reaction,
194
+ reactionCount: data.reactionCount,
195
+ reacted: isSelf && type === 'add',
196
+ reactionImage: data.reactionImage,
197
+ }, function (html) {
198
+ require(['forum/chats/messages'], function (messages) {
199
+ const reactionEl = $('[component="message/reactions"][data-mid="' + data.mid + '"]');
200
+ const chatContentEl = reactionEl.parents('[component="chat/message/content"]');
201
+ const isAtBottom = messages.isAtBottom(chatContentEl);
202
+ reactionEl.append(html);
203
+ if (isAtBottom || isSelf) {
204
+ messages.scrollToBottom(chatContentEl);
205
+ }
206
+ });
207
+ });
208
+ } else {
209
+ reactionEl.find('.reaction-emoji-count').attr('data-count', data.reactionCount);
210
+ reactionEl.attr('data-bs-original-title', data.usernames);
211
+ reactionEl.attr('aria-label', data.usernames);
212
+ if (isSelf) {
213
+ reactionEl.toggleClass('reacted', type === 'add');
214
+ }
103
215
  }
104
- createReactionTooltips();
105
216
  }
106
217
 
107
218
  function createReactionTooltips() {
108
- $('.reaction, .reaction-add').each(function () {
109
- if (!utils.isTouchDevice()) {
110
- $(this).tooltip('dispose');
111
- $(this).tooltip({
219
+ require(['bootstrap', 'translator'], function (bootstrap, translator) {
220
+ async function createTooltip(data) {
221
+ if (!mouseOverReactionEl || !mouseOverReactionEl.length) {
222
+ return;
223
+ }
224
+ const el = mouseOverReactionEl;
225
+ let usernames = data.usernames.filter(name => name !== '[[global:former_user]]');
226
+ if (!usernames.length) {
227
+ return;
228
+ }
229
+ if (usernames.length + data.otherCount > data.cutoff) {
230
+ usernames = usernames.join(', ').replace(/,/g, '|');
231
+ usernames = await translator.translate('[[topic:users_and_others, ' + usernames + ', ' + data.otherCount + ']]');
232
+ usernames = usernames.replace(/\|/g, ',');
233
+ } else {
234
+ usernames = usernames.join(', ');
235
+ }
236
+
237
+ el.attr('title', usernames);
238
+ (new bootstrap.Tooltip(el, {
239
+ container: '#content',
240
+ html: true,
112
241
  placement: 'top',
113
- title: $(this).attr('title') || $(this).attr('data-bs-original-title'),
242
+ animation: false,
243
+ })).show();
244
+ }
245
+
246
+ if (!utils.isTouchDevice()) {
247
+ $('#content').on('mouseenter', '.reaction', function () {
248
+ const $this = $(this);
249
+ mouseOverReactionEl = $this;
250
+ const mid = $this.attr('data-mid');
251
+ const pid = $this.attr('data-pid');
252
+ tooltipTimeoutId = setTimeout(async () => {
253
+ if (mouseOverReactionEl && mouseOverReactionEl.length) {
254
+ const d = await socket.emit('plugins.reactions.getReactionUsernames', {
255
+ type: pid ? 'post' : 'message',
256
+ mid: mid,
257
+ pid: pid,
258
+ reaction: $this.attr('data-reaction'),
259
+ });
260
+ createTooltip(d);
261
+ }
262
+ }, 200);
263
+ });
264
+ $('#content').on('mouseleave', '.reaction', function () {
265
+ if (tooltipTimeoutId) {
266
+ clearTimeout(tooltipTimeoutId);
267
+ tooltipTimeoutId = 0;
268
+ }
269
+ mouseOverReactionEl = null;
270
+ const $this = $(this);
271
+ const tooltip = bootstrap.Tooltip.getInstance(this);
272
+ if (tooltip) {
273
+ tooltip.dispose();
274
+ $this.attr('title', '');
275
+ }
114
276
  });
115
277
  }
116
278
  });
@@ -1,6 +1,6 @@
1
1
  .reactions {
2
2
  .reaction {
3
- border: 1px solid #9ddefe;
3
+ border: 1px solid var(--bs-primary-bg-subtle);
4
4
  display: inline-block;
5
5
  border-radius: 4px;
6
6
  padding: 2px 4px;
@@ -10,7 +10,7 @@
10
10
  height: 18px;
11
11
  }
12
12
  &.reacted {
13
- border: 1px solid #039BE5;
13
+ border: 1px solid var(--bs-primary);
14
14
  }
15
15
  .reaction-emoji-count {
16
16
  margin-left: 5px;
@@ -1,33 +1,60 @@
1
- <form role="form" class="reactions-settings">
2
- <div class="row">
3
- <div class="col-sm-2 col-xs-12 settings-header">[[reactions:settings.title]]</div>
4
- <div class="col-sm-10 col-xs-12">
5
- <div class="form-group">
6
- <label>[[reactions:settings.max-reactions-per-post]]</label>
7
- <input type="number" min="0" class="form-control" id="maximumReactions" name="maximumReactions">
8
1
 
9
- <label>[[reactions:settings.max-reactions-per-user-per-post]]</label>
10
- <input type="number" min="0" class="form-control" id="maximumReactionsPerUserPerPost" name="maximumReactionsPerUserPerPost">
11
- <p class="help-text">
12
- [[reactions:settings.max-reactions-per-user-per-post-help]]
13
- </p>
14
- </div>
15
- </div>
16
- </div>
2
+ <div class="acp-page-container">
3
+ <!-- IMPORT admin/partials/settings/header.tpl -->
17
4
 
18
- <div class="row mt-3">
19
- <div class="col-sm-2 col-xs-12 settings-header">[[reactions:settings.reaction-reputations]]</div>
20
- <div class="col-sm-10 col-xs-12">
21
- <p class="help-text">
22
- [[reactions:settings.reaction-reputations-help]]
23
- </p>
24
- <div class="form-group" data-type="sorted-list" data-sorted-list="reaction-reputations" data-item-template="admin/plugins/reactions/partials/sorted-list/emoji-item" data-form-template="admin/plugins/reactions/partials/sorted-list/emoji-form">
25
- <ul data-type="list" class="list-group"></ul>
26
- <button type="button" data-type="add" class="btn btn-info mt-2">[[reactions:settings.reaction-reputations.add]]</button>
27
- </div>
28
- </div>
29
- </div>
30
- </form>
5
+ <div class="row m-0">
6
+ <div id="spy-container" class="col-12 col-md-8 px-0 mb-4" tabindex="0">
7
+ <form role="form" class="reactions-settings">
8
+ <div class="mb-3">
9
+ <h5 class="fw-bold tracking-tight settings-header">[[reactions:settings.title]]</h5>
10
+ <div class="form-check form-switch mb-3">
11
+ <input id="enablePostReactions" name="enablePostReactions" type="checkbox" class="form-check-input">
12
+ <label for="enablePostReactions" class="form-check-label">[[reactions:settings.enable-post-reactions]]</label>
13
+ </div>
14
+ <div class="mb-3">
15
+ <label class="form-label">[[reactions:settings.max-reactions-per-post]]</label>
16
+ <input type="number" min="0" class="form-control" id="maximumReactions" name="maximumReactions">
17
+ </div>
18
+ <div class="mb-3">
19
+ <label class="form-label">[[reactions:settings.max-reactions-per-user-per-post]]</label>
20
+ <input type="number" min="0" class="form-control" id="maximumReactionsPerUserPerPost" name="maximumReactionsPerUserPerPost">
21
+ <p class="form-text">
22
+ [[reactions:settings.max-reactions-per-user-per-post-help]]
23
+ </p>
24
+ </div>
25
+ <hr/>
26
+ <div class="form-check form-switch mb-3">
27
+ <input id="enableMessageReactions" name="enableMessageReactions" type="checkbox" class="form-check-input">
28
+ <label for="enableMessageReactions" class="form-check-label">[[reactions:settings.enable-message-reactions]]</label>
29
+ </div>
30
+ <div class="mb-3">
31
+ <label class="form-label">[[reactions:settings.max-reactions-per-message]]</label>
32
+ <input type="number" min="0" class="form-control" id="maximumReactionsPerMessage" name="maximumReactionsPerMessage">
31
33
 
34
+ </div>
35
+ <div class="">
36
+ <label class="form-label">[[reactions:settings.max-reactions-per-user-per-message]]</label>
37
+ <input type="number" min="0" class="form-control" id="maximumReactionsPerUserPerMessage" name="maximumReactionsPerUserPerMessage">
38
+ <p class="form-text">
39
+ [[reactions:settings.max-reactions-per-user-per-message-help]]
40
+ </p>
41
+ </div>
42
+ </div>
32
43
 
33
- <!-- IMPORT admin/partials/save_button.tpl -->
44
+ <div class="mb-3">
45
+ <h5 class="fw-bold tracking-tight settings-header">[[reactions:settings.reaction-reputations]]</h5>
46
+
47
+ <p class="form-text">
48
+ [[reactions:settings.reaction-reputations-help]]
49
+ </p>
50
+ <div class="form-group" data-type="sorted-list" data-sorted-list="reaction-reputations" data-item-template="admin/plugins/reactions/partials/sorted-list/emoji-item" data-form-template="admin/plugins/reactions/partials/sorted-list/emoji-form">
51
+ <ul data-type="list" class="list-group"></ul>
52
+ <button type="button" data-type="add" class="btn btn-info mt-2">[[reactions:settings.reaction-reputations.add]]</button>
53
+ </div>
54
+ </div>
55
+ </form>
56
+ </div>
57
+
58
+ <!-- IMPORT admin/partials/settings/toc.tpl -->
59
+ </div>
60
+ </div>
@@ -0,0 +1,6 @@
1
+ {{{ if config.enableMessageReactions }}}
2
+ <button class="reaction-add btn btn-sm btn-link {{{ if ./maxReactionsReached }}}max-reactions{{{ end }}}" component="message/reaction/add" data-mid="{./mid}" title="[[reactions:add-reaction]]">
3
+ <i class="fa fa-face-smile"></i>
4
+ </button>
5
+ {{{ end }}}
6
+
@@ -0,0 +1,4 @@
1
+ <span class="reaction mb-2 {{{ if ./reacted }}}reacted{{{ end }}}" component="message/reaction" data-mid="{./mid}" data-reaction="{./reaction}">
2
+ {./reactionImage}
3
+ <small class="reaction-emoji-count" data-count="{./reactionCount}"></small>
4
+ </span>
@@ -0,0 +1,7 @@
1
+ {{{ if config.enableMessageReactions }}}
2
+ <div class="reactions {{{ if ./deleted}}}hidden{{{ end }}}" component="message/reactions" data-mid="{./mid}">
3
+ {{{ each ./reactions }}}
4
+ <!-- IMPORT partials/chats/reaction.tpl -->
5
+ {{{ end }}}
6
+ </div>
7
+ {{{ end }}}
@@ -1,4 +1,4 @@
1
- <span class="reaction {{{ if ./reacted }}}reacted{{{ end }}}" component="post/reaction" data-pid="{./pid}" data-reaction="{./reaction}" title="{./usernames}">
1
+ <span class="reaction {{{ if ./reacted }}}reacted{{{ end }}}" component="post/reaction" data-pid="{./pid}" data-reaction="{./reaction}">
2
2
  {./reactionImage}
3
3
  <small class="reaction-emoji-count" data-count="{./reactionCount}"></small>
4
4
  </span>
@@ -1,8 +1,10 @@
1
+ {{{ if config.enablePostReactions }}}
1
2
  <span class="reactions" component="post/reactions" data-pid="{./pid}">
2
3
  <span class="reaction-add d-inline-block px-2 mx-1 btn-ghost-sm {{{ if ./maxReactionsReached }}}max-reactions{{{ end }}}" component="post/reaction/add" data-pid="{./pid}" title="[[reactions:add-reaction]]">
3
- <i class="fa fa-plus-square-o"></i>
4
+ <i class="fa fa-face-smile text-primary"></i>
4
5
  </span>
5
6
  {{{ each ./reactions }}}
6
7
  <!-- IMPORT partials/topic/reaction.tpl -->
7
8
  {{{ end }}}
8
- </span>
9
+ </span>
10
+ {{{ end }}}