@linktr.ee/messaging-react 1.32.1 → 1.33.0-rc-1777272812

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,17 +7,17 @@ import { renderWithProviders, screen } from '../test/utils'
7
7
  import { ChannelView } from './ChannelView'
8
8
 
9
9
  let activeChannel: unknown
10
+ let activeChannelProps: Record<string, unknown> = {}
10
11
 
11
12
  vi.mock('stream-chat-react', () => ({
12
- Channel: ({
13
- channel,
14
- children,
15
- }: {
13
+ Channel: (props: {
16
14
  channel: unknown
17
15
  children: React.ReactNode
16
+ [key: string]: unknown
18
17
  }) => {
19
- activeChannel = channel
20
- return <div data-testid="channel">{children}</div>
18
+ activeChannel = props.channel
19
+ activeChannelProps = props
20
+ return <div data-testid="channel">{props.children}</div>
21
21
  },
22
22
  Window: ({ children }: { children: React.ReactNode }) => (
23
23
  <div data-testid="window">{children}</div>
@@ -96,6 +96,7 @@ const createChannel = () =>
96
96
  describe('ChannelView', () => {
97
97
  beforeEach(() => {
98
98
  activeChannel = undefined
99
+ activeChannelProps = {}
99
100
  avatarRenderCalls.length = 0
100
101
  mockIsStarred = false
101
102
  })
@@ -193,4 +194,22 @@ describe('ChannelView', () => {
193
194
  )
194
195
  expect(renderMessageInputActions).toHaveBeenCalledWith(channel)
195
196
  })
197
+
198
+ it('passes sendButton to Channel as SendButton when provided', () => {
199
+ const CustomSendButton = () => (
200
+ <button type="button" aria-label="Custom Send" />
201
+ )
202
+
203
+ renderWithProviders(
204
+ <ChannelView channel={createChannel()} sendButton={CustomSendButton} />
205
+ )
206
+
207
+ expect(activeChannelProps.SendButton).toBe(CustomSendButton)
208
+ })
209
+
210
+ it('does not pass SendButton to Channel when sendButton is not provided', () => {
211
+ renderWithProviders(<ChannelView channel={createChannel()} />)
212
+
213
+ expect(activeChannelProps.SendButton).toBeUndefined()
214
+ })
196
215
  })
@@ -376,6 +376,7 @@ export const ChannelView = React.memo<ChannelViewProps>(
376
376
  customProfileContent,
377
377
  customChannelActions,
378
378
  renderMessage,
379
+ sendButton,
379
380
  }) => {
380
381
  // Custom send message handler that:
381
382
  // 1. Applies messageMetadata if provided
@@ -435,6 +436,7 @@ export const ChannelView = React.memo<ChannelViewProps>(
435
436
  LoadingIndicator={LoadingState}
436
437
  DateSeparator={CustomDateSeparator}
437
438
  doSendMessageRequest={doSendMessageRequest}
439
+ {...(sendButton ? { SendButton: sendButton } : {})}
438
440
  >
439
441
  <ChannelViewInner
440
442
  onBack={onBack}
@@ -0,0 +1,180 @@
1
+ import type { Meta, StoryFn } from '@storybook/react'
2
+ import React, { useEffect } from 'react'
3
+ import { QueryChannelAPIResponse, StreamChat } from 'stream-chat'
4
+ import { Channel, Chat } from 'stream-chat-react'
5
+
6
+ import { CustomMessageInput } from '.'
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // Setup helpers (mirrors ChannelView.stories.tsx)
10
+ // ---------------------------------------------------------------------------
11
+
12
+ const mockUser = {
13
+ id: 'storybook-user',
14
+ name: 'Storybook User',
15
+ image: 'https://i.pravatar.cc/150?img=1',
16
+ }
17
+
18
+ const mockParticipant = {
19
+ id: 'participant-1',
20
+ name: 'Jane Creator',
21
+ image: 'https://i.pravatar.cc/150?img=5',
22
+ }
23
+
24
+ const createMockChannel = async (
25
+ client: StreamChat,
26
+ opts: { frozen?: boolean } = {}
27
+ ) => {
28
+ const channelData = { members: [mockUser.id, mockParticipant.id], frozen: opts.frozen ?? false }
29
+ const ch = client.channel('messaging', 'storybook-cmi-channel', channelData)
30
+
31
+ ch.watch = async () => {
32
+ ch.state.members = {
33
+ [mockUser.id]: { user: mockUser, user_id: mockUser.id },
34
+ [mockParticipant.id]: { user: mockParticipant, user_id: mockParticipant.id },
35
+ }
36
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
37
+ ;(ch as any)._data = channelData
38
+ return { channel: channelData, members: [], messages: [], watchers: [], pinned_messages: [], duration: '0ms' } as unknown as QueryChannelAPIResponse
39
+ }
40
+
41
+ try { await ch.watch() } catch (_) { /* mock */ }
42
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
43
+ ;(ch as any)._data = channelData
44
+ return ch
45
+ }
46
+
47
+ // ---------------------------------------------------------------------------
48
+ // Story wrapper — mounts the input inside a real Stream Channel context
49
+ // ---------------------------------------------------------------------------
50
+
51
+ type WrapperProps = {
52
+ frozen?: boolean
53
+ sendButton?: React.ComponentType<{
54
+ sendMessage: (...args: unknown[]) => unknown
55
+ disabled?: boolean
56
+ [key: string]: unknown
57
+ }>
58
+ renderActions?: () => React.ReactNode
59
+ }
60
+
61
+ const Wrapper: React.FC<WrapperProps> = ({ frozen, sendButton, renderActions }) => {
62
+ const [client] = React.useState(() => {
63
+ const c = new StreamChat('mock-api-key', { allowServerSideConnect: true })
64
+ c.userID = mockUser.id
65
+ c.user = mockUser
66
+ return c
67
+ })
68
+
69
+ const [channel, setChannel] = React.useState<ReturnType<StreamChat['channel']> | null>(null)
70
+
71
+ useEffect(() => {
72
+ createMockChannel(client, { frozen }).then(setChannel)
73
+ }, [client, frozen])
74
+
75
+ if (!channel) return <div className="p-4 text-sm text-gray-400">Loading…</div>
76
+
77
+ return (
78
+ <Chat client={client}>
79
+ <Channel
80
+ channel={channel}
81
+ {...(sendButton ? { SendButton: sendButton } : {})}
82
+ >
83
+ <div className="bg-white" style={{ minWidth: 360 }}>
84
+ <CustomMessageInput renderActions={renderActions} />
85
+ </div>
86
+ </Channel>
87
+ </Chat>
88
+ )
89
+ }
90
+
91
+ // ---------------------------------------------------------------------------
92
+ // Meta
93
+ // ---------------------------------------------------------------------------
94
+
95
+ const meta: Meta<WrapperProps> = {
96
+ title: 'CustomMessageInput',
97
+ component: CustomMessageInput,
98
+ parameters: {
99
+ layout: 'centered',
100
+ },
101
+ }
102
+ export default meta
103
+
104
+ // ---------------------------------------------------------------------------
105
+ // Stories
106
+ // ---------------------------------------------------------------------------
107
+
108
+ export const Default: StoryFn<WrapperProps> = (args) => <Wrapper {...args} />
109
+ Default.args = {}
110
+ Default.parameters = {
111
+ docs: {
112
+ description: {
113
+ story:
114
+ 'Default message input. The send button (arrow) is disabled until the user types text — `useMessageComposerHasSendableData` controls this.',
115
+ },
116
+ },
117
+ }
118
+
119
+ export const Frozen: StoryFn<WrapperProps> = (args) => <Wrapper {...args} />
120
+ Frozen.args = { frozen: true }
121
+ Frozen.parameters = {
122
+ docs: {
123
+ description: {
124
+ story:
125
+ 'Frozen channel state: the entire input area fades out (`aria-disabled`, `inert`), the textarea becomes read-only, and the send button is disabled.',
126
+ },
127
+ },
128
+ }
129
+
130
+ export const WithCustomActionButton: StoryFn<WrapperProps> = (args) => <Wrapper {...args} />
131
+ WithCustomActionButton.args = {
132
+ renderActions: () => (
133
+ <button
134
+ type="button"
135
+ aria-label="Attach media"
136
+ className="flex items-center justify-center size-10 rounded-full bg-[#F1F0EE] hover:bg-[#E5E4E1] shrink-0"
137
+ onClick={() => console.log('media picker')}
138
+ >
139
+ 📷
140
+ </button>
141
+ ),
142
+ }
143
+ WithCustomActionButton.parameters = {
144
+ docs: {
145
+ description: {
146
+ story:
147
+ 'Shows how `renderActions` injects extra controls (e.g. a media picker button) to the left of the message bubble.',
148
+ },
149
+ },
150
+ }
151
+
152
+ /** A custom send button that turns purple to signal a media attachment is staged. */
153
+ const PurpleMediaSendButton: React.FC<{
154
+ sendMessage: (...args: unknown[]) => unknown
155
+ disabled?: boolean
156
+ [key: string]: unknown
157
+ }> = ({ sendMessage, disabled, ...rest }) => (
158
+ <button
159
+ {...rest}
160
+ type="button"
161
+ aria-label="Send"
162
+ disabled={disabled}
163
+ onClick={() => sendMessage()}
164
+ className="str-chat__send-button mt-auto flex items-center justify-center shrink-0 rounded-full size-8 bg-[#6D28D9] disabled:bg-[#F1F0EE] disabled:text-black/20 text-white"
165
+ style={{ fontSize: 15 }}
166
+ >
167
+ 🚀
168
+ </button>
169
+ )
170
+
171
+ export const WithCustomSendButton: StoryFn<WrapperProps> = (args) => <Wrapper {...args} />
172
+ WithCustomSendButton.args = { sendButton: PurpleMediaSendButton }
173
+ WithCustomSendButton.parameters = {
174
+ docs: {
175
+ description: {
176
+ story:
177
+ 'Passes a custom `SendButton` via `Channel`\'s `SendButton` prop (which is how `ChannelView`\'s `sendButton` prop surfaces it). `CustomMessageInput` reads the button from `useComponentContext` and falls back to the default when none is provided.',
178
+ },
179
+ },
180
+ }
@@ -1,5 +1,5 @@
1
1
  import React from 'react'
2
- import { describe, expect, it, vi } from 'vitest'
2
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
3
3
 
4
4
  import { renderWithProviders, screen } from '../../test/utils'
5
5
 
@@ -7,6 +7,21 @@ import { CustomMessageInput } from '.'
7
7
 
8
8
  let mockChannelData: Record<string, unknown> = {}
9
9
 
10
+ let mockContextSendButton:
11
+ | React.ComponentType<{
12
+ sendMessage: (...args: unknown[]) => unknown
13
+ disabled?: boolean
14
+ }>
15
+ | undefined = ({ sendMessage, disabled }) => (
16
+ <button
17
+ type="button"
18
+ data-testid="send-button"
19
+ aria-label="Send"
20
+ disabled={disabled}
21
+ onClick={() => sendMessage()}
22
+ />
23
+ )
24
+
10
25
  vi.mock('stream-chat-react', () => ({
11
26
  MessageInput: ({
12
27
  Input,
@@ -35,6 +50,9 @@ vi.mock('stream-chat-react', () => ({
35
50
  handleSubmit: vi.fn(),
36
51
  }),
37
52
  useMessageComposerHasSendableData: () => false,
53
+ useComponentContext: () => ({
54
+ SendButton: mockContextSendButton,
55
+ }),
38
56
  }))
39
57
 
40
58
  vi.mock('../CustomLinkPreviewList', () => ({
@@ -42,6 +60,18 @@ vi.mock('../CustomLinkPreviewList', () => ({
42
60
  }))
43
61
 
44
62
  describe('CustomMessageInput', () => {
63
+ beforeEach(() => {
64
+ mockContextSendButton = ({ sendMessage, disabled }) => (
65
+ <button
66
+ type="button"
67
+ data-testid="send-button"
68
+ aria-label="Send"
69
+ disabled={disabled}
70
+ onClick={() => sendMessage()}
71
+ />
72
+ )
73
+ })
74
+
45
75
  it('renders the interactive message input when channel is not frozen', () => {
46
76
  mockChannelData = {}
47
77
 
@@ -124,4 +154,36 @@ describe('CustomMessageInput', () => {
124
154
  expect(messageInput).not.toHaveAttribute('aria-disabled')
125
155
  expect(screen.getByTestId('custom-action')).toBeInTheDocument()
126
156
  })
157
+
158
+ it('renders the custom SendButton from component context', () => {
159
+ mockChannelData = {}
160
+ mockContextSendButton = () => (
161
+ <button type="button" data-testid="custom-context-send-button" aria-label="Custom Send" />
162
+ )
163
+
164
+ renderWithProviders(<CustomMessageInput />)
165
+
166
+ expect(screen.getByTestId('custom-context-send-button')).toBeInTheDocument()
167
+ expect(screen.queryByTestId('default-send-button')).not.toBeInTheDocument()
168
+ })
169
+
170
+ it('falls back to the arrow send button when component context provides none', () => {
171
+ mockChannelData = {}
172
+ mockContextSendButton = undefined
173
+
174
+ renderWithProviders(<CustomMessageInput />)
175
+
176
+ expect(screen.getByTestId('send-button')).toBeInTheDocument()
177
+ expect(screen.queryByTestId('custom-context-send-button')).not.toBeInTheDocument()
178
+ })
179
+
180
+ it('preserves disabled state on the send button when there is no sendable data', () => {
181
+ mockChannelData = {}
182
+
183
+ renderWithProviders(<CustomMessageInput />)
184
+
185
+ const sendButton = screen.getByRole('button', { name: /send/i })
186
+ // useMessageComposerHasSendableData is mocked to return false
187
+ expect(sendButton).toBeDisabled()
188
+ })
127
189
  })
@@ -7,16 +7,37 @@ import {
7
7
  SimpleAttachmentSelector,
8
8
  TextareaComposer,
9
9
  useChannelStateContext,
10
+ useComponentContext,
10
11
  useMessageComposerHasSendableData,
11
12
  useMessageInputContext,
12
13
  } from 'stream-chat-react'
13
14
 
14
15
  import { CustomLinkPreviewList } from '../CustomLinkPreviewList'
15
16
 
17
+ const DefaultSendButton: React.FC<{
18
+ sendMessage: () => void
19
+ disabled?: boolean
20
+ [key: string]: unknown
21
+ }> = ({ sendMessage, disabled, ...rest }) => (
22
+ <button
23
+ {...rest}
24
+ type="button"
25
+ aria-label="Send"
26
+ disabled={disabled}
27
+ onClick={sendMessage}
28
+ >
29
+ <ArrowUpIcon weight="bold" className="size-4" />
30
+ </button>
31
+ )
32
+
16
33
  const CustomMessageInputInner: React.FC = () => {
17
34
  const { channel } = useChannelStateContext()
18
35
  const isFrozen = channel?.data?.frozen === true
19
36
  const { handleSubmit } = useMessageInputContext()
37
+ const { SendButton: SendButtonFromContext } = useComponentContext(
38
+ 'CustomMessageInput',
39
+ )
40
+ const SendButton = SendButtonFromContext ?? DefaultSendButton
20
41
  const hasSendableData = useMessageComposerHasSendableData()
21
42
  const isSendDisabled = isFrozen || !hasSendableData
22
43
 
@@ -44,16 +65,14 @@ const CustomMessageInputInner: React.FC = () => {
44
65
  tabIndex={isFrozen ? -1 : undefined}
45
66
  />
46
67
  </div>
47
- <button
68
+ <SendButton
69
+ sendMessage={handleSubmit}
48
70
  aria-label="Send"
49
71
  className="str-chat__send-button mt-auto flex justify-center items-center flex-shrink-0 rounded-full size-8 bg-[#121110] disabled:bg-[#F1F0EE] disabled:text-black/20 text-white focus-ring"
50
72
  data-testid="send-button"
51
73
  disabled={isSendDisabled}
52
- onClick={handleSubmit}
53
74
  type="button"
54
- >
55
- <ArrowUpIcon className="size-4" />
56
- </button>
75
+ />
57
76
  </div>
58
77
  </div>
59
78
  </>
@@ -0,0 +1,170 @@
1
+ import type { Meta, StoryFn } from '@storybook/react'
2
+ import React from 'react'
3
+ import type { LocalMessage } from 'stream-chat'
4
+
5
+ import { MediaMessage } from '.'
6
+
7
+ const meta: Meta<typeof MediaMessage> = {
8
+ title: 'Components/MediaMessage',
9
+ component: MediaMessage,
10
+ parameters: {
11
+ layout: 'centered',
12
+ },
13
+ }
14
+ export default meta
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Shared helpers
18
+ // ---------------------------------------------------------------------------
19
+
20
+ const SENDER = { id: 'user-other', name: 'Jane Creator', image: 'https://i.pravatar.cc/40?img=5' }
21
+ const ME = { id: 'user-me', name: 'Me' }
22
+
23
+ const base = (overrides: Partial<LocalMessage> = {}): LocalMessage => ({
24
+ id: 'msg-1',
25
+ text: '',
26
+ type: 'regular',
27
+ created_at: new Date(),
28
+ updated_at: new Date(),
29
+ user: SENDER,
30
+ attachments: [],
31
+ ...overrides,
32
+ })
33
+
34
+ const VARIANTS = [
35
+ {
36
+ label: 'Video',
37
+ attachment: {
38
+ type: 'video',
39
+ asset_url: 'https://www.w3schools.com/html/mov_bbb.mp4',
40
+ mime_type: 'video/mp4',
41
+ title: "Alicia's Workout Plan",
42
+ file_size: 788_456,
43
+ },
44
+ },
45
+ {
46
+ label: 'Audio',
47
+ attachment: {
48
+ type: 'audio',
49
+ asset_url: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3',
50
+ mime_type: 'audio/mpeg',
51
+ title: 'Morning Meditation',
52
+ file_size: 6_900_000,
53
+ },
54
+ },
55
+ {
56
+ label: 'Image',
57
+ attachment: {
58
+ type: 'image',
59
+ image_url: 'https://picsum.photos/seed/storybook/560/315',
60
+ mime_type: 'image/jpeg',
61
+ title: 'Picture of my cat',
62
+ file_size: 3_355_443,
63
+ },
64
+ },
65
+ {
66
+ label: 'Document',
67
+ attachment: {
68
+ type: 'file',
69
+ asset_url: 'https://www.w3.org/WAI/WCAG21/wcag21.pdf',
70
+ mime_type: 'application/pdf',
71
+ title: 'Strength Training Guide',
72
+ file_size: 1_240_000,
73
+ },
74
+ },
75
+ {
76
+ label: 'Unknown',
77
+ attachment: {
78
+ type: 'file',
79
+ asset_url: 'https://www.w3.org/WAI/WCAG21/wcag21.pdf',
80
+ mime_type: 'application/octet-stream',
81
+ title: 'Unknown Attachment',
82
+ file_size: 456_000,
83
+ },
84
+ },
85
+ ] as const
86
+
87
+ // ---------------------------------------------------------------------------
88
+ // Layout primitives
89
+ // ---------------------------------------------------------------------------
90
+
91
+ const GridTable: React.FC<{ children: React.ReactNode }> = ({ children }) => (
92
+ <div className="p-12 bg-[#F9F7F4]">
93
+ <table className="border-separate border-spacing-4">{children}</table>
94
+ </div>
95
+ )
96
+
97
+ const GridHead = () => (
98
+ <thead>
99
+ <tr>
100
+ <th className="text-left text-xs font-medium text-black/40 pb-2 w-16" />
101
+ {VARIANTS.map(({ label }) => (
102
+ <th key={label} className="text-left text-xs font-medium text-black/40 pb-2">
103
+ {label}
104
+ </th>
105
+ ))}
106
+ </tr>
107
+ </thead>
108
+ )
109
+
110
+ const RowLabel: React.FC<{ children: React.ReactNode }> = ({ children }) => (
111
+ <td className="text-xs text-right font-medium text-black/40 pr-4 align-top pt-2">
112
+ {children}
113
+ </td>
114
+ )
115
+
116
+ // ---------------------------------------------------------------------------
117
+ // Stories
118
+ // ---------------------------------------------------------------------------
119
+
120
+ export const Visitor: StoryFn = () => (
121
+ <GridTable>
122
+ <GridHead />
123
+ <tbody>
124
+ <tr>
125
+ <RowLabel>Sent</RowLabel>
126
+ {VARIANTS.map(({ label, attachment }) => (
127
+ <td key={label} className="align-top">
128
+ <MediaMessage
129
+ isMyMessage={false}
130
+ message={base({ user: SENDER, attachments: [attachment as LocalMessage['attachments'][number]] })}
131
+ />
132
+ </td>
133
+ ))}
134
+ </tr>
135
+ </tbody>
136
+ </GridTable>
137
+ )
138
+ Visitor.parameters = {
139
+ docs: {
140
+ description: {
141
+ story: 'Visitor perspective — messages from the creator, left-aligned with avatar.',
142
+ },
143
+ },
144
+ }
145
+
146
+ export const Creator: StoryFn = () => (
147
+ <GridTable>
148
+ <GridHead />
149
+ <tbody>
150
+ <tr>
151
+ <RowLabel>Sent</RowLabel>
152
+ {VARIANTS.map(({ label, attachment }) => (
153
+ <td key={label} className="align-top">
154
+ <MediaMessage
155
+ isMyMessage={true}
156
+ message={base({ user: ME, attachments: [attachment as LocalMessage['attachments'][number]] })}
157
+ />
158
+ </td>
159
+ ))}
160
+ </tr>
161
+ </tbody>
162
+ </GridTable>
163
+ )
164
+ Creator.parameters = {
165
+ docs: {
166
+ description: {
167
+ story: 'Creator perspective — own messages, right-aligned, no avatar.',
168
+ },
169
+ },
170
+ }