import { ArrowUpIcon } from '@phosphor-icons/react' import React, { useContext } from 'react' import { AttachmentPreviewList as DefaultAttachmentPreviewList, MessageInput, QuotedMessagePreview, TextareaComposer, useChannelStateContext, useComponentContext, useMessageComposerHasSendableData, useMessageInputContext, } from 'stream-chat-react' import { CustomLinkPreviewList } from '../CustomLinkPreviewList' /** * Carries the channel's `frozen` state down to CustomMessageInputInner, which * renders the composer as a read-only/disabled input. Using a context — rather * than closing the value into the `Input` component passed to Stream's * — keeps CustomMessageInputInner a stable reference, so the * value toggling re-renders the composer instead of unmounting and remounting * it (which would drop textarea focus and interrupt IME composition). */ const ComposerLockedContext = React.createContext(false) /** * Distance from the bottom (px) still treated as "at the latest message", used * to decide whether a growing composer should re-pin the thread to the bottom * (MES-1303). Matches stream-chat-react's own `scrolledUpThreshold` default * (200) so composer growth keeps the thread pinned under the same condition * Stream already auto-scrolls a new message — anything closer than one message * would let a user resting just off the bottom fall through and get covered. */ const NEAR_BOTTOM_THRESHOLD_PX = 200 /** * Read the composer's locked (frozen-channel) state from inside a custom * composer supplied via `composerInput`. Returns true when the channel is * frozen; mirror the default composer by rendering the textarea read-only and * disabling the send control. */ export const useComposerLocked = (): boolean => useContext(ComposerLockedContext) const DefaultSendButton: React.FC<{ sendMessage: () => void disabled?: boolean [key: string]: unknown }> = ({ sendMessage, disabled, ...rest }) => ( ) const CustomMessageInputInner: React.FC = () => { const disabled = useContext(ComposerLockedContext) const { handleSubmit } = useMessageInputContext() const hasSendableData = useMessageComposerHasSendableData() const isSendDisabled = disabled || !hasSendableData const { SendButton = DefaultSendButton, AttachmentPreviewList = DefaultAttachmentPreviewList, } = useComponentContext('CustomMessageInput') // Scroll the textarea back into view when the card shrinks under it. Browsers // reveal a newly focused element by themselves, and the caret while typing, so // neither needs help; the one thing nothing reveals is the box getting shorter // underneath the input — which is exactly what the keyboard does here, a beat // after the tap that raised it, once the host reports it and the cap below // follows the shrunken surface (MES-1037). const cardRef = React.useRef(null) React.useEffect(() => { const card = cardRef.current if (!card || typeof ResizeObserver === 'undefined') { return } const observer = new ResizeObserver(() => { if (card.contains(document.activeElement)) { card.scrollTop = card.scrollHeight } }) observer.observe(card) return () => observer.disconnect() }, []) // Let the browser insert pasted text itself, so the composer grows to fit it // immediately (MES-1037). Stream's paste handler calls preventDefault and // feeds the text back through composer state asynchronously; TextareaComposer // then assigns `textarea.value` in a layout effect, which fires no input event // and so never triggers react-textarea-autosize's re-measure — leaving the // composer one line tall, with the pasted text hidden, until the next // keystroke happens to resize it. Letting the default action run puts pasted // text on exactly the path typed text takes, which measures correctly. // // It has to be a native listener that stops propagation rather than an // `onPaste` prop: TextareaComposer takes `onPaste` from the message-input // context and applies it after spreading our props, so ours would be // overridden, and a React handler on this card would run after the textarea's // anyway. Stopping the event here keeps it from reaching React's root, where // Stream's handler is dispatched from. Pastes carrying a file still go to // Stream, which uploads it as an attachment. React.useEffect(() => { const card = cardRef.current if (!card) { return } const passToBrowser = (event: ClipboardEvent) => { // `items`, not `files`: Safari never populates `clipboardData.files` on // paste, even for an image, only `items` — which is why Stream's own // handler reads files via `items` (`dataTransferItemsToFiles`) rather // than `.files`. Checking `.files.length` here would treat a pasted // screenshot as plain text on Safari and drop it silently instead of // handing it to Stream to upload. const hasFile = Array.from(event.clipboardData?.items ?? []).some( (item) => item.kind === 'file' ) if (!hasFile) { event.stopPropagation() } } card.addEventListener('paste', passToBrowser) return () => card.removeEventListener('paste', passToBrowser) }, []) return ( // This card is the composer's ONE scroll container: staged previews and the // textarea row scroll together past a cap, instead of the previews being // squeezed to keep the row in view (MES-1037). Nothing inside may shrink, or // the previews get cropped rather than scrolled — hence no `min-h-0` here or // on the previews wrapper, no `shrink-0` on the row, and no nested scroller // in CustomLinkPreviewList. Every child keeps its intrinsic height and the // overflow lands on this scrollport. `self-stretch` went with the squeeze it // fed: the parent `.message-input` row is `items-end`, so the card now sizes // to its content up to the cap. Stream's renders its Input // directly (context providers only, no wrapper element), so this is a direct // flex item of that row. // // The cap is half the *keyboard-shrunk* surface height, because a cap that // doesn't shrink with the keyboard lets the card spill past the visible // viewport — which is the bug. No CSS unit tracks the visual viewport, so // `--messaging-surface-height` is an optional host contract, like this // package's own `--messaging-composer-height`: linktr.ee-profiles publishes // it on the messaging surface (an ancestor of this composer at runtime) from // its own `visualViewport` measurement, and it inherits in. Hosts that don't // fall back to `100dvh` — bounded, but not keyboard-aware. Deliberately not // measured here: the host owns viewport control and the package must not // fight it for it. // // The container itself carries no padding of its own: it used to (p-2), // but that inset now lives on the textarea as padding instead, so the // textarea's actual (clickable) box is as large as the container — save // for the small margin and the send button on the right (MES-1325). // Visually nothing moves; the container ends up exactly the size of the // textarea + send button row it contains. // // Some hosts portal staged-media thumbnails (e.g. ComposerPendingMediaPortal, // see this component's stories) straight into `.central-container` rather // than through this React tree, bypassing the two wrappers below. The // `[&>:not(...)]:p-2` variant restores the container's old inset for any // such foreign direct child, without re-adding it to our own wrappers.
{/* empty:hidden collapses this row (padding, gap, and all) when none of the previews below have anything to render, so an idle composer has no leftover top inset before the textarea row. No bottom inset: the textarea's own py-3.5 already separates the last preview from the text, and adding one here double-spaces them now that both sit in the same scroll. */}
in its own div, so // `containerClassName` (not `className`) is what joins the row's // flex layout; `leading-[0]` drops the line-height space an // inline-level parent otherwise reserves, keeping that wrapper // exactly the textarea's height rather than stretched by the row // (items-start above is a second guard against that same stretch). containerClassName="min-w-0 flex-1 leading-[0]" // bg-transparent: the container has no padding to inset it behind // anymore, so an opaque textarea would paint over its rounded corners. // // overflow-hidden makes "the card is the only scrollable region" a // property of the CSS rather than of the autosize arithmetic always // landing exactly right. A textarea's UA default is `overflow: auto`, // and TextareaComposer sizes itself by copying the computed style into // a detached measuring textarea on input, window resize and font load // only — so any width change it does not hear about (most obviously // this card gaining its own scrollbar, which narrows the textarea and // rewraps the text onto another line) leaves it a line short and // sprouts a second scrollbar inside the first. The caret stays visible // either way: browsers still scroll an overflow-hidden textarea to // reveal it, and the next keystroke re-fits the height. className="w-full resize-none overflow-hidden bg-transparent py-3.5 pl-4 pr-4 outline-none leading-5 placeholder:text-black/30 text-sm" // While this might usually be considered an anti-pattern, in most // cases, when a message thread is rendered, we want the input to // gain focus automatically. // eslint-disable-next-line jsx-a11y/no-autofocus autoFocus={!disabled} // Enter sends (Shift+Enter inserts a newline), so label the // on-screen keyboard's return key accordingly. enterKeyHint="send" // Grow to fit every line rather than scrolling the overflow inside // the textarea past a few of them: the card above is the one scroll // container, so a message long enough to outgrow it scrolls there, as // one piece with the previews. Explicit because TextareaComposer // defaults an absent `maxRows` to 1, not to "no cap". maxRows={Infinity} readOnly={disabled} tabIndex={disabled ? -1 : undefined} /> {/* The container no longer has its own p-2, so this inset — and the mt-auto that anchors the button to the bottom as the textarea grows — lives on a wrapper instead of the button's own className: a host-supplied SendButton can (and, per our own stories, does) spread its own className over the one we pass in, which would otherwise silently drop this spacing. The sticky is here for the same reason. sticky: mt-auto alone anchors the button to the bottom of a row the textarea can grow taller than the card, carrying send out of the scroll's view while you are still typing. Sticking it to the scrollport keeps it reachable without a scroll, floating over the textarea's own column, so nothing reflows and no text runs under it. The offset matches mb-2 because sticky resolves against the border box, not the margin box: at bottom-0 the button would drop that inset and sit flush with the card's edge for as long as it stayed stuck. A sticky element cannot leave its containing block, which is the row — so scrolling all the way up to the previews takes the button off-screen with the row it belongs to, and it is pinned at every position where the row is in view, i.e. whenever you are typing. pt-2 gives the button the same inset above it that mb-2 gives below, and keeps it there while the card scrolls: sticky offsets only the bottom edge, so once the card scrolls far enough for the button to ride up to the top of the row, a staged preview would otherwise meet it flush. The padding travels with the button, so the gap holds at every scroll position rather than only at rest. It needs no background to sit on: sticky cannot leave its containing block, so the button stays inside the row and a preview never passes behind it. */}
) } export interface CustomMessageInputProps { renderActions?: () => React.ReactNode /** * Content rendered above the composer controls (and above the locked panel * when `disabled`). Used by the floating layout to keep conversation footer * content inside the absolutely positioned chrome so it is not covered. */ renderHeader?: () => React.ReactNode renderFooter?: () => React.ReactNode /** * Replace the composer entirely with a non-interactive locked panel that * shows `disabledReason`. Used by the Linktree official channel, where the * linker cannot message Linktree from the inbox. Defaults to false. * * Distinct from the channel's `frozen` flag, which keeps the composer * rendered but read-only/dimmed. */ disabled?: boolean /** * Explanatory text shown inside the locked panel. Only rendered when * `disabled` is true. */ disabledReason?: string /** * Replace the inner composer — the textarea + send arrangement rendered * inside Stream's `` — with a host-owned component. When * provided it is passed to Stream as the `Input` component, so it runs inside * the message-input context and can drive sending, attachments, and previews * via the Stream hooks (`useMessageInputContext`, * `useMessageComposerHasSendableData`, `TextareaComposer`, …). The outer * frozen/`renderActions`/`renderFooter` wrapper is unchanged; read * `useComposerLocked()` to honour the frozen state. Defaults to the built-in * composer. */ composerInput?: React.ComponentType } const ComposerBackdrop: React.FC = () => (