@linktr.ee/messaging-react 1.25.1 → 1.26.1-rc-1776055454

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.
Files changed (47) hide show
  1. package/dist/Preview-DqAv16NS.js +87 -0
  2. package/dist/Preview-DqAv16NS.js.map +1 -0
  3. package/dist/dash.all.min-Duv4lvGS.js +18858 -0
  4. package/dist/dash.all.min-Duv4lvGS.js.map +1 -0
  5. package/dist/hls-Bogc7CBn.js +21710 -0
  6. package/dist/hls-Bogc7CBn.js.map +1 -0
  7. package/dist/index-Da-xN4Yq.js +16142 -0
  8. package/dist/index-Da-xN4Yq.js.map +1 -0
  9. package/dist/index-Dj9rqWcU.js +69 -0
  10. package/dist/index-Dj9rqWcU.js.map +1 -0
  11. package/dist/index.d.ts +85 -9
  12. package/dist/index.js +1745 -1156
  13. package/dist/index.js.map +1 -1
  14. package/dist/mixin-B6jYfIcp.js +808 -0
  15. package/dist/mixin-B6jYfIcp.js.map +1 -0
  16. package/dist/react-BxlQMOfz.js +419 -0
  17. package/dist/react-BxlQMOfz.js.map +1 -0
  18. package/dist/react-COAP-MIW.js +377 -0
  19. package/dist/react-COAP-MIW.js.map +1 -0
  20. package/dist/react-Cn4WlMcl.js +3108 -0
  21. package/dist/react-Cn4WlMcl.js.map +1 -0
  22. package/dist/react-CwTJArKY.js +459 -0
  23. package/dist/react-CwTJArKY.js.map +1 -0
  24. package/dist/react-DkfS_atT.js +373 -0
  25. package/dist/react-DkfS_atT.js.map +1 -0
  26. package/dist/react-Pea5fum1.js +286 -0
  27. package/dist/react-Pea5fum1.js.map +1 -0
  28. package/dist/react-RiBbsUDd.js +534 -0
  29. package/dist/react-RiBbsUDd.js.map +1 -0
  30. package/dist/react-dS1WBxxz.js +238 -0
  31. package/dist/react-dS1WBxxz.js.map +1 -0
  32. package/package.json +2 -1
  33. package/src/components/ChannelList/index.test.tsx +18 -0
  34. package/src/components/ChannelList/index.tsx +2 -0
  35. package/src/components/ChannelView.test.tsx +50 -1
  36. package/src/components/ChannelView.tsx +13 -3
  37. package/src/components/CustomMessage/CustomMessage.stories.tsx +61 -2
  38. package/src/components/CustomMessage/MessageTag.tsx +5 -0
  39. package/src/components/CustomMessage/index.tsx +46 -4
  40. package/src/components/LockedAttachmentCard/LockedAttachmentCard.stories.tsx +351 -0
  41. package/src/components/LockedAttachmentCard/index.tsx +378 -0
  42. package/src/components/LockedAttachmentCard/mimeType.test.ts +97 -0
  43. package/src/components/LockedAttachmentCard/mimeType.ts +35 -0
  44. package/src/components/MessagingShell/index.tsx +2 -0
  45. package/src/index.ts +4 -0
  46. package/src/stream-custom-data.ts +10 -3
  47. package/src/types.ts +41 -0
