From 562c4d57e428db811abab7ad097bcdad5f011945 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Wed, 27 May 2026 11:42:38 +0300 Subject: [PATCH 01/25] feat(onboarding): add new tab activation primer before signup wall Insert a permission-priming step at the very top of the post-install onboarding flow so users see an explicit "this is what Chrome is about to ask and which button to tap" screen before they encounter Chrome's "Change back to Google?" override-confirmation bubble. The screen recreates the dialog visually, highlights the "Keep it" button with a brand-color callout, and addresses developer skepticism with concrete trust claims (no browsing history, no clickbait, reversible in chrome://extensions). The webapp asks the extension to programmatically open chrome://newtab via the existing ping content-script bridge so the bubble appears while the user is still primed. Success is detected via a localStorage signal written by the ping script after the new-tab page broadcasts activation; failure falls through to a recovery screen with a "I activated it" retry and a "Continue without new tab" skip path. Gated behind featureOnboardingPermissionPrimer (default off) for A/B rollout. Re-entry via the existing ?r=extension param from HijackingLoginStrip now correctly skips the primer. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/extension/src/background/index.ts | 51 +++ packages/extension/src/newtab/App.tsx | 24 ++ packages/extension/src/ping/index.ts | 53 +++ .../extensionEmbed/newTabActivationBridge.ts | 88 ++++ .../components/NewTabActivationPrimer.tsx | 395 ++++++++++++++++++ packages/shared/src/lib/extension.ts | 3 + packages/shared/src/lib/featureManagement.ts | 5 + packages/shared/src/lib/log.ts | 8 + packages/webapp/pages/onboarding.tsx | 59 +++ 9 files changed, 686 insertions(+) create mode 100644 packages/shared/src/features/extensionEmbed/newTabActivationBridge.ts create mode 100644 packages/shared/src/features/onboarding/components/NewTabActivationPrimer.tsx diff --git a/packages/extension/src/background/index.ts b/packages/extension/src/background/index.ts index c2f5f2e968f..fdba2628c65 100644 --- a/packages/extension/src/background/index.ts +++ b/packages/extension/src/background/index.ts @@ -194,6 +194,57 @@ async function handleMessages( return { ready: true }; } + if (message.type === ExtensionMessageType.RequestOpenNewTab) { + // Trigger Chrome's "Keep changes / Change it back" confirmation bubble + // by opening a real new tab. The literal `chrome://newtab` URL is + // required — `chrome.runtime.getURL('index.html')` loads the override + // page directly via `chrome-extension://` and does NOT register as an + // NTP visit, so the bubble would be deferred to the next real new tab. + try { + await browser.tabs.create({ url: 'chrome://newtab', active: true }); + return { triggered: true }; + } catch (error) { + return { + triggered: false, + error: + error instanceof Error ? error.message : 'Failed to open new tab', + }; + } + } + + if (message.type === ExtensionMessageType.NewTabActivated) { + // Forwarded from the post-install new tab page so the primer (running + // in another tab on daily.dev) can learn the override was accepted. + // We broadcast to every daily.dev tab; the ping content script in each + // writes a localStorage marker the primer polls. + try { + const tabs = await browser.tabs.query({ + url: ['https://daily.dev/*', 'https://*.daily.dev/*'], + }); + await Promise.all( + tabs.map((tab) => { + if (typeof tab.id !== 'number') { + return Promise.resolve(); + } + return browser.tabs + .sendMessage(tab.id, { + type: ExtensionMessageType.NotifyNewTabActivated, + }) + .catch(() => undefined); + }), + ); + return { delivered: true }; + } catch (error) { + return { + delivered: false, + error: + error instanceof Error + ? error.message + : 'Failed to broadcast new tab activation', + }; + } + } + 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 diff --git a/packages/extension/src/newtab/App.tsx b/packages/extension/src/newtab/App.tsx index 5376f830e7a..c6d6510c38b 100644 --- a/packages/extension/src/newtab/App.tsx +++ b/packages/extension/src/newtab/App.tsx @@ -26,6 +26,9 @@ import { useHostStatus } from '@dailydotdev/shared/src/hooks/useHostPermissionSt import ExtensionPermissionsPrompt from '@dailydotdev/shared/src/components/ExtensionPermissionsPrompt'; import { useExtensionContext } from '@dailydotdev/shared/src/contexts/ExtensionContext'; import { useConsoleLogo } from '@dailydotdev/shared/src/hooks/useConsoleLogo'; +import { useLogContext } from '@dailydotdev/shared/src/contexts/LogContext'; +import { LogEvent } from '@dailydotdev/shared/src/lib/log'; +import { ExtensionMessageType } from '@dailydotdev/shared/src/lib/extension'; import { DndContextProvider } from '@dailydotdev/shared/src/contexts/DndContext'; import { structuredCloneJsonPolyfill } from '@dailydotdev/shared/src/lib/structuredClone'; import { useOnboardingActions } from '@dailydotdev/shared/src/hooks/auth'; @@ -46,6 +49,7 @@ import { useContentScriptStatus } from '../../../shared/src/hooks'; structuredCloneJsonPolyfill(); const DEFAULT_TAB_TITLE = 'New Tab'; +const NEWTAB_ACTIVATED_KEY = 'daily-extension-newtab-activated-fired'; const router = new CustomRouter(); const queryClient = new QueryClient(defaultQueryClientConfig); Modal.setAppElement('#__next'); @@ -103,6 +107,7 @@ function InternalApp(): ReactElement { const { hostGranted, isFetching: isCheckingHostPermissions } = useHostStatus(); const routeChangedCallbackRef = useLogPageView(); + const { logEvent } = useLogContext(); useConsoleLogo(); const { user, isAuthReady } = useAuthContext(); const { growthbook } = useGrowthBookContext(); @@ -136,6 +141,25 @@ function InternalApp(): ReactElement { } }, [contentScriptGranted]); + useEffect(() => { + // Fire the activation signal the first time the daily.dev new tab + // renders for this install — the only reliable success signal we have + // for Chrome's "Keep changes / Change it back" override bubble. The + // companion broadcast lets the post-install primer in the webapp tab + // know the user accepted the override. + if (!isPageReady) { + return; + } + if (localStorage.getItem(NEWTAB_ACTIVATED_KEY)) { + return; + } + localStorage.setItem(NEWTAB_ACTIVATED_KEY, Date.now().toString()); + logEvent({ event_name: LogEvent.ExtensionNewTabActivated }); + browser.runtime + .sendMessage({ type: ExtensionMessageType.NewTabActivated }) + .catch(() => undefined); + }, [isPageReady, logEvent]); + useEffect(() => { document.title = unreadCount ? `(${unreadCount}) ${DEFAULT_TAB_TITLE}` diff --git a/packages/extension/src/ping/index.ts b/packages/extension/src/ping/index.ts index 4c71ef98909..e7546051e57 100644 --- a/packages/extension/src/ping/index.ts +++ b/packages/extension/src/ping/index.ts @@ -22,6 +22,12 @@ import type { PagePermissionBridgeResult, PermissionGrantResponse, } from '@dailydotdev/shared/src/features/extensionEmbed/pagePermissionBridge'; +import { + newTabActivationBridgeRequestEvent, + newTabActivationBridgeResultEvent, + newTabActivationSuccessKey, +} from '@dailydotdev/shared/src/features/extensionEmbed/newTabActivationBridge'; +import type { NewTabActivationBridgeResult } from '@dailydotdev/shared/src/features/extensionEmbed/newTabActivationBridge'; const INSTALL_MARKER = 'dailyExtensionInstalled'; const ID_MARKER = 'dailyExtensionId'; @@ -65,6 +71,53 @@ const waitForExtensionReady = async (): Promise => { } }; +browser.runtime.onMessage.addListener((message: { type?: string }) => { + if (message?.type === ExtensionMessageType.NotifyNewTabActivated) { + // Broadcast from the background after the post-install new tab page + // rendered. Writing localStorage here surfaces the signal to the + // primer running on this same daily.dev origin. + try { + localStorage.setItem(newTabActivationSuccessKey, Date.now().toString()); + } catch { + // localStorage can fail in private contexts; the primer will fall + // back to its timeout-driven recovery screen. + } + } + return undefined; +}); + +window.addEventListener(newTabActivationBridgeRequestEvent, () => { + const dispatchResult = (result: NewTabActivationBridgeResult): void => { + window.dispatchEvent( + new CustomEvent( + newTabActivationBridgeResultEvent, + { detail: result }, + ), + ); + }; + + browser.runtime + .sendMessage({ + type: ExtensionMessageType.RequestOpenNewTab, + }) + .then((response) => { + const typed = response as NewTabActivationBridgeResult | undefined; + dispatchResult({ + triggered: !!typed?.triggered, + error: typed?.error, + }); + }) + .catch((error: unknown) => { + dispatchResult({ + triggered: false, + error: + error instanceof Error + ? error.message + : 'Failed to request new tab open', + }); + }); +}); + window.addEventListener(pagePermissionBridgeRequestEvent, () => { const dispatchResult = (result: PagePermissionBridgeResult): void => { window.dispatchEvent( diff --git a/packages/shared/src/features/extensionEmbed/newTabActivationBridge.ts b/packages/shared/src/features/extensionEmbed/newTabActivationBridge.ts new file mode 100644 index 00000000000..8878c2a1b8d --- /dev/null +++ b/packages/shared/src/features/extensionEmbed/newTabActivationBridge.ts @@ -0,0 +1,88 @@ +// Page <-> content-script bridge for the post-install activation primer. +// +// The webapp asks the extension to open `chrome://newtab` so Chrome shows +// the override-confirmation bubble while the user is still in our primer +// context. The webapp dispatches a CustomEvent on `window`; the ping +// content script forwards it to the background, which calls +// `chrome.tabs.create({ url: 'chrome://newtab' })`. The literal +// `chrome://newtab` URL is required — passing the override page via +// `chrome.runtime.getURL` would not register as an NTP visit and would +// not trigger the bubble. +// +// The outcome of the bubble is not directly observable. We infer it from +// two side channels written by the extension into localStorage on the +// daily.dev origin: +// - `newTabActivationSuccessKey` is written by the override page when it +// renders post-install (success — the user kept our override). +// - `newTabActivationRejectedKey` is written by the background when +// `chrome.management.onDisabled` fires (best-effort rejection signal — +// Chrome disables the extension when the user picks "Change it back"). +// The primer polls both keys after triggering the new tab and falls back +// to a recovery screen if neither resolves within a short window. + +export const newTabActivationBridgeRequestEvent = + 'daily-extension-request-open-new-tab'; +export const newTabActivationBridgeResultEvent = + 'daily-extension-open-new-tab-result'; + +export type NewTabActivationBridgeResult = { + triggered: boolean; + error?: string; +}; + +// Storage keys read by the primer and written by the extension. The keys +// are scoped per-install via a millisecond timestamp so a fresh primer +// session does not get a stale signal from a previous attempt. +export const newTabActivationSuccessKey = 'daily-extension-newtab-activated'; +export const newTabActivationRejectedKey = 'daily-extension-newtab-rejected'; + +const PAGE_HELPER_TIMEOUT_MS = 10_000; + +// Drives `chrome.tabs.create({ url: 'chrome://newtab' })` through the +// installed extension's content script. Returns once the background has +// confirmed the tab was opened; observing whether the user accepted the +// override is the primer's responsibility (via the storage keys above). +export const requestOpenNewTabFromPage = ( + timeoutMs: number = PAGE_HELPER_TIMEOUT_MS, +): Promise => { + if (typeof window === 'undefined') { + return Promise.resolve({ + triggered: 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(newTabActivationBridgeResultEvent, onResult); + if (timeoutId !== undefined) { + globalThis.clearTimeout(timeoutId); + } + const { detail } = event as CustomEvent; + resolve({ + triggered: !!detail?.triggered, + error: detail?.error, + }); + }; + + timeoutId = globalThis.setTimeout(() => { + if (settled) { + return; + } + settled = true; + window.removeEventListener(newTabActivationBridgeResultEvent, onResult); + resolve({ triggered: false, error: 'timeout' }); + }, timeoutMs); + + window.addEventListener(newTabActivationBridgeResultEvent, onResult); + + window.dispatchEvent(new CustomEvent(newTabActivationBridgeRequestEvent)); + }); +}; diff --git a/packages/shared/src/features/onboarding/components/NewTabActivationPrimer.tsx b/packages/shared/src/features/onboarding/components/NewTabActivationPrimer.tsx new file mode 100644 index 00000000000..9d649936e17 --- /dev/null +++ b/packages/shared/src/features/onboarding/components/NewTabActivationPrimer.tsx @@ -0,0 +1,395 @@ +import type { ReactElement } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import classNames from 'classnames'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '../../../components/buttons/Button'; +import { + Typography, + TypographyColor, + TypographyTag, + TypographyType, +} from '../../../components/typography/Typography'; +import { useLogContext } from '../../../contexts/LogContext'; +import { LogEvent } from '../../../lib/log'; +import { + newTabActivationRejectedKey, + newTabActivationSuccessKey, + requestOpenNewTabFromPage, +} from '../../extensionEmbed/newTabActivationBridge'; + +type NewTabActivationPrimerProps = { + onComplete: () => void; +}; + +type PrimerState = 'idle' | 'waiting' | 'recovery'; + +const ACTIVATION_TIMEOUT_MS = 10_000; +const POLL_INTERVAL_MS = 250; + +const clearActivationStorage = (): void => { + try { + localStorage.removeItem(newTabActivationSuccessKey); + localStorage.removeItem(newTabActivationRejectedKey); + } catch { + // Ignore — storage failures fall back to the recovery screen. + } +}; + +const readActivationStorage = (): { + success: boolean; + rejected: boolean; +} => { + try { + return { + success: !!localStorage.getItem(newTabActivationSuccessKey), + rejected: !!localStorage.getItem(newTabActivationRejectedKey), + }; + } catch { + return { success: false, rejected: false }; + } +}; + +// Recreation of Chrome's "Change back to Google?" override-confirmation +// bubble. Button colors match Chrome's actual palette (#1a73e8 / #d3e3fd) +// so when the real dialog appears the user sees identical UI to the +// preview. The "Tap this" callout sits outside the dialog frame in our +// brand color so it reads clearly as our annotation, not part of Chrome's +// surface. +function ChromeDialogMockup(): ReactElement { + return ( +
+
+
+
+ + + + + + +
+
+

