From 0723672c09bfc41285356ceceffad88171f641cd Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Wed, 22 Jul 2026 17:15:52 +0300 Subject: [PATCH 01/24] feat(share): floating share bar for text selected in a post Selecting text inside a post body raises a portaled floating bar anchored to the selection with three actions: copy link to the post (native share sheet on mobile), copy the selection itself, and generate a shareable quote image. - `useTextSelectionShare` detects selection end inside a container, exposes the text plus a viewport rect, follows it on scroll/resize and clears on collapse - `SelectionShareBar` portals the bar to the root, clamps to the visual viewport (pinch-zoom/Android), flips below the selection when there is no room above, dismisses on click-away and Escape, and respects reduced motion - `QuoteImageCard` + `/image-generator/quote/[id]` follow the existing screenshot-service pattern (`#screenshot_wrapper`, ISR) and carry `og:image` / `twitter:card=summary_large_image` - Wired into all three post bodies: classic `PostContent`, `SquadPostContent`, and the redesign `PostFocusCard` (post page and post modal) Gated by the new `share_text_selection` flag AND the `sharing_visibility` master gate. Flag-off mounts none of the hooks, so no listeners are attached. Co-Authored-By: Claude Opus 4.8 --- .../src/components/post/PostContent.tsx | 7 +- .../src/components/post/QuoteImageCard.tsx | 81 ++++++ .../post/SelectionShareBar.spec.tsx | 176 +++++++++++++ .../src/components/post/SelectionShareBar.tsx | 239 ++++++++++++++++++ .../src/components/post/SquadPostContent.tsx | 7 +- .../components/post/focus/PostFocusCard.tsx | 4 + .../src/hooks/useTextSelectionShare.spec.ts | 112 ++++++++ .../shared/src/hooks/useTextSelectionShare.ts | 158 ++++++++++++ packages/shared/src/lib/featureManagement.ts | 9 + packages/shared/src/lib/log.ts | 1 + packages/shared/src/lib/share.ts | 2 + .../components/QuoteImageCard.stories.tsx | 50 ++++ .../components/SelectionShareBar.stories.tsx | 186 ++++++++++++++ .../QuoteImageGeneratorPage.spec.tsx | 89 +++++++ .../pages/image-generator/quote/[id].tsx | 101 ++++++++ 15 files changed, 1220 insertions(+), 2 deletions(-) create mode 100644 packages/shared/src/components/post/QuoteImageCard.tsx create mode 100644 packages/shared/src/components/post/SelectionShareBar.spec.tsx create mode 100644 packages/shared/src/components/post/SelectionShareBar.tsx create mode 100644 packages/shared/src/hooks/useTextSelectionShare.spec.ts create mode 100644 packages/shared/src/hooks/useTextSelectionShare.ts create mode 100644 packages/storybook/stories/components/QuoteImageCard.stories.tsx create mode 100644 packages/storybook/stories/components/SelectionShareBar.stories.tsx create mode 100644 packages/webapp/__tests__/QuoteImageGeneratorPage.spec.tsx create mode 100644 packages/webapp/pages/image-generator/quote/[id].tsx diff --git a/packages/shared/src/components/post/PostContent.tsx b/packages/shared/src/components/post/PostContent.tsx index 702972165b2..1eddd4c9905 100644 --- a/packages/shared/src/components/post/PostContent.tsx +++ b/packages/shared/src/components/post/PostContent.tsx @@ -1,6 +1,6 @@ import classNames from 'classnames'; import type { ComponentProps, ReactElement } from 'react'; -import React from 'react'; +import React, { useRef } from 'react'; import dynamic from 'next/dynamic'; import type { Post } from '../../graphql/posts'; import { isVideoPost } from '../../graphql/posts'; @@ -27,6 +27,7 @@ import { useSmartTitle } from '../../hooks/post/useSmartTitle'; import { PostTagList } from './tags/PostTagList'; import PostSourceInfo from './PostSourceInfo'; import { useReaderInstallPromptGate } from '../../hooks/useReaderInstallPromptGate'; +import { SelectionShareBar } from './SelectionShareBar'; type PostContentRawProps = Omit & { post: Post }; @@ -136,8 +137,11 @@ export function PostContentRaw({ useTrackPostView({ post, shouldTrack: isVideoType }); + const bodyRef = useRef(null); + const postMainColumn = ( @@ -259,6 +263,7 @@ export function PostContentRaw({ /> )} + ); diff --git a/packages/shared/src/components/post/QuoteImageCard.tsx b/packages/shared/src/components/post/QuoteImageCard.tsx new file mode 100644 index 00000000000..8c7182dca37 --- /dev/null +++ b/packages/shared/src/components/post/QuoteImageCard.tsx @@ -0,0 +1,81 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import LogoIcon from '../../svg/LogoIcon'; +import LogoText from '../../svg/LogoText'; +import { + Typography, + TypographyColor, + TypographyTag, + TypographyType, +} from '../typography/Typography'; + +export interface QuoteImageCardProps { + quote: string; + title: string; + sourceName?: string | null; + authorName?: string | null; +} + +/** + * The 1200x630 quote card the screenshot service renders into a shareable + * image. Sized in fixed pixels because the output is a bitmap, not a + * responsive page. + */ +export const QuoteImageCard = ({ + quote, + title, + sourceName, + authorName, +}: QuoteImageCardProps): ReactElement => { + const attribution = [authorName, sourceName].filter(Boolean).join(' · '); + + return ( +
+ + “ + + + {quote} + +
+
+ + {title} + + {!!attribution && ( + + {attribution} + + )} +
+
+ + +
+
+
+ ); +}; diff --git a/packages/shared/src/components/post/SelectionShareBar.spec.tsx b/packages/shared/src/components/post/SelectionShareBar.spec.tsx new file mode 100644 index 00000000000..71985a62a31 --- /dev/null +++ b/packages/shared/src/components/post/SelectionShareBar.spec.tsx @@ -0,0 +1,176 @@ +import React from 'react'; +import type { RenderResult } from '@testing-library/react'; +import { + act, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react'; +import { QueryClient } from '@tanstack/react-query'; +import { GrowthBook } from '@growthbook/growthbook-react'; +import { SelectionShareBar } from './SelectionShareBar'; +import { TestBootProvider } from '../../../__tests__/helpers/boot'; +import { useTextSelectionShare } from '../../hooks/useTextSelectionShare'; +import { shouldUseNativeShare } from '../../lib/func'; +import { TOAST_NOTIF_KEY } from '../../hooks/useToastNotification'; +import type { Post } from '../../graphql/posts'; + +jest.mock('../../hooks/useTextSelectionShare', () => ({ + __esModule: true, + useTextSelectionShare: jest.fn(), +})); + +jest.mock('../../lib/func', () => { + const actual = jest.requireActual('../../lib/func'); + return { __esModule: true, ...actual, shouldUseNativeShare: jest.fn() }; +}); + +const useTextSelectionShareMock = useTextSelectionShare as jest.Mock; +const shouldUseNativeShareMock = shouldUseNativeShare as jest.Mock; +const writeText = jest.fn().mockResolvedValue(undefined); +const share = jest.fn().mockResolvedValue(undefined); +const clear = jest.fn(); +const selection = 'shrinking the distance between a decision and its effect'; + +const post = { + id: 'post-1', + title: 'How to ship fast', + commentsPermalink: 'https://daily.dev/posts/how-to-ship-fast', + permalink: 'https://daily.dev/r/how-to-ship-fast', +} as unknown as Post; + +const enabledGrowthBook = () => + new GrowthBook({ + features: { + sharing_visibility: { defaultValue: true }, + share_text_selection: { defaultValue: true }, + }, + }); + +beforeEach(() => { + jest.clearAllMocks(); + shouldUseNativeShareMock.mockReturnValue(false); + Object.assign(navigator, { clipboard: { writeText }, share }); + useTextSelectionShareMock.mockReturnValue({ + text: selection, + rect: { top: 400, bottom: 420, left: 100, right: 300 }, + clear, + }); +}); + +const renderComponent = ( + gb = enabledGrowthBook(), +): RenderResult & { client: QueryClient } => { + const client = new QueryClient(); + const containerRef = { current: document.createElement('div') }; + document.body.appendChild(containerRef.current); + + return { + client, + ...render( + + + , + ), + }; +}; + +describe('SelectionShareBar flag gate', () => { + it('renders nothing and attaches no selection listeners when off', () => { + renderComponent(new GrowthBook()); + + expect(screen.queryByTestId('selectionShareBar')).not.toBeInTheDocument(); + expect(useTextSelectionShareMock).not.toHaveBeenCalled(); + }); + + it('renders nothing when only the per-surface flag is on', () => { + renderComponent( + new GrowthBook({ + features: { share_text_selection: { defaultValue: true } }, + }), + ); + + expect(screen.queryByTestId('selectionShareBar')).not.toBeInTheDocument(); + expect(useTextSelectionShareMock).not.toHaveBeenCalled(); + }); +}); + +describe('SelectionShareBar actions', () => { + it('renders the three share actions for a selection', () => { + renderComponent(); + + expect(screen.getByTestId('selectionShareBar')).toBeInTheDocument(); + expect(screen.getByLabelText('Copy link to this post')).toBeInTheDocument(); + expect(screen.getByLabelText('Copy selected text')).toBeInTheDocument(); + expect(screen.getByLabelText('Generate quote image')).toBeInTheDocument(); + }); + + it('copies the post link and shows a toast', async () => { + const { client } = renderComponent(); + + await act(async () => { + fireEvent.click(screen.getByLabelText('Copy link to this post')); + }); + + await waitFor(() => + expect(writeText).toHaveBeenCalledWith(post.commentsPermalink), + ); + expect(client.getQueryData(TOAST_NOTIF_KEY)).toMatchObject({ + message: '✅ Copied link to clipboard', + }); + }); + + it('copies the selected text and shows a toast', async () => { + const { client } = renderComponent(); + + await act(async () => { + fireEvent.click(screen.getByLabelText('Copy selected text')); + }); + + await waitFor(() => expect(writeText).toHaveBeenCalledWith(selection)); + expect(client.getQueryData(TOAST_NOTIF_KEY)).toMatchObject({ + message: '✅ Copied text to clipboard', + }); + }); + + it('opens the native share sheet on mobile instead of copying', async () => { + shouldUseNativeShareMock.mockReturnValue(true); + renderComponent(); + + await act(async () => { + fireEvent.click(screen.getByLabelText('Copy link to this post')); + }); + + await waitFor(() => expect(share).toHaveBeenCalled()); + expect(share).toHaveBeenCalledWith({ + text: `${selection}\n${post.commentsPermalink}`, + }); + expect(writeText).not.toHaveBeenCalled(); + }); + + it('opens the quote image generator with the selection', async () => { + const open = jest.fn(); + Object.assign(window, { open }); + renderComponent(); + + await act(async () => { + fireEvent.click(screen.getByLabelText('Generate quote image')); + }); + + const [url] = open.mock.calls[0]; + expect(url).toContain(`image-generator/quote/${post.id}`); + expect(url).toContain(encodeURIComponent(selection)); + expect(clear).toHaveBeenCalled(); + }); + + it('dismisses on a click outside the bar', async () => { + renderComponent(); + + await act(async () => { + fireEvent.click(document.body); + }); + + expect(clear).toHaveBeenCalled(); + }); +}); diff --git a/packages/shared/src/components/post/SelectionShareBar.tsx b/packages/shared/src/components/post/SelectionShareBar.tsx new file mode 100644 index 00000000000..2ae35196c5b --- /dev/null +++ b/packages/shared/src/components/post/SelectionShareBar.tsx @@ -0,0 +1,239 @@ +import type { ReactElement, RefObject } from 'react'; +import React, { useCallback, useLayoutEffect, useRef, useState } from 'react'; +import type { Post } from '../../graphql/posts'; +import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; +import { CopyIcon, ImageIcon, LinkIcon } from '../icons'; +import { Tooltip } from '../tooltip/Tooltip'; +import { RootPortal } from '../tooltips/Portal'; +import { useCopyText } from '../../hooks/useCopy'; +import { useShareOrCopyLink } from '../../hooks/useShareOrCopyLink'; +import { useTextSelectionShare } from '../../hooks/useTextSelectionShare'; +import { useSharingVisibility } from '../../hooks/useSharingVisibility'; +import { useConditionalFeature } from '../../hooks/useConditionalFeature'; +import { useOutsideClick } from '../../hooks/utils/useOutsideClick'; +import { useEventListener } from '../../hooks/useEventListener'; +import { useVisualViewport } from '../../hooks/utils/useVisualViewport'; +import { useLogContext } from '../../contexts/LogContext'; +import { usePostLogEvent } from '../../lib/feed'; +import { featureShareTextSelection } from '../../lib/featureManagement'; +import { LogEvent, Origin } from '../../lib/log'; +import { ShareProvider } from '../../lib/share'; +import { ReferralCampaignKey } from '../../lib/referral'; +import { webappUrl } from '../../lib/constants'; + +export interface SelectionShareBarProps { + post: Post; + /** The post body. Only selections made inside it raise the bar. */ + containerRef: RefObject; +} + +// Quote images read badly past a couple of sentences, and the text rides in the +// generator URL, so cap it well below any browser URL limit. +const MAX_QUOTE_LENGTH = 280; +// Breathing room between the selection and the bar. +const ANCHOR_GAP = 8; +// Below this distance from the top of the viewport there is no room above the +// selection, so the bar flips underneath it. +const FLIP_THRESHOLD = 64; +const VIEWPORT_MARGIN = 8; +const FALLBACK_BAR_WIDTH = 160; + +const buildQuoteImageUrl = (postId: string, text: string): string => { + const quote = + text.length > MAX_QUOTE_LENGTH + ? `${text.slice(0, MAX_QUOTE_LENGTH).trimEnd()}…` + : text; + + return `${webappUrl}image-generator/quote/${postId}?text=${encodeURIComponent( + quote, + )}`; +}; + +// The bar itself. Split from the flag gate below so a disabled experiment +// mounts none of these hooks — and therefore attaches no listeners at all. +function SelectionShareBarContent({ + post, + containerRef, +}: SelectionShareBarProps): ReactElement | null { + const { text, rect, clear } = useTextSelectionShare({ containerRef }); + const barRef = useRef(null); + const [barWidth, setBarWidth] = useState(FALLBACK_BAR_WIDTH); + const { width: viewportWidth } = useVisualViewport(); + const [viewportOffset, setViewportOffset] = useState({ left: 0, top: 0 }); + + const { logEvent } = useLogContext(); + const postLogEvent = usePostLogEvent(); + const [, shareOrCopyLink] = useShareOrCopyLink({ + link: post.commentsPermalink, + text: text ?? post.title ?? '', + cid: ReferralCampaignKey.SharePost, + }); + const [, copyText] = useCopyText(); + + const dismiss = useCallback(() => { + globalThis?.window?.getSelection?.()?.removeAllRanges(); + clear(); + }, [clear]); + + const logShare = useCallback( + (provider: ShareProvider) => { + logEvent( + postLogEvent(LogEvent.SharePost, post, { + extra: { provider, origin: Origin.TextSelection }, + }), + ); + }, + [logEvent, post, postLogEvent], + ); + + useLayoutEffect(() => { + if (barRef.current) { + setBarWidth(barRef.current.offsetWidth); + } + }, [text]); + + useOutsideClick( + barRef, + (event) => { + // Clicks back inside the body collapse the selection on their own; acting + // here too would race the browser and drop the bar mid-drag. + if (containerRef.current?.contains(event.target as Node)) { + return; + } + + clear(); + }, + !!text, + ); + + useEventListener( + text ? globalThis?.document : null, + 'keydown', + (event: KeyboardEvent) => { + if (event.key === 'Escape') { + dismiss(); + } + }, + ); + + // Pinch-zoom pans the visual viewport without moving the layout viewport that + // a `fixed` element is positioned in, so track the offset and clamp to it. + useEventListener( + text ? globalThis?.window?.visualViewport : null, + 'scroll', + () => { + const viewport = globalThis?.window?.visualViewport; + setViewportOffset({ + left: viewport?.offsetLeft ?? 0, + top: viewport?.offsetTop ?? 0, + }); + }, + ); + + if (!text || !rect) { + return null; + } + + const availableWidth = viewportWidth || globalThis?.window?.innerWidth || 0; + const half = barWidth / 2; + const minCenter = viewportOffset.left + VIEWPORT_MARGIN + half; + const maxCenter = + viewportOffset.left + availableWidth - VIEWPORT_MARGIN - half; + const center = rect.left + (rect.right - rect.left) / 2; + const left = Math.min( + Math.max(center, minCenter), + Math.max(minCenter, maxCenter), + ); + const flipsBelow = rect.top - viewportOffset.top < FLIP_THRESHOLD; + const top = flipsBelow ? rect.bottom + ANCHOR_GAP : rect.top - ANCHOR_GAP; + + const onCopyLink = () => { + logShare(ShareProvider.CopyLink); + shareOrCopyLink(); + }; + + const onCopyText = () => { + logShare(ShareProvider.CopyText); + copyText({ textToCopy: text, message: '✅ Copied text to clipboard' }); + }; + + const onGenerateImage = () => { + logShare(ShareProvider.QuoteImage); + globalThis?.window?.open( + buildQuoteImageUrl(post.id, text), + '_blank', + 'noopener,noreferrer', + ); + dismiss(); + }; + + return ( + +
+ +
+
+ ); +} + +/** + * Floating share bar for text selected inside a post body. Flag-off it renders + * nothing and, because every listener lives in the inner component, attaches no + * selection/viewport listeners at all. + */ +export function SelectionShareBar( + props: SelectionShareBarProps, +): ReactElement | null { + const { isEnabled: isSharingVisible } = useSharingVisibility(); + const { value: isSelectionShareOn } = useConditionalFeature({ + feature: featureShareTextSelection, + shouldEvaluate: isSharingVisible, + }); + + if (!isSharingVisible || !isSelectionShareOn) { + return null; + } + + // eslint-disable-next-line react/jsx-props-no-spreading + return ; +} diff --git a/packages/shared/src/components/post/SquadPostContent.tsx b/packages/shared/src/components/post/SquadPostContent.tsx index f85f09ec59f..cddfd79bb6f 100644 --- a/packages/shared/src/components/post/SquadPostContent.tsx +++ b/packages/shared/src/components/post/SquadPostContent.tsx @@ -1,5 +1,5 @@ import type { ReactElement } from 'react'; -import React from 'react'; +import React, { useRef } from 'react'; import classNames from 'classnames'; import PostContentContainer from './PostContentContainer'; import usePostContent from '../../hooks/usePostContent'; @@ -26,6 +26,7 @@ import { useActions, useViewSize, ViewSize } from '../../hooks'; import { ActionType } from '../../graphql/actions'; import { useShowBoostButton } from '../../features/boost/useShowBoostButton'; import type { Post } from '../../graphql/posts'; +import { SelectionShareBar } from './SelectionShareBar'; const ContentMap = { [PostType.Freeform]: MarkdownPostContent, @@ -105,6 +106,8 @@ function SquadPostContentRaw({ useTrackPostView({ post }); + const bodyRef = useRef(null); + const socialTwitterType = getSocialTwitterPostType(post); const finalType = isVideoPost(post) ? PostType.VideoYouTube @@ -131,6 +134,7 @@ function SquadPostContentRaw({ } >
+
import(/* webpackChunkName: "postCodeSnippets" */ '../PostCodeSnippets').then( @@ -252,6 +253,7 @@ export const PostFocusCard = ({ const showCodeSnippets = useFeature(feature.showCodeSnippets); const focusCommentRef = useRef<() => void>(() => {}); const discussionRef = useRef(null); + const bodyRef = useRef(null); // The video is a small floating preview on tablet/desktop and expands to the // full width on first interaction. We keep YouTube's native iframe (so the // first click plays with sound), so there's no React click handler to hook — @@ -326,6 +328,7 @@ export const PostFocusCard = ({ return (
@@ -583,6 +586,7 @@ export const PostFocusCard = ({ +
); }; diff --git a/packages/shared/src/hooks/useTextSelectionShare.spec.ts b/packages/shared/src/hooks/useTextSelectionShare.spec.ts new file mode 100644 index 00000000000..ae35a2627c7 --- /dev/null +++ b/packages/shared/src/hooks/useTextSelectionShare.spec.ts @@ -0,0 +1,112 @@ +import { act, renderHook } from '@testing-library/react'; +import { useTextSelectionShare } from './useTextSelectionShare'; + +const rect = { + top: 200, + bottom: 220, + left: 40, + right: 260, + width: 220, + height: 20, +}; + +const mockSelection = ({ + text, + node, + isCollapsed = false, +}: { + text: string; + node: Node | null; + isCollapsed?: boolean; +}) => { + const range = { getBoundingClientRect: () => rect } as unknown as Range; + + window.getSelection = jest.fn().mockReturnValue({ + isCollapsed, + rangeCount: isCollapsed ? 0 : 1, + anchorNode: node, + focusNode: node, + toString: () => text, + getRangeAt: () => range, + }); +}; + +const setup = (enabled = true) => { + const container = document.createElement('div'); + const child = document.createTextNode('some post body text'); + container.appendChild(child); + document.body.appendChild(container); + + const containerRef = { current: container }; + const { result } = renderHook(() => + useTextSelectionShare({ containerRef, enabled }), + ); + + return { result, container, child }; +}; + +afterEach(() => { + document.body.innerHTML = ''; + jest.restoreAllMocks(); +}); + +describe('useTextSelectionShare', () => { + it('exposes the selected text and its rect once the selection ends', () => { + const { result, child } = setup(); + + mockSelection({ text: 'post body', node: child }); + act(() => { + document.dispatchEvent(new MouseEvent('mouseup')); + }); + + expect(result.current.text).toEqual('post body'); + expect(result.current.rect).toEqual({ + top: rect.top, + bottom: rect.bottom, + left: rect.left, + right: rect.right, + }); + }); + + it('ignores selections made outside the container', () => { + const { result } = setup(); + const outside = document.createTextNode('sidebar text'); + document.body.appendChild(outside); + + mockSelection({ text: 'sidebar text', node: outside }); + act(() => { + document.dispatchEvent(new MouseEvent('mouseup')); + }); + + expect(result.current.text).toBeNull(); + }); + + it('clears once the selection collapses', () => { + const { result, child } = setup(); + + mockSelection({ text: 'post body', node: child }); + act(() => { + document.dispatchEvent(new MouseEvent('mouseup')); + }); + expect(result.current.text).toEqual('post body'); + + mockSelection({ text: '', node: child, isCollapsed: true }); + act(() => { + document.dispatchEvent(new Event('selectionchange')); + }); + + expect(result.current.text).toBeNull(); + expect(result.current.rect).toBeNull(); + }); + + it('stays inert when disabled', () => { + const { result, child } = setup(false); + + mockSelection({ text: 'post body', node: child }); + act(() => { + document.dispatchEvent(new MouseEvent('mouseup')); + }); + + expect(result.current.text).toBeNull(); + }); +}); diff --git a/packages/shared/src/hooks/useTextSelectionShare.ts b/packages/shared/src/hooks/useTextSelectionShare.ts new file mode 100644 index 00000000000..4942eb0f486 --- /dev/null +++ b/packages/shared/src/hooks/useTextSelectionShare.ts @@ -0,0 +1,158 @@ +import type { RefObject } from 'react'; +import { useCallback, useRef, useState } from 'react'; +import { useEventListener } from './useEventListener'; + +export interface TextSelectionRect { + top: number; + bottom: number; + left: number; + right: number; +} + +export interface UseTextSelectionShareProps { + /** Only selections that both start and end inside this element count. */ + containerRef: RefObject; + /** + * When false no listeners are attached at all, so a disabled experiment costs + * nothing and leaves the page byte-for-byte identical to the control. + */ + enabled?: boolean; +} + +export interface UseTextSelectionShare { + /** The trimmed selected text, or null when there is no usable selection. */ + text: string | null; + /** Viewport-space rect of the selection, for anchoring a fixed element. */ + rect: TextSelectionRect | null; + clear: () => void; +} + +// Single-word accidental selections (double-clicking a link, tapping a word) +// are noise — require enough text for a quote to be worth sharing. +const MIN_SELECTION_LENGTH = 2; + +const isInsideContainer = ( + node: Node | null, + container: HTMLElement, +): boolean => { + if (!node) { + return false; + } + + return container.contains(node); +}; + +const toRect = (range: Range): TextSelectionRect | null => { + const { top, bottom, left, right, width, height } = + range.getBoundingClientRect(); + + // A range that collapsed or scrolled into a display:none ancestor reports an + // all-zero rect; anchoring to it would pin the bar to the top-left corner. + if (!width && !height) { + return null; + } + + return { top, bottom, left, right }; +}; + +/** + * Watches for a completed text selection inside `containerRef` and exposes the + * selected text plus a viewport rect to anchor a floating bar to. The rect is + * recomputed on scroll/resize so the bar follows the selection. + */ +export const useTextSelectionShare = ({ + containerRef, + enabled = true, +}: UseTextSelectionShareProps): UseTextSelectionShare => { + const [text, setText] = useState(null); + const [rect, setRect] = useState(null); + const rangeRef = useRef(null); + + const clear = useCallback(() => { + rangeRef.current = null; + setText(null); + setRect(null); + }, []); + + const readSelection = useCallback(() => { + const container = containerRef.current; + + if (!container) { + clear(); + return; + } + + const selection = globalThis?.window?.getSelection?.(); + + if (!selection || selection.isCollapsed || selection.rangeCount === 0) { + clear(); + return; + } + + const selected = selection.toString().trim(); + + if (selected.length < MIN_SELECTION_LENGTH) { + clear(); + return; + } + + if ( + !isInsideContainer(selection.anchorNode, container) || + !isInsideContainer(selection.focusNode, container) + ) { + clear(); + return; + } + + const range = selection.getRangeAt(0); + const nextRect = toRect(range); + + if (!nextRect) { + clear(); + return; + } + + rangeRef.current = range; + setText(selected); + setRect(nextRect); + }, [clear, containerRef]); + + const target = enabled ? globalThis?.document : null; + + // Selection *end* — mouse release, touch release, or a shift+arrow keyup. + useEventListener(target, 'mouseup', readSelection); + useEventListener(target, 'touchend', readSelection); + useEventListener(target, 'keyup', readSelection); + + // A click elsewhere collapses the selection without firing another mouseup on + // the container, so drop the bar as soon as the browser reports it collapsed. + useEventListener(target, 'selectionchange', () => { + const selection = globalThis?.window?.getSelection?.(); + + if (!selection || selection.isCollapsed) { + clear(); + } + }); + + const followTarget = enabled && rect ? globalThis?.window : null; + + const follow = useCallback(() => { + if (!rangeRef.current) { + return; + } + + const nextRect = toRect(rangeRef.current); + + if (!nextRect) { + clear(); + return; + } + + setRect(nextRect); + }, [clear]); + + useEventListener(followTarget, 'scroll', follow, true); + useEventListener(followTarget, 'resize', follow); + + return { text, rect, clear }; +}; diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts index 97b38cbdd2b..edc71d4d995 100644 --- a/packages/shared/src/lib/featureManagement.ts +++ b/packages/shared/src/lib/featureManagement.ts @@ -308,3 +308,12 @@ export const featureSharingVisibility = new Feature( // high-traffic icon, so it ramps on its own flag to watch share/copy metrics. // Keep the default `false` (control = existing `LinkIcon`). export const featureShareCopyIcon = new Feature('share_copy_icon', false); + +// Shows a floating share bar anchored to text selected inside a post body +// (copy link, copy the selection, generate a quote image). Part of the +// sharing-visibility initiative, so it also requires `sharing_visibility`. +// Keep the default `false` — GrowthBook ramps it. +export const featureShareTextSelection = new Feature( + 'share_text_selection', + false, +); diff --git a/packages/shared/src/lib/log.ts b/packages/shared/src/lib/log.ts index 9696707fa61..da77b326644 100644 --- a/packages/shared/src/lib/log.ts +++ b/packages/shared/src/lib/log.ts @@ -108,6 +108,7 @@ export enum Origin { GameCenter = 'game center', DevCard = 'devcard', CopyMyFeed = 'copy my feed', + TextSelection = 'text selection', } export enum LogEvent { diff --git a/packages/shared/src/lib/share.ts b/packages/shared/src/lib/share.ts index b6bb78125b7..cedc2d2d40b 100644 --- a/packages/shared/src/lib/share.ts +++ b/packages/shared/src/lib/share.ts @@ -10,6 +10,8 @@ export enum ShareProvider { LinkedIn = 'linkedin', Telegram = 'telegram', Email = 'email', + CopyText = 'copy text', + QuoteImage = 'quote image', } export const getWhatsappShareLink = (link: string): string => diff --git a/packages/storybook/stories/components/QuoteImageCard.stories.tsx b/packages/storybook/stories/components/QuoteImageCard.stories.tsx new file mode 100644 index 00000000000..a86286226d6 --- /dev/null +++ b/packages/storybook/stories/components/QuoteImageCard.stories.tsx @@ -0,0 +1,50 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import React from 'react'; +import { QuoteImageCard } from '@dailydotdev/shared/src/components/post/QuoteImageCard'; + +const meta: Meta = { + title: 'Components/Share/QuoteImageCard', + component: QuoteImageCard, + parameters: { + docs: { + description: { + component: + 'The 1200x630 card rendered at `/image-generator/quote/[id]` and screenshotted into a shareable quote image. Fixed pixel sizing on purpose — the output is a bitmap.', + }, + }, + }, + args: { + quote: + 'Shipping fast is not about typing faster. It is about shrinking the distance between a decision and the moment a real developer feels its effect.', + title: 'How to ship fast without breaking everything', + sourceName: 'daily.dev', + authorName: 'Ido Shamun', + }, + // The card is wider than the docs frame, so scale it down to fit. + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +// Long selections are truncated by the share bar, but the card still clamps. +export const LongQuote: Story = { + args: { + quote: + 'Shipping fast is not about typing faster. It is about shrinking the distance between a decision and the moment a real developer feels its effect, which means every layer between the two is either helping or in the way, and most of them are in the way…', + }, +}; + +// Posts without an author fall back to the source alone. +export const SourceOnly: Story = { + args: { authorName: null }, +}; diff --git a/packages/storybook/stories/components/SelectionShareBar.stories.tsx b/packages/storybook/stories/components/SelectionShareBar.stories.tsx new file mode 100644 index 00000000000..7a9648b6cf9 --- /dev/null +++ b/packages/storybook/stories/components/SelectionShareBar.stories.tsx @@ -0,0 +1,186 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import React, { useRef } from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { SelectionShareBar } from '@dailydotdev/shared/src/components/post/SelectionShareBar'; +import { getLogContextStatic } from '@dailydotdev/shared/src/contexts/LogContext'; +import AuthContext from '@dailydotdev/shared/src/contexts/AuthContext'; +import { + FeaturesReadyContext, + GrowthBookProvider, +} from '@dailydotdev/shared/src/components/GrowthBookProvider'; +import { BootApp } from '@dailydotdev/shared/src/lib/boot'; +import type { LoggedUser } from '@dailydotdev/shared/src/lib/user'; +import type { Post } from '@dailydotdev/shared/src/graphql/posts'; +import { fn } from 'storybook/test'; + +const mockUser = { + id: '1', + name: 'Test User', + username: 'testuser', + email: 'test@example.com', + image: 'https://daily-now-res.cloudinary.com/image/upload/placeholder.jpg', + providers: ['google'], + createdAt: '2024-01-01T00:00:00.000Z', + permalink: 'https://daily.dev/testuser', +} as unknown as LoggedUser; + +const post = { + id: 'post-1', + title: 'How to ship fast without breaking everything', + commentsPermalink: 'https://daily.dev/posts/how-to-ship-fast', + permalink: 'https://daily.dev/r/how-to-ship-fast', + source: { id: 'daily', name: 'daily.dev', handle: 'daily' }, + author: { id: '1', name: 'Ido Shamun' }, +} as unknown as Post; + +const body = [ + 'Shipping fast is not about typing faster. It is about shrinking the distance', + 'between a decision and the moment a real developer feels its effect. Select', + 'any part of this paragraph to raise the floating share bar — copy a link to', + 'the post, copy the selection itself, or turn it into a quote image.', +].join(' '); + +// The bar only reacts to selections made inside the container it is handed, so +// every story renders a fake post body to select from. +const SelectionPlayground = ({ compact = false }: { compact?: boolean }) => { + const containerRef = useRef(null); + + return ( +
+ {!compact && ( +

+ Select text inside the card below. +

+ )} +
+

{post.title}

+

{body}

+
+

+ Selecting text outside the card does nothing. +

+

{body}

+ +
+ ); +}; + +// Storybook aliases `@growthbook/growthbook` to a mock whose `getFeatureValue` +// coerces every falsy default to the truthy string `'control'`, so a flag can't +// be evaluated as `false` here. Flag-off is therefore simulated by holding the +// features context as "not ready", which is the exact path +// `useConditionalFeature` takes to fall back to the (false) default value. +const withProviders = + (enabled: boolean) => + (Story: React.ComponentType): React.ReactElement => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: Infinity } }, + }); + // Mock the short-URL resolution so copy/share actions don't hit network. + queryClient.setQueryData(['shortUrl'], 'https://dly.to/abc123'); + + const LogContext = getLogContextStatic(); + + return ( + + + + feature.defaultValue as any, + }} + > + false, + }} + > + + + + + + + ); + }; + +const meta: Meta = { + title: 'Components/Share/SelectionShareBar', + component: SelectionShareBar, + parameters: { + docs: { + description: { + component: + 'Floating share bar anchored to a text selection inside a post body. Behind the `share_text_selection` flag plus the `sharing_visibility` master gate.', + }, + }, + }, + decorators: [withProviders(true)], +}; + +export default meta; + +type Story = StoryObj; + +// Desktop: the bar floats above the selection and follows it while scrolling. +export const Desktop: Story = { + render: () => , +}; + +// Mobile: same bar, but "Copy link" hands off to the native share sheet when +// the device exposes one, and the bar clamps to the visual viewport. +export const Mobile: Story = { + render: () => , + parameters: { viewport: { defaultViewport: 'mobile1' } }, +}; + +// A selection near the very top of the viewport has no room above it, so the +// bar flips underneath the selection instead. +export const FlippedBelow: Story = { + render: () => , +}; + +// Control: selecting text raises nothing at all — no bar, no listeners. +export const FlagOff: Story = { + render: () => , + decorators: [withProviders(false)], +}; diff --git a/packages/webapp/__tests__/QuoteImageGeneratorPage.spec.tsx b/packages/webapp/__tests__/QuoteImageGeneratorPage.spec.tsx new file mode 100644 index 00000000000..bf06ef3220c --- /dev/null +++ b/packages/webapp/__tests__/QuoteImageGeneratorPage.spec.tsx @@ -0,0 +1,89 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { gqlClient } from '@dailydotdev/shared/src/graphql/common'; +import { useRouter } from 'next/router'; +import QuoteImagePage, { + getStaticProps, +} from '../pages/image-generator/quote/[id]'; + +jest.mock('@dailydotdev/shared/src/graphql/common', () => { + const actual = jest.requireActual('@dailydotdev/shared/src/graphql/common'); + + return { ...actual, gqlClient: { request: jest.fn() } }; +}); + +jest.mock('next/router', () => ({ + __esModule: true, + useRouter: jest.fn(), +})); + +const mockRequest = gqlClient.request as jest.Mock; +const useRouterMock = useRouter as jest.Mock; + +const post = { + id: 'post-1', + title: 'How to ship fast', + source: { name: 'daily.dev' }, + author: { name: 'Ido Shamun' }, +}; + +beforeEach(() => { + jest.clearAllMocks(); + useRouterMock.mockReturnValue({ query: { text: 'a quote worth sharing' } }); +}); + +describe('quote image generator getStaticProps', () => { + it('returns the post attribution and a large-image twitter card', async () => { + mockRequest.mockResolvedValue({ post }); + + const result = await getStaticProps({ params: { id: 'post-1' } }); + const { props } = result as unknown as { + props: { + id: string; + seo: { openGraph: { images: { url: string }[] } }; + }; + }; + + expect(props).toMatchObject({ + id: 'post-1', + title: 'How to ship fast', + sourceName: 'daily.dev', + authorName: 'Ido Shamun', + }); + expect(props.seo).toMatchObject({ + twitter: { cardType: 'summary_large_image' }, + }); + expect(props.seo.openGraph.images[0].url).toContain('post-1'); + }); + + it('404s when the post is missing', async () => { + mockRequest.mockRejectedValue(new Error('not found')); + + const result = await getStaticProps({ params: { id: 'nope' } }); + + expect(result).toMatchObject({ notFound: true }); + }); +}); + +describe('quote image generator page', () => { + it('renders the screenshot wrapper around the quote', () => { + render( + , + ); + + // The screenshot service targets this exact id. + expect(screen.getByTestId('screenshot_wrapper')).toHaveAttribute( + 'id', + 'screenshot_wrapper', + ); + expect(screen.getByText('a quote worth sharing')).toBeInTheDocument(); + expect(screen.getByText('How to ship fast')).toBeInTheDocument(); + expect(screen.getByText('Ido Shamun · daily.dev')).toBeInTheDocument(); + }); +}); diff --git a/packages/webapp/pages/image-generator/quote/[id].tsx b/packages/webapp/pages/image-generator/quote/[id].tsx new file mode 100644 index 00000000000..850caa61edb --- /dev/null +++ b/packages/webapp/pages/image-generator/quote/[id].tsx @@ -0,0 +1,101 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import type { + GetStaticPathsResult, + GetStaticPropsContext, + GetStaticPropsResult, +} from 'next'; +import type { NextSeoProps } from 'next-seo'; +import { useRouter } from 'next/router'; +import type { PostData } from '@dailydotdev/shared/src/graphql/posts'; +import { POST_BY_ID_STATIC_FIELDS_QUERY } from '@dailydotdev/shared/src/graphql/posts'; +import { gqlClient } from '@dailydotdev/shared/src/graphql/common'; +import { QuoteImageCard } from '@dailydotdev/shared/src/components/post/QuoteImageCard'; + +export async function getStaticPaths(): Promise { + return { paths: [], fallback: 'blocking' }; +} + +interface QuotePageProps { + id: string; + title: string; + sourceName: string | null; + authorName: string | null; + seo: NextSeoProps; +} + +export async function getStaticProps({ + params, +}: GetStaticPropsContext): Promise> { + const id = params?.id as string; + + if (!id) { + return { notFound: true, revalidate: false }; + } + + try { + const { post } = await gqlClient.request( + POST_BY_ID_STATIC_FIELDS_QUERY, + { id }, + ); + + return { + props: { + id: post.id, + title: post.title ?? '', + sourceName: post.source?.name ?? null, + authorName: post.author?.name ?? null, + seo: { + title: post.title ?? 'Quote from daily.dev', + description: + 'A quote from a post shared with millions of developers on daily.dev', + noindex: true, + twitter: { cardType: 'summary_large_image' }, + openGraph: { + type: 'website', + images: [ + { + url: `https://og.daily.dev/api/posts/${post.id}`, + width: 1200, + height: 630, + alt: post.title ?? 'Quote image', + }, + ], + }, + }, + }, + revalidate: 60, + }; + } catch (err) { + return { notFound: true, revalidate: 60 }; + } +} + +const QuoteImagePage = ({ + title, + sourceName, + authorName, +}: QuotePageProps): ReactElement => { + const { query } = useRouter(); + // The quote itself is never part of the cached page — it comes from the + // reader's selection, so it rides in the query string and the ISR'd shell is + // reused for every quote of the same post. + const quote = (query?.text as string) ?? ''; + + return ( +
+ +
+ ); +}; + +export default QuoteImagePage; From 95350b47cffc765044dc1f43dbf97a0ce6aef3cc Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Wed, 22 Jul 2026 23:04:46 +0300 Subject: [PATCH 02/24] fix(share): drop the quote-image action until the service renders it Opening the raw /image-generator/quote page landed users on a bare 1200x630 bitmap template with no download, share or back affordance. The route, QuoteImageCard and the URL builder stay for the follow-up that previews and shares a real PNG once the screenshot service serves the route. Co-Authored-By: Claude Opus 4.8 --- .../post/SelectionShareBar.spec.tsx | 26 ++++++----------- .../src/components/post/SelectionShareBar.tsx | 29 +++++-------------- 2 files changed, 16 insertions(+), 39 deletions(-) diff --git a/packages/shared/src/components/post/SelectionShareBar.spec.tsx b/packages/shared/src/components/post/SelectionShareBar.spec.tsx index 71985a62a31..2d72b05c411 100644 --- a/packages/shared/src/components/post/SelectionShareBar.spec.tsx +++ b/packages/shared/src/components/post/SelectionShareBar.spec.tsx @@ -97,13 +97,20 @@ describe('SelectionShareBar flag gate', () => { }); describe('SelectionShareBar actions', () => { - it('renders the three share actions for a selection', () => { + it('renders the share actions for a selection', () => { renderComponent(); expect(screen.getByTestId('selectionShareBar')).toBeInTheDocument(); expect(screen.getByLabelText('Copy link to this post')).toBeInTheDocument(); expect(screen.getByLabelText('Copy selected text')).toBeInTheDocument(); - expect(screen.getByLabelText('Generate quote image')).toBeInTheDocument(); + }); + + it('does not offer the quote image until the service renders it', () => { + renderComponent(); + + expect( + screen.queryByLabelText('Generate quote image'), + ).not.toBeInTheDocument(); }); it('copies the post link and shows a toast', async () => { @@ -149,21 +156,6 @@ describe('SelectionShareBar actions', () => { expect(writeText).not.toHaveBeenCalled(); }); - it('opens the quote image generator with the selection', async () => { - const open = jest.fn(); - Object.assign(window, { open }); - renderComponent(); - - await act(async () => { - fireEvent.click(screen.getByLabelText('Generate quote image')); - }); - - const [url] = open.mock.calls[0]; - expect(url).toContain(`image-generator/quote/${post.id}`); - expect(url).toContain(encodeURIComponent(selection)); - expect(clear).toHaveBeenCalled(); - }); - it('dismisses on a click outside the bar', async () => { renderComponent(); diff --git a/packages/shared/src/components/post/SelectionShareBar.tsx b/packages/shared/src/components/post/SelectionShareBar.tsx index 2ae35196c5b..b3eb2cfca8e 100644 --- a/packages/shared/src/components/post/SelectionShareBar.tsx +++ b/packages/shared/src/components/post/SelectionShareBar.tsx @@ -2,7 +2,7 @@ import type { ReactElement, RefObject } from 'react'; import React, { useCallback, useLayoutEffect, useRef, useState } from 'react'; import type { Post } from '../../graphql/posts'; import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; -import { CopyIcon, ImageIcon, LinkIcon } from '../icons'; +import { CopyIcon, LinkIcon } from '../icons'; import { Tooltip } from '../tooltip/Tooltip'; import { RootPortal } from '../tooltips/Portal'; import { useCopyText } from '../../hooks/useCopy'; @@ -38,7 +38,12 @@ const FLIP_THRESHOLD = 64; const VIEWPORT_MARGIN = 8; const FALLBACK_BAR_WIDTH = 160; -const buildQuoteImageUrl = (postId: string, text: string): string => { +// The quote-image route renders headlessly for the screenshot service, so +// there is no user-facing entry point yet: sending someone to the raw +// generator page lands them on a bare 1200x630 bitmap template. Exported for +// the image-generator route and for the follow-up that turns this into a +// previewable, downloadable share once the service serves the PNG. +export const buildQuoteImageUrl = (postId: string, text: string): string => { const quote = text.length > MAX_QUOTE_LENGTH ? `${text.slice(0, MAX_QUOTE_LENGTH).trimEnd()}…` @@ -157,16 +162,6 @@ function SelectionShareBarContent({ copyText({ textToCopy: text, message: '✅ Copied text to clipboard' }); }; - const onGenerateImage = () => { - logShare(ShareProvider.QuoteImage); - globalThis?.window?.open( - buildQuoteImageUrl(post.id, text), - '_blank', - 'noopener,noreferrer', - ); - dismiss(); - }; - return (
- -
); From 4f042eb160651828b740d6896d09acea6da04110 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Sun, 26 Jul 2026 11:20:08 +0300 Subject: [PATCH 03/24] chore(storybook): full state matrix for SelectionShareBar Stories now fake the selection on load so every state is visible without dragging a cursor: placement (above/flipped/clamped/scroll-follow), mobile, selection shapes, the three host surfaces, the ignored cases, dismissal and flag-off. Co-Authored-By: Claude Opus 5 --- .../components/QuoteImageCard.stories.tsx | 9 +- .../components/SelectionShareBar.stories.tsx | 498 ++++++++++++++++-- 2 files changed, 461 insertions(+), 46 deletions(-) diff --git a/packages/storybook/stories/components/QuoteImageCard.stories.tsx b/packages/storybook/stories/components/QuoteImageCard.stories.tsx index a86286226d6..44fa9c5ad8e 100644 --- a/packages/storybook/stories/components/QuoteImageCard.stories.tsx +++ b/packages/storybook/stories/components/QuoteImageCard.stories.tsx @@ -8,8 +8,13 @@ const meta: Meta = { parameters: { docs: { description: { - component: - 'The 1200x630 card rendered at `/image-generator/quote/[id]` and screenshotted into a shareable quote image. Fixed pixel sizing on purpose — the output is a bitmap.', + component: [ + 'The 1200x630 card rendered at `/image-generator/quote/[id]` and screenshotted into a shareable quote image.', + 'Fixed pixel sizing on purpose — the output is a bitmap.', + '', + '**Parked:** the share bar no longer offers a "generate quote image" action;', + 'the route and this card stay in place until the screenshot service serves the PNG.', + ].join('\n'), }, }, }, diff --git a/packages/storybook/stories/components/SelectionShareBar.stories.tsx b/packages/storybook/stories/components/SelectionShareBar.stories.tsx index 7a9648b6cf9..78df4c5e022 100644 --- a/packages/storybook/stories/components/SelectionShareBar.stories.tsx +++ b/packages/storybook/stories/components/SelectionShareBar.stories.tsx @@ -1,5 +1,7 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; -import React, { useRef } from 'react'; +import type { ReactElement, ReactNode } from 'react'; +import React, { useCallback, useRef } from 'react'; +import classNames from 'classnames'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { SelectionShareBar } from '@dailydotdev/shared/src/components/post/SelectionShareBar'; import { getLogContextStatic } from '@dailydotdev/shared/src/contexts/LogContext'; @@ -33,47 +35,182 @@ const post = { author: { id: '1', name: 'Ido Shamun' }, } as unknown as Post; -const body = [ - 'Shipping fast is not about typing faster. It is about shrinking the distance', - 'between a decision and the moment a real developer feels its effect. Select', - 'any part of this paragraph to raise the floating share bar — copy a link to', - 'the post, copy the selection itself, or turn it into a quote image.', -].join(' '); +const paragraph = + 'Shipping fast is not about typing faster. It is about shrinking the distance between a decision and the moment a real developer feels its effect.'; -// The bar only reacts to selections made inside the container it is handed, so -// every story renders a fake post body to select from. -const SelectionPlayground = ({ compact = false }: { compact?: boolean }) => { +const secondParagraph = + 'Every layer between those two points is either helping or in the way, and most of them are in the way. Removing one is worth more than speeding up all of them.'; + +// --------------------------------------------------------------------------- +// Selection harness +// --------------------------------------------------------------------------- + +// The bar only exists while the browser reports a live selection, so every +// story fakes one instead of asking the reviewer to drag a cursor. The element +// carrying this attribute is the one whose contents get selected. +const AUTO_SELECT = 'data-autoselect'; +const autoSelect = { [AUTO_SELECT]: true }; + +const raiseBar = (root: ParentNode | null): void => { + const target = root?.querySelector(`[${AUTO_SELECT}]`); + + if (!target) { + return; + } + + const doc = target.ownerDocument; + const win = doc.defaultView; + + if (!win) { + return; + } + + const range = doc.createRange(); + range.selectNodeContents(target); + + const selection = win.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(range); + + // The hook listens for selection *end*, not `selectionchange`, so replay the + // event a real mouse release would have produced. + target.dispatchEvent(new win.MouseEvent('mouseup', { bubbles: true })); +}; + +// `play` runs immediately after render; give the component a tick to attach its +// document listeners before faking the selection. +const autoRaise = async ({ + canvasElement, +}: { + canvasElement: HTMLElement; +}): Promise => { + await new Promise((resolve) => { + setTimeout(resolve, 150); + }); + + raiseBar(canvasElement); +}; + +interface StageProps { + /** What to look at in this story. */ + hint?: ReactNode; + /** Post body — the container the bar is bound to. */ + children: ReactNode; + /** Rendered outside the body. Selecting it must never raise the bar. */ + outside?: ReactNode; + className?: string; + bodyClassName?: string; + showRaiseButton?: boolean; +} + +const Stage = ({ + hint, + children, + outside, + className, + bodyClassName, + showRaiseButton = true, +}: StageProps): ReactElement => { + const stageRef = useRef(null); const containerRef = useRef(null); + const onRaise = useCallback(() => { + // The bar's own outside-click handler runs on this very click and clears + // the selection, so re-select on the next tick. + setTimeout(() => raiseBar(stageRef.current), 0); + }, []); + return ( -
- {!compact && ( -

- Select text inside the card below. -

- )} +
+ {!!hint &&

{hint}

}
-

{post.title}

-

{body}

+ {children}
-

- Selecting text outside the card does nothing. -

-

{body}

+ {outside} + {showRaiseButton && ( + + )}
); }; +// --------------------------------------------------------------------------- +// Bodies — stand-ins for the three surfaces the bar is wired into. They carry +// the same typography as the real bodies; the real components (PostContent, +// SquadPostContent, PostFocusCard) pull in routing, feed queries and lazy +// modals that do not belong in a story. +// --------------------------------------------------------------------------- + +const ArticleBody = (): ReactElement => ( + <> +

{post.title}

+

+ Ido Shamun · daily.dev · 6 min read +

+ {/* eslint-disable-next-line react/jsx-props-no-spreading */} +

+ {paragraph} +

+

{secondParagraph}

+ +); + +const SquadBody = (): ReactElement => ( + <> +
+ + 🥑 + +
+ Ido Shamun + + Frontend Squad · 2h + +
+
+

+ A rule of thumb for shipping under pressure +

+ {/* eslint-disable-next-line react/jsx-props-no-spreading */} +

+ {paragraph} +

+

{secondParagraph}

+ +); + +const FocusCardBody = (): ReactElement => ( + <> +
+

{post.title}

+ {/* eslint-disable-next-line react/jsx-props-no-spreading */} +

+ {paragraph} +

+
+ #webdev + #productivity +
+ +); + +// --------------------------------------------------------------------------- +// Providers +// --------------------------------------------------------------------------- + // Storybook aliases `@growthbook/growthbook` to a mock whose `getFeatureValue` // coerces every falsy default to the truthy string `'control'`, so a flag can't // be evaluated as `false` here. Flag-off is therefore simulated by holding the @@ -147,40 +284,313 @@ const meta: Meta = { title: 'Components/Share/SelectionShareBar', component: SelectionShareBar, parameters: { + layout: 'fullscreen', docs: { description: { - component: - 'Floating share bar anchored to a text selection inside a post body. Behind the `share_text_selection` flag plus the `sharing_visibility` master gate.', + component: [ + 'Floating share bar anchored to a text selection inside a post body.', + 'Behind the `share_text_selection` flag **and** the `sharing_visibility` master gate.', + '', + 'Every story fakes a selection on load, so the bar is visible without dragging a cursor.', + 'Clicking anywhere dismisses it — hit **Raise the bar again** to bring it back.', + 'Use the Storybook theme toggle to check dark and light; both are supported.', + ].join('\n'), }, }, }, decorators: [withProviders(true)], + play: autoRaise, }; export default meta; type Story = StoryObj; -// Desktop: the bar floats above the selection and follows it while scrolling. -export const Desktop: Story = { - render: () => , +// -- Placement -------------------------------------------------------------- + +/** Default: the bar floats centred above the selection, 8px clear of it. */ +export const AboveSelection: Story = { + render: () => ( + + + + ), }; -// Mobile: same bar, but "Copy link" hands off to the native share sheet when -// the device exposes one, and the bar clamps to the visual viewport. +/** + * Under 64px from the top of the viewport there is no room above the + * selection, so the bar flips underneath it. + */ +export const FlippedBelow: Story = { + render: () => ( + + {/* eslint-disable-next-line react/jsx-props-no-spreading */} +

{paragraph}

+
+ ), +}; + +/** + * A short selection hard against the left edge would centre the bar partly + * off-screen, so the position is clamped to 8px inside the viewport. + */ +export const ClampedToLeftEdge: Story = { + globals: { viewport: { value: 'mobile1', isRotated: false } }, + render: () => ( + + The bar sits 8px from the edge, not centred over the word. + + } + > +

+ {/* eslint-disable-next-line react/jsx-props-no-spreading */} + shipping +

+
+ ), +}; + +/** The same clamp on the other side. */ +export const ClampedToRightEdge: Story = { + globals: { viewport: { value: 'mobile1', isRotated: false } }, + render: () => ( + +

+ {/* eslint-disable-next-line react/jsx-props-no-spreading */} + shipping +

+
+ ), +}; + +/** + * The rect is recomputed on scroll (capture phase, so inner scrollers count), + * so the bar tracks the selection instead of hanging in place. + */ +export const FollowsWhileScrolling: Story = { + render: () => ( + +

{secondParagraph}

+

{paragraph}

+

{secondParagraph}

+

{paragraph}

+

{secondParagraph}

+

{paragraph}

+
+ } + > + + + ), +}; + +// -- Devices ---------------------------------------------------------------- + +/** + * Mobile: identical bar, but "Copy link" hands off to the native share sheet + * when the device exposes `navigator.share`, and the position clamps to the + * *visual* viewport so pinch-zoom cannot push it off screen. + */ export const Mobile: Story = { - render: () => , - parameters: { viewport: { defaultViewport: 'mobile1' } }, + globals: { viewport: { value: 'mobile1', isRotated: false } }, + render: () => ( + + + + ), }; -// A selection near the very top of the viewport has no room above it, so the -// bar flips underneath the selection instead. -export const FlippedBelow: Story = { - render: () => , +// -- Selection shapes ------------------------------------------------------- + +/** A couple of words — the shortest selection the hook accepts. */ +export const ShortSelection: Story = { + render: () => ( + +

+ Shipping fast is not about{' '} + {/* eslint-disable-next-line react/jsx-props-no-spreading */} + typing faster. It is about shrinking the + distance between a decision and the moment a real developer feels its + effect. +

+
+ ), +}; + +/** A selection spanning several paragraphs still gets one bar. */ +export const MultiParagraphSelection: Story = { + render: () => ( + + {/* eslint-disable-next-line react/jsx-props-no-spreading */} +
+

{paragraph}

+

{secondParagraph}

+

{paragraph}

+
+
+ ), +}; + +/** Selections inside code blocks behave the same — copy text keeps the code. */ +export const CodeBlockSelection: Story = { + render: () => ( + +

{paragraph}

+
+        {/* eslint-disable-next-line react/jsx-props-no-spreading */}
+        
+          {`const [, copyText] = useCopyText();\ncopyText({ textToCopy: selection });`}
+        
+      
+
+ ), +}; + +// -- Nothing should happen -------------------------------------------------- + +/** Under two characters is treated as an accidental tap: no bar. */ +export const IgnoredTinySelection: Story = { + render: () => ( + +

+ Shipping fast is not about typing faster + {/* eslint-disable-next-line react/jsx-props-no-spreading */} + . It is about shrinking the distance + between a decision and its effect. +

+
+ ), +}; + +/** Selections outside the post body are ignored entirely. */ +export const IgnoredOutsideBody: Story = { + render: () => ( + + {secondParagraph} +

+ } + > +

{paragraph}

+
+ ), }; -// Control: selecting text raises nothing at all — no bar, no listeners. +/** + * A drag that starts inside the body and ends outside it is ignored too — both + * the anchor and the focus node have to be inside. + */ +export const IgnoredCrossingBoundary: Story = { + render: () => ( +
+

