diff --git a/packages/extension/src/background/index.ts b/packages/extension/src/background/index.ts index 00d5bf2cc00..c2f5f2e968f 100644 --- a/packages/extension/src/background/index.ts +++ b/packages/extension/src/background/index.ts @@ -11,6 +11,8 @@ import { install, uninstall } from '@dailydotdev/shared/src/lib/constants'; import { BOOT_LOCAL_KEY } from '@dailydotdev/shared/src/contexts/common'; import { ExtensionMessageType } from '@dailydotdev/shared/src/lib/extension'; import { storageWrapper as storage } from '@dailydotdev/shared/src/lib/storageWrapper'; +import { frameEmbeddingPermissionBridgeTiming } from '@dailydotdev/shared/src/features/extensionEmbed/pagePermissionBridge'; +import type { PermissionGrantResponse } from '@dailydotdev/shared/src/features/extensionEmbed/pagePermissionBridge'; import { getContentScriptPermissionAndRegister, registerEmbedTargetReadyContentScript, @@ -20,6 +22,7 @@ import { disableFrameEmbeddingForTab, enableFrameEmbeddingForTab, } from '../lib/frameEmbedding'; +import { requestFrameEmbeddingPermissions } from '../lib/frameEmbeddingPermissions'; type ChromeRuntimeMessageSender = Runtime.MessageSender; type ChromeSendResponse = (response?: unknown) => void; @@ -185,6 +188,48 @@ async function handleMessages( return disableFrameEmbeddingForTab(tabId); } + if (message.type === ExtensionMessageType.PingFrameEmbeddingReady) { + // Lightweight liveness check used by the content script to detect when + // the service worker is back online after the post-grant runtime.reload. + return { ready: true }; + } + + if (message.type === ExtensionMessageType.RequestFrameEmbeddingPermissions) { + // Relay path so the daily.dev page can drive chrome.permissions.request + // without losing the user gesture. chrome.permissions.request is only + // callable from extension pages, so the content script forwards here and + // the user gesture from the originating click is preserved via the + // Runtime.MessageSender.userGesture flag. + try { + const granted = await requestFrameEmbeddingPermissions(); + if (granted) { + // Chromium needs a fresh extension context to pick up the just-granted + // optional host permission for declarativeNetRequest — without it the + // DNR session-rule call from frame.html hangs. Schedule the reload + // on the next tick so the response can be delivered first; the + // content script then polls until the new worker is up before + // resolving the grant Promise on the page. + globalThis.setTimeout(() => { + browser.runtime.reload(); + }, frameEmbeddingPermissionBridgeTiming.reloadDelayMs); + } + const response: PermissionGrantResponse = { + granted, + willReload: granted, + }; + return response; + } catch (error) { + const response: PermissionGrantResponse = { + granted: false, + error: + error instanceof Error + ? error.message + : 'Failed to request frame embedding permissions', + }; + return response; + } + } + await getContentScriptPermissionAndRegister(); if (message.type === ExtensionMessageType.ContentLoaded) { diff --git a/packages/extension/src/ping/index.ts b/packages/extension/src/ping/index.ts index e57fd92a68f..4c71ef98909 100644 --- a/packages/extension/src/ping/index.ts +++ b/packages/extension/src/ping/index.ts @@ -11,10 +11,95 @@ // The webapp reads the marker to decide install-prompt vs. embed flow. The // frame.html iframe still owns the permission-granted check. +import browser from 'webextension-polyfill'; +import { ExtensionMessageType } from '@dailydotdev/shared/src/lib/extension'; +import { + frameEmbeddingPermissionBridgeTiming, + pagePermissionBridgeRequestEvent, + pagePermissionBridgeResultEvent, +} from '@dailydotdev/shared/src/features/extensionEmbed/pagePermissionBridge'; +import type { + PagePermissionBridgeResult, + PermissionGrantResponse, +} from '@dailydotdev/shared/src/features/extensionEmbed/pagePermissionBridge'; + const INSTALL_MARKER = 'dailyExtensionInstalled'; const ID_MARKER = 'dailyExtensionId'; const MESSAGE_SOURCE = 'daily-extension-ping'; +// chrome.permissions.request is only callable from extension pages. We relay +// the page's click — while the user gesture is still active in the same task +// — to the background service worker, which calls request() on the page's +// behalf. The synchronous dispatchEvent on the page side is what keeps the +// gesture alive across the message boundary. +// +// After the grant the background schedules runtime.reload() so the new +// optional host permission is picked up for declarativeNetRequest. We hold +// the result event until the new service worker responds to a ping, so the +// page only mounts the reader iframe once DNR rules can be installed. +const sleep = (ms: number): Promise => + new Promise((resolve) => { + globalThis.setTimeout(resolve, ms); + }); + +const waitForExtensionReady = async (): Promise => { + const { pingDelayBeforeFirstAttemptMs, pingRetryDelayMs, pingMaxAttempts } = + frameEmbeddingPermissionBridgeTiming; + + await sleep(pingDelayBeforeFirstAttemptMs); + + for (let attempt = 0; attempt < pingMaxAttempts; attempt += 1) { + try { + // eslint-disable-next-line no-await-in-loop -- sequential polling is the point + const response = (await browser.runtime.sendMessage({ + type: ExtensionMessageType.PingFrameEmbeddingReady, + })) as { ready?: boolean } | undefined; + if (response?.ready) { + return; + } + } catch { + // sendMessage rejects while the service worker is mid-reload; retry. + } + // eslint-disable-next-line no-await-in-loop -- sequential backoff between pings + await sleep(pingRetryDelayMs); + } +}; + +window.addEventListener(pagePermissionBridgeRequestEvent, () => { + const dispatchResult = (result: PagePermissionBridgeResult): void => { + window.dispatchEvent( + new CustomEvent( + pagePermissionBridgeResultEvent, + { detail: result }, + ), + ); + }; + + browser.runtime + .sendMessage({ + type: ExtensionMessageType.RequestFrameEmbeddingPermissions, + }) + .then(async (response) => { + const typed = response as PermissionGrantResponse | undefined; + if (typed?.granted && typed?.willReload) { + await waitForExtensionReady(); + } + dispatchResult({ + granted: !!typed?.granted, + error: typed?.error, + }); + }) + .catch((error: unknown) => { + dispatchResult({ + granted: false, + error: + error instanceof Error + ? error.message + : 'Failed to request frame embedding permissions', + }); + }); +}); + const marker = document.documentElement; if (marker && !marker.dataset[INSTALL_MARKER]) { diff --git a/packages/shared/src/components/Feed.tsx b/packages/shared/src/components/Feed.tsx index 55fffced7b2..00edae2a092 100644 --- a/packages/shared/src/components/Feed.tsx +++ b/packages/shared/src/components/Feed.tsx @@ -147,13 +147,6 @@ const SocialTwitterPostModal = dynamic( ), ); -const ReaderPostModal = dynamic( - () => - import( - /* webpackChunkName: "readerPostModal" */ './modals/ReaderPostModal' - ), -); - const BriefCardFeed = dynamic( () => import( @@ -544,20 +537,8 @@ export default function Feed({ if (!selectedPost) { return undefined; } - const readerEligibleTypes = new Set([ - PostType.Article, - PostType.Digest, - PostType.VideoYouTube, - ]); - if ( - isReaderModalFeatureReady && - isReaderModalOn && - readerEligibleTypes.has(selectedPost.type) - ) { - return ReaderPostModal; - } return PostModalMap[selectedPost.type]; - }, [selectedPost, isReaderModalFeatureReady, isReaderModalOn]); + }, [selectedPost]); if (!loadedSettings || isFallback) { return <>; diff --git a/packages/shared/src/components/cards/common/PostCardHeader.tsx b/packages/shared/src/components/cards/common/PostCardHeader.tsx index 8a6eccb4690..56d7b0b1b07 100644 --- a/packages/shared/src/components/cards/common/PostCardHeader.tsx +++ b/packages/shared/src/components/cards/common/PostCardHeader.tsx @@ -9,13 +9,18 @@ import { isSourceUserSource } from '../../../graphql/sources'; import { ReadArticleButton, getReadPostButtonIcon } from './ReadArticleButton'; import { getGroupedHoverContainer } from './common'; import { useBookmarkProvider, useFeedPreviewMode } from '../../../hooks'; +import { useReaderInstallPromptGate } from '../../../hooks/useReaderInstallPromptGate'; +import { useLegacyPostLayoutOptOut } from '../../post/reader/hooks/useLegacyPostLayoutOptOut'; import type { Post } from '../../../graphql/posts'; import { getReadPostButtonText, isInternalReadType, isSharedPostSquadPost, + PostType, } from '../../../graphql/posts'; import { ButtonVariant } from '../../buttons/Button'; +import { useReaderModalEligibility } from '../../post/reader/hooks/useReaderModalEligibility'; +import { EarthIcon } from '../../icons'; import type { FlagProps } from './FeedItemContainer'; import { BookmakProviderHeader, @@ -68,6 +73,33 @@ export const PostCardHeader = ({ const { highlightBookmarkedPost } = useBookmarkProvider({ bookmarked: (post.bookmarked && !showFeedback) ?? false, }); + const { isEligible: isReaderEligible, isReaderModalEnabled } = + useReaderModalEligibility(); + const { isOptedOut: isLegacyLayoutOptedOut } = useLegacyPostLayoutOptOut(); + // Variant: clicking Read post on the card opens the reader preview inside + // daily.dev (globe-icon CTA) instead of navigating to the external article. + // Once a user opts out (via the install-prompt overlay) the card reverts + // to the classic external Read post button. + const isReaderVariant = + isReaderEligible && + isReaderModalEnabled && + post.type === PostType.Article && + !isLegacyLayoutOptedOut; + const { onReadClick: onReaderInstallGateClick } = + useReaderInstallPromptGate(post); + + const handleReadArticleClick = (event: React.MouseEvent) => { + if (onReaderInstallGateClick(event)) { + return; + } + onReadArticleClick?.(event); + }; + + const readPostIcon = isReaderVariant ? ( + + ) : ( + getReadPostButtonIcon(post) + ); const articleLink = useMemo(() => { if (post.sharedPost) { @@ -121,9 +153,9 @@ export const PostCardHeader = ({ className="mr-2" variant={ButtonVariant.Primary} href={articleLink ?? ''} - onClick={onReadArticleClick} + onClick={handleReadArticleClick} openNewTab={openNewTab} - icon={getReadPostButtonIcon(post)} + icon={readPostIcon} /> ))} diff --git a/packages/shared/src/components/modals/ReaderInstallPromptModal.tsx b/packages/shared/src/components/modals/ReaderInstallPromptModal.tsx new file mode 100644 index 00000000000..6e20b6d2dbd --- /dev/null +++ b/packages/shared/src/components/modals/ReaderInstallPromptModal.tsx @@ -0,0 +1,576 @@ +import type { MouseEvent, ReactElement } from 'react'; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import classNames from 'classnames'; +import { Modal } from './common/Modal'; +import type { LazyModalCommonProps } from './common/Modal'; +import { LazyModal, ModalKind, ModalSize } from './common/types'; +import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; +import { + Typography, + TypographyColor, + TypographyTag, + TypographyType, +} from '../typography/Typography'; +import { + ArrowIcon, + ChromeIcon, + EdgeIcon, + MiniCloseIcon as CloseIcon, + RefreshIcon, +} from '../icons'; +import { downloadBrowserExtension, isChrome } from '../../lib/constants'; +import { apiUrl } from '../../lib/config'; +import { useLogContext } from '../../contexts/LogContext'; +import { LogEvent, TargetId } from '../../lib/log'; +import { useLazyModal } from '../../hooks/useLazyModal'; +import type { Post } from '../../graphql/posts'; +import styles from './BasePostModal.module.css'; +import { useLegacyPostLayoutOptOut } from '../post/reader/hooks/useLegacyPostLayoutOptOut'; +import { useSettingsContext } from '../../contexts/SettingsContext'; +import { + detectBrowserExtensionInstalled, + isBrowserExtensionInstalled, + useIsBrowserExtensionInstalled, +} from '../../features/extensionEmbed/useIsBrowserExtensionInstalled'; +import { getBrowserExtensionInstallId } from '../../features/extensionEmbed/getBrowserExtensionInstallId'; +import { ExtensionSiteEmbed } from '../../features/extensionEmbed/ExtensionSiteEmbed'; +import type { ExtensionSiteEmbedStatus } from '../../features/extensionEmbed/common'; +import { isEmbeddableSiteTarget } from '../../features/extensionEmbed/common'; +import { requestFrameEmbeddingPermissionFromPage } from '../../features/extensionEmbed/pagePermissionBridge'; + +interface ReaderInstallPromptModalProps extends LazyModalCommonProps { + post: Post; + /** + * Close handler for the surface that owns the Read post click (e.g. the + * classic post modal). Fired alongside the prompt's own dismiss paths + * so closing the prompt tears down the surface behind it instead of + * silently reverting to it. + */ + onCloseParent?: () => void; +} + +const useFaviconSrc = (host: string | undefined): string | undefined => { + return useMemo(() => { + if (!host) { + return undefined; + } + const pixelRatio = globalThis?.window?.devicePixelRatio ?? 1; + const iconSize = Math.max(Math.round(16 * pixelRatio), 96); + return `${apiUrl}/icon?url=${encodeURIComponent(host)}&size=${iconSize}`; + }, [host]); +}; + +const getPostHost = (post: Post): string | undefined => { + if (post.domain) { + return post.domain; + } + if (!post.permalink) { + return undefined; + } + try { + return new URL(post.permalink).hostname; + } catch { + return undefined; + } +}; + +// Show the article's source host (e.g. `thenewstack.io`) in the mock browser +// URL bar instead of the daily.dev redirector permalink — the redirector +// reads like an internal API URL and obscures which site the user is about +// to land on. +const getDisplayUrl = (host: string | undefined): string => host || 'daily.dev'; + +interface BrowserChromeProps { + faviconSrc: string | undefined; + displayUrl: string; + onClose: (event: MouseEvent) => void; +} + +function BrowserChrome({ + faviconSrc, + displayUrl, + onClose, +}: BrowserChromeProps): ReactElement { + return ( +
+
+ + + + + + + + + +
+
+ {faviconSrc && ( + + )} + + {displayUrl} + +
+
+ ); +} + +const ARTICLE_PARAGRAPHS = ['p1', 'p2', 'p3', 'p4', 'p5', 'p6'] as const; + +// Explicit light hex colors keep the backdrop reading like a real article page +// behind glass, even when the rest of the app is in dark mode. Semantic tokens +// would invert the surface in dark mode and break the "webpage preview" cue. +function BlurredArticleBackdrop(): ReactElement { + return ( +
+
+
+
+ + + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {ARTICLE_PARAGRAPHS.map((id) => ( +
+
+
+
+
+
+ ))} +
+
+
+
+ ); +} + +function ReaderInstallPromptModal({ + post, + isOpen, + onRequestClose, + onCloseParent, +}: ReaderInstallPromptModalProps): ReactElement { + const { logEvent } = useLogContext(); + const { openModal, closeModal } = useLazyModal(); + const { optOut } = useLegacyPostLayoutOptOut(); + const { updateFlag } = useSettingsContext(); + // Detecting the extension when the modal opens combines three signals: + // 1. The synchronous `` marker + // stamped by the extension's content script. + // 2. A `chrome-extension:///...` preload probe (same strategy as + // `PostArticlePreviewEmbed`) for older builds where the marker is + // stamped late or not at all. + // 3. A short poll on the marker to catch content scripts that inject + // after React has mounted the modal. + const { isInstalled: initialMarker } = useIsBrowserExtensionInstalled(); + const [hasInstalledExtension, setHasInstalledExtension] = + useState(initialMarker); + useEffect(() => { + if (hasInstalledExtension) { + return undefined; + } + + let cancelled = false; + const markAsInstalled = () => { + if (!cancelled) { + setHasInstalledExtension(true); + } + }; + + const pollIntervalId = globalThis.setInterval(() => { + if (isBrowserExtensionInstalled()) { + markAsInstalled(); + } + }, 200); + const pollTimeoutId = globalThis.setTimeout(() => { + globalThis.clearInterval(pollIntervalId); + }, 3000); + + const extensionId = getBrowserExtensionInstallId(); + if (extensionId) { + detectBrowserExtensionInstalled(extensionId).then((installed) => { + if (installed) { + markAsInstalled(); + } + }); + } + + return () => { + cancelled = true; + globalThis.clearInterval(pollIntervalId); + globalThis.clearTimeout(pollTimeoutId); + }; + }, [hasInstalledExtension]); + const isChromeBrowser = isChrome(); + const BrowserIcon = isChromeBrowser ? ChromeIcon : EdgeIcon; + const installButtonLabel = isChromeBrowser + ? 'Install Chrome extension' + : 'Install Edge extension'; + const browser = isChromeBrowser ? 'chrome' : 'edge'; + const host = getPostHost(post); + const faviconSrc = useFaviconSrc(host); + const displayUrl = getDisplayUrl(host); + + useEffect(() => { + logEvent({ + event_name: LogEvent.ImpressionReaderInstallPrompt, + extra: JSON.stringify({ browser, post_id: post.id }), + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const onInstallClick = () => { + logEvent({ + event_name: LogEvent.ClickReaderInstallExtension, + extra: JSON.stringify({ browser, post_id: post.id }), + }); + }; + + // Permission flow: when the extension is installed we ask the user to grant + // (or confirm) the embedded-browsing permission inline, then forward to the + // reader modal so it opens already past the permission gate. + const [embedExtensionId] = useState(() => getBrowserExtensionInstallId()); + const [isPreparingReader, setIsPreparingReader] = useState(false); + const [isRequestingPermission, setIsRequestingPermission] = useState(false); + const [embedStatus, setEmbedStatus] = + useState('idle'); + const hasOpenedReaderRef = useRef(false); + const isTargetEmbeddable = isEmbeddableSiteTarget(post.permalink ?? ''); + const canRequestPermissions = + !!embedExtensionId && isTargetEmbeddable && hasInstalledExtension; + + const openReaderPreview = useCallback(() => { + if (hasOpenedReaderRef.current) { + return; + } + hasOpenedReaderRef.current = true; + closeModal(); + // Forward the parent-close callback so dismissing the reader preview + // also tears down the surface that originally opened the install prompt + // (e.g. the classic post modal behind it). + openModal({ + type: LazyModal.ReaderPreview, + props: { post, onCloseParent }, + }); + }, [closeModal, onCloseParent, openModal, post]); + + // `preparing-tab` arrives once `PermissionsReady` has fired (the user has + // either just granted access or had it from a previous session). Transition + // here so the embed never visibly mounts the target frame inside the install + // prompt — the reader modal owns that surface. + useEffect(() => { + if (!isPreparingReader) { + return; + } + if (embedStatus === 'preparing-tab' || embedStatus === 'ready') { + openReaderPreview(); + } + }, [embedStatus, isPreparingReader, openReaderPreview]); + + const onPreviewClick = (event: MouseEvent) => { + event.preventDefault(); + logEvent({ + event_name: LogEvent.ClickReaderInstallPreview, + extra: JSON.stringify({ browser, post_id: post.id }), + }); + + // Targets the extension can't embed (non-http(s)) or surfaces without a + // resolvable extension id skip the inline permission step — the reader + // modal falls back to its own classic flow. + if (!canRequestPermissions) { + openReaderPreview(); + return; + } + + // Drive chrome.permissions.request through the installed content script. + // The dispatchEvent inside the helper must happen synchronously here so + // the click's user activation is still alive when the content script + // forwards the request to the background. The Promise resolves later + // with the OS prompt outcome. + setIsRequestingPermission(true); + const permissionPromise = requestFrameEmbeddingPermissionFromPage(); + + // Persist that the user engaged with the install prompt so subsequent + // reads bypass it and open the reader modal directly. Only set on the + // explicit accept path — dismissing the modal leaves the flag untouched. + updateFlag('readerInstallPromptAcknowledged', true); + + permissionPromise.then(({ granted }) => { + setIsRequestingPermission(false); + if (granted) { + setIsPreparingReader(true); + } + }); + }; + + const onPermissionFrameOptOut = () => { + optOut(TargetId.ReaderPermissionPrompt); + logEvent({ + event_name: LogEvent.ClickReaderInstallSkip, + extra: JSON.stringify({ browser, post_id: post.id }), + }); + if (post.permalink) { + globalThis.window?.open(post.permalink, '_blank', 'noopener,noreferrer'); + } + onRequestClose({} as MouseEvent); + }; + + // "Don't ask again" persists the opt-out so the gate hook bypasses the prompt + // on future Read clicks and falls back to the default new-tab navigation. + const onSkipClick = (event: MouseEvent) => { + event.preventDefault(); + optOut(TargetId.ReaderInstallPrompt); + logEvent({ + event_name: LogEvent.ClickReaderInstallSkip, + extra: JSON.stringify({ browser, post_id: post.id }), + }); + if (post.permalink) { + globalThis.window?.open(post.permalink, '_blank', 'noopener,noreferrer'); + } + onRequestClose(event); + }; + + // The install prompt is an intermediate step — dismissing it (X / overlay / + // ESC) only closes the prompt itself. `onCloseParent` is reserved for the + // reader preview close so the user doesn't lose the surface they were on + // unless they actually entered the reader and chose to exit. + + return ( + +
+ +
+ {isPreparingReader && canRequestPermissions && embedExtensionId ? ( +
+ setEmbedStatus(state.status)} + onOptOutRequested={onPermissionFrameOptOut} + /> +
+ ) : ( + <> + +
+
+ + Read it right here. + + + {hasInstalledExtension + ? 'Open the article inside daily.dev with the discussion right next to it.' + : 'Try the reader on this article — install the extension to keep it that way for every link.'} + +
+ {hasInstalledExtension ? ( + + ) : ( + <> + + + + )} +
+ + + OR + + +
+ +
+
+
+ + )} +
+
+
+ ); +} + +export default ReaderInstallPromptModal; diff --git a/packages/shared/src/components/modals/ReaderPostModal.tsx b/packages/shared/src/components/modals/ReaderPostModal.tsx index 85223f22e40..ade3be6ab9b 100644 --- a/packages/shared/src/components/modals/ReaderPostModal.tsx +++ b/packages/shared/src/components/modals/ReaderPostModal.tsx @@ -68,7 +68,7 @@ export default function ReaderPostModal({ overlayClassName="post-modal-overlay bg-overlay-quaternary-onion" className={classNames( className, - 'reader-post-modal !mx-0 h-full max-h-screen !max-w-full !bg-background-default focus:outline-none tablet:!mx-auto tablet:!max-w-[min(100vw-1rem,100rem)] laptop:!mb-2 laptop:!mt-2 laptop:h-[calc(100vh-1rem)] laptop:max-h-[calc(100vh-1rem)] laptop:overflow-hidden', + 'reader-post-modal !mx-0 h-full max-h-screen !w-full !max-w-full !bg-background-default focus:outline-none tablet:!mx-auto tablet:!w-[min(96vw,100rem)] tablet:!max-w-[min(96vw,100rem)] laptop:!mb-2 laptop:!mt-2 laptop:h-[calc(100vh-1rem)] laptop:max-h-[calc(100vh-1rem)] laptop:overflow-hidden', '!overscroll-y-auto', )} > diff --git a/packages/shared/src/components/modals/ReaderPreviewLazyModal.tsx b/packages/shared/src/components/modals/ReaderPreviewLazyModal.tsx new file mode 100644 index 00000000000..29e268a944f --- /dev/null +++ b/packages/shared/src/components/modals/ReaderPreviewLazyModal.tsx @@ -0,0 +1,44 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import type { LazyModalCommonProps } from './common/Modal'; +import type { Post } from '../../graphql/posts'; +import { PostPosition } from '../../hooks/usePostModalNavigation'; +import ReaderPostModal from './ReaderPostModal'; + +interface ReaderPreviewLazyModalProps extends LazyModalCommonProps { + post: Post; + /** + * Close handler for the surface that owns the original Read post click + * (e.g. the classic post modal). Forwarded from the install prompt so + * closing the reader preview also tears down the underlying surface + * instead of bouncing the user back to the post modal they were trying + * to leave. + */ + onCloseParent?: () => void; +} + +function ReaderPreviewLazyModal({ + post, + isOpen, + onRequestClose, + onCloseParent, +}: ReaderPreviewLazyModalProps): ReactElement { + const onRequestCloseWithParent: typeof onRequestClose = (event) => { + onRequestClose(event); + onCloseParent?.(); + }; + + return ( + undefined} + onNextPost={() => undefined} + /> + ); +} + +export default ReaderPreviewLazyModal; diff --git a/packages/shared/src/components/modals/common.tsx b/packages/shared/src/components/modals/common.tsx index 73d0b0c45b8..57434ea3e6c 100644 --- a/packages/shared/src/components/modals/common.tsx +++ b/packages/shared/src/components/modals/common.tsx @@ -494,6 +494,20 @@ const IntroQuestModal = dynamic( () => import(/* webpackChunkName: "introQuestModal" */ './IntroQuestModal'), ); +const ReaderInstallPromptModal = dynamic( + () => + import( + /* webpackChunkName: "readerInstallPromptModal" */ './ReaderInstallPromptModal' + ), +); + +const ReaderPreviewLazyModal = dynamic( + () => + import( + /* webpackChunkName: "readerPreviewLazyModal" */ './ReaderPreviewLazyModal' + ), +); + export const modals = { [LazyModal.SquadMember]: SquadMemberModal, [LazyModal.UpvotedPopup]: UpvotedPopupModal, @@ -574,6 +588,8 @@ export const modals = { [LazyModal.CompareAchievements]: CompareAchievementsModal, [LazyModal.AchievementShowcase]: AchievementShowcaseModal, [LazyModal.IntroQuests]: IntroQuestModal, + [LazyModal.ReaderInstallPrompt]: ReaderInstallPromptModal, + [LazyModal.ReaderPreview]: ReaderPreviewLazyModal, }; type GetComponentProps = T extends diff --git a/packages/shared/src/components/modals/common/types.ts b/packages/shared/src/components/modals/common/types.ts index 8f8add1ee11..97e727101fd 100644 --- a/packages/shared/src/components/modals/common/types.ts +++ b/packages/shared/src/components/modals/common/types.ts @@ -104,6 +104,8 @@ export enum LazyModal { CompareAchievements = 'compareAchievements', AchievementShowcase = 'achievementShowcase', IntroQuests = 'introQuests', + ReaderInstallPrompt = 'readerInstallPrompt', + ReaderPreview = 'readerPreview', } export type ModalTabItem = { diff --git a/packages/shared/src/components/post/PostArticlePreviewEmbed.tsx b/packages/shared/src/components/post/PostArticlePreviewEmbed.tsx index 7a084634162..37be4c55a52 100644 --- a/packages/shared/src/components/post/PostArticlePreviewEmbed.tsx +++ b/packages/shared/src/components/post/PostArticlePreviewEmbed.tsx @@ -527,7 +527,7 @@ export function PostArticlePreviewEmbed({ aria-label="Article preview" >
-
+
{leftHeaderActions ? (
diff --git a/packages/shared/src/components/post/PostContent.tsx b/packages/shared/src/components/post/PostContent.tsx index ecb3cb2fa5b..ccc3bac80da 100644 --- a/packages/shared/src/components/post/PostContent.tsx +++ b/packages/shared/src/components/post/PostContent.tsx @@ -1,25 +1,13 @@ import classNames from 'classnames'; import type { ComponentProps, ReactElement } from 'react'; -import React, { - useCallback, - useContext, - useEffect, - useRef, - useState, -} from 'react'; +import React, { useEffect } from 'react'; import dynamic from 'next/dynamic'; import type { Post } from '../../graphql/posts'; -import { getReadArticleHref, isVideoPost, PostType } from '../../graphql/posts'; -import { isEmbeddableSiteTarget } from '../../features/extensionEmbed/common'; +import { isVideoPost } from '../../graphql/posts'; import PostMetadata from '../cards/common/PostMetadata'; import { PostWidgets } from './PostWidgets'; import PostToc from '../widgets/PostToc'; -import { - ToastSubject, - useToastNotification, - useViewSize, - ViewSize, -} from '../../hooks'; +import { ToastSubject, useToastNotification } from '../../hooks'; import PostContentContainer from './PostContentContainer'; import usePostContent from '../../hooks/usePostContent'; import { BasePostContent } from './BasePostContent'; @@ -28,7 +16,6 @@ import type { PostContentProps, PostNavigationProps } from './common'; import { PostContainer } from './common'; import YoutubeVideo from '../video/YoutubeVideo'; import { useAuthContext } from '../../contexts/AuthContext'; -import SettingsContext from '../../contexts/SettingsContext'; import { useViewPost } from '../../hooks/post'; import { TruncateText } from '../utilities'; import { useFeature } from '../GrowthBookProvider'; @@ -40,29 +27,11 @@ import { PostClickbaitShield } from './common/PostClickbaitShield'; import { useSmartTitle } from '../../hooks/post/useSmartTitle'; import { PostTagList } from './tags/PostTagList'; import PostSourceInfo from './PostSourceInfo'; -import { PostArticlePreviewEmbed } from './PostArticlePreviewEmbed'; -import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; -import { EarthIcon } from '../icons'; -import { Drawer } from '../drawers/Drawer'; -import { useLegacyPostLayoutOptOut } from './reader/hooks/useLegacyPostLayoutOptOut'; -import { useReaderModalEligibility } from './reader/hooks/useReaderModalEligibility'; 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, @@ -117,18 +86,13 @@ export function PostContentRaw({ post, }); const { onCopyPostLink, onReadArticle } = engagementActions; - const { openNewTab } = useContext(SettingsContext); - const readArticleHref = getReadArticleHref(post); 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'; @@ -139,240 +103,6 @@ export function PostContentRaw({ isCompactModalSpacing ? 'mt-3 !typo-callout' : 'mt-4 !typo-callout', metadataMarginClassName, ); - const embedArticleTargetUrl = - post.permalink && isEmbeddableSiteTarget(post.permalink) - ? post.permalink - : null; - const { isEligible: isReaderEligible, isReaderModalEnabled } = - useReaderModalEligibility(); - - const { isOptedOut: isLegacyLayoutOptedOut } = useLegacyPostLayoutOptOut(); - const showArticlePreviewEmbed = - isPreviewHydrated && - isReaderEligible && - isReaderModalEnabled && - !isLegacyLayoutOptedOut && - !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< - ReturnType | undefined - >(); - const floatingPreviewCloseTimeoutRef = useRef< - ReturnType | undefined - >(); - 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 shouldShowArticlePreviewToggle = false; - const isPreviewFloating = - isLaptop && showArticlePreviewColumn && isPreviewNarrow; - const shouldRenderFloatingPreview = - isFloatingPreviewVisible || isPreviewFloating; - - 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 undefined; - } - - const observer = new ResizeObserver(([entry]) => { - if (ignorePreviewResizeRef.current) { - return; - } - - const { width } = entry.contentRect; - - 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', className?.container, @@ -400,12 +130,7 @@ export function PostContentRaw({ const postMainColumn = ( - {!isTablet && - showArticlePreviewEmbed && - shouldShowArticlePreviewToggle && ( -

); - const getPreviewGridColsClass = (): string => { - if (!showArticlePreviewColumn || !isTablet) { - return 'grid-cols-[1fr_0px_0fr]'; - } - - if (!isLaptop) { - return 'grid-cols-[1fr_0px_1fr]'; - } - - if (isPreviewFloating) { - return 'grid-cols-[1fr_0px_0fr]'; - } - - return 'grid-cols-[22rem_0px_1fr]'; - }; - return ( - {showArticlePreviewEmbed ? ( -
-
-
- {postMainColumn} - {!isLaptop && postWidgetsColumn} -
-
-
- {!isPreviewFloating && !isTabletPreviewToggling && ( - - )} -
-
- {isLaptop && postWidgetsColumn} - {shouldRenderFloatingPreview && ( -
- -
- )} - setMobilePreviewOpen(false)} - className={{ - wrapper: 'h-[88vh]', - drawer: 'flex-1 !p-0', - }} - displayCloseButton - appendOnRoot - > - - -
- ) : ( - <> - {postMainColumn} - {postWidgetsColumn} - - )} + {postMainColumn} + {postWidgetsColumn} ); } diff --git a/packages/shared/src/components/post/PostHeaderActions.tsx b/packages/shared/src/components/post/PostHeaderActions.tsx index c53bb986de4..be3feb6f2dd 100644 --- a/packages/shared/src/components/post/PostHeaderActions.tsx +++ b/packages/shared/src/components/post/PostHeaderActions.tsx @@ -20,9 +20,10 @@ import { useViewSizeClient, ViewSize } from '../../hooks'; import { BoostPostButton } from '../../features/boost/BoostButton'; import { Tooltip } from '../tooltip/Tooltip'; import { useShowBoostButton } from '../../features/boost/useShowBoostButton'; -import { ReaderLegacyLayoutToggleButton } from './reader/ReaderHeaderActionButtons'; -import { useLegacyPostLayoutOptOut } from './reader/hooks/useLegacyPostLayoutOptOut'; import { useReaderModalEligibility } from './reader/hooks/useReaderModalEligibility'; +import { useLegacyPostLayoutOptOut } from './reader/hooks/useLegacyPostLayoutOptOut'; +import { useReaderInstallPromptGate } from '../../hooks/useReaderInstallPromptGate'; +import { EarthIcon } from '../icons'; const Container = classed('div', 'flex flex-row items-center'); @@ -54,8 +55,45 @@ export function PostHeaderActions({ const { isEligible: isReaderEligible, isReaderModalEnabled } = useReaderModalEligibility(); const { isOptedOut: isLegacyLayoutOptedOut } = useLegacyPostLayoutOptOut(); - const showReaderToggle = - isArticle && isReaderEligible && isReaderModalEnabled; + // When enrolled in the variant — and the user hasn't opted out — we flip + // the action semantics: "Read post" becomes the inside-daily.dev entry + // (globe icon, opens reader preview), and a secondary external-link + // button takes over the "open in a new tab" role that used to live on + // Read post. Once a user opts out (via the install-prompt overlay) the + // UI snaps back to the classic Read post button + no secondary button. + const isReaderVariant = + isArticle && + isReaderEligible && + isReaderModalEnabled && + !isLegacyLayoutOptedOut; + const { onReadClick: onReaderInstallGateClick } = useReaderInstallPromptGate( + post, + // Dismissing the install prompt tears down the classic post modal too, + // so the user doesn't bounce back to the surface they just rejected. + // `onClose` is a React event handler — the post modal close ignores its + // argument, so a fake undefined event is fine. + { + onCloseParent: onClose + ? // The post modal's onClose is typed as `MouseEventHandler | + // KeyboardEventHandler` (a union) but the underlying close handler + // ignores its event argument. Pass through with a cast. + () => (onClose as (event?: unknown) => void)() + : undefined, + }, + ); + + const handleReadArticle = (event: React.MouseEvent) => { + if (onReaderInstallGateClick(event)) { + return; + } + onReadArticle?.(); + }; + + const readPostIcon = isReaderVariant ? ( + + ) : ( + getReadPostButtonIcon(post) + ); return ( @@ -77,11 +115,11 @@ export function PostHeaderActions({ tag="a" href={readHref} target={openNewTab ? '_blank' : '_self'} - icon={getReadPostButtonIcon(post)} + icon={readPostIcon} iconPosition={ isTwitter ? ButtonIconPosition.Right : (undefined as never) } - onClick={onReadArticle} + onClick={handleReadArticle} data-testid="postActionsRead" size={buttonSize} > @@ -95,11 +133,6 @@ export function PostHeaderActions({ {isCollection && !hideSubscribeAction && ( )} - {showReaderToggle && ( - - )} void; + onClose?: () => void; isPostPage?: boolean; fallbackScrollRef?: Ref; contentTopOffsetPx?: number; @@ -21,6 +21,18 @@ type ArticleReaderFrameProps = { targetHref?: string; onTargetLinkClick?: () => void; targetLinkInNewTab?: boolean; + /** + * Renders to the left of the preview URL inside the iframe chrome header. + * Used by the standalone post page to surface a "Back to feed" arrow next + * to the URL bar. + */ + leftHeaderActions?: ReactElement | null; + /** + * Close handler for the "Close preview" legacy-layout toggle. Stays wired + * even when the rail owns the (X) close, so the toggle can actually exit + * the reader instead of just flipping the opt-out flag. + */ + onLegacyLayoutClose?: () => void; }; export function ArticleReaderFrame({ @@ -36,16 +48,23 @@ export function ArticleReaderFrame({ targetHref, onTargetLinkClick, targetLinkInNewTab, + leftHeaderActions, + onLegacyLayoutClose, }: ArticleReaderFrameProps): ReactElement { const { optOut } = useLegacyPostLayoutOptOut(); - const onInstallPromptOptOut = useCallback( - () => optOut(TargetId.ReaderInstallPrompt), - [optOut], - ); - const onPermissionScreenOptOut = useCallback( - () => optOut(TargetId.ReaderPermissionPrompt), - [optOut], - ); + // Opting out through either the inline install prompt or the in-iframe + // permission screen flips the persistent setting AND closes the reader + // so the user immediately returns to the classic external Read post UX + // — flipping the flag alone leaves them inside the very surface they + // just rejected. + const onInstallPromptOptOut = useCallback(() => { + optOut(TargetId.ReaderInstallPrompt); + onLegacyLayoutClose?.(); + }, [onLegacyLayoutClose, optOut]); + const onPermissionScreenOptOut = useCallback(() => { + optOut(TargetId.ReaderPermissionPrompt); + onLegacyLayoutClose?.(); + }, [onLegacyLayoutClose, optOut]); const isFallback = !targetUrl || !isEmbeddable; if (isFallback) { @@ -73,10 +92,11 @@ export function ArticleReaderFrame({ } collapseOnUnavailable={false} diff --git a/packages/shared/src/components/post/reader/EngagementRail.tsx b/packages/shared/src/components/post/reader/EngagementRail.tsx index 0265b25fa01..1ac4bda6a88 100644 --- a/packages/shared/src/components/post/reader/EngagementRail.tsx +++ b/packages/shared/src/components/post/reader/EngagementRail.tsx @@ -18,8 +18,6 @@ import type { NewCommentRef } from '../NewComment'; import { NewComment } from '../NewComment'; import { PostTagList } from '../tags/PostTagList'; import PostMetadata from '../../cards/common/PostMetadata'; -import { useSmartTitle } from '../../../hooks/post/useSmartTitle'; -import { PostClickbaitShield } from '../common/PostClickbaitShield'; import { useSettingsContext } from '../../../contexts/SettingsContext'; import ShowMoreContent from '../../cards/common/ShowMoreContent'; import { @@ -31,11 +29,6 @@ import { import { TimeSortIcon } from '../../icons/Sort/Time'; import { AnalyticsIcon, ArrowIcon } from '../../icons'; import { PostMenuOptions } from '../PostMenuOptions'; -import { - Typography, - TypographyTag, - TypographyType, -} from '../../typography/Typography'; import { SortCommentsBy } from '../../../graphql/comments'; import { Tooltip } from '../../tooltip/Tooltip'; import { ClickableText } from '../../buttons/ClickableText'; @@ -48,6 +41,7 @@ import { PostPosition } from '../../../hooks/usePostModalNavigation'; import { SourceStrip } from './SourceStrip'; import { ReaderRailActionBar } from './ReaderRailActionBar'; import ShareBar from '../../ShareBar'; +import { ReaderCloseButton } from './ReaderHeaderActionButtons'; const SquadEntityCard = dynamic( () => @@ -74,10 +68,16 @@ type EngagementRailProps = { onRegisterFocusComment: (fn: () => void) => void; className?: string; /** - * Standalone post page only: when provided, renders a left-aligned - * back-to-feed arrow button at the top of the discussion rail. + * Modal only: when provided, renders a close (X) button on the right side + * of the rail header next to the three-dots menu. + */ + onClose?: () => void; + /** + * Post page only: drop the sticky rail header entirely and surface the + * three-dots menu inline next to the source's Follow button so the rail + * doesn't render a near-empty header bar. */ - onBackToFeed?: () => void; + inlineHeaderMenu?: boolean; }; const noopFocus = (): void => {}; @@ -89,7 +89,8 @@ export function EngagementRail({ onNextPost, onRegisterFocusComment, className, - onBackToFeed, + onClose, + inlineHeaderMenu = false, }: EngagementRailProps): ReactElement { const { tokenRefreshed } = useContext(AuthContext); const { user } = useAuthContext(); @@ -99,7 +100,6 @@ export function EngagementRail({ const [isComposerOpen, setIsComposerOpen] = useState(false); const { onShowUpvoted } = useUpvoteQuery(); const { openShareComment } = useShareComment(Origin.ReaderModal); - const { title: displayTitle } = useSmartTitle(post); const isVideoType = isVideoPost(post); const upvotes = post.numUpvotes || 0; const comments = post.numComments || 0; @@ -128,107 +128,100 @@ export function EngagementRail({
diff --git a/packages/shared/src/features/extensionEmbed/pagePermissionBridge.ts b/packages/shared/src/features/extensionEmbed/pagePermissionBridge.ts new file mode 100644 index 00000000000..fb7756e2a67 --- /dev/null +++ b/packages/shared/src/features/extensionEmbed/pagePermissionBridge.ts @@ -0,0 +1,100 @@ +// Page <-> content-script bridge for driving chrome.permissions.request from +// the webapp while preserving the user gesture. +// +// chrome.permissions.request is only callable from extension pages, so the +// page can't call it directly. Instead, the page dispatches a synchronous +// CustomEvent on `window`; the extension's content script listens for it and +// forwards the request to the background via chrome.runtime.sendMessage — +// all within the same task as the original click, which is what keeps +// Chrome's transient user activation alive across the boundary. +// +// After the grant the background must runtime.reload() so DNR picks up the +// new optional host permission. The content script polls until the new +// service worker answers before dispatching the result CustomEvent back to +// the page, so callers only learn the request succeeded once everything +// downstream (DNR rule installation) will work. + +export const pagePermissionBridgeRequestEvent = + 'daily-extension-request-frame-permissions'; +export const pagePermissionBridgeResultEvent = + 'daily-extension-frame-permissions-result'; + +export type PagePermissionBridgeResult = { + granted: boolean; + error?: string; +}; + +// Shape returned by the background's RequestFrameEmbeddingPermissions +// handler. Internal to the bridge — the page-facing helper collapses this +// into `PagePermissionBridgeResult` once the post-grant reload + ready-poll +// has completed. +export type PermissionGrantResponse = { + granted?: boolean; + willReload?: boolean; + error?: string; +}; + +// Timing for the post-grant reload coordination. Background fires +// runtime.reload() ~`reloadDelayMs` after returning the response; the +// content script waits `pingDelayBeforeFirstAttemptMs` before its first +// liveness ping so it doesn't accidentally hit the old worker, then polls +// every `pingRetryDelayMs` up to `pingMaxAttempts` times. +export const frameEmbeddingPermissionBridgeTiming = { + reloadDelayMs: 50, + pingDelayBeforeFirstAttemptMs: 800, + pingRetryDelayMs: 200, + pingMaxAttempts: 25, +} as const; + +const PAGE_HELPER_TIMEOUT_MS = 60_000; + +// Drives chrome.permissions.request through the installed extension's +// content script. Must be invoked synchronously from a user-input handler +// (e.g. an onClick) so the click's transient activation is still alive when +// the content script forwards the request to the background. +export const requestFrameEmbeddingPermissionFromPage = ( + timeoutMs: number = PAGE_HELPER_TIMEOUT_MS, +): Promise => { + if (typeof window === 'undefined') { + return Promise.resolve({ + granted: false, + error: 'window-unavailable', + }); + } + + return new Promise((resolve) => { + let settled = false; + let timeoutId: ReturnType | undefined; + + const onResult = (event: Event): void => { + if (settled) { + return; + } + settled = true; + window.removeEventListener(pagePermissionBridgeResultEvent, onResult); + if (timeoutId !== undefined) { + globalThis.clearTimeout(timeoutId); + } + const { detail } = event as CustomEvent; + resolve({ + granted: !!detail?.granted, + error: detail?.error, + }); + }; + + timeoutId = globalThis.setTimeout(() => { + if (settled) { + return; + } + settled = true; + window.removeEventListener(pagePermissionBridgeResultEvent, onResult); + resolve({ granted: false, error: 'timeout' }); + }, timeoutMs); + + window.addEventListener(pagePermissionBridgeResultEvent, onResult); + + // Synchronous dispatch is what preserves the user activation across the + // page <-> content-script boundary. + window.dispatchEvent(new CustomEvent(pagePermissionBridgeRequestEvent)); + }); +}; diff --git a/packages/shared/src/graphql/settings.ts b/packages/shared/src/graphql/settings.ts index ccb1483c830..58ae5021b37 100644 --- a/packages/shared/src/graphql/settings.ts +++ b/packages/shared/src/graphql/settings.ts @@ -55,6 +55,11 @@ export type SettingsFlags = { prompt?: Record; defaultWriteTab?: WriteFormTab; legacyPostLayoutOptOut?: boolean; + // Persists that the user already chose to engage with the reader install + // prompt (clicked "Enable permissions & read inside"). Future read clicks + // skip the prompt and open the reader modal directly. Dismissing the prompt + // without choosing an option leaves this unset so the prompt reappears. + readerInstallPromptAcknowledged?: boolean; shortcutMeta?: Record; shortcutsMode?: ShortcutsMode; shortcutsAppearance?: ShortcutsAppearance; diff --git a/packages/shared/src/hooks/useReaderInstallPromptGate.ts b/packages/shared/src/hooks/useReaderInstallPromptGate.ts new file mode 100644 index 00000000000..1b47f018872 --- /dev/null +++ b/packages/shared/src/hooks/useReaderInstallPromptGate.ts @@ -0,0 +1,88 @@ +import type { MouseEvent } from 'react'; +import { useCallback } from 'react'; +import { useLazyModal } from './useLazyModal'; +import { LazyModal } from '../components/modals/common/types'; +import type { Post } from '../graphql/posts'; +import { PostType } from '../graphql/posts'; +import { useReaderModalEligibility } from '../components/post/reader/hooks/useReaderModalEligibility'; +import { useLegacyPostLayoutOptOut } from '../components/post/reader/hooks/useLegacyPostLayoutOptOut'; +import { useSettingsContext } from '../contexts/SettingsContext'; + +const READER_GATE_ELIGIBLE_TYPES = new Set([ + PostType.Article, + PostType.Digest, + PostType.VideoYouTube, +]); + +interface UseReaderInstallPromptGateOptions { + /** + * Optional close handler for the modal/page that owns the Read post + * button (e.g. the classic post modal). Fired alongside the install + * prompt's own close paths (X dismiss, "Don't ask again") so closing + * the prompt also tears down the surface that opened it instead of + * silently reverting to it. + */ + onCloseParent?: () => void; +} + +interface UseReaderInstallPromptGateResult { + isGated: boolean; + /** + * Returns `true` when the gate intercepted the click and opened the + * install prompt. Callers should skip their default behavior in that + * case. + */ + onReadClick: (event: MouseEvent) => boolean; +} + +export function useReaderInstallPromptGate( + post: Post | undefined, + { onCloseParent }: UseReaderInstallPromptGateOptions = {}, +): UseReaderInstallPromptGateResult { + const { openModal } = useLazyModal(); + const { isEligible, isReaderModalEnabled } = useReaderModalEligibility(); + const { isOptedOut: isLegacyLayoutOptedOut } = useLegacyPostLayoutOptOut(); + const { flags } = useSettingsContext(); + const isInstallPromptAcknowledged = + flags?.readerInstallPromptAcknowledged ?? false; + + const isGated = + !!post && + isEligible && + isReaderModalEnabled && + !isLegacyLayoutOptedOut && + READER_GATE_ELIGIBLE_TYPES.has(post.type); + + const onReadClick = useCallback( + (event: MouseEvent): boolean => { + if (!isGated || !post) { + return false; + } + // Preserve cmd/ctrl/shift/middle-click escape hatches so power users + // can still open the article in a new tab without the prompt. + if ( + event.metaKey || + event.ctrlKey || + event.shiftKey || + event.button > 0 + ) { + return false; + } + event.preventDefault(); + event.stopPropagation(); + // Once the user has accepted the install prompt, skip it on future reads + // and route straight to the reader modal. The prompt only reappears if + // the user dismissed it without picking an option. + openModal({ + type: isInstallPromptAcknowledged + ? LazyModal.ReaderPreview + : LazyModal.ReaderInstallPrompt, + props: { post, onCloseParent }, + }); + return true; + }, + [isGated, isInstallPromptAcknowledged, onCloseParent, openModal, post], + ); + + return { isGated, onReadClick }; +} diff --git a/packages/shared/src/lib/extension.ts b/packages/shared/src/lib/extension.ts index 366e77ddd9d..c6dc5019467 100644 --- a/packages/shared/src/lib/extension.ts +++ b/packages/shared/src/lib/extension.ts @@ -6,6 +6,8 @@ export enum ExtensionMessageType { RequestUpdate = 'REQUEST_UPDATE', EnableFrameEmbeddingForTab = 'ENABLE_FRAME_EMBEDDING_FOR_TAB', DisableFrameEmbeddingForTab = 'DISABLE_FRAME_EMBEDDING_FOR_TAB', + RequestFrameEmbeddingPermissions = 'REQUEST_FRAME_EMBEDDING_PERMISSIONS', + PingFrameEmbeddingReady = 'PING_FRAME_EMBEDDING_READY', } export const getCompanionWrapper = (): HTMLElement | null => diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts index 11a2946d5a6..7ae0ce7fcfe 100644 --- a/packages/shared/src/lib/featureManagement.ts +++ b/packages/shared/src/lib/featureManagement.ts @@ -182,7 +182,7 @@ export const featureOnboardingPersonas = new Feature( export const featurePostSignupWidget = new Feature('post_signup_widget', false); -export const featureReaderModal = new Feature('reader_modal', false); +export const featureReaderModal = new Feature('reader_modal_v2', false); export const featureGenericReferralPopupV2 = new Feature( 'generic_referral_popup_v2', diff --git a/packages/shared/src/lib/log.ts b/packages/shared/src/lib/log.ts index 4ed96ae7058..985cfb0dd3e 100644 --- a/packages/shared/src/lib/log.ts +++ b/packages/shared/src/lib/log.ts @@ -482,6 +482,8 @@ export enum LogEvent { ToggleEmbeddedReader = 'toggle embedded reader', ImpressionReaderInstallPrompt = 'impression reader install prompt', ClickReaderInstallExtension = 'click reader install extension', + ClickReaderInstallPreview = 'click reader install preview', + ClickReaderInstallSkip = 'click reader install skip', ImpressionReaderFallback = 'impression reader fallback', ReaderEmbedReady = 'reader embed ready', ReaderEmbedPermissionRequired = 'reader embed permission required', diff --git a/packages/webapp/pages/posts/[id]/index.tsx b/packages/webapp/pages/posts/[id]/index.tsx index 752f690bead..fce81b9431d 100644 --- a/packages/webapp/pages/posts/[id]/index.tsx +++ b/packages/webapp/pages/posts/[id]/index.tsx @@ -46,8 +46,6 @@ import { ActivePostContextProvider } from '@dailydotdev/shared/src/contexts/Acti import { LogExtraContextProvider } from '@dailydotdev/shared/src/contexts/LogExtraContext'; import { useLogContext } from '@dailydotdev/shared/src/contexts/LogContext'; import useDebounceFn from '@dailydotdev/shared/src/hooks/useDebounceFn'; -import { useLegacyPostLayoutOptOut } from '@dailydotdev/shared/src/components/post/reader/hooks/useLegacyPostLayoutOptOut'; -import { useReaderModalEligibility } from '@dailydotdev/shared/src/components/post/reader/hooks/useReaderModalEligibility'; import { useEngagementAdsContext } from '@dailydotdev/shared/src/contexts/EngagementAdsContext'; import { CompanionDemoWidget } from '@dailydotdev/shared/src/components/post/CompanionDemoWidget'; import { getPageSeoTitles } from '../../../components/layouts/utils'; @@ -118,12 +116,6 @@ const DigestPostContent = dynamic(() => ).then((module) => module.DigestPostContent), ); -const ReaderPostLayout = dynamic(() => - import( - /* webpackChunkName: "lazyReaderPostLayout" */ '@dailydotdev/shared/src/components/post/reader/ReaderPostLayout' - ).then((module) => module.ReaderPostLayout), -); - export interface Props extends DynamicSeoProps { id: string; initialData?: PostData; @@ -133,15 +125,6 @@ export interface Props extends DynamicSeoProps { type PostContentComponent = ComponentType; -const READER_ELIGIBLE_POST_TYPES = new Set([ - PostType.Article, - PostType.Digest, - PostType.VideoYouTube, -]); - -const READER_PAGE_LAYOUT_CLASS_NAME = - 'flex h-[calc(100vh-4rem)] max-h-[calc(100vh-4rem)] min-h-0 w-full flex-col'; - const CONTENT_MAP: Record> = { article: PostContent as PostContentComponent, share: SquadPostContent as PostContentComponent, @@ -211,19 +194,6 @@ export const PostPage = ({ retry: false, }, }); - const { - isEligible: isReaderEligible, - isReaderModalEnabled: readerModalFromGrowthBook, - isReaderFeatureLoading, - } = useReaderModalEligibility(); - const { isOptedOut: isLegacyLayoutOptedOut } = useLegacyPostLayoutOptOut(); - const isTabletViewport = useViewSize(ViewSize.Tablet); - const isReaderModalOn = - isReaderEligible && - readerModalFromGrowthBook && - !isLegacyLayoutOptedOut && - isTabletViewport; - const isReaderModalFeatureReady = !isReaderFeatureLoading; const featureTheme = useFeatureTheme(); const containerClass = classNames( 'mb-16 min-h-page max-w-[69.25rem] tablet:mb-8 laptop:mb-0 laptop:pb-6 laptopL:pb-0', @@ -283,19 +253,6 @@ export const PostPage = ({ return ; } - const onReaderClose = () => { - if (globalThis.window?.history?.length > 1) { - router.back(); - return; - } - router.push(webappUrl); - }; - - const shouldUseReaderLayout = - isReaderModalFeatureReady && - isReaderModalOn && - READER_ELIGIBLE_POST_TYPES.has(post.type); - return ( - {shouldUseReaderLayout ? ( - - ) : ( - - )} + {shouldShowAuthBanner && isLaptop && } diff --git a/packages/webapp/pages/settings/appearance.tsx b/packages/webapp/pages/settings/appearance.tsx index d3efbceace8..9c0d2b0958a 100644 --- a/packages/webapp/pages/settings/appearance.tsx +++ b/packages/webapp/pages/settings/appearance.tsx @@ -6,6 +6,8 @@ import dynamic from 'next/dynamic'; import { ThemeSection } from '@dailydotdev/shared/src/components/ProfileMenu/sections/ThemeSection'; import { useSettingsContext } from '@dailydotdev/shared/src/contexts/SettingsContext'; import { useViewSize, ViewSize } from '@dailydotdev/shared/src/hooks'; +import { useReaderModalEligibility } from '@dailydotdev/shared/src/components/post/reader/hooks/useReaderModalEligibility'; +import { useLegacyPostLayoutOptOut } from '@dailydotdev/shared/src/components/post/reader/hooks/useLegacyPostLayoutOptOut'; import { Typography, TypographyType, @@ -51,6 +53,22 @@ const AccountManageSubscriptionPage = (): ReactElement => { autoDismissNotifications, toggleAutoDismissNotifications, } = useSettingsContext(); + const { isEligible: isReaderEligible, isReaderModalEnabled } = + useReaderModalEligibility(); + const { + isOptedOut: isLegacyLayoutOptedOut, + optIn, + optOut, + } = useLegacyPostLayoutOptOut(); + const showReaderToggle = isReaderEligible && isReaderModalEnabled; + const isReadInsideEnabled = !isLegacyLayoutOptedOut; + const onToggleReadInside = () => { + if (isReadInsideEnabled) { + optOut(); + return; + } + optIn(); + }; const onLayoutToggle = useCallback( async (enabled: boolean) => { @@ -121,6 +139,16 @@ const AccountManageSubscriptionPage = (): ReactElement => { > Show companion widget on external sites + + {showReaderToggle && ( + + Read articles inside daily.dev + + )}