@nodebb/nodebb-plugin-reactions 3.0.4 → 3.1.3

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/helpers.js ADDED
@@ -0,0 +1,106 @@
1
+ 'use strict';
2
+
3
+ let emojiTable = null;
4
+ let emojiAliases = null;
5
+ let characterIndex = null;
6
+
7
+ function getEmojiTable() {
8
+ if (!emojiTable) {
9
+ emojiTable = nodebb.require('nodebb-plugin-emoji/build/emoji/table.json');
10
+ }
11
+ return emojiTable;
12
+ }
13
+
14
+ function getEmojiAliases() {
15
+ if (!emojiAliases) {
16
+ emojiAliases = nodebb.require('nodebb-plugin-emoji/build/emoji/aliases.json');
17
+ }
18
+ return emojiAliases;
19
+ }
20
+
21
+ function getCharacterIndex() {
22
+ if (!characterIndex) {
23
+ characterIndex = new Map();
24
+ Object.keys(getEmojiTable()).forEach((name) => {
25
+ const entry = getEmojiTable()[name];
26
+ if (entry && entry.character && !characterIndex.has(entry.character)) {
27
+ characterIndex.set(entry.character, name);
28
+ }
29
+ });
30
+ }
31
+ return characterIndex;
32
+ }
33
+
34
+ function resolveByName(name) {
35
+ if (!name || typeof name !== 'string') {
36
+ return null;
37
+ }
38
+ name = name.trim();
39
+ if (getEmojiTable()[name]) {
40
+ return name;
41
+ }
42
+ const aliases = getEmojiAliases();
43
+ if (aliases[name] && getEmojiTable()[aliases[name]]) {
44
+ return aliases[name];
45
+ }
46
+ return null;
47
+ }
48
+
49
+ /**
50
+ * Resolve FEP-c0e0 reaction content (unicode grapheme, `:shortcode:`, bare name,
51
+ * or a custom-emoji `tag`) to a local emoji name. Returns null when unresolvable.
52
+ */
53
+ function resolveReaction(content, tag) {
54
+ if (typeof content !== 'string' || !content.trim()) {
55
+ return null;
56
+ }
57
+ const trimmed = content.trim();
58
+
59
+ let reaction;
60
+
61
+ // `:shortcode:`
62
+ const shortcode = trimmed.match(/^:([a-z0-9_+-]+):$/i);
63
+ if (shortcode) {
64
+ reaction = resolveByName(shortcode[1]);
65
+ } else {
66
+ // Bare name (only accepted when it resolves to a local emoji)
67
+ reaction = resolveByName(trimmed);
68
+
69
+ if (!reaction) {
70
+ // Unicode grapheme
71
+ const index = getCharacterIndex();
72
+ if (index.has(trimmed)) {
73
+ reaction = index.get(trimmed);
74
+ }
75
+ // Retry without variation selectors (table entries may omit them, e.g. keycaps)
76
+ const noVariation = trimmed.replace(/\uFE0F/g, '');
77
+ if (!reaction && noVariation !== trimmed && index.has(noVariation)) {
78
+ reaction = index.get(noVariation);
79
+ }
80
+ const firstCodepoint = [...trimmed][0];
81
+ if (!reaction && firstCodepoint && index.has(firstCodepoint)) {
82
+ reaction = index.get(firstCodepoint);
83
+ }
84
+ }
85
+ }
86
+
87
+ // Custom emoji: the tag carries the emoji's identity, so it is consulted
88
+ // when the content alone does not resolve to a local emoji (a remote
89
+ // custom emoji whose name matches a built-in one is usable)
90
+ if (!reaction && Array.isArray(tag)) {
91
+ const emojiTag = tag.find(t => t && t.type === 'Emoji' && typeof t.name === 'string');
92
+ if (emojiTag) {
93
+ reaction = resolveByName(emojiTag.name.replace(/^:|:$/g, ''));
94
+ }
95
+ }
96
+
97
+ return reaction;
98
+ }
99
+
100
+ module.exports = {
101
+ getEmojiTable,
102
+ getEmojiAliases,
103
+ getCharacterIndex,
104
+ resolveByName,
105
+ resolveReaction,
106
+ };
package/library.js CHANGED
@@ -10,28 +10,23 @@ const db = nodebb.require('./src/database');
10
10
  const translator = nodebb.require('./src/translator');
11
11
  const notifications = nodebb.require('./src/notifications');
12
12
  const routesHelpers = nodebb.require('./src/routes/helpers');
13
+ const nconf = nodebb.require('nconf');
14
+ const categories = nodebb.require('./src/categories');
15
+ const activitypub = nodebb.require('./src/activitypub');
13
16
  const websockets = nodebb.require('./src/socket.io/index');
14
17
  const SocketPlugins = nodebb.require('./src/socket.io/plugins');
15
18
 
16
19
  const emojiParser = nodebb.require('nodebb-plugin-emoji/build/lib/parse.js');
17
-
18
- let emojiTable = null;
19
- let emojiAliases = null;
20
+ const helpers = require('./helpers');
20
21
 
21
22
  const DEFAULT_MAX_EMOTES = 4;
22
23
 
23
24
  function nameToEmoji(name) {
24
- if (!emojiTable) {
25
- emojiTable = nodebb.require('nodebb-plugin-emoji/build/emoji/table.json');
26
- }
27
- return emojiTable[name];
25
+ return helpers.getEmojiTable()[name];
28
26
  }
29
27
 
30
28
  function parse(name) {
31
- if (!emojiAliases) {
32
- emojiAliases = nodebb.require('nodebb-plugin-emoji/build/emoji/aliases.json');
33
- }
34
- const emoji = nameToEmoji(name) || emojiTable[emojiAliases[name]];
29
+ const emoji = nameToEmoji(name) || helpers.getEmojiTable()[helpers.getEmojiAliases()[name]];
35
30
  return emoji ? emojiParser.buildEmoji(emoji, '') : '';
36
31
  }
37
32
 
@@ -340,87 +335,292 @@ async function giveOwnerReactionReputation(reactionReputation, pid) {
340
335
  }
341
336
  }
342
337
 
