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 ( - - - + <> + {offScreenCard} + + + + ); } 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 ( -
+
Cover @@ -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; +}