diff --git a/packages/shared/src/components/comments/CommentContainer.tsx b/packages/shared/src/components/comments/CommentContainer.tsx index 03ea9d7eb3..74cc36be1e 100644 --- a/packages/shared/src/components/comments/CommentContainer.tsx +++ b/packages/shared/src/components/comments/CommentContainer.tsx @@ -6,6 +6,7 @@ import type { Comment } from '../../graphql/comments'; import { getCommentHash } from '../../graphql/comments'; import type { Post } from '../../graphql/posts'; import Markdown from '../Markdown'; +import { useSelectionShareArea } from '../post/SelectionShareProvider'; import { ProfileImageLink } from '../profile/ProfileImageLink'; import { ProfileLink } from '../profile/ProfileLink'; import { ProfileTooltip } from '../profile/ProfileTooltip'; @@ -45,6 +46,11 @@ export interface CommentContainerProps { showContextHeader?: boolean; actions?: ReactNode; onClick?: () => void; + /** + * Opens a reply to this comment seeded with the given markdown quote. Without + * it the selection bar still copies and shares, it just cannot quote. + */ + onQuote?: (markdownQuote: string) => void; } export default function CommentContainer({ @@ -61,7 +67,10 @@ export default function CommentContainer({ showContextHeader, actions, onClick, + onQuote, }: CommentContainerProps): ReactElement { + // Scoped to the comment's own prose: not its header, badges or action row. + const contentRef = useSelectionShareArea({ post, comment, onQuote }); const isCommentReferenced = commentHash === getCommentHash(comment.id); const { author } = comment; const companies = author?.companies; @@ -174,12 +183,15 @@ export default function CommentContainer({ linkToComment && 'pointer-events-none', )} > - - + {/* `display: contents` — a node for `Node.contains`, no layout box. */} +
+ + +
{actions} diff --git a/packages/shared/src/components/comments/MainComment.tsx b/packages/shared/src/components/comments/MainComment.tsx index 6c084772fe..b5abe8a267 100644 --- a/packages/shared/src/components/comments/MainComment.tsx +++ b/packages/shared/src/components/comments/MainComment.tsx @@ -13,6 +13,7 @@ import { LogEvent, NotificationCtaPlacement, NotificationPromptSource, + Origin, TargetType, } from '../../lib/log'; import type { CommentMarkdownInputProps } from '../fields/MarkdownInput/CommentMarkdownInput'; @@ -170,6 +171,15 @@ export default function MainComment({ commentId: selected.id, }) } + onQuote={(quote) => + onReplyTo({ + username: comment.author?.username ?? null, + parentCommentId: comment.id, + commentId: comment.id, + quote, + origin: Origin.TextSelection, + }) + } onEdit={({ id, lastUpdatedAt }) => onEdit({ commentId: id, lastUpdatedAt }) } diff --git a/packages/shared/src/components/comments/SubComment.tsx b/packages/shared/src/components/comments/SubComment.tsx index d59ccaa0cf..543f1003ed 100644 --- a/packages/shared/src/components/comments/SubComment.tsx +++ b/packages/shared/src/components/comments/SubComment.tsx @@ -6,6 +6,7 @@ import type { Comment } from '../../graphql/comments'; import type { CommentBoxProps } from './CommentBox'; import CommentBox from './CommentBox'; import type { CommentMarkdownInputProps } from '../fields/MarkdownInput/CommentMarkdownInput'; +import { Origin } from '../../lib/log'; import { useComments } from '../../hooks/post'; import { useEditCommentProps } from '../../hooks/post/useEditCommentProps'; @@ -75,6 +76,15 @@ function SubComment({ commentId: selected.id, }) } + onQuote={(quote) => + onReplyTo({ + username: comment.author?.username ?? null, + parentCommentId: parentComment.id, + commentId: comment.id, + quote, + origin: Origin.TextSelection, + }) + } isModalThread={isModalThread} > {!isModalThread && ( diff --git a/packages/shared/src/components/highlights/HighlightItem.spec.tsx b/packages/shared/src/components/highlights/HighlightItem.spec.tsx index 5f1e186989..994f58b35b 100644 --- a/packages/shared/src/components/highlights/HighlightItem.spec.tsx +++ b/packages/shared/src/components/highlights/HighlightItem.spec.tsx @@ -1,7 +1,9 @@ import React from 'react'; import { render, screen } from '@testing-library/react'; +import { QueryClient } from '@tanstack/react-query'; import type { PostHighlightFeed } from '../../graphql/highlights'; import { HighlightItem } from './HighlightItem'; +import { TestBootProvider } from '../../../__tests__/helpers/boot'; const scrollIntoView = jest.fn(); const summary = 'A concise summary for the expanded highlight item.'; @@ -19,6 +21,16 @@ const highlight: PostHighlightFeed = { }, }; +// The expanded summary carries the selection share bar, which reads auth and +// react-query for its copy/share actions. +const renderItem = (props: { defaultExpanded?: boolean } = {}) => + render( + + {/* eslint-disable-next-line react/jsx-props-no-spreading */} + + , + ); + beforeAll(() => { Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', { configurable: true, @@ -32,11 +44,15 @@ beforeEach(() => { describe('HighlightItem', () => { it('should expand when the route-driven default changes after mount', () => { - const { rerender } = render(); + const { rerender } = renderItem(); expect(screen.queryByText(summary)).not.toBeInTheDocument(); - rerender(); + rerender( + + + , + ); expect(screen.getByText(summary)).toBeInTheDocument(); expect(screen.getByRole('link', { name: /read more/i })).toHaveAttribute( @@ -45,4 +61,16 @@ describe('HighlightItem', () => { ); expect(scrollIntoView).toHaveBeenCalled(); }); + + it('binds the share bar to the expanded summary, not the headline', () => { + renderItem({ defaultExpanded: true }); + + const summaryNode = screen.getByText(summary); + const bound = summaryNode.closest('[data-selection-area]'); + + expect(bound).not.toBeNull(); + expect(bound).not.toContainElement( + screen.getByRole('button', { name: /the first highlight/i }), + ); + }); }); diff --git a/packages/shared/src/components/highlights/HighlightItem.tsx b/packages/shared/src/components/highlights/HighlightItem.tsx index 4256c618b3..a5a3a82a0e 100644 --- a/packages/shared/src/components/highlights/HighlightItem.tsx +++ b/packages/shared/src/components/highlights/HighlightItem.tsx @@ -4,6 +4,7 @@ import classNames from 'classnames'; import type { PostHighlightFeed } from '../../graphql/highlights'; import { stripHtmlTags } from '../../lib/strings'; import { PostType } from '../../graphql/posts'; +import { PostSelectionArea } from '../post/PostSelectionArea'; import { ArrowIcon } from '../icons/Arrow'; import { IconSize } from '../Icon'; import Link from '../utilities/Link'; @@ -80,7 +81,15 @@ export const HighlightItem = ({ {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/highlights/HighlightsPage.tsx b/packages/shared/src/components/highlights/HighlightsPage.tsx index eb11f9e756..38752657a7 100644 --- a/packages/shared/src/components/highlights/HighlightsPage.tsx +++ b/packages/shared/src/components/highlights/HighlightsPage.tsx @@ -14,6 +14,7 @@ import { import { Tab, TabContainer } from '../tabs/TabContainer'; import { DigestCTA } from './DigestCTA'; import { HighlightItem } from './HighlightItem'; +import { SelectionShareProvider } from '../post/SelectionShareProvider'; const MAJOR_HEADLINES_LABEL = 'Headlines'; const ALL_HIGHLIGHTS_LABEL = 'All'; @@ -74,15 +75,18 @@ const HighlightFeedList = ({ } return ( -
- {highlights.map((highlight) => ( - - ))} -
+ // One watcher for the whole list, not one per row. + +
+ {highlights.map((highlight) => ( + + ))} +
+
); }; diff --git a/packages/shared/src/components/post/BasePostContent.tsx b/packages/shared/src/components/post/BasePostContent.tsx index 0e1d81a904..bc4998c116 100644 --- a/packages/shared/src/components/post/BasePostContent.tsx +++ b/packages/shared/src/components/post/BasePostContent.tsx @@ -6,6 +6,7 @@ import PostEngagements from './PostEngagements'; import type { BasePostContentProps } from './common'; import { PostHeaderActions } from './PostHeaderActions'; import { ButtonSize } from '../buttons/common'; +import { SelectionShareProvider } from './SelectionShareProvider'; const Custom404 = dynamic( () => import(/* webpackChunkName: "custom404" */ '../Custom404'), @@ -47,7 +48,9 @@ export function BasePostContent({ } return ( - <> + // One selection watcher for the whole post: its content and its comments + // both register with this, and it renders the single bar between them. + {isPostPage && ( )} - + ); } diff --git a/packages/shared/src/components/post/NewComment.tsx b/packages/shared/src/components/post/NewComment.tsx index 705f41c358..f5fb2e9c44 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; } @@ -117,26 +123,45 @@ 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 = + if (!user) { + // Opening a composer nobody can post from is worse than not opening one. + // The query param stays put, so the draft survives the login round-trip + // and this effect picks it up again once the user comes back. + showLogin({ trigger: AuthTriggers.NewComment }); + return; + } + + 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); + // 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, + showLogin, + user, + ]); const onCommentClick = (origin: Origin) => { if (!user) { diff --git a/packages/shared/src/components/post/PostContent.tsx b/packages/shared/src/components/post/PostContent.tsx index 702972165b..70bf2bb803 100644 --- a/packages/shared/src/components/post/PostContent.tsx +++ b/packages/shared/src/components/post/PostContent.tsx @@ -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 { PostSelectionArea } from './PostSelectionArea'; type PostContentRawProps = Omit & { post: Post }; @@ -159,48 +160,50 @@ export function PostContentRaw({ origin={origin} post={post} > -
-
- -
-

- - {title} - -

- {post.clickbaitTitleDetected && } -
- {isVideoType && ( - - )} - {post.summary && ( -
-

+

+
+ +
+

- {post.summary} -

+ + {title} + +

+ {post.clickbaitTitleDetected && }
- )} + {isVideoType && ( + + )} + {post.summary && ( +
+

+ {post.summary} +

+
+ )} + + {children} +
+ ); +} 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 0000000000..2022c1b9d5 --- /dev/null +++ b/packages/shared/src/components/post/SelectionShareBar.spec.tsx @@ -0,0 +1,299 @@ +import type { ComponentProps } from 'react'; +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 { shouldUseNativeShare } from '../../lib/func'; +import { useLogContext } from '../../contexts/LogContext'; +import { Origin } from '../../lib/log'; +import { ShareProvider } from '../../lib/share'; +import { TOAST_NOTIF_KEY } from '../../hooks/useToastNotification'; +import type { Post } from '../../graphql/posts'; +import type { Comment } from '../../graphql/comments'; + +const mockReplace = jest.fn(); + +jest.mock('next/router', () => ({ + __esModule: true, + useRouter: () => ({ + replace: mockReplace, + pathname: '/posts/[id]', + query: {}, + }), +})); + +jest.mock('../../lib/func', () => { + const actual = jest.requireActual('../../lib/func'); + return { __esModule: true, ...actual, shouldUseNativeShare: jest.fn() }; +}); + +// Only the hook: TestBootProvider still needs the real LogContextProvider. +jest.mock('../../contexts/LogContext', () => { + const actual = jest.requireActual('../../contexts/LogContext'); + return { __esModule: true, ...actual, useLogContext: jest.fn() }; +}); + +const mockUseLogContext = useLogContext as jest.MockedFunction< + typeof useLogContext +>; +const logEvent = jest.fn(); +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; + +// The bar ships unflagged, so GrowthBook only has to exist for TestBootProvider. +const enabledGrowthBook = () => new GrowthBook(); + +beforeEach(() => { + jest.clearAllMocks(); + mockUseLogContext.mockReturnValue({ logEvent } as unknown as ReturnType< + typeof useLogContext + >); + shouldUseNativeShareMock.mockReturnValue(false); + Object.assign(navigator, { clipboard: { writeText }, share }); +}); + +const comment = { + id: 'comment-1', + permalink: 'https://daily.dev/posts/how-to-ship-fast#c-comment-1', + author: { id: '2', username: 'ido' }, +} as unknown as Comment; + +const rect = { top: 400, bottom: 420, left: 100, right: 300 }; + +const renderComponent = ( + gb = enabledGrowthBook(), + props: Partial> = {}, +): RenderResult & { client: QueryClient } => { + const client = new QueryClient(); + + return { + client, + ...render( + + + , + ), + }; +}; + +describe('SelectionShareBar gating', () => { + it('renders for every user, with no feature flag set', () => { + renderComponent(new GrowthBook()); + + expect(screen.getByTestId('selectionShareBar')).toBeInTheDocument(); + }); +}); + +describe('SelectionShareBar actions', () => { + 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(); + }); + + 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 () => { + 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}\n\n${post.commentsPermalink}`, + ), + ); + 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('dismisses on a click outside the bar', async () => { + renderComponent(); + + await act(async () => { + fireEvent.click(document.body); + }); + + expect(clear).toHaveBeenCalled(); + }); +}); + +describe('SelectionShareBar on a comment', () => { + it('copies the comment permalink, not the post link', async () => { + renderComponent(undefined, { comment }); + + await act(async () => { + fireEvent.click(screen.getByLabelText('Copy link to this post')); + }); + + await waitFor(() => + expect(writeText).toHaveBeenCalledWith(comment.permalink), + ); + }); + + it('appends the comment link to copied text, not the post link', async () => { + renderComponent(undefined, { comment }); + + await act(async () => { + fireEvent.click(screen.getByLabelText('Copy selected text')); + }); + + await waitFor(() => + expect(writeText).toHaveBeenCalledWith( + `${selection}\n\n${comment.permalink}`, + ), + ); + }); + + it('hands the quote to the reply composer', () => { + const onQuote = jest.fn(); + renderComponent(undefined, { comment, onQuote }); + + fireEvent.click(screen.getByLabelText('Quote in a comment')); + + expect(onQuote).toHaveBeenCalledWith(`> ${selection}\n\n`); + }); + + 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(); + }); +}); + +describe('SelectionShareBar analytics', () => { + const lastEvent = () => + JSON.parse(logEvent.mock.calls.at(-1)?.[0]?.extra ?? '{}'); + + it('logs a post share the way the rest of the app does', () => { + renderComponent(); + + fireEvent.click(screen.getByLabelText('Copy link to this post')); + + expect(logEvent.mock.calls.at(-1)?.[0]).toMatchObject({ + event_name: 'share post', + target_id: post.id, + }); + expect(lastEvent()).toEqual({ + provider: ShareProvider.CopyLink, + origin: Origin.TextSelection, + }); + }); + + it('logs a comment share as share comment, carrying the comment id', () => { + renderComponent(undefined, { comment }); + + fireEvent.click(screen.getByLabelText('Copy link to this post')); + + expect(logEvent.mock.calls.at(-1)?.[0]).toMatchObject({ + event_name: 'share comment', + }); + expect(lastEvent()).toEqual({ + provider: ShareProvider.CopyLink, + origin: Origin.TextSelection, + commentId: comment.id, + }); + }); + + it('reports the native provider when the sheet takes over', () => { + shouldUseNativeShareMock.mockReturnValue(true); + renderComponent(); + + fireEvent.click(screen.getByLabelText('Copy link to this post')); + + expect(lastEvent()).toMatchObject({ provider: ShareProvider.Native }); + }); +}); diff --git a/packages/shared/src/components/post/SelectionShareBar.tsx b/packages/shared/src/components/post/SelectionShareBar.tsx new file mode 100644 index 0000000000..ebc88b9a6a --- /dev/null +++ b/packages/shared/src/components/post/SelectionShareBar.tsx @@ -0,0 +1,272 @@ +import type { ReactElement } from 'react'; +import React, { useCallback, useRef, useState } from 'react'; +import { useRouter } from 'next/router'; +import type { Post } from '../../graphql/posts'; +import type { Comment } from '../../graphql/comments'; +import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; +import { DiscussIcon, LinkIcon, ShareIcon } from '../icons'; +import { Tooltip } from '../tooltip/Tooltip'; +import { RootPortal } from '../tooltips/Portal'; +import { ShareActions } from '../share/ShareActions'; +import type { SelectionSharePost } from './SelectionShareProvider'; +import { CopyStateIcon } from '../share/CopyStateIcon'; +import { useCopyText } from '../../hooks/useCopy'; +import { useShareOrCopyLink } from '../../hooks/useShareOrCopyLink'; +import { useGetShortUrl } from '../../hooks/utils/useGetShortUrl'; +import { useSelectionAnchor } from '../../hooks/useSelectionAnchor'; +import type { TextSelectionRect } from '../../hooks/useTextSelectionShare'; +import { useOutsideClick } from '../../hooks/utils/useOutsideClick'; +import { useEventListener } from '../../hooks/useEventListener'; +import { useLogContext } from '../../contexts/LogContext'; +import { usePostLogEvent } from '../../lib/feed'; +import { LogEvent, Origin } from '../../lib/log'; +import { ShareProvider } from '../../lib/share'; +import { shouldUseNativeShare } from '../../lib/func'; +import { ReferralCampaignKey } from '../../lib/referral'; +import { buildCommentQuote, truncateForUrl } from '../../lib/strings'; + +export interface SelectionShareBarProps { + post: SelectionSharePost; + /** The live selection, supplied by `SelectionShareProvider`. */ + text: string; + rect: TextSelectionRect; + clear: () => void; + /** + * Set when the selection sits in a comment or reply rather than post content. + * The share link, the logged event and the quote then belong to the comment, + * so a reader never quotes a commenter as if they were the author. + */ + comment?: Comment; + /** + * 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; + /** + * 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; +} + +/** + * Floating share bar for text selected inside a post or comment. Ships to + * everyone — there is no flag gate. + * + * Rendered once per page by `SelectionShareProvider`, which owns the selection + * watcher and decides which region the selection belongs to. + */ +export function SelectionShareBar({ + post, + text, + rect, + clear, + comment, + canQuote = true, + onQuote, +}: SelectionShareBarProps): ReactElement { + const barRef = useRef(null); + // 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(); + const shareLink = comment?.permalink ?? post.commentsPermalink; + // Referral credit follows what is being shared, matching `useShareComment`. + const campaign = comment + ? ReferralCampaignKey.ShareComment + : ReferralCampaignKey.SharePost; + const [isLinkCopied, shareOrCopyLink] = useShareOrCopyLink({ + link: shareLink, + text, + cid: campaign, + }); + const [isTextCopied, copyText] = useCopyText(); + const { left, top, flipsBelow, isMeasured } = useSelectionAnchor( + rect, + barRef, + ); + + // Resolved while the bar is open, so the copy handler can stay synchronous. + // WebKit only honours a clipboard write inside the task that handled the + // gesture; awaiting a round-trip on click loses the write entirely. + const { shareLink: reference } = useGetShortUrl({ + query: { url: shareLink, cid: campaign }, + }); + + const dismiss = useCallback(() => { + globalThis?.window?.getSelection?.()?.removeAllRanges(); + clear(); + }, [clear]); + + const logShare = useCallback( + (provider: ShareProvider) => { + logEvent( + postLogEvent( + comment ? LogEvent.ShareComment : LogEvent.SharePost, + // `postLogEvent` optional-chains every field it reads, but its + // signature demands a whole Post. + post as Post, + { + extra: { + provider, + origin: Origin.TextSelection, + ...(comment && { commentId: comment.id }), + }, + }, + ), + ); + }, + [comment, logEvent, post, postLogEvent], + ); + + useOutsideClick( + barRef, + () => clear(), + // The provider drops the selection on its own when the reader clicks back + // into the text, so this only has to catch clicks elsewhere on the page. + !isShareOpen, + ); + + useEventListener(globalThis?.document, 'keydown', (event: KeyboardEvent) => { + // The popover closes itself on Escape; only the second press drops the bar. + if (event.key === 'Escape' && !isShareOpen) { + dismiss(); + } + }); + + const onCopyLink = () => { + // `shareOrCopyLink` hands off to the native sheet where one exists, so the + // provider has to be resolved the same way `ShareActions` resolves it — + // otherwise every mobile share is logged as a copy. + logShare( + shouldUseNativeShare() ? ShareProvider.Native : ShareProvider.CopyLink, + ); + shareOrCopyLink(); + }; + + const onCopyText = () => { + logShare(ShareProvider.CopyText); + copyText({ + textToCopy: `${text}\n\n${reference ?? shareLink}`, + 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, + // A whole-article selection would otherwise become a multi-kilobyte + // URL and history entry. + comment: truncateForUrl(quote), + commentOrigin: Origin.TextSelection, + }, + }, + undefined, + { shallow: true }, + ); + }; + + // 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; + + 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. + */} +
+
+ {/* + 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. + */} + +
+
+
+ ); +} diff --git a/packages/shared/src/components/post/SelectionShareProvider.tsx b/packages/shared/src/components/post/SelectionShareProvider.tsx new file mode 100644 index 0000000000..bbcc94904c --- /dev/null +++ b/packages/shared/src/components/post/SelectionShareProvider.tsx @@ -0,0 +1,154 @@ +import type { ReactElement, ReactNode, RefObject } from 'react'; +import React, { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, +} from 'react'; +import type { Comment } from '../../graphql/comments'; +import { SelectionShareBar } from './SelectionShareBar'; +import { useTextSelectionShare } from '../../hooks/useTextSelectionShare'; + +/** + * What the bar and its logging actually read off a post. Lists that fetch a + * deliberate subset — highlights, for one — satisfy this without a cast. + */ +export interface SelectionSharePost { + id: string; + commentsPermalink: string; + type?: string; + title?: string; + permalink?: string; + image?: string; + createdAt?: string; + readTime?: number; + numComments?: number; + numUpvotes?: number; + tags?: string[]; + trending?: number; + source?: { id?: string; type?: string }; + author?: { id?: string }; + scout?: { id?: string }; +} + +export interface SelectionShareTarget { + post: SelectionSharePost; + /** Set when the region is a comment or reply rather than post content. */ + comment?: Comment; + /** False where the surrounding surface renders no comment composer. */ + canQuote?: boolean; + /** Opens a reply to this comment, seeded with the quote. */ + onQuote?: (markdownQuote: string) => void; +} + +type GetTarget = () => SelectionShareTarget; +type Register = (element: HTMLElement, getTarget: GetTarget) => () => void; + +const SelectionShareContext = createContext(null); + +/** + * Marks an element as quotable and attributes whatever is selected inside it. + * + * Registration is all a region does — no listeners, no queries. A thread with + * 150 comments therefore costs 150 map entries rather than 150 copies of the + * selection machinery. + */ +export const useSelectionShareArea = ( + target: SelectionShareTarget, +): RefObject => { + const ref = useRef(null); + const register = useContext(SelectionShareContext); + + // The target closes over per-render values (`onQuote` most of all). Reading it + // through a ref keeps registration a mount-time concern instead of + // re-registering every region on every render. + const latest = useRef(target); + latest.current = target; + + useEffect(() => { + const element = ref.current; + + if (!register || !element) { + return undefined; + } + + return register(element, () => latest.current); + }, [register]); + + return ref; +}; + +export interface SelectionShareProviderProps { + children: ReactNode; +} + +/** + * Runs the selection watcher once for a whole page and renders the single share + * bar it can ever need, attributed to whichever registered region the selection + * landed in. + */ +export function SelectionShareProvider({ + children, +}: SelectionShareProviderProps): ReactElement { + const areas = useRef(new Map()).current; + + const register = useCallback( + (element, getTarget) => { + areas.set(element, getTarget); + + return () => { + areas.delete(element); + }; + }, + [areas], + ); + + const resolveArea = useCallback( + (node: Node | null): HTMLElement | null => { + if (!node) { + return null; + } + + let owner: HTMLElement | null = null; + + areas.forEach((_, element) => { + if (!element.contains(node)) { + return; + } + + // Regions nest — a comment sits inside the discussion, which sits + // inside the page. The innermost one owns the selection. + if (!owner || owner.contains(element)) { + owner = element; + } + }); + + return owner; + }, + [areas], + ); + + const { text, rect, area, clear } = useTextSelectionShare({ resolveArea }); + const target = useMemo(() => area && areas.get(area)?.(), [area, areas]); + + const value = useMemo(() => register, [register]); + + return ( + + {children} + {!!text && !!rect && !!target && ( + + )} + + ); +} diff --git a/packages/shared/src/components/post/SocialTwitterPostContent.tsx b/packages/shared/src/components/post/SocialTwitterPostContent.tsx index 6d6f8a9191..30338bf7f9 100644 --- a/packages/shared/src/components/post/SocialTwitterPostContent.tsx +++ b/packages/shared/src/components/post/SocialTwitterPostContent.tsx @@ -31,6 +31,7 @@ import { getSocialTwitterMetadataLabel, } from '../cards/socialTwitter/socialTwitterHelpers'; import { Separator } from '../cards/common/common'; +import { PostSelectionArea } from './PostSelectionArea'; type SocialTwitterPostContentRawProps = Omit & { post: Post; @@ -168,64 +169,69 @@ function SocialTwitterPostContentRaw({ {!!post.createdAt && } {metadataLabel} - {!shouldHideRepostHeadlineAndTags && - !shouldRenderPrimaryTweetPreview && ( -
+ + )} + {isThread && !!post.contentHtml && ( + )} - {!shouldHideRepostHeadlineAndTags && - !shouldRenderPrimaryTweetPreview && - !!post.image && - !isPlaceholderImage(post.image) && - !!post.permalink && ( - - - + {shouldRenderPrimaryTweetPreview && ( + )} - {isThread && !!post.contentHtml && ( - - )} - {shouldRenderPrimaryTweetPreview && ( - - )} +
)} - + + + & { post: Post }; @@ -348,49 +349,51 @@ const BriefPostContentRaw = ({ >
{!!user && !user?.isPlus && } - - - -
- {!isNullOrUndefined(post.readTime) && ( - - - - {post.readTime}m read - -
- } + + + - )} -
- {collectionSources.length > 0 && ( - +
+ {!isNullOrUndefined(post.readTime) && ( + + + + {post.readTime}m read + +
+ } /> )} - - {sourcesCount ?? 0} Sources - +
+ {collectionSources.length > 0 && ( + + )} + + {sourcesCount ?? 0} Sources + +
-
- + + {isNotPlus && (
& { post: Post; @@ -144,48 +145,50 @@ const CollectionPostContentRaw = ({ contextMenuId="post-widgets-context" />
-

- {post.title} -

- - {!!dateToShow && ( -
- - {hasSources && ( - <> - - - {numCollectionSources}{' '} - {pluralize('source', numCollectionSources)} - - - )} -
- )} - {image && ( -
- -
- )} - + +

+ {post.title} +

+ + {!!dateToShow && ( +
+ + {hasSources && ( + <> + + + {numCollectionSources}{' '} + {pluralize('source', numCollectionSources)} + + + )} +
+ )} + {image && ( +
+ +
+ )} + +
diff --git a/packages/shared/src/components/post/focus/PostFocusCard.tsx b/packages/shared/src/components/post/focus/PostFocusCard.tsx index 9d0706c024..26d8667a11 100644 --- a/packages/shared/src/components/post/focus/PostFocusCard.tsx +++ b/packages/shared/src/components/post/focus/PostFocusCard.tsx @@ -51,6 +51,8 @@ import { PostMenuOptions } from '../PostMenuOptions'; import { FocusCardActionBar } from './FocusCardActionBar'; import { PostDiscussionPanel } from './PostDiscussionPanel'; import { CollectionSources } from './CollectionSources'; +import { PostSelectionArea } from '../PostSelectionArea'; +import { SelectionShareProvider } from '../SelectionShareProvider'; const PostCodeSnippets = dynamic(() => import(/* webpackChunkName: "postCodeSnippets" */ '../PostCodeSnippets').then( @@ -325,264 +327,276 @@ export const PostFocusCard = ({ ) : null; return ( -
-
-
-
- {author ? ( -
- null} - className={{ - container: 'min-w-0 !p-0 hover:bg-transparent', - textWrapper: 'min-w-0', - }} - /> - +
+
+
+
+ {author ? ( +
+ null} + className={{ + container: 'min-w-0 !p-0 hover:bg-transparent', + textWrapper: 'min-w-0', + }} + /> + +
+ ) : ( + article.source && ( + + ) + )} +
+
- ) : ( - article.source && ( - - ) - )} -
-
-
-
- {sharedVia && ( -

- Shared via - - - - {sharedVia.image && ( - - )} - {sharedVia.name} - - - - } - > - - -

- )} - {isShared && !sharedVia && ( -

Shared post

- )} - {!isShared && isCollection && ( -

Collection

- )} - {/* Title and image are top-aligned columns. The cover image opens a + +
+ {sharedVia && ( +

+ Shared via + + + + {sharedVia.image && ( + + )} + {sharedVia.name} + + + + } + > + + +

+ )} + {isShared && !sharedVia && ( +

+ Shared post +

+ )} + {!isShared && isCollection && ( +

Collection

+ )} + {/* Title and image are top-aligned columns. The cover image opens a lightbox rather than navigating away. The read button lives in the title column (right under the title) so it hugs the title regardless of the image height — a short title next to a tall image keeps the button close instead of dragging it down. */} -
-
-

+
+

+ {title} +

+ {renderReadButton('w-fit')} +
+ {!isVideoType && article.image && ( + )} - data-testid="post-modal-title" - > - {title} -

- {renderReadButton('w-fit')} +
- {!isVideoType && article.image && ( - +
)} -
-
- 0 && ( - - From{' '} - + + + + ) : ( + article.summary && + (isVideoType ? ( + + ) : ( +

- {article.domain} - - - ) - } - isVideoType={isVideoType} - readTime={article.readTime} - /> - - {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.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" - /> - - {isCollection && } - - {showCodeSnippets && ( -
- -
- )} - - - - - -
- { - focusCommentRef.current = fn; - }} + + + + + 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 && } + + {showCodeSnippets && ( +
+ +
+ )} + + + + + +
+ { + focusCommentRef.current = fn; + }} + post={post} + origin={origin} + /> +
-
-
+ + ); }; diff --git a/packages/shared/src/components/post/poll/PollPostContent.tsx b/packages/shared/src/components/post/poll/PollPostContent.tsx index 5aec9f26ba..4cc9f0467b 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? +
+
- -
- )} + )} +
-
+
ReactElement; +}): ReactElement => { + const layer = classNames( + className, + 'col-start-1 row-start-1 transition-[opacity,transform,filter] duration-200 motion-reduce:transition-none', + EASE_OUT_EXPO, + ); + + return ( + + + + + ); +}; diff --git a/packages/shared/src/components/share/ShareActions.tsx b/packages/shared/src/components/share/ShareActions.tsx index 50ee53e30e..541c956e64 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'; @@ -7,7 +7,6 @@ import { SocialShareList } from '../widgets/SocialShareList'; import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; import { CopyIcon } from '../icons'; import { Tooltip } from '../tooltip/Tooltip'; -import { Typography, TypographyType } from '../typography/Typography'; import { useViewSize, ViewSize } from '../../hooks/useViewSize'; import { useShareOrCopyLink } from '../../hooks/useShareOrCopyLink'; import { shouldUseNativeShare } from '../../lib/func'; @@ -33,6 +32,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 +56,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 +154,7 @@ export function ShareActions({ type="button" variant={buttonVariant} size={buttonSize} - icon={} + icon={triggerIcon} aria-label={label} pressed={open} className={className} @@ -148,12 +166,20 @@ export function ShareActions({ side="top" align="center" avoidCollisions - className="flex w-80 flex-wrap justify-center gap-2 rounded-16 border border-border-subtlest-tertiary bg-background-popover p-4 shadow-2 data-[side=bottom]:mt-1 data-[side=top]:mb-1" + collisionPadding={8} + // The selection bar is `fixed` and re-anchors as the reader scrolls, so + // the popover has to track it every frame. The default strategy only + // recomputes on scroll/resize of ancestors and leaves the popover + // parked at a stale position until the next event. + updatePositionStrategy="always" + // Four fixed columns sized to their content: every side gets the same + // padding, and a short last row stays left-aligned with the grid above + // it. Wrapping a flex row inside a fixed width left slack that + // `justify-center` split between the sides, so they read wider than the + // top and bottom, and it re-centred a short last row against the rest. + className="grid w-fit grid-cols-4 gap-2 rounded-16 border border-border-subtlest-tertiary bg-background-popover p-4 shadow-2 data-[side=bottom]:mt-1 data-[side=top]:mb-1" {...hoverProps} > - - Share - {list} diff --git a/packages/shared/src/graphql/highlights.ts b/packages/shared/src/graphql/highlights.ts index 208e416486..ef8d8b2110 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 diff --git a/packages/shared/src/hooks/post/useComments.ts b/packages/shared/src/hooks/post/useComments.ts index cd7b6db9a1..0b7576c6d3 100644 --- a/packages/shared/src/hooks/post/useComments.ts +++ b/packages/shared/src/hooks/post/useComments.ts @@ -11,6 +11,13 @@ import type { CommentWrite, CommentWriteProps } from './common'; interface ReplyTo extends CommentWriteProps { username: string | null; + /** + * Markdown to seed the composer with — a blockquote of text the reader + * selected in the comment they are replying to. + */ + quote?: string; + /** What opened the composer. Defaults to the reply button. */ + origin?: Origin; } interface UseComments extends CommentWrite { @@ -33,12 +40,15 @@ export const useComments = (post: Post): UseComments => { return null; } - const { username, parentCommentId } = replyTo ?? {}; + const { username, parentCommentId, quote } = replyTo ?? {}; + const mention = getReplyToInitialContent(username ?? undefined); return { ...(parentCommentId ? { parentCommentId } : {}), ...(username ? { replyTo: username } : {}), - initialContent: getReplyToInitialContent(username ?? undefined), + // The quote leads, the mention follows it, so the cursor lands after the + // handle with the quoted text already above. + initialContent: [quote, mention].filter(Boolean).join('') || undefined, }; }, [replyTo]); @@ -51,7 +61,7 @@ export const useComments = (post: Post): UseComments => { if (!isNullOrUndefined(params)) { logEvent( postLogEvent(LogEvent.OpenComment, post, { - extra: { origin: Origin.PostCommentButton }, + extra: { origin: params?.origin ?? Origin.PostCommentButton }, ...(logOpts && logOpts), }), ); diff --git a/packages/shared/src/hooks/useSelectionAnchor.ts b/packages/shared/src/hooks/useSelectionAnchor.ts new file mode 100644 index 0000000000..709a0be696 --- /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 new file mode 100644 index 0000000000..047c2e6dfe --- /dev/null +++ b/packages/shared/src/hooks/useTextSelectionShare.spec.ts @@ -0,0 +1,100 @@ +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 = () => { + const container = document.createElement('div'); + const child = document.createTextNode('some post body text'); + container.appendChild(child); + document.body.appendChild(container); + + const resolveArea = (node: Node | null) => + node && container.contains(node) ? container : null; + const { result } = renderHook(() => useTextSelectionShare({ resolveArea })); + + 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(); + }); +}); diff --git a/packages/shared/src/hooks/useTextSelectionShare.ts b/packages/shared/src/hooks/useTextSelectionShare.ts new file mode 100644 index 0000000000..04e5d6c51e --- /dev/null +++ b/packages/shared/src/hooks/useTextSelectionShare.ts @@ -0,0 +1,168 @@ +import { useCallback, useRef, useState } from 'react'; +import { useEventListener } from './useEventListener'; + +export interface TextSelectionRect { + top: number; + bottom: number; + left: number; + right: number; +} + +export interface UseTextSelectionShareProps { + /** + * 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 { + /** 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; + /** The region the selection sits in, for the caller to attribute it. */ + area: HTMLElement | null; + clear: () => void; +} + +// 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; + +// 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 } = + 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 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 = ({ + 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 selection = globalThis?.window?.getSelection?.(); + + if (!selection || selection.isCollapsed || selection.rangeCount === 0) { + clear(); + return; + } + + const owner = resolveArea(selection.anchorNode); + + // 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; + } + + const selected = selection.toString().trim(); + + if (selected.length < MIN_SELECTION_LENGTH) { + clear(); + return; + } + + const range = selection.getRangeAt(0); + const nextRect = toRect(range); + + if (!nextRect) { + clear(); + return; + } + + rangeRef.current = range; + setText(selected); + setRect(nextRect); + setArea(owner); + }, [clear, resolveArea]); + + const target = globalThis?.document; + + // Selection *end* — mouse release, touch release, or a keyboard selection. + useEventListener(target, 'mouseup', readSelection); + useEventListener(target, 'touchend', 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. + useEventListener(target, 'selectionchange', () => { + const selection = globalThis?.window?.getSelection?.(); + + if (!selection || selection.isCollapsed) { + clear(); + } + }); + + const followTarget = 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, area, clear }; +}; diff --git a/packages/shared/src/lib/log.ts b/packages/shared/src/lib/log.ts index 9696707fa6..da77b32664 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 b6bb78125b..3651f7adeb 100644 --- a/packages/shared/src/lib/share.ts +++ b/packages/shared/src/lib/share.ts @@ -10,6 +10,7 @@ export enum ShareProvider { LinkedIn = 'linkedin', Telegram = 'telegram', Email = 'email', + CopyText = 'copy text', } export const getWhatsappShareLink = (link: string): string => diff --git a/packages/shared/src/lib/strings.ts b/packages/shared/src/lib/strings.ts index a673ffa7f4..76eb2aeaff 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/SelectionShareBar.stories.tsx b/packages/storybook/stories/components/SelectionShareBar.stories.tsx new file mode 100644 index 0000000000..99b4e6f980 --- /dev/null +++ b/packages/storybook/stories/components/SelectionShareBar.stories.tsx @@ -0,0 +1,994 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +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'; +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 { + 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 type { Comment } from '@dailydotdev/shared/src/graphql/comments'; +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 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.'; + +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 })); +}; + +// 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]); +}; + +// 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; + /** 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; + /** 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 = ({ + hint, + children, + outside, + className, + bodyClassName, + showRaiseButton = true, + autoRaise = true, + onQuote, +}: StageProps): ReactElement => { + const stageRef = 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. + setTimeout(() => raiseBar(stageRef.current), 0); + }, []); + + return ( + +
+ {!!hint &&

{hint}

} + + {children} + + {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. +// --------------------------------------------------------------------------- + +/** + * `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}

+ +); + +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 +
+ +); + +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 +// --------------------------------------------------------------------------- + +// 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 = + () => + (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: { + layout: 'fullscreen', + docs: { + description: { + component: [ + 'Floating share bar anchored to a text selection inside a post body.', + '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.', + 'Use the Storybook theme toggle to check dark and light; both are supported.', + ].join('\n'), + }, + }, + }, + decorators: [withProviders()], +}; + +export default meta; + +type Story = StoryObj; + +// -- Placement -------------------------------------------------------------- + +/** Default: the bar floats centred above the selection, 8px clear of it. */ +export const AboveSelection: Story = { + render: () => ( + + + + ), +}; + +/** + * 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}

