Skip to content

Commit e2af836

Browse files
committed
Sending emoji reactions
1 parent a9873dd commit e2af836

7 files changed

Lines changed: 308 additions & 7 deletions

File tree

.vscode/settings.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
"activitypub",
4848
"botkit",
4949
"denokv",
50+
"fanout",
5051
"fedi",
5152
"Fedify",
5253
"fediverse",
@@ -72,6 +73,7 @@
7273
"unfollowing",
7374
"unfollows",
7475
"unliked",
76+
"unreact",
7577
"uuidv7",
7678
"vitepress"
7779
]

CHANGES.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,13 @@ To be released.
2121
- Added `CustomEmoji` type.
2222
- Added `DeferredCustomEmoji` type.
2323

24-
- Added `Emoji` type.
24+
- Added emoji reaction support.
2525

26-
- Added `isEmoji()` predicate function.
26+
- Added `Emoji` type.
27+
- Added `isEmoji()` predicate function.
28+
- Added `Message.react()` method.
29+
- Added `Reaction` interface.
30+
- Added `AuthorizedReaction` interface.
2731

2832
- Added `SessionGetOutboxOptions` interface.
2933

src/message-impl.test.ts

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,10 @@ import {
2020
ChatMessage,
2121
Create,
2222
Delete,
23+
Emoji as CustomEmoji,
24+
EmojiReact,
2325
Hashtag,
26+
Image,
2427
Like as RawLike,
2528
Mention,
2629
Note,
@@ -36,6 +39,7 @@ import { assertEquals } from "@std/assert/equals";
3639
import { assertInstanceOf } from "@std/assert/instance-of";
3740
import { assertRejects } from "@std/assert/rejects";
3841
import { BotImpl } from "./bot-impl.ts";
42+
import { type DeferredCustomEmoji, isEmoji } from "./emoji.ts";
3943
import {
4044
createMessage,
4145
getMessageClass,
@@ -578,3 +582,141 @@ Deno.test("getMessageVisibility()", () => {
578582
);
579583
assertEquals(getMessageVisibility([], [], new Person({})), "unknown");
580584
});
585+
586+
Deno.test("MessageImpl.react()", async (t) => {
587+
const bot = new BotImpl<void>({
588+
kv: new MemoryKvStore(),
589+
username: "bot",
590+
});
591+
const ctx = createMockContext(bot, "https://example.com");
592+
const session = new SessionImpl(bot, ctx);
593+
const originalPost = new Note({
594+
id: new URL(
595+
"https://example.com/ap/note/react-test-note",
596+
),
597+
content: "<p>React to this!</p>",
598+
attribution: new Person({
599+
id: new URL("https://example.com/ap/actor/john"),
600+
preferredUsername: "john",
601+
}),
602+
to: PUBLIC_COLLECTION,
603+
});
604+
const message = await createMessage<Note, void>(
605+
originalPost,
606+
session,
607+
{},
608+
);
609+
610+
await t.step("react() with string emoji", async () => {
611+
ctx.sentActivities = []; // Clear previous activities
612+
const emoji = "👍";
613+
assert(isEmoji(emoji));
614+
const reaction = await message.react(emoji);
615+
assertEquals(ctx.sentActivities.length, 2);
616+
const { recipients, activity } = ctx.sentActivities[0];
617+
assertEquals(recipients, "followers");
618+
assertInstanceOf(activity, EmojiReact);
619+
assertEquals(activity.actorId, ctx.getActorUri(bot.identifier));
620+
assertEquals(activity.objectId, message.id);
621+
assertEquals(activity.name, "👍");
622+
assertEquals(await Array.fromAsync(activity.getTags()), []);
623+
const { recipients: recipients2, activity: activity2 } =
624+
ctx.sentActivities[1];
625+
assertEquals(recipients2, [message.actor]);
626+
assertInstanceOf(activity2, EmojiReact);
627+
assertEquals(activity2, activity);
628+
assertEquals(reaction.actor, await session.getActor());
629+
assertEquals(reaction.raw, activity);
630+
assertEquals(reaction.id, activity.id);
631+
assertEquals(reaction.message, message);
632+
assertEquals(reaction.emoji, emoji);
633+
634+
// Test unreact
635+
ctx.sentActivities = [];
636+
await reaction.unreact();
637+
assertEquals(ctx.sentActivities.length, 2);
638+
const { recipients: urRecipients, activity: urActivity } =
639+
ctx.sentActivities[0];
640+
assertEquals(urRecipients, "followers");
641+
assertInstanceOf(urActivity, Undo);
642+
assertEquals(urActivity.actorId, ctx.getActorUri(bot.identifier));
643+
const urObject = await urActivity.getObject();
644+
assertInstanceOf(urObject, EmojiReact);
645+
assertEquals(urObject.id, reaction.id);
646+
const { recipients: urRecipients2, activity: urActivity2 } =
647+
ctx.sentActivities[1];
648+
assertEquals(urRecipients2, [message.actor]);
649+
assertInstanceOf(urActivity2, Undo);
650+
assertEquals(urActivity2, urActivity);
651+
});
652+
653+
await t.step("react() with CustomEmoji", async () => {
654+
ctx.sentActivities = [];
655+
const customEmoji = new CustomEmoji({
656+
id: new URL("https://example.com/emojis/custom"),
657+
name: ":custom:",
658+
icon: new Image({
659+
url: new URL("https://example.com/emojis/custom.png"),
660+
}),
661+
});
662+
const reaction = await message.react(customEmoji);
663+
assertEquals(ctx.sentActivities.length, 2);
664+
const { activity } = ctx.sentActivities[0];
665+
assertInstanceOf(activity, EmojiReact);
666+
assertEquals(activity.name, ":custom:");
667+
const tags = await Array.fromAsync(activity.getTags());
668+
assertEquals(tags.length, 1);
669+
assertEquals(tags[0], customEmoji);
670+
assertEquals(reaction.emoji, customEmoji);
671+
672+
// Test unreact
673+
ctx.sentActivities = [];
674+
await reaction.unreact();
675+
assertEquals(ctx.sentActivities.length, 2);
676+
const { activity: urActivity } = ctx.sentActivities[0];
677+
assertInstanceOf(urActivity, Undo);
678+
const urObject = await urActivity.getObject();
679+
assertInstanceOf(urObject, EmojiReact);
680+
assertEquals(urObject.id, reaction.id);
681+
const urTags = await Array.fromAsync(urActivity.getTags());
682+
assertEquals(urTags.length, 1);
683+
assertEquals(urTags[0], customEmoji);
684+
});
685+
686+
await t.step("react() with DeferredCustomEmoji", async () => {
687+
ctx.sentActivities = [];
688+
const deferredEmoji: DeferredCustomEmoji<void> = (sessionParam) => {
689+
assertEquals(sessionParam, session); // Ensure correct session is passed
690+
return new CustomEmoji({
691+
id: new URL("https://example.com/emojis/deferred"),
692+
name: ":deferred:",
693+
icon: new Image({
694+
url: new URL("https://example.com/emojis/deferred.png"),
695+
}),
696+
});
697+
};
698+
const reaction = await message.react(deferredEmoji);
699+
assertEquals(ctx.sentActivities.length, 2);
700+
const { activity } = ctx.sentActivities[0];
701+
assertInstanceOf(activity, EmojiReact);
702+
assertEquals(activity.name, ":deferred:");
703+
const tags = await Array.fromAsync(activity.getTags());
704+
assertEquals(tags.length, 1);
705+
assertInstanceOf(tags[0], CustomEmoji);
706+
assertEquals(tags[0].id?.href, "https://example.com/emojis/deferred");
707+
assertEquals(reaction.emoji, tags[0]);
708+
709+
// Test unreact
710+
ctx.sentActivities = [];
711+
await reaction.unreact();
712+
assertEquals(ctx.sentActivities.length, 2);
713+
const { activity: urActivity } = ctx.sentActivities[0];
714+
assertInstanceOf(urActivity, Undo);
715+
const urObject = await urActivity.getObject();
716+
assertInstanceOf(urObject, EmojiReact);
717+
assertEquals(urObject.id, reaction.id);
718+
const urTags = await Array.fromAsync(urActivity.getTags());
719+
assertEquals(urTags.length, 1);
720+
assertEquals(urTags[0], tags[0]); // Should be the resolved CustomEmoji
721+
});
722+
});

src/message-impl.ts

Lines changed: 79 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ import {
2222
Create,
2323
Delete,
2424
Document,
25+
Emoji as CustomEmoji,
26+
EmojiReact,
2527
Hashtag,
2628
isActor,
2729
Like as RawLike,
@@ -39,6 +41,7 @@ import type { LanguageTag } from "@phensley/language-tag";
3941
import { unescape } from "@std/html/entities";
4042
import { generate as uuidv7 } from "@std/uuid/unstable-v7";
4143
import { FilterXSS, getDefaultWhiteList } from "xss";
44+
import { DeferredCustomEmoji, Emoji } from "./emoji.ts";
4245
import type {
4346
AuthorizedMessage,
4447
AuthorizedSharedMessage,
@@ -47,7 +50,7 @@ import type {
4750
MessageShareOptions,
4851
MessageVisibility,
4952
} from "./message.ts";
50-
import type { AuthorizedLike } from "./reaction.ts";
53+
import type { AuthorizedLike, AuthorizedReaction } from "./reaction.ts";
5154
import type { Uuid } from "./repository.ts";
5255
import type { SessionImpl } from "./session-impl.ts";
5356
import type {
@@ -97,7 +100,7 @@ export class MessageImpl<T extends MessageClass, TContextData>
97100
session: SessionImpl<TContextData>,
98101
message: Omit<
99102
Message<T, TContextData>,
100-
"delete" | "reply" | "share" | "like"
103+
"delete" | "reply" | "share" | "like" | "react"
101104
>,
102105
) {
103106
this.session = session;
@@ -234,6 +237,7 @@ export class MessageImpl<T extends MessageClass, TContextData>
234237
{
235238
preferSharedInbox: true,
236239
excludeBaseUris: [new URL(this.session.context.origin)],
240+
fanout: "skip",
237241
},
238242
);
239243
return {
@@ -263,6 +267,79 @@ export class MessageImpl<T extends MessageClass, TContextData>
263267
{
264268
preferSharedInbox: true,
265269
excludeBaseUris: [new URL(this.session.context.origin)],
270+
fanout: "skip",
271+
},
272+
);
273+
},
274+
};
275+
}
276+
277+
async react(
278+
emoji: Emoji | CustomEmoji | DeferredCustomEmoji<TContextData>,
279+
): Promise<AuthorizedReaction<TContextData>> {
280+
const uuid = crypto.randomUUID();
281+
const actor = this.session.context.getActorUri(this.session.bot.identifier);
282+
const id = new URL(`#react/${uuid}`, actor);
283+
if (typeof emoji === "function") {
284+
emoji = await emoji(this.session);
285+
}
286+
const activity = new EmojiReact({
287+
id,
288+
actor,
289+
object: this.id,
290+
name: typeof emoji === "string" ? emoji : emoji.name,
291+
tags: typeof emoji === "string" ? [] : [emoji],
292+
});
293+
await this.session.context.sendActivity(
294+
this.session.bot,
295+
"followers",
296+
activity,
297+
{
298+
preferSharedInbox: true,
299+
excludeBaseUris: [new URL(this.session.context.origin)],
300+
},
301+
);
302+
await this.session.context.sendActivity(
303+
this.session.bot,
304+
this.actor,
305+
activity,
306+
{
307+
preferSharedInbox: true,
308+
excludeBaseUris: [new URL(this.session.context.origin)],
309+
fanout: "skip",
310+
},
311+
);
312+
return {
313+
raw: activity,
314+
id,
315+
actor: await this.session.getActor(),
316+
message: this,
317+
emoji,
318+
unreact: async () => {
319+
const undo = new Undo({
320+
id: new URL(`#unreact/${uuid}`, actor),
321+
actor,
322+
object: activity,
323+
name: typeof emoji === "string" ? emoji : emoji.name,
324+
tags: typeof emoji === "string" ? [] : [emoji],
325+
});
326+
await this.session.context.sendActivity(
327+
this.session.bot,
328+
"followers",
329+
undo,
330+
{
331+
preferSharedInbox: true,
332+
excludeBaseUris: [new URL(this.session.context.origin)],
333+
},
334+
);
335+
await this.session.context.sendActivity(
336+
this.session.bot,
337+
this.actor,
338+
undo,
339+
{
340+
preferSharedInbox: true,
341+
excludeBaseUris: [new URL(this.session.context.origin)],
342+
fanout: "skip",
266343
},
267344
);
268345
},

src/message.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,14 @@ import type {
1919
Article,
2020
ChatMessage,
2121
Document,
22+
Emoji as CustomEmoji,
2223
Hashtag,
2324
Note,
2425
Question,
2526
} from "@fedify/fedify/vocab";
2627
import type { LanguageTag } from "@phensley/language-tag";
27-
import type { AuthorizedLike } from "./reaction.ts";
28+
import { DeferredCustomEmoji, Emoji } from "./emoji.ts";
29+
import type { AuthorizedLike, AuthorizedReaction } from "./reaction.ts";
2830
import type {
2931
SessionPublishOptions,
3032
SessionPublishOptionsWithClass,
@@ -196,6 +198,17 @@ export interface Message<T extends MessageClass, TContextData> {
196198
* @returns The like object.
197199
*/
198200
like(): Promise<AuthorizedLike<TContextData>>;
201+
202+
/**
203+
* Reacts to the message with a Unicode emoji or a custom emoji.
204+
* @param emoji The emoji to react with. It can be either a Unicode emoji or
205+
* a custom emoji.
206+
* @returns The reaction object.
207+
* @since 0.2.0
208+
*/
209+
react(
210+
emoji: Emoji | CustomEmoji | DeferredCustomEmoji<TContextData>,
211+
): Promise<AuthorizedReaction<TContextData>>;
199212
}
200213

201214
/**

src/mod.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,14 @@ export type {
6363
MessageVisibility,
6464
SharedMessage,
6565
} from "./message.ts";
66-
export { type AuthorizedLike, type Like, RawLike } from "./reaction.ts";
66+
export {
67+
type AuthorizedLike,
68+
type AuthorizedReaction,
69+
EmojiReact,
70+
type Like,
71+
RawLike,
72+
type Reaction,
73+
} from "./reaction.ts";
6774
export {
6875
Announce,
6976
Create,

0 commit comments

Comments
 (0)