diff --git a/packages/extension/src/background/index.ts b/packages/extension/src/background/index.ts index c2f5f2e968f..47d2a2a5a70 100644 --- a/packages/extension/src/background/index.ts +++ b/packages/extension/src/background/index.ts @@ -194,6 +194,82 @@ 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.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 + // 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. + // 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..0fa9c367d84 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,38 @@ import { useContentScriptStatus } from '../../../shared/src/hooks'; 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'); @@ -103,6 +138,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 +172,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}` @@ -175,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 ( diff --git a/packages/extension/src/ping/index.ts b/packages/extension/src/ping/index.ts index 4c71ef98909..52803032b06 100644 --- a/packages/extension/src/ping/index.ts +++ b/packages/extension/src/ping/index.ts @@ -22,6 +22,20 @@ import type { PagePermissionBridgeResult, PermissionGrantResponse, } from '@dailydotdev/shared/src/features/extensionEmbed/pagePermissionBridge'; +import { + newTabActivationBridgeRequestEvent, + newTabActivationBridgeResultEvent, + 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'; const ID_MARKER = 'dailyExtensionId'; @@ -65,6 +79,113 @@ 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(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( + 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 new file mode 100644 index 00000000000..f353ce5e6c0 --- /dev/null +++ b/packages/shared/src/features/extensionEmbed/newTabActivationBridge.ts @@ -0,0 +1,206 @@ +// 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 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 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; +}; + +// 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)); + }); +}; + +// 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)); + }); +}; + +// 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 new file mode 100644 index 00000000000..5ab4bc0d167 --- /dev/null +++ b/packages/shared/src/features/onboarding/components/NewTabActivationPrimer.tsx @@ -0,0 +1,411 @@ +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, + pingExtensionFromPage, + requestOpenExtensionsPageFromPage, + requestOpenNewTabFromPage, +} from '../../extensionEmbed/newTabActivationBridge'; + +type NewTabActivationPrimerProps = { + onComplete: () => void; +}; + +type PrimerState = 'idle' | 'waiting' | 'recovery'; + +const ACTIVATION_TIMEOUT_MS = 10_000; +const POLL_INTERVAL_MS = 250; +// Heartbeat pings the extension service worker. If Chrome later +// disables the extension (a side effect of "Change it back" in some +// Chrome behaviors, or a manual disable), the ping starts failing. +// Two consecutive misses flips the primer to recovery. +const HEARTBEAT_INTERVAL_MS = 2_000; +const HEARTBEAT_FAILURES_BEFORE_RECOVERY = 2; + +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 }; + } +}; + +// The whole non-recovery primer visual: a single autoplay/loop video +// that rehearses the upcoming click — bubble fades in, cursor lands on +// "Keep it", bubble dismisses, feed flashes. Subtitles are intended to +// be burned into the video itself so the user learns the motor sequence +// visually + verbally in a single artifact. +// +// TODO(BEFORE-MERGE): currently served from webapp/public for local +// testing. Upload to Cloudinary and swap ACTIVATION_DEMO_URL for the +// hosted URL before merging. While re-recording for production, bake +// in subtitles per the design brief: +// 0:00 "In a moment, Chrome will ask…" +// 0:02 "Tap Keep it — left button." +// 0:04 "Done. Your new tab is live." +const ACTIVATION_DEMO_URL = '/activate-demo.mp4'; + +// Video is THE page — sized large (full container width, ~50% viewport +// height) so the burned-in subtitles are readable. Everything else on +// the page is a small label around it. +function ActivationDemoVideo(): ReactElement { + return ( +