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.
+ */}
+
+
+ ))
)}
- >
- {/* Embed YouTube's native player directly so the first click
- plays inside the iframe with sound — no custom overlay or
- muted autoplay. */}
-
-
- ))
- )}
-
-
-
- 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 && (
+
+ }
+ >
+
+
+ ),
+};
+
+// -- 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: () => (
+
+
+
+ ),
+};
+
+// -- 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.
+
+ );
+};
+
+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: () => (
+
+
+ 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
+
+ );
+};
+
+/**
+ * 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) => (
+