Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions packages/extension/src/newtab/InHouseDndPage.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<main className="flex min-h-dvh w-full items-center justify-center bg-background-default px-6 text-center">
<section className="flex w-full max-w-sm flex-col items-center gap-6">
<img
src={cloudinaryReadingReminderCat}
alt=""
className="h-auto w-52 max-w-full"
/>
<h1 className="text-text-primary typo-title2">Focus mode on</h1>
<Button
type="button"
variant={ButtonVariant.Secondary}
size={ButtonSize.Small}
onClick={onExit}
>
Exit focus mode
</Button>
</section>
</main>
);
}
46 changes: 39 additions & 7 deletions packages/extension/src/newtab/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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(<App localBootData={data ?? undefined} />);
};

const renderDndApp = (data?: BootCacheData | null) => {
const onExit = async () => {
await setCache('dnd', null);
renderApp(data);
};

const renderApp = (data?: BootCacheData) => {
root.render(<App localBootData={data} />);
root.render(<InHouseDndPage onExit={onExit} />);
};

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 });
};
Expand All @@ -53,8 +76,17 @@ const redirectApp = async (url: string) => {
return renderApp(data);
}

const dnd = await getCache<DndSettings>('dnd');
const isDnd = dnd?.expiration?.getTime() > new Date().getTime();
const dnd = await getCache<DndSettings | null>('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);
})();
181 changes: 181 additions & 0 deletions packages/shared/src/lib/evaluateFeatureFromBoot.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;

const getIsMobile = (): boolean =>
globalThis.matchMedia?.('(max-width: 655px)').matches ?? false;

const getExtraAttributes = (
attributes: NonNullable<BootCacheData['exp']>['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<NonNullable<Context['trackingCallback']>>[0];
result: Parameters<NonNullable<Context['trackingCallback']>>[1];
}): Promise<void> => {
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 <T extends JSONValue>({
bootData,
feature,
app,
version,
}: {
bootData?: BootCacheData | null;
feature: Feature<T>;
app: BootApp;
version?: string;
}): Promise<WidenPrimitives<T>> => {
if (!bootData?.exp?.features) {
return feature.defaultValue as WidenPrimitives<T>;
}

const deviceId = await getOrGenerateDeviceId();
const trackingCalls: Promise<void>[] = [];
const growthbook = new GrowthBook({
enableDevMode: !isProduction || isGBDevMode,
trackingCallback: (experiment, result) => {
trackingCalls.push(
sendAllocation({ bootData, deviceId, experiment, result }).catch(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not sure if valid without userId, could allocate differently, also flash would only be first time no? then boot cache kicks in either way?

() => undefined,
),
);
},
});

growthbook.setFeatures(bootData.exp.features);
growthbook.setAttributes(
getAttributes({
bootData,
app,
deviceId,
version,
}),
);

const value = growthbook.getFeatureValue(
feature.id,
feature.defaultValue,
) as WidenPrimitives<T>;

await Promise.all(trackingCalls);

return value;
};
5 changes: 5 additions & 0 deletions packages/shared/src/lib/featureManagement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading