/** * Message — a chat turn: avatar, bubble, and the metadata around it. * * The pieces are separate because conversations differ in which ones they * need. A support thread wants a sender name and a read receipt; an assistant * transcript wants neither, but does want the bubble to carry tool output and * actions. Composing them beats a component with a dozen optional props. * * `align` is the only thing the root decides, and everything downstream reads * it from context: which side the row sits on, which way the avatar goes, * which corner of the bubble is squared off, and which colour it takes. * * ```tsx * * * * How can I help? * * * * ``` */ import { Children, cloneElement, createContext, forwardRef, isValidElement, useContext, type ReactNode, } from 'react'; import { View, type GestureResponderEvent, type ViewProps } from 'react-native'; import { tv, type VariantProps } from 'tailwind-variants'; import { useCSSVariable } from 'uniwind'; import { IconColorProvider } from '../../icons'; import { AnimatedPressable } from '../../primitives/animated-pressable'; import { Text, type TextProps, textChildren } from '../../primitives/text'; import { cn } from '../../utils/cn'; const messageVariants = tv({ slots: { root: 'w-full flex-row items-end gap-2', avatar: 'shrink-0', content: 'max-w-[85%] gap-1', header: 'text-xs font-medium text-muted-foreground', bubble: 'rounded-2xl px-3.5 py-2.5', bubbleContent: 'text-base', footer: 'text-xs text-muted-foreground', group: 'w-full gap-1', }, variants: { align: { start: { root: 'flex-row justify-start', content: 'items-start', // A squared corner on the side the message comes from points the // bubble back at its sender. Logical, so "the side it comes from" // stays true when the script runs the other way. bubble: 'rounded-es-md bg-muted', bubbleContent: 'text-foreground', header: 'text-start', footer: 'text-start', }, end: { root: 'flex-row-reverse justify-start', content: 'items-end', bubble: 'rounded-ee-md bg-primary', bubbleContent: 'text-primary-foreground', header: 'text-end', footer: 'text-end', }, }, }, defaultVariants: { align: 'start', }, }); type Align = 'start' | 'end'; interface MessageContextValue { align: Align; /** True when the message is not the first in its group. */ stacked: boolean; } const MessageContext = createContext({ align: 'start', stacked: false, }); /** Reads the alignment set by the nearest Message. */ function useMessageContext() { return useContext(MessageContext); } const GroupContext = createContext<{ align: Align } | null>(null); export interface MessageProps extends ViewProps, VariantProps { className?: string; /** * Which side of the conversation this turn belongs to. `end` is the * outgoing side — the person holding the device. */ align?: Align; /** * Continuation of the message above it: tighter spacing, and the avatar slot * is reserved but left empty so bubbles stay aligned. `Message.Group` sets * this for you. */ stacked?: boolean; /** * Fires on a long press anywhere on the turn — the gesture a chat uses to * surface per-message actions (copy, reply, react). Open a menu from it; the * component only exposes the press. When set, the whole row gains press * feedback. A plain tap should still do nothing, so there is no `onPress`. */ onLongPress?: (event: GestureResponderEvent) => void; children?: ReactNode; } const MessageRoot = forwardRef( ({ className, align, stacked = false, onLongPress, children, ...props }, ref) => { const group = useContext(GroupContext); const resolvedAlign = align ?? group?.align ?? 'start'; const { root } = messageVariants({ align: resolvedAlign }); const body = onLongPress ? ( // A long-press target, not a button — a tap does nothing, so no role is // announced. The feedback matches every other pressable in the library. {textChildren(children)} ) : ( {textChildren(children)} ); return ( {body} ); } ); MessageRoot.displayName = 'Message'; export interface MessageGroupProps extends ViewProps { className?: string; /** Alignment applied to every Message inside that does not set its own. */ align?: Align; children?: ReactNode; } /** * Consecutive turns from one sender. Tightens the spacing between them and * marks every message after the first as `stacked`, so only the first shows an * avatar and the rest stay aligned with it. */ const MessageGroup = forwardRef( ({ className, align = 'start', children, ...props }, ref) => { const { group } = messageVariants({ align }); // Every turn after the first is a continuation, so it drops its avatar and // sits tight against the one above. A child that sets `stacked` itself // wins — grouping is a default, not a rule. const stackedChildren = Children.map(children, (child, index) => isValidElement(child) && child.props.stacked === undefined ? cloneElement(child, { stacked: index > 0 }) : child ); return ( {stackedChildren} ); } ); MessageGroup.displayName = 'Message.Group'; export interface MessageAvatarProps extends ViewProps { className?: string; children?: ReactNode; } /** * Slot for the sender's avatar. On a stacked message the slot is rendered but * left empty, which keeps the bubbles in a column instead of letting the * second one slide under the first one's avatar. */ const MessageAvatar = forwardRef( ({ className, children, ...props }, ref) => { const { stacked } = useMessageContext(); const { avatar } = messageVariants(); return ( {stacked ? {textChildren(children)} : children} ); } ); MessageAvatar.displayName = 'Message.Avatar'; export interface MessageContentProps extends ViewProps { className?: string; children?: ReactNode; } /** Column holding the header, bubble and footer. */ const MessageContent = forwardRef( ({ className, children, ...props }, ref) => { const { align } = useMessageContext(); const { content } = messageVariants({ align }); return ( {textChildren(children)} ); } ); MessageContent.displayName = 'Message.Content'; export interface MessageHeaderProps extends TextProps { className?: string; } /** Sender name or timestamp above the bubble. */ const MessageHeader = forwardRef, MessageHeaderProps>( ({ className, ...props }, ref) => { const { align } = useMessageContext(); const { header } = messageVariants({ align }); return ; } ); MessageHeader.displayName = 'Message.Header'; export interface MessageBubbleProps extends ViewProps { className?: string; children?: ReactNode; } /** The speech bubble. Takes its colour and squared corner from `align`. */ const MessageBubble = forwardRef( ({ className, children, ...props }, ref) => { const { align } = useMessageContext(); const { bubble } = messageVariants({ align }); /* * A sent bubble is painted in the primary colour, and the theme's text * colour is often that same colour — so anything inside it that resolves * the foreground for itself draws black on black. Text has always been * handled, through `Message.BubbleContent`; an icon or a waveform dropped * into a bubble had no way to know. Publishing the bubble's own foreground * here fixes all of them at once, the same way Button does for its label. */ const foreground = useCSSVariable( align === 'end' ? '--color-primary-foreground' : '--color-foreground' ); return ( {textChildren(children)} ); } ); MessageBubble.displayName = 'Message.Bubble'; export interface MessageBubbleContentProps extends TextProps { className?: string; } /** Text inside the bubble, in whichever colour reads against it. */ const MessageBubbleContent = forwardRef< React.ElementRef, MessageBubbleContentProps >(({ className, ...props }, ref) => { const { align } = useMessageContext(); const { bubbleContent } = messageVariants({ align }); return ; }); MessageBubbleContent.displayName = 'Message.BubbleContent'; export interface MessageFooterProps extends TextProps { className?: string; } /** Delivery state, timestamp or actions below the bubble. */ const MessageFooter = forwardRef, MessageFooterProps>( ({ className, ...props }, ref) => { const { align } = useMessageContext(); const { footer } = messageVariants({ align }); return ; } ); MessageFooter.displayName = 'Message.Footer'; export interface MessageActionsProps extends ViewProps { className?: string; children?: ReactNode; } /** Row of controls under the bubble — copy, retry, feedback. */ const MessageActions = forwardRef( ({ className, children, ...props }, ref) => { const { align } = useMessageContext(); return ( {textChildren(children)} ); } ); MessageActions.displayName = 'Message.Actions'; export const Message = Object.assign(MessageRoot, { Group: MessageGroup, Avatar: MessageAvatar, Content: MessageContent, Header: MessageHeader, Bubble: MessageBubble, BubbleContent: MessageBubbleContent, Footer: MessageFooter, Actions: MessageActions, });