From 0723672c09bfc41285356ceceffad88171f641cd Mon Sep 17 00:00:00 2001
From: Tsahi Matsliah
Date: Wed, 22 Jul 2026 17:15:52 +0300
Subject: [PATCH 01/24] feat(share): floating share bar for text selected in a
post
Selecting text inside a post body raises a portaled floating bar anchored to
the selection with three actions: copy link to the post (native share sheet on
mobile), copy the selection itself, and generate a shareable quote image.
- `useTextSelectionShare` detects selection end inside a container, exposes the
text plus a viewport rect, follows it on scroll/resize and clears on collapse
- `SelectionShareBar` portals the bar to the root, clamps to the visual
viewport (pinch-zoom/Android), flips below the selection when there is no
room above, dismisses on click-away and Escape, and respects reduced motion
- `QuoteImageCard` + `/image-generator/quote/[id]` follow the existing
screenshot-service pattern (`#screenshot_wrapper`, ISR) and carry
`og:image` / `twitter:card=summary_large_image`
- Wired into all three post bodies: classic `PostContent`, `SquadPostContent`,
and the redesign `PostFocusCard` (post page and post modal)
Gated by the new `share_text_selection` flag AND the `sharing_visibility`
master gate. Flag-off mounts none of the hooks, so no listeners are attached.
Co-Authored-By: Claude Opus 4.8
---
.../src/components/post/PostContent.tsx | 7 +-
.../src/components/post/QuoteImageCard.tsx | 81 ++++++
.../post/SelectionShareBar.spec.tsx | 176 +++++++++++++
.../src/components/post/SelectionShareBar.tsx | 239 ++++++++++++++++++
.../src/components/post/SquadPostContent.tsx | 7 +-
.../components/post/focus/PostFocusCard.tsx | 4 +
.../src/hooks/useTextSelectionShare.spec.ts | 112 ++++++++
.../shared/src/hooks/useTextSelectionShare.ts | 158 ++++++++++++
packages/shared/src/lib/featureManagement.ts | 9 +
packages/shared/src/lib/log.ts | 1 +
packages/shared/src/lib/share.ts | 2 +
.../components/QuoteImageCard.stories.tsx | 50 ++++
.../components/SelectionShareBar.stories.tsx | 186 ++++++++++++++
.../QuoteImageGeneratorPage.spec.tsx | 89 +++++++
.../pages/image-generator/quote/[id].tsx | 101 ++++++++
15 files changed, 1220 insertions(+), 2 deletions(-)
create mode 100644 packages/shared/src/components/post/QuoteImageCard.tsx
create mode 100644 packages/shared/src/components/post/SelectionShareBar.spec.tsx
create mode 100644 packages/shared/src/components/post/SelectionShareBar.tsx
create mode 100644 packages/shared/src/hooks/useTextSelectionShare.spec.ts
create mode 100644 packages/shared/src/hooks/useTextSelectionShare.ts
create mode 100644 packages/storybook/stories/components/QuoteImageCard.stories.tsx
create mode 100644 packages/storybook/stories/components/SelectionShareBar.stories.tsx
create mode 100644 packages/webapp/__tests__/QuoteImageGeneratorPage.spec.tsx
create mode 100644 packages/webapp/pages/image-generator/quote/[id].tsx
diff --git a/packages/shared/src/components/post/PostContent.tsx b/packages/shared/src/components/post/PostContent.tsx
index 702972165b2..1eddd4c9905 100644
--- a/packages/shared/src/components/post/PostContent.tsx
+++ b/packages/shared/src/components/post/PostContent.tsx
@@ -1,6 +1,6 @@
import classNames from 'classnames';
import type { ComponentProps, ReactElement } from 'react';
-import React from 'react';
+import React, { useRef } from 'react';
import dynamic from 'next/dynamic';
import type { Post } from '../../graphql/posts';
import { isVideoPost } from '../../graphql/posts';
@@ -27,6 +27,7 @@ import { useSmartTitle } from '../../hooks/post/useSmartTitle';
import { PostTagList } from './tags/PostTagList';
import PostSourceInfo from './PostSourceInfo';
import { useReaderInstallPromptGate } from '../../hooks/useReaderInstallPromptGate';
+import { SelectionShareBar } from './SelectionShareBar';
type PostContentRawProps = Omit & { post: Post };
@@ -136,8 +137,11 @@ export function PostContentRaw({
useTrackPostView({ post, shouldTrack: isVideoType });
+ const bodyRef = useRef(null);
+
const postMainColumn = (
@@ -259,6 +263,7 @@ export function PostContentRaw({
/>
)}
+
);
diff --git a/packages/shared/src/components/post/QuoteImageCard.tsx b/packages/shared/src/components/post/QuoteImageCard.tsx
new file mode 100644
index 00000000000..8c7182dca37
--- /dev/null
+++ b/packages/shared/src/components/post/QuoteImageCard.tsx
@@ -0,0 +1,81 @@
+import type { ReactElement } from 'react';
+import React from 'react';
+import LogoIcon from '../../svg/LogoIcon';
+import LogoText from '../../svg/LogoText';
+import {
+ Typography,
+ TypographyColor,
+ TypographyTag,
+ TypographyType,
+} from '../typography/Typography';
+
+export interface QuoteImageCardProps {
+ quote: string;
+ title: string;
+ sourceName?: string | null;
+ authorName?: string | null;
+}
+
+/**
+ * The 1200x630 quote card the screenshot service renders into a shareable
+ * image. Sized in fixed pixels because the output is a bitmap, not a
+ * responsive page.
+ */
+export const QuoteImageCard = ({
+ quote,
+ title,
+ sourceName,
+ authorName,
+}: QuoteImageCardProps): ReactElement => {
+ const attribution = [authorName, sourceName].filter(Boolean).join(' · ');
+
+ return (
+
+
+ ),
};
-// Control: selecting text raises nothing at all — no bar, no listeners.
+/**
+ * A drag that starts inside the body and ends outside it is ignored too — both
+ * the anchor and the focus node have to be inside.
+ */
+export const IgnoredCrossingBoundary: Story = {
+ render: () => (
+
+
+ Expected: no bar. The selection starts in the body and ends in the
+ comment below it.
+
);
From f2e21e49c1e3d1e4020cfb22852d023c35f628c3 Mon Sep 17 00:00:00 2001
From: Tsahi Matsliah
Date: Sun, 26 Jul 2026 11:28:06 +0300
Subject: [PATCH 05/24] chore(storybook): raise the selection on mount instead
of via play
Co-Authored-By: Claude Opus 5
---
.../components/SelectionShareBar.stories.tsx | 57 ++++++++++++-------
1 file changed, 37 insertions(+), 20 deletions(-)
diff --git a/packages/storybook/stories/components/SelectionShareBar.stories.tsx b/packages/storybook/stories/components/SelectionShareBar.stories.tsx
index 78df4c5e022..9e7eed8336f 100644
--- a/packages/storybook/stories/components/SelectionShareBar.stories.tsx
+++ b/packages/storybook/stories/components/SelectionShareBar.stories.tsx
@@ -1,6 +1,6 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
-import type { ReactElement, ReactNode } from 'react';
-import React, { useCallback, useRef } from 'react';
+import type { ReactElement, ReactNode, RefObject } from 'react';
+import React, { useCallback, useEffect, useRef } from 'react';
import classNames from 'classnames';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { SelectionShareBar } from '@dailydotdev/shared/src/components/post/SelectionShareBar';
@@ -77,18 +77,23 @@ const raiseBar = (root: ParentNode | null): void => {
target.dispatchEvent(new win.MouseEvent('mouseup', { bubbles: true }));
};
-// `play` runs immediately after render; give the component a tick to attach its
-// document listeners before faking the selection.
-const autoRaise = async ({
- canvasElement,
-}: {
- canvasElement: HTMLElement;
-}): Promise => {
- await new Promise((resolve) => {
- setTimeout(resolve, 150);
- });
-
- raiseBar(canvasElement);
+// Raise the bar once the story has mounted. An effect is used rather than a
+// `play` function because it also fires in the docs view and does not depend on
+// Storybook's instrumentation timing.
+const useAutoRaise = (
+ root: RefObject,
+ enabled = true,
+): void => {
+ useEffect(() => {
+ if (!enabled) {
+ return undefined;
+ }
+
+ // One tick for the bar to attach its document listeners.
+ const timeout = setTimeout(() => raiseBar(root.current), 120);
+
+ return () => clearTimeout(timeout);
+ }, [enabled, root]);
};
interface StageProps {
@@ -101,6 +106,8 @@ interface StageProps {
className?: string;
bodyClassName?: string;
showRaiseButton?: boolean;
+ /** Off when an enclosing story owns the selection. */
+ autoRaise?: boolean;
}
const Stage = ({
@@ -110,10 +117,13 @@ const Stage = ({
className,
bodyClassName,
showRaiseButton = true,
+ autoRaise = true,
}: StageProps): ReactElement => {
const stageRef = useRef(null);
const containerRef = useRef(null);
+ useAutoRaise(stageRef, autoRaise);
+
const onRaise = useCallback(() => {
// The bar's own outside-click handler runs on this very click and clears
// the selection, so re-select on the next tick.
@@ -299,7 +309,6 @@ const meta: Meta = {
},
},
decorators: [withProviders(true)],
- play: autoRaise,
};
export default meta;
@@ -505,16 +514,20 @@ export const IgnoredOutsideBody: Story = {
* A drag that starts inside the body and ends outside it is ignored too — both
* the anchor and the focus node have to be inside.
*/
-export const IgnoredCrossingBoundary: Story = {
- render: () => (
-
@@ -522,7 +535,11 @@ export const IgnoredCrossingBoundary: Story = {
- ),
+ );
+};
+
+export const IgnoredCrossingBoundary: Story = {
+ render: () => ,
};
// -- Surfaces ---------------------------------------------------------------
From 1975763bb17e5f2572303f98e3cbd0b9d2234211 Mon Sep 17 00:00:00 2001
From: Tsahi Matsliah
Date: Sun, 26 Jul 2026 12:07:44 +0300
Subject: [PATCH 06/24] feat(share): quote-in-comment and full social share on
the selection bar
Two new actions alongside copy link / copy text:
- Quote: renders the selection as a markdown blockquote and hands it to the
comment composer via ?comment=, which NewComment already watches on every
surface the bar appears on. The post-type gate there is relaxed so the
hand-off works beyond Welcome/Poll posts, and an optional ?commentOrigin
keeps the logged origin accurate without double-logging OpenComment.
- Share: the existing ShareActions popover with the full social row. It gains
an overridable trigger icon and an onOpenChange callback so the bar can hold
itself open while the (portaled) popover is up.
QuoteImageCard grows author and source imagery in its attribution row.
Co-Authored-By: Claude Opus 5
---
.../shared/src/components/post/NewComment.tsx | 13 ++-
.../src/components/post/QuoteImageCard.tsx | 91 ++++++++++++++++---
.../src/components/post/SelectionShareBar.tsx | 74 ++++++++++++++-
.../src/components/share/ShareActions.tsx | 27 +++++-
.../components/QuoteImageCard.stories.tsx | 26 +++++-
.../components/SelectionShareBar.stories.tsx | 67 +++++++++++++-
.../pages/image-generator/quote/[id].tsx | 8 ++
7 files changed, 274 insertions(+), 32 deletions(-)
diff --git a/packages/shared/src/components/post/NewComment.tsx b/packages/shared/src/components/post/NewComment.tsx
index 705f41c3580..6d63d04e01c 100644
--- a/packages/shared/src/components/post/NewComment.tsx
+++ b/packages/shared/src/components/post/NewComment.tsx
@@ -117,19 +117,18 @@ function NewCommentComponent(
}, [isComposerOpen, onComposerOpenChange]);
useEffect(() => {
- if (
- !shouldHandleCommentQuery ||
- !hasCommentQuery ||
- (post.type !== PostType.Welcome && post.type !== PostType.Poll)
- ) {
+ if (!shouldHandleCommentQuery || !hasCommentQuery) {
return;
}
- const { comment, ...query } = router.query;
- const origin =
+ const { comment, commentOrigin, ...query } = router.query;
+ // Callers that know where the draft came from say so (the text-selection
+ // share bar does); otherwise fall back to the post type, as before.
+ const originByPostType =
post.type === PostType.Poll
? Origin.PollCommentButton
: Origin.SquadChecklist;
+ const origin = (commentOrigin as Origin) ?? originByPostType;
onShowComment(origin, comment as string);
diff --git a/packages/shared/src/components/post/QuoteImageCard.tsx b/packages/shared/src/components/post/QuoteImageCard.tsx
index 8c7182dca37..e330fd764b3 100644
--- a/packages/shared/src/components/post/QuoteImageCard.tsx
+++ b/packages/shared/src/components/post/QuoteImageCard.tsx
@@ -8,14 +8,67 @@ import {
TypographyTag,
TypographyType,
} from '../typography/Typography';
+import { fallbackImages } from '../../lib/config';
export interface QuoteImageCardProps {
quote: string;
title: string;
sourceName?: string | null;
authorName?: string | null;
+ /** Avatar of the post author, badged with the source below. */
+ authorImage?: string | null;
+ /** Source (publication or squad) logo. */
+ sourceImage?: string | null;
}
+/**
+ * Author avatar with the source badged on its corner, falling back to whichever
+ * one exists. Plain `` on purpose: the screenshot service renders this page
+ * once and captures it, so there is nothing for lazy loading to defer to.
+ */
+const Attribution = ({
+ authorImage,
+ authorName,
+ sourceImage,
+ sourceName,
+}: Pick<
+ QuoteImageCardProps,
+ 'authorImage' | 'authorName' | 'sourceImage' | 'sourceName'
+>): ReactElement | null => {
+ const avatar = authorName ? authorImage ?? fallbackImages.avatar : null;
+
+ if (!avatar && !sourceImage) {
+ return null;
+ }
+
+ if (!avatar) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+ {!!sourceImage && (
+
+ )}
+
+ );
+};
+
/**
* The 1200x630 quote card the screenshot service renders into a shareable
* image. Sized in fixed pixels because the output is a bitmap, not a
@@ -26,6 +79,8 @@ export const QuoteImageCard = ({
title,
sourceName,
authorName,
+ authorImage,
+ sourceImage,
}: QuoteImageCardProps): ReactElement => {
const attribution = [authorName, sourceName].filter(Boolean).join(' · ');
@@ -53,23 +108,31 @@ export const QuoteImageCard = ({
{quote}
diff --git a/packages/shared/src/components/post/SelectionShareBar.tsx b/packages/shared/src/components/post/SelectionShareBar.tsx
index abc35e7251f..4ffb9346c4d 100644
--- a/packages/shared/src/components/post/SelectionShareBar.tsx
+++ b/packages/shared/src/components/post/SelectionShareBar.tsx
@@ -1,10 +1,12 @@
import type { ReactElement, RefObject } from 'react';
import React, { useCallback, useLayoutEffect, useRef, useState } from 'react';
+import { useRouter } from 'next/router';
import type { Post } from '../../graphql/posts';
import { Button, ButtonSize, ButtonVariant } from '../buttons/Button';
-import { CopyIcon, LinkIcon } from '../icons';
+import { CopyIcon, DiscussIcon, LinkIcon, ShareIcon } from '../icons';
import { Tooltip } from '../tooltip/Tooltip';
import { RootPortal } from '../tooltips/Portal';
+import { ShareActions } from '../share/ShareActions';
import { useCopyText } from '../../hooks/useCopy';
import { useShareOrCopyLink } from '../../hooks/useShareOrCopyLink';
import { useTextSelectionShare } from '../../hooks/useTextSelectionShare';
@@ -25,8 +27,20 @@ export interface SelectionShareBarProps {
post: Post;
/** The post body. Only selections made inside it raise the bar. */
containerRef: RefObject;
+ /**
+ * Overrides where a quote is sent. By default the selection is written into
+ * the URL as `?comment=`, which the post's comment composer picks up.
+ */
+ onQuote?: (markdownQuote: string) => void;
}
+/** Renders the selection as a markdown blockquote for the comment composer. */
+export const buildCommentQuote = (selection: string): string =>
+ `${selection
+ .split('\n')
+ .map((line) => `> ${line}`.trimEnd())
+ .join('\n')}\n\n`;
+
// Quote images read badly past a couple of sentences, and the text rides in the
// generator URL, so cap it well below any browser URL limit.
const MAX_QUOTE_LENGTH = 280;
@@ -59,12 +73,17 @@ export const buildQuoteImageUrl = (postId: string, text: string): string => {
function SelectionShareBarContent({
post,
containerRef,
+ onQuote,
}: SelectionShareBarProps): ReactElement | null {
const { text, rect, clear } = useTextSelectionShare({ containerRef });
const barRef = useRef(null);
const [barWidth, setBarWidth] = useState(FALLBACK_BAR_WIDTH);
const { width: viewportWidth } = useVisualViewport();
const [viewportOffset, setViewportOffset] = useState({ left: 0, top: 0 });
+ // The share popover portals out of the bar, so an open popover has to hold
+ // the bar open — otherwise clicking a network inside it reads as a click away.
+ const [isShareOpen, setIsShareOpen] = useState(false);
+ const router = useRouter();
const { logEvent } = useLogContext();
const postLogEvent = usePostLogEvent();
@@ -108,14 +127,15 @@ function SelectionShareBarContent({
clear();
},
- !!text,
+ !!text && !isShareOpen,
);
useEventListener(
text ? globalThis?.document : null,
'keydown',
(event: KeyboardEvent) => {
- if (event.key === 'Escape') {
+ // The popover closes itself on Escape; only the second press drops the bar.
+ if (event.key === 'Escape' && !isShareOpen) {
dismiss();
}
},
@@ -162,6 +182,35 @@ function SelectionShareBarContent({
copyText({ textToCopy: text, message: '✅ Copied text to clipboard' });
};
+ // The composer is the one action that consumes the selection rather than
+ // copying it, so hand off the markdown and get out of the way. `NewComment`
+ // is already mounted on every surface the bar appears on and already watches
+ // `?comment=`, so the URL is the hand-off — no ref plumbing across the page.
+ // It logs `OpenComment` when it opens, so the bar deliberately does not.
+ const onQuoteInComment = () => {
+ const quote = buildCommentQuote(text);
+
+ dismiss();
+
+ if (onQuote) {
+ onQuote(quote);
+ return;
+ }
+
+ router.replace(
+ {
+ pathname: router.pathname,
+ query: {
+ ...router.query,
+ comment: quote,
+ commentOrigin: Origin.TextSelection,
+ },
+ },
+ undefined,
+ { shallow: true },
+ );
+ };
+
return (
{/*
@@ -205,6 +254,25 @@ function SelectionShareBarContent({
variant={ButtonVariant.Tertiary}
/>
+
+ }
+ onClick={onQuoteInComment}
+ size={ButtonSize.Small}
+ variant={ButtonVariant.Tertiary}
+ />
+
+ }
+ label="Share"
+ link={post.commentsPermalink}
+ onOpenChange={setIsShareOpen}
+ onShare={logShare}
+ text={text}
+ />
diff --git a/packages/shared/src/components/share/ShareActions.tsx b/packages/shared/src/components/share/ShareActions.tsx
index 50ee53e30e8..9cd310305ae 100644
--- a/packages/shared/src/components/share/ShareActions.tsx
+++ b/packages/shared/src/components/share/ShareActions.tsx
@@ -1,5 +1,5 @@
import type { ReactElement } from 'react';
-import React, { useRef, useState } from 'react';
+import React, { useCallback, useRef, useState } from 'react';
import classNames from 'classnames';
import { Popover, PopoverTrigger } from '@radix-ui/react-popover';
import { PopoverContent } from '../popover/Popover';
@@ -33,6 +33,13 @@ export interface ShareActionsProps {
className?: string;
/** Called for any share/copy so the caller can log with its own origin. */
onShare?: (provider: ShareProvider) => void;
+ /** Overrides the trigger icon. Defaults to the copy icon. */
+ icon?: ReactElement;
+ /**
+ * Notifies the caller when the popover opens or closes, so a host that
+ * dismisses itself on outside clicks can stay put while it is open.
+ */
+ onOpenChange?: (open: boolean) => void;
}
const HOVER_CLOSE_DELAY = 120;
@@ -50,12 +57,24 @@ export function ShareActions({
emailSummary,
className,
onShare,
+ icon,
+ onOpenChange,
}: ShareActionsProps): ReactElement {
const isLaptop = useViewSize(ViewSize.Laptop);
- const [open, setOpen] = useState(false);
+ const [open, setOpenState] = useState(false);
const [copying, shareOrCopy] = useShareOrCopyLink({ link, text, cid });
const closeTimeout = useRef>();
+ const setOpen = useCallback(
+ (next: boolean) => {
+ setOpenState(next);
+ onOpenChange?.(next);
+ },
+ [onOpenChange],
+ );
+
+ const triggerIcon = icon ?? ;
+
const list = (
}
+ icon={triggerIcon}
aria-label={label}
className={className}
onClick={() => {
@@ -136,7 +155,7 @@ export function ShareActions({
type="button"
variant={buttonVariant}
size={buttonSize}
- icon={}
+ icon={triggerIcon}
aria-label={label}
pressed={open}
className={className}
diff --git a/packages/storybook/stories/components/QuoteImageCard.stories.tsx b/packages/storybook/stories/components/QuoteImageCard.stories.tsx
index 44fa9c5ad8e..9d16ab73474 100644
--- a/packages/storybook/stories/components/QuoteImageCard.stories.tsx
+++ b/packages/storybook/stories/components/QuoteImageCard.stories.tsx
@@ -2,6 +2,11 @@ import type { Meta, StoryObj } from '@storybook/react-vite';
import React from 'react';
import { QuoteImageCard } from '@dailydotdev/shared/src/components/post/QuoteImageCard';
+const authorImage =
+ 'https://media.daily.dev/image/upload/s---xy_OAwk--/f_auto,q_auto/v1703781380/avatars/avatar_28849d86070e4c099c877ab6837c61f0';
+const sourceImage =
+ 'https://media.daily.dev/image/upload/s--mqP40YbK--/f_auto/v1707831184/squads/303a826b-28e4-4d2f-938a-c610148e6f01';
+
const meta: Meta = {
title: 'Components/Share/QuoteImageCard',
component: QuoteImageCard,
@@ -12,6 +17,10 @@ const meta: Meta = {
'The 1200x630 card rendered at `/image-generator/quote/[id]` and screenshotted into a shareable quote image.',
'Fixed pixel sizing on purpose — the output is a bitmap.',
'',
+ 'The attribution row carries the author avatar badged with the source logo.',
+ 'Plain `` tags: the screenshot service renders the page once and captures it,',
+ 'so there is nothing for lazy loading to defer to.',
+ '',
'**Parked:** the share bar no longer offers a "generate quote image" action;',
'the route and this card stay in place until the screenshot service serves the PNG.',
].join('\n'),
@@ -24,6 +33,8 @@ const meta: Meta = {
title: 'How to ship fast without breaking everything',
sourceName: 'daily.dev',
authorName: 'Ido Shamun',
+ authorImage,
+ sourceImage,
},
// The card is wider than the docs frame, so scale it down to fit.
decorators: [
@@ -39,6 +50,7 @@ export default meta;
type Story = StoryObj;
+/** Author avatar with the source badged on its corner. */
export const Default: Story = {};
// Long selections are truncated by the share bar, but the card still clamps.
@@ -49,7 +61,17 @@ export const LongQuote: Story = {
},
};
-// Posts without an author fall back to the source alone.
+/** No author (most syndicated articles): the source logo stands alone. */
export const SourceOnly: Story = {
- args: { authorName: null },
+ args: { authorName: null, authorImage: null },
+};
+
+/** Author with no avatar on file falls back to the anonymous placeholder. */
+export const AuthorWithoutAvatar: Story = {
+ args: { authorImage: null },
+};
+
+/** Neither image resolved — the row collapses to text, no empty boxes. */
+export const NoImages: Story = {
+ args: { authorImage: null, sourceImage: null, authorName: null },
};
diff --git a/packages/storybook/stories/components/SelectionShareBar.stories.tsx b/packages/storybook/stories/components/SelectionShareBar.stories.tsx
index 9e7eed8336f..9e6d6408721 100644
--- a/packages/storybook/stories/components/SelectionShareBar.stories.tsx
+++ b/packages/storybook/stories/components/SelectionShareBar.stories.tsx
@@ -108,6 +108,8 @@ interface StageProps {
showRaiseButton?: boolean;
/** Off when an enclosing story owns the selection. */
autoRaise?: boolean;
+ /** Intercepts the quote action instead of writing it to the URL. */
+ onQuote?: (markdownQuote: string) => void;
}
const Stage = ({
@@ -118,6 +120,7 @@ const Stage = ({
bodyClassName,
showRaiseButton = true,
autoRaise = true,
+ onQuote,
}: StageProps): ReactElement => {
const stageRef = useRef(null);
const containerRef = useRef(null);
@@ -152,7 +155,11 @@ const Stage = ({
Raise the bar again
)}
-
+
);
};
@@ -322,7 +329,7 @@ export const AboveSelection: Story = {
render: () => (
@@ -408,6 +415,62 @@ export const FollowsWhileScrolling: Story = {
),
};
+// -- Actions ----------------------------------------------------------------
+
+/**
+ * The share button opens the full social row — the same `ShareActions` popover
+ * used elsewhere in the app, with the selection riding along as the share text.
+ * An open popover holds the bar open: it portals out of the bar, so without
+ * that guard clicking a network would read as a click away and dismiss it.
+ */
+export const ShareNetworks: Story = {
+ render: () => (
+
+
+
+ ),
+ play: async () => {
+ await new Promise((resolve) => {
+ setTimeout(resolve, 400);
+ });
+
+ const trigger = globalThis.document.querySelector(
+ '[data-testid="selectionShareBar"] [aria-label="Share"]',
+ );
+
+ trigger?.click();
+ },
+};
+
+/**
+ * Quote sends the selection to the comment composer as a markdown blockquote,
+ * via `?comment=`. Storybook stubs the router, so nothing navigates here — the
+ * payload below is what the composer receives.
+ */
+export const QuoteInComment: Story = {
+ render: () => {
+ const [quote, setQuote] = React.useState(null);
+
+ return (
+
+ {quote ?? 'Nothing quoted yet.'}
+
+ }
+ onQuote={setQuote}
+ >
+
+
+ );
+ },
+};
+
// -- Devices ----------------------------------------------------------------
/**
diff --git a/packages/webapp/pages/image-generator/quote/[id].tsx b/packages/webapp/pages/image-generator/quote/[id].tsx
index 850caa61edb..7dd6008af2e 100644
--- a/packages/webapp/pages/image-generator/quote/[id].tsx
+++ b/packages/webapp/pages/image-generator/quote/[id].tsx
@@ -21,6 +21,8 @@ interface QuotePageProps {
title: string;
sourceName: string | null;
authorName: string | null;
+ sourceImage: string | null;
+ authorImage: string | null;
seo: NextSeoProps;
}
@@ -45,6 +47,8 @@ export async function getStaticProps({
title: post.title ?? '',
sourceName: post.source?.name ?? null,
authorName: post.author?.name ?? null,
+ sourceImage: post.source?.image ?? null,
+ authorImage: post.author?.image ?? null,
seo: {
title: post.title ?? 'Quote from daily.dev',
description:
@@ -75,6 +79,8 @@ const QuoteImagePage = ({
title,
sourceName,
authorName,
+ sourceImage,
+ authorImage,
}: QuotePageProps): ReactElement => {
const { query } = useRouter();
// The quote itself is never part of the cached page — it comes from the
@@ -89,8 +95,10 @@ const QuoteImagePage = ({
className="w-fit"
>
From ed30c4f5781eb764e7afaa55fd58908a8c734bcc Mon Sep 17 00:00:00 2001
From: Tsahi Matsliah
Date: Sun, 26 Jul 2026 12:30:39 +0300
Subject: [PATCH 07/24] fix(share): shorten selection-bar tooltips to one or
two words
The bar sits directly over the selection, so a sentence-length tooltip
covers the text the reader is looking at. aria-labels keep the long form.
Co-Authored-By: Claude Opus 5
---
.../src/components/post/SelectionShareBar.tsx | 12 ++++++--
.../components/SelectionShareBar.stories.tsx | 29 +++++++++++++++++++
2 files changed, 38 insertions(+), 3 deletions(-)
diff --git a/packages/shared/src/components/post/SelectionShareBar.tsx b/packages/shared/src/components/post/SelectionShareBar.tsx
index 4ffb9346c4d..7f87dbd581a 100644
--- a/packages/shared/src/components/post/SelectionShareBar.tsx
+++ b/packages/shared/src/components/post/SelectionShareBar.tsx
@@ -234,7 +234,13 @@ function SelectionShareBarContent({
data-testid="selectionShareBar"
className="flex animate-composer-in items-center gap-1 rounded-12 border border-border-subtlest-tertiary bg-background-popover p-1 shadow-2 motion-reduce:animate-none"
>
-
+ {/*
+ Tooltips stay to one or two words — the bar sits right on top of
+ what the reader just selected, so a sentence in a tooltip covers the
+ thing they are trying to look at. The aria-labels keep the long
+ form, where the extra context costs nothing.
+ */}
+
-
+
-
+
{
it('renders the screenshot wrapper around the quote', () => {
render(
,
From ed33a0343b113d614042de95d47cd96036b8c3c9 Mon Sep 17 00:00:00 2001
From: Tsahi Matsliah
Date: Sun, 26 Jul 2026 14:42:37 +0300
Subject: [PATCH 10/24] chore(storybook): cover every wired surface and the
content-area scoping
Adds the four post types the bar newly reaches (collection, poll, brief,
social) plus stories proving comments, post chrome and digest feeds stay
out of bounds.
Co-Authored-By: Claude Opus 5
---
.../components/SelectionShareBar.stories.tsx | 218 +++++++++++++++++-
1 file changed, 208 insertions(+), 10 deletions(-)
diff --git a/packages/storybook/stories/components/SelectionShareBar.stories.tsx b/packages/storybook/stories/components/SelectionShareBar.stories.tsx
index dbfd6db21ac..6c9d7ed88d3 100644
--- a/packages/storybook/stories/components/SelectionShareBar.stories.tsx
+++ b/packages/storybook/stories/components/SelectionShareBar.stories.tsx
@@ -80,10 +80,7 @@ const raiseBar = (root: ParentNode | null): void => {
// Raise the bar once the story has mounted. An effect is used rather than a
// `play` function because it also fires in the docs view and does not depend on
// Storybook's instrumentation timing.
-const useAutoRaise = (
- root: RefObject,
- enabled = true,
-): void => {
+const useAutoRaise = (root: RefObject, enabled = true): void => {
useEffect(() => {
if (!enabled) {
return undefined;
@@ -134,7 +131,10 @@ const Stage = ({
}, []);
return (
-
+ >
+);
+
// ---------------------------------------------------------------------------
// Providers
// ---------------------------------------------------------------------------
@@ -634,26 +704,106 @@ export const IgnoredCrossingBoundary: Story = {
render: () => ,
};
+/**
+ * The comment section sits inside the same column as the body on every surface
+ * (`BasePostContent` renders it), so binding the bar to that column armed it
+ * over replies — quoting one would have credited a commenter's words to the
+ * post. `PostSelectionArea` scopes the bar to title, TL;DR and body only.
+ */
+export const IgnoredComments: Story = {
+ render: () => (
+
+
+ Completely agree — the second point is the one people miss.
+
+
+
+ }
+ >
+
+
+ ),
+};
+
+/**
+ * Post chrome — navigation, source strip, tags, metadata, action bars — is
+ * outside the selection area too. Only readable content raises the bar.
+ */
+export const IgnoredPostChrome: Story = {
+ render: () => (
+
+ {/* eslint-disable-next-line react/jsx-props-no-spreading */}
+ #webdev · 6 min read · From daily.dev
+
+ }
+ >
+
+
+ ),
+};
+
+/**
+ * Digest posts are deliberately excluded. A digest has no prose of its own —
+ * it is a header plus an embedded feed of *other* posts, so a selection there
+ * would quote a different post's headline and attribute it to the digest.
+ */
+export const IgnoredDigestPost: Story = {
+ render: () => (
+
+ {/* eslint-disable-next-line react/jsx-props-no-spreading */}
+
+ Another post’s headline, listed inside the digest feed
+
+
+ daily.dev · 4 min read
+
+
+ }
+ >
+
Today
+
Your personalized digest
+
12 posts · 6 sources
+
+ ),
+};
+
// -- Surfaces ---------------------------------------------------------------
-/** Classic article/video body — `PostContent`. */
+/** Article & video posts — `PostContent`. Page and modal. */
export const SurfaceArticlePost: Story = {
render: () => (
),
};
-/** Freeform / welcome / share / YouTube squad post — `SquadPostContent`. */
+/** Freeform / welcome / share squad posts — `SquadPostContent`. */
export const SurfaceSquadPost: Story = {
render: () => (
@@ -665,13 +815,61 @@ export const SurfaceFocusCard: Story = {
render: () => (
),
};
+/** Collections — `CollectionPostContent`. Newly covered. */
+export const SurfaceCollectionPost: Story = {
+ render: () => (
+
+
+
+ ),
+};
+
+/** Polls — `PollPostContent`. The question and options are quotable. */
+export const SurfacePollPost: Story = {
+ render: () => (
+
+
+
+ ),
+};
+
+/** Briefs — `BriefPostContent`. Long generated prose, the best quote source. */
+export const SurfaceBriefPost: Story = {
+ render: () => (
+
+
+
+ ),
+};
+
+/** Social / Twitter posts — `SocialTwitterPostContent`. */
+export const SurfaceSocialPost: Story = {
+ render: () => (
+
+
+
+ ),
+};
+
// -- Dismissal --------------------------------------------------------------
/** Click away, press Escape, or collapse the selection — all drop the bar. */
From 284f6eb2ce8ee2747da3872dc5c2fcc21b938c60 Mon Sep 17 00:00:00 2001
From: Tsahi Matsliah
Date: Sun, 26 Jul 2026 14:48:44 +0300
Subject: [PATCH 11/24] fix(storybook): ignored-comments and
ignored-post-chrome selected the body
ArticleBody carries the auto-selection target, so both stories selected the
article instead of the thing they claim to ignore and showed a bar. They now
render it with the target removed.
Co-Authored-By: Claude Opus 5
---
.../components/SelectionShareBar.stories.tsx | 17 +++++++++++++----
1 file changed, 13 insertions(+), 4 deletions(-)
diff --git a/packages/storybook/stories/components/SelectionShareBar.stories.tsx b/packages/storybook/stories/components/SelectionShareBar.stories.tsx
index 6c9d7ed88d3..c3506af9aa5 100644
--- a/packages/storybook/stories/components/SelectionShareBar.stories.tsx
+++ b/packages/storybook/stories/components/SelectionShareBar.stories.tsx
@@ -171,14 +171,23 @@ const Stage = ({
// modals that do not belong in a story.
// ---------------------------------------------------------------------------
-const ArticleBody = (): ReactElement => (
+/**
+ * `selectable={false}` moves the auto-selection target out of the body, so a
+ * story can prove that selecting something *outside* raises nothing. Leaving it
+ * on would select the body instead and the story would silently pass.
+ */
+const ArticleBody = ({
+ selectable = true,
+}: {
+ selectable?: boolean;
+}): ReactElement => (
<>
@@ -728,7 +737,7 @@ export const IgnoredComments: Story = {
}
>
-
+
),
};
@@ -749,7 +758,7 @@ export const IgnoredPostChrome: Story = {
}
>
-
+
),
};
From cfc959ec601410495bedb5b95c66cf8a8952a778 Mon Sep 17 00:00:00 2001
From: Tsahi Matsliah
Date: Sun, 26 Jul 2026 23:16:24 +0300
Subject: [PATCH 12/24] feat(share): ship the selection bar unflagged
Removes share_text_selection entirely and drops the sharing_visibility gate
from the bar, so it renders for every user with no experiment. The
inner/outer component split existed only so a disabled experiment mounted no
hooks; with no gate it collapses into one component.
Tests and stories that asserted on the gate are replaced by ones asserting
the bar renders for everyone and only hides when there is no selection.
Co-Authored-By: Claude Opus 5
---
.../post/SelectionShareBar.spec.tsx | 31 ++++++++---------
.../src/components/post/SelectionShareBar.tsx | 33 +++----------------
packages/shared/src/lib/featureManagement.ts | 9 -----
.../components/SelectionShareBar.stories.tsx | 33 ++++---------------
4 files changed, 24 insertions(+), 82 deletions(-)
diff --git a/packages/shared/src/components/post/SelectionShareBar.spec.tsx b/packages/shared/src/components/post/SelectionShareBar.spec.tsx
index 2d72b05c411..eee341f401e 100644
--- a/packages/shared/src/components/post/SelectionShareBar.spec.tsx
+++ b/packages/shared/src/components/post/SelectionShareBar.spec.tsx
@@ -40,13 +40,8 @@ const post = {
permalink: 'https://daily.dev/r/how-to-ship-fast',
} as unknown as Post;
-const enabledGrowthBook = () =>
- new GrowthBook({
- features: {
- sharing_visibility: { defaultValue: true },
- share_text_selection: { defaultValue: true },
- },
- });
+// The bar ships unflagged, so GrowthBook only has to exist for TestBootProvider.
+const enabledGrowthBook = () => new GrowthBook();
beforeEach(() => {
jest.clearAllMocks();
@@ -76,23 +71,23 @@ const renderComponent = (
};
};
-describe('SelectionShareBar flag gate', () => {
- it('renders nothing and attaches no selection listeners when off', () => {
+describe('SelectionShareBar gating', () => {
+ it('renders for every user, with no feature flag set', () => {
renderComponent(new GrowthBook());
- expect(screen.queryByTestId('selectionShareBar')).not.toBeInTheDocument();
- expect(useTextSelectionShareMock).not.toHaveBeenCalled();
+ expect(screen.getByTestId('selectionShareBar')).toBeInTheDocument();
});
- it('renders nothing when only the per-surface flag is on', () => {
- renderComponent(
- new GrowthBook({
- features: { share_text_selection: { defaultValue: true } },
- }),
- );
+ it('renders nothing when there is no selection', () => {
+ useTextSelectionShareMock.mockReturnValue({
+ text: null,
+ rect: null,
+ clear,
+ });
+
+ renderComponent();
expect(screen.queryByTestId('selectionShareBar')).not.toBeInTheDocument();
- expect(useTextSelectionShareMock).not.toHaveBeenCalled();
});
});
diff --git a/packages/shared/src/components/post/SelectionShareBar.tsx b/packages/shared/src/components/post/SelectionShareBar.tsx
index 7f87dbd581a..d5ac06ba1e3 100644
--- a/packages/shared/src/components/post/SelectionShareBar.tsx
+++ b/packages/shared/src/components/post/SelectionShareBar.tsx
@@ -10,14 +10,11 @@ import { ShareActions } from '../share/ShareActions';
import { useCopyText } from '../../hooks/useCopy';
import { useShareOrCopyLink } from '../../hooks/useShareOrCopyLink';
import { useTextSelectionShare } from '../../hooks/useTextSelectionShare';
-import { useSharingVisibility } from '../../hooks/useSharingVisibility';
-import { useConditionalFeature } from '../../hooks/useConditionalFeature';
import { useOutsideClick } from '../../hooks/utils/useOutsideClick';
import { useEventListener } from '../../hooks/useEventListener';
import { useVisualViewport } from '../../hooks/utils/useVisualViewport';
import { useLogContext } from '../../contexts/LogContext';
import { usePostLogEvent } from '../../lib/feed';
-import { featureShareTextSelection } from '../../lib/featureManagement';
import { LogEvent, Origin } from '../../lib/log';
import { ShareProvider } from '../../lib/share';
import { ReferralCampaignKey } from '../../lib/referral';
@@ -68,9 +65,11 @@ export const buildQuoteImageUrl = (postId: string, text: string): string => {
)}`;
};
-// The bar itself. Split from the flag gate below so a disabled experiment
-// mounts none of these hooks — and therefore attaches no listeners at all.
-function SelectionShareBarContent({
+/**
+ * Floating share bar for text selected inside a post body. Ships to everyone —
+ * there is no flag gate.
+ */
+export function SelectionShareBar({
post,
containerRef,
onQuote,
@@ -284,25 +283,3 @@ function SelectionShareBarContent({
);
}
-
-/**
- * Floating share bar for text selected inside a post body. Flag-off it renders
- * nothing and, because every listener lives in the inner component, attaches no
- * selection/viewport listeners at all.
- */
-export function SelectionShareBar(
- props: SelectionShareBarProps,
-): ReactElement | null {
- const { isEnabled: isSharingVisible } = useSharingVisibility();
- const { value: isSelectionShareOn } = useConditionalFeature({
- feature: featureShareTextSelection,
- shouldEvaluate: isSharingVisible,
- });
-
- if (!isSharingVisible || !isSelectionShareOn) {
- return null;
- }
-
- // eslint-disable-next-line react/jsx-props-no-spreading
- return ;
-}
diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts
index edc71d4d995..97b38cbdd2b 100644
--- a/packages/shared/src/lib/featureManagement.ts
+++ b/packages/shared/src/lib/featureManagement.ts
@@ -308,12 +308,3 @@ export const featureSharingVisibility = new Feature(
// high-traffic icon, so it ramps on its own flag to watch share/copy metrics.
// Keep the default `false` (control = existing `LinkIcon`).
export const featureShareCopyIcon = new Feature('share_copy_icon', false);
-
-// Shows a floating share bar anchored to text selected inside a post body
-// (copy link, copy the selection, generate a quote image). Part of the
-// sharing-visibility initiative, so it also requires `sharing_visibility`.
-// Keep the default `false` — GrowthBook ramps it.
-export const featureShareTextSelection = new Feature(
- 'share_text_selection',
- false,
-);
diff --git a/packages/storybook/stories/components/SelectionShareBar.stories.tsx b/packages/storybook/stories/components/SelectionShareBar.stories.tsx
index c3506af9aa5..ee54b881e97 100644
--- a/packages/storybook/stories/components/SelectionShareBar.stories.tsx
+++ b/packages/storybook/stories/components/SelectionShareBar.stories.tsx
@@ -307,13 +307,10 @@ const SocialBody = (): ReactElement => (
// Providers
// ---------------------------------------------------------------------------
-// Storybook aliases `@growthbook/growthbook` to a mock whose `getFeatureValue`
-// coerces every falsy default to the truthy string `'control'`, so a flag can't
-// be evaluated as `false` here. Flag-off is therefore simulated by holding the
-// features context as "not ready", which is the exact path
-// `useConditionalFeature` takes to fall back to the (false) default value.
+// The bar ships unflagged, so these providers only supply the contexts it
+// reads — auth, logging and react-query. There is no experiment to simulate.
const withProviders =
- (enabled: boolean) =>
+ () =>
(Story: React.ComponentType): React.ReactElement => {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, staleTime: Infinity } },
@@ -354,7 +351,7 @@ const withProviders =
>
feature.defaultValue as any,
}}
@@ -385,7 +382,7 @@ const meta: Meta = {
description: {
component: [
'Floating share bar anchored to a text selection inside a post body.',
- 'Behind the `share_text_selection` flag **and** the `sharing_visibility` master gate.',
+ 'Ships to every user — there is no feature flag or experiment behind it.',
'',
'Every story fakes a selection on load, so the bar is visible without dragging a cursor.',
'Clicking anywhere dismisses it — hit **Raise the bar again** to bring it back.',
@@ -394,7 +391,7 @@ const meta: Meta = {
},
},
},
- decorators: [withProviders(true)],
+ decorators: [withProviders()],
};
export default meta;
@@ -892,21 +889,3 @@ export const Dismissal: Story = {
),
};
-
-// -- Flag off ---------------------------------------------------------------
-
-/**
- * Control. Nothing renders and none of the inner hooks mount, so no selection
- * or viewport listeners are attached at all.
- */
-export const FlagOff: Story = {
- decorators: [withProviders(false)],
- render: () => (
-
-
-
- ),
-};
From 8b4e622896759c0243ac2b2a7ed2eda4807ec7a8 Mon Sep 17 00:00:00 2001
From: Tsahi Matsliah
Date: Mon, 27 Jul 2026 09:28:32 +0300
Subject: [PATCH 13/24] fix(share): copy confirms with a check, quote scrolls
to the composer
- Copy link and copy text swap their icon to a check for the second that
useCopyLink/useCopyText hold `copying`, cross-fading in a single grid cell
so the button never changes width. Tooltip says "Copied!" alongside.
- A draft handed to the composer through ?comment= has no click behind it to
move the page, so it opened unseen below the fold. focusInputById now
scrolls it into view first and focuses without stealing that scroll.
Optional-chained because jsdom has no scrollIntoView.
- The share popover tracks the bar every frame. The bar is fixed and
re-anchors on scroll; the default strategy left the popover at a stale
position until the next scroll/resize event.
Co-Authored-By: Claude Opus 5
---
.../shared/src/components/post/NewComment.tsx | 20 +++++++-
.../src/components/post/SelectionShareBar.tsx | 51 ++++++++++++++++---
.../src/components/share/ShareActions.tsx | 6 +++
3 files changed, 68 insertions(+), 9 deletions(-)
diff --git a/packages/shared/src/components/post/NewComment.tsx b/packages/shared/src/components/post/NewComment.tsx
index 6d63d04e01c..1dc842e8910 100644
--- a/packages/shared/src/components/post/NewComment.tsx
+++ b/packages/shared/src/components/post/NewComment.tsx
@@ -52,7 +52,13 @@ const focusInputById = (inputId: string, remainingFrames = 30): void => {
const input = document.getElementById(inputId);
if (input) {
- input.focus();
+ // Bring the composer into view before focusing. A draft can arrive from far
+ // up the page — quoting a selection from the post body, say — and a bare
+ // `focus()` jumps the scroll position instead of moving to it. Scrolling
+ // first, with focus not stealing the scroll, keeps that motion smooth.
+ // Optional-chained: jsdom does not implement `scrollIntoView`.
+ input.scrollIntoView?.({ behavior: 'smooth', block: 'center' });
+ input.focus({ preventScroll: true });
return;
}
@@ -131,11 +137,21 @@ function NewCommentComponent(
const origin = (commentOrigin as Origin) ?? originByPostType;
onShowComment(origin, comment as string);
+ // A draft handed over through the URL has no click behind it to scroll the
+ // page, so the composer would open unseen below the fold.
+ focusInputById(inputId);
router.replace({ pathname: router.pathname, query }, undefined, {
shallow: true,
});
- }, [post, hasCommentQuery, onShowComment, router, shouldHandleCommentQuery]);
+ }, [
+ post,
+ hasCommentQuery,
+ inputId,
+ onShowComment,
+ router,
+ shouldHandleCommentQuery,
+ ]);
const onCommentClick = (origin: Origin) => {
if (!user) {
diff --git a/packages/shared/src/components/post/SelectionShareBar.tsx b/packages/shared/src/components/post/SelectionShareBar.tsx
index d5ac06ba1e3..788db5772cc 100644
--- a/packages/shared/src/components/post/SelectionShareBar.tsx
+++ b/packages/shared/src/components/post/SelectionShareBar.tsx
@@ -1,9 +1,10 @@
import type { ReactElement, RefObject } from 'react';
import React, { useCallback, useLayoutEffect, useRef, useState } from 'react';
+import classNames from 'classnames';
import { useRouter } from 'next/router';
import type { Post } from '../../graphql/posts';
import { Button, ButtonSize, ButtonVariant } from '../buttons/Button';
-import { CopyIcon, DiscussIcon, LinkIcon, ShareIcon } from '../icons';
+import { CopyIcon, DiscussIcon, LinkIcon, ShareIcon, VIcon } from '../icons';
import { Tooltip } from '../tooltip/Tooltip';
import { RootPortal } from '../tooltips/Portal';
import { ShareActions } from '../share/ShareActions';
@@ -65,6 +66,38 @@ export const buildQuoteImageUrl = (postId: string, text: string): string => {
)}`;
};
+// Copy actions confirm twice: the toast says what happened, and the button
+// itself swaps to a check. `useCopyLink`/`useCopyText` hold `copying` for a
+// second, which is the whole life of this transition. Both icons are stacked in
+// one grid cell so the button never changes width mid-swap.
+const CopyFeedbackIcon = ({
+ copied,
+ icon,
+}: {
+ copied: boolean;
+ icon: ReactElement;
+}): ReactElement => (
+
+
+ {icon}
+
+
+
+
+
+);
+
/**
* Floating share bar for text selected inside a post body. Ships to everyone —
* there is no flag gate.
@@ -86,12 +119,12 @@ export function SelectionShareBar({
const { logEvent } = useLogContext();
const postLogEvent = usePostLogEvent();
- const [, shareOrCopyLink] = useShareOrCopyLink({
+ const [isLinkCopied, shareOrCopyLink] = useShareOrCopyLink({
link: post.commentsPermalink,
text: text ?? post.title ?? '',
cid: ReferralCampaignKey.SharePost,
});
- const [, copyText] = useCopyText();
+ const [isTextCopied, copyText] = useCopyText();
const dismiss = useCallback(() => {
globalThis?.window?.getSelection?.()?.removeAllRanges();
@@ -239,21 +272,25 @@ export function SelectionShareBar({
thing they are trying to look at. The aria-labels keep the long
form, where the extra context costs nothing.
*/}
-
+
}
+ icon={
+ } />
+ }
onClick={onCopyLink}
size={ButtonSize.Small}
variant={ButtonVariant.Tertiary}
/>
-
+
}
+ icon={
+ } />
+ }
onClick={onCopyText}
size={ButtonSize.Small}
variant={ButtonVariant.Tertiary}
diff --git a/packages/shared/src/components/share/ShareActions.tsx b/packages/shared/src/components/share/ShareActions.tsx
index 9cd310305ae..792f0171c05 100644
--- a/packages/shared/src/components/share/ShareActions.tsx
+++ b/packages/shared/src/components/share/ShareActions.tsx
@@ -167,6 +167,12 @@ export function ShareActions({
side="top"
align="center"
avoidCollisions
+ 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"
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"
{...hoverProps}
>
From 2170337507f9fec6f9172bee8659a0c61395389f Mon Sep 17 00:00:00 2001
From: Tsahi Matsliah
Date: Mon, 27 Jul 2026 10:28:33 +0300
Subject: [PATCH 14/24] feat(share): raise the selection bar on comments and
replies
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Selecting text in a comment or reply now raises the bar, bound to that
comment rather than the post:
- Copy link yields the comment's own permalink, and sharing logs
ShareComment with its id, so a reader never shares a commenter's words
under the post's link.
- Quote opens a reply to that comment seeded with the blockquote. ReplyTo
carries an optional quote and useComments puts it ahead of the @mention,
so the cursor lands after the handle with the quote already above.
- A comment with no reply handler still copies and shares but will not
quote — falling back to the post composer would misattribute it.
Bound to the comment's prose only, via the same display:contents wrapper as
the post body, so the header, badges and action row stay out of bounds.
Co-Authored-By: Claude Opus 5
---
.../components/comments/CommentContainer.tsx | 30 +++++--
.../src/components/comments/MainComment.tsx | 8 ++
.../src/components/comments/SubComment.tsx | 8 ++
.../post/SelectionShareBar.spec.tsx | 58 ++++++++++++-
.../src/components/post/SelectionShareBar.tsx | 37 +++++++--
packages/shared/src/hooks/post/useComments.ts | 12 ++-
.../components/SelectionShareBar.stories.tsx | 82 +++++++++++++++++++
7 files changed, 219 insertions(+), 16 deletions(-)
diff --git a/packages/shared/src/components/comments/CommentContainer.tsx b/packages/shared/src/components/comments/CommentContainer.tsx
index 03ea9d7eb3b..614081b16e8 100644
--- a/packages/shared/src/components/comments/CommentContainer.tsx
+++ b/packages/shared/src/components/comments/CommentContainer.tsx
@@ -1,11 +1,12 @@
import classNames from 'classnames';
import type { ReactElement, ReactNode } from 'react';
-import React from 'react';
+import React, { useRef } from 'react';
import Link from '../utilities/Link';
import type { Comment } from '../../graphql/comments';
import { getCommentHash } from '../../graphql/comments';
import type { Post } from '../../graphql/posts';
import Markdown from '../Markdown';
+import { SelectionShareBar } from '../post/SelectionShareBar';
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 = useRef(null);
const isCommentReferenced = commentHash === getCommentHash(comment.id);
const { author } = comment;
const companies = author?.companies;
@@ -174,12 +183,21 @@ export default function CommentContainer({
linkToComment && 'pointer-events-none',
)}
>
-
+
+
+
+
-
{actions}
diff --git a/packages/shared/src/components/comments/MainComment.tsx b/packages/shared/src/components/comments/MainComment.tsx
index 6c084772fe6..f4d4d08e976 100644
--- a/packages/shared/src/components/comments/MainComment.tsx
+++ b/packages/shared/src/components/comments/MainComment.tsx
@@ -170,6 +170,14 @@ export default function MainComment({
commentId: selected.id,
})
}
+ onQuote={(quote) =>
+ onReplyTo({
+ username: comment.author?.username ?? null,
+ parentCommentId: comment.id,
+ commentId: comment.id,
+ quote,
+ })
+ }
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 d59ccaa0cf3..743b75750e9 100644
--- a/packages/shared/src/components/comments/SubComment.tsx
+++ b/packages/shared/src/components/comments/SubComment.tsx
@@ -75,6 +75,14 @@ function SubComment({
commentId: selected.id,
})
}
+ onQuote={(quote) =>
+ onReplyTo({
+ username: comment.author?.username ?? null,
+ parentCommentId: parentComment.id,
+ commentId: comment.id,
+ quote,
+ })
+ }
isModalThread={isModalThread}
>
{!isModalThread && (
diff --git a/packages/shared/src/components/post/SelectionShareBar.spec.tsx b/packages/shared/src/components/post/SelectionShareBar.spec.tsx
index eee341f401e..bd0dde90456 100644
--- a/packages/shared/src/components/post/SelectionShareBar.spec.tsx
+++ b/packages/shared/src/components/post/SelectionShareBar.spec.tsx
@@ -1,3 +1,4 @@
+import type { ComponentProps } from 'react';
import React from 'react';
import type { RenderResult } from '@testing-library/react';
import {
@@ -15,12 +16,24 @@ import { useTextSelectionShare } from '../../hooks/useTextSelectionShare';
import { shouldUseNativeShare } from '../../lib/func';
import { TOAST_NOTIF_KEY } from '../../hooks/useToastNotification';
import type { Post } from '../../graphql/posts';
+import type { Comment } from '../../graphql/comments';
jest.mock('../../hooks/useTextSelectionShare', () => ({
__esModule: true,
useTextSelectionShare: jest.fn(),
}));
+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() };
@@ -54,8 +67,15 @@ beforeEach(() => {
});
});
+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 renderComponent = (
gb = enabledGrowthBook(),
+ props: Partial> = {},
): RenderResult & { client: QueryClient } => {
const client = new QueryClient();
const containerRef = { current: document.createElement('div') };
@@ -65,7 +85,12 @@ const renderComponent = (
client,
...render(
-
+ ,
),
};
@@ -161,3 +186,34 @@ describe('SelectionShareBar actions', () => {
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('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('never quotes a comment into the post composer', () => {
+ renderComponent(undefined, { comment });
+
+ fireEvent.click(screen.getByLabelText('Quote in a comment'));
+
+ expect(mockReplace).not.toHaveBeenCalled();
+ });
+});
diff --git a/packages/shared/src/components/post/SelectionShareBar.tsx b/packages/shared/src/components/post/SelectionShareBar.tsx
index 788db5772cc..ea6b936f81e 100644
--- a/packages/shared/src/components/post/SelectionShareBar.tsx
+++ b/packages/shared/src/components/post/SelectionShareBar.tsx
@@ -3,6 +3,7 @@ import React, { useCallback, useLayoutEffect, useRef, useState } from 'react';
import classNames from 'classnames';
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 { CopyIcon, DiscussIcon, LinkIcon, ShareIcon, VIcon } from '../icons';
import { Tooltip } from '../tooltip/Tooltip';
@@ -23,8 +24,14 @@ import { webappUrl } from '../../lib/constants';
export interface SelectionShareBarProps {
post: Post;
- /** The post body. Only selections made inside it raise the bar. */
+ /** The content the bar is bound to. Only selections inside it raise it. */
containerRef: RefObject;
+ /**
+ * Set when the bound content is a comment or reply rather than the post
+ * body. 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;
/**
* 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.
@@ -105,6 +112,7 @@ const CopyFeedbackIcon = ({
export function SelectionShareBar({
post,
containerRef,
+ comment,
onQuote,
}: SelectionShareBarProps): ReactElement | null {
const { text, rect, clear } = useTextSelectionShare({ containerRef });
@@ -119,8 +127,9 @@ export function SelectionShareBar({
const { logEvent } = useLogContext();
const postLogEvent = usePostLogEvent();
+ const shareLink = comment?.permalink ?? post.commentsPermalink;
const [isLinkCopied, shareOrCopyLink] = useShareOrCopyLink({
- link: post.commentsPermalink,
+ link: shareLink,
text: text ?? post.title ?? '',
cid: ReferralCampaignKey.SharePost,
});
@@ -134,12 +143,20 @@ export function SelectionShareBar({
const logShare = useCallback(
(provider: ShareProvider) => {
logEvent(
- postLogEvent(LogEvent.SharePost, post, {
- extra: { provider, origin: Origin.TextSelection },
- }),
+ postLogEvent(
+ comment ? LogEvent.ShareComment : LogEvent.SharePost,
+ post,
+ {
+ extra: {
+ provider,
+ origin: Origin.TextSelection,
+ ...(comment && { commentId: comment.id }),
+ },
+ },
+ ),
);
},
- [logEvent, post, postLogEvent],
+ [comment, logEvent, post, postLogEvent],
);
useLayoutEffect(() => {
@@ -229,6 +246,12 @@ export function SelectionShareBar({
return;
}
+ if (comment) {
+ // A comment with no reply handler can still copy and share; silently
+ // quoting it into the post composer would misattribute it.
+ return;
+ }
+
router.replace(
{
pathname: router.pathname,
@@ -310,7 +333,7 @@ export function SelectionShareBar({
cid={ReferralCampaignKey.SharePost}
icon={}
label="Share"
- link={post.commentsPermalink}
+ link={shareLink}
onOpenChange={setIsShareOpen}
onShare={logShare}
text={text}
diff --git a/packages/shared/src/hooks/post/useComments.ts b/packages/shared/src/hooks/post/useComments.ts
index cd7b6db9a1e..c100cd6df4c 100644
--- a/packages/shared/src/hooks/post/useComments.ts
+++ b/packages/shared/src/hooks/post/useComments.ts
@@ -11,6 +11,11 @@ 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;
}
interface UseComments extends CommentWrite {
@@ -33,12 +38,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]);
diff --git a/packages/storybook/stories/components/SelectionShareBar.stories.tsx b/packages/storybook/stories/components/SelectionShareBar.stories.tsx
index ee54b881e97..8e938fd0af8 100644
--- a/packages/storybook/stories/components/SelectionShareBar.stories.tsx
+++ b/packages/storybook/stories/components/SelectionShareBar.stories.tsx
@@ -13,6 +13,7 @@ import {
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 = {
@@ -889,3 +890,84 @@ export const Dismissal: Story = {
),
};
+
+// -- 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 contentRef = 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.
+
+ );
+};
+
+/**
+ * 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}
+
+ ),
+};
From 59ca964cae2df97e218ef2202d5293d268021868 Mon Sep 17 00:00:00 2001
From: Tsahi Matsliah
Date: Mon, 27 Jul 2026 16:10:09 +0300
Subject: [PATCH 15/24] fix(share): drop the popover title and even out its
padding
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The heading was redundant — the popover only opens from a share control.
Removing it also removes the asymmetry it created at the top.
The grid is now four fixed columns sized to its content. Wrapping a flex row
inside w-80 left 8px of slack that justify-center split between the sides, so
the left and right padding read wider than the top and bottom. A trailing
'Share via...' alone on its row still centres across all four columns.
Co-Authored-By: Claude Opus 5
---
packages/shared/src/components/share/ShareActions.tsx | 10 +++++-----
1 file changed, 5 insertions(+), 5 deletions(-)
diff --git a/packages/shared/src/components/share/ShareActions.tsx b/packages/shared/src/components/share/ShareActions.tsx
index 792f0171c05..ff71840c8e0 100644
--- a/packages/shared/src/components/share/ShareActions.tsx
+++ b/packages/shared/src/components/share/ShareActions.tsx
@@ -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';
@@ -173,12 +172,13 @@ export function ShareActions({
// recomputes on scroll/resize of ancestors and leaves the popover
// parked at a stale position until the next event.
updatePositionStrategy="always"
- 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"
+ // A fixed four-column grid sized to its content, so the padding is the
+ // same on every side. Wrapping a flex row inside a fixed width left
+ // slack that `justify-center` split between the sides, making them read
+ // wider than the top and bottom.
+ 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 [&>*:last-child:nth-child(4n+1)]:col-span-4 [&>*:last-child:nth-child(4n+1)]:justify-self-center"
{...hoverProps}
>
-
- Share
-
{list}
From b5455d3427561c3cd4c9060dcc74da6329d10188 Mon Sep 17 00:00:00 2001
From: Tsahi Matsliah
Date: Mon, 27 Jul 2026 16:22:23 +0300
Subject: [PATCH 16/24] chore(storybook): story for the share popover, opened
on load
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Makes the popover layout reviewable without hovering, and gives the
storybook build something under packages/storybook to trigger on — the
Ignored Build Step skips it otherwise.
Co-Authored-By: Claude Opus 5
---
.../components/ShareActions.stories.tsx | 24 +++++++++++++++++++
1 file changed, 24 insertions(+)
diff --git a/packages/storybook/stories/components/ShareActions.stories.tsx b/packages/storybook/stories/components/ShareActions.stories.tsx
index 0857b12c550..ffcf02f4dea 100644
--- a/packages/storybook/stories/components/ShareActions.stories.tsx
+++ b/packages/storybook/stories/components/ShareActions.stories.tsx
@@ -111,3 +111,27 @@ 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, so the padding is identical on every
+ * side, 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();
+ },
+};
From d0f03ccb1075208f0a83e77721dc349f478ed6b2 Mon Sep 17 00:00:00 2001
From: Tsahi Matsliah
Date: Mon, 27 Jul 2026 16:32:07 +0300
Subject: [PATCH 17/24] refactor(share): left-align the popover grid, reuse
CopyStateIcon
The share popover's last row now stays left-aligned with the grid above it
instead of re-centring.
Replaces the copy-confirmation icon this branch had grown with CopyStateIcon
from PR 6369, taken verbatim apart from an optional idleIcon so the
selection bar can show a link glyph. Both branches touch ShareActions, so
one shared implementation keeps that merge small.
Co-Authored-By: Claude Opus 5
---
.../src/components/post/SelectionShareBar.tsx | 44 ++--------------
.../src/components/share/CopyStateIcon.tsx | 52 +++++++++++++++++++
.../src/components/share/ShareActions.tsx | 11 ++--
3 files changed, 62 insertions(+), 45 deletions(-)
create mode 100644 packages/shared/src/components/share/CopyStateIcon.tsx
diff --git a/packages/shared/src/components/post/SelectionShareBar.tsx b/packages/shared/src/components/post/SelectionShareBar.tsx
index ea6b936f81e..6a536d33004 100644
--- a/packages/shared/src/components/post/SelectionShareBar.tsx
+++ b/packages/shared/src/components/post/SelectionShareBar.tsx
@@ -1,14 +1,14 @@
import type { ReactElement, RefObject } from 'react';
import React, { useCallback, useLayoutEffect, useRef, useState } from 'react';
-import classNames from 'classnames';
import { useRouter } from 'next/router';
import type { Post } from '../../graphql/posts';
import type { Comment } from '../../graphql/comments';
import { Button, ButtonSize, ButtonVariant } from '../buttons/Button';
-import { CopyIcon, DiscussIcon, LinkIcon, ShareIcon, VIcon } from '../icons';
+import { DiscussIcon, LinkIcon, ShareIcon } from '../icons';
import { Tooltip } from '../tooltip/Tooltip';
import { RootPortal } from '../tooltips/Portal';
import { ShareActions } from '../share/ShareActions';
+import { CopyStateIcon } from '../share/CopyStateIcon';
import { useCopyText } from '../../hooks/useCopy';
import { useShareOrCopyLink } from '../../hooks/useShareOrCopyLink';
import { useTextSelectionShare } from '../../hooks/useTextSelectionShare';
@@ -73,38 +73,6 @@ export const buildQuoteImageUrl = (postId: string, text: string): string => {
)}`;
};
-// Copy actions confirm twice: the toast says what happened, and the button
-// itself swaps to a check. `useCopyLink`/`useCopyText` hold `copying` for a
-// second, which is the whole life of this transition. Both icons are stacked in
-// one grid cell so the button never changes width mid-swap.
-const CopyFeedbackIcon = ({
- copied,
- icon,
-}: {
- copied: boolean;
- icon: ReactElement;
-}): ReactElement => (
-
-
- {icon}
-
-
-
-
-
-);
-
/**
* Floating share bar for text selected inside a post body. Ships to everyone —
* there is no flag gate.
@@ -299,9 +267,7 @@ export function SelectionShareBar({
} />
- }
+ icon={}
onClick={onCopyLink}
size={ButtonSize.Small}
variant={ButtonVariant.Tertiary}
@@ -311,9 +277,7 @@ export function SelectionShareBar({
} />
- }
+ icon={}
onClick={onCopyText}
size={ButtonSize.Small}
variant={ButtonVariant.Tertiary}
diff --git a/packages/shared/src/components/share/CopyStateIcon.tsx b/packages/shared/src/components/share/CopyStateIcon.tsx
new file mode 100644
index 00000000000..7a7db3b45e3
--- /dev/null
+++ b/packages/shared/src/components/share/CopyStateIcon.tsx
@@ -0,0 +1,52 @@
+import type { ReactElement } from 'react';
+import React from 'react';
+import classNames from 'classnames';
+import { CopyIcon, VIcon } from '../icons';
+import type { IconProps } from '../Icon';
+
+/**
+ * easeOutExpo — the curve the design-system dropdown animates on. It
+ * decelerates into the target with no overshoot, which is what keeps a swap
+ * from reading as a wobble.
+ */
+export const EASE_OUT_EXPO = 'ease-[cubic-bezier(0.16,1,0.3,1)]';
+
+/**
+ * A copy is a rare, deliberate moment, so the confirmation earns real motion.
+ * Both glyphs share one grid cell so the label never shifts mid-swap, and the
+ * transition collapses to an instant swap under `prefers-reduced-motion`.
+ */
+export const CopyStateIcon = ({
+ copied,
+ className,
+ idleIcon: IdleIcon = CopyIcon,
+ ...props
+}: IconProps & {
+ copied: boolean;
+ /** Glyph shown before the copy lands. Defaults to the copy icon. */
+ idleIcon?: (iconProps: IconProps) => 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 ff71840c8e0..541c956e648 100644
--- a/packages/shared/src/components/share/ShareActions.tsx
+++ b/packages/shared/src/components/share/ShareActions.tsx
@@ -172,11 +172,12 @@ export function ShareActions({
// recomputes on scroll/resize of ancestors and leaves the popover
// parked at a stale position until the next event.
updatePositionStrategy="always"
- // A fixed four-column grid sized to its content, so the padding is the
- // same on every side. Wrapping a flex row inside a fixed width left
- // slack that `justify-center` split between the sides, making them read
- // wider than the top and bottom.
- 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 [&>*:last-child:nth-child(4n+1)]:col-span-4 [&>*:last-child:nth-child(4n+1)]:justify-self-center"
+ // 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}
>
{list}
From d9f80333069e99e85ae93a9c7a2294a51c5b7ab8 Mon Sep 17 00:00:00 2001
From: Tsahi Matsliah
Date: Mon, 27 Jul 2026 16:33:46 +0300
Subject: [PATCH 18/24] docs(storybook): describe the popover's left-aligned
grid
Co-Authored-By: Claude Opus 5
---
.../storybook/stories/components/ShareActions.stories.tsx | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/packages/storybook/stories/components/ShareActions.stories.tsx b/packages/storybook/stories/components/ShareActions.stories.tsx
index ffcf02f4dea..7dfecbbb5d8 100644
--- a/packages/storybook/stories/components/ShareActions.stories.tsx
+++ b/packages/storybook/stories/components/ShareActions.stories.tsx
@@ -114,8 +114,9 @@ export const Inline: Story = {
/**
* The popover, opened on load so its layout can be reviewed directly. Four
- * fixed columns sized to their content, so the padding is identical on every
- * side, and no heading — the popover only ever opens from a share control.
+ * 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' },
From 56e23547e02770a7ab64777ac430963753912545 Mon Sep 17 00:00:00 2001
From: Tsahi Matsliah
Date: Mon, 27 Jul 2026 16:53:59 +0300
Subject: [PATCH 19/24] feat(share): copied text carries a reference link
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Copied prose travels — into a doc, a DM, a slide — and arrives with no idea
where it came from. Copy text now appends the link, routed through
getShortUrl with the share campaign so it earns the same referral credit a
plain copy-link would. On a comment the reference is that comment's
permalink, so the quote points at who actually said it.
Co-Authored-By: Claude Opus 5
---
.../post/SelectionShareBar.spec.tsx | 20 ++++++++++++++++++-
.../src/components/post/SelectionShareBar.tsx | 20 +++++++++++++++++--
2 files changed, 37 insertions(+), 3 deletions(-)
diff --git a/packages/shared/src/components/post/SelectionShareBar.spec.tsx b/packages/shared/src/components/post/SelectionShareBar.spec.tsx
index bd0dde90456..9a1d05aeeee 100644
--- a/packages/shared/src/components/post/SelectionShareBar.spec.tsx
+++ b/packages/shared/src/components/post/SelectionShareBar.spec.tsx
@@ -155,7 +155,11 @@ describe('SelectionShareBar actions', () => {
fireEvent.click(screen.getByLabelText('Copy selected text'));
});
- await waitFor(() => expect(writeText).toHaveBeenCalledWith(selection));
+ await waitFor(() =>
+ expect(writeText).toHaveBeenCalledWith(
+ `${selection}\n\n${post.commentsPermalink}`,
+ ),
+ );
expect(client.getQueryData(TOAST_NOTIF_KEY)).toMatchObject({
message: '✅ Copied text to clipboard',
});
@@ -200,6 +204,20 @@ describe('SelectionShareBar on a comment', () => {
);
});
+ 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 });
diff --git a/packages/shared/src/components/post/SelectionShareBar.tsx b/packages/shared/src/components/post/SelectionShareBar.tsx
index 6a536d33004..9fdb5c1f7fd 100644
--- a/packages/shared/src/components/post/SelectionShareBar.tsx
+++ b/packages/shared/src/components/post/SelectionShareBar.tsx
@@ -11,6 +11,7 @@ import { ShareActions } from '../share/ShareActions';
import { CopyStateIcon } from '../share/CopyStateIcon';
import { useCopyText } from '../../hooks/useCopy';
import { useShareOrCopyLink } from '../../hooks/useShareOrCopyLink';
+import { useGetShortUrl } from '../../hooks/utils/useGetShortUrl';
import { useTextSelectionShare } from '../../hooks/useTextSelectionShare';
import { useOutsideClick } from '../../hooks/utils/useOutsideClick';
import { useEventListener } from '../../hooks/useEventListener';
@@ -102,6 +103,7 @@ export function SelectionShareBar({
cid: ReferralCampaignKey.SharePost,
});
const [isTextCopied, copyText] = useCopyText();
+ const { getShortUrl } = useGetShortUrl();
const dismiss = useCallback(() => {
globalThis?.window?.getSelection?.()?.removeAllRanges();
@@ -194,9 +196,23 @@ export function SelectionShareBar({
shareOrCopyLink();
};
- const onCopyText = () => {
+ // Copied prose travels — into a doc, a DM, a slide — and arrives with no idea
+ // where it came from. Appending the link keeps the quote attributable, and
+ // routing it through `getShortUrl` gives it the same referral credit a plain
+ // copy-link would earn. On a comment the reference is that comment, not the
+ // post, so the quote points at who actually said it.
+ const onCopyText = async () => {
logShare(ShareProvider.CopyText);
- copyText({ textToCopy: text, message: '✅ Copied text to clipboard' });
+
+ const reference = await getShortUrl(
+ shareLink,
+ ReferralCampaignKey.SharePost,
+ );
+
+ copyText({
+ textToCopy: `${text}\n\n${reference}`,
+ message: '✅ Copied text to clipboard',
+ });
};
// The composer is the one action that consumes the selection rather than
From 57b61512b943349662de069d306156ab96d75ec0 Mon Sep 17 00:00:00 2001
From: Tsahi Matsliah
Date: Tue, 28 Jul 2026 10:08:59 +0300
Subject: [PATCH 20/24] feat(share): raise the selection bar on Happening Now
highlights
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
/highlights showed readable prose with no way to quote it. The expanded
summary is now bound to the bar.
The headline is deliberately left out: it lives inside the expand/collapse
button, where a drag toggles the row instead of selecting text, so binding
to it would raise nothing.
The list query only fetched id/type/commentsPermalink, which was enough to
render but would have made every share from here a hollow log event. It now
also fetches the fields postLogEvent reads — still short of a full Post,
since this is a list query.
Co-Authored-By: Claude Opus 5
---
.../highlights/HighlightItem.spec.tsx | 32 +++++++++++++++++--
.../components/highlights/HighlightItem.tsx | 12 ++++++-
.../src/components/post/PostSelectionArea.tsx | 2 +-
packages/shared/src/graphql/highlights.ts | 28 ++++++++++++++++
4 files changed, 70 insertions(+), 4 deletions(-)
diff --git a/packages/shared/src/components/highlights/HighlightItem.spec.tsx b/packages/shared/src/components/highlights/HighlightItem.spec.tsx
index 5f1e186989a..994f58b35b1 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 4256c618b38..226fcdbe241 100644
--- a/packages/shared/src/components/highlights/HighlightItem.tsx
+++ b/packages/shared/src/components/highlights/HighlightItem.tsx
@@ -3,7 +3,9 @@ import React, { useEffect, useMemo, useRef, useState } from 'react';
import classNames from 'classnames';
import type { PostHighlightFeed } from '../../graphql/highlights';
import { stripHtmlTags } from '../../lib/strings';
+import type { Post } from '../../graphql/posts';
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 +82,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.
+ */}
+
+