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 (
+
+
+ {shouldShowPreviewHeader ? (
+
+

+
+
+
+
+ ) : null}
+ {previewContent}
+ {shouldShowPrompt && !shouldShowUnavailablePrompt ? (
+
+ ) : null}
+
+
+ );
+}
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="Preview article"
+ aria-label="Preview article"
+ />
+ )}
+
+
+
+ {title}
+
+
+ {post.clickbaitTitleDetected &&
}
+
+ {isVideoType && (
+
+ )}
+ {post.summary && (
+
+ )}
+
+ 0 && (
+
+ From{' '}
+
+ {post.domain}
+
+
+ )
+ }
+ />
+ {!isVideoType && (
+
+
+
+ )}
+ {hasToc && (
+
+ )}
+ {showCodeSnippets && (
+
+ )}
+
+
+ );
+
+ const postWidgetsColumn = (
+
+ );
return (
-
-
-
-
-
+
+ {postMainColumn}
+ {!isLaptop && postWidgetsColumn}
+
+
+ : }
+ title={
+ isPreviewActive
+ ? 'Hide inline article preview'
+ : 'Show inline article preview'
+ }
+ aria-label={
+ isPreviewActive
+ ? 'Hide inline article preview'
+ : 'Show inline article preview'
+ }
+ />
+
+
-
{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 = (
+ }
+ >
+ {isChromeBrowser ? 'Install Chrome extension' : 'Install Edge extension'}
+
+ );
+ }
+
+ 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 () => {