Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
0723672
feat(share): floating share bar for text selected in a post
tsahimatsliah Jul 22, 2026
95350b4
fix(share): drop the quote-image action until the service renders it
tsahimatsliah Jul 22, 2026
4f042eb
chore(storybook): full state matrix for SelectionShareBar
Tsahimatzliah Jul 26, 2026
ed2931a
fix(share): stop the reveal animation clobbering the anchor transform
Tsahimatzliah Jul 26, 2026
f2e21e4
chore(storybook): raise the selection on mount instead of via play
Tsahimatzliah Jul 26, 2026
1975763
feat(share): quote-in-comment and full social share on the selection bar
Tsahimatzliah Jul 26, 2026
ed30c4f
fix(share): shorten selection-bar tooltips to one or two words
Tsahimatzliah Jul 26, 2026
7e7fec6
fix(share): quote card avatars follow the app shape conventions
Tsahimatzliah Jul 26, 2026
9d2a65e
feat(share): scope the selection bar to post content, on every post type
Tsahimatzliah Jul 26, 2026
ed33a03
chore(storybook): cover every wired surface and the content-area scoping
Tsahimatzliah Jul 26, 2026
284f6eb
fix(storybook): ignored-comments and ignored-post-chrome selected the…
Tsahimatzliah Jul 26, 2026
cfc959e
feat(share): ship the selection bar unflagged
Tsahimatzliah Jul 26, 2026
8b4e622
fix(share): copy confirms with a check, quote scrolls to the composer
Tsahimatzliah Jul 27, 2026
2170337
feat(share): raise the selection bar on comments and replies
Tsahimatzliah Jul 27, 2026
59ca964
fix(share): drop the popover title and even out its padding
Tsahimatzliah Jul 27, 2026
b5455d3
chore(storybook): story for the share popover, opened on load
Tsahimatzliah Jul 27, 2026
d0f03cc
refactor(share): left-align the popover grid, reuse CopyStateIcon
Tsahimatzliah Jul 27, 2026
d9f8033
docs(storybook): describe the popover's left-aligned grid
Tsahimatzliah Jul 27, 2026
56e2354
feat(share): copied text carries a reference link
Tsahimatzliah Jul 27, 2026
57b6151
feat(share): raise the selection bar on Happening Now highlights
Tsahimatzliah Jul 28, 2026
e0e309e
fix(share): only offer Quote where a composer exists to receive it
Tsahimatzliah Jul 28, 2026
92f4fce
chore(share): drop leftovers from the dropped quote image and the flag
Tsahimatzliah Jul 28, 2026
a89221c
fix(share): align selection-bar analytics with the rest of the app
Tsahimatzliah Jul 28, 2026
a8951ed
refactor(share): one selection watcher per page, not one per comment
Tsahimatzliah Jul 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 18 additions & 6 deletions packages/shared/src/components/comments/CommentContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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({
Expand All @@ -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;
Expand Down Expand Up @@ -174,12 +183,15 @@ export default function CommentContainer({
linkToComment && 'pointer-events-none',
)}
>
<Markdown
className={className.markdown}
content={comment.contentHtml}
appendTooltipTo={appendTooltipTo}
/>
<ContentEmbeds embeds={comment.contentEmbeds} variant="comment" />
{/* `display: contents` — a node for `Node.contains`, no layout box. */}
<div className="contents" data-selection-area ref={contentRef}>
<Markdown
className={className.markdown}
content={comment.contentHtml}
appendTooltipTo={appendTooltipTo}
/>
<ContentEmbeds embeds={comment.contentEmbeds} variant="comment" />
</div>
{actions}
</div>
</article>
Expand Down
10 changes: 10 additions & 0 deletions packages/shared/src/components/comments/MainComment.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
LogEvent,
NotificationCtaPlacement,
NotificationPromptSource,
Origin,
TargetType,
} from '../../lib/log';
import type { CommentMarkdownInputProps } from '../fields/MarkdownInput/CommentMarkdownInput';
Expand Down Expand Up @@ -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 })
}
Expand Down
10 changes: 10 additions & 0 deletions packages/shared/src/components/comments/SubComment.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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 && (
Expand Down
32 changes: 30 additions & 2 deletions packages/shared/src/components/highlights/HighlightItem.spec.tsx
Original file line number Diff line number Diff line change
@@ -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.';
Expand All @@ -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(
<TestBootProvider client={new QueryClient()}>
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
<HighlightItem highlight={highlight} {...props} />
</TestBootProvider>,
);

beforeAll(() => {
Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', {
configurable: true,
Expand All @@ -32,11 +44,15 @@ beforeEach(() => {

describe('HighlightItem', () => {
it('should expand when the route-driven default changes after mount', () => {
const { rerender } = render(<HighlightItem highlight={highlight} />);
const { rerender } = renderItem();

expect(screen.queryByText(summary)).not.toBeInTheDocument();

rerender(<HighlightItem highlight={highlight} defaultExpanded />);
rerender(
<TestBootProvider client={new QueryClient()}>
<HighlightItem highlight={highlight} defaultExpanded />
</TestBootProvider>,
);

expect(screen.getByText(summary)).toBeInTheDocument();
expect(screen.getByRole('link', { name: /read more/i })).toHaveAttribute(
Expand All @@ -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 }),
);
});
});
11 changes: 10 additions & 1 deletion packages/shared/src/components/highlights/HighlightItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -80,7 +81,15 @@ export const HighlightItem = ({
</button>
{expanded && tldr && (
<div className="flex flex-col gap-3 px-4 pb-3">
<p className="text-text-secondary typo-markdown">{tldr}</p>
{/*
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.
*/}
<PostSelectionArea canQuote={false} post={highlight.post}>
<p className="text-text-secondary typo-markdown">{tldr}</p>
</PostSelectionArea>
<Link href={highlight.post.commentsPermalink}>
<a className="flex items-center gap-1 font-bold text-text-link typo-footnote hover:underline">
Read more
Expand Down
22 changes: 13 additions & 9 deletions packages/shared/src/components/highlights/HighlightsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
import { Tab, TabContainer } from '../tabs/TabContainer';
import { DigestCTA } from './DigestCTA';
import { HighlightItem } from './HighlightItem';
import { SelectionShareProvider } from '../post/SelectionShareProvider';

const MAJOR_HEADLINES_LABEL = 'Headlines';
const ALL_HIGHLIGHTS_LABEL = 'All';
Expand Down Expand Up @@ -74,15 +75,18 @@ const HighlightFeedList = ({
}

return (
<div className="flex flex-col">
{highlights.map((highlight) => (
<HighlightItem
key={highlight.id}
highlight={highlight}
defaultExpanded={highlight.id === expandedId}
/>
))}
</div>
// One watcher for the whole list, not one per row.
<SelectionShareProvider>
<div className="flex flex-col">
{highlights.map((highlight) => (
<HighlightItem
key={highlight.id}
highlight={highlight}
defaultExpanded={highlight.id === expandedId}
/>
))}
</div>
</SelectionShareProvider>
);
};

Expand Down
7 changes: 5 additions & 2 deletions packages/shared/src/components/post/BasePostContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import PostEngagements from './PostEngagements';
import type { BasePostContentProps } from './common';
import { PostHeaderActions } from './PostHeaderActions';
import { ButtonSize } from '../buttons/common';
import { SelectionShareProvider } from './SelectionShareProvider';

const Custom404 = dynamic(
() => import(/* webpackChunkName: "custom404" */ '../Custom404'),
Expand Down Expand Up @@ -47,7 +48,9 @@ export function BasePostContent({
}

return (
<>
// One selection watcher for the whole post: its content and its comments
// both register with this, and it renders the single bar between them.
<SelectionShareProvider>
{isPostPage && (
<GoBackHeaderMobile
className={classNames(className.header, '-mx-4 bg-background-subtle')}
Expand All @@ -70,6 +73,6 @@ export function BasePostContent({
shouldOnboardAuthor={shouldOnboardAuthor}
/>
)}
</>
</SelectionShareProvider>
);
}
43 changes: 34 additions & 9 deletions packages/shared/src/components/post/NewComment.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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' });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We'll have to monitor this doesn't make bad ux for people

input.focus({ preventScroll: true });
return;
}

Expand Down Expand Up @@ -117,26 +123,45 @@ function NewCommentComponent(
}, [isComposerOpen, onComposerOpenChange]);

useEffect(() => {
if (
!shouldHandleCommentQuery ||
!hasCommentQuery ||
(post.type !== PostType.Welcome && post.type !== PostType.Poll)
) {
if (!shouldHandleCommentQuery || !hasCommentQuery) {
return;
}

const { comment, ...query } = router.query;
const origin =
if (!user) {
// Opening a composer nobody can post from is worse than not opening one.
// The query param stays put, so the draft survives the login round-trip
// and this effect picks it up again once the user comes back.
showLogin({ trigger: AuthTriggers.NewComment });
return;
}

const { comment, commentOrigin, ...query } = router.query;
// Callers that know where the draft came from say so (the text-selection
// share bar does); otherwise fall back to the post type, as before.
const originByPostType =
post.type === PostType.Poll
? Origin.PollCommentButton
: Origin.SquadChecklist;
const origin = (commentOrigin as Origin) ?? originByPostType;

onShowComment(origin, comment as string);
// A draft handed over through the URL has no click behind it to scroll the
// page, so the composer would open unseen below the fold.
focusInputById(inputId);

router.replace({ pathname: router.pathname, query }, undefined, {
shallow: true,
});
}, [post, hasCommentQuery, onShowComment, router, shouldHandleCommentQuery]);
}, [
post,
hasCommentQuery,
inputId,
onShowComment,
router,
shouldHandleCommentQuery,
showLogin,
user,
]);

const onCommentClick = (origin: Origin) => {
if (!user) {
Expand Down
Loading
Loading