diff --git a/packages/shared/src/components/post/PostArticlePreviewEmbed.tsx b/packages/shared/src/components/post/PostArticlePreviewEmbed.tsx new file mode 100644 index 00000000000..6bd7487a842 --- /dev/null +++ b/packages/shared/src/components/post/PostArticlePreviewEmbed.tsx @@ -0,0 +1,271 @@ +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] = useState(() => getBrowserExtensionInstallId()); + 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 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(() => { + setHasPreviewFrameLoaded(false); + setHasTimedOutUnavailable(false); + setEmbedState({ status: 'idle', errorReason: null }); + hasNotifiedUnavailableRef.current = false; + }, [extensionId, targetUrl]); + + const onExtensionPreviewFrameLoad = useCallback(() => { + setHasPreviewFrameLoaded(true); + }, []); + + const onCopyPreviewUrl = useCallback(() => { + if (!targetUrl) { + return; + } + + navigator.clipboard?.writeText(targetUrl).catch(() => {}); + }, [targetUrl]); + + const isExtensionPreviewAwaitingLoad = + !!extensionId && + embedState.status === 'ready' && + !hasPreviewFrameLoaded && + !forceUnavailable; + const shouldShowUnavailablePrompt = + forceUnavailable || hasTimedOutUnavailable; + const shouldShowPrompt = !extensionId; + const shouldShowPreviewHeader = + !!extensionId && !shouldShowUnavailablePrompt; + + const handleEmbedStateChange = useCallback( + (state: UseExtensionSiteEmbedResult) => { + setEmbedState({ + status: state.status, + errorReason: state.errorReason, + }); + + if (state.status !== 'ready') { + setHasPreviewFrameLoaded(false); + } + }, + [], + ); + + const handleRenderState = useCallback( + (state: UseExtensionSiteEmbedResult) => { + if ( + state.errorReason === 'preview-unavailable' && + onPreviewUnavailable && + !hasNotifiedUnavailableRef.current + ) { + hasNotifiedUnavailableRef.current = true; + onPreviewUnavailable(); + } + + return renderEmbedChrome({ + extensionId, + state, + }); + }, + [extensionId, onPreviewUnavailable], + ); + + useEffect(() => { + const timeout = + isExtensionPreviewAwaitingLoad + ? globalThis.setTimeout(() => { + if (hasNotifiedUnavailableRef.current) { + return; + } + + hasNotifiedUnavailableRef.current = true; + setHasTimedOutUnavailable(true); + onPreviewUnavailable?.(); + }, 7000) + : undefined; + + return () => { + if (timeout) { + globalThis.clearTimeout(timeout); + } + }; + }, [ + isExtensionPreviewAwaitingLoad, + onPreviewUnavailable, + ]); + + let previewContent: ReactElement; + if (shouldShowUnavailablePrompt) { + previewContent = ( + + ); + } else { + previewContent = ( + + ); + } + + return ( + + ); +} diff --git a/packages/shared/src/components/post/PostContent.tsx b/packages/shared/src/components/post/PostContent.tsx index 955d0fabffa..e5f26e33e7e 100644 --- a/packages/shared/src/components/post/PostContent.tsx +++ b/packages/shared/src/components/post/PostContent.tsx @@ -1,9 +1,10 @@ import classNames from 'classnames'; import type { ComponentProps, ReactElement } from 'react'; -import React, { useEffect } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import dynamic from 'next/dynamic'; import type { Post } from '../../graphql/posts'; -import { isVideoPost } from '../../graphql/posts'; +import { isVideoPost, PostType } from '../../graphql/posts'; +import { isEmbeddableSiteTarget } from '../../features/extensionEmbed/common'; import PostMetadata from '../cards/common/PostMetadata'; import { PostWidgets } from './PostWidgets'; import PostToc from '../widgets/PostToc'; @@ -28,17 +29,54 @@ import { useSmartTitle } from '../../hooks/post/useSmartTitle'; import { SmartPrompt } from './smartPrompts/SmartPrompt'; import { PostTagList } from './tags/PostTagList'; import PostSourceInfo from './PostSourceInfo'; +import { PostArticlePreviewEmbed } from './PostArticlePreviewEmbed'; +import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; +import { EarthIcon, MiniCloseIcon } from '../icons'; +import { useViewSize, ViewSize } from '../../hooks'; +import { Drawer } from '../drawers/Drawer'; type PostContentRawProps = Omit & { post: Post }; export const SCROLL_OFFSET = 80; +// Post content fixed column (matches grid-cols-[22rem_...] on laptop) +const POST_COLUMN_REM = 22; +// Widgets sidebar width (matches w-[21.25rem] on laptop) +const WIDGETS_COLUMN_REM = 21.25; + +const PREVIEW_MIN_WIDTH = 360; +const PREVIEW_RESTORE_WIDTH = 380; +const FLOATING_PREVIEW_ANIMATION_MS = 300; +const REM_IN_PX = 16; +const PREVIEW_LAYOUT_MIN_WIDTH = + (POST_COLUMN_REM + WIDGETS_COLUMN_REM) * REM_IN_PX + PREVIEW_MIN_WIDTH; + const PostCodeSnippets = dynamic(() => import(/* webpackChunkName: "postCodeSnippets" */ './PostCodeSnippets').then( (mod) => mod.PostCodeSnippets, ), ); +const ArticleLink = ({ + href, + onClick, + children, + ...props +}: ComponentProps<'a'> & { href?: string; onClick?: () => void }) => { + return ( + + {children} + + ); +}; + export function PostContentRaw({ post, className = {}, @@ -67,10 +105,13 @@ export function PostContentRaw({ const onSendViewPost = useViewPost(); const showCodeSnippets = useFeature(feature.showCodeSnippets); const { title } = useSmartTitle(post); + const isTablet = useViewSize(ViewSize.Tablet); + const isLaptop = useViewSize(ViewSize.Laptop); const hasNavigation = !!onPreviousPost || !!onNextPost; const isVideoType = isVideoPost(post); const hasToc = (post.toc?.length ?? 0) > 0; const isCompactModalSpacing = !isPostPage; + const [isPreviewHydrated, setIsPreviewHydrated] = useState(false); let metadataMarginClassName = 'mb-8'; if (isVideoType) { metadataMarginClassName = isCompactModalSpacing ? 'mb-3' : 'mb-4'; @@ -81,8 +122,234 @@ export function PostContentRaw({ isCompactModalSpacing ? 'mt-3 !typo-callout' : 'mt-4 !typo-callout', metadataMarginClassName, ); + const embedArticleTargetUrl = + post.permalink && isEmbeddableSiteTarget(post.permalink) + ? post.permalink + : null; + + const showArticlePreviewEmbed = + isPreviewHydrated && + !isVideoType && + post.type === PostType.Article && + embedArticleTargetUrl !== null; + + const [isArticlePreviewDismissed, setArticlePreviewDismissed] = + useState(false); + const [isArticlePreviewUnavailable, setArticlePreviewUnavailable] = + useState(false); + const [isMobilePreviewOpen, setMobilePreviewOpen] = useState(false); + const [isPreviewNarrow, setIsPreviewNarrow] = useState(false); + const [isFloatingPreviewVisible, setFloatingPreviewVisible] = useState(false); + const [isFloatingPreviewClosing, setFloatingPreviewClosing] = useState(false); + const [isFloatingPreviewActive, setFloatingPreviewActive] = useState(false); + const [isTabletPreviewToggling, setTabletPreviewToggling] = useState(false); + const previewLayoutRef = useRef(null); + const previewColumnRef = useRef(null); + const ignorePreviewResizeRef = useRef(false); + const resizeObserverResetTimeoutRef = useRef(); + const floatingPreviewCloseTimeoutRef = useRef(); + const floatingPreviewEnterFrameRef = useRef(); + + useEffect(() => { + setIsPreviewHydrated(true); + }, []); + + const evaluatePreviewWidth = useCallback((width: number) => { + setIsPreviewNarrow((prev) => { + if (!prev && width < PREVIEW_MIN_WIDTH) { + return true; + } + + if (prev && width >= PREVIEW_RESTORE_WIDTH) { + return false; + } + + return prev; + }); + }, []); + + useEffect(() => { + return () => { + if (!resizeObserverResetTimeoutRef.current) { + return; + } + + globalThis.clearTimeout(resizeObserverResetTimeoutRef.current); + }; + }, []); + + useEffect(() => { + return () => { + if (!floatingPreviewCloseTimeoutRef.current) { + return; + } + + globalThis.clearTimeout(floatingPreviewCloseTimeoutRef.current); + }; + }, []); + + useEffect(() => { + return () => { + if (!floatingPreviewEnterFrameRef.current) { + return; + } + + globalThis.cancelAnimationFrame(floatingPreviewEnterFrameRef.current); + }; + }, []); + + useEffect(() => { + if (floatingPreviewCloseTimeoutRef.current) { + globalThis.clearTimeout(floatingPreviewCloseTimeoutRef.current); + floatingPreviewCloseTimeoutRef.current = undefined; + } + if (floatingPreviewEnterFrameRef.current) { + globalThis.cancelAnimationFrame(floatingPreviewEnterFrameRef.current); + floatingPreviewEnterFrameRef.current = undefined; + } + setArticlePreviewDismissed(false); + setArticlePreviewUnavailable(false); + setMobilePreviewOpen(false); + setIsPreviewNarrow(false); + setFloatingPreviewVisible(false); + setFloatingPreviewClosing(false); + setFloatingPreviewActive(false); + setTabletPreviewToggling(false); + }, [post.id]); + + const showArticlePreviewColumn = + showArticlePreviewEmbed && !isArticlePreviewDismissed; + const isPreviewFloating = + isLaptop && showArticlePreviewColumn && isPreviewNarrow; + const shouldRenderFloatingPreview = + isFloatingPreviewVisible || isPreviewFloating; + const isPreviewActive = isTablet + ? showArticlePreviewColumn + : isMobilePreviewOpen; + + useEffect(() => { + if (floatingPreviewCloseTimeoutRef.current) { + globalThis.clearTimeout(floatingPreviewCloseTimeoutRef.current); + floatingPreviewCloseTimeoutRef.current = undefined; + } + if (floatingPreviewEnterFrameRef.current) { + globalThis.cancelAnimationFrame(floatingPreviewEnterFrameRef.current); + floatingPreviewEnterFrameRef.current = undefined; + } + + if (isPreviewFloating) { + setFloatingPreviewVisible(true); + setFloatingPreviewClosing(false); + setFloatingPreviewActive(false); + floatingPreviewEnterFrameRef.current = globalThis.requestAnimationFrame( + () => { + setFloatingPreviewActive(true); + }, + ); + return; + } + + if (!isFloatingPreviewVisible) { + return; + } + + setFloatingPreviewActive(false); + setFloatingPreviewClosing(true); + floatingPreviewCloseTimeoutRef.current = globalThis.setTimeout(() => { + setFloatingPreviewVisible(false); + setFloatingPreviewClosing(false); + setFloatingPreviewActive(false); + }, FLOATING_PREVIEW_ANIMATION_MS); + }, [isFloatingPreviewVisible, isPreviewFloating]); + + useEffect(() => { + const node = previewColumnRef.current; + + if (!isLaptop || !showArticlePreviewColumn || !node) { + setIsPreviewNarrow(false); + return; + } + + const observer = new ResizeObserver(([entry]) => { + if (ignorePreviewResizeRef.current) { + return; + } + + const width = entry.contentRect.width; + + if (width < 1) { + return; + } + + evaluatePreviewWidth(width); + }); + + observer.observe(node); + if (!ignorePreviewResizeRef.current) { + evaluatePreviewWidth(node.getBoundingClientRect().width); + } + + return () => observer.disconnect(); + }, [evaluatePreviewWidth, isLaptop, showArticlePreviewColumn]); + + const onToggleArticlePreview = useCallback(() => { + if (isTablet) { + const isOpeningPreview = isArticlePreviewDismissed; + let shouldForceFloatingOnOpen = false; + if (isOpeningPreview && isLaptop) { + const layoutWidth = + previewLayoutRef.current?.getBoundingClientRect().width; + if (layoutWidth && layoutWidth < PREVIEW_LAYOUT_MIN_WIDTH) { + setIsPreviewNarrow(true); + shouldForceFloatingOnOpen = true; + } + } + + if (isOpeningPreview) { + if (floatingPreviewCloseTimeoutRef.current) { + globalThis.clearTimeout(floatingPreviewCloseTimeoutRef.current); + floatingPreviewCloseTimeoutRef.current = undefined; + } + if (floatingPreviewEnterFrameRef.current) { + globalThis.cancelAnimationFrame(floatingPreviewEnterFrameRef.current); + floatingPreviewEnterFrameRef.current = undefined; + } + setFloatingPreviewVisible(false); + setFloatingPreviewClosing(false); + setFloatingPreviewActive(false); + } + ignorePreviewResizeRef.current = true; + if (resizeObserverResetTimeoutRef.current) { + globalThis.clearTimeout(resizeObserverResetTimeoutRef.current); + } + resizeObserverResetTimeoutRef.current = globalThis.setTimeout(() => { + ignorePreviewResizeRef.current = false; + setTabletPreviewToggling(false); + const width = previewColumnRef.current?.getBoundingClientRect().width; + if (!width || width < 1) { + setIsPreviewNarrow(false); + return; + } + evaluatePreviewWidth(width); + }, 350); + setArticlePreviewDismissed((currentState) => !currentState); + if (!shouldForceFloatingOnOpen) { + setIsPreviewNarrow(false); + } + setTabletPreviewToggling(true); + } else { + setMobilePreviewOpen((currentState) => !currentState); + } + }, [evaluatePreviewWidth, isArticlePreviewDismissed, isLaptop, isTablet]); + + const onPreviewUnavailable = useCallback(() => { + setArticlePreviewUnavailable(true); + setArticlePreviewDismissed(true); + setMobilePreviewOpen(false); + }, []); + const containerClass = classNames( - 'laptop:flex-row laptop:pb-0', + 'tablet:flex-row tablet:pb-0', className?.container, ); @@ -106,20 +373,156 @@ export function PostContentRaw({ onSendViewPost(post.id); }, [isVideoType, onSendViewPost, post.id, user?.id]); - const ArticleLink = ({ children, ...props }: ComponentProps<'a'>) => { - return ( - + - {children} - - ); - }; +
+
+ + {!isTablet && showArticlePreviewEmbed && ( +
+

+ + {title} + +

+ {post.clickbaitTitleDetected && } +
+ {isVideoType && ( + + )} + {post.summary && ( + + )} + + 0 && ( + + From{' '} + + {post.domain} + + + ) + } + /> + {!isVideoType && ( + + + + )} + {hasToc && ( + + )} + {showCodeSnippets && ( + + )} + + + ); + + const postWidgetsColumn = ( + + ); return ( - - -
- -

+
+ {postMainColumn} + {!isLaptop && postWidgetsColumn} +
+
+
+
- {title} -

- {post.clickbaitTitleDetected && } + {!isPreviewFloating && !isTabletPreviewToggling && ( + + )} +
- {isVideoType && ( - - )} - {post.summary && ( - - )} - - 0 && ( - - From{' '} - - {post.domain} - - - ) - } - /> - {!isVideoType && ( - - - + )} - {hasToc && ( - - )} - {showCodeSnippets && ( - setMobilePreviewOpen(false)} + className={{ + wrapper: 'h-[88vh]', + drawer: 'flex-1 !p-0', + }} + displayCloseButton + appendOnRoot + > + setMobilePreviewOpen(false)} + onPreviewUnavailable={onPreviewUnavailable} + forceUnavailable={isArticlePreviewUnavailable} + className="!flex" /> - )} -
-
- + + + ) : ( + <> + {postMainColumn} + {postWidgetsColumn} + + )}
); } diff --git a/packages/shared/src/components/sidebar/Sidebar.tsx b/packages/shared/src/components/sidebar/Sidebar.tsx index 347ed949036..7137265a3f0 100644 --- a/packages/shared/src/components/sidebar/Sidebar.tsx +++ b/packages/shared/src/components/sidebar/Sidebar.tsx @@ -5,15 +5,56 @@ import { useViewSize, ViewSize } from '../../hooks'; import { useFeatureTheme } from '../../hooks/utils/useFeatureTheme'; import { isExtension } from '../../lib/func'; +const chunkReloadSessionKey = 'sidebar_chunk_reload_attempted'; + +const isChunkLoadError = (error: unknown): boolean => { + if (!(error instanceof Error)) { + return false; + } + + return ( + error.name.includes('ChunkLoadError') || + error.message.includes('ChunkLoadError') || + error.message.includes('Failed to load chunk') + ); +}; + +const tryReloadOnChunkError = (error: unknown): never => { + if ( + typeof window !== 'undefined' && + isChunkLoadError(error) && + !window.sessionStorage.getItem(chunkReloadSessionKey) + ) { + window.sessionStorage.setItem(chunkReloadSessionKey, '1'); + window.location.reload(); + } + + throw error; +}; + +const clearChunkReloadFlag = (): void => { + if (typeof window === 'undefined') { + return; + } + + window.sessionStorage.removeItem(chunkReloadSessionKey); +}; + const SidebarTablet = dynamic(() => - import(/* webpackChunkName: "sidebarTablet" */ './SidebarTablet').then( - (mod) => mod.SidebarTablet, - ), + import('./SidebarTablet') + .then((mod) => { + clearChunkReloadFlag(); + return mod.SidebarTablet; + }) + .catch(tryReloadOnChunkError), ); const SidebarDesktop = dynamic(() => - import(/* webpackChunkName: "sidebarDesktop" */ './SidebarDesktop').then( - (mod) => mod.SidebarDesktop, - ), + import('./SidebarDesktop') + .then((mod) => { + clearChunkReloadFlag(); + return mod.SidebarDesktop; + }) + .catch(tryReloadOnChunkError), ); interface SidebarProps { diff --git a/packages/shared/src/features/extensionEmbed/EmbeddedBrowsingWebPrompt.module.css b/packages/shared/src/features/extensionEmbed/EmbeddedBrowsingWebPrompt.module.css new file mode 100644 index 00000000000..2a924b7665d --- /dev/null +++ b/packages/shared/src/features/extensionEmbed/EmbeddedBrowsingWebPrompt.module.css @@ -0,0 +1,119 @@ +.root { + position: relative; + display: flex; + flex: 1; + flex-direction: column; + align-items: stretch; + align-self: stretch; + width: 100%; + height: 100%; + min-height: 28rem; + overflow: visible; +} + +@media (max-width: 655px) { + .root { + align-items: center; + justify-content: center; + } +} + +/* Centers the card at the vertical midpoint of the user's screen */ +.stickyShell { + position: sticky; + top: 50vh; + transform: translateY(-50%); + z-index: 2; + display: flex; + width: 100%; + flex-shrink: 0; + align-items: center; + justify-content: center; + padding: 1.5rem; +} + +@media (max-width: 655px) { + .stickyShell { + position: static; + transform: none; + } +} + +.ambient { + pointer-events: none; + position: absolute; + inset: 0; + overflow: hidden; + opacity: 0.44; + background: + repeating-linear-gradient( + 120deg, + color-mix(in srgb, var(--theme-border-subtlest-tertiary) 12%, transparent) + 0, + color-mix(in srgb, var(--theme-border-subtlest-tertiary) 12%, transparent) + 0.75rem, + transparent 0.75rem, + transparent 1.5rem + ), + linear-gradient( + 180deg, + color-mix(in srgb, var(--theme-text-tertiary) 5%, transparent), + color-mix(in srgb, var(--theme-border-subtlest-tertiary) 6%, transparent) + ); + background-size: 190% 190%, 100% 100%; + animation: embeddedBrowsingStripeShift 42s linear infinite; + will-change: background-position; +} + +.ambient::before { + content: ''; + position: absolute; + inset: 0; + background: + repeating-linear-gradient( + 120deg, + transparent 0, + transparent 1.75rem, + color-mix(in srgb, var(--theme-text-tertiary) 4%, transparent) 1.75rem, + color-mix(in srgb, var(--theme-text-tertiary) 4%, transparent) 2.5rem + ); + background-size: 220% 220%; + animation: embeddedBrowsingStripeShiftReverse 58s linear infinite; + opacity: 0.24; +} + +@keyframes embeddedBrowsingStripeShift { + 0% { + background-position: 0% 0%, 50% 50%; + } + + 100% { + background-position: 220% 120%, 50% 50%; + } +} + +@keyframes embeddedBrowsingStripeShiftReverse { + 0% { + background-position: 220% 140%; + } + + 100% { + background-position: 0% 0%; + } +} + +@media (prefers-reduced-motion: reduce) { + .ambient, + .ambient::before { + animation: none; + } + + .ambient { + background-position: 50% 50%, 50% 50%; + opacity: 0.42; + } + + .ambient::before { + opacity: 0.18; + } +} diff --git a/packages/shared/src/features/extensionEmbed/EmbeddedBrowsingWebPrompt.tsx b/packages/shared/src/features/extensionEmbed/EmbeddedBrowsingWebPrompt.tsx new file mode 100644 index 00000000000..6a52c8a7e1a --- /dev/null +++ b/packages/shared/src/features/extensionEmbed/EmbeddedBrowsingWebPrompt.tsx @@ -0,0 +1,142 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '../../components/buttons/Button'; +import { + Typography, + TypographyColor, + TypographyTag, + TypographyType, +} from '../../components/typography/Typography'; +import { downloadBrowserExtension, isChrome } from '../../lib/constants'; +import styles from './EmbeddedBrowsingWebPrompt.module.css'; +import { ChromeIcon, EdgeIcon } from '../../components/icons'; + +export type EmbeddedBrowsingWebPromptProps = { + onEnablePreview?: () => void; + isPreviewUnavailable?: boolean; + unavailablePreviewUrl?: string; +}; + +/** + * Webapp fallback when the extension install id is unknown. Copy and structure + * should stay aligned with `packages/extension/src/frame/render.ts` + * (`renderPermissionPrompt`). + */ +export function EmbeddedBrowsingWebPrompt({ + onEnablePreview, + isPreviewUnavailable = false, + unavailablePreviewUrl, +}: EmbeddedBrowsingWebPromptProps): ReactElement { + const externalPreviewUrl = + unavailablePreviewUrl && unavailablePreviewUrl.length > 0 + ? unavailablePreviewUrl + : null; + const showUnavailableActions = isPreviewUnavailable && !!externalPreviewUrl; + + let primaryAction: ReactElement; + if (showUnavailableActions) { + primaryAction = ( + + ); + } else if (onEnablePreview) { + primaryAction = ( + + ); + } else { + const isChromeBrowser = isChrome(); + const BrowserIcon = isChromeBrowser ? ChromeIcon : EdgeIcon; + primaryAction = ( + + ); + } + + return ( +
+
+
+
+ + {isPreviewUnavailable + ? 'Preview not available' + : 'Enable embedded browsing'} + + + {isPreviewUnavailable + ? 'This site blocks embedded previews.' + : onEnablePreview + ? 'Let daily.dev load and preview sites inside the app. (Only affects embedded pages.)' + : 'Preview and open sites directly inside daily.dev. To use this feature, install the daily.dev browser extension.'} + +
+ {primaryAction} +
+ {isPreviewUnavailable && externalPreviewUrl ? ( + + {externalPreviewUrl} + + ) : null} + {isPreviewUnavailable ? ( + + Open the full page in a new tab to keep reading. + + ) : null} +
+
+
+ ); +} diff --git a/packages/shared/src/features/extensionEmbed/ExtensionSiteEmbed.tsx b/packages/shared/src/features/extensionEmbed/ExtensionSiteEmbed.tsx index c9b26c9f1b3..8f9e9b8c27d 100644 --- a/packages/shared/src/features/extensionEmbed/ExtensionSiteEmbed.tsx +++ b/packages/shared/src/features/extensionEmbed/ExtensionSiteEmbed.tsx @@ -1,5 +1,5 @@ import type { ReactElement, ReactNode } from 'react'; -import React from 'react'; +import React, { useEffect, useRef } from 'react'; import { useExtensionSiteEmbed } from './useExtensionSiteEmbed'; import type { UseExtensionSiteEmbedOptions, @@ -11,6 +11,8 @@ interface ExtensionSiteEmbedProps extends UseExtensionSiteEmbedOptions { permissionFrameTitle?: string; targetFrameTitle?: string; renderState?: (state: UseExtensionSiteEmbedResult) => ReactNode; + onStateChange?: (state: UseExtensionSiteEmbedResult) => void; + onTargetFrameLoad?: () => void; } const hiddenPermissionFrameClassName = @@ -22,12 +24,20 @@ export const ExtensionSiteEmbed = ({ permissionFrameTitle = 'Extension permission frame', targetFrameTitle = 'Embedded site', renderState, + onStateChange, + onTargetFrameLoad, ...options }: ExtensionSiteEmbedProps): ReactElement | null => { const state = useExtensionSiteEmbed(options); + const stateRef = useRef(state); + stateRef.current = state; const view = renderState?.(state); const hasAnyFrame = !!state.permissionFrameSrc || !!state.targetFrameSrc; + useEffect(() => { + onStateChange?.(stateRef.current); + }, [onStateChange, state.status, state.errorReason]); + if (!hasAnyFrame && !view) { return null; } @@ -56,6 +66,7 @@ export const ExtensionSiteEmbed = ({ src={state.targetFrameSrc} title={targetFrameTitle} className={className} + onLoad={onTargetFrameLoad} /> ) : null}
diff --git a/packages/shared/src/features/extensionEmbed/common.ts b/packages/shared/src/features/extensionEmbed/common.ts index 79f280c02dc..d9b8c359d06 100644 --- a/packages/shared/src/features/extensionEmbed/common.ts +++ b/packages/shared/src/features/extensionEmbed/common.ts @@ -29,6 +29,7 @@ export type ExtensionSiteEmbedFrameErrorReason = | 'permission-denied' | 'permission-request-failed' | 'enable-frame-embedding-failed' + | 'preview-unavailable' | 'missing-target' | 'invalid-target' | 'unsupported-target-protocol' @@ -117,5 +118,9 @@ export const getExtensionSiteEmbedErrorMessage = ({ return 'The extension could not prepare this tab for embedding.'; } + if (reason === 'preview-unavailable') { + return 'Preview not available for this site.'; + } + return 'The extension could not prepare the embedded site.'; }; diff --git a/packages/shared/src/features/extensionEmbed/extensionEmbedMessaging.ts b/packages/shared/src/features/extensionEmbed/extensionEmbedMessaging.ts index 6f19bb50998..fe202822dba 100644 --- a/packages/shared/src/features/extensionEmbed/extensionEmbedMessaging.ts +++ b/packages/shared/src/features/extensionEmbed/extensionEmbedMessaging.ts @@ -48,7 +48,7 @@ type HandleExtensionSiteEmbedMessageOptions = { onEmbeddingReady: () => void; onReloadRequested: () => void; onMissingPermission: () => void; - onError: (message: string) => void; + onError: (payload: { message: string; reason?: string }) => void; }; export const handleExtensionSiteEmbedMessage = ({ @@ -102,10 +102,11 @@ export const handleExtensionSiteEmbedMessage = ({ return; } - onError( - getExtensionSiteEmbedErrorMessage({ + onError({ + message: getExtensionSiteEmbedErrorMessage({ reason: message.reason, error: message.error, }), - ); + reason: message.reason, + }); }; diff --git a/packages/shared/src/features/extensionEmbed/getBrowserExtensionInstallId.ts b/packages/shared/src/features/extensionEmbed/getBrowserExtensionInstallId.ts new file mode 100644 index 00000000000..09254fc3591 --- /dev/null +++ b/packages/shared/src/features/extensionEmbed/getBrowserExtensionInstallId.ts @@ -0,0 +1,18 @@ +/** + * Resolves the installed browser extension id for embedded article previews. + * - In the extension new tab / extension surfaces, `window.location.origin` is + * `chrome-extension://`. + * - On the webapp, set `NEXT_PUBLIC_DAILY_EXTENSION_ID` to the Chrome Web Store + * extension id so iframe URLs can point at `frame.html`. + */ +export const getBrowserExtensionInstallId = (): string | null => { + if (typeof window !== 'undefined') { + const match = /^chrome-extension:\/\/([^/]+)/.exec(window.location.origin); + if (match?.[1]) { + return match[1]; + } + } + + const fromEnv = process.env.NEXT_PUBLIC_DAILY_EXTENSION_ID?.trim(); + return fromEnv || null; +}; diff --git a/packages/shared/src/features/extensionEmbed/useExtensionSiteEmbed.ts b/packages/shared/src/features/extensionEmbed/useExtensionSiteEmbed.ts index 5eebb061160..38eb156732c 100644 --- a/packages/shared/src/features/extensionEmbed/useExtensionSiteEmbed.ts +++ b/packages/shared/src/features/extensionEmbed/useExtensionSiteEmbed.ts @@ -34,6 +34,7 @@ export interface UseExtensionSiteEmbedResult { showTargetFrame: boolean; status: ExtensionSiteEmbedStatus; error: string | null; + errorReason: string | null; isTargetValid: boolean; reset: () => void; } @@ -49,6 +50,7 @@ export const useExtensionSiteEmbed = ({ const [frameMode, setFrameMode] = useState('permission-check'); const [status, setStatus] = useState('idle'); const [error, setError] = useState(null); + const [errorReason, setErrorReason] = useState(null); const permissionFrameRef = useRef(null); const trimmedExtensionId = extensionId?.trim() ?? ''; const trimmedTargetUrl = targetUrl.trim(); @@ -86,6 +88,7 @@ export const useExtensionSiteEmbed = ({ setFrameMode('permission-check'); setStatus(nextStatus); setError(nextError); + setErrorReason(null); }, [postDisableMessage, stopReconnectLoop], ); @@ -136,27 +139,32 @@ export const useExtensionSiteEmbed = ({ onPermissionsReady: () => { setStatus('preparing-tab'); setError(null); + setErrorReason(null); }, onEmbeddingReady: () => { stopReconnectLoop(); setFrameMode('target-embed'); setStatus('ready'); setError(null); + setErrorReason(null); }, onReloadRequested: () => { setStatus('reloading-extension'); setError(null); + setErrorReason(null); startReconnectLoop(); }, onMissingPermission: () => { stopReconnectLoop(); setStatus('permission-required'); setError(null); + setErrorReason('missing-permission'); }, - onError: (nextError) => { + onError: ({ message, reason }) => { stopReconnectLoop(); setStatus('error'); - setError(nextError); + setError(message); + setErrorReason(reason ?? null); }, }); }; @@ -225,6 +233,7 @@ export const useExtensionSiteEmbed = ({ showTargetFrame: !!targetFrameSrc, status, error, + errorReason, isTargetValid, reset, }; diff --git a/packages/webapp/next.config.ts b/packages/webapp/next.config.ts index c8e35a99212..159bb6bf865 100644 --- a/packages/webapp/next.config.ts +++ b/packages/webapp/next.config.ts @@ -109,6 +109,14 @@ const nextConfig: NextConfig = { }, env: { CURRENT_VERSION: version, + // If both CHROME and EDGE IDs are present (e.g. in a shared CI environment), + // Chrome silently takes precedence. Since the ID is baked into the build and + // getBrowserExtensionInstallId cannot distinguish the user's browser, this is expected. + NEXT_PUBLIC_DAILY_EXTENSION_ID: + process.env.NEXT_PUBLIC_DAILY_EXTENSION_ID || + process.env.EXTENSION_ID_CHROME || + process.env.EXTENSION_ID_EDGE || + '', }, assetPrefix: process.env.NEXT_PUBLIC_CDN_ASSET_PREFIX, rewrites: async () => {