@nodebb/nodebb-plugin-reactions 3.0.4 → 3.1.2
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 +106 -0
- package/library.js +285 -99
- package/package.json +2 -2
- package/plugin.json +4 -1
- package/test/index.js +506 -0
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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
|
-
|
|
350
|
-
|
|
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
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
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
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
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
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
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
|
-
|
|
388
|
-
|
|
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
|
-
|
|
393
|
-
|
|
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
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
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
|
-
|
|
600
|
+
if (!(await posts.exists(id)) || (await posts.getCidByPid(id)) !== actor) {
|
|
601
|
+
return context;
|
|
419
602
|
}
|
|
603
|
+
}
|
|
420
604
|
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
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.
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
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
|
-
|
|
452
|
-
|
|
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
|
-
|
|
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
package/plugin.json
CHANGED
|
@@ -3,7 +3,6 @@
|
|
|
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
|
+
});
|