diff --git a/packages/shared/package.json b/packages/shared/package.json index 00e6ef543b4..db6af74b047 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -102,6 +102,7 @@ "typescript": "5.6.3" }, "dependencies": { + "@zumer/snapdom": "^2.23.1", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^8.0.0", "@dnd-kit/utilities": "^3.2.2", diff --git a/packages/shared/src/components/cards/Leaderboard/UserTopList.tsx b/packages/shared/src/components/cards/Leaderboard/UserTopList.tsx index 8fe79bb08c2..5ba26400e4d 100644 --- a/packages/shared/src/components/cards/Leaderboard/UserTopList.tsx +++ b/packages/shared/src/components/cards/Leaderboard/UserTopList.tsx @@ -4,6 +4,10 @@ import classNames from 'classnames'; import type { CommonLeaderboardProps } from './LeaderboardList'; import { LeaderboardList } from './LeaderboardList'; import { LeaderboardListItem } from './LeaderboardListItem'; +import { SnapshotButton } from '../../../features/snapshot/SnapshotButton'; +import { LeaderboardSnapshotCard } from '../../../features/snapshot/LeaderboardSnapshotCard'; +import { useSnapshotShare } from '../../../features/snapshot/useSnapshotShare'; +import { ButtonSize, ButtonVariant } from '../../buttons/Button'; import { CurrentUserPositionRow } from './CurrentUserPositionRow'; import { UserHighlight } from '../../widgets/PostUsersHighlights'; import type { LoggedUser } from '../../../lib/user'; @@ -36,6 +40,8 @@ export function UserTopList({ showLevel?: boolean; leaderboardType?: LeaderboardType; }): ReactElement { + const boardLabel = props.containerProps?.title ?? 'Leaderboard'; + const createRowMouseEnter = useCallback( (rankIndex: number) => (e: React.MouseEvent) => { const rankStyle = TOP_RANK_STYLES[rankIndex]; @@ -54,6 +60,8 @@ export function UserTopList({ [], ); + const isSnapshotEnabled = useSnapshotShare(); + return ( {items?.map((item, i) => ( @@ -88,6 +96,30 @@ export function UserTopList({ }} allowSubscribe={false} /> + {isSnapshotEnabled && ( + + } + className="ml-auto opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100" + filename={`daily-dev-rank-${i + 1}`} + size={ButtonSize.XSmall} + variant={ButtonVariant.Float} + /> + )} ))} {leaderboardType && ( diff --git a/packages/shared/src/components/modals/streaks/NewStreakModal.tsx b/packages/shared/src/components/modals/streaks/NewStreakModal.tsx index 44f8aa841f0..953938735d2 100644 --- a/packages/shared/src/components/modals/streaks/NewStreakModal.tsx +++ b/packages/shared/src/components/modals/streaks/NewStreakModal.tsx @@ -5,7 +5,11 @@ import { useQueryClient } from '@tanstack/react-query'; import { Modal } from '../common/Modal'; import classed from '../../../lib/classed'; import { Checkbox } from '../../fields/Checkbox'; +import { ButtonVariant } from '../../buttons/Button'; import { ModalClose } from '../common/ModalClose'; +import { SnapshotButton } from '../../../features/snapshot/SnapshotButton'; +import { useSnapshotShare } from '../../../features/snapshot/useSnapshotShare'; +import { StreakSnapshotCard } from '../../../features/snapshot/StreakSnapshotCard'; import { cloudinaryStreakSplash, cloudinaryStreakFire, @@ -68,6 +72,8 @@ export default function NewStreakModal({ } }; + const isSnapshotEnabled = useSnapshotShare(); + return ( Protect your streak with streak freezes + {isSnapshotEnabled && user && ( + + } + className="mt-6" + filename={`daily-dev-streak-${currentStreak}`} + label + variant={ButtonVariant.Primary} + /> + )} -
+
+ {isSnapshotEnabled && isUnlocked && ( + + } + filename={`daily-dev-achievement-${achievement.name}`} + size={ButtonSize.XSmall} + /> + )} , +): 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/LeaderboardSnapshotCard.tsx b/packages/shared/src/features/snapshot/LeaderboardSnapshotCard.tsx new file mode 100644 index 00000000000..8ca17b70a14 --- /dev/null +++ b/packages/shared/src/features/snapshot/LeaderboardSnapshotCard.tsx @@ -0,0 +1,131 @@ +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'; +import { SnapshotLevelRing } from './SnapshotLevelRing'; + +const MUTED = colors.salt['90']; +const DIVIDER = colors.pepper['10']; + +/** Gold, silver and bronze, matching the leaderboard's own top-rank palette. */ +const RANK_COLORS = [ + colors.cheese['40'], + colors.salt['90'], + colors.bacon['40'], +]; + +export interface LeaderboardSnapshotCardProps { + board: string; + rank: number; + name: string; + handle: string; + image?: string; + score: number; + level: number; + levelProgress: number; + reputation: number; + seed?: string; +} + +function LeaderboardSnapshotCardComponent( + { + board, + rank, + name, + handle, + image, + score, + level, + levelProgress, + reputation, + seed, + }: LeaderboardSnapshotCardProps, + ref: React.Ref, +): ReactElement { + const rankColor = RANK_COLORS[rank - 1] ?? colors.cabbage['10']; + + return ( + +
+
+ + #{rank} + + + {board} + +
+ + {image && ( + + )} + +
+ + {name} + + {handle} +
+ + + + {largeNumberFormat(score) ?? score} + + } + /> + } + /> + + {largeNumberFormat(reputation) ?? reputation} + + } + /> + +
+
+ ); +} + +export const LeaderboardSnapshotCard = forwardRef( + LeaderboardSnapshotCardComponent, +); diff --git a/packages/shared/src/features/snapshot/SnapshotButton.tsx b/packages/shared/src/features/snapshot/SnapshotButton.tsx new file mode 100644 index 00000000000..567981bd286 --- /dev/null +++ b/packages/shared/src/features/snapshot/SnapshotButton.tsx @@ -0,0 +1,77 @@ +import type { ReactElement, ReactNode } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '../../components/buttons/Button'; +import { SnapshotIcon } from '../../components/icons'; +import { useSnapshotCapture } from './useSnapshotCapture'; + +export interface SnapshotButtonProps { + /** The designed square card to rasterize, from this module. */ + card: ReactNode; + /** Basename of the saved PNG, without the extension. */ + filename: string; + className?: string; + /** Renders the label beside the icon; icon-only without it. */ + label?: boolean; + size?: ButtonSize; + variant?: ButtonVariant; +} + +export const SnapshotButton = ({ + card, + filename, + className, + label, + size = ButtonSize.Small, + variant = ButtonVariant.Tertiary, +}: SnapshotButtonProps): ReactElement => { + // The 1080px card only mounts once someone asks, so a feed of achievement + // cards does not carry one render per row. + const [isArmed, setIsArmed] = useState(false); + const isPending = useRef(false); + const { offScreenCard, shareImage, status } = useSnapshotCapture({ + card, + filename, + isActive: isArmed, + }); + + // The card rasterizes after it mounts, so the press arms the capture and the + // share fires on the render that reports it ready. + useEffect(() => { + if (!isPending.current || status !== 'ready') { + return; + } + + isPending.current = false; + shareImage(); + }, [status, shareImage]); + + return ( + <> + + {offScreenCard} + + ); +}; 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/SnapshotLevelRing.tsx b/packages/shared/src/features/snapshot/SnapshotLevelRing.tsx new file mode 100644 index 00000000000..948a6a83bd3 --- /dev/null +++ b/packages/shared/src/features/snapshot/SnapshotLevelRing.tsx @@ -0,0 +1,64 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import colors from '../../styles/colors'; +import { SNAPSHOT_STAT_HEIGHT } from './SnapshotStats'; + +interface SnapshotLevelRingProps { + level: number; + progress: number; + size?: number; + stroke?: number; + fontSize?: number; +} + +export function SnapshotLevelRing({ + level, + progress, + size = SNAPSHOT_STAT_HEIGHT, + stroke = 10, + fontSize = 40, +}: SnapshotLevelRingProps): ReactElement { + const radius = (size - stroke) / 2; + const circumference = 2 * Math.PI * radius; + const safeProgress = Math.max(0, Math.min(progress, 100)); + + return ( + + + + + + + {level} + + + ); +} 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/StreakSnapshotCard.tsx b/packages/shared/src/features/snapshot/StreakSnapshotCard.tsx new file mode 100644 index 00000000000..82b9f109a53 --- /dev/null +++ b/packages/shared/src/features/snapshot/StreakSnapshotCard.tsx @@ -0,0 +1,85 @@ +import type { ReactElement } from 'react'; +import React, { forwardRef } from 'react'; +import colors from '../../styles/colors'; +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 StreakSnapshotCardProps { + user: SnapshotIdentityProps; + days: number; + milestone?: string; + longestStreak: number; + totalReadingDays: number; + seed?: string; +} + +function StreakSnapshotCardComponent( + { + user, + days, + milestone, + longestStreak, + totalReadingDays, + seed, + }: StreakSnapshotCardProps, + ref: React.Ref, +): ReactElement { + return ( + } + ref={ref} + seed={seed ?? `streak-${days}`} + watermark="🔥" + > +
+ + +
+ + {days} + + + day reading streak + + {milestone && ( + + {milestone} + + )} +
+ +
+ + Longest streak {longestStreak} + + + Total reading days {totalReadingDays} + +
+
+
+ ); +} + +export const StreakSnapshotCard = forwardRef(StreakSnapshotCardComponent); 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/features/snapshot/useSnapshotShare.ts b/packages/shared/src/features/snapshot/useSnapshotShare.ts new file mode 100644 index 00000000000..2583e166241 --- /dev/null +++ b/packages/shared/src/features/snapshot/useSnapshotShare.ts @@ -0,0 +1,15 @@ +import { useConditionalFeature } from '../../hooks/useConditionalFeature'; +import { featureSnapshotShare } from '../../lib/featureManagement'; + +/** + * Gates the Snapshot control. `shouldEvaluate` keeps a viewer out of the + * experiment until the surface carrying the control would actually render. + */ +export const useSnapshotShare = (shouldEvaluate = true): boolean => { + const { value } = useConditionalFeature({ + feature: featureSnapshotShare, + shouldEvaluate, + }); + + return value; +}; diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts index 19040d3eff0..4aa46f28fad 100644 --- a/packages/shared/src/lib/featureManagement.ts +++ b/packages/shared/src/lib/featureManagement.ts @@ -58,6 +58,10 @@ export const featurePlusCtaCopy = new Feature('plus_cta_copy', { }); export const featureLuckyButton = new Feature('lucky_button', false); +// Experiment: a Snapshot control on the status moments that have no page to +// link to — the streak popup, an unlocked achievement, a leaderboard row, an +// award received, and a post's analytics. Control hides it entirely. +export const featureSnapshotShare = new Feature('snapshot_share', false); export const featureJobsUI = new Feature('jobs_ui', false); diff --git a/packages/shared/src/lib/imageShare/captureShareImage.ts b/packages/shared/src/lib/imageShare/captureShareImage.ts new file mode 100644 index 00000000000..0365ec1b77e --- /dev/null +++ b/packages/shared/src/lib/imageShare/captureShareImage.ts @@ -0,0 +1,207 @@ +import type { RefObject } from 'react'; +import { createElement } from 'react'; +import type { SnapdomOptions } from '@zumer/snapdom'; +import LogoIcon from '../../svg/LogoIcon'; +import LogoText from '../../svg/LogoText'; + +export const SHARE_IMAGE_WIDTH = 1200; +export const SHARE_IMAGE_HEIGHT = 630; + +const LOGO_BAR_HEIGHT = 72; +const LOGO_BAR_BORDER = 2; +const LOGO_HEIGHT = 26; +const LOGO_GAP = 8; +const LOGO_ICON_RATIO = 35 / 20; +const LOGO_TEXT_RATIO = 77 / 20; + +export type CaptureTarget = HTMLElement | RefObject; + +export interface CaptureShareImageOptions extends SnapdomOptions { + width?: number; + height?: number; + padding?: number; + frameBackgroundColor?: string; + branded?: boolean; +} + +const TRANSPARENT = 'rgba(0, 0, 0, 0)'; +const CAPTURE_TIMEOUT_MS = 15000; + +// A cross-origin image without CORS headers leaves snapdom's inliner pending +// forever, which would otherwise spin the trigger button indefinitely. +const withTimeout = (promise: Promise): Promise => + Promise.race([ + promise, + new Promise((_, reject) => { + setTimeout( + () => reject(new Error('captureShareImage: capture timed out')), + CAPTURE_TIMEOUT_MS, + ); + }), + ]); + +const resolveFrameBackground = (): string => { + const rootStyle = getComputedStyle(document.documentElement); + const rootBackground = rootStyle.backgroundColor; + + if (rootBackground && rootBackground !== TRANSPARENT) { + return rootBackground; + } + + const themeBackground = rootStyle + .getPropertyValue('--theme-background-default') + .trim(); + + if (themeBackground) { + return themeBackground; + } + + return getComputedStyle(document.body).backgroundColor; +}; + +const svgToImage = async (markup: string): Promise => { + const image = new Image(); + image.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(markup)}`; + await image.decode(); + + return image; +}; + +const drawLogoBar = async ( + context: CanvasRenderingContext2D, + canvasWidth: number, + canvasHeight: number, +): Promise => { + const { renderToStaticMarkup } = await import('react-dom/server'); + const rootStyle = getComputedStyle(document.documentElement); + const themeColor = rootStyle.getPropertyValue('--theme-text-primary').trim(); + const color = themeColor || getComputedStyle(document.body).color; + const barBackground = rootStyle + .getPropertyValue('--theme-background-default') + .trim(); + const barBorder = rootStyle + .getPropertyValue('--theme-border-subtlest-tertiary') + .trim(); + + const barTop = canvasHeight - LOGO_BAR_HEIGHT; + + if (barBackground) { + context.fillStyle = barBackground; + context.fillRect(0, barTop, canvasWidth, LOGO_BAR_HEIGHT); + } + + if (barBorder) { + context.fillStyle = barBorder; + context.fillRect(0, barTop, canvasWidth, LOGO_BAR_BORDER); + } + + const toSizedMarkup = (markup: string, width: number): string => + markup + .replace(' { + const element = target instanceof HTMLElement ? target : target.current; + + if (!element) { + throw new Error('captureShareImage: target element is not mounted'); + } + + const { + width = SHARE_IMAGE_WIDTH, + height = SHARE_IMAGE_HEIGHT, + padding = 48, + frameBackgroundColor, + branded = true, + ...snapOptions + } = options; + const barHeight = branded ? LOGO_BAR_HEIGHT : 0; + const contentWidth = width - padding * 2; + const contentHeight = height - padding * 2 - barHeight; + + const rect = element.getBoundingClientRect(); + + if (!rect.width || !rect.height) { + throw new Error('captureShareImage: target element has no size'); + } + + const fitScale = Math.min( + contentWidth / rect.width, + contentHeight / rect.height, + ); + const captureScale = Math.max(1, fitScale); + + const { snapdom } = await import('@zumer/snapdom'); + const result = await withTimeout( + snapdom(element, { + embedFonts: true, + scale: captureScale, + ...snapOptions, + }), + ); + const source = await result.toCanvas(); + + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + const context = canvas.getContext('2d'); + + if (!context) { + throw new Error('captureShareImage: canvas 2d context unavailable'); + } + + context.fillStyle = frameBackgroundColor ?? resolveFrameBackground(); + context.fillRect(0, 0, canvas.width, canvas.height); + + const drawScale = Math.min( + contentWidth / source.width, + contentHeight / source.height, + ); + const drawWidth = source.width * drawScale; + const drawHeight = source.height * drawScale; + + context.imageSmoothingQuality = 'high'; + context.drawImage( + source, + (canvas.width - drawWidth) / 2, + padding + (contentHeight - drawHeight) / 2, + drawWidth, + drawHeight, + ); + + if (branded) { + await drawLogoBar(context, width, height); + } + + return new Promise((resolve, reject) => { + canvas.toBlob((blob) => { + if (blob) { + resolve(blob); + } else { + reject(new Error('captureShareImage: failed to encode PNG')); + } + }, 'image/png'); + }); +} diff --git a/packages/shared/src/lib/imageShare/copyShareImage.ts b/packages/shared/src/lib/imageShare/copyShareImage.ts new file mode 100644 index 00000000000..a712696ceef --- /dev/null +++ b/packages/shared/src/lib/imageShare/copyShareImage.ts @@ -0,0 +1,19 @@ +/** + * Puts the PNG on the clipboard so it can be pasted straight into a chat or a + * composer. Safari only honours a clipboard write inside the task that handled + * the gesture, so the blob is handed over as a promise rather than awaited + * first — `ClipboardItem` resolves it without losing the gesture. + */ +export async function copyShareImage(blob: Promise): Promise { + if (typeof ClipboardItem === 'undefined' || !navigator.clipboard?.write) { + return false; + } + + try { + await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]); + + return true; + } catch { + return false; + } +} diff --git a/packages/shared/src/lib/imageShare/downloadShareImage.ts b/packages/shared/src/lib/imageShare/downloadShareImage.ts new file mode 100644 index 00000000000..e4d411d267d --- /dev/null +++ b/packages/shared/src/lib/imageShare/downloadShareImage.ts @@ -0,0 +1,10 @@ +export function downloadShareImage(blob: Blob, filename: string): void { + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = `${filename}.png`; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + URL.revokeObjectURL(url); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7126de99fe4..16e236d8ccb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -447,6 +447,9 @@ importers: '@tiptap/starter-kit': specifier: ^3.22.5 version: 3.22.5 + '@zumer/snapdom': + specifier: ^2.23.1 + version: 2.24.15 border-beam: specifier: 1.3.0 version: 1.3.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -1124,7 +1127,7 @@ importers: dependencies: '@dailydotdev/world-kit': specifier: 0.1.1 - version: link:../world-kit + version: 0.1.1 packages/world-kit: {} @@ -1900,6 +1903,9 @@ packages: peerDependencies: postcss-selector-parser: ^7.0.0 + '@dailydotdev/world-kit@0.1.1': + resolution: {integrity: sha512-t5pzFaCP5vbh7rjAb+lZ4L/wwSAwEVNFvHEfiL42nHBa/lOuFoMBQPTldEKuBW1mQlSAl3q4bh0ZQezpHhn6cA==} + '@discoveryjs/json-ext@0.5.7': resolution: {integrity: sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==} engines: {node: '>=10.0.0'} @@ -4834,6 +4840,9 @@ packages: '@xtuc/long@4.2.2': resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==} + '@zumer/snapdom@2.24.15': + resolution: {integrity: sha512-4YE+3ekbBFEAxMyHr++wxOSGt/k+n721eeNT9N9Map27A+ra5sBut3qkYyheYlRj9dJ3HtZaUN1/yrh/aCiuKw==} + abab@2.0.6: resolution: {integrity: sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==} deprecated: Use your platform's native atob() and btoa() methods instead @@ -11390,6 +11399,8 @@ snapshots: dependencies: postcss-selector-parser: 7.0.0 + '@dailydotdev/world-kit@0.1.1': {} + '@discoveryjs/json-ext@0.5.7': {} '@dnd-kit/accessibility@3.1.1(react@18.3.1)': @@ -14226,6 +14237,8 @@ snapshots: '@xtuc/long@4.2.2': {} + '@zumer/snapdom@2.24.15': {} + abab@2.0.6: {} accepts@1.3.8: