From cccd78fd431f866e3fe82f9bad186a137cf9b4c2 Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Thu, 24 Sep 2026 11:24:28 -0400 Subject: [PATCH] fix(embeds): decode leftover HTML entities in link-preview text (#187 DP17) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Steam double-encodes its meta tags (`&quot;`), so after Synapse's single decode the card showed "The "Perpetual Testing Initiative" …". The preview's og:title / og:description / og:site_name are now decoded once where the preview is fetched, so every card (about 20 read those fields directly) gets clean text. Rendered as React text only, so decoding can't inject markup; exactly one level is decoded. Verified on the Portal 2 store link: "The \"Perpetual Testing Initiative\" has been expanded…", no literal " left on the page. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA --- .../components/url-preview/UrlPreviewCard.tsx | 22 ++++++++++- src/app/utils/htmlEntities.test.ts | 28 ++++++++++++++ src/app/utils/htmlEntities.ts | 38 +++++++++++++++++++ 3 files changed, 86 insertions(+), 2 deletions(-) create mode 100644 src/app/utils/htmlEntities.test.ts create mode 100644 src/app/utils/htmlEntities.ts diff --git a/src/app/components/url-preview/UrlPreviewCard.tsx b/src/app/components/url-preview/UrlPreviewCard.tsx index ee80f58e3..d559f03e9 100644 --- a/src/app/components/url-preview/UrlPreviewCard.tsx +++ b/src/app/components/url-preview/UrlPreviewCard.tsx @@ -17,6 +17,7 @@ import { import { ImageOverlay } from '../ImageOverlay'; import { MobileTouchTarget } from '../../styles/mobile.css'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; +import { decodeHtmlEntities } from '../../utils/htmlEntities'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { UrlPreview, UrlPreviewContent, UrlPreviewDescription, UrlPreviewImg } from './UrlPreview'; import { @@ -308,6 +309,23 @@ function isTenor(url: string): boolean { // Synapse's thumbnailer flattens animated images to a single still frame, so a // GIF served from /thumbnail renders but never plays. Detect GIF previews so the // card can point at /download (the original) instead. +/** Text fields that are rendered as-is by the cards below. */ +const PREVIEW_TEXT_KEYS = ['og:title', 'og:description', 'og:site_name'] as const; + +/** + * Decode character references left in preview text once, before any card + * reads it (e.g. Steam double-encodes, so `"` survived Synapse's decode + * and showed literally). #187 DP17. + */ +const decodePreviewText = (prev: IPreviewUrlResponse): IPreviewUrlResponse => { + const out: IPreviewUrlResponse = { ...prev }; + PREVIEW_TEXT_KEYS.forEach((key) => { + const value = out[key]; + if (typeof value === 'string') out[key] = decodeHtmlEntities(value); + }); + return out; +}; + function isGifPreview(url: string, prev: IPreviewUrlResponse): boolean { if (prev['og:image:type'] === 'image/gif') return true; try { @@ -2281,8 +2299,8 @@ export const UrlPreviewCard = as<'div', { url: string; ts: number }>( const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); const [viewer, setViewer] = useState(false); - const [previewStatus, loadPreview] = useAsyncCallback( - useCallback(() => mx.getUrlPreview(url, ts), [url, ts, mx]), + const [previewStatus, loadPreview] = useAsyncCallback( + useCallback(() => mx.getUrlPreview(url, ts).then(decodePreviewText), [url, ts, mx]), ); useEffect(() => { diff --git a/src/app/utils/htmlEntities.test.ts b/src/app/utils/htmlEntities.test.ts new file mode 100644 index 000000000..6d271c19b --- /dev/null +++ b/src/app/utils/htmlEntities.test.ts @@ -0,0 +1,28 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { decodeHtmlEntities } from './htmlEntities'; + +test('decodes the leftover entity from a double-encoded meta tag (Steam)', () => { + assert.equal( + decodeHtmlEntities('The "Perpetual Testing Initiative" has been expanded'), + 'The "Perpetual Testing Initiative" has been expanded', + ); +}); + +test('named, decimal and hex references', () => { + assert.equal( + decodeHtmlEntities('Tom & Jerry 'n' co — …'), + "Tom & Jerry 'n' co — …", + ); + assert.equal(decodeHtmlEntities('emoji 😀'), 'emoji 😀'); +}); + +test('leaves plain text, unknown names and bare ampersands alone', () => { + assert.equal(decodeHtmlEntities('no entities here'), 'no entities here'); + assert.equal(decodeHtmlEntities('AT&T and &bogus; stay'), 'AT&T and &bogus; stay'); + assert.equal(decodeHtmlEntities('� �'), '� �'); +}); + +test('decodes only one level (a literal &lt; becomes <, not <)', () => { + assert.equal(decodeHtmlEntities('&lt;b&gt;'), '<b>'); +}); diff --git a/src/app/utils/htmlEntities.ts b/src/app/utils/htmlEntities.ts new file mode 100644 index 000000000..8faf73601 --- /dev/null +++ b/src/app/utils/htmlEntities.ts @@ -0,0 +1,38 @@ +const NAMED: Record = { + amp: '&', + lt: '<', + gt: '>', + quot: '"', + apos: "'", + nbsp: ' ', + hellip: '…', + mdash: '—', + ndash: '–', + lsquo: '‘', + rsquo: '’', + ldquo: '“', + rdquo: '”', + trade: '™', + copy: '©', + reg: '®', +}; + +/** + * Decode HTML character references left in plain text. Link previews need it: + * some sites double-encode their meta tags (Steam: `&quot;`), so after + * Synapse decodes once the card showed a literal `"`. The result is only + * ever rendered as React text, so decoding can't inject markup. + */ +export const decodeHtmlEntities = (text: string): string => { + if (!text.includes('&')) return text; + return text.replace(/&(#x[0-9a-f]+|#[0-9]+|[a-z]+);/gi, (match, ref: string) => { + if (ref[0] === '#') { + const code = + ref[1] === 'x' || ref[1] === 'X' ? parseInt(ref.slice(2), 16) : parseInt(ref.slice(1), 10); + return Number.isFinite(code) && code > 0 && code <= 0x10ffff + ? String.fromCodePoint(code) + : match; + } + return NAMED[ref.toLowerCase()] ?? match; + }); +};