@linktr.ee/messaging-react 3.4.2-rc-1782480798 → 3.4.3-rc-1782553322

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 (32) hide show
  1. package/dist/{Card-VO-i6CuV.cjs → Card-1oglowd2.cjs} +2 -2
  2. package/dist/{Card-VO-i6CuV.cjs.map → Card-1oglowd2.cjs.map} +1 -1
  3. package/dist/{Card-BYu6OBuX.js → Card-BmVutVEt.js} +2 -2
  4. package/dist/{Card-BYu6OBuX.js.map → Card-BmVutVEt.js.map} +1 -1
  5. package/dist/{Card-BKi2g6x-.js → Card-CKn-iPjf.js} +2 -2
  6. package/dist/{Card-BKi2g6x-.js.map → Card-CKn-iPjf.js.map} +1 -1
  7. package/dist/{Card-BMmrCMqJ.cjs → Card-DEEK3wsC.cjs} +2 -2
  8. package/dist/{Card-BMmrCMqJ.cjs.map → Card-DEEK3wsC.cjs.map} +1 -1
  9. package/dist/{Card-DvE5BCPf.js → Card-DoBWURRU.js} +3 -3
  10. package/dist/{Card-DvE5BCPf.js.map → Card-DoBWURRU.js.map} +1 -1
  11. package/dist/{Card-DtIjtWy6.cjs → Card-DwaOIthl.cjs} +2 -2
  12. package/dist/{Card-DtIjtWy6.cjs.map → Card-DwaOIthl.cjs.map} +1 -1
  13. package/dist/{LockedThumbnail-Cl8uwCxM.cjs → LockedThumbnail-B59cJFsF.cjs} +2 -2
  14. package/dist/{LockedThumbnail-Cl8uwCxM.cjs.map → LockedThumbnail-B59cJFsF.cjs.map} +1 -1
  15. package/dist/{LockedThumbnail-DTkd_kEt.js → LockedThumbnail-DI-aReA7.js} +2 -2
  16. package/dist/{LockedThumbnail-DTkd_kEt.js.map → LockedThumbnail-DI-aReA7.js.map} +1 -1
  17. package/dist/index-12HxQ_GM.cjs +2 -0
  18. package/dist/index-12HxQ_GM.cjs.map +1 -0
  19. package/dist/{index-IzhF38yj.js → index-Dv13-2iu.js} +3200 -3445
  20. package/dist/index-Dv13-2iu.js.map +1 -0
  21. package/dist/index.cjs +1 -1
  22. package/dist/index.js +1 -1
  23. package/package.json +2 -2
  24. package/src/components/ChannelView.stories.tsx +7 -5
  25. package/src/components/ChannelView.test.tsx +10 -0
  26. package/src/components/ChannelView.tsx +95 -59
  27. package/src/components/CustomMessage/index.tsx +0 -42
  28. package/dist/index-CSSe9eu7.cjs +0 -2
  29. package/dist/index-CSSe9eu7.cjs.map +0 -1
  30. package/dist/index-IzhF38yj.js.map +0 -1
  31. package/src/components/CustomMessage/StreamAttachmentMessage.test.tsx +0 -109
  32. package/src/components/CustomMessage/StreamAttachmentMessage.tsx +0 -408