@@ -0,0 +1,378 @@
1
+ import {
2
+ DownloadSimpleIcon,
3
+ CircleNotchIcon,
4
+ CheckCircleIcon,
5
+ FileIcon,
6
+ FileCsvIcon,
7
+ FileDocIcon,
8
+ FileMdIcon,
9
+ FilePdfIcon,
10
+ FilePptIcon,
11
+ FileTextIcon,
12
+ FileXlsIcon,
13
+ FileZipIcon,
14
+ ImageIcon,
15
+ LockSimpleIcon,
16
+ LockSimpleOpenIcon,
17
+ PauseIcon,
18
+ PlayIcon,
19
+ SpeakerHighIcon,
20
+ VideoCameraIcon,
21
+ } from '@phosphor-icons/react'
22
+ import React, { useCallback, useEffect, useRef, useState } from 'react'
23
+ import ReactPlayer from 'react-player'
24
+
25
+ import { getDocumentIconType, getSourceType } from './mimeType'
26
+ import type { AttachmentSourceType } from './mimeType'
27
+
28
+ export interface LockedAttachmentCardProps {
29
+ title: string
30
+ amountText?: string
31
+ thumbnail?: string
32
+ /**
33
+ * The unlocked media URL. When undefined the card is in locked state.
34
+ * Typically undefined while the source is being fetched post-purchase.
35
+ */
36
+ source?: string
37
+ /** MIME type of the attachment (e.g. 'video/mp4', 'audio/mpeg', 'image/jpeg', 'application/pdf'). Determines the icon and player behaviour. */
38
+ mimeType: string
39
+ detail?: string
40
+ /**
41
+ * Called when the visitor clicks Unlock.
42
+ * Omit to hide the Unlock button (e.g. creator-side views).
43
+ */
44
+ onUnlock?: () => void
45
+ /** Called when the visitor clicks Download on an unlocked card. */
46
+ onDownload?: () => void
47
+ /** Shows a loading spinner on the unlock button while the source is being fetched. */
48
+ loading?: boolean
49
+ /**
50
+ * Payment has been completed but the source URL hasn't been resolved yet.
51
+ * Shows a "Preparing…" state instead of the lock overlay.
52
+ */
53
+ isPurchased?: boolean
54
+ }
55
+
56
+ const MEDIA_TYPE_ICON: Record<AttachmentSourceType, React.ElementType> = {
57
+ video: VideoCameraIcon,
58
+ audio: SpeakerHighIcon,
59
+ image: ImageIcon,
60
+ document: FileIcon,
61
+ }
62
+
63
+ const DOCUMENT_ICON_COMPONENT = {
64
+ pdf: FilePdfIcon,
65
+ doc: FileDocIcon,
66
+ xls: FileXlsIcon,
67
+ csv: FileCsvIcon,
68
+ ppt: FilePptIcon,
69
+ zip: FileZipIcon,
70
+ text: FileTextIcon,
71
+ markdown: FileMdIcon,
72
+ generic: FileIcon,
73
+ } as const
74
+
75
+ function getTypeIcon(mimeType: string): React.ElementType {
76
+ const sourceType = getSourceType(mimeType)
77
+ if (sourceType !== 'document') return MEDIA_TYPE_ICON[sourceType]
78
+ return DOCUMENT_ICON_COMPONENT[getDocumentIconType(mimeType)]
79
+ }
80
+
81
+ const LockedAttachmentCard: React.FC<LockedAttachmentCardProps> = ({
82
+ title,
83
+ amountText,
84
+ thumbnail,
85
+ source,
86
+ mimeType,
87
+ detail,
88
+ onUnlock,
89
+ onDownload,
90
+ loading = false,
91
+ isPurchased = false,
92
+ }) => {
93
+ const isLocked = source === undefined
94
+ const LockIcon = isPurchased ? LockSimpleOpenIcon : LockSimpleIcon
95
+ const sourceType = getSourceType(mimeType)
96
+ const TypeIcon = getTypeIcon(mimeType)
97
+ const [playing, setPlaying] = useState(false)
98
+ const [played, setPlayed] = useState(0)
99
+ const [seeking, setSeeking] = useState(false)
100
+ const [scrubberHovered, setScrubberHovered] = useState(false)
101
+ const [sourceReady, setSourceReady] = useState(false)
102
+ const [videoAspect, setVideoAspect] = useState<number | null>(null)
103
+ const [buffering, setBuffering] = useState(false)
104
+ const playerRef = useRef<HTMLVideoElement>(null)
105
+ const trackRef = useRef<HTMLDivElement>(null)
106
+ const rafRef = useRef<number | null>(null)
107
+
108
+ const tickProgress = useCallback(() => {
109
+ const el = playerRef.current
110
+ if (el && el.duration && !seeking) setPlayed(el.currentTime / el.duration)
111
+ rafRef.current = requestAnimationFrame(tickProgress)
112
+ }, [seeking])
113
+
114
+ useEffect(() => {
115
+ if (playing) {
116
+ rafRef.current = requestAnimationFrame(tickProgress)
117
+ } else {
118
+ if (rafRef.current !== null) cancelAnimationFrame(rafRef.current)
119
+ }
120
+ return () => { if (rafRef.current !== null) cancelAnimationFrame(rafRef.current) }
121
+ }, [playing, tickProgress])
122
+
123
+ // ReactPlayer v3 uses native HTML media elements and does not support a
124
+ // declarative `playing` prop — playback must be driven imperatively.
125
+ useEffect(() => {
126
+ const el = playerRef.current
127
+ if (!el) return
128
+ if (playing) {
129
+ el.play().catch(() => {})
130
+ } else {
131
+ el.pause()
132
+ }
133
+ }, [playing])
134
+
135
+ const getFraction = (e: MouseEvent | React.MouseEvent) => {
136
+ const track = trackRef.current
137
+ if (!track) return 0
138
+ const rect = track.getBoundingClientRect()
139
+ return Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
140
+ }
141
+
142
+ const seekTo = (fraction: number) => {
143
+ const el = playerRef.current
144
+ if (el && el.duration) el.currentTime = fraction * el.duration
145
+ }
146
+
147
+ const handleTrackMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
148
+ e.stopPropagation()
149
+ setSeeking(true)
150
+ const fraction = getFraction(e)
151
+ setPlayed(fraction)
152
+ seekTo(fraction)
153
+ }
154
+
155
+ useEffect(() => {
156
+ if (!seeking) return
157
+ const onMove = (e: MouseEvent) => setPlayed(getFraction(e))
158
+ const onUp = (e: MouseEvent) => {
159
+ setSeeking(false)
160
+ seekTo(getFraction(e))
161
+ }
162
+ window.addEventListener('mousemove', onMove)
163
+ window.addEventListener('mouseup', onUp)
164
+ return () => {
165
+ window.removeEventListener('mousemove', onMove)
166
+ window.removeEventListener('mouseup', onUp)
167
+ }
168
+ }, [seeking])
169
+
170
+ const mediaPlayer = (
171
+ <div
172
+ role="button"
173
+ tabIndex={0}
174
+ className={`relative cursor-pointer overflow-hidden ${sourceType === 'audio' && !thumbnail ? 'bg-black/5' : 'bg-black'}${!thumbnail ? ' aspect-video' : ''}`}
175
+ style={!thumbnail && videoAspect ? { aspectRatio: String(videoAspect) } : undefined}
176
+ onClick={() => setPlaying((p) => !p)}
177
+ onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') setPlaying((p) => !p) }}
178
+ >
179
+ {thumbnail && (
180
+ <img src={thumbnail} alt="" className="block w-full" />
181
+ )}
182
+ {!thumbnail && (
183
+ <div className="absolute inset-0 flex items-center justify-center">
184
+ <TypeIcon className="size-12 text-black/20" weight="regular" />
185
+ </div>
186
+ )}
187
+ <div className="absolute inset-0">
188
+ <ReactPlayer
189
+ ref={playerRef}
190
+ src={source}
191
+ poster={thumbnail}
192
+ width="100%"
193
+ height="100%"
194
+ onLoadStart={() => setBuffering(true)}
195
+ onCanPlay={() => setBuffering(false)}
196
+ onWaiting={() => setBuffering(true)}
197
+ onLoadedMetadata={() => {
198
+ const el = playerRef.current
199
+ if (el && el.videoWidth && el.videoHeight) {
200
+ setVideoAspect(el.videoWidth / el.videoHeight)
201
+ }
202
+ }}
203
+ onEnded={() => {
204
+ setPlaying(false)
205
+ setPlayed(0)
206
+ }}
207
+ />
208
+ </div>
209
+
210
+ {buffering && (
211
+ <div className="absolute inset-0 flex items-center justify-center">
212
+ <CircleNotchIcon className="size-8 animate-spin text-white/80" weight="bold" />
213
+ </div>
214
+ )}
215
+
216
+ {/* Custom controls */}
217
+ <div className="absolute inset-x-0 bottom-0 flex items-center gap-2 bg-gradient-to-t from-black/60 to-transparent px-3 pb-2.5 pt-6">
218
+ <button
219
+ type="button"
220
+ onClick={(e) => { e.stopPropagation(); setPlaying((p) => !p) }}
221
+ className="shrink-0 text-white"
222
+ aria-label={playing ? 'Pause' : 'Play'}
223
+ >
224
+ {playing
225
+ ? <PauseIcon className="size-5" weight="fill" />
226
+ : <PlayIcon className="size-5 translate-x-px" weight="fill" />
227
+ }
228
+ </button>
229
+
230
+ {/* Scrubber */}
231
+ <div
232
+ role="slider"
233
+ aria-label="Playback position"
234
+ aria-valuenow={Math.round(played * 100)}
235
+ aria-valuemin={0}
236
+ aria-valuemax={100}
237
+ tabIndex={0}
238
+ ref={trackRef}
239
+ className="relative flex h-4 w-full cursor-pointer items-center"
240
+ onMouseDown={handleTrackMouseDown}
241
+ onClick={(e) => e.stopPropagation()}
242
+ onMouseEnter={() => setScrubberHovered(true)}
243
+ onMouseLeave={() => setScrubberHovered(false)}
244
+ onKeyDown={(e) => {
245
+ if (e.key === 'ArrowRight') seekTo(Math.min(1, played + 0.05))
246
+ if (e.key === 'ArrowLeft') seekTo(Math.max(0, played - 0.05))
247
+ }}
248
+ >
249
+ <div className={`w-full overflow-hidden rounded-full bg-white/30 transition-all duration-200 ${scrubberHovered || seeking ? 'h-1.5' : 'h-1'}`}>
250
+ <div className="h-full rounded-full bg-white" style={{ width: `${played * 100}%` }} />
251
+ </div>
252
+ <div
253
+ className={`absolute size-3 -translate-x-1/2 rounded-full bg-white shadow transition-[opacity,transform] duration-200 ${scrubberHovered || seeking ? 'scale-100 opacity-100' : 'scale-0 opacity-0'}`}
254
+ style={{ left: `${played * 100}%` }}
255
+ />
256
+ </div>
257
+ </div>
258
+ </div>
259
+ )
260
+
261
+ return (
262
+ <div className="w-[280px] overflow-hidden rounded-3xl bg-white shadow-[0px_0px_0px_1px_rgba(0,0,0,0.04),0px_1px_2px_0px_rgba(0,0,0,0.04),0px_8px_32px_0px_rgba(0,0,0,0.1)]">
263
+
264
+ {/* Media area */}
265
+ {sourceType === 'image' || sourceType === 'document' ? (
266
+ <div className={`relative overflow-hidden bg-black/5${!thumbnail && (isLocked || sourceType === 'document') ? ' aspect-video' : ''}`}>
267
+ {thumbnail && (
268
+ <img
269
+ src={thumbnail}
270
+ alt=""
271
+ className="block w-full"
272
+ />
273
+ )}
274
+ {!thumbnail && !isLocked && (
275
+ <div className="absolute inset-0 flex items-center justify-center">
276
+ <TypeIcon className="size-12 text-black/20" weight="regular" />
277
+ </div>
278
+ )}
279
+ {!thumbnail && !isLocked && sourceType === 'image' && (
280
+ <img
281
+ src={source}
282
+ alt={title}
283
+ className="relative block w-full"
284
+ />
285
+ )}
286
+ {isLocked ? (
287
+ <div className="absolute inset-0 flex items-center justify-center bg-black/30">
288
+ <div className="flex size-12 items-center justify-center rounded-full bg-black/60">
289
+ <LockIcon className="size-6 text-white" weight="regular" />
290
+ </div>
291
+ </div>
292
+ ) : thumbnail && sourceType === 'image' && (
293
+ <img
294
+ src={source}
295
+ alt={title}
296
+ className={`absolute inset-0 h-full w-full object-cover transition-opacity duration-300 ${sourceReady ? 'opacity-100' : 'opacity-0'}`}
297
+ onLoad={() => setSourceReady(true)}
298
+ />
299
+ )}
300
+ </div>
301
+ ) : isLocked ? (
302
+ <div className={`relative overflow-hidden bg-black/5${!thumbnail ? ' aspect-video' : ''}`}>
303
+ {thumbnail && (
304
+ <img src={thumbnail} alt="" className="block w-full" />
305
+ )}
306
+ <div className="absolute inset-0 flex items-center justify-center bg-black/30">
307
+ <div className="flex size-12 items-center justify-center rounded-full bg-black/60">
308
+ <LockIcon className="size-6 text-white" weight="regular" />
309
+ </div>
310
+ </div>
311
+ </div>
312
+ ) : (
313
+ mediaPlayer
314
+ )}
315
+
316
+ {/* Footer */}
317
+ <div className="px-4 pb-3 pt-3">
318
+ <p className="mb-1.5 truncate text-base font-medium text-black">{title}</p>
319
+
320
+ <div className="flex items-center gap-1">
321
+ <TypeIcon className="size-5 shrink-0 text-black/55" weight="regular" />
322
+ {detail && (
323
+ <span className="text-xs font-medium text-black/55">{detail}</span>
324
+ )}
325
+
326
+ {isPurchased || source ? (
327
+ <>
328
+ <span className="text-xs font-medium text-black/55">•</span>
329
+ <span className="text-xs font-medium text-[#008236]">Purchased</span>
330
+ <CheckCircleIcon className="size-4 text-[#008236]" weight="bold" />
331
+ </>
332
+ ) : amountText && (
333
+ <>
334
+ <span className="text-xs font-medium text-black/55">•</span>
335
+ <span className="text-xs font-medium text-black/55">{amountText}</span>
336
+ </>
337
+ )}
338
+
339
+ </div>
340
+
341
+ {isLocked ? (onUnlock || loading) && (
342
+ <button
343
+ type="button"
344
+ onClick={onUnlock}
345
+ disabled={loading}
346
+ className="mt-3 inline-flex h-10 w-full items-center justify-center gap-2 rounded-full bg-[#121110] px-4 text-sm font-medium leading-none text-white disabled:opacity-70"
347
+ >
348
+ {loading ? (
349
+ <span className="flex items-center gap-1">
350
+ <span className="size-1 rounded-full bg-white animate-bounce [animation-delay:-0.3s]" />
351
+ <span className="size-1 rounded-full bg-white animate-bounce [animation-delay:-0.15s]" />
352
+ <span className="size-1 rounded-full bg-white animate-bounce" />
353
+ </span>
354
+ ) : (
355
+ <>
356
+ <LockIcon className="size-4" weight="fill" />
357
+ {isPurchased ? 'Open' : 'Unlock'}
358
+ </>
359
+ )}
360
+ </button>
361
+ ) : onDownload && source && (
362
+ <a
363
+ href={`${source}${source.includes('?') ? '&' : '?'}d=true`}
364
+ target="_blank"
365
+ rel="noopener noreferrer"
366
+ onClick={onDownload}
367
+ className="mt-3 inline-flex h-10 w-full items-center justify-center gap-2 rounded-full bg-[#121110] px-4 text-sm font-medium leading-none !text-white"
368
+ >
369
+ <DownloadSimpleIcon className="size-4" weight="bold" />
370
+ Download
371
+ </a>
372
+ )}
373
+ </div>
374
+ </div>
375
+ )
376
+ }
377
+
378
+ export default LockedAttachmentCard
@@ -0,0 +1,97 @@
1
+ import { getDocumentIconType, getSourceType } from './mimeType'
2
+
3
+ describe('getSourceType', () => {
4
+ it('returns video for video/* types', () => {
5
+ expect(getSourceType('video/mp4')).toBe('video')
6
+ expect(getSourceType('video/webm')).toBe('video')
7
+ expect(getSourceType('video/quicktime')).toBe('video')
8
+ })
9
+
10
+ it('returns audio for audio/* types', () => {
11
+ expect(getSourceType('audio/mpeg')).toBe('audio')
12
+ expect(getSourceType('audio/mp4')).toBe('audio')
13
+ expect(getSourceType('audio/ogg')).toBe('audio')
14
+ })
15
+
16
+ it('returns image for image/* types', () => {
17
+ expect(getSourceType('image/jpeg')).toBe('image')
18
+ expect(getSourceType('image/png')).toBe('image')
19
+ expect(getSourceType('image/webp')).toBe('image')
20
+ })
21
+
22
+ it('returns document for application/* types', () => {
23
+ expect(getSourceType('application/pdf')).toBe('document')
24
+ expect(getSourceType('application/msword')).toBe('document')
25
+ expect(getSourceType('application/zip')).toBe('document')
26
+ })
27
+
28
+ it('returns document for text/* types', () => {
29
+ expect(getSourceType('text/plain')).toBe('document')
30
+ expect(getSourceType('text/csv')).toBe('document')
31
+ expect(getSourceType('text/markdown')).toBe('document')
32
+ })
33
+ })
34
+
35
+ describe('getDocumentIconType', () => {
36
+ it('returns pdf for application/pdf', () => {
37
+ expect(getDocumentIconType('application/pdf')).toBe('pdf')
38
+ })
39
+
40
+ it('returns doc for Word types', () => {
41
+ expect(getDocumentIconType('application/msword')).toBe('doc')
42
+ expect(getDocumentIconType('application/vnd.openxmlformats-officedocument.wordprocessingml.document')).toBe('doc')
43
+ })
44
+
45
+ it('returns xls for Excel types', () => {
46
+ expect(getDocumentIconType('application/vnd.ms-excel')).toBe('xls')
47
+ expect(getDocumentIconType('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')).toBe('xls')
48
+ })
49
+
50
+ it('returns csv for text/csv', () => {
51
+ expect(getDocumentIconType('text/csv')).toBe('csv')
52
+ })
53
+
54
+ it('returns ppt for PowerPoint types', () => {
55
+ expect(getDocumentIconType('application/vnd.ms-powerpoint')).toBe('ppt')
56
+ expect(getDocumentIconType('application/vnd.openxmlformats-officedocument.presentationml.presentation')).toBe('ppt')
57
+ })
58
+
59
+ it('returns zip for archive types', () => {
60
+ expect(getDocumentIconType('application/zip')).toBe('zip')
61
+ expect(getDocumentIconType('application/x-zip-compressed')).toBe('zip')
62
+ expect(getDocumentIconType('application/gzip')).toBe('zip')
63
+ expect(getDocumentIconType('application/x-gzip')).toBe('zip')
64
+ expect(getDocumentIconType('application/x-rar-compressed')).toBe('zip')
65
+ expect(getDocumentIconType('application/x-7z-compressed')).toBe('zip')
66
+ expect(getDocumentIconType('application/x-tar')).toBe('zip')
67
+ })
68
+
69
+ it('returns text for plain text and rtf', () => {
70
+ expect(getDocumentIconType('text/plain')).toBe('text')
71
+ expect(getDocumentIconType('text/rtf')).toBe('text')
72
+ expect(getDocumentIconType('application/rtf')).toBe('text')
73
+ expect(getDocumentIconType('application/x-rtf')).toBe('text')
74
+ })
75
+
76
+ it('returns markdown for markdown types', () => {
77
+ expect(getDocumentIconType('text/markdown')).toBe('markdown')
78
+ expect(getDocumentIconType('text/x-markdown')).toBe('markdown')
79
+ })
80
+
81
+ it('returns xls for macro-enabled Excel', () => {
82
+ expect(getDocumentIconType('application/vnd.ms-excel.sheet.macroEnabled.12')).toBe('xls')
83
+ })
84
+
85
+ it('returns ppt for macro-enabled PowerPoint', () => {
86
+ expect(getDocumentIconType('application/vnd.ms-powerpoint.presentation.macroEnabled.12')).toBe('ppt')
87
+ })
88
+
89
+ it('returns generic for unknown types', () => {
90
+ expect(getDocumentIconType('application/octet-stream')).toBe('generic')
91
+ expect(getDocumentIconType('application/json')).toBe('generic')
92
+ expect(getDocumentIconType('text/html')).toBe('generic')
93
+ expect(getDocumentIconType('application/vnd.rar')).toBe('generic')
94
+ expect(getDocumentIconType('application/vnd.oasis.opendocument.text')).toBe('generic')
95
+ expect(getDocumentIconType('application/vnd.oasis.opendocument.spreadsheet')).toBe('generic')
96
+ })
97
+ })
@@ -0,0 +1,35 @@
1
+ export type AttachmentSourceType = 'image' | 'audio' | 'video' | 'document'
2
+
3
+ export type DocumentIconType =
4
+ | 'pdf'
5
+ | 'doc'
6
+ | 'xls'
7
+ | 'csv'
8
+ | 'ppt'
9
+ | 'zip'
10
+ | 'text'
11
+ | 'markdown'
12
+ | 'generic'
13
+
14
+ const DOCUMENT_ICON_PATTERNS: Array<[RegExp, DocumentIconType]> = [
15
+ [/pdf/, 'pdf'],
16
+ [/wordprocessingml|msword|\.doc/, 'doc'],
17
+ [/spreadsheetml|ms-excel|\.xls/, 'xls'],
18
+ [/csv/, 'csv'],
19
+ [/presentationml|ms-powerpoint|\.ppt/, 'ppt'],
20
+ [/zip|x-rar|x-7z|x-tar|x-gzip/, 'zip'],
21
+ [/plain|rtf/, 'text'],
22
+ [/markdown/, 'markdown'],
23
+ ]
24
+
25
+ export function getSourceType(mimeType: string): AttachmentSourceType {
26
+ if (mimeType.startsWith('video/')) return 'video'
27
+ if (mimeType.startsWith('audio/')) return 'audio'
28
+ if (mimeType.startsWith('image/')) return 'image'
29
+ return 'document'
30
+ }
31
+
32
+ export function getDocumentIconType(mimeType: string): DocumentIconType {
33
+ const match = DOCUMENT_ICON_PATTERNS.find(([pattern]) => pattern.test(mimeType))
34
+ return match ? match[1] : 'generic'
35
+ }
@@ -27,6 +27,7 @@ export const MessagingShell: React.FC<MessagingShellProps> = ({
27
27
  CustomChannelEmptyState,
28
28
  showChannelList = true,
29
29
  filters,
30
+ channelRenderFilterFn,
30
31
  channelListCustomEmptyStateIndicator,
31
32
  onDeleteConversationClick,
32
33
  onBlockParticipantClick,
@@ -455,6 +456,7 @@ export const MessagingShell: React.FC<MessagingShellProps> = ({
455
456
  onChannelSelect={handleChannelSelect}
456
457
  selectedChannel={selectedChannel || undefined}
457
458
  filters={channelFilters}
459
+ channelRenderFilterFn={channelRenderFilterFn}
458
460
  customEmptyStateIndicator={channelListCustomEmptyStateIndicator}
459
461
  renderMessagePreview={renderMessagePreview}
460
462
  />
package/src/index.ts CHANGED
@@ -6,12 +6,14 @@ export { MessagingShell } from './components/MessagingShell'
6
6
  export { ChannelList } from './components/ChannelList'
7
7
  export { ChannelView } from './components/ChannelView'
8
8
  export { default as ActionButton } from './components/ActionButton'
9
+ export { default as LockedAttachmentCard } from './components/LockedAttachmentCard'
9
10
  export { ParticipantPicker } from './components/ParticipantPicker'
10
11
  export { Avatar } from './components/Avatar'
11
12
  export { FaqList } from './components/FaqList'
12
13
  export { FaqListItem } from './components/FaqList/FaqListItem'
13
14
  export { ChannelEmptyState } from './components/MessagingShell/ChannelEmptyState'
14
15
  export { MessageVoteButtons } from './components/CustomMessage/MessageVoteButtons'
16
+ export { isAttachmentMessage } from './components/CustomMessage/MessageTag'
15
17
 
16
18
  // Providers
17
19
  export { MessagingProvider } from './providers/MessagingProvider'
@@ -38,6 +40,8 @@ export type {
38
40
  export type { MessageMetadata } from './stream-custom-data'
39
41
  export type { AvatarProps } from './components/Avatar'
40
42
  export type { ActionButtonProps } from './components/ActionButton'
43
+ export type { LockedAttachmentCardProps } from './components/LockedAttachmentCard'
44
+ export type { AttachmentSourceType } from './components/LockedAttachmentCard/mimeType'
41
45
  export type { Faq, FaqListProps } from './components/FaqList'
42
46
  export type { FaqListItemProps } from './components/FaqList/FaqListItem'
43
47
  export type { VoteSelection } from './hooks/useMessageVote'
@@ -24,6 +24,7 @@ export type MessageCustomType =
24
24
  | 'MESSAGE_TIP'
25
25
  | 'MESSAGE_PAID'
26
26
  | 'MESSAGE_CHATBOT'
27
+ | 'MESSAGE_ATTACHMENT'
27
28
  | AgeSafetySystemType
28
29
  | DmAgentSystemType
29
30
 
@@ -31,12 +32,18 @@ export type MessageCustomType =
31
32
  * Message metadata for paid messaging and chatbot flows.
32
33
  * Used to identify message types and payment status.
33
34
  */
35
+ export type PaymentStatus = 'pending' | 'paid' | 'failed' | 'refunded'
36
+
34
37
  export interface MessageMetadata {
35
38
  custom_type?: MessageCustomType
36
- amount_text?: string
37
- payment_status?: string
38
- payment_intent_id?: string
39
39
  listing_id?: string
40
+ amount_text?: string
41
+ payment_status?: PaymentStatus
42
+ // MESSAGE_ATTACHMENT
43
+ attachment_title?: string
44
+ attachment_mime_type?: string
45
+ attachment_thumbnail?: string
46
+ attachment_detail?: string
40
47
  }
41
48
 
42
49
  declare module 'stream-chat' {
package/src/types.ts CHANGED
@@ -91,6 +91,20 @@ export interface ChannelListProps {
91
91
  * channel belongs in the current list before inserting it.
92
92
  */
93
93
  onAddedToChannel?: StreamChannelListProps['onAddedToChannel']
94
+ /**
95
+ * Client-side filter applied before rendering the channel list.
96
+ * Websocket events can add channels to the list that bypass server-side
97
+ * query filters. Use this to enforce visibility rules that can't be
98
+ * automatically derived from the filters prop (e.g. $or conditions).
99
+ *
100
+ * @example
101
+ * // Hide channels where the visitor hasn't sent a message yet,
102
+ * // but keep legacy channels that predate the has_visitor_message field
103
+ * channelRenderFilterFn={(channels) =>
104
+ * channels.filter(ch => ch.data?.has_visitor_message !== false)
105
+ * }
106
+ */
107
+ channelRenderFilterFn?: (channels: Channel[]) => Channel[]
94
108
  /**
95
109
  * Sort order for the channel list query.
96
110
  * Defaults to `{ last_message_at: -1 }` (most recent first).
@@ -217,6 +231,19 @@ export interface ChannelViewProps {
217
231
  messageNode: React.ReactElement,
218
232
  message: LocalMessage
219
233
  ) => React.ReactNode
234
+
235
+ /**
236
+ * Called when the visitor clicks Unlock on a locked attachment message.
237
+ * Receives the message and channel. Show checkout, confirm payment, fetch
238
+ * the unlocked URL. `attachment_source` must NOT be stored on the Stream message metadata.
239
+ * The card shows a loading state for the full duration of the promise.
240
+ */
241
+ onAttachmentUnlock?: (message: LocalMessage, channel: Channel) => Promise<string>
242
+
243
+ /**
244
+ * Called when the visitor clicks Download on an unlocked attachment message.
245
+ */
246
+ onAttachmentDownload?: (message: LocalMessage, channel: Channel) => void
220
247
  }
221
248
 
222
249
  /**
@@ -240,6 +267,8 @@ export type ChannelViewPassthroughProps = Pick<
240
267
  | 'customProfileContent'
241
268
  | 'customChannelActions'
242
269
  | 'renderMessage'
270
+ | 'onAttachmentUnlock'
271
+ | 'onAttachmentDownload'
243
272
  >
244
273
 
245
274
  /**
@@ -282,6 +311,18 @@ export interface MessagingShellProps extends ChannelViewPassthroughProps {
282
311
  */
283
312
  filters?: ChannelFilters
284
313
 
314
+ /**
315
+ * Client-side filter applied before rendering the channel list.
316
+ * Websocket events can add channels to the list that bypass server-side
317
+ * query filters. Use this to enforce visibility rules client-side.
318
+ *
319
+ * @example
320
+ * channelRenderFilterFn={(channels) =>
321
+ * channels.filter(ch => ch.data?.has_visitor_message !== false)
322
+ * }
323
+ */
324
+ channelRenderFilterFn?: (channels: Channel[]) => Channel[]
325
+
285
326
  /**
286
327
  * Custom empty state indicator component to render when the channel list is empty.
287
328
  * Useful for showing a custom empty state indicator when the channel list is empty.