fix(embeds): quality pass — close button, focus, a11y, perf, SoundCloud revert

From the quality-review agents:
- Revert on.soundcloud.com support: the w.soundcloud widget doesn't follow the
  redirect (needs an oEmbed resolve, deferred).
- Add a Close button to playing video/TikTok embeds and a Collapse button to the
  expanded X post — playback was previously one-way (only escapable by scrolling).
- focus-visible ring on the embed facade (folds resets outline:none, leaving
  keyboard users with no indicator).
- Only subscribe to resize postMessages while the iframe is mounted (was attaching
  a global listener per Instagram/Reddit facade before play).
- TikTok oEmbed fetch now uses AbortController (abort on unmount) + aria-busy /
  'Loading…' label on the resolving spinner.
- Decorative facade thumbnails use alt="" (parent already names them); drop the
  dangling-colon aria-labels when there's no title.
- Cap URL previews at 6 per message so a link-dump can't spawn dozens of fetches.

Tests 728. No CSP change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-07 00:45:51 -04:00
parent a28c305835
commit a52c9e12a4
5 changed files with 98 additions and 24 deletions
+3 -1
View File
@@ -86,7 +86,9 @@ export function RenderMessageContent({
eventId, eventId,
}: RenderMessageContentProps) { }: RenderMessageContentProps) {
const renderUrlsPreview = (urls: string[]) => { const renderUrlsPreview = (urls: string[]) => {
const filteredUrls = urls.filter((url) => !testMatrixTo(url)); // Cap previews per message so a link-dump doesn't spawn dozens of preview
// fetches + iframes at once.
const filteredUrls = urls.filter((url) => !testMatrixTo(url)).slice(0, 6);
if (filteredUrls.length === 0) return undefined; if (filteredUrls.length === 0) return undefined;
return ( return (
<UrlPreviewHolder> <UrlPreviewHolder>
@@ -255,6 +255,12 @@ export const EmbedFacade = style([
':hover': { ':hover': {
filter: 'brightness(0.85)', filter: 'brightness(0.85)',
}, },
selectors: {
'&:focus-visible': {
outline: `2px solid ${color.Primary.Main}`,
outlineOffset: '-2px',
},
},
}, },
]); ]);
@@ -676,7 +676,27 @@ function TwitterCard({
if (canEmbed && expanded && tweetId) { if (canEmbed && expanded && tweetId) {
return ( return (
<> <>
{header} <div className={previewCss.TwitterHeader}>
<XLogoIcon size={18} />
<Text priority="400" style={{ fontWeight: 600 }} truncate>
{name}
</Text>
{handle && (
<Text size="T200" priority="300" style={{ opacity: 0.55 }} truncate>
@{handle}
</Text>
)}
<Box grow="Yes" />
<IconButton
size="300"
radii="300"
variant="SurfaceVariant"
onClick={() => setExpanded(false)}
aria-label="Collapse post"
>
<Icon size="100" src={Icons.Cross} />
</IconButton>
</div>
<UrlPreviewContent> <UrlPreviewContent>
<TweetEmbed id={tweetId} /> <TweetEmbed id={tweetId} />
</UrlPreviewContent> </UrlPreviewContent>
@@ -1059,8 +1079,9 @@ function MediaEmbedCard({
: embed.provider === 'instagram' : embed.provider === 'instagram'
? INSTAGRAM_ORIGINS ? INSTAGRAM_ORIGINS
: REDDIT_ORIGINS; : REDDIT_ORIGINS;
// Only subscribe to resize messages while the iframe is actually mounted.
const { ref: resizeRef, height: resizeHeight } = useIframeAutoHeight( const { ref: resizeRef, height: resizeHeight } = useIframeAutoHeight(
resizeOrigins, playing ? resizeOrigins : NO_RESIZE_ORIGINS,
embed.height ?? 480, embed.height ?? 480,
); );
// 'rich' providers (esp. Reddit) can carry a bot-wall title — suppress it. // 'rich' providers (esp. Reddit) can carry a bot-wall title — suppress it.
@@ -1093,7 +1114,8 @@ function MediaEmbedCard({
const mediaStyle = fixedHeight ? { height: `${embed.height ?? 152}px` } : undefined; const mediaStyle = fixedHeight ? { height: `${embed.height ?? 152}px` } : undefined;
const thumb = thumbnailUrl ? ( const thumb = thumbnailUrl ? (
<img className={previewCss.MediaThumbnailImg} src={thumbnailUrl} alt={title} loading="lazy" /> // alt="" — the parent button/link already carries the accessible name.
<img className={previewCss.MediaThumbnailImg} src={thumbnailUrl} alt="" loading="lazy" />
) : ( ) : (
<span className={previewCss.EmbedPlaceholder}> <span className={previewCss.EmbedPlaceholder}>
<Icon size="600" src={Icons.Play} /> <Icon size="600" src={Icons.Play} />
@@ -1155,7 +1177,7 @@ function MediaEmbedCard({
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
className={previewCss.EmbedFacade} className={previewCss.EmbedFacade}
aria-label={`Open on ${badge.label}: ${title}`} aria-label={title ? `Open on ${badge.label}: ${title}` : `Open on ${badge.label}`}
> >
{thumb} {thumb}
{playOverlay} {playOverlay}
@@ -1177,10 +1199,28 @@ function MediaEmbedCard({
> >
<SiteBadge label={badge.label} colorClass={badge.class} /> <SiteBadge label={badge.label} colorClass={badge.class} />
</Text> </Text>
{playing && video && ( {playing && (
<Chip variant="Secondary" radii="Pill" onClick={enterFullscreen} aria-label="Fullscreen"> <Box alignItems="Center" gap="100" shrink="No">
<Text size="T200"> Fullscreen</Text> {video && (
</Chip> <Chip
variant="Secondary"
radii="Pill"
onClick={enterFullscreen}
aria-label="Fullscreen"
>
<Text size="T200"> Fullscreen</Text>
</Chip>
)}
<IconButton
size="300"
radii="300"
variant="SurfaceVariant"
onClick={() => setPlaying(false)}
aria-label="Close player"
>
<Icon size="100" src={Icons.Cross} />
</IconButton>
</Box>
)} )}
</Box> </Box>
{/* While a video plays, drop the title/description so the player isn't {/* While a video plays, drop the title/description so the player isn't
@@ -1213,6 +1253,10 @@ function TikTokEmbedCard({ url, prev }: { url: string; prev: IPreviewUrlResponse
const [resolving, setResolving] = useState(false); const [resolving, setResolving] = useState(false);
const [failed, setFailed] = useState(false); const [failed, setFailed] = useState(false);
const mediaRef = useRef<HTMLDivElement>(null); const mediaRef = useRef<HTMLDivElement>(null);
const abortRef = useRef<AbortController | null>(null);
// Abort an in-flight oEmbed resolve if the card unmounts (scrolled away).
useEffect(() => () => abortRef.current?.abort(), []);
const mxcImage = prev['og:image'] as string | undefined; const mxcImage = prev['og:image'] as string | undefined;
const rawTitle = prev['og:title'] ?? ''; const rawTitle = prev['og:title'] ?? '';
@@ -1228,9 +1272,12 @@ function TikTokEmbedCard({ url, prev }: { url: string; prev: IPreviewUrlResponse
} }
setResolving(true); setResolving(true);
setFailed(false); setFailed(false);
const controller = new AbortController();
abortRef.current = controller;
try { try {
const res = await fetch(tiktokOembedUrl(url)); const res = await fetch(tiktokOembedUrl(url), { signal: controller.signal });
const id = tiktokIdFromOembed(await res.json()); const id = tiktokIdFromOembed(await res.json());
if (controller.signal.aborted) return;
if (id) { if (id) {
setVideoId(id); setVideoId(id);
setPlaying(true); setPlaying(true);
@@ -1238,9 +1285,9 @@ function TikTokEmbedCard({ url, prev }: { url: string; prev: IPreviewUrlResponse
setFailed(true); setFailed(true);
} }
} catch { } catch {
setFailed(true); if (!controller.signal.aborted) setFailed(true);
} finally { } finally {
setResolving(false); if (!controller.signal.aborted) setResolving(false);
} }
}, [url, videoId]); }, [url, videoId]);
@@ -1249,7 +1296,7 @@ function TikTokEmbedCard({ url, prev }: { url: string; prev: IPreviewUrlResponse
const facadeInner = ( const facadeInner = (
<> <>
{thumbnailUrl ? ( {thumbnailUrl ? (
<img className={previewCss.MediaThumbnailImg} src={thumbnailUrl} alt={title} loading="lazy" /> <img className={previewCss.MediaThumbnailImg} src={thumbnailUrl} alt="" loading="lazy" />
) : ( ) : (
<span className={previewCss.EmbedPlaceholder} /> <span className={previewCss.EmbedPlaceholder} />
)} )}
@@ -1280,7 +1327,10 @@ function TikTokEmbedCard({ url, prev }: { url: string; prev: IPreviewUrlResponse
className={previewCss.EmbedFacade} className={previewCss.EmbedFacade}
onClick={start} onClick={start}
disabled={resolving} disabled={resolving}
aria-label={`Play TikTok${title ? `: ${title}` : ''}`} aria-busy={resolving}
aria-label={
resolving ? 'Loading TikTok…' : `Play TikTok${title ? `: ${title}` : ''}`
}
> >
{facadeInner} {facadeInner}
</button> </button>
@@ -1311,9 +1361,25 @@ function TikTokEmbedCard({ url, prev }: { url: string; prev: IPreviewUrlResponse
<SiteBadge label="TikTok" colorClass={previewCss.BadgeTikTok} /> <SiteBadge label="TikTok" colorClass={previewCss.BadgeTikTok} />
</Text> </Text>
{playing && videoId && ( {playing && videoId && (
<Chip variant="Secondary" radii="Pill" onClick={enterFullscreen} aria-label="Fullscreen"> <Box alignItems="Center" gap="100" shrink="No">
<Text size="T200"> Fullscreen</Text> <Chip
</Chip> variant="Secondary"
radii="Pill"
onClick={enterFullscreen}
aria-label="Fullscreen"
>
<Text size="T200"> Fullscreen</Text>
</Chip>
<IconButton
size="300"
radii="300"
variant="SurfaceVariant"
onClick={() => setPlaying(false)}
aria-label="Close player"
>
<Icon size="100" src={Icons.Cross} />
</IconButton>
</Box>
)} )}
</Box> </Box>
{failed && ( {failed && (
+3 -3
View File
@@ -132,11 +132,11 @@ test('Spotify target + height', () => {
assert.equal(spotifyEmbedHeight('album'), 352); assert.equal(spotifyEmbedHeight('album'), 352);
}); });
test('SoundCloud track detection (+ on. short link)', () => { test('SoundCloud track detection', () => {
assert.equal(isSoundCloudTrack('https://soundcloud.com/artist/some-track'), true); assert.equal(isSoundCloudTrack('https://soundcloud.com/artist/some-track'), true);
assert.equal(isSoundCloudTrack('https://soundcloud.com/artist'), false); // bare profile assert.equal(isSoundCloudTrack('https://soundcloud.com/artist'), false); // bare profile
assert.equal(isSoundCloudTrack('https://on.soundcloud.com/abc123'), true); // share short link // on.soundcloud.com short links intentionally not handled (need oEmbed resolve)
assert.equal(parseMediaEmbed('https://on.soundcloud.com/abc123', 'h')?.provider, 'soundcloud'); assert.equal(isSoundCloudTrack('https://on.soundcloud.com/abc123'), false);
}); });
test('buildVideoEmbedUrl: cookie-less YouTube + Vimeo', () => { test('buildVideoEmbedUrl: cookie-less YouTube + Vimeo', () => {
+4 -4
View File
@@ -247,10 +247,10 @@ export function getSpotifyEmbedTarget(url: string): { type: SpotifyType; id: str
export function isSoundCloudTrack(url: string): boolean { export function isSoundCloudTrack(url: string): boolean {
try { try {
const { hostname, pathname } = new URL(url); const { hostname, pathname } = new URL(url);
const h = hostname.replace(/^www\./, ''); // NOTE: on.soundcloud.com short links are NOT handled here — the w.soundcloud
// on.soundcloud.com/<code> is a share short link — the widget follows it. // widget resolver doesn't follow the redirect; supporting them needs an oEmbed
if (h === 'on.soundcloud.com') return pathname.replace(/^\/+|\/+$/g, '').length > 0; // round-trip (soundcloud.com/oembed is CORS-enabled) to get the canonical URL.
if (h !== 'soundcloud.com') return false; if (hostname.replace(/^www\./, '') !== 'soundcloud.com') return false;
// /<artist>/<track|sets/set> — at least two segments, not a bare profile // /<artist>/<track|sets/set> — at least two segments, not a bare profile
const parts = pathname.replace(/^\/+|\/+$/g, '').split('/').filter(Boolean); const parts = pathname.replace(/^\/+|\/+$/g, '').split('/').filter(Boolean);
return parts.length >= 2; return parts.length >= 2;