+
+ } + > + + + ), +}; + +// -- 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(); + }, +}; + +/** + * Tooltip copy. One or two words each — the bar sits directly over the text the + * reader just selected, so a full sentence in a tooltip hides what they are + * looking at. Screen readers still get the long form from `aria-label`. + */ +export const Tooltips: Story = { + render: () => ( + + + + ), + play: async () => { + await new Promise((resolve) => { + setTimeout(resolve, 400); + }); + + const quote = globalThis.document.querySelector( + '[data-testid="selectionShareBar"] [aria-label="Quote in a comment"]', + ); + + quote?.dispatchEvent( + new MouseEvent('mouseover', { bubbles: true, cancelable: true }), + ); + }, +}; + +/** + * 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 ---------------------------------------------------------------- + +/** + * 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 = { + globals: { viewport: { value: 'mobile1', isRotated: false } }, + 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}

+
+ ), +}; + +/** + * 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. + */ +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}

+
+

+ Great post — bookmarking this one. +

+
+
+ ); +}; + +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 --------------------------------------------------------------- + +/** Article & video posts — `PostContent`. Page and modal. */ +export const SurfaceArticlePost: Story = { + render: () => ( + + + + ), +}; + +/** Freeform / welcome / share squad posts — `SquadPostContent`. */ +export const SurfaceSquadPost: Story = { + render: () => ( + + + + ), +}; + +/** Redesigned post page and modal — `PostFocusCard`. */ +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. */ +export const Dismissal: Story = { + render: () => ( + + + + ), +}; + +// -- Comments and replies ---------------------------------------------------- + +const comment = { + id: 'comment-1', + permalink: 'https://daily.dev/posts/how-to-ship-fast#c-comment-1', + author: { id: '2', username: 'ido', name: 'Ido Shamun' }, +} as unknown as Comment; + +const CommentSurface = (): ReactElement => { + const rootRef = useRef(null); + const [reply, setReply] = React.useState(null); + + useAutoRaise(rootRef); + + return ( +
+

