From ba5b1ffe7dac1c59d4782b28ca10851d0c5f8352 Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Sat, 26 Sep 2026 11:54:16 -0400 Subject: [PATCH] feat(embeds): fallback for hung embeds and deleted X posts (#200) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Probed how real providers fail before picking signals: - X renders an EMPTY frame for a deleted/private/suspended post and says so only via postMessage `twttr.private.no_results`. The post embed now swaps to "This post isn't available…" with an "Open on X" link. - A hung frame never fires `load`. After 20 s every player (media, rich posts, TikTok, Steam widget, X) overlays "This embed is taking too long to load" with Retry (remounts the iframe) and "Open on ". A late `load` clears it. - Instagram and Bluesky show their own "removed / not found" page, and a refused request still fires `load` (browser error page), so neither needs or can use a guess. A missing height message is not treated as failure. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA --- .../components/url-preview/UrlPreview.css.tsx | 27 ++ .../components/url-preview/UrlPreviewCard.tsx | 249 ++++++++++++++---- src/app/utils/videoEmbed.test.ts | 26 ++ src/app/utils/videoEmbed.ts | 27 ++ 4 files changed, 277 insertions(+), 52 deletions(-) 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 ( -