Skip to content

Commit 3451f2d

Browse files
Merge branch 'main' into feat/fields-v2-redesign
2 parents 0b0536f + 5d4d6a5 commit 3451f2d

13 files changed

Lines changed: 400 additions & 9 deletions

File tree

packages/extension/src/background/index.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,13 @@ const getTargetTabId = async (
7070

7171
const client = new GraphQLClient(graphqlUrl, { fetch: globalThis.fetch });
7272

73+
// Once-off signal set when the activation primer opens chrome://newtab. The
74+
// new tab page consumes it on load to route a logged-out user into onboarding
75+
// a single time. Kept in memory (no `storage` permission in prod) — the
76+
// service worker stays alive across the brief open-tab → tab-load handoff; if
77+
// it doesn't, the new tab simply falls back to its normal behavior.
78+
let activateOnboardingPending = false;
79+
7380
const excludedCompanionOrigins = [
7481
'http://127.0.0.1:5002',
7582
'http://localhost',
@@ -194,6 +201,36 @@ async function handleMessages(
194201
return { ready: true };
195202
}
196203

204+
if (message.type === ExtensionMessageType.RequestOpenNewTab) {
205+
// Open a real new tab so Chrome shows the new-tab override-confirmation
206+
// bubble. The literal `chrome://newtab` URL is required —
207+
// `chrome.runtime.getURL('index.html')` loads the override page directly
208+
// via `chrome-extension://` and does NOT register as an NTP visit. We also
209+
// arm the once-off onboarding signal so the new tab can route a logged-out
210+
// user straight into onboarding a single time.
211+
try {
212+
activateOnboardingPending = true;
213+
await browser.tabs.create({ url: 'chrome://newtab', active: true });
214+
return { triggered: true };
215+
} catch (error) {
216+
activateOnboardingPending = false;
217+
return {
218+
triggered: false,
219+
error:
220+
error instanceof Error ? error.message : 'Failed to open new tab',
221+
};
222+
}
223+
}
224+
225+
if (message.type === ExtensionMessageType.ConsumeActivateOnboarding) {
226+
// The new tab page asks once on load whether it should route into
227+
// onboarding. Reset immediately so this only ever fires for the tab the
228+
// primer just opened.
229+
const pending = activateOnboardingPending;
230+
activateOnboardingPending = false;
231+
return { pending };
232+
}
233+
197234
if (message.type === ExtensionMessageType.RequestFrameEmbeddingPermissions) {
198235
// Relay path so the daily.dev page can drive chrome.permissions.request
199236
// without losing the user gesture. chrome.permissions.request is only

packages/extension/src/newtab/App.tsx

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,17 @@ import { defaultQueryClientConfig } from '@dailydotdev/shared/src/lib/query';
1919
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
2020
import { useWebVitals } from '@dailydotdev/shared/src/hooks/useWebVitals';
2121
import { useGrowthBookContext } from '@dailydotdev/shared/src/components/GrowthBookProvider';
22-
import { isTesting } from '@dailydotdev/shared/src/lib/constants';
22+
import {
23+
isTesting,
24+
onboardingUrl,
25+
} from '@dailydotdev/shared/src/lib/constants';
2326
import { withFeaturesBoundary } from '@dailydotdev/shared/src/components/withFeaturesBoundary';
2427
import { LazyModalElement } from '@dailydotdev/shared/src/components/modals/LazyModalElement';
2528
import { useHostStatus } from '@dailydotdev/shared/src/hooks/useHostPermissionStatus';
2629
import ExtensionPermissionsPrompt from '@dailydotdev/shared/src/components/ExtensionPermissionsPrompt';
2730
import { useExtensionContext } from '@dailydotdev/shared/src/contexts/ExtensionContext';
2831
import { useConsoleLogo } from '@dailydotdev/shared/src/hooks/useConsoleLogo';
32+
import { ExtensionMessageType } from '@dailydotdev/shared/src/lib/extension';
2933
import { DndContextProvider } from '@dailydotdev/shared/src/contexts/DndContext';
3034
import { structuredCloneJsonPolyfill } from '@dailydotdev/shared/src/lib/structuredClone';
3135
import { useOnboardingActions } from '@dailydotdev/shared/src/hooks/auth';
@@ -136,6 +140,29 @@ function InternalApp(): ReactElement {
136140
}
137141
}, [contentScriptGranted]);
138142

143+
useEffect(() => {
144+
if (!isPageReady) {
145+
return undefined;
146+
}
147+
let isActive = true;
148+
// Ask the background once whether the activation primer opened this tab.
149+
// If so, a logged-out user is routed into onboarding a single time; every
150+
// later new tab falls back to the normal behavior below.
151+
browser.runtime
152+
.sendMessage({ type: ExtensionMessageType.ConsumeActivateOnboarding })
153+
.then((response) => {
154+
const pending = (response as { pending?: boolean } | undefined)
155+
?.pending;
156+
if (isActive && pending && !user) {
157+
window.location.href = onboardingUrl;
158+
}
159+
})
160+
.catch(() => undefined);
161+
return () => {
162+
isActive = false;
163+
};
164+
}, [isPageReady, user]);
165+
139166
useEffect(() => {
140167
document.title = unreadCount
141168
? `(${unreadCount}) ${DEFAULT_TAB_TITLE}`

packages/extension/src/ping/index.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,11 @@ import type {
2222
PagePermissionBridgeResult,
2323
PermissionGrantResponse,
2424
} from '@dailydotdev/shared/src/features/extensionEmbed/pagePermissionBridge';
25+
import {
26+
newTabActivationBridgeRequestEvent,
27+
newTabActivationBridgeResultEvent,
28+
} from '@dailydotdev/shared/src/features/extensionEmbed/newTabActivationBridge';
29+
import type { NewTabActivationBridgeResult } from '@dailydotdev/shared/src/features/extensionEmbed/newTabActivationBridge';
2530

2631
const INSTALL_MARKER = 'dailyExtensionInstalled';
2732
const ID_MARKER = 'dailyExtensionId';
@@ -65,6 +70,38 @@ const waitForExtensionReady = async (): Promise<void> => {
6570
}
6671
};
6772

73+
window.addEventListener(newTabActivationBridgeRequestEvent, () => {
74+
const dispatchResult = (result: NewTabActivationBridgeResult): void => {
75+
window.dispatchEvent(
76+
new CustomEvent<NewTabActivationBridgeResult>(
77+
newTabActivationBridgeResultEvent,
78+
{ detail: result },
79+
),
80+
);
81+
};
82+
83+
browser.runtime
84+
.sendMessage({
85+
type: ExtensionMessageType.RequestOpenNewTab,
86+
})
87+
.then((response) => {
88+
const typed = response as NewTabActivationBridgeResult | undefined;
89+
dispatchResult({
90+
triggered: !!typed?.triggered,
91+
error: typed?.error,
92+
});
93+
})
94+
.catch((error: unknown) => {
95+
dispatchResult({
96+
triggered: false,
97+
error:
98+
error instanceof Error
99+
? error.message
100+
: 'Failed to request new tab open',
101+
});
102+
});
103+
});
104+
68105
window.addEventListener(pagePermissionBridgeRequestEvent, () => {
69106
const dispatchResult = (result: PagePermissionBridgeResult): void => {
70107
window.dispatchEvent(
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
// Page <-> content-script bridge for the post-install activation primer.
2+
//
3+
// The webapp asks the extension to open `chrome://newtab` so Chrome shows
4+
// the new-tab override-confirmation bubble while the user is still in our
5+
// primer context. The webapp dispatches a CustomEvent on `window`; the ping
6+
// content script forwards it to the background, which calls
7+
// `chrome.tabs.create({ url: 'chrome://newtab' })`. The literal
8+
// `chrome://newtab` URL is required — passing the override page via
9+
// `chrome.runtime.getURL` would not register as an NTP visit and would not
10+
// trigger the bubble.
11+
12+
export const newTabActivationBridgeRequestEvent =
13+
'daily-extension-request-open-new-tab';
14+
export const newTabActivationBridgeResultEvent =
15+
'daily-extension-open-new-tab-result';
16+
17+
export type NewTabActivationBridgeResult = {
18+
triggered: boolean;
19+
error?: string;
20+
};
21+
22+
const PAGE_HELPER_TIMEOUT_MS = 10_000;
23+
24+
// Drives `chrome.tabs.create({ url: 'chrome://newtab' })` through the
25+
// installed extension's content script. Resolves once the background has
26+
// confirmed the tab was opened (or on timeout if the extension is absent).
27+
export const requestOpenNewTabFromPage = (
28+
timeoutMs: number = PAGE_HELPER_TIMEOUT_MS,
29+
): Promise<NewTabActivationBridgeResult> => {
30+
if (typeof window === 'undefined') {
31+
return Promise.resolve({
32+
triggered: false,
33+
error: 'window-unavailable',
34+
});
35+
}
36+
37+
return new Promise<NewTabActivationBridgeResult>((resolve) => {
38+
let settled = false;
39+
let timeoutId: ReturnType<typeof globalThis.setTimeout> | undefined;
40+
41+
const onResult = (event: Event): void => {
42+
if (settled) {
43+
return;
44+
}
45+
settled = true;
46+
window.removeEventListener(newTabActivationBridgeResultEvent, onResult);
47+
if (timeoutId !== undefined) {
48+
globalThis.clearTimeout(timeoutId);
49+
}
50+
const { detail } = event as CustomEvent<NewTabActivationBridgeResult>;
51+
resolve({
52+
triggered: !!detail?.triggered,
53+
error: detail?.error,
54+
});
55+
};
56+
57+
timeoutId = globalThis.setTimeout(() => {
58+
if (settled) {
59+
return;
60+
}
61+
settled = true;
62+
window.removeEventListener(newTabActivationBridgeResultEvent, onResult);
63+
resolve({ triggered: false, error: 'timeout' });
64+
}, timeoutMs);
65+
66+
window.addEventListener(newTabActivationBridgeResultEvent, onResult);
67+
68+
window.dispatchEvent(new CustomEvent(newTabActivationBridgeRequestEvent));
69+
});
70+
};
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import type { ReactElement } from 'react';
2+
import React, { useCallback, useEffect, useState } from 'react';
3+
import {
4+
Button,
5+
ButtonSize,
6+
ButtonVariant,
7+
} from '../../../components/buttons/Button';
8+
import {
9+
Typography,
10+
TypographyColor,
11+
TypographyTag,
12+
TypographyType,
13+
} from '../../../components/typography/Typography';
14+
import { useLogContext } from '../../../contexts/LogContext';
15+
import { LogEvent } from '../../../lib/log';
16+
import { cloudinaryOnboardingActivationDemo } from '../../../lib/image';
17+
import { requestOpenNewTabFromPage } from '../../extensionEmbed/newTabActivationBridge';
18+
19+
type NewTabActivationPrimerProps = {
20+
onComplete: () => void;
21+
};
22+
23+
// Aspect ratio matches source dimensions so the element hugs the frame.
24+
const DEMO_URL = cloudinaryOnboardingActivationDemo; // 1748×1080
25+
26+
function ActivationDemoVideo(): ReactElement {
27+
return (
28+
<video
29+
src={DEMO_URL}
30+
className="aspect-[1748/1080] w-full max-w-[43rem] rounded-16 border border-border-subtlest-tertiary bg-background-subtle shadow-2"
31+
muted
32+
autoPlay
33+
loop
34+
playsInline
35+
disablePictureInPicture
36+
controls={false}
37+
aria-label="Rehearsal of Chrome's confirmation bubble and the Keep it click"
38+
/>
39+
);
40+
}
41+
42+
export function NewTabActivationPrimer({
43+
onComplete,
44+
}: NewTabActivationPrimerProps): ReactElement {
45+
const { logEvent } = useLogContext();
46+
const [isOpening, setIsOpening] = useState(false);
47+
48+
useEffect(() => {
49+
logEvent({ event_name: LogEvent.ExtensionPrimerShown });
50+
}, [logEvent]);
51+
52+
const handleOpenNewTab = useCallback(async (): Promise<void> => {
53+
logEvent({ event_name: LogEvent.ExtensionPrimerCtaClick });
54+
setIsOpening(true);
55+
// Ask the extension to open chrome://newtab so Chrome's confirmation
56+
// bubble appears. Advance this tab afterwards regardless of the result,
57+
// so the user is never stranded on the primer.
58+
await requestOpenNewTabFromPage();
59+
onComplete();
60+
}, [logEvent, onComplete]);
61+
62+
return (
63+
<div className="flex w-full flex-1 flex-col items-center justify-center px-6 py-10">
64+
<div className="flex w-full max-w-[48rem] flex-col items-center gap-5 text-center">
65+
<div className="flex flex-col items-center gap-2">
66+
<Typography
67+
tag={TypographyTag.H1}
68+
type={TypographyType.LargeTitle}
69+
color={TypographyColor.Primary}
70+
bold
71+
className="text-balance"
72+
>
73+
Welcome! Let&apos;s activate your new tab.
74+
</Typography>
75+
76+
<Typography
77+
tag={TypographyTag.P}
78+
type={TypographyType.Title3}
79+
color={TypographyColor.Secondary}
80+
className="text-balance"
81+
>
82+
Tap “Keep it” on the Chrome popup.
83+
</Typography>
84+
</div>
85+
86+
<ActivationDemoVideo />
87+
88+
<div className="flex flex-col items-center gap-4">
89+
<Button
90+
type="button"
91+
variant={ButtonVariant.Primary}
92+
size={ButtonSize.XLarge}
93+
disabled={isOpening}
94+
onClick={handleOpenNewTab}
95+
>
96+
{isOpening ? 'Opening…' : 'Open new tab'}
97+
</Button>
98+
<p className="text-text-tertiary typo-callout">
99+
Takes 2 seconds. Reversible anytime.
100+
</p>
101+
</div>
102+
</div>
103+
</div>
104+
);
105+
}

packages/shared/src/lib/extension.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ export enum ExtensionMessageType {
88
DisableFrameEmbeddingForTab = 'DISABLE_FRAME_EMBEDDING_FOR_TAB',
99
RequestFrameEmbeddingPermissions = 'REQUEST_FRAME_EMBEDDING_PERMISSIONS',
1010
PingFrameEmbeddingReady = 'PING_FRAME_EMBEDDING_READY',
11+
RequestOpenNewTab = 'REQUEST_OPEN_NEW_TAB',
12+
ConsumeActivateOnboarding = 'CONSUME_ACTIVATE_ONBOARDING',
1113
}
1214

1315
export const getCompanionWrapper = (): HTMLElement | null =>

packages/shared/src/lib/featureManagement.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,3 +218,8 @@ export const featurePostHighlightCards = new Feature(
218218
'post_highlight_cards',
219219
false,
220220
);
221+
222+
export const featureOnboardingPermissionPrimer = new Feature(
223+
'onboarding_permission_primer',
224+
false,
225+
);

packages/shared/src/lib/image.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,9 @@ export const cloudinaryOnboardingExtension = {
268268
},
269269
};
270270

271+
export const cloudinaryOnboardingActivationDemo =
272+
'https://media.daily.dev/video/upload/v1780303637/daily.dev_-_Keep_it_acphx8.mp4';
273+
271274
export const bookmarkFolderSoonImage =
272275
'https://media.daily.dev/image/upload/s--_jM3zDSE--/f_auto/v1733239852/daily_dev_bookmarks_folders_fsughm';
273276

packages/shared/src/lib/log.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -486,6 +486,10 @@ export enum LogEvent {
486486
ReaderEmbedError = 'reader embed error',
487487
// Onboarding personas
488488
SelectOnboardingPersona = 'select onboarding persona',
489+
// Extension activation primer
490+
ExtensionPrimerShown = 'impression extension primer',
491+
ExtensionPrimerCtaClick = 'click extension primer cta',
492+
ExtensionPrimerSkipped = 'skip extension primer',
489493
}
490494

491495
export enum TargetType {

packages/webapp/components/layouts/FeedByIds/FeedByIdsLayout.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { ReactElement, ReactNode } from 'react';
22
import React, { useMemo, useState } from 'react';
3+
import classNames from 'classnames';
34

45
import { useAuthContext } from '@dailydotdev/shared/src/contexts/AuthContext';
56
import {
@@ -54,7 +55,12 @@ export default function FeedByIdsLayout({
5455
<MobileFeedActions />
5556
</div>
5657
<FeedPageHeader className="mb-5" />
57-
{!showEmptyScreen && !!ids?.length && <Feed {...feedProps} />}
58+
{!showEmptyScreen && !!ids?.length && (
59+
<Feed
60+
{...feedProps}
61+
className={classNames('laptop:px-10', feedProps.className)}
62+
/>
63+
)}
5864
{showEmptyScreen && <SearchEmptyScreen />}
5965
</>
6066
);

0 commit comments

Comments
 (0)