From 46ceab0c425958599b4158bfb7bf8b0171bc8787 Mon Sep 17 00:00:00 2001 From: tomeredlich Date: Sun, 12 Apr 2026 11:53:10 +0300 Subject: [PATCH 1/8] feat: add inline post preview embed flow Add a three-column post modal with an inline article preview that supports extension embedding and webapp fallback states, including dismiss/restore and unavailable UX. Expose the extension id to webapp runtime so preview initialization can run consistently across app surfaces. Made-with: Cursor --- .../post/PostArticlePreviewEmbed.tsx | 408 ++++++++++++++++++ .../src/components/post/PostContent.tsx | 321 +++++++++----- .../EmbeddedBrowsingWebPrompt.module.css | 103 +++++ .../EmbeddedBrowsingWebPrompt.tsx | 137 ++++++ .../extensionEmbed/ExtensionSiteEmbed.tsx | 11 +- .../src/features/extensionEmbed/common.ts | 5 + .../extensionEmbed/extensionEmbedMessaging.ts | 9 +- .../getBrowserExtensionInstallId.ts | 18 + .../extensionEmbed/useExtensionSiteEmbed.ts | 76 +++- packages/webapp/next.config.ts | 5 + 10 files changed, 970 insertions(+), 123 deletions(-) create mode 100644 packages/shared/src/components/post/PostArticlePreviewEmbed.tsx create mode 100644 packages/shared/src/features/extensionEmbed/EmbeddedBrowsingWebPrompt.module.css create mode 100644 packages/shared/src/features/extensionEmbed/EmbeddedBrowsingWebPrompt.tsx create mode 100644 packages/shared/src/features/extensionEmbed/getBrowserExtensionInstallId.ts diff --git a/packages/shared/src/components/post/PostArticlePreviewEmbed.tsx b/packages/shared/src/components/post/PostArticlePreviewEmbed.tsx new file mode 100644 index 00000000000..9cd5b36cb37 --- /dev/null +++ b/packages/shared/src/components/post/PostArticlePreviewEmbed.tsx @@ -0,0 +1,408 @@ +import classNames from 'classnames'; +import type { ReactElement } from 'react'; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { EmbeddedBrowsingWebPrompt } from '../../features/extensionEmbed/EmbeddedBrowsingWebPrompt'; +import { ExtensionSiteEmbed } from '../../features/extensionEmbed/ExtensionSiteEmbed'; +import { getBrowserExtensionInstallId } from '../../features/extensionEmbed/getBrowserExtensionInstallId'; +import type { UseExtensionSiteEmbedResult } from '../../features/extensionEmbed/useExtensionSiteEmbed'; +import { apiUrl } from '../../lib/config'; +import { Loader } from '../Loader'; +import { + Typography, + TypographyTag, + TypographyColor, + TypographyType, +} from '../typography/Typography'; + +type PostArticlePreviewEmbedProps = { + targetUrl: string; + previewHost?: string; + className?: string; + onDismissArticlePreview?: () => void; + onPreviewUnavailable?: () => void; + forceUnavailable?: boolean; +}; + +const renderEmbedChrome = ({ + extensionId, + state, +}: { + extensionId: string | null; + state: UseExtensionSiteEmbedResult; +}): ReactElement | null => { + if (!extensionId) { + return null; + } + + if (state.status === 'error' && state.error) { + return ( +
+ + {state.error} + +
+ ); + } + + if (state.status === 'reloading-extension') { + return ( +
+ + + Reloading extension… + +
+ ); + } + + return null; +}; + +export function PostArticlePreviewEmbed({ + targetUrl, + previewHost, + className, + onDismissArticlePreview, + onPreviewUnavailable, + forceUnavailable = false, +}: PostArticlePreviewEmbedProps): ReactElement { + const extensionId = useMemo(() => getBrowserExtensionInstallId(), []); + const [showDirectWebPreview, setShowDirectWebPreview] = useState(false); + const [hasPreviewFrameLoaded, setHasPreviewFrameLoaded] = useState(false); + const [hasTimedOutUnavailable, setHasTimedOutUnavailable] = useState(false); + const [embedState, setEmbedState] = useState<{ + status: UseExtensionSiteEmbedResult['status']; + errorReason: UseExtensionSiteEmbedResult['errorReason']; + }>({ + status: 'idle', + errorReason: null, + }); + const hasNotifiedUnavailableRef = useRef(false); + const previewDomain = useMemo(() => { + if (previewHost && previewHost.length > 0) { + return previewHost; + } + + try { + return new URL(targetUrl).hostname; + } catch { + return targetUrl; + } + }, [previewHost, targetUrl]); + const previewUrlLabel = useMemo(() => { + try { + const parsedTarget = new URL(targetUrl); + return `${parsedTarget.hostname}${parsedTarget.pathname}`; + } catch { + return targetUrl; + } + }, [targetUrl]); + const faviconSrc = useMemo(() => { + const pixelRatio = globalThis?.window?.devicePixelRatio ?? 1; + const iconSize = Math.max(Math.round(16 * pixelRatio), 96); + + return `${apiUrl}/icon?url=${encodeURIComponent( + previewDomain, + )}&size=${iconSize}`; + }, [previewDomain]); + + useEffect(() => { + // #region agent log + fetch( + 'http://127.0.0.1:7456/ingest/fdbfceea-236d-410d-a991-0af0a5442e8e', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Debug-Session-Id': '8fe848', + }, + body: JSON.stringify({ + sessionId: '8fe848', + runId: 'initial', + hypothesisId: 'H2', + location: 'PostArticlePreviewEmbed.tsx:106', + message: 'embed target changed reset local preview state', + data: { targetUrl, extensionId }, + timestamp: Date.now(), + }), + }, + ).catch(() => {}); + // #endregion + setShowDirectWebPreview(false); + setHasPreviewFrameLoaded(false); + setHasTimedOutUnavailable(false); + setEmbedState({ status: 'idle', errorReason: null }); + hasNotifiedUnavailableRef.current = false; + }, [extensionId, targetUrl]); + + const onEnableDirectWebPreview = useCallback(() => { + // #region agent log + fetch( + 'http://127.0.0.1:7456/ingest/fdbfceea-236d-410d-a991-0af0a5442e8e', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Debug-Session-Id': '8fe848', + }, + body: JSON.stringify({ + sessionId: '8fe848', + runId: 'initial', + hypothesisId: 'H6', + location: 'PostArticlePreviewEmbed.tsx:160', + message: 'enable clicked in webapp prompt', + data: { targetUrl, extensionId }, + timestamp: Date.now(), + }), + }, + ).catch(() => {}); + // #endregion + setHasPreviewFrameLoaded(false); + setHasTimedOutUnavailable(false); + setShowDirectWebPreview(true); + }, [extensionId, targetUrl]); + + const onExtensionPreviewFrameLoad = useCallback(() => { + // #region agent log + fetch( + 'http://127.0.0.1:7456/ingest/fdbfceea-236d-410d-a991-0af0a5442e8e', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Debug-Session-Id': '8fe848', + }, + body: JSON.stringify({ + sessionId: '8fe848', + runId: 'initial', + hypothesisId: 'H1', + location: 'PostArticlePreviewEmbed.tsx:141', + message: 'extension preview iframe onLoad fired', + data: { targetUrl, extensionId }, + timestamp: Date.now(), + }), + }, + ).catch(() => {}); + // #endregion + setHasPreviewFrameLoaded(true); + }, [extensionId, targetUrl]); + + const onCopyPreviewUrl = useCallback(() => { + if (!targetUrl) { + return; + } + + navigator.clipboard?.writeText(targetUrl).catch(() => {}); + }, [targetUrl]); + + const isExtensionPreviewAwaitingLoad = + !!extensionId && + embedState.status === 'ready' && + !hasPreviewFrameLoaded && + !forceUnavailable; + const showDirectPreviewFrame = !extensionId && showDirectWebPreview; + const isDirectPreviewAwaitingLoad = + showDirectPreviewFrame && !hasPreviewFrameLoaded && !forceUnavailable; + const shouldShowUnavailablePrompt = + forceUnavailable || hasTimedOutUnavailable; + const shouldShowPrompt = !extensionId && !showDirectWebPreview; + const shouldShowPreviewHeader = + (showDirectPreviewFrame || !!extensionId) && !shouldShowUnavailablePrompt; + + useEffect(() => { + const timeout = + isExtensionPreviewAwaitingLoad || isDirectPreviewAwaitingLoad + ? globalThis.setTimeout(() => { + // #region agent log + fetch( + 'http://127.0.0.1:7456/ingest/fdbfceea-236d-410d-a991-0af0a5442e8e', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Debug-Session-Id': '8fe848', + }, + body: JSON.stringify({ + sessionId: '8fe848', + runId: 'initial', + hypothesisId: 'H1', + location: 'PostArticlePreviewEmbed.tsx:165', + message: 'preview load timeout fired', + data: { + targetUrl, + extensionId, + isExtensionPreviewAwaitingLoad, + isDirectPreviewAwaitingLoad, + }, + timestamp: Date.now(), + }), + }, + ).catch(() => {}); + // #endregion + if (hasNotifiedUnavailableRef.current) { + return; + } + + hasNotifiedUnavailableRef.current = true; + setHasTimedOutUnavailable(true); + onPreviewUnavailable?.(); + }, 7000) + : undefined; + + return () => { + if (timeout) { + globalThis.clearTimeout(timeout); + } + }; + }, [ + isDirectPreviewAwaitingLoad, + isExtensionPreviewAwaitingLoad, + onPreviewUnavailable, + ]); + + let previewContent: ReactElement; + if (shouldShowUnavailablePrompt) { + previewContent = ( + + ); + } else if (showDirectPreviewFrame) { + previewContent = ( +