Skip to content

Commit 02dd8f9

Browse files
committed
Hide quote fallback when quote is rendered
Strip incoming quote-inline fallback paragraphs from API responses and profile rendering only when Hollo can show the structured quoted post. Keep the fallback visible when no structured quote is available so the quoted post URL remains accessible. Assisted-by: Codex:gpt-5.5
1 parent dc97473 commit 02dd8f9

6 files changed

Lines changed: 228 additions & 5 deletions

File tree

CHANGES.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,11 @@ To be released.
1111
support quote posts can now still show the quoted post permalink, while
1212
quote-aware software can hide the fallback paragraph by class name.
1313

14+
- Hid incoming `quote-inline` fallback paragraphs from Mastodon API status
15+
content and profile pages when Hollo can render the structured quoted
16+
post. If the quoted post is unavailable, the fallback link remains
17+
visible so the quoted URL is not lost.
18+
1419

1520
Version 0.8.1
1621
-------------

src/api/v1/timelines.test.ts

Lines changed: 73 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -238,7 +238,7 @@ describe.sequential("/api/v1/timelines/home", () => {
238238
});
239239

240240
it("serializes quotes using the Mastodon Quote entity format", async () => {
241-
expect.assertions(7);
241+
expect.assertions(9);
242242

243243
const authorId = crypto.randomUUID() as Uuid;
244244
const quotedPostId = uuidv7();
@@ -289,7 +289,10 @@ describe.sequential("/api/v1/timelines/home", () => {
289289
quoteTargetId: quotedPostId,
290290
visibility: "public",
291291
content: "Quote post",
292-
contentHtml: "<p>Quote post</p>",
292+
contentHtml:
293+
"<p>Quote post</p>" +
294+
`<p class="quote-inline">RE: <a href="https://remote.test/notes/${quotedPostId}">` +
295+
`https://remote.test/notes/${quotedPostId}</a></p>`,
293296
published: new Date(),
294297
},
295298
]);
@@ -308,8 +311,76 @@ describe.sequential("/api/v1/timelines/home", () => {
308311

309312
expect(Array.isArray(json)).toBe(true);
310313
expect(json[0].id).toBe(quotePostId);
314+
expect(json[0].content).toBe("<p>Quote post</p>");
315+
expect(json[0].content).not.toContain("quote-inline");
311316
expect(json[0].quote_id).toBe(quotedPostId);
312317
expect(json[0].quote.state).toBe("accepted");
313318
expect(json[0].quote.quoted_status.id).toBe(quotedPostId);
314319
});
320+
321+
it("keeps quote-inline fallback content without a structured quote", async () => {
322+
expect.assertions(6);
323+
324+
const authorId = crypto.randomUUID() as Uuid;
325+
const postId = uuidv7();
326+
const quotedPostUrl = "https://remote.test/notes/missing";
327+
const contentHtml =
328+
"<p>Quote post</p>" +
329+
`<p class="quote-inline">RE: <a href="${quotedPostUrl}">` +
330+
`${quotedPostUrl}</a></p>`;
331+
332+
await db
333+
.insert(instances)
334+
.values({ host: "remote.test" })
335+
.onConflictDoNothing();
336+
337+
await db.insert(accounts).values({
338+
id: authorId,
339+
iri: "https://remote.test/users/author",
340+
instanceHost: "remote.test",
341+
type: "Person",
342+
name: "Remote author",
343+
emojis: {},
344+
handle: "@author@remote.test",
345+
bioHtml: "",
346+
url: "https://remote.test/@author",
347+
protected: false,
348+
inboxUrl: "https://remote.test/users/author/inbox",
349+
});
350+
351+
await db.insert(follows).values({
352+
iri: "https://hollo.test/follows/author",
353+
followingId: authorId,
354+
followerId: owner.id,
355+
approved: new Date(),
356+
});
357+
358+
await db.insert(posts).values({
359+
id: postId,
360+
iri: `https://remote.test/notes/${postId}`,
361+
type: "Note",
362+
accountId: authorId,
363+
visibility: "public",
364+
content: "Quote post",
365+
contentHtml,
366+
published: new Date(),
367+
});
368+
369+
const response = await app.request("/api/v1/timelines/home", {
370+
method: "GET",
371+
headers: {
372+
authorization: bearerAuthorization(accessToken),
373+
},
374+
});
375+
376+
expect(response.status).toBe(200);
377+
378+
const json = await response.json();
379+
380+
expect(Array.isArray(json)).toBe(true);
381+
expect(json[0].id).toBe(postId);
382+
expect(json[0].quote).toBeNull();
383+
expect(json[0].content).toContain("quote-inline");
384+
expect(json[0].content).toContain(quotedPostUrl);
385+
});
315386
});

src/components/Post.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { renderCustomEmojis } from "../custom-emoji";
2+
import { stripQuoteInlineFallbacks } from "../html";
23
import type {
34
Account,
45
Medium as DbMedium,
@@ -223,10 +224,14 @@ interface PostContentProps {
223224
}
224225

225226
function PostContent({ post }: PostContentProps) {
226-
const contentHtml = renderCustomEmojis(post.contentHtml, post.emojis);
227+
const displayContentHtml =
228+
post.quoteTarget == null
229+
? post.contentHtml
230+
: stripQuoteInlineFallbacks(post.contentHtml);
231+
const contentHtml = renderCustomEmojis(displayContentHtml, post.emojis);
227232
return (
228233
<>
229-
{post.contentHtml && (
234+
{displayContentHtml && (
230235
<div
231236
dangerouslySetInnerHTML={{ __html: contentHtml ?? "" }}
232237
lang={post.language ?? undefined}

src/entities/status.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { eq, sql } from "drizzle-orm";
22

3+
import { stripQuoteInlineFallbacks } from "../html";
34
import type { PreviewCard } from "../previewcard";
45
import {
56
type Account,
@@ -295,7 +296,11 @@ export function serializePost(
295296
currentAccountOwner == null
296297
? false
297298
: post.pin != null && post.pin.accountId === currentAccountOwner.id,
298-
content: sanitizeHtml(post.contentHtml ?? ""),
299+
content: sanitizeHtml(
300+
post.quoteTarget == null
301+
? (post.contentHtml ?? "")
302+
: stripQuoteInlineFallbacks(post.contentHtml ?? ""),
303+
),
299304
reblog:
300305
post.sharing == null
301306
? null

src/html.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
let cheerioPromise: Promise<typeof import("cheerio")> | undefined;
22

3+
const HTML_ELEMENT_REGEXP = /<([a-z][\w:-]*)\b([^>]*)>[\s\S]*?<\/\1>/giu;
4+
const CLASS_ATTRIBUTE_REGEXP =
5+
/\bclass\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+))/iu;
6+
37
async function getCheerio() {
48
if (cheerioPromise == null) {
59
cheerioPromise = import("cheerio");
@@ -19,3 +23,58 @@ export async function extractText(html: string | null): Promise<string | null> {
1923
const $ = cheerio.load(html);
2024
return $(":root").text();
2125
}
26+
27+
export function stripQuoteInlineFallbacks(html: string): string;
28+
export function stripQuoteInlineFallbacks(html: null): null;
29+
export function stripQuoteInlineFallbacks(html: string | null): string | null;
30+
31+
export function stripQuoteInlineFallbacks(html: string | null): string | null {
32+
if (html == null) return null;
33+
return html.replaceAll(HTML_ELEMENT_REGEXP, (element, _tag, attributes) =>
34+
hasQuoteInlineClass(attributes) ? "" : element,
35+
);
36+
}
37+
38+
function hasQuoteInlineClass(attributes: string): boolean {
39+
const match = CLASS_ATTRIBUTE_REGEXP.exec(attributes);
40+
if (match == null) return false;
41+
return decodeHtmlEntities(match[1] ?? match[2] ?? match[3] ?? "")
42+
.split(/\s+/)
43+
.includes("quote-inline");
44+
}
45+
46+
function decodeHtmlEntities(value: string): string {
47+
return value.replaceAll(
48+
/&(?:#(\d+)|#x([\da-f]+)|amp|lt|gt|quot|apos);/gi,
49+
(entity, decimal: string | undefined, hexadecimal: string | undefined) => {
50+
const codePoint =
51+
decimal == null
52+
? hexadecimal == null
53+
? null
54+
: Number.parseInt(hexadecimal, 16)
55+
: Number.parseInt(decimal, 10);
56+
if (codePoint != null) {
57+
try {
58+
return String.fromCodePoint(codePoint);
59+
} catch {
60+
return entity;
61+
}
62+
}
63+
64+
switch (entity.toLowerCase()) {
65+
case "&amp;":
66+
return "&";
67+
case "&lt;":
68+
return "<";
69+
case "&gt;":
70+
return ">";
71+
case "&quot;":
72+
return '"';
73+
case "&apos;":
74+
return "'";
75+
default:
76+
return entity;
77+
}
78+
},
79+
);
80+
}

src/pages/profile/index.test.tsx

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,4 +91,82 @@ describe.sequential("profile tagged page", () => {
9191
expect(response.status).toBe(200);
9292
expect(await response.text()).toContain('href="/@hollo/tagged/TestTag"');
9393
});
94+
95+
it("hides quote-inline fallback content on profile pages", async () => {
96+
expect.assertions(5);
97+
98+
const quotedPostId = uuidv7();
99+
const quotePostId = uuidv7();
100+
const quotedPostUrl = `https://hollo.test/@hollo/${quotedPostId}`;
101+
102+
await db.insert(posts).values([
103+
{
104+
id: quotedPostId,
105+
iri: quotedPostUrl,
106+
type: "Note",
107+
accountId: account.id,
108+
visibility: "public",
109+
content: "Quoted post",
110+
contentHtml: "<p>Quoted post</p>",
111+
published: new Date(),
112+
},
113+
{
114+
id: quotePostId,
115+
iri: `https://hollo.test/@hollo/${quotePostId}`,
116+
type: "Note",
117+
accountId: account.id,
118+
quoteTargetId: quotedPostId,
119+
visibility: "public",
120+
content: "Quote post",
121+
contentHtml:
122+
"<p>Quote post</p>" +
123+
`<p class="quote-inline">RE: <a href="${quotedPostUrl}">` +
124+
`${quotedPostUrl}</a></p>`,
125+
published: new Date(),
126+
},
127+
]);
128+
129+
const response = await app.request("/@hollo");
130+
131+
expect(response.status).toBe(200);
132+
133+
const html = await response.text();
134+
135+
expect(html).toContain("Quote post");
136+
expect(html).toContain("Quoted post");
137+
expect(html).not.toContain("quote-inline");
138+
expect(html).not.toContain("RE:");
139+
});
140+
141+
it("keeps quote-inline fallback content without a rendered quote", async () => {
142+
expect.assertions(5);
143+
144+
const postId = uuidv7();
145+
const quotedPostUrl = "https://remote.test/notes/missing";
146+
147+
await db.insert(posts).values({
148+
id: postId,
149+
iri: `https://hollo.test/@hollo/${postId}`,
150+
type: "Note",
151+
accountId: account.id,
152+
visibility: "public",
153+
content: "Quote post",
154+
contentHtml:
155+
"<p>Quote post</p>" +
156+
`<p class="quote-inline">RE: <a href="${quotedPostUrl}">` +
157+
`${quotedPostUrl}</a></p>`,
158+
published: new Date(),
159+
});
160+
161+
const response = await app.request("/@hollo");
162+
163+
expect(response.status).toBe(200);
164+
165+
const html = await response.text();
166+
167+
expect(html).toContain("Quote post");
168+
expect(html).toContain("quote-inline");
169+
expect(html).toContain("RE:");
170+
expect(html).toContain(quotedPostUrl);
171+
});
94172
});

0 commit comments

Comments
 (0)