From 639abc680be74c63f8e0d16fc1b273376299577a Mon Sep 17 00:00:00 2001 From: tomeredlich Date: Sun, 6 Sep 2026 16:05:35 +0300 Subject: [PATCH 1/6] feat(snapshot): rasterize designed cards instead of the live DOM MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A snapshot was a screenshot: the live element fitted into a 1200x630 frame with a logo bar drawn under it. It looked nothing like the designed square cards the surface pages compare against. Brings the card system over from snapshot-share-images verbatim — the 1080px SnapshotFrame with its lit edge and seeded gradient, the identity and stat rows, and the five profile cards — along with useSnapshotCapture, which mounts a card off-screen and rasterizes it, and that branch's SnapshotButton. The button renders on hover or focus rather than on press, so the press itself still owns the gesture the clipboard needs; a press that lands first falls back to a download. That replaces the shutter sound, the sweep animation and the Snapshot icon, which go with it. The achievement card is wired to AchievementSnapshotCard, and only where the achievement is unlocked: a locked one has no completion date to show. Co-Authored-By: Claude Opus 5 --- .../src/components/icons/Snapshot/filled.svg | 13 - .../src/components/icons/Snapshot/index.tsx | 10 - .../components/icons/Snapshot/outlined.svg | 11 - packages/shared/src/components/icons/index.ts | 1 - .../imageShare/SnapshotButton.spec.tsx | 109 ++++----- .../components/imageShare/SnapshotButton.tsx | 153 ++++++------ .../achievements/AchievementCard.tsx | 40 +-- .../snapshot/AchievementSnapshotCard.tsx | 130 ++++++++++ .../snapshot/AchievementsSnapshotCard.tsx | 115 +++++++++ .../features/snapshot/BadgesSnapshotCard.tsx | 145 +++++++++++ .../features/snapshot/ProfileSnapshotCard.tsx | 146 +++++++++++ .../snapshot/ReadingOverviewSnapshotCard.tsx | 189 +++++++++++++++ .../src/features/snapshot/SnapshotFrame.tsx | 136 +++++++++++ .../features/snapshot/SnapshotIdentity.tsx | 57 +++++ .../src/features/snapshot/SnapshotStats.tsx | 70 ++++++ .../src/features/snapshot/shutterSound.ts | 23 -- .../src/features/snapshot/snapshotGradient.ts | 71 ++++++ .../features/snapshot/useSnapshotCapture.tsx | 229 ++++++++++++++++++ packages/shared/src/styles/utilities.css | 47 +--- packages/webapp/public/sounds/shutter.mp3 | Bin 45824 -> 0 bytes 20 files changed, 1443 insertions(+), 252 deletions(-) delete mode 100644 packages/shared/src/components/icons/Snapshot/filled.svg delete mode 100644 packages/shared/src/components/icons/Snapshot/index.tsx delete mode 100644 packages/shared/src/components/icons/Snapshot/outlined.svg create mode 100644 packages/shared/src/features/snapshot/AchievementSnapshotCard.tsx create mode 100644 packages/shared/src/features/snapshot/AchievementsSnapshotCard.tsx create mode 100644 packages/shared/src/features/snapshot/BadgesSnapshotCard.tsx create mode 100644 packages/shared/src/features/snapshot/ProfileSnapshotCard.tsx create mode 100644 packages/shared/src/features/snapshot/ReadingOverviewSnapshotCard.tsx create mode 100644 packages/shared/src/features/snapshot/SnapshotFrame.tsx create mode 100644 packages/shared/src/features/snapshot/SnapshotIdentity.tsx create mode 100644 packages/shared/src/features/snapshot/SnapshotStats.tsx delete mode 100644 packages/shared/src/features/snapshot/shutterSound.ts create mode 100644 packages/shared/src/features/snapshot/snapshotGradient.ts create mode 100644 packages/shared/src/features/snapshot/useSnapshotCapture.tsx delete mode 100644 packages/webapp/public/sounds/shutter.mp3 diff --git a/packages/shared/src/components/icons/Snapshot/filled.svg b/packages/shared/src/components/icons/Snapshot/filled.svg deleted file mode 100644 index d4cc05f0b56..00000000000 --- a/packages/shared/src/components/icons/Snapshot/filled.svg +++ /dev/null @@ -1,13 +0,0 @@ - - - Icon/Snapshot/Filled - - - - - - - - - - diff --git a/packages/shared/src/components/icons/Snapshot/index.tsx b/packages/shared/src/components/icons/Snapshot/index.tsx deleted file mode 100644 index 8707b229fad..00000000000 --- a/packages/shared/src/components/icons/Snapshot/index.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import type { ReactElement } from 'react'; -import React from 'react'; -import type { IconProps } from '../../Icon'; -import Icon from '../../Icon'; -import OutlinedIcon from './outlined.svg'; -import FilledIcon from './filled.svg'; - -export const SnapshotIcon = (props: IconProps): ReactElement => ( - -); diff --git a/packages/shared/src/components/icons/Snapshot/outlined.svg b/packages/shared/src/components/icons/Snapshot/outlined.svg deleted file mode 100644 index af265154e03..00000000000 --- a/packages/shared/src/components/icons/Snapshot/outlined.svg +++ /dev/null @@ -1,11 +0,0 @@ - - - Icon/Snapshot/Outline - - - - - - - - diff --git a/packages/shared/src/components/icons/index.ts b/packages/shared/src/components/icons/index.ts index 5c1057b1724..52ee9458013 100644 --- a/packages/shared/src/components/icons/index.ts +++ b/packages/shared/src/components/icons/index.ts @@ -150,7 +150,6 @@ export * from './Shortcuts'; export * from './Sidebar'; export * from './Sites'; export * from './Slack'; -export * from './Snapshot'; export * from './Sort'; export * from './Source'; export * from './Sparkle'; diff --git a/packages/shared/src/components/imageShare/SnapshotButton.spec.tsx b/packages/shared/src/components/imageShare/SnapshotButton.spec.tsx index 57c00d50959..c987ee2ab8c 100644 --- a/packages/shared/src/components/imageShare/SnapshotButton.spec.tsx +++ b/packages/shared/src/components/imageShare/SnapshotButton.spec.tsx @@ -5,10 +5,11 @@ import { SnapshotButton } from './SnapshotButton'; const mockCapture = jest.fn(); const mockCopy = jest.fn(); const mockDownload = jest.fn(); -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,12 +20,8 @@ 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 }), + useToastNotification: () => ({ displayToast: jest.fn() }), ToastType: { Success: 'success', Error: 'error' }, })); @@ -33,78 +30,76 @@ jest.mock('../../hooks/useRequestProtocol', () => ({ })); 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('Share as image'); - clickSnapshot(); +it('does not rasterize the card until there is intent', () => { + render(); - await waitFor(() => - expect(mockDisplayToast).toHaveBeenCalledWith('Image copied', { - variant: 'success', - }), - ); - expect(mockDownload).not.toHaveBeenCalled(); + expect(mockCapture).not.toHaveBeenCalled(); }); -it('falls back to a download when the clipboard is unavailable', async () => { - mockCopy.mockResolvedValue(false); - renderComponent({ filename: 'daily-profile-tomer' }); +it('rasterizes on hover, so the press still owns the gesture', async () => { + render(); - clickSnapshot(); + fireEvent.pointerEnter(button()); - await waitFor(() => - expect(mockDownload).toHaveBeenCalledWith(blob, 'daily-profile-tomer'), - ); - expect(mockDisplayToast).toHaveBeenCalledWith('Image saved', { - variant: 'success', - }); + await waitFor(() => expect(mockCapture).toHaveBeenCalledTimes(1)); +}); + +it('rasterizes on keyboard focus too', async () => { + render(); + + fireEvent.focus(button()); + + await waitFor(() => expect(mockCapture).toHaveBeenCalledTimes(1)); }); -it('reports a failed capture instead of copying or downloading', async () => { - mockCapture.mockRejectedValue(new Error('target element has no size')); +it('copies the rendered card once it is ready', async () => { + render(); + + fireEvent.pointerEnter(button()); + await waitFor(() => expect(mockCapture).toHaveBeenCalled()); + fireEvent.click(button()); + + await waitFor(() => expect(mockCopy).toHaveBeenCalled()); + expect(mockDownload).not.toHaveBeenCalled(); +}); + +it('downloads when the clipboard cannot take an image', async () => { mockCopy.mockResolvedValue(false); - renderComponent(); + render(); - clickSnapshot(); + fireEvent.pointerEnter(button()); + await waitFor(() => expect(mockCapture).toHaveBeenCalled()); + fireEvent.click(button()); await waitFor(() => - expect(mockDisplayToast).toHaveBeenCalledWith( - 'Could not create the snapshot, please try again', - { variant: 'error' }, - ), + expect(mockDownload).toHaveBeenCalledWith(blob, 'daily-achievement-1'), ); - 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..94e0283fc16 100644 --- a/packages/shared/src/components/imageShare/SnapshotButton.tsx +++ b/packages/shared/src/components/imageShare/SnapshotButton.tsx @@ -1,29 +1,22 @@ -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 { DownloadIcon, ShareIcon } 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 { playShutterSound } from '../../features/snapshot/shutterSound'; +import { useSnapshotCapture } from '../../features/snapshot/useSnapshotCapture'; -const SNAPSHOT_LABEL = 'Snapshot'; - -/** Matches the snapshot-shutter-sweep animation in utilities.css. */ -const SHUTTER_SWEEP_MS = 380; +export const SHARE_LABEL = 'Share as image'; 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,9 +28,10 @@ export interface SnapshotButtonProps { } export function SnapshotButton({ + card, target, - filename = 'daily-snapshot', - label = SNAPSHOT_LABEL, + filename = 'daily-share', + label = SHARE_LABEL, showLabel = true, captureOptions, onCapture, @@ -45,82 +39,75 @@ export function SnapshotButton({ variant = ButtonVariant.Tertiary, className, }: SnapshotButtonProps): ReactElement { - const { displayToast } = useToastNotification(); - const [isCapturing, setIsCapturing] = useState(false); - const [isFlashing, setIsFlashing] = useState(false); - const flashTimeout = useRef>(); + if (!card && !target) { + throw new Error('SnapshotButton needs either a card or a target'); + } - useEffect( - () => () => { - if (flashTimeout.current) { - clearTimeout(flashTimeout.current); - } - }, - [], - ); + // 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 isPending = useRef(false); - const onSnapshot = useCallback( - async (event: React.MouseEvent) => { - // Every placement sits inside a clickable card, row or link. - event.preventDefault(); - event.stopPropagation(); - playShutterSound(); - setIsFlashing(true); - flashTimeout.current = setTimeout( - () => setIsFlashing(false), - SHUTTER_SWEEP_MS, - ); - setIsCapturing(true); + const { status, canShareFile, canCopyImage, offScreenCard, shareImage } = + useSnapshotCapture({ + card, + target, + filename, + captureOptions, + isActive: isPrepared, + onCapture, + }); - try { - const capture = captureShareImage(target, captureOptions); + // 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 && status === 'ready') { + isPending.current = false; + shareImage(); + } + }, [shareImage, status]); - if (onCapture) { - onCapture(await capture); - return; - } + const prepare = useCallback(() => setIsPrepared(true), []); - // 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; - } + const onClick = useCallback( + (event: React.MouseEvent) => { + // The trigger sits inside clickable cards, rows and links. + event.preventDefault(); + event.stopPropagation(); - 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], ); + const canShare = canShareFile || canCopyImage; + return ( - - - + <> + {offScreenCard} + + + + ); } diff --git a/packages/shared/src/features/profile/components/achievements/AchievementCard.tsx b/packages/shared/src/features/profile/components/achievements/AchievementCard.tsx index 92297d83131..c3b4c5c99c6 100644 --- a/packages/shared/src/features/profile/components/achievements/AchievementCard.tsx +++ b/packages/shared/src/features/profile/components/achievements/AchievementCard.tsx @@ -1,5 +1,5 @@ import type { ReactElement } from 'react'; -import React, { useRef } from 'react'; +import React from 'react'; import classNames from 'classnames'; import type { UserAchievement } from '../../../../graphql/user/achievements'; import { @@ -30,6 +30,7 @@ import { } from './achievementRarity'; import { RaritySparkles } from './RaritySparkles'; import { SnapshotButton } from '../../../../components/imageShare/SnapshotButton'; +import { AchievementSnapshotCard } from '../../../snapshot/AchievementSnapshotCard'; interface AchievementCardProps { userAchievement: UserAchievement; @@ -50,7 +51,6 @@ export function AchievementCard({ onUntrack, isUntrackPending = false, }: AchievementCardProps): ReactElement { - const cardRef = useRef(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..4551ee3801f --- /dev/null +++ b/packages/shared/src/features/snapshot/AchievementsSnapshotCard.tsx @@ -0,0 +1,115 @@ +import type { ReactElement } from 'react'; +import React, { forwardRef } from 'react'; +import colors from '../../styles/colors'; +import { largeNumberFormat } from '../../lib'; +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: Omit; + 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 ( + +
+ + +
+ + +
+ +
+ 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..ce42daf7915 --- /dev/null +++ b/packages/shared/src/features/snapshot/BadgesSnapshotCard.tsx @@ -0,0 +1,145 @@ +import type { ReactElement } from 'react'; +import React, { forwardRef } from 'react'; +import colors from '../../styles/colors'; +import { largeNumberFormat } from '../../lib'; +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: Omit; + 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 ( + +
+ + +
+ + +
+ +
+ {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..ef93e56fd22 --- /dev/null +++ b/packages/shared/src/features/snapshot/ReadingOverviewSnapshotCard.tsx @@ -0,0 +1,189 @@ +import type { ReactElement } from 'react'; +import React, { forwardRef } from 'react'; +import colors from '../../styles/colors'; +import { largeNumberFormat } from '../../lib'; +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: Omit; + 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 ( + +
+ + +
+ + +
+ +
+ + 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/SnapshotFrame.tsx b/packages/shared/src/features/snapshot/SnapshotFrame.tsx new file mode 100644 index 00000000000..05c5a2c5589 --- /dev/null +++ b/packages/shared/src/features/snapshot/SnapshotFrame.tsx @@ -0,0 +1,136 @@ +import type { ReactElement, ReactNode } from 'react'; +import React, { forwardRef } from 'react'; +import LogoIcon from '../../svg/LogoIcon'; +import LogoText from '../../svg/LogoText'; +import { getSnapshotGradient, SNAPSHOT_SIZE } from './snapshotGradient'; + +export const SNAPSHOT_CARD_SIZE = 780; +/** 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; + +/** + * 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; + /** Drop the card shell and stand the children straight on the gradient. */ + bare?: boolean; + children: ReactNode; +} + +function SnapshotFrameComponent( + { + seed, + watermark, + bare, + logoPlacement = 'inline', + children, + }: SnapshotFrameProps, + ref: React.Ref, +): ReactElement { + const isOverlaid = logoPlacement !== 'inline'; + const overlayStyle = { + position: 'absolute' as const, + top: 30, + ...(logoPlacement === 'top-right' ? { right: 30 } : { left: 30 }), + zIndex: 4, + }; + const 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 && logo} + + {bare ? ( +
+ {isOverlaid && logo} + {children} +
+ ) : ( +
+
+ {watermark && ( + + {watermark} + + )} + {isOverlaid && logo} +
+ {!isOverlaid && logo} + {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..915c51604b1 --- /dev/null +++ b/packages/shared/src/features/snapshot/SnapshotIdentity.tsx @@ -0,0 +1,57 @@ +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; + /** Small uppercase label pushed to the trailing edge of the row. */ + label?: string; +} + +export function SnapshotIdentity({ + name, + handle, + image, + label, +}: SnapshotIdentityProps): ReactElement { + return ( +
+ {image && ( + + )} +
+ + {name} + + + {handle} + +
+ {label && ( + + {label} + + )} +
+ ); +} 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/shutterSound.ts b/packages/shared/src/features/snapshot/shutterSound.ts deleted file mode 100644 index ac00c91412d..00000000000 --- a/packages/shared/src/features/snapshot/shutterSound.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { fromCDN } from '../../lib/links'; - -let shutter: HTMLAudioElement | null = null; - -/** - * One shared element rather than one per press: rewinding an existing clip is - * instant, while a fresh Audio has to fetch and decode before it plays. - */ -export function playShutterSound(): void { - if (typeof window === 'undefined') { - return; - } - - if (!shutter) { - shutter = new Audio(fromCDN('/sounds/shutter.mp3')); - shutter.volume = 0.45; - } - - shutter.currentTime = 0; - // Autoplay policy rejects until the page has been interacted with, and the - // capture must not fail because the sound did. - shutter.play().catch(() => {}); -} diff --git a/packages/shared/src/features/snapshot/snapshotGradient.ts b/packages/shared/src/features/snapshot/snapshotGradient.ts new file mode 100644 index 00000000000..03059ea599c --- /dev/null +++ b/packages/shared/src/features/snapshot/snapshotGradient.ts @@ -0,0 +1,71 @@ +export const SNAPSHOT_SIZE = 1080; + +/* 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..17633f5335d --- /dev/null +++ b/packages/shared/src/features/snapshot/useSnapshotCapture.tsx @@ -0,0 +1,229 @@ +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 { SNAPSHOT_SIZE } from './snapshotGradient'; + +export type SnapshotStatus = 'loading' | 'ready' | 'error'; + +/** A designed card is already square and carries its own logo. */ +const CARD_CAPTURE_OPTIONS: CaptureShareImageOptions = { + width: SNAPSHOT_SIZE, + height: SNAPSHOT_SIZE, + padding: 0, + branded: false, +}; + +// 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 options = + captureOptions ?? (hasCard ? CARD_CAPTURE_OPTIONS : undefined); + + 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'); + + try { + const result = await captureShareImage(subject, options); + + blob.current = result; + releasePreview(); + previewUrl.current = URL.createObjectURL(result); + setPreview(previewUrl.current); + setStatus('ready'); + onCapture?.(result); + } catch { + setStatus('error'); + } + }, [onCapture, options, 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 { width = SHARE_IMAGE_WIDTH, height = SHARE_IMAGE_HEIGHT } = + options ?? {}; + + const offScreenCard = isActive && hasCard && ( +
+ {card} +
+ ); + + return { + status, + preview, + width, + height, + offScreenCard, + canShareFile, + canCopyImage, + shareImage, + }; +} diff --git a/packages/shared/src/styles/utilities.css b/packages/shared/src/styles/utilities.css index dd39dc9678b..98c8ff59cb8 100644 --- a/packages/shared/src/styles/utilities.css +++ b/packages/shared/src/styles/utilities.css @@ -1164,44 +1164,11 @@ img.agent-media-ring { against the container instead, at the same 500px the card switches on, so a panel dragged wide gets the side-by-side layout back. */ -/* Shutter feedback on the snapshot button: a highlight crossing the face once, - left to right, so the press reads as a capture rather than a submit. */ -@keyframes snapshot-shutter-sweep { - 0% { - opacity: 0; - transform: translateX(-120%) skewX(-18deg); - } - - 22% { - opacity: 1; - } - - 100% { - opacity: 0; - transform: translateX(220%) skewX(-18deg); - } -} - -.snapshot-shutter-sweep::after { - content: ''; - position: absolute; - top: 0; - bottom: 0; - left: 0; - width: 60%; - pointer-events: none; - background: linear-gradient( - 90deg, - transparent 0%, - rgba(255, 255, 255, 0.85) 50%, - transparent 100% - ); - animation: snapshot-shutter-sweep 380ms cubic-bezier(0.22, 1, 0.36, 1); -} - -@media (prefers-reduced-motion: reduce) { - .snapshot-shutter-sweep::after { - animation: none; - 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; } diff --git a/packages/webapp/public/sounds/shutter.mp3 b/packages/webapp/public/sounds/shutter.mp3 deleted file mode 100644 index f49b95f152c6d13f7a411f01abb94bab8b734be3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 45824 zcmeI&Yfw{X8o=?B1Ofs=i#RjWV=XJbF^m-ov%JCpQbWN_v@^UOKF z_dn;;@%_k=Mtt~j2;Sutr2ea`{^dGworAr$d8+@rSpEBnyT2DZ?jYOy6ZVee!_jeK zwtB#coUMz07*7y7|MqMB#YRF1Mi&30^fd98)Rn@P8iFEPUmIhbK;6k!FQ8CujNV-M zkDpm%X&HTGyqnJ5C<(*BGE*4FpyZ562ufG!d8_aRIpc1zuG|%wlM7;=8=0&X#jpma zI7y#=^=?vr=pZ@#T3#}|er8+A{Q6l=#nAN4Sx!<(>h5oX3jW?luGu#DU2ku1-(r=e zOMj~&$t*RR-Wsss2`yc6EP)*md#p6-ZW}{M%9k+p?j6^qpBSg<*5qdp`WcLLy*?#l z=AGT>-%fVwQC?5#tbb~-y?U_Ic>Z>_iJZ?gyTmY0NFzI^+$y$Sp$qP1F0&=&2?CkG zD(BAFuhVm>0;`kl<$ci;T#xc*&)iX|wSK|Ct2xV;Pq{%(w8qWp|6D~^wd8%jNvg<| zh*wkc;;G?Z1|{k!b8MvDw0ufQFrE2~V&MJrl9;U5t&{?DN~foX;cJL!1CgxG;)TP$z5~(#u))g{>Jy& zyv(F|#)+vn{x736a$fwH{M3Wh^MEc&t6xRXZT2#on-~oQb z*U7ir2VVR*5<0QwNV7;3s(y?EMMXDLL?YR5GGS#uxtOf^onQ5}qajTwDiobN>l?Q* z@_bpOmzS4+Xry$1pu)E%QravOM!HuynZ%Yp+iH9!FwAc0@(p^C9rx|&?E^XzgNS5m zJC$&7HaSsYd@=6u9_ADGDyqbMi)F*3665shy5wTc!i#pE51G2kDJ4PSirPaOEqq9J zZa(Y94rce|K2l{QBIgwIGBrNA{jWXYKMD#JYYx4Mm!&>7+9Pw$;dMKfH~4*bW-Y5H zsMRUYQ{j9qsQi0nd5vs?pY?KT)A{bus`ja?REB7yc-DcSd}LK)+j#FzO>^$WHbGbZkk*~o5hrGHL z*NW21J0C@q3T<^=STW9&TDOiV)}rvvO|OO@=~K2PwOuWF>&-fp%2FNw>P29Y^uV`9 zKC5q14$FEY?56(wtM@(5x8@a=3Ddobx0jFYZ%y-Y zlw9@-=$nxqdQmO(|2X~Pk$rQD@c^aNY^5Z_ylz1yL32`m*6?70>RrC;Nt0C~S!*@< zt+(D&@9jm4a@%>gO&xf>w=!3(sO>9^5eFsYvaMsqDG&YarrVAbgpIu~znk6H!>eo? zp6@+(wEjbKO4HRjZyI8oo~Jve*pBFJ(XpneUuhNU_nP|M#*?Vufc^PN-4k<#$9@v$ z_h&6hOw9T?jrV!u#JI12A{ir|55BM~uZ8U4J!o!TS4Y(A)9l$-mo=t^R+fo(3&`27 z-71e8T^8iE;6l#u`(K57=gYqM@XB?xcX6?Is)3(Bm%xYQHklD7Qv?yGZcLvb7OFe@ z-{kNHf_K}W>%6q!X(2f+I7H2Q1V~P^&PxmK7LwC~L)5HCfaEmmytLqMAvrBLM9q2x zNKUiPOAGE6lGB1i)T~E<b<6^g9A;2hhZ2C~P(pAWvj8NA z85Gu`1Ryz-5M0MB0Lftng>@(aNDd_g*D(t~a+pD39ZCQ`mqQ7`b-V)blEdo>+mHf~ z9FhnI@Crb3cs*eoQUH=e62Smo0Z0z7Cu~CsKypYT7{Dt4$>H^cZAbw~4oL(9cm*Ii zyq>TPDFDeKiC_S)03?Uk6Sg4*AUPxv4B!=jH^cZAbw~4oL(9cm*Ii Zyq>TPDFDeKiC_S)03?Uk6Sg4*{tKU|N9h0n From f8d3c8cbef0f197097daea0c73307ac022517a6b Mon Sep 17 00:00:00 2001 From: tomeredlich Date: Sun, 6 Sep 2026 16:27:03 +0300 Subject: [PATCH 2/6] feat(profile): wire the remaining four placements to their designed cards Every profile placement now rasterizes a designed card instead of the live element, so no snapshot is a screenshot any more. The values come from whatever the page itself renders, not a parallel derivation, because a share image that disagrees with the page is worse than no share image. The rarest-unlocked sort moves into sortAchievements so the widget's five and the card's ten come from one comparator. CalendarHeatmap exports its bins, so the card's cells are bucketed exactly as the profile heatmap buckets them. Badge counts read topReaders[0].total and tag labels read tagTitles, matching the widgets beside them. ProfileHeader needed posts read, which only the widgets column had. That query moves into useProfileReadingHistory: the window was never part of the key, so the header and the column share one cache entry and one request. Co-Authored-By: Claude Opus 5 --- .../shared/src/components/CalendarHeatmap.tsx | 4 +- .../achievement/sortAchievements.spec.ts | 81 ++++++++++++++++++- .../modals/achievement/sortAchievements.ts | 31 +++++++ .../src/components/profile/ProfileHeader.tsx | 29 +++++-- .../ProfileWidgets/AchievementsWidget.tsx | 56 ++++++------- .../ProfileWidgets/BadgesAndAwards.tsx | 36 ++++++++- .../ProfileWidgets/ProfileWidgets.tsx | 36 +++------ .../ProfileWidgets/ReadingOverview.spec.tsx | 13 +++ .../ProfileWidgets/ReadingOverview.tsx | 49 +++++++++-- .../hooks/profile/useProfileReadingHistory.ts | 52 ++++++++++++ 10 files changed, 315 insertions(+), 72 deletions(-) create mode 100644 packages/shared/src/hooks/profile/useProfileReadingHistory.ts 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/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, }) && } (null); const totalReads = useMemo(() => { if (!readHistory?.length) { return 0; @@ -79,12 +88,22 @@ export function ReadingOverview({ }, 0); }, [readHistory]); + const { data: tagTitles = {} } = useQuery>( + tagTitlesQueryOptions(), + ); + const heatmap = useMemo(() => { + const counts = readHistory?.map(readHistoryToValue) ?? []; + const bins = getBins(counts); + + return counts.map((count) => getBin(count, bins)); + }, [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} />
+ 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 }; +} From e5184d9b7adb476a27f6908f2cd265331a8d2f2f Mon Sep 17 00:00:00 2001 From: tomeredlich Date: Mon, 7 Sep 2026 11:59:33 +0300 Subject: [PATCH 3/6] fix(snapshot): keep the Snapshot button, not a share button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adopting snapshot-share-images' button wholesale brought its identity with it: the control became "Share as image" with a share-or-download glyph, and the Snapshot icon, the shutter and the sweep went in the bin. The ask was for the captured image to match the designed cards, not for the button to become something else. The card mechanism stays — render on hover or focus so the press still owns the gesture the clipboard needs — under the Snapshot icon, the Snapshot label, and the shutter and sweep on press. Co-Authored-By: Claude Opus 5 --- .../src/components/icons/Snapshot/filled.svg | 13 ++++ .../src/components/icons/Snapshot/index.tsx | 10 +++ .../components/icons/Snapshot/outlined.svg | 11 ++++ packages/shared/src/components/icons/index.ts | 1 + .../imageShare/SnapshotButton.spec.tsx | 6 +- .../components/imageShare/SnapshotButton.tsx | 59 +++++++++++++----- .../src/features/snapshot/shutterSound.ts | 23 +++++++ packages/shared/src/styles/utilities.css | 42 +++++++++++++ packages/webapp/public/sounds/shutter.mp3 | Bin 0 -> 45824 bytes 9 files changed, 147 insertions(+), 18 deletions(-) create mode 100644 packages/shared/src/components/icons/Snapshot/filled.svg create mode 100644 packages/shared/src/components/icons/Snapshot/index.tsx create mode 100644 packages/shared/src/components/icons/Snapshot/outlined.svg create mode 100644 packages/shared/src/features/snapshot/shutterSound.ts create mode 100644 packages/webapp/public/sounds/shutter.mp3 diff --git a/packages/shared/src/components/icons/Snapshot/filled.svg b/packages/shared/src/components/icons/Snapshot/filled.svg new file mode 100644 index 00000000000..d4cc05f0b56 --- /dev/null +++ b/packages/shared/src/components/icons/Snapshot/filled.svg @@ -0,0 +1,13 @@ + + + Icon/Snapshot/Filled + + + + + + + + + + diff --git a/packages/shared/src/components/icons/Snapshot/index.tsx b/packages/shared/src/components/icons/Snapshot/index.tsx new file mode 100644 index 00000000000..8707b229fad --- /dev/null +++ b/packages/shared/src/components/icons/Snapshot/index.tsx @@ -0,0 +1,10 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import type { IconProps } from '../../Icon'; +import Icon from '../../Icon'; +import OutlinedIcon from './outlined.svg'; +import FilledIcon from './filled.svg'; + +export const SnapshotIcon = (props: IconProps): ReactElement => ( + +); diff --git a/packages/shared/src/components/icons/Snapshot/outlined.svg b/packages/shared/src/components/icons/Snapshot/outlined.svg new file mode 100644 index 00000000000..af265154e03 --- /dev/null +++ b/packages/shared/src/components/icons/Snapshot/outlined.svg @@ -0,0 +1,11 @@ + + + Icon/Snapshot/Outline + + + + + + + + diff --git a/packages/shared/src/components/icons/index.ts b/packages/shared/src/components/icons/index.ts index 52ee9458013..5c1057b1724 100644 --- a/packages/shared/src/components/icons/index.ts +++ b/packages/shared/src/components/icons/index.ts @@ -150,6 +150,7 @@ export * from './Shortcuts'; export * from './Sidebar'; export * from './Sites'; export * from './Slack'; +export * from './Snapshot'; export * from './Sort'; export * from './Source'; export * from './Sparkle'; diff --git a/packages/shared/src/components/imageShare/SnapshotButton.spec.tsx b/packages/shared/src/components/imageShare/SnapshotButton.spec.tsx index c987ee2ab8c..11a94097535 100644 --- a/packages/shared/src/components/imageShare/SnapshotButton.spec.tsx +++ b/packages/shared/src/components/imageShare/SnapshotButton.spec.tsx @@ -25,6 +25,10 @@ jest.mock('../../hooks/useToastNotification', () => ({ ToastType: { Success: 'success', Error: 'error' }, })); +jest.mock('../../features/snapshot/shutterSound', () => ({ + playShutterSound: jest.fn(), +})); + jest.mock('../../hooks/useRequestProtocol', () => ({ useRequestProtocol: () => ({ isCompanion: false }), })); @@ -45,7 +49,7 @@ beforeEach(() => { Object.assign(navigator, { clipboard: { write: async () => undefined } }); }); -const button = () => screen.getByLabelText('Share as image'); +const button = () => screen.getByLabelText('Snapshot'); it('does not rasterize the card until there is intent', () => { render(); diff --git a/packages/shared/src/components/imageShare/SnapshotButton.tsx b/packages/shared/src/components/imageShare/SnapshotButton.tsx index 94e0283fc16..0602a418ace 100644 --- a/packages/shared/src/components/imageShare/SnapshotButton.tsx +++ b/packages/shared/src/components/imageShare/SnapshotButton.tsx @@ -2,15 +2,19 @@ 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 { DownloadIcon, ShareIcon } from '../icons'; +import { SnapshotIcon } from '../icons'; import { Tooltip } from '../tooltip/Tooltip'; import type { CaptureShareImageOptions, CaptureTarget, } from '../../lib/imageShare/captureShareImage'; import { useSnapshotCapture } from '../../features/snapshot/useSnapshotCapture'; +import { playShutterSound } from '../../features/snapshot/shutterSound'; -export const SHARE_LABEL = 'Share as image'; +export const SNAPSHOT_LABEL = 'Snapshot'; + +/** Matches the snapshot-shutter-sweep animation in utilities.css. */ +const SHUTTER_SWEEP_MS = 380; export interface SnapshotButtonProps { /** The designed square card to rasterize. */ @@ -30,8 +34,8 @@ export interface SnapshotButtonProps { export function SnapshotButton({ card, target, - filename = 'daily-share', - label = SHARE_LABEL, + filename = 'daily-snapshot', + label = SNAPSHOT_LABEL, showLabel = true, captureOptions, onCapture, @@ -46,17 +50,27 @@ export function SnapshotButton({ // 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 { status, canShareFile, canCopyImage, offScreenCard, shareImage } = - useSnapshotCapture({ - card, - target, - filename, - captureOptions, - isActive: isPrepared, - onCapture, - }); + const { status, offScreenCard, shareImage } = useSnapshotCapture({ + card, + target, + filename, + captureOptions, + isActive: isPrepared, + onCapture, + }); + + useEffect( + () => () => { + if (flashTimeout.current) { + clearTimeout(flashTimeout.current); + } + }, + [], + ); // 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 @@ -76,6 +90,13 @@ export function SnapshotButton({ event.preventDefault(); event.stopPropagation(); + playShutterSound(); + setIsFlashing(true); + flashTimeout.current = setTimeout( + () => setIsFlashing(false), + SHUTTER_SWEEP_MS, + ); + if (status === 'ready') { shareImage(); return; @@ -87,8 +108,6 @@ export function SnapshotButton({ [shareImage, status], ); - const canShare = canShareFile || canCopyImage; - return ( <> {offScreenCard} @@ -96,8 +115,14 @@ export function SnapshotButton({