From d527632b684a2a30c1eb64decab64fdd51e6fd1c Mon Sep 17 00:00:00 2001 From: vigneshakaviki Date: Fri, 10 Apr 2026 20:26:35 -0700 Subject: [PATCH 1/5] Add stub endpoints for trends and suggestions Mastodon clients (including the official iOS app) expect the trends and suggestions endpoints to return empty arrays rather than 404. Without these stubs, the iOS app throws "The data couldn't be read" errors when navigating to Search or certain timelines. Added endpoints: - GET /api/v1/trends (alias for /trends/tags) - GET /api/v1/trends/tags - GET /api/v1/trends/statuses - GET /api/v1/trends/links - GET /api/v1/suggestions - GET /api/v2/suggestions All return empty JSON arrays for now, matching the behavior of Mastodon instances that have no trending data or suggestions. Ref #421 --- src/api/v1/index.ts | 22 ++++++++++++++++++++++ src/api/v2/index.ts | 4 ++++ 2 files changed, 26 insertions(+) diff --git a/src/api/v1/index.ts b/src/api/v1/index.ts index d2d6db4b..7000788a 100644 --- a/src/api/v1/index.ts +++ b/src/api/v1/index.ts @@ -92,6 +92,28 @@ app.get("/announcements", (c) => { return c.json([]); }); +app.get("/trends/tags", (c) => { + return c.json([]); +}); + +app.get("/trends/statuses", (c) => { + return c.json([]); +}); + +app.get("/trends/links", (c) => { + return c.json([]); +}); + +// Mastodon clients also request /trends without a subpath, +// which is equivalent to /trends/tags: +app.get("/trends", (c) => { + return c.json([]); +}); + +app.get("/suggestions", (c) => { + return c.json([]); +}); + app.get( "/favourites", tokenRequired, diff --git a/src/api/v2/index.ts b/src/api/v2/index.ts index d1016d2b..9eaec220 100644 --- a/src/api/v2/index.ts +++ b/src/api/v2/index.ts @@ -47,6 +47,10 @@ app.route("/notifications", notificationsRoutes); app.post("/media", tokenRequired, scopeRequired(["write:media"]), postMedia); +app.get("/suggestions", (c) => { + return c.json([]); +}); + app.get( "/search", tokenRequired, From ea459e1ec50325a1f8c09af560c8765df9924854 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Sun, 26 Apr 2026 23:53:58 +0900 Subject: [PATCH 2/5] Require auth for suggestions stubs Match Mastodon's suggestions API contract by requiring a user token with account read access before returning the empty stub response. https://github.com/fedify-dev/hollo/issues/421 Assisted-by: OpenCode:gpt-5.5 --- src/api/v1/index.ts | 11 ++++++++--- src/api/v2/index.ts | 11 ++++++++--- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/src/api/v1/index.ts b/src/api/v1/index.ts index 7000788a..87045bc9 100644 --- a/src/api/v1/index.ts +++ b/src/api/v1/index.ts @@ -110,9 +110,14 @@ app.get("/trends", (c) => { return c.json([]); }); -app.get("/suggestions", (c) => { - return c.json([]); -}); +app.get( + "/suggestions", + tokenRequired, + scopeRequired(["read:accounts"]), + (c) => { + return c.json([]); + }, +); app.get( "/favourites", diff --git a/src/api/v2/index.ts b/src/api/v2/index.ts index 9eaec220..a0557040 100644 --- a/src/api/v2/index.ts +++ b/src/api/v2/index.ts @@ -47,9 +47,14 @@ app.route("/notifications", notificationsRoutes); app.post("/media", tokenRequired, scopeRequired(["write:media"]), postMedia); -app.get("/suggestions", (c) => { - return c.json([]); -}); +app.get( + "/suggestions", + tokenRequired, + scopeRequired(["read:accounts"]), + (c) => { + return c.json([]); + }, +); app.get( "/search", From 4fae3db425acc8b56da80550a6931476a12858c8 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Sun, 26 Apr 2026 23:56:10 +0900 Subject: [PATCH 3/5] Cover compatibility stub endpoints Add regression coverage for the trends stubs and for suggestions requiring a readable user token before returning an empty response. https://github.com/fedify-dev/hollo/issues/421 Assisted-by: OpenCode:gpt-5.5 --- src/api/stub-endpoints.test.ts | 89 ++++++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 src/api/stub-endpoints.test.ts diff --git a/src/api/stub-endpoints.test.ts b/src/api/stub-endpoints.test.ts new file mode 100644 index 00000000..7d46a797 --- /dev/null +++ b/src/api/stub-endpoints.test.ts @@ -0,0 +1,89 @@ +import { beforeEach, describe, expect, it } from "vitest"; + +import { cleanDatabase } from "../../tests/helpers"; +import { + bearerAuthorization, + createAccount, + createOAuthApplication, + getAccessToken, + type Token, +} from "../../tests/helpers/oauth"; +import app from "../index"; + +describe.sequential("Mastodon compatibility stub endpoints", () => { + let readAccountsToken: Token; + let readSearchToken: Token; + + beforeEach(async () => { + await cleanDatabase(); + + const account = await createAccount(); + const client = await createOAuthApplication({ + scopes: ["read:accounts", "read:search"], + }); + readAccountsToken = await getAccessToken(client, account, [ + "read:accounts", + ]); + readSearchToken = await getAccessToken(client, account, ["read:search"]); + }); + + it.each([ + "/api/v1/trends", + "/api/v1/trends/tags", + "/api/v1/trends/statuses?offset=0", + "/api/v1/trends/links", + ])("returns an empty array for GET %s", async (path) => { + expect.assertions(3); + + const response = await app.request(path); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe("application/json"); + expect(await response.json()).toEqual([]); + }); + + it.each(["/api/v1/suggestions", "/api/v2/suggestions"])( + "requires authentication for GET %s", + async (path) => { + expect.assertions(2); + + const response = await app.request(path); + + expect(response.status).toBe(401); + expect(await response.json()).toEqual({ error: "unauthorized" }); + }, + ); + + it.each(["/api/v1/suggestions", "/api/v2/suggestions"])( + "rejects insufficient scope for GET %s", + async (path) => { + expect.assertions(2); + + const response = await app.request(path, { + headers: { + authorization: bearerAuthorization(readSearchToken), + }, + }); + + expect(response.status).toBe(403); + expect(await response.json()).toEqual({ error: "insufficient_scope" }); + }, + ); + + it.each(["/api/v1/suggestions", "/api/v2/suggestions"])( + "returns an empty array for GET %s", + async (path) => { + expect.assertions(3); + + const response = await app.request(path, { + headers: { + authorization: bearerAuthorization(readAccountsToken), + }, + }); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe("application/json"); + expect(await response.json()).toEqual([]); + }, + ); +}); From fc2d1ee9ef3b95a98de7fe570b24c45db0c840bb Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Mon, 27 Apr 2026 00:17:01 +0900 Subject: [PATCH 4/5] Cover timeline quote compatibility Add a regression test for home timelines returning quote posts in the Mastodon Quote entity shape expected by current Mastodon clients. https://github.com/fedify-dev/hollo/issues/421 Assisted-by: OpenCode:gpt-5.5 --- src/api/v1/timelines.test.ts | 93 +++++++++++++++++++++++++++++++++++- 1 file changed, 92 insertions(+), 1 deletion(-) diff --git a/src/api/v1/timelines.test.ts b/src/api/v1/timelines.test.ts index 04fe105a..57b379c5 100644 --- a/src/api/v1/timelines.test.ts +++ b/src/api/v1/timelines.test.ts @@ -142,7 +142,6 @@ describe.sequential("/api/v1/timelines/list/:list_id", () => { expect(json[0].media_attachments[0].type).toBe("unknown"); }); }); - describe.sequential("/api/v1/timelines/home", () => { let owner: Awaited>; let approvedAuthor: Awaited>; @@ -222,3 +221,95 @@ describe.sequential("/api/v1/timelines/home", () => { expect(ids).toEqual([approvedPostId]); }); }); + +describe.sequential("/api/v1/timelines/home", () => { + let owner: Awaited>; + let client: Awaited>; + let accessToken: Awaited>; + + beforeEach(async () => { + await cleanDatabase(); + + owner = await createAccount(); + client = await createOAuthApplication({ + scopes: ["read:statuses"], + }); + accessToken = await getAccessToken(client, owner, ["read:statuses"]); + }); + + it("serializes quotes using the Mastodon Quote entity format", async () => { + expect.assertions(7); + + const authorId = crypto.randomUUID() as Uuid; + const quotedPostId = uuidv7(); + const quotePostId = uuidv7(); + + await db + .insert(instances) + .values({ host: "remote.test" }) + .onConflictDoNothing(); + + await db.insert(accounts).values({ + id: authorId, + iri: "https://remote.test/users/author", + instanceHost: "remote.test", + type: "Person", + name: "Remote author", + emojis: {}, + handle: "@author@remote.test", + bioHtml: "", + url: "https://remote.test/@author", + protected: false, + inboxUrl: "https://remote.test/users/author/inbox", + }); + + await db.insert(follows).values({ + iri: "https://hollo.test/follows/author", + followingId: authorId, + followerId: owner.id, + approved: new Date(), + }); + + await db.insert(posts).values([ + { + id: quotedPostId, + iri: `https://remote.test/notes/${quotedPostId}`, + type: "Note", + accountId: authorId, + visibility: "public", + content: "Quoted post", + contentHtml: "

