UserMessage
Render a single user bubble. Supports text, image parts (image/data-image/image file), and file attachments.
Getting Started
$pnpm add @sinups/ai-kit @mantine/core @mantine/hooks @tabler/icons-reactExamples
Text only
With image
Source
The component source as it ships in the package. Useful as a reference, or as a starting point if you want to fork a piece.
UserMessage/UserMessage.tsx
import React, { memo, useState } from 'react';
import { Box, UnstyledButton } from '@mantine/core';
import type { ChatMessage } from '../types';
import { cx } from '../utils/cx';
import { isRecord, isTextPart } from '../utils/parts';
import { FileAttachment } from '../input/FileAttachment';
import { ImageLightbox } from '../ImageLightbox/ImageLightbox';
import { CommandChip } from '../message-actions/CommandChip/CommandChip';
import { matchSlashCommand } from '../message-actions/slash-command';
import type { SlashCommandInfo } from '../message-actions/types';
import { collapseLongText, type LongTextThreshold } from './long-text';
import classes from './UserMessage.module.css';
export type UserMessageProps = {
message: ChatMessage;
className?: string;
/** Opens an attached image in a fullscreen lightbox on click, plain thumbnails when `false`; `true` by default */
enableImagePreview?: boolean;
/**
* Known slash commands. Text that starts with one of them, for example `/review src/auth`, is shown
* as a command chip with its arguments; unknown `/…` text such as a file path stays plain.
*/
commands?: SlashCommandInfo[];
/** Shows the head and tail of a text longer than the threshold with a button that expands it; `true` uses `{ chars: 2000, lines: 30 }`, `false` by default */
longMessageThreshold?: LongTextThreshold | boolean;
/** Overrides of the default English labels */
labels?: Partial<UserMessageLabels>;
};
export interface UserMessageLabels {
/** Expand button, receives the number of hidden lines or characters */
showFull: (hidden: { lines: number; chars: number }) => string;
/** Collapse button, `Show less` by default */
showLess: string;
}
export const DEFAULT_USER_MESSAGE_LABELS: UserMessageLabels = {
showFull: ({ lines, chars }) =>
lines > 0
? `Show full message (${lines} more ${lines === 1 ? 'line' : 'lines'})`
: `Show full message (${chars.toLocaleString('en-US')} more characters)`,
showLess: 'Show less',
};
function getMimeType(part: Record<string, unknown>): string | undefined {
const mime = part.mediaType ?? part.mimeType;
return typeof mime === 'string' ? mime : undefined;
}
function getImageUrlFromPart(part: unknown): string | null {
if (!isRecord(part)) {
return null;
}
const type = part.type;
if (typeof type !== 'string') {
return null;
}
if (type === 'image') {
const imagePart = part as { url?: string; image?: string };
return imagePart.url ?? imagePart.image ?? null;
}
if (type === 'data-image') {
const dataPart = part as { data?: { url?: string } };
return dataPart.data?.url ?? null;
}
if (type === 'file') {
const mimeType = getMimeType(part);
const filePart = part as { url?: string; data?: string };
if (mimeType?.startsWith('image/')) {
if (filePart.url) {
return filePart.url;
}
if (filePart.data) {
return `data:${mimeType};base64,${filePart.data}`;
}
}
}
return null;
}
type FilePartLike = {
type: 'file';
filename?: string;
name?: string;
fileName?: string;
size?: number;
url?: string;
};
function getFileFromPart(part: unknown) {
if (!isRecord(part)) {
return null;
}
if (part.type !== 'file') {
return null;
}
const filePart = part as FilePartLike;
const filename = filePart.filename || filePart.name || filePart.fileName || 'Attachment';
const isImage = getMimeType(part)?.startsWith('image/') ?? false;
if (isImage) {
return null;
}
return {
filename,
size: filePart.size,
};
}
/** Right-aligned user bubble with optional image thumbnails and file attachments */
export const UserMessage = memo(function UserMessage({
message,
className,
enableImagePreview = true,
commands,
longMessageThreshold = false,
labels: labelsProp,
}: UserMessageProps) {
const labels = { ...DEFAULT_USER_MESSAGE_LABELS, ...labelsProp };
const [expanded, setExpanded] = useState(false);
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
const textParts = message.parts?.filter(isTextPart) ?? [];
const text = textParts.map((p) => p.text).join('');
const images: string[] = [];
const files: Array<{ filename: string; size?: number }> = [];
for (const part of message.parts ?? []) {
const imageUrl = getImageUrlFromPart(part);
if (imageUrl) {
images.push(imageUrl);
}
const file = getFileFromPart(part);
if (file) {
files.push(file);
}
}
if (Array.isArray(message.experimental_attachments)) {
for (const att of message.experimental_attachments as Array<{
contentType?: string;
url?: string;
}>) {
if (att.contentType?.startsWith('image/') && att.url) {
images.push(att.url);
}
}
}
const command = commands?.length ? matchSlashCommand(text, commands) : null;
const collapsed =
command || longMessageThreshold === false
? null
: collapseLongText(text, longMessageThreshold === true ? undefined : longMessageThreshold);
if (!text && images.length === 0 && files.length === 0) {
return null;
}
const lightboxImages = images.map((url, i) => ({
id: `${message.id}-img-${i}`,
url,
filename: `image-${i + 1}`,
}));
return (
<Box className={cx(classes.root, className)}>
{images.length > 0 &&
images.map((url, i) =>
enableImagePreview ? (
<UnstyledButton
key={i}
className={classes.imageFrame}
data-clickable
aria-label="Open image preview"
onClick={() => setLightboxIndex(i)}
>
<img src={url} alt="attachment" className={classes.image} />
</UnstyledButton>
) : (
<div key={i} className={classes.imageFrame}>
<img src={url} alt="attachment" className={classes.image} />
</div>
)
)}
{enableImagePreview && lightboxImages.length > 0 && (
<ImageLightbox
open={lightboxIndex !== null}
onClose={() => setLightboxIndex(null)}
images={lightboxImages}
initialIndex={lightboxIndex ?? 0}
/>
)}
{files.length > 0 && (
<div className={classes.files}>
{files.map((file, i) => (
<FileAttachment
key={`${file.filename}-${i}`}
id={`${file.filename}-${i}`}
filename={file.filename}
size={file.size}
/>
))}
</div>
)}
{text && (
<div className={classes.bubbleWrapper}>
<div className={classes.bubble}>
{command ? (
<CommandChip
name={command.name}
args={command.args}
description={command.command.description}
icon={command.command.icon}
className={classes.text}
/>
) : collapsed ? (
<>
<p className={classes.text}>{expanded ? text : collapsed.head}</p>
{!expanded && (
<p className={classes.text} data-collapsed-tail>
{collapsed.tail}
</p>
)}
<UnstyledButton
className={classes.expandButton}
aria-expanded={expanded}
onClick={() => setExpanded((value) => !value)}
>
{expanded
? labels.showLess
: labels.showFull({
lines: collapsed.hiddenLines,
chars: collapsed.hiddenChars,
})}
</UnstyledButton>
</>
) : (
<p className={classes.text}>{text}</p>
)}
</div>
</div>
)}
</Box>
);
});
UserMessage.displayName = 'UserMessage';
Depends on:
API reference
Prop
Type
Required
message
ChatMessage
Yes
className
string
No
enableImagePreview
boolean
No
Opens an attached image in a fullscreen lightbox on click, plain thumbnails when `false`; `true` by default
commands
SlashCommandInfo[]
No
Known slash commands. Text that starts with one of them, for example `/review src/auth`, is shown as a command chip with its arguments; unknown `/…` text such as a file path stays plain.
longMessageThreshold
LongTextThreshold | boolean
No
Shows the head and tail of a text longer than the threshold with a button that expands it; `true` uses `{ chars: 2000, lines: 30 }`, `false` by default
labels
Partial<UserMessageLabels>
No
Overrides of the default English labels