+ Change back to Google? +

+

+ This page was changed by the "daily.dev" extension. +

+
+
+
+ + Keep it + + + Change it back + +
+
+ +
+
+ + + + + Tap this button + +
+
+
+ ); +} + +function TrustBullets(): ReactElement { + const items: Array<{ title: string; body: string }> = [ + { + title: 'No browsing history.', + body: 'The new tab only fetches your daily.dev feed.', + }, + { + title: 'No clickbait.', + body: 'A curated stream of dev news ranked by what other developers find useful.', + }, + { + title: 'One click to disable.', + body: 'chrome://extensions → daily.dev → toggle off.', + }, + ]; + + return ( +
    + {items.map((item) => ( +
  • + + + + + {item.title}{' '} + {item.body} + +
  • + ))} +
+ ); +} + +function WhyChromeAsks(): ReactElement { + const [open, setOpen] = useState(false); + + return ( +
+ + {open && ( + + Chrome shows this any time an extension changes your new tab — + it's a privacy guardrail we actually appreciate, even when the + wording is confusing. + + )} +
+ ); +} + +export function NewTabActivationPrimer({ + onComplete, +}: NewTabActivationPrimerProps): ReactElement { + const { logEvent } = useLogContext(); + const [state, setState] = useState('idle'); + const pollTimerRef = useRef>(); + const timeoutTimerRef = useRef>(); + const completedRef = useRef(false); + + const stopPolling = useCallback((): void => { + if (pollTimerRef.current !== undefined) { + globalThis.clearInterval(pollTimerRef.current); + pollTimerRef.current = undefined; + } + if (timeoutTimerRef.current !== undefined) { + globalThis.clearTimeout(timeoutTimerRef.current); + timeoutTimerRef.current = undefined; + } + }, []); + + const finish = useCallback( + (reason: 'activated' | 'skipped'): void => { + if (completedRef.current) { + return; + } + completedRef.current = true; + stopPolling(); + if (reason === 'activated') { + logEvent({ event_name: LogEvent.ExtensionNewTabActivated }); + } + onComplete(); + }, + [logEvent, onComplete, stopPolling], + ); + + useEffect(() => { + logEvent({ event_name: LogEvent.ExtensionPrimerShown }); + clearActivationStorage(); + return stopPolling; + }, [logEvent, stopPolling]); + + const startPolling = useCallback((): void => { + stopPolling(); + pollTimerRef.current = globalThis.setInterval(() => { + const { success, rejected } = readActivationStorage(); + if (success) { + finish('activated'); + return; + } + if (rejected) { + stopPolling(); + logEvent({ event_name: LogEvent.ExtensionDialogRejected }); + logEvent({ event_name: LogEvent.ExtensionPrimerRecoveryShown }); + setState('recovery'); + } + }, POLL_INTERVAL_MS); + + timeoutTimerRef.current = globalThis.setTimeout(() => { + stopPolling(); + if (completedRef.current) { + return; + } + logEvent({ event_name: LogEvent.ExtensionPrimerRecoveryShown }); + setState('recovery'); + }, ACTIVATION_TIMEOUT_MS); + }, [finish, logEvent, stopPolling]); + + const handleActivateClick = useCallback(async (): Promise => { + logEvent({ event_name: LogEvent.ExtensionPrimerCtaClick }); + setState('waiting'); + startPolling(); + const result = await requestOpenNewTabFromPage(); + if (result.triggered) { + logEvent({ event_name: LogEvent.ExtensionNewTabTriggered }); + return; + } + stopPolling(); + if (completedRef.current) { + return; + } + logEvent({ event_name: LogEvent.ExtensionPrimerRecoveryShown }); + setState('recovery'); + }, [logEvent, startPolling, stopPolling]); + + const handleRetryClick = useCallback((): void => { + clearActivationStorage(); + startPolling(); + setState('waiting'); + }, [startPolling]); + + const handleSkipClick = useCallback((): void => { + finish('skipped'); + }, [finish]); + + const isRecovery = state === 'recovery'; + const isWaiting = state === 'waiting'; + + return ( +
+
+
+ + {isRecovery ? 'Almost there' : 'Your new tab, made for developers'} + + + {isRecovery ? ( + <> + Looks like daily.dev wasn't set as your new tab. If you + tapped{' '} + + “Change it back” + {' '} + by mistake, re-enable daily.dev in{' '} + chrome://extensions — + or continue and we'll set you up without it. + + ) : ( + <> + In a moment Chrome will ask{' '} + + “Change back to Google?” + + . Tap{' '} + Keep it to + make daily.dev your homepage for every new tab. + + )} + +
+ + {!isRecovery && } + + {!isRecovery && } + + {!isRecovery && ( +
+ + +
+ )} + + {isRecovery && ( +
+ + +
+ )} +
+
+ ); +} diff --git a/packages/shared/src/lib/extension.ts b/packages/shared/src/lib/extension.ts index c6dc5019467..b5347bff798 100644 --- a/packages/shared/src/lib/extension.ts +++ b/packages/shared/src/lib/extension.ts @@ -8,6 +8,9 @@ export enum ExtensionMessageType { DisableFrameEmbeddingForTab = 'DISABLE_FRAME_EMBEDDING_FOR_TAB', RequestFrameEmbeddingPermissions = 'REQUEST_FRAME_EMBEDDING_PERMISSIONS', PingFrameEmbeddingReady = 'PING_FRAME_EMBEDDING_READY', + RequestOpenNewTab = 'REQUEST_OPEN_NEW_TAB', + NewTabActivated = 'NEW_TAB_ACTIVATED', + NotifyNewTabActivated = 'NOTIFY_NEW_TAB_ACTIVATED', } export const getCompanionWrapper = (): HTMLElement | null => diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts index aca27fe03ee..2bbb359c526 100644 --- a/packages/shared/src/lib/featureManagement.ts +++ b/packages/shared/src/lib/featureManagement.ts @@ -62,6 +62,11 @@ export const featureSmartComposer = new Feature('smart_composer', false); export const featureStandupCreation = new Feature('standup_creation', false); +export const featureOnboardingPermissionPrimer = new Feature( + 'onboarding_permission_primer', + false, +); + export const featureAutorotateAds = new Feature('autorotate_ads', 0); export const featureFeedAdTemplate = new Feature('feed_ad_template', { diff --git a/packages/shared/src/lib/log.ts b/packages/shared/src/lib/log.ts index 6cd328eaf04..3045bf71d6f 100644 --- a/packages/shared/src/lib/log.ts +++ b/packages/shared/src/lib/log.ts @@ -486,6 +486,14 @@ export enum LogEvent { ReaderEmbedError = 'reader embed error', // Onboarding personas SelectOnboardingPersona = 'select onboarding persona', + // Extension activation primer + ExtensionPrimerShown = 'impression extension primer', + ExtensionPrimerCtaClick = 'click extension primer cta', + ExtensionNewTabTriggered = 'trigger extension new tab', + ExtensionNewTabActivated = 'activate extension new tab', + ExtensionDialogRejected = 'reject extension new tab', + ExtensionPrimerRecoveryShown = 'impression extension primer recovery', + ExtensionPrimerSkipped = 'skip extension primer', } export enum TargetType { diff --git a/packages/webapp/pages/onboarding.tsx b/packages/webapp/pages/onboarding.tsx index fd07902fa3e..d40330e9fab 100644 --- a/packages/webapp/pages/onboarding.tsx +++ b/packages/webapp/pages/onboarding.tsx @@ -62,6 +62,12 @@ import { FunnelStepper } from '@dailydotdev/shared/src/features/onboarding/share import { useOnboardingActions } from '@dailydotdev/shared/src/hooks/auth'; import { ActionType } from '@dailydotdev/shared/src/graphql/actions'; import { isLocalhost } from '@dailydotdev/shared/src/lib/config'; +import { useConditionalFeature } from '@dailydotdev/shared/src/hooks/useConditionalFeature'; +import { featureOnboardingPermissionPrimer } from '@dailydotdev/shared/src/lib/featureManagement'; +import { useIsBrowserExtensionInstalled } from '@dailydotdev/shared/src/features/extensionEmbed/useIsBrowserExtensionInstalled'; +import { useLogContext } from '@dailydotdev/shared/src/contexts/LogContext'; +import { LogEvent } from '@dailydotdev/shared/src/lib/log'; +import { NewTabActivationPrimer } from '@dailydotdev/shared/src/features/onboarding/components/NewTabActivationPrimer'; import { getPageSeoTitles } from '../components/layouts/utils'; import { defaultOpenGraph, defaultSeo } from '../next-seo'; @@ -268,6 +274,8 @@ const useOnboardingAuth = () => { }; }; +const PRIMER_COMPLETED_KEY = 'daily-extension-primer-completed'; + function Onboarding({ initialStepId }: PageProps): ReactElement { const router = useRouter(); const { @@ -280,6 +288,47 @@ function Onboarding({ initialStepId }: PageProps): ReactElement { const { isOnboardingComplete, isOnboardingActionsReady, completeStep } = useOnboardingActions(); const [isFunnelReady, setFunnelReady] = useState(false); + const { logEvent } = useLogContext(); + const { isInstalled: isExtensionInstalled } = + useIsBrowserExtensionInstalled(); + const isExtensionReentry = router.query.r === 'extension'; + const { value: isPrimerFeatureEnabled, isLoading: isPrimerFeatureLoading } = + useConditionalFeature({ + feature: featureOnboardingPermissionPrimer, + shouldEvaluate: + isAuthReady && + !isLoggedIn && + isExtensionInstalled && + !isExtensionReentry, + }); + const [isPrimerDone, setPrimerDone] = useState(() => { + if (typeof window === 'undefined') { + return false; + } + return !!storage.getItem(PRIMER_COMPLETED_KEY); + }); + + useEffect(() => { + if (isExtensionReentry && !storage.getItem(PRIMER_COMPLETED_KEY)) { + storage.setItem(PRIMER_COMPLETED_KEY, Date.now().toString()); + setPrimerDone(true); + logEvent({ event_name: LogEvent.ExtensionPrimerSkipped }); + } + }, [isExtensionReentry, logEvent]); + + const handlePrimerComplete = useCallback((): void => { + storage.setItem(PRIMER_COMPLETED_KEY, Date.now().toString()); + setPrimerDone(true); + }, []); + + const shouldShowPrimer = + isAuthReady && + !isLoggedIn && + isExtensionInstalled && + !isExtensionReentry && + !isPrimerDone && + !isPrimerFeatureLoading && + isPrimerFeatureEnabled; const onComplete = useCallback(async () => { completeStep(ActionType.CompletedOnboarding); @@ -327,6 +376,16 @@ function Onboarding({ initialStepId }: PageProps): ReactElement { router, ]); + if (shouldShowPrimer) { + return ( +
+ + + +
+ ); + } + if (isAuthenticating) { return (
Date: Wed, 27 May 2026 12:01:12 +0300 Subject: [PATCH 02/25] chore(onboarding): force primer on for local testing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TEMPORARY — remove before merging. Forces the new tab activation primer to render for every fresh install regardless of the GrowthBook featureOnboardingPermissionPrimer value. Marked with TODO(REMOVE-BEFORE-MERGE) so the override is easy to find and revert. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/webapp/pages/onboarding.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/webapp/pages/onboarding.tsx b/packages/webapp/pages/onboarding.tsx index d40330e9fab..fbc92b87231 100644 --- a/packages/webapp/pages/onboarding.tsx +++ b/packages/webapp/pages/onboarding.tsx @@ -301,6 +301,11 @@ function Onboarding({ initialStepId }: PageProps): ReactElement { isExtensionInstalled && !isExtensionReentry, }); + // TODO(REMOVE-BEFORE-MERGE): testing override — forces the primer on for + // every fresh install regardless of the GrowthBook flag, so the flow can + // be exercised end-to-end locally. Delete this constant and the usage in + // `shouldShowPrimer` below before merging. + const FORCE_PRIMER_FOR_TESTING = true; const [isPrimerDone, setPrimerDone] = useState(() => { if (typeof window === 'undefined') { return false; @@ -327,8 +332,10 @@ function Onboarding({ initialStepId }: PageProps): ReactElement { isExtensionInstalled && !isExtensionReentry && !isPrimerDone && - !isPrimerFeatureLoading && - isPrimerFeatureEnabled; + // TODO(REMOVE-BEFORE-MERGE): testing override — replace the next line + // with `!isPrimerFeatureLoading && isPrimerFeatureEnabled` before merge. + (FORCE_PRIMER_FOR_TESTING || + (!isPrimerFeatureLoading && isPrimerFeatureEnabled)); const onComplete = useCallback(async () => { completeStep(ActionType.CompletedOnboarding); From 79759a4295e5a55bf50ed5277de3e81e11849116 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Wed, 27 May 2026 12:16:10 +0300 Subject: [PATCH 03/25] fix(extension): route post-install URL to local webapp in dev builds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously the install URL was hardcoded to the production Rebrandly redirect (https://r.daily.dev/install), which always bounced the post-install tab to app.daily.dev — even for dev/staging builds. That made the local primer flow untestable end-to-end: installing a locally-built extension would open the production webapp, where the new code does not exist. Now in non-production builds the install URL points directly at the configured webapp's /onboarding path. Production behavior is unchanged. Also adds a TODO-marked one-shot console.info on the onboarding page that surfaces the primer gating conditions for local QA — to be removed along with the FORCE_PRIMER_FOR_TESTING override before merge. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/shared/src/lib/constants.ts | 9 ++++++++- packages/webapp/pages/onboarding.tsx | 30 ++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/packages/shared/src/lib/constants.ts b/packages/shared/src/lib/constants.ts index c7907deefda..d31197b7b88 100644 --- a/packages/shared/src/lib/constants.ts +++ b/packages/shared/src/lib/constants.ts @@ -17,7 +17,14 @@ export const companionPermissionGrantedLink = 'https://r.daily.dev/try-the-companion'; export const recruiterScheduleUrl = 'https://recruiter.daily.dev/schedule'; export const initialDataKey = 'initial'; -export const install = 'https://r.daily.dev/install'; +// In production, point at the Rebrandly redirect so install attribution +// is tracked. In dev/staging builds, route directly to the configured +// webapp's onboarding page so the post-install flow exercises the local +// build instead of bouncing to prod. +export const install = + process.env.NEXT_PUBLIC_API_URL === 'https://api.daily.dev' + ? 'https://r.daily.dev/install' + : `${process.env.NEXT_PUBLIC_WEBAPP_URL ?? ''}onboarding`; export const uninstall = 'https://r.daily.dev/uninstall'; export const sharingBookmarks = 'https://r.daily.dev/sharing-bookmarks'; export const devCard = 'https://r.daily.dev/devcard-github'; diff --git a/packages/webapp/pages/onboarding.tsx b/packages/webapp/pages/onboarding.tsx index fbc92b87231..c454c1c527e 100644 --- a/packages/webapp/pages/onboarding.tsx +++ b/packages/webapp/pages/onboarding.tsx @@ -326,6 +326,36 @@ function Onboarding({ initialStepId }: PageProps): ReactElement { setPrimerDone(true); }, []); + // TODO(REMOVE-BEFORE-MERGE): one-shot console log so the primer gating + // conditions can be inspected during local QA. Delete before merge. + useEffect(() => { + if (typeof window === 'undefined') { + return; + } + // eslint-disable-next-line no-console + console.info('[primer-debug]', { + isAuthReady, + isLoggedIn, + isExtensionInstalled, + isExtensionReentry, + isPrimerDone, + isPrimerFeatureLoading, + isPrimerFeatureEnabled, + FORCE_PRIMER_FOR_TESTING, + installedMarker: + document.documentElement?.dataset?.dailyExtensionInstalled, + }); + }, [ + isAuthReady, + isLoggedIn, + isExtensionInstalled, + isExtensionReentry, + isPrimerDone, + isPrimerFeatureLoading, + isPrimerFeatureEnabled, + FORCE_PRIMER_FOR_TESTING, + ]); + const shouldShowPrimer = isAuthReady && !isLoggedIn && From 9666837bb9fd990a81c793e76adcde0f40ae1b97 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Wed, 27 May 2026 12:35:38 +0300 Subject: [PATCH 04/25] chore(onboarding): show primer on every visit in testing override Broadens the FORCE_PRIMER_FOR_TESTING bypass so the primer is visible even when the user is already logged in, has completed it before, lacks the extension marker, or arrived via ?r=extension. Also skips the localStorage write on complete so reloading re-triggers the primer. Promotes the gating-condition console log from .info to .warn so it is hard to miss in DevTools. All marked TODO(REMOVE-BEFORE-MERGE). Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/webapp/pages/onboarding.tsx | 44 ++++++++++++++++++++-------- 1 file changed, 31 insertions(+), 13 deletions(-) diff --git a/packages/webapp/pages/onboarding.tsx b/packages/webapp/pages/onboarding.tsx index c454c1c527e..9c45ea42a16 100644 --- a/packages/webapp/pages/onboarding.tsx +++ b/packages/webapp/pages/onboarding.tsx @@ -276,6 +276,13 @@ const useOnboardingAuth = () => { const PRIMER_COMPLETED_KEY = 'daily-extension-primer-completed'; +// TODO(REMOVE-BEFORE-MERGE): testing override. When true, the new tab +// activation primer renders for every visit to /onboarding regardless of +// auth state, GrowthBook flag, prior completion, extension marker, or +// `?r=extension` re-entry. Delete this constant and restore the original +// `shouldShowPrimer` predicate before merging. +const FORCE_PRIMER_FOR_TESTING = true; + function Onboarding({ initialStepId }: PageProps): ReactElement { const router = useRouter(); const { @@ -301,19 +308,22 @@ function Onboarding({ initialStepId }: PageProps): ReactElement { isExtensionInstalled && !isExtensionReentry, }); - // TODO(REMOVE-BEFORE-MERGE): testing override — forces the primer on for - // every fresh install regardless of the GrowthBook flag, so the flow can - // be exercised end-to-end locally. Delete this constant and the usage in - // `shouldShowPrimer` below before merging. - const FORCE_PRIMER_FOR_TESTING = true; const [isPrimerDone, setPrimerDone] = useState(() => { if (typeof window === 'undefined') { return false; } + // In testing-override mode, never treat the primer as done so it + // re-renders on every reload. + if (FORCE_PRIMER_FOR_TESTING) { + return false; + } return !!storage.getItem(PRIMER_COMPLETED_KEY); }); useEffect(() => { + if (FORCE_PRIMER_FOR_TESTING) { + return; + } if (isExtensionReentry && !storage.getItem(PRIMER_COMPLETED_KEY)) { storage.setItem(PRIMER_COMPLETED_KEY, Date.now().toString()); setPrimerDone(true); @@ -322,7 +332,11 @@ function Onboarding({ initialStepId }: PageProps): ReactElement { }, [isExtensionReentry, logEvent]); const handlePrimerComplete = useCallback((): void => { - storage.setItem(PRIMER_COMPLETED_KEY, Date.now().toString()); + // Skip the persistence write in testing-override mode so reloading + // surfaces the primer again. + if (!FORCE_PRIMER_FOR_TESTING) { + storage.setItem(PRIMER_COMPLETED_KEY, Date.now().toString()); + } setPrimerDone(true); }, []); @@ -333,7 +347,7 @@ function Onboarding({ initialStepId }: PageProps): ReactElement { return; } // eslint-disable-next-line no-console - console.info('[primer-debug]', { + console.warn('[primer-debug]', { isAuthReady, isLoggedIn, isExtensionInstalled, @@ -353,19 +367,23 @@ function Onboarding({ initialStepId }: PageProps): ReactElement { isPrimerDone, isPrimerFeatureLoading, isPrimerFeatureEnabled, - FORCE_PRIMER_FOR_TESTING, ]); - const shouldShowPrimer = + // TODO(REMOVE-BEFORE-MERGE): restore the full gating predicate below + // before merging. The testing override bypasses every condition except + // `isAuthReady` so the primer is visible on every visit, even when + // logged in or with the extension absent. + const shouldShowPrimerProd = isAuthReady && !isLoggedIn && isExtensionInstalled && !isExtensionReentry && !isPrimerDone && - // TODO(REMOVE-BEFORE-MERGE): testing override — replace the next line - // with `!isPrimerFeatureLoading && isPrimerFeatureEnabled` before merge. - (FORCE_PRIMER_FOR_TESTING || - (!isPrimerFeatureLoading && isPrimerFeatureEnabled)); + !isPrimerFeatureLoading && + isPrimerFeatureEnabled; + const shouldShowPrimer = FORCE_PRIMER_FOR_TESTING + ? isAuthReady && !isPrimerDone + : shouldShowPrimerProd; const onComplete = useCallback(async () => { completeStep(ActionType.CompletedOnboarding); From e5dfe1285723843d068b31a65fbc713574095f4a Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Wed, 27 May 2026 12:46:26 +0300 Subject: [PATCH 05/25] Revert "fix(extension): route post-install URL to local webapp in dev builds" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dev webapp URL in .env points at local.fylla.dev, which is not actually wired up on every developer's machine, so the post-install tab opened a blank page instead of the local webapp. Restore the original production Rebrandly redirect. To exercise the primer locally, navigate to /onboarding directly — the FORCE_PRIMER_FOR_TESTING override in onboarding.tsx renders the primer on every visit regardless of how you arrived. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/shared/src/lib/constants.ts | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/packages/shared/src/lib/constants.ts b/packages/shared/src/lib/constants.ts index d31197b7b88..c7907deefda 100644 --- a/packages/shared/src/lib/constants.ts +++ b/packages/shared/src/lib/constants.ts @@ -17,14 +17,7 @@ export const companionPermissionGrantedLink = 'https://r.daily.dev/try-the-companion'; export const recruiterScheduleUrl = 'https://recruiter.daily.dev/schedule'; export const initialDataKey = 'initial'; -// In production, point at the Rebrandly redirect so install attribution -// is tracked. In dev/staging builds, route directly to the configured -// webapp's onboarding page so the post-install flow exercises the local -// build instead of bouncing to prod. -export const install = - process.env.NEXT_PUBLIC_API_URL === 'https://api.daily.dev' - ? 'https://r.daily.dev/install' - : `${process.env.NEXT_PUBLIC_WEBAPP_URL ?? ''}onboarding`; +export const install = 'https://r.daily.dev/install'; export const uninstall = 'https://r.daily.dev/uninstall'; export const sharingBookmarks = 'https://r.daily.dev/sharing-bookmarks'; export const devCard = 'https://r.daily.dev/devcard-github'; From 634361b813445be38d8e9ad25b473b76ea237941 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Wed, 27 May 2026 12:49:01 +0300 Subject: [PATCH 06/25] chore(extension): point dev-build install URL at local staging webapp In dev builds (NODE_ENV=development) route the post-install tab to https://app.staging.daily.dev:5002/onboarding so the primer flow can be tested locally end-to-end. Production builds keep the Rebrandly redirect unchanged. Marked TODO(REMOVE-BEFORE-MERGE). Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/shared/src/lib/constants.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/shared/src/lib/constants.ts b/packages/shared/src/lib/constants.ts index c7907deefda..29d20eca149 100644 --- a/packages/shared/src/lib/constants.ts +++ b/packages/shared/src/lib/constants.ts @@ -17,7 +17,14 @@ export const companionPermissionGrantedLink = 'https://r.daily.dev/try-the-companion'; export const recruiterScheduleUrl = 'https://recruiter.daily.dev/schedule'; export const initialDataKey = 'initial'; -export const install = 'https://r.daily.dev/install'; +// TODO(REMOVE-BEFORE-MERGE): testing override — dev extension builds open +// the local staging webapp so the post-install primer flow can be +// exercised end-to-end. Restore to the single literal +// `'https://r.daily.dev/install'` before merging. +export const install = + process.env.NODE_ENV === 'development' + ? 'https://app.staging.daily.dev:5002/onboarding' + : 'https://r.daily.dev/install'; export const uninstall = 'https://r.daily.dev/uninstall'; export const sharingBookmarks = 'https://r.daily.dev/sharing-bookmarks'; export const devCard = 'https://r.daily.dev/devcard-github'; From 3680c5a389658f887fe887ad85c24eac14b58f28 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Wed, 27 May 2026 12:53:06 +0300 Subject: [PATCH 07/25] chore(extension): hardcode install URL to local staging for testing Replace the NODE_ENV check with a literal staging URL so the post-install tab reliably opens the local primer flow regardless of how the extension is built. Marked TODO(REMOVE-BEFORE-MERGE). Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/shared/src/lib/constants.ts | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/packages/shared/src/lib/constants.ts b/packages/shared/src/lib/constants.ts index 29d20eca149..6c7bef54875 100644 --- a/packages/shared/src/lib/constants.ts +++ b/packages/shared/src/lib/constants.ts @@ -17,14 +17,11 @@ export const companionPermissionGrantedLink = 'https://r.daily.dev/try-the-companion'; export const recruiterScheduleUrl = 'https://recruiter.daily.dev/schedule'; export const initialDataKey = 'initial'; -// TODO(REMOVE-BEFORE-MERGE): testing override — dev extension builds open -// the local staging webapp so the post-install primer flow can be -// exercised end-to-end. Restore to the single literal -// `'https://r.daily.dev/install'` before merging. -export const install = - process.env.NODE_ENV === 'development' - ? 'https://app.staging.daily.dev:5002/onboarding' - : 'https://r.daily.dev/install'; +// TODO(REMOVE-BEFORE-MERGE): testing override — install URL is hardcoded +// to the local staging webapp so the post-install primer flow can be +// exercised end-to-end. Restore to 'https://r.daily.dev/install' before +// merging. +export const install = 'https://app.staging.daily.dev:5002/onboarding'; export const uninstall = 'https://r.daily.dev/uninstall'; export const sharingBookmarks = 'https://r.daily.dev/sharing-bookmarks'; export const devCard = 'https://r.daily.dev/devcard-github'; From 843140a514149c0f60499785e7a961f32f4f5d76 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Wed, 27 May 2026 13:21:07 +0300 Subject: [PATCH 08/25] chore(primer): redirect to production signup wall after trigger Testing override so the full visual flow can be simulated locally: after the primer triggers the new tab (and Chrome's confirmation bubble appears in that new tab), redirect the originating tab to https://app.daily.dev/onboarding so the user lands on the real production signup wall. In production this redirect is unnecessary because the primer is already served from app.daily.dev. Marked TODO(REMOVE-BEFORE-MERGE). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../onboarding/components/NewTabActivationPrimer.tsx | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/shared/src/features/onboarding/components/NewTabActivationPrimer.tsx b/packages/shared/src/features/onboarding/components/NewTabActivationPrimer.tsx index 9d649936e17..5410c466cf5 100644 --- a/packages/shared/src/features/onboarding/components/NewTabActivationPrimer.tsx +++ b/packages/shared/src/features/onboarding/components/NewTabActivationPrimer.tsx @@ -281,6 +281,15 @@ export function NewTabActivationPrimer({ const result = await requestOpenNewTabFromPage(); if (result.triggered) { logEvent({ event_name: LogEvent.ExtensionNewTabTriggered }); + // TODO(REMOVE-BEFORE-MERGE): testing override — after triggering the + // new tab, hand off this tab to the production signup wall so the + // full visual flow (primer → Chrome dialog in the new tab → + // production signup wall here) can be exercised locally. In + // production the primer is already served from app.daily.dev and + // success-detection auto-advances on the same domain; remove this + // redirect before merging so the success path runs. + stopPolling(); + window.location.href = 'https://app.daily.dev/onboarding'; return; } stopPolling(); From 4d159cae3597d2704c395de7c6830b0a1a36b24b Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Wed, 27 May 2026 13:26:00 +0300 Subject: [PATCH 09/25] feat(primer): recovery screen opens chrome://extensions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reworks the primer recovery state to focus on a single, clear action: re-enable daily.dev. Removes the "Continue without new tab" skip path. The new primary CTA bounces a request through the existing ping content-script bridge to the extension's service worker, which calls chrome.tabs.create({ url: 'chrome://extensions' }) — web origins cannot navigate to chrome:// URLs directly. Also adds a stylized mockup of the daily.dev card on the extensions page with a callout on the on/off toggle so users know exactly what to flip. If the bridge fails (extension already disabled, content script gone), nothing visibly happens, but the mockup gives the user enough context to navigate manually. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/extension/src/background/index.ts | 19 +++ packages/extension/src/ping/index.ts | 39 ++++++- .../extensionEmbed/newTabActivationBridge.ts | 62 ++++++++++ .../components/NewTabActivationPrimer.tsx | 110 +++++++++++++----- packages/shared/src/lib/extension.ts | 1 + 5 files changed, 203 insertions(+), 28 deletions(-) diff --git a/packages/extension/src/background/index.ts b/packages/extension/src/background/index.ts index fdba2628c65..91cf71701e2 100644 --- a/packages/extension/src/background/index.ts +++ b/packages/extension/src/background/index.ts @@ -212,6 +212,25 @@ async function handleMessages( } } + if (message.type === ExtensionMessageType.RequestOpenExtensionsPage) { + // Open the browser's extension management page. Web origins cannot + // navigate to chrome:// URLs directly, so the primer recovery screen + // bounces the request through the background to call + // `chrome.tabs.create` here. + try { + await browser.tabs.create({ url: 'chrome://extensions', active: true }); + return { opened: true }; + } catch (error) { + return { + opened: false, + error: + error instanceof Error + ? error.message + : 'Failed to open extensions page', + }; + } + } + if (message.type === ExtensionMessageType.NewTabActivated) { // Forwarded from the post-install new tab page so the primer (running // in another tab on daily.dev) can learn the override was accepted. diff --git a/packages/extension/src/ping/index.ts b/packages/extension/src/ping/index.ts index e7546051e57..cf2d8fd9721 100644 --- a/packages/extension/src/ping/index.ts +++ b/packages/extension/src/ping/index.ts @@ -26,8 +26,13 @@ import { newTabActivationBridgeRequestEvent, newTabActivationBridgeResultEvent, newTabActivationSuccessKey, + openExtensionsPageBridgeRequestEvent, + openExtensionsPageBridgeResultEvent, +} from '@dailydotdev/shared/src/features/extensionEmbed/newTabActivationBridge'; +import type { + NewTabActivationBridgeResult, + OpenExtensionsPageBridgeResult, } from '@dailydotdev/shared/src/features/extensionEmbed/newTabActivationBridge'; -import type { NewTabActivationBridgeResult } from '@dailydotdev/shared/src/features/extensionEmbed/newTabActivationBridge'; const INSTALL_MARKER = 'dailyExtensionInstalled'; const ID_MARKER = 'dailyExtensionId'; @@ -118,6 +123,38 @@ window.addEventListener(newTabActivationBridgeRequestEvent, () => { }); }); +window.addEventListener(openExtensionsPageBridgeRequestEvent, () => { + const dispatchResult = (result: OpenExtensionsPageBridgeResult): void => { + window.dispatchEvent( + new CustomEvent( + openExtensionsPageBridgeResultEvent, + { detail: result }, + ), + ); + }; + + browser.runtime + .sendMessage({ + type: ExtensionMessageType.RequestOpenExtensionsPage, + }) + .then((response) => { + const typed = response as OpenExtensionsPageBridgeResult | undefined; + dispatchResult({ + opened: !!typed?.opened, + error: typed?.error, + }); + }) + .catch((error: unknown) => { + dispatchResult({ + opened: false, + error: + error instanceof Error + ? error.message + : 'Failed to request extensions page', + }); + }); +}); + window.addEventListener(pagePermissionBridgeRequestEvent, () => { const dispatchResult = (result: PagePermissionBridgeResult): void => { window.dispatchEvent( diff --git a/packages/shared/src/features/extensionEmbed/newTabActivationBridge.ts b/packages/shared/src/features/extensionEmbed/newTabActivationBridge.ts index 8878c2a1b8d..9f361c831ca 100644 --- a/packages/shared/src/features/extensionEmbed/newTabActivationBridge.ts +++ b/packages/shared/src/features/extensionEmbed/newTabActivationBridge.ts @@ -25,6 +25,16 @@ export const newTabActivationBridgeRequestEvent = export const newTabActivationBridgeResultEvent = 'daily-extension-open-new-tab-result'; +export const openExtensionsPageBridgeRequestEvent = + 'daily-extension-request-open-extensions-page'; +export const openExtensionsPageBridgeResultEvent = + 'daily-extension-open-extensions-page-result'; + +export type OpenExtensionsPageBridgeResult = { + opened: boolean; + error?: string; +}; + export type NewTabActivationBridgeResult = { triggered: boolean; error?: string; @@ -86,3 +96,55 @@ export const requestOpenNewTabFromPage = ( window.dispatchEvent(new CustomEvent(newTabActivationBridgeRequestEvent)); }); }; + +// Asks the extension's service worker to open `chrome://extensions` in a +// new tab. Web pages cannot navigate to chrome:// URLs directly, but the +// extension's background can call `chrome.tabs.create` with one. Used by +// the primer recovery screen to send users to where they can re-enable +// daily.dev if Chrome disabled it. Fails (resolves `opened: false`) if +// the extension has already been disabled — in that case nothing on the +// daily.dev origin can reach the background. +export const requestOpenExtensionsPageFromPage = ( + timeoutMs: number = PAGE_HELPER_TIMEOUT_MS, +): Promise => { + if (typeof window === 'undefined') { + return Promise.resolve({ + opened: 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(openExtensionsPageBridgeResultEvent, onResult); + if (timeoutId !== undefined) { + globalThis.clearTimeout(timeoutId); + } + const { detail } = event as CustomEvent; + resolve({ + opened: !!detail?.opened, + error: detail?.error, + }); + }; + + timeoutId = globalThis.setTimeout(() => { + if (settled) { + return; + } + settled = true; + window.removeEventListener(openExtensionsPageBridgeResultEvent, onResult); + resolve({ opened: false, error: 'timeout' }); + }, timeoutMs); + + window.addEventListener(openExtensionsPageBridgeResultEvent, onResult); + + window.dispatchEvent(new CustomEvent(openExtensionsPageBridgeRequestEvent)); + }); +}; diff --git a/packages/shared/src/features/onboarding/components/NewTabActivationPrimer.tsx b/packages/shared/src/features/onboarding/components/NewTabActivationPrimer.tsx index 5410c466cf5..060b2678a07 100644 --- a/packages/shared/src/features/onboarding/components/NewTabActivationPrimer.tsx +++ b/packages/shared/src/features/onboarding/components/NewTabActivationPrimer.tsx @@ -17,6 +17,7 @@ import { LogEvent } from '../../../lib/log'; import { newTabActivationRejectedKey, newTabActivationSuccessKey, + requestOpenExtensionsPageFromPage, requestOpenNewTabFromPage, } from '../../extensionEmbed/newTabActivationBridge'; @@ -131,6 +132,76 @@ function ChromeDialogMockup(): ReactElement { ); } +// Stylized recreation of the daily.dev card on the chrome://extensions +// page with the toggle highlighted in the OFF position. Used by the +// recovery screen to show the user exactly what to look for when they +// need to re-enable the extension. +function ExtensionsPageCardMockup(): ReactElement { + return ( +
+
+
+
+
+
+

+ daily.dev | Developer News Done Righ… +

+ + 3.45.3 + +
+

+ Developer news, personalized to your stack, in every new tab. +

+

+ ID: jlmpjdjjbgclbocgajdjefcidcncaied +

+
+
+ + Turn this on + + + + + + + +
+
+
+ + Details + + + Remove + +
+
+
+ ); +} + function TrustBullets(): ReactElement { const items: Array<{ title: string; body: string }> = [ { @@ -300,15 +371,12 @@ export function NewTabActivationPrimer({ setState('recovery'); }, [logEvent, startPolling, stopPolling]); - const handleRetryClick = useCallback((): void => { - clearActivationStorage(); - startPolling(); - setState('waiting'); - }, [startPolling]); - - const handleSkipClick = useCallback((): void => { - finish('skipped'); - }, [finish]); + const handleOpenExtensionsPage = useCallback(async (): Promise => { + await requestOpenExtensionsPageFromPage(); + // If the bridge fails (e.g. the extension was already disabled by + // Chrome and the content script is no longer running) the user can + // still follow the on-screen mockup and navigate manually. + }, []); const isRecovery = state === 'recovery'; const isWaiting = state === 'waiting'; @@ -336,14 +404,8 @@ export function NewTabActivationPrimer({ > {isRecovery ? ( <> - Looks like daily.dev wasn't set as your new tab. If you - tapped{' '} - - “Change it back” - {' '} - by mistake, re-enable daily.dev in{' '} - chrome://extensions — - or continue and we'll set you up without it. + Looks like daily.dev was turned off. Open the extensions page, + find daily.dev, and flip the toggle back on. ) : ( <> @@ -378,23 +440,17 @@ export function NewTabActivationPrimer({
)} + {isRecovery && } + {isRecovery && (
-
)} diff --git a/packages/shared/src/lib/extension.ts b/packages/shared/src/lib/extension.ts index b5347bff798..cfcc7564a7f 100644 --- a/packages/shared/src/lib/extension.ts +++ b/packages/shared/src/lib/extension.ts @@ -11,6 +11,7 @@ export enum ExtensionMessageType { RequestOpenNewTab = 'REQUEST_OPEN_NEW_TAB', NewTabActivated = 'NEW_TAB_ACTIVATED', NotifyNewTabActivated = 'NOTIFY_NEW_TAB_ACTIVATED', + RequestOpenExtensionsPage = 'REQUEST_OPEN_EXTENSIONS_PAGE', } export const getCompanionWrapper = (): HTMLElement | null => From 79e8f3f1caa78e3a7d4533a2624b6e357cdcf8ae Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Wed, 27 May 2026 13:59:23 +0300 Subject: [PATCH 10/25] ci: retrigger to retry flaky PostPage test From 48cf994b777041ca2c1eaf77a9d347a2a34eaa1a Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Wed, 27 May 2026 14:16:58 +0300 Subject: [PATCH 11/25] chore(newtab): redirect first post-install tab to prod onboarding Testing override so the new tab does not fall into the boot/API "Connection lost" UI when running an unsigned local extension build. On the very first new tab after install we (a) broadcast activation back to the primer and (b) immediately redirect the tab to app.daily.dev/onboarding so the user lands on the real signup wall. Implemented at the top of the App component (before providers render) so no API call ever fires. Subsequent new tabs and the action-button new tab (?source=button) render normally. Marked TODO(REMOVE-BEFORE-MERGE). Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/extension/src/newtab/App.tsx | 38 +++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/packages/extension/src/newtab/App.tsx b/packages/extension/src/newtab/App.tsx index c6d6510c38b..0fa9c367d84 100644 --- a/packages/extension/src/newtab/App.tsx +++ b/packages/extension/src/newtab/App.tsx @@ -50,6 +50,37 @@ structuredCloneJsonPolyfill(); const DEFAULT_TAB_TITLE = 'New Tab'; const NEWTAB_ACTIVATED_KEY = 'daily-extension-newtab-activated-fired'; + +// TODO(REMOVE-BEFORE-MERGE): testing override — on the very first new tab +// after install, broadcast activation back to the primer and immediately +// hand off this tab to the production signup wall. Bypasses any +// boot/API failures (e.g. "Connection lost") that local extension builds +// would otherwise hit. Remove the constant, the helper, and the early +// return in `App` before merging. +const FIRST_NEWTAB_REDIRECT_URL = 'https://app.daily.dev/onboarding'; +let didRedirectForTesting = false; +const maybeRedirectForTesting = (): boolean => { + if (typeof window === 'undefined' || didRedirectForTesting) { + return false; + } + if (window.location.href.includes('source=')) { + return false; + } + try { + if (localStorage.getItem(NEWTAB_ACTIVATED_KEY)) { + return false; + } + localStorage.setItem(NEWTAB_ACTIVATED_KEY, Date.now().toString()); + } catch { + return false; + } + didRedirectForTesting = true; + browser.runtime + .sendMessage({ type: ExtensionMessageType.NewTabActivated }) + .catch(() => undefined); + window.location.href = FIRST_NEWTAB_REDIRECT_URL; + return true; +}; const router = new CustomRouter(); const queryClient = new QueryClient(defaultQueryClientConfig); Modal.setAppElement('#__next'); @@ -199,6 +230,13 @@ export default function App({ const [currentPage, setCurrentPage] = useState('/'); const deviceId = useDeviceId(); + // TODO(REMOVE-BEFORE-MERGE): testing override at top of file. Hooks + // above are called unconditionally to satisfy rules-of-hooks; the + // redirect short-circuits the render so providers never mount. + if (maybeRedirectForTesting()) { + return <>; + } + return ( From 9450238274702011429ba2eabe1bffd8befdcd74 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Wed, 27 May 2026 14:19:20 +0300 Subject: [PATCH 12/25] fix(primer): drop primer-tab redirect, add clipboard fallback for extensions page Two fixes: 1. Remove the post-trigger window.location.href redirect from the primer tab. The redirect lives on the new tab itself (extension App.tsx), so the primer tab should stay in its waiting state and detect activation via the localStorage signal. This is the intended split: new tab handles its own handoff, primer tab observes the success signal. 2. The "Open extensions page" button now falls through to copying chrome://extensions to the clipboard with an inline confirmation message if the extension bridge fails (the most common failure path: the user picked "Change it back", Chrome disabled the extension, and the service worker is no longer there to receive the bridge message). Also handles the rare case where clipboard write is blocked by showing a plain instruction. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../components/NewTabActivationPrimer.tsx | 53 ++++++++++++++----- 1 file changed, 40 insertions(+), 13 deletions(-) diff --git a/packages/shared/src/features/onboarding/components/NewTabActivationPrimer.tsx b/packages/shared/src/features/onboarding/components/NewTabActivationPrimer.tsx index 060b2678a07..ce11feeae26 100644 --- a/packages/shared/src/features/onboarding/components/NewTabActivationPrimer.tsx +++ b/packages/shared/src/features/onboarding/components/NewTabActivationPrimer.tsx @@ -352,15 +352,6 @@ export function NewTabActivationPrimer({ const result = await requestOpenNewTabFromPage(); if (result.triggered) { logEvent({ event_name: LogEvent.ExtensionNewTabTriggered }); - // TODO(REMOVE-BEFORE-MERGE): testing override — after triggering the - // new tab, hand off this tab to the production signup wall so the - // full visual flow (primer → Chrome dialog in the new tab → - // production signup wall here) can be exercised locally. In - // production the primer is already served from app.daily.dev and - // success-detection auto-advances on the same domain; remove this - // redirect before merging so the success path runs. - stopPolling(); - window.location.href = 'https://app.daily.dev/onboarding'; return; } stopPolling(); @@ -371,11 +362,24 @@ export function NewTabActivationPrimer({ setState('recovery'); }, [logEvent, startPolling, stopPolling]); + const [extensionsHelperState, setExtensionsHelperState] = useState< + 'idle' | 'copied' | 'unsupported' + >('idle'); + const handleOpenExtensionsPage = useCallback(async (): Promise => { - await requestOpenExtensionsPageFromPage(); - // If the bridge fails (e.g. the extension was already disabled by - // Chrome and the content script is no longer running) the user can - // still follow the on-screen mockup and navigate manually. + const result = await requestOpenExtensionsPageFromPage(); + if (result.opened) { + return; + } + // Bridge failed — most likely the extension was already disabled by + // Chrome (no service worker, no content script). Fall back to copying + // the URL so the user can paste it into the address bar. + try { + await navigator.clipboard.writeText('chrome://extensions'); + setExtensionsHelperState('copied'); + } catch { + setExtensionsHelperState('unsupported'); + } }, []); const isRecovery = state === 'recovery'; @@ -452,6 +456,29 @@ export function NewTabActivationPrimer({ > Open extensions page + {extensionsHelperState === 'copied' && ( + + Copied{' '} + chrome://extensions — + paste it into your address bar. + + )} + {extensionsHelperState === 'unsupported' && ( + + Open a new tab and go to{' '} + chrome://extensions. + + )}
)} From 3657bc975ed7b5ba682f99289c9ed2f02709cd8c Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Wed, 27 May 2026 14:31:06 +0300 Subject: [PATCH 13/25] fix(primer): drop clipboard fallback, show manual hint on bridge failure Per request, the "Open extensions page" button no longer copies to clipboard when the bridge fails. It tries to navigate via the extension service worker, and if that fails (almost always because Chrome disabled the extension when the user picked "Change it back") shows an inline message telling the user to open chrome://extensions manually. Web pages cannot navigate to chrome:// URLs without an extension proxying the call, so when the extension is disabled the only option is to instruct the user. No JS workaround exists for this Chrome browser restriction. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../components/NewTabActivationPrimer.tsx | 37 +++++-------------- 1 file changed, 10 insertions(+), 27 deletions(-) diff --git a/packages/shared/src/features/onboarding/components/NewTabActivationPrimer.tsx b/packages/shared/src/features/onboarding/components/NewTabActivationPrimer.tsx index ce11feeae26..5436f614680 100644 --- a/packages/shared/src/features/onboarding/components/NewTabActivationPrimer.tsx +++ b/packages/shared/src/features/onboarding/components/NewTabActivationPrimer.tsx @@ -362,24 +362,18 @@ export function NewTabActivationPrimer({ setState('recovery'); }, [logEvent, startPolling, stopPolling]); - const [extensionsHelperState, setExtensionsHelperState] = useState< - 'idle' | 'copied' | 'unsupported' - >('idle'); + const [showManualHint, setShowManualHint] = useState(false); const handleOpenExtensionsPage = useCallback(async (): Promise => { const result = await requestOpenExtensionsPageFromPage(); if (result.opened) { return; } - // Bridge failed — most likely the extension was already disabled by - // Chrome (no service worker, no content script). Fall back to copying - // the URL so the user can paste it into the address bar. - try { - await navigator.clipboard.writeText('chrome://extensions'); - setExtensionsHelperState('copied'); - } catch { - setExtensionsHelperState('unsupported'); - } + // Bridge failed — almost always because Chrome already disabled the + // extension when the user picked "Change it back". Web pages cannot + // navigate to chrome:// URLs without an enabled extension proxying + // the navigation, so fall back to telling the user to do it. + setShowManualHint(true); }, []); const isRecovery = state === 'recovery'; @@ -456,27 +450,16 @@ export function NewTabActivationPrimer({ > Open extensions page - {extensionsHelperState === 'copied' && ( - - Copied{' '} - chrome://extensions — - paste it into your address bar. - - )} - {extensionsHelperState === 'unsupported' && ( + {showManualHint && ( - Open a new tab and go to{' '} - chrome://extensions. + Couldn't open it automatically — open a new tab and go to{' '} + chrome://extensions{' '} + to turn daily.dev back on. )} From 6d28253226638beda297af91829a1cf20b6b8c8d Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Wed, 27 May 2026 14:44:25 +0300 Subject: [PATCH 14/25] refactor(primer): split new-tab activation into a dedicated /activate page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The primer no longer lives inside /onboarding. A new /activate page hosts only the new-tab activation flow — no auth wall, no funnel, no gating. The user stays on that page until the new-tab override is detected (via the localStorage bridge), at which point we router.replace to /onboarding so the existing signup flow takes over. This makes the post-install funnel two clearly separated steps instead of a conditional state inside /onboarding: install → /activate (primer) → success → /onboarding (signup) Also removes all the testing scaffolding I had layered on /onboarding (FORCE_PRIMER_FOR_TESTING, debug console.warn, shouldShowPrimerProd, ?r=extension skip, related imports). /onboarding goes back to its pre-PR shape. Install URL constant now points at /activate so the post-install tab opens the primer directly. Production deployment also needs the Rebrandly redirect updated to /activate (noted in the comment). Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/shared/src/lib/constants.ts | 9 ++- packages/webapp/pages/activate.tsx | 44 +++++++++++ packages/webapp/pages/onboarding.tsx | 114 --------------------------- 3 files changed, 49 insertions(+), 118 deletions(-) create mode 100644 packages/webapp/pages/activate.tsx diff --git a/packages/shared/src/lib/constants.ts b/packages/shared/src/lib/constants.ts index 6c7bef54875..53c32bd06ce 100644 --- a/packages/shared/src/lib/constants.ts +++ b/packages/shared/src/lib/constants.ts @@ -18,10 +18,11 @@ export const companionPermissionGrantedLink = export const recruiterScheduleUrl = 'https://recruiter.daily.dev/schedule'; export const initialDataKey = 'initial'; // TODO(REMOVE-BEFORE-MERGE): testing override — install URL is hardcoded -// to the local staging webapp so the post-install primer flow can be -// exercised end-to-end. Restore to 'https://r.daily.dev/install' before -// merging. -export const install = 'https://app.staging.daily.dev:5002/onboarding'; +// to the local staging webapp's dedicated /activate page so the +// post-install primer flow can be exercised end-to-end. Before merging, +// restore to 'https://r.daily.dev/install' and update the Rebrandly +// redirect to point at /activate (not /onboarding) on production. +export const install = 'https://app.staging.daily.dev:5002/activate'; export const uninstall = 'https://r.daily.dev/uninstall'; export const sharingBookmarks = 'https://r.daily.dev/sharing-bookmarks'; export const devCard = 'https://r.daily.dev/devcard-github'; diff --git a/packages/webapp/pages/activate.tsx b/packages/webapp/pages/activate.tsx new file mode 100644 index 00000000000..eef5ee485b9 --- /dev/null +++ b/packages/webapp/pages/activate.tsx @@ -0,0 +1,44 @@ +import type { ReactElement } from 'react'; +import React, { useCallback } from 'react'; +import { useRouter } from 'next/router'; +import type { NextSeoProps } from 'next-seo'; +import { OnboardingHeader } from '@dailydotdev/shared/src/components/onboarding'; +import { NewTabActivationPrimer } from '@dailydotdev/shared/src/features/onboarding/components/NewTabActivationPrimer'; +import { ErrorBoundary } from '@dailydotdev/shared/src/components/ErrorBoundary'; +import { FooterLinks } from '@dailydotdev/shared/src/components'; +import { getPageSeoTitles } from '../components/layouts/utils'; +import { defaultOpenGraph, defaultSeo } from '../next-seo'; + +const seoTitles = getPageSeoTitles('Activate your new tab'); +const seo: NextSeoProps = { + title: seoTitles.title, + openGraph: { ...seoTitles.openGraph, ...defaultOpenGraph }, + ...defaultSeo, + noindex: true, +}; + +function ActivatePage(): ReactElement { + const router = useRouter(); + + // When the primer detects a successful activation (via the localStorage + // bridge from the new tab page), advance to the regular onboarding flow + // so signup/funnel kicks in. The user stays on the primer page as long + // as they need to — no timeout boots them off. + const handleComplete = useCallback((): void => { + router.replace('/onboarding'); + }, [router]); + + return ( + +
+ + + +
+
+ ); +} + +ActivatePage.layoutProps = { seo }; + +export default ActivatePage; diff --git a/packages/webapp/pages/onboarding.tsx b/packages/webapp/pages/onboarding.tsx index 9c45ea42a16..fd07902fa3e 100644 --- a/packages/webapp/pages/onboarding.tsx +++ b/packages/webapp/pages/onboarding.tsx @@ -62,12 +62,6 @@ import { FunnelStepper } from '@dailydotdev/shared/src/features/onboarding/share import { useOnboardingActions } from '@dailydotdev/shared/src/hooks/auth'; import { ActionType } from '@dailydotdev/shared/src/graphql/actions'; import { isLocalhost } from '@dailydotdev/shared/src/lib/config'; -import { useConditionalFeature } from '@dailydotdev/shared/src/hooks/useConditionalFeature'; -import { featureOnboardingPermissionPrimer } from '@dailydotdev/shared/src/lib/featureManagement'; -import { useIsBrowserExtensionInstalled } from '@dailydotdev/shared/src/features/extensionEmbed/useIsBrowserExtensionInstalled'; -import { useLogContext } from '@dailydotdev/shared/src/contexts/LogContext'; -import { LogEvent } from '@dailydotdev/shared/src/lib/log'; -import { NewTabActivationPrimer } from '@dailydotdev/shared/src/features/onboarding/components/NewTabActivationPrimer'; import { getPageSeoTitles } from '../components/layouts/utils'; import { defaultOpenGraph, defaultSeo } from '../next-seo'; @@ -274,15 +268,6 @@ const useOnboardingAuth = () => { }; }; -const PRIMER_COMPLETED_KEY = 'daily-extension-primer-completed'; - -// TODO(REMOVE-BEFORE-MERGE): testing override. When true, the new tab -// activation primer renders for every visit to /onboarding regardless of -// auth state, GrowthBook flag, prior completion, extension marker, or -// `?r=extension` re-entry. Delete this constant and restore the original -// `shouldShowPrimer` predicate before merging. -const FORCE_PRIMER_FOR_TESTING = true; - function Onboarding({ initialStepId }: PageProps): ReactElement { const router = useRouter(); const { @@ -295,95 +280,6 @@ function Onboarding({ initialStepId }: PageProps): ReactElement { const { isOnboardingComplete, isOnboardingActionsReady, completeStep } = useOnboardingActions(); const [isFunnelReady, setFunnelReady] = useState(false); - const { logEvent } = useLogContext(); - const { isInstalled: isExtensionInstalled } = - useIsBrowserExtensionInstalled(); - const isExtensionReentry = router.query.r === 'extension'; - const { value: isPrimerFeatureEnabled, isLoading: isPrimerFeatureLoading } = - useConditionalFeature({ - feature: featureOnboardingPermissionPrimer, - shouldEvaluate: - isAuthReady && - !isLoggedIn && - isExtensionInstalled && - !isExtensionReentry, - }); - const [isPrimerDone, setPrimerDone] = useState(() => { - if (typeof window === 'undefined') { - return false; - } - // In testing-override mode, never treat the primer as done so it - // re-renders on every reload. - if (FORCE_PRIMER_FOR_TESTING) { - return false; - } - return !!storage.getItem(PRIMER_COMPLETED_KEY); - }); - - useEffect(() => { - if (FORCE_PRIMER_FOR_TESTING) { - return; - } - if (isExtensionReentry && !storage.getItem(PRIMER_COMPLETED_KEY)) { - storage.setItem(PRIMER_COMPLETED_KEY, Date.now().toString()); - setPrimerDone(true); - logEvent({ event_name: LogEvent.ExtensionPrimerSkipped }); - } - }, [isExtensionReentry, logEvent]); - - const handlePrimerComplete = useCallback((): void => { - // Skip the persistence write in testing-override mode so reloading - // surfaces the primer again. - if (!FORCE_PRIMER_FOR_TESTING) { - storage.setItem(PRIMER_COMPLETED_KEY, Date.now().toString()); - } - setPrimerDone(true); - }, []); - - // TODO(REMOVE-BEFORE-MERGE): one-shot console log so the primer gating - // conditions can be inspected during local QA. Delete before merge. - useEffect(() => { - if (typeof window === 'undefined') { - return; - } - // eslint-disable-next-line no-console - console.warn('[primer-debug]', { - isAuthReady, - isLoggedIn, - isExtensionInstalled, - isExtensionReentry, - isPrimerDone, - isPrimerFeatureLoading, - isPrimerFeatureEnabled, - FORCE_PRIMER_FOR_TESTING, - installedMarker: - document.documentElement?.dataset?.dailyExtensionInstalled, - }); - }, [ - isAuthReady, - isLoggedIn, - isExtensionInstalled, - isExtensionReentry, - isPrimerDone, - isPrimerFeatureLoading, - isPrimerFeatureEnabled, - ]); - - // TODO(REMOVE-BEFORE-MERGE): restore the full gating predicate below - // before merging. The testing override bypasses every condition except - // `isAuthReady` so the primer is visible on every visit, even when - // logged in or with the extension absent. - const shouldShowPrimerProd = - isAuthReady && - !isLoggedIn && - isExtensionInstalled && - !isExtensionReentry && - !isPrimerDone && - !isPrimerFeatureLoading && - isPrimerFeatureEnabled; - const shouldShowPrimer = FORCE_PRIMER_FOR_TESTING - ? isAuthReady && !isPrimerDone - : shouldShowPrimerProd; const onComplete = useCallback(async () => { completeStep(ActionType.CompletedOnboarding); @@ -431,16 +327,6 @@ function Onboarding({ initialStepId }: PageProps): ReactElement { router, ]); - if (shouldShowPrimer) { - return ( -
- - - -
- ); - } - if (isAuthenticating) { return (
Date: Wed, 27 May 2026 14:50:11 +0300 Subject: [PATCH 15/25] feat(primer): heartbeat-based real-time rejection detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a 2s heartbeat from the primer page to the extension service worker via the existing content-script bridge. Chrome has no event for "user picked Change it back", but it disables our extension as a side effect — at which point browser.runtime.sendMessage from the content script starts throwing "Extension context invalidated". The heartbeat catches that and flips the primer to the recovery screen after two consecutive missed pings (~4-7s), well before the 10s post-trigger blind timeout would fire. Mechanics: - New ExtensionMessageType.PingExtensionAlive - Background returns { alive: true } - New bridge helper pingExtensionFromPage with 3s timeout - Ping content script forwards and catches throws/rejects - Primer runs interval heartbeat from mount until completion/recovery - One forgiveness slot per cycle to absorb service-worker cold starts Also threads the existing recovery transition through a single goToRecovery helper so the three paths (storage-key rejection signal, post-trigger timeout, heartbeat failure) stay in sync on log events and idempotency. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/extension/src/background/index.ts | 6 ++ packages/extension/src/ping/index.ts | 31 ++++++ .../extensionEmbed/newTabActivationBridge.ts | 56 +++++++++++ .../components/NewTabActivationPrimer.tsx | 96 +++++++++++++++---- packages/shared/src/lib/extension.ts | 1 + 5 files changed, 172 insertions(+), 18 deletions(-) diff --git a/packages/extension/src/background/index.ts b/packages/extension/src/background/index.ts index 91cf71701e2..47d2a2a5a70 100644 --- a/packages/extension/src/background/index.ts +++ b/packages/extension/src/background/index.ts @@ -212,6 +212,12 @@ async function handleMessages( } } + if (message.type === ExtensionMessageType.PingExtensionAlive) { + // Heartbeat from the primer page — used to detect when Chrome + // disables the extension after the user picks "Change it back". + return { alive: true }; + } + if (message.type === ExtensionMessageType.RequestOpenExtensionsPage) { // Open the browser's extension management page. Web origins cannot // navigate to chrome:// URLs directly, so the primer recovery screen diff --git a/packages/extension/src/ping/index.ts b/packages/extension/src/ping/index.ts index cf2d8fd9721..52803032b06 100644 --- a/packages/extension/src/ping/index.ts +++ b/packages/extension/src/ping/index.ts @@ -28,10 +28,13 @@ import { newTabActivationSuccessKey, openExtensionsPageBridgeRequestEvent, openExtensionsPageBridgeResultEvent, + pingExtensionBridgeRequestEvent, + pingExtensionBridgeResultEvent, } from '@dailydotdev/shared/src/features/extensionEmbed/newTabActivationBridge'; import type { NewTabActivationBridgeResult, OpenExtensionsPageBridgeResult, + PingExtensionBridgeResult, } from '@dailydotdev/shared/src/features/extensionEmbed/newTabActivationBridge'; const INSTALL_MARKER = 'dailyExtensionInstalled'; @@ -123,6 +126,34 @@ window.addEventListener(newTabActivationBridgeRequestEvent, () => { }); }); +window.addEventListener(pingExtensionBridgeRequestEvent, () => { + const dispatchResult = (result: PingExtensionBridgeResult): void => { + window.dispatchEvent( + new CustomEvent( + pingExtensionBridgeResultEvent, + { detail: result }, + ), + ); + }; + + // If the extension has been disabled, `browser.runtime.sendMessage` + // throws "Extension context invalidated" synchronously or rejects. + // Either way we report `alive: false` and the primer flips to recovery. + try { + browser.runtime + .sendMessage({ type: ExtensionMessageType.PingExtensionAlive }) + .then((response) => { + const typed = response as { alive?: boolean } | undefined; + dispatchResult({ alive: !!typed?.alive }); + }) + .catch(() => { + dispatchResult({ alive: false }); + }); + } catch { + dispatchResult({ alive: false }); + } +}); + window.addEventListener(openExtensionsPageBridgeRequestEvent, () => { const dispatchResult = (result: OpenExtensionsPageBridgeResult): void => { window.dispatchEvent( diff --git a/packages/shared/src/features/extensionEmbed/newTabActivationBridge.ts b/packages/shared/src/features/extensionEmbed/newTabActivationBridge.ts index 9f361c831ca..f353ce5e6c0 100644 --- a/packages/shared/src/features/extensionEmbed/newTabActivationBridge.ts +++ b/packages/shared/src/features/extensionEmbed/newTabActivationBridge.ts @@ -35,6 +35,14 @@ export type OpenExtensionsPageBridgeResult = { error?: string; }; +export const pingExtensionBridgeRequestEvent = 'daily-extension-ping-alive'; +export const pingExtensionBridgeResultEvent = + 'daily-extension-ping-alive-result'; + +export type PingExtensionBridgeResult = { + alive: boolean; +}; + export type NewTabActivationBridgeResult = { triggered: boolean; error?: string; @@ -148,3 +156,51 @@ export const requestOpenExtensionsPageFromPage = ( window.dispatchEvent(new CustomEvent(openExtensionsPageBridgeRequestEvent)); }); }; + +// Heartbeat used by the primer to detect rejection in real time. Chrome +// has no event for "user picked Change it back", but as a side effect it +// disables our extension entirely — at which point the content script's +// `browser.runtime.sendMessage` starts throwing "Extension context +// invalidated" and this helper resolves `alive: false`. Use a short +// timeout (default 3s) so a single missed ping does not trigger +// recovery. +const PING_TIMEOUT_MS = 3_000; + +export const pingExtensionFromPage = ( + timeoutMs: number = PING_TIMEOUT_MS, +): Promise => { + if (typeof window === 'undefined') { + return Promise.resolve({ alive: false }); + } + + return new Promise((resolve) => { + let settled = false; + let timeoutId: ReturnType | undefined; + + const onResult = (event: Event): void => { + if (settled) { + return; + } + settled = true; + window.removeEventListener(pingExtensionBridgeResultEvent, onResult); + if (timeoutId !== undefined) { + globalThis.clearTimeout(timeoutId); + } + const { detail } = event as CustomEvent; + resolve({ alive: !!detail?.alive }); + }; + + timeoutId = globalThis.setTimeout(() => { + if (settled) { + return; + } + settled = true; + window.removeEventListener(pingExtensionBridgeResultEvent, onResult); + resolve({ alive: false }); + }, timeoutMs); + + window.addEventListener(pingExtensionBridgeResultEvent, onResult); + + window.dispatchEvent(new CustomEvent(pingExtensionBridgeRequestEvent)); + }); +}; diff --git a/packages/shared/src/features/onboarding/components/NewTabActivationPrimer.tsx b/packages/shared/src/features/onboarding/components/NewTabActivationPrimer.tsx index 5436f614680..4633801282c 100644 --- a/packages/shared/src/features/onboarding/components/NewTabActivationPrimer.tsx +++ b/packages/shared/src/features/onboarding/components/NewTabActivationPrimer.tsx @@ -17,6 +17,7 @@ import { LogEvent } from '../../../lib/log'; import { newTabActivationRejectedKey, newTabActivationSuccessKey, + pingExtensionFromPage, requestOpenExtensionsPageFromPage, requestOpenNewTabFromPage, } from '../../extensionEmbed/newTabActivationBridge'; @@ -29,6 +30,13 @@ type PrimerState = 'idle' | 'waiting' | 'recovery'; const ACTIVATION_TIMEOUT_MS = 10_000; const POLL_INTERVAL_MS = 250; +// Heartbeat pings the extension service worker. When the user picks +// "Change it back" on Chrome's dialog the extension is disabled and the +// heartbeat starts failing; two consecutive misses flips the primer to +// recovery so the user can re-enable the extension without waiting on +// the longer activation timeout. +const HEARTBEAT_INTERVAL_MS = 2_000; +const HEARTBEAT_FAILURES_BEFORE_RECOVERY = 2; const clearActivationStorage = (): void => { try { @@ -283,10 +291,26 @@ export function NewTabActivationPrimer({ }: NewTabActivationPrimerProps): ReactElement { const { logEvent } = useLogContext(); const [state, setState] = useState('idle'); + const stateRef = useRef('idle'); const pollTimerRef = useRef>(); const timeoutTimerRef = useRef>(); + const heartbeatTimerRef = useRef>(); + const heartbeatFailuresRef = useRef(0); const completedRef = useRef(false); + const updateState = useCallback((next: PrimerState): void => { + stateRef.current = next; + setState(next); + }, []); + + const goToRecovery = useCallback((): void => { + if (completedRef.current || stateRef.current === 'recovery') { + return; + } + logEvent({ event_name: LogEvent.ExtensionPrimerRecoveryShown }); + updateState('recovery'); + }, [logEvent, updateState]); + const stopPolling = useCallback((): void => { if (pollTimerRef.current !== undefined) { globalThis.clearInterval(pollTimerRef.current); @@ -298,6 +322,13 @@ export function NewTabActivationPrimer({ } }, []); + const stopHeartbeat = useCallback((): void => { + if (heartbeatTimerRef.current !== undefined) { + globalThis.clearInterval(heartbeatTimerRef.current); + heartbeatTimerRef.current = undefined; + } + }, []); + const finish = useCallback( (reason: 'activated' | 'skipped'): void => { if (completedRef.current) { @@ -305,19 +336,57 @@ export function NewTabActivationPrimer({ } completedRef.current = true; stopPolling(); + stopHeartbeat(); if (reason === 'activated') { logEvent({ event_name: LogEvent.ExtensionNewTabActivated }); } onComplete(); }, - [logEvent, onComplete, stopPolling], + [logEvent, onComplete, stopPolling, stopHeartbeat], ); useEffect(() => { logEvent({ event_name: LogEvent.ExtensionPrimerShown }); clearActivationStorage(); - return stopPolling; - }, [logEvent, stopPolling]); + return () => { + stopPolling(); + stopHeartbeat(); + }; + }, [logEvent, stopPolling, stopHeartbeat]); + + // Heartbeat: ping the extension every couple of seconds so we can flip + // to recovery immediately when Chrome disables it (the side effect of + // the user picking "Change it back" on the override dialog). Two + // consecutive failures trigger recovery — a single missed ping during + // service-worker sleep is forgiven. + useEffect(() => { + const runHeartbeat = async (): Promise => { + if (completedRef.current || stateRef.current === 'recovery') { + return; + } + const { alive } = await pingExtensionFromPage(); + if (alive) { + heartbeatFailuresRef.current = 0; + return; + } + heartbeatFailuresRef.current += 1; + if (heartbeatFailuresRef.current < HEARTBEAT_FAILURES_BEFORE_RECOVERY) { + return; + } + logEvent({ event_name: LogEvent.ExtensionDialogRejected }); + goToRecovery(); + }; + + heartbeatTimerRef.current = globalThis.setInterval( + runHeartbeat, + HEARTBEAT_INTERVAL_MS, + ); + // Fire one immediately so a missing extension at mount time is caught + // before the first interval tick. + runHeartbeat(); + + return stopHeartbeat; + }, [goToRecovery, logEvent, stopHeartbeat]); const startPolling = useCallback((): void => { stopPolling(); @@ -330,24 +399,19 @@ export function NewTabActivationPrimer({ if (rejected) { stopPolling(); logEvent({ event_name: LogEvent.ExtensionDialogRejected }); - logEvent({ event_name: LogEvent.ExtensionPrimerRecoveryShown }); - setState('recovery'); + goToRecovery(); } }, POLL_INTERVAL_MS); timeoutTimerRef.current = globalThis.setTimeout(() => { stopPolling(); - if (completedRef.current) { - return; - } - logEvent({ event_name: LogEvent.ExtensionPrimerRecoveryShown }); - setState('recovery'); + goToRecovery(); }, ACTIVATION_TIMEOUT_MS); - }, [finish, logEvent, stopPolling]); + }, [finish, goToRecovery, logEvent, stopPolling]); const handleActivateClick = useCallback(async (): Promise => { logEvent({ event_name: LogEvent.ExtensionPrimerCtaClick }); - setState('waiting'); + updateState('waiting'); startPolling(); const result = await requestOpenNewTabFromPage(); if (result.triggered) { @@ -355,12 +419,8 @@ export function NewTabActivationPrimer({ return; } stopPolling(); - if (completedRef.current) { - return; - } - logEvent({ event_name: LogEvent.ExtensionPrimerRecoveryShown }); - setState('recovery'); - }, [logEvent, startPolling, stopPolling]); + goToRecovery(); + }, [goToRecovery, logEvent, startPolling, stopPolling, updateState]); const [showManualHint, setShowManualHint] = useState(false); diff --git a/packages/shared/src/lib/extension.ts b/packages/shared/src/lib/extension.ts index cfcc7564a7f..af99261c403 100644 --- a/packages/shared/src/lib/extension.ts +++ b/packages/shared/src/lib/extension.ts @@ -12,6 +12,7 @@ export enum ExtensionMessageType { NewTabActivated = 'NEW_TAB_ACTIVATED', NotifyNewTabActivated = 'NOTIFY_NEW_TAB_ACTIVATED', RequestOpenExtensionsPage = 'REQUEST_OPEN_EXTENSIONS_PAGE', + PingExtensionAlive = 'PING_EXTENSION_ALIVE', } export const getCompanionWrapper = (): HTMLElement | null => From 38b73557d1c8ff8325c1c7d399ea9d6cf01e5b62 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Wed, 27 May 2026 16:42:51 +0300 Subject: [PATCH 16/25] =?UTF-8?q?refactor(primer):=20simplify=20activate?= =?UTF-8?q?=20page=20=E2=80=94=20bigger=20title,=20fewer=20words?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per design feedback: the page was content-heavy and felt over-explanatory, which can trigger more suspicion from developers instead of less. Pared back to a single focused screen: • Headline jumps from LargeTitle to Mega2: "Please activate your new tab" • Removed the subhead paragraph entirely — the visual mockup already shows "Change back to Google? — tap Keep it" in literal Chrome UI, so the prose was redundant. • CTA copy shortened from "Activate new tab" to "Activate". • Three trust bullets collapsed to a single privacy-first one-liner under the button: "Privacy-first — no browsing history collected." Privacy is the developer's primary concern at activation time; keeping it small and singular reads as confidence, not hedging. • Replaced the expandable "Why does Chrome ask this?" disclosure with one always-visible caption: "Chrome shows this prompt for every extension that changes the new tab — it's a standard privacy check." • Left a TODO marker where the static Chrome dialog mockup currently sits, documenting the planned swap for a short autoplay/loop/muted video showing the click target. Recovery screen untouched — it already has the right tone. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../components/NewTabActivationPrimer.tsx | 150 ++++++------------ 1 file changed, 48 insertions(+), 102 deletions(-) diff --git a/packages/shared/src/features/onboarding/components/NewTabActivationPrimer.tsx b/packages/shared/src/features/onboarding/components/NewTabActivationPrimer.tsx index 4633801282c..5637a02034d 100644 --- a/packages/shared/src/features/onboarding/components/NewTabActivationPrimer.tsx +++ b/packages/shared/src/features/onboarding/components/NewTabActivationPrimer.tsx @@ -210,82 +210,39 @@ function ExtensionsPageCardMockup(): ReactElement { ); } -function TrustBullets(): ReactElement { - const items: Array<{ title: string; body: string }> = [ - { - title: 'No browsing history.', - body: 'The new tab only fetches your daily.dev feed.', - }, - { - title: 'No clickbait.', - body: 'A curated stream of dev news ranked by what other developers find useful.', - }, - { - title: 'One click to disable.', - body: 'chrome://extensions → daily.dev → toggle off.', - }, - ]; - - return ( -
    - {items.map((item) => ( -
  • - - - - - {item.title}{' '} - {item.body} - -
  • - ))} -
- ); -} - -function WhyChromeAsks(): ReactElement { - const [open, setOpen] = useState(false); - +// Single-line trust bullet under the CTA. Kept intentionally minimal — +// the more we hedge here, the more we sound like we have something to +// hide. Privacy is the developer's primary concern at activation time. +function TrustBullet(): ReactElement { return ( -
- - {open && ( - - Chrome shows this any time an extension changes your new tab — - it's a privacy guardrail we actually appreciate, even when the - wording is confusing. - - )} -
+ + + Privacy-first — no browsing history collected. +

); } +// TODO: swap ChromeDialogMockup for a short autoplay/loop/muted video +// once recorded. Pattern to use (see OnboardingPlusVariationV1.tsx): +//