Quoted post

", + published: new Date(), + }, + { + id: quotePostId, + iri: `https://remote.test/notes/${quotePostId}`, + type: "Note", + accountId: authorId, + quoteTargetId: quotedPostId, + visibility: "public", + content: "Quote post", + contentHtml: "

Quote post

", + published: new Date(), + }, + ]); + + const response = await app.request("/api/v1/timelines/home", { + method: "GET", + headers: { + authorization: bearerAuthorization(accessToken), + }, + }); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe("application/json"); + + const json = await response.json(); + + expect(Array.isArray(json)).toBe(true); + expect(json[0].id).toBe(quotePostId); + expect(json[0].quote_id).toBe(quotedPostId); + expect(json[0].quote.state).toBe("accepted"); + expect(json[0].quote.quoted_status.id).toBe(quotedPostId); + }); +}); From ea924b7a77d15a5968893ce107bb8ac12cff3bad Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Mon, 27 Apr 2026 00:28:49 +0900 Subject: [PATCH 5/5] Document client API compatibility fix Note the Mastodon-compatible trends and suggestions stub endpoints in the changelog so the client compatibility fix is visible in the next release notes. https://github.com/fedify-dev/hollo/issues/421 https://github.com/fedify-dev/hollo/pull/427 Assisted-by: OpenCode:gpt-5.5 --- CHANGES.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGES.md b/CHANGES.md index e8539f6a..b1c1db0a 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -144,6 +144,11 @@ To be released. editor and is now returned from `GET /api/v1/preferences`, which helps clients like Phanpy honor each account's preferred CW behavior. [[#425]] + - Fixed Mastodon API compatibility for clients such as the official Mastodon + iOS app by returning empty arrays for unimplemented trends and suggestions + endpoints instead of `404 Not Found` responses. The suggestions endpoints + still require an authenticated user token. [[#421], [#427] by Vignesh] + - Added a new dashboard page for thumbnail cleanup at `/thumbnail_cleanup`. Thumbnails from remote posts that have not been bookmarked, liked, reacted to, shared nor quoted by a local account before a given cut-off data can @@ -159,8 +164,10 @@ To be released. [#357]: https://github.com/fedify-dev/hollo/issues/357 [#409]: https://github.com/fedify-dev/hollo/issues/409 [#420]: https://github.com/fedify-dev/hollo/issues/420 +[#421]: https://github.com/fedify-dev/hollo/issues/421 [#424]: https://github.com/fedify-dev/hollo/issues/424 [#425]: https://github.com/fedify-dev/hollo/issues/425 +[#427]: https://github.com/fedify-dev/hollo/pull/427 [#435]: https://github.com/fedify-dev/hollo/issues/435 [#436]: https://github.com/fedify-dev/hollo/pull/436 [#445]: https://github.com/fedify-dev/hollo/issues/445