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,