import type { Meta, StoryFn } from '@storybook/react' import React, { useEffect } from 'react' import { Channel as ChannelType, ChannelMemberResponse, Event, QueryChannelAPIResponse, StreamChat, } from 'stream-chat' import { Chat } from 'stream-chat-react' import { hoursAgo, minutesAgo, now } from '../stories/decorators/storyTime' import { mockParticipants } from '../stories/mocks' import { createMockStreamChatClient } from '../testing/createMockStreamChatClient' import { ChannelView } from './ChannelView' import { ChannelEmptyState } from './MessagingShell/ChannelEmptyState' type ComponentProps = React.ComponentProps const SCROLLABLE_MESSAGE_COUNT = 10 const meta: Meta = { title: 'ChannelView', component: ChannelView, parameters: { layout: 'fullscreen', viewport: { defaultViewport: 'responsive', }, }, } export default meta // Mock user for Storybook const mockUser = { id: 'storybook-user', name: 'Storybook User', image: 'https://i.pravatar.cc/150?img=1', } const FloatingComposerInput: React.FC = () => (
Redesigned composer
) // Create a real channel using client.channel() and mock the API responses const createMockChannel = async ( client: StreamChat, hasMessages = true, followerStatus?: string | boolean, isFrozen = false, channelCustomerTag?: string, hasScrollableMessages = false ) => { const participant = mockParticipants[0] const mockMessages = hasMessages ? [ { id: 'msg-1', text: 'Hey! How are you doing?', type: 'regular' as const, created_at: hoursAgo(1), updated_at: hoursAgo(1), user: participant, html: '

Hey! How are you doing?

', attachments: [], latest_reactions: [], own_reactions: [], reaction_counts: {}, reaction_scores: {}, reply_count: 0, status: 'received', cid: 'messaging:storybook-channel-1', mentioned_users: [], }, { id: 'msg-2', text: "I'm doing great, thanks! How about you?", type: 'regular' as const, created_at: minutesAgo(50), updated_at: minutesAgo(50), user: mockUser, html: "

I'm doing great, thanks! How about you?

", attachments: [], latest_reactions: [], own_reactions: [], reaction_counts: {}, reaction_scores: {}, reply_count: 0, status: 'received', cid: 'messaging:storybook-channel-1', mentioned_users: [], }, { id: 'msg-3', text: 'Pretty good! Just working on some exciting stuff.', type: 'regular' as const, created_at: minutesAgo(30), updated_at: minutesAgo(30), user: participant, html: '

Pretty good! Just working on some exciting stuff.

', attachments: [], latest_reactions: [], own_reactions: [], reaction_counts: {}, reaction_scores: {}, reply_count: 0, status: 'received', cid: 'messaging:storybook-channel-1', mentioned_users: [], }, ...(hasScrollableMessages ? Array.from({ length: SCROLLABLE_MESSAGE_COUNT }, (_, index) => { const isParticipantMessage = index % 2 === 0 const user = isParticipantMessage ? participant : mockUser const text = isParticipantMessage ? `Here is another message to show how the redesigned header floats above the thread (${index + 1}).` : `Replying so the conversation has enough content to scroll under the header (${index + 1}).` return { id: `msg-extra-${index + 1}`, text, type: 'regular' as const, created_at: minutesAgo(Math.max(1, 25 - index)), updated_at: minutesAgo(Math.max(1, 25 - index)), user, html: `

${text}

