feat(share): floating share bar for text selected in posts and comments - #6352
feat(share): floating share bar for text selected in posts and comments#6352tsahimatsliah wants to merge 24 commits into
Conversation
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 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Opening the raw /image-generator/quote page landed users on a bare 1200x630 bitmap template with no download, share or back affordance. The route, QuoteImageCard and the URL builder stay for the follow-up that previews and shares a real PNG once the screenshot service serves the route. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Stories now fake the selection on load so every state is visible without dragging a cursor: placement (above/flipped/clamped/scroll-follow), mobile, selection shapes, the three host surfaces, the ignored cases, dismissal and flag-off. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
animate-composer-in animates transform with animation-fill-mode: both, so its final translateY(0) permanently overrode the inline translate(-50%, -100%). The bar rendered un-centred and on top of the selection instead of above it. Split anchoring and reveal onto separate elements. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Users are rounded squares and sources are circles, matching ProfilePicture and SourceAvatar. The avatar is sized against the attribution text beside it rather than the card, so it reads as part of that strip instead of dominating it. The quote mark takes the brand (cabbage) accent. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Coverage: the bar reached only PostContent, SquadPostContent and PostFocusCard, so Collection, Poll, Brief and social/Twitter posts never raised it — on the page or in the modal. All seven surfaces are wired now. Scope: each surface bound the bar to its outer column, which via BasePostContent also contains the whole comment section. Selecting a reply raised the bar and quoting it attributed a commenter's words to the post. PostSelectionArea now wraps title, TL;DR and body only. Its wrapper is display:contents, so it adds a node to the DOM tree — all Node.contains needs — without adding a box that could disturb flex or grid children. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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 <noreply@anthropic.com>
|
|
… 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
- 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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 <noreply@anthropic.com>
/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 <noreply@anthropic.com>
Quote hands the selection to a comment composer, so on a surface without one it was a dead button. Briefings and the highlights list have no comment section — BriefPostContent passes no engagementProps, and /highlights is a list — so both now render the bar without it. Same bug on the reply modal's quoted comment: CommentContainer there gets no onQuote, so the button did nothing. A comment now offers Quote only when its reply composer is actually wired, which is structural rather than a silent no-op inside the handler. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three things the branch outgrew and nothing consumed: - buildQuoteImageUrl and MAX_QUOTE_LENGTH, exported but never called since the quote-image action was dropped from the bar. - ShareProvider.QuoteImage, an enum member no call site ever passed. - useTextSelectionShare's `enabled` option, which existed so a disabled experiment attached no listeners. There is no experiment now, and no caller passed it; its test went with it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three ways the bar disagreed with existing share tracking:
- A comment share used ReferralCampaignKey.SharePost, so its short link
earned post-share credit. useShareComment uses ShareComment; the campaign
now follows what is actually being shared.
- Copy link always logged provider 'copy link', even where
shareOrCopyLink hands off to the native sheet, so every mobile share was
logged as a copy. Resolved the way ShareActions resolves it.
- Quoting a comment logged origin 'post comment button', because
useComments hardcoded it. ReplyTo now carries an origin and the quote
action passes TextSelection.
Payload shapes were already right: SharePost sends { provider, origin } and
ShareComment sends { provider, origin, commentId }, matching
useLoggedCopyPostLink and useShareComment key for key. Three tests pin that.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
rebelchris
left a comment
There was a problem hiding this comment.
@claude please review this, some hooks seem heavily bloated?
| // `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' }); |
There was a problem hiding this comment.
We'll have to monitor this doesn't make bad ux for people
|
Claude finished @rebelchris's task in 6m 20s —— View job Review — selection share barAnswering the question first: 1. One full hook stack per comment (the real cost)
On a 150-comment thread that's ~750 document/window listeners and ~450 react-query observers, all for one bar that can only ever be visible once. The hot path is worse than the count suggests:
2.
|
capJavert
left a comment
There was a problem hiding this comment.
Code review complete. Blocking issues remain in the current diff:
CommentContainermounts a fullSelectionShareBarfor every comment. Each instance installs document/window listeners and URL-shortening query observers, so long threads scale this work with comment count. Please move selection ownership to one page/discussion-level bar and resolve the selected comment from the nearest selection-area element.onCopyTextawaitsgetShortUrlbefore callingnavigator.clipboard.writeText. Safari/iOS requires the clipboard write to begin within the user-activation task, so this path can fail withNotAllowedError. Use the existing ClipboardItem-with-promise pattern or pre-resolve the URL.- The
?comment=effect opens the composer without the normal anonymous-user login gate, so logged-out users can enter a quote flow they cannot submit.
The branch has no merge conflict, but GitHub currently marks it unstable because the CLA check is unresolved.
Addresses the blocking review points on #6352. 1. CommentContainer mounted a whole SelectionShareBar per comment — four document listeners, a window click listener, a visualViewport listener and three short-URL query observers each. A 150-comment thread paid all of that 150 times for a bar that can only ever be visible once. SelectionShareProvider now owns one useTextSelectionShare and renders the single bar. Regions register an element and a target getter, and the hook resolves the innermost registered ancestor of the selection, so a comment nested in a post attributes to the comment. Registration is the whole cost per region. useTextSelectionShare takes a resolveArea callback instead of a containerRef, and resolves the region before calling selection.toString(), which is O(selection length). 2. onCopyText awaited getShortUrl before writing to the clipboard, which WebKit rejects once the gesture's activation has lapsed. The short URL now resolves through useGetShortUrl's query mode while the bar is open, so the click handler is synchronous. 3. The ?comment= effect opened a composer for logged-out readers who then could not post. It now calls showLogin and leaves the param in place, so the draft survives the round-trip. Also from the review: - Anchoring math extracted to useSelectionAnchor, which reads the visualViewport pan offset in the same layout effect that measures the bar instead of waiting for the first scroll — a reader who pinch-panned before selecting was clamped against a stale origin. The bar stays hidden until measured, so a clamped bar no longer jumps on its first frame. - Quote text is capped before going into the URL. - SelectionSharePost types what the bar actually reads, so the highlights double-cast is gone. - Deleted the parked quote-image feature: QuoteImageCard, its ISR route, its story and its spec. It was unreachable and CLAUDE.md says not to keep code for later; it belongs to the follow-up that ships it. - keyup only re-reads the selection for keys that can move it, so typing in the composer no longer runs getSelection().toString() per keystroke. - MIN_SELECTION_LENGTH comment now says characters, which is what it counts. - buildCommentQuote and the URL cap moved to lib/strings. - data-selection-area is now load-bearing rather than decorative. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Thanks both — the per-comment hook stack was the right thing to lead with, and the Safari clipboard one would have shipped broken. All three blocking items are fixed in Blocking1. One watcher per page, not one per comment.
Providers mount at three roots: 2. Safari clipboard. 3. Logged-out quote flow. The Also fixed
Two I pushed back on§9, the description. You're right that it was wrong, and I've corrected the description rather than the code. §Nit, Verification
Two notes on the suite, neither caused by this PR: @rebelchris on the One thing still open, unrelated to this review: the base is |
capJavert
left a comment
There was a problem hiding this comment.
Re-reviewed the latest refactor. The per-comment listener/query fan-out, Safari clipboard timing, and logged-out quote flow are addressed. I found no remaining code blocker. Approved; the unresolved CLA check still blocks merge.
Stacks on PR 1 (#6343) — base branch is
claude/website-sharing-visibility-be6b32, notmain.What changed
Selecting text inside a post body, a comment, or a Happening Now summary raises a portaled floating bar anchored to the selection.
Ships to everyone — there is no feature flag and no experiment.
Actions
getShortUrlso a copied quote carries the same referral credit as a plain copy-link. Same check animation.@mention.ShareActionspopover with the full social row.Quoteis only rendered where a composer exists to receive it — hidden on briefings and Happening Now, and on a comment whose reply composer is not wired.Where it appears
PostSelectionAreabinds the bar to a surface's readable content only — title, TL;DR and body. Its wrapper isdisplay: contents, so it adds a node forNode.containswithout adding a layout box.PostContentSquadPostContentPostFocusCardCollectionPostContentPollPostContentSocialTwitterPostContentBriefPostContentCommentContainerHighlightItemPage and modal resolve to the same components (
CONTENT_MAPandgetPostModalRenderConfig), so there is no surface where one works and the other does not.Digest posts are excluded on purpose. A digest is a header plus an embedded feed of other posts, so a selection there would quote someone else's headline under the digest's link.
Nav, source strips, tags, metadata, action bars and the comment section are all outside the bound region — selecting a reply used to raise a bar that would quote a commenter as if they were the post author.
New files
hooks/useTextSelectionShare.tscomponents/post/SelectionShareBar.tsxcomponents/post/PostSelectionArea.tsxcomponents/share/CopyStateIcon.tsxcomponents/post/QuoteImageCard.tsxpages/image-generator/quote/[id].tsxid="screenshot_wrapper".QuoteImageCardand its route are parked: the bar offers no quote-image action until the screenshot service serves the PNG. They stay so the follow-up is a wiring change.Behaviour details
position: fixed, centred over the selection, flipping below when there is under 64px above it, clamped horizontally so it never leaves the viewport.visualViewportwidth andoffsetLeft— pinch-zoom pans the visual viewport without moving the layout viewport afixedelement sits in.animate-composer-inanimatestransformwithanimation-fill-mode: both, which otherwise overrides the anchoring translate permanently.<button type="button">with anaria-label; the bar isrole="toolbar".Also here
ShareActionspopover: heading removed, four fixed columns sized to content so padding is equal on all sides, short last row left-aligned. Affects the three other consumers of that popover.NewCommentaccepts a?comment=draft on any post type, not just welcome/poll, with an optional?commentOriginso the logged origin stays accurate.postLogEventreads, so shares from there are not hollow events.Storybook
28 stories under
Components/Share/SelectionShareBarcovering placement, clamping, mobile, selection shapes, all wired surfaces, the ignored cases, and dismissal. Each fakes a selection on mount, so every state is visible without dragging a cursor.Preview domain
https://claude-share-text-selection.preview.app.daily.dev