diff --git a/packages/extension/src/newtab/InHouseDndPage.tsx b/packages/extension/src/newtab/InHouseDndPage.tsx new file mode 100644 index 0000000000..81e5105800 --- /dev/null +++ b/packages/extension/src/newtab/InHouseDndPage.tsx @@ -0,0 +1,37 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import { cloudinaryReadingReminderCat } from '@dailydotdev/shared/src/lib/image'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; + +type InHouseDndPageProps = { + onExit: () => void; +}; + +export default function InHouseDndPage({ + onExit, +}: InHouseDndPageProps): ReactElement { + return ( +
+
+ +

Focus mode on

+ +
+
+ ); +} diff --git a/packages/extension/src/newtab/index.tsx b/packages/extension/src/newtab/index.tsx index 176eb6ed93..c62452d330 100644 --- a/packages/extension/src/newtab/index.tsx +++ b/packages/extension/src/newtab/index.tsx @@ -7,10 +7,15 @@ import { applyTheme, themeModes, } from '@dailydotdev/shared/src/contexts/SettingsContext'; -import { get as getCache } from 'idb-keyval'; +import { get as getCache, set as setCache } from 'idb-keyval'; import browser from 'webextension-polyfill'; import type { DndSettings } from '@dailydotdev/shared/src/contexts/DndContext'; +import { featureExtensionInHouseDnd } from '@dailydotdev/shared/src/lib/featureManagement'; +import { evaluateFeatureFromBoot } from '@dailydotdev/shared/src/lib/evaluateFeatureFromBoot'; +import { BootApp } from '@dailydotdev/shared/src/lib/boot'; +import { version } from '../../package.json'; import App from './App'; +import InHouseDndPage from './InHouseDndPage'; declare global { interface Window { @@ -28,14 +33,32 @@ window.addEventListener( }, ); -const root = createRoot(document.getElementById('__next')); +const rootElement = document.getElementById('__next'); +if (!rootElement) { + throw new Error('Missing new tab root element'); +} + +const root = createRoot(rootElement); + +const renderApp = (data?: BootCacheData | null) => { + root.render(); +}; + +const renderDndApp = (data?: BootCacheData | null) => { + const onExit = async () => { + await setCache('dnd', null); + renderApp(data); + }; -const renderApp = (data?: BootCacheData) => { - root.render(); + root.render(); }; const redirectApp = async (url: string) => { const tab = await browser.tabs.getCurrent(); + if (tab.id == null) { + throw new Error('Cannot redirect Do Not Disturb tab without a tab id'); + } + window.stop(); await browser.tabs.update(tab.id, { url }); }; @@ -53,8 +76,17 @@ const redirectApp = async (url: string) => { return renderApp(data); } - const dnd = await getCache('dnd'); - const isDnd = dnd?.expiration?.getTime() > new Date().getTime(); + const dnd = await getCache('dnd'); + if (!dnd || dnd.expiration.getTime() <= new Date().getTime()) { + return renderApp(data); + } + + const isInHouseDnd = await evaluateFeatureFromBoot({ + bootData: data, + feature: featureExtensionInHouseDnd, + app: BootApp.Extension, + version, + }); - return isDnd ? redirectApp(dnd.link) : renderApp(data); + return isInHouseDnd ? renderDndApp(data) : redirectApp(dnd.link); })(); diff --git a/packages/shared/src/lib/evaluateFeatureFromBoot.ts b/packages/shared/src/lib/evaluateFeatureFromBoot.ts new file mode 100644 index 0000000000..b5a2b450c0 --- /dev/null +++ b/packages/shared/src/lib/evaluateFeatureFromBoot.ts @@ -0,0 +1,181 @@ +import type { + Context, + JSONValue, + WidenPrimitives, +} from '@growthbook/growthbook'; +import { GrowthBook } from '@growthbook/growthbook'; +import type { BootApp, BootCacheData } from './boot'; +import { apiUrl } from './config'; +import type { Feature } from './featureManagement'; +import { isGBDevMode, isProduction } from './constants'; +import { getOrGenerateDeviceId } from '../hooks/log/useDeviceId'; +import { BOOT_LOCAL_KEY } from '../contexts/common'; +import { storageWrapper as storage } from './storageWrapper'; + +type ExperimentAttributes = Record; + +const getIsMobile = (): boolean => + globalThis.matchMedia?.('(max-width: 655px)').matches ?? false; + +const getExtraAttributes = ( + attributes: NonNullable['a'] | undefined, +): ExperimentAttributes => { + if (!attributes || Array.isArray(attributes)) { + return {}; + } + + return attributes as ExperimentAttributes; +}; + +const getAttributes = ({ + bootData, + app, + deviceId, + version, +}: { + bootData: BootCacheData; + app: BootApp; + deviceId: string; + version?: string; +}): ExperimentAttributes => { + const { user } = bootData; + const attributes: ExperimentAttributes = { + userId: user?.id, + deviceId, + version, + platform: app, + mobile: getIsMobile(), + ...getExtraAttributes(bootData.exp?.a), + }; + + if (!user) { + return attributes; + } + + if ('providers' in user) { + return { + ...attributes, + loggedIn: true, + registrationDate: user.createdAt, + }; + } + + return { + ...attributes, + loggedIn: false, + firstVisit: user.firstVisit, + }; +}; + +const cacheExperimentAllocation = ( + bootData: BootCacheData, + key: string, +): void => { + if (!bootData.exp) { + return; + } + + const cached = storage.getItem(BOOT_LOCAL_KEY); + const parsed = cached ? (JSON.parse(cached) as BootCacheData) : bootData; + const e = parsed.exp?.e ?? []; + + if (e.includes(key)) { + return; + } + + storage.setItem( + BOOT_LOCAL_KEY, + JSON.stringify({ + ...parsed, + exp: { + ...parsed.exp, + e: [...e, key], + }, + lastModifier: 'extension', + }), + ); +}; + +const sendAllocation = async ({ + bootData, + deviceId, + experiment, + result, +}: { + bootData: BootCacheData; + deviceId: string; + experiment: Parameters>[0]; + result: Parameters>[1]; +}): Promise => { + const variationId = result.variationId.toString(); + const key = btoa(`${experiment.key}:${variationId}`); + + if (bootData.exp?.e?.includes(key)) { + return; + } + + await fetch(`${apiUrl}/e/x`, { + method: 'POST', + keepalive: true, + body: JSON.stringify({ + event_timestamp: new Date(), + user_id: bootData.user?.id, + device_id: deviceId, + experiment_id: experiment.key, + variation_id: variationId, + }), + credentials: 'include', + headers: { + 'content-type': 'application/json', + }, + }); + cacheExperimentAllocation(bootData, key); +}; + +export const evaluateFeatureFromBoot = async ({ + bootData, + feature, + app, + version, +}: { + bootData?: BootCacheData | null; + feature: Feature; + app: BootApp; + version?: string; +}): Promise> => { + if (!bootData?.exp?.features) { + return feature.defaultValue as WidenPrimitives; + } + + const deviceId = await getOrGenerateDeviceId(); + const trackingCalls: Promise[] = []; + const growthbook = new GrowthBook({ + enableDevMode: !isProduction || isGBDevMode, + trackingCallback: (experiment, result) => { + trackingCalls.push( + sendAllocation({ bootData, deviceId, experiment, result }).catch( + () => undefined, + ), + ); + }, + }); + + growthbook.setFeatures(bootData.exp.features); + growthbook.setAttributes( + getAttributes({ + bootData, + app, + deviceId, + version, + }), + ); + + const value = growthbook.getFeatureValue( + feature.id, + feature.defaultValue, + ) as WidenPrimitives; + + await Promise.all(trackingCalls); + + return value; +}; diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts index db0e9593c0..d1827f57ad 100644 --- a/packages/shared/src/lib/featureManagement.ts +++ b/packages/shared/src/lib/featureManagement.ts @@ -195,6 +195,11 @@ export const featureNewTabCustomizer = new Feature( false, ); +export const featureExtensionInHouseDnd = new Feature( + 'extension_in_house_dnd', + true, +); + export const featureCompanionDemoWidget = new Feature( 'companion_demo_widget', false,