diff --git a/src/app/components/url-preview/UrlPreview.css.tsx b/src/app/components/url-preview/UrlPreview.css.tsx index 5eac2acee..6ac55b660 100644 --- a/src/app/components/url-preview/UrlPreview.css.tsx +++ b/src/app/components/url-preview/UrlPreview.css.tsx @@ -315,6 +315,33 @@ export const EmbedIframeStatic = style([ }, ]); +// Shown over (or instead of) an embed that hung or that the provider says is gone. +export const EmbedNotice = style([ + DefaultReset, + { + position: 'absolute', + inset: 0, + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + gap: config.space.S200, + padding: config.space.S300, + textAlign: 'center', + backgroundColor: color.Surface.Container, + color: color.Surface.OnContainer, + }, +]); + +export const EmbedNoticeStatic = style([ + EmbedNotice, + { + position: 'static', + minHeight: toRem(120), + borderRadius: config.radii.R300, + }, +]); + export const EmbedPlaceholder = style([ DefaultReset, { diff --git a/src/app/components/url-preview/UrlPreviewCard.tsx b/src/app/components/url-preview/UrlPreviewCard.tsx index d559f03e9..e7bd29ffc 100644 --- a/src/app/components/url-preview/UrlPreviewCard.tsx +++ b/src/app/components/url-preview/UrlPreviewCard.tsx @@ -34,11 +34,13 @@ import { onEnterOrSpace } from '../../utils/keyboard'; import { useSetting } from '../../state/hooks/settings'; import { settingsAtom } from '../../state/settings'; import { + EMBED_LOAD_TIMEOUT_MS, extractEmbedHeight, getSteamTarget, getTikTokVideoId, getTweetId, isTikTokLink, + isTwitterNoResults, MediaEmbed, parseMediaEmbed, steamWidgetEmbedUrl, @@ -669,10 +671,84 @@ function useIframeAutoHeight(origins: string[], initial: number) { return { ref, height }; } +// Embed iframes that never fire `load` (provider down, network stall) would +// otherwise sit as an empty box forever. After EMBED_LOAD_TIMEOUT_MS the card +// offers Retry / "Open on …"; a late `load` clears the notice on its own. +// `attempt` is the iframe's `key`, so Retry remounts it. +function useEmbedWatchdog(active: boolean) { + const [attempt, setAttempt] = useState(0); + const [loaded, setLoaded] = useState(false); + const [timedOut, setTimedOut] = useState(false); + useEffect(() => { + setLoaded(false); + setTimedOut(false); + if (!active) return undefined; + const t = window.setTimeout(() => setTimedOut(true), EMBED_LOAD_TIMEOUT_MS); + return () => window.clearTimeout(t); + }, [active, attempt]); + const onLoad = useCallback(() => setLoaded(true), []); + const retry = useCallback(() => setAttempt((a) => a + 1), []); + return { attempt, onLoad, stalled: active && timedOut && !loaded, retry }; +} + +function EmbedNotice({ + message, + url, + site, + onRetry, + inFlow, +}: { + message: string; + url: string; + site: string; + onRetry?: () => void; + /** Render in the layout flow instead of as an overlay on the player box. */ + inFlow?: boolean; +}) { + return ( +
+ {message} + + {onRetry && ( + + Retry + + )} + + Open on {site} + + +
+ ); +} + +const EMBED_STALLED_MESSAGE = 'This embed is taking too long to load.'; + // Interactive X/Twitter post embed — playable video/GIF, galleries, quote tweets. // Self-sizes via useIframeAutoHeight. Only mounted after "View post" (facade). -function TweetEmbed({ id }: { id: string }) { +function TweetEmbed({ id, url }: { id: string; url: string }) { const { ref, height } = useIframeAutoHeight(TWITTER_ORIGINS, 320); + const { attempt, onLoad, stalled, retry } = useEmbedWatchdog(true); + // A deleted/private/suspended post renders an EMPTY frame; X only says so + // via `twttr.private.no_results`. + const [unavailable, setUnavailable] = useState(false); + useEffect(() => { + const onMessage = (e: MessageEvent) => { + if (!TWITTER_ORIGINS.includes(e.origin)) return; + if (!ref.current || e.source !== ref.current.contentWindow) return; + if (isTwitterNoResults(e.data)) setUnavailable(true); + }; + window.addEventListener('message', onMessage); + return () => window.removeEventListener('message', onMessage); + }, [ref]); const theme = typeof window !== 'undefined' && window.matchMedia && @@ -680,20 +756,38 @@ function TweetEmbed({ id }: { id: string }) { ? 'light' : 'dark'; + if (unavailable) { + return ( + + ); + } + return ( -