Skip to content
Draft
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
73 changes: 73 additions & 0 deletions packages/shared/src/components/streak/StreakShareCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import type { ReactElement } from 'react';
import React from 'react';
import LogoIcon from '../../svg/LogoIcon';
import LogoText from '../../svg/LogoText';
import { ReadingStreakIcon } from '../icons';
import { IconSize } from '../Icon';
import {
Typography,
TypographyColor,
TypographyType,
} from '../typography/Typography';
import type { PublicUserStreak } from '../../graphql/users';

const Stat = ({
value,
label,
}: {
value: number;
label: string;
}): ReactElement => (
<span className="flex flex-1 flex-col items-center">
<Typography type={TypographyType.Title3} bold className="tabular-nums">
{value}
</Typography>
<Typography
type={TypographyType.Caption1}
color={TypographyColor.Quaternary}
>
{label}
</Typography>
</span>
);

// Rendered headless by the screenshot service (see
// `webapp/pages/image-generator/streak/[userId]`), so it must stay
// self-contained: no context, no data fetching, fixed size.
export function StreakShareCard({
streak,
}: {
streak: PublicUserStreak;
}): ReactElement {
return (
<div className="flex h-80 w-80 flex-col items-center justify-between rounded-24 border border-border-subtlest-tertiary bg-background-default p-6 text-center text-text-primary">
<div className="flex items-center gap-1">
<LogoIcon className={{ container: 'h-logo' }} />
<LogoText className={{ container: 'h-logo' }} />
</div>

<div className="flex flex-col items-center gap-1">
<ReadingStreakIcon secondary size={IconSize.XXXLarge} />
<Typography
type={TypographyType.Mega1}
bold
className="tabular-nums"
data-testid="streak-share-current"
>
{streak.current}
</Typography>
<Typography
type={TypographyType.Body}
color={TypographyColor.Secondary}
>
day reading streak
</Typography>
</div>

<div className="flex w-full gap-2">
<Stat value={streak.max} label="Longest streak" />
<Stat value={streak.total} label="Total reading days" />
</div>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import React from 'react';
import type { RenderResult } from '@testing-library/react';
import { render, screen, waitFor } from '@testing-library/react';
import { QueryClient } from '@tanstack/react-query';
import { GrowthBook } from '@growthbook/growthbook-react';
import { ReadingStreakPopup } from './ReadingStreakPopup';
import { TestBootProvider } from '../../../../__tests__/helpers/boot';
import type { UserStreak } from '../../../graphql/users';
import { DayOfWeek } from '../../../lib/date';
import type { LoggedUser } from '../../../lib/user';

jest.mock('next/router', () => ({
useRouter: () => ({ push: jest.fn(), isReady: true, query: {} }),
}));

jest.mock('../../../graphql/users', () => ({
...jest.requireActual('../../../graphql/users'),
getReadingStreak30Days: jest.fn().mockResolvedValue([]),
}));

jest.mock('../../../hooks/useActions', () => ({
useActions: () => ({
completeAction: jest.fn(),
checkHasCompleted: () => false,
isActionsFetched: true,
}),
}));

const user = {
id: '1',
name: 'Ido',
username: 'idoshamun',
permalink: 'https://app.daily.dev/idoshamun',
timezone: 'Etc/UTC',
} as unknown as LoggedUser;

const streak: UserStreak = {
current: 12,
max: 40,
total: 120,
weekStart: DayOfWeek.Monday,
lastViewAt: new Date(),
};

const renderPopup = ({
enabled = false,
overrides = {},
}: {
enabled?: boolean;
overrides?: Partial<UserStreak>;
} = {}): RenderResult => {
const gb = new GrowthBook();
gb.setFeatures({
sharing_visibility: { defaultValue: enabled },
share_streak: { defaultValue: enabled },
});

return render(
<TestBootProvider
client={
new QueryClient({ defaultOptions: { queries: { retry: false } } })
}
auth={{ user }}
gb={gb}
>
<ReadingStreakPopup streak={{ ...streak, ...overrides }} />
</TestBootProvider>,
);
};

const getSettingsLink = (): HTMLElement =>
document.querySelector<HTMLElement>(
'a[href*="account/customization/streaks"]',
)!;

describe('ReadingStreakPopup share action', () => {
it('keeps the current UI untouched when the flag is off', async () => {
renderPopup();

expect(await screen.findByText('Current streak')).toBeInTheDocument();
expect(screen.queryByLabelText('Share streak')).not.toBeInTheDocument();
// No wrapper is injected: settings stays a direct child of the action row.
expect(getSettingsLink().parentElement?.className).toContain('mt-4');
});

it('renders the share action in the existing row when enabled', async () => {
renderPopup({ enabled: true });

await waitFor(() =>
expect(screen.getByLabelText('Share streak')).toBeInTheDocument(),
);
// Share and settings share one row wrapper, so the popup height is stable.
const shareButton = screen.getByLabelText('Share streak');
expect(shareButton.parentElement).toContainElement(getSettingsLink());
});

it('hides the share action with nothing to share yet', () => {
renderPopup({ enabled: true, overrides: { current: 0 } });

expect(screen.queryByLabelText('Share streak')).not.toBeInTheDocument();
});
});
58 changes: 48 additions & 10 deletions packages/shared/src/components/streak/popup/ReadingStreakPopup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,11 @@ import { NotificationSvg } from '../../notifications/NotificationSvg';
import { usePushNotificationContext } from '../../../contexts/PushNotificationContext';
import { IconSize } from '../../Icon';
import { Tooltip } from '../../tooltip/Tooltip';
import ConditionalWrapper from '../../ConditionalWrapper';
import { ShareStreakButton } from './ShareStreakButton';
import { useConditionalFeature } from '../../../hooks/useConditionalFeature';
import { useSharingVisibility } from '../../../hooks/useSharingVisibility';
import { featureShareStreak } from '../../../lib/featureManagement';

const getStreak = ({
value,
Expand Down Expand Up @@ -136,6 +141,19 @@ export function ReadingStreakPopup({
);
const { onTogglePermission, acceptedJustNow } = usePushNotificationMutation();

// Nothing to be proud of at zero, and a share needs a profile to point at.
const hasShareableStreak = streak?.current > 0 && !!user?.permalink;
const { isEnabled: isSharingVisible } =
useSharingVisibility(hasShareableStreak);
const { value: isShareStreakEnabled } = useConditionalFeature({
feature: featureShareStreak,
// Only evaluated once the master gate passes, so control users are never
// bucketed into the share_streak experiment.
shouldEvaluate: hasShareableStreak && isSharingVisible,
});
const canShareStreak =
hasShareableStreak && isSharingVisible && isShareStreakEnabled;

const showAlert =
isPushSupported &&
isAlertShown &&
Expand Down Expand Up @@ -303,16 +321,36 @@ export function ReadingStreakPopup({
</div>
</Tooltip>
</div>
<Link href={`${webappUrl}account/customization/streaks`} passHref>
<Button
tag="a"
variant={ButtonVariant.Float}
icon={<SettingsIcon />}
className={isMobile ? 'w-full' : 'ml-auto'}
>
{isMobile ? 'Settings' : null}
</Button>
</Link>
<ConditionalWrapper
condition={canShareStreak}
wrapper={(children) => (
// Share sits inside the existing action row so the popup keeps
// its height; on mobile both buttons split the row evenly.
<div className="flex w-full gap-2 tablet:ml-auto tablet:w-auto">
<ShareStreakButton
currentStreak={streak.current}
link={user!.permalink}
showLabel={isMobile}
className={isMobile ? 'w-full' : undefined}
/>
{children}
</div>
)}
>
<Link href={`${webappUrl}account/customization/streaks`} passHref>
<Button
tag="a"
variant={ButtonVariant.Float}
icon={<SettingsIcon />}
className={classNames(
isMobile && 'w-full',
!isMobile && !canShareStreak && 'ml-auto',
)}
>
{isMobile ? 'Settings' : null}
</Button>
</Link>
</ConditionalWrapper>
</div>
</div>
{showAlert && (
Expand Down
143 changes: 143 additions & 0 deletions packages/shared/src/components/streak/popup/ShareStreakButton.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import React from 'react';
import type { RenderResult } from '@testing-library/react';
import {
act,
fireEvent,
render,
screen,
waitFor,
} from '@testing-library/react';
import { QueryClient } from '@tanstack/react-query';
import { ShareStreakButton, getStreakShareText } from './ShareStreakButton';
import { TestBootProvider } from '../../../../__tests__/helpers/boot';
import type { ToastNotification } from '../../../hooks/useToastNotification';
import { TOAST_NOTIF_KEY } from '../../../hooks/useToastNotification';

const link = 'https://app.daily.dev/idoshamun';
const writeText = jest.fn().mockResolvedValue(undefined);
const share = jest.fn().mockResolvedValue(undefined);
const canShare = jest.fn();
// `shouldUseNativeShare` gates on `isMobile()`, which reads this flag.
const setMobile = () => {
globalThis.localStorage.mobile = 'true';
};

beforeEach(() => {
jest.clearAllMocks();
delete globalThis.localStorage.mobile;
Object.assign(globalThis.navigator, { clipboard: { writeText } });
delete (globalThis.navigator as { share?: unknown }).share;
delete (globalThis.navigator as { canShare?: unknown }).canShare;
});

let client: QueryClient;

const renderComponent = (
props: Partial<Parameters<typeof ShareStreakButton>[0]> = {},
): RenderResult => {
client = new QueryClient();

return render(
<TestBootProvider client={client}>
<ShareStreakButton currentStreak={12} link={link} {...props} />
</TestBootProvider>,
);
};

const clickShare = async () => {
await act(async () => {
fireEvent.click(screen.getByLabelText('Share streak'));
});
};

describe('getStreakShareText', () => {
it('sounds like a developer and carries the streak length', () => {
expect(getStreakShareText(3)).toBe(
'3-day reading streak on daily.dev and counting.',
);
expect(getStreakShareText(45)).toContain('45 days straight');
expect(getStreakShareText(120)).toContain('120 days without breaking');
});
});

describe('ShareStreakButton on desktop', () => {
it('copies the link to the clipboard and shows a toast', async () => {
renderComponent();

await clickShare();

await waitFor(() => expect(writeText).toHaveBeenCalledWith(link));
await waitFor(() =>
expect(
client.getQueryData<ToastNotification>(TOAST_NOTIF_KEY)?.message,
).toEqual('✅ Copied link to clipboard'),
);
});
});

describe('ShareStreakButton on mobile', () => {
beforeEach(setMobile);

it('opens the native share sheet with the streak text and link', async () => {
Object.assign(globalThis.navigator, { share });

renderComponent();
await clickShare();

await waitFor(() => expect(share).toHaveBeenCalledTimes(1));
expect(share).toHaveBeenCalledWith({
text: `${getStreakShareText(12)}\n${link}`,
});
expect(writeText).not.toHaveBeenCalled();
});

it('attaches the streak image when the platform can share files', async () => {
Object.assign(globalThis.navigator, { share, canShare });
canShare.mockReturnValue(true);
globalThis.fetch = jest.fn().mockResolvedValue({
ok: true,
blob: async () => new Blob(['x'], { type: 'image/png' }),
});

renderComponent({ imageUrl: 'https://media.daily.dev/streak.png' });
await clickShare();

await waitFor(() => expect(share).toHaveBeenCalledTimes(1));
const [payload] = share.mock.calls[0];
expect(payload.files).toHaveLength(1);
expect(payload.files[0].type).toBe('image/png');
});

it('falls back to link + text when file sharing is unsupported', async () => {
Object.assign(globalThis.navigator, { share, canShare });
canShare.mockReturnValue(false);
globalThis.fetch = jest.fn().mockResolvedValue({
ok: true,
blob: async () => new Blob(['x'], { type: 'image/png' }),
});

renderComponent({ imageUrl: 'https://media.daily.dev/streak.png' });
await clickShare();

await waitFor(() => expect(share).toHaveBeenCalledTimes(1));
expect(share).toHaveBeenCalledWith({
text: `${getStreakShareText(12)}\n${link}`,
});
});

it('falls back to link + text when navigator.canShare is missing', async () => {
Object.assign(globalThis.navigator, { share });
globalThis.fetch = jest.fn().mockResolvedValue({
ok: true,
blob: async () => new Blob(['x'], { type: 'image/png' }),
});

renderComponent({ imageUrl: 'https://media.daily.dev/streak.png' });
await clickShare();

await waitFor(() => expect(share).toHaveBeenCalledTimes(1));
expect(share).toHaveBeenCalledWith({
text: `${getStreakShareText(12)}\n${link}`,
});
});
});
Loading
Loading