diff --git a/packages/extension/src/frame/render.ts b/packages/extension/src/frame/render.ts index 5851011186a..b6f2d48240d 100644 --- a/packages/extension/src/frame/render.ts +++ b/packages/extension/src/frame/render.ts @@ -23,46 +23,47 @@ const ensureStyles = (): void => { position: absolute; inset: 0; overflow: hidden; - opacity: 0.44; + opacity: 0.5; background: - repeating-linear-gradient( - 120deg, - rgba(255, 255, 255, 0.06) 0, - rgba(255, 255, 255, 0.06) 0.75rem, - transparent 0.75rem, - transparent 1.5rem + radial-gradient( + circle at 50% 42%, + rgba(192, 41, 240, 0.18) 0, + transparent 18rem ), - linear-gradient( - 180deg, - rgba(255, 255, 255, 0.02), - rgba(255, 255, 255, 0.04) + radial-gradient( + circle at 38% 58%, + rgba(57, 217, 138, 0.1) 0, + transparent 16rem + ), + radial-gradient( + circle at 62% 58%, + rgba(82, 139, 255, 0.1) 0, + transparent 16rem ); - background-size: 190% 190%, 100% 100%; - animation: embeddedBrowsingStripeShift 42s linear infinite; - will-change: background-position; + animation: embeddedBrowsingGlow 6s ease-in-out infinite alternate; + will-change: opacity, transform; } .embedded-browsing-ambient::before { content: ''; position: absolute; - inset: 0; - background: repeating-linear-gradient( - 120deg, - transparent 0, - transparent 1.75rem, - rgba(255, 255, 255, 0.04) 1.75rem, - rgba(255, 255, 255, 0.04) 2.5rem - ); - background-size: 220% 220%; - animation: embeddedBrowsingStripeShiftReverse 58s linear infinite; - opacity: 0.24; + top: 50%; + left: 50%; + width: 12rem; + height: 12rem; + border: 1px solid rgba(255, 255, 255, 0.16); + border-radius: 9999px; + opacity: 0.22; + transform: translate(-50%, -50%) scale(0.9); + animation: embeddedBrowsingPulse 3.8s ease-in-out infinite; } - @keyframes embeddedBrowsingStripeShift { - 0% { background-position: 0% 0%, 50% 50%; } - 100% { background-position: 220% 120%, 50% 50%; } + @keyframes embeddedBrowsingGlow { + 0% { opacity: 0.36; transform: scale(0.99); } + 100% { opacity: 0.54; transform: scale(1.01); } } - @keyframes embeddedBrowsingStripeShiftReverse { - 0% { background-position: 220% 140%; } - 100% { background-position: 0% 0%; } + @keyframes embeddedBrowsingPulse { + 0% { opacity: 0.08; transform: translate(-50%, -50%) scale(0.82); } + 55% { opacity: 0.22; } + 100% { opacity: 0; transform: translate(-50%, -50%) scale(1.35); } } @media (prefers-reduced-motion: reduce) { .embedded-browsing-ambient, @@ -70,7 +71,6 @@ const ensureStyles = (): void => { animation: none; } .embedded-browsing-ambient { - background-position: 50% 50%, 50% 50%; opacity: 0.42; } .embedded-browsing-ambient::before { @@ -82,7 +82,7 @@ const ensureStyles = (): void => { z-index: 10; display: flex; width: 100%; - max-width: 40rem; + max-width: 24rem; flex-shrink: 0; flex-direction: column; align-items: center; @@ -99,6 +99,7 @@ const ensureStyles = (): void => { } .embedded-browsing-body { margin: 0; + max-width: 21rem; font-size: 0.9375rem; line-height: 1.25rem; color: #cfd6e6; @@ -142,15 +143,16 @@ const ensureStyles = (): void => { .embedded-browsing-button:disabled { opacity: 0.6; cursor: not-allowed; } .embedded-browsing-button-secondary { background: transparent; - color: #cfd6e6; - font-weight: 500; - min-width: 0; - padding: 0 0.75rem; + color: #e3e8f3; + border: 1.5px solid rgba(255, 255, 255, 0.28); + font-weight: 600; } .embedded-browsing-button-secondary:hover { - opacity: 1; + background: rgba(255, 255, 255, 0.08); + border-color: rgba(255, 255, 255, 0.42); color: #ffffff; - transform: none; + opacity: 1; + transform: translateY(-1px); } `; document.head.appendChild(style); @@ -210,17 +212,16 @@ export const renderPermissionPrompt = ({ const heading = document.createElement('h2'); heading.className = 'embedded-browsing-heading'; - heading.textContent = 'Enable embedded browsing'; + heading.textContent = 'Read it right here.'; const description = document.createElement('p'); description.className = 'embedded-browsing-body'; description.textContent = - 'To load websites inside daily.dev, allow this extension to modify response headers on embedded pages.'; + 'Enable reader preview to open articles inside daily.dev with the discussion next to them.'; const status = document.createElement('p'); status.className = 'embedded-browsing-status'; - status.textContent = - 'This is optional and only applies to pages embedded by daily.dev.'; + status.textContent = 'Only for links you open from daily.dev.'; const actions = document.createElement('div'); actions.className = 'embedded-browsing-actions'; @@ -228,7 +229,7 @@ export const renderPermissionPrompt = ({ const button = document.createElement('button'); button.type = 'button'; button.className = 'embedded-browsing-button'; - button.textContent = 'Enable for this browser'; + button.textContent = 'Enable reader preview'; const resetButton = () => { button.disabled = false; @@ -236,21 +237,22 @@ export const renderPermissionPrompt = ({ button.addEventListener('click', async () => { button.disabled = true; - status.textContent = 'Requesting permissions...'; + status.textContent = 'Just a sec…'; const outcome = await onRequestPermission(); if (outcome === 'granted') { - status.textContent = 'Permissions granted. Reloading extension...'; + status.textContent = "You're in. Reloading…"; return; } if (outcome === 'dismissed') { - status.textContent = 'Permission request was dismissed.'; + status.textContent = + 'Permission was dismissed — tap below when you’re ready.'; resetButton(); return; } - status.textContent = 'Failed to enable embedded browsing.'; + status.textContent = "Something didn't quite work. Try again?"; resetButton(); }); @@ -261,7 +263,7 @@ export const renderPermissionPrompt = ({ optOutButton.type = 'button'; optOutButton.className = 'embedded-browsing-button embedded-browsing-button-secondary'; - optOutButton.textContent = "I'd rather not read inside daily.dev"; + optOutButton.textContent = "Don't ask again, open new tab"; optOutButton.addEventListener('click', () => { onOptOut(); }); diff --git a/packages/shared/src/components/Feed.tsx b/packages/shared/src/components/Feed.tsx index bb5b8ad2377..3c8b9c20dad 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,11 @@ 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; - } + // DEMO ONLY: card clicks now always open the classic post modal so the + // user can see title / TL;DR first. The reader modal is reached via the + // new install-prompt pop-up's "Preview the experience" CTA instead. 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 ef129257a81..ee64cd8c81c 100644 --- a/packages/shared/src/components/cards/common/PostCardHeader.tsx +++ b/packages/shared/src/components/cards/common/PostCardHeader.tsx @@ -9,6 +9,7 @@ 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 type { Post } from '../../../graphql/posts'; import { getReadPostButtonText, @@ -66,6 +67,15 @@ export const PostCardHeader = ({ const { highlightBookmarkedPost } = useBookmarkProvider({ bookmarked: (post.bookmarked && !showFeedback) ?? false, }); + const { onReadClick: onReaderInstallGateClick } = + useReaderInstallPromptGate(post); + + const handleReadArticleClick = (event: React.MouseEvent) => { + if (onReaderInstallGateClick(event)) { + return; + } + onReadArticleClick?.(event); + }; const articleLink = useMemo(() => { if (post.sharedPost) { @@ -118,7 +128,7 @@ export const PostCardHeader = ({ className="mr-2" variant={ButtonVariant.Primary} href={articleLink ?? ''} - onClick={onReadArticleClick} + onClick={handleReadArticleClick} openNewTab={openNewTab} icon={getReadPostButtonIcon(post)} /> diff --git a/packages/shared/src/components/modals/ReaderInstallPromptModal.tsx b/packages/shared/src/components/modals/ReaderInstallPromptModal.tsx new file mode 100644 index 00000000000..2a4c60653a3 --- /dev/null +++ b/packages/shared/src/components/modals/ReaderInstallPromptModal.tsx @@ -0,0 +1,393 @@ +import type { MouseEvent, ReactElement } from 'react'; +import React, { useEffect, useMemo } 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'; + +interface ReaderInstallPromptModalProps extends LazyModalCommonProps { + post: Post; +} + +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; + } +}; + +const getDisplayUrl = (post: Post, host: string | undefined): string => { + if (post.permalink) { + return post.permalink; + } + return host || 'daily.dev'; +}; + +interface BrowserChromeProps { + faviconSrc: string | undefined; + displayUrl: string; + onClose: (event: MouseEvent) => void; +} + +// The header is a non-interactive mockup of a browser bar so users +// understand the article is "opening inside daily.dev". Only the close +// (X) button is real; navigation icons are decorative and aria-hidden. +function BrowserChrome({ + faviconSrc, + displayUrl, + onClose, +}: BrowserChromeProps): ReactElement { + return ( +
+
+ + + + + + + + + +
+
+ {faviconSrc && ( + + )} + + {displayUrl} + +
+
+ ); +} + +// Heavily blurred, white-page article mockup so the surface reads like a +// real website opened inside daily.dev. We use explicit hex colors instead +// of theme tokens so the article surface stays light even when the rest +// of the app is in dark mode — that's what most real article pages look +// like, and it makes the blur read as "a webpage behind glass". +const ARTICLE_PARAGRAPHS = ['p1', 'p2', 'p3', 'p4', 'p5', 'p6'] as const; + +function BlurredArticleBackdrop(): ReactElement { + return ( +
+
+
+
+ + + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {ARTICLE_PARAGRAPHS.map((id) => ( +
+
+
+
+
+
+ ))} +
+
+
+
+ ); +} + +function ReaderInstallPromptModal({ + post, + isOpen, + onRequestClose, +}: ReaderInstallPromptModalProps): ReactElement { + const { logEvent } = useLogContext(); + const { openModal, closeModal } = useLazyModal(); + const { optOut } = useLegacyPostLayoutOptOut(); + 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(post, 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 }), + }); + }; + + const onPreviewClick = (event: MouseEvent) => { + event.preventDefault(); + closeModal(); + openModal({ + type: LazyModal.ReaderPreview, + props: { post }, + }); + }; + + // DEMO ONLY: bypass the install flow entirely and persist the classic + // behavior so future "Read post" clicks open normally. + const onOpenInNewTabClick = (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); + }; + + const onCloseFromChrome = (event: MouseEvent) => { + onRequestClose(event); + }; + + return ( + +
+ +
+ +
+
+ + Read it right here. + + + Install the extension to open every article inside daily.dev + with the discussion right next to it. + +
+ + +
+ + + 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..d12b76ad8a9 --- /dev/null +++ b/packages/shared/src/components/modals/ReaderPreviewLazyModal.tsx @@ -0,0 +1,30 @@ +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; +} + +function ReaderPreviewLazyModal({ + post, + isOpen, + onRequestClose, +}: ReaderPreviewLazyModalProps): ReactElement { + 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 b75b7d6d95b..d3665bb412e 100644 --- a/packages/shared/src/components/modals/common.tsx +++ b/packages/shared/src/components/modals/common.tsx @@ -487,6 +487,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, @@ -566,6 +580,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 e0f59663375..9429ac184cb 100644 --- a/packages/shared/src/components/modals/common/types.ts +++ b/packages/shared/src/components/modals/common/types.ts @@ -103,6 +103,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..2401a43492d 100644 --- a/packages/shared/src/components/post/PostArticlePreviewEmbed.tsx +++ b/packages/shared/src/components/post/PostArticlePreviewEmbed.tsx @@ -60,8 +60,9 @@ type PostArticlePreviewEmbedProps = { targetLinkInNewTab?: boolean; /** * Opt-out action shown inside the "install the extension" prompt. When - * provided, the prompt surfaces an "I'd rather not read inside daily.dev" - * button below the Install CTA. + * provided, the prompt surfaces a "Don't ask again, open new tab" + * floating button below + * the Install CTA so users have an obvious escape hatch. */ onInstallPromptOptOut?: () => void; /** @@ -527,7 +528,7 @@ export function PostArticlePreviewEmbed({ aria-label="Article preview" >
-
+
{leftHeaderActions ? (
diff --git a/packages/shared/src/components/post/PostHeaderActions.tsx b/packages/shared/src/components/post/PostHeaderActions.tsx index c53bb986de4..baf9e503bb5 100644 --- a/packages/shared/src/components/post/PostHeaderActions.tsx +++ b/packages/shared/src/components/post/PostHeaderActions.tsx @@ -23,6 +23,7 @@ import { useShowBoostButton } from '../../features/boost/useShowBoostButton'; import { ReaderLegacyLayoutToggleButton } from './reader/ReaderHeaderActionButtons'; import { useLegacyPostLayoutOptOut } from './reader/hooks/useLegacyPostLayoutOptOut'; import { useReaderModalEligibility } from './reader/hooks/useReaderModalEligibility'; +import { useReaderInstallPromptGate } from '../../hooks/useReaderInstallPromptGate'; const Container = classed('div', 'flex flex-row items-center'); @@ -56,6 +57,15 @@ export function PostHeaderActions({ const { isOptedOut: isLegacyLayoutOptedOut } = useLegacyPostLayoutOptOut(); const showReaderToggle = isArticle && isReaderEligible && isReaderModalEnabled; + const { onReadClick: onReaderInstallGateClick } = + useReaderInstallPromptGate(post); + + const handleReadArticle = (event: React.MouseEvent) => { + if (onReaderInstallGateClick(event)) { + return; + } + onReadArticle?.(); + }; return ( @@ -81,7 +91,7 @@ export function PostHeaderActions({ iconPosition={ isTwitter ? ButtonIconPosition.Right : (undefined as never) } - onClick={onReadArticle} + onClick={handleReadArticle} data-testid="postActionsRead" size={buttonSize} > diff --git a/packages/shared/src/components/post/reader/ArticleReaderFrame.tsx b/packages/shared/src/components/post/reader/ArticleReaderFrame.tsx index 063929fdd15..16a69c6257b 100644 --- a/packages/shared/src/components/post/reader/ArticleReaderFrame.tsx +++ b/packages/shared/src/components/post/reader/ArticleReaderFrame.tsx @@ -13,7 +13,7 @@ type ArticleReaderFrameProps = { targetUrl: string | null; isEmbeddable: boolean; className?: string; - onClose: () => void; + onClose?: () => void; isPostPage?: boolean; fallbackScrollRef?: Ref; contentTopOffsetPx?: number; @@ -21,6 +21,12 @@ 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; }; export function ArticleReaderFrame({ @@ -36,6 +42,7 @@ export function ArticleReaderFrame({ targetHref, onTargetLinkClick, targetLinkInNewTab, + leftHeaderActions, }: ArticleReaderFrameProps): ReactElement { const { optOut } = useLegacyPostLayoutOptOut(); const onInstallPromptOptOut = useCallback( @@ -73,10 +80,12 @@ 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..63e8619e08e 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; @@ -133,102 +133,100 @@ export function EngagementRail({ )} aria-label="Discussion and related" > -
-
- {onBackToFeed && ( -
- -
+ )} +
+
+ + {onClose && } +
+
+ )} +
+ {source && ( +
+
+ {source.type === SourceType.Squad ? ( + - -
- )} - {showNavigation && ( -
- {onPreviousPost && ( - -
- )} -
-
- -
-
-
- {source && source.type === SourceType.Squad && ( - - )} - {source && source.type !== SourceType.Squad && ( - + {inlineHeaderMenu && ( +
+ +
+ )} +
)}
- - {displayTitle} - - {post.clickbaitTitleDetected && } {post.summary && (
void; + onClose?: () => void; isPostPage?: boolean; }; diff --git a/packages/shared/src/components/post/reader/ReaderFloatingActionBar.tsx b/packages/shared/src/components/post/reader/ReaderFloatingActionBar.tsx index 44250e65692..3cba359b628 100644 --- a/packages/shared/src/components/post/reader/ReaderFloatingActionBar.tsx +++ b/packages/shared/src/components/post/reader/ReaderFloatingActionBar.tsx @@ -24,12 +24,14 @@ type ReaderFloatingActionBarProps = { onCommentClick: () => void; }; -const FLOATING_ICON_SIZE = IconSize.XSmall; +// DEMO ONLY: bigger touch targets + more breathing room between actions so +// the floating bar reads as the primary "react to this post" surface. +const FLOATING_ICON_SIZE = IconSize.Medium; const countActionButtonClasses = - '!h-8 !min-w-0 !gap-1 !rounded-10 !px-2 !justify-center !font-normal'; + '!h-11 !min-w-0 !gap-1.5 !rounded-12 !px-3 !justify-center !font-normal'; const iconActionButtonClasses = - '!h-8 !w-8 !min-w-8 !rounded-10 !p-0 !justify-center'; -const countClasses = 'text-text-tertiary typo-footnote tabular-nums'; + '!h-11 !w-11 !min-w-11 !rounded-12 !p-0 !justify-center'; +const countClasses = 'text-text-tertiary typo-callout tabular-nums'; export function ReaderFloatingActionBar({ post, @@ -46,7 +48,7 @@ export function ReaderFloatingActionBar({ return (
@@ -120,7 +122,7 @@ export function ReaderFloatingActionBar({ () => {}, ); }, - className: '!h-8 !w-8 !shrink-0', + className: '!h-11 !w-11 !shrink-0', buttonClassName: classNames( iconActionButtonClasses, 'btn-tertiary-bun', diff --git a/packages/shared/src/components/post/reader/ReaderHeaderActionButtons.tsx b/packages/shared/src/components/post/reader/ReaderHeaderActionButtons.tsx index 6ec2c98cc90..33c78090f78 100644 --- a/packages/shared/src/components/post/reader/ReaderHeaderActionButtons.tsx +++ b/packages/shared/src/components/post/reader/ReaderHeaderActionButtons.tsx @@ -2,7 +2,7 @@ import type { ReactElement } from 'react'; import React from 'react'; import { Button, ButtonSize, ButtonVariant } from '../../buttons/Button'; import { - EyeIcon, + EarthIcon, MiniCloseIcon as CloseIcon, SidebarArrowRight, } from '../../icons'; @@ -61,31 +61,50 @@ export function ReaderCloseButton({ type ReaderLegacyLayoutToggleButtonProps = { target?: 'classic' | 'reader'; + /** + * When true, render the toggle as icon + text so users inside the reader + * modal can clearly find the exit. The standalone post page keeps the + * icon-only variant for a compact chrome. + */ + showLabel?: boolean; }; export function ReaderLegacyLayoutToggleButton({ target = 'classic', + showLabel = false, }: ReaderLegacyLayoutToggleButtonProps): ReactElement { const { optIn, optOut } = useLegacyPostLayoutOptOut(); const isClassicTarget = target === 'classic'; + const label = isClassicTarget ? 'Close preview' : 'Open preview'; + const ariaLabel = label; + const onClick = isClassicTarget ? () => optOut() : optIn; + + if (showLabel) { + return ( + + ); + } return ( - +
+ ) : undefined + } /> {!hasEmbeddedReaderHeader && ( )} @@ -237,6 +247,18 @@ export function ReaderPostLayout({ onCommentClick={focusDiscussionComposer} />
+ {isRailOpen && ( + + )}
diff --git a/packages/shared/src/components/post/reader/hooks/useReaderLayoutPrefs.ts b/packages/shared/src/components/post/reader/hooks/useReaderLayoutPrefs.ts index 9daca6bdef2..ee6bc89072f 100644 --- a/packages/shared/src/components/post/reader/hooks/useReaderLayoutPrefs.ts +++ b/packages/shared/src/components/post/reader/hooks/useReaderLayoutPrefs.ts @@ -9,7 +9,10 @@ const LEGACY_STORAGE_KEY_RAIL_WIDTH_PX = 'readerModal.railWidthPx'; // Persisted rail width is stored as a fraction of the layout container so the // chosen proportion carries across screen sizes and between the reader modal // (capped container) and the standalone post page (full-width container). -const DEFAULT_RAIL_WIDTH_RATIO = 0.3; +// DEMO ONLY: bumped the default ratio so the rail starts noticeably wider +// — there's enough room for the TL;DR + comments without immediately +// dragging the divider. +const DEFAULT_RAIL_WIDTH_RATIO = 0.36; const MIN_RAIL_WIDTH_RATIO = 0.2; const MAX_RAIL_WIDTH_RATIO = 0.55; // Absolute pixel bounds keep the rail usable on very small or very large diff --git a/packages/shared/src/components/post/reader/hooks/useReaderModalEligibility.ts b/packages/shared/src/components/post/reader/hooks/useReaderModalEligibility.ts index 314bc2bf095..9b510c3982e 100644 --- a/packages/shared/src/components/post/reader/hooks/useReaderModalEligibility.ts +++ b/packages/shared/src/components/post/reader/hooks/useReaderModalEligibility.ts @@ -34,5 +34,15 @@ export function useReaderModalEligibility(): UseReaderModalEligibilityResult { shouldEvaluate: isEligible, }); - return { isEligible, isReaderModalEnabled, isReaderFeatureLoading }; + // DEMO ONLY: force-enable the reader modal experiment so we can record a + // walkthrough of the next iteration (install-prompt + layout swap) before + // engineering wires up the real GrowthBook variant. Remove this override + // when the production implementation lands. + const demoForceEnabled = isEligible; + + return { + isEligible, + isReaderModalEnabled: demoForceEnabled || isReaderModalEnabled, + isReaderFeatureLoading, + }; } diff --git a/packages/shared/src/features/extensionEmbed/EmbeddedBrowsingWebPrompt.module.css b/packages/shared/src/features/extensionEmbed/EmbeddedBrowsingWebPrompt.module.css index de617f3705e..6a5f392a1c2 100644 --- a/packages/shared/src/features/extensionEmbed/EmbeddedBrowsingWebPrompt.module.css +++ b/packages/shared/src/features/extensionEmbed/EmbeddedBrowsingWebPrompt.module.css @@ -43,61 +43,69 @@ position: absolute; inset: 0; overflow: hidden; - opacity: 0.44; + opacity: 0.5; background: - repeating-linear-gradient( - 120deg, - color-mix(in srgb, var(--theme-border-subtlest-tertiary) 12%, transparent) + radial-gradient( + circle at 50% 42%, + color-mix(in srgb, var(--theme-accent-bun-default) 16%, transparent) 0, + transparent 18rem + ), + radial-gradient( + circle at 38% 58%, + color-mix(in srgb, var(--theme-accent-avocado-default) 10%, transparent) 0, - color-mix(in srgb, var(--theme-border-subtlest-tertiary) 12%, transparent) - 0.75rem, - transparent 0.75rem, - transparent 1.5rem + transparent 16rem ), - linear-gradient( - 180deg, - color-mix(in srgb, var(--theme-text-tertiary) 5%, transparent), - color-mix(in srgb, var(--theme-border-subtlest-tertiary) 6%, transparent) + radial-gradient( + circle at 62% 58%, + color-mix(in srgb, var(--theme-accent-blueCheese-default) 10%, transparent) + 0, + transparent 16rem ); - background-size: 190% 190%, 100% 100%; - animation: embeddedBrowsingStripeShift 42s linear infinite; - will-change: background-position; + animation: embeddedBrowsingGlow 6s ease-in-out infinite alternate; + will-change: opacity, transform; } .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; + top: 50%; + left: 50%; + width: 12rem; + height: 12rem; + border: 1px solid + color-mix(in srgb, var(--theme-text-tertiary) 16%, transparent); + border-radius: 9999px; + opacity: 0.22; + transform: translate(-50%, -50%) scale(0.9); + animation: embeddedBrowsingPulse 3.8s ease-in-out infinite; } -@keyframes embeddedBrowsingStripeShift { +@keyframes embeddedBrowsingGlow { 0% { - background-position: 0% 0%, 50% 50%; + opacity: 0.36; + transform: scale(0.99); } 100% { - background-position: 220% 120%, 50% 50%; + opacity: 0.54; + transform: scale(1.01); } } -@keyframes embeddedBrowsingStripeShiftReverse { +@keyframes embeddedBrowsingPulse { 0% { - background-position: 220% 140%; + opacity: 0.08; + transform: translate(-50%, -50%) scale(0.82); + } + + 55% { + opacity: 0.22; } 100% { - background-position: 0% 0%; + opacity: 0; + transform: translate(-50%, -50%) scale(1.35); } } @@ -108,7 +116,6 @@ } .ambient { - background-position: 50% 50%, 50% 50%; opacity: 0.42; } diff --git a/packages/shared/src/features/extensionEmbed/EmbeddedBrowsingWebPrompt.tsx b/packages/shared/src/features/extensionEmbed/EmbeddedBrowsingWebPrompt.tsx index a2821ba1a6c..775f574c38c 100644 --- a/packages/shared/src/features/extensionEmbed/EmbeddedBrowsingWebPrompt.tsx +++ b/packages/shared/src/features/extensionEmbed/EmbeddedBrowsingWebPrompt.tsx @@ -73,23 +73,23 @@ export function EmbeddedBrowsingWebPrompt({
-
+
- Enable embedded browsing + Read it right here. - Preview and open sites directly inside daily.dev. To use this - feature, install the daily.dev browser extension. + Install the extension to open articles inside daily.dev with the + discussion next to them.
) : null}
diff --git a/packages/shared/src/hooks/useReaderInstallPromptGate.ts b/packages/shared/src/hooks/useReaderInstallPromptGate.ts new file mode 100644 index 00000000000..72170b2d023 --- /dev/null +++ b/packages/shared/src/hooks/useReaderInstallPromptGate.ts @@ -0,0 +1,75 @@ +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 { useReaderModalEligibility } from '../components/post/reader/hooks/useReaderModalEligibility'; +import { PostType } from '../graphql/posts'; +import { useLegacyPostLayoutOptOut } from '../components/post/reader/hooks/useLegacyPostLayoutOptOut'; + +const READER_GATE_ELIGIBLE_TYPES = new Set([ + PostType.Article, + PostType.Digest, + PostType.VideoYouTube, +]); + +interface UseReaderInstallPromptGateResult { + isGated: boolean; + /** + * Returns `true` when the gate has intercepted the click and opened the + * install prompt; the caller should then skip its default click behavior. + * Returns `false` otherwise (gate inactive, modifier keys, non-eligible + * post) so the caller can fall through to its normal handler. + */ + onReadClick: (event: MouseEvent) => boolean; +} + +/** + * DEMO ONLY: gate the "Read post" click on eligible posts behind the new + * install-extension prompt. Replaces the default browser navigation with the + * `LazyModal.ReaderInstallPrompt` flow so the user can preview the next-step + * UX without installing. + * + * The hook never swallows modifier-key clicks (cmd / ctrl / middle-click) + * so users can still open the article in a new tab if they want to. + */ +export function useReaderInstallPromptGate( + post: Post | undefined, +): UseReaderInstallPromptGateResult { + const { openModal } = useLazyModal(); + const { isEligible, isReaderModalEnabled } = useReaderModalEligibility(); + const { isOptedOut: isLegacyLayoutOptedOut } = useLegacyPostLayoutOptOut(); + + const isGated = + !!post && + isEligible && + isReaderModalEnabled && + !isLegacyLayoutOptedOut && + READER_GATE_ELIGIBLE_TYPES.has(post.type); + + const onReadClick = useCallback( + (event: MouseEvent): boolean => { + if (!isGated || !post) { + return false; + } + if ( + event.metaKey || + event.ctrlKey || + event.shiftKey || + event.button > 0 + ) { + return false; + } + event.preventDefault(); + event.stopPropagation(); + openModal({ + type: LazyModal.ReaderInstallPrompt, + props: { post }, + }); + return true; + }, + [isGated, openModal, post], + ); + + return { isGated, onReadClick }; +} diff --git a/packages/shared/src/lib/log.ts b/packages/shared/src/lib/log.ts index 67409532ad4..e05f65db081 100644 --- a/packages/shared/src/lib/log.ts +++ b/packages/shared/src/lib/log.ts @@ -467,6 +467,7 @@ export enum LogEvent { ToggleEmbeddedReader = 'toggle embedded reader', ImpressionReaderInstallPrompt = 'impression reader install prompt', ClickReaderInstallExtension = 'click reader install extension', + 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 cb1b096f71f..83912ccfa4b 100644 --- a/packages/webapp/pages/posts/[id]/index.tsx +++ b/packages/webapp/pages/posts/[id]/index.tsx @@ -139,6 +139,9 @@ const READER_ELIGIBLE_POST_TYPES = new Set([ PostType.VideoYouTube, ]); +// DEMO ONLY: only set the viewport-height bounds here. Width capping and +// centering are owned by `ReaderPostLayout` itself so the post page and +// the modal use the exact same shell. const READER_PAGE_LAYOUT_CLASS_NAME = 'flex h-[calc(100vh-4rem)] max-h-[calc(100vh-4rem)] min-h-0 w-full flex-col'; @@ -348,8 +351,12 @@ export const PostPage = ({ }; PostPage.getLayout = getLayout; +// DEMO ONLY: `screenCentered: true` removes the `laptop:!pl-60` expansion +// that MainLayout otherwise adds when the sidebar is expanded, so the +// reader-layout post page can be centered on the viewport like the +// reader modal portal — same shell, same width, same behavior. PostPage.layoutProps = { - screenCentered: false, + screenCentered: true, customBanner: , };