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
96 changes: 96 additions & 0 deletions packages/shared/__tests__/helpers/qr.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import jsQR from 'jsqr';

// The repo ships two hand-committed QR matrices (the header get-app asset and
// the onboarding signup panel). Their destination URLs live in code, but the
// matrices are opaque path strings - if one moves without the other being
// regenerated, the only symptom is a scan landing on a stale destination.
// These helpers parse a committed path back into a module matrix and decode it
// the way a phone camera would (via jsQR), so a spec can assert the committed
// pixels still say what the code claims. Decoding rather than re-encoding
// keeps the assertion valid regardless of which generator or mask pattern
// produced the asset.

type Matrix = boolean[][];

// Stroke-run format, as emitted by `npx qrcode -t svg`: rows drawn as
// 1px-tall horizontal strokes on half-pixel centres, e.g.
// `M4 4.5h7m2 0h2` = at row 4 draw 7 modules from col 4, skip 2, draw 2.
// `quietZone` is the generator's fixed 4-module margin baked into the coords.
export const parseStrokePath = (d: string, quietZone = 4): Matrix => {
const cells: Array<[number, number]> = [];
const rowRuns = d.split('M').filter(Boolean);

rowRuns.forEach((run) => {
const [, xStart, yCentre] = run.match(/^([\d.]+) ([\d.]+)/) ?? [];
const row = Math.floor(Number(yCentre)) - quietZone;
let col = Number(xStart) - quietZone;

Array.from(run.matchAll(/([hm])(-?[\d.]+)/g)).forEach(([, op, a]) => {
const n = Number(a);
if (op === 'h') {
for (let i = 0; i < n; i += 1) {
cells.push([row, col + i]);
}
}
col += n;
});
});

const size = Math.max(...cells.map(([row]) => row)) + 1;
const matrix: Matrix = Array.from({ length: size }, () =>
Array.from({ length: size }, () => false),
);
cells.forEach(([row, col]) => {
matrix[row][col] = true;
});

return matrix;
};

// Rect format, as used by the hand-flattened onboarding matrix:
// `M0 0h7v1h-7z` = a 7-module run at row 0, col 0. Coordinates are already
// module-space (the quiet zone lives in the viewBox origin instead).
export const parseRectPath = (d: string): Matrix => {
const rects = Array.from(d.matchAll(/M(\d+) (\d+)h(\d+)v1h-\3z/g));
const size = Math.max(...rects.map(([, , y]) => Number(y))) + 1;
const matrix: Matrix = Array.from({ length: size }, () =>
Array.from({ length: size }, () => false),
);

rects.forEach(([, x, y, w]) => {
for (let i = 0; i < Number(w); i += 1) {
matrix[Number(y)][Number(x) + i] = true;
}
});

return matrix;
};

