import React from 'react' import { LinkPreviewsManager, LinkPreviewsManagerState } from 'stream-chat' import { useMessageComposer, useStateStore } from 'stream-chat-react' import LinkAttachment from '../LinkAttachment' const linkPreviewsManagerStateSelector = (state: LinkPreviewsManagerState) => ({ linkPreviews: Array.from(state.previews.values()).filter( (preview) => LinkPreviewsManager.previewIsLoaded(preview) || LinkPreviewsManager.previewIsLoading(preview) ), }) /** * The composer's staged link previews, rendered as `LinkAttachment.Composer` * cards. * * **The cards are deliberately dismiss-only — they do not navigate.** * `LinkAttachment.Composer` is the one card variant whose shell is never an * anchor or a button, and the hand-rolled card this replaced wrapped its whole * body in `` with the dismiss button * calling `preventDefault()` inside it. That shape is a touch hazard — * MES-1358 §7 Q7 raised it and deferred the call to MES-1349 — because a * mis-tap while composing opens a new tab mid-draft, and the dismiss button's * 44px hit halo (`Composer/Card.tsx`, `before:-inset-[10px]`) sits *inside* * that anchor, so enlarging the target for touch enlarges the navigation * trigger with it. A staged preview is a draft affordance rather than * content — its URL is still in the textarea the author just typed it into, * one tap away without leaving the thread. * * Nothing depended on the old behaviour: no test, story or analytics asserted * it here or in frontyard's parallel `ComposerLinkPreviewList`, whose own * tests cover text, dismiss and layout only. If a surface ever genuinely needs * activation, add an explicit prop to `ComposerCardProps` — do not re-wrap the * card in an anchor. (MES-1349) */ export const CustomLinkPreviewList = () => { const { linkPreviewsManager } = useMessageComposer() const { linkPreviews: stateLinkPreviews } = useStateStore( linkPreviewsManager.state, linkPreviewsManagerStateSelector ) const handleDismiss = (url: string) => { linkPreviewsManager.dismissPreview(url) } const showLinkPreviews = stateLinkPreviews.length > 0 if (!showLinkPreviews) return null return ( // Layout contract: this list is NOT a scrollable or shrinkable region. It // used to be the composer's one squeezable box, giving way (min-h-0) and // scrolling on its own so the textarea row could stay pinned above the // keyboard. That cropped the preview to make room for the input, which read // as the preview being cut off; MES-1037 inverted it. The composer card // (`.central-container`) is now the single capped scroll container and this // list scrolls as one piece with the textarea inside it, so the preview // always keeps its full height and only the visible slice of it changes. // Do not reintroduce a nested scroller or a min-h-0 here. // // Horizontally the card does NOT shrink, deliberately. `CardShell` bakes // Figma's `w-[290px]` and there is exactly one width, in the toolkit // (MES-1349 and MES-1358 agreed consumers add no competing cap). Do not // "fix" a narrow viewport with `max-w-full` here or on the shell: the box is // `flex h-[250px] flex-col` with a `flex-1` hero and a title clamped to two // lines against a known width, so capping deforms it on every narrow // surface, in-thread Sent/Received included. // // Measured against the real class chain in headless Chromium: // // chrome = 32px (`CustomMessageInput`'s outer `p-4`) // + 16px (this region's `px-2`) = 48px // card = 290px -> floor = 290 + 48 = 338px viewport // // viewport 320 360 375 393 430 // available 272 312 327 345 382 // delta -18 +22 +37 +55 +92 // // 393px is the design frame, and its 345px reproduces the frame's measured // `attachment-area` width, so the chrome arithmetic is corroborated. The // lowest named breakpoint is `xs: 359px` // (`@repo/tailwind-config/transitional-tokens.js:29-36`), which is *above* // the 338px floor — the card fits at every width the scale names. MES-1358's // `max-md:p-3` trims chrome 48px -> 40px and moves the floor to 330px, still // above 320px, so capping `CardShell` would be the only way to reach a 320px // viewport. That is the whole trade: deform every narrow surface, or leave // one sub-floor device class awkward. // // It is awkward, not broken. The card above sets only `overflow-y`, which // promotes the other axis, so its `overflow-x` computes to `auto` and it // scrolls sideways instead of spilling; at 320px the dismiss button's right // edge lands ~6px past the fold, reachable by scrolling. That residue // belongs to whoever owns the mobile layout contract, not to this component.
{stateLinkPreviews.map((linkPreview) => { // A LOADING preview has no `image_url` yet, so keying the layout off // the image alone rendered the skeleton as two bare text bars and then // snapped it open to the 250px featured box the instant the scrape // returned. Hold the hero's space for the duration instead — reserving // that space is what the skeleton is for. const isLoading = LinkPreviewsManager.previewIsLoading(linkPreview) return ( handleDismiss(linkPreview.og_scrape_url)} /> ) })}
) }