@linktr.ee/messaging-react 1.27.0 → 1.28.0-rc-1776225927

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.
@@ -1,169 +1,467 @@
1
- import { CheckCircleIcon, EyeIcon, EyeSlashIcon } from '@phosphor-icons/react'
1
+ import {
2
+ CheckCircleIcon,
3
+ EyeIcon,
4
+ EyeSlashIcon,
5
+ PauseIcon,
6
+ PlayIcon,
7
+ XIcon,
8
+ } from '@phosphor-icons/react'
2
9
  import classNames from 'classnames'
3
- import React, { useState } from 'react'
10
+ import React, { useCallback, useEffect, useRef, useState } from 'react'
4
11
 
5
- import type { LockedAttachmentBaseProps, PaymentStatus } from '../types'
12
+ import type { LockedAttachmentBaseProps } from '../types'
6
13
  import { renderTypeIcon } from '../utils/icons'
7
- import { getSourceType, type AttachmentSourceType } from '../utils/mimeType'
14
+ import { getSourceType } from '../utils/mimeType'
8
15
 
9
16
  import MediaPlayer from './MediaPlayer'
10
17
 
11
18
  export interface CreatorCardProps extends LockedAttachmentBaseProps {
12
19
  title?: string
20
+ /** When true, shows interactive media preview (composing state). When false/omitted, shows static thumbnail (sent/sold state). */
21
+ isPreview?: boolean
13
22
  placeholderTitle?: string
14
23
  placeholderAmountText?: string
24
+ onDismiss?: () => void
15
25
  }
16
26
 
17
- const CloseButton = ({ onClose }: { onClose: () => void }) => (
18
- <button
19
- type="button"
20
- onClick={onClose}
21
- className="absolute right-3 top-3 z-40 flex size-8 items-center justify-center rounded-full bg-black/60 text-white"
22
- aria-label="Close preview"
23
- >
24
- <EyeIcon className="size-4" weight="fill" />
25
- </button>
26
- )
27
+ // ─── Shared primitives ────────────────────────────────────────────────────────
28
+
29
+ interface CloseButtonProps {
30
+ onClose: () => void
31
+ }
32
+
33
+ const CloseButton: React.FC<CloseButtonProps> = (props) => {
34
+ const { onClose } = props
35
+ return (
36
+ <button
37
+ type="button"
38
+ onClick={onClose}
39
+ className="absolute left-3 top-3 z-40 flex size-8 items-center justify-center rounded-full bg-black/60 text-white"
40
+ aria-label="Close preview"
41
+ >
42
+ <EyeIcon className="size-4" weight="fill" />
43
+ </button>
44
+ )
45
+ }
27
46
 
28
47
  interface CollapsedThumbnailProps {
29
48
  thumbnail?: string
30
49
  mimeType: string
31
- canExpand: boolean
32
- onExpand: () => void
50
+ overlayIcon?: React.ElementType
51
+ onClick?: () => void
33
52
  }
34
53
 
35
- const CollapsedThumbnail: React.FC<CollapsedThumbnailProps> = ({ thumbnail, mimeType, canExpand, onExpand }) => {
54
+ const CollapsedThumbnail: React.FC<CollapsedThumbnailProps> = (props) => {
55
+ const { thumbnail, mimeType, overlayIcon: OverlayIcon, onClick } = props
36
56
  return (
37
57
  <button
38
58
  type="button"
39
- disabled={!canExpand}
40
- className={classNames('relative aspect-video block w-full overflow-hidden border-0 bg-black/5 p-0 text-left appearance-none', { 'cursor-pointer': canExpand, 'cursor-default': !canExpand })}
41
- onClick={onExpand}
42
- aria-label={canExpand ? 'Expand attachment preview' : undefined}
59
+ disabled={!onClick}
60
+ className={classNames(
61
+ 'relative aspect-video block w-full overflow-hidden border-0 bg-black/5 p-0 text-left appearance-none',
62
+ { 'cursor-pointer': !!onClick, 'cursor-default': !onClick }
63
+ )}
64
+ onClick={onClick}
65
+ aria-label={OverlayIcon ? 'Toggle preview' : undefined}
43
66
  >
44
- {thumbnail
45
- ? <img src={thumbnail} alt="" className="absolute inset-0 h-full w-full object-cover" />
46
- : <div className="absolute inset-0 flex items-center justify-center">{renderTypeIcon(mimeType, { className: 'size-12 text-black/20', weight: 'regular' })}</div>
47
- }
48
- {canExpand && (
49
- <div className="pointer-events-none absolute right-3 top-3 flex size-8 items-center justify-center rounded-full bg-black/60 text-white">
50
- <EyeSlashIcon className="size-4" weight="fill" />
67
+ {thumbnail ? (
68
+ <img
69
+ src={thumbnail}
70
+ alt=""
71
+ className="absolute inset-0 h-full w-full object-cover"
72
+ />
73
+ ) : (
74
+ <div className="absolute inset-0 flex items-center justify-center">
75
+ {renderTypeIcon(mimeType, {
76
+ className: 'size-12 text-black/20',
77
+ weight: 'regular',
78
+ })}
79
+ </div>
80
+ )}
81
+ {OverlayIcon && (
82
+ <div className="pointer-events-none absolute left-3 top-3 flex size-8 items-center justify-center rounded-full bg-black/60 text-white">
83
+ <OverlayIcon className="size-4" weight="fill" />
51
84
  </div>
52
85
  )}
53
86
  </button>
54
87
  )
55
88
  }
56
89
 
57
- interface ExpandedMediaProps {
58
- source: string
59
- mimeType: string
60
- sourceType: AttachmentSourceType
61
- poster?: string
90
+ // ─── Per-type preview components ─────────────────────────────────────────────
91
+
92
+ interface AudioPreviewProps {
93
+ source?: string
62
94
  thumbnail?: string
63
- title?: string
64
- onCollapse: () => void
95
+ mimeType: string
65
96
  }
66
97
 
67
- const getExpandedImgSrc = (sourceType: AttachmentSourceType, source: string, poster?: string, thumbnail?: string) =>
68
- sourceType === 'document' ? (poster ?? thumbnail) : source
98
+ const AudioPreview: React.FC<AudioPreviewProps> = (props) => {
99
+ const { source, thumbnail, mimeType } = props
100
+ const [playing, setPlaying] = useState(false)
101
+ const [played, setPlayed] = useState(0)
102
+ const [seeking, setSeeking] = useState(false)
103
+ const audioRef = useRef<HTMLAudioElement>(null)
104
+ const trackRef = useRef<HTMLDivElement>(null)
105
+ const rafRef = useRef<number | null>(null)
106
+
107
+ useEffect(() => {
108
+ const el = audioRef.current
109
+ if (!el) return
110
+ if (playing) {
111
+ void el.play().catch(() => setPlaying(false))
112
+ } else {
113
+ el.pause()
114
+ }
115
+ }, [playing])
116
+
117
+ useEffect(() => {
118
+ if (!playing) {
119
+ if (rafRef.current !== null) cancelAnimationFrame(rafRef.current)
120
+ return
121
+ }
122
+ const tick = () => {
123
+ const el = audioRef.current
124
+ if (el && el.duration && !seeking) setPlayed(el.currentTime / el.duration)
125
+ rafRef.current = requestAnimationFrame(tick)
126
+ }
127
+ rafRef.current = requestAnimationFrame(tick)
128
+ return () => {
129
+ if (rafRef.current !== null) cancelAnimationFrame(rafRef.current)
130
+ }
131
+ }, [playing, seeking])
132
+
133
+ useEffect(() => {
134
+ setPlaying(false)
135
+ setPlayed(0)
136
+ }, [source])
137
+
138
+ const getFraction = useCallback(
139
+ (e: MouseEvent | TouchEvent | React.MouseEvent | React.TouchEvent) => {
140
+ const track = trackRef.current
141
+ if (!track) return 0
142
+ const clientX =
143
+ 'touches' in e
144
+ ? (e.touches[0]?.clientX ?? e.changedTouches[0]?.clientX ?? 0)
145
+ : e.clientX
146
+ const rect = track.getBoundingClientRect()
147
+ return Math.max(0, Math.min(1, (clientX - rect.left) / rect.width))
148
+ },
149
+ []
150
+ )
151
+
152
+ const seekTo = useCallback((fraction: number) => {
153
+ const el = audioRef.current
154
+ if (el && el.duration) el.currentTime = fraction * el.duration
155
+ }, [])
156
+
157
+ useEffect(() => {
158
+ if (!seeking) return
159
+ const onMove = (e: MouseEvent | TouchEvent) => {
160
+ const f = getFraction(e)
161
+ setPlayed(f)
162
+ seekTo(f)
163
+ }
164
+ const onUp = (e: MouseEvent | TouchEvent) => {
165
+ setSeeking(false)
166
+ seekTo(getFraction(e))
167
+ }
168
+ window.addEventListener('mousemove', onMove)
169
+ window.addEventListener('mouseup', onUp)
170
+ window.addEventListener('touchmove', onMove, { passive: true })
171
+ window.addEventListener('touchend', onUp)
172
+ return () => {
173
+ window.removeEventListener('mousemove', onMove)
174
+ window.removeEventListener('mouseup', onUp)
175
+ window.removeEventListener('touchmove', onMove)
176
+ window.removeEventListener('touchend', onUp)
177
+ }
178
+ }, [seeking, getFraction, seekTo])
179
+
180
+ const toggle = useCallback(() => setPlaying((p) => !p), [])
69
181
 
70
- const ExpandedMedia: React.FC<ExpandedMediaProps> = ({ source, mimeType, sourceType, poster, thumbnail, title, onCollapse }) => {
71
- if (sourceType === 'video' || sourceType === 'audio') {
72
- return (
73
- <div className="relative">
74
- <MediaPlayer
75
- source={source}
76
- mimeType={mimeType}
77
- poster={poster ?? thumbnail}
78
- autoPlay
79
- loop
80
- controls={false}
81
- showProgress
82
- onContainerClick={onCollapse}
83
- muted={sourceType === 'video'}
84
- />
85
- <CloseButton onClose={onCollapse} />
86
- </div>
87
- )
88
- }
89
- const imgSrc = getExpandedImgSrc(sourceType, source, poster, thumbnail)
90
182
  return (
91
183
  <div className="relative">
92
- <button type="button" className="block w-full cursor-pointer border-0 p-0 text-left appearance-none" onClick={onCollapse} aria-label="Close preview">
93
- <img src={imgSrc} alt={title ?? ''} className="block w-full" />
94
- </button>
95
- <CloseButton onClose={onCollapse} />
184
+ {source && (
185
+ <audio
186
+ ref={audioRef}
187
+ src={source}
188
+ loop
189
+ onEnded={() => {
190
+ setPlaying(false)
191
+ setPlayed(0)
192
+ }}
193
+ >
194
+ <track kind="captions" />
195
+ </audio>
196
+ )}
197
+ <CollapsedThumbnail
198
+ thumbnail={thumbnail}
199
+ mimeType={mimeType}
200
+ overlayIcon={source ? (playing ? PauseIcon : PlayIcon) : undefined}
201
+ onClick={source ? toggle : undefined}
202
+ />
203
+ {source && (
204
+ <div className="absolute inset-x-0 bottom-0 px-3 pb-2.5 pt-6 bg-gradient-to-t from-black/40 to-transparent">
205
+ <div
206
+ ref={trackRef}
207
+ role="slider"
208
+ aria-label="Playback position"
209
+ aria-valuenow={Math.round(played * 100)}
210
+ aria-valuemin={0}
211
+ aria-valuemax={100}
212
+ tabIndex={0}
213
+ className="relative flex h-4 w-full cursor-pointer items-center"
214
+ onMouseDown={(e) => {
215
+ e.stopPropagation()
216
+ setSeeking(true)
217
+ const f = getFraction(e)
218
+ setPlayed(f)
219
+ seekTo(f)
220
+ }}
221
+ onTouchStart={(e) => {
222
+ e.stopPropagation()
223
+ setSeeking(true)
224
+ const f = getFraction(e)
225
+ setPlayed(f)
226
+ seekTo(f)
227
+ }}
228
+ onClick={(e) => e.stopPropagation()}
229
+ onKeyDown={(e) => {
230
+ if (e.key === 'ArrowRight') seekTo(Math.min(1, played + 0.05))
231
+ if (e.key === 'ArrowLeft') seekTo(Math.max(0, played - 0.05))
232
+ }}
233
+ >
234
+ <div className="w-full overflow-hidden rounded-full bg-white/30 h-1">
235
+ <div
236
+ className="h-full rounded-full bg-white"
237
+ style={{ width: `${Math.round(played * 100)}%` }}
238
+ />
239
+ </div>
240
+ </div>
241
+ </div>
242
+ )}
96
243
  </div>
97
244
  )
98
245
  }
99
246
 
100
- interface CreatorCardMetaProps {
101
- title?: string
102
- placeholderTitle: string
247
+ interface VideoPreviewProps {
248
+ source?: string
249
+ thumbnail?: string
103
250
  mimeType: string
104
- detail?: string
105
- paymentStatus?: PaymentStatus
106
- displayAmountText?: string
107
- isPlaceholderAmount: boolean
108
251
  }
109
252
 
110
- const CreatorCardMeta: React.FC<CreatorCardMetaProps> = ({ title, placeholderTitle, mimeType, detail, paymentStatus, displayAmountText, isPlaceholderAmount }) => {
253
+ const VideoPreview: React.FC<VideoPreviewProps> = (props) => {
254
+ const { source, thumbnail, mimeType } = props
255
+ const [expanded, setExpanded] = useState(false)
256
+ const collapse = () => setExpanded(false)
257
+
258
+ useEffect(() => {
259
+ setExpanded(false)
260
+ }, [source])
261
+
262
+ if (!source) {
263
+ return <CollapsedThumbnail thumbnail={thumbnail} mimeType={mimeType} />
264
+ }
265
+
111
266
  return (
112
- <div className="px-4 pb-3 pt-3">
113
- <p className={classNames('mb-1.5 truncate text-base font-medium', { 'text-black/30': !title, 'text-black': !!title })}>
114
- {title || placeholderTitle}
115
- </p>
116
- <div className="flex items-center gap-1">
117
- {renderTypeIcon(mimeType, { className: 'size-5 shrink-0 text-black/55', weight: 'regular' })}
118
- {detail && <span className="text-xs font-medium text-black/55">{detail}</span>}
119
- {paymentStatus === 'paid' ? (
120
- <>
121
- <span className="text-xs font-medium text-black/55">•</span>
122
- <span className="text-xs font-medium text-[#008236]">Purchased</span>
123
- <CheckCircleIcon className="size-4 text-[#008236]" weight="bold" />
124
- </>
125
- ) : displayAmountText && (
126
- <>
127
- <span className={classNames('text-xs font-medium', { 'text-black/30': isPlaceholderAmount, 'text-black/55': !isPlaceholderAmount })}>•</span>
128
- <span className={classNames('text-xs font-medium', { 'text-black/30': isPlaceholderAmount, 'text-black/55': !isPlaceholderAmount })}>{displayAmountText}</span>
129
- </>
130
- )}
131
- </div>
267
+ <div
268
+ className={classNames('relative overflow-hidden', {
269
+ 'aspect-video': !expanded,
270
+ })}
271
+ >
272
+ <MediaPlayer
273
+ source={source}
274
+ mimeType={mimeType}
275
+ poster={thumbnail}
276
+ playing={expanded}
277
+ loop
278
+ controls={false}
279
+ showProgress
280
+ onContainerClick={collapse}
281
+ muted
282
+ />
283
+ {!expanded && (
284
+ <button
285
+ type="button"
286
+ className="absolute inset-0 block cursor-pointer border-0 p-0 text-left appearance-none"
287
+ onClick={() => setExpanded(true)}
288
+ aria-label="Expand video preview"
289
+ >
290
+ {thumbnail ? (
291
+ <img
292
+ src={thumbnail}
293
+ alt=""
294
+ className="absolute inset-0 h-full w-full object-cover"
295
+ />
296
+ ) : (
297
+ <div className="absolute inset-0 flex items-center justify-center">
298
+ {renderTypeIcon(mimeType, {
299
+ className: 'size-12 text-black/20',
300
+ weight: 'regular',
301
+ })}
302
+ </div>
303
+ )}
304
+ <div className="pointer-events-none absolute left-3 top-3 flex size-8 items-center justify-center rounded-full bg-black/60 text-white">
305
+ <EyeSlashIcon className="size-4" weight="fill" />
306
+ </div>
307
+ </button>
308
+ )}
309
+ {expanded && <CloseButton onClose={collapse} />}
132
310
  </div>
133
311
  )
134
312
  }
135
313
 
136
- const CreatorCard: React.FC<CreatorCardProps> = ({
137
- title,
138
- mimeType = 'application/octet-stream',
139
- thumbnail,
140
- poster,
141
- source,
142
- detail,
143
- amountText,
144
- placeholderTitle = 'Attachment title',
145
- placeholderAmountText,
146
- paymentStatus,
147
- }) => {
148
- const sourceType = getSourceType(mimeType)
314
+ interface ImagePreviewProps {
315
+ source?: string
316
+ thumbnail?: string
317
+ mimeType: string
318
+ title?: string
319
+ }
320
+
321
+ const ImagePreview: React.FC<ImagePreviewProps> = (props) => {
322
+ const { source, thumbnail, mimeType, title } = props
149
323
  const [expanded, setExpanded] = useState(false)
324
+ const collapse = () => setExpanded(false)
325
+
326
+ useEffect(() => {
327
+ setExpanded(false)
328
+ }, [source])
150
329
 
330
+ if (expanded && source) {
331
+ return (
332
+ <div className="relative">
333
+ <button
334
+ type="button"
335
+ className="block w-full cursor-pointer border-0 p-0 text-left appearance-none"
336
+ onClick={collapse}
337
+ aria-label="Close preview"
338
+ >
339
+ <img src={source} alt={title ?? ''} className="block w-full" />
340
+ </button>
341
+ <CloseButton onClose={collapse} />
342
+ </div>
343
+ )
344
+ }
345
+
346
+ return (
347
+ <CollapsedThumbnail
348
+ thumbnail={thumbnail}
349
+ mimeType={mimeType}
350
+ overlayIcon={source ? EyeSlashIcon : undefined}
351
+ onClick={source ? () => setExpanded(true) : undefined}
352
+ />
353
+ )
354
+ }
355
+
356
+ // ─── Card shell ───────────────────────────────────────────────────────────────
357
+
358
+ const CreatorCard: React.FC<CreatorCardProps> = (props) => {
359
+ const {
360
+ title,
361
+ mimeType = 'application/octet-stream',
362
+ thumbnail,
363
+ source,
364
+ detail,
365
+ amountText,
366
+ placeholderTitle = 'Attachment title',
367
+ placeholderAmountText,
368
+ paymentStatus,
369
+ onDismiss,
370
+ isPreview = false,
371
+ } = props
372
+ const sourceType = getSourceType(mimeType)
151
373
  const displayAmountText = placeholderAmountText ?? amountText
152
374
  const isPlaceholderAmount = !!placeholderAmountText
153
- const canExpand = sourceType === 'document'
154
- ? !!(source && (poster || thumbnail))
155
- : !!source
156
375
 
157
- const collapse = () => setExpanded(false)
158
-
159
- const mediaPreview = expanded && source
160
- ? <ExpandedMedia source={source} mimeType={mimeType} sourceType={sourceType} poster={poster} thumbnail={thumbnail} title={title} onCollapse={collapse} />
161
- : <CollapsedThumbnail thumbnail={thumbnail} mimeType={mimeType} canExpand={canExpand} onExpand={() => setExpanded(true)} />
376
+ let mediaPreview: React.ReactNode
377
+ if (isPreview && sourceType === 'audio') {
378
+ mediaPreview = (
379
+ <AudioPreview source={source} thumbnail={thumbnail} mimeType={mimeType} />
380
+ )
381
+ } else if (isPreview && sourceType === 'video') {
382
+ mediaPreview = (
383
+ <VideoPreview source={source} thumbnail={thumbnail} mimeType={mimeType} />
384
+ )
385
+ } else if (isPreview && sourceType === 'image') {
386
+ mediaPreview = (
387
+ <ImagePreview
388
+ source={source}
389
+ thumbnail={thumbnail}
390
+ mimeType={mimeType}
391
+ title={title}
392
+ />
393
+ )
394
+ } else {
395
+ mediaPreview = (
396
+ <CollapsedThumbnail thumbnail={thumbnail} mimeType={mimeType} />
397
+ )
398
+ }
162
399
 
163
400
  return (
164
- <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)]">
401
+ <div className="relative w-[280px] select-none overflow-hidden rounded-3xl bg-white shadow-card">
402
+ {onDismiss && (
403
+ <button
404
+ type="button"
405
+ onClick={onDismiss}
406
+ className="absolute right-3 top-3 z-50 flex size-8 items-center justify-center rounded-full bg-black/60 text-white"
407
+ aria-label="Dismiss attachment"
408
+ >
409
+ <XIcon className="size-4" weight="bold" />
410
+ </button>
411
+ )}
165
412
  {mediaPreview}
166
- <CreatorCardMeta title={title} placeholderTitle={placeholderTitle} mimeType={mimeType} detail={detail} paymentStatus={paymentStatus} displayAmountText={displayAmountText} isPlaceholderAmount={isPlaceholderAmount} />
413
+ <div className="px-4 pb-3 pt-3">
414
+ <p
415
+ className={classNames('mb-1.5 truncate text-base font-medium', {
416
+ 'text-black/30': !title,
417
+ 'text-black': !!title,
418
+ })}
419
+ >
420
+ {title || placeholderTitle}
421
+ </p>
422
+ <div className="flex items-center gap-1">
423
+ {renderTypeIcon(mimeType, {
424
+ className: 'size-5 shrink-0 text-black/55',
425
+ weight: 'regular',
426
+ })}
427
+ {detail && (
428
+ <span className="text-xs font-medium text-black/55">{detail}</span>
429
+ )}
430
+ {paymentStatus === 'paid' ? (
431
+ <>
432
+ <span className="text-xs font-medium text-black/55">•</span>
433
+ <span className="text-xs font-medium text-[#008236]">
434
+ Purchased
435
+ </span>
436
+ <CheckCircleIcon
437
+ className="size-4 text-[#008236]"
438
+ weight="bold"
439
+ />
440
+ </>
441
+ ) : (
442
+ displayAmountText && (
443
+ <>
444
+ <span
445
+ className={classNames('text-xs font-medium', {
446
+ 'text-black/30': isPlaceholderAmount,
447
+ 'text-black/55': !isPlaceholderAmount,
448
+ })}
449
+ >
450
+
451
+ </span>
452
+ <span
453
+ className={classNames('text-xs font-medium', {
454
+ 'text-black/30': isPlaceholderAmount,
455
+ 'text-black/55': !isPlaceholderAmount,
456
+ })}
457
+ >
458
+ {displayAmountText}
459
+ </span>
460
+ </>
461
+ )
462
+ )}
463
+ </div>
464
+ </div>
167
465
  </div>
168
466
  )
169
467
  }