// Rasterises the matrix into greyscale RGBA (scaled up, with a real quiet
// zone) and runs the same decoder a scanner pipeline would.
export const decodeMatrix = (matrix: Matrix): string | null => {
const scale = 4;
const quiet = 4 * scale;
const px = matrix.length * scale + quiet * 2;
const rgba = new Uint8ClampedArray(px * px * 4).fill(255);

matrix.forEach((rowCells, row) => {
rowCells.forEach((dark, col) => {
if (!dark) {
return;
}
for (let dy = 0; dy < scale; dy += 1) {
for (let dx = 0; dx < scale; dx += 1) {
const x = quiet + col * scale + dx;
const y = quiet + row * scale + dy;
const at = (y * px + x) * 4;
rgba[at] = 0;
rgba[at + 1] = 0;
rgba[at + 2] = 0;
}
}
});
});

return jsQR(rgba, px, px)?.data ?? null;
};
1 change: 1 addition & 0 deletions packages/shared/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"jest-junit": "^12.3.0",
"jsqr": "^1.4.0",
"next": "16.2.6",
"nock": "^13.3.1",
"postcss": "^8.5.13",
Expand Down
1 change: 0 additions & 1 deletion packages/shared/src/components/layout/HeaderButtons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,6 @@ export function HeaderButtons({
<QuestHeaderButton />
<GivebackGiftEntry compact={!isLaptop} />
{additionalButtons}
<GetAppButton />
<NotificationsBell />
<ProfileButton className="hidden laptop:flex" />
</Container>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,25 +4,24 @@ import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { TestBootProvider } from '../../../../__tests__/helpers/boot';
import { GetAppButton } from './GetAppButton';
import { useConditionalFeature } from '../../../hooks/useConditionalFeature';
import { useViewSize } from '../../../hooks';
import { isIOSNative } from '../../../lib/func';
import { appStoreUrl, playStoreUrl } from '../../../lib/constants';

jest.mock('../../../hooks/useConditionalFeature', () => ({
useConditionalFeature: jest.fn(),
jest.mock('../../../lib/func', () => ({
...jest.requireActual('../../../lib/func'),
isIOSNative: jest.fn(),
}));

jest.mock('../../../hooks', () => ({
...jest.requireActual('../../../hooks'),
useViewSize: jest.fn(),
}));

const mockUseConditionalFeature = useConditionalFeature as jest.Mock;
const mockUseViewSize = useViewSize as jest.Mock;
const mockIsIOSNative = isIOSNative as jest.Mock;

// TestBootProvider defaults to a logged-in session; this surface is for
// anonymous visitors, so the tests start logged out and opt in explicitly.
const renderComponent = (props = {}, auth = {}) =>
render(
<TestBootProvider client={new QueryClient()} auth={auth}>
<TestBootProvider
client={new QueryClient()}
auth={{ isLoggedIn: false, ...auth }}
>
<GetAppButton {...props} />
</TestBootProvider>,
);
Expand All @@ -31,58 +30,56 @@ const triggerName = /get the daily\.dev mobile app/i;

beforeEach(() => {
jest.clearAllMocks();
mockUseViewSize.mockReturnValue(true);
mockUseConditionalFeature.mockReturnValue({ value: true, isLoading: false });
mockIsIOSNative.mockReturnValue(false);
});

describe('GetAppButton', () => {
it('should render the trigger when the feature is enabled on desktop', () => {
it('should render the trigger for anonymous desktop visitors', () => {
renderComponent();

expect(
screen.getByRole('button', { name: triggerName }),
).toBeInTheDocument();
});

it('should render nothing when the feature is off', () => {
mockUseConditionalFeature.mockReturnValue({
value: false,
isLoading: false,
});
renderComponent();
it('should render nothing for logged-in users', () => {
renderComponent({}, { isLoggedIn: true });

expect(
screen.queryByRole('button', { name: triggerName }),
).not.toBeInTheDocument();
});

it('should render nothing below laptop, where the app is already at hand', () => {
mockUseViewSize.mockReturnValue(false);
// The desktop-only half of the gate is CSS, not JS, so the SSR HTML already
// contains the pill and hydration doesn't reflow Log in / Sign up. jsdom
// applies no stylesheets, so the assertable contract is the classes.
it('should gate the desktop breakpoint in CSS to avoid a hydration reflow', () => {
renderComponent();

expect(
screen.queryByRole('button', { name: triggerName }),
).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: triggerName })).toHaveClass(
'hidden',
'laptop:flex',
);
});

// The Android wrapper has no isIOSNative()-style runtime bridge and is
// flagged through boot data instead. A tablet/desktop-mode viewport can
// satisfy the laptop breakpoint from inside the app, so the boot signal must
// veto the viewport gate.
it('should render nothing inside the Android app even at laptop width', () => {
renderComponent({}, { isAndroidApp: true });
// The wrappers render this same webapp shell and a tablet/desktop-mode
// viewport can satisfy the laptop breakpoint from inside the app, so both
// need a JS veto the CSS gate can't provide. iOS is detected through its
// WebKit runtime bridge.
it('should render nothing inside the iOS app even at laptop width', () => {
mockIsIOSNative.mockReturnValue(true);
renderComponent();

expect(
screen.queryByRole('button', { name: triggerName }),
).not.toBeInTheDocument();
});

it('should not evaluate the flag when the caller already resolved it', () => {
renderComponent({ isFeatureEnabled: false });
// Android has no isIOSNative()-style runtime bridge and is flagged through
// boot data instead.
it('should render nothing inside the Android app even at laptop width', () => {
renderComponent({}, { isAndroidApp: true });

expect(mockUseConditionalFeature).toHaveBeenCalledWith(
expect.objectContaining({ shouldEvaluate: false }),
);
expect(
screen.queryByRole('button', { name: triggerName }),
).not.toBeInTheDocument();
Expand Down
110 changes: 37 additions & 73 deletions packages/shared/src/features/getApp/components/GetAppButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,6 @@ import { AppleIcon, PhoneIcon } from '../../../components/icons';
import { GooglePlayIcon } from '../../../components/icons/GooglePlay';
import type { IconProps } from '../../../components/Icon';
import { IconSize } from '../../../components/Icon';
import { useViewSize, ViewSize } from '../../../hooks';
import { useConditionalFeature } from '../../../hooks/useConditionalFeature';
import usePersistentContext from '../../../hooks/usePersistentContext';
import { featureHeaderGetApp } from '../../../lib/featureManagement';
import { useAuthContext } from '../../../contexts/AuthContext';
import { useLogContext } from '../../../contexts/LogContext';
import { LogEvent, TargetType } from '../../../lib/log';
Expand Down Expand Up @@ -52,50 +48,33 @@ const stores: AppStore[] = [
},
];

// One-time attention dot. The button is a permanent affordance, not a campaign,
// so it nudges once and then goes quiet - a banner that keeps reappearing is
// what we're deliberately not building here.
const GET_APP_SEEN_KEY = 'getAppHeaderSeen';

export interface GetAppButtonProps {
// The logged-out header has room for the full label; the logged-in action
// rail (opportunity, quests, giveback, bell, avatar) does not, so it gets the
// icon-only trigger with the label in a tooltip.
// The logged-out header has room for the full label next to Log in /
// Sign up; icon-only (label in a tooltip) exists as the compact alternative.
showLabel?: boolean;
className?: string;
// Escape hatch for surfaces that already resolved `featureHeaderGetApp` (and
// for Storybook, which has no GrowthBook instance). Left undefined, the
// button evaluates the flag itself.
isFeatureEnabled?: boolean;
}

export function GetAppButton({
showLabel = false,
className,
isFeatureEnabled: isFeatureEnabledProp,
}: GetAppButtonProps): ReactElement | null {
const [isOpen, setIsOpen] = useState(false);
const { logEvent } = useLogContext();
const { isAndroidApp } = useAuthContext();
const isLaptop = useViewSize(ViewSize.Laptop);
// The point of this entry point is telling *desktop* visitors that a mobile
// app exists. Below laptop we're either on mobile web or inside the native
// wrapper - which renders this same webapp shell - and neither should be
// told to go get an app they're already holding. The wrappers need their own
// signals on top of the viewport gate because a tablet/desktop-mode viewport
// can satisfy the laptop breakpoint from inside the app: iOS exposes a
// WebKit bridge at runtime (isIOSNative), Android has no such bridge and is
// flagged through boot data instead.
const shouldRender = isLaptop && !isIOSNative() && !isAndroidApp;
const { value: isFlagEnabled } = useConditionalFeature({
feature: featureHeaderGetApp,
shouldEvaluate: shouldRender && isFeatureEnabledProp === undefined,
});
const isFeatureEnabled = isFeatureEnabledProp ?? isFlagEnabled;
const [hasSeen, setHasSeen, isSeenFetched] = usePersistentContext<boolean>(
GET_APP_SEEN_KEY,
false,
);
const { isLoggedIn, isAndroidApp } = useAuthContext();
// This entry point is for *anonymous desktop* visitors only - logged-in
// users made a product call to keep their action rail clean, so the gate
// lives here rather than trusting every call site. The desktop half is
// gated in CSS (`hidden laptop:flex` on the trigger, matching LoginButton
// and ProfileButton in the same row) so the server HTML already contains
// the pill and hydration doesn't reflow Log in / Sign up on first paint.
// The native wrappers still need JS vetoes on top: they render this same
// webapp shell, a tablet/desktop-mode viewport can satisfy the laptop
// breakpoint from inside the app, and nobody should be told to go get an
// app they're already holding. iOS exposes a WebKit bridge at runtime
// (isIOSNative); Android has no such bridge and is flagged through boot
// data instead.
const shouldRender = !isLoggedIn && !isIOSNative() && !isAndroidApp;

const onOpenChange = useCallback(
(open: boolean) => {
Expand All @@ -105,13 +84,12 @@ export function GetAppButton({
return;
}

setHasSeen(true);
logEvent({
event_name: LogEvent.Click,
target_type: TargetType.GetAppButton,
});
},
[logEvent, setHasSeen],
[logEvent],
);

const onStoreClick = useCallback(
Expand All @@ -125,45 +103,31 @@ export function GetAppButton({
[logEvent],
);

if (!shouldRender || !isFeatureEnabled) {
if (!shouldRender) {
Comment thread
tsahimatsliah marked this conversation as resolved.
return null;
}

const showDot = !showLabel && isSeenFetched && !hasSeen;

return (
<Popover open={isOpen} onOpenChange={onOpenChange}>
{/* The dot is a sibling of the Button, not a child: Button derives its
icon-only geometry from `!children`, so nesting the dot inside would
switch it to the labelled layout (`pl-2 pr-4 gap-1`) and push the
glyph 4px off centre. */}
<span className={classNames('relative inline-flex', className)}>
<Tooltip
content="Get the mobile app"
side="bottom"
visible={!showLabel}
>
<PopoverTrigger asChild>
<Button
variant={ButtonVariant.Float}
size={showLabel ? ButtonSize.Medium : undefined}
className={classNames('justify-center', !showLabel && 'w-10')}
icon={<PhoneIcon secondary={isOpen} />}
aria-haspopup="dialog"
aria-expanded={isOpen}
aria-label="Get the daily.dev mobile app"
>
{showLabel ? 'Get the app' : null}
</Button>
</PopoverTrigger>
</Tooltip>
{showDot && (
<span
aria-hidden
className="pointer-events-none absolute -right-0.5 -top-0.5 size-2.5 rounded-full border-2 border-background-default bg-accent-cabbage-default"
/>
)}
</span>
<Tooltip content="Get the mobile app" side="bottom" visible={!showLabel}>
<PopoverTrigger asChild>
<Button
variant={ButtonVariant.Float}
size={showLabel ? ButtonSize.Medium : undefined}
className={classNames(
'hidden justify-center laptop:flex',
!showLabel && 'w-10',
className,
)}
icon={<PhoneIcon secondary={isOpen} />}
aria-haspopup="dialog"
aria-expanded={isOpen}
aria-label="Get the daily.dev mobile app"
>
{showLabel ? 'Get the app' : null}
</Button>
</PopoverTrigger>
</Tooltip>
<PopoverContent
align="end"
sideOffset={8}
Expand Down
Loading
Loading