Skip to content

Commit 52309cc

Browse files
committed
Merge tag '0.7.1'
Hollo 0.7.1
2 parents 8cdfbf8 + 669e48c commit 52309cc

File tree

3 files changed

+225
-17
lines changed

3 files changed

+225
-17
lines changed

CHANGES.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,20 @@ To be released.
4242
[#350]: https://github.com/fedify-dev/hollo/issues/350
4343

4444

45+
Version 0.7.1
46+
-------------
47+
48+
Released on February 4, 2026.
49+
50+
- Fixed emoji reaction notifications not displaying emoji information in
51+
Mastodon-compatible clients. The `/api/v1/notifications` endpoint now
52+
includes top-level `emoji` and `emoji_url` fields for `emoji_reaction`
53+
notifications, compatible with Pleroma/Akkoma clients like Phanpy.
54+
[[#358]]
55+
56+
[#358]: https://github.com/fedify-dev/hollo/issues/358
57+
58+
4559
Version 0.7.0
4660
-------------
4761

src/api/v1/notifications.test.ts

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,4 +364,136 @@ describe.sequential("/api/v1/notifications", () => {
364364
expect(body).toHaveLength(0);
365365
});
366366
});
367+
368+
describe("Emoji reaction notifications", () => {
369+
it("includes emoji in emoji_reaction notifications", async () => {
370+
expect.assertions(5);
371+
372+
const accessToken = await getAccessToken(client, account, [
373+
"read:notifications",
374+
]);
375+
376+
// Create a post by the local user
377+
const postId = crypto.randomUUID() as Uuid;
378+
const postIri = `https://test.local/@test/${postId}`;
379+
await db.insert(Schema.posts).values({
380+
id: postId,
381+
iri: postIri,
382+
type: "Note",
383+
accountId: account.id as Uuid,
384+
visibility: "public",
385+
contentHtml: "Test post",
386+
content: "Test post",
387+
url: postIri,
388+
});
389+
390+
// Create an emoji reaction from remote account
391+
const emoji = "😊";
392+
await db.insert(Schema.reactions).values({
393+
postId,
394+
accountId: remoteAccount.id,
395+
emoji,
396+
customEmoji: null,
397+
emojiIri: null,
398+
});
399+
400+
// Create emoji_reaction notification
401+
await db.insert(Schema.notifications).values({
402+
id: crypto.randomUUID() as Uuid,
403+
accountOwnerId: account.id as Uuid,
404+
type: "emoji_reaction",
405+
actorAccountId: remoteAccount.id,
406+
targetPostId: postId,
407+
groupKey: `test-emoji-reaction-${postId}`,
408+
created: new Date(),
409+
});
410+
411+
const response = await app.request(
412+
"/api/v1/notifications?types[]=emoji_reaction",
413+
{
414+
method: "GET",
415+
headers: {
416+
authorization: bearerAuthorization(accessToken),
417+
},
418+
},
419+
);
420+
421+
expect(response.status).toBe(200);
422+
423+
const notifications = await response.json();
424+
expect(notifications.length).toBe(1);
425+
426+
const notification = notifications[0];
427+
expect(notification.type).toBe("emoji_reaction");
428+
expect(notification.emoji_reaction).toBeDefined();
429+
expect(notification.emoji_reaction.name).toBe(emoji);
430+
});
431+
432+
it("handles custom emoji in emoji_reaction notifications", async () => {
433+
expect.assertions(6);
434+
435+
const accessToken = await getAccessToken(client, account, [
436+
"read:notifications",
437+
]);
438+
439+
// Create a post by the local user
440+
const postId = crypto.randomUUID() as Uuid;
441+
const postIri = `https://test.local/@test/${postId}`;
442+
await db.insert(Schema.posts).values({
443+
id: postId,
444+
iri: postIri,
445+
type: "Note",
446+
accountId: account.id as Uuid,
447+
visibility: "public",
448+
contentHtml: "Test post",
449+
content: "Test post",
450+
url: postIri,
451+
});
452+
453+
// Create a custom emoji reaction from remote account
454+
const emojiName = ":custom_emoji:";
455+
const emojiUrl = "https://remote.test/emoji/custom_emoji.png";
456+
const emojiIri = "https://remote.test/emoji/custom_emoji";
457+
458+
await db.insert(Schema.reactions).values({
459+
postId,
460+
accountId: remoteAccount.id,
461+
emoji: emojiName,
462+
customEmoji: emojiUrl,
463+
emojiIri: emojiIri,
464+
});
465+
466+
// Create emoji_reaction notification
467+
await db.insert(Schema.notifications).values({
468+
id: crypto.randomUUID() as Uuid,
469+
accountOwnerId: account.id as Uuid,
470+
type: "emoji_reaction",
471+
actorAccountId: remoteAccount.id,
472+
targetPostId: postId,
473+
groupKey: `test-custom-emoji-${postId}`,
474+
created: new Date(),
475+
});
476+
477+
const response = await app.request(
478+
"/api/v1/notifications?types[]=emoji_reaction",
479+
{
480+
method: "GET",
481+
headers: {
482+
authorization: bearerAuthorization(accessToken),
483+
},
484+
},
485+
);
486+
487+
expect(response.status).toBe(200);
488+
489+
const notifications = await response.json();
490+
expect(notifications.length).toBe(1);
491+
492+
const notification = notifications[0];
493+
expect(notification.type).toBe("emoji_reaction");
494+
expect(notification.emoji_reaction).toBeDefined();
495+
expect(notification.emoji_reaction.name).toBe("custom_emoji");
496+
expect(notification.emoji_reaction.url).toBe(emojiUrl);
497+
});
498+
});
367499
});

src/api/v1/notifications.ts

Lines changed: 79 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
polls,
2121
pollVotes,
2222
posts,
23+
reactions,
2324
} from "../../schema";
2425
import type { Uuid } from "../../uuid";
2526

@@ -173,6 +174,48 @@ app.get(
173174
count: notificationsData.length,
174175
});
175176

177+
// Fetch emoji reactions for emoji_reaction notifications
178+
const emojiReactionNotifications = notificationsData.filter(
179+
(n) =>
180+
n.type === "emoji_reaction" &&
181+
n.targetPostId != null &&
182+
n.actorAccount != null,
183+
);
184+
185+
// Fetch reactions from DB
186+
const reactionsData =
187+
emojiReactionNotifications.length > 0
188+
? await db.query.reactions.findMany({
189+
where: or(
190+
...emojiReactionNotifications.map((n) =>
191+
and(
192+
eq(reactions.postId, n.targetPostId!),
193+
eq(reactions.accountId, n.actorAccount!.id),
194+
),
195+
),
196+
),
197+
with: {
198+
account: { with: { owner: true, successor: true } },
199+
},
200+
})
201+
: [];
202+
203+
// Build map: "postId:accountId" -> Reaction
204+
const reactionsMap = new Map<string, (typeof reactionsData)[number]>();
205+
for (const reaction of reactionsData) {
206+
const mapKey = `${reaction.postId}:${reaction.accountId}`;
207+
reactionsMap.set(mapKey, reaction);
208+
}
209+
210+
if (reactionsData.length > 0) {
211+
logger.debug(
212+
"Fetched {count} emoji reactions for emoji_reaction notifications",
213+
{
214+
count: reactionsData.length,
215+
},
216+
);
217+
}
218+
176219
// Query poll expiry notifications dynamically (not stored in DB)
177220
type StoredNotification = (typeof notificationsData)[number];
178221
type PollNotification = {
@@ -321,7 +364,7 @@ app.get(
321364
});
322365
return null;
323366
}
324-
return {
367+
const result: Record<string, unknown> = {
325368
id: `${created_at}/${n.type}/${n.id}`,
326369
type: n.type,
327370
created_at,
@@ -338,23 +381,42 @@ app.get(
338381
status: n.targetPost
339382
? serializePost(n.targetPost, owner, c.req.url)
340383
: null,
341-
...(n.type === "emoji_reaction" && n.targetPost && account
342-
? {
343-
emoji_reaction: serializeReaction(
344-
{
345-
postId: n.targetPost.id,
346-
accountId: account.id,
347-
account,
348-
emoji: "", // Will be fetched from reactions table if needed
349-
customEmoji: null,
350-
emojiIri: null,
351-
created: n.created,
352-
},
353-
owner,
354-
),
355-
}
356-
: {}),
357384
};
385+
386+
// Add emoji and emoji_url fields for emoji_reaction notifications
387+
// These fields are used by clients like Phanpy, Misskey, Pleroma
388+
if (n.type === "emoji_reaction" && n.targetPost && account != null) {
389+
const mapKey = `${n.targetPost.id}:${account.id}`;
390+
const reaction = reactionsMap.get(mapKey);
391+
392+
if (reaction != null) {
393+
// Add top-level emoji and emoji_url fields for client compatibility
394+
result.emoji = reaction.emoji;
395+
if (reaction.customEmoji != null) {
396+
result.emoji_url = reaction.customEmoji;
397+
// Also add camelCase variant for Phanpy compatibility (Phanpy bug workaround)
398+
result.emojiURL = reaction.customEmoji;
399+
}
400+
401+
// Also include emoji_reaction object for Mastodon-compatible clients
402+
result.emoji_reaction = serializeReaction(reaction, owner);
403+
} else {
404+
// Fallback: reaction not found (deleted)
405+
logger.warn(
406+
"Reaction not found for emoji_reaction notification {notifId}",
407+
{ notifId: n.id },
408+
);
409+
result.emoji = "";
410+
result.emoji_reaction = {
411+
name: "",
412+
count: 1,
413+
me: false,
414+
account_ids: [account.id],
415+
};
416+
}
417+
}
418+
419+
return result;
358420
})
359421
.filter((n) => n != null);
360422

0 commit comments

Comments
 (0)