Skip to content

Commit 4a45363

Browse files
committed
Merge tag '0.7.13'
Hollo 0.7.13
2 parents 054b272 + 302a800 commit 4a45363

4 files changed

Lines changed: 141 additions & 37 deletions

File tree

CHANGES.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,19 @@ To be released.
156156
[Fedify debugger]: https://fedify.dev/manual/debug
157157

158158

159+
Version 0.7.13
160+
--------------
161+
162+
Released on April 26, 2026.
163+
164+
- Fixed a Mastodon API compatibility regression where replies to local posts
165+
were stored as `status` notifications, causing clients to show generic
166+
“posted” titles instead of reply notifications. Replies are now stored as
167+
`mention` notifications. [[#380]]
168+
169+
[#380]: https://github.com/fedify-dev/hollo/issues/380
170+
171+
159172
Version 0.7.12
160173
--------------
161174

src/federation/inbox.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ import {
3636
createQuotedUpdateNotifications,
3737
createQuoteNotification,
3838
createReblogNotification,
39-
createStatusNotification,
39+
createReplyMentionNotification,
4040
} from "../notification";
4141
import {
4242
type Account,
@@ -485,12 +485,14 @@ export async function onPostCreated(
485485
refreshActorIfStale(db, post.account, ctx.origin, ctx);
486486
}
487487

488-
// Create status notification for reply target author (if this is a reply)
488+
// Create mention notification for reply target author (if this is a reply)
489489
// and mention notifications for other mentioned local users
490490
if (post != null) {
491491
let replyTargetAuthorId: typeof post.accountId | null = null;
492492

493-
// If this is a reply, create a "status" notification for the original post author
493+
// If this is a reply, create a "mention" notification for the original
494+
// post author. Mastodon clients render this as a reply based on the
495+
// attached status's in_reply_to_account_id.
494496
if (post.replyTargetId != null) {
495497
const replyTarget = await db.query.posts.findFirst({
496498
where: eq(posts.id, post.replyTargetId),
@@ -502,13 +504,12 @@ export async function onPostCreated(
502504
if (replyTarget != null) {
503505
replyTargetAuthorId = replyTarget.accountId;
504506

505-
// Create status notification for the reply target author
506-
await createStatusNotification(post.account, post, replyTarget);
507+
await createReplyMentionNotification(post.account, post, replyTarget);
507508
}
508509
}
509510

510511
// Create mention notifications for mentioned local users
511-
// Skip the reply target author since they already got a "status" notification
512+
// Skip the reply target author since they already got a reply mention.
512513
if (post.mentions.length > 0) {
513514
const mentionedAccountsWithOwners = await db.query.accounts.findMany({
514515
where: inArray(

src/notification.test.ts

Lines changed: 113 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@ import { cleanDatabase } from "../tests/helpers";
55
import { createAccount } from "../tests/helpers/oauth";
66
import db from "./db";
77
import {
8+
createMentionNotifications,
89
createQuotedUpdateNotifications,
910
createQuoteNotification,
11+
createReplyMentionNotification,
1012
} from "./notification";
1113
import * as Schema from "./schema";
1214
import type { Uuid } from "./uuid";
@@ -51,7 +53,10 @@ async function createRemoteAccount(username: string): Promise<Schema.Account> {
5153
async function createPost(
5254
accountId: Uuid,
5355
content: string,
54-
quoteTargetId?: Uuid,
56+
options: {
57+
quoteTargetId?: Uuid;
58+
replyTargetId?: Uuid;
59+
} = {},
5560
): Promise<Schema.Post> {
5661
const postId = crypto.randomUUID() as Uuid;
5762
const postIri = `https://test.example/@test/${postId}`;
@@ -65,14 +70,107 @@ async function createPost(
6570
accountId,
6671
visibility: "public",
6772
content,
68-
quoteTargetId,
73+
quoteTargetId: options.quoteTargetId,
74+
replyTargetId: options.replyTargetId,
6975
published: new Date(),
7076
})
7177
.returning();
7278

7379
return post;
7480
}
7581

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

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

113209
// Create quote notification
114210
const notificationId = await createQuoteNotification(
@@ -157,7 +253,7 @@ describe("Quote notifications", () => {
157253
const quotePost = await createPost(
158254
localAccount.id as Uuid,
159255
"Quoting my own post",
160-
originalPost.id,
256+
{ quoteTargetId: originalPost.id },
161257
);
162258

163259
// Create quote notification - should return null for self-quote
@@ -188,11 +284,9 @@ describe("Quote notifications", () => {
188284
const anotherRemote = await createRemoteAccount("another_remote");
189285

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

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

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

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

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

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

src/notification.ts

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

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

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

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

0 commit comments

Comments
 (0)