diff --git a/src/app/components/RenderMessageContent.tsx b/src/app/components/RenderMessageContent.tsx index 0abfd3a6e..903bedf75 100644 --- a/src/app/components/RenderMessageContent.tsx +++ b/src/app/components/RenderMessageContent.tsx @@ -86,7 +86,9 @@ export function RenderMessageContent({ eventId, }: RenderMessageContentProps) { 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; return ( diff --git a/src/app/components/url-preview/UrlPreview.css.tsx b/src/app/components/url-preview/UrlPreview.css.tsx index 664ceffb2..850ec05fd 100644 --- a/src/app/components/url-preview/UrlPreview.css.tsx +++ b/src/app/components/url-preview/UrlPreview.css.tsx @@ -255,6 +255,12 @@ export const EmbedFacade = style([ ':hover': { filter: 'brightness(0.85)', }, + selectors: { + '&:focus-visible': { + outline: `2px solid ${color.Primary.Main}`, + outlineOffset: '-2px', + }, + }, }, ]); diff --git a/src/app/components/url-preview/UrlPreviewCard.tsx b/src/app/components/url-preview/UrlPreviewCard.tsx index c0e000ec8..89abee899 100644 --- a/src/app/components/url-preview/UrlPreviewCard.tsx +++ b/src/app/components/url-preview/UrlPreviewCard.tsx @@ -676,7 +676,27 @@ function TwitterCard({ if (canEmbed && expanded && tweetId) { return ( <> - {header} +
+ + + {name} + + {handle && ( + + @{handle} + + )} + + setExpanded(false)} + aria-label="Collapse post" + > + + +
@@ -1059,8 +1079,9 @@ function MediaEmbedCard({ : embed.provider === 'instagram' ? INSTAGRAM_ORIGINS : REDDIT_ORIGINS; + // Only subscribe to resize messages while the iframe is actually mounted. const { ref: resizeRef, height: resizeHeight } = useIframeAutoHeight( - resizeOrigins, + playing ? resizeOrigins : NO_RESIZE_ORIGINS, embed.height ?? 480, ); // '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 thumb = thumbnailUrl ? ( - {title} + // alt="" — the parent button/link already carries the accessible name. + ) : ( @@ -1155,7 +1177,7 @@ function MediaEmbedCard({ target="_blank" rel="noreferrer" className={previewCss.EmbedFacade} - aria-label={`Open on ${badge.label}: ${title}`} + aria-label={title ? `Open on ${badge.label}: ${title}` : `Open on ${badge.label}`} > {thumb} {playOverlay} @@ -1177,10 +1199,28 @@ function MediaEmbedCard({ > - {playing && video && ( - - ⛶ Fullscreen - + {playing && ( + + {video && ( + + ⛶ Fullscreen + + )} + setPlaying(false)} + aria-label="Close player" + > + + + )} {/* 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 [failed, setFailed] = useState(false); const mediaRef = useRef(null); + const abortRef = useRef(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 rawTitle = prev['og:title'] ?? ''; @@ -1228,9 +1272,12 @@ function TikTokEmbedCard({ url, prev }: { url: string; prev: IPreviewUrlResponse } setResolving(true); setFailed(false); + const controller = new AbortController(); + abortRef.current = controller; try { - const res = await fetch(tiktokOembedUrl(url)); + const res = await fetch(tiktokOembedUrl(url), { signal: controller.signal }); const id = tiktokIdFromOembed(await res.json()); + if (controller.signal.aborted) return; if (id) { setVideoId(id); setPlaying(true); @@ -1238,9 +1285,9 @@ function TikTokEmbedCard({ url, prev }: { url: string; prev: IPreviewUrlResponse setFailed(true); } } catch { - setFailed(true); + if (!controller.signal.aborted) setFailed(true); } finally { - setResolving(false); + if (!controller.signal.aborted) setResolving(false); } }, [url, videoId]); @@ -1249,7 +1296,7 @@ function TikTokEmbedCard({ url, prev }: { url: string; prev: IPreviewUrlResponse const facadeInner = ( <> {thumbnailUrl ? ( - {title} + ) : ( )} @@ -1280,7 +1327,10 @@ function TikTokEmbedCard({ url, prev }: { url: string; prev: IPreviewUrlResponse className={previewCss.EmbedFacade} onClick={start} disabled={resolving} - aria-label={`Play TikTok${title ? `: ${title}` : ''}`} + aria-busy={resolving} + aria-label={ + resolving ? 'Loading TikTok…' : `Play TikTok${title ? `: ${title}` : ''}` + } > {facadeInner} @@ -1311,9 +1361,25 @@ function TikTokEmbedCard({ url, prev }: { url: string; prev: IPreviewUrlResponse {playing && videoId && ( - - ⛶ Fullscreen - + + + ⛶ Fullscreen + + setPlaying(false)} + aria-label="Close player" + > + + + )} {failed && ( diff --git a/src/app/utils/videoEmbed.test.ts b/src/app/utils/videoEmbed.test.ts index b8a5986fd..3971da021 100644 --- a/src/app/utils/videoEmbed.test.ts +++ b/src/app/utils/videoEmbed.test.ts @@ -132,11 +132,11 @@ test('Spotify target + height', () => { 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'), false); // bare profile - assert.equal(isSoundCloudTrack('https://on.soundcloud.com/abc123'), true); // share short link - assert.equal(parseMediaEmbed('https://on.soundcloud.com/abc123', 'h')?.provider, 'soundcloud'); + // on.soundcloud.com short links intentionally not handled (need oEmbed resolve) + assert.equal(isSoundCloudTrack('https://on.soundcloud.com/abc123'), false); }); test('buildVideoEmbedUrl: cookie-less YouTube + Vimeo', () => { diff --git a/src/app/utils/videoEmbed.ts b/src/app/utils/videoEmbed.ts index 9fd81e7bc..2de03a79d 100644 --- a/src/app/utils/videoEmbed.ts +++ b/src/app/utils/videoEmbed.ts @@ -247,10 +247,10 @@ export function getSpotifyEmbedTarget(url: string): { type: SpotifyType; id: str export function isSoundCloudTrack(url: string): boolean { try { const { hostname, pathname } = new URL(url); - const h = hostname.replace(/^www\./, ''); - // on.soundcloud.com/ is a share short link — the widget follows it. - if (h === 'on.soundcloud.com') return pathname.replace(/^\/+|\/+$/g, '').length > 0; - if (h !== 'soundcloud.com') return false; + // NOTE: on.soundcloud.com short links are NOT handled here — the w.soundcloud + // widget resolver doesn't follow the redirect; supporting them needs an oEmbed + // round-trip (soundcloud.com/oembed is CORS-enabled) to get the canonical URL. + if (hostname.replace(/^www\./, '') !== 'soundcloud.com') return false; // // — at least two segments, not a bare profile const parts = pathname.replace(/^\/+|\/+$/g, '').split('/').filter(Boolean); return parts.length >= 2;