/**
* 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,
});