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; + }); +};