+ Expected: no bar. The selection starts in the body and ends in the + comment below it. +

+ {/* eslint-disable-next-line react/jsx-props-no-spreading */} +
+ +

{paragraph}

+
+

+ Great post — bookmarking this one. +

+
+
+ ), +}; + +// -- Surfaces --------------------------------------------------------------- + +/** Classic article/video body — `PostContent`. */ +export const SurfaceArticlePost: Story = { + render: () => ( + + + + ), +}; + +/** Freeform / welcome / share / YouTube squad post — `SquadPostContent`. */ +export const SurfaceSquadPost: Story = { + render: () => ( + + + + ), +}; + +/** Redesigned post page and modal — `PostFocusCard`. */ +export const SurfaceFocusCard: Story = { + render: () => ( + + + + ), +}; + +// -- Dismissal -------------------------------------------------------------- + +/** Click away, press Escape, or collapse the selection — all drop the bar. */ +export const Dismissal: Story = { + render: () => ( + + + + ), +}; + +// -- Flag off --------------------------------------------------------------- + +/** + * Control. Nothing renders and none of the inner hooks mount, so no selection + * or viewport listeners are attached at all. + */ export const FlagOff: Story = { - render: () => , decorators: [withProviders(false)], + render: () => ( + + + + ), }; From ed2931a3ba5cf1a3f45849ef99bf059b13ebdb17 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Sun, 26 Jul 2026 11:28:06 +0300 Subject: [PATCH 04/24] fix(share): stop the reveal animation clobbering the anchor transform animate-composer-in animates transform with animation-fill-mode: both, so its final translateY(0) permanently overrode the inline translate(-50%, -100%). The bar rendered un-centred and on top of the selection instead of above it. Split anchoring and reveal onto separate elements. Co-Authored-By: Claude Opus 5 --- .../src/components/post/SelectionShareBar.tsx | 60 +++++++++++-------- 1 file changed, 35 insertions(+), 25 deletions(-) diff --git a/packages/shared/src/components/post/SelectionShareBar.tsx b/packages/shared/src/components/post/SelectionShareBar.tsx index b3eb2cfca8e..abc35e7251f 100644 --- a/packages/shared/src/components/post/SelectionShareBar.tsx +++ b/packages/shared/src/components/post/SelectionShareBar.tsx @@ -164,38 +164,48 @@ function SelectionShareBarContent({ return ( + {/* + Anchoring and the reveal have to live on separate elements: + `animate-composer-in` animates `transform` with `animation-fill-mode: + both`, so sharing an element would leave the animation's final + `translateY(0)` overriding the anchoring translate for good. + */}
- -
); From f2e21e49c1e3d1e4020cfb22852d023c35f628c3 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Sun, 26 Jul 2026 11:28:06 +0300 Subject: [PATCH 05/24] chore(storybook): raise the selection on mount instead of via play Co-Authored-By: Claude Opus 5 --- .../components/SelectionShareBar.stories.tsx | 57 ++++++++++++------- 1 file changed, 37 insertions(+), 20 deletions(-) diff --git a/packages/storybook/stories/components/SelectionShareBar.stories.tsx b/packages/storybook/stories/components/SelectionShareBar.stories.tsx index 78df4c5e022..9e7eed8336f 100644 --- a/packages/storybook/stories/components/SelectionShareBar.stories.tsx +++ b/packages/storybook/stories/components/SelectionShareBar.stories.tsx @@ -1,6 +1,6 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; -import type { ReactElement, ReactNode } from 'react'; -import React, { useCallback, useRef } from 'react'; +import type { ReactElement, ReactNode, RefObject } from 'react'; +import React, { useCallback, useEffect, useRef } from 'react'; import classNames from 'classnames'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { SelectionShareBar } from '@dailydotdev/shared/src/components/post/SelectionShareBar'; @@ -77,18 +77,23 @@ const raiseBar = (root: ParentNode | null): void => { target.dispatchEvent(new win.MouseEvent('mouseup', { bubbles: true })); }; -// `play` runs immediately after render; give the component a tick to attach its -// document listeners before faking the selection. -const autoRaise = async ({ - canvasElement, -}: { - canvasElement: HTMLElement; -}): Promise => { - await new Promise((resolve) => { - setTimeout(resolve, 150); - }); - - raiseBar(canvasElement); +// Raise the bar once the story has mounted. An effect is used rather than a +// `play` function because it also fires in the docs view and does not depend on +// Storybook's instrumentation timing. +const useAutoRaise = ( + root: RefObject, + enabled = true, +): void => { + useEffect(() => { + if (!enabled) { + return undefined; + } + + // One tick for the bar to attach its document listeners. + const timeout = setTimeout(() => raiseBar(root.current), 120); + + return () => clearTimeout(timeout); + }, [enabled, root]); }; interface StageProps { @@ -101,6 +106,8 @@ interface StageProps { className?: string; bodyClassName?: string; showRaiseButton?: boolean; + /** Off when an enclosing story owns the selection. */ + autoRaise?: boolean; } const Stage = ({ @@ -110,10 +117,13 @@ const Stage = ({ className, bodyClassName, showRaiseButton = true, + autoRaise = true, }: StageProps): ReactElement => { const stageRef = useRef(null); const containerRef = useRef(null); + useAutoRaise(stageRef, autoRaise); + const onRaise = useCallback(() => { // The bar's own outside-click handler runs on this very click and clears // the selection, so re-select on the next tick. @@ -299,7 +309,6 @@ const meta: Meta = { }, }, decorators: [withProviders(true)], - play: autoRaise, }; export default meta; @@ -505,16 +514,20 @@ export const IgnoredOutsideBody: Story = { * A drag that starts inside the body and ends outside it is ignored too — both * the anchor and the focus node have to be inside. */ -export const IgnoredCrossingBoundary: Story = { - render: () => ( -
+const CrossingBoundary = (): ReactElement => { + const rootRef = useRef(null); + + useAutoRaise(rootRef); + + return ( +

Expected: no bar. The selection starts in the body and ends in the comment below it.

{/* eslint-disable-next-line react/jsx-props-no-spreading */}
- +

{paragraph}

@@ -522,7 +535,11 @@ export const IgnoredCrossingBoundary: Story = {

- ), + ); +}; + +export const IgnoredCrossingBoundary: Story = { + render: () => , }; // -- Surfaces --------------------------------------------------------------- From 1975763bb17e5f2572303f98e3cbd0b9d2234211 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Sun, 26 Jul 2026 12:07:44 +0300 Subject: [PATCH 06/24] feat(share): quote-in-comment and full social share on the selection bar Two new actions alongside copy link / copy text: - Quote: renders the selection as a markdown blockquote and hands it to the comment composer via ?comment=, which NewComment already watches on every surface the bar appears on. The post-type gate there is relaxed so the hand-off works beyond Welcome/Poll posts, and an optional ?commentOrigin keeps the logged origin accurate without double-logging OpenComment. - Share: the existing ShareActions popover with the full social row. It gains an overridable trigger icon and an onOpenChange callback so the bar can hold itself open while the (portaled) popover is up. QuoteImageCard grows author and source imagery in its attribution row. Co-Authored-By: Claude Opus 5 --- .../shared/src/components/post/NewComment.tsx | 13 ++- .../src/components/post/QuoteImageCard.tsx | 91 ++++++++++++++++--- .../src/components/post/SelectionShareBar.tsx | 74 ++++++++++++++- .../src/components/share/ShareActions.tsx | 27 +++++- .../components/QuoteImageCard.stories.tsx | 26 +++++- .../components/SelectionShareBar.stories.tsx | 67 +++++++++++++- .../pages/image-generator/quote/[id].tsx | 8 ++ 7 files changed, 274 insertions(+), 32 deletions(-) diff --git a/packages/shared/src/components/post/NewComment.tsx b/packages/shared/src/components/post/NewComment.tsx index 705f41c3580..6d63d04e01c 100644 --- a/packages/shared/src/components/post/NewComment.tsx +++ b/packages/shared/src/components/post/NewComment.tsx @@ -117,19 +117,18 @@ function NewCommentComponent( }, [isComposerOpen, onComposerOpenChange]); useEffect(() => { - if ( - !shouldHandleCommentQuery || - !hasCommentQuery || - (post.type !== PostType.Welcome && post.type !== PostType.Poll) - ) { + if (!shouldHandleCommentQuery || !hasCommentQuery) { return; } - const { comment, ...query } = router.query; - const origin = + const { comment, commentOrigin, ...query } = router.query; + // Callers that know where the draft came from say so (the text-selection + // share bar does); otherwise fall back to the post type, as before. + const originByPostType = post.type === PostType.Poll ? Origin.PollCommentButton : Origin.SquadChecklist; + const origin = (commentOrigin as Origin) ?? originByPostType; onShowComment(origin, comment as string); diff --git a/packages/shared/src/components/post/QuoteImageCard.tsx b/packages/shared/src/components/post/QuoteImageCard.tsx index 8c7182dca37..e330fd764b3 100644 --- a/packages/shared/src/components/post/QuoteImageCard.tsx +++ b/packages/shared/src/components/post/QuoteImageCard.tsx @@ -8,14 +8,67 @@ import { TypographyTag, TypographyType, } from '../typography/Typography'; +import { fallbackImages } from '../../lib/config'; export interface QuoteImageCardProps { quote: string; title: string; sourceName?: string | null; authorName?: string | null; + /** Avatar of the post author, badged with the source below. */ + authorImage?: string | null; + /** Source (publication or squad) logo. */ + sourceImage?: string | null; } +/** + * Author avatar with the source badged on its corner, falling back to whichever + * one exists. Plain `` on purpose: the screenshot service renders this page + * once and captures it, so there is nothing for lazy loading to defer to. + */ +const Attribution = ({ + authorImage, + authorName, + sourceImage, + sourceName, +}: Pick< + QuoteImageCardProps, + 'authorImage' | 'authorName' | 'sourceImage' | 'sourceName' +>): ReactElement | null => { + const avatar = authorName ? authorImage ?? fallbackImages.avatar : null; + + if (!avatar && !sourceImage) { + return null; + } + + if (!avatar) { + return ( + {sourceName + ); + } + + return ( +
+ {authorName + {!!sourceImage && ( + {sourceName + )} +
+ ); +}; + /** * The 1200x630 quote card the screenshot service renders into a shareable * image. Sized in fixed pixels because the output is a bitmap, not a @@ -26,6 +79,8 @@ export const QuoteImageCard = ({ title, sourceName, authorName, + authorImage, + sourceImage, }: QuoteImageCardProps): ReactElement => { const attribution = [authorName, sourceName].filter(Boolean).join(' · '); @@ -53,23 +108,31 @@ export const QuoteImageCard = ({ {quote}
-
- - {title} - - {!!attribution && ( +
+ +
- {attribution} + {title} - )} + {!!attribution && ( + + {attribution} + + )} +
diff --git a/packages/shared/src/components/post/SelectionShareBar.tsx b/packages/shared/src/components/post/SelectionShareBar.tsx index abc35e7251f..4ffb9346c4d 100644 --- a/packages/shared/src/components/post/SelectionShareBar.tsx +++ b/packages/shared/src/components/post/SelectionShareBar.tsx @@ -1,10 +1,12 @@ import type { ReactElement, RefObject } from 'react'; import React, { useCallback, useLayoutEffect, useRef, useState } from 'react'; +import { useRouter } from 'next/router'; import type { Post } from '../../graphql/posts'; import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; -import { CopyIcon, LinkIcon } from '../icons'; +import { CopyIcon, DiscussIcon, LinkIcon, ShareIcon } from '../icons'; import { Tooltip } from '../tooltip/Tooltip'; import { RootPortal } from '../tooltips/Portal'; +import { ShareActions } from '../share/ShareActions'; import { useCopyText } from '../../hooks/useCopy'; import { useShareOrCopyLink } from '../../hooks/useShareOrCopyLink'; import { useTextSelectionShare } from '../../hooks/useTextSelectionShare'; @@ -25,8 +27,20 @@ export interface SelectionShareBarProps { post: Post; /** The post body. Only selections made inside it raise the bar. */ containerRef: RefObject; + /** + * Overrides where a quote is sent. By default the selection is written into + * the URL as `?comment=`, which the post's comment composer picks up. + */ + onQuote?: (markdownQuote: string) => void; } +/** Renders the selection as a markdown blockquote for the comment composer. */ +export const buildCommentQuote = (selection: string): string => + `${selection + .split('\n') + .map((line) => `> ${line}`.trimEnd()) + .join('\n')}\n\n`; + // Quote images read badly past a couple of sentences, and the text rides in the // generator URL, so cap it well below any browser URL limit. const MAX_QUOTE_LENGTH = 280; @@ -59,12 +73,17 @@ export const buildQuoteImageUrl = (postId: string, text: string): string => { function SelectionShareBarContent({ post, containerRef, + onQuote, }: SelectionShareBarProps): ReactElement | null { const { text, rect, clear } = useTextSelectionShare({ containerRef }); const barRef = useRef(null); const [barWidth, setBarWidth] = useState(FALLBACK_BAR_WIDTH); const { width: viewportWidth } = useVisualViewport(); const [viewportOffset, setViewportOffset] = useState({ left: 0, top: 0 }); + // The share popover portals out of the bar, so an open popover has to hold + // the bar open — otherwise clicking a network inside it reads as a click away. + const [isShareOpen, setIsShareOpen] = useState(false); + const router = useRouter(); const { logEvent } = useLogContext(); const postLogEvent = usePostLogEvent(); @@ -108,14 +127,15 @@ function SelectionShareBarContent({ clear(); }, - !!text, + !!text && !isShareOpen, ); useEventListener( text ? globalThis?.document : null, 'keydown', (event: KeyboardEvent) => { - if (event.key === 'Escape') { + // The popover closes itself on Escape; only the second press drops the bar. + if (event.key === 'Escape' && !isShareOpen) { dismiss(); } }, @@ -162,6 +182,35 @@ function SelectionShareBarContent({ copyText({ textToCopy: text, message: '✅ Copied text to clipboard' }); }; + // The composer is the one action that consumes the selection rather than + // copying it, so hand off the markdown and get out of the way. `NewComment` + // is already mounted on every surface the bar appears on and already watches + // `?comment=`, so the URL is the hand-off — no ref plumbing across the page. + // It logs `OpenComment` when it opens, so the bar deliberately does not. + const onQuoteInComment = () => { + const quote = buildCommentQuote(text); + + dismiss(); + + if (onQuote) { + onQuote(quote); + return; + } + + router.replace( + { + pathname: router.pathname, + query: { + ...router.query, + comment: quote, + commentOrigin: Origin.TextSelection, + }, + }, + undefined, + { shallow: true }, + ); + }; + return ( {/* @@ -205,6 +254,25 @@ function SelectionShareBarContent({ variant={ButtonVariant.Tertiary} /> + +
diff --git a/packages/shared/src/components/share/ShareActions.tsx b/packages/shared/src/components/share/ShareActions.tsx index 50ee53e30e8..9cd310305ae 100644 --- a/packages/shared/src/components/share/ShareActions.tsx +++ b/packages/shared/src/components/share/ShareActions.tsx @@ -1,5 +1,5 @@ import type { ReactElement } from 'react'; -import React, { useRef, useState } from 'react'; +import React, { useCallback, useRef, useState } from 'react'; import classNames from 'classnames'; import { Popover, PopoverTrigger } from '@radix-ui/react-popover'; import { PopoverContent } from '../popover/Popover'; @@ -33,6 +33,13 @@ export interface ShareActionsProps { className?: string; /** Called for any share/copy so the caller can log with its own origin. */ onShare?: (provider: ShareProvider) => void; + /** Overrides the trigger icon. Defaults to the copy icon. */ + icon?: ReactElement; + /** + * Notifies the caller when the popover opens or closes, so a host that + * dismisses itself on outside clicks can stay put while it is open. + */ + onOpenChange?: (open: boolean) => void; } const HOVER_CLOSE_DELAY = 120; @@ -50,12 +57,24 @@ export function ShareActions({ emailSummary, className, onShare, + icon, + onOpenChange, }: ShareActionsProps): ReactElement { const isLaptop = useViewSize(ViewSize.Laptop); - const [open, setOpen] = useState(false); + const [open, setOpenState] = useState(false); const [copying, shareOrCopy] = useShareOrCopyLink({ link, text, cid }); const closeTimeout = useRef>(); + const setOpen = useCallback( + (next: boolean) => { + setOpenState(next); + onOpenChange?.(next); + }, + [onOpenChange], + ); + + const triggerIcon = icon ?? ; + const list = ( } + icon={triggerIcon} aria-label={label} className={className} onClick={() => { @@ -136,7 +155,7 @@ export function ShareActions({ type="button" variant={buttonVariant} size={buttonSize} - icon={} + icon={triggerIcon} aria-label={label} pressed={open} className={className} diff --git a/packages/storybook/stories/components/QuoteImageCard.stories.tsx b/packages/storybook/stories/components/QuoteImageCard.stories.tsx index 44fa9c5ad8e..9d16ab73474 100644 --- a/packages/storybook/stories/components/QuoteImageCard.stories.tsx +++ b/packages/storybook/stories/components/QuoteImageCard.stories.tsx @@ -2,6 +2,11 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import React from 'react'; import { QuoteImageCard } from '@dailydotdev/shared/src/components/post/QuoteImageCard'; +const authorImage = + 'https://media.daily.dev/image/upload/s---xy_OAwk--/f_auto,q_auto/v1703781380/avatars/avatar_28849d86070e4c099c877ab6837c61f0'; +const sourceImage = + 'https://media.daily.dev/image/upload/s--mqP40YbK--/f_auto/v1707831184/squads/303a826b-28e4-4d2f-938a-c610148e6f01'; + const meta: Meta = { title: 'Components/Share/QuoteImageCard', component: QuoteImageCard, @@ -12,6 +17,10 @@ const meta: Meta = { 'The 1200x630 card rendered at `/image-generator/quote/[id]` and screenshotted into a shareable quote image.', 'Fixed pixel sizing on purpose — the output is a bitmap.', '', + 'The attribution row carries the author avatar badged with the source logo.', + 'Plain `` tags: the screenshot service renders the page once and captures it,', + 'so there is nothing for lazy loading to defer to.', + '', '**Parked:** the share bar no longer offers a "generate quote image" action;', 'the route and this card stay in place until the screenshot service serves the PNG.', ].join('\n'), @@ -24,6 +33,8 @@ const meta: Meta = { title: 'How to ship fast without breaking everything', sourceName: 'daily.dev', authorName: 'Ido Shamun', + authorImage, + sourceImage, }, // The card is wider than the docs frame, so scale it down to fit. decorators: [ @@ -39,6 +50,7 @@ export default meta; type Story = StoryObj; +/** Author avatar with the source badged on its corner. */ export const Default: Story = {}; // Long selections are truncated by the share bar, but the card still clamps. @@ -49,7 +61,17 @@ export const LongQuote: Story = { }, }; -// Posts without an author fall back to the source alone. +/** No author (most syndicated articles): the source logo stands alone. */ export const SourceOnly: Story = { - args: { authorName: null }, + args: { authorName: null, authorImage: null }, +}; + +/** Author with no avatar on file falls back to the anonymous placeholder. */ +export const AuthorWithoutAvatar: Story = { + args: { authorImage: null }, +}; + +/** Neither image resolved — the row collapses to text, no empty boxes. */ +export const NoImages: Story = { + args: { authorImage: null, sourceImage: null, authorName: null }, }; diff --git a/packages/storybook/stories/components/SelectionShareBar.stories.tsx b/packages/storybook/stories/components/SelectionShareBar.stories.tsx index 9e7eed8336f..9e6d6408721 100644 --- a/packages/storybook/stories/components/SelectionShareBar.stories.tsx +++ b/packages/storybook/stories/components/SelectionShareBar.stories.tsx @@ -108,6 +108,8 @@ interface StageProps { showRaiseButton?: boolean; /** Off when an enclosing story owns the selection. */ autoRaise?: boolean; + /** Intercepts the quote action instead of writing it to the URL. */ + onQuote?: (markdownQuote: string) => void; } const Stage = ({ @@ -118,6 +120,7 @@ const Stage = ({ bodyClassName, showRaiseButton = true, autoRaise = true, + onQuote, }: StageProps): ReactElement => { const stageRef = useRef(null); const containerRef = useRef(null); @@ -152,7 +155,11 @@ const Stage = ({ Raise the bar again )} - +
); }; @@ -322,7 +329,7 @@ export const AboveSelection: Story = { render: () => ( @@ -408,6 +415,62 @@ export const FollowsWhileScrolling: Story = { ), }; +// -- Actions ---------------------------------------------------------------- + +/** + * The share button opens the full social row — the same `ShareActions` popover + * used elsewhere in the app, with the selection riding along as the share text. + * An open popover holds the bar open: it portals out of the bar, so without + * that guard clicking a network would read as a click away and dismiss it. + */ +export const ShareNetworks: Story = { + render: () => ( + + + + ), + play: async () => { + await new Promise((resolve) => { + setTimeout(resolve, 400); + }); + + const trigger = globalThis.document.querySelector( + '[data-testid="selectionShareBar"] [aria-label="Share"]', + ); + + trigger?.click(); + }, +}; + +/** + * Quote sends the selection to the comment composer as a markdown blockquote, + * via `?comment=`. Storybook stubs the router, so nothing navigates here — the + * payload below is what the composer receives. + */ +export const QuoteInComment: Story = { + render: () => { + const [quote, setQuote] = React.useState(null); + + return ( + + {quote ?? 'Nothing quoted yet.'} + + } + onQuote={setQuote} + > + + + ); + }, +}; + // -- Devices ---------------------------------------------------------------- /** diff --git a/packages/webapp/pages/image-generator/quote/[id].tsx b/packages/webapp/pages/image-generator/quote/[id].tsx index 850caa61edb..7dd6008af2e 100644 --- a/packages/webapp/pages/image-generator/quote/[id].tsx +++ b/packages/webapp/pages/image-generator/quote/[id].tsx @@ -21,6 +21,8 @@ interface QuotePageProps { title: string; sourceName: string | null; authorName: string | null; + sourceImage: string | null; + authorImage: string | null; seo: NextSeoProps; } @@ -45,6 +47,8 @@ export async function getStaticProps({ title: post.title ?? '', sourceName: post.source?.name ?? null, authorName: post.author?.name ?? null, + sourceImage: post.source?.image ?? null, + authorImage: post.author?.image ?? null, seo: { title: post.title ?? 'Quote from daily.dev', description: @@ -75,6 +79,8 @@ const QuoteImagePage = ({ title, sourceName, authorName, + sourceImage, + authorImage, }: QuotePageProps): ReactElement => { const { query } = useRouter(); // The quote itself is never part of the cached page — it comes from the @@ -89,8 +95,10 @@ const QuoteImagePage = ({ className="w-fit" > From ed30c4f5781eb764e7afaa55fd58908a8c734bcc Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Sun, 26 Jul 2026 12:30:39 +0300 Subject: [PATCH 07/24] fix(share): shorten selection-bar tooltips to one or two words The bar sits directly over the selection, so a sentence-length tooltip covers the text the reader is looking at. aria-labels keep the long form. Co-Authored-By: Claude Opus 5 --- .../src/components/post/SelectionShareBar.tsx | 12 ++++++-- .../components/SelectionShareBar.stories.tsx | 29 +++++++++++++++++++ 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/packages/shared/src/components/post/SelectionShareBar.tsx b/packages/shared/src/components/post/SelectionShareBar.tsx index 4ffb9346c4d..7f87dbd581a 100644 --- a/packages/shared/src/components/post/SelectionShareBar.tsx +++ b/packages/shared/src/components/post/SelectionShareBar.tsx @@ -234,7 +234,13 @@ function SelectionShareBarContent({ data-testid="selectionShareBar" className="flex animate-composer-in items-center gap-1 rounded-12 border border-border-subtlest-tertiary bg-background-popover p-1 shadow-2 motion-reduce:animate-none" > - + {/* + Tooltips stay to one or two words — the bar sits right on top of + what the reader just selected, so a sentence in a tooltip covers the + thing they are trying to look at. The aria-labels keep the long + form, where the extra context costs nothing. + */} + + )}
- {!isVideoType && article.image && ( - - )} - - 0 && ( - - From{' '} - - {article.domain} - - - ) - } - isVideoType={isVideoType} - readTime={article.readTime} - /> + 0 && ( + + From{' '} + + {article.domain} + + + ) + } + isVideoType={isVideoType} + readTime={article.readTime} + /> - {isVideoType && ( -
- {/* Embed YouTube's native player directly so the first click + {isVideoType && ( +
+ {/* Embed YouTube's native player directly so the first click plays inside the iframe with sound — no custom overlay or muted autoplay. */} - -
- )} + +
+ )} - {article.contentHtml ? ( - <> - - - - ) : ( - article.summary && - (isVideoType ? ( - + {article.contentHtml ? ( + <> + + + ) : ( -

- {article.summary} -

- )) - )} + article.summary && + (isVideoType ? ( + + ) : ( +

+ {article.summary} +

+ )) + )} + @@ -586,7 +589,6 @@ export const PostFocusCard = ({ - ); }; diff --git a/packages/shared/src/components/post/poll/PollPostContent.tsx b/packages/shared/src/components/post/poll/PollPostContent.tsx index 5aec9f26ba8..4cc9f0467bb 100644 --- a/packages/shared/src/components/post/poll/PollPostContent.tsx +++ b/packages/shared/src/components/post/poll/PollPostContent.tsx @@ -27,6 +27,7 @@ import { Typography, TypographyType } from '../../typography/Typography'; import { DiscussIcon } from '../../icons'; import { Button, ButtonSize, ButtonVariant } from '../../buttons/Button'; import type { Post } from '../../../graphql/posts'; +import { PostSelectionArea } from '../PostSelectionArea'; type PollPostContentRawProps = Omit & { post: Post }; @@ -201,55 +202,57 @@ function PollPostContentRaw({ {shouldShowBanner && isUserSource && isLaptop && ( )} -
- - {post?.title} - -
- - - - {justVoted && ( -
-
- - Why did you vote this way? + +
+ + {post?.title} + +
+ + + + {justVoted && ( +
+
+ + Why did you vote this way? +
+
- -
- )} + )} +
-
+
{ it('renders the screenshot wrapper around the quote', () => { render( , From ed33a0343b113d614042de95d47cd96036b8c3c9 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Sun, 26 Jul 2026 14:42:37 +0300 Subject: [PATCH 10/24] chore(storybook): cover every wired surface and the content-area scoping Adds the four post types the bar newly reaches (collection, poll, brief, social) plus stories proving comments, post chrome and digest feeds stay out of bounds. Co-Authored-By: Claude Opus 5 --- .../components/SelectionShareBar.stories.tsx | 218 +++++++++++++++++- 1 file changed, 208 insertions(+), 10 deletions(-) diff --git a/packages/storybook/stories/components/SelectionShareBar.stories.tsx b/packages/storybook/stories/components/SelectionShareBar.stories.tsx index dbfd6db21ac..6c9d7ed88d3 100644 --- a/packages/storybook/stories/components/SelectionShareBar.stories.tsx +++ b/packages/storybook/stories/components/SelectionShareBar.stories.tsx @@ -80,10 +80,7 @@ const raiseBar = (root: ParentNode | null): void => { // Raise the bar once the story has mounted. An effect is used rather than a // `play` function because it also fires in the docs view and does not depend on // Storybook's instrumentation timing. -const useAutoRaise = ( - root: RefObject, - enabled = true, -): void => { +const useAutoRaise = (root: RefObject, enabled = true): void => { useEffect(() => { if (!enabled) { return undefined; @@ -134,7 +131,10 @@ const Stage = ({ }, []); return ( -
+
{!!hint &&

{hint}

}
( ); +const CollectionBody = (): ReactElement => ( + <> +

+ What changed in frontend tooling this month +

+

+ Last updated today · 6 sources +

+ {/* eslint-disable-next-line react/jsx-props-no-spreading */} +

+ {paragraph} +

+

{secondParagraph}

+ +); + +const PollBody = (): ReactElement => ( + <> + {/* eslint-disable-next-line react/jsx-props-no-spreading */} +

+ Do you write tests before or after the implementation? +

+
+ {['Before, always', 'After, usually', 'Depends on the change'].map( + (option) => ( +
+ {option} +
+ ), + )} +
+ +); + +const BriefBody = (): ReactElement => ( + <> +

Today

+

Your presidential briefing

+

+ 3m read · 12 Sources +

+ {/* eslint-disable-next-line react/jsx-props-no-spreading */} +
+

What matters today

+

{paragraph}

+

{secondParagraph}

+
+ +); + +const SocialBody = (): ReactElement => ( + <> +
+ + @idoshamun +
+ {/* eslint-disable-next-line react/jsx-props-no-spreading */} +

+ {paragraph} +

+

{secondParagraph}

+ +); + // --------------------------------------------------------------------------- // Providers // --------------------------------------------------------------------------- @@ -634,26 +704,106 @@ export const IgnoredCrossingBoundary: Story = { render: () => , }; +/** + * The comment section sits inside the same column as the body on every surface + * (`BasePostContent` renders it), so binding the bar to that column armed it + * over replies — quoting one would have credited a commenter's words to the + * post. `PostSelectionArea` scopes the bar to title, TL;DR and body only. + */ +export const IgnoredComments: Story = { + render: () => ( + +

3 comments

+
+ + {/* eslint-disable-next-line react/jsx-props-no-spreading */} +

+ Completely agree — the second point is the one people miss. +

+
+
+ } + > + + + ), +}; + +/** + * Post chrome — navigation, source strip, tags, metadata, action bars — is + * outside the selection area too. Only readable content raises the bar. + */ +export const IgnoredPostChrome: Story = { + render: () => ( + + {/* eslint-disable-next-line react/jsx-props-no-spreading */} + #webdev · 6 min read · From daily.dev +
+ } + > + + + ), +}; + +/** + * Digest posts are deliberately excluded. A digest has no prose of its own — + * it is a header plus an embedded feed of *other* posts, so a selection there + * would quote a different post's headline and attribute it to the digest. + */ +export const IgnoredDigestPost: Story = { + render: () => ( + + {/* eslint-disable-next-line react/jsx-props-no-spreading */} +

+ Another post’s headline, listed inside the digest feed +

+

+ daily.dev · 4 min read +

+
+ } + > +

Today

+

Your personalized digest

+

12 posts · 6 sources

+ + ), +}; + // -- Surfaces --------------------------------------------------------------- -/** Classic article/video body — `PostContent`. */ +/** Article & video posts — `PostContent`. Page and modal. */ export const SurfaceArticlePost: Story = { render: () => ( ), }; -/** Freeform / welcome / share / YouTube squad post — `SquadPostContent`. */ +/** Freeform / welcome / share squad posts — `SquadPostContent`. */ export const SurfaceSquadPost: Story = { render: () => ( @@ -665,13 +815,61 @@ export const SurfaceFocusCard: Story = { render: () => ( ), }; +/** Collections — `CollectionPostContent`. Newly covered. */ +export const SurfaceCollectionPost: Story = { + render: () => ( + + + + ), +}; + +/** Polls — `PollPostContent`. The question and options are quotable. */ +export const SurfacePollPost: Story = { + render: () => ( + + + + ), +}; + +/** Briefs — `BriefPostContent`. Long generated prose, the best quote source. */ +export const SurfaceBriefPost: Story = { + render: () => ( + + + + ), +}; + +/** Social / Twitter posts — `SocialTwitterPostContent`. */ +export const SurfaceSocialPost: Story = { + render: () => ( + + + + ), +}; + // -- Dismissal -------------------------------------------------------------- /** Click away, press Escape, or collapse the selection — all drop the bar. */ From 284f6eb2ce8ee2747da3872dc5c2fcc21b938c60 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Sun, 26 Jul 2026 14:48:44 +0300 Subject: [PATCH 11/24] fix(storybook): ignored-comments and ignored-post-chrome selected the body ArticleBody carries the auto-selection target, so both stories selected the article instead of the thing they claim to ignore and showed a bar. They now render it with the target removed. Co-Authored-By: Claude Opus 5 --- .../components/SelectionShareBar.stories.tsx | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/packages/storybook/stories/components/SelectionShareBar.stories.tsx b/packages/storybook/stories/components/SelectionShareBar.stories.tsx index 6c9d7ed88d3..c3506af9aa5 100644 --- a/packages/storybook/stories/components/SelectionShareBar.stories.tsx +++ b/packages/storybook/stories/components/SelectionShareBar.stories.tsx @@ -171,14 +171,23 @@ const Stage = ({ // modals that do not belong in a story. // --------------------------------------------------------------------------- -const ArticleBody = (): ReactElement => ( +/** + * `selectable={false}` moves the auto-selection target out of the body, so a + * story can prove that selecting something *outside* raises nothing. Leaving it + * on would select the body instead and the story would silently pass. + */ +const ArticleBody = ({ + selectable = true, +}: { + selectable?: boolean; +}): ReactElement => ( <>

{post.title}

Ido Shamun · daily.dev · 6 min read

{/* eslint-disable-next-line react/jsx-props-no-spreading */} -

+

{paragraph}

{secondParagraph}

@@ -728,7 +737,7 @@ export const IgnoredComments: Story = {
} > - + ), }; @@ -749,7 +758,7 @@ export const IgnoredPostChrome: Story = { } > - + ), }; From cfc959ec601410495bedb5b95c66cf8a8952a778 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Sun, 26 Jul 2026 23:16:24 +0300 Subject: [PATCH 12/24] feat(share): ship the selection bar unflagged Removes share_text_selection entirely and drops the sharing_visibility gate from the bar, so it renders for every user with no experiment. The inner/outer component split existed only so a disabled experiment mounted no hooks; with no gate it collapses into one component. Tests and stories that asserted on the gate are replaced by ones asserting the bar renders for everyone and only hides when there is no selection. Co-Authored-By: Claude Opus 5 --- .../post/SelectionShareBar.spec.tsx | 31 ++++++++--------- .../src/components/post/SelectionShareBar.tsx | 33 +++---------------- packages/shared/src/lib/featureManagement.ts | 9 ----- .../components/SelectionShareBar.stories.tsx | 33 ++++--------------- 4 files changed, 24 insertions(+), 82 deletions(-) diff --git a/packages/shared/src/components/post/SelectionShareBar.spec.tsx b/packages/shared/src/components/post/SelectionShareBar.spec.tsx index 2d72b05c411..eee341f401e 100644 --- a/packages/shared/src/components/post/SelectionShareBar.spec.tsx +++ b/packages/shared/src/components/post/SelectionShareBar.spec.tsx @@ -40,13 +40,8 @@ const post = { permalink: 'https://daily.dev/r/how-to-ship-fast', } as unknown as Post; -const enabledGrowthBook = () => - new GrowthBook({ - features: { - sharing_visibility: { defaultValue: true }, - share_text_selection: { defaultValue: true }, - }, - }); +// The bar ships unflagged, so GrowthBook only has to exist for TestBootProvider. +const enabledGrowthBook = () => new GrowthBook(); beforeEach(() => { jest.clearAllMocks(); @@ -76,23 +71,23 @@ const renderComponent = ( }; }; -describe('SelectionShareBar flag gate', () => { - it('renders nothing and attaches no selection listeners when off', () => { +describe('SelectionShareBar gating', () => { + it('renders for every user, with no feature flag set', () => { renderComponent(new GrowthBook()); - expect(screen.queryByTestId('selectionShareBar')).not.toBeInTheDocument(); - expect(useTextSelectionShareMock).not.toHaveBeenCalled(); + expect(screen.getByTestId('selectionShareBar')).toBeInTheDocument(); }); - it('renders nothing when only the per-surface flag is on', () => { - renderComponent( - new GrowthBook({ - features: { share_text_selection: { defaultValue: true } }, - }), - ); + it('renders nothing when there is no selection', () => { + useTextSelectionShareMock.mockReturnValue({ + text: null, + rect: null, + clear, + }); + + renderComponent(); expect(screen.queryByTestId('selectionShareBar')).not.toBeInTheDocument(); - expect(useTextSelectionShareMock).not.toHaveBeenCalled(); }); }); diff --git a/packages/shared/src/components/post/SelectionShareBar.tsx b/packages/shared/src/components/post/SelectionShareBar.tsx index 7f87dbd581a..d5ac06ba1e3 100644 --- a/packages/shared/src/components/post/SelectionShareBar.tsx +++ b/packages/shared/src/components/post/SelectionShareBar.tsx @@ -10,14 +10,11 @@ import { ShareActions } from '../share/ShareActions'; import { useCopyText } from '../../hooks/useCopy'; import { useShareOrCopyLink } from '../../hooks/useShareOrCopyLink'; import { useTextSelectionShare } from '../../hooks/useTextSelectionShare'; -import { useSharingVisibility } from '../../hooks/useSharingVisibility'; -import { useConditionalFeature } from '../../hooks/useConditionalFeature'; import { useOutsideClick } from '../../hooks/utils/useOutsideClick'; import { useEventListener } from '../../hooks/useEventListener'; import { useVisualViewport } from '../../hooks/utils/useVisualViewport'; import { useLogContext } from '../../contexts/LogContext'; import { usePostLogEvent } from '../../lib/feed'; -import { featureShareTextSelection } from '../../lib/featureManagement'; import { LogEvent, Origin } from '../../lib/log'; import { ShareProvider } from '../../lib/share'; import { ReferralCampaignKey } from '../../lib/referral'; @@ -68,9 +65,11 @@ export const buildQuoteImageUrl = (postId: string, text: string): string => { )}`; }; -// The bar itself. Split from the flag gate below so a disabled experiment -// mounts none of these hooks — and therefore attaches no listeners at all. -function SelectionShareBarContent({ +/** + * Floating share bar for text selected inside a post body. Ships to everyone — + * there is no flag gate. + */ +export function SelectionShareBar({ post, containerRef, onQuote, @@ -284,25 +283,3 @@ function SelectionShareBarContent({ ); } - -/** - * Floating share bar for text selected inside a post body. Flag-off it renders - * nothing and, because every listener lives in the inner component, attaches no - * selection/viewport listeners at all. - */ -export function SelectionShareBar( - props: SelectionShareBarProps, -): ReactElement | null { - const { isEnabled: isSharingVisible } = useSharingVisibility(); - const { value: isSelectionShareOn } = useConditionalFeature({ - feature: featureShareTextSelection, - shouldEvaluate: isSharingVisible, - }); - - if (!isSharingVisible || !isSelectionShareOn) { - return null; - } - - // eslint-disable-next-line react/jsx-props-no-spreading - return ; -} diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts index edc71d4d995..97b38cbdd2b 100644 --- a/packages/shared/src/lib/featureManagement.ts +++ b/packages/shared/src/lib/featureManagement.ts @@ -308,12 +308,3 @@ export const featureSharingVisibility = new Feature( // high-traffic icon, so it ramps on its own flag to watch share/copy metrics. // Keep the default `false` (control = existing `LinkIcon`). export const featureShareCopyIcon = new Feature('share_copy_icon', false); - -// Shows a floating share bar anchored to text selected inside a post body -// (copy link, copy the selection, generate a quote image). Part of the -// sharing-visibility initiative, so it also requires `sharing_visibility`. -// Keep the default `false` — GrowthBook ramps it. -export const featureShareTextSelection = new Feature( - 'share_text_selection', - false, -); diff --git a/packages/storybook/stories/components/SelectionShareBar.stories.tsx b/packages/storybook/stories/components/SelectionShareBar.stories.tsx index c3506af9aa5..ee54b881e97 100644 --- a/packages/storybook/stories/components/SelectionShareBar.stories.tsx +++ b/packages/storybook/stories/components/SelectionShareBar.stories.tsx @@ -307,13 +307,10 @@ const SocialBody = (): ReactElement => ( // Providers // --------------------------------------------------------------------------- -// Storybook aliases `@growthbook/growthbook` to a mock whose `getFeatureValue` -// coerces every falsy default to the truthy string `'control'`, so a flag can't -// be evaluated as `false` here. Flag-off is therefore simulated by holding the -// features context as "not ready", which is the exact path -// `useConditionalFeature` takes to fall back to the (false) default value. +// The bar ships unflagged, so these providers only supply the contexts it +// reads — auth, logging and react-query. There is no experiment to simulate. const withProviders = - (enabled: boolean) => + () => (Story: React.ComponentType): React.ReactElement => { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } }, @@ -354,7 +351,7 @@ const withProviders = > feature.defaultValue as any, }} @@ -385,7 +382,7 @@ const meta: Meta = { description: { component: [ 'Floating share bar anchored to a text selection inside a post body.', - 'Behind the `share_text_selection` flag **and** the `sharing_visibility` master gate.', + 'Ships to every user — there is no feature flag or experiment behind it.', '', 'Every story fakes a selection on load, so the bar is visible without dragging a cursor.', 'Clicking anywhere dismisses it — hit **Raise the bar again** to bring it back.', @@ -394,7 +391,7 @@ const meta: Meta = { }, }, }, - decorators: [withProviders(true)], + decorators: [withProviders()], }; export default meta; @@ -892,21 +889,3 @@ export const Dismissal: Story = { ), }; - -// -- Flag off --------------------------------------------------------------- - -/** - * Control. Nothing renders and none of the inner hooks mount, so no selection - * or viewport listeners are attached at all. - */ -export const FlagOff: Story = { - decorators: [withProviders(false)], - render: () => ( - - - - ), -}; From 8b4e622896759c0243ac2b2a7ed2eda4807ec7a8 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Mon, 27 Jul 2026 09:28:32 +0300 Subject: [PATCH 13/24] fix(share): copy confirms with a check, quote scrolls to the composer - Copy link and copy text swap their icon to a check for the second that useCopyLink/useCopyText hold `copying`, cross-fading in a single grid cell so the button never changes width. Tooltip says "Copied!" alongside. - A draft handed to the composer through ?comment= has no click behind it to move the page, so it opened unseen below the fold. focusInputById now scrolls it into view first and focuses without stealing that scroll. Optional-chained because jsdom has no scrollIntoView. - The share popover tracks the bar every frame. The bar is fixed and re-anchors on scroll; the default strategy left the popover at a stale position until the next scroll/resize event. Co-Authored-By: Claude Opus 5 --- .../shared/src/components/post/NewComment.tsx | 20 +++++++- .../src/components/post/SelectionShareBar.tsx | 51 ++++++++++++++++--- .../src/components/share/ShareActions.tsx | 6 +++ 3 files changed, 68 insertions(+), 9 deletions(-) diff --git a/packages/shared/src/components/post/NewComment.tsx b/packages/shared/src/components/post/NewComment.tsx index 6d63d04e01c..1dc842e8910 100644 --- a/packages/shared/src/components/post/NewComment.tsx +++ b/packages/shared/src/components/post/NewComment.tsx @@ -52,7 +52,13 @@ const focusInputById = (inputId: string, remainingFrames = 30): void => { const input = document.getElementById(inputId); if (input) { - input.focus(); + // Bring the composer into view before focusing. A draft can arrive from far + // up the page — quoting a selection from the post body, say — and a bare + // `focus()` jumps the scroll position instead of moving to it. Scrolling + // first, with focus not stealing the scroll, keeps that motion smooth. + // Optional-chained: jsdom does not implement `scrollIntoView`. + input.scrollIntoView?.({ behavior: 'smooth', block: 'center' }); + input.focus({ preventScroll: true }); return; } @@ -131,11 +137,21 @@ function NewCommentComponent( const origin = (commentOrigin as Origin) ?? originByPostType; onShowComment(origin, comment as string); + // A draft handed over through the URL has no click behind it to scroll the + // page, so the composer would open unseen below the fold. + focusInputById(inputId); router.replace({ pathname: router.pathname, query }, undefined, { shallow: true, }); - }, [post, hasCommentQuery, onShowComment, router, shouldHandleCommentQuery]); + }, [ + post, + hasCommentQuery, + inputId, + onShowComment, + router, + shouldHandleCommentQuery, + ]); const onCommentClick = (origin: Origin) => { if (!user) { diff --git a/packages/shared/src/components/post/SelectionShareBar.tsx b/packages/shared/src/components/post/SelectionShareBar.tsx index d5ac06ba1e3..788db5772cc 100644 --- a/packages/shared/src/components/post/SelectionShareBar.tsx +++ b/packages/shared/src/components/post/SelectionShareBar.tsx @@ -1,9 +1,10 @@ import type { ReactElement, RefObject } from 'react'; import React, { useCallback, useLayoutEffect, useRef, useState } from 'react'; +import classNames from 'classnames'; import { useRouter } from 'next/router'; import type { Post } from '../../graphql/posts'; import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; -import { CopyIcon, DiscussIcon, LinkIcon, ShareIcon } from '../icons'; +import { CopyIcon, DiscussIcon, LinkIcon, ShareIcon, VIcon } from '../icons'; import { Tooltip } from '../tooltip/Tooltip'; import { RootPortal } from '../tooltips/Portal'; import { ShareActions } from '../share/ShareActions'; @@ -65,6 +66,38 @@ export const buildQuoteImageUrl = (postId: string, text: string): string => { )}`; }; +// Copy actions confirm twice: the toast says what happened, and the button +// itself swaps to a check. `useCopyLink`/`useCopyText` hold `copying` for a +// second, which is the whole life of this transition. Both icons are stacked in +// one grid cell so the button never changes width mid-swap. +const CopyFeedbackIcon = ({ + copied, + icon, +}: { + copied: boolean; + icon: ReactElement; +}): ReactElement => ( + + + {icon} + + + + + +); + /** * Floating share bar for text selected inside a post body. Ships to everyone — * there is no flag gate. @@ -86,12 +119,12 @@ export function SelectionShareBar({ const { logEvent } = useLogContext(); const postLogEvent = usePostLogEvent(); - const [, shareOrCopyLink] = useShareOrCopyLink({ + const [isLinkCopied, shareOrCopyLink] = useShareOrCopyLink({ link: post.commentsPermalink, text: text ?? post.title ?? '', cid: ReferralCampaignKey.SharePost, }); - const [, copyText] = useCopyText(); + const [isTextCopied, copyText] = useCopyText(); const dismiss = useCallback(() => { globalThis?.window?.getSelection?.()?.removeAllRanges(); @@ -239,21 +272,25 @@ export function SelectionShareBar({ thing they are trying to look at. The aria-labels keep the long form, where the extra context costs nothing. */} - + {expanded && tldr && (
-