`, attachments: [], latest_reactions: [], own_reactions: [], reaction_counts: {}, reaction_scores: {}, reply_count: 0, status: 'received', cid: 'messaging:storybook-channel-1', mentioned_users: [], } }) : []), ] : [] // Prepare channel data with optional follower status const channelData: Record = { members: [mockUser.id, participant.id], frozen: isFrozen, ...(channelCustomerTag ? { customer_tag: channelCustomerTag } : {}), } // Add follower status if provided if (typeof followerStatus === 'string') { channelData.followerStatus = followerStatus } else if (typeof followerStatus === 'boolean') { channelData.isFollower = followerStatus } // Create a real channel using the client const channel = client.channel( 'messaging', 'storybook-channel-1', channelData ) // Mock the watch method to return mocked data channel.watch = async () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any channel.state.messages = mockMessages as unknown as any[] channel.state.members = { [mockUser.id]: { user: { ...mockUser, is_account: false, }, user_id: mockUser.id, role: 'owner', is_account: false, }, [participant.id]: { user: { ...participant, is_account: true, }, user_id: participant.id, role: 'member', is_account: true, ...(channelCustomerTag ? { is_follower: true } : {}), }, } return { channel: channelData, members: [], messages: mockMessages, watchers: [], pinned_messages: [], duration: '0ms', } as unknown as QueryChannelAPIResponse } // Initialize the channel try { await channel.watch() } catch { // Ignore errors - we're in mock mode } // Force set the channel data after watch // eslint-disable-next-line @typescript-eslint/no-explicit-any ;(channel as any)._data = channelData return channel } type TemplateProps = ComponentProps & { hasMessages?: boolean followerStatus?: string | boolean isFrozen?: boolean typingUser?: { id: string name?: string image?: string } channelCustomerTag?: string hasScrollableMessages?: boolean } const Template: StoryFn = (args) => { const { hasMessages = true, followerStatus, isFrozen = false, channelCustomerTag, hasScrollableMessages, typingUser, ...channelViewProps } = args const [client] = React.useState(() => createMockStreamChatClient(mockUser)) const [channel, setChannel] = React.useState(null) useEffect(() => { createMockChannel( client, hasMessages, followerStatus, isFrozen, channelCustomerTag, hasScrollableMessages ).then((mockChannel) => { setChannel(mockChannel) }) }, [ client, hasMessages, followerStatus, isFrozen, channelCustomerTag, hasScrollableMessages, ]) useEffect(() => { if (!channel || !typingUser) { return } const timer = setTimeout(() => { client.dispatchEvent({ type: 'typing.start', cid: channel.cid, user: typingUser, }) }, 0) return () => clearTimeout(timer) }, [channel, client, typingUser]) if (!channel) { return
Loading...
} return (
) } export const Default: StoryFn = Template.bind({}) Default.args = { showBackButton: false, onBack: () => console.log('Back clicked'), onLeaveConversation: (channel) => console.log('Leave conversation:', channel.id), onBlockParticipant: (participantId) => console.log('Block participant:', participantId), followerStatus: true, } Default.parameters = { docs: { description: { story: 'Default channel view with messages and conversation header.', }, }, } export const FloatingComposer: StoryFn = Template.bind({}) FloatingComposer.args = { ...Default.args, composerInput: FloatingComposerInput, hasScrollableMessages: true, } FloatingComposer.parameters = { docs: { description: { story: 'Floating composer variant with the gradient and progressive blur chrome enabled by the custom composerInput prop.', }, }, } export const FloatingComposerWithConversationFooter: StoryFn = Template.bind({}) FloatingComposerWithConversationFooter.args = { ...FloatingComposer.args, renderConversationFooter: (channel) => (

You will get notified when a new reply arrives in this conversation.

), } FloatingComposerWithConversationFooter.parameters = { docs: { description: { story: 'Floating composer with renderConversationFooter content kept inside the absolute chrome so footer controls remain clickable.', }, }, } export const ParticipantIdentityTrigger: StoryFn = Template.bind( {} ) ParticipantIdentityTrigger.args = { showBackButton: false, showStarButton: true, channelCustomerTag: 'CUSTOMER_PAID', hasScrollableMessages: true, onParticipantNameClick: () => window.alert('Participant details clicked — open Thread content'), } ParticipantIdentityTrigger.parameters = { docs: { description: { story: 'Header with `onParticipantNameClick` set: the redesigned floating header centers the avatar and name pill, shows paid/star badges when present, and fires the callback when clicked (shows an alert here). With the prop omitted (see Default) the previous header remains in use.', }, }, } export const WithHeaderTitleBadges: StoryFn = Template.bind({}) WithHeaderTitleBadges.args = { showBackButton: false, renderHeaderTitleBadges: ({ participant }) => ( $ ), } WithHeaderTitleBadges.parameters = { docs: { description: { story: 'Renders custom badges inline after the participant name in the channel header via renderHeaderTitleBadges.', }, }, } export const RestrictedOfficialChannel: StoryFn = Template.bind( {} ) RestrictedOfficialChannel.args = { showBackButton: false, // Restricted surface for the Linktree official channel: showBlockParticipant: false, showReportParticipant: false, composerDisabled: true, composerDisabledReason: 'Only Linktree can send messages on this thread', onLeaveConversation: (channel) => console.log('Leave conversation:', channel.id), } RestrictedOfficialChannel.parameters = { docs: { description: { story: 'Restricted action surface used by the Linktree official channel: block and report are hidden, and the composer is replaced by a locked panel explaining the linker cannot send messages on this thread. Delete conversation and favorite remain available in the "..." actions popover.', }, }, } export const WithBackButton: StoryFn = Template.bind({}) WithBackButton.args = { showBackButton: true, onBack: () => console.log('Back clicked'), onLeaveConversation: (channel) => console.log('Leave conversation:', channel.id), onBlockParticipant: (participantId) => console.log('Block participant:', participantId), } WithBackButton.parameters = { viewport: { defaultViewport: 'mobile1', }, docs: { description: { story: 'Channel view with back button visible on mobile (switch to mobile viewport to see the back button).', }, }, } export const WithMessageActions: StoryFn = Template.bind({}) WithMessageActions.args = { showBackButton: true, onBack: () => console.log('Back clicked'), renderMessageInputActions: (channel) => ( ), } WithMessageActions.parameters = { viewport: { defaultViewport: 'mobile1', }, docs: { description: { story: 'Channel view with custom action buttons in the message input area (e.g., attachment button).', }, }, } export const WithConversationFooter: StoryFn = Template.bind({}) WithConversationFooter.args = { showBackButton: false, renderConversationFooter: (channel) => (

You will get notified when a new reply arrives in this conversation.

), } WithConversationFooter.parameters = { docs: { description: { story: 'Channel view with a custom footer rendered between the message list and message input via renderConversationFooter(channel).', }, }, } export const WithMessageDecoration: StoryFn = Template.bind({}) WithMessageDecoration.args = { showBackButton: false, renderMessage: (messageNode, message) => { const isPriorityMessage = message.id === 'msg-3' if (!isPriorityMessage) { return messageNode } return (
✧ Marked as a priority by AI
{messageNode}
) }, } WithMessageDecoration.parameters = { docs: { description: { story: 'Decorates one message with a priority treatment using ChannelView renderMessage(messageNode, message).', }, }, } const WithStarButtonTemplate: StoryFn = (args) => { const [client] = React.useState(() => createMockStreamChatClient(mockUser)) const [channel, setChannel] = React.useState(null) useEffect(() => { createMockChannel(client, true).then((mockChannel) => { const updateChannelPin = (isStarred: boolean) => { const member: ChannelMemberResponse = { user: mockUser, user_id: mockUser.id, pinned_at: isStarred ? now().toISOString() : null, } client.dispatchEvent({ type: 'member.updated', cid: mockChannel.cid, member, user: mockUser, } as Event) } mockChannel.pin = async () => { updateChannelPin(true) return {} as QueryChannelAPIResponse } mockChannel.unpin = async () => { updateChannelPin(false) return {} as QueryChannelAPIResponse } setChannel(mockChannel) }) }, [client]) if (!channel) { return
Loading...
} return (
) } export const WithStarButton: StoryFn = WithStarButtonTemplate.bind({}) WithStarButton.args = { showBackButton: false, showStarButton: true, onLeaveConversation: (channel) => console.log('Leave conversation:', channel.id), onBlockParticipant: (participantId) => console.log('Block participant:', participantId), } WithStarButton.argTypes = { showStarButton: { control: false, table: { disable: true, }, }, } WithStarButton.parameters = { docs: { description: { story: 'Channel view with a star button that toggles pinned state.', }, }, } const EmptyTemplate: StoryFn = (args) => { const [client] = React.useState(() => createMockStreamChatClient(mockUser)) const [channel, setChannel] = React.useState(null) useEffect(() => { createMockChannel(client, false).then((mockChannel) => { setChannel(mockChannel) }) }, [client]) if (!channel) { return
Loading...
} return (
) } export const EmptyChannel: StoryFn = EmptyTemplate.bind({}) EmptyChannel.args = { showBackButton: true, CustomChannelEmptyState: ChannelEmptyState, } EmptyChannel.parameters = { viewport: { defaultViewport: 'mobile1', }, docs: { description: { story: 'Channel view with no messages showing a custom empty state component.', }, }, } export const FrozenChannel: StoryFn = Template.bind({}) FrozenChannel.args = { showBackButton: false, isFrozen: true, renderMessageInputActions: (channel) => ( ), } FrozenChannel.parameters = { docs: { description: { story: 'Channel view for a frozen conversation. The message composer renders in its disabled frozen state while the rest of the conversation remains readable.', }, }, } export const WithTypingIndicator: StoryFn = Template.bind({}) WithTypingIndicator.args = { showBackButton: false, typingUser: { id: mockParticipants[0].id, name: mockParticipants[0].name, image: mockParticipants[0].image, }, } WithTypingIndicator.parameters = { // The typing indicator is animation-dominant, so a snapshot mostly captures // whichever frame the animation happened to pause at — a false-positive // source. Skip it in Chromatic; the story stays browsable in Storybook. chromatic: { disableSnapshot: true }, docs: { description: { story: 'Channel view with the typing indicator visible, driven by a mocked typing.start event from the other participant.', }, }, } // --------------------------------------------------------------------------- // Custom SendButton stories // --------------------------------------------------------------------------- /** Demo send button: shows a camera icon when media is ready, arrow otherwise. */ const MediaAwareSendButton: React.FC<{ sendMessage: (e?: React.BaseSyntheticEvent) => void disabled?: boolean hasMedia?: boolean [key: string]: unknown }> = ({ sendMessage, disabled, hasMedia = false, ...rest }) => ( ) export const WithCustomSendButton: StoryFn = Template.bind({}) WithCustomSendButton.args = { showBackButton: false, sendButton: MediaAwareSendButton, } WithCustomSendButton.parameters = { docs: { description: { story: 'Passes a custom `sendButton` component via the `sendButton` prop. Stream `Channel` receives it as `SendButton` so it overrides the default arrow button without breaking `WithComponents` overrides. The purple rocket button here is a placeholder; real-world usage is a media-aware button that injects a `LocalAttachment` before sending.', }, }, }