@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,13 +1,17 @@
1
1
  import {
2
2
  CheckCircleIcon,
3
3
  DownloadSimpleIcon,
4
+ LockOpenIcon,
4
5
  LockSimpleIcon,
5
- LockSimpleOpenIcon,
6
6
  } from '@phosphor-icons/react'
7
7
  import React, { useEffect, useState } from 'react'
8
8
 
9
9
  import { isDevBuild } from '../../../utils/isDevBuild'
10
- import type { LockedAttachmentBaseProps, LockedAttachmentSource, PaymentStatus } from '../types'
10
+ import type {
11
+ LockedAttachmentBaseProps,
12
+ LockedAttachmentSource,
13
+ PaymentStatus,
14
+ } from '../types'
11
15
  import { renderTypeIcon } from '../utils/icons'
12
16
  import { getSourceType } from '../utils/mimeType'
13
17
 
@@ -16,8 +20,8 @@ import MediaPlayer from './MediaPlayer'
16
20
  export interface VisitorCardProps extends LockedAttachmentBaseProps {
17
21
  title?: string
18
22
  /**
19
- * Called when the visitor clicks Unlock. Return the resolved source and optional poster.
20
- * The component manages loading state and sets source/poster internally on resolution.
23
+ * Called when the visitor clicks Unlock. Return the resolved source URL.
24
+ * The component manages loading state and sets source internally on resolution.
21
25
  * Omit to hide the Unlock button.
22
26
  */
23
27
  onUnlock?: () => Promise<LockedAttachmentSource>
@@ -25,75 +29,164 @@ export interface VisitorCardProps extends LockedAttachmentBaseProps {
25
29
  onDownload?: () => void
26
30
  }
27
31
 
28
- const getLockIcon = (paymentStatus?: PaymentStatus): React.ElementType => {
29
- return paymentStatus === 'paid' ? LockSimpleOpenIcon : LockSimpleIcon
32
+ const getLockIcon = (paymentStatus?: PaymentStatus): React.ElementType =>
33
+ paymentStatus === 'paid' ? LockOpenIcon : LockSimpleIcon
34
+
35
+ // ─── Shared primitives ────────────────────────────────────────────────────────
36
+
37
+ interface LockOverlayProps {
38
+ icon: React.ElementType
39
+ }
40
+
41
+ const LockOverlay: React.FC<LockOverlayProps> = (props) => {
42
+ const { icon: Icon } = props
43
+ return (
44
+ <div className="absolute inset-0 bg-black/30">
45
+ <div className="absolute left-3 top-3 flex size-8 items-center justify-center rounded-full bg-black/60">
46
+ <Icon className="size-4 text-white" weight="fill" />
47
+ </div>
48
+ </div>
49
+ )
50
+ }
51
+
52
+ interface LockedPreviewProps {
53
+ thumbnail?: string
54
+ mimeType: string
55
+ LockIcon: React.ElementType
56
+ }
57
+
58
+ const LockedPreview: React.FC<LockedPreviewProps> = (props) => {
59
+ const { thumbnail, mimeType, LockIcon } = props
60
+ return (
61
+ <div className="relative aspect-video overflow-hidden bg-black/5">
62
+ {thumbnail ? (
63
+ <img
64
+ src={thumbnail}
65
+ alt=""
66
+ className="absolute inset-0 h-full w-full object-cover"
67
+ />
68
+ ) : (
69
+ <div className="absolute inset-0 flex items-center justify-center">
70
+ {renderTypeIcon(mimeType, {
71
+ className: 'size-12 text-black/20',
72
+ weight: 'regular',
73
+ })}
74
+ </div>
75
+ )}
76
+ <LockOverlay icon={LockIcon} />
77
+ </div>
78
+ )
79
+ }
80
+
81
+ // ─── Per-type preview components ─────────────────────────────────────────────
82
+
83
+ interface ImagePreviewProps {
84
+ source?: string
85
+ thumbnail?: string
86
+ mimeType: string
87
+ title?: string
88
+ paymentStatus?: PaymentStatus
89
+ isLocked: boolean
30
90
  }
31
91
 
32
- const ThumbnailOrIcon: React.FC<{ src?: string; mimeType: string }> = ({
33
- src,
34
- mimeType,
35
- }) => {
36
- if (src) {
92
+ const ImagePreview: React.FC<ImagePreviewProps> = (props) => {
93
+ const { source, thumbnail, mimeType, title, paymentStatus, isLocked } = props
94
+ const [sourceReady, setSourceReady] = useState(false)
95
+
96
+ useEffect(() => {
97
+ setSourceReady(false)
98
+ }, [source])
99
+
100
+ if (isLocked) {
37
101
  return (
38
- <img
39
- src={src}
40
- alt=""
41
- className="absolute inset-0 h-full w-full object-cover"
102
+ <LockedPreview
103
+ thumbnail={thumbnail}
104
+ mimeType={mimeType}
105
+ LockIcon={getLockIcon(paymentStatus)}
42
106
  />
43
107
  )
44
108
  }
45
109
 
46
110
  return (
47
- <div className="absolute inset-0 flex items-center justify-center">
48
- {renderTypeIcon(mimeType, { className: 'size-12 text-black/20', weight: 'regular' })}
111
+ <div className="relative overflow-hidden bg-black/5">
112
+ <img
113
+ src={source}
114
+ alt={title}
115
+ className={`block w-full transition-opacity duration-300 ${sourceReady ? 'opacity-100' : 'opacity-0'}`}
116
+ onLoad={() => setSourceReady(true)}
117
+ />
49
118
  </div>
50
119
  )
51
120
  }
52
121
 
53
- const LockOverlay: React.FC<{ icon: React.ElementType }> = ({ icon: Icon }) => (
54
- <div className="absolute inset-0 flex items-center justify-center bg-black/30">
55
- <div className="flex size-12 items-center justify-center rounded-full bg-black/60">
56
- <Icon className="size-6 text-white" weight="regular" />
122
+ interface DocumentPreviewProps {
123
+ thumbnail?: string
124
+ mimeType: string
125
+ paymentStatus?: PaymentStatus
126
+ isLocked: boolean
127
+ }
128
+
129
+ const DocumentPreview: React.FC<DocumentPreviewProps> = (props) => {
130
+ const { thumbnail, mimeType, paymentStatus, isLocked } = props
131
+ return (
132
+ <div className="relative aspect-video overflow-hidden bg-black/5">
133
+ {thumbnail ? (
134
+ <img
135
+ src={thumbnail}
136
+ alt=""
137
+ className="absolute inset-0 h-full w-full object-cover"
138
+ />
139
+ ) : (
140
+ <div className="absolute inset-0 flex items-center justify-center">
141
+ {renderTypeIcon(mimeType, {
142
+ className: 'size-12 text-black/20',
143
+ weight: 'regular',
144
+ })}
145
+ </div>
146
+ )}
147
+ {isLocked && <LockOverlay icon={getLockIcon(paymentStatus)} />}
57
148
  </div>
58
- </div>
59
- )
149
+ )
150
+ }
60
151
 
61
- interface LockedPreviewProps {
152
+ interface MediaPreviewProps {
153
+ source?: string
62
154
  thumbnail?: string
63
155
  mimeType: string
64
- LockIcon: React.ElementType
156
+ paymentStatus?: PaymentStatus
157
+ isLocked: boolean
65
158
  }
66
159
 
67
- const LockedPreview: React.FC<LockedPreviewProps> = ({
68
- thumbnail,
69
- mimeType,
70
- LockIcon,
71
- }) => (
72
- <div className="relative aspect-video overflow-hidden bg-black/5">
73
- <ThumbnailOrIcon src={thumbnail} mimeType={mimeType} />
74
- <LockOverlay icon={LockIcon} />
75
- </div>
76
- )
160
+ const MediaPreview: React.FC<MediaPreviewProps> = (props) => {
161
+ const { source, thumbnail, mimeType, paymentStatus, isLocked } = props
162
+ if (isLocked) {
163
+ return (
164
+ <LockedPreview
165
+ thumbnail={thumbnail}
166
+ mimeType={mimeType}
167
+ LockIcon={getLockIcon(paymentStatus)}
168
+ />
169
+ )
170
+ }
171
+ return <MediaPlayer source={source!} mimeType={mimeType} poster={thumbnail} />
172
+ }
173
+
174
+ // ─── Actions ─────────────────────────────────────────────────────────────────
77
175
 
78
176
  interface CardActionsProps {
79
177
  isLocked: boolean
80
178
  loading: boolean
81
179
  paymentStatus?: PaymentStatus
82
180
  source?: string
83
- LockIcon: React.ElementType
84
181
  onUnlock?: () => void
85
182
  onDownload?: () => void
86
183
  }
87
184
 
88
- const CardActions: React.FC<CardActionsProps> = ({
89
- isLocked,
90
- loading,
91
- paymentStatus,
92
- source,
93
- LockIcon,
94
- onUnlock,
95
- onDownload,
96
- }) => {
185
+ const CardActions: React.FC<CardActionsProps> = (props) => {
186
+ const { isLocked, loading, paymentStatus, source, onUnlock, onDownload } =
187
+ props
188
+ const LockIcon = getLockIcon(paymentStatus)
189
+
97
190
  if (isLocked && onUnlock) {
98
191
  return (
99
192
  <button
@@ -110,13 +203,18 @@ const CardActions: React.FC<CardActionsProps> = ({
110
203
  </span>
111
204
  ) : (
112
205
  <>
113
- <LockIcon className="size-4" weight="fill" />
206
+ {paymentStatus === 'paid' ? (
207
+ <LockOpenIcon className="size-4" weight="fill" />
208
+ ) : (
209
+ <LockIcon className="size-4" weight="fill" />
210
+ )}
114
211
  {paymentStatus === 'paid' ? 'Open' : 'Unlock'}
115
212
  </>
116
213
  )}
117
214
  </button>
118
215
  )
119
216
  }
217
+
120
218
  if (!isLocked && onDownload && source) {
121
219
  return (
122
220
  <a
@@ -131,75 +229,32 @@ const CardActions: React.FC<CardActionsProps> = ({
131
229
  </a>
132
230
  )
133
231
  }
134
- return null
135
- }
136
232
 
137
- interface VisitorCardMetaProps {
138
- mimeType: string
139
- detail?: string
140
- paymentStatus?: PaymentStatus
141
- amountText?: string
233
+ return null
142
234
  }
143
235
 
144
- const VisitorCardMeta: React.FC<VisitorCardMetaProps> = ({
145
- mimeType,
146
- detail,
147
- paymentStatus,
148
- amountText,
149
- }) => {
150
- return (
151
- <div className="flex items-center gap-1">
152
- {renderTypeIcon(mimeType, { className: 'size-5 shrink-0 text-black/55', weight: 'regular' })}
153
- {detail && (
154
- <span className="text-xs font-medium text-black/55">{detail}</span>
155
- )}
156
- {paymentStatus === 'paid' ? (
157
- <>
158
- <span className="text-xs font-medium text-black/55">•</span>
159
- <span className="text-xs font-medium text-[#008236]">Purchased</span>
160
- <CheckCircleIcon className="size-4 text-[#008236]" weight="bold" />
161
- </>
162
- ) : (
163
- amountText && (
164
- <>
165
- <span className="text-xs font-medium text-black/55">•</span>
166
- <span className="text-xs font-medium text-black/55">
167
- {amountText}
168
- </span>
169
- </>
170
- )
171
- )}
172
- </div>
173
- )
174
- }
236
+ // ─── Card shell ───────────────────────────────────────────────────────────────
175
237
 
176
- const VisitorCard: React.FC<VisitorCardProps> = ({
177
- title,
178
- amountText,
179
- thumbnail,
180
- poster: posterProp,
181
- source: sourceProp,
182
- mimeType = 'application/octet-stream',
183
- detail,
184
- onUnlock,
185
- onDownload,
186
- paymentStatus,
187
- }) => {
238
+ const VisitorCard: React.FC<VisitorCardProps> = (props) => {
239
+ const {
240
+ title,
241
+ amountText,
242
+ thumbnail,
243
+ source: sourceProp,
244
+ mimeType = 'application/octet-stream',
245
+ detail,
246
+ onUnlock,
247
+ onDownload,
248
+ paymentStatus,
249
+ } = props
188
250
  const [source, setSource] = useState(sourceProp)
189
- const [poster, setPoster] = useState(posterProp)
190
251
  const [loading, setLoading] = useState(false)
191
- const [sourceReady, setSourceReady] = useState(false)
192
252
 
193
253
  useEffect(() => {
194
254
  if (sourceProp !== undefined) setSource(sourceProp)
195
255
  }, [sourceProp])
196
256
 
197
- useEffect(() => {
198
- if (posterProp !== undefined) setPoster(posterProp)
199
- }, [posterProp])
200
-
201
257
  const isLocked = source === undefined
202
- const LockIcon = getLockIcon(paymentStatus)
203
258
  const sourceType = getSourceType(mimeType)
204
259
 
205
260
  const handleUnlock = async () => {
@@ -208,12 +263,8 @@ const VisitorCard: React.FC<VisitorCardProps> = ({
208
263
  try {
209
264
  const result = await onUnlock()
210
265
  setSource(result.source)
211
- if (result.poster) setPoster(result.poster)
212
266
  } catch (err) {
213
- // Avoid unhandled rejection from async onClick; host may still surface UI in onUnlock.
214
- if (isDevBuild()) {
215
- console.debug('[LockedAttachment] onUnlock failed', err)
216
- }
267
+ if (isDevBuild()) console.debug('[LockedAttachment] onUnlock failed', err)
217
268
  } finally {
218
269
  setLoading(false)
219
270
  }
@@ -221,67 +272,79 @@ const VisitorCard: React.FC<VisitorCardProps> = ({
221
272
 
222
273
  let mediaPreview: React.ReactNode
223
274
  if (sourceType === 'image') {
224
- mediaPreview = isLocked ? (
225
- <LockedPreview
275
+ mediaPreview = (
276
+ <ImagePreview
277
+ source={source}
226
278
  thumbnail={thumbnail}
227
279
  mimeType={mimeType}
228
- LockIcon={LockIcon}
280
+ title={title}
281
+ paymentStatus={paymentStatus}
282
+ isLocked={isLocked}
229
283
  />
230
- ) : (
231
- <div className="relative overflow-hidden bg-black/5">
232
- <img
233
- src={source}
234
- alt={title}
235
- className={`block w-full transition-opacity duration-300 ${sourceReady ? 'opacity-100' : 'opacity-0'}`}
236
- onLoad={() => setSourceReady(true)}
237
- />
238
- </div>
239
284
  )
240
285
  } else if (sourceType === 'document') {
241
286
  mediaPreview = (
242
- <div className="relative aspect-video overflow-hidden bg-black/5">
243
- <ThumbnailOrIcon
244
- src={isLocked ? thumbnail : poster}
245
- mimeType={mimeType}
246
- />
247
- {isLocked && <LockOverlay icon={LockIcon} />}
248
- </div>
249
- )
250
- } else {
251
- mediaPreview = isLocked ? (
252
- <LockedPreview
287
+ <DocumentPreview
253
288
  thumbnail={thumbnail}
254
289
  mimeType={mimeType}
255
- LockIcon={LockIcon}
290
+ paymentStatus={paymentStatus}
291
+ isLocked={isLocked}
256
292
  />
257
- ) : (
258
- <MediaPlayer
293
+ )
294
+ } else {
295
+ mediaPreview = (
296
+ <MediaPreview
259
297
  source={source}
298
+ thumbnail={thumbnail}
260
299
  mimeType={mimeType}
261
- poster={poster ?? thumbnail}
300
+ paymentStatus={paymentStatus}
301
+ isLocked={isLocked}
262
302
  />
263
303
  )
264
304
  }
265
305
 
266
306
  return (
267
- <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)]">
307
+ <div className="w-[280px] select-none overflow-hidden rounded-3xl bg-white shadow-card">
268
308
  {mediaPreview}
269
309
  <div className="px-4 pb-3 pt-3">
270
310
  <p className="mb-1.5 truncate text-base font-medium text-black">
271
311
  {title}
272
312
  </p>
273
- <VisitorCardMeta
274
- mimeType={mimeType}
275
- detail={detail}
276
- paymentStatus={paymentStatus}
277
- amountText={amountText}
278
- />
313
+ <div className="flex items-center gap-1">
314
+ {renderTypeIcon(mimeType, {
315
+ className: 'size-5 shrink-0 text-black/55',
316
+ weight: 'regular',
317
+ })}
318
+ {detail && (
319
+ <span className="text-xs font-medium text-black/55">{detail}</span>
320
+ )}
321
+ {paymentStatus === 'paid' ? (
322
+ <>
323
+ <span className="text-xs font-medium text-black/55">•</span>
324
+ <span className="text-xs font-medium text-[#008236]">
325
+ Purchased
326
+ </span>
327
+ <CheckCircleIcon
328
+ className="size-4 text-[#008236]"
329
+ weight="bold"
330
+ />
331
+ </>
332
+ ) : (
333
+ amountText && (
334
+ <>
335
+ <span className="text-xs font-medium text-black/55">•</span>
336
+ <span className="text-xs font-medium text-black/55">
337
+ {amountText}
338
+ </span>
339
+ </>
340
+ )
341
+ )}
342
+ </div>
279
343
  <CardActions
280
344
  isLocked={isLocked}
281
345
  loading={loading}
282
346
  paymentStatus={paymentStatus}
283
347
  source={source}
284
- LockIcon={LockIcon}
285
348
  onUnlock={onUnlock ? handleUnlock : undefined}
286
349
  onDownload={onDownload}
287
350
  />
@@ -2,11 +2,10 @@ import type { PaymentStatus } from '../../stream-custom-data'
2
2
 
3
3
  /** Shared fields for creator and visitor locked-attachment cards (internal). */
4
4
  export interface LockedAttachmentBaseProps {
5
+ title?: string
5
6
  mimeType?: string
6
- /** Blurred preview image shown in the locked/collapsed state. */
7
+ /** Preview image. Video/image: pass blurred version. Audio/document: pass unblurred version. */
7
8
  thumbnail?: string
8
- /** Clean poster image passed to the media player. Falls back to thumbnail. */
9
- poster?: string
10
9
  /** Unlocked media URL. Undefined while locked or pending unlock. */
11
10
  source?: string
12
11
  detail?: string
@@ -11,6 +11,7 @@ import {
11
11
  ImageIcon,
12
12
  SpeakerHighIcon,
13
13
  VideoCameraIcon,
14
+ IconProps,
14
15
  } from '@phosphor-icons/react'
15
16
  import React from 'react'
16
17
 
@@ -46,7 +47,7 @@ export function getTypeIcon(mimeType: string): React.ElementType {
46
47
  /** Use instead of `<TypeIcon />` where TypeIcon = getTypeIcon(mime) to satisfy react-hooks/static-components. */
47
48
  export function renderTypeIcon(
48
49
  mimeType: string,
49
- props: { className?: string; weight?: 'thin' | 'light' | 'regular' | 'bold' | 'fill' | 'duotone' }
50
+ props: IconProps
50
51
  ): React.ReactElement {
51
52
  return React.createElement(getTypeIcon(mimeType), props)
52
53
  }
@@ -39,12 +39,20 @@ describe('getDocumentIconType', () => {
39
39
 
40
40
  it('returns doc for Word types', () => {
41
41
  expect(getDocumentIconType('application/msword')).toBe('doc')
42
- expect(getDocumentIconType('application/vnd.openxmlformats-officedocument.wordprocessingml.document')).toBe('doc')
42
+ expect(
43
+ getDocumentIconType(
44
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
45
+ )
46
+ ).toBe('doc')
43
47
  })
44
48
 
45
49
  it('returns xls for Excel types', () => {
46
50
  expect(getDocumentIconType('application/vnd.ms-excel')).toBe('xls')
47
- expect(getDocumentIconType('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')).toBe('xls')
51
+ expect(
52
+ getDocumentIconType(
53
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
54
+ )
55
+ ).toBe('xls')
48
56
  })
49
57
 
50
58
  it('returns csv for text/csv', () => {
@@ -53,7 +61,11 @@ describe('getDocumentIconType', () => {
53
61
 
54
62
  it('returns ppt for PowerPoint types', () => {
55
63
  expect(getDocumentIconType('application/vnd.ms-powerpoint')).toBe('ppt')
56
- expect(getDocumentIconType('application/vnd.openxmlformats-officedocument.presentationml.presentation')).toBe('ppt')
64
+ expect(
65
+ getDocumentIconType(
66
+ 'application/vnd.openxmlformats-officedocument.presentationml.presentation'
67
+ )
68
+ ).toBe('ppt')
57
69
  })
58
70
 
59
71
  it('returns zip for archive types', () => {
@@ -79,11 +91,17 @@ describe('getDocumentIconType', () => {
79
91
  })
80
92
 
81
93
  it('returns xls for macro-enabled Excel', () => {
82
- expect(getDocumentIconType('application/vnd.ms-excel.sheet.macroEnabled.12')).toBe('xls')
94
+ expect(
95
+ getDocumentIconType('application/vnd.ms-excel.sheet.macroEnabled.12')
96
+ ).toBe('xls')
83
97
  })
84
98
 
85
99
  it('returns ppt for macro-enabled PowerPoint', () => {
86
- expect(getDocumentIconType('application/vnd.ms-powerpoint.presentation.macroEnabled.12')).toBe('ppt')
100
+ expect(
101
+ getDocumentIconType(
102
+ 'application/vnd.ms-powerpoint.presentation.macroEnabled.12'
103
+ )
104
+ ).toBe('ppt')
87
105
  })
88
106
 
89
107
  it('returns generic for unknown types', () => {
@@ -91,7 +109,11 @@ describe('getDocumentIconType', () => {
91
109
  expect(getDocumentIconType('application/json')).toBe('generic')
92
110
  expect(getDocumentIconType('text/html')).toBe('generic')
93
111
  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')
112
+ expect(getDocumentIconType('application/vnd.oasis.opendocument.text')).toBe(
113
+ 'generic'
114
+ )
115
+ expect(
116
+ getDocumentIconType('application/vnd.oasis.opendocument.spreadsheet')
117
+ ).toBe('generic')
96
118
  })
97
119
  })
@@ -30,6 +30,8 @@ export function getSourceType(mimeType: string): AttachmentSourceType {
30
30
  }
31
31
 
32
32
  export function getDocumentIconType(mimeType: string): DocumentIconType {
33
- const match = DOCUMENT_ICON_PATTERNS.find(([pattern]) => pattern.test(mimeType))
33
+ const match = DOCUMENT_ICON_PATTERNS.find(([pattern]) =>
34
+ pattern.test(mimeType)
35
+ )
34
36
  return match ? match[1] : 'generic'
35
37
  }
package/src/types.ts CHANGED
@@ -119,7 +119,6 @@ export interface ChannelListProps {
119
119
 
120
120
  export interface LockedAttachmentSource {
121
121
  source: string
122
- poster?: string
123
122
  }
124
123
 
125
124
  /**