343
- SocketPlugins.reactions = {
344
- addPostReaction: async function (socket, data) {
345
- if (!socket.uid) {
346
- throw new Error('[[error:not-logged-in]]');
347
- }
338
+ /**
339
+ * Core reaction logic, shared by the socket handlers and the ActivityPub
340
+ * (FEP-c0e0) inbox integration. `uid` may be a local numeric uid or a remote
341
+ * actor URL.
342
+ */
343
+ ReactionsPlugin.addPostReaction = async function (pid, uid, reaction) {
344
+ const settings = await loadPluginConfig();
345
+ if (!settings.enablePostReactions) {
346
+ throw new Error('[[error:post-reactions-disabled]]');
347
+ }
348
348
 
349
- if (!nameToEmoji(data.reaction)) {
350
- throw new Error('[[reactions:error.invalid-reaction]]');
349
+ const [postData, totalReactions, emojiIsAlreadyExist, alreadyReacted, reactionReputation] = await Promise.all([
350
+ posts.getPostFields(pid, ['pid', 'tid', 'uid', 'content', 'sourceContent']),
351
+ db.setCount(`pid:${pid}:reactions`),
352
+ db.isSetMember(`pid:${pid}:reactions`, reaction),
353
+ db.isSetMember(`pid:${pid}:reaction:${reaction}`, uid),
354
+ getReactionReputation(reaction),
355
+ ]);
356
+ const { tid } = postData;
357
+ if (!tid) {
358
+ throw new Error('[[error:no-post]]');
359
+ }
360
+
361
+ if (!emojiIsAlreadyExist) {
362
+ const { maximumReactions, maximumReactionsPerUserPerPost } = settings;
363
+ if (maximumReactions > 0 && totalReactions >= maximumReactions) {
364
+ throw new Error(`[[reactions:error.maximum-reached, ${maximumReactions}]]`);
351
365
  }
352
366
 
353
- const settings = await loadPluginConfig();
354
- if (!settings.enablePostReactions) {
355
- throw new Error('[[error:post-reactions-disabled]]');
367
+ if (maximumReactionsPerUserPerPost > 0) {
368
+ const emojiesInPost = await db.getSetMembers(`pid:${pid}:reactions`);
369
+ const userPostReactions = await db.isMemberOfSets(emojiesInPost.map(emojiName => `pid:${pid}:reaction:${emojiName}`), uid);
370
+ const userPostReactionCount = userPostReactions.filter(Boolean).length;
371
+ if (userPostReactionCount >= maximumReactionsPerUserPerPost) {
372
+ throw new Error(`[[reactions:error.maximum-per-user-per-post-reached, ${maximumReactionsPerUserPerPost}]]`);
373
+ }
356
374
  }
375
+ }
357
376
 
358
- const [postData, totalReactions, emojiIsAlreadyExist, alreadyReacted, reactionReputation] = await Promise.all([
359
- posts.getPostFields(data.pid, ['pid', 'tid', 'uid', 'content', 'sourceContent']),
360
- db.setCount(`pid:${data.pid}:reactions`),
361
- db.isSetMember(`pid:${data.pid}:reactions`, data.reaction),
362
- db.isSetMember(`pid:${data.pid}:reaction:${data.reaction}`, socket.uid),
363
- getReactionReputation(data.reaction),
377
+ await Promise.all([
378
+ db.setAdd(`pid:${pid}:reactions`, reaction),
379
+ db.setAdd(`pid:${pid}:reaction:${reaction}`, uid),
380
+ ]);
381
+
382
+ if (!alreadyReacted && reactionReputation > 0) {
383
+ await giveOwnerReactionReputation(reactionReputation, pid);
384
+ }
385
+
386
+ if (postData.uid && postData.uid !== uid) {
387
+ const [displayname, topicTitle, parsedPostData] = await Promise.all([
388
+ user.getNotificationDisplayname(uid),
389
+ topics.getNotificationTitle(tid),
390
+ posts.parsePost(postData),
364
391
  ]);
365
- const { tid } = postData;
366
- if (!tid) {
367
- throw new Error('[[error:no-post]]');
368
- }
369
- data.uid = socket.uid;
370
- data.tid = tid;
371
- if (!emojiIsAlreadyExist) {
372
- const { maximumReactions, maximumReactionsPerUserPerPost } = settings;
373
- if (maximumReactions > 0 && totalReactions >= maximumReactions) {
374
- throw new Error(`[[reactions:error.maximum-reached, ${maximumReactions}]]`);
375
- }
392
+ const notifObj = await notifications.create({
393
+ type: 'reaction',
394
+ bodyShort: translator.compile(
395
+ 'reactions:notification.user-has-reacted-with-to-your-post-in-topic',
396
+ displayname,
397
+ `:${reaction}:`,
398
+ topicTitle
399
+ ),
400
+ bodyLong: parsedPostData.content,
401
+ nid: `uid:${uid}:pid:${pid}:reaction:${reaction}`,
402
+ pid: pid,
403
+ tid: tid,
404
+ from: uid,
405
+ path: `/post/${pid}`,
406
+ });
376
407
 
377
- if (maximumReactionsPerUserPerPost > 0) {
378
- const emojiesInPost = await db.getSetMembers(`pid:${data.pid}:reactions`);
379
- const userPostReactions = await db.isMemberOfSets(emojiesInPost.map(emojiName => `pid:${data.pid}:reaction:${emojiName}`), socket.uid);
380
- const userPostReactionCount = userPostReactions.filter(Boolean).length;
381
- if (userPostReactionCount >= maximumReactionsPerUserPerPost) {
382
- throw new Error(`[[reactions:error.maximum-per-user-per-post-reached, ${maximumReactionsPerUserPerPost}]]`);
383
- }
408
+ await notifications.push(notifObj, [postData.uid]);
409
+ }
410
+
411
+ await sendPostEvent({ pid, uid, tid, reaction }, 'event:reactions.addPostReaction');
412
+ };
413
+
414
+ ReactionsPlugin.removePostReaction = async function (pid, uid, reaction) {
415
+ const settings = await loadPluginConfig();
416
+ if (!settings.enablePostReactions) {
417
+ throw new Error('[[error:post-reactions-disabled]]');
418
+ }
419
+
420
+ const [tid, hasReacted, reactionReputation] = await Promise.all([
421
+ posts.getPostField(pid, 'tid'),
422
+ db.isSetMember(`pid:${pid}:reaction:${reaction}`, uid),
423
+ getReactionReputation(reaction),
424
+ ]);
425
+ if (!tid) {
426
+ throw new Error('[[error:no-post]]');
427
+ }
428
+
429
+ if (hasReacted) {
430
+ await db.setRemove(`pid:${pid}:reaction:${reaction}`, uid);
431
+ }
432
+
433
+ const reactionCount = await db.setCount(`pid:${pid}:reaction:${reaction}`);
434
+ if (reactionCount === 0) {
435
+ await db.setRemove(`pid:${pid}:reactions`, reaction);
436
+ }
437
+ if (hasReacted && reactionReputation > 0) {
438
+ await giveOwnerReactionReputation(-reactionReputation, pid);
439
+ }
440
+
441
+ await sendPostEvent({ pid, uid, tid, reaction }, 'event:reactions.removePostReaction');
442
+ };
443
+
444
+ ReactionsPlugin.rescindPostReaction = async function (pid, uid, reaction) {
445
+ await notifications.rescind(`uid:${uid}:pid:${pid}:reaction:${reaction}`);
446
+ };
447
+
448
+ /*
449
+ ActivityPub (FEP-c0e0) integration.
450
+
451
+ Core fires `filter:activitypub.<type>` for every incoming activity before
452
+ built-in handling. The filter payload is `{ req, activity, claimed }` — a
453
+ plugin may claim the activity (core then skips its built-in handler) and/or
454
+ transparently rewrite `activity` for the rest of the chain.
455
+
456
+ This plugin claims:
457
+ - `EmojiReact` (always — it is the implementation)
458
+ - `Like` with `content` (FEP-c0e0 requires identical handling)
459
+ - `Undo` of either of the above
460
+ - `Announce` of either of the above (category sync / relays)
461
+ */
462
+
463
+ /**
464
+ * Resolve the (local or remote) post referenced by an EmojiReact activity.
465
+ * Returns a pid for local posts, the note URL for remote posts, or null when
466
+ * the post cannot be found.
467
+ * Handles both full objects (object.id) and bare URL strings.
468
+ */
469
+ async function resolveReactionPost(object) {
470
+ // Normalize: bare URL string or { id: '...' }
471
+ const objectUrl = typeof object === 'string' ? object : (object?.id || null);
472
+ if (!objectUrl) {
473
+ return null;
474
+ }
475
+
476
+ let id;
477
+ let exists;
478
+ if (objectUrl.startsWith(nconf.get('url'))) {
479
+ const { type, id: localId } = await activitypub.helpers.resolveLocalId(objectUrl);
480
+ if (type === 'post') {
481
+ id = localId;
482
+ exists = await posts.exists(id);
483
+ }
484
+ } else {
485
+ id = objectUrl;
486
+ exists = await posts.exists(id);
487
+ if (!exists) {
488
+ // Proactively pull in the note
489
+ const asserted = await activitypub.notes.assert(0, id, { skipChecks: 1 });
490
+ if (!asserted) {
491
+ return null;
384
492
  }
493
+ exists = true;
385
494
  }
495
+ }
496
+ return id && exists ? id : null;
497
+ }
386
498
 
387
- await Promise.all([
388
- db.setAdd(`pid:${data.pid}:reactions`, data.reaction),
389
- db.setAdd(`pid:${data.pid}:reaction:${data.reaction}`, socket.uid),
390
- ]);
499
+ ReactionsPlugin.applyEmojiReact = async function (activity) {
500
+ const { actor, object, content, tag } = activity;
391
501
 
392
- if (!alreadyReacted && reactionReputation > 0) {
393
- await giveOwnerReactionReputation(reactionReputation, data.pid);
502
+ const id = await resolveReactionPost(object);
503
+ if (!id) {
504
+ return;
505
+ }
506
+
507
+ const reaction = helpers.resolveReaction(content, tag);
508
+ if (!reaction) {
509
+ activitypub.helpers.log(`[reactions/ap] Unresolvable reaction content (${JSON.stringify(content)}), ignoring.`);
510
+ return;
511
+ }
512
+
513
+ const allowed = await privileges.posts.can('posts:upvote', id, activitypub._constants.uid);
514
+ if (!allowed) {
515
+ activitypub.helpers.log(`[reactions/ap] ${id} not allowed to be reacted on.`);
516
+ throw new Error('[[error:no-privileges]]');
517
+ }
518
+
519
+ activitypub.helpers.log(`[reactions/ap] id ${id} (${reaction}) via ${actor}`);
520
+ await ReactionsPlugin.addPostReaction(id, actor, reaction);
521
+ await activitypub.feps.announce(object.id, activity);
522
+ };
523
+
524
+ ReactionsPlugin.undoEmojiReact = async function (activity) {
525
+ const { actor, object, content, tag } = activity;
526
+
527
+ const id = await resolveReactionPost(object);
528
+ if (!id) {
529
+ return;
530
+ }
531
+
532
+ const reaction = helpers.resolveReaction(content, tag);
533
+ if (!reaction) {
534
+ activitypub.helpers.log(`[reactions/ap] Unresolvable reaction content in undo, ignoring.`);
535
+ return;
536
+ }
537
+
538
+ activitypub.helpers.log(`[reactions/ap] undo id ${id} (${reaction}) via ${actor}`);
539
+ await ReactionsPlugin.removePostReaction(id, actor, reaction);
540
+ await ReactionsPlugin.rescindPostReaction(id, actor, reaction);
541
+ await activitypub.feps.announce(object.id, activity);
542
+ };
543
+
544
+ ReactionsPlugin.handleEmojiReact = async function (context) {
545
+ await ReactionsPlugin.applyEmojiReact(context.activity);
546
+ return { ...context, claimed: true };
547
+ };
548
+
549
+ ReactionsPlugin.handleLike = async function (context) {
550
+ // FEP-c0e0: a Like with content is an emoji reaction; a plain Like falls through to core
551
+ if (typeof context.activity.content === 'string' && context.activity.content.trim()) {
552
+ await ReactionsPlugin.applyEmojiReact(context.activity);
553
+ return { ...context, claimed: true };
554
+ }
555
+ return context;
556
+ };
557
+
558
+ ReactionsPlugin.handleUndo = async function (context) {
559
+ const { object } = context.activity;
560
+ if (!object || (object.type !== 'EmojiReact' && !(object.type === 'Like' && typeof object.content === 'string' && object.content.trim()))) {
561
+ return context;
562
+ }
563
+ await ReactionsPlugin.undoEmojiReact(object);
564
+ return { ...context, claimed: true };
565
+ };
566
+
567
+ ReactionsPlugin.handleAnnounce = async function (context) {
568
+ const { actor } = context.activity;
569
+
570
+ // Unwrap nested Announces and resolve string references, mirroring core
571
+ let { object } = context.activity;
572
+ while (object && object.type === 'Announce') {
573
+ object = object.object;
574
+ }
575
+ if (typeof object === 'string') {
576
+ try {
577
+ object = await activitypub.helpers.resolveObjects(object);
578
+ } catch (e) {
579
+ object = { id: object };
394
580
  }
581
+ }
582
+ if (!object || (object.type !== 'EmojiReact' && !(object.type === 'Like' && typeof object.content === 'string' && object.content.trim()))) {
583
+ return context;
584
+ }
395
585
 
396
- if (postData.uid && postData.uid !== socket.uid) {
397
- const [displayname, topicTitle, parsedPostData] = await Promise.all([
398
- user.getNotificationDisplayname(socket.uid),
399
- topics.getNotificationTitle(data.tid),
400
- posts.parsePost(postData),
401
- ]);
402
- const notifObj = await notifications.create({
403
- type: 'reaction',
404
- bodyShort: translator.compile(
405
- 'reactions:notification.user-has-reacted-with-to-your-post-in-topic',
406
- displayname,
407
- `:${data.reaction}:`,
408
- topicTitle
409
- ),
410
- bodyLong: parsedPostData.content,
411
- nid: `uid:${socket.uid}:pid:${data.pid}:reaction:${data.reaction}`,
412
- pid: data.pid,
413
- tid: data.tid,
414
- from: socket.uid,
415
- path: `/post/${data.pid}`,
416
- });
586
+ // Only category-synced or relayed announces reach local posts
587
+ const fromRelay = await activitypub.relays.is(actor);
588
+ const categoryActor = await categories.exists(actor);
589
+ if (!categoryActor && !fromRelay) {
590
+ return context;
591
+ }
592
+
593
+ if (categoryActor) {
594
+ // Mirrors core's protection: category actors can only announce activities
595
+ // concerning posts in said category (the post's cid is the category actor URL)
596
+ let id = (object.object && object.object.id) || object.object;
597
+ const { id: localId } = await activitypub.helpers.resolveLocalId(id);
598
+ id = localId || id;
417
599
 
418
- await notifications.push(notifObj, [postData.uid]);
600
+ if (!(await posts.exists(id)) || (await posts.getCidByPid(id)) !== actor) {
601
+ return context;
419
602
  }
603
+ }
420
604
 
421
- await sendPostEvent(data, 'event:reactions.addPostReaction');
422
- },
423
- removePostReaction: async function (socket, data) {
605
+ if (!(await activitypub.actors.assert(object.actor))) {
606
+ throw new Error('[[error:activitypub.invalid-id]]');
607
+ }
608
+
609
+ if (typeof object.object === 'string') {
610
+ try {
611
+ object.object = await activitypub.helpers.resolveObjects(object.object);
612
+ } catch (e) {
613
+ activitypub.helpers.log(`[reactions/ap] Failed to resolve announced object, using raw id: ${object.object}`);
614
+ object.object = { id: object.object };
615
+ }
616
+ }
617
+
618
+ await ReactionsPlugin.applyEmojiReact(object);
619
+ return { ...context, claimed: true };
620
+ };
621
+
622
+ SocketPlugins.reactions = {
623
+ addPostReaction: async function (socket, data) {
424
624
  if (!socket.uid) {
425
625
  throw new Error('[[error:not-logged-in]]');
426
626
  }
@@ -429,34 +629,20 @@ SocketPlugins.reactions = {
429
629
  throw new Error('[[reactions:error.invalid-reaction]]');
430
630
  }
431
631
 
432
- const [settings, tid, hasReacted, reactionReputation] = await Promise.all([
433
- loadPluginConfig(),
434
- posts.getPostField(data.pid, 'tid'),
435
- db.isSetMember(`pid:${data.pid}:reaction:${data.reaction}`, socket.uid),
436
- getReactionReputation(data.reaction),
437
- ]);
438
- if (!settings.enablePostReactions) {
439
- throw new Error('[[error:post-reactions-disabled]]');
440
- }
441
- if (!tid) {
442
- throw new Error('[[error:no-post]]');
443
- }
444
632
  data.uid = socket.uid;
445
- data.tid = tid;
446
-
447
- if (hasReacted) {
448
- await db.setRemove(`pid:${data.pid}:reaction:${data.reaction}`, socket.uid);
633
+ await ReactionsPlugin.addPostReaction(data.pid, socket.uid, data.reaction);
634
+ },
635
+ removePostReaction: async function (socket, data) {
636
+ if (!socket.uid) {
637
+ throw new Error('[[error:not-logged-in]]');
449
638
  }
450
639
 
451
- const reactionCount = await db.setCount(`pid:${data.pid}:reaction:${data.reaction}`);
452
- if (reactionCount === 0) {
453
- await db.setRemove(`pid:${data.pid}:reactions`, data.reaction);
454
- }
455
- if (hasReacted && reactionReputation > 0) {
456
- await giveOwnerReactionReputation(-reactionReputation, data.pid);
640
+ if (!nameToEmoji(data.reaction)) {
641
+ throw new Error('[[reactions:error.invalid-reaction]]');
457
642
  }
458
643
 
459
- await sendPostEvent(data, 'event:reactions.removePostReaction');
644
+ data.uid = socket.uid;
645
+ await ReactionsPlugin.removePostReaction(data.pid, socket.uid, data.reaction);
460
646
  },
461
647
  addMessageReaction: async function (socket, data) {
462
648
  if (!socket.uid) {
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@nodebb/nodebb-plugin-reactions",
3
- "version": "3.0.4",
3
+ "version": "3.1.3",
4
4
  "nbbpm": {
5
- "compatibility": "^4.14.0"
5
+ "compatibility": "^4.16.0"
6
6
  },
7
7
  "description": "Reactions plugin for NodeBB",
8
8
  "main": "library.js",
package/plugin.json CHANGED
@@ -1,9 +1,8 @@
1
1
  {
2
- "id": "nodebb-plugin-reactions",
2
+ "id": "@nodebb/nodebb-plugin-reactions",
3
3
  "name": "NodeBB Reactions",
4
4
  "description": "Reactions plugin for NodeBB",
5
5
  "url": "https://github.com/NodeBB-Community/nodebb-plugin-reactions",
6
- "library": "./library.js",
7
6
  "templates": "templates",
8
7
  "languages": "languages",
9
8
  "scss": [
@@ -24,6 +23,10 @@
24
23
  { "hook": "filter:messaging.getMessages", "method": "getMessageReactions" },
25
24
  { "hook": "filter:post.get", "method": "onReply" },
26
25
  { "hook": "action:posts.purge", "method": "deleteReactions" },
26
+ { "hook": "filter:activitypub.emojireact", "method": "handleEmojiReact" },
27
+ { "hook": "filter:activitypub.like", "method": "handleLike" },
28
+ { "hook": "filter:activitypub.undo", "method": "handleUndo" },
29
+ { "hook": "filter:activitypub.announce", "method": "handleAnnounce" },
27
30
  { "hook": "filter:notifications.addFilters", "method": "addNotificationFilters" },
28
31
  { "hook": "filter:user.notificationTypes", "method": "notificationTypes" }
29
32
  ]
package/test/index.js ADDED
@@ -0,0 +1,506 @@
1
+ 'use strict';
2
+
3
+ /* globals nodebb, describe, it, before, after, beforeEach */
4
+
5
+ const assert = require('assert');
6
+ const util = require('util');
7
+
8
+ const sleep = util.promisify(setTimeout);
9
+
10
+ const db = nodebb.require('./test/mocks/databasemock');
11
+ const nconf = nodebb.require('nconf');
12
+ const meta = nodebb.require('./src/meta');
13
+ const install = nodebb.require('./src/install');
14
+ const user = nodebb.require('./src/user');
15
+ const categories = nodebb.require('./src/categories');
16
+ const topics = nodebb.require('./src/topics');
17
+ const posts = nodebb.require('./src/posts');
18
+ const privileges = nodebb.require('./src/privileges');
19
+ const controllers = nodebb.require('./src/controllers');
20
+ const activitypub = nodebb.require('./src/activitypub');
21
+ const utils = nodebb.require('./src/utils');
22
+ const SocketPlugins = nodebb.require('./src/socket.io/plugins');
23
+ const apHelpers = nodebb.require('./test/activitypub/helpers');
24
+
25
+ const plugin = require('../library');
26
+ const helpers = require('../helpers');
27
+
28
+ describe('helpers.resolveReaction', () => {
29
+ it('should resolve a unicode grapheme', () => {
30
+ assert.strictEqual(helpers.resolveReaction('🔥'), 'fire');
31
+ });
32
+
33
+ it('should resolve a :shortcode:', () => {
34
+ assert.strictEqual(helpers.resolveReaction(':fire:'), 'fire');
35
+ });
36
+
37
+ it('should resolve a bare name', () => {
38
+ assert.strictEqual(helpers.resolveReaction('fire'), 'fire');
39
+ });
40
+
41
+ it('should resolve an aliased :shortcode:', () => {
42
+ assert.strictEqual(helpers.resolveReaction(':telephone:'), 'phone');
43
+ });
44
+
45
+ it('should resolve the character of an aliased emoji', () => {
46
+ assert.strictEqual(helpers.resolveReaction('☎'), 'phone');
47
+ });
48
+
49
+ it('should resolve a keycap with a variation selector', () => {
50
+ assert.strictEqual(helpers.resolveReaction('1️⃣'), 'one'); // "1" + U+FE0F + U+20E3
51
+ });
52
+
53
+ it('should resolve a keycap without a variation selector', () => {
54
+ assert.strictEqual(helpers.resolveReaction('1⃣'), 'one'); // "1" + U+20E3
55
+ });
56
+
57
+ it('should resolve by first codepoint when the full grapheme is not in the table', () => {
58
+ // waving hand + medium-light skin tone → wave
59
+ assert.strictEqual(helpers.resolveReaction('👋🏻'), 'wave');
60
+ });
61
+
62
+ it('should resolve a custom emoji tag that matches a local emoji', () => {
63
+ assert.strictEqual(helpers.resolveReaction(':blobwtf:', [{ type: 'Emoji', name: ':fire:' }]), 'fire');
64
+ });
65
+
66
+ it('should return null for a custom emoji tag that does not match locally', () => {
67
+ assert.strictEqual(helpers.resolveReaction(':blobwtf:', [{ type: 'Emoji', name: ':blobwtf:' }]), null);
68
+ });
69
+
70
+ it('should return null for unresolvable content', () => {
71
+ assert.strictEqual(helpers.resolveReaction(':nope:'), null);
72
+ assert.strictEqual(helpers.resolveReaction('🛸'), null);
73
+ assert.strictEqual(helpers.resolveReaction('blobwtf'), null);
74
+ assert.strictEqual(helpers.resolveReaction(''), null);
75
+ assert.strictEqual(helpers.resolveReaction(null), null);
76
+ });
77
+ });
78
+
79
+ describe('ActivityPub (FEP-c0e0)', () => {
80
+ const remoteActor = 'https://example.org/user/reactions-tester';
81
+ const defaultSettings = {
82
+ enablePostReactions: 'on',
83
+ 'reaction-reputations': [{ reaction: 'fire', reputation: 5 }],
84
+ };
85
+
86
+ let apEnabled;
87
+ let cid;
88
+ let ownerUid;
89
+ let postData;
90
+
91
+ before(async () => {
92
+ apEnabled = meta.config.activitypubEnabled;
93
+ meta.config.activitypubEnabled = 1;
94
+ nconf.set('runJobs', 1);
95
+ await install.giveWorldPrivileges();
96
+ await meta.settings.set('reactions', defaultSettings);
97
+ ({ cid } = await categories.create({ name: utils.generateUUID().slice(0, 8) }));
98
+ });
99
+
100
+ after(async () => {
101
+ meta.config.activitypubEnabled = apEnabled;
102
+ nconf.set('runJobs', undefined);
103
+ await meta.settings.set('reactions', defaultSettings);
104
+ });
105
+
106
+ beforeEach(async () => {
107
+ ownerUid = await user.create({ username: utils.generateUUID().slice(0, 10) });
108
+ ({ postData } = await topics.post({
109
+ uid: ownerUid,
110
+ cid,
111
+ title: utils.generateUUID(),
112
+ content: utils.generateUUID(),
113
+ }));
114
+ });
115
+
116
+ function reactionActivity(override = {}) {
117
+ const activity = {
118
+ '@context': 'https://www.w3.org/ns/activitystreams',
119
+ id: `https://example.org/activity/${utils.generateUUID()}`,
120
+ type: 'EmojiReact',
121
+ actor: remoteActor,
122
+ object: {
123
+ type: 'Note',
124
+ id: `${nconf.get('url')}/post/${postData.pid}`,
125
+ },
126
+ content: '🔥',
127
+ };
128
+ Object.assign(activity, override);
129
+ return activity;
130
+ }
131
+
132
+ function mockRes() {
133
+ const res = { req: { method: 'POST', loggedIn: false }, statusCode: null, payload: null };
134
+ res.set = (key, value) => {
135
+ res[key] = value;
136
+ };
137
+ res.status = (code) => {
138
+ res.statusCode = code;
139
+ return res;
140
+ };
141
+ res.json = (payload) => {
142
+ res.payload = payload;
143
+ return res;
144
+ };
145
+ res.sendStatus = (code) => {
146
+ res.statusCode = code;
147
+ };
148
+ return res;
149
+ }
150
+
151
+ it('should still ignore unknown activity types (200) when the plugin is installed', async () => {
152
+ const res = mockRes();
153
+ await controllers.activitypub.postInbox({
154
+ body: {
155
+ id: `https://example.org/activity/${utils.generateUUID()}`,
156
+ type: 'BlowAWhistle',
157
+ actor: remoteActor,
158
+ object: { id: `${nconf.get('url')}/post/${postData.pid}` },
159
+ },
160
+ }, res);
161
+
162
+ assert.strictEqual(res.statusCode, 200);
163
+ });
164
+
165
+ describe('EmojiReact', () => {
166
+ it('should store a reaction from a unicode grapheme on a local post', async () => {
167
+ const res = mockRes();
168
+ await controllers.activitypub.postInbox({ body: reactionActivity() }, res);
169
+
170
+ assert.strictEqual(res.statusCode, 202);
171
+ assert(await db.isSetMember(`pid:${postData.pid}:reactions`, 'fire'));
172
+ assert(await db.isSetMember(`pid:${postData.pid}:reaction:fire`, remoteActor));
173
+ });
174
+
175
+ it('should store a reaction from a :shortcode:', async () => {
176
+ const res = mockRes();
177
+ await controllers.activitypub.postInbox({ body: reactionActivity({ content: ':fire:' }) }, res);
178
+
179
+ assert.strictEqual(res.statusCode, 202);
180
+ assert(await db.isSetMember(`pid:${postData.pid}:reactions`, 'fire'));
181
+ });
182
+
183
+ it('should notify the post owner', async () => {
184
+ const res = mockRes();
185
+ await controllers.activitypub.postInbox({ body: reactionActivity() }, res);
186
+
187
+ // notifications.push is deferred (500ms) through the batch queue
188
+ await sleep(700);
189
+ const nid = `uid:${remoteActor}:pid:${postData.pid}:reaction:fire`;
190
+ assert(await db.isSortedSetMember(`uid:${ownerUid}:notifications:unread`, nid));
191
+ });
192
+
193
+ it('should grant reaction reputation only once per reactor', async () => {
194
+ const res = mockRes();
195
+ await controllers.activitypub.postInbox({ body: reactionActivity() }, res);
196
+ await controllers.activitypub.postInbox({ body: reactionActivity() }, res);
197
+
198
+ assert.strictEqual(parseInt(await user.getUserField(ownerUid, 'reputation'), 10), 5);
199
+ assert.strictEqual(await db.setCount(`pid:${postData.pid}:reaction:fire`), 1);
200
+ });
201
+
202
+ it('should not upvote the post', async () => {
203
+ const res = mockRes();
204
+ await controllers.activitypub.postInbox({ body: reactionActivity() }, res);
205
+
206
+ const { upvoted } = await posts.hasVoted(postData.pid, remoteActor);
207
+ assert.strictEqual(upvoted, false);
208
+ assert.strictEqual(await posts.getPostField(postData.pid, 'upvotes'), 0);
209
+ });
210
+
211
+ it('should ignore unresolvable custom emoji', async () => {
212
+ const res = mockRes();
213
+ await controllers.activitypub.postInbox({
214
+ body: reactionActivity({
215
+ content: ':blobwtf:',
216
+ tag: [{ type: 'Emoji', name: ':blobwtf:' }],
217
+ }),
218
+ }, res);
219
+
220
+ assert.strictEqual(res.statusCode, 202);
221
+ assert.strictEqual(await db.setCount(`pid:${postData.pid}:reactions`), 0);
222
+ });
223
+
224
+ describe('with posts:upvote revoked from the fediverse pseudo-user', () => {
225
+ before(async () => {
226
+ await privileges.categories.rescind(['groups:posts:upvote'], cid, 'fediverse');
227
+ });
228
+
229
+ after(async () => {
230
+ await privileges.categories.give(['groups:posts:upvote'], cid, 'fediverse');
231
+ });
232
+
233
+ it('should throw [[error:no-privileges]]', async () => {
234
+ try {
235
+ await plugin.applyEmojiReact(reactionActivity());
236
+ assert.fail('expected applyEmojiReact to throw');
237
+ } catch (e) {
238
+ assert.strictEqual(e.message, '[[error:no-privileges]]');
239
+ }
240
+ });
241
+ });
242
+ });
243
+
244
+ describe('Like with content', () => {
245
+ it('should store a reaction, not an upvote', async () => {
246
+ const res = mockRes();
247
+ await controllers.activitypub.postInbox({
248
+ body: { ...reactionActivity({ type: 'Like' }) },
249
+ }, res);
250
+
251
+ assert.strictEqual(res.statusCode, 202);
252
+ assert(await db.isSetMember(`pid:${postData.pid}:reactions`, 'fire'));
253
+ const { upvoted } = await posts.hasVoted(postData.pid, remoteActor);
254
+ assert.strictEqual(upvoted, false);
255
+ });
256
+ });
257
+
258
+ describe('Undo', () => {
259
+ async function react() {
260
+ const res = mockRes();
261
+ await controllers.activitypub.postInbox({ body: reactionActivity() }, res);
262
+ assert.strictEqual(res.statusCode, 202);
263
+ }
264
+
265
+ it('should remove an EmojiReact reaction', async () => {
266
+ await react();
267
+ const original = reactionActivity();
268
+
269
+ const res = mockRes();
270
+ await controllers.activitypub.postInbox({
271
+ body: {
272
+ id: `https://example.org/activity/${utils.generateUUID()}`,
273
+ type: 'Undo',
274
+ actor: remoteActor,
275
+ object: original,
276
+ },
277
+ }, res);
278
+
279
+ assert.strictEqual(res.statusCode, 202);
280
+ assert(!(await db.isSetMember(`pid:${postData.pid}:reactions`, 'fire')));
281
+ assert(!(await db.isSetMember(`pid:${postData.pid}:reaction:fire`, remoteActor)));
282
+ });
283
+
284
+ it('should rescind the post owner notification', async () => {
285
+ await react();
286
+ await sleep(700);
287
+ const nid = `uid:${remoteActor}:pid:${postData.pid}:reaction:fire`;
288
+ assert(await db.isSortedSetMember(`uid:${ownerUid}:notifications:unread`, nid));
289
+
290
+ const res = mockRes();
291
+ await controllers.activitypub.postInbox({
292
+ body: {
293
+ id: `https://example.org/activity/${utils.generateUUID()}`,
294
+ type: 'Undo',
295
+ actor: remoteActor,
296
+ object: reactionActivity(),
297
+ },
298
+ }, res);
299
+
300
+ // the notification object is deleted (stale entries are pruned lazily)
301
+ assert(!(await db.exists(`notifications:${nid}`)));
302
+ assert(!(await db.isSortedSetMember('notifications', nid)));
303
+ });
304
+
305
+ it('should remove a Like-with-content reaction without touching the vote', async () => {
306
+ const like = { ...reactionActivity({ type: 'Like' }) };
307
+ const res = mockRes();
308
+ await controllers.activitypub.postInbox({ body: like }, res);
309
+ assert.strictEqual(res.statusCode, 202);
310
+
311
+ const undoRes = mockRes();
312
+ await controllers.activitypub.postInbox({
313
+ body: {
314
+ id: `https://example.org/activity/${utils.generateUUID()}`,
315
+ type: 'Undo',
316
+ actor: remoteActor,
317
+ object: like,
318
+ },
319
+ }, undoRes);
320
+
321
+ assert.strictEqual(undoRes.statusCode, 202);
322
+ assert(!(await db.isSetMember(`pid:${postData.pid}:reactions`, 'fire')));
323
+ assert.strictEqual(await posts.getPostField(postData.pid, 'upvotes'), 0);
324
+ });
325
+
326
+ it('should not claim undos of plain activities (core handles them)', async () => {
327
+ // plain Like (no content) → core upvotes
328
+ const plainLike = {
329
+ id: `https://example.org/activity/${utils.generateUUID()}`,
330
+ type: 'Like',
331
+ actor: remoteActor,
332
+ object: { type: 'Note', id: `${nconf.get('url')}/post/${postData.pid}` },
333
+ };
334
+ const res = mockRes();
335
+ await controllers.activitypub.postInbox({ body: plainLike }, res);
336
+ assert.strictEqual(res.statusCode, 202);
337
+ const { upvoted } = await posts.hasVoted(postData.pid, remoteActor);
338
+ assert.strictEqual(upvoted, true);
339
+
340
+ // plain Undo(Like) → core unvotes
341
+ const undoRes = mockRes();
342
+ await controllers.activitypub.postInbox({
343
+ body: {
344
+ id: `https://example.org/activity/${utils.generateUUID()}`,
345
+ type: 'Undo',
346
+ actor: remoteActor,
347
+ object: plainLike,
348
+ },
349
+ }, undoRes);
350
+ assert.strictEqual(undoRes.statusCode, 202);
351
+ const { upvoted: stillUpvoted } = await posts.hasVoted(postData.pid, remoteActor);
352
+ assert.strictEqual(stillUpvoted, false);
353
+ });
354
+
355
+ it('should handle Undo(Like) when object is a bare URL (not a full activity)', async () => {
356
+ // Some senders send the object as a bare URL instead of the full Like activity
357
+ const likeRes = mockRes();
358
+ await controllers.activitypub.postInbox({
359
+ body: { ...reactionActivity({ type: 'Like' }) },
360
+ }, likeRes);
361
+ assert.strictEqual(likeRes.statusCode, 202);
362
+
363
+ // Undo with a bare URL as the object — core inbox.undo normalizes this
364
+ // but the plugin's handleUndo fires first and must not crash
365
+ const undoRes = mockRes();
366
+ await controllers.activitypub.postInbox({
367
+ body: {
368
+ id: `https://example.org/activity/${utils.generateUUID()}`,
369
+ type: 'Undo',
370
+ actor: remoteActor,
371
+ object: `${nconf.get('url')}/post/${postData.pid}`,
372
+ },
373
+ }, undoRes);
374
+ // The plugin can't claim this (missing inner activity content), core handles it
375
+ assert.strictEqual(undoRes.statusCode, 202);
376
+ });
377
+ });
378
+
379
+ describe('Announce', () => {
380
+ let remoteCid;
381
+ let remotePostId;
382
+ let emojiReact;
383
+
384
+ before(async function () {
385
+ ({ id: remoteCid } = apHelpers.mocks.group());
386
+ await activitypub.actors.assertGroup([remoteCid]);
387
+
388
+ // A remote post that lands in the remote category
389
+ const { note, id } = apHelpers.mocks.note({ audience: [remoteCid] });
390
+ const { activity } = apHelpers.mocks.create(note);
391
+ await activitypub.inbox.create({ body: activity });
392
+ this.remotePostId = id;
393
+ remotePostId = id;
394
+
395
+ emojiReact = {
396
+ id: `https://example.org/activity/${utils.generateUUID()}`,
397
+ type: 'EmojiReact',
398
+ actor: remoteActor,
399
+ object: { type: 'Note', id },
400
+ content: '🔥',
401
+ };
402
+ });
403
+
404
+ it('should ignore EmojiReact announces from non-category, non-relay actors', async () => {
405
+ const { activity } = apHelpers.mocks.announce({ actor: remoteActor, object: emojiReact });
406
+ const res = mockRes();
407
+ await controllers.activitypub.postInbox({ body: activity }, res);
408
+
409
+ // falls through to core's announce handler, which also does nothing for this
410
+ assert.strictEqual(res.statusCode, 202);
411
+ assert.strictEqual(await db.setCount(`pid:${remotePostId}:reactions`), 0);
412
+ });
413
+
414
+ it('should apply a reaction announced by the remote category', async () => {
415
+ const { activity } = apHelpers.mocks.announce({ actor: remoteCid, object: emojiReact });
416
+ const res = mockRes();
417
+ await controllers.activitypub.postInbox({ body: activity }, res);
418
+
419
+ assert.strictEqual(res.statusCode, 202);
420
+ assert(await db.isSetMember(`pid:${remotePostId}:reactions`, 'fire'));
421
+ assert(await db.isSetMember(`pid:${remotePostId}:reaction:fire`, remoteActor));
422
+ });
423
+ });
424
+ });
425
+
426
+ describe('Socket handlers', () => {
427
+ const defaultSettings = {
428
+ enablePostReactions: 'on',
429
+ 'reaction-reputations': [{ reaction: 'fire', reputation: 5 }],
430
+ };
431
+
432
+ let cid;
433
+ let uid;
434
+ let postData;
435
+
436
+ before(async () => {
437
+ ({ cid } = await categories.create({ name: utils.generateUUID().slice(0, 8) }));
438
+ await meta.settings.set('reactions', defaultSettings);
439
+ });
440
+
441
+ after(async () => {
442
+ await meta.settings.set('reactions', defaultSettings);
443
+ });
444
+
445
+ beforeEach(async () => {
446
+ uid = await user.create({ username: utils.generateUUID().slice(0, 10) });
447
+ ({ postData } = await topics.post({
448
+ uid,
449
+ cid,
450
+ title: utils.generateUUID(),
451
+ content: utils.generateUUID(),
452
+ }));
453
+ });
454
+
455
+ it('should add a reaction', async () => {
456
+ await SocketPlugins.reactions.addPostReaction({ uid }, { pid: postData.pid, reaction: 'fire' });
457
+ assert(await db.isSetMember(`pid:${postData.pid}:reaction:fire`, uid));
458
+ });
459
+
460
+ it('should require a logged-in socket', async () => {
461
+ try {
462
+ await SocketPlugins.reactions.addPostReaction({}, { pid: postData.pid, reaction: 'fire' });
463
+ assert.fail('expected addPostReaction to throw');
464
+ } catch (e) {
465
+ assert.strictEqual(e.message, '[[error:not-logged-in]]');
466
+ }
467
+ });
468
+
469
+ it('should reject unknown reaction names', async () => {
470
+ try {
471
+ await SocketPlugins.reactions.addPostReaction({ uid }, { pid: postData.pid, reaction: 'notarealemoji' });
472
+ assert.fail('expected addPostReaction to throw');
473
+ } catch (e) {
474
+ assert.strictEqual(e.message, '[[reactions:error.invalid-reaction]]');
475
+ }
476
+ });
477
+
478
+ it('should remove a reaction', async () => {
479
+ await SocketPlugins.reactions.addPostReaction({ uid }, { pid: postData.pid, reaction: 'fire' });
480
+ await SocketPlugins.reactions.removePostReaction({ uid }, { pid: postData.pid, reaction: 'fire' });
481
+ assert(!(await db.isSetMember(`pid:${postData.pid}:reaction:fire`, uid)));
482
+ assert(!(await db.isSetMember(`pid:${postData.pid}:reactions`, 'fire')));
483
+ });
484
+
485
+ it('should enforce the maximumReactions cap', async () => {
486
+ await meta.settings.set('reactions', { ...defaultSettings, maximumReactions: '2' });
487
+ await SocketPlugins.reactions.addPostReaction({ uid }, { pid: postData.pid, reaction: 'fire' });
488
+ await SocketPlugins.reactions.addPostReaction({ uid }, { pid: postData.pid, reaction: 'phone' });
489
+ try {
490
+ await SocketPlugins.reactions.addPostReaction({ uid }, { pid: postData.pid, reaction: 'smile' });
491
+ assert.fail('expected addPostReaction to throw');
492
+ } catch (e) {
493
+ assert(e.message.startsWith('[[reactions:error.maximum-reached, '));
494
+ }
495
+ });
496
+
497
+ it('should throw when post reactions are disabled', async () => {
498
+ await meta.settings.set('reactions', { ...defaultSettings, enablePostReactions: 'off' });
499
+ try {
500
+ await SocketPlugins.reactions.addPostReaction({ uid }, { pid: postData.pid, reaction: 'fire' });
501
+ assert.fail('expected addPostReaction to throw');
502
+ } catch (e) {
503
+ assert.strictEqual(e.message, '[[error:post-reactions-disabled]]');
504
+ }
505
+ });
506
+ });