diff --git a/packages/shared/src/components/CalendarHeatmap.tsx b/packages/shared/src/components/CalendarHeatmap.tsx
index 1e8a33353b5..b1aac02b2f1 100644
--- a/packages/shared/src/components/CalendarHeatmap.tsx
+++ b/packages/shared/src/components/CalendarHeatmap.tsx
@@ -51,7 +51,7 @@ function getRange(count: number): number[] {
return Array.from(new Array(Math.max(0, count)), (_, i) => i);
}
-function getBins(values: number[]): number[] {
+export function getBins(values: number[]): number[] {
const uniques = Array.from(new Set(values)).sort((a, b) => a - b);
if (uniques.length <= BINS) {
return [
@@ -66,7 +66,7 @@ function getBins(values: number[]): number[] {
);
}
-function getBin(value: number, bins: number[]): number {
+export function getBin(value: number, bins: number[]): number {
if (!value) {
return 0;
}
diff --git a/packages/shared/src/components/imageShare/SnapshotButton.spec.tsx b/packages/shared/src/components/imageShare/SnapshotButton.spec.tsx
index 57c00d50959..34188020349 100644
--- a/packages/shared/src/components/imageShare/SnapshotButton.spec.tsx
+++ b/packages/shared/src/components/imageShare/SnapshotButton.spec.tsx
@@ -9,6 +9,8 @@ const mockDisplayToast = jest.fn();
jest.mock('../../lib/imageShare/captureShareImage', () => ({
captureShareImage: (...args: unknown[]) => mockCapture(...args),
+ SHARE_IMAGE_WIDTH: 1200,
+ SHARE_IMAGE_HEIGHT: 630,
}));
jest.mock('../../lib/imageShare/copyShareImage', () => ({
@@ -19,44 +21,65 @@ jest.mock('../../lib/imageShare/downloadShareImage', () => ({
downloadShareImage: (...args: unknown[]) => mockDownload(...args),
}));
-jest.mock('../../features/snapshot/shutterSound', () => ({
- playShutterSound: jest.fn(),
-}));
-
jest.mock('../../hooks/useToastNotification', () => ({
useToastNotification: () => ({ displayToast: mockDisplayToast }),
ToastType: { Success: 'success', Error: 'error' },
}));
+jest.mock('../../features/snapshot/shutterSound', () => ({
+ playShutterSound: jest.fn(),
+}));
+
jest.mock('../../hooks/useRequestProtocol', () => ({
useRequestProtocol: () => ({ isCompanion: false }),
}));
const blob = new Blob(['png'], { type: 'image/png' });
-
-const renderComponent = (props = {}) => {
- const target = document.createElement('div');
-
- return render(
- ,
- );
-};
-
-const clickSnapshot = () =>
- fireEvent.click(screen.getByLabelText('Snapshot'), {
- preventDefault: jest.fn(),
- });
+const card =
a designed card
;
beforeEach(() => {
jest.clearAllMocks();
mockCapture.mockResolvedValue(blob);
+ mockCopy.mockResolvedValue(true);
+ // jsdom has neither, and the hook probes both before it will offer a copy.
+ Object.assign(URL, {
+ createObjectURL: () => 'blob:preview',
+ revokeObjectURL: () => undefined,
+ });
+ Object.assign(globalThis, { ClipboardItem: class {} });
+ Object.assign(navigator, { clipboard: { write: async () => undefined } });
});
-it('copies the image and says so', async () => {
- mockCopy.mockResolvedValue(true);
- renderComponent();
+const button = () => screen.getByLabelText('Snapshot');
+
+it('does not rasterize the card until there is intent', () => {
+ render( );
- clickSnapshot();
+ expect(mockCapture).not.toHaveBeenCalled();
+});
+
+it('rasterizes on hover, so the press still owns the gesture', async () => {
+ render( );
+
+ fireEvent.pointerEnter(button());
+
+ await waitFor(() => expect(mockCapture).toHaveBeenCalledTimes(1));
+});
+
+it('rasterizes on keyboard focus too', async () => {
+ render( );
+
+ fireEvent.focus(button());
+
+ await waitFor(() => expect(mockCapture).toHaveBeenCalledTimes(1));
+});
+
+it('copies the rendered card and says so', async () => {
+ render( );
+
+ fireEvent.pointerEnter(button());
+ await waitFor(() => expect(mockCapture).toHaveBeenCalled());
+ fireEvent.click(button());
await waitFor(() =>
expect(mockDisplayToast).toHaveBeenCalledWith('Image copied', {
@@ -66,26 +89,27 @@ it('copies the image and says so', async () => {
expect(mockDownload).not.toHaveBeenCalled();
});
-it('falls back to a download when the clipboard is unavailable', async () => {
+it('downloads when the clipboard cannot take an image', async () => {
mockCopy.mockResolvedValue(false);
- renderComponent({ filename: 'daily-profile-tomer' });
+ render( );
- clickSnapshot();
+ fireEvent.pointerEnter(button());
+ await waitFor(() => expect(mockCapture).toHaveBeenCalled());
+ fireEvent.click(button());
await waitFor(() =>
- expect(mockDownload).toHaveBeenCalledWith(blob, 'daily-profile-tomer'),
+ expect(mockDownload).toHaveBeenCalledWith(blob, 'daily-achievement-1'),
);
expect(mockDisplayToast).toHaveBeenCalledWith('Image saved', {
variant: 'success',
});
});
-it('reports a failed capture instead of copying or downloading', async () => {
+it('reports a failed rasterization instead of going quiet', async () => {
mockCapture.mockRejectedValue(new Error('target element has no size'));
- mockCopy.mockResolvedValue(false);
- renderComponent();
+ render( );
- clickSnapshot();
+ fireEvent.click(button());
await waitFor(() =>
expect(mockDisplayToast).toHaveBeenCalledWith(
@@ -93,18 +117,17 @@ it('reports a failed capture instead of copying or downloading', async () => {
{ variant: 'error' },
),
);
+ expect(mockCopy).not.toHaveBeenCalled();
expect(mockDownload).not.toHaveBeenCalled();
});
-it('hands the blob to onCapture instead of sharing it', async () => {
- const onCapture = jest.fn();
- mockCopy.mockResolvedValue(true);
- renderComponent({ onCapture });
+it('refuses to render without a card or a target', () => {
+ // eslint-disable-next-line no-console
+ const error = jest.spyOn(console, 'error').mockImplementation(() => {});
- clickSnapshot();
+ expect(() => render( )).toThrow(
+ 'SnapshotButton needs either a card or a target',
+ );
- await waitFor(() => expect(onCapture).toHaveBeenCalledWith(blob));
- expect(mockCopy).not.toHaveBeenCalled();
- expect(mockDownload).not.toHaveBeenCalled();
- expect(mockDisplayToast).not.toHaveBeenCalled();
+ error.mockRestore();
});
diff --git a/packages/shared/src/components/imageShare/SnapshotButton.tsx b/packages/shared/src/components/imageShare/SnapshotButton.tsx
index e9a8e000d7d..2e60c121449 100644
--- a/packages/shared/src/components/imageShare/SnapshotButton.tsx
+++ b/packages/shared/src/components/imageShare/SnapshotButton.tsx
@@ -1,29 +1,32 @@
-import type { ReactElement } from 'react';
+import type { ReactElement, ReactNode } from 'react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import classNames from 'classnames';
import { Button, ButtonSize, ButtonVariant } from '../buttons/Button';
import { SnapshotIcon } from '../icons';
import { Tooltip } from '../tooltip/Tooltip';
-import {
- ToastType,
- useToastNotification,
-} from '../../hooks/useToastNotification';
import type {
CaptureShareImageOptions,
CaptureTarget,
} from '../../lib/imageShare/captureShareImage';
-import { captureShareImage } from '../../lib/imageShare/captureShareImage';
-import { downloadShareImage } from '../../lib/imageShare/downloadShareImage';
-import { copyShareImage } from '../../lib/imageShare/copyShareImage';
+import { useSnapshotCapture } from '../../features/snapshot/useSnapshotCapture';
import { playShutterSound } from '../../features/snapshot/shutterSound';
+import { copyShareImage } from '../../lib/imageShare/copyShareImage';
+import { downloadShareImage } from '../../lib/imageShare/downloadShareImage';
+import {
+ ToastType,
+ useToastNotification,
+} from '../../hooks/useToastNotification';
-const SNAPSHOT_LABEL = 'Snapshot';
+export const SNAPSHOT_LABEL = 'Snapshot';
/** Matches the snapshot-shutter-sweep animation in utilities.css. */
const SHUTTER_SWEEP_MS = 380;
export interface SnapshotButtonProps {
- target: CaptureTarget;
+ /** The designed square card to rasterize. */
+ card?: ReactNode;
+ /** Captured instead of `card`, for surfaces with no designed card yet. */
+ target?: CaptureTarget;
filename?: string;
label?: string;
showLabel?: boolean;
@@ -35,6 +38,7 @@ export interface SnapshotButtonProps {
}
export function SnapshotButton({
+ card,
target,
filename = 'daily-snapshot',
label = SNAPSHOT_LABEL,
@@ -45,10 +49,51 @@ export function SnapshotButton({
variant = ButtonVariant.Tertiary,
className,
}: SnapshotButtonProps): ReactElement {
- const { displayToast } = useToastNotification();
- const [isCapturing, setIsCapturing] = useState(false);
+ if (!card && !target) {
+ throw new Error('SnapshotButton needs either a card or a target');
+ }
+
+ // Rendering starts on intent, not on mount: a feed would otherwise carry a
+ // 1080px card for every item it shows.
+ const [isPrepared, setIsPrepared] = useState(false);
const [isFlashing, setIsFlashing] = useState(false);
+ const isPending = useRef(false);
const flashTimeout = useRef>();
+ const rendered = useRef();
+ const { displayToast } = useToastNotification();
+
+ const onRendered = useCallback(
+ (blob: Blob) => {
+ rendered.current = blob;
+ onCapture?.(blob);
+ },
+ [onCapture],
+ );
+
+ const { status, offScreenCard } = useSnapshotCapture({
+ card,
+ target,
+ filename,
+ captureOptions,
+ isActive: isPrepared,
+ onCapture: onRendered,
+ });
+
+ // Pasting beats a file in Downloads for every target we share to, so the
+ // clipboard leads and the download is the fallback.
+ const shareImage = useCallback(async () => {
+ if (!rendered.current) {
+ return;
+ }
+
+ if (await copyShareImage(Promise.resolve(rendered.current))) {
+ displayToast('Image copied', { variant: ToastType.Success });
+ return;
+ }
+
+ downloadShareImage(rendered.current, filename);
+ displayToast('Image saved', { variant: ToastType.Success });
+ }, [displayToast, filename]);
useEffect(
() => () => {
@@ -59,68 +104,78 @@ export function SnapshotButton({
[],
);
- const onSnapshot = useCallback(
- async (event: React.MouseEvent) => {
- // Every placement sits inside a clickable card, row or link.
+ // A press before the render finished waits for it. The clipboard needs the
+ // press's own gesture, so this path can only download — hovering first is
+ // what buys the copy.
+ useEffect(() => {
+ if (!isPending.current) {
+ return;
+ }
+
+ if (status === 'ready') {
+ isPending.current = false;
+ shareImage();
+ }
+
+ if (status === 'error') {
+ isPending.current = false;
+ displayToast('Could not create the snapshot, please try again', {
+ variant: ToastType.Error,
+ });
+ }
+ }, [displayToast, shareImage, status]);
+
+ const prepare = useCallback(() => setIsPrepared(true), []);
+
+ const onClick = useCallback(
+ (event: React.MouseEvent) => {
+ // The trigger sits inside clickable cards, rows and links.
event.preventDefault();
event.stopPropagation();
+
playShutterSound();
setIsFlashing(true);
flashTimeout.current = setTimeout(
() => setIsFlashing(false),
SHUTTER_SWEEP_MS,
);
- setIsCapturing(true);
-
- try {
- const capture = captureShareImage(target, captureOptions);
-
- if (onCapture) {
- onCapture(await capture);
- return;
- }
-
- // Pasting beats a file in Downloads for every target we share to, so
- // the clipboard leads and the download is the fallback.
- if (await copyShareImage(capture)) {
- displayToast('Image copied', { variant: ToastType.Success });
- return;
- }
-
- downloadShareImage(await capture, filename);
- displayToast('Image saved', { variant: ToastType.Success });
- } catch {
- displayToast('Could not create the snapshot, please try again', {
- variant: ToastType.Error,
- });
- } finally {
- setIsCapturing(false);
+
+ if (status === 'ready') {
+ shareImage();
+ return;
}
+
+ isPending.current = true;
+ setIsPrepared(true);
},
- [captureOptions, displayToast, filename, onCapture, target],
+ [shareImage, status],
);
return (
-
- }
- onClick={onSnapshot}
- >
- {showLabel ? label : undefined}
-
-
+ <>
+ {offScreenCard}
+
+ }
+ loading={isPending.current && status === 'loading'}
+ onClick={onClick}
+ onFocus={prepare}
+ onPointerEnter={prepare}
+ size={size}
+ variant={variant}
+ >
+ {showLabel ? label : undefined}
+
+
+ >
);
}
diff --git a/packages/shared/src/components/modals/achievement/sortAchievements.spec.ts b/packages/shared/src/components/modals/achievement/sortAchievements.spec.ts
index 41fc7875730..b3559b8ab42 100644
--- a/packages/shared/src/components/modals/achievement/sortAchievements.spec.ts
+++ b/packages/shared/src/components/modals/achievement/sortAchievements.spec.ts
@@ -1,6 +1,9 @@
import { AchievementType } from '../../../graphql/user/achievements';
import type { UserAchievement } from '../../../graphql/user/achievements';
-import { sortLockedAchievements } from './sortAchievements';
+import {
+ sortLockedAchievements,
+ sortRarestUnlockedAchievements,
+} from './sortAchievements';
const createAchievement = ({
id,
@@ -71,3 +74,79 @@ describe('sortLockedAchievements', () => {
]);
});
});
+
+describe('sortRarestUnlockedAchievements', () => {
+ const unlocked = ({
+ id,
+ rarity,
+ points = 10,
+ unlockedAt = '2026-01-01T00:00:00.000Z',
+ }: {
+ id: string;
+ rarity: number | null;
+ points?: number;
+ unlockedAt?: string;
+ }): UserAchievement => {
+ const base = createAchievement({
+ id,
+ progress: 1,
+ targetCount: 1,
+ points,
+ unlockedAt,
+ });
+
+ return { ...base, achievement: { ...base.achievement, rarity } };
+ };
+
+ it('drops the locked ones', () => {
+ const result = sortRarestUnlockedAchievements([
+ createAchievement({
+ id: 'locked',
+ progress: 0,
+ targetCount: 5,
+ points: 1,
+ }),
+ unlocked({ id: 'earned', rarity: 20 }),
+ ]);
+
+ expect(result.map((a) => a.achievement.id)).toEqual(['earned']);
+ });
+
+ it('puts the rarest first, and an unknown rarity last', () => {
+ const result = sortRarestUnlockedAchievements([
+ unlocked({ id: 'common', rarity: 40 }),
+ unlocked({ id: 'unknown', rarity: null }),
+ unlocked({ id: 'rarest', rarity: 1 }),
+ ]);
+
+ expect(result.map((a) => a.achievement.id)).toEqual([
+ 'rarest',
+ 'common',
+ 'unknown',
+ ]);
+ });
+
+ it('breaks a rarity tie on points, then on the more recent unlock', () => {
+ const result = sortRarestUnlockedAchievements([
+ unlocked({
+ id: 'older',
+ rarity: 5,
+ points: 50,
+ unlockedAt: '2026-01-01T00:00:00.000Z',
+ }),
+ unlocked({ id: 'fewer-points', rarity: 5, points: 10 }),
+ unlocked({
+ id: 'newer',
+ rarity: 5,
+ points: 50,
+ unlockedAt: '2026-06-01T00:00:00.000Z',
+ }),
+ ]);
+
+ expect(result.map((a) => a.achievement.id)).toEqual([
+ 'newer',
+ 'older',
+ 'fewer-points',
+ ]);
+ });
+});
diff --git a/packages/shared/src/components/modals/achievement/sortAchievements.ts b/packages/shared/src/components/modals/achievement/sortAchievements.ts
index 430b392f7e8..827b2df7a1d 100644
--- a/packages/shared/src/components/modals/achievement/sortAchievements.ts
+++ b/packages/shared/src/components/modals/achievement/sortAchievements.ts
@@ -29,3 +29,34 @@ export const sortLockedAchievements = (
return b.achievement.points - a.achievement.points;
});
};
+
+/**
+ * Rarest first, so the profile widget and the share card can never disagree
+ * about which achievements are the ones worth showing.
+ */
+export const sortRarestUnlockedAchievements = (
+ achievements: UserAchievement[],
+): UserAchievement[] => {
+ return achievements
+ .filter((achievement) => achievement.unlockedAt !== null)
+ .sort((a, b) => {
+ const rarityA = a.achievement.rarity ?? Infinity;
+ const rarityB = b.achievement.rarity ?? Infinity;
+ if (rarityA !== rarityB) {
+ return rarityA - rarityB;
+ }
+
+ const pointsDelta = b.achievement.points - a.achievement.points;
+ if (pointsDelta !== 0) {
+ return pointsDelta;
+ }
+
+ const unlockedDateA = a.unlockedAt ? new Date(a.unlockedAt).getTime() : 0;
+ const unlockedDateB = b.unlockedAt ? new Date(b.unlockedAt).getTime() : 0;
+ if (unlockedDateA !== unlockedDateB) {
+ return unlockedDateB - unlockedDateA;
+ }
+
+ return a.achievement.id.localeCompare(b.achievement.id);
+ });
+};
diff --git a/packages/shared/src/components/profile/ProfileHeader.tsx b/packages/shared/src/components/profile/ProfileHeader.tsx
index 0622d8b0aac..b81fe1bfdbe 100644
--- a/packages/shared/src/components/profile/ProfileHeader.tsx
+++ b/packages/shared/src/components/profile/ProfileHeader.tsx
@@ -1,6 +1,7 @@
import type { ReactNode } from 'react';
-import React, { useRef } from 'react';
+import React from 'react';
import dynamic from 'next/dynamic';
+import { format } from 'date-fns';
import classNames from 'classnames';
import { Image } from '../image/Image';
import {
@@ -26,6 +27,11 @@ import { IconSize } from '../Icon';
import { fallbackImages } from '../../lib/config';
import { ProfileDesktopPwaBackButton } from './ProfileBackButton';
import { SnapshotButton } from '../imageShare/SnapshotButton';
+import { ProfileSnapshotCard } from '../../features/snapshot/ProfileSnapshotCard';
+import {
+ sumReads,
+ useProfileReadingHistory,
+} from '../../hooks/profile/useProfileReadingHistory';
import { Tooltip } from '../tooltip/Tooltip';
import { useCopyLink } from '../../hooks/useCopy';
import { useLogContext } from '../../contexts/LogContext';
@@ -74,8 +80,8 @@ const ProfileHeader = ({
const { name, username, bio, image, cover, isPlus } = user;
const { user: loggedUser } = useAuthContext();
const isSameUser = propIsSameUser ?? loggedUser?.id === user.id;
- const headerRef = useRef(null);
const { logEvent } = useLogContext();
+ const { readingHistory } = useProfileReadingHistory(user);
const [isCopying, copyLink] = useCopyLink(() => user.permalink);
const onCopyLink = () => {
@@ -89,10 +95,7 @@ const ProfileHeader = ({
};
return (
-
+
@@ -124,11 +127,23 @@ const ProfileHeader = ({
/>
+ }
filename={`daily-profile-${username ?? user.id}`}
showLabel={false}
// Matches the edit button beside it, which takes Button's default.
size={ButtonSize.Medium}
- target={headerRef}
variant={ButtonVariant.Float}
/>
diff --git a/packages/shared/src/features/profile/components/ProfileWidgets/AchievementsWidget.tsx b/packages/shared/src/features/profile/components/ProfileWidgets/AchievementsWidget.tsx
index f0e045f7204..2c9d206ef83 100644
--- a/packages/shared/src/features/profile/components/ProfileWidgets/AchievementsWidget.tsx
+++ b/packages/shared/src/features/profile/components/ProfileWidgets/AchievementsWidget.tsx
@@ -1,5 +1,5 @@
import type { ReactElement } from 'react';
-import React, { useRef } from 'react';
+import React from 'react';
import classNames from 'classnames';
import Link from '../../../../components/utilities/Link';
import { ActivityContainer } from '../../../../components/profile/ActivitySection';
@@ -21,6 +21,8 @@ import {
import { RaritySparkles } from '../achievements/RaritySparkles';
import HoverCard from '../../../../components/cards/common/HoverCard';
import { AchievementCard } from '../achievements/AchievementCard';
+import { AchievementsSnapshotCard } from '../../../snapshot/AchievementsSnapshotCard';
+import { sortRarestUnlockedAchievements } from '../../../../components/modals/achievement/sortAchievements';
import { SnapshotButton } from '../../../../components/imageShare/SnapshotButton';
import { ButtonSize } from '../../../../components/buttons/common';
@@ -49,28 +51,8 @@ function RecentAchievements({
const { achievements, isPending } = useProfileAchievements(user);
const rarestUnlocked = achievements
- ?.filter((a) => a.unlockedAt !== null)
- .sort((a, b) => {
- const rarityA = a.achievement.rarity ?? Infinity;
- const rarityB = b.achievement.rarity ?? Infinity;
- if (rarityA !== rarityB) {
- return rarityA - rarityB;
- }
-
- const pointsDelta = b.achievement.points - a.achievement.points;
- if (pointsDelta !== 0) {
- return pointsDelta;
- }
-
- const unlockedDateA = a.unlockedAt ? new Date(a.unlockedAt).getTime() : 0;
- const unlockedDateB = b.unlockedAt ? new Date(b.unlockedAt).getTime() : 0;
- if (unlockedDateA !== unlockedDateB) {
- return unlockedDateB - unlockedDateA;
- }
-
- return a.achievement.id.localeCompare(b.achievement.id);
- })
- .slice(0, 5);
+ ? sortRarestUnlockedAchievements(achievements).slice(0, 5)
+ : undefined;
if (isPending) {
return ;
@@ -135,11 +117,15 @@ function RecentAchievements({
export function AchievementsWidget({
user,
}: AchievementsWidgetProps): ReactElement {
- const { unlockedCount, totalCount } = useProfileAchievements(user);
- const widgetRef = useRef(null);
+ const { achievements, unlockedCount, totalCount, totalPoints } =
+ useProfileAchievements(user);
+
+ const rarest = achievements
+ ? sortRarestUnlockedAchievements(achievements).slice(0, 10)
+ : [];
return (
-
+
({
+ image: achievement.image,
+ name: achievement.name,
+ }))}
+ points={totalPoints}
+ seed={user.username ?? user.id}
+ total={totalCount}
+ unlocked={unlockedCount}
+ user={{
+ handle: `@${user.username ?? user.id}`,
+ image: user.image,
+ name: user.name,
+ }}
+ />
+ }
filename={`daily-achievements-${user.username ?? user.id}`}
showLabel={false}
size={ButtonSize.XSmall}
- target={widgetRef}
/>
diff --git a/packages/shared/src/features/profile/components/ProfileWidgets/BadgesAndAwards.tsx b/packages/shared/src/features/profile/components/ProfileWidgets/BadgesAndAwards.tsx
index 7a67a1fe074..dc9f41ef52c 100644
--- a/packages/shared/src/features/profile/components/ProfileWidgets/BadgesAndAwards.tsx
+++ b/packages/shared/src/features/profile/components/ProfileWidgets/BadgesAndAwards.tsx
@@ -1,5 +1,5 @@
import type { ReactElement } from 'react';
-import React, { useRef } from 'react';
+import React from 'react';
import { useQuery } from '@tanstack/react-query';
import { ActivityContainer } from '../../../../components/profile/ActivitySection';
import { topReaderBadgeDocs } from '../../../../lib/constants';
@@ -25,6 +25,8 @@ import {
} from './BadgesAndAwardsComponents';
import { anchorDefaultRel } from '../../../../lib/strings';
import { SnapshotButton } from '../../../../components/imageShare/SnapshotButton';
+import { BadgesSnapshotCard } from '../../../snapshot/BadgesSnapshotCard';
+import { formatDate, TimeFormatType } from '../../../../lib/dateFormat';
import { ButtonSize } from '../../../../components/buttons/common';
export const BadgesAndAwards = ({
@@ -32,7 +34,6 @@ export const BadgesAndAwards = ({
}: {
user: PublicProfile;
}): ReactElement | null => {
- const widgetRef = useRef
(null);
const { data: topReaders, isPending: isTopReaderLoading } = useTopReader({
user,
limit: 5,
@@ -65,7 +66,7 @@ export const BadgesAndAwards = ({
awards?.reduce((sum, award) => sum + (award?.count || 0), 0) ?? 0;
return (
-
+
({
+ count: award.count,
+ image: award.image,
+ name: award.name,
+ })) ?? []
+ }
+ badges={
+ topReaders?.map((badge) => ({
+ earnedAt: formatDate({
+ value: badge.issuedAt,
+ type: TimeFormatType.TopReaderBadge,
+ }),
+ keyword: badge.keyword.flags?.title || badge.keyword.value,
+ })) ?? []
+ }
+ seed={user.username ?? user.id}
+ topReaderBadges={topReaders?.[0]?.total ?? 0}
+ totalAwards={totalAwards}
+ user={{
+ handle: `@${user.username ?? user.id}`,
+ image: user.image,
+ name: user.name,
+ }}
+ />
+ }
filename={`daily-badges-${user.username ?? user.id}`}
showLabel={false}
size={ButtonSize.XSmall}
- target={widgetRef}
/>
({
- queryKey: generateQueryKey(RequestKey.ReadingStats, user),
- queryFn: () =>
- gqlClient.request(USER_READING_HISTORY_QUERY, {
- id: user?.id,
- before,
- after,
- version: 2,
- limit: 6,
- }),
- enabled: !!user && tokenRefreshed && !!before && !!after,
- refetchOnWindowFocus: false,
- refetchOnReconnect: false,
- refetchOnMount: false,
- });
+ const {
+ readingHistory,
+ isLoading: isReadingHistoryLoading,
+ before,
+ after,
+ } = useProfileReadingHistory(user);
const squads = sources?.edges?.map((s) => s.node.source) ?? [];
return (
@@ -147,6 +130,7 @@ export function ProfileWidgets({
profileUserId: user.id,
}) && }
value.reads;
@@ -52,6 +63,7 @@ const readHistoryToTooltip = (
};
export interface ReadingOverviewProps {
+ user: PublicProfile;
readHistory?: UserReadHistory[];
before: Date;
after: Date;
@@ -61,6 +73,7 @@ export interface ReadingOverviewProps {
}
export function ReadingOverview({
+ user,
readHistory,
before,
after,
@@ -68,7 +81,6 @@ export function ReadingOverview({
mostReadTags,
isLoading = false,
}: ReadingOverviewProps): ReactElement {
- const widgetRef = useRef(null);
const totalReads = useMemo(() => {
if (!readHistory?.length) {
return 0;
@@ -79,12 +91,40 @@ export function ReadingOverview({
}, 0);
}, [readHistory]);
+ const { data: tagTitles = {} } = useQuery>(
+ tagTitlesQueryOptions(),
+ );
+ const heatmap = useMemo(() => {
+ if (!readHistory?.length) {
+ return [];
+ }
+
+ // The card draws one cell per bucket and stops at its grid, so the window
+ // is compressed into that many buckets rather than handed a day each: a
+ // day per cell would show the oldest weeks and drop everything since.
+ const start = after.getTime();
+ const span = Math.max(1, before.getTime() - start);
+ const buckets = new Array(SNAPSHOT_HEATMAP_CELLS).fill(0);
+
+ readHistory.forEach((entry) => {
+ const offset = (new Date(entry.date).getTime() - start) / span;
+ const cell = Math.floor(offset * SNAPSHOT_HEATMAP_CELLS);
+
+ buckets[Math.min(SNAPSHOT_HEATMAP_CELLS - 1, Math.max(0, cell))] +=
+ readHistoryToValue(entry);
+ });
+
+ const bins = getBins(buckets);
+
+ return buckets.map((reads) => getBin(reads, bins));
+ }, [after, before, readHistory]);
+
if (isLoading) {
return ;
}
return (
-
+
({
+ name: tagTitles[tag.value] || tag.value,
+ percentage: Math.round((tag.percentage ?? 0) * 100),
+ })) ?? []
+ }
+ totalReadingDays={streak?.total ?? 0}
+ user={{
+ handle: `@${user.username ?? user.id}`,
+ image: user.image,
+ name: user.name,
+ }}
+ />
+ }
filename="daily-reading-overview"
showLabel={false}
size={ButtonSize.XSmall}
- target={widgetRef}
/>
(null);
const { achievement, progress, unlockedAt } = userAchievement;
const targetCount = getTargetCount(achievement);
const isUnlocked = unlockedAt !== null;
@@ -66,7 +66,6 @@ export function AchievementCard({
: `${Math.round(achievement.rarity ?? 0)}%`;
return (
- {/* SnapshotButton sets `relative` on itself, which beats an
- `absolute` passed in, so the wrapper carries the positioning. */}
-
-
-
+ {isUnlocked && unlockedAt && (
+
+
+ }
+ filename={`daily-achievement-${achievement.id}`}
+ showLabel={false}
+ size={ButtonSize.XSmall}
+ variant={ButtonVariant.Secondary}
+ />
+
+ )}
,
+): ReactElement {
+ const isEmerald = tier === AchievementRarityTier.Emerald;
+ const pill = isEmerald ? PILL.gold : PILL.plain;
+ const rarityLabel = isEmerald ? '<1%' : `${Math.round(rarity ?? 0)}%`;
+
+ return (
+
+
+ {image && (
+
+ )}
+
+
+
+ {tier && (
+
+ {rarityLabel} rare
+
+ )}
+
+
+
+ {name}
+
+
+ {description}
+
+
+ Completed {completedAt}
+
+
+
+
+ );
+}
+
+export const AchievementSnapshotCard = forwardRef(
+ AchievementSnapshotCardComponent,
+);
diff --git a/packages/shared/src/features/snapshot/AchievementsSnapshotCard.tsx b/packages/shared/src/features/snapshot/AchievementsSnapshotCard.tsx
new file mode 100644
index 00000000000..7e500b3ac5a
--- /dev/null
+++ b/packages/shared/src/features/snapshot/AchievementsSnapshotCard.tsx
@@ -0,0 +1,120 @@
+import type { ReactElement } from 'react';
+import React, { forwardRef } from 'react';
+import colors from '../../styles/colors';
+import { largeNumberFormat } from '../../lib';
+import { SnapshotEyebrow } from './SnapshotEyebrow';
+import { SnapshotFrame } from './SnapshotFrame';
+import type { SnapshotIdentityProps } from './SnapshotIdentity';
+import { SnapshotIdentity } from './SnapshotIdentity';
+
+const MUTED = colors.salt['90'];
+const DIVIDER = colors.pepper['10'];
+
+const TILE_SIZE = 104;
+
+export interface UnlockedAchievement {
+ name: string;
+ image?: string;
+ emoji?: string;
+}
+
+export interface AchievementsSnapshotCardProps {
+ user: SnapshotIdentityProps;
+ unlocked: number;
+ total: number;
+ points: number;
+ achievements: UnlockedAchievement[];
+ seed?: string;
+}
+
+const Tile = ({
+ value,
+ label,
+}: {
+ value: string;
+ label: string;
+}): ReactElement => (
+
+
+ {value}
+
+ {label}
+
+);
+
+function AchievementsSnapshotCardComponent(
+ {
+ user,
+ unlocked,
+ total,
+ points,
+ achievements,
+ seed,
+ }: AchievementsSnapshotCardProps,
+ ref: React.Ref,
+): ReactElement {
+ return (
+ }
+ ref={ref}
+ seed={seed ?? 'achievements'}
+ >
+
+
+
+
+
+
+
+
+
+
Rarest unlocked
+
+ {achievements.slice(0, 10).map((achievement) => (
+
+ {achievement.image ? (
+
+ ) : (
+
+ {achievement.emoji}
+
+ )}
+
+ ))}
+
+
+
+
+ );
+}
+
+export const AchievementsSnapshotCard = forwardRef(
+ AchievementsSnapshotCardComponent,
+);
diff --git a/packages/shared/src/features/snapshot/BadgesSnapshotCard.tsx b/packages/shared/src/features/snapshot/BadgesSnapshotCard.tsx
new file mode 100644
index 00000000000..f6b623b834e
--- /dev/null
+++ b/packages/shared/src/features/snapshot/BadgesSnapshotCard.tsx
@@ -0,0 +1,150 @@
+import type { ReactElement } from 'react';
+import React, { forwardRef } from 'react';
+import colors from '../../styles/colors';
+import { largeNumberFormat } from '../../lib';
+import { SnapshotEyebrow } from './SnapshotEyebrow';
+import { SnapshotFrame } from './SnapshotFrame';
+import type { SnapshotIdentityProps } from './SnapshotIdentity';
+import { SnapshotIdentity } from './SnapshotIdentity';
+
+const MUTED = colors.salt['90'];
+const DIVIDER = colors.pepper['10'];
+
+export interface TopReaderBadge {
+ keyword: string;
+ earnedAt: string;
+}
+
+export interface AwardTally {
+ count: number;
+ emoji?: string;
+ image?: string;
+ name: string;
+}
+
+export interface BadgesSnapshotCardProps {
+ user: SnapshotIdentityProps;
+ topReaderBadges: number;
+ totalAwards: number;
+ badges: TopReaderBadge[];
+ awards: AwardTally[];
+ seed?: string;
+}
+
+const Tile = ({
+ value,
+ label,
+}: {
+ value: string;
+ label: string;
+}): ReactElement => (
+
+
+ {value}
+
+ {label}
+
+);
+
+function BadgesSnapshotCardComponent(
+ {
+ user,
+ topReaderBadges,
+ totalAwards,
+ badges,
+ awards,
+ seed,
+ }: BadgesSnapshotCardProps,
+ ref: React.Ref,
+): ReactElement {
+ return (
+ }
+ ref={ref}
+ seed={seed ?? 'badges'}
+ >
+
+
+
+
+
+
+
+
+
+ {badges.slice(0, 4).map((badge) => (
+
+
+ {badge.keyword}
+
+
+ {badge.earnedAt}
+
+
+ ))}
+
+
+
+ {awards.slice(0, 6).map((award) => (
+
+ {award.image ? (
+
+ ) : (
+
+ {award.emoji}
+
+ )}
+
+ x{award.count}
+
+
+ ))}
+
+
+
+ );
+}
+
+export const BadgesSnapshotCard = forwardRef(BadgesSnapshotCardComponent);
diff --git a/packages/shared/src/features/snapshot/ProfileSnapshotCard.tsx b/packages/shared/src/features/snapshot/ProfileSnapshotCard.tsx
new file mode 100644
index 00000000000..16ccf5bb5b5
--- /dev/null
+++ b/packages/shared/src/features/snapshot/ProfileSnapshotCard.tsx
@@ -0,0 +1,146 @@
+import type { ReactElement } from 'react';
+import React, { forwardRef } from 'react';
+import colors from '../../styles/colors';
+import { largeNumberFormat } from '../../lib';
+import { SnapshotFrame } from './SnapshotFrame';
+import {
+ SnapshotStat,
+ SnapshotStatRow,
+ SnapshotStatValue,
+} from './SnapshotStats';
+
+const MUTED = colors.salt['90'];
+
+const COVER_HEIGHT = 268;
+const AVATAR_SIZE = 208;
+const AVATAR_RING = 8;
+const AVATAR_RADIUS = 46;
+/** The frame's body padding, which the cover has to escape to bleed. */
+const BODY_PADDING = 58;
+
+export interface ProfileSnapshotCardProps {
+ name: string;
+ handle: string;
+ bio?: string;
+ image?: string;
+ cover?: string;
+ postsRead: number;
+ joined: string;
+ reputation: number;
+ seed?: string;
+}
+
+function ProfileSnapshotCardComponent(
+ {
+ name,
+ handle,
+ bio,
+ image,
+ cover,
+ postsRead,
+ joined,
+ reputation,
+ seed,
+ }: ProfileSnapshotCardProps,
+ ref: React.Ref,
+): ReactElement {
+ return (
+
+
+
+
+ {image && (
+ // The ring is a padded wrapper rather than a border on the image:
+ // its radius is the image's plus the ring width, so the two curves
+ // stay concentric and no cover shows through at the corners.
+
+
+
+ )}
+
+
+
+ {name}
+
+ {handle}
+
+
+ {bio && (
+
+ {bio}
+
+ )}
+
+
+
+ {largeNumberFormat(postsRead) ?? postsRead}
+
+ }
+ />
+ {joined}}
+ />
+
+ {largeNumberFormat(reputation) ?? reputation}
+
+ }
+ />
+
+
+
+ );
+}
+
+export const ProfileSnapshotCard = forwardRef(ProfileSnapshotCardComponent);
diff --git a/packages/shared/src/features/snapshot/ReadingOverviewSnapshotCard.tsx b/packages/shared/src/features/snapshot/ReadingOverviewSnapshotCard.tsx
new file mode 100644
index 00000000000..1fe2243bc60
--- /dev/null
+++ b/packages/shared/src/features/snapshot/ReadingOverviewSnapshotCard.tsx
@@ -0,0 +1,194 @@
+import type { ReactElement } from 'react';
+import React, { forwardRef } from 'react';
+import colors from '../../styles/colors';
+import { largeNumberFormat } from '../../lib';
+import { SnapshotEyebrow } from './SnapshotEyebrow';
+import { SnapshotFrame } from './SnapshotFrame';
+import type { SnapshotIdentityProps } from './SnapshotIdentity';
+import { SnapshotIdentity } from './SnapshotIdentity';
+
+const MUTED = colors.salt['90'];
+const DIVIDER = colors.pepper['10'];
+
+const HEATMAP_ROWS = 4;
+const HEATMAP_COLS = 22;
+const HEATMAP_CELL = 20;
+const HEATMAP_GAP = 6;
+
+/** Four steps, matching the Less -> More legend on the profile heatmap. */
+const HEATMAP_LEVELS = [
+ colors.pepper['70'],
+ colors.pepper['40'],
+ colors.pepper['10'],
+ '#FFFFFF',
+];
+
+export interface ReadingOverviewTag {
+ name: string;
+ percentage: number;
+}
+
+export interface ReadingOverviewSnapshotCardProps {
+ user: SnapshotIdentityProps;
+ longestStreak: number;
+ totalReadingDays: number;
+ postsRead: number;
+ monthsLabel: string;
+ topTags: ReadingOverviewTag[];
+ /** One entry per cell, 0-3, read left to right like the profile heatmap. */
+ heatmap: number[];
+ seed?: string;
+}
+
+const Tile = ({
+ value,
+ label,
+ glyph,
+}: {
+ value: string;
+ label: string;
+ glyph?: string;
+}): ReactElement => (
+
+
+ {value}
+
+
+ {label} {glyph}
+
+
+);
+
+const TagChip = ({
+ name,
+ percentage,
+ share,
+}: ReadingOverviewTag & { share: number }): ReactElement => {
+ // Relative to the strongest tag, so the leader reads as a full-ish bar and
+ // the rest fall away from it — an absolute percentage would fill them all.
+ const fill = Math.max(12, Math.min(share * 68, 68));
+
+ return (
+
+
+ {name}
+
+
+ +{percentage}%
+
+
+ );
+};
+
+function ReadingOverviewSnapshotCardComponent(
+ {
+ user,
+ longestStreak,
+ totalReadingDays,
+ postsRead,
+ monthsLabel,
+ topTags,
+ heatmap,
+ seed,
+ }: ReadingOverviewSnapshotCardProps,
+ ref: React.Ref,
+): ReactElement {
+ const cells = heatmap.slice(0, HEATMAP_ROWS * HEATMAP_COLS);
+ const visibleTags = topTags.slice(0, 6);
+ const topPercentage = Math.max(
+ ...visibleTags.map((tag) => tag.percentage),
+ 1,
+ );
+
+ return (
+ }
+ ref={ref}
+ seed={seed ?? 'reading-overview'}
+ >
+
+
+
+
+
+
+
+
+
+
+ Top tags by reading days
+
+
+ {visibleTags.map((tag) => (
+
+ ))}
+
+
+
+
+
+ Posts read {monthsLabel} (
+ {largeNumberFormat(postsRead) ?? postsRead})
+
+
+ {cells.map((level, index) => (
+
+ ))}
+
+
+
+
+ );
+}
+
+export const ReadingOverviewSnapshotCard = forwardRef(
+ ReadingOverviewSnapshotCardComponent,
+);
diff --git a/packages/shared/src/features/snapshot/SnapshotEyebrow.tsx b/packages/shared/src/features/snapshot/SnapshotEyebrow.tsx
new file mode 100644
index 00000000000..64797e2650b
--- /dev/null
+++ b/packages/shared/src/features/snapshot/SnapshotEyebrow.tsx
@@ -0,0 +1,39 @@
+import type { ReactElement } from 'react';
+import React from 'react';
+import colors from '../../styles/colors';
+
+export interface SnapshotEyebrowProps {
+ label: string;
+ /** Paints the label with the surface's own wordmark gradient. */
+ gradient?: string;
+}
+
+/**
+ * Which part of the product the card came from. It rides the logo row rather
+ * than the copy: it is a sibling of the mark, not a headline for the text
+ * under it.
+ */
+export function SnapshotEyebrow({
+ label,
+ gradient,
+}: SnapshotEyebrowProps): ReactElement {
+ return (
+
+ {label}
+
+ );
+}
diff --git a/packages/shared/src/features/snapshot/SnapshotFrame.tsx b/packages/shared/src/features/snapshot/SnapshotFrame.tsx
new file mode 100644
index 00000000000..298e40f3f9a
--- /dev/null
+++ b/packages/shared/src/features/snapshot/SnapshotFrame.tsx
@@ -0,0 +1,196 @@
+import type { ReactElement, ReactNode } from 'react';
+import React, { forwardRef } from 'react';
+import classNames from 'classnames';
+import LogoIcon from '../../svg/LogoIcon';
+import LogoText from '../../svg/LogoText';
+import {
+ getSnapshotGradient,
+ SNAPSHOT_MAX_HEIGHT,
+ SNAPSHOT_SIZE,
+} from './snapshotGradient';
+
+export const SNAPSHOT_CARD_SIZE = 780;
+/**
+ * A page-shaped card: the gradient stays as a border rather than a stage, so
+ * the copy gets the room instead. Surfaces where the text *is* the payload use
+ * it — a wide margin around a cramped article is space spent on nothing.
+ */
+export const SNAPSHOT_CARD_WIDE = 1008;
+/** Canvas minus the logo row and the gaps either side of the card. */
+export const SNAPSHOT_CARD_MAX = SNAPSHOT_SIZE - 150;
+
+const CARD_RADIUS = 48;
+const CARD_EDGE = 2;
+const CARD_PADDING = 58;
+const CARD_PADDING_WIDE = 32;
+
+/**
+ * The App Store device frame: a lit hairline that is brightest along the top
+ * edge and fades out by the middle, over a body darker than the ground.
+ */
+const CARD_EDGE_GRADIENT =
+ 'linear-gradient(170deg, rgba(214, 196, 255, 0.92) 0%, rgba(158, 126, 236, 0.5) 12%, rgba(104, 82, 168, 0.16) 38%, rgba(255, 255, 255, 0.05) 72%, rgba(180, 156, 255, 0.14) 100%)';
+const CARD_BODY = '#0B0812';
+const CARD_GLOW =
+ '0 0 120px rgba(126, 82, 214, 0.38), 0 48px 96px rgba(4, 2, 9, 0.62)';
+
+export type SnapshotLogoPlacement = 'inline' | 'top-left' | 'top-right';
+
+interface SnapshotFrameProps {
+ seed: string;
+ /**
+ * 'inline' leads the content with the mark. The overlay placements float it
+ * over whatever fills the card instead, for cards whose own artwork reaches
+ * the top edge.
+ */
+ logoPlacement?: SnapshotLogoPlacement;
+ /** A glyph bled across the card body at low opacity, behind the content. */
+ watermark?: string;
+ /**
+ * Sits on the logo row, far right — for a surface label that belongs with
+ * the mark rather than with the copy.
+ */
+ logoAside?: ReactNode;
+ /** Drop the card shell and stand the children straight on the gradient. */
+ bare?: boolean;
+ /**
+ * Let the height follow the content instead of holding 1:1. Text surfaces
+ * use it so the image can carry more than a screenshot would; it still
+ * starts at the square and stops at SNAPSHOT_MAX_HEIGHT.
+ */
+ grow?: boolean;
+ /**
+ * Widen the card to SNAPSHOT_CARD_WIDE and tighten its padding, for surfaces
+ * whose copy needs the room more than the frame needs the margin.
+ */
+ wide?: boolean;
+ children: ReactNode;
+}
+
+function SnapshotFrameComponent(
+ {
+ seed,
+ watermark,
+ logoAside,
+ bare,
+ grow,
+ wide,
+ logoPlacement = 'inline',
+ children,
+ }: SnapshotFrameProps,
+ ref: React.Ref,
+): ReactElement {
+ const cardWidth = wide ? SNAPSHOT_CARD_WIDE : SNAPSHOT_CARD_SIZE;
+ const gutter = (SNAPSHOT_SIZE - cardWidth) / 2;
+ const isOverlaid = logoPlacement !== 'inline';
+ const overlayStyle = {
+ position: 'absolute' as const,
+ top: 30,
+ ...(logoPlacement === 'top-right' ? { right: 30 } : { left: 30 }),
+ zIndex: 4,
+ };
+ const logo = (
+
+
+
+
+ );
+
+ const logoRow = logoAside ? (
+
+ {logo}
+ {logoAside}
+
+ ) : (
+ logo
+ );
+
+ return (
+
+ {/* Standing alone on the gradient, the collectible has no card to sit
+ in: the mark leads above it, or floats over its artwork. */}
+ {bare && !isOverlaid && logoRow}
+
+ {bare ? (
+
+ {isOverlaid && logo}
+ {children}
+
+ ) : (
+
+
+ {watermark && (
+
+ {watermark}
+
+ )}
+ {isOverlaid && logo}
+
+ {!isOverlaid && logoRow}
+ {children}
+
+
+
+ )}
+
+ );
+}
+
+export const SnapshotFrame = forwardRef(SnapshotFrameComponent);
diff --git a/packages/shared/src/features/snapshot/SnapshotIdentity.tsx b/packages/shared/src/features/snapshot/SnapshotIdentity.tsx
new file mode 100644
index 00000000000..1858e397c87
--- /dev/null
+++ b/packages/shared/src/features/snapshot/SnapshotIdentity.tsx
@@ -0,0 +1,42 @@
+import type { ReactElement } from 'react';
+import React from 'react';
+import colors from '../../styles/colors';
+
+const MUTED = colors.salt['90'];
+
+export interface SnapshotIdentityProps {
+ name: string;
+ handle: string;
+ image?: string;
+}
+
+export function SnapshotIdentity({
+ name,
+ handle,
+ image,
+}: SnapshotIdentityProps): ReactElement {
+ return (
+
+ {image && (
+
+ )}
+
+
+ {name}
+
+
+ {handle}
+
+
+
+ );
+}
diff --git a/packages/shared/src/features/snapshot/SnapshotStats.tsx b/packages/shared/src/features/snapshot/SnapshotStats.tsx
new file mode 100644
index 00000000000..665d5f56218
--- /dev/null
+++ b/packages/shared/src/features/snapshot/SnapshotStats.tsx
@@ -0,0 +1,70 @@
+import type { ReactElement, ReactNode } from 'react';
+import React from 'react';
+import colors from '../../styles/colors';
+
+const MUTED = colors.salt['90'];
+const DIVIDER = colors.pepper['10'];
+
+/** Tall enough to hold the level ring, so numbers and rings share one axis. */
+export const SNAPSHOT_STAT_HEIGHT = 116;
+
+export const SnapshotStatValue = ({
+ children,
+ compact,
+}: {
+ children: ReactNode;
+ /** For word-shaped values like a date, which run wider than a number. */
+ compact?: boolean;
+}): ReactElement => (
+
+ {children}
+
+);
+
+export const SnapshotStat = ({
+ value,
+ label,
+}: {
+ value: ReactNode;
+ label: string;
+}): ReactElement => (
+
+
+ {value}
+
+
+ {label}
+
+
+);
+
+export const SnapshotStatRow = ({
+ children,
+}: {
+ children: ReactNode;
+}): ReactElement => (
+
+ {React.Children.toArray(children).map((child, index) => (
+ // eslint-disable-next-line react/no-array-index-key
+
+ {index > 0 && }
+ {child}
+
+ ))}
+
+);
diff --git a/packages/shared/src/features/snapshot/snapshotCapture.ts b/packages/shared/src/features/snapshot/snapshotCapture.ts
new file mode 100644
index 00000000000..ebcfe1a4473
--- /dev/null
+++ b/packages/shared/src/features/snapshot/snapshotCapture.ts
@@ -0,0 +1,22 @@
+import type { CaptureShareImageOptions } from '../../lib/imageShare/captureShareImage';
+import { SNAPSHOT_MAX_HEIGHT, SNAPSHOT_SIZE } from './snapshotGradient';
+
+/**
+ * A designed card is 1080 wide and carries its own logo, so the capture only
+ * has to match its height. Growing cards are measured rather than assumed, in
+ * both directions: assuming the square would letterbox a long passage down to
+ * a screenshot's worth of text, and pad a short one out with dead gradient.
+ * An unmeasurable element falls back to the square.
+ */
+export function getSnapshotCaptureOptions(
+ element?: HTMLElement | null,
+): CaptureShareImageOptions {
+ const measured = Math.round(element?.getBoundingClientRect().height ?? 0);
+
+ return {
+ width: SNAPSHOT_SIZE,
+ height: measured ? Math.min(SNAPSHOT_MAX_HEIGHT, measured) : SNAPSHOT_SIZE,
+ padding: 0,
+ branded: false,
+ };
+}
diff --git a/packages/shared/src/features/snapshot/snapshotGradient.ts b/packages/shared/src/features/snapshot/snapshotGradient.ts
new file mode 100644
index 00000000000..e6384db61c3
--- /dev/null
+++ b/packages/shared/src/features/snapshot/snapshotGradient.ts
@@ -0,0 +1,76 @@
+export const SNAPSHOT_SIZE = 1080;
+/**
+ * 9:16 — the tallest frame every share destination still shows whole. Text
+ * surfaces grow into it instead of clamping their copy to the square.
+ */
+export const SNAPSHOT_MAX_HEIGHT = 1920;
+
+/* eslint-disable no-bitwise -- an FNV hash and a mulberry32 PRNG are defined
+ in terms of integer bit operations; expressing them any other way would
+ change the numbers they produce. */
+const hashSeed = (seed: string): number => {
+ let hash = 2166136261;
+
+ for (let i = 0; i < seed.length; i += 1) {
+ hash ^= seed.charCodeAt(i);
+ hash = Math.imul(hash, 16777619);
+ }
+
+ return hash >>> 0;
+};
+
+const createRandom = (seed: string): (() => number) => {
+ let state = hashSeed(seed) || 1;
+
+ return () => {
+ state += 0x6d2b79f5;
+ let t = state;
+ t = Math.imul(t ^ (t >>> 15), t | 1);
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
+ };
+};
+/* eslint-enable no-bitwise */
+
+/**
+ * Sampled from the App Store screenshots: a near-black violet ground with one
+ * large halo behind the subject and a quieter wash along the bottom.
+ */
+const BASE = 'linear-gradient(178deg, #150C26 0%, #0B0713 52%, #08060F 100%)';
+
+const HALOS = [
+ { r: 128, g: 82, b: 214 },
+ { r: 151, g: 78, b: 224 },
+ { r: 106, g: 78, b: 220 },
+ { r: 177, g: 75, b: 215 },
+];
+
+const rgba = (
+ { r, g, b }: { r: number; g: number; b: number },
+ alpha: number,
+): string => `rgba(${r}, ${g}, ${b}, ${alpha})`;
+
+export function getSnapshotGradient(seed: string): string {
+ const random = createRandom(seed);
+ const halo = HALOS[Math.floor(random() * HALOS.length)];
+ const accent = HALOS[Math.floor(random() * HALOS.length)];
+
+ const haloX = Math.round(38 + random() * 24);
+ const haloY = Math.round(2 + random() * 12);
+ const haloAlpha = 0.5 + random() * 0.18;
+
+ const washX = Math.round(12 + random() * 76);
+ const washAlpha = 0.16 + random() * 0.12;
+
+ return [
+ `radial-gradient(72% 48% at ${haloX}% ${haloY}%, ${rgba(
+ halo,
+ haloAlpha,
+ )} 0%, ${rgba(halo, 0)} 68%)`,
+ `radial-gradient(58% 34% at ${washX}% 104%, ${rgba(
+ accent,
+ washAlpha,
+ )} 0%, ${rgba(accent, 0)} 72%)`,
+ BASE,
+ ].join(', ');
+}
diff --git a/packages/shared/src/features/snapshot/useSnapshotCapture.tsx b/packages/shared/src/features/snapshot/useSnapshotCapture.tsx
new file mode 100644
index 00000000000..4565d89a0b7
--- /dev/null
+++ b/packages/shared/src/features/snapshot/useSnapshotCapture.tsx
@@ -0,0 +1,231 @@
+import type { ReactNode } from 'react';
+import React, { useCallback, useEffect, useRef, useState } from 'react';
+import type {
+ CaptureShareImageOptions,
+ CaptureTarget,
+} from '../../lib/imageShare/captureShareImage';
+import {
+ captureShareImage,
+ SHARE_IMAGE_HEIGHT,
+ SHARE_IMAGE_WIDTH,
+} from '../../lib/imageShare/captureShareImage';
+import { downloadShareImage } from '../../lib/imageShare/downloadShareImage';
+import { copyShareImage } from '../../lib/imageShare/copyShareImage';
+import {
+ ToastType,
+ useToastNotification,
+} from '../../hooks/useToastNotification';
+import { getSnapshotCaptureOptions } from './snapshotCapture';
+
+export type SnapshotStatus = 'loading' | 'ready' | 'error';
+
+// Writing an image needs both the async clipboard and ClipboardItem; Firefox
+// has the former without the latter. copyShareImage makes the same check before
+// it writes, but the label has to be decided before the press.
+const supportsImageCopy = (): boolean =>
+ typeof ClipboardItem !== 'undefined' &&
+ typeof navigator !== 'undefined' &&
+ typeof navigator.clipboard?.write === 'function';
+
+// Probing needs a File instance, so capability is resolved on the client only.
+const supportsFileShare = (): boolean => {
+ if (typeof navigator === 'undefined' || !navigator.canShare) {
+ return false;
+ }
+
+ try {
+ return navigator.canShare({
+ files: [new File([], 'probe.png', { type: 'image/png' })],
+ });
+ } catch {
+ return false;
+ }
+};
+
+export interface UseSnapshotCaptureProps {
+ /** The designed square card to rasterize, mounted off-screen while active. */
+ card?: ReactNode;
+ /** Captured instead of `card`, for surfaces with no designed card yet. */
+ target?: CaptureTarget;
+ filename: string;
+ captureOptions?: CaptureShareImageOptions;
+ /**
+ * Gates both the off-screen mount and the capture, so a feed never carries
+ * one 1080px card per item until someone actually asks to share.
+ */
+ isActive: boolean;
+ onCapture?: (blob: Blob) => void;
+}
+
+export interface UseSnapshotCaptureResult {
+ status: SnapshotStatus;
+ /** Object URL of the render, once `status` is 'ready'. */
+ preview?: string;
+ /** Intrinsic dimensions, for holding the preview's aspect ratio. */
+ width: number;
+ height: number;
+ /** Render this somewhere in the tree; it positions itself off-screen. */
+ offScreenCard: ReactNode;
+ /** True where the platform can hand a PNG to a native share sheet. */
+ canShareFile: boolean;
+ /** True where the PNG can go straight to the clipboard. */
+ canCopyImage: boolean;
+ /**
+ * Native share sheet where available, clipboard next, download as the last
+ * resort. Toasts on the clipboard path, which has no UI of its own.
+ */
+ shareImage: () => Promise;
+}
+
+/**
+ * Rasterizes a designed card off-screen and hands back the preview plus the
+ * share action. Shared by the dropdown and the modal section so both render
+ * from one implementation.
+ */
+export function useSnapshotCapture({
+ card,
+ target,
+ filename,
+ captureOptions,
+ isActive,
+ onCapture,
+}: UseSnapshotCaptureProps): UseSnapshotCaptureResult {
+ const [status, setStatus] = useState('loading');
+ const [preview, setPreview] = useState();
+ const [canShareFile, setCanShareFile] = useState(false);
+ const [canCopyImage, setCanCopyImage] = useState(false);
+ const { displayToast } = useToastNotification();
+ const blob = useRef();
+ const previewUrl = useRef();
+ const cardRef = useRef(null);
+ const captured = useRef(false);
+
+ const hasCard = !!card;
+ const subject = hasCard ? cardRef : target;
+ const [size, setSize] = useState({
+ width: SHARE_IMAGE_WIDTH,
+ height: SHARE_IMAGE_HEIGHT,
+ });
+
+ const releasePreview = useCallback(() => {
+ if (previewUrl.current) {
+ URL.revokeObjectURL(previewUrl.current);
+ previewUrl.current = undefined;
+ }
+ }, []);
+
+ useEffect(() => {
+ setCanShareFile(supportsFileShare());
+ setCanCopyImage(supportsImageCopy());
+ }, []);
+
+ useEffect(() => releasePreview, [releasePreview]);
+
+ const renderPreview = useCallback(async () => {
+ if (!subject) {
+ setStatus('error');
+ return;
+ }
+
+ setStatus('loading');
+
+ // Resolved here, not at render: a growing card's height is only known
+ // once it is mounted off-screen.
+ const options =
+ captureOptions ??
+ (hasCard ? getSnapshotCaptureOptions(cardRef.current) : undefined);
+
+ try {
+ const result = await captureShareImage(subject, options);
+
+ setSize({
+ width: options?.width ?? SHARE_IMAGE_WIDTH,
+ height: options?.height ?? SHARE_IMAGE_HEIGHT,
+ });
+
+ blob.current = result;
+ releasePreview();
+ previewUrl.current = URL.createObjectURL(result);
+ setPreview(previewUrl.current);
+ setStatus('ready');
+ onCapture?.(result);
+ } catch {
+ setStatus('error');
+ }
+ }, [captureOptions, hasCard, onCapture, releasePreview, subject]);
+
+ // Rasterizing is a long synchronous task, so yield once and let the caller
+ // paint its skeleton before it starts — otherwise the press feels dropped.
+ // The ref pins it to one capture per activation: callers pass inline card
+ // elements, so renderPreview's identity changes on every render.
+ useEffect(() => {
+ if (!isActive) {
+ captured.current = false;
+ return undefined;
+ }
+
+ if (captured.current) {
+ return undefined;
+ }
+
+ captured.current = true;
+ const timeout = setTimeout(renderPreview);
+
+ return () => clearTimeout(timeout);
+ }, [isActive, renderPreview]);
+
+ const shareImage = useCallback(async () => {
+ if (!blob.current) {
+ return;
+ }
+
+ if (canShareFile) {
+ const file = new File([blob.current], `${filename}.png`, {
+ type: 'image/png',
+ });
+
+ try {
+ await navigator.share({ files: [file] });
+ } catch {
+ // The user dismissed the native sheet.
+ }
+ return;
+ }
+
+ if (canCopyImage) {
+ // Promise, not the resolved blob: the util relies on ClipboardItem
+ // resolving it so Safari does not lose the gesture.
+ const copied = await copyShareImage(Promise.resolve(blob.current));
+
+ if (copied) {
+ displayToast('Image copied, paste it anywhere', {
+ variant: ToastType.Success,
+ });
+ return;
+ }
+ }
+
+ downloadShareImage(blob.current, filename);
+ }, [canCopyImage, canShareFile, displayToast, filename]);
+
+ const offScreenCard = isActive && hasCard && (
+
+ {card}
+
+ );
+
+ return {
+ status,
+ preview,
+ width: size.width,
+ height: size.height,
+ offScreenCard,
+ canShareFile,
+ canCopyImage,
+ shareImage,
+ };
+}
diff --git a/packages/shared/src/hooks/profile/useProfileReadingHistory.ts b/packages/shared/src/hooks/profile/useProfileReadingHistory.ts
new file mode 100644
index 00000000000..ccbdd719cbe
--- /dev/null
+++ b/packages/shared/src/hooks/profile/useProfileReadingHistory.ts
@@ -0,0 +1,52 @@
+import { useQuery } from '@tanstack/react-query';
+import { startOfTomorrow, subDays, subMonths } from 'date-fns';
+import type { ProfileReadingData, UserReadHistory } from '../../graphql/users';
+import { USER_READING_HISTORY_QUERY } from '../../graphql/users';
+import { gqlClient } from '../../graphql/common';
+import { generateQueryKey, RequestKey } from '../../lib/query';
+import { useAuthContext } from '../../contexts/AuthContext';
+import type { PublicProfile } from '../../lib/user';
+
+export const sumReads = (readHistory?: UserReadHistory[]): number =>
+ readHistory?.reduce((total, entry) => {
+ const reads = entry?.reads || 0;
+
+ return total + (typeof reads === 'number' && reads >= 0 ? reads : 0);
+ }, 0) ?? 0;
+
+interface UseProfileReadingHistoryResult {
+ readingHistory?: ProfileReadingData;
+ isLoading: boolean;
+ before: Date;
+ after: Date;
+}
+
+/**
+ * The window is not part of the key, so the header and the widgets column
+ * share one cache entry and one request between them.
+ */
+export function useProfileReadingHistory(
+ user?: PublicProfile,
+): UseProfileReadingHistoryResult {
+ const { tokenRefreshed } = useAuthContext();
+ const before = startOfTomorrow();
+ const after = subMonths(subDays(before, 2), 5);
+
+ const { data: readingHistory, isLoading } = useQuery({
+ queryKey: generateQueryKey(RequestKey.ReadingStats, user),
+ queryFn: () =>
+ gqlClient.request(USER_READING_HISTORY_QUERY, {
+ id: user?.id,
+ before,
+ after,
+ version: 2,
+ limit: 6,
+ }),
+ enabled: !!user && tokenRefreshed,
+ refetchOnWindowFocus: false,
+ refetchOnReconnect: false,
+ refetchOnMount: false,
+ });
+
+ return { readingHistory, isLoading, before, after };
+}
diff --git a/packages/shared/src/styles/utilities.css b/packages/shared/src/styles/utilities.css
index dd39dc9678b..c52c0b68613 100644
--- a/packages/shared/src/styles/utilities.css
+++ b/packages/shared/src/styles/utilities.css
@@ -1205,3 +1205,12 @@ img.agent-media-ring {
opacity: 0;
}
}
+
+/* Snapshot copy is rasterized once and never reflows, so it can afford the
+ expensive wrapping: balance evens the line lengths and removes the orphan
+ word, and anywhere keeps long names inside the card. */
+.snapshot-copy {
+ text-wrap: balance;
+ overflow-wrap: anywhere;
+ hyphens: none;
+}