import type { Meta, StoryFn } from '@storybook/react' import classNames from 'classnames' import React from 'react' import { currentUserArgType, StoryUser, storyUsers, } from '../../stories/decorators/storyUser' import { Avatar } from '../Avatar' import MessageAttachment, { type BubbleGroupPosition, } from '../MessageAttachment' /* ────────────────────────────────────────────────────────────────── * Lightweight conversation mock used only by these stories. * * The production `CustomMessage` flow needs the full stream-chat-react * machinery (Channel + MessageList + the `Attachment` override pipe). * That's overkill for a visual showcase, so these stories render a * bare conversation timeline that mimics the same layout primitives: * * - Sender (current user) bubbles align right, no avatar. * - Recipient bubbles align left with a 28px circle avatar. * - Adjacent bubbles from the same user gap by 2px (matches the * `.str-chat__message-bubble-wrapper { gap: 2px }` rule in * `styles.css`), separate-author boundaries get more breathing * room. * - Text bubbles inherit the same `MessageAttachment.Bubble` chrome * (background, padding, radius, font) so attachments and text sit * consistently inside the same conversation column. * ────────────────────────────────────────────────────────────────── */ type ChatRole = 'sender' | 'receiver' interface ChatNode { id: string role: ChatRole /** Either a string (rendered as a text bubble) or a custom React node * (e.g. ``). */ content: React.ReactNode } const TEXT_BG_BY_ROLE: Record = { sender: 'bg-[#1e2330] text-white', receiver: 'bg-[#f2f1ef] text-[#000000]', } const TextBubble: React.FC<{ role: ChatRole; children: React.ReactNode }> = ({ role, children, }) => (
{children}
) const ChatRow: React.FC<{ role: ChatRole showAvatar: boolean user: StoryUser children: React.ReactNode }> = ({ role, showAvatar, user, children }) => (
{role === 'receiver' ? (
{showAvatar ? ( ) : null}
) : null}
{children}
) const ChatMock: React.FC<{ currentUser: StoryUser receiver: StoryUser messages: ChatNode[] }> = ({ currentUser, receiver, messages }) => { // Render an avatar on the *last* message of each receiver run — same // grouping rule the real chat uses so consecutive bubbles from one // sender share avatar real estate. const isLastInRun = (index: number): boolean => { const next = messages[index + 1] return !next || next.role !== messages[index].role } return (
{messages.map((msg, index) => { const prev = messages[index - 1] const newAuthorBoundary = !prev || prev.role !== msg.role return (
0 ? 'mt-3' : undefined} > {typeof msg.content === 'string' ? ( {msg.content} ) : ( msg.content )}
) })}
) } /* ────────────────────────────────────────────────────────────────── * Multi-attachment message wrapper. * * Real chat UIs let one logical "message" carry several attachments * of different kinds — e.g. a voice memo + a PDF, or two photos + an * audio note. Each attachment renders as its own `MessageAttachment.*` * bubble, but the whole set should read as a single visual cluster * from the same author. To do that we: * * - Stack the bubbles vertically with `gap-[2px]` (the same gap * `.str-chat__message-bubble-wrapper` uses between grouped text * bubbles in `styles.css`). * - Inject `groupPosition='first' | 'middle' | 'end'` on each child * so the corners flatten on the edges that face siblings inside * the cluster — same merging rule the `Bubble` component already * applies to consecutive text messages. * - Right-align for sender clusters / left-align for receiver * clusters so the run anchors to the correct chat edge. * * The wrapper is intentionally local to this stories file: the real * `CustomMessage` integration derives `groupPosition` from * stream-chat-react's `firstOfGroup` / `endOfGroup` flags via the * `bubbleGroupPositionFromStream` helper instead. This wrapper just * gives the story author a clean way to express "one message, many * attachments" without wiring up the full message-context plumbing. * ────────────────────────────────────────────────────────────────── */ interface MultiAttachmentMessageProps { align: 'sender' | 'receiver' children: React.ReactNode } const MultiAttachmentMessage: React.FC = ({ align, children, }) => { const items = React.Children.toArray(children).filter( React.isValidElement ) as React.ReactElement<{ groupPosition?: BubbleGroupPosition }>[] const total = items.length return (
{items.map((child, index) => { const groupPosition: BubbleGroupPosition = total === 1 ? 'single' : index === 0 ? 'first' : index === total - 1 ? 'end' : 'middle' return React.cloneElement(child, { key: child.key ?? index, groupPosition, }) })}
) } const meta: Meta = { title: 'CustomMessage', argTypes: currentUserArgType, args: { currentUser: storyUsers.creator }, parameters: { layout: 'fullscreen', // The render functions build a `messages` array containing JSX // attachment elements (with live React fiber `_owner` refs once // mounted) and hand it to ``. Storybook's // default `dynamic` source extractor recurses through that prop // via `react-element-to-jsx-string` → `prettyPrint`, walks each // element's fiber graph, and blows the call stack. Pin source // generation to the static story code instead. docs: { source: { type: 'code' } }, }, } export default meta interface ConversationStoryArgs { currentUser: StoryUser } // Single-attachment-type conversations (`ConversationWithImages`, // `ConversationWithVideos`, etc.) are useful for design review but the // per-attachment-type RENDERING is already covered by the dedicated // `MessageAttachment/{Image,Video,Audio,Pdf,File}` stories. The // "mixed" conversations below exercise composition that the per-type // stories don't, so those keep their snapshots. const skipInChromatic = { chromatic: { disableSnapshot: true } } const HERO_PHOTO = 'https://picsum.photos/seed/portrait/720/720' const STUDIO_PHOTOS = [ { src: 'https://picsum.photos/seed/studio-1/720/720', alt: 'Studio shot 1' }, { src: 'https://picsum.photos/seed/studio-2/720/720', alt: 'Studio shot 2' }, { src: 'https://picsum.photos/seed/studio-3/720/720', alt: 'Studio shot 3' }, ] const VIDEO_SRC = '/video-source.mp4' const VIDEO_POSTER = '/video-thumbnail.jpg' const AUDIO_SRC = 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3' const PDF_SRC = 'https://www.w3.org/WAI/WCAG21/wcag21.pdf' /** * Image attachments inside a conversation — the receiver shares a * single hero shot (with caption) and the sender replies with a * stacked album. Click any bubble to open the lightbox viewer. */ export const ConversationWithImages: StoryFn = ({ currentUser, }) => { const isSenderTheCreator = currentUser.id === storyUsers.creator.id const receiver = isSenderTheCreator ? storyUsers.visitor : storyUsers.creator return ( ), }, { id: 'msg-3', role: 'sender', content: 'Love it! Here are a few more from the studio set.', }, { id: 'msg-4', role: 'sender', content: ( ), }, { id: 'msg-5', role: 'receiver', content: 'These are great — let me pick two for the launch page.', }, ]} /> ) } ConversationWithImages.parameters = skipInChromatic /** * Video attachments — receiver sends a single clip with a caption, * sender replies and shares back a stacked set. */ export const ConversationWithVideos: StoryFn = ({ currentUser, }) => { const isSenderTheCreator = currentUser.id === storyUsers.creator.id const receiver = isSenderTheCreator ? storyUsers.visitor : storyUsers.creator const stack = Array.from({ length: 3 }, () => ({ src: VIDEO_SRC, poster: VIDEO_POSTER, mimeType: 'video/mp4', })) return ( ), }, { id: 'msg-2', role: 'receiver', content: 'And here is the rough cut from yesterday.', }, { id: 'msg-3', role: 'receiver', content: ( ), }, { id: 'msg-4', role: 'sender', content: 'On it. Sending three angles back.', }, { id: 'msg-5', role: 'sender', content: ( ), }, ]} /> ) } ConversationWithVideos.parameters = skipInChromatic /** * Audio attachments — voice memo back-and-forth, plus a stacked * studio takes message. */ export const ConversationWithAudio: StoryFn = ({ currentUser, }) => { const isSenderTheCreator = currentUser.id === storyUsers.creator.id const receiver = isSenderTheCreator ? storyUsers.visitor : storyUsers.creator return ( ), }, { id: 'msg-3', role: 'sender', content: 'Love that. Here are three takes I cut tonight.', }, { id: 'msg-4', role: 'sender', content: ( ), }, { id: 'msg-5', role: 'receiver', content: 'Take 2 is the one. Shipping it.', }, ]} /> ) } ConversationWithAudio.parameters = skipInChromatic /** * PDF attachments — single document with a caption and a stacked * package of related documents. */ export const ConversationWithPdfs: StoryFn = ({ currentUser, }) => { const isSenderTheCreator = currentUser.id === storyUsers.creator.id const receiver = isSenderTheCreator ? storyUsers.visitor : storyUsers.creator return ( ), }, { id: 'msg-4', role: 'receiver', content: 'Got it. Sending back the full package for tonight.', }, { id: 'msg-5', role: 'receiver', content: ( ), }, ]} /> ) } ConversationWithPdfs.parameters = skipInChromatic /** * Generic file attachments — non-PDF documents with a download * affordance on each row. Includes a stacked-files example where * three documents share a single bubble with an 8px gap between * rows, matching the stacked PDF / Audio treatment. */ export const ConversationWithFiles: StoryFn = ({ currentUser, }) => { const isSenderTheCreator = currentUser.id === storyUsers.creator.id const receiver = isSenderTheCreator ? storyUsers.visitor : storyUsers.creator return ( ), }, { id: 'msg-4', role: 'receiver', content: 'Perfect — also got the brand kit handy?', }, { id: 'msg-5', role: 'sender', content: ( ), }, { id: 'msg-6', role: 'receiver', content: 'Amazing, thanks!', }, ]} /> ) } ConversationWithFiles.parameters = skipInChromatic /** * Mixed conversation — a realistic chat where the same thread carries * a text exchange, a single image, a stacked video set, an audio memo, * and a PDF handoff. Useful for sanity-checking spacing and bubble * rhythm when multiple attachment types coexist. */ export const ConversationWithMixedAttachments: StoryFn< ConversationStoryArgs > = ({ currentUser }) => { const isSenderTheCreator = currentUser.id === storyUsers.creator.id const receiver = isSenderTheCreator ? storyUsers.visitor : storyUsers.creator return ( ), }, { id: 'msg-5', role: 'receiver', content: ( ), }, { id: 'msg-6', role: 'sender', content: 'Looks great. Quick voice memo with notes ↓', }, { id: 'msg-7', role: 'sender', content: ( ), }, { id: 'msg-8', role: 'sender', content: ( ), }, { id: 'msg-9', role: 'receiver', content: 'Got everything. Will sync back in the morning.', }, ]} /> ) } /** * Multi-attachment message — audio + PDF. * * Mirrors the mobile picker UX where the composer can hold a single * attachment but the recipient sees them sent together as one * cluster. The voice memo sits on top (`groupPosition='first'`, flat * bottom-right) and the PDF carries the caption + the cluster's tail * corner (`groupPosition='end'`, flat top-right). */ export const ConversationWithAudioAndPdfAttached: StoryFn< ConversationStoryArgs > = ({ currentUser }) => { const isSenderTheCreator = currentUser.id === storyUsers.creator.id const receiver = isSenderTheCreator ? storyUsers.visitor : storyUsers.creator return ( ), }, { id: 'msg-3', role: 'receiver', content: 'Perfect — listening now.', }, ]} /> ) } /** * Multi-attachment message — two photos + a voice memo. * * Demonstrates a mixed sensory recap: the receiver shares two * reference photos in one stacked image bubble (`groupPosition='first'`) * paired with an audio clip explaining what to look at * (`groupPosition='end'`, carries the caption). All three pieces feel * like one unit instead of three loose bubbles. */ export const ConversationWithPhotosAndAudioAttached: StoryFn< ConversationStoryArgs > = ({ currentUser }) => { const isSenderTheCreator = currentUser.id === storyUsers.creator.id const receiver = isSenderTheCreator ? storyUsers.visitor : storyUsers.creator return ( ), }, { id: 'msg-3', role: 'sender', content: 'Great picks. Locking those in.', }, ]} /> ) } /** * Multi-attachment message — image + video + PDF (3 different types). * * Stress-tests the cluster with three different bubble shapes back to * back. Image first (cropped poster, flat bottom-right), video middle * (poster tile with play badge, flats on top-right + bottom-right), * PDF last (compact row with download trailing button + caption, * flat top-right). The whole stack reads as one message run. */ export const ConversationWithImageVideoPdfAttached: StoryFn< ConversationStoryArgs > = ({ currentUser }) => { const isSenderTheCreator = currentUser.id === storyUsers.creator.id const receiver = isSenderTheCreator ? storyUsers.visitor : storyUsers.creator return ( ), }, { id: 'msg-3', role: 'receiver', content: 'Got it. Reviewing now.', }, ]} /> ) }