From 04c5cd99b064faa1c98bea3ab2fd1eb6d480a330 Mon Sep 17 00:00:00 2001 From: tomeredlich Date: Wed, 2 Sep 2026 17:36:58 +0300 Subject: [PATCH 01/30] feat(shared): capture an element as a branded share image Adds a Snapshot control that rasterizes any element with snapdom, fits it inside a 1200x630 frame on the current theme's background and draws the daily.dev logo bar. The PNG goes to the clipboard, because a paste beats a file in Downloads for every place we share to, and falls back to a download where ClipboardItem is unavailable. A cross-origin image without CORS headers leaves snapdom's inliner pending forever, so the capture times out rather than spinning the button. Co-Authored-By: Claude Opus 5 --- packages/shared/package.json | 1 + .../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 | 110 ++++++++++ .../components/imageShare/SnapshotButton.tsx | 126 +++++++++++ .../src/features/snapshot/shutterSound.ts | 23 ++ .../src/lib/imageShare/captureShareImage.ts | 207 ++++++++++++++++++ .../src/lib/imageShare/copyShareImage.ts | 19 ++ .../src/lib/imageShare/downloadShareImage.ts | 10 + packages/shared/src/styles/utilities.css | 42 ++++ packages/webapp/public/sounds/shutter.mp3 | Bin 0 -> 45824 bytes pnpm-lock.yaml | 15 +- 14 files changed, 587 insertions(+), 1 deletion(-) 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/components/imageShare/SnapshotButton.spec.tsx create mode 100644 packages/shared/src/components/imageShare/SnapshotButton.tsx create mode 100644 packages/shared/src/features/snapshot/shutterSound.ts create mode 100644 packages/shared/src/lib/imageShare/captureShareImage.ts create mode 100644 packages/shared/src/lib/imageShare/copyShareImage.ts create mode 100644 packages/shared/src/lib/imageShare/downloadShareImage.ts create mode 100644 packages/webapp/public/sounds/shutter.mp3 diff --git a/packages/shared/package.json b/packages/shared/package.json index 00e6ef543b4..02c0664bf98 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -125,6 +125,7 @@ "@tiptap/extension-placeholder": "^3.22.5", "@tiptap/react": "^3.22.5", "@tiptap/starter-kit": "^3.22.5", + "@zumer/snapdom": "^2.23.1", "border-beam": "1.3.0", "check-password-strength": "^2.0.10", "cmdk": "^1.0.0", 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 new file mode 100644 index 00000000000..57c00d50959 --- /dev/null +++ b/packages/shared/src/components/imageShare/SnapshotButton.spec.tsx @@ -0,0 +1,110 @@ +import React from 'react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +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), +})); + +jest.mock('../../lib/imageShare/copyShareImage', () => ({ + copyShareImage: (...args: unknown[]) => mockCopy(...args), +})); + +jest.mock('../../lib/imageShare/downloadShareImage', () => ({ + downloadShareImage: (...args: unknown[]) => mockDownload(...args), +})); + +jest.mock('../../features/snapshot/shutterSound', () => ({ + playShutterSound: jest.fn(), +})); + +jest.mock('../../hooks/useToastNotification', () => ({ + useToastNotification: () => ({ displayToast: mockDisplayToast }), + ToastType: { Success: 'success', Error: 'error' }, +})); + +jest.mock('../../hooks/useRequestProtocol', () => ({ + useRequestProtocol: () => ({ isCompanion: false }), +})); + +const blob = new Blob(['png'], { type: 'image/png' }); + +const renderComponent = (props = {}) => { + const target = document.createElement('div'); + + return render( + , + ); +}; + +const clickSnapshot = () => + fireEvent.click(screen.getByLabelText('Snapshot'), { + preventDefault: jest.fn(), + }); + +beforeEach(() => { + jest.clearAllMocks(); + mockCapture.mockResolvedValue(blob); +}); + +it('copies the image and says so', async () => { + mockCopy.mockResolvedValue(true); + renderComponent(); + + clickSnapshot(); + + await waitFor(() => + expect(mockDisplayToast).toHaveBeenCalledWith('Image copied', { + variant: 'success', + }), + ); + expect(mockDownload).not.toHaveBeenCalled(); +}); + +it('falls back to a download when the clipboard is unavailable', async () => { + mockCopy.mockResolvedValue(false); + renderComponent({ filename: 'daily-profile-tomer' }); + + clickSnapshot(); + + await waitFor(() => + expect(mockDownload).toHaveBeenCalledWith(blob, 'daily-profile-tomer'), + ); + expect(mockDisplayToast).toHaveBeenCalledWith('Image saved', { + variant: 'success', + }); +}); + +it('reports a failed capture instead of copying or downloading', async () => { + mockCapture.mockRejectedValue(new Error('target element has no size')); + mockCopy.mockResolvedValue(false); + renderComponent(); + + clickSnapshot(); + + await waitFor(() => + expect(mockDisplayToast).toHaveBeenCalledWith( + 'Could not create the snapshot, please try again', + { variant: 'error' }, + ), + ); + expect(mockDownload).not.toHaveBeenCalled(); +}); + +it('hands the blob to onCapture instead of sharing it', async () => { + const onCapture = jest.fn(); + mockCopy.mockResolvedValue(true); + renderComponent({ onCapture }); + + clickSnapshot(); + + await waitFor(() => expect(onCapture).toHaveBeenCalledWith(blob)); + expect(mockCopy).not.toHaveBeenCalled(); + expect(mockDownload).not.toHaveBeenCalled(); + expect(mockDisplayToast).not.toHaveBeenCalled(); +}); diff --git a/packages/shared/src/components/imageShare/SnapshotButton.tsx b/packages/shared/src/components/imageShare/SnapshotButton.tsx new file mode 100644 index 00000000000..e9a8e000d7d --- /dev/null +++ b/packages/shared/src/components/imageShare/SnapshotButton.tsx @@ -0,0 +1,126 @@ +import type { ReactElement } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import classNames from 'classnames'; +import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; +import { SnapshotIcon } from '../icons'; +import { Tooltip } from '../tooltip/Tooltip'; +import { + ToastType, + useToastNotification, +} from '../../hooks/useToastNotification'; +import type { + CaptureShareImageOptions, + CaptureTarget, +} from '../../lib/imageShare/captureShareImage'; +import { captureShareImage } from '../../lib/imageShare/captureShareImage'; +import { downloadShareImage } from '../../lib/imageShare/downloadShareImage'; +import { copyShareImage } from '../../lib/imageShare/copyShareImage'; +import { playShutterSound } from '../../features/snapshot/shutterSound'; + +const SNAPSHOT_LABEL = 'Snapshot'; + +/** Matches the snapshot-shutter-sweep animation in utilities.css. */ +const SHUTTER_SWEEP_MS = 380; + +export interface SnapshotButtonProps { + target: CaptureTarget; + filename?: string; + label?: string; + showLabel?: boolean; + size?: ButtonSize; + variant?: ButtonVariant; + className?: string; + captureOptions?: CaptureShareImageOptions; + onCapture?: (blob: Blob) => void; +} + +export function SnapshotButton({ + target, + filename = 'daily-snapshot', + label = SNAPSHOT_LABEL, + showLabel = true, + captureOptions, + onCapture, + size = ButtonSize.Small, + variant = ButtonVariant.Tertiary, + className, +}: SnapshotButtonProps): ReactElement { + const { displayToast } = useToastNotification(); + const [isCapturing, setIsCapturing] = useState(false); + const [isFlashing, setIsFlashing] = useState(false); + const flashTimeout = useRef>(); + + useEffect( + () => () => { + if (flashTimeout.current) { + clearTimeout(flashTimeout.current); + } + }, + [], + ); + + 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); + + try { + const capture = captureShareImage(target, captureOptions); + + if (onCapture) { + onCapture(await capture); + return; + } + + // Pasting beats a file in Downloads for every target we share to, so + // the clipboard leads and the download is the fallback. + if (await copyShareImage(capture)) { + displayToast('Image copied', { variant: ToastType.Success }); + return; + } + + downloadShareImage(await capture, filename); + displayToast('Image saved', { variant: ToastType.Success }); + } catch { + displayToast('Could not create the snapshot, please try again', { + variant: ToastType.Error, + }); + } finally { + setIsCapturing(false); + } + }, + [captureOptions, displayToast, filename, onCapture, target], + ); + + return ( + + + + ); +} diff --git a/packages/shared/src/features/snapshot/shutterSound.ts b/packages/shared/src/features/snapshot/shutterSound.ts new file mode 100644 index 00000000000..ac00c91412d --- /dev/null +++ b/packages/shared/src/features/snapshot/shutterSound.ts @@ -0,0 +1,23 @@ +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/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/packages/shared/src/styles/utilities.css b/packages/shared/src/styles/utilities.css index 8a391c46a3e..dd39dc9678b 100644 --- a/packages/shared/src/styles/utilities.css +++ b/packages/shared/src/styles/utilities.css @@ -1163,3 +1163,45 @@ img.agent-media-ring { panel, hanging off the right edge. These re-run the card's own mobile rules 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; + } +} diff --git a/packages/webapp/public/sounds/shutter.mp3 b/packages/webapp/public/sounds/shutter.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..f49b95f152c6d13f7a411f01abb94bab8b734be3 GIT binary patch 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 literal 0 HcmV?d00001 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7126de99fe4..41be8eddbbe 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.10 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.10': + resolution: {integrity: sha512-yK+5HvcP96aZCG8dcOuJDsOD1TACDeSTI0wlsmQkMeeGaM/JVHdBQZPS4h0Uae6bY/zx+WQCJtOKnrbB6D7NgQ==} + 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.10': {} + abab@2.0.6: {} accepts@1.3.8: From a9805fd2a34999b4106e530d92b50b9c203ea575 Mon Sep 17 00:00:00 2001 From: tomeredlich Date: Wed, 2 Sep 2026 17:37:06 +0300 Subject: [PATCH 02/30] feat(profile): snapshot the header, its widgets and achievements Five placements: the header action row beside edit, the Reading Overview, Badges & Awards and Achievements widget headers, and each achievement card on hover or keyboard focus. The achievement card's control sits out of flow. In flow it took 28px from the middle column and pushed long names into an ellipsis to reserve room for a button that is invisible until hover. It is positioned from a wrapper element because SnapshotButton sets `relative` on itself, which beats an `absolute` passed through className. Co-Authored-By: Claude Opus 5 --- .../src/components/profile/ProfileHeader.tsx | 19 ++++++++++-- .../ProfileWidgets/AchievementsWidget.tsx | 23 +++++++++----- .../ProfileWidgets/BadgesAndAwards.tsx | 31 ++++++++++++------- .../ProfileWidgets/ReadingOverview.tsx | 31 ++++++++++++------- .../achievements/AchievementCard.tsx | 20 ++++++++++-- 5 files changed, 89 insertions(+), 35 deletions(-) diff --git a/packages/shared/src/components/profile/ProfileHeader.tsx b/packages/shared/src/components/profile/ProfileHeader.tsx index 3061b34c3bf..35588752c53 100644 --- a/packages/shared/src/components/profile/ProfileHeader.tsx +++ b/packages/shared/src/components/profile/ProfileHeader.tsx @@ -1,5 +1,5 @@ import type { ReactNode } from 'react'; -import React from 'react'; +import React, { useRef } from 'react'; import dynamic from 'next/dynamic'; import classNames from 'classnames'; import { Image } from '../image/Image'; @@ -14,7 +14,7 @@ import type { UserStatsProps } from './UserStats'; import { UserStats } from './UserStats'; import JoinedDate from './JoinedDate'; import { Separator } from '../cards/common/common'; -import { Button, ButtonVariant } from '../buttons/Button'; +import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; import { webappUrl } from '../../lib/constants'; import Link from '../utilities/Link'; import { useAuthContext } from '../../contexts/AuthContext'; @@ -24,6 +24,7 @@ import { locationToString } from '../../lib/utils'; import { IconSize } from '../Icon'; import { fallbackImages } from '../../lib/config'; import { ProfileDesktopPwaBackButton } from './ProfileBackButton'; +import { SnapshotButton } from '../imageShare/SnapshotButton'; import { ElementPlaceholder } from '../ElementPlaceholder'; @@ -67,9 +68,13 @@ 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); return ( -
+
Cover @@ -100,6 +105,14 @@ const ProfileHeader = ({ aria-label="Edit profile" /> + {actions}
diff --git a/packages/shared/src/features/profile/components/ProfileWidgets/AchievementsWidget.tsx b/packages/shared/src/features/profile/components/ProfileWidgets/AchievementsWidget.tsx index 2e246ff8c46..4bdd131a459 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 from 'react'; +import React, { useRef } from 'react'; import classNames from 'classnames'; import Link from '../../../../components/utilities/Link'; import { ActivityContainer } from '../../../../components/profile/ActivitySection'; @@ -21,6 +21,7 @@ import { import { RaritySparkles } from '../achievements/RaritySparkles'; import HoverCard from '../../../../components/cards/common/HoverCard'; import { AchievementCard } from '../achievements/AchievementCard'; +import { SnapshotButton } from '../../../../components/imageShare/SnapshotButton'; interface AchievementsWidgetProps { user: PublicProfile; @@ -134,9 +135,10 @@ export function AchievementsWidget({ user, }: AchievementsWidgetProps): ReactElement { const { unlockedCount, totalCount } = useProfileAchievements(user); + const widgetRef = useRef(null); return ( - +
Achievements - - - {unlockedCount}/{totalCount} - - +
+ + + {unlockedCount}/{totalCount} + + + +
diff --git a/packages/shared/src/features/profile/components/ProfileWidgets/BadgesAndAwards.tsx b/packages/shared/src/features/profile/components/ProfileWidgets/BadgesAndAwards.tsx index 6d800232861..bf3b83a8114 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 from 'react'; +import React, { useRef } from 'react'; import { useQuery } from '@tanstack/react-query'; import { ActivityContainer } from '../../../../components/profile/ActivitySection'; import { topReaderBadgeDocs } from '../../../../lib/constants'; @@ -24,12 +24,14 @@ import { BadgesAndAwardsSkeleton, } from './BadgesAndAwardsComponents'; import { anchorDefaultRel } from '../../../../lib/strings'; +import { SnapshotButton } from '../../../../components/imageShare/SnapshotButton'; export const BadgesAndAwards = ({ user, }: { user: PublicProfile; }): ReactElement | null => { + const widgetRef = useRef(null); const { data: topReaders, isPending: isTopReaderLoading } = useTopReader({ user, limit: 5, @@ -62,16 +64,23 @@ export const BadgesAndAwards = ({ awards?.reduce((sum, award) => sum + (award?.count || 0), 0) ?? 0; return ( - - - Badges & Awards - + +
+ + Badges & Awards + + +
value.reads; @@ -66,6 +67,7 @@ export function ReadingOverview({ mostReadTags, isLoading = false, }: ReadingOverviewProps): ReactElement { + const widgetRef = useRef(null); const totalReads = useMemo(() => { if (!readHistory?.length) { return 0; @@ -81,16 +83,23 @@ export function ReadingOverview({ } return ( - - - Reading Overview - + +
+ + Reading Overview + + +
(null); const { achievement, progress, unlockedAt } = userAchievement; const targetCount = getTargetCount(achievement); const isUnlocked = unlockedAt !== null; @@ -64,8 +66,9 @@ 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. */} + + + Date: Wed, 2 Sep 2026 17:41:53 +0300 Subject: [PATCH 03/30] feat(profile): copy link in the header, and lead the DevCard with share MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The header gains a copy-link control beside snapshot, matched to the buttons already there at Medium Float: sharing a profile is for getting followed, and an image cannot be followed. It reuses the existing ShareProfile event, so the header stops being a blind spot beside the ⋯ menu's Share. The DevCard flips its default from private save to public post. Download keeps its place at Float; Share leads at Primary, opening the native sheet on mobile and copying the link on desktop, under the ShareDevcard event that already existed and had no caller. Co-Authored-By: Claude Opus 5 --- .../src/components/profile/ProfileHeader.tsx | 28 ++++++++++- .../Customization/DevCard/DevCardStep2.tsx | 46 ++++++++++++++----- 2 files changed, 61 insertions(+), 13 deletions(-) diff --git a/packages/shared/src/components/profile/ProfileHeader.tsx b/packages/shared/src/components/profile/ProfileHeader.tsx index 35588752c53..ea318b689a1 100644 --- a/packages/shared/src/components/profile/ProfileHeader.tsx +++ b/packages/shared/src/components/profile/ProfileHeader.tsx @@ -8,7 +8,7 @@ import { TypographyColor, TypographyType, } from '../typography/Typography'; -import { DevPlusIcon, EditIcon } from '../icons'; +import { DevPlusIcon, EditIcon, LinkIcon } from '../icons'; import type { PublicProfile } from '../../lib/user'; import type { UserStatsProps } from './UserStats'; import { UserStats } from './UserStats'; @@ -25,6 +25,11 @@ import { IconSize } from '../Icon'; import { fallbackImages } from '../../lib/config'; import { ProfileDesktopPwaBackButton } from './ProfileBackButton'; import { SnapshotButton } from '../imageShare/SnapshotButton'; +import { Tooltip } from '../tooltip/Tooltip'; +import { useCopyLink } from '../../hooks/useCopy'; +import { useLogContext } from '../../contexts/LogContext'; +import { LogEvent, TargetType } from '../../lib/log'; +import { ShareProvider } from '../../lib/share'; import { ElementPlaceholder } from '../ElementPlaceholder'; @@ -69,6 +74,18 @@ const ProfileHeader = ({ const { user: loggedUser } = useAuthContext(); const isSameUser = propIsSameUser ?? loggedUser?.id === user.id; const headerRef = useRef(null); + const { logEvent } = useLogContext(); + const [isCopying, copyLink] = useCopyLink(() => user.permalink); + + const onCopyLink = () => { + copyLink(); + logEvent({ + event_name: LogEvent.ShareProfile, + target_type: TargetType.ProfilePage, + target_id: user.id, + extra: JSON.stringify({ provider: ShareProvider.CopyLink }), + }); + }; return (
+ +
diff --git a/packages/webapp/components/layouts/SettingsLayout/Customization/DevCard/DevCardStep2.tsx b/packages/webapp/components/layouts/SettingsLayout/Customization/DevCard/DevCardStep2.tsx index f386d178e2e..080ca472072 100644 --- a/packages/webapp/components/layouts/SettingsLayout/Customization/DevCard/DevCardStep2.tsx +++ b/packages/webapp/components/layouts/SettingsLayout/Customization/DevCard/DevCardStep2.tsx @@ -15,6 +15,7 @@ import { useViewSize, ViewSize } from '@dailydotdev/shared/src/hooks'; import type { DevCardQueryData } from '@dailydotdev/shared/src/hooks/profile/useDevCard'; import { useDevCard } from '@dailydotdev/shared/src/hooks/profile/useDevCard'; import { useCopyLink } from '@dailydotdev/shared/src/hooks/useCopy'; +import { useShareOrCopyLink } from '@dailydotdev/shared/src/hooks/useShareOrCopyLink'; import { downloadUrl } from '@dailydotdev/shared/src/lib/blob'; import { generateQueryKey, @@ -32,8 +33,10 @@ import { import { RadioItem } from '@dailydotdev/shared/src/components/fields/RadioItem'; import { IconSize } from '@dailydotdev/shared/src/components/Icon'; import { + DownloadIcon, GitHubIcon, OpenLinkIcon, + ShareIcon, TwitterIcon, } from '@dailydotdev/shared/src/components/icons'; import { DevCardFetchWrapper } from '@dailydotdev/shared/src/components/profile/devcard/DevCardFetchWrapper'; @@ -90,6 +93,14 @@ export const DevCardStep2 = ({ [user?.name, user?.username, devCardSrc, type], ); const [copyingEmbed, copyEmbed] = useCopyLink(() => embedCode); + const [sharing, onShareDevCard] = useShareOrCopyLink({ + link: user?.permalink ?? '', + text: 'Check out my #DevCard on daily.dev', + logObject: (provider) => ({ + event_name: LogEvent.ShareDevcard, + extra: JSON.stringify({ provider }), + }), + }); const [selectedTab, setSelectedTab] = useState(0); const { mutateAsync: onDownloadUrl, isPending: downloading } = useMutation({ mutationFn: downloadUrl, @@ -230,18 +241,29 @@ export const DevCardStep2 = ({
{!isNullOrUndefined(devcard) && ( - +
+ + +
)} From 3e9e5310d155ae0d52e8d3c8af968315644242db Mon Sep 17 00:00:00 2001 From: tomeredlich Date: Wed, 2 Sep 2026 17:41:53 +0300 Subject: [PATCH 04/30] docs(snapshot): add the profile surface page to Storybook The design page behind these controls: where each one sits, on desktop and mobile, against the alternatives that were rejected. Mockup-to-eng-pass: 1 Co-Authored-By: Claude Opus 5 --- .../features/snapshot/surfaceChrome.tsx | 230 +++++++++ .../snapshot/surfaces/Profile.stories.tsx | 435 ++++++++++++++++++ 2 files changed, 665 insertions(+) create mode 100644 packages/storybook/stories/features/snapshot/surfaceChrome.tsx create mode 100644 packages/storybook/stories/features/snapshot/surfaces/Profile.stories.tsx diff --git a/packages/storybook/stories/features/snapshot/surfaceChrome.tsx b/packages/storybook/stories/features/snapshot/surfaceChrome.tsx new file mode 100644 index 00000000000..3442fc3724c --- /dev/null +++ b/packages/storybook/stories/features/snapshot/surfaceChrome.tsx @@ -0,0 +1,230 @@ +import React from 'react'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { + LinkIcon, + ShareIcon, + SnapshotIcon, +} from '@dailydotdev/shared/src/components/icons'; + +export const AVATAR = + 'https://res.cloudinary.com/daily-now/image/upload/s--O0TOmw4y--/f_auto/v1715772965/public/noProfile'; + +/* ------------------------------------------------------------------ prose */ + +const H1 = ({ children }: { children: React.ReactNode }) => ( +

{children}

+); + +const P = ({ children }: { children: React.ReactNode }) => ( +

{children}

+); + +const Note = ({ children }: { children: React.ReactNode }) => ( +

+ {children} +

+); + +/* ---------------------------------------------------------------- controls */ + +type LeadAction = 'Link' | 'Share to' | 'Snapshot'; + +const ICONS: Record = { + Link: , + 'Share to': , + Snapshot: , +}; + +const LABELS: Record = { + Link: 'Copy link', + 'Share to': 'Share', + Snapshot: 'Snapshot', +}; + +/** + * Inert on purpose: this page compares where a control sits inside a real + * screen. The working buttons and live capture are on the profile itself. + */ +export const Control = ({ + action, + className, + label, + size = ButtonSize.Small, + variant = ButtonVariant.Tertiary, +}: { + action: LeadAction; + className?: string; + label?: boolean; + size?: ButtonSize; + variant?: ButtonVariant; +}) => ( + +); + +/* ---------------------------------------------------------- page furniture */ + +/** + * A real context menu. Every production menu in the product leads with a + * share item — "Share via" on posts and squads, "Share" on profiles and + * tags — and none of them offers "Copy link" directly, so the items are + * passed in rather than invented. + */ +export const OverflowMenu = ({ + items, + highlight, + className, +}: { + items: string[]; + /** The share item, whatever this surface actually calls it. */ + highlight?: string; + className?: string; +}) => ( +
+ {items.map((item) => { + const isShare = item === highlight; + + return ( + + {isShare && } + {item} + + ); + })} +
+); + +export type DeviceName = 'Desktop' | 'Tablet' | 'Mobile'; + +const DEVICES: Record< + DeviceName, + { width: number; viewport: string } +> = { + Desktop: { width: 680, viewport: '1020px and up' }, + Tablet: { width: 560, viewport: '768px' }, + Mobile: { width: 375, viewport: '375px' }, +}; + +/** A surface drawn at one real viewport width, so density is comparable. */ +export const Device = ({ + name, + children, + height, +}: { + name: DeviceName; + children: React.ReactNode; + /** Mobile surfaces pin a floating bar, so the frame needs a known height. */ + height?: number; +}) => ( +
+ + {name} · {DEVICES[name].viewport} + +
+ {children} +
+
+); + +/** Devices sit in a scroller rather than wrapping, so widths stay honest. */ +export const Rail = ({ children }: { children: React.ReactNode }) => ( +
+ {children} +
+); + +export const Variant = ({ + step, + headline, + note, + children, +}: { + step: string; + headline: string; + note: string; + children: React.ReactNode; +}) => ( + // Full width so a device rail can scroll across the whole canvas. +
+
+ + {step} + + + {headline} + + {note} +
+ {children} +
+); + +export const Category = ({ + title, + covers, + verdict, + children, +}: { + title: string; + covers: string; + verdict: string; + children: React.ReactNode; +}) => ( +
+
+

{title}

+ {covers} +

+ {verdict} +

+
+
{children}
+
+); + +/** Every category page opens with the same header, so they read as a set. */ +export const SurfacePage = ({ + title, + intro, + map, + children, +}: { + title: string; + intro: string; + map: string; + children: React.ReactNode; +}) => ( +
+
+

{title}

+

{intro}

+ {map} +
+ {children} +
+); diff --git a/packages/storybook/stories/features/snapshot/surfaces/Profile.stories.tsx b/packages/storybook/stories/features/snapshot/surfaces/Profile.stories.tsx new file mode 100644 index 00000000000..f45c55ac43c --- /dev/null +++ b/packages/storybook/stories/features/snapshot/surfaces/Profile.stories.tsx @@ -0,0 +1,435 @@ +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { + DownloadIcon, + EditIcon, + MedalBadgeIcon, + MenuIcon, + ReputationIcon, +} from '@dailydotdev/shared/src/components/icons'; +import type { DeviceName } from '../surfaceChrome'; +import { + AVATAR, + Category, + Control, + Device, + OverflowMenu, + Rail, + SurfacePage, + Variant, +} from '../surfaceChrome'; + +type Spot = 'today' | 'menu' | 'link' | 'lead'; + +/* ------------------------------------------------------------------ header */ + +const ProfileScreen = ({ + device, + spot, + visitor, +}: { + device: DeviceName; + spot: Spot; + visitor?: boolean; +}) => ( + +
+
+ + +
+
+
+ + + Tomer Redlich + + +
+

+ Building the feed developers actually read. +

+ Tel Aviv + + @tomer · Joined Jan 4. 2021 + + + {visitor && ( +
+ + +
+ )} + +
+ + + 1.2K Reputation + + + 3.4K Upvotes + + + 842 Followers + + + 61 Following + +
+
+
+
+ +); + +/* ----------------------------------------------------------------- widgets */ + +const SummaryCard = ({ count, label }: { count: string; label: string }) => ( +
+ {count} + {label} +
+); + +const WidgetHeader = ({ + title, + icon, + trailing, + snapshot, +}: { + title: string; + icon?: React.ReactNode; + trailing?: React.ReactNode; + snapshot: boolean; +}) => ( +
+

+ {icon} + {title} +

+
+ {trailing} + {snapshot && } +
+
+); + +const WidgetsScreen = ({ + device, + snapshot, +}: { + device: DeviceName; + snapshot: boolean; +}) => ( + +
+
+ + Learn more +
+ + +
+

+ Top tags by reading days +

+
+ {[ + ['#typescript', 82], + ['#react', 64], + ['#webdev', 41], + ['#css', 28], + ].map(([tag, pct]) => ( +
+ + + {tag} + +
+ ))} +
+

+ Posts read in the last months (3.4K) +

+
+ {Array.from({ length: 60 }, (_, i) => { + const level = Math.max( + 0, + Math.min(3, Math.round(2 + Math.sin(i / 4) * 1.4)), + ); + const tone = [ + 'bg-surface-float', + 'bg-overlay-float-cabbage', + 'bg-accent-cabbage-subtler', + 'bg-accent-cabbage-default', + ][level]; + + return ( + // eslint-disable-next-line react/no-array-index-key + + ); + })} +
+
+ +
+ + Learn more +
+ + +
+
+ {['#typescript', '#react'].map((tag) => ( + + 🥇 Top reader in {tag} + + ))} +
+
+ +
+ } + snapshot={snapshot} + title="Achievements" + trailing={12/40} + /> +
+ {["Can't spend it all", 'Big byte energy'].map((name) => ( +
+ +
+ + {name} + + + Unlocked 12 Aug 2026 + +
+ + 120 + +
+ ))} +
+
+ {device === 'Mobile' && mobile} +
+
+); + +/* ---------------------------------------------------------------- devcard */ + +const DevCardScreen = ({ lead }: { lead: boolean }) => ( + +
+ + Your DevCard is ready + +
+
+ + +
+
+ +); + +/* -------------------------------------------------------------------- page */ + +const Profile = () => ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +); + +const meta: Meta = { + title: 'Features/Snapshot/Surfaces/Profile', + component: Profile, + parameters: { layout: 'fullscreen' }, +}; + +export default meta; + +export const Variations: StoryObj = {}; From 6e7ab3e7e5cffbbbe4b2f259d150d2eb9a20bc9a Mon Sep 17 00:00:00 2001 From: tomeredlich Date: Thu, 3 Sep 2026 12:56:30 +0300 Subject: [PATCH 05/30] feat(profile): confirm the copy with an arrow, and show only what ships The header's copy link relied on the toast alone. It now swaps to the upvote button's filled avocado arrow and spins through the same curve, so the gesture that means "that worked" looks the same in both places. The Storybook page drew each of the three surfaces twice, before and after, plus a louder copy-link treatment we did not take. Only the shipped state remains, and the props that switched between states go with the halves they served. Co-Authored-By: Claude Opus 5 --- .../src/components/profile/ProfileHeader.tsx | 13 +- packages/shared/tailwind.config.ts | 9 ++ .../features/snapshot/surfaceChrome.tsx | 41 ----- .../snapshot/surfaces/Profile.stories.tsx | 149 +++--------------- 4 files changed, 40 insertions(+), 172 deletions(-) diff --git a/packages/shared/src/components/profile/ProfileHeader.tsx b/packages/shared/src/components/profile/ProfileHeader.tsx index ea318b689a1..1c764000d3b 100644 --- a/packages/shared/src/components/profile/ProfileHeader.tsx +++ b/packages/shared/src/components/profile/ProfileHeader.tsx @@ -8,7 +8,7 @@ import { TypographyColor, TypographyType, } from '../typography/Typography'; -import { DevPlusIcon, EditIcon, LinkIcon } from '../icons'; +import { DevPlusIcon, EditIcon, LinkIcon, UpvoteIcon } from '../icons'; import type { PublicProfile } from '../../lib/user'; import type { UserStatsProps } from './UserStats'; import { UserStats } from './UserStats'; @@ -133,7 +133,16 @@ const ProfileHeader = ({
@@ -90,34 +67,6 @@ const ProfileScreen = ({ @tomer · Joined Jan 4. 2021 - {visitor && ( -
- - -
- )} -
@@ -152,12 +101,10 @@ const WidgetHeader = ({ title, icon, trailing, - snapshot, }: { title: string; icon?: React.ReactNode; trailing?: React.ReactNode; - snapshot: boolean; }) => (

@@ -166,22 +113,16 @@ const WidgetHeader = ({

{trailing} - {snapshot && } +
); -const WidgetsScreen = ({ - device, - snapshot, -}: { - device: DeviceName; - snapshot: boolean; -}) => ( +const WidgetsScreen = ({ device }: { device: DeviceName }) => (
- + Learn more
@@ -236,7 +177,7 @@ const WidgetsScreen = ({
- + Learn more
@@ -257,7 +198,6 @@ const WidgetsScreen = ({
} - snapshot={snapshot} title="Achievements" trailing={12/40} /> @@ -290,7 +230,7 @@ const WidgetsScreen = ({ /* ---------------------------------------------------------------- devcard */ -const DevCardScreen = ({ lead }: { lead: boolean }) => ( +const DevCardScreen = () => (
@@ -301,14 +241,14 @@ const DevCardScreen = ({ lead }: { lead: boolean }) => (
@@ -326,46 +266,16 @@ const Profile = () => ( - - - - - - - - - - - - - - - - - - - - + + @@ -373,26 +283,16 @@ const Profile = () => ( - - - - - - - - + + @@ -402,22 +302,13 @@ const Profile = () => ( title="The DevCard" verdict="Share to leads, and now ships. The card is already an image; the job is getting it posted rather than saved." > - - - - - - + From e93a91fd9b76637d0ce312e2837497601db4db8a Mon Sep 17 00:00:00 2001 From: tomeredlich Date: Thu, 3 Sep 2026 14:35:50 +0300 Subject: [PATCH 06/30] fix(profile): shrink the widget snapshot buttons to XSmall The three widget headers took Button's default Small, which sat heavier than the Learn more and 12/40 links beside them. XSmall matches the achievement card's control and the weight of the text it shares the row with. The header button keeps Medium, where it is matched to edit. Co-Authored-By: Claude Opus 5 --- .../profile/components/ProfileWidgets/AchievementsWidget.tsx | 2 ++ .../profile/components/ProfileWidgets/BadgesAndAwards.tsx | 2 ++ .../profile/components/ProfileWidgets/ReadingOverview.tsx | 2 ++ .../stories/features/snapshot/surfaces/Profile.stories.tsx | 4 ++-- 4 files changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/shared/src/features/profile/components/ProfileWidgets/AchievementsWidget.tsx b/packages/shared/src/features/profile/components/ProfileWidgets/AchievementsWidget.tsx index 4bdd131a459..f0e045f7204 100644 --- a/packages/shared/src/features/profile/components/ProfileWidgets/AchievementsWidget.tsx +++ b/packages/shared/src/features/profile/components/ProfileWidgets/AchievementsWidget.tsx @@ -22,6 +22,7 @@ import { RaritySparkles } from '../achievements/RaritySparkles'; import HoverCard from '../../../../components/cards/common/HoverCard'; import { AchievementCard } from '../achievements/AchievementCard'; import { SnapshotButton } from '../../../../components/imageShare/SnapshotButton'; +import { ButtonSize } from '../../../../components/buttons/common'; interface AchievementsWidgetProps { user: PublicProfile; @@ -159,6 +160,7 @@ export function AchievementsWidget({
diff --git a/packages/shared/src/features/profile/components/ProfileWidgets/BadgesAndAwards.tsx b/packages/shared/src/features/profile/components/ProfileWidgets/BadgesAndAwards.tsx index bf3b83a8114..7a67a1fe074 100644 --- a/packages/shared/src/features/profile/components/ProfileWidgets/BadgesAndAwards.tsx +++ b/packages/shared/src/features/profile/components/ProfileWidgets/BadgesAndAwards.tsx @@ -25,6 +25,7 @@ import { } from './BadgesAndAwardsComponents'; import { anchorDefaultRel } from '../../../../lib/strings'; import { SnapshotButton } from '../../../../components/imageShare/SnapshotButton'; +import { ButtonSize } from '../../../../components/buttons/common'; export const BadgesAndAwards = ({ user, @@ -78,6 +79,7 @@ export const BadgesAndAwards = ({
diff --git a/packages/shared/src/features/profile/components/ProfileWidgets/ReadingOverview.tsx b/packages/shared/src/features/profile/components/ProfileWidgets/ReadingOverview.tsx index f6eea20b2d4..5b75e7402cb 100644 --- a/packages/shared/src/features/profile/components/ProfileWidgets/ReadingOverview.tsx +++ b/packages/shared/src/features/profile/components/ProfileWidgets/ReadingOverview.tsx @@ -24,6 +24,7 @@ import { import { anchorDefaultRel, pluralize } from '../../../../lib/strings'; import { largeNumberFormat } from '../../../../lib'; import { SnapshotButton } from '../../../../components/imageShare/SnapshotButton'; +import { ButtonSize } from '../../../../components/buttons/common'; // Utility functions const readHistoryToValue = (value: UserReadHistory): number => value.reads; @@ -97,6 +98,7 @@ export function ReadingOverview({
diff --git a/packages/storybook/stories/features/snapshot/surfaces/Profile.stories.tsx b/packages/storybook/stories/features/snapshot/surfaces/Profile.stories.tsx index cdbc60bbb30..7ef5e069503 100644 --- a/packages/storybook/stories/features/snapshot/surfaces/Profile.stories.tsx +++ b/packages/storybook/stories/features/snapshot/surfaces/Profile.stories.tsx @@ -113,7 +113,7 @@ const WidgetHeader = ({
{trailing} - +
); @@ -287,7 +287,7 @@ const Profile = () => ( > From f4bc21f9396bc08a395cd88069dd682be0e1b72b Mon Sep 17 00:00:00 2001 From: tomeredlich Date: Thu, 3 Sep 2026 14:44:04 +0300 Subject: [PATCH 07/30] docs(snapshot): drop the profile surface page from Storybook The design page has served its purpose: the controls it compared are shipped and reviewable on the profile itself. Removing it takes the surface chrome with it, since nothing else imported either file. Co-Authored-By: Claude Opus 5 --- .../features/snapshot/surfaceChrome.tsx | 189 ---------- .../snapshot/surfaces/Profile.stories.tsx | 326 ------------------ 2 files changed, 515 deletions(-) delete mode 100644 packages/storybook/stories/features/snapshot/surfaceChrome.tsx delete mode 100644 packages/storybook/stories/features/snapshot/surfaces/Profile.stories.tsx diff --git a/packages/storybook/stories/features/snapshot/surfaceChrome.tsx b/packages/storybook/stories/features/snapshot/surfaceChrome.tsx deleted file mode 100644 index 1ab3473c53a..00000000000 --- a/packages/storybook/stories/features/snapshot/surfaceChrome.tsx +++ /dev/null @@ -1,189 +0,0 @@ -import React from 'react'; -import { - Button, - ButtonSize, - ButtonVariant, -} from '@dailydotdev/shared/src/components/buttons/Button'; -import { - LinkIcon, - ShareIcon, - SnapshotIcon, -} from '@dailydotdev/shared/src/components/icons'; - -export const AVATAR = - 'https://res.cloudinary.com/daily-now/image/upload/s--O0TOmw4y--/f_auto/v1715772965/public/noProfile'; - -/* ------------------------------------------------------------------ prose */ - -const H1 = ({ children }: { children: React.ReactNode }) => ( -

{children}

-); - -const P = ({ children }: { children: React.ReactNode }) => ( -

{children}

-); - -const Note = ({ children }: { children: React.ReactNode }) => ( -

- {children} -

-); - -/* ---------------------------------------------------------------- controls */ - -type LeadAction = 'Link' | 'Share to' | 'Snapshot'; - -const ICONS: Record = { - Link: , - 'Share to': , - Snapshot: , -}; - -const LABELS: Record = { - Link: 'Copy link', - 'Share to': 'Share', - Snapshot: 'Snapshot', -}; - -/** - * Inert on purpose: this page compares where a control sits inside a real - * screen. The working buttons and live capture are on the profile itself. - */ -export const Control = ({ - action, - className, - label, - size = ButtonSize.Small, - variant = ButtonVariant.Tertiary, -}: { - action: LeadAction; - className?: string; - label?: boolean; - size?: ButtonSize; - variant?: ButtonVariant; -}) => ( - -); - -/* ---------------------------------------------------------- page furniture */ - -export type DeviceName = 'Desktop' | 'Tablet' | 'Mobile'; - -const DEVICES: Record< - DeviceName, - { width: number; viewport: string } -> = { - Desktop: { width: 680, viewport: '1020px and up' }, - Tablet: { width: 560, viewport: '768px' }, - Mobile: { width: 375, viewport: '375px' }, -}; - -/** A surface drawn at one real viewport width, so density is comparable. */ -export const Device = ({ - name, - children, - height, -}: { - name: DeviceName; - children: React.ReactNode; - /** Mobile surfaces pin a floating bar, so the frame needs a known height. */ - height?: number; -}) => ( -
- - {name} · {DEVICES[name].viewport} - -
- {children} -
-
-); - -/** Devices sit in a scroller rather than wrapping, so widths stay honest. */ -export const Rail = ({ children }: { children: React.ReactNode }) => ( -
- {children} -
-); - -export const Variant = ({ - step, - headline, - note, - children, -}: { - step: string; - headline: string; - note: string; - children: React.ReactNode; -}) => ( - // Full width so a device rail can scroll across the whole canvas. -
-
- - {step} - - - {headline} - - {note} -
- {children} -
-); - -export const Category = ({ - title, - covers, - verdict, - children, -}: { - title: string; - covers: string; - verdict: string; - children: React.ReactNode; -}) => ( -
-
-

{title}

- {covers} -

- {verdict} -

-
-
{children}
-
-); - -/** Every category page opens with the same header, so they read as a set. */ -export const SurfacePage = ({ - title, - intro, - map, - children, -}: { - title: string; - intro: string; - map: string; - children: React.ReactNode; -}) => ( -
-
-

{title}

-

{intro}

- {map} -
- {children} -
-); diff --git a/packages/storybook/stories/features/snapshot/surfaces/Profile.stories.tsx b/packages/storybook/stories/features/snapshot/surfaces/Profile.stories.tsx deleted file mode 100644 index 7ef5e069503..00000000000 --- a/packages/storybook/stories/features/snapshot/surfaces/Profile.stories.tsx +++ /dev/null @@ -1,326 +0,0 @@ -import React from 'react'; -import type { Meta, StoryObj } from '@storybook/react-vite'; -import { - Button, - ButtonSize, - ButtonVariant, -} from '@dailydotdev/shared/src/components/buttons/Button'; -import { - DownloadIcon, - EditIcon, - MedalBadgeIcon, - ReputationIcon, -} from '@dailydotdev/shared/src/components/icons'; -import type { DeviceName } from '../surfaceChrome'; -import { - AVATAR, - Category, - Control, - Device, - Rail, - SurfacePage, - Variant, -} from '../surfaceChrome'; - -/* ------------------------------------------------------------------ header */ - -const ProfileScreen = ({ device }: { device: DeviceName }) => ( - -
-
- - -
-
-
- - - Tomer Redlich - - -
-

- Building the feed developers actually read. -

- Tel Aviv - - @tomer · Joined Jan 4. 2021 - - -
- - - 1.2K Reputation - - - 3.4K Upvotes - - - 842 Followers - - - 61 Following - -
-
-
-
- -); - -/* ----------------------------------------------------------------- widgets */ - -const SummaryCard = ({ count, label }: { count: string; label: string }) => ( -
- {count} - {label} -
-); - -const WidgetHeader = ({ - title, - icon, - trailing, -}: { - title: string; - icon?: React.ReactNode; - trailing?: React.ReactNode; -}) => ( -
-

- {icon} - {title} -

-
- {trailing} - -
-
-); - -const WidgetsScreen = ({ device }: { device: DeviceName }) => ( - -
-
- - Learn more -
- - -
-

- Top tags by reading days -

-
- {[ - ['#typescript', 82], - ['#react', 64], - ['#webdev', 41], - ['#css', 28], - ].map(([tag, pct]) => ( -
- - - {tag} - -
- ))} -
-

- Posts read in the last months (3.4K) -

-
- {Array.from({ length: 60 }, (_, i) => { - const level = Math.max( - 0, - Math.min(3, Math.round(2 + Math.sin(i / 4) * 1.4)), - ); - const tone = [ - 'bg-surface-float', - 'bg-overlay-float-cabbage', - 'bg-accent-cabbage-subtler', - 'bg-accent-cabbage-default', - ][level]; - - return ( - // eslint-disable-next-line react/no-array-index-key - - ); - })} -
-
- -
- - Learn more -
- - -
-
- {['#typescript', '#react'].map((tag) => ( - - 🥇 Top reader in {tag} - - ))} -
-
- -
- } - title="Achievements" - trailing={12/40} - /> -
- {["Can't spend it all", 'Big byte energy'].map((name) => ( -
- -
- - {name} - - - Unlocked 12 Aug 2026 - -
- - 120 - -
- ))} -
-
- {device === 'Mobile' && mobile} -
-
-); - -/* ---------------------------------------------------------------- devcard */ - -const DevCardScreen = () => ( - -
- - Your DevCard is ready - -
-
- - -
-
- -); - -/* -------------------------------------------------------------------- page */ - -const Profile = () => ( - - - - - - - - - - - - - - - - - - - - - - - - - - - -); - -const meta: Meta = { - title: 'Features/Snapshot/Surfaces/Profile', - component: Profile, - parameters: { layout: 'fullscreen' }, -}; - -export default meta; - -export const Variations: StoryObj = {}; From aa8f9c09a443a4dba4ff97fdb22f9a45d6e4ff23 Mon Sep 17 00:00:00 2001 From: tomeredlich Date: Thu, 3 Sep 2026 16:11:44 +0300 Subject: [PATCH 08/30] fix(share): tell the user when a copy did not happen A refused clipboard write rejected out of useCopyLink, so the caller got no toast, no copied state and an unhandled rejection: the button read as dead. It now reports the failure, and the copied state is set only after the write lands, so a confirmation cannot claim a copy that did not happen. The missing-link path stops reporting a copy for the same reason. Two strict-mode errors in the file surfaced once it entered the changed set. An optional getLink was invoked unconditionally, and useCopyText passed a possibly undefined value to writeText, which would have put the string "undefined" on the clipboard. Co-Authored-By: Claude Opus 5 --- packages/shared/src/hooks/useCopy.spec.ts | 67 +++++++++++++++++++++++ packages/shared/src/hooks/useCopy.ts | 63 +++++++++++++-------- 2 files changed, 108 insertions(+), 22 deletions(-) create mode 100644 packages/shared/src/hooks/useCopy.spec.ts diff --git a/packages/shared/src/hooks/useCopy.spec.ts b/packages/shared/src/hooks/useCopy.spec.ts new file mode 100644 index 00000000000..905a7892053 --- /dev/null +++ b/packages/shared/src/hooks/useCopy.spec.ts @@ -0,0 +1,67 @@ +import { act, renderHook } from '@testing-library/react'; +import { useCopyLink } from './useCopy'; + +const mockDisplayToast = jest.fn(); +const mockWriteText = jest.fn(); + +jest.mock('./useToastNotification', () => ({ + useToastNotification: () => ({ displayToast: mockDisplayToast }), +})); + +jest.mock('./utils/useGetShortUrl', () => ({ + useGetShortUrl: () => ({ getShortUrl: jest.fn() }), +})); + +beforeEach(() => { + jest.clearAllMocks(); + Object.assign(navigator, { clipboard: { writeText: mockWriteText } }); +}); + +it('copies the link and reports the copied state', async () => { + mockWriteText.mockResolvedValue(undefined); + const { result } = renderHook(() => useCopyLink(() => 'https://daily.dev')); + + await act(async () => { + await result.current[1](); + }); + + expect(mockWriteText).toHaveBeenCalledWith('https://daily.dev'); + expect(mockDisplayToast).toHaveBeenCalledWith( + '✅ Copied link to clipboard', + {}, + ); + expect(result.current[0]).toBe(true); +}); + +it('says so when the clipboard refuses the write', async () => { + mockWriteText.mockRejectedValue( + new DOMException('Document is not focused.', 'NotAllowedError'), + ); + const { result } = renderHook(() => useCopyLink(() => 'https://daily.dev')); + + await act(async () => { + await result.current[1](); + }); + + expect(mockDisplayToast).toHaveBeenCalledWith( + '❌ Could not copy, please try again', + {}, + ); + // Nothing was copied, so the caller must not render a copied confirmation. + expect(result.current[0]).toBe(false); +}); + +it('does not report a copy when there is no link', async () => { + const { result } = renderHook(() => useCopyLink(() => '')); + + await act(async () => { + await result.current[1](); + }); + + expect(mockWriteText).not.toHaveBeenCalled(); + expect(mockDisplayToast).toHaveBeenCalledWith( + '❌ Could not copy, link is missing', + {}, + ); + expect(result.current[0]).toBe(false); +}); diff --git a/packages/shared/src/hooks/useCopy.ts b/packages/shared/src/hooks/useCopy.ts index ac772be919b..cc8f6fbe787 100644 --- a/packages/shared/src/hooks/useCopy.ts +++ b/packages/shared/src/hooks/useCopy.ts @@ -14,6 +14,8 @@ type CopyNotifyFunctionProps = NotifyOptionalProps & { const defaultMessage = '✅ Copied to clipboard'; const defaultLinkMessage = '✅ Copied link to clipboard'; const noLinkErrorMessage = '❌ Could not copy, link is missing'; +const copyFailedMessage = '❌ Could not copy, please try again'; +const noTextErrorMessage = '❌ Could not copy, there is nothing to copy'; export type CopyNotifyFunction = | ((props?: CopyNotifyFunctionProps) => void) @@ -28,33 +30,42 @@ export function useCopyLink( const { getShortUrl } = useGetShortUrl(); const copy: CopyNotifyFunction = async (props = {}) => { - const link = props.link || getLink(); + const link = props.link || getLink?.(); const shortenLink = props.shorten || shorten; - if (link) { - // write the link to clipboard + if (!link) { + displayToast(noLinkErrorMessage, props); + + return; + } + + try { await navigator.clipboard.writeText(link); + } catch { + // A refused write used to reject out of here, leaving the caller with no + // toast and no copied state, so the button read as dead. + displayToast(copyFailedMessage, props); - // try with a shortened link as well, if requested - if (shortenLink) { - try { - const clipBoardItem = new ClipboardItem({ - 'text/plain': getShortUrl(link).then((shortenedLink) => { - return new Blob([shortenedLink], { type: 'text/plain' }); - }), - }); - await navigator.clipboard.write([clipBoardItem]); - } catch (e) { - // eslint-disable-next-line no-console - console.warn('Error copying to clipboard', e); - } - } + return; + } - if (!props.disableToast) { - displayToast(props.message || defaultLinkMessage, props); + // try with a shortened link as well, if requested + if (shortenLink) { + try { + const clipBoardItem = new ClipboardItem({ + 'text/plain': getShortUrl(link).then((shortenedLink) => { + return new Blob([shortenedLink], { type: 'text/plain' }); + }), + }); + await navigator.clipboard.write([clipBoardItem]); + } catch (e) { + // eslint-disable-next-line no-console + console.warn('Error copying to clipboard', e); } - } else { - displayToast(noLinkErrorMessage, props); + } + + if (!props.disableToast) { + displayToast(props.message || defaultLinkMessage, props); } setCopying(true); @@ -71,7 +82,15 @@ export function useCopyText(text?: string): [boolean, CopyNotifyFunction] { const { displayToast } = useToastNotification(); const copy: CopyNotifyFunction = async (props = {}) => { - await navigator.clipboard.writeText(props.textToCopy || text); + const textToCopy = props.textToCopy || text; + + if (!textToCopy) { + displayToast(noTextErrorMessage, props); + + return; + } + + await navigator.clipboard.writeText(textToCopy); if (!props.disableToast) { displayToast(props.message || defaultMessage, props); From 56723b63d6ce8c973fe565d6ec8df7eb8695d5f7 Mon Sep 17 00:00:00 2001 From: tomeredlich Date: Thu, 3 Sep 2026 17:52:19 +0300 Subject: [PATCH 09/30] feat(profile): confirm the share widget's copy with the same green arrow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The header's copy link already swapped to the upvote arrow, but the profile page's other copy control — the Public profile & URL row — only filled its copy icon, so the same gesture confirmed two different ways on one page. Both now render CopyConfirmIcon, which carries the arrow, the avocado and the spin in one place instead of each caller repeating the class list. Co-Authored-By: Claude Opus 5 --- .../components/buttons/CopyConfirmIcon.tsx | 26 +++++++++++++++++++ .../src/components/profile/ProfileHeader.tsx | 14 +++------- .../components/ProfileWidgets/Share.tsx | 3 ++- 3 files changed, 31 insertions(+), 12 deletions(-) create mode 100644 packages/shared/src/components/buttons/CopyConfirmIcon.tsx diff --git a/packages/shared/src/components/buttons/CopyConfirmIcon.tsx b/packages/shared/src/components/buttons/CopyConfirmIcon.tsx new file mode 100644 index 00000000000..cfbd574bfa4 --- /dev/null +++ b/packages/shared/src/components/buttons/CopyConfirmIcon.tsx @@ -0,0 +1,26 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import { UpvoteIcon } from '../icons'; +import type { IconProps } from '../Icon'; + +/** + * The confirmation half of a copy control: the same filled arrow and spin the + * upvote button uses, so the gesture that means "that worked" looks the same + * everywhere. Swap it in for the resting icon while the copy is confirmed. + */ +export function CopyConfirmIcon({ + className, + ...props +}: IconProps): ReactElement { + return ( + + ); +} diff --git a/packages/shared/src/components/profile/ProfileHeader.tsx b/packages/shared/src/components/profile/ProfileHeader.tsx index 1c764000d3b..685ff396cb4 100644 --- a/packages/shared/src/components/profile/ProfileHeader.tsx +++ b/packages/shared/src/components/profile/ProfileHeader.tsx @@ -8,13 +8,14 @@ import { TypographyColor, TypographyType, } from '../typography/Typography'; -import { DevPlusIcon, EditIcon, LinkIcon, UpvoteIcon } from '../icons'; +import { DevPlusIcon, EditIcon, LinkIcon } from '../icons'; import type { PublicProfile } from '../../lib/user'; import type { UserStatsProps } from './UserStats'; import { UserStats } from './UserStats'; import JoinedDate from './JoinedDate'; import { Separator } from '../cards/common/common'; import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; +import { CopyConfirmIcon } from '../buttons/CopyConfirmIcon'; import { webappUrl } from '../../lib/constants'; import Link from '../utilities/Link'; import { useAuthContext } from '../../contexts/AuthContext'; @@ -133,16 +134,7 @@ const ProfileHeader = ({ - + <> + {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 9782fea40b6de866761326b3dd92c716cf0d21e1 Mon Sep 17 00:00:00 2001 From: tomeredlich Date: Sun, 6 Sep 2026 16:27:03 +0300 Subject: [PATCH 13/30] 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 f426b4950c6bf29a5e006419a17c5af9f7a4cb61 Mon Sep 17 00:00:00 2001 From: tomeredlich Date: Mon, 7 Sep 2026 11:59:33 +0300 Subject: [PATCH 14/30] 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({
- - Tomer Redlich - - @tomer -
-
- ); -}; - -/** 6b–6d. Profile widgets — icon-only, in the widget header row. */ -const WidgetPlacement = ({ - title, - trailing, - children, -}: { - title: React.ReactNode; - trailing?: React.ReactNode; - children: React.ReactNode; -}) => { - const ref = useRef(null); - - return ( -
-
-

- {title} -

-
- {trailing} - -
-
- {children} -
- ); -}; - -const ACHIEVEMENT: UserAchievement = { - achievement: { - id: 'achievement-1', - name: 'Streak keeper', - description: 'Read something on daily.dev 100 days in a row.', - image: - 'https://media.daily.dev/image/upload/s--SNnLKKWe--/q_auto/v1773608419/achievements/coraholic', - points: 120, - rarity: 4, - type: AchievementType.Milestone, - criteria: { targetCount: 100 }, - unit: 'days', - }, - progress: 100, - unlockedAt: '2026-06-01T00:00:00Z', - createdAt: '2026-01-01T00:00:00Z', - updatedAt: '2026-06-01T00:00:00Z', -}; - -const AchievementBox = ({ entry }: { entry: UserAchievement }) => { - const isUnlocked = entry.unlockedAt !== null; - - return ( -
-
- -
- - {entry.achievement.name} - - - {entry.achievement.description} - -
-
- - - {entry.achievement.points} - -
-
-
- ); -}; - -const LOCKED_ACHIEVEMENT: UserAchievement = { - ...ACHIEVEMENT, - achievement: { - ...ACHIEVEMENT.achievement, - id: 'achievement-2', - name: 'First take', - description: 'Post your first hot take.', - rarity: 38, - image: - 'https://media.daily.dev/image/upload/v1770222937/achievements/Town_crier.png', - criteria: { targetCount: 1 }, - unit: null, - }, - progress: 0, - unlockedAt: null, -}; - const Placements = () => { const [capture, setCapture] = useState(null); const onCapture = React.useCallback((blob: Blob) => { @@ -748,9 +574,11 @@ const Placements = () => { Sharing map.

- Placements 1–7 are built and live; 8–20 are mock-ups of surfaces the - Sharing map covers but the code does not touch yet, so the control - and its verdict can be reviewed before anything is wired. + Placements 1–5 are built and live, and the profile placements (6 + and 7) are left out because the live profile is the reference; 8–20 + are mock-ups of surfaces the Sharing map covers but the code does + not touch yet, so the control and its verdict can be reviewed before + anything is wired.

@@ -926,96 +754,6 @@ const Placements = () => { - -
- -
- - Learn more - - } - > -

- Posts read in the last months (412) -

-
- {Array.from({ length: 36 }).map((_, i) => ( - - ))} -
-
- - -
-
- - x4 - - - Top reader badge - -
-
- - x12 - - - Total Awards - -
-
-
- - - - Achievements - - } - trailing={ - 18/60 - } - > -
- {Array.from({ length: 5 }).map((_, i) => ( - - ))} -
-
-
-
-
- - -
- {[ACHIEVEMENT, LOCKED_ACHIEVEMENT].map((entry) => ( - - ))} -
-
- ( - -
-
- - -
-
-
- - - Tomer Redlich - - -
-

- Building the feed developers actually read. -

- Tel Aviv - - @tomer · Joined Jan 4. 2021 - - -
- - - 1.2K Reputation - - - 3.4K Upvotes - - - 842 Followers - - - 61 Following - -
-
-
-
- -); - -/* ----------------------------------------------------------------- widgets */ - -const SummaryCard = ({ count, label }: { count: string; label: string }) => ( -
- {count} - {label} -
-); - -const WidgetHeader = ({ - title, - icon, - trailing, -}: { - title: string; - icon?: React.ReactNode; - trailing?: React.ReactNode; -}) => ( -
-

- {icon} - {title} -

-
- {trailing} - -
-
-); - -const WidgetsScreen = ({ device }: { device: DeviceName }) => ( - -
-
- - Learn more -
- - -
-

- Top tags by reading days -

-
- {[ - ['#typescript', 82], - ['#react', 64], - ['#webdev', 41], - ['#css', 28], - ].map(([tag, pct]) => ( -
- - - {tag} - -
- ))} -
-

- Posts read in the last months (3.4K) -

-
- {Array.from({ length: 60 }, (_, i) => { - const level = Math.max( - 0, - Math.min(3, Math.round(2 + Math.sin(i / 4) * 1.4)), - ); - const tone = [ - 'bg-surface-float', - 'bg-overlay-float-cabbage', - 'bg-accent-cabbage-subtler', - 'bg-accent-cabbage-default', - ][level]; - - return ( - // eslint-disable-next-line react/no-array-index-key - - ); - })} -
-
- -
- - Learn more -
- - -
-
- {['#typescript', '#react'].map((tag) => ( - - 🥇 Top reader in {tag} - - ))} -
-
- -
- } - title="Achievements" - trailing={12/40} - /> -
- {['Can't spend it all', 'Big byte energy'].map((name) => ( -
- -
- - {name} - - - Unlocked 12 Aug 2026 - -
- - 120 - -
- ))} -
-
- {device === 'Mobile' && mobile} -
-
-); - -/* ---------------------------------------------------------------- devcard */ - -const DevCardScreen = () => ( - -
- - Your DevCard is ready - -
-
- - -
-
- -); - -/* -------------------------------------------------------------------- page */ - -const Profile = () => ( - - - - - - - - - - - - - - - - - - - - - - - - - - - -); - -const meta: Meta = { - title: 'Features/Snapshot/Surfaces/Profile', - component: Profile, - parameters: { layout: 'fullscreen' }, -}; - -export default meta; - -export const Variations: StoryObj = {}; From ad5a31f27c9f213f32f56ac82a53c75b3d7417cf Mon Sep 17 00:00:00 2001 From: Ido Shamun <1993245+idoshamun@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:50:42 +0300 Subject: [PATCH 24/30] refactor(profile): drop ProfileSnapshotButton's unused className --- .../shared/src/features/snapshot/ProfileSnapshotButton.tsx | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/shared/src/features/snapshot/ProfileSnapshotButton.tsx b/packages/shared/src/features/snapshot/ProfileSnapshotButton.tsx index 614fb9cd62f..4eb11a5edc2 100644 --- a/packages/shared/src/features/snapshot/ProfileSnapshotButton.tsx +++ b/packages/shared/src/features/snapshot/ProfileSnapshotButton.tsx @@ -25,7 +25,6 @@ export interface ProfileSnapshotButtonProps { renderCard: (ref: Ref) => ReactElement; size?: ButtonSize; variant?: ButtonVariant; - className?: string; } /** @@ -45,7 +44,6 @@ export function ProfileSnapshotButton({ renderCard, size = ButtonSize.XSmall, variant, - className, }: ProfileSnapshotButtonProps): ReactElement { const cardRef = useRef(null); const { isArmed, armProps } = useArmedCard(); @@ -71,7 +69,6 @@ export function ProfileSnapshotButton({ getSnapshotCaptureOptions(cardRef.current)} - className={className} filename={filename} onResult={onResult} showLabel={false} From 9a580b3fba08ab917101f29bcef4f19be605279b Mon Sep 17 00:00:00 2001 From: Ido Shamun <1993245+idoshamun@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:20:09 +0300 Subject: [PATCH 25/30] fix(profile): label the share image's posts read with its window The profile header card showed "Posts read" beside Joined and Reputation, so it read as a lifetime total, but the number is the Reading Overview's window (about the last six months). It now carries the page's own wording, "Posts read in the last months", and is left out when the reading history has not loaded instead of printing 0. SnapshotStat centres its label so the longer one wraps evenly. The reading overview card now drops the streak tiles when there is no streak and the tags heading when there are no tags, as the page does, instead of stating a 0 streak it never measured. --- .../src/components/profile/ProfileHeader.tsx | 6 +- .../ProfileWidgets/ReadingOverview.tsx | 4 +- .../features/snapshot/ProfileSnapshotCard.tsx | 21 ++++--- .../snapshot/ReadingOverviewSnapshotCard.tsx | 61 ++++++++++--------- .../src/features/snapshot/SnapshotStats.tsx | 2 +- 5 files changed, 53 insertions(+), 41 deletions(-) diff --git a/packages/shared/src/components/profile/ProfileHeader.tsx b/packages/shared/src/components/profile/ProfileHeader.tsx index 2fdfbc2c0e4..580f08b05e5 100644 --- a/packages/shared/src/components/profile/ProfileHeader.tsx +++ b/packages/shared/src/components/profile/ProfileHeader.tsx @@ -79,7 +79,11 @@ const ProfileCard = forwardRef( image={user.image} joined={format(new Date(user.createdAt), 'MMMM y')} name={user.name} - postsRead={sumReadHistory(readingHistory?.userReadHistory)} + postsRead={ + readingHistory + ? sumReadHistory(readingHistory.userReadHistory) + : undefined + } ref={ref} reputation={user.reputation} seed={handle} diff --git a/packages/shared/src/features/profile/components/ProfileWidgets/ReadingOverview.tsx b/packages/shared/src/features/profile/components/ProfileWidgets/ReadingOverview.tsx index 2c3f1bf5c6d..559425c4bcf 100644 --- a/packages/shared/src/features/profile/components/ProfileWidgets/ReadingOverview.tsx +++ b/packages/shared/src/features/profile/components/ProfileWidgets/ReadingOverview.tsx @@ -104,7 +104,7 @@ const ReadingOverviewCard = forwardRef< return ( getBin(reads, bins))} - longestStreak={streak?.max ?? 0} + longestStreak={streak?.max} monthsLabel="in the last months" postsRead={sumReadHistory(readHistory)} ref={ref} @@ -115,7 +115,7 @@ const ReadingOverviewCard = forwardRef< percentage: Math.round((tag.percentage ?? 0) * 100), })) ?? [] } - totalReadingDays={streak?.total ?? 0} + totalReadingDays={streak?.total} user={{ handle: `@${user.username ?? user.id}`, image: user.image, diff --git a/packages/shared/src/features/snapshot/ProfileSnapshotCard.tsx b/packages/shared/src/features/snapshot/ProfileSnapshotCard.tsx index d2cb1c33bcd..6a6cddbe222 100644 --- a/packages/shared/src/features/snapshot/ProfileSnapshotCard.tsx +++ b/packages/shared/src/features/snapshot/ProfileSnapshotCard.tsx @@ -22,7 +22,8 @@ export interface ProfileSnapshotCardProps { bio?: string; image?: string; cover?: string; - postsRead: number; + /** Over the Reading Overview's window. Left out when it is not loaded. */ + postsRead?: number; joined: string; reputation: number; seed?: string; @@ -115,14 +116,16 @@ function ProfileSnapshotCardComponent( )} - - {largeNumberFormat(postsRead) ?? postsRead} - - } - /> + {postsRead !== undefined && ( + + {largeNumberFormat(postsRead) ?? postsRead} + + } + /> + )} {joined}} diff --git a/packages/shared/src/features/snapshot/ReadingOverviewSnapshotCard.tsx b/packages/shared/src/features/snapshot/ReadingOverviewSnapshotCard.tsx index ff4cb6235c7..e2002f1cd9e 100644 --- a/packages/shared/src/features/snapshot/ReadingOverviewSnapshotCard.tsx +++ b/packages/shared/src/features/snapshot/ReadingOverviewSnapshotCard.tsx @@ -31,8 +31,9 @@ export interface ReadingOverviewTag { export interface ReadingOverviewSnapshotCardProps { user: SnapshotIdentityProps; - longestStreak: number; - totalReadingDays: number; + /** Both left out when there is no streak, as the profile page does. */ + longestStreak?: number; + totalReadingDays?: number; postsRead: number; monthsLabel: string; topTags: ReadingOverviewTag[]; @@ -101,34 +102,38 @@ function ReadingOverviewSnapshotCardComponent(
-
- - -
+ {longestStreak !== undefined && totalReadingDays !== undefined && ( +
+ + +
+ )} -
- - Top tags by reading days - -
- {visibleTags.map((tag) => ( - - ))} + {visibleTags.length > 0 && ( +
+ + Top tags by reading days + +
+ {visibleTags.map((tag) => ( + + ))} +
-
+ )}
diff --git a/packages/shared/src/features/snapshot/SnapshotStats.tsx b/packages/shared/src/features/snapshot/SnapshotStats.tsx index fb4769920f7..16c359ad3a0 100644 --- a/packages/shared/src/features/snapshot/SnapshotStats.tsx +++ b/packages/shared/src/features/snapshot/SnapshotStats.tsx @@ -42,7 +42,7 @@ export const SnapshotStat = ({ {value} {label} From d0bdad2d0e33092bfb8d371de95b9e0ac7398d4a Mon Sep 17 00:00:00 2001 From: Ido Shamun <1993245+idoshamun@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:20:15 +0300 Subject: [PATCH 26/30] chore(storybook): drop invented stats from the placement mock-ups The Button placements story carried numbers nobody measured: follower, member and post counts on real tags, sources and squads, a reply count, a hot take count, a "Top 20" feed claim, and a referral offer ("a month of Plus") that does not exist. They are removed rather than swapped for other numbers. The leaderboard placement only existed to show real users beside invented scores and levels, so it goes, and the watercooler mock no longer puts an invented post under a real person's name. The header no longer claims placements 2 to 5 are live; only the post page is. The profile share specs use a fictional user instead of a real one. --- .../ProfileWidgets/ReadingOverview.spec.tsx | 8 +- .../snapshot/ProfileSnapshotButton.spec.tsx | 2 +- .../snapshot/SnapshotPlacements.stories.tsx | 99 +++---------------- 3 files changed, 19 insertions(+), 90 deletions(-) diff --git a/packages/shared/src/features/profile/components/ProfileWidgets/ReadingOverview.spec.tsx b/packages/shared/src/features/profile/components/ProfileWidgets/ReadingOverview.spec.tsx index e934379664a..23ef4e72d26 100644 --- a/packages/shared/src/features/profile/components/ProfileWidgets/ReadingOverview.spec.tsx +++ b/packages/shared/src/features/profile/components/ProfileWidgets/ReadingOverview.spec.tsx @@ -61,11 +61,11 @@ const mockMostReadTags: MostReadTag[] = [ const mockUser = { id: 'u1', - name: 'Tomer Redlich', - username: 'tomer', - image: 'https://daily.dev/tomer.jpg', + name: 'Test User', + username: 'testuser', + image: 'https://daily.dev/testuser.jpg', createdAt: '2021-01-04T00:00:00.000Z', - permalink: 'https://app.daily.dev/tomer', + permalink: 'https://app.daily.dev/testuser', reputation: 1200, premium: false, } as PublicProfile; diff --git a/packages/shared/src/features/snapshot/ProfileSnapshotButton.spec.tsx b/packages/shared/src/features/snapshot/ProfileSnapshotButton.spec.tsx index acc4ffb3cd2..e3bb412c3cd 100644 --- a/packages/shared/src/features/snapshot/ProfileSnapshotButton.spec.tsx +++ b/packages/shared/src/features/snapshot/ProfileSnapshotButton.spec.tsx @@ -22,7 +22,7 @@ const renderButton = () => render( { ); }; -/** 3. Leaderboard — icon-only, revealed on row hover. */ -const LEADERBOARD_ROWS = [ - { score: 15500, name: 'Bobby Iliev', handle: 'bobbyiliev', level: 103 }, - { score: 14200, name: 'Keshav Ashiya', handle: 'keshavashiya', level: 98 }, - { score: 13700, name: 'Hadil Ben Abdallah', handle: 'hadilben', level: 96 }, -]; - -const LeaderboardPlacement = () => ( -
    - {LEADERBOARD_ROWS.map((row) => ( -
  • - - {row.score.toLocaleString()} - - - {row.level} - - - - - {row.name} - - - @{row.handle} - - - -
  • - ))} -
-); - /** 4. Watercooler feed — one per post card, in the card action row. */ const WatercoolerPlacement = () => { const ref = useRef(null); @@ -477,14 +434,9 @@ const WatercoolerPlacement = () => {
-
- - Ante Barić - - - Watercooler · 2h - -
+ + Watercooler · 2h +

What is the one dev tool you would not give up? @@ -526,19 +478,11 @@ const HotTakePlacement = () => {

Every formatter argument is a proxy war over indentation.

-
-
- - - 128 - -
- -
+

); @@ -574,11 +518,11 @@ const Placements = () => { Sharing map.

- Placements 1–5 are built and live, and the profile placements (6 - and 7) are left out because the live profile is the reference; 8–20 - are mock-ups of surfaces the Sharing map covers but the code does - not touch yet, so the control and its verdict can be reviewed before - anything is wired. + Placement 1 is live on the post page, and the profile placements (6 + and 7) are left out because the live profile is the reference. The + rest are mock-ups of surfaces the Sharing map covers but the code + does not touch yet, so the control and its verdict can be reviewed + before anything is wired.

@@ -727,15 +671,6 @@ const Placements = () => { - - - - { filename="daily-thread" leads="Link" title="Enjoyed this discussion?" - body="24 replies · last one 4 minutes ago" /> @@ -816,7 +750,6 @@ const Placements = () => { @@ -827,7 +760,6 @@ const Placements = () => { @@ -842,7 +774,7 @@ const Placements = () => { step="Placement 13" leads="Link" title="Leaderboard page" - note="Copy link leads for the board itself — it changes weekly, so a link stays true where an image does not. Sharing your own rank is Placement 3." + note="Copy link leads for the board itself: it changes weekly, so a link stays true where an image does not." > { eyebrow="My feed" filename="daily-my-feed" leads="Snapshot" - meta="Top 20 posts right now" title="What I'm reading" /> @@ -925,7 +856,6 @@ const Placements = () => { @@ -960,7 +890,6 @@ const Placements = () => { filename="daily-invite" leads="Link" title="Come read with me on daily.dev" - body="We both get a month of Plus · daily.dev/join/tomer" />
From d51d61e12e75d1526a201d4d2189cdf46e88012d Mon Sep 17 00:00:00 2001 From: Ido Shamun <1993245+idoshamun@users.noreply.github.com> Date: Thu, 10 Sep 2026 17:42:52 +0300 Subject: [PATCH 27/30] fix(profile): tie profile snapshots to the profile they show The single achievement image said "Completed May 21" with no name, no handle and no year, and the button showed on any unlocked achievement. A visitor sharing someone else's achievement produced an image that read as their own. The card now carries the owner's identity (the same SnapshotIdentity the other profile cards lead with) and dates the unlock with its year. AchievementCard only offers the snapshot when it is told whose achievement it is: the profile widget, the showcase, the achievements page and the game center pass the user; the feed's tracker shows locked achievements only and passes nothing. Every placement now names its owner with ownerId, and the button is keyed by it. Router navigation between profiles reuses the components, so a card armed on one profile stayed mounted with the next profile's data without a hover. The target still defaults to the profile, and the achievement card keeps its own target. SnapshotIdentity's name column takes flex-1: the global flex-shrink: 0 in base.css kept it at its content width, so a long name ran past the card instead of truncating. The achievement card is narrow enough to show it. --- .../src/components/profile/ProfileHeader.tsx | 2 +- .../ProfileWidgets/AchievementsWidget.tsx | 4 +-- .../ProfileWidgets/BadgesAndAwards.tsx | 2 +- .../ProfileWidgets/ReadingOverview.tsx | 2 +- .../achievements/AchievementCard.spec.tsx | 26 +++++++++++++++ .../achievements/AchievementCard.tsx | 21 +++++++++--- .../achievements/AchievementsList.tsx | 1 + .../ProfileAchievementShowcase.tsx | 5 ++- .../snapshot/AchievementSnapshotCard.tsx | 14 ++++++++ .../snapshot/ProfileSnapshotButton.spec.tsx | 33 ++++++++++++------- .../snapshot/ProfileSnapshotButton.tsx | 21 ++++++++++-- .../features/snapshot/SnapshotIdentity.tsx | 2 +- .../features/snapshot/ShareImages.stories.tsx | 3 +- .../snapshot/SnapshotEdgeCases.stories.tsx | 7 +++- packages/webapp/pages/game-center/index.tsx | 1 + 15 files changed, 116 insertions(+), 28 deletions(-) diff --git a/packages/shared/src/components/profile/ProfileHeader.tsx b/packages/shared/src/components/profile/ProfileHeader.tsx index 580f08b05e5..46cf8dcb21a 100644 --- a/packages/shared/src/components/profile/ProfileHeader.tsx +++ b/packages/shared/src/components/profile/ProfileHeader.tsx @@ -171,7 +171,7 @@ const ProfileHeader = ({ renderCard={(ref) => } // Matches the edit button beside it, which takes Button's default. size={ButtonSize.Medium} - targetId={user.id} + ownerId={user.id} 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 59c7efd55ec..f152ee12ac8 100644 --- a/packages/shared/src/features/profile/components/ProfileWidgets/AchievementsWidget.tsx +++ b/packages/shared/src/features/profile/components/ProfileWidgets/AchievementsWidget.tsx @@ -94,7 +94,7 @@ function RecentAchievements({ } >
- +
); @@ -162,7 +162,7 @@ export function AchievementsWidget({ }} /> )} - targetId={user.id} + ownerId={user.id} />
diff --git a/packages/shared/src/features/profile/components/ProfileWidgets/BadgesAndAwards.tsx b/packages/shared/src/features/profile/components/ProfileWidgets/BadgesAndAwards.tsx index b077ecf3cc3..64938147ed0 100644 --- a/packages/shared/src/features/profile/components/ProfileWidgets/BadgesAndAwards.tsx +++ b/packages/shared/src/features/profile/components/ProfileWidgets/BadgesAndAwards.tsx @@ -109,7 +109,7 @@ export const BadgesAndAwards = ({ }} /> )} - targetId={user.id} + ownerId={user.id} />
)} - targetId={user.id} + ownerId={user.id} />
{ ).not.toBeInTheDocument(); }); }); + +describe('AchievementCard snapshot', () => { + const unlocked = createLockedAchievement({ + unlockedAt: '2025-05-21T12:00:00.000Z', + progress: 1, + }); + + it('is not offered when the card does not know whose achievement it is', () => { + renderCard({ userAchievement: unlocked }); + + expect(screen.queryByLabelText('Snapshot')).not.toBeInTheDocument(); + }); + + it('names the owner and dates the unlock with its year', () => { + renderCard({ + userAchievement: unlocked, + user: { id: 'u1', name: 'Ada Lovelace', username: 'ada', image: '' }, + }); + + fireEvent.pointerEnter(screen.getByLabelText('Snapshot')); + + expect(screen.getByText('Ada Lovelace')).toBeInTheDocument(); + expect(screen.getByText('@ada')).toBeInTheDocument(); + expect(screen.getByText('Completed May 21, 2025')).toBeInTheDocument(); + }); +}); diff --git a/packages/shared/src/features/profile/components/achievements/AchievementCard.tsx b/packages/shared/src/features/profile/components/achievements/AchievementCard.tsx index f68eaffc110..8c23aea9721 100644 --- a/packages/shared/src/features/profile/components/achievements/AchievementCard.tsx +++ b/packages/shared/src/features/profile/components/achievements/AchievementCard.tsx @@ -1,7 +1,9 @@ import type { ReactElement } from 'react'; import React from 'react'; import classNames from 'classnames'; +import { format } from 'date-fns'; import type { UserAchievement } from '../../../../graphql/user/achievements'; +import type { PublicProfile } from '../../../../lib/user'; import { AchievementType, getTargetCount, @@ -35,6 +37,11 @@ import { Origin, TargetType } from '../../../../lib/log'; interface AchievementCardProps { userAchievement: UserAchievement; + /** + * Whose achievement this is. The snapshot names them, so it is only offered + * where the card knows. + */ + user?: Pick; isOwner?: boolean; isTracked?: boolean; isTrackPending?: boolean; @@ -45,6 +52,7 @@ interface AchievementCardProps { export function AchievementCard({ userAchievement, + user, isOwner = false, isTracked = false, isTrackPending = false, @@ -125,17 +133,15 @@ export function AchievementCard({
- {isUnlocked && unlockedAt && ( + {isUnlocked && unlockedAt && user && ( ( )} targetId={achievement.id} diff --git a/packages/shared/src/features/profile/components/achievements/AchievementsList.tsx b/packages/shared/src/features/profile/components/achievements/AchievementsList.tsx index 8d71b630627..9a8c922c22e 100644 --- a/packages/shared/src/features/profile/components/achievements/AchievementsList.tsx +++ b/packages/shared/src/features/profile/components/achievements/AchievementsList.tsx @@ -268,6 +268,7 @@ export function AchievementsList({
- +
); diff --git a/packages/shared/src/features/snapshot/AchievementSnapshotCard.tsx b/packages/shared/src/features/snapshot/AchievementSnapshotCard.tsx index 1dd4bd0ae64..848d1066ab7 100644 --- a/packages/shared/src/features/snapshot/AchievementSnapshotCard.tsx +++ b/packages/shared/src/features/snapshot/AchievementSnapshotCard.tsx @@ -2,6 +2,8 @@ import type { ReactElement } from 'react'; import React, { forwardRef } from 'react'; import { AchievementRarityTier } from '../profile/components/achievements/achievementRarity'; import { SnapshotFrame } from './SnapshotFrame'; +import type { SnapshotIdentityProps } from './SnapshotIdentity'; +import { SnapshotIdentity } from './SnapshotIdentity'; const CARD_WIDTH = 620; /** Trading-card proportions (2.5:3.5) rather than a square slab. */ @@ -21,6 +23,8 @@ const PILL = { }; export interface AchievementSnapshotCardProps { + /** Who earned it, so a visitor's share does not read as their own. */ + user: SnapshotIdentityProps; name: string; description: string; image?: string; @@ -32,6 +36,7 @@ export interface AchievementSnapshotCardProps { function AchievementSnapshotCardComponent( { + user, name, description, image, @@ -119,6 +124,15 @@ function AchievementSnapshotCardComponent( > Completed {completedAt}
+
+ +
diff --git a/packages/shared/src/features/snapshot/ProfileSnapshotButton.spec.tsx b/packages/shared/src/features/snapshot/ProfileSnapshotButton.spec.tsx index e3bb412c3cd..ebee126d886 100644 --- a/packages/shared/src/features/snapshot/ProfileSnapshotButton.spec.tsx +++ b/packages/shared/src/features/snapshot/ProfileSnapshotButton.spec.tsx @@ -18,17 +18,18 @@ jest.mock('../../lib/imageShare/copyShareImage', () => ({ const logEvent = jest.fn(); const renderCard = jest.fn((ref) =>
profile card
); -const renderButton = () => - render( - - - , - ); +const client = new QueryClient(); +const snapshotButton = (ownerId = 'u1') => ( + + + +); +const renderButton = () => render(snapshotButton()); beforeEach(() => { jest.clearAllMocks(); @@ -49,6 +50,16 @@ describe('ProfileSnapshotButton', () => { expect(screen.getByText('profile card')).toBeInTheDocument(); }); + it('drops the armed card when the profile changes under it', () => { + const { rerender } = renderButton(); + fireEvent.pointerEnter(screen.getByLabelText('Snapshot')); + expect(screen.getByText('profile card')).toBeInTheDocument(); + + rerender(snapshotButton('u2')); + + expect(screen.queryByText('profile card')).not.toBeInTheDocument(); + }); + it('logs the press as a profile share with its placement', async () => { renderButton(); const button = screen.getByLabelText('Snapshot'); diff --git a/packages/shared/src/features/snapshot/ProfileSnapshotButton.tsx b/packages/shared/src/features/snapshot/ProfileSnapshotButton.tsx index 4eb11a5edc2..56b3e732006 100644 --- a/packages/shared/src/features/snapshot/ProfileSnapshotButton.tsx +++ b/packages/shared/src/features/snapshot/ProfileSnapshotButton.tsx @@ -16,7 +16,9 @@ export interface ProfileSnapshotButtonProps { /** Which placement this is, for the snapshot's share event. */ origin: Origin; filename: string; - targetId: string; + /** The profile's user. The profile is also the target unless one is set. */ + ownerId: string; + targetId?: string; targetType?: TargetType; /** * Called only once the button is armed, so whatever the card derives from @@ -36,10 +38,11 @@ export interface ProfileSnapshotButtonProps { * the live DOM, and portalled to the body so it inherits neither a widget's * overflow nor a hover card's transform. */ -export function ProfileSnapshotButton({ +function ArmedProfileSnapshotButton({ origin, filename, - targetId, + ownerId, + targetId = ownerId, targetType = TargetType.ProfilePage, renderCard, size = ButtonSize.XSmall, @@ -91,3 +94,15 @@ export function ProfileSnapshotButton({ ); } + +// Keyed by the owner: a client-side move to another profile reuses this +// component, and a card armed on the last profile would stay mounted with the +// next one's data. +export function ProfileSnapshotButton({ + ownerId, + ...props +}: ProfileSnapshotButtonProps): ReactElement { + return ( + + ); +} diff --git a/packages/shared/src/features/snapshot/SnapshotIdentity.tsx b/packages/shared/src/features/snapshot/SnapshotIdentity.tsx index 1858e397c87..167f49b97a5 100644 --- a/packages/shared/src/features/snapshot/SnapshotIdentity.tsx +++ b/packages/shared/src/features/snapshot/SnapshotIdentity.tsx @@ -26,7 +26,7 @@ export function SnapshotIdentity({ style={{ width: 76, height: 76, borderRadius: 22 }} /> )} -
+
( ), }, diff --git a/packages/storybook/stories/features/snapshot/SnapshotEdgeCases.stories.tsx b/packages/storybook/stories/features/snapshot/SnapshotEdgeCases.stories.tsx index b243c3d5b26..eaa3a3e0be4 100644 --- a/packages/storybook/stories/features/snapshot/SnapshotEdgeCases.stories.tsx +++ b/packages/storybook/stories/features/snapshot/SnapshotEdgeCases.stories.tsx @@ -530,13 +530,14 @@ const CARDS: CardSpec[] = [ node: (ref) => ( ), }, @@ -551,6 +552,10 @@ const CARDS: CardSpec[] = [ rarity={38} seed="ac-b" tier={AchievementRarityTier.Bronze} + user={{ + name: 'A Considerably Longer Display Name For Truncation', + handle: '@an-extremely-long-handle-that-keeps-going', + }} /> ), }, diff --git a/packages/webapp/pages/game-center/index.tsx b/packages/webapp/pages/game-center/index.tsx index 6c0241f3c24..18e7c7f1b92 100644 --- a/packages/webapp/pages/game-center/index.tsx +++ b/packages/webapp/pages/game-center/index.tsx @@ -486,6 +486,7 @@ function GameCenterPage({ Date: Thu, 10 Sep 2026 17:43:03 +0300 Subject: [PATCH 28/30] fix(profile): show the header image's lifetime reads, and no zeros The header card's reads stat was the Reading Overview's six-month sum, labelled "Posts read in the last months". The label squeezed to five lines next to a long Joined value and was vague about its window. The card now shows the lifetime count the DevCard shows (devCard.articlesRead, every view the user has), labelled "Posts read" like the DevCard. It comes from the DevCard's own query, now an options creator the DevCard hook spreads, under the same key so the two share a cache entry. Only the armed card mounts the query, so a profile view does not fetch it; until it answers, or if it fails, the stat is left out. The stat is also left out at zero, and so is Reputation, so a new profile's image reads Joined alone instead of "0 posts read". The reading history options creator no longer has a second reader, so the comment about sharing its cache entry with the header goes. --- .../src/components/profile/ProfileHeader.tsx | 22 +++++--------- .../features/snapshot/ProfileSnapshotCard.tsx | 24 ++++++++------- packages/shared/src/graphql/users.ts | 2 -- .../shared/src/hooks/profile/useDevCard.ts | 29 ++++++++++++------- 4 files changed, 39 insertions(+), 38 deletions(-) diff --git a/packages/shared/src/components/profile/ProfileHeader.tsx b/packages/shared/src/components/profile/ProfileHeader.tsx index 46cf8dcb21a..870a132d476 100644 --- a/packages/shared/src/components/profile/ProfileHeader.tsx +++ b/packages/shared/src/components/profile/ProfileHeader.tsx @@ -29,10 +29,7 @@ import { fallbackImages } from '../../lib/config'; import { ProfileDesktopPwaBackButton } from './ProfileBackButton'; import { ProfileSnapshotButton } from '../../features/snapshot/ProfileSnapshotButton'; import { ProfileSnapshotCard } from '../../features/snapshot/ProfileSnapshotCard'; -import { - profileReadingHistoryQueryOptions, - sumReadHistory, -} from '../../graphql/users'; +import { devCardQueryOptions } from '../../hooks/profile/useDevCard'; import { Tooltip } from '../tooltip/Tooltip'; import { useCopyLink } from '../../hooks/useCopy'; import { useLogContext } from '../../contexts/LogContext'; @@ -63,11 +60,10 @@ const ProfileActions = dynamic( const ProfileCard = forwardRef( function ProfileCard({ user }, ref): ReactElement { - const { tokenRefreshed } = useAuthContext(); - // The widgets column already fetched this, so arming the card is a cache - // read on the profile page. - const { data: readingHistory } = useQuery( - profileReadingHistoryQueryOptions({ user, enabled: tokenRefreshed }), + // The lifetime count the DevCard shows. Only an armed card mounts this, so + // a profile view does not fetch it. + const { data: devCard } = useQuery( + devCardQueryOptions({ userId: user.id }), ); const handle = user.username ?? user.id; @@ -79,11 +75,7 @@ const ProfileCard = forwardRef( image={user.image} joined={format(new Date(user.createdAt), 'MMMM y')} name={user.name} - postsRead={ - readingHistory - ? sumReadHistory(readingHistory.userReadHistory) - : undefined - } + postsRead={devCard?.devCard.articlesRead} ref={ref} reputation={user.reputation} seed={handle} @@ -168,10 +160,10 @@ const ProfileHeader = ({ } // Matches the edit button beside it, which takes Button's default. size={ButtonSize.Medium} - ownerId={user.id} variant={ButtonVariant.Float} /> diff --git a/packages/shared/src/features/snapshot/ProfileSnapshotCard.tsx b/packages/shared/src/features/snapshot/ProfileSnapshotCard.tsx index 6a6cddbe222..5147f251f07 100644 --- a/packages/shared/src/features/snapshot/ProfileSnapshotCard.tsx +++ b/packages/shared/src/features/snapshot/ProfileSnapshotCard.tsx @@ -22,7 +22,7 @@ export interface ProfileSnapshotCardProps { bio?: string; image?: string; cover?: string; - /** Over the Reading Overview's window. Left out when it is not loaded. */ + /** Lifetime, like the DevCard's. Left out when unknown or zero. */ postsRead?: number; joined: string; reputation: number; @@ -116,9 +116,9 @@ function ProfileSnapshotCardComponent( )} - {postsRead !== undefined && ( + {!!postsRead && ( {largeNumberFormat(postsRead) ?? postsRead} @@ -130,14 +130,16 @@ function ProfileSnapshotCardComponent( label="Joined" value={{joined}} /> - - {largeNumberFormat(reputation) ?? reputation} - - } - /> + {!!reputation && ( + + {largeNumberFormat(reputation) ?? reputation} + + } + /> + )}
diff --git a/packages/shared/src/graphql/users.ts b/packages/shared/src/graphql/users.ts index 4a2bd873231..570dd2ddd07 100644 --- a/packages/shared/src/graphql/users.ts +++ b/packages/shared/src/graphql/users.ts @@ -248,8 +248,6 @@ export const getProfileReadingWindow = (): { before: Date; after: Date } => { return { before, after: subMonths(subDays(before, 2), 5) }; }; -// The window is not part of the key, so the profile header's snapshot card -// reads the entry the widgets column already fetched. export const profileReadingHistoryQueryOptions = ({ user, enabled = true, diff --git a/packages/shared/src/hooks/profile/useDevCard.ts b/packages/shared/src/hooks/profile/useDevCard.ts index 2e83e72e853..056a7462dde 100644 --- a/packages/shared/src/hooks/profile/useDevCard.ts +++ b/packages/shared/src/hooks/profile/useDevCard.ts @@ -3,6 +3,8 @@ import type { DevCardTheme } from '../../components/profile/devcard/common'; import { generateQueryKey, RequestKey, StaleTime } from '../../lib/query'; import { DEV_CARD_QUERY } from '../../graphql/users'; import { useRequestProtocol } from '../useRequestProtocol'; +import type { RequestProtocol } from '../../graphql/common'; +import { gqlRequest } from '../../graphql/common'; import type { PublicProfile } from '../../lib/user'; import type { Source } from '../../graphql/sources'; import { cloudinaryDevcardDefaultCoverImage } from '../../lib/image'; @@ -36,18 +38,25 @@ export interface UseDevCard { coverImage: string; } +export const devCardQueryOptions = ({ + userId, + requestMethod = gqlRequest, +}: { + userId: string; + requestMethod?: RequestProtocol['requestMethod']; +}) => ({ + queryKey: generateQueryKey(RequestKey.DevCard, { id: userId }), + queryFn: (): Promise => + requestMethod(DEV_CARD_QUERY, { id: userId }), + staleTime: StaleTime.Default, + enabled: !!userId, +}); + export const useDevCard = (userId: string): UseDevCard => { const { requestMethod } = useRequestProtocol(); - const { data, isLoading } = useQuery({ - queryKey: generateQueryKey(RequestKey.DevCard, { id: userId }), - - queryFn: async () => - await requestMethod(DEV_CARD_QUERY, { - id: userId, - }), - staleTime: StaleTime.Default, - enabled: !!userId, - }); + const { data, isLoading } = useQuery( + devCardQueryOptions({ userId, requestMethod }), + ); const { devCard, userStreakProfile } = data || {}; From dedbf611337ec79549a4b54a29aa15976cd8e7f2 Mon Sep 17 00:00:00 2001 From: Ido Shamun <1993245+idoshamun@users.noreply.github.com> Date: Thu, 10 Sep 2026 17:43:14 +0300 Subject: [PATCH 29/30] fix(profile): offer a profile snapshot only when its card has something Every widget offered its Snapshot whatever it held. On a low-data profile the Achievements image read "0 of 74 unlocked" and "0 Achievement points" over a "Rarest unlocked" heading with nothing under it, Badges read "x0" twice above a stray divider, and Reading Overview showed "(0)" over a blank heatmap. Each widget now offers the button only when its card would show something, judged from the same data the widget renders: - Achievements: at least one unlocked achievement. - Badges & Awards: at least one top reader badge or one award. - Reading Overview: reads in the window, a longest streak, reading days or read tags. A reader with a streak and a quiet six months still gets the image, with the heatmap left off. Inside the cards, a section whose number is zero or whose list is empty is left out instead of rendering a zero or an orphan heading: each streak tile, the posts read line with its heatmap, each badge tally, the badge list, the awards row and its divider, the points tile and the rarest row. --- .../AchievementsWidget.spec.tsx | 27 ++++ .../ProfileWidgets/AchievementsWidget.tsx | 54 ++++--- .../ProfileWidgets/BadgesAndAwards.spec.tsx | 3 + .../ProfileWidgets/BadgesAndAwards.tsx | 76 +++++----- .../ProfileWidgets/ReadingOverview.spec.tsx | 18 +++ .../ProfileWidgets/ReadingOverview.tsx | 41 +++-- .../snapshot/AchievementsSnapshotCard.tsx | 71 +++++---- .../features/snapshot/BadgesSnapshotCard.tsx | 143 ++++++++++-------- .../snapshot/ProfileSnapshotCards.spec.tsx | 107 +++++++++++++ .../snapshot/ReadingOverviewSnapshotCard.tsx | 88 ++++++----- 10 files changed, 413 insertions(+), 215 deletions(-) create mode 100644 packages/shared/src/features/snapshot/ProfileSnapshotCards.spec.tsx diff --git a/packages/shared/src/features/profile/components/ProfileWidgets/AchievementsWidget.spec.tsx b/packages/shared/src/features/profile/components/ProfileWidgets/AchievementsWidget.spec.tsx index f3b4ad2bd77..e219905c800 100644 --- a/packages/shared/src/features/profile/components/ProfileWidgets/AchievementsWidget.spec.tsx +++ b/packages/shared/src/features/profile/components/ProfileWidgets/AchievementsWidget.spec.tsx @@ -142,5 +142,32 @@ describe('AchievementsWidget', () => { .filter((alt): alt is string => expectedVisibleNames.includes(alt ?? '')); expect(renderedNames).toEqual(expectedVisibleNames); + expect(screen.getByLabelText('Snapshot')).toBeInTheDocument(); + }); + + it('should not offer a snapshot before anything is unlocked', () => { + mockUseProfileAchievements.mockReturnValue({ + achievements: [ + createUserAchievement({ + id: 'locked', + name: 'Locked', + rarity: 1, + points: 100, + unlockedAt: null, + }), + ], + unlockedCount: 0, + totalCount: 1, + totalPoints: 0, + isPending: false, + isError: false, + }); + + renderComponent(); + + expect( + screen.getByText('No achievements unlocked yet'), + ).toBeInTheDocument(); + expect(screen.queryByLabelText('Snapshot')).not.toBeInTheDocument(); }); }); diff --git a/packages/shared/src/features/profile/components/ProfileWidgets/AchievementsWidget.tsx b/packages/shared/src/features/profile/components/ProfileWidgets/AchievementsWidget.tsx index f152ee12ac8..61dc5d257ae 100644 --- a/packages/shared/src/features/profile/components/ProfileWidgets/AchievementsWidget.tsx +++ b/packages/shared/src/features/profile/components/ProfileWidgets/AchievementsWidget.tsx @@ -139,31 +139,35 @@ export function AchievementsWidget({ {unlockedCount}/{totalCount} - ( - ({ - image: achievement.image, - name: achievement.name, - }))} - points={totalPoints} - ref={ref} - seed={user.username ?? user.id} - total={totalCount} - unlocked={unlockedCount} - user={{ - handle: `@${user.username ?? user.id}`, - image: user.image, - name: user.name, - }} - /> - )} - ownerId={user.id} - /> + {unlockedCount > 0 && ( + ( + ({ + image: achievement.image, + name: achievement.name, + }))} + points={totalPoints} + ref={ref} + seed={user.username ?? user.id} + total={totalCount} + unlocked={unlockedCount} + user={{ + handle: `@${user.username ?? user.id}`, + image: user.image, + name: user.name, + }} + /> + )} + /> + )}
diff --git a/packages/shared/src/features/profile/components/ProfileWidgets/BadgesAndAwards.spec.tsx b/packages/shared/src/features/profile/components/ProfileWidgets/BadgesAndAwards.spec.tsx index c7b6a0e135e..d62b5fed301 100644 --- a/packages/shared/src/features/profile/components/ProfileWidgets/BadgesAndAwards.spec.tsx +++ b/packages/shared/src/features/profile/components/ProfileWidgets/BadgesAndAwards.spec.tsx @@ -184,6 +184,8 @@ describe('BadgesAndAwards component', () => { // Should not show any badge or award items expect(screen.queryByRole('list')).not.toBeInTheDocument(); + // Nor offer an image of two zeros + expect(screen.queryByLabelText('Snapshot')).not.toBeInTheDocument(); }); it('should render top reader badges when available', async () => { @@ -206,6 +208,7 @@ describe('BadgesAndAwards component', () => { // Check badge items expect(screen.getByText('JavaScript')).toBeInTheDocument(); expect(screen.getByText('React')).toBeInTheDocument(); + expect(screen.getByLabelText('Snapshot')).toBeInTheDocument(); }); it('should render awards when user has cores access', async () => { diff --git a/packages/shared/src/features/profile/components/ProfileWidgets/BadgesAndAwards.tsx b/packages/shared/src/features/profile/components/ProfileWidgets/BadgesAndAwards.tsx index 64938147ed0..3547f9a79fa 100644 --- a/packages/shared/src/features/profile/components/ProfileWidgets/BadgesAndAwards.tsx +++ b/packages/shared/src/features/profile/components/ProfileWidgets/BadgesAndAwards.tsx @@ -64,6 +64,7 @@ export const BadgesAndAwards = ({ const totalAwards = awards?.reduce((sum, award) => sum + (award?.count || 0), 0) ?? 0; + const topReaderBadges = topReaders?.[0]?.total ?? 0; return ( @@ -77,40 +78,42 @@ export const BadgesAndAwards = ({ > Badges & Awards - ( - ({ - 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, - })) ?? [] - } - ref={ref} - seed={user.username ?? user.id} - topReaderBadges={topReaders?.[0]?.total ?? 0} - totalAwards={totalAwards} - user={{ - handle: `@${user.username ?? user.id}`, - image: user.image, - name: user.name, - }} - /> - )} - ownerId={user.id} - /> + {(topReaderBadges > 0 || totalAwards > 0) && ( + ( + ({ + 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, + })) ?? [] + } + ref={ref} + seed={user.username ?? user.id} + topReaderBadges={topReaderBadges} + totalAwards={totalAwards} + user={{ + handle: `@${user.username ?? user.id}`, + image: user.image, + name: user.name, + }} + /> + )} + /> + )}
- +
diff --git a/packages/shared/src/features/profile/components/ProfileWidgets/ReadingOverview.spec.tsx b/packages/shared/src/features/profile/components/ProfileWidgets/ReadingOverview.spec.tsx index 23ef4e72d26..549f28d046e 100644 --- a/packages/shared/src/features/profile/components/ProfileWidgets/ReadingOverview.spec.tsx +++ b/packages/shared/src/features/profile/components/ProfileWidgets/ReadingOverview.spec.tsx @@ -120,6 +120,24 @@ describe('ReadingOverview component', () => { expect(screen.getByText('react')).toBeInTheDocument(); expect(screen.getByText('+60%')).toBeInTheDocument(); // javascript percentage expect(screen.getByText('+40%')).toBeInTheDocument(); // react percentage + expect(screen.getByLabelText('Snapshot')).toBeInTheDocument(); + }); + + it('should not offer a snapshot when there is no reading to show', () => { + renderComponent({ + readHistory: [], + streak: { ...mockStreak, max: 0, total: 0, current: 0 }, + mostReadTags: [], + }); + + expect(screen.getByText('Reading Overview')).toBeInTheDocument(); + expect(screen.queryByLabelText('Snapshot')).not.toBeInTheDocument(); + }); + + it('should offer a snapshot for a streak with no reads in the window', () => { + renderComponent({ readHistory: [], mostReadTags: [] }); + + expect(screen.getByLabelText('Snapshot')).toBeInTheDocument(); }); it('should render the keyword title once it is available', async () => { diff --git a/packages/shared/src/features/profile/components/ProfileWidgets/ReadingOverview.tsx b/packages/shared/src/features/profile/components/ProfileWidgets/ReadingOverview.tsx index fe9a5fb2f1d..1a0672891f6 100644 --- a/packages/shared/src/features/profile/components/ProfileWidgets/ReadingOverview.tsx +++ b/packages/shared/src/features/profile/components/ProfileWidgets/ReadingOverview.tsx @@ -135,6 +135,13 @@ export function ReadingOverview({ isLoading = false, }: ReadingOverviewProps): ReactElement { const totalReads = sumReadHistory(readHistory); + // The card leaves out every section whose number is zero, so with no reads, + // no streak and no tags there would be nothing on it but the name. + const hasSnapshot = + totalReads > 0 || + !!streak?.max || + !!streak?.total || + !!mostReadTags?.length; if (isLoading) { return ; @@ -152,22 +159,24 @@ export function ReadingOverview({ > Reading Overview - ( - - )} - ownerId={user.id} - /> + {hasSnapshot && ( + ( + + )} + /> + )}
- + {points > 0 && ( + + )}
-
- Rarest unlocked -
- {achievements.slice(0, 10).map((achievement) => ( - - {achievement.image ? ( - - ) : ( - - {achievement.emoji} - - )} - - ))} + {achievements.length > 0 && ( +
+ Rarest unlocked +
+ {achievements.slice(0, 10).map((achievement) => ( + + {achievement.image ? ( + + ) : ( + + {achievement.emoji} + + )} + + ))} +
-
+ )}
); diff --git a/packages/shared/src/features/snapshot/BadgesSnapshotCard.tsx b/packages/shared/src/features/snapshot/BadgesSnapshotCard.tsx index ea79db1db00..1973be7dcec 100644 --- a/packages/shared/src/features/snapshot/BadgesSnapshotCard.tsx +++ b/packages/shared/src/features/snapshot/BadgesSnapshotCard.tsx @@ -25,6 +25,7 @@ export interface AwardTally { export interface BadgesSnapshotCardProps { user: SnapshotIdentityProps; + /** Each tally and list is left out when it is zero or empty. */ topReaderBadges: number; totalAwards: number; badges: TopReaderBadge[]; @@ -52,75 +53,87 @@ function BadgesSnapshotCardComponent(
-
- - -
+ {(topReaderBadges > 0 || totalAwards > 0) && ( +
+ {topReaderBadges > 0 && ( + + )} + {totalAwards > 0 && ( + + )} +
+ )} -
- {badges.slice(0, 4).map((badge) => ( -
- 0 && ( +
+ {badges.slice(0, 4).map((badge) => ( +
- {badge.keyword} - - - {badge.earnedAt} - -
- ))} -
- -
- {awards.slice(0, 6).map((award) => ( -
- {award.image ? ( - - ) : ( - - {award.emoji} + + {badge.keyword} + + + {badge.earnedAt} - )} - + ))} +
+ )} + + {awards.length > 0 && ( +
+ {awards.slice(0, 6).map((award) => ( +
- x{award.count} - -
- ))} -
+ {award.image ? ( + + ) : ( + + {award.emoji} + + )} + + x{award.count} + +
+ ))} +
+ )}
); diff --git a/packages/shared/src/features/snapshot/ProfileSnapshotCards.spec.tsx b/packages/shared/src/features/snapshot/ProfileSnapshotCards.spec.tsx new file mode 100644 index 00000000000..d2b689ea805 --- /dev/null +++ b/packages/shared/src/features/snapshot/ProfileSnapshotCards.spec.tsx @@ -0,0 +1,107 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { ProfileSnapshotCard } from './ProfileSnapshotCard'; +import { ReadingOverviewSnapshotCard } from './ReadingOverviewSnapshotCard'; +import { BadgesSnapshotCard } from './BadgesSnapshotCard'; +import { AchievementsSnapshotCard } from './AchievementsSnapshotCard'; + +const user = { name: 'Ada Lovelace', handle: '@ada' }; + +describe('profile snapshot cards with little to show', () => { + it('leaves zero stats off the header card', () => { + render( + , + ); + + expect(screen.getByText('Joined')).toBeInTheDocument(); + expect(screen.queryByText('Posts read')).not.toBeInTheDocument(); + expect(screen.queryByText('Reputation')).not.toBeInTheDocument(); + }); + + it('labels the header card reads as a plain count', () => { + render( + , + ); + + expect(screen.getByText('Posts read')).toBeInTheDocument(); + expect(screen.getByText('1.2K')).toBeInTheDocument(); + }); + + it('leaves the heatmap, tags and zero streak off the reading card', () => { + render( + , + ); + + expect(screen.getByText('Total reading days')).toBeInTheDocument(); + expect(screen.queryByText(/Longest streak/)).not.toBeInTheDocument(); + expect(screen.queryByText(/Posts read/)).not.toBeInTheDocument(); + expect( + screen.queryByText('Top tags by reading days'), + ).not.toBeInTheDocument(); + }); + + it('leaves the zero tally and the empty rows off the badges card', () => { + const { rerender } = render( + , + ); + + expect(screen.getByText('Top reader badge')).toBeInTheDocument(); + expect(screen.queryByText('Total awards')).not.toBeInTheDocument(); + + rerender( + , + ); + + expect(screen.getByText('Total awards')).toBeInTheDocument(); + expect(screen.queryByText('Top reader badge')).not.toBeInTheDocument(); + expect(screen.queryByText('x0')).not.toBeInTheDocument(); + }); + + it('leaves zero points and an empty rarest row off the achievements card', () => { + render( + , + ); + + expect(screen.getByText('of 74 unlocked')).toBeInTheDocument(); + expect(screen.queryByText('Achievement points')).not.toBeInTheDocument(); + expect(screen.queryByText('Rarest unlocked')).not.toBeInTheDocument(); + }); +}); diff --git a/packages/shared/src/features/snapshot/ReadingOverviewSnapshotCard.tsx b/packages/shared/src/features/snapshot/ReadingOverviewSnapshotCard.tsx index e2002f1cd9e..1870bf89e93 100644 --- a/packages/shared/src/features/snapshot/ReadingOverviewSnapshotCard.tsx +++ b/packages/shared/src/features/snapshot/ReadingOverviewSnapshotCard.tsx @@ -31,7 +31,10 @@ export interface ReadingOverviewTag { export interface ReadingOverviewSnapshotCardProps { user: SnapshotIdentityProps; - /** Both left out when there is no streak, as the profile page does. */ + /** + * Each section below is left out when its number is zero or unknown, so a + * quiet stretch reads as fewer sections rather than as zeros. + */ longestStreak?: number; totalReadingDays?: number; postsRead: number; @@ -102,19 +105,26 @@ function ReadingOverviewSnapshotCardComponent(
- {longestStreak !== undefined && totalReadingDays !== undefined && ( + {(!!longestStreak || !!totalReadingDays) && (
- - + {!!longestStreak && ( + + )} + {!!totalReadingDays && ( + + )}
)} @@ -135,32 +145,34 @@ function ReadingOverviewSnapshotCardComponent(
)} -
- - Posts read {monthsLabel} ( - {largeNumberFormat(postsRead) ?? postsRead}) - -
- {cells.map((level, index) => ( - - ))} + {postsRead > 0 && ( +
+ + Posts read {monthsLabel} ( + {largeNumberFormat(postsRead) ?? postsRead}) + +
+ {cells.map((level, index) => ( + + ))} +
-
+ )}
); From 4275a955c54832dec29a5bbb2be2213cad30d140 Mon Sep 17 00:00:00 2001 From: Ido Shamun <1993245+idoshamun@users.noreply.github.com> Date: Thu, 10 Sep 2026 17:43:21 +0300 Subject: [PATCH 30/30] fix(devcard): share a tracked profile link, like the profile header The DevCard's Share copied the bare permalink, with no cid or userid and no short link, while the profile header's copy link gave a tracked short link. The share now builds the ShareProfile tracked link without a request, so it reaches the share sheet and the clipboard inside the press, and the copy swaps in the short link through useCopyLink's shorten path once the shortener answers. It no longer goes through useShareOrCopyLink, which awaits the shortener before writing and loses Safari's user gesture (#6566 fixes that hook separately). The header's copy link now writes the tracked link first too, as useSharePost does, instead of the bare permalink with the tracked one only in the short link. When the short link cannot be written, the clipboard keeps an attributed link either way. --- .../src/components/profile/ProfileHeader.tsx | 5 +- .../webapp/__tests__/DevCardShare.spec.tsx | 74 +++++++++++++++++++ .../Customization/DevCard/DevCardStep2.tsx | 53 ++++++++++--- 3 files changed, 118 insertions(+), 14 deletions(-) create mode 100644 packages/webapp/__tests__/DevCardShare.spec.tsx diff --git a/packages/shared/src/components/profile/ProfileHeader.tsx b/packages/shared/src/components/profile/ProfileHeader.tsx index 870a132d476..78c31170415 100644 --- a/packages/shared/src/components/profile/ProfileHeader.tsx +++ b/packages/shared/src/components/profile/ProfileHeader.tsx @@ -32,6 +32,7 @@ import { ProfileSnapshotCard } from '../../features/snapshot/ProfileSnapshotCard import { devCardQueryOptions } from '../../hooks/profile/useDevCard'; import { Tooltip } from '../tooltip/Tooltip'; import { useCopyLink } from '../../hooks/useCopy'; +import { useGetShortUrl } from '../../hooks/utils/useGetShortUrl'; import { useLogContext } from '../../contexts/LogContext'; import { LogEvent, Origin, TargetType } from '../../lib/log'; import { ShareProvider } from '../../lib/share'; @@ -107,6 +108,7 @@ const ProfileHeader = ({ const isSameUser = propIsSameUser ?? loggedUser?.id === user.id; const { logEvent } = useLogContext(); const [isCopying, copyLink] = useCopyLink(); + const { getTrackedUrl } = useGetShortUrl(); const onCopyLink = () => { logEvent({ @@ -119,9 +121,8 @@ const ProfileHeader = ({ }), }); copyLink({ - link: user.permalink, + link: getTrackedUrl(user.permalink, ReferralCampaignKey.ShareProfile), shorten: true, - cid: ReferralCampaignKey.ShareProfile, }); }; diff --git a/packages/webapp/__tests__/DevCardShare.spec.tsx b/packages/webapp/__tests__/DevCardShare.spec.tsx new file mode 100644 index 00000000000..3fa345ae85e --- /dev/null +++ b/packages/webapp/__tests__/DevCardShare.spec.tsx @@ -0,0 +1,74 @@ +import React from 'react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { QueryClient } from '@tanstack/react-query'; +import { TestBootProvider } from '@dailydotdev/shared/__tests__/helpers/boot'; +import loggedUser from '@dailydotdev/shared/__tests__/fixture/loggedUser'; +import type { DevCardQueryData } from '@dailydotdev/shared/src/hooks/profile/useDevCard'; +import { DevCardTheme } from '@dailydotdev/shared/src/components/profile/devcard/common'; +import { + generateQueryKey, + RequestKey, +} from '@dailydotdev/shared/src/lib/query'; +import { LogEvent, Origin } from '@dailydotdev/shared/src/lib/log'; +import { ShareProvider } from '@dailydotdev/shared/src/lib/share'; +import { DevCardStep2 } from '../components/layouts/SettingsLayout/Customization/DevCard/DevCardStep2'; + +const writeText = jest.fn(); +const logEvent = jest.fn(); + +const devCard: DevCardQueryData = { + devCard: { + id: 'dc1', + user: { ...loggedUser, premium: false, reputation: 10 }, + createdAt: '2024-01-01T00:00:00.000Z', + theme: DevCardTheme.Default, + isProfileCover: false, + showBorder: true, + reputation: 10, + articlesRead: 3, + tags: [], + sources: [], + streak: { max: 1 }, + }, + userStreakProfile: { max: 1 }, +}; + +beforeEach(() => { + jest.clearAllMocks(); + writeText.mockResolvedValue(undefined); + Object.assign(navigator, { clipboard: { writeText } }); +}); + +it('copies the profile link with the share campaign on it', async () => { + const client = new QueryClient(); + client.setQueryData( + generateQueryKey(RequestKey.DevCard, { id: loggedUser.id }), + devCard, + ); + + render( + + + , + ); + + fireEvent.click(screen.getByRole('button', { name: 'Share' })); + + await waitFor(() => + expect(writeText).toHaveBeenCalledWith( + `${loggedUser.permalink}?userid=${loggedUser.id}&cid=share_profile`, + ), + ); + expect(logEvent).toHaveBeenCalledWith({ + event_name: LogEvent.ShareDevcard, + target_id: loggedUser.id, + extra: JSON.stringify({ + provider: ShareProvider.CopyLink, + origin: Origin.DevCard, + }), + }); +}); diff --git a/packages/webapp/components/layouts/SettingsLayout/Customization/DevCard/DevCardStep2.tsx b/packages/webapp/components/layouts/SettingsLayout/Customization/DevCard/DevCardStep2.tsx index 6fec9574af6..7363219b003 100644 --- a/packages/webapp/components/layouts/SettingsLayout/Customization/DevCard/DevCardStep2.tsx +++ b/packages/webapp/components/layouts/SettingsLayout/Customization/DevCard/DevCardStep2.tsx @@ -15,8 +15,10 @@ import { useViewSize, ViewSize } from '@dailydotdev/shared/src/hooks'; import type { DevCardQueryData } from '@dailydotdev/shared/src/hooks/profile/useDevCard'; import { useDevCard } from '@dailydotdev/shared/src/hooks/profile/useDevCard'; import { useCopyLink } from '@dailydotdev/shared/src/hooks/useCopy'; -import { useShareOrCopyLink } from '@dailydotdev/shared/src/hooks/useShareOrCopyLink'; +import { useGetShortUrl } from '@dailydotdev/shared/src/hooks/utils/useGetShortUrl'; import { downloadUrl } from '@dailydotdev/shared/src/lib/blob'; +import { ReferralCampaignKey } from '@dailydotdev/shared/src/lib/referral'; +import { ShareProvider } from '@dailydotdev/shared/src/lib/share'; import { generateQueryKey, RequestKey, @@ -43,7 +45,10 @@ import { DevCardFetchWrapper } from '@dailydotdev/shared/src/components/profile/ import { devCard } from '@dailydotdev/shared/src/lib/constants'; import { checkLowercaseEquality } from '@dailydotdev/shared/src/lib/strings'; import classNames from 'classnames'; -import { isNullOrUndefined } from '@dailydotdev/shared/src/lib/func'; +import { + isNullOrUndefined, + shouldUseNativeShare, +} from '@dailydotdev/shared/src/lib/func'; import { Switch } from '@dailydotdev/shared/src/components/fields/Switch'; import { Typography, @@ -93,15 +98,39 @@ export const DevCardStep2 = ({ [user?.name, user?.username, devCardSrc, type], ); const [copyingEmbed, copyEmbed] = useCopyLink(() => embedCode); - const [sharing, onShareDevCard] = useShareOrCopyLink({ - link: user?.permalink ?? '', - text: 'Check out my #DevCard on daily.dev', - logObject: (provider) => ({ - event_name: LogEvent.ShareDevcard, - target_id: userId, - extra: JSON.stringify({ provider, origin: Origin.DevCard }), - }), - }); + const [copyingProfileLink, copyProfileLink] = useCopyLink(); + const { getTrackedUrl } = useGetShortUrl(); + const onShareDevCard = async () => { + // The tracked link is known without a request, so both the share sheet + // and the clipboard get it inside the press; the copy swaps in the short + // link once it resolves. + const link = getTrackedUrl( + user?.permalink ?? '', + ReferralCampaignKey.ShareProfile, + ); + const logShare = (provider: ShareProvider) => + logEvent({ + event_name: LogEvent.ShareDevcard, + target_id: userId, + extra: JSON.stringify({ provider, origin: Origin.DevCard }), + }); + + if (shouldUseNativeShare()) { + try { + await navigator.share({ + text: `Check out my #DevCard on daily.dev\n${link}`, + }); + logShare(ShareProvider.Native); + } catch { + // Dismissing the sheet rejects too. + } + + return; + } + + logShare(ShareProvider.CopyLink); + copyProfileLink({ link, shorten: true }); + }; const [selectedTab, setSelectedTab] = useState(0); const { mutateAsync: onDownloadUrl, isPending: downloading } = useMutation({ mutationFn: downloadUrl, @@ -260,7 +289,7 @@ export const DevCardStep2 = ({ size={ButtonSize.Medium} icon={} onClick={onShareDevCard} - disabled={sharing || isLoading} + disabled={copyingProfileLink || isLoading} > Share