@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.
@@ -0,0 +1,261 @@
1
+ import React from 'react'
2
+ import type { LocalMessage } from 'stream-chat'
3
+ import { describe, expect, it, vi } from 'vitest'
4
+
5
+ import { renderWithProviders, screen } from '../../test/utils'
6
+
7
+ import { MediaMessage } from '.'
8
+
9
+ vi.mock('../Avatar', () => ({
10
+ Avatar: ({ id }: { id: string }) => (
11
+ <div data-testid="avatar" data-user-id={id} />
12
+ ),
13
+ }))
14
+
15
+ vi.mock('../LockedAttachment/components/MediaPlayer', () => ({
16
+ default: ({
17
+ source,
18
+ mimeType,
19
+ }: {
20
+ source: string
21
+ mimeType: string
22
+ }) => (
23
+ <div
24
+ data-testid="media-player"
25
+ data-source={source}
26
+ data-mime-type={mimeType}
27
+ />
28
+ ),
29
+ }))
30
+
31
+ vi.mock('../LockedAttachment/utils/icons', () => ({
32
+ renderTypeIcon: () => <span data-testid="type-icon" />,
33
+ }))
34
+
35
+ vi.mock('../LockedAttachment/utils/mimeType', () => ({
36
+ getSourceType: (mimeType: string) => {
37
+ if (mimeType.startsWith('video/')) return 'video'
38
+ if (mimeType.startsWith('image/')) return 'image'
39
+ if (mimeType.startsWith('audio/')) return 'audio'
40
+ return 'document'
41
+ },
42
+ }))
43
+
44
+ // Cast through unknown to avoid satisfying every optional field of LocalMessage
45
+ const msg = (overrides: Record<string, unknown> = {}): LocalMessage =>
46
+ ({
47
+ id: 'msg-1',
48
+ text: '',
49
+ type: 'regular',
50
+ created_at: new Date(),
51
+ updated_at: new Date(),
52
+ deleted_at: null,
53
+ pinned_at: null,
54
+ status: 'received',
55
+ user: { id: 'user-1', name: 'Alice' },
56
+ attachments: [],
57
+ ...overrides,
58
+ }) as unknown as LocalMessage
59
+
60
+ describe('MediaMessage', () => {
61
+ it('renders nothing when no media URL is resolvable', () => {
62
+ const { container } = renderWithProviders(<MediaMessage message={msg()} />)
63
+ expect(container.firstChild).toBeNull()
64
+ })
65
+
66
+ it('renders MediaPlayer for a video attachment', () => {
67
+ renderWithProviders(
68
+ <MediaMessage
69
+ message={msg({
70
+ attachments: [
71
+ {
72
+ type: 'video',
73
+ asset_url: 'https://cdn.example.com/clip.mp4',
74
+ mime_type: 'video/mp4',
75
+ },
76
+ ],
77
+ })}
78
+ />
79
+ )
80
+
81
+ const player = screen.getByTestId('media-player')
82
+ expect(player).toBeInTheDocument()
83
+ expect(player).toHaveAttribute(
84
+ 'data-source',
85
+ 'https://cdn.example.com/clip.mp4'
86
+ )
87
+ expect(player).toHaveAttribute('data-mime-type', 'video/mp4')
88
+ })
89
+
90
+ it('renders MediaPlayer for an audio attachment', () => {
91
+ renderWithProviders(
92
+ <MediaMessage
93
+ message={msg({
94
+ attachments: [
95
+ {
96
+ type: 'audio',
97
+ asset_url: 'https://cdn.example.com/track.mp3',
98
+ mime_type: 'audio/mpeg',
99
+ },
100
+ ],
101
+ })}
102
+ />
103
+ )
104
+
105
+ expect(screen.getByTestId('media-player')).toHaveAttribute(
106
+ 'data-source',
107
+ 'https://cdn.example.com/track.mp3'
108
+ )
109
+ })
110
+
111
+ it('renders an img for an image attachment', () => {
112
+ renderWithProviders(
113
+ <MediaMessage
114
+ message={msg({
115
+ attachments: [
116
+ {
117
+ type: 'image',
118
+ image_url: 'https://cdn.example.com/photo.jpg',
119
+ mime_type: 'image/jpeg',
120
+ title: 'My Photo',
121
+ },
122
+ ],
123
+ })}
124
+ />
125
+ )
126
+
127
+ const image = screen.getByRole('img', { name: 'My Photo' })
128
+ expect(image).toHaveAttribute('src', 'https://cdn.example.com/photo.jpg')
129
+ })
130
+
131
+ it('renders a download link for a document attachment', () => {
132
+ renderWithProviders(
133
+ <MediaMessage
134
+ message={msg({
135
+ attachments: [
136
+ {
137
+ type: 'file',
138
+ asset_url: 'https://cdn.example.com/report.pdf',
139
+ mime_type: 'application/pdf',
140
+ title: 'Annual Report',
141
+ },
142
+ ],
143
+ })}
144
+ />
145
+ )
146
+
147
+ const link = screen.getByRole('link')
148
+ expect(link).toHaveAttribute('href', 'https://cdn.example.com/report.pdf')
149
+ expect(screen.getByText('Annual Report')).toBeInTheDocument()
150
+ expect(screen.getByTestId('type-icon')).toBeInTheDocument()
151
+ })
152
+
153
+ it('shows title and file size below video', () => {
154
+ renderWithProviders(
155
+ <MediaMessage
156
+ message={msg({
157
+ attachments: [
158
+ {
159
+ type: 'video',
160
+ asset_url: 'https://cdn.example.com/clip.mp4',
161
+ mime_type: 'video/mp4',
162
+ title: 'Clip',
163
+ file_size: 2048,
164
+ },
165
+ ],
166
+ })}
167
+ />
168
+ )
169
+
170
+ expect(screen.getByText('Clip')).toBeInTheDocument()
171
+ expect(screen.getByText('2.0 KB')).toBeInTheDocument()
172
+ })
173
+
174
+ it('renders Avatar for a message from another user', () => {
175
+ renderWithProviders(
176
+ <MediaMessage
177
+ isMyMessage={false}
178
+ message={msg({
179
+ attachments: [
180
+ {
181
+ type: 'image',
182
+ image_url: 'https://cdn.example.com/photo.jpg',
183
+ mime_type: 'image/jpeg',
184
+ },
185
+ ],
186
+ })}
187
+ />
188
+ )
189
+
190
+ expect(screen.getByTestId('avatar')).toBeInTheDocument()
191
+ expect(screen.getByTestId('avatar')).toHaveAttribute(
192
+ 'data-user-id',
193
+ 'user-1'
194
+ )
195
+ })
196
+
197
+ it('does not render Avatar for own messages', () => {
198
+ renderWithProviders(
199
+ <MediaMessage
200
+ isMyMessage={true}
201
+ message={msg({
202
+ attachments: [
203
+ {
204
+ type: 'image',
205
+ image_url: 'https://cdn.example.com/photo.jpg',
206
+ mime_type: 'image/jpeg',
207
+ },
208
+ ],
209
+ })}
210
+ />
211
+ )
212
+
213
+ expect(screen.queryByTestId('avatar')).not.toBeInTheDocument()
214
+ })
215
+
216
+ it('applies the --me class for own messages', () => {
217
+ const { container } = renderWithProviders(
218
+ <MediaMessage
219
+ isMyMessage={true}
220
+ message={msg({
221
+ attachments: [
222
+ {
223
+ type: 'image',
224
+ image_url: 'https://cdn.example.com/photo.jpg',
225
+ mime_type: 'image/jpeg',
226
+ },
227
+ ],
228
+ })}
229
+ />
230
+ )
231
+
232
+ expect(container.firstChild).toHaveClass('str-chat__message--me')
233
+ })
234
+
235
+ it('applies the --other class for messages from other users', () => {
236
+ const { container } = renderWithProviders(
237
+ <MediaMessage
238
+ isMyMessage={false}
239
+ message={msg({
240
+ attachments: [
241
+ {
242
+ type: 'image',
243
+ image_url: 'https://cdn.example.com/photo.jpg',
244
+ mime_type: 'image/jpeg',
245
+ },
246
+ ],
247
+ })}
248
+ />
249
+ )
250
+
251
+ expect(container.firstChild).toHaveClass('str-chat__message--other')
252
+ })
253
+
254
+ it('renders nothing when no attachments', () => {
255
+ const { container } = renderWithProviders(
256
+ <MediaMessage message={msg({ attachments: [] })} />
257
+ )
258
+
259
+ expect(container.firstChild).toBeNull()
260
+ })
261
+ })
@@ -0,0 +1,165 @@
1
+ import React from 'react'
2
+ import type { LocalMessage } from 'stream-chat'
3
+
4
+ import { Avatar } from '../Avatar'
5
+ import MediaPlayer from '../LockedAttachment/components/MediaPlayer'
6
+ import { renderTypeIcon } from '../LockedAttachment/utils/icons'
7
+ import { getSourceType } from '../LockedAttachment/utils/mimeType'
8
+
9
+ function formatBytes(bytes: number): string {
10
+ if (bytes < 1024) return `${bytes} B`
11
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
12
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
13
+ }
14
+
15
+ const CARD_CLASS =
16
+ 'w-[280px] select-none overflow-hidden rounded-[24px] bg-white shadow-[0_0_0_1px_rgba(0,0,0,0.04),0_4px_8px_rgba(0,0,0,0.06)]'
17
+
18
+ const MediaMeta: React.FC<{ mimeType: string; title?: string; fileSize?: number }> = ({
19
+ mimeType,
20
+ title,
21
+ fileSize,
22
+ }) => {
23
+ if (!title && fileSize === undefined) return null
24
+ return (
25
+ <div className="px-4 pb-3 pt-3">
26
+ {title && (
27
+ <p className="mb-1.5 truncate text-base font-medium text-black">{title}</p>
28
+ )}
29
+ {fileSize !== undefined && (
30
+ <div className="flex items-center gap-1">
31
+ {renderTypeIcon(mimeType, { className: 'size-5 shrink-0 text-black/55', weight: 'regular' })}
32
+ <span className="text-xs font-medium text-black/55">{formatBytes(fileSize)}</span>
33
+ </div>
34
+ )}
35
+ </div>
36
+ )
37
+ }
38
+
39
+ const FileCard: React.FC<{
40
+ url: string
41
+ mimeType: string
42
+ title?: string
43
+ fileSize?: number
44
+ }> = ({ url, mimeType, title, fileSize }) => (
45
+ <a href={url} target="_blank" rel="noopener noreferrer" className="block no-underline">
46
+ <div className="aspect-video w-full bg-black/5 flex items-center justify-center">
47
+ {renderTypeIcon(mimeType, { className: 'size-12 text-black/20', weight: 'regular' })}
48
+ </div>
49
+ <div className="px-4 pb-3 pt-3">
50
+ {title && (
51
+ <p className="mb-1.5 truncate text-base font-medium text-black">{title}</p>
52
+ )}
53
+ {fileSize !== undefined && (
54
+ <div className="flex items-center gap-1">
55
+ {renderTypeIcon(mimeType, { className: 'size-5 shrink-0 text-black/55', weight: 'regular' })}
56
+ <span className="text-xs font-medium text-black/55">{formatBytes(fileSize)}</span>
57
+ </div>
58
+ )}
59
+ </div>
60
+ </a>
61
+ )
62
+
63
+ export interface MediaMessageProps {
64
+ message: LocalMessage
65
+ isMyMessage?: boolean
66
+ }
67
+
68
+ export const MediaMessage: React.FC<MediaMessageProps> = ({
69
+ message,
70
+ isMyMessage = false,
71
+ }) => {
72
+ const videoAttachment = message.attachments?.find(
73
+ (a) => a.type === 'video' && a.asset_url
74
+ )
75
+ const imageAttachment = message.attachments?.find(
76
+ (a) => a.type === 'image' && (a as { image_url?: string }).image_url
77
+ )
78
+ const audioAttachment = message.attachments?.find(
79
+ (a) => a.type === 'audio' && a.asset_url
80
+ )
81
+ const fileAttachment = message.attachments?.find(
82
+ (a) => a.type === 'file' && a.asset_url
83
+ )
84
+
85
+ const activeAttachment =
86
+ videoAttachment ?? imageAttachment ?? audioAttachment ?? fileAttachment
87
+
88
+ const resolvedUrl =
89
+ videoAttachment?.asset_url ??
90
+ (imageAttachment as { image_url?: string } | undefined)?.image_url ??
91
+ audioAttachment?.asset_url ??
92
+ fileAttachment?.asset_url
93
+
94
+ const resolvedType =
95
+ activeAttachment?.mime_type ??
96
+ (imageAttachment
97
+ ? 'image/jpeg'
98
+ : videoAttachment
99
+ ? 'video/mp4'
100
+ : audioAttachment
101
+ ? 'audio/mpeg'
102
+ : 'application/octet-stream')
103
+
104
+ if (!resolvedUrl) return null
105
+
106
+ const sourceType = getSourceType(resolvedType)
107
+ const title = (activeAttachment as { title?: string } | undefined)?.title
108
+ const fileSize = (activeAttachment as { file_size?: number } | undefined)?.file_size
109
+ const thumbnailUrl = (videoAttachment as { thumb_url?: string } | undefined)?.thumb_url
110
+
111
+ const messageClass = isMyMessage
112
+ ? 'str-chat__message str-chat__message-simple str-chat__message--me str-chat__message-simple--me'
113
+ : 'str-chat__message str-chat__message-simple str-chat__message--other'
114
+
115
+ return (
116
+ <div className={messageClass}>
117
+ {!isMyMessage && message.user && (
118
+ <Avatar
119
+ className="str-chat__avatar str-chat__message-sender-avatar"
120
+ id={message.user.id}
121
+ image={message.user.image}
122
+ name={message.user.name ?? message.user.id}
123
+ />
124
+ )}
125
+ <div
126
+ className="str-chat__message-inner"
127
+ style={{ marginInlineEnd: 0, marginInlineStart: 0 }}
128
+ >
129
+ <div className="str-chat__message-bubble-wrapper">
130
+ <div className="str-chat__message-bubble" style={{ padding: 0, borderRadius: 0, overflow: 'visible', background: 'transparent' }}>
131
+ <div className={CARD_CLASS}>
132
+ {sourceType === 'image' ? (
133
+ <>
134
+ <img
135
+ src={resolvedUrl}
136
+ alt={title ?? ''}
137
+ className="block w-full"
138
+ />
139
+ <MediaMeta mimeType={resolvedType} title={title} fileSize={fileSize} />
140
+ </>
141
+ ) : sourceType === 'document' ? (
142
+ <FileCard
143
+ url={resolvedUrl}
144
+ mimeType={resolvedType}
145
+ title={title}
146
+ fileSize={fileSize}
147
+ />
148
+ ) : (
149
+ <>
150
+ <MediaPlayer
151
+ source={resolvedUrl}
152
+ mimeType={resolvedType}
153
+ poster={thumbnailUrl}
154
+ controls
155
+ />
156
+ <MediaMeta mimeType={resolvedType} title={title} fileSize={fileSize} />
157
+ </>
158
+ )}
159
+ </div>
160
+ </div>
161
+ </div>
162
+ </div>
163
+ </div>
164
+ )
165
+ }
@@ -42,6 +42,7 @@ export const MessagingShell: React.FC<MessagingShellProps> = ({
42
42
  customProfileContent,
43
43
  customChannelActions,
44
44
  renderMessage,
45
+ sendButton,
45
46
  }) => {
46
47
  const {
47
48
  service,
@@ -505,6 +506,7 @@ export const MessagingShell: React.FC<MessagingShellProps> = ({
505
506
  customProfileContent={customProfileContent}
506
507
  customChannelActions={customChannelActions}
507
508
  renderMessage={renderMessage}
509
+ sendButton={sendButton}
508
510
  />
509
511
  </div>
510
512
  ) : initialParticipantFilter ? (
package/src/index.ts CHANGED
@@ -13,6 +13,8 @@ export { FaqList } from './components/FaqList'
13
13
  export { FaqListItem } from './components/FaqList/FaqListItem'
14
14
  export { ChannelEmptyState } from './components/MessagingShell/ChannelEmptyState'
15
15
  export { MessageVoteButtons } from './components/CustomMessage/MessageVoteButtons'
16
+ export { MediaMessage } from './components/MediaMessage'
17
+ export type { MediaMessageProps } from './components/MediaMessage'
16
18
 
17
19
  // Providers
18
20
  export { MessagingProvider } from './providers/MessagingProvider'
package/src/types.ts CHANGED
@@ -2,6 +2,7 @@ import type {
2
2
  MessagingUser,
3
3
  StreamChatServiceConfig,
4
4
  } from '@linktr.ee/messaging-core'
5
+ import type { ComponentType } from 'react'
5
6
  import type {
6
7
  Channel,
7
8
  ChannelFilters,
@@ -234,6 +235,17 @@ export interface ChannelViewProps {
234
235
  message: LocalMessage
235
236
  ) => React.ReactNode
236
237
 
238
+ /**
239
+ * Passed to Stream `Channel` as `SendButton`. Required for hosts that replace
240
+ * the send control: `Channel` merges this into `ComponentContext` and an
241
+ * explicit `SendButton: undefined` would otherwise override outer
242
+ * `WithComponents` overrides.
243
+ *
244
+ * @example
245
+ * sendButton={MediaSendButton}
246
+ */
247
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
248
+ sendButton?: ComponentType<any>
237
249
  }
238
250
 
239
251
  /**
@@ -257,6 +269,7 @@ export type ChannelViewPassthroughProps = Pick<
257
269
  | 'customProfileContent'
258
270
  | 'customChannelActions'
259
271
  | 'renderMessage'
272
+ | 'sendButton'
260
273
  >
261
274
 
262
275
  /**