{tldr}

+ {/* + Only the expanded summary is quotable. The headline sits inside the + expand/collapse button, where a drag toggles the row instead of + selecting, so binding the bar to it would raise nothing. + The query fetches enough of the post for the bar and its logging. + */} + +

{tldr}

+
Read more diff --git a/packages/shared/src/components/post/PostSelectionArea.tsx b/packages/shared/src/components/post/PostSelectionArea.tsx index 3bfe143d41c..9b1a3ed3b07 100644 --- a/packages/shared/src/components/post/PostSelectionArea.tsx +++ b/packages/shared/src/components/post/PostSelectionArea.tsx @@ -30,7 +30,7 @@ export function PostSelectionArea({ return ( <> -
+
{children}
diff --git a/packages/shared/src/graphql/highlights.ts b/packages/shared/src/graphql/highlights.ts index 208e4164865..ef8d8b2110f 100644 --- a/packages/shared/src/graphql/highlights.ts +++ b/packages/shared/src/graphql/highlights.ts @@ -29,6 +29,19 @@ export interface PostHighlightFeed { commentsPermalink: string; summary?: string; contentHtml?: string; + // Enough of a post for the selection share bar: the link it copies, and + // the fields `postLogEvent` reads so a share from here is not a hollow + // event. Deliberately short of a full Post — this is a list query. + title?: string; + permalink?: string; + image?: string; + createdAt?: string; + readTime?: number; + numComments?: number; + numUpvotes?: number; + tags?: string[]; + source?: { id: string; type: string }; + author?: { id: string }; sharedPost?: { summary?: string; contentHtml?: string; @@ -120,6 +133,21 @@ export const POST_HIGHLIGHT_FEED_FRAGMENT = gql` commentsPermalink summary contentHtml + title + permalink + image + createdAt + readTime + numComments + numUpvotes + tags + source { + id + type + } + author { + id + } sharedPost { summary contentHtml From e0e309e1aa5f5d0e066d76c78b008397f17a06a1 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Tue, 28 Jul 2026 10:23:34 +0300 Subject: [PATCH 21/24] fix(share): only offer Quote where a composer exists to receive it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Quote hands the selection to a comment composer, so on a surface without one it was a dead button. Briefings and the highlights list have no comment section — BriefPostContent passes no engagementProps, and /highlights is a list — so both now render the bar without it. Same bug on the reply modal's quoted comment: CommentContainer there gets no onQuote, so the button did nothing. A comment now offers Quote only when its reply composer is actually wired, which is structural rather than a silent no-op inside the handler. Co-Authored-By: Claude Opus 5 --- .../components/highlights/HighlightItem.tsx | 5 ++- .../src/components/post/PostSelectionArea.tsx | 9 ++++- .../post/SelectionShareBar.spec.tsx | 26 ++++++++++++++- .../src/components/post/SelectionShareBar.tsx | 33 +++++++++++++------ .../post/brief/BriefPostContent.tsx | 2 +- 5 files changed, 61 insertions(+), 14 deletions(-) diff --git a/packages/shared/src/components/highlights/HighlightItem.tsx b/packages/shared/src/components/highlights/HighlightItem.tsx index 226fcdbe241..cc0bf201b21 100644 --- a/packages/shared/src/components/highlights/HighlightItem.tsx +++ b/packages/shared/src/components/highlights/HighlightItem.tsx @@ -88,7 +88,10 @@ export const HighlightItem = ({ selecting, so binding the bar to it would raise nothing. The query fetches enough of the post for the bar and its logging. */} - +

{tldr}

diff --git a/packages/shared/src/components/post/PostSelectionArea.tsx b/packages/shared/src/components/post/PostSelectionArea.tsx index 9b1a3ed3b07..0881dbd4c73 100644 --- a/packages/shared/src/components/post/PostSelectionArea.tsx +++ b/packages/shared/src/components/post/PostSelectionArea.tsx @@ -7,6 +7,8 @@ export interface PostSelectionAreaProps { post: Post; /** Title, TL;DR and body — the parts of a post worth quoting. */ children: ReactNode; + /** False where the surface renders no comment composer. */ + canQuote?: boolean; } /** @@ -25,6 +27,7 @@ export interface PostSelectionAreaProps { export function PostSelectionArea({ post, children, + canQuote = true, }: PostSelectionAreaProps): ReactElement { const contentRef = useRef(null); @@ -33,7 +36,11 @@ export function PostSelectionArea({
{children}
- + ); } diff --git a/packages/shared/src/components/post/SelectionShareBar.spec.tsx b/packages/shared/src/components/post/SelectionShareBar.spec.tsx index 9a1d05aeeee..0b3668de686 100644 --- a/packages/shared/src/components/post/SelectionShareBar.spec.tsx +++ b/packages/shared/src/components/post/SelectionShareBar.spec.tsx @@ -227,11 +227,35 @@ describe('SelectionShareBar on a comment', () => { expect(onQuote).toHaveBeenCalledWith(`> ${selection}\n\n`); }); - it('never quotes a comment into the post composer', () => { + it('hides quote on a comment with no reply composer wired', () => { renderComponent(undefined, { comment }); + expect(screen.getByTestId('selectionShareBar')).toBeInTheDocument(); + expect( + screen.queryByLabelText('Quote in a comment'), + ).not.toBeInTheDocument(); + }); + + it('never routes a comment quote through the post composer', () => { + const onQuote = jest.fn(); + renderComponent(undefined, { comment, onQuote }); + fireEvent.click(screen.getByLabelText('Quote in a comment')); + expect(onQuote).toHaveBeenCalled(); expect(mockReplace).not.toHaveBeenCalled(); }); }); + +describe('SelectionShareBar where nothing can be quoted', () => { + it('hides quote when the surface has no comment composer', () => { + renderComponent(undefined, { canQuote: false }); + + expect(screen.getByTestId('selectionShareBar')).toBeInTheDocument(); + expect(screen.getByLabelText('Copy selected text')).toBeInTheDocument(); + expect(screen.getByLabelText('Share')).toBeInTheDocument(); + expect( + screen.queryByLabelText('Quote in a comment'), + ).not.toBeInTheDocument(); + }); +}); diff --git a/packages/shared/src/components/post/SelectionShareBar.tsx b/packages/shared/src/components/post/SelectionShareBar.tsx index 9fdb5c1f7fd..10b9fc78ef2 100644 --- a/packages/shared/src/components/post/SelectionShareBar.tsx +++ b/packages/shared/src/components/post/SelectionShareBar.tsx @@ -38,6 +38,13 @@ export interface SelectionShareBarProps { * the URL as `?comment=`, which the post's comment composer picks up. */ onQuote?: (markdownQuote: string) => void; + /** + * Set false on surfaces with no comment composer — briefings, digests and + * the highlights list. Quote hands off to a composer, so without one the + * button would be dead. Ignored when `onQuote` is given, since that is a + * composer by definition. + */ + canQuote?: boolean; } /** Renders the selection as a markdown blockquote for the comment composer. */ @@ -83,6 +90,7 @@ export function SelectionShareBar({ containerRef, comment, onQuote, + canQuote = true, }: SelectionShareBarProps): ReactElement | null { const { text, rect, clear } = useTextSelectionShare({ containerRef }); const barRef = useRef(null); @@ -104,6 +112,9 @@ export function SelectionShareBar({ }); const [isTextCopied, copyText] = useCopyText(); const { getShortUrl } = useGetShortUrl(); + // A comment can only be quoted by whoever wired its reply composer; a post + // only where its surface renders one. + const showQuote = onQuote ? true : !comment && canQuote; const dismiss = useCallback(() => { globalThis?.window?.getSelection?.()?.removeAllRanges(); @@ -299,16 +310,18 @@ export function SelectionShareBar({ variant={ButtonVariant.Tertiary} /> - -
+ )}
- {!isVideoType && article.image && ( - - )}
- - 0 && ( - - From{' '} - - {article.domain} - - - ) - } - isVideoType={isVideoType} - readTime={article.readTime} - /> + 0 && ( + + From{' '} + + {article.domain} + + + ) + } + isVideoType={isVideoType} + readTime={article.readTime} + /> - {isVideoType && ( -
- {/* Embed YouTube's native player directly so the first click + {isVideoType && ( +
+ {/* Embed YouTube's native player directly so the first click plays inside the iframe with sound — no custom overlay or muted autoplay. */} - -
- )} + +
+ )} - {article.contentHtml ? ( - <> - - - - ) : ( - article.summary && - (isVideoType ? ( - + {article.contentHtml ? ( + <> + + + ) : ( -

- {article.summary} -

- )) - )} - + article.summary && + (isVideoType ? ( + + ) : ( +

+ {article.summary} +

+ )) + )} + - + - onShowUpvoted(post.id, upvotes)} - onCommentsClick={scrollToComment} - // Spacing in this column is governed by its `gap-4`; drop the stats - // row's own bottom margin so the gap above the action bar matches - // the gap below it. - className="!mb-0" - /> + onShowUpvoted(post.id, upvotes)} + onCommentsClick={scrollToComment} + // Spacing in this column is governed by its `gap-4`; drop the stats + // row's own bottom margin so the gap above the action bar matches + // the gap below it. + className="!mb-0" + /> - {isCollection && } + {isCollection && } - {showCodeSnippets && ( -
- -
- )} - - - - - -
- { - focusCommentRef.current = fn; - }} + {showCodeSnippets && ( +
+ +
+ )} + + + + + +
+ { + focusCommentRef.current = fn; + }} + post={post} + origin={origin} + /> +
- - + + ); }; diff --git a/packages/shared/src/hooks/useSelectionAnchor.ts b/packages/shared/src/hooks/useSelectionAnchor.ts new file mode 100644 index 00000000000..709a0be6960 --- /dev/null +++ b/packages/shared/src/hooks/useSelectionAnchor.ts @@ -0,0 +1,78 @@ +import type { RefObject } from 'react'; +import { useLayoutEffect, useState } from 'react'; +import type { TextSelectionRect } from './useTextSelectionShare'; +import { useEventListener } from './useEventListener'; +import { useVisualViewport } from './utils/useVisualViewport'; + +// Breathing room between the selection and the bar. +const ANCHOR_GAP = 8; +// Below this distance from the top of the viewport there is no room above the +// selection, so the bar flips underneath it. +const FLIP_THRESHOLD = 64; +const VIEWPORT_MARGIN = 8; + +export interface SelectionAnchor { + left: number; + top: number; + flipsBelow: boolean; + /** False until the bar has been measured; anchoring before that would jump. */ + isMeasured: boolean; +} + +const readViewportOffset = () => ({ + left: globalThis?.window?.visualViewport?.offsetLeft ?? 0, + top: globalThis?.window?.visualViewport?.offsetTop ?? 0, +}); + +/** + * Places a fixed-position bar against a selection: centred above it, flipped + * below when there is no room, and clamped so it never leaves the viewport. + * + * Clamping uses the *visual* viewport. Pinch-zoom pans the visual viewport + * without moving the layout viewport a `fixed` element sits in, so a bar + * anchored to layout coordinates drifts off screen on a zoomed phone. + */ +export const useSelectionAnchor = ( + rect: TextSelectionRect | null, + barRef: RefObject, +): SelectionAnchor => { + const [barWidth, setBarWidth] = useState(null); + const { width: viewportWidth } = useVisualViewport(); + const [viewportOffset, setViewportOffset] = useState(readViewportOffset); + + useLayoutEffect(() => { + if (barRef.current) { + setBarWidth(barRef.current.offsetWidth); + } + + // Read the pan offset here too, not only on the next `scroll`. A reader who + // pinch-zoomed and panned *before* selecting — the usual order on mobile — + // would otherwise be clamped against a stale origin until they panned again. + setViewportOffset(readViewportOffset()); + }, [barRef, rect]); + + useEventListener( + rect ? globalThis?.window?.visualViewport : null, + 'scroll', + () => setViewportOffset(readViewportOffset()), + ); + + if (!rect) { + return { left: 0, top: 0, flipsBelow: false, isMeasured: false }; + } + + const availableWidth = viewportWidth || globalThis?.window?.innerWidth || 0; + const half = (barWidth ?? 0) / 2; + const minCenter = viewportOffset.left + VIEWPORT_MARGIN + half; + const maxCenter = + viewportOffset.left + availableWidth - VIEWPORT_MARGIN - half; + const center = rect.left + (rect.right - rect.left) / 2; + const flipsBelow = rect.top - viewportOffset.top < FLIP_THRESHOLD; + + return { + left: Math.min(Math.max(center, minCenter), Math.max(minCenter, maxCenter)), + top: flipsBelow ? rect.bottom + ANCHOR_GAP : rect.top - ANCHOR_GAP, + flipsBelow, + isMeasured: barWidth !== null, + }; +}; diff --git a/packages/shared/src/hooks/useTextSelectionShare.spec.ts b/packages/shared/src/hooks/useTextSelectionShare.spec.ts index 3dd3aa6af74..047c2e6dfe0 100644 --- a/packages/shared/src/hooks/useTextSelectionShare.spec.ts +++ b/packages/shared/src/hooks/useTextSelectionShare.spec.ts @@ -37,8 +37,9 @@ const setup = () => { container.appendChild(child); document.body.appendChild(container); - const containerRef = { current: container }; - const { result } = renderHook(() => useTextSelectionShare({ containerRef })); + const resolveArea = (node: Node | null) => + node && container.contains(node) ? container : null; + const { result } = renderHook(() => useTextSelectionShare({ resolveArea })); return { result, container, child }; }; diff --git a/packages/shared/src/hooks/useTextSelectionShare.ts b/packages/shared/src/hooks/useTextSelectionShare.ts index acc438e2511..04e5d6c51ee 100644 --- a/packages/shared/src/hooks/useTextSelectionShare.ts +++ b/packages/shared/src/hooks/useTextSelectionShare.ts @@ -1,4 +1,3 @@ -import type { RefObject } from 'react'; import { useCallback, useRef, useState } from 'react'; import { useEventListener } from './useEventListener'; @@ -10,8 +9,13 @@ export interface TextSelectionRect { } export interface UseTextSelectionShareProps { - /** Only selections that both start and end inside this element count. */ - containerRef: RefObject; + /** + * Maps a selection boundary to the registered region that owns it, or null + * when the boundary falls outside every watched region. A selection counts + * only when both ends resolve to the *same* region, so a drag that starts in + * a post and ends in a comment belongs to neither. + */ + resolveArea: (node: Node | null) => HTMLElement | null; } export interface UseTextSelectionShare { @@ -19,23 +23,30 @@ export interface UseTextSelectionShare { text: string | null; /** Viewport-space rect of the selection, for anchoring a fixed element. */ rect: TextSelectionRect | null; + /** The region the selection sits in, for the caller to attribute it. */ + area: HTMLElement | null; clear: () => void; } -// Single-word accidental selections (double-clicking a link, tapping a word) -// are noise — require enough text for a quote to be worth sharing. +// Two characters, not two words: enough to drop a stray click-drag without +// second-guessing someone who genuinely wants to quote one short word. const MIN_SELECTION_LENGTH = 2; -const isInsideContainer = ( - node: Node | null, - container: HTMLElement, -): boolean => { - if (!node) { - return false; - } - - return container.contains(node); -}; +// Keys that can move a selection boundary. Every other keystroke — typing in +// the composer the bar just opened, for instance — leaves the selection alone, +// so there is nothing to re-read. +const SELECTION_KEYS = new Set([ + 'ArrowLeft', + 'ArrowRight', + 'ArrowUp', + 'ArrowDown', + 'Home', + 'End', + 'PageUp', + 'PageDown', + 'a', + 'A', +]); const toRect = (range: Range): TextSelectionRect | null => { const { top, bottom, left, right, width, height } = @@ -51,34 +62,42 @@ const toRect = (range: Range): TextSelectionRect | null => { }; /** - * Watches for a completed text selection inside `containerRef` and exposes the - * selected text plus a viewport rect to anchor a floating bar to. The rect is - * recomputed on scroll/resize so the bar follows the selection. + * Watches for a completed text selection inside any registered region and + * exposes the selected text, a viewport rect to anchor a floating bar to, and + * the region that owns it. The rect is recomputed on scroll/resize so the bar + * follows the selection. + * + * One instance is meant to serve a whole page: mounting it per item would put + * four document listeners on every comment in a thread. */ export const useTextSelectionShare = ({ - containerRef, + resolveArea, }: UseTextSelectionShareProps): UseTextSelectionShare => { const [text, setText] = useState(null); const [rect, setRect] = useState(null); + const [area, setArea] = useState(null); const rangeRef = useRef(null); const clear = useCallback(() => { rangeRef.current = null; setText(null); setRect(null); + setArea(null); }, []); const readSelection = useCallback(() => { - const container = containerRef.current; + const selection = globalThis?.window?.getSelection?.(); - if (!container) { + if (!selection || selection.isCollapsed || selection.rangeCount === 0) { clear(); return; } - const selection = globalThis?.window?.getSelection?.(); + const owner = resolveArea(selection.anchorNode); - if (!selection || selection.isCollapsed || selection.rangeCount === 0) { + // Resolve before reading the string: `toString()` is O(selection length) + // and most selections on a page are outside any watched region. + if (!owner || resolveArea(selection.focusNode) !== owner) { clear(); return; } @@ -90,14 +109,6 @@ export const useTextSelectionShare = ({ return; } - if ( - !isInsideContainer(selection.anchorNode, container) || - !isInsideContainer(selection.focusNode, container) - ) { - clear(); - return; - } - const range = selection.getRangeAt(0); const nextRect = toRect(range); @@ -109,14 +120,19 @@ export const useTextSelectionShare = ({ rangeRef.current = range; setText(selected); setRect(nextRect); - }, [clear, containerRef]); + setArea(owner); + }, [clear, resolveArea]); const target = globalThis?.document; - // Selection *end* — mouse release, touch release, or a shift+arrow keyup. + // Selection *end* — mouse release, touch release, or a keyboard selection. useEventListener(target, 'mouseup', readSelection); useEventListener(target, 'touchend', readSelection); - useEventListener(target, 'keyup', readSelection); + useEventListener(target, 'keyup', (event: KeyboardEvent) => { + if (SELECTION_KEYS.has(event.key)) { + readSelection(); + } + }); // A click elsewhere collapses the selection without firing another mouseup on // the container, so drop the bar as soon as the browser reports it collapsed. @@ -148,5 +164,5 @@ export const useTextSelectionShare = ({ useEventListener(followTarget, 'scroll', follow, true); useEventListener(followTarget, 'resize', follow); - return { text, rect, clear }; + return { text, rect, area, clear }; }; diff --git a/packages/shared/src/lib/strings.ts b/packages/shared/src/lib/strings.ts index a673ffa7f49..76eb2aeaffd 100644 --- a/packages/shared/src/lib/strings.ts +++ b/packages/shared/src/lib/strings.ts @@ -176,3 +176,22 @@ export const truncateAtWordBoundary = ( * Used for validating user input like emoji shortcuts or mentions. */ export const specialCharsRegex = /[^A-Za-z0-9_.]/; + +/** + * Renders a text selection as a markdown blockquote for a comment composer. + */ +export const buildCommentQuote = (selection: string): string => + `${selection + .split('\n') + .map((line) => `> ${line}`.trimEnd()) + .join('\n')}\n\n`; + +// Long enough for any quote worth reading, short enough that the resulting URL +// and history entry stay sane once encoded. +const MAX_URL_TEXT_LENGTH = 1000; + +/** Caps text that has to survive a round-trip through a query string. */ +export const truncateForUrl = (value: string): string => + value.length > MAX_URL_TEXT_LENGTH + ? `${value.slice(0, MAX_URL_TEXT_LENGTH).trimEnd()}…` + : value; diff --git a/packages/storybook/stories/components/QuoteImageCard.stories.tsx b/packages/storybook/stories/components/QuoteImageCard.stories.tsx deleted file mode 100644 index 9d16ab73474..00000000000 --- a/packages/storybook/stories/components/QuoteImageCard.stories.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/react-vite'; -import React from 'react'; -import { QuoteImageCard } from '@dailydotdev/shared/src/components/post/QuoteImageCard'; - -const authorImage = - 'https://media.daily.dev/image/upload/s---xy_OAwk--/f_auto,q_auto/v1703781380/avatars/avatar_28849d86070e4c099c877ab6837c61f0'; -const sourceImage = - 'https://media.daily.dev/image/upload/s--mqP40YbK--/f_auto/v1707831184/squads/303a826b-28e4-4d2f-938a-c610148e6f01'; - -const meta: Meta = { - title: 'Components/Share/QuoteImageCard', - component: QuoteImageCard, - parameters: { - docs: { - description: { - component: [ - 'The 1200x630 card rendered at `/image-generator/quote/[id]` and screenshotted into a shareable quote image.', - 'Fixed pixel sizing on purpose — the output is a bitmap.', - '', - 'The attribution row carries the author avatar badged with the source logo.', - 'Plain `` tags: the screenshot service renders the page once and captures it,', - 'so there is nothing for lazy loading to defer to.', - '', - '**Parked:** the share bar no longer offers a "generate quote image" action;', - 'the route and this card stay in place until the screenshot service serves the PNG.', - ].join('\n'), - }, - }, - }, - args: { - quote: - 'Shipping fast is not about typing faster. It is about shrinking the distance between a decision and the moment a real developer feels its effect.', - title: 'How to ship fast without breaking everything', - sourceName: 'daily.dev', - authorName: 'Ido Shamun', - authorImage, - sourceImage, - }, - // The card is wider than the docs frame, so scale it down to fit. - decorators: [ - (Story) => ( -
- -
- ), - ], -}; - -export default meta; - -type Story = StoryObj; - -/** Author avatar with the source badged on its corner. */ -export const Default: Story = {}; - -// Long selections are truncated by the share bar, but the card still clamps. -export const LongQuote: Story = { - args: { - quote: - 'Shipping fast is not about typing faster. It is about shrinking the distance between a decision and the moment a real developer feels its effect, which means every layer between the two is either helping or in the way, and most of them are in the way…', - }, -}; - -/** No author (most syndicated articles): the source logo stands alone. */ -export const SourceOnly: Story = { - args: { authorName: null, authorImage: null }, -}; - -/** Author with no avatar on file falls back to the anonymous placeholder. */ -export const AuthorWithoutAvatar: Story = { - args: { authorImage: null }, -}; - -/** Neither image resolved — the row collapses to text, no empty boxes. */ -export const NoImages: Story = { - args: { authorImage: null, sourceImage: null, authorName: null }, -}; diff --git a/packages/storybook/stories/components/SelectionShareBar.stories.tsx b/packages/storybook/stories/components/SelectionShareBar.stories.tsx index 8e938fd0af8..99b4e6f9809 100644 --- a/packages/storybook/stories/components/SelectionShareBar.stories.tsx +++ b/packages/storybook/stories/components/SelectionShareBar.stories.tsx @@ -4,6 +4,10 @@ import React, { useCallback, useEffect, useRef } from 'react'; import classNames from 'classnames'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { SelectionShareBar } from '@dailydotdev/shared/src/components/post/SelectionShareBar'; +import { + SelectionShareProvider, + useSelectionShareArea, +} from '@dailydotdev/shared/src/components/post/SelectionShareProvider'; import { getLogContextStatic } from '@dailydotdev/shared/src/contexts/LogContext'; import AuthContext from '@dailydotdev/shared/src/contexts/AuthContext'; import { @@ -94,6 +98,28 @@ const useAutoRaise = (root: RefObject, enabled = true): void => { }, [enabled, root]); }; +// Marks a region quotable exactly the way the app does, so the stories exercise +// the real provider/registration path rather than the bar in isolation. +const Area = ({ + children, + className, + comment, + onQuote, +}: { + children: ReactNode; + className?: string; + comment?: Comment; + onQuote?: (markdownQuote: string) => void; +}): ReactElement => { + const ref = useSelectionShareArea({ post, comment, onQuote }); + + return ( +
+ {children} +
+ ); +}; + interface StageProps { /** What to look at in this story. */ hint?: ReactNode; @@ -121,7 +147,6 @@ const Stage = ({ onQuote, }: StageProps): ReactElement => { const stageRef = useRef(null); - const containerRef = useRef(null); useAutoRaise(stageRef, autoRaise); @@ -132,36 +157,33 @@ const Stage = ({ }, []); return ( -
- {!!hint &&

{hint}

} +
- {children} -
- {outside} - {showRaiseButton && ( - - )} - -
+ {children} + + {outside} + {showRaiseButton && ( + + )} + + ); }; @@ -901,7 +923,6 @@ const comment = { const CommentSurface = (): ReactElement => { const rootRef = useRef(null); - const contentRef = useRef(null); const [reply, setReply] = React.useState(null); useAutoRaise(rootRef); @@ -920,20 +941,16 @@ const CommentSurface = (): ReactElement => { @ido · 1h - {/* eslint-disable-next-line react/jsx-props-no-spreading */} -
-

{secondParagraph}

-
+ + {/* eslint-disable-next-line react/jsx-props-no-spreading */} +

+ {secondParagraph} +

+
Upvote Reply
-
         {reply ?? 'Reply composer is empty.'}
@@ -948,7 +965,11 @@ const CommentSurface = (): ReactElement => {
  * reply to that comment seeded with the blockquote.
  */
 export const SurfaceComment: Story = {
-  render: () => ,
+  render: () => (
+    
+      
+    
+  ),
 };
 
 /**
diff --git a/packages/webapp/__tests__/QuoteImageGeneratorPage.spec.tsx b/packages/webapp/__tests__/QuoteImageGeneratorPage.spec.tsx
deleted file mode 100644
index df16dc44c0b..00000000000
--- a/packages/webapp/__tests__/QuoteImageGeneratorPage.spec.tsx
+++ /dev/null
@@ -1,91 +0,0 @@
-import React from 'react';
-import { render, screen } from '@testing-library/react';
-import { gqlClient } from '@dailydotdev/shared/src/graphql/common';
-import { useRouter } from 'next/router';
-import QuoteImagePage, {
-  getStaticProps,
-} from '../pages/image-generator/quote/[id]';
-
-jest.mock('@dailydotdev/shared/src/graphql/common', () => {
-  const actual = jest.requireActual('@dailydotdev/shared/src/graphql/common');
-
-  return { ...actual, gqlClient: { request: jest.fn() } };
-});
-
-jest.mock('next/router', () => ({
-  __esModule: true,
-  useRouter: jest.fn(),
-}));
-
-const mockRequest = gqlClient.request as jest.Mock;
-const useRouterMock = useRouter as jest.Mock;
-
-const post = {
-  id: 'post-1',
-  title: 'How to ship fast',
-  source: { name: 'daily.dev' },
-  author: { name: 'Ido Shamun' },
-};
-
-beforeEach(() => {
-  jest.clearAllMocks();
-  useRouterMock.mockReturnValue({ query: { text: 'a quote worth sharing' } });
-});
-
-describe('quote image generator getStaticProps', () => {
-  it('returns the post attribution and a large-image twitter card', async () => {
-    mockRequest.mockResolvedValue({ post });
-
-    const result = await getStaticProps({ params: { id: 'post-1' } });
-    const { props } = result as unknown as {
-      props: {
-        id: string;
-        seo: { openGraph: { images: { url: string }[] } };
-      };
-    };
-
-    expect(props).toMatchObject({
-      id: 'post-1',
-      title: 'How to ship fast',
-      sourceName: 'daily.dev',
-      authorName: 'Ido Shamun',
-    });
-    expect(props.seo).toMatchObject({
-      twitter: { cardType: 'summary_large_image' },
-    });
-    expect(props.seo.openGraph.images[0].url).toContain('post-1');
-  });
-
-  it('404s when the post is missing', async () => {
-    mockRequest.mockRejectedValue(new Error('not found'));
-
-    const result = await getStaticProps({ params: { id: 'nope' } });
-
-    expect(result).toMatchObject({ notFound: true });
-  });
-});
-
-describe('quote image generator page', () => {
-  it('renders the screenshot wrapper around the quote', () => {
-    render(
-      ,
-    );
-
-    // The screenshot service targets this exact id.
-    expect(screen.getByTestId('screenshot_wrapper')).toHaveAttribute(
-      'id',
-      'screenshot_wrapper',
-    );
-    expect(screen.getByText('a quote worth sharing')).toBeInTheDocument();
-    expect(screen.getByText('How to ship fast')).toBeInTheDocument();
-    expect(screen.getByText('Ido Shamun · daily.dev')).toBeInTheDocument();
-  });
-});
diff --git a/packages/webapp/pages/image-generator/quote/[id].tsx b/packages/webapp/pages/image-generator/quote/[id].tsx
deleted file mode 100644
index 7dd6008af2e..00000000000
--- a/packages/webapp/pages/image-generator/quote/[id].tsx
+++ /dev/null
@@ -1,109 +0,0 @@
-import type { ReactElement } from 'react';
-import React from 'react';
-import type {
-  GetStaticPathsResult,
-  GetStaticPropsContext,
-  GetStaticPropsResult,
-} from 'next';
-import type { NextSeoProps } from 'next-seo';
-import { useRouter } from 'next/router';
-import type { PostData } from '@dailydotdev/shared/src/graphql/posts';
-import { POST_BY_ID_STATIC_FIELDS_QUERY } from '@dailydotdev/shared/src/graphql/posts';
-import { gqlClient } from '@dailydotdev/shared/src/graphql/common';
-import { QuoteImageCard } from '@dailydotdev/shared/src/components/post/QuoteImageCard';
-
-export async function getStaticPaths(): Promise {
-  return { paths: [], fallback: 'blocking' };
-}
-
-interface QuotePageProps {
-  id: string;
-  title: string;
-  sourceName: string | null;
-  authorName: string | null;
-  sourceImage: string | null;
-  authorImage: string | null;
-  seo: NextSeoProps;
-}
-
-export async function getStaticProps({
-  params,
-}: GetStaticPropsContext): Promise> {
-  const id = params?.id as string;
-
-  if (!id) {
-    return { notFound: true, revalidate: false };
-  }
-
-  try {
-    const { post } = await gqlClient.request(
-      POST_BY_ID_STATIC_FIELDS_QUERY,
-      { id },
-    );
-
-    return {
-      props: {
-        id: post.id,
-        title: post.title ?? '',
-        sourceName: post.source?.name ?? null,
-        authorName: post.author?.name ?? null,
-        sourceImage: post.source?.image ?? null,
-        authorImage: post.author?.image ?? null,
-        seo: {
-          title: post.title ?? 'Quote from daily.dev',
-          description:
-            'A quote from a post shared with millions of developers on daily.dev',
-          noindex: true,
-          twitter: { cardType: 'summary_large_image' },
-          openGraph: {
-            type: 'website',
-            images: [
-              {
-                url: `https://og.daily.dev/api/posts/${post.id}`,
-                width: 1200,
-                height: 630,
-                alt: post.title ?? 'Quote image',
-              },
-            ],
-          },
-        },
-      },
-      revalidate: 60,
-    };
-  } catch (err) {
-    return { notFound: true, revalidate: 60 };
-  }
-}
-
-const QuoteImagePage = ({
-  title,
-  sourceName,
-  authorName,
-  sourceImage,
-  authorImage,
-}: QuotePageProps): ReactElement => {
-  const { query } = useRouter();
-  // The quote itself is never part of the cached page — it comes from the
-  // reader's selection, so it rides in the query string and the ISR'd shell is
-  // reused for every quote of the same post.
-  const quote = (query?.text as string) ?? '';
-
-  return (
-    
- -
- ); -}; - -export default QuoteImagePage;