From 039f0f7f9b2f3d7b6d9dd02cf961a045dd640157 Mon Sep 17 00:00:00 2001 From: tomeredlich Date: Wed, 2 Sep 2026 17:47:01 +0300 Subject: [PATCH 01/13] docs(snapshot): document hot take and reading history share placements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds one Storybook page — Features / Snapshot / Surfaces / Hot takes & history — recording where a share control belongs on the two hot-take frames (the swipe modal, the profile list) and on reading history. Each surface is drawn at desktop, tablet and mobile, with what ships today beside the placement the page argues for. No production surface changes. The controls are inert: the page compares placement, not behaviour. SnapshotIcon comes with it because the page draws it and it is not on main yet. Mockup-to-eng-pass: 1 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 + .../features/snapshot/surfaceChrome.tsx | 217 ++++++++++ .../surfaces/HotTakesAndHistory.stories.tsx | 392 ++++++++++++++++++ 6 files changed, 644 insertions(+) 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/storybook/stories/features/snapshot/surfaceChrome.tsx create mode 100644 packages/storybook/stories/features/snapshot/surfaces/HotTakesAndHistory.stories.tsx 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/storybook/stories/features/snapshot/surfaceChrome.tsx b/packages/storybook/stories/features/snapshot/surfaceChrome.tsx new file mode 100644 index 00000000000..b6787bff0a6 --- /dev/null +++ b/packages/storybook/stories/features/snapshot/surfaceChrome.tsx @@ -0,0 +1,217 @@ +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'; + +type LeadAction = 'Link' | 'Snapshot'; + +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 */ + +const ICONS: Record = { + Link: , + Snapshot: , +}; + +const LABELS: Record = { + Link: 'Copy link', + Snapshot: 'Snapshot', +}; + +/** + * Inert on purpose: the page compares where a control sits inside a real + * screen, not what it does when pressed. + */ +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 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'; + +/** A control that only works at one of these widths is not a recommendation. */ +const DEVICES: Record = { + 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, +}: { + name: DeviceName; + children: React.ReactNode; +}) => ( +
+ + {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/HotTakesAndHistory.stories.tsx b/packages/storybook/stories/features/snapshot/surfaces/HotTakesAndHistory.stories.tsx new file mode 100644 index 00000000000..c44892c61cd --- /dev/null +++ b/packages/storybook/stories/features/snapshot/surfaces/HotTakesAndHistory.stories.tsx @@ -0,0 +1,392 @@ +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { + DownvoteIcon, + HotIcon, + MenuIcon, + MiniCloseIcon, + ReputationIcon, + UpvoteIcon, +} from '@dailydotdev/shared/src/components/icons'; +import type { DeviceName } from '../surfaceChrome'; +import { + AVATAR, + Category, + Control, + Device, + OverflowMenu, + Rail, + SurfacePage, + Variant, +} from '../surfaceChrome'; + +/** What ships today, against the placement this page argues for. */ +type Placement = 'today' | 'chosen'; + +type ScreenProps = { device: DeviceName; placement: Placement }; + +const DEVICE_ORDER: DeviceName[] = ['Desktop', 'Tablet', 'Mobile']; + +const Rails = ({ + screen: Screen, + placement, +}: { + screen: React.ComponentType; + placement: Placement; +}) => ( + + {DEVICE_ORDER.map((device) => ( + + ))} + +); + +/* --------------------------------------------------------- the swipe modal */ + +const REACTIONS = [ + { glyph: '❄️', label: 'Cold take - downvote', className: '!size-14' }, + { glyph: '😐', label: 'Skip hot take', className: '!size-12' }, + { glyph: '🔥', label: 'Hot take - upvote', className: '!size-14' }, +]; + +const HotTakeModalScreen = ({ device, placement }: ScreenProps) => ( + +
+
+ + Hot Takes + +
+ +
+
+
+
+ 😐 +
+ + Most developers have a talent for turning simple problems into + overengineered nightmares. + + + “Simplicity is prerequisite for reliability” - Edsger + W. Dijkstra + +
+ + + + 587 + + + +
+
+ +
+ +
+ + + James Davis + + + @jamesdavis7 + + + + + 11.4K + +
+
+
+
+ +
+ {REACTIONS.map(({ glyph, label, className }) => ( +
+ +
+ +
+
+
+); + +/* -------------------------------------------------------- the profile list */ + +const TAKES = [ + { + emoji: '🔥', + title: 'Microservices were a mistake for most teams', + subtitle: 'Distributed systems are a tax, not a feature', + upvotes: 128, + }, + { + emoji: '🧊', + title: 'Code review is mostly theatre', + subtitle: 'Two approvals, forty seconds of reading', + upvotes: 64, + }, +]; + +const HotTakeRow = ({ + take, + placement, +}: { + take: (typeof TAKES)[number]; + placement: Placement; +}) => ( +
+
+ {take.emoji} +
+
+ + {take.title} + + {take.subtitle} +
+
+ {placement === 'chosen' && ( + + )} + +
+
+); + +const HotTakeListScreen = ({ device, placement }: ScreenProps) => ( + +
+ Hot takes + {TAKES.map((take) => ( + + ))} +
+
+); + +/* -------------------------------------------------------- reading history */ + +const HISTORY = [ + 'Why iconic tech brands lost their dominance', + 'The case against microservices', + 'Postgres is all you need, again', +]; + +const HistoryRow = ({ + title, + device, + placement, + menuOpen, +}: { + title: string; + device: DeviceName; + placement: Placement; + menuOpen?: boolean; +}) => ( +
+
+
+ +
+
+

+ {title} +

+ + 4 min read · 128 upvotes + +
+
+ {device === 'Desktop' && ( + <> +
+
+
+); + +const HistoryScreen = ({ device, placement }: ScreenProps) => ( + +
+ + Reading history + + {HISTORY.map((title, index) => ( + + ))} +
+
+); + +/* -------------------------------------------------------------------- page */ + +const HotTakesAndHistory = () => ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + +); + +const meta: Meta = { + title: 'Features/Snapshot/Surfaces/Hot takes & history', + component: HotTakesAndHistory, + parameters: { layout: 'fullscreen' }, +}; + +export default meta; + +export const Variations: StoryObj = {}; From 0a096852f63efc0c569f9e69dcb25c2cf37efe0f Mon Sep 17 00:00:00 2001 From: tomeredlich Date: Thu, 3 Sep 2026 12:50:38 +0300 Subject: [PATCH 02/13] docs(snapshot): cut the page to the placements it argues for MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each of the three surfaces was drawn twice, before and after. A review page for a change should show the change, so the before halves go: the modal's Float snapshot, the profile list with no share route, and the history row's ⋯ menu. The placement prop that switched between them, and the menu furniture only the history row needed, go with them. Co-Authored-By: Claude Opus 5 --- .../features/snapshot/surfaceChrome.tsx | 36 ------ .../surfaces/HotTakesAndHistory.stories.tsx | 120 +++++------------- 2 files changed, 31 insertions(+), 125 deletions(-) diff --git a/packages/storybook/stories/features/snapshot/surfaceChrome.tsx b/packages/storybook/stories/features/snapshot/surfaceChrome.tsx index b6787bff0a6..efb1f8eb33e 100644 --- a/packages/storybook/stories/features/snapshot/surfaceChrome.tsx +++ b/packages/storybook/stories/features/snapshot/surfaceChrome.tsx @@ -6,7 +6,6 @@ import { } from '@dailydotdev/shared/src/components/buttons/Button'; import { LinkIcon, - ShareIcon, SnapshotIcon, } from '@dailydotdev/shared/src/components/icons'; @@ -73,41 +72,6 @@ export const Control = ({ /* ---------------------------------------------------------- page furniture */ -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'; /** A control that only works at one of these widths is not a recommendation. */ diff --git a/packages/storybook/stories/features/snapshot/surfaces/HotTakesAndHistory.stories.tsx b/packages/storybook/stories/features/snapshot/surfaces/HotTakesAndHistory.stories.tsx index c44892c61cd..abaa2f31895 100644 --- a/packages/storybook/stories/features/snapshot/surfaces/HotTakesAndHistory.stories.tsx +++ b/packages/storybook/stories/features/snapshot/surfaces/HotTakesAndHistory.stories.tsx @@ -19,29 +19,23 @@ import { Category, Control, Device, - OverflowMenu, Rail, SurfacePage, Variant, } from '../surfaceChrome'; -/** What ships today, against the placement this page argues for. */ -type Placement = 'today' | 'chosen'; - -type ScreenProps = { device: DeviceName; placement: Placement }; +type ScreenProps = { device: DeviceName }; const DEVICE_ORDER: DeviceName[] = ['Desktop', 'Tablet', 'Mobile']; const Rails = ({ screen: Screen, - placement, }: { screen: React.ComponentType; - placement: Placement; }) => ( {DEVICE_ORDER.map((device) => ( - + ))} ); @@ -54,7 +48,7 @@ const REACTIONS = [ { glyph: '🔥', label: 'Hot take - upvote', className: '!size-14' }, ]; -const HotTakeModalScreen = ({ device, placement }: ScreenProps) => ( +const HotTakeModalScreen = ({ device }: ScreenProps) => (
@@ -92,12 +86,8 @@ const HotTakeModalScreen = ({ device, placement }: ScreenProps) => (
@@ -173,13 +163,7 @@ const TAKES = [ }, ]; -const HotTakeRow = ({ - take, - placement, -}: { - take: (typeof TAKES)[number]; - placement: Placement; -}) => ( +const HotTakeRow = ({ take }: { take: (typeof TAKES)[number] }) => (
{take.emoji} @@ -191,9 +175,7 @@ const HotTakeRow = ({ {take.subtitle}
- {placement === 'chosen' && ( - - )} +
); -const HotTakeListScreen = ({ device, placement }: ScreenProps) => ( +const HotTakeListScreen = ({ device }: ScreenProps) => (
Hot takes {TAKES.map((take) => ( - + ))}
@@ -227,13 +209,9 @@ const HISTORY = [ const HistoryRow = ({ title, device, - placement, - menuOpen, }: { title: string; device: DeviceName; - placement: Placement; - menuOpen?: boolean; }) => (
@@ -269,40 +247,25 @@ const HistoryRow = ({ /> )} - {placement === 'chosen' && } -
-
+ +
); -const HistoryScreen = ({ device, placement }: ScreenProps) => ( +const HistoryScreen = ({ device }: ScreenProps) => (
Reading history - {HISTORY.map((title, index) => ( - + {HISTORY.map((title) => ( + ))}
@@ -319,63 +282,42 @@ const HotTakesAndHistory = () => ( - - - - + - - - - + - - - - + From 802cad4cf36de9581f93129b9b31af347975acd6 Mon Sep 17 00:00:00 2001 From: tomeredlich Date: Thu, 3 Sep 2026 16:15:55 +0300 Subject: [PATCH 03/13] feat(history): copy a post link from the reading history row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reading-history row is a pointer back to a post, so copying its link is the share that fits it. The control sits before the ⋯ menu, icon-only because the row already drops its vote buttons below laptop, and always visible rather than hover-gated so it survives touch. CopyStateIcon confirms on the button itself: the copy glyph and a green check share one grid cell, so nothing beside them moves during the swap. Gated on showCopyLink, which only the history list passes, so the reading-history modal keeps the DOM it has. ReadingHistoryList came into the strict-typecheck guard's scope with this change, which surfaced an untyped reduce accumulator and an unguarded Date built from an optional field. Both are typed now, with no change to what runs. Co-Authored-By: Claude Opus 5 --- .../history/ReadingHistory.spec.tsx | 22 ++++++- .../components/history/ReadingHistoryList.tsx | 58 ++++++++++--------- .../src/components/post/PostItemCard.tsx | 18 ++++++ .../src/components/share/CopyStateIcon.tsx | 46 +++++++++++++++ 4 files changed, 117 insertions(+), 27 deletions(-) create mode 100644 packages/shared/src/components/share/CopyStateIcon.tsx diff --git a/packages/shared/src/components/history/ReadingHistory.spec.tsx b/packages/shared/src/components/history/ReadingHistory.spec.tsx index d25c8bee114..de835c2cf3d 100644 --- a/packages/shared/src/components/history/ReadingHistory.spec.tsx +++ b/packages/shared/src/components/history/ReadingHistory.spec.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { subDays } from 'date-fns'; import type { RenderResult } from '@testing-library/react'; -import { fireEvent, render, screen } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import nock from 'nock'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import type { PostItemCardProps } from '../post/PostItemCard'; @@ -199,6 +199,26 @@ describe('PostItemCard component', () => { ); }); + it('should copy the post link and confirm on the button itself', async () => { + const writeText = jest.fn().mockResolvedValue(undefined); + Object.assign(navigator, { clipboard: { writeText } }); + + renderCard({ showCopyLink: true }); + + fireEvent.click(await screen.findByLabelText('Copy link')); + + await waitFor(() => + expect(writeText).toHaveBeenCalledWith(post.commentsPermalink), + ); + await screen.findByLabelText('Link copied'); + }); + + it('should not render the copy link button by default', async () => { + renderCard(); + await screen.findByText(postTitle); + expect(screen.queryByLabelText('Copy link')).not.toBeInTheDocument(); + }); + it('should call onHide on close button clicked', async () => { renderCard({ onHide }); const button = (await screen.findAllByRole('button'))[0]; diff --git a/packages/shared/src/components/history/ReadingHistoryList.tsx b/packages/shared/src/components/history/ReadingHistoryList.tsx index cf17c826d9b..befe9b2b9f2 100644 --- a/packages/shared/src/components/history/ReadingHistoryList.tsx +++ b/packages/shared/src/components/history/ReadingHistoryList.tsx @@ -23,37 +23,43 @@ export default function ReadHistoryList({ let currentDate: Date; return data?.pages.map((page, pageIndex) => - page.readHistory.edges.reduce((dom, { node: history }, edgeIndex) => { - const { timestamp } = history; - const date = new Date(timestamp); + page.readHistory.edges.reduce( + (dom, { node: history }, edgeIndex) => { + const { timestamp } = history; + // Optional only because PostItem is shared with surfaces that carry + // no timestamp; every reading-history edge has one. + const date = new Date(timestamp as Date); + + if (!currentDate || !isDateOnlyEqual(currentDate, date)) { + currentDate = date; + dom.push( + , + ); + } + + const indexes = { page: pageIndex, edge: edgeIndex }; - if (!currentDate || !isDateOnlyEqual(currentDate, date)) { - currentDate = date; dom.push( - onHide({ ...params, ...indexes })} + showVoteActions + showCopyLink + logOrigin={Origin.History} />, ); - } - - const indexes = { page: pageIndex, edge: edgeIndex }; - - dom.push( - onHide({ ...params, ...indexes })} - showVoteActions - logOrigin={Origin.History} - />, - ); - return dom; - }, []), + return dom; + }, + [], + ), ); // @NOTE see https://dailydotdev.atlassian.net/l/cp/dK9h1zoM // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/packages/shared/src/components/post/PostItemCard.tsx b/packages/shared/src/components/post/PostItemCard.tsx index 4a49c05a0ed..ac148a74857 100644 --- a/packages/shared/src/components/post/PostItemCard.tsx +++ b/packages/shared/src/components/post/PostItemCard.tsx @@ -24,6 +24,8 @@ import { isSourceUserSource } from '../../graphql/sources'; import { ReadingHistoryOptionsMenu } from '../history/ReadingHistoryOptionsMenu'; import type { QueryIndexes } from '../../hooks/useReadingHistory'; +import { useCopyPostLink } from '../../hooks/useCopyPostLink'; +import { CopyStateIcon } from '../share/CopyStateIcon'; export interface PostItemCardProps { className?: string; @@ -32,6 +34,7 @@ export interface PostItemCardProps { clickable?: boolean; onHide?: (params: HidePostItemCardProps) => Promise; showVoteActions?: boolean; + showCopyLink?: boolean; logOrigin?: Origin; indexes?: QueryIndexes; } @@ -48,6 +51,7 @@ export default function PostItemCard({ onHide, className, showVoteActions = false, + showCopyLink = false, logOrigin = Origin.Feed, indexes, }: PostItemCardProps): ReactElement { @@ -66,6 +70,7 @@ export default function PostItemCard({ const isUserSource = isSourceUserSource(source); const { toggleUpvote, toggleDownvote } = useReadHistoryVotePost(); + const [copying, copyLink] = useCopyPostLink(post.commentsPermalink); const classes = classNames( 'relative flex w-full flex-row py-3 pl-9 pr-5', @@ -185,6 +190,19 @@ export default function PostItemCard({ onClick={onHideClick} /> )} + {showButtons && showCopyLink && ( + + + ); +} 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.spec.ts b/packages/shared/src/lib/imageShare/copyShareImage.spec.ts new file mode 100644 index 00000000000..b6fa2e42a51 --- /dev/null +++ b/packages/shared/src/lib/imageShare/copyShareImage.spec.ts @@ -0,0 +1,70 @@ +import { copyShareImage } from './copyShareImage'; + +const LINK = 'https://app.daily.dev/posts/p1'; + +class FakeClipboardItem { + public readonly types: string[]; + + constructor(public readonly items: Record>) { + this.types = Object.keys(items); + } +} + +const write = jest.fn(); + +beforeEach(() => { + write.mockReset().mockResolvedValue(undefined); + Object.assign(globalThis, { ClipboardItem: FakeClipboardItem }); + Object.assign(navigator, { clipboard: { write } }); +}); + +const blob = () => Promise.resolve(new Blob(['png'], { type: 'image/png' })); + +describe('copyShareImage', () => { + it('puts the image and the link on the clipboard together', async () => { + await expect(copyShareImage(blob(), LINK)).resolves.toBe(true); + + const [[[item]]] = write.mock.calls; + expect(item.types).toEqual(['image/png', 'text/plain']); + // A promise, so the write stays inside the gesture while the short link + // is still being fetched. jsdom's Blob has no text(); size is its bytes. + const text = await (item.items['text/plain'] as Promise); + expect(text.type).toBe('text/plain'); + expect(text.size).toBe(LINK.length); + }); + + it('accepts a link that is still resolving', async () => { + const pending = new Promise((resolve) => { + setTimeout(() => resolve(LINK), 0); + }); + + await expect(copyShareImage(blob(), pending)).resolves.toBe(true); + + const [[[item]]] = write.mock.calls; + const text = await (item.items['text/plain'] as Promise); + expect(text.size).toBe(LINK.length); + }); + + it('keeps the image when a browser refuses two representations', async () => { + write.mockRejectedValueOnce(new Error('NotAllowedError')); + + await expect(copyShareImage(blob(), LINK)).resolves.toBe(true); + + expect(write).toHaveBeenCalledTimes(2); + const [, [[retry]]] = write.mock.calls; + expect(retry.types).toEqual(['image/png']); + }); + + it('copies the image alone when there is no link to carry', async () => { + await expect(copyShareImage(blob())).resolves.toBe(true); + + const [[[item]]] = write.mock.calls; + expect(item.types).toEqual(['image/png']); + }); + + it('reports failure so the caller can fall back to a download', async () => { + write.mockRejectedValue(new Error('NotAllowedError')); + + await expect(copyShareImage(blob(), LINK)).resolves.toBe(false); + }); +}); diff --git a/packages/shared/src/lib/imageShare/copyShareImage.ts b/packages/shared/src/lib/imageShare/copyShareImage.ts new file mode 100644 index 00000000000..2f4d08051e4 --- /dev/null +++ b/packages/shared/src/lib/imageShare/copyShareImage.ts @@ -0,0 +1,50 @@ +/** + * Puts the PNG on the clipboard so it can be pasted straight into a chat or a + * composer, with the post's link beside it as text: a rich composer takes the + * image, a plain one takes the link, and neither leaves the reader having to + * go back for the other half. + * + * 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, + /** May still be resolving — the short link is fetched at press time. */ + link?: string | Promise, +): Promise { + if (typeof ClipboardItem === 'undefined' || !navigator.clipboard?.write) { + return false; + } + + const write = async (item: ClipboardItem): Promise => { + try { + await navigator.clipboard.write([item]); + + return true; + } catch { + return false; + } + }; + + if (link) { + const copied = await write( + new ClipboardItem({ + 'image/png': blob, + // A promise, not an awaited value: awaiting here would end the task + // that handled the gesture, and Safari refuses the write after that. + 'text/plain': Promise.resolve(link).then( + (resolved) => new Blob([resolved], { type: 'text/plain' }), + ), + }), + ); + + if (copied) { + return true; + } + } + + // Not every browser accepts two representations in one item, and the image + // is the half worth keeping when one of them has to go. + return write(new ClipboardItem({ 'image/png': blob })); +} 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..0d73e18826f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -447,6 +447,9 @@ importers: '@tiptap/starter-kit': specifier: ^3.22.5 version: 3.22.5 + '@zumer/snapdom': + specifier: ^2.23.1 + version: 2.24.15 border-beam: specifier: 1.3.0 version: 1.3.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -4834,6 +4837,9 @@ packages: '@xtuc/long@4.2.2': resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==} + '@zumer/snapdom@2.24.15': + resolution: {integrity: sha512-4YE+3ekbBFEAxMyHr++wxOSGt/k+n721eeNT9N9Map27A+ra5sBut3qkYyheYlRj9dJ3HtZaUN1/yrh/aCiuKw==} + abab@2.0.6: resolution: {integrity: sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==} deprecated: Use your platform's native atob() and btoa() methods instead @@ -14226,6 +14232,8 @@ snapshots: '@xtuc/long@4.2.2': {} + '@zumer/snapdom@2.24.15': {} + abab@2.0.6: {} accepts@1.3.8: From 11bba0bb9bc54c661f7a5e4affd42cffe5548a17 Mon Sep 17 00:00:00 2001 From: tomeredlich Date: Sun, 6 Sep 2026 09:17:06 +0300 Subject: [PATCH 05/13] feat(hot-takes): snapshot a hot take from the modal and the profile list A hot take is a self-contained opinion with nowhere to link to, so the card is the whole payload and an image is the only share that carries it. In the swipe modal the control sits beside the upvote pill, labeled and filled, on the top card only: the cards stacked behind are rendered too, and a control on those would capture a take the reader has not reached. On the profile list it is icon-only at XSmall to match the upvote counter it sits next to, and placed before it so the count stays at the edge. Both HotTakeItem variants carry it, so engagement_bar_v2 does not change whether a hot take can be shared. Stacked on the capture path. --- .../modals/hotTakes/HotAndColdModal.spec.tsx | 30 +++++++++++++--- .../modals/hotTakes/HotAndColdModal.tsx | 36 ++++++++++++------- .../components/hotTakes/HotTakeItem.tsx | 11 +++++- .../components/hotTakes/HotTakeItem.v2.tsx | 11 +++++- .../hotTakes/ProfileUserHotTakes.spec.tsx | 11 ++++++ 5 files changed, 80 insertions(+), 19 deletions(-) diff --git a/packages/shared/src/components/modals/hotTakes/HotAndColdModal.spec.tsx b/packages/shared/src/components/modals/hotTakes/HotAndColdModal.spec.tsx index 50d99767be4..a72ba1a48f5 100644 --- a/packages/shared/src/components/modals/hotTakes/HotAndColdModal.spec.tsx +++ b/packages/shared/src/components/modals/hotTakes/HotAndColdModal.spec.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { act, fireEvent, render, screen } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import type { HotTake } from '../../../graphql/user/userHotTake'; import { useDiscoverHotTakes } from '../../../hooks/useDiscoverHotTakes'; import { useVoteHotTake } from '../../../hooks/vote/useVoteHotTake'; @@ -48,11 +49,13 @@ const createHotTake = (id = 'take-1'): HotTake => ({ const renderComponent = (onRequestClose = jest.fn()) => { render( - , + + + , ); return { onRequestClose }; @@ -253,6 +256,23 @@ describe('HotAndColdModal', () => { expect(onRequestClose).toHaveBeenCalledTimes(1); }); + it('should offer a snapshot on the top card only', () => { + mockedUseDiscoverHotTakes.mockReturnValue({ + hotTakes: [createHotTake('top'), createHotTake('behind')], + currentTake: createHotTake('top'), + nextTake: createHotTake('behind'), + isEmpty: false, + isLoading: false, + dismissCurrent, + }); + + renderComponent(); + + // The card behind is rendered too, and a second control would capture a + // take the reader has not reached yet. + expect(screen.getAllByLabelText('Snapshot')).toHaveLength(1); + }); + it('should keep subtitle visible even when title is very long', () => { const currentTake = { ...createHotTake('long-text'), diff --git a/packages/shared/src/components/modals/hotTakes/HotAndColdModal.tsx b/packages/shared/src/components/modals/hotTakes/HotAndColdModal.tsx index 51f9d8628af..cf863d0e1cc 100644 --- a/packages/shared/src/components/modals/hotTakes/HotAndColdModal.tsx +++ b/packages/shared/src/components/modals/hotTakes/HotAndColdModal.tsx @@ -26,6 +26,7 @@ import { TypographyColor, } from '../../typography/Typography'; import { ProfilePicture, ProfileImageSize } from '../../ProfilePicture'; +import { SnapshotButton } from '../../imageShare/SnapshotButton'; import { ReputationUserBadge } from '../../ReputationUserBadge'; import { VerifiedCompanyUserBadge } from '../../VerifiedCompanyUserBadge'; import { PlusUserBadge } from '../../PlusUserBadge'; @@ -862,6 +863,7 @@ const HotTakeCard = ({ isDragging: boolean; dismissDurationMs: number; }): ReactElement => { + const cardRef = useRef(null); const isSkipAnimating = isTop && isDismissAnimating && skipDeltaY !== 0; const isSkipDragging = isTop && !isDismissAnimating && skipDeltaY < 0; const rotation = isTop ? Math.max(Math.min(swipeDelta * 0.08, 18), -18) : 0; @@ -960,6 +962,7 @@ const HotTakeCard = ({ return (
)} - {hotTake.upvotes > 0 && ( -
- - - {hotTake.upvotes} - -
- )} +
+ {hotTake.upvotes > 0 && ( +
+ + + {hotTake.upvotes} + +
+ )} + {isTop && ( + + )} +
{hotTake.user && ( diff --git a/packages/shared/src/features/profile/components/hotTakes/HotTakeItem.tsx b/packages/shared/src/features/profile/components/hotTakes/HotTakeItem.tsx index 5694a1e6812..fda4df035ed 100644 --- a/packages/shared/src/features/profile/components/hotTakes/HotTakeItem.tsx +++ b/packages/shared/src/features/profile/components/hotTakes/HotTakeItem.tsx @@ -1,5 +1,5 @@ import type { ReactElement } from 'react'; -import React from 'react'; +import React, { useRef } from 'react'; import classNames from 'classnames'; import type { HotTake } from '../../../../graphql/user/userHotTake'; import { @@ -19,6 +19,7 @@ import { IconSize } from '../../../../components/Icon'; import { QuaternaryButton } from '../../../../components/buttons/QuaternaryButton'; import { Tooltip } from '../../../../components/tooltip/Tooltip'; import { useEngagementBarV2 } from '../../../../hooks/useEngagementBarV2'; +import { SnapshotButton } from '../../../../components/imageShare/SnapshotButton'; import { HotTakeItem as HotTakeItemV2 } from './HotTakeItem.v2'; interface HotTakeItemProps { @@ -38,9 +39,11 @@ function HotTakeItemV1({ }: HotTakeItemProps): ReactElement { const { emoji, title, subtitle } = item; const isUpvoteActive = item.upvoted; + const rowRef = useRef(null); return (
)} + {onUpvoteClick && ( (null); return (
)} + {onUpvoteClick && ( { expect(screen.getByText('Hot take 1')).toBeVisible(); }); + it('offers a snapshot on every hot take, visitors included', () => { + mockHotTakes({ + hotTakes: [createHotTake(1), createHotTake(2)], + isOwner: false, + }); + + renderProfileUserHotTakes(); + + expect(screen.getAllByLabelText('Snapshot')).toHaveLength(2); + }); + it('renders the hot takes anchor while loading for visitors', () => { mockHotTakes({ isOwner: false, From 813dc1011c6e5ea25ee2da61a9c0dd2b38d23f1b Mon Sep 17 00:00:00 2001 From: tomeredlich Date: Sun, 6 Sep 2026 09:45:04 +0300 Subject: [PATCH 06/13] fix(snapshot): capture the designed hot-take card, not the live row The image was a photograph of the card as it sits on screen, at 1200x630 with a logo bar bolted on. It should be the square card #6544 designs: 1080x1080, a gradient seeded from the take's id, the eyebrow, the take, the emoji as a watermark and the count read as agreement. SnapshotButton now takes that card and mounts it off-screen for as long as the button is mounted, so the capture still starts inside the press: Safari only honours a clipboard write in the task that handled it. Co-Authored-By: Claude Opus 5 --- .../components/imageShare/SnapshotButton.tsx | 88 ++++++--- .../modals/hotTakes/HotAndColdModal.spec.tsx | 27 +-- .../modals/hotTakes/HotAndColdModal.tsx | 5 +- .../components/hotTakes/HotTakeItem.tsx | 7 +- .../components/hotTakes/HotTakeItem.v2.tsx | 7 +- .../hotTakes/ProfileUserHotTakes.spec.tsx | 6 +- .../features/snapshot/HotTakeSnapshotCard.tsx | 33 ++++ .../src/features/snapshot/SnapshotContent.tsx | 180 ++++++++++++++++++ .../src/features/snapshot/SnapshotFrame.tsx | 136 +++++++++++++ .../src/features/snapshot/snapshotGradient.ts | 71 +++++++ .../src/features/snapshot/snapshotText.ts | 26 +++ 11 files changed, 537 insertions(+), 49 deletions(-) create mode 100644 packages/shared/src/features/snapshot/HotTakeSnapshotCard.tsx create mode 100644 packages/shared/src/features/snapshot/SnapshotContent.tsx create mode 100644 packages/shared/src/features/snapshot/SnapshotFrame.tsx create mode 100644 packages/shared/src/features/snapshot/snapshotGradient.ts create mode 100644 packages/shared/src/features/snapshot/snapshotText.ts diff --git a/packages/shared/src/components/imageShare/SnapshotButton.tsx b/packages/shared/src/components/imageShare/SnapshotButton.tsx index 5dc15db9d0d..467cecabc45 100644 --- a/packages/shared/src/components/imageShare/SnapshotButton.tsx +++ b/packages/shared/src/components/imageShare/SnapshotButton.tsx @@ -1,4 +1,4 @@ -import type { ReactElement } from 'react'; +import type { ReactElement, ReactNode } from 'react'; import React, { useCallback, useEffect, useRef, useState } from 'react'; import classNames from 'classnames'; import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; @@ -16,14 +16,30 @@ import { captureShareImage } from '../../lib/imageShare/captureShareImage'; import { downloadShareImage } from '../../lib/imageShare/downloadShareImage'; import { copyShareImage } from '../../lib/imageShare/copyShareImage'; import { playShutterSound } from '../../features/snapshot/shutterSound'; +import { SNAPSHOT_SIZE } from '../../features/snapshot/snapshotGradient'; export const SNAPSHOT_LABEL = 'Snapshot'; /** Matches the snapshot-shutter-sweep animation in utilities.css. */ const SHUTTER_SWEEP_MS = 380; +/** 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, +}; + export interface SnapshotButtonProps { - target: CaptureTarget; + /** + * The designed square card to rasterize. It is mounted off-screen for as + * long as the button is, so the capture can start inside the press: Safari + * only honours a clipboard write in the task that handled the gesture. + */ + card?: ReactNode; + /** Captured instead of `card`, for surfaces with no designed card yet. */ + target?: CaptureTarget; /** * Copied as text beside the image, so a paste carries both halves. A getter * rather than a string: the tracked short link is fetched when pressed, the @@ -41,6 +57,7 @@ export interface SnapshotButtonProps { } export function SnapshotButton({ + card, target, link, filename = 'daily-snapshot', @@ -53,6 +70,7 @@ export function SnapshotButton({ className, }: SnapshotButtonProps): ReactElement { const { displayToast } = useToastNotification(); + const cardRef = useRef(null); const [isCapturing, setIsCapturing] = useState(false); const [isFlashing, setIsFlashing] = useState(false); const flashTimeout = useRef>(); @@ -80,7 +98,16 @@ export function SnapshotButton({ setIsCapturing(true); try { - const capture = captureShareImage(target, captureOptions); + const subject = card ? cardRef : target; + + if (!subject) { + throw new Error('SnapshotButton: nothing to capture'); + } + + const capture = captureShareImage( + subject, + captureOptions ?? (card ? CARD_CAPTURE_OPTIONS : undefined), + ); if (onCapture) { onCapture(await capture); @@ -110,30 +137,41 @@ export function SnapshotButton({ setIsCapturing(false); } }, - [captureOptions, displayToast, filename, link, onCapture, target], + [card, captureOptions, displayToast, filename, link, onCapture, target], ); return ( - - - + <> + {card && ( +
+ {card} +
+ )} + + + + ); } diff --git a/packages/shared/src/components/modals/hotTakes/HotAndColdModal.spec.tsx b/packages/shared/src/components/modals/hotTakes/HotAndColdModal.spec.tsx index a72ba1a48f5..f4b4baa4f43 100644 --- a/packages/shared/src/components/modals/hotTakes/HotAndColdModal.spec.tsx +++ b/packages/shared/src/components/modals/hotTakes/HotAndColdModal.spec.tsx @@ -293,18 +293,21 @@ describe('HotAndColdModal', () => { renderComponent(); - expect(screen.getByText(currentTake.title)).toHaveClass( - 'w-full', - 'break-words', - ); - expect(screen.getByText(currentTake.subtitle)).toHaveClass( - 'w-full', - 'break-words', - 'text-center', - ); - expect(screen.getByText(currentTake.subtitle)).not.toHaveClass( - 'line-clamp-3', - ); + expect( + screen.getByText(currentTake.title, { + ignore: '[aria-hidden="true"], [aria-hidden="true"] *', + }), + ).toHaveClass('w-full', 'break-words'); + expect( + screen.getByText(currentTake.subtitle, { + ignore: '[aria-hidden="true"], [aria-hidden="true"] *', + }), + ).toHaveClass('w-full', 'break-words', 'text-center'); + expect( + screen.getByText(currentTake.subtitle, { + ignore: '[aria-hidden="true"], [aria-hidden="true"] *', + }), + ).not.toHaveClass('line-clamp-3'); }); it('should keep long author names and handles shrinkable in the attribution row', () => { diff --git a/packages/shared/src/components/modals/hotTakes/HotAndColdModal.tsx b/packages/shared/src/components/modals/hotTakes/HotAndColdModal.tsx index cf863d0e1cc..68eb55be722 100644 --- a/packages/shared/src/components/modals/hotTakes/HotAndColdModal.tsx +++ b/packages/shared/src/components/modals/hotTakes/HotAndColdModal.tsx @@ -27,6 +27,7 @@ import { } from '../../typography/Typography'; import { ProfilePicture, ProfileImageSize } from '../../ProfilePicture'; import { SnapshotButton } from '../../imageShare/SnapshotButton'; +import { HotTakeSnapshotCard } from '../../../features/snapshot/HotTakeSnapshotCard'; import { ReputationUserBadge } from '../../ReputationUserBadge'; import { VerifiedCompanyUserBadge } from '../../VerifiedCompanyUserBadge'; import { PlusUserBadge } from '../../PlusUserBadge'; @@ -863,7 +864,6 @@ const HotTakeCard = ({ isDragging: boolean; dismissDurationMs: number; }): ReactElement => { - const cardRef = useRef(null); const isSkipAnimating = isTop && isDismissAnimating && skipDeltaY !== 0; const isSkipDragging = isTop && !isDismissAnimating && skipDeltaY < 0; const rotation = isTop ? Math.max(Math.min(swipeDelta * 0.08, 18), -18) : 0; @@ -962,7 +962,6 @@ const HotTakeCard = ({ return (
} filename={`hot-take-${hotTake.id}`} variant={ButtonVariant.Primary} /> diff --git a/packages/shared/src/features/profile/components/hotTakes/HotTakeItem.tsx b/packages/shared/src/features/profile/components/hotTakes/HotTakeItem.tsx index fda4df035ed..2ea313f9e05 100644 --- a/packages/shared/src/features/profile/components/hotTakes/HotTakeItem.tsx +++ b/packages/shared/src/features/profile/components/hotTakes/HotTakeItem.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 { HotTake } from '../../../../graphql/user/userHotTake'; import { @@ -20,6 +20,7 @@ import { QuaternaryButton } from '../../../../components/buttons/QuaternaryButto import { Tooltip } from '../../../../components/tooltip/Tooltip'; import { useEngagementBarV2 } from '../../../../hooks/useEngagementBarV2'; import { SnapshotButton } from '../../../../components/imageShare/SnapshotButton'; +import { HotTakeSnapshotCard } from '../../../snapshot/HotTakeSnapshotCard'; import { HotTakeItem as HotTakeItemV2 } from './HotTakeItem.v2'; interface HotTakeItemProps { @@ -39,11 +40,9 @@ function HotTakeItemV1({ }: HotTakeItemProps): ReactElement { const { emoji, title, subtitle } = item; const isUpvoteActive = item.upvoted; - const rowRef = useRef(null); return (
)} } filename={`hot-take-${item.id}`} showLabel={false} size={ButtonSize.XSmall} diff --git a/packages/shared/src/features/profile/components/hotTakes/HotTakeItem.v2.tsx b/packages/shared/src/features/profile/components/hotTakes/HotTakeItem.v2.tsx index ffc4cb1f088..0bdc93dbdfd 100644 --- a/packages/shared/src/features/profile/components/hotTakes/HotTakeItem.v2.tsx +++ b/packages/shared/src/features/profile/components/hotTakes/HotTakeItem.v2.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 { HotTake } from '../../../../graphql/user/userHotTake'; import { @@ -17,6 +17,7 @@ import { EditIcon, TrashIcon, UpvoteIcon } from '../../../../components/icons'; import { CardAction } from '../../../../components/buttons/CardAction'; import { Tooltip } from '../../../../components/tooltip/Tooltip'; import { SnapshotButton } from '../../../../components/imageShare/SnapshotButton'; +import { HotTakeSnapshotCard } from '../../../snapshot/HotTakeSnapshotCard'; interface HotTakeItemProps { item: HotTake; @@ -35,11 +36,9 @@ export function HotTakeItem({ }: HotTakeItemProps): ReactElement { const { emoji, title, subtitle } = item; const isUpvoteActive = item.upvoted; - const rowRef = useRef(null); return (
)} } filename={`hot-take-${item.id}`} showLabel={false} size={ButtonSize.XSmall} diff --git a/packages/shared/src/features/profile/components/hotTakes/ProfileUserHotTakes.spec.tsx b/packages/shared/src/features/profile/components/hotTakes/ProfileUserHotTakes.spec.tsx index 94be6c379e1..0150467d905 100644 --- a/packages/shared/src/features/profile/components/hotTakes/ProfileUserHotTakes.spec.tsx +++ b/packages/shared/src/features/profile/components/hotTakes/ProfileUserHotTakes.spec.tsx @@ -264,7 +264,11 @@ describe('ProfileUserHotTakes', () => { ).not.toBeInTheDocument(); expect(screen.queryByText(`1/${MAX_HOT_TAKES}`)).not.toBeInTheDocument(); expect(screen.queryByText(HOT_TAKE_LIMIT_HINT)).not.toBeInTheDocument(); - expect(screen.getByText('Hot take 1')).toBeVisible(); + expect( + screen.getByText('Hot take 1', { + ignore: '[aria-hidden="true"], [aria-hidden="true"] *', + }), + ).toBeVisible(); }); it('offers a snapshot on every hot take, visitors included', () => { diff --git a/packages/shared/src/features/snapshot/HotTakeSnapshotCard.tsx b/packages/shared/src/features/snapshot/HotTakeSnapshotCard.tsx new file mode 100644 index 00000000000..38d6e5b94a2 --- /dev/null +++ b/packages/shared/src/features/snapshot/HotTakeSnapshotCard.tsx @@ -0,0 +1,33 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import type { HotTake } from '../../graphql/user/userHotTake'; +import { SnapshotFrame } from './SnapshotFrame'; +import { HOT_TAKE_EYEBROW_GRADIENT, SnapshotContent } from './SnapshotContent'; + +/** + * The take is the whole payload, so the card carries the opinion rather than + * the row it was read in: no avatar, and the upvote count reads as agreement + * rather than a score. + */ +export const HotTakeSnapshotCard = ({ + take, +}: { + take: HotTake; +}): ReactElement => ( + + 0 + ? { value: `${take.upvotes}`, label: 'found this hot' } + : undefined + } + statVariant="inline" + title={take.title} + titleLines={3} + /> + +); diff --git a/packages/shared/src/features/snapshot/SnapshotContent.tsx b/packages/shared/src/features/snapshot/SnapshotContent.tsx new file mode 100644 index 00000000000..1a793c11107 --- /dev/null +++ b/packages/shared/src/features/snapshot/SnapshotContent.tsx @@ -0,0 +1,180 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import colors from '../../styles/colors'; + +const MUTED = colors.salt['90']; +const DIVIDER = colors.pepper['10']; + +/** + * The production "Happening Now" wordmark animates across + * blueCheese -> cheese -> avocado. A still frame has to pick a position, and + * the yellow-to-green end is the one the brand shots use. + */ +/** Fire, for a take that ran hot: yellow core through orange into red. */ +export const HOT_TAKE_EYEBROW_GRADIENT = `linear-gradient(100deg, ${colors.cheese['40']} 0%, ${colors.ketchup['10']} 48%, ${colors.ketchup['50']} 100%)`; + +export const HIGHLIGHTS_EYEBROW_GRADIENT = `linear-gradient(120deg, ${colors.cheese['40']} 0%, ${colors.avocado['10']} 52%, ${colors.avocado['40']} 100%)`; + +export interface SnapshotAvatar { + src?: string; + name: string; + handle?: string; +} + +export interface SnapshotStat { + value: string; + label: string; +} + +export interface SnapshotContentProps { + eyebrow?: string; + eyebrowGradient?: string; + avatar?: SnapshotAvatar; + emoji?: string; + title: string; + titleLines?: number; + meta?: string[]; + body?: string; + bodyLines?: number; + stat?: SnapshotStat; + /** + * 'display' sets the number apart at headline scale; 'inline' keeps it level + * with its label, so the pair reads as one sentence. + */ + statVariant?: 'display' | 'inline'; +} + +const clamp = (lines: number) => ({ + display: '-webkit-box' as const, + WebkitBoxOrient: 'vertical' as const, + WebkitLineClamp: lines, + overflow: 'hidden' as const, +}); + +export function SnapshotContent({ + eyebrow, + eyebrowGradient, + avatar, + emoji, + title, + titleLines = 4, + meta, + body, + bodyLines = 7, + stat, + statVariant = 'display', +}: SnapshotContentProps): ReactElement { + const isInlineStat = statVariant === 'inline'; + const statColor = eyebrowGradient + ? { + color: 'transparent', + backgroundImage: eyebrowGradient, + backgroundClip: 'text' as const, + WebkitBackgroundClip: 'text' as const, + } + : { color: colors.cabbage['10'] }; + return ( + <> + {eyebrow && ( + + {eyebrow} + + )} + + {emoji && ( + + {emoji} + + )} + + {avatar && ( +
+ {avatar.src && ( + + )} +
+ + {avatar.name} + + {avatar.handle && ( + + {avatar.handle} + + )} +
+
+ )} + +

+ {title} +

+ + {meta && meta.length > 0 && ( + + {meta.join(' · ')} + + )} + + {body && ( + <> + +

+ {body} +

+ + )} + + {stat && ( +
+ + {stat.value} + + {stat.label} +
+ )} + + ); +} diff --git a/packages/shared/src/features/snapshot/SnapshotFrame.tsx b/packages/shared/src/features/snapshot/SnapshotFrame.tsx new file mode 100644 index 00000000000..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/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/snapshotText.ts b/packages/shared/src/features/snapshot/snapshotText.ts new file mode 100644 index 00000000000..dcc0561a55a --- /dev/null +++ b/packages/shared/src/features/snapshot/snapshotText.ts @@ -0,0 +1,26 @@ +/** + * A shared quote reads as one thought, and 280 characters still sets legibly + * inside the square. Longer selections are cut rather than refused: the reader + * gets the opening of what was marked, and the link carries the rest. + */ +export const SNAPSHOT_TEXT_LIMIT = 280; + +export function truncateAtWord( + text: string, + limit = SNAPSHOT_TEXT_LIMIT, +): string { + const trimmed = text.trim(); + + if (trimmed.length <= limit) { + return trimmed; + } + + const cut = trimmed.slice(0, limit); + const lastSpace = cut.lastIndexOf(' '); + + // A single unbroken run longer than the limit has no word to fall back to. + return `${(lastSpace > limit * 0.6 + ? cut.slice(0, lastSpace) + : cut + ).trimEnd()}…`; +} From 5f58d675fc5bad9b63600e642c4dd5d8bdd2162b Mon Sep 17 00:00:00 2001 From: tomeredlich Date: Tue, 8 Sep 2026 12:29:56 +0300 Subject: [PATCH 07/13] style(snapshot): resync the hot-take card with its design MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Picks up the treatment #6544 settled on since the card was copied across. The surface label rides the logo row instead of heading the copy, the take and its subtitle are set as one statement rather than split across two type styles that read as two voices, and the copy is centred. The frame grows to the card rather than holding 1:1, so the capture is measured through getSnapshotCaptureOptions instead of assuming the square: a short take gives a short image rather than one padded out with dead gradient. Carries the .snapshot-copy rule the cards have always referenced, which the first port missed — without it a long take wrapped ragged and a long unbroken word could leave the card. Co-Authored-By: Claude Opus 5 --- .../components/imageShare/SnapshotButton.tsx | 14 +--- .../features/snapshot/HotTakeSnapshotCard.tsx | 22 ++++-- .../src/features/snapshot/SnapshotContent.tsx | 34 ++++++-- .../src/features/snapshot/SnapshotEyebrow.tsx | 39 ++++++++++ .../src/features/snapshot/SnapshotFrame.tsx | 78 ++++++++++++++++--- .../src/features/snapshot/snapshotCapture.ts | 22 ++++++ .../src/features/snapshot/snapshotGradient.ts | 5 ++ .../src/features/snapshot/snapshotText.ts | 14 ++++ packages/shared/src/styles/utilities.css | 9 +++ 9 files changed, 202 insertions(+), 35 deletions(-) create mode 100644 packages/shared/src/features/snapshot/SnapshotEyebrow.tsx create mode 100644 packages/shared/src/features/snapshot/snapshotCapture.ts diff --git a/packages/shared/src/components/imageShare/SnapshotButton.tsx b/packages/shared/src/components/imageShare/SnapshotButton.tsx index 467cecabc45..398fbb048f8 100644 --- a/packages/shared/src/components/imageShare/SnapshotButton.tsx +++ b/packages/shared/src/components/imageShare/SnapshotButton.tsx @@ -16,21 +16,13 @@ import { captureShareImage } from '../../lib/imageShare/captureShareImage'; import { downloadShareImage } from '../../lib/imageShare/downloadShareImage'; import { copyShareImage } from '../../lib/imageShare/copyShareImage'; import { playShutterSound } from '../../features/snapshot/shutterSound'; -import { SNAPSHOT_SIZE } from '../../features/snapshot/snapshotGradient'; +import { getSnapshotCaptureOptions } from '../../features/snapshot/snapshotCapture'; export const SNAPSHOT_LABEL = 'Snapshot'; /** Matches the snapshot-shutter-sweep animation in utilities.css. */ const SHUTTER_SWEEP_MS = 380; -/** 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, -}; - export interface SnapshotButtonProps { /** * The designed square card to rasterize. It is mounted off-screen for as @@ -106,7 +98,9 @@ export function SnapshotButton({ const capture = captureShareImage( subject, - captureOptions ?? (card ? CARD_CAPTURE_OPTIONS : undefined), + // Measured, not assumed: a grown card is taller than the square. + captureOptions ?? + (card ? getSnapshotCaptureOptions(cardRef.current) : undefined), ); if (onCapture) { diff --git a/packages/shared/src/features/snapshot/HotTakeSnapshotCard.tsx b/packages/shared/src/features/snapshot/HotTakeSnapshotCard.tsx index 38d6e5b94a2..8094b5690d9 100644 --- a/packages/shared/src/features/snapshot/HotTakeSnapshotCard.tsx +++ b/packages/shared/src/features/snapshot/HotTakeSnapshotCard.tsx @@ -3,31 +3,37 @@ import React from 'react'; import type { HotTake } from '../../graphql/user/userHotTake'; import { SnapshotFrame } from './SnapshotFrame'; import { HOT_TAKE_EYEBROW_GRADIENT, SnapshotContent } from './SnapshotContent'; +import { SnapshotEyebrow } from './SnapshotEyebrow'; /** * The take is the whole payload, so the card carries the opinion rather than * the row it was read in: no avatar, and the upvote count reads as agreement - * rather than a score. + * rather than a score. Title and subtitle are set as one statement — split + * across two type styles they read as two voices arguing the same point. */ export const HotTakeSnapshotCard = ({ take, }: { take: HotTake; }): ReactElement => ( - + + } + seed={take.id} + watermark={take.emoji} + > 0 ? { value: `${take.upvotes}`, label: 'found this hot' } : undefined } statVariant="inline" - title={take.title} - titleLines={3} + title={[take.title, take.subtitle].filter(Boolean).join(' ')} + titleLines={0} /> ); diff --git a/packages/shared/src/features/snapshot/SnapshotContent.tsx b/packages/shared/src/features/snapshot/SnapshotContent.tsx index 1a793c11107..bb431d62bac 100644 --- a/packages/shared/src/features/snapshot/SnapshotContent.tsx +++ b/packages/shared/src/features/snapshot/SnapshotContent.tsx @@ -1,5 +1,6 @@ import type { ReactElement } from 'react'; import React from 'react'; +import classNames from 'classnames'; import colors from '../../styles/colors'; const MUTED = colors.salt['90']; @@ -32,9 +33,11 @@ export interface SnapshotContentProps { avatar?: SnapshotAvatar; emoji?: string; title: string; + /** 0 lets the title run in full, for frames that grow to fit. */ titleLines?: number; meta?: string[]; body?: string; + /** 0 lets the body run in full, for frames that grow to fit. */ bodyLines?: number; stat?: SnapshotStat; /** @@ -42,14 +45,20 @@ export interface SnapshotContentProps { * with its label, so the pair reads as one sentence. */ statVariant?: 'display' | 'inline'; + /** Centres the copy and its stat, for a card that is one short statement. */ + centered?: boolean; } -const clamp = (lines: number) => ({ - display: '-webkit-box' as const, - WebkitBoxOrient: 'vertical' as const, - WebkitLineClamp: lines, - overflow: 'hidden' as const, -}); +// 0 means no clamp: a growing frame carries the copy instead of cutting it. +const clamp = (lines: number) => + lines + ? { + display: '-webkit-box' as const, + WebkitBoxOrient: 'vertical' as const, + WebkitLineClamp: lines, + overflow: 'hidden' as const, + } + : {}; export function SnapshotContent({ eyebrow, @@ -63,6 +72,7 @@ export function SnapshotContent({ bodyLines = 7, stat, statVariant = 'display', + centered, }: SnapshotContentProps): ReactElement { const isInlineStat = statVariant === 'inline'; const statColor = eyebrowGradient @@ -136,7 +146,10 @@ export function SnapshotContent({ )}

{title} @@ -165,7 +178,12 @@ export function SnapshotContent({ )} {stat && ( -
+
+ {label} + + ); +} diff --git a/packages/shared/src/features/snapshot/SnapshotFrame.tsx b/packages/shared/src/features/snapshot/SnapshotFrame.tsx index 05c5a2c5589..298e40f3f9a 100644 --- a/packages/shared/src/features/snapshot/SnapshotFrame.tsx +++ b/packages/shared/src/features/snapshot/SnapshotFrame.tsx @@ -1,15 +1,28 @@ import type { ReactElement, ReactNode } from 'react'; import React, { forwardRef } from 'react'; +import classNames from 'classnames'; import LogoIcon from '../../svg/LogoIcon'; import LogoText from '../../svg/LogoText'; -import { getSnapshotGradient, SNAPSHOT_SIZE } from './snapshotGradient'; +import { + getSnapshotGradient, + SNAPSHOT_MAX_HEIGHT, + SNAPSHOT_SIZE, +} from './snapshotGradient'; export const SNAPSHOT_CARD_SIZE = 780; +/** + * A page-shaped card: the gradient stays as a border rather than a stage, so + * the copy gets the room instead. Surfaces where the text *is* the payload use + * it — a wide margin around a cramped article is space spent on nothing. + */ +export const SNAPSHOT_CARD_WIDE = 1008; /** Canvas minus the logo row and the gaps either side of the card. */ export const SNAPSHOT_CARD_MAX = SNAPSHOT_SIZE - 150; const CARD_RADIUS = 48; const CARD_EDGE = 2; +const CARD_PADDING = 58; +const CARD_PADDING_WIDE = 32; /** * The App Store device frame: a lit hairline that is brightest along the top @@ -33,8 +46,24 @@ interface SnapshotFrameProps { logoPlacement?: SnapshotLogoPlacement; /** A glyph bled across the card body at low opacity, behind the content. */ watermark?: string; + /** + * Sits on the logo row, far right — for a surface label that belongs with + * the mark rather than with the copy. + */ + logoAside?: ReactNode; /** Drop the card shell and stand the children straight on the gradient. */ bare?: boolean; + /** + * Let the height follow the content instead of holding 1:1. Text surfaces + * use it so the image can carry more than a screenshot would; it still + * starts at the square and stops at SNAPSHOT_MAX_HEIGHT. + */ + grow?: boolean; + /** + * Widen the card to SNAPSHOT_CARD_WIDE and tighten its padding, for surfaces + * whose copy needs the room more than the frame needs the margin. + */ + wide?: boolean; children: ReactNode; } @@ -42,12 +71,17 @@ function SnapshotFrameComponent( { seed, watermark, + logoAside, bare, + grow, + wide, logoPlacement = 'inline', children, }: SnapshotFrameProps, ref: React.Ref, ): ReactElement { + const cardWidth = wide ? SNAPSHOT_CARD_WIDE : SNAPSHOT_CARD_SIZE; + const gutter = (SNAPSHOT_SIZE - cardWidth) / 2; const isOverlaid = logoPlacement !== 'inline'; const overlayStyle = { position: 'absolute' as const, @@ -65,19 +99,38 @@ function SnapshotFrameComponent(
); + const logoRow = logoAside ? ( +
+ {logo} + {logoAside} +
+ ) : ( + logo + ); + return (
{/* Standing alone on the gradient, the collectible has no card to sit in: the mark leads above it, or floats over its artwork. */} - {bare && !isOverlaid && logo} + {bare && !isOverlaid && logoRow} {bare ? (
@@ -87,8 +140,10 @@ function SnapshotFrameComponent( ) : (
- {!isOverlaid && logo} + {!isOverlaid && logoRow} {children}
diff --git a/packages/shared/src/features/snapshot/snapshotCapture.ts b/packages/shared/src/features/snapshot/snapshotCapture.ts new file mode 100644 index 00000000000..ebcfe1a4473 --- /dev/null +++ b/packages/shared/src/features/snapshot/snapshotCapture.ts @@ -0,0 +1,22 @@ +import type { CaptureShareImageOptions } from '../../lib/imageShare/captureShareImage'; +import { SNAPSHOT_MAX_HEIGHT, SNAPSHOT_SIZE } from './snapshotGradient'; + +/** + * A designed card is 1080 wide and carries its own logo, so the capture only + * has to match its height. Growing cards are measured rather than assumed, in + * both directions: assuming the square would letterbox a long passage down to + * a screenshot's worth of text, and pad a short one out with dead gradient. + * An unmeasurable element falls back to the square. + */ +export function getSnapshotCaptureOptions( + element?: HTMLElement | null, +): CaptureShareImageOptions { + const measured = Math.round(element?.getBoundingClientRect().height ?? 0); + + return { + width: SNAPSHOT_SIZE, + height: measured ? Math.min(SNAPSHOT_MAX_HEIGHT, measured) : SNAPSHOT_SIZE, + padding: 0, + branded: false, + }; +} diff --git a/packages/shared/src/features/snapshot/snapshotGradient.ts b/packages/shared/src/features/snapshot/snapshotGradient.ts index 03059ea599c..e6384db61c3 100644 --- a/packages/shared/src/features/snapshot/snapshotGradient.ts +++ b/packages/shared/src/features/snapshot/snapshotGradient.ts @@ -1,4 +1,9 @@ export const SNAPSHOT_SIZE = 1080; +/** + * 9:16 — the tallest frame every share destination still shows whole. Text + * surfaces grow into it instead of clamping their copy to the square. + */ +export const SNAPSHOT_MAX_HEIGHT = 1920; /* eslint-disable no-bitwise -- an FNV hash and a mulberry32 PRNG are defined in terms of integer bit operations; expressing them any other way would diff --git a/packages/shared/src/features/snapshot/snapshotText.ts b/packages/shared/src/features/snapshot/snapshotText.ts index dcc0561a55a..ff239d7dcd6 100644 --- a/packages/shared/src/features/snapshot/snapshotText.ts +++ b/packages/shared/src/features/snapshot/snapshotText.ts @@ -5,6 +5,20 @@ */ export const SNAPSHOT_TEXT_LIMIT = 280; +/** + * The frame grows to fit, so the ceiling on a shared passage is about + * legibility at 1080 wide rather than about the square. + */ +export const SNAPSHOT_PASSAGE_LIMIT = 900; + +/** + * One size for the copy on the post and highlight cards, not a scale. Those + * frames grow to fit now, so type no longer has to shrink to reach the bottom + * of a fixed square — and a shared image that changes size with its length + * reads as two different cards. + */ +export const SNAPSHOT_COPY_SIZE = 38; + export function truncateAtWord( text: string, limit = SNAPSHOT_TEXT_LIMIT, diff --git a/packages/shared/src/styles/utilities.css b/packages/shared/src/styles/utilities.css index dd39dc9678b..8f713ce7a69 100644 --- a/packages/shared/src/styles/utilities.css +++ b/packages/shared/src/styles/utilities.css @@ -1205,3 +1205,12 @@ img.agent-media-ring { opacity: 0; } } + +/* Snapshot copy is rasterized once and never reflows, so it can afford the + expensive wrapping: balance evens the line lengths and removes the orphan + word, and anywhere keeps long URLs or type names inside the card. */ +.snapshot-copy { + text-wrap: balance; + overflow-wrap: anywhere; + hyphens: none; +} From ac303bff1ca18fdbf1009d6dc73ad762068c64aa Mon Sep 17 00:00:00 2001 From: Ido Shamun <1993245+idoshamun@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:27:12 +0300 Subject: [PATCH 08/13] fix(snapshot): port the hot take snapshot onto main's armed card The hot take placements used the vendored card-prop SnapshotButton, which mounted a 1080px card beside every button on render: one per take in a profile list, plus the modal's top card. They now go through HotTakeSnapshotButton, which follows TextSnapshotButton and PollSnapshotButton: useArmedCard mounts the card on hover, touch or focus, and it is portalled to the body because the swipe card it sits on is transformed while it moves. HotTakeSnapshotCard is rebuilt on main's SnapshotFrame and SnapshotEyebrow instead of the vendored SnapshotContent (which main keeps in Storybook), with HOT_TAKE_EYEBROW_GRADIENT defined beside it. The image is unchanged. A hot take is not a post, so the press logs a new ShareHotTake event with the SharePost extra scheme: provider snapshot, the placement as origin (hot and cold for the modal, hot take list for the profile), and the result. Removed with the vendored path: SnapshotContent, the shutter sound and its mp3 (#6556 dropped both), and the spec for the old SnapshotButton API, whose clipboard, download and error cases main already covers in useLogSnapshot.spec. The specs no longer need to skip aria-hidden text, since no card is mounted on load. --- .../imageShare/SnapshotButton.spec.tsx | 85 -------- .../modals/hotTakes/HotAndColdModal.spec.tsx | 27 ++- .../modals/hotTakes/HotAndColdModal.tsx | 9 +- .../components/hotTakes/HotTakeItem.tsx | 10 +- .../components/hotTakes/HotTakeItem.v2.tsx | 10 +- .../hotTakes/ProfileUserHotTakes.spec.tsx | 6 +- .../snapshot/HotTakeSnapshotButton.spec.tsx | 79 +++++++ .../snapshot/HotTakeSnapshotButton.tsx | 82 ++++++++ .../features/snapshot/HotTakeSnapshotCard.tsx | 76 ++++--- .../src/features/snapshot/SnapshotContent.tsx | 198 ------------------ .../src/features/snapshot/shutterSound.ts | 23 -- packages/shared/src/lib/log.ts | 1 + packages/webapp/public/sounds/shutter.mp3 | Bin 45824 -> 0 bytes 13 files changed, 236 insertions(+), 370 deletions(-) delete mode 100644 packages/shared/src/components/imageShare/SnapshotButton.spec.tsx create mode 100644 packages/shared/src/features/snapshot/HotTakeSnapshotButton.spec.tsx create mode 100644 packages/shared/src/features/snapshot/HotTakeSnapshotButton.tsx delete mode 100644 packages/shared/src/features/snapshot/SnapshotContent.tsx delete mode 100644 packages/shared/src/features/snapshot/shutterSound.ts delete mode 100644 packages/webapp/public/sounds/shutter.mp3 diff --git a/packages/shared/src/components/imageShare/SnapshotButton.spec.tsx b/packages/shared/src/components/imageShare/SnapshotButton.spec.tsx deleted file mode 100644 index af330ae8072..00000000000 --- a/packages/shared/src/components/imageShare/SnapshotButton.spec.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import React from 'react'; -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { SnapshotButton } from './SnapshotButton'; -import * as captureModule from '../../lib/imageShare/captureShareImage'; -import * as copyModule from '../../lib/imageShare/copyShareImage'; -import * as downloadModule from '../../lib/imageShare/downloadShareImage'; -import * as shutterModule from '../../features/snapshot/shutterSound'; -import { TOAST_NOTIF_KEY } from '../../hooks/useToastNotification'; - -const blob = new Blob(['png'], { type: 'image/png' }); - -let client: QueryClient; - -const renderButton = ( - props: Partial> = {}, -) => { - client = new QueryClient(); - - return render( - - - , - ); -}; - -const expectToast = (message: string) => - waitFor(() => - expect(client.getQueryData(TOAST_NOTIF_KEY)).toMatchObject({ message }), - ); - -beforeEach(() => { - jest.restoreAllMocks(); - jest.spyOn(shutterModule, 'playShutterSound').mockImplementation(); - jest - .spyOn(captureModule, 'captureShareImage') - .mockResolvedValue(blob as never); -}); - -it('should copy the image and report it', async () => { - const copy = jest.spyOn(copyModule, 'copyShareImage').mockResolvedValue(true); - const download = jest - .spyOn(downloadModule, 'downloadShareImage') - .mockImplementation(); - - renderButton(); - fireEvent.click(await screen.findByLabelText('Snapshot')); - - await waitFor(() => expect(copy).toHaveBeenCalled()); - await expectToast('Image copied'); - expect(download).not.toHaveBeenCalled(); -}); - -it('should download when the clipboard refuses the image', async () => { - jest.spyOn(copyModule, 'copyShareImage').mockResolvedValue(false); - const download = jest - .spyOn(downloadModule, 'downloadShareImage') - .mockImplementation(); - - renderButton({ filename: 'hot-take' }); - fireEvent.click(await screen.findByLabelText('Snapshot')); - - await waitFor(() => expect(download).toHaveBeenCalledWith(blob, 'hot-take')); - await expectToast('Image saved'); -}); - -it('should report a failed capture rather than throwing', async () => { - jest - .spyOn(captureModule, 'captureShareImage') - .mockRejectedValue(new Error('no canvas')); - - renderButton(); - fireEvent.click(await screen.findByLabelText('Snapshot')); - - await expectToast('Could not create the snapshot, please try again'); -}); - -it('should say both halves were copied when a link is passed', async () => { - jest.spyOn(copyModule, 'copyShareImage').mockResolvedValue(true); - - renderButton({ link: () => Promise.resolve('https://daily.dev/p/1') }); - fireEvent.click(await screen.findByLabelText('Snapshot')); - - await expectToast('Image and link copied'); -}); diff --git a/packages/shared/src/components/modals/hotTakes/HotAndColdModal.spec.tsx b/packages/shared/src/components/modals/hotTakes/HotAndColdModal.spec.tsx index f4b4baa4f43..a72ba1a48f5 100644 --- a/packages/shared/src/components/modals/hotTakes/HotAndColdModal.spec.tsx +++ b/packages/shared/src/components/modals/hotTakes/HotAndColdModal.spec.tsx @@ -293,21 +293,18 @@ describe('HotAndColdModal', () => { renderComponent(); - expect( - screen.getByText(currentTake.title, { - ignore: '[aria-hidden="true"], [aria-hidden="true"] *', - }), - ).toHaveClass('w-full', 'break-words'); - expect( - screen.getByText(currentTake.subtitle, { - ignore: '[aria-hidden="true"], [aria-hidden="true"] *', - }), - ).toHaveClass('w-full', 'break-words', 'text-center'); - expect( - screen.getByText(currentTake.subtitle, { - ignore: '[aria-hidden="true"], [aria-hidden="true"] *', - }), - ).not.toHaveClass('line-clamp-3'); + expect(screen.getByText(currentTake.title)).toHaveClass( + 'w-full', + 'break-words', + ); + expect(screen.getByText(currentTake.subtitle)).toHaveClass( + 'w-full', + 'break-words', + 'text-center', + ); + expect(screen.getByText(currentTake.subtitle)).not.toHaveClass( + 'line-clamp-3', + ); }); it('should keep long author names and handles shrinkable in the attribution row', () => { diff --git a/packages/shared/src/components/modals/hotTakes/HotAndColdModal.tsx b/packages/shared/src/components/modals/hotTakes/HotAndColdModal.tsx index 68eb55be722..644aa05a23f 100644 --- a/packages/shared/src/components/modals/hotTakes/HotAndColdModal.tsx +++ b/packages/shared/src/components/modals/hotTakes/HotAndColdModal.tsx @@ -26,8 +26,7 @@ import { TypographyColor, } from '../../typography/Typography'; import { ProfilePicture, ProfileImageSize } from '../../ProfilePicture'; -import { SnapshotButton } from '../../imageShare/SnapshotButton'; -import { HotTakeSnapshotCard } from '../../../features/snapshot/HotTakeSnapshotCard'; +import { HotTakeSnapshotButton } from '../../../features/snapshot/HotTakeSnapshotButton'; import { ReputationUserBadge } from '../../ReputationUserBadge'; import { VerifiedCompanyUserBadge } from '../../VerifiedCompanyUserBadge'; import { PlusUserBadge } from '../../PlusUserBadge'; @@ -1340,9 +1339,9 @@ const HotTakeCard = ({
)} {isTop && ( - } - filename={`hot-take-${hotTake.id}`} + )} diff --git a/packages/shared/src/features/profile/components/hotTakes/HotTakeItem.tsx b/packages/shared/src/features/profile/components/hotTakes/HotTakeItem.tsx index 2ea313f9e05..842d82b9ed8 100644 --- a/packages/shared/src/features/profile/components/hotTakes/HotTakeItem.tsx +++ b/packages/shared/src/features/profile/components/hotTakes/HotTakeItem.tsx @@ -19,8 +19,8 @@ import { IconSize } from '../../../../components/Icon'; import { QuaternaryButton } from '../../../../components/buttons/QuaternaryButton'; import { Tooltip } from '../../../../components/tooltip/Tooltip'; import { useEngagementBarV2 } from '../../../../hooks/useEngagementBarV2'; -import { SnapshotButton } from '../../../../components/imageShare/SnapshotButton'; -import { HotTakeSnapshotCard } from '../../../snapshot/HotTakeSnapshotCard'; +import { HotTakeSnapshotButton } from '../../../snapshot/HotTakeSnapshotButton'; +import { Origin } from '../../../../lib/log'; import { HotTakeItem as HotTakeItemV2 } from './HotTakeItem.v2'; interface HotTakeItemProps { @@ -95,9 +95,9 @@ function HotTakeItemV1({ )}
)} - } - filename={`hot-take-${item.id}`} + diff --git a/packages/shared/src/features/profile/components/hotTakes/HotTakeItem.v2.tsx b/packages/shared/src/features/profile/components/hotTakes/HotTakeItem.v2.tsx index 0bdc93dbdfd..0ddd60c72a7 100644 --- a/packages/shared/src/features/profile/components/hotTakes/HotTakeItem.v2.tsx +++ b/packages/shared/src/features/profile/components/hotTakes/HotTakeItem.v2.tsx @@ -16,8 +16,8 @@ import { import { EditIcon, TrashIcon, UpvoteIcon } from '../../../../components/icons'; import { CardAction } from '../../../../components/buttons/CardAction'; import { Tooltip } from '../../../../components/tooltip/Tooltip'; -import { SnapshotButton } from '../../../../components/imageShare/SnapshotButton'; -import { HotTakeSnapshotCard } from '../../../snapshot/HotTakeSnapshotCard'; +import { HotTakeSnapshotButton } from '../../../snapshot/HotTakeSnapshotButton'; +import { Origin } from '../../../../lib/log'; interface HotTakeItemProps { item: HotTake; @@ -91,9 +91,9 @@ export function HotTakeItem({ )}
)} - } - filename={`hot-take-${item.id}`} + diff --git a/packages/shared/src/features/profile/components/hotTakes/ProfileUserHotTakes.spec.tsx b/packages/shared/src/features/profile/components/hotTakes/ProfileUserHotTakes.spec.tsx index 0150467d905..94be6c379e1 100644 --- a/packages/shared/src/features/profile/components/hotTakes/ProfileUserHotTakes.spec.tsx +++ b/packages/shared/src/features/profile/components/hotTakes/ProfileUserHotTakes.spec.tsx @@ -264,11 +264,7 @@ describe('ProfileUserHotTakes', () => { ).not.toBeInTheDocument(); expect(screen.queryByText(`1/${MAX_HOT_TAKES}`)).not.toBeInTheDocument(); expect(screen.queryByText(HOT_TAKE_LIMIT_HINT)).not.toBeInTheDocument(); - expect( - screen.getByText('Hot take 1', { - ignore: '[aria-hidden="true"], [aria-hidden="true"] *', - }), - ).toBeVisible(); + expect(screen.getByText('Hot take 1')).toBeVisible(); }); it('offers a snapshot on every hot take, visitors included', () => { diff --git a/packages/shared/src/features/snapshot/HotTakeSnapshotButton.spec.tsx b/packages/shared/src/features/snapshot/HotTakeSnapshotButton.spec.tsx new file mode 100644 index 00000000000..ec400c1ce21 --- /dev/null +++ b/packages/shared/src/features/snapshot/HotTakeSnapshotButton.spec.tsx @@ -0,0 +1,79 @@ +import React from 'react'; +import { QueryClient } from '@tanstack/react-query'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { TestBootProvider } from '../../../__tests__/helpers/boot'; +import type { HotTake } from '../../graphql/user/userHotTake'; +import { captureShareImage } from '../../lib/imageShare/captureShareImage'; +import { copyShareImage } from '../../lib/imageShare/copyShareImage'; +import { LogEvent, Origin } from '../../lib/log'; +import { ShareProvider } from '../../lib/share'; +import { HotTakeSnapshotButton } from './HotTakeSnapshotButton'; + +jest.mock('../../lib/imageShare/captureShareImage', () => ({ + captureShareImage: jest.fn(), +})); +jest.mock('../../lib/imageShare/copyShareImage', () => ({ + copyShareImage: jest.fn(), +})); + +const hotTake: HotTake = { + id: 'take-1', + emoji: '🔥', + title: 'Tabs won', + subtitle: 'Prettier just hid the bodies', + position: 0, + createdAt: '2026-09-01T00:00:00.000Z', + upvotes: 12, +}; + +const logEvent = jest.fn(); + +const renderButton = () => + render( + + + , + ); + +const cardCopies = () => + screen.queryAllByText('Tabs won Prettier just hid the bodies').length; + +beforeEach(() => { + logEvent.mockReset(); + jest + .mocked(captureShareImage) + .mockResolvedValue(new Blob(['png'], { type: 'image/png' })); + jest.mocked(copyShareImage).mockResolvedValue(true); +}); + +describe('HotTakeSnapshotButton', () => { + it('keeps the card out of the page until the reader reaches for it', () => { + renderButton(); + + // Every take in a profile list carries a button, and each card is 1080px. + expect(cardCopies()).toBe(0); + + fireEvent.pointerEnter(screen.getByLabelText('Snapshot')); + + expect(cardCopies()).toBe(1); + }); + + it('logs the snapshot as a hot take share with its placement', async () => { + renderButton(); + const button = screen.getByLabelText('Snapshot'); + fireEvent.pointerEnter(button); + fireEvent.click(button); + + await waitFor(() => + expect(logEvent).toHaveBeenCalledWith({ + event_name: LogEvent.ShareHotTake, + target_id: hotTake.id, + extra: JSON.stringify({ + provider: ShareProvider.Snapshot, + origin: Origin.HotTakeList, + result: 'clipboard', + }), + }), + ); + }); +}); diff --git a/packages/shared/src/features/snapshot/HotTakeSnapshotButton.tsx b/packages/shared/src/features/snapshot/HotTakeSnapshotButton.tsx new file mode 100644 index 00000000000..e6343c99ccf --- /dev/null +++ b/packages/shared/src/features/snapshot/HotTakeSnapshotButton.tsx @@ -0,0 +1,82 @@ +import type { ReactElement } from 'react'; +import React, { useCallback, useRef } from 'react'; +import { createPortal } from 'react-dom'; +import type { + ButtonSize, + ButtonVariant, +} from '../../components/buttons/common'; +import type { SnapshotResult } from '../../components/imageShare/SnapshotButton'; +import { SnapshotButton } from '../../components/imageShare/SnapshotButton'; +import { useLogContext } from '../../contexts/LogContext'; +import type { HotTake } from '../../graphql/user/userHotTake'; +import type { Origin } from '../../lib/log'; +import { LogEvent } from '../../lib/log'; +import { ShareProvider } from '../../lib/share'; +import { HotTakeSnapshotCard } from './HotTakeSnapshotCard'; +import { getSnapshotCaptureOptions } from './snapshotCapture'; +import { useArmedCard } from './useArmedCard'; + +/** + * A hot take is a self-contained opinion with nowhere to link to, so the card + * is the whole share. It is portalled to the body: the swipe card it sits on + * is transformed while it moves, which would carry a fixed child with it. + */ +export function HotTakeSnapshotButton({ + hotTake, + origin, + showLabel, + size, + variant, +}: { + hotTake: HotTake; + /** Which placement this is, for the snapshot's share event. */ + origin: Origin; + showLabel?: boolean; + size?: ButtonSize; + variant?: ButtonVariant; +}): ReactElement { + const cardRef = useRef(null); + const { isArmed, armProps } = useArmedCard(); + const { logEvent } = useLogContext(); + + const onResult = useCallback( + (result: SnapshotResult) => + logEvent({ + event_name: LogEvent.ShareHotTake, + target_id: hotTake.id, + extra: JSON.stringify({ + provider: ShareProvider.Snapshot, + origin, + result, + }), + }), + [hotTake.id, logEvent, origin], + ); + + return ( + <> + + getSnapshotCaptureOptions(cardRef.current)} + filename={`hot-take-${hotTake.id}`} + onResult={onResult} + showLabel={showLabel} + size={size} + target={cardRef} + variant={variant} + /> + + {isArmed && + typeof document !== 'undefined' && + createPortal( +
+ +
, + document.body, + )} + + ); +} diff --git a/packages/shared/src/features/snapshot/HotTakeSnapshotCard.tsx b/packages/shared/src/features/snapshot/HotTakeSnapshotCard.tsx index 8094b5690d9..d8ed92abe3b 100644 --- a/packages/shared/src/features/snapshot/HotTakeSnapshotCard.tsx +++ b/packages/shared/src/features/snapshot/HotTakeSnapshotCard.tsx @@ -1,39 +1,57 @@ import type { ReactElement } from 'react'; -import React from 'react'; +import React, { forwardRef } from 'react'; import type { HotTake } from '../../graphql/user/userHotTake'; -import { SnapshotFrame } from './SnapshotFrame'; -import { HOT_TAKE_EYEBROW_GRADIENT, SnapshotContent } from './SnapshotContent'; +import colors from '../../styles/colors'; import { SnapshotEyebrow } from './SnapshotEyebrow'; +import { SnapshotFrame } from './SnapshotFrame'; + +const MUTED = colors.salt['90']; + +/** Fire, for a take that ran hot: yellow core through orange into red. */ +export const HOT_TAKE_EYEBROW_GRADIENT = `linear-gradient(100deg, ${colors.cheese['40']} 0%, ${colors.ketchup['10']} 48%, ${colors.ketchup['50']} 100%)`; /** * The take is the whole payload, so the card carries the opinion rather than * the row it was read in: no avatar, and the upvote count reads as agreement - * rather than a score. Title and subtitle are set as one statement — split + * rather than a score. Title and subtitle are set as one statement: split * across two type styles they read as two voices arguing the same point. */ -export const HotTakeSnapshotCard = ({ - take, -}: { - take: HotTake; -}): ReactElement => ( - - } - seed={take.id} - watermark={take.emoji} - > - 0 - ? { value: `${take.upvotes}`, label: 'found this hot' } - : undefined +function HotTakeSnapshotCardComponent( + { take }: { take: HotTake }, + ref: React.Ref, +): ReactElement { + return ( + } - statVariant="inline" - title={[take.title, take.subtitle].filter(Boolean).join(' ')} - titleLines={0} - /> - -); + ref={ref} + seed={take.id} + watermark={take.emoji} + > +

+ {[take.title, take.subtitle].filter(Boolean).join(' ')} +

+ {take.upvotes > 0 && ( +
+ + {take.upvotes} + + found this hot +
+ )} +
+ ); +} + +export const HotTakeSnapshotCard = forwardRef(HotTakeSnapshotCardComponent); diff --git a/packages/shared/src/features/snapshot/SnapshotContent.tsx b/packages/shared/src/features/snapshot/SnapshotContent.tsx deleted file mode 100644 index bb431d62bac..00000000000 --- a/packages/shared/src/features/snapshot/SnapshotContent.tsx +++ /dev/null @@ -1,198 +0,0 @@ -import type { ReactElement } from 'react'; -import React from 'react'; -import classNames from 'classnames'; -import colors from '../../styles/colors'; - -const MUTED = colors.salt['90']; -const DIVIDER = colors.pepper['10']; - -/** - * The production "Happening Now" wordmark animates across - * blueCheese -> cheese -> avocado. A still frame has to pick a position, and - * the yellow-to-green end is the one the brand shots use. - */ -/** Fire, for a take that ran hot: yellow core through orange into red. */ -export const HOT_TAKE_EYEBROW_GRADIENT = `linear-gradient(100deg, ${colors.cheese['40']} 0%, ${colors.ketchup['10']} 48%, ${colors.ketchup['50']} 100%)`; - -export const HIGHLIGHTS_EYEBROW_GRADIENT = `linear-gradient(120deg, ${colors.cheese['40']} 0%, ${colors.avocado['10']} 52%, ${colors.avocado['40']} 100%)`; - -export interface SnapshotAvatar { - src?: string; - name: string; - handle?: string; -} - -export interface SnapshotStat { - value: string; - label: string; -} - -export interface SnapshotContentProps { - eyebrow?: string; - eyebrowGradient?: string; - avatar?: SnapshotAvatar; - emoji?: string; - title: string; - /** 0 lets the title run in full, for frames that grow to fit. */ - titleLines?: number; - meta?: string[]; - body?: string; - /** 0 lets the body run in full, for frames that grow to fit. */ - bodyLines?: number; - stat?: SnapshotStat; - /** - * 'display' sets the number apart at headline scale; 'inline' keeps it level - * with its label, so the pair reads as one sentence. - */ - statVariant?: 'display' | 'inline'; - /** Centres the copy and its stat, for a card that is one short statement. */ - centered?: boolean; -} - -// 0 means no clamp: a growing frame carries the copy instead of cutting it. -const clamp = (lines: number) => - lines - ? { - display: '-webkit-box' as const, - WebkitBoxOrient: 'vertical' as const, - WebkitLineClamp: lines, - overflow: 'hidden' as const, - } - : {}; - -export function SnapshotContent({ - eyebrow, - eyebrowGradient, - avatar, - emoji, - title, - titleLines = 4, - meta, - body, - bodyLines = 7, - stat, - statVariant = 'display', - centered, -}: SnapshotContentProps): ReactElement { - const isInlineStat = statVariant === 'inline'; - const statColor = eyebrowGradient - ? { - color: 'transparent', - backgroundImage: eyebrowGradient, - backgroundClip: 'text' as const, - WebkitBackgroundClip: 'text' as const, - } - : { color: colors.cabbage['10'] }; - return ( - <> - {eyebrow && ( - - {eyebrow} - - )} - - {emoji && ( - - {emoji} - - )} - - {avatar && ( -
- {avatar.src && ( - - )} -
- - {avatar.name} - - {avatar.handle && ( - - {avatar.handle} - - )} -
-
- )} - -

- {title} -

- - {meta && meta.length > 0 && ( - - {meta.join(' · ')} - - )} - - {body && ( - <> - -

- {body} -

- - )} - - {stat && ( -
- - {stat.value} - - {stat.label} -
- )} - - ); -} 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/lib/log.ts b/packages/shared/src/lib/log.ts index bf644bf2bb5..8c064dd889c 100644 --- a/packages/shared/src/lib/log.ts +++ b/packages/shared/src/lib/log.ts @@ -361,6 +361,7 @@ export enum LogEvent { ShareLog = 'share log', ShareWorld = 'share world', ShareTool = 'share tool', + ShareHotTake = 'share hot take', // End Share /* Start World `world view` is the denominator and fires whatever happens next, so the 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 20ba5b1575cb5b78cd4c213bc353f9fe9ddbc801 Mon Sep 17 00:00:00 2001 From: Ido Shamun <1993245+idoshamun@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:28:58 +0300 Subject: [PATCH 09/13] fix(history): log the reading history copy link and copy the tracked link The history row's copy link copied the raw permalink and logged nothing. It now does what PostMenuOptions and SelectionSnapshotBar do on main: logs SharePost with provider copy link and the row's origin (history), and passes the permalink to useCopyLink's shorten path with the SharePost campaign, so the tracked short link replaces it without an await before the clipboard write (Safari drops the write otherwise). The button rests on the link glyph with a "Copy link" tooltip, like the post page's copy link; the check swap still confirms it. The label no longer flips to "Link copied", which only the old spec asserted. The spec now checks the copied link, the share event and that the click does not reach the row, and drops the "absent by default" case, which only pinned the prop's default. --- .../history/ReadingHistory.spec.tsx | 37 +++++++++++---- .../src/components/post/PostItemCard.tsx | 46 +++++++++++++------ 2 files changed, 61 insertions(+), 22 deletions(-) diff --git a/packages/shared/src/components/history/ReadingHistory.spec.tsx b/packages/shared/src/components/history/ReadingHistory.spec.tsx index de835c2cf3d..c3dce291631 100644 --- a/packages/shared/src/components/history/ReadingHistory.spec.tsx +++ b/packages/shared/src/components/history/ReadingHistory.spec.tsx @@ -15,6 +15,9 @@ import user from '../../../__tests__/fixture/loggedUser'; import { getLabel } from '../../lib/dateFormat.spec'; import post from '../../../__tests__/fixture/post'; import { SourceType } from '../../graphql/sources'; +import { TestBootProvider } from '../../../__tests__/helpers/boot'; +import { LogEvent, Origin } from '../../lib/log'; +import { ShareProvider } from '../../lib/share'; beforeEach(() => { nock.cleanAll(); @@ -199,24 +202,40 @@ describe('PostItemCard component', () => { ); }); - it('should copy the post link and confirm on the button itself', async () => { + it('should copy the post link and log it as a share from history', async () => { const writeText = jest.fn().mockResolvedValue(undefined); Object.assign(navigator, { clipboard: { writeText } }); + const logEvent = jest.fn(); + const onRowClick = jest.fn(); - renderCard({ showCopyLink: true }); + render( + + {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */} +
+ +
+
, + ); fireEvent.click(await screen.findByLabelText('Copy link')); await waitFor(() => expect(writeText).toHaveBeenCalledWith(post.commentsPermalink), ); - await screen.findByLabelText('Link copied'); - }); - - it('should not render the copy link button by default', async () => { - renderCard(); - await screen.findByText(postTitle); - expect(screen.queryByLabelText('Copy link')).not.toBeInTheDocument(); + expect(onRowClick).not.toHaveBeenCalled(); + expect(logEvent).toHaveBeenCalledWith( + expect.objectContaining({ + event_name: LogEvent.SharePost, + target_id: post.id, + extra: expect.stringContaining( + `"provider":"${ShareProvider.CopyLink}","origin":"${Origin.History}"`, + ), + }), + ); }); it('should call onHide on close button clicked', async () => { diff --git a/packages/shared/src/components/post/PostItemCard.tsx b/packages/shared/src/components/post/PostItemCard.tsx index ac148a74857..0f4955847be 100644 --- a/packages/shared/src/components/post/PostItemCard.tsx +++ b/packages/shared/src/components/post/PostItemCard.tsx @@ -6,6 +6,7 @@ import type { HidePostItemCardProps } from '../../graphql/users'; import type { PostItem } from '../../graphql/posts'; import { UserVote, isVideoPost } from '../../graphql/posts'; import { MiniCloseIcon as XIcon, UpvoteIcon, DownvoteIcon } from '../icons'; +import { LinkIcon } from '../icons/Link'; import classed from '../../lib/classed'; import PostMetadata from '../cards/common/PostMetadata'; import { ProfileImageSize, ProfilePicture } from '../ProfilePicture'; @@ -13,7 +14,7 @@ import { Image } from '../image/Image'; import ConditionalWrapper from '../ConditionalWrapper'; import { cloudinaryPostImageCoverPlaceholder } from '../../lib/image'; import { useReadHistoryVotePost } from '../../hooks'; -import { Origin } from '../../lib/log'; +import { LogEvent, Origin } from '../../lib/log'; import { Button, ButtonColor, @@ -26,6 +27,11 @@ import { ReadingHistoryOptionsMenu } from '../history/ReadingHistoryOptionsMenu' import type { QueryIndexes } from '../../hooks/useReadingHistory'; import { useCopyPostLink } from '../../hooks/useCopyPostLink'; import { CopyStateIcon } from '../share/CopyStateIcon'; +import { Tooltip } from '../tooltip/Tooltip'; +import { useLogContext } from '../../contexts/LogContext'; +import { postLogEvent } from '../../lib/feed'; +import { ReferralCampaignKey } from '../../lib/referral'; +import { ShareProvider } from '../../lib/share'; export interface PostItemCardProps { className?: string; @@ -70,7 +76,23 @@ export default function PostItemCard({ const isUserSource = isSourceUserSource(source); const { toggleUpvote, toggleDownvote } = useReadHistoryVotePost(); - const [copying, copyLink] = useCopyPostLink(post.commentsPermalink); + const [linkCopied, copyLink] = useCopyPostLink(); + const { logEvent } = useLogContext(); + + const onCopyLink = (e: MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + logEvent( + postLogEvent(LogEvent.SharePost, post, { + extra: { provider: ShareProvider.CopyLink, origin: logOrigin }, + }), + ); + copyLink({ + link: post.commentsPermalink, + shorten: true, + cid: ReferralCampaignKey.SharePost, + }); + }; const classes = classNames( 'relative flex w-full flex-row py-3 pl-9 pr-5', @@ -191,17 +213,15 @@ export default function PostItemCard({ /> )} {showButtons && showCopyLink && ( -

- -
-
-
-
- 😐 -
- - Most developers have a talent for turning simple problems into - overengineered nightmares. - - - “Simplicity is prerequisite for reliability” - Edsger - W. Dijkstra - -
- - - - 587 - - - -
-
- -
- -
- - - James Davis - - - @jamesdavis7 - - - - - 11.4K - -
-
-
-
- -
- {REACTIONS.map(({ glyph, label, className }) => ( -
- -
- -
-
- -); - -/* -------------------------------------------------------- the profile list */ - -const TAKES = [ - { - emoji: '🔥', - title: 'Microservices were a mistake for most teams', - subtitle: 'Distributed systems are a tax, not a feature', - upvotes: 128, - }, - { - emoji: '🧊', - title: 'Code review is mostly theatre', - subtitle: 'Two approvals, forty seconds of reading', - upvotes: 64, - }, -]; - -const HotTakeRow = ({ take }: { take: (typeof TAKES)[number] }) => ( -
-
- {take.emoji} -
-
- - {take.title} - - {take.subtitle} -
-
- - -
-
-); - -const HotTakeListScreen = ({ device }: ScreenProps) => ( - -
- Hot takes - {TAKES.map((take) => ( - - ))} -
-
-); - -/* -------------------------------------------------------- reading history */ - -const HISTORY = [ - 'Why iconic tech brands lost their dominance', - 'The case against microservices', - 'Postgres is all you need, again', -]; - -const HistoryRow = ({ - title, - device, -}: { - title: string; - device: DeviceName; -}) => ( -
-
-
- -
-
-

- {title} -

- - 4 min read · 128 upvotes - -
-
- {device === 'Desktop' && ( - <> -
-
-); - -const HistoryScreen = ({ device }: ScreenProps) => ( - -
- - Reading history - - {HISTORY.map((title) => ( - - ))} -
-
-); - -/* -------------------------------------------------------------------- page */ - -const HotTakesAndHistory = () => ( - - - - - - - - - - - - - - - - - - - -); - -const meta: Meta = { - title: 'Features/Snapshot/Surfaces/Hot takes & history', - component: HotTakesAndHistory, - parameters: { layout: 'fullscreen' }, -}; - -export default meta; - -export const Variations: StoryObj = {}; From 38ecf32519b34f693194c506ce19ff46d0180b24 Mon Sep 17 00:00:00 2001 From: Ido Shamun <1993245+idoshamun@users.noreply.github.com> Date: Thu, 10 Sep 2026 17:33:16 +0300 Subject: [PATCH 11/13] feat(snapshot): credit the author on the hot take image QA found the hot take snapshot had no byline, so sharing another member's take produced an image that read as the sharer's own opinion. The card now ends with main's SnapshotCredit (name and avatar), the same credit the post and highlight cards use, and leaves it off when there is no author. No query change. The swipe modal's discoverHotTakes query already selects user { ...UserShortInfo }, so the card reads hotTake.user. The profile list comes from the showcase query, which fetches takes without a user because the profile already names its owner, so ProfileUserHotTakes passes that profile through HotTakeItem (both variants) as the author. --- .../components/hotTakes/HotTakeItem.tsx | 5 +++ .../components/hotTakes/HotTakeItem.v2.tsx | 5 +++ .../hotTakes/ProfileUserHotTakes.spec.tsx | 12 ++++++- .../hotTakes/ProfileUserHotTakes.tsx | 1 + .../snapshot/HotTakeSnapshotButton.spec.tsx | 36 +++++++++++++++++-- .../snapshot/HotTakeSnapshotButton.tsx | 13 ++++++- .../features/snapshot/HotTakeSnapshotCard.tsx | 22 +++++++++--- 7 files changed, 85 insertions(+), 9 deletions(-) diff --git a/packages/shared/src/features/profile/components/hotTakes/HotTakeItem.tsx b/packages/shared/src/features/profile/components/hotTakes/HotTakeItem.tsx index 842d82b9ed8..5784340e250 100644 --- a/packages/shared/src/features/profile/components/hotTakes/HotTakeItem.tsx +++ b/packages/shared/src/features/profile/components/hotTakes/HotTakeItem.tsx @@ -20,11 +20,14 @@ import { QuaternaryButton } from '../../../../components/buttons/QuaternaryButto import { Tooltip } from '../../../../components/tooltip/Tooltip'; import { useEngagementBarV2 } from '../../../../hooks/useEngagementBarV2'; import { HotTakeSnapshotButton } from '../../../snapshot/HotTakeSnapshotButton'; +import type { SnapshotCreditProps } from '../../../snapshot/SnapshotCredit'; import { Origin } from '../../../../lib/log'; import { HotTakeItem as HotTakeItemV2 } from './HotTakeItem.v2'; interface HotTakeItemProps { item: HotTake; + /** The profile's owner, credited on the take's snapshot. */ + author?: SnapshotCreditProps; isOwner: boolean; onEdit?: (item: HotTake) => void; onDelete?: (item: HotTake) => void; @@ -33,6 +36,7 @@ interface HotTakeItemProps { function HotTakeItemV1({ item, + author, isOwner, onEdit, onDelete, @@ -96,6 +100,7 @@ function HotTakeItemV1({
)} void; onDelete?: (item: HotTake) => void; @@ -29,6 +32,7 @@ interface HotTakeItemProps { export function HotTakeItem({ item, + author, isOwner, onEdit, onDelete, @@ -92,6 +96,7 @@ export function HotTakeItem({
)} { expect(screen.getAllByLabelText('Snapshot')).toHaveLength(2); }); + it("credits the profile's owner on a hot take's snapshot", () => { + mockHotTakes({ hotTakes: [createHotTake(1)], isOwner: false }); + + renderProfileUserHotTakes(); + fireEvent.pointerEnter(screen.getByLabelText('Snapshot')); + + // The list is fetched without a user on each take. + expect(screen.getByText(user.name)).toBeInTheDocument(); + }); + it('renders the hot takes anchor while loading for visitors', () => { mockHotTakes({ isOwner: false, diff --git a/packages/shared/src/features/profile/components/hotTakes/ProfileUserHotTakes.tsx b/packages/shared/src/features/profile/components/hotTakes/ProfileUserHotTakes.tsx index a3b7a676a32..1a8985ded40 100644 --- a/packages/shared/src/features/profile/components/hotTakes/ProfileUserHotTakes.tsx +++ b/packages/shared/src/features/profile/components/hotTakes/ProfileUserHotTakes.tsx @@ -247,6 +247,7 @@ export function ProfileUserHotTakes({ {hotTakes.map((item) => ( +const renderButton = (take = hotTake) => render( - + , ); @@ -39,7 +39,7 @@ const cardCopies = () => screen.queryAllByText('Tabs won Prettier just hid the bodies').length; beforeEach(() => { - logEvent.mockReset(); + jest.clearAllMocks(); jest .mocked(captureShareImage) .mockResolvedValue(new Blob(['png'], { type: 'image/png' })); @@ -58,6 +58,36 @@ describe('HotTakeSnapshotButton', () => { expect(cardCopies()).toBe(1); }); + it('credits the author so a shared take is not read as the sharer', () => { + renderButton({ + ...hotTake, + user: { + id: 'user-1', + name: 'Ada Lovelace', + username: 'ada', + image: 'https://media.daily.dev/ada.png', + createdAt: '2026-01-01T00:00:00.000Z', + reputation: 10, + permalink: 'https://app.daily.dev/ada', + }, + }); + fireEvent.pointerEnter(screen.getByLabelText('Snapshot')); + + expect(screen.getByText('Ada Lovelace')).toBeInTheDocument(); + expect(document.querySelector('img')).toHaveAttribute( + 'src', + 'https://media.daily.dev/ada.png', + ); + }); + + it('leaves the credit off a take without an author', () => { + renderButton(); + fireEvent.pointerEnter(screen.getByLabelText('Snapshot')); + + expect(cardCopies()).toBe(1); + expect(document.querySelector('img')).not.toBeInTheDocument(); + }); + it('logs the snapshot as a hot take share with its placement', async () => { renderButton(); const button = screen.getByLabelText('Snapshot'); diff --git a/packages/shared/src/features/snapshot/HotTakeSnapshotButton.tsx b/packages/shared/src/features/snapshot/HotTakeSnapshotButton.tsx index e6343c99ccf..50a9bf507d0 100644 --- a/packages/shared/src/features/snapshot/HotTakeSnapshotButton.tsx +++ b/packages/shared/src/features/snapshot/HotTakeSnapshotButton.tsx @@ -12,6 +12,7 @@ import type { HotTake } from '../../graphql/user/userHotTake'; import type { Origin } from '../../lib/log'; import { LogEvent } from '../../lib/log'; import { ShareProvider } from '../../lib/share'; +import type { SnapshotCreditProps } from './SnapshotCredit'; import { HotTakeSnapshotCard } from './HotTakeSnapshotCard'; import { getSnapshotCaptureOptions } from './snapshotCapture'; import { useArmedCard } from './useArmedCard'; @@ -22,12 +23,18 @@ import { useArmedCard } from './useArmedCard'; * is transformed while it moves, which would carry a fixed child with it. */ export function HotTakeSnapshotButton({ + author, hotTake, origin, showLabel, size, variant, }: { + /** + * Credited on the card. Defaults to the take's own user; a profile's list + * fetches its takes without one, since the profile already names them. + */ + author?: SnapshotCreditProps; hotTake: HotTake; /** Which placement this is, for the snapshot's share event. */ origin: Origin; @@ -73,7 +80,11 @@ export function HotTakeSnapshotButton({ aria-hidden className="pointer-events-none fixed left-[-300vw] top-0" > - +
, document.body, )} diff --git a/packages/shared/src/features/snapshot/HotTakeSnapshotCard.tsx b/packages/shared/src/features/snapshot/HotTakeSnapshotCard.tsx index d8ed92abe3b..a5c96b9cee2 100644 --- a/packages/shared/src/features/snapshot/HotTakeSnapshotCard.tsx +++ b/packages/shared/src/features/snapshot/HotTakeSnapshotCard.tsx @@ -2,6 +2,8 @@ import type { ReactElement } from 'react'; import React, { forwardRef } from 'react'; import type { HotTake } from '../../graphql/user/userHotTake'; import colors from '../../styles/colors'; +import type { SnapshotCreditProps } from './SnapshotCredit'; +import { SnapshotCredit } from './SnapshotCredit'; import { SnapshotEyebrow } from './SnapshotEyebrow'; import { SnapshotFrame } from './SnapshotFrame'; @@ -10,14 +12,23 @@ const MUTED = colors.salt['90']; /** Fire, for a take that ran hot: yellow core through orange into red. */ export const HOT_TAKE_EYEBROW_GRADIENT = `linear-gradient(100deg, ${colors.cheese['40']} 0%, ${colors.ketchup['10']} 48%, ${colors.ketchup['50']} 100%)`; +interface HotTakeSnapshotCardProps { + take: HotTake; + /** + * Who wrote the take. Anyone can share it, so without the credit the image + * reads as the sharer's own opinion. + */ + author?: SnapshotCreditProps; +} + /** * The take is the whole payload, so the card carries the opinion rather than - * the row it was read in: no avatar, and the upvote count reads as agreement - * rather than a score. Title and subtitle are set as one statement: split - * across two type styles they read as two voices arguing the same point. + * the row it was read in, and the upvote count reads as agreement rather than + * a score. Title and subtitle are set as one statement: split across two type + * styles they read as two voices arguing the same point. */ function HotTakeSnapshotCardComponent( - { take }: { take: HotTake }, + { take, author }: HotTakeSnapshotCardProps, ref: React.Ref, ): ReactElement { return ( @@ -50,6 +61,9 @@ function HotTakeSnapshotCardComponent( found this hot
)} + {author?.name && ( + + )} ); } From 86cf54bdefbdf660b51f8d91e5c540e255c0d43b Mon Sep 17 00:00:00 2001 From: Ido Shamun <1993245+idoshamun@users.noreply.github.com> Date: Thu, 10 Sep 2026 17:33:26 +0300 Subject: [PATCH 12/13] fix(snapshot): keep the button filled while it captures A capture takes a second or two, and SnapshotButton set both loading and disabled for it. Disabled routes the label to text-disabled and drops the fill, and the loader paints in the label color, so a Primary button (the swipe modal's, and main's selection bar and poll prompt) turned into an empty grey pill with a spinner too faint to see. The call site cannot reach this without overriding the button's CSS variables, so the fix is in SnapshotButton: loading alone already sets aria-busy, which hides the content, shows the loader and blocks pointer events, and the design system documents it as its own state. A guard in the handler replaces what disabled did for a keyboard re-press. Icon-only Tertiary placements on the post page now show the spinner in their default color instead of the disabled one. --- .../src/components/imageShare/SnapshotButton.tsx | 14 ++++++++++++-- .../snapshot/HotTakeSnapshotButton.spec.tsx | 15 +++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/packages/shared/src/components/imageShare/SnapshotButton.tsx b/packages/shared/src/components/imageShare/SnapshotButton.tsx index 40a15690dec..a178eec68e5 100644 --- a/packages/shared/src/components/imageShare/SnapshotButton.tsx +++ b/packages/shared/src/components/imageShare/SnapshotButton.tsx @@ -73,6 +73,9 @@ export function SnapshotButton({ // Every placement sits inside a clickable card, row or link. event.preventDefault(); event.stopPropagation(); + if (isCapturing) { + return; + } setIsFlashing(true); flashTimeout.current = setTimeout( () => setIsFlashing(false), @@ -115,7 +118,15 @@ export function SnapshotButton({ setIsCapturing(false); } }, - [captureOptions, displayToast, filename, onCapture, onResult, target], + [ + captureOptions, + displayToast, + filename, + isCapturing, + onCapture, + onResult, + target, + ], ); return ( @@ -133,7 +144,6 @@ export function SnapshotButton({ size={size} variant={variant} loading={isCapturing} - disabled={isCapturing} icon={} onClick={onSnapshot} > diff --git a/packages/shared/src/features/snapshot/HotTakeSnapshotButton.spec.tsx b/packages/shared/src/features/snapshot/HotTakeSnapshotButton.spec.tsx index 37c8267030e..96f3fc9855d 100644 --- a/packages/shared/src/features/snapshot/HotTakeSnapshotButton.spec.tsx +++ b/packages/shared/src/features/snapshot/HotTakeSnapshotButton.spec.tsx @@ -106,4 +106,19 @@ describe('HotTakeSnapshotButton', () => { }), ); }); + + it('stays filled while capturing and ignores a second press', async () => { + jest.mocked(copyShareImage).mockReturnValue(new Promise(() => {})); + renderButton(); + const button = screen.getByLabelText('Snapshot'); + fireEvent.pointerEnter(button); + fireEvent.click(button); + + // A disabled button paints the Primary fill grey and the spinner with it. + await waitFor(() => expect(button).toHaveAttribute('aria-busy', 'true')); + expect(button).toBeEnabled(); + + fireEvent.click(button); + expect(captureShareImage).toHaveBeenCalledTimes(1); + }); }); From 155964e4cb8ea9bde6536df89ebc4efb622008df Mon Sep 17 00:00:00 2001 From: Ido Shamun <1993245+idoshamun@users.noreply.github.com> Date: Thu, 10 Sep 2026 17:33:26 +0300 Subject: [PATCH 13/13] fix(hot-takes): do not swipe a card from its snapshot button react-swipeable listens for touchstart natively on the swipe area, so a finger that landed on the Snapshot button and moved dragged the card, and past the threshold it registered a hot or cold vote. A React stopPropagation on the button would run too late to stop that listener. The swipe handlers now note in onTouchStartOrOnMouseDown whether the gesture began on a button and skip onSwiping when it did, which covers touch and mouse and leaves the rest of the card (author row, upvote pill) draggable. The swipe-end handlers read the refs onSwiping writes, so the card snaps back without a vote. --- .../modals/hotTakes/HotAndColdModal.spec.tsx | 30 +++++++++++++++++++ .../modals/hotTakes/HotAndColdModal.tsx | 7 ++++- 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/packages/shared/src/components/modals/hotTakes/HotAndColdModal.spec.tsx b/packages/shared/src/components/modals/hotTakes/HotAndColdModal.spec.tsx index a72ba1a48f5..0f0f9bef6d9 100644 --- a/packages/shared/src/components/modals/hotTakes/HotAndColdModal.spec.tsx +++ b/packages/shared/src/components/modals/hotTakes/HotAndColdModal.spec.tsx @@ -273,6 +273,36 @@ describe('HotAndColdModal', () => { expect(screen.getAllByLabelText('Snapshot')).toHaveLength(1); }); + it('should not swipe the card when a drag starts on the snapshot button', () => { + const currentTake = createHotTake('snapshot-drag'); + mockedUseDiscoverHotTakes.mockReturnValue({ + hotTakes: [currentTake], + currentTake, + nextTake: null, + isEmpty: false, + isLoading: false, + dismissCurrent, + }); + + renderComponent(); + + const swipeRight = (from: Element) => + act(() => { + fireEvent.touchStart(from, { touches: [{ clientX: 0, clientY: 0 }] }); + fireEvent.touchMove(from, { touches: [{ clientX: 200, clientY: 0 }] }); + fireEvent.touchEnd(from, { touches: [] }); + }); + + swipeRight(screen.getByLabelText('Snapshot')); + expect(toggleUpvote).not.toHaveBeenCalled(); + + swipeRight(screen.getByText(currentTake.title)); + expect(toggleUpvote).toHaveBeenCalledWith({ + payload: currentTake, + origin: Origin.HotAndCold, + }); + }); + it('should keep subtitle visible even when title is very long', () => { const currentTake = { ...createHotTake('long-text'), diff --git a/packages/shared/src/components/modals/hotTakes/HotAndColdModal.tsx b/packages/shared/src/components/modals/hotTakes/HotAndColdModal.tsx index 644aa05a23f..f6768c04a5c 100644 --- a/packages/shared/src/components/modals/hotTakes/HotAndColdModal.tsx +++ b/packages/shared/src/components/modals/hotTakes/HotAndColdModal.tsx @@ -1726,6 +1726,7 @@ const HotAndColdModal = ({ const dismissTimerRef = useRef | null>(null); const [skipDelta, setSkipDelta] = useState(0); const swipeDeltaYRef = useRef(0); + const swipeStartedOnButtonRef = useRef(false); const [internalDismissedCardIds, setInternalDismissedCardIds] = useState< Set >(() => new Set()); @@ -2192,8 +2193,12 @@ const HotAndColdModal = ({ }; const handlers = useSwipeable({ + onTouchStartOrOnMouseDown: ({ event }) => { + swipeStartedOnButtonRef.current = + event.target instanceof Element && !!event.target.closest('button'); + }, onSwiping: (e) => { - if (!isAnimating) { + if (!isAnimating && !swipeStartedOnButtonRef.current) { if (isOnboardingMode && e.event.cancelable) { e.event.preventDefault(); }