Skip to content

Commit af307a6

Browse files
committed
Fix reply notification type
Store reply notifications as mention notifications instead of status notifications so Mastodon-compatible clients render them as replies rather than generic post notifications. Add regression coverage for reply notification creation and duplicate prevention when a reply also explicitly mentions the original author. Fixes #380 Assisted-by: Codex:gpt-5.5
1 parent 1ac1a12 commit af307a6

4 files changed

Lines changed: 135 additions & 37 deletions

File tree

CHANGES.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,13 @@ Version 0.7.13
66

77
To be released.
88

9+
- Fixed a Mastodon API compatibility regression where replies to local posts
10+
were stored as `status` notifications, causing clients to show generic
11+
“posted” titles instead of reply notifications. Replies are now stored as
12+
`mention` notifications. [[#380]]
13+
14+
[#380]: https://github.com/fedify-dev/hollo/issues/380
15+
916

1017
Version 0.7.12
1118
--------------

src/federation/inbox.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ import {
3535
createQuotedUpdateNotifications,
3636
createQuoteNotification,
3737
createReblogNotification,
38-
createStatusNotification,
38+
createReplyMentionNotification,
3939
} from "../notification";
4040
import {
4141
type Account,
@@ -431,12 +431,14 @@ export async function onPostCreated(
431431
await updatePostStats(db, { id: post.replyTargetId });
432432
}
433433

434-
// Create status notification for reply target author (if this is a reply)
434+
// Create mention notification for reply target author (if this is a reply)
435435
// and mention notifications for other mentioned local users
436436
if (post != null) {
437437
let replyTargetAuthorId: typeof post.accountId | null = null;
438438

439-
// If this is a reply, create a "status" notification for the original post author
439+
// If this is a reply, create a "mention" notification for the original
440+
// post author. Mastodon clients render this as a reply based on the
441+
// attached status's in_reply_to_account_id.
440442
if (post.replyTargetId != null) {
441443
const replyTarget = await db.query.posts.findFirst({
442444
where: eq(posts.id, post.replyTargetId),
@@ -448,13 +450,12 @@ export async function onPostCreated(
448450
if (replyTarget != null) {
449451
replyTargetAuthorId = replyTarget.accountId;
450452

451-
// Create status notification for the reply target author
452-
await createStatusNotification(post.account, post, replyTarget);
453+
await createReplyMentionNotification(post.account, post, replyTarget);
453454
}
454455
}
455456

456457
// Create mention notifications for mentioned local users
457-
// Skip the reply target author since they already got a "status" notification
458+
// Skip the reply target author since they already got a reply mention.
458459
if (post.mentions.length > 0) {
459460
const mentionedAccountsWithOwners = await db.query.accounts.findMany({
460461
where: inArray(

src/notification.test.ts

Lines changed: 113 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@ import { cleanDatabase } from "../tests/helpers";
44
import { createAccount } from "../tests/helpers/oauth";
55
import db from "./db";
66
import {
7+
createMentionNotifications,
78
createQuotedUpdateNotifications,
89
createQuoteNotification,
10+
createReplyMentionNotification,
911
} from "./notification";
1012
import * as Schema from "./schema";
1113
import type { Uuid } from "./uuid";
@@ -50,7 +52,10 @@ async function createRemoteAccount(username: string): Promise<Schema.Account> {
5052
async function createPost(
5153
accountId: Uuid,
5254
content: string,
53-
quoteTargetId?: Uuid,
55+
options: {
56+
quoteTargetId?: Uuid;
57+
replyTargetId?: Uuid;
58+
} = {},
5459
): Promise<Schema.Post> {
5560
const postId = crypto.randomUUID() as Uuid;
5661
const postIri = `https://test.example/@test/${postId}`;
@@ -64,14 +69,107 @@ async function createPost(
6469
accountId,
6570
visibility: "public",
6671
content,
67-
quoteTargetId,
72+
quoteTargetId: options.quoteTargetId,
73+
replyTargetId: options.replyTargetId,
6874
published: new Date(),
6975
})
7076
.returning();
7177

7278
return post;
7379
}
7480

81+
describe("Reply mention notifications", () => {
82+
let localAccount: Awaited<ReturnType<typeof createAccount>>;
83+
let remoteAccount: Schema.Account;
84+
85+
beforeEach(async () => {
86+
await cleanDatabase();
87+
localAccount = await createAccount();
88+
remoteAccount = await createRemoteAccount("remote_user");
89+
});
90+
91+
it("creates mention notifications for replies to local posts", async () => {
92+
expect.assertions(5);
93+
94+
const originalPost = await createPost(
95+
localAccount.id as Uuid,
96+
"Original post",
97+
);
98+
const replyPost = await createPost(remoteAccount.id, "Reply", {
99+
replyTargetId: originalPost.id,
100+
});
101+
102+
const originalPostWithAccount = await db.query.posts.findFirst({
103+
where: eq(Schema.posts.id, originalPost.id),
104+
with: {
105+
account: { with: { owner: true } },
106+
},
107+
});
108+
109+
const notificationId = await createReplyMentionNotification(
110+
remoteAccount,
111+
replyPost,
112+
originalPostWithAccount!,
113+
);
114+
115+
expect(notificationId).not.toBeNull();
116+
117+
const notification = await db.query.notifications.findFirst({
118+
where: eq(Schema.notifications.id, notificationId!),
119+
});
120+
121+
expect(notification).not.toBeNull();
122+
expect(notification?.type).toBe("mention");
123+
expect(notification?.actorAccountId).toBe(remoteAccount.id);
124+
expect(notification?.targetPostId).toBe(replyPost.id);
125+
});
126+
127+
it("does not duplicate reply mention notifications when the reply also mentions the original author", async () => {
128+
expect.assertions(2);
129+
130+
const originalPost = await createPost(
131+
localAccount.id as Uuid,
132+
"Original post",
133+
);
134+
const replyPost = await createPost(remoteAccount.id, "Reply", {
135+
replyTargetId: originalPost.id,
136+
});
137+
138+
const originalPostWithAccount = await db.query.posts.findFirst({
139+
where: eq(Schema.posts.id, originalPost.id),
140+
with: {
141+
account: { with: { owner: true } },
142+
},
143+
});
144+
const mentionedAccount = await db.query.accounts.findFirst({
145+
where: eq(Schema.accounts.id, localAccount.id as Uuid),
146+
with: { owner: true },
147+
});
148+
149+
await createReplyMentionNotification(
150+
remoteAccount,
151+
replyPost,
152+
originalPostWithAccount!,
153+
);
154+
const mentionNotificationIds = await createMentionNotifications(
155+
replyPost,
156+
[mentionedAccount!],
157+
originalPost.accountId,
158+
);
159+
160+
expect(mentionNotificationIds).toHaveLength(0);
161+
162+
const notifications = await db.query.notifications.findMany({
163+
where: and(
164+
eq(Schema.notifications.type, "mention"),
165+
eq(Schema.notifications.targetPostId, replyPost.id),
166+
),
167+
});
168+
169+
expect(notifications).toHaveLength(1);
170+
});
171+
});
172+
75173
describe("Quote notifications", () => {
76174
let localAccount: Awaited<ReturnType<typeof createAccount>>;
77175
let remoteAccount: Schema.Account;
@@ -103,11 +201,9 @@ describe("Quote notifications", () => {
103201
expect(originalPostWithAccount).not.toBeNull();
104202

105203
// Create quote post by remote user
106-
const quotePost = await createPost(
107-
remoteAccount.id,
108-
"Quoting this!",
109-
originalPost.id,
110-
);
204+
const quotePost = await createPost(remoteAccount.id, "Quoting this!", {
205+
quoteTargetId: originalPost.id,
206+
});
111207

112208
// Create quote notification
113209
const notificationId = await createQuoteNotification(
@@ -156,7 +252,7 @@ describe("Quote notifications", () => {
156252
const quotePost = await createPost(
157253
localAccount.id as Uuid,
158254
"Quoting my own post",
159-
originalPost.id,
255+
{ quoteTargetId: originalPost.id },
160256
);
161257

162258
// Create quote notification - should return null for self-quote
@@ -187,11 +283,9 @@ describe("Quote notifications", () => {
187283
const anotherRemote = await createRemoteAccount("another_remote");
188284

189285
// Create quote post
190-
const quotePost = await createPost(
191-
anotherRemote.id,
192-
"Quoting remote",
193-
originalPost.id,
194-
);
286+
const quotePost = await createPost(anotherRemote.id, "Quoting remote", {
287+
quoteTargetId: originalPost.id,
288+
});
195289

196290
// Create quote notification - should return null (original author not local)
197291
const notificationId = await createQuoteNotification(
@@ -212,11 +306,9 @@ describe("Quote notifications", () => {
212306
const originalPost = await createPost(remoteAccount.id, "Original post");
213307

214308
// Local user creates a quote post
215-
const quotePost = await createPost(
216-
localAccount.id as Uuid,
217-
"My quote",
218-
originalPost.id,
219-
);
309+
const quotePost = await createPost(localAccount.id as Uuid, "My quote", {
310+
quoteTargetId: originalPost.id,
311+
});
220312

221313
// Get local account with owner info
222314
const quoteAuthor = await db.query.accounts.findFirst({
@@ -308,11 +400,9 @@ describe("Quote notifications", () => {
308400
});
309401

310402
// Create quote post by remote user
311-
const quotePost = await createPost(
312-
remoteAccount.id,
313-
"Quoting this!",
314-
originalPost.id,
315-
);
403+
const quotePost = await createPost(remoteAccount.id, "Quoting this!", {
404+
quoteTargetId: originalPost.id,
405+
});
316406

317407
// Create quote notification twice
318408
const notificationId1 = await createQuoteNotification(

src/notification.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -258,10 +258,11 @@ export async function createReblogNotification(
258258
}
259259

260260
/**
261-
* Creates a status notification when someone posts a reply to a user's post.
262-
* This is different from mention - it specifically means "someone replied to your post".
261+
* Creates a mention notification when someone replies to a user's post.
262+
* Mastodon clients render reply notifications from mention notifications
263+
* whose status points back to the current account via in_reply_to_account_id.
263264
*/
264-
export async function createStatusNotification(
265+
export async function createReplyMentionNotification(
265266
replier: Account,
266267
replyPost: Post,
267268
originalPost: Post & { account: Account & { owner: AccountOwner | null } },
@@ -278,7 +279,7 @@ export async function createStatusNotification(
278279

279280
return await createNotification({
280281
accountOwnerId: originalPost.account.owner.id,
281-
type: "status",
282+
type: "mention",
282283
actorAccountId: replier.id,
283284
targetPostId: replyPost.id,
284285
});
@@ -305,10 +306,9 @@ export async function createMentionNotifications(
305306
continue;
306307
}
307308

308-
// Skip mention notification for the reply target author to avoid duplicates.
309-
// When someone replies to a post and mentions the original author,
310-
// they will receive a "status" notification for the reply, so we don't
311-
// need to send a separate "mention" notification for the same post.
309+
// Skip explicit mention notification for the reply target author to avoid
310+
// duplicates. Reply notifications are already represented as "mention"
311+
// notifications for Mastodon API compatibility.
312312
if (replyTargetAuthorId != null && mentioned.id === replyTargetAuthorId) {
313313
logger.debug(
314314
"Skipping mention notification for reply target author {accountId} on post {postId}",

0 commit comments

Comments
 (0)