@linktr.ee/messaging-react 1.26.0 → 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 (44) 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 +60 -9
  12. package/dist/index.js +1766 -1181
  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/ChannelView.test.tsx +50 -1
  34. package/src/components/ChannelView.tsx +13 -3
  35. package/src/components/CustomMessage/CustomMessage.stories.tsx +61 -2
  36. package/src/components/CustomMessage/MessageTag.tsx +5 -0
  37. package/src/components/CustomMessage/index.tsx +46 -4
  38. package/src/components/LockedAttachmentCard/LockedAttachmentCard.stories.tsx +351 -0
  39. package/src/components/LockedAttachmentCard/index.tsx +378 -0
  40. package/src/components/LockedAttachmentCard/mimeType.test.ts +97 -0
  41. package/src/components/LockedAttachmentCard/mimeType.ts +35 -0
  42. package/src/index.ts +4 -0
  43. package/src/stream-custom-data.ts +10 -3
  44. package/src/types.ts +15 -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
+ }
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
@@ -231,6 +231,19 @@ export interface ChannelViewProps {
231
231
  messageNode: React.ReactElement,
232
232
  message: LocalMessage
233
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
234
247
  }
235
248
 
236
249
  /**
@@ -254,6 +267,8 @@ export type ChannelViewPassthroughProps = Pick<
254
267
  | 'customProfileContent'
255
268
  | 'customChannelActions'
256
269
  | 'renderMessage'
270
+ | 'onAttachmentUnlock'
271
+ | 'onAttachmentDownload'
257
272
  >
258
273
 
259
274
  /**