@@ -1,109 +0,0 @@
1
- import React from 'react'
2
- import { describe, expect, it } from 'vitest'
3
-
4
- import { renderWithProviders, screen } from '../../test/utils'
5
-
6
- import StreamAttachmentMessage, {
7
- linkCaptionDuplicatesAttachmentUrl,
8
- } from './StreamAttachmentMessage'
9
-
10
- describe('linkCaptionDuplicatesAttachmentUrl', () => {
11
- it('suppresses captions that are just the link plus punctuation', () => {
12
- expect(
13
- linkCaptionDuplicatesAttachmentUrl(
14
- 'https://tr.ee/abc !',
15
- 'https://tr.ee/abc'
16
- )
17
- ).toBe(true)
18
- })
19
-
20
- it('keeps captions that add emoji after the link', () => {
21
- expect(
22
- linkCaptionDuplicatesAttachmentUrl(
23
- 'https://tr.ee/abc 😎',
24
- 'https://tr.ee/abc'
25
- )
26
- ).toBe(false)
27
- })
28
- })
29
-
30
- describe('StreamAttachmentMessage', () => {
31
- it('renders a sent link card and suppresses a URL-only caption', () => {
32
- renderWithProviders(
33
- <StreamAttachmentMessage
34
- groupPosition="single"
35
- isMyMessage={true}
36
- message={{
37
- attachments: [
38
- {
39
- type: 'link',
40
- og_scrape_url: 'https://linktr.ee/someone',
41
- title: 'My Linktree',
42
- text: 'Check out my links',
43
- image_url: 'https://cdn.example.com/thumb.jpg',
44
- },
45
- ],
46
- text: 'https://linktr.ee/someone !',
47
- }}
48
- />
49
- )
50
-
51
- expect(
52
- screen.getByRole('link', { name: /my linktree/i })
53
- ).toHaveAttribute('href', 'https://linktr.ee/someone')
54
- expect(screen.queryByText('https://linktr.ee/someone !')).toBeNull()
55
- })
56
-
57
- it('keeps an emoji-only caption instead of dropping it', () => {
58
- renderWithProviders(
59
- <StreamAttachmentMessage
60
- groupPosition="single"
61
- isMyMessage={false}
62
- message={{
63
- attachments: [
64
- {
65
- type: 'link',
66
- og_scrape_url: 'https://linktr.ee/someone',
67
- title: 'My Linktree',
68
- },
69
- ],
70
- text: 'https://linktr.ee/someone 😎',
71
- }}
72
- />
73
- )
74
-
75
- expect(screen.getByText('https://linktr.ee/someone 😎')).toBeInTheDocument()
76
- })
77
-
78
- it('renders mixed link and media attachments in order', () => {
79
- const { container } = renderWithProviders(
80
- <StreamAttachmentMessage
81
- groupPosition="single"
82
- isMyMessage={true}
83
- message={{
84
- attachments: [
85
- {
86
- type: 'link',
87
- og_scrape_url: 'https://linktr.ee/someone',
88
- title: 'My Linktree',
89
- },
90
- {
91
- type: 'image',
92
- image_url: 'https://cdn.example.com/photo.jpg',
93
- title: 'Vacation',
94
- },
95
- ],
96
- text: 'caption',
97
- }}
98
- />
99
- )
100
-
101
- const link = screen.getByRole('link', { name: /my linktree/i })
102
- const imageBubble = screen.getByTestId('image-attachment')
103
-
104
- expect(link.compareDocumentPosition(imageBubble)).toBe(
105
- Node.DOCUMENT_POSITION_FOLLOWING
106
- )
107
- expect(container).toHaveTextContent('caption')
108
- })
109
- })
@@ -1,408 +0,0 @@
1
- import React from 'react'
2
- import type { Attachment } from 'stream-chat'
3
-
4
- import LinkAttachment from '../LinkAttachment'
5
- import MessageAttachment from '../MessageAttachment'
6
- import type { BubbleGroupPosition } from '../MessageAttachment/types'
7
-
8
- type StreamMediaKind = 'image' | 'video' | 'audio' | 'pdf' | 'file'
9
-
10
- export type AttachmentSegment =
11
- | { type: 'link'; attachments: Attachment[] }
12
- | { type: 'media'; kind: StreamMediaKind; attachments: Attachment[] }
13
-
14
- type StreamThreadMessage = {
15
- attachments?: Attachment[]
16
- text?: string
17
- }
18
-
19
- const SENT_CAPTION_CLASS =
20
- 'mt-[2px] max-w-[280px] whitespace-pre-wrap break-words rounded-[18px] bg-[#121110] px-4 py-2 text-white'
21
- const RECEIVED_CAPTION_CLASS =
22
- 'mt-[2px] max-w-[280px] whitespace-pre-wrap break-words rounded-[18px] bg-[#e9eaed] px-4 py-2 text-[#080707]'
23
-
24
- function trimToUndefined(value?: string | null): string | undefined {
25
- const trimmed = value?.trim()
26
- return trimmed ? trimmed : undefined
27
- }
28
-
29
- export function normalizeLinkUrl(url: string): string {
30
- return url
31
- .trim()
32
- .replace(/^https?:\/\//i, '')
33
- .replace(/\/+$/, '')
34
- .toLowerCase()
35
- }
36
-
37
- export function linkCaptionDuplicatesAttachmentUrl(
38
- caption: string | undefined,
39
- attachmentUrl?: string
40
- ): boolean {
41
- if (!caption?.trim() || !attachmentUrl?.trim()) {
42
- return false
43
- }
44
-
45
- const normalizedCaption = normalizeLinkUrl(caption)
46
- const normalizedUrl = normalizeLinkUrl(attachmentUrl)
47
-
48
- if (normalizedCaption === normalizedUrl) {
49
- return true
50
- }
51
-
52
- if (!normalizedCaption.includes(normalizedUrl)) {
53
- return false
54
- }
55
-
56
- const remainder = normalizedCaption.split(normalizedUrl).join('')
57
- return !/[^\p{P}\p{Z}\p{C}]/u.test(remainder)
58
- }
59
-
60
- export function linkCardPropsFromStreamAttachment(a: Attachment) {
61
- const url = trimToUndefined(a.og_scrape_url) ?? trimToUndefined(a.title_link)
62
- const thumbnailUrl =
63
- trimToUndefined((a as { image_url?: string }).image_url) ??
64
- trimToUndefined((a as { thumb_url?: string }).thumb_url)
65
-
66
- return {
67
- title: trimToUndefined((a as { title?: string }).title),
68
- description: trimToUndefined((a as { text?: string }).text),
69
- url,
70
- thumbnailUrl,
71
- layout: thumbnailUrl ? 'featured' : 'classic',
72
- mimeType: trimToUndefined(a.mime_type),
73
- } as const
74
- }
75
-
76
- export function getAttachmentMediaKind(
77
- attachment: Attachment
78
- ): StreamMediaKind | null {
79
- if (isLinkAttachment(attachment)) return null
80
-
81
- if (attachment.type === 'image') {
82
- const imageUrl = (attachment as { image_url?: string }).image_url
83
- if (imageUrl || attachment.asset_url) return 'image'
84
- return null
85
- }
86
-
87
- if (attachment.type === 'video' && attachment.asset_url) return 'video'
88
- if (attachment.type === 'audio' && attachment.asset_url) return 'audio'
89
-
90
- if (attachment.type === 'file' && attachment.asset_url) {
91
- if (attachment.mime_type === 'application/pdf') return 'pdf'
92
- if (attachment.mime_type?.startsWith('audio/')) return 'audio'
93
- return 'file'
94
- }
95
-
96
- return null
97
- }
98
-
99
- export function isLinkAttachment(a: Attachment): boolean {
100
- const ogScrapeUrl = trimToUndefined(a.og_scrape_url)
101
- return a.type === 'link' || (!!ogScrapeUrl && !a.asset_url)
102
- }
103
-
104
- export function buildOrderedAttachmentSegments(
105
- attachments: Attachment[] | undefined
106
- ): AttachmentSegment[] {
107
- if (!attachments?.length) return []
108
-
109
- const segments: AttachmentSegment[] = []
110
-
111
- const pushOrMergeMedia = (kind: StreamMediaKind, attachment: Attachment) => {
112
- const last = segments[segments.length - 1]
113
- if (last?.type === 'media' && last.kind === kind) {
114
- last.attachments.push(attachment)
115
- return
116
- }
117
-
118
- segments.push({ type: 'media', kind, attachments: [attachment] })
119
- }
120
-
121
- const pushOrMergeLink = (attachment: Attachment) => {
122
- const last = segments[segments.length - 1]
123
- if (last?.type === 'link') {
124
- last.attachments.push(attachment)
125
- return
126
- }
127
-
128
- segments.push({ type: 'link', attachments: [attachment] })
129
- }
130
-
131
- for (const attachment of attachments) {
132
- if (isLinkAttachment(attachment)) {
133
- pushOrMergeLink(attachment)
134
- continue
135
- }
136
-
137
- const kind = getAttachmentMediaKind(attachment)
138
- if (!kind) continue
139
-
140
- pushOrMergeMedia(kind, attachment)
141
- }
142
-
143
- return segments
144
- }
145
-
146
- export function countAttachmentSegmentBubbles(
147
- segments: AttachmentSegment[]
148
- ): number {
149
- let count = 0
150
-
151
- for (const segment of segments) {
152
- count += segment.type === 'link' ? segment.attachments.length : 1
153
- }
154
-
155
- return count
156
- }
157
-
158
- export function countMediaSegmentBubbles(segments: AttachmentSegment[]): number {
159
- return segments.filter((segment) => segment.type === 'media').length
160
- }
161
-
162
- export function bubblePositionInRun(
163
- index: number,
164
- total: number,
165
- streamPosition: BubbleGroupPosition
166
- ): BubbleGroupPosition {
167
- if (total <= 1) return streamPosition
168
- if (index === 0) return 'first'
169
- if (index === total - 1) return 'end'
170
- return 'middle'
171
- }
172
-
173
- function imageSrcFromAttachment(attachment: Attachment): string {
174
- return (
175
- trimToUndefined((attachment as { image_url?: string }).image_url) ??
176
- trimToUndefined(attachment.asset_url) ??
177
- ''
178
- )
179
- }
180
-
181
- function messageAttachmentNamespaceForKind(kind: StreamMediaKind) {
182
- switch (kind) {
183
- case 'image':
184
- return MessageAttachment.Image
185
- case 'video':
186
- return MessageAttachment.Video
187
- case 'audio':
188
- return MessageAttachment.Audio
189
- case 'pdf':
190
- return MessageAttachment.Pdf
191
- default:
192
- return MessageAttachment.File
193
- }
194
- }
195
-
196
- function buildMediaClusterRenderProps(
197
- kind: StreamMediaKind,
198
- attachments: Attachment[]
199
- ): Record<string, unknown> {
200
- if (attachments.length > 1) {
201
- switch (kind) {
202
- case 'image':
203
- return {
204
- items: attachments.map((attachment) => ({
205
- src: imageSrcFromAttachment(attachment),
206
- alt: trimToUndefined((attachment as { title?: string }).title),
207
- })),
208
- }
209
- case 'video':
210
- return {
211
- items: attachments.map((attachment) => ({
212
- src: trimToUndefined(attachment.asset_url) ?? '',
213
- poster: trimToUndefined(
214
- (attachment as { thumb_url?: string }).thumb_url
215
- ),
216
- mimeType: trimToUndefined(attachment.mime_type),
217
- })),
218
- }
219
- case 'audio':
220
- return {
221
- items: attachments.map((attachment) => ({
222
- src: trimToUndefined(attachment.asset_url) ?? '',
223
- mimeType:
224
- trimToUndefined(attachment.mime_type) ?? 'audio/mpeg',
225
- filename: trimToUndefined(
226
- (attachment as { title?: string }).title
227
- ),
228
- })),
229
- }
230
- case 'pdf':
231
- return {
232
- items: attachments.map((attachment) => ({
233
- src: trimToUndefined(attachment.asset_url) ?? '',
234
- filename: trimToUndefined(
235
- (attachment as { title?: string }).title
236
- ),
237
- fileSize: (attachment as { file_size?: number }).file_size,
238
- })),
239
- }
240
- case 'file':
241
- return {
242
- items: attachments.map((attachment) => ({
243
- src: trimToUndefined(attachment.asset_url) ?? '',
244
- filename: trimToUndefined(
245
- (attachment as { title?: string }).title
246
- ),
247
- fileSize: (attachment as { file_size?: number }).file_size,
248
- mimeType:
249
- trimToUndefined(attachment.mime_type) ??
250
- 'application/octet-stream',
251
- })),
252
- }
253
- }
254
- }
255
-
256
- const attachment = attachments[0]
257
- if (!attachment) return {}
258
-
259
- switch (kind) {
260
- case 'image':
261
- return {
262
- src: imageSrcFromAttachment(attachment),
263
- alt: trimToUndefined((attachment as { title?: string }).title),
264
- }
265
- case 'video':
266
- return {
267
- src: trimToUndefined(attachment.asset_url) ?? '',
268
- poster: trimToUndefined((attachment as { thumb_url?: string }).thumb_url),
269
- mimeType: trimToUndefined(attachment.mime_type),
270
- }
271
- case 'audio':
272
- return {
273
- src: trimToUndefined(attachment.asset_url) ?? '',
274
- mimeType: trimToUndefined(attachment.mime_type) ?? 'audio/mpeg',
275
- filename: trimToUndefined((attachment as { title?: string }).title),
276
- }
277
- case 'pdf':
278
- return {
279
- src: trimToUndefined(attachment.asset_url) ?? '',
280
- filename: trimToUndefined((attachment as { title?: string }).title),
281
- fileSize: (attachment as { file_size?: number }).file_size,
282
- }
283
- case 'file':
284
- return {
285
- src: trimToUndefined(attachment.asset_url) ?? '',
286
- filename: trimToUndefined((attachment as { title?: string }).title),
287
- fileSize: (attachment as { file_size?: number }).file_size,
288
- mimeType:
289
- trimToUndefined(attachment.mime_type) ?? 'application/octet-stream',
290
- }
291
- }
292
- }
293
-
294
- function MediaClusterBubble({
295
- groupPosition,
296
- isMyMessage,
297
- segment,
298
- text,
299
- }: {
300
- groupPosition: BubbleGroupPosition
301
- isMyMessage: boolean
302
- segment: Extract<AttachmentSegment, { type: 'media' }>
303
- text?: string
304
- }) {
305
- const TypeNamespace = messageAttachmentNamespaceForKind(segment.kind)
306
- const props = {
307
- ...buildMediaClusterRenderProps(segment.kind, segment.attachments),
308
- groupPosition,
309
- ...(text ? { text } : {}),
310
- }
311
-
312
- if (isMyMessage) {
313
- return <TypeNamespace.Sent {...props} />
314
- }
315
-
316
- return <TypeNamespace.Received {...props} />
317
- }
318
-
319
- export interface StreamAttachmentMessageProps {
320
- groupPosition: BubbleGroupPosition
321
- isMyMessage: boolean
322
- message: StreamThreadMessage
323
- }
324
-
325
- const StreamAttachmentMessage: React.FC<StreamAttachmentMessageProps> = ({
326
- groupPosition,
327
- isMyMessage,
328
- message,
329
- }) => {
330
- const segments = buildOrderedAttachmentSegments(message.attachments)
331
- if (segments.length === 0) return null
332
-
333
- const totalBubbles = countAttachmentSegmentBubbles(segments)
334
- const totalMediaBubbles = countMediaSegmentBubbles(segments)
335
- const rawCaption = message.text?.trim()
336
- const linkSegment = segments.find((segment) => segment.type === 'link')
337
- const primaryLinkUrl =
338
- linkSegment?.type === 'link'
339
- ? linkCardPropsFromStreamAttachment(linkSegment.attachments[0] ?? {}).url
340
- : undefined
341
- const caption =
342
- rawCaption &&
343
- linkCaptionDuplicatesAttachmentUrl(rawCaption, primaryLinkUrl)
344
- ? undefined
345
- : rawCaption
346
-
347
- let bubbleIndex = 0
348
- let mediaBubbleIndex = 0
349
- const rows: React.ReactNode[] = []
350
-
351
- const pushLinkRow = (attachment: Attachment, key: string) => {
352
- const isLastBubble = bubbleIndex === totalBubbles - 1
353
- bubbleIndex += 1
354
-
355
- const Card = isMyMessage ? LinkAttachment.Sent : LinkAttachment.Received
356
-
357
- rows.push(
358
- <React.Fragment key={key}>
359
- <Card {...linkCardPropsFromStreamAttachment(attachment)} />
360
- {isLastBubble && caption ? (
361
- <div className={isMyMessage ? SENT_CAPTION_CLASS : RECEIVED_CAPTION_CLASS}>
362
- {caption}
363
- </div>
364
- ) : null}
365
- </React.Fragment>
366
- )
367
- }
368
-
369
- const pushMediaCluster = (
370
- segment: Extract<AttachmentSegment, { type: 'media' }>,
371
- key: string
372
- ) => {
373
- const isLastBubble = bubbleIndex === totalBubbles - 1
374
- const position = bubblePositionInRun(
375
- mediaBubbleIndex,
376
- totalMediaBubbles,
377
- groupPosition
378
- )
379
-
380
- bubbleIndex += 1
381
- mediaBubbleIndex += 1
382
-
383
- rows.push(
384
- <MediaClusterBubble
385
- key={key}
386
- groupPosition={position}
387
- isMyMessage={isMyMessage}
388
- segment={segment}
389
- text={isLastBubble ? caption : undefined}
390
- />
391
- )
392
- }
393
-
394
- segments.forEach((segment, segmentIndex) => {
395
- if (segment.type === 'link') {
396
- segment.attachments.forEach((attachment, attachmentIndex) => {
397
- pushLinkRow(attachment, `link-${segmentIndex}-${attachmentIndex}`)
398
- })
399
- return
400
- }
401
-
402
- pushMediaCluster(segment, `media-${segmentIndex}`)
403
- })
404
-
405
- return <>{rows}</>
406
- }
407
-
408
- export default StreamAttachmentMessage