+ Selecting inside a comment raises the bar for that comment — copy link + gives the comment permalink, quote opens a reply to it. +

+
+
+ +
+ Ido Shamun + @ido · 1h +
+
+ + {/* eslint-disable-next-line react/jsx-props-no-spreading */} +

+ {secondParagraph} +

+ +
+ Upvote + Reply +
+
+
+        {reply ?? 'Reply composer is empty.'}
+      
+
+ ); +}; + +/** + * A comment or reply. The bar targets the comment, not the post: copy link + * yields the comment permalink, sharing logs `ShareComment`, and quote opens a + * reply to that comment seeded with the blockquote. + */ +export const SurfaceComment: Story = { + render: () => ( + + + + ), +}; + +/** + * The action row under a comment is chrome, so selecting it raises nothing — + * only the comment's own prose is quotable. + */ +export const IgnoredCommentActions: Story = { + render: () => ( + + {/* eslint-disable-next-line react/jsx-props-no-spreading */} + Upvote · Reply · Share + + } + > +

{secondParagraph}

+
+ ), +}; diff --git a/packages/storybook/stories/components/ShareActions.stories.tsx b/packages/storybook/stories/components/ShareActions.stories.tsx index 0857b12c55..7dfecbbb5d 100644 --- a/packages/storybook/stories/components/ShareActions.stories.tsx +++ b/packages/storybook/stories/components/ShareActions.stories.tsx @@ -111,3 +111,28 @@ export const IconOpenOnHover: Story = { export const Inline: Story = { args: { variant: 'inline' }, }; + +/** + * The popover, opened on load so its layout can be reviewed directly. Four + * fixed columns sized to their content: identical padding on every side, a + * short last row left-aligned with the grid above it, and no heading — the + * popover only ever opens from a share control. + */ +export const OpenPopover: Story = { + args: { variant: 'icon' }, + parameters: { layout: 'fullscreen' }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], + play: async ({ canvasElement }) => { + await new Promise((resolve) => { + setTimeout(resolve, 150); + }); + + canvasElement.querySelector('button')?.click(); + }, +};