From 0723672c09bfc41285356ceceffad88171f641cd Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Wed, 22 Jul 2026 17:15:52 +0300 Subject: [PATCH 1/2] 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 2/2] 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 (
- -
);