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 (
-
+
+
+ {stalled && (
+
+ )}
+
);
}
@@ -773,7 +867,7 @@ function TwitterCard({
-
+
>
);
@@ -1151,6 +1245,7 @@ function MediaEmbedCard({
const [inlineMediaEmbeds] = useSetting(settingsAtom, 'inlineMediaEmbeds');
const [playing, setPlaying] = useState(false);
const mediaRef = useRef(null);
+ const { attempt, onLoad, stalled, retry } = useEmbedWatchdog(playing);
const rawTitle = prev['og:title'] ?? '';
const rawDescription = prev['og:description'] ?? '';
@@ -1228,30 +1323,54 @@ function MediaEmbedCard({
{playing && rich ? (
// Rich post embeds (Instagram/Reddit) are variable-height and self-size.
-
+
+
+ {stalled && (
+
+ )}
+
) : (
{playing ? (
-
+ <>
+
+ {stalled && (
+
+ )}
+ >
) : inlineMediaEmbeds ? (