@linktr.ee/messaging-react 3.12.2-rc-1784020746 → 3.12.3-rc-1784032997

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 (33) hide show
  1. package/dist/{Card-Bk9bLpuH.js → Card-Bme-pBSF.js} +123 -119
  2. package/dist/Card-Bme-pBSF.js.map +1 -0
  3. package/dist/Card-D-SQP6VG.cjs +2 -0
  4. package/dist/Card-D-SQP6VG.cjs.map +1 -0
  5. package/dist/index-B8niA4Dg.cjs +2 -0
  6. package/dist/index-B8niA4Dg.cjs.map +1 -0
  7. package/dist/{index-ZjkTaCN6.js → index-D9_M5IWl.js} +1247 -1221
  8. package/dist/index-D9_M5IWl.js.map +1 -0
  9. package/dist/index.cjs +1 -1
  10. package/dist/index.d.ts +0 -10
  11. package/dist/index.js +1 -1
  12. package/dist/testing.d.ts +0 -10
  13. package/package.json +2 -2
  14. package/src/components/AttachmentCard/Thumbnail.tsx +10 -6
  15. package/src/components/ChannelHeaderRedesign.tsx +13 -4
  16. package/src/components/ChannelView.stories.tsx +7 -8
  17. package/src/components/ChannelView.test.tsx +5 -24
  18. package/src/components/CustomMessage/LockedAttachment/components/_shared/GalleryThumbnail.tsx +5 -1
  19. package/src/components/CustomMessage/LockedAttachment/components/_shared/SingleThumbnail.tsx +5 -1
  20. package/src/components/CustomMessage/StreamAttachmentMessage.test.tsx +33 -0
  21. package/src/components/CustomMessage/StreamAttachmentMessage.tsx +6 -3
  22. package/src/components/LinkAttachment/components/_shared/CardThumbnail.tsx +4 -2
  23. package/src/components/MediaMessage/index.tsx +2 -1
  24. package/src/components/MessageAttachment/Image/index.tsx +3 -2
  25. package/src/components/MessageAttachment/MessageAttachment.test.tsx +20 -0
  26. package/src/stream-custom-data.ts +0 -11
  27. package/src/utils/cdnImageUrl.ts +32 -0
  28. package/dist/Card-Bk9bLpuH.js.map +0 -1
  29. package/dist/Card-xlNNaZJO.cjs +0 -2
  30. package/dist/Card-xlNNaZJO.cjs.map +0 -1
  31. package/dist/index-BMr8tbg4.cjs +0 -2
  32. package/dist/index-BMr8tbg4.cjs.map +0 -1
  33. package/dist/index-ZjkTaCN6.js.map +0 -1
package/dist/index.cjs CHANGED
@@ -1,2 +1,2 @@
1
- "use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const e=require("./index-BMr8tbg4.cjs");exports.ActionButton=e.ActionButton;exports.Avatar=e.Avatar;exports.ChannelEmptyState=e.ChannelEmptyState;exports.ChannelList=e.ChannelList;exports.ChannelView=e.ChannelView;exports.CustomMessageProvider=e.CustomMessageProvider;exports.FaqList=e.FaqList;exports.FaqListItem=e.FaqListItem;exports.LinkAttachment=e.LinkAttachment;exports.LockedAttachment=e.LockedAttachment;exports.MediaMessage=e.MediaMessage;exports.MessageAttachment=e.MessageAttachment;exports.MessageBubble=e.MessageBubble;exports.MessageVoteButtons=e.MessageVoteButtons;exports.MessagingProvider=e.MessagingProvider;exports.MessagingShell=e.MessagingShell;exports.buildCompactMetaLabel=e.buildCompactMetaLabel;exports.formatFileSize=e.formatFileSize;exports.formatRelativeTime=e.formatRelativeTime;exports.getFileExtensionLabel=e.getFileExtensionLabel;exports.getMessageDisplayText=e.getMessageDisplayText;exports.isLinkAttachment=e.isLinkAttachment;exports.isUuidLike=e.isUuidLike;exports.messageAttachmentGroupPositionFromStream=e.bubbleGroupPositionFromStream;exports.normalizeLanguageCode=e.normalizeLanguageCode;exports.resolveConversationParticipant=e.resolveConversationParticipant;exports.resolveLinkAttachment=e.resolveLinkAttachment;exports.resolveMediaFromMessage=e.resolveMediaFromMessage;exports.resolveParticipantDisplayName=e.resolveParticipantDisplayName;exports.useComposerLocked=e.useComposerLocked;exports.useCustomMessage=e.useCustomMessage;exports.useMessageVote=e.useMessageVote;exports.useMessaging=e.useMessaging;
1
+ "use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const e=require("./index-B8niA4Dg.cjs");exports.ActionButton=e.ActionButton;exports.Avatar=e.Avatar;exports.ChannelEmptyState=e.ChannelEmptyState;exports.ChannelList=e.ChannelList;exports.ChannelView=e.ChannelView;exports.CustomMessageProvider=e.CustomMessageProvider;exports.FaqList=e.FaqList;exports.FaqListItem=e.FaqListItem;exports.LinkAttachment=e.LinkAttachment;exports.LockedAttachment=e.LockedAttachment;exports.MediaMessage=e.MediaMessage;exports.MessageAttachment=e.MessageAttachment;exports.MessageBubble=e.MessageBubble;exports.MessageVoteButtons=e.MessageVoteButtons;exports.MessagingProvider=e.MessagingProvider;exports.MessagingShell=e.MessagingShell;exports.buildCompactMetaLabel=e.buildCompactMetaLabel;exports.formatFileSize=e.formatFileSize;exports.formatRelativeTime=e.formatRelativeTime;exports.getFileExtensionLabel=e.getFileExtensionLabel;exports.getMessageDisplayText=e.getMessageDisplayText;exports.isLinkAttachment=e.isLinkAttachment;exports.isUuidLike=e.isUuidLike;exports.messageAttachmentGroupPositionFromStream=e.bubbleGroupPositionFromStream;exports.normalizeLanguageCode=e.normalizeLanguageCode;exports.resolveConversationParticipant=e.resolveConversationParticipant;exports.resolveLinkAttachment=e.resolveLinkAttachment;exports.resolveMediaFromMessage=e.resolveMediaFromMessage;exports.resolveParticipantDisplayName=e.resolveParticipantDisplayName;exports.useComposerLocked=e.useComposerLocked;exports.useCustomMessage=e.useCustomMessage;exports.useMessageVote=e.useMessageVote;exports.useMessaging=e.useMessaging;
2
2
  //# sourceMappingURL=index.cjs.map
package/dist/index.d.ts CHANGED
@@ -1713,16 +1713,6 @@ declare module 'stream-chat' {
1713
1713
  * Whether DM agent auto-replies are paused for this channel.
1714
1714
  */
1715
1715
  chatbot_paused?: boolean;
1716
- /**
1717
- * Customer classification tag for this conversation (e.g. CUSTOMER_PAID).
1718
- */
1719
- customer_tag?: string;
1720
- }
1721
- interface CustomMemberData {
1722
- /**
1723
- * Whether this member follows the channel's linker account.
1724
- */
1725
- is_follower?: boolean;
1726
1716
  }
1727
1717
  interface CustomMessageData {
1728
1718
  /**
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { a as e, b as t, C as i, c as o, d as n, e as m, F as r, f as g, L as l, h as M, M as u, i as c, j as L, k as h, l as d, m as p, n as v, o as C, p as A, q as k, s as F, t as b, u as P, v as f, w as x, x as y, y as B, z as S, B as q, D as z, E as D, G as E, H as V } from "./index-ZjkTaCN6.js";
1
+ import { a as e, b as t, C as i, c as o, d as n, e as m, F as r, f as g, L as l, h as M, M as u, i as c, j as L, k as h, l as d, m as p, n as v, p as C, q as A, s as k, t as F, u as b, v as P, w as f, x, y, z as B, B as S, D as q, E as z, G as D, H as E, I as V } from "./index-D9_M5IWl.js";
2
2
  export {
3
3
  e as ActionButton,
4
4
  t as Avatar,
package/dist/testing.d.ts CHANGED
@@ -133,16 +133,6 @@ declare module 'stream-chat' {
133
133
  * Whether DM agent auto-replies are paused for this channel.
134
134
  */
135
135
  chatbot_paused?: boolean;
136
- /**
137
- * Customer classification tag for this conversation (e.g. CUSTOMER_PAID).
138
- */
139
- customer_tag?: string;
140
- }
141
- interface CustomMemberData {
142
- /**
143
- * Whether this member follows the channel's linker account.
144
- */
145
- is_follower?: boolean;
146
136
  }
147
137
  interface CustomMessageData {
148
138
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@linktr.ee/messaging-react",
3
- "version": "3.12.2-rc-1784020746",
3
+ "version": "3.12.3-rc-1784032997",
4
4
  "description": "React messaging components built on messaging-core for web applications",
5
5
  "type": "module",
6
6
  "main": "dist/index.cjs",
@@ -50,7 +50,7 @@
50
50
  },
51
51
  "dependencies": {
52
52
  "@linktr.ee/component-library": "11.8.6",
53
- "@linktr.ee/messaging-core": "2.3.0-rc-1784020746",
53
+ "@linktr.ee/messaging-core": "2.3.0-rc-1784032997",
54
54
  "@phosphor-icons/react": "^2.1.10"
55
55
  },
56
56
  "devDependencies": {
@@ -1,5 +1,7 @@
1
1
  import React, { useState } from 'react'
2
2
 
3
+ import { optimizeMessagingAttachmentUrl } from '../../utils/cdnImageUrl'
4
+
3
5
  import MediaPlayer, { type MediaPlayerProps } from './MediaPlayer'
4
6
  import { renderTypeIcon } from './utils/icons'
5
7
  import { getSourceType } from './utils/mimeType'
@@ -48,13 +50,15 @@ const AttachmentThumbnail: React.FC<AttachmentThumbnailProps> = ({
48
50
  }) => {
49
51
  const sourceType = getSourceType(mimeType)
50
52
  const [sourceReady, setSourceReady] = useState(false)
53
+ const optimizedSourceUrl = optimizeMessagingAttachmentUrl(sourceUrl)
54
+ const optimizedThumbnailUrl = optimizeMessagingAttachmentUrl(thumbnailUrl)
51
55
 
52
56
  if (sourceUrl && (sourceType === 'video' || sourceType === 'audio')) {
53
57
  return (
54
58
  <MediaPlayer
55
59
  source={sourceUrl}
56
60
  mimeType={mimeType}
57
- poster={thumbnailUrl}
61
+ poster={optimizedThumbnailUrl}
58
62
  controls
59
63
  {...mediaPlayerProps}
60
64
  />
@@ -66,7 +70,7 @@ const AttachmentThumbnail: React.FC<AttachmentThumbnailProps> = ({
66
70
  return (
67
71
  <div className="relative aspect-video overflow-hidden bg-black/5">
68
72
  <img
69
- src={sourceUrl}
73
+ src={optimizedSourceUrl ?? sourceUrl}
70
74
  alt={title ?? ''}
71
75
  className={`absolute inset-0 h-full w-full object-contain transition-opacity duration-300 ${sourceReady ? 'opacity-100' : 'opacity-0'}`}
72
76
  draggable={false}
@@ -77,7 +81,7 @@ const AttachmentThumbnail: React.FC<AttachmentThumbnailProps> = ({
77
81
  }
78
82
  return (
79
83
  <img
80
- src={sourceUrl}
84
+ src={optimizedSourceUrl ?? sourceUrl}
81
85
  alt={title ?? ''}
82
86
  className="block w-full"
83
87
  draggable={false}
@@ -91,7 +95,7 @@ const AttachmentThumbnail: React.FC<AttachmentThumbnailProps> = ({
91
95
  return (
92
96
  <div className="relative aspect-video overflow-hidden bg-black/5">
93
97
  <img
94
- src={thumbnailUrl}
98
+ src={optimizedThumbnailUrl ?? thumbnailUrl}
95
99
  alt={title ?? ''}
96
100
  className={`absolute inset-0 h-full w-full object-contain transition-opacity duration-300 ${sourceReady ? 'opacity-100' : 'opacity-0'}`}
97
101
  draggable={false}
@@ -102,7 +106,7 @@ const AttachmentThumbnail: React.FC<AttachmentThumbnailProps> = ({
102
106
  }
103
107
  return (
104
108
  <img
105
- src={thumbnailUrl}
109
+ src={optimizedThumbnailUrl ?? thumbnailUrl}
106
110
  alt=""
107
111
  className="block w-full"
108
112
  draggable={false}
@@ -126,7 +130,7 @@ const AttachmentThumbnail: React.FC<AttachmentThumbnailProps> = ({
126
130
  return (
127
131
  <div className={`relative ${posterShellClass(variant)}`}>
128
132
  <img
129
- src={thumbnailUrl}
133
+ src={optimizedThumbnailUrl ?? thumbnailUrl}
130
134
  alt={title ?? ''}
131
135
  draggable={false}
132
136
  className="absolute inset-0 h-full w-full object-cover"
@@ -7,7 +7,7 @@ import {
7
7
  } from '@phosphor-icons/react'
8
8
  import classNames from 'classnames'
9
9
  import React from 'react'
10
- import type { Channel as ChannelType } from 'stream-chat'
10
+ import type { ChannelMemberResponse, Channel as ChannelType } from 'stream-chat'
11
11
  import { useChannelStateContext, useChatContext } from 'stream-chat-react'
12
12
 
13
13
  import { useChannelStar } from '../hooks/useChannelStar'
@@ -30,6 +30,17 @@ const isCustomerPaidTag = (value: unknown) =>
30
30
  .replace(/[\s-]+/g, '_')
31
31
  .toUpperCase() === CUSTOMER_PAID_TAG
32
32
 
33
+ const hasCustomerPaidTag = (participant?: ChannelMemberResponse) => {
34
+ const member = participant as
35
+ | {
36
+ customerTag?: unknown
37
+ customer_tag?: unknown
38
+ }
39
+ | undefined
40
+
41
+ return [member?.customerTag, member?.customer_tag].some(isCustomerPaidTag)
42
+ }
43
+
33
44
  interface ChannelHeaderRedesignProps {
34
45
  className?: string
35
46
  onBack?: () => void
@@ -85,9 +96,7 @@ export const ChannelHeaderRedesign: React.FC<ChannelHeaderRedesignProps> = ({
85
96
  const participantName = resolveParticipantDisplayName(participant?.user)
86
97
  const participantImage = participant?.user?.image
87
98
  const isStarred = useChannelStar(channel)
88
- const isPaidCustomer =
89
- isCustomerPaidTag(channel.data?.customer_tag) &&
90
- participant?.is_follower === true
99
+ const isPaidCustomer = hasCustomerPaidTag(participant)
91
100
 
92
101
  const handleStarClick = async () => {
93
102
  try {
@@ -44,7 +44,7 @@ const createMockChannel = async (
44
44
  hasMessages = true,
45
45
  followerStatus?: string | boolean,
46
46
  isFrozen = false,
47
- channelCustomerTag?: string,
47
+ participantCustomerTag?: string,
48
48
  hasScrollableMessages = false
49
49
  ) => {
50
50
  const participant = mockParticipants[0]
@@ -140,7 +140,6 @@ const createMockChannel = async (
140
140
  const channelData: Record<string, unknown> = {
141
141
  members: [mockUser.id, participant.id],
142
142
  frozen: isFrozen,
143
- ...(channelCustomerTag ? { customer_tag: channelCustomerTag } : {}),
144
143
  }
145
144
 
146
145
  // Add follower status if provided
@@ -179,7 +178,7 @@ const createMockChannel = async (
179
178
  user_id: participant.id,
180
179
  role: 'member',
181
180
  is_account: true,
182
- ...(channelCustomerTag ? { is_follower: true } : {}),
181
+ customerTag: participantCustomerTag,
183
182
  },
184
183
  }
185
184
  return {
@@ -215,7 +214,7 @@ type TemplateProps = ComponentProps & {
215
214
  name?: string
216
215
  image?: string
217
216
  }
218
- channelCustomerTag?: string
217
+ participantCustomerTag?: string
219
218
  hasScrollableMessages?: boolean
220
219
  }
221
220
 
@@ -224,7 +223,7 @@ const Template: StoryFn<TemplateProps> = (args) => {
224
223
  hasMessages = true,
225
224
  followerStatus,
226
225
  isFrozen = false,
227
- channelCustomerTag,
226
+ participantCustomerTag,
228
227
  hasScrollableMessages,
229
228
  typingUser,
230
229
  ...channelViewProps
@@ -239,7 +238,7 @@ const Template: StoryFn<TemplateProps> = (args) => {
239
238
  hasMessages,
240
239
  followerStatus,
241
240
  isFrozen,
242
- channelCustomerTag,
241
+ participantCustomerTag,
243
242
  hasScrollableMessages
244
243
  ).then((mockChannel) => {
245
244
  setChannel(mockChannel)
@@ -249,7 +248,7 @@ const Template: StoryFn<TemplateProps> = (args) => {
249
248
  hasMessages,
250
249
  followerStatus,
251
250
  isFrozen,
252
- channelCustomerTag,
251
+ participantCustomerTag,
253
252
  hasScrollableMessages,
254
253
  ])
255
254
 
@@ -306,7 +305,7 @@ export const ParticipantIdentityTrigger: StoryFn<TemplateProps> = Template.bind(
306
305
  ParticipantIdentityTrigger.args = {
307
306
  showBackButton: false,
308
307
  showStarButton: true,
309
- channelCustomerTag: 'CUSTOMER_PAID',
308
+ participantCustomerTag: 'CUSTOMER_PAID',
310
309
  hasScrollableMessages: true,
311
310
  onParticipantNameClick: () =>
312
311
  // eslint-disable-next-line no-alert
@@ -111,14 +111,12 @@ const createChannel = ({
111
111
  username: undefined as string | undefined,
112
112
  is_account: true,
113
113
  },
114
- channelCustomerTag,
115
- otherMemberIsFollower,
114
+ otherMemberCustomerTag,
116
115
  }: {
117
116
  currentUserId?: 'visitor-1' | 'linker-1'
118
117
  currentUserRole?: 'owner' | 'member'
119
118
  messages?: Channel['state']['messages']
120
- channelCustomerTag?: string
121
- otherMemberIsFollower?: boolean
119
+ otherMemberCustomerTag?: string
122
120
  otherUser?: {
123
121
  id: string
124
122
  name?: string
@@ -129,9 +127,7 @@ const createChannel = ({
129
127
  ({
130
128
  id: 'channel-1',
131
129
  cid: 'messaging:channel-1',
132
- data: {
133
- customer_tag: channelCustomerTag,
134
- },
130
+ data: {},
135
131
  _client: { userID: currentUserId },
136
132
  state: {
137
133
  members: {
@@ -151,7 +147,7 @@ const createChannel = ({
151
147
  (currentUserId === 'visitor-1' ? 'linker-1' : 'visitor-1'),
152
148
  is_account: otherUser.is_account ?? currentUserId === 'visitor-1',
153
149
  },
154
- is_follower: otherMemberIsFollower,
150
+ customerTag: otherMemberCustomerTag,
155
151
  role: currentUserRole === 'owner' ? 'member' : 'owner',
156
152
  },
157
153
  },
@@ -255,8 +251,7 @@ describe('ChannelView', () => {
255
251
  renderWithProviders(
256
252
  <ChannelView
257
253
  channel={createChannel({
258
- channelCustomerTag: 'CUSTOMER_PAID',
259
- otherMemberIsFollower: true,
254
+ otherMemberCustomerTag: 'CUSTOMER_PAID',
260
255
  })}
261
256
  onParticipantNameClick={vi.fn()}
262
257
  showStarButton={true}
@@ -267,20 +262,6 @@ describe('ChannelView', () => {
267
262
  expect(screen.getByLabelText('Starred conversation')).toBeInTheDocument()
268
263
  })
269
264
 
270
- it('hides the paid customer badge when the participant is not a follower', () => {
271
- renderWithProviders(
272
- <ChannelView
273
- channel={createChannel({
274
- channelCustomerTag: 'CUSTOMER_PAID',
275
- otherMemberIsFollower: false,
276
- })}
277
- onParticipantNameClick={vi.fn()}
278
- />
279
- )
280
-
281
- expect(screen.queryByLabelText('Paid customer')).not.toBeInTheDocument()
282
- })
283
-
284
265
  it('renders the redesigned header outside MessageList head when all messages are deleted', () => {
285
266
  renderWithProviders(
286
267
  <ChannelView
@@ -2,6 +2,7 @@ import { CaretLeftIcon, CaretRightIcon } from '@phosphor-icons/react'
2
2
  import classNames from 'classnames'
3
3
  import React, { useState } from 'react'
4
4
 
5
+ import { optimizeMessagingAttachmentUrl } from '../../../../../utils/cdnImageUrl'
5
6
  import { AttachmentThumbnail, getSourceType } from '../../../../AttachmentCard'
6
7
  import type {
7
8
  LockedAttachmentGalleryItem,
@@ -46,6 +47,9 @@ const GalleryThumbnail: React.FC<GalleryThumbnailProps> = ({
46
47
  const safeIndex = Math.min(index, total - 1)
47
48
  const current = gallery[safeIndex]
48
49
  const isVideoItem = isPlayableItem(current)
50
+ const optimizedCurrentThumbnailUrl = optimizeMessagingAttachmentUrl(
51
+ current.thumbnailUrl
52
+ )
49
53
 
50
54
  // Stop propagation so arrow taps don't bubble up to the outer preview-toggle
51
55
  // wrapper on PreviewMediaCard / OwnMediaCard.
@@ -90,7 +94,7 @@ const GalleryThumbnail: React.FC<GalleryThumbnailProps> = ({
90
94
  >
91
95
  {current.thumbnailUrl && (
92
96
  <img
93
- src={current.thumbnailUrl}
97
+ src={optimizedCurrentThumbnailUrl ?? current.thumbnailUrl}
94
98
  alt=""
95
99
  aria-hidden
96
100
  draggable={false}
@@ -1,6 +1,7 @@
1
1
  import classNames from 'classnames'
2
2
  import React from 'react'
3
3
 
4
+ import { optimizeMessagingAttachmentUrl } from '../../../../../utils/cdnImageUrl'
4
5
  import {
5
6
  AttachmentThumbnail,
6
7
  renderTypeIcon,
@@ -55,6 +56,9 @@ const SingleThumbnail: React.FC<SingleThumbnailProps> = ({
55
56
  const effectiveThumbnailUrl = showLocked
56
57
  ? thumbnailUrl
57
58
  : (source?.thumbnailUrl ?? thumbnailUrl)
59
+ const optimizedThumbnailUrl = optimizeMessagingAttachmentUrl(
60
+ effectiveThumbnailUrl
61
+ )
58
62
 
59
63
  const isDark = variant === 'dark'
60
64
 
@@ -80,7 +84,7 @@ const SingleThumbnail: React.FC<SingleThumbnailProps> = ({
80
84
  >
81
85
  {effectiveThumbnailUrl ? (
82
86
  <img
83
- src={effectiveThumbnailUrl}
87
+ src={optimizedThumbnailUrl ?? effectiveThumbnailUrl}
84
88
  alt=""
85
89
  aria-hidden
86
90
  draggable={false}
@@ -25,6 +25,18 @@ describe('linkCardPropsFromStreamAttachment', () => {
25
25
  ).toBe('featured')
26
26
  })
27
27
 
28
+ it('optimizes Linktree-hosted thumbnails for messaging attachment renders', () => {
29
+ expect(
30
+ linkCardPropsFromStreamAttachment({
31
+ type: 'link',
32
+ og_scrape_url: 'https://linktr.ee/someone',
33
+ image_url: 'https://assets.linktr.ee/thumb.jpg',
34
+ }).thumbnailUrl
35
+ ).toBe(
36
+ 'https://assets.linktr.ee/thumb.jpg?io=true&size=messaging-attachment-v1_0'
37
+ )
38
+ })
39
+
28
40
  it('uses the classic layout for a plain link with no thumbnail', () => {
29
41
  expect(
30
42
  linkCardPropsFromStreamAttachment({
@@ -152,6 +164,27 @@ describe('StreamAttachmentMessage', () => {
152
164
  expect(container).toHaveTextContent('caption')
153
165
  })
154
166
 
167
+ it('optimizes Linktree-hosted image attachments for the channel bubble', () => {
168
+ renderWithProviders(
169
+ <StreamAttachmentMessage
170
+ groupPosition="single"
171
+ isMyMessage={false}
172
+ message={message([
173
+ {
174
+ type: 'image',
175
+ image_url: 'https://ugc.production.linktr.ee/photo.jpg',
176
+ title: 'Vacation',
177
+ },
178
+ ])}
179
+ />
180
+ )
181
+
182
+ expect(screen.getByRole('img', { name: 'Vacation' })).toHaveAttribute(
183
+ 'src',
184
+ 'https://ugc.production.linktr.ee/photo.jpg?io=true&size=messaging-attachment-v1_0'
185
+ )
186
+ })
187
+
155
188
  it('renders the accompanying message text next to a link attachment', () => {
156
189
  const { container } = renderWithProviders(
157
190
  <StreamAttachmentMessage
@@ -6,6 +6,7 @@ import {
6
6
  type MessageContextValue,
7
7
  } from 'stream-chat-react'
8
8
 
9
+ import { optimizeMessagingAttachmentUrl } from '../../utils/cdnImageUrl'
9
10
  import LinkAttachment from '../LinkAttachment'
10
11
  import { isPlayableMedia } from '../LinkAttachment/components/_shared/CardThumbnail'
11
12
  import MessageAttachment from '../MessageAttachment'
@@ -65,7 +66,7 @@ export function linkCardPropsFromStreamAttachment(a: Attachment) {
65
66
  title: trimToUndefined((a as { title?: string }).title),
66
67
  description: trimToUndefined((a as { text?: string }).text),
67
68
  url,
68
- thumbnailUrl,
69
+ thumbnailUrl: optimizeMessagingAttachmentUrl(thumbnailUrl),
69
70
  // Playable clip embedded in a link preview — lets LinkAttachment render its
70
71
  // native video/audio player in the hero instead of a static thumbnail.
71
72
  sourceUrl,
@@ -166,8 +167,10 @@ export function bubblePositionInRun(
166
167
 
167
168
  function imageSrcFromAttachment(attachment: Attachment): string {
168
169
  return (
169
- trimToUndefined((attachment as { image_url?: string }).image_url) ??
170
- trimToUndefined(attachment.asset_url) ??
170
+ optimizeMessagingAttachmentUrl(
171
+ trimToUndefined((attachment as { image_url?: string }).image_url)
172
+ ) ??
173
+ optimizeMessagingAttachmentUrl(trimToUndefined(attachment.asset_url)) ??
171
174
  ''
172
175
  )
173
176
  }
@@ -1,6 +1,7 @@
1
1
  import classNames from 'classnames'
2
2
  import React from 'react'
3
3
 
4
+ import { optimizeMessagingAttachmentUrl } from '../../../../utils/cdnImageUrl'
4
5
  import { renderTypeIcon } from '../../../AttachmentCard'
5
6
  import { getSourceType } from '../../../AttachmentCard/utils/mimeType'
6
7
 
@@ -81,6 +82,7 @@ const CardThumbnail: React.FC<CardThumbnailProps> = ({
81
82
  }) => {
82
83
  const sourceType = getSourceType(mimeType)
83
84
  const isPlayableVideo = !!sourceUrl && sourceType === 'video'
85
+ const optimizedThumbnailUrl = optimizeMessagingAttachmentUrl(thumbnailUrl)
84
86
 
85
87
  if (isPlayableAudio(mimeType, sourceUrl)) {
86
88
  // Audio collapses the hero entirely — the native player sits inside
@@ -110,7 +112,7 @@ const CardThumbnail: React.FC<CardThumbnailProps> = ({
110
112
  {isPlayableVideo ? (
111
113
  <video
112
114
  src={sourceUrl}
113
- poster={thumbnailUrl}
115
+ poster={optimizedThumbnailUrl}
114
116
  controls
115
117
  playsInline
116
118
  preload="metadata"
@@ -120,7 +122,7 @@ const CardThumbnail: React.FC<CardThumbnailProps> = ({
120
122
  </video>
121
123
  ) : thumbnailUrl ? (
122
124
  <img
123
- src={thumbnailUrl}
125
+ src={optimizedThumbnailUrl ?? thumbnailUrl}
124
126
  alt={title ?? ''}
125
127
  draggable={false}
126
128
  className="absolute inset-0 h-full w-full object-cover"
@@ -2,6 +2,7 @@ import { CircleNotchIcon, DownloadSimpleIcon, LinkIcon } from '@phosphor-icons/r
2
2
  import React, { useState } from 'react'
3
3
  import type { Attachment, LocalMessage } from 'stream-chat'
4
4
 
5
+ import { optimizeMessagingAttachmentUrl } from '../../utils/cdnImageUrl'
5
6
  import AttachmentCard, {
6
7
  AttachmentThumbnail,
7
8
  getSourceType,
@@ -54,7 +55,7 @@ const LinkCard: React.FC<{
54
55
  <div className="p-2">
55
56
  {image_url ? (
56
57
  <img
57
- src={image_url}
58
+ src={optimizeMessagingAttachmentUrl(image_url) ?? image_url}
58
59
  alt={title ?? ''}
59
60
  className="aspect-video w-full rounded-[20px] object-cover"
60
61
  />
@@ -1,5 +1,6 @@
1
1
  import React from 'react'
2
2
 
3
+ import { optimizeMessagingAttachmentUrl } from '../../../utils/cdnImageUrl'
3
4
  import Bubble from '../_shared/Bubble'
4
5
  import DismissButton from '../_shared/DismissButton'
5
6
  import ImageViewer, {
@@ -61,7 +62,7 @@ const tileFromItem = (
61
62
  ariaLabel: `Open image ${index + 1} of ${totalCount}`,
62
63
  content: (
63
64
  <img
64
- src={item.src}
65
+ src={optimizeMessagingAttachmentUrl(item.src) ?? item.src}
65
66
  alt={item.alt ?? ''}
66
67
  width={item.width}
67
68
  height={item.height}
@@ -148,7 +149,7 @@ const ImageComposerInner: React.FC<{
148
149
  className="block size-[280px] cursor-zoom-in overflow-hidden rounded-md outline-none focus-visible:ring-2 focus-visible:ring-black/40"
149
150
  >
150
151
  <img
151
- src={src}
152
+ src={optimizeMessagingAttachmentUrl(src) ?? src}
152
153
  alt={alt ?? ''}
153
154
  draggable={false}
154
155
  loading={loading}
@@ -33,6 +33,26 @@ const expectViewerOpen = (testId: string) => {
33
33
  }
34
34
 
35
35
  describe('MessageAttachment.Image', () => {
36
+ it('optimizes Linktree-hosted composer previews without changing the viewer source', () => {
37
+ renderWithProviders(
38
+ <MessageAttachment.Image.Composer
39
+ src="https://assets.linktr.ee/photo.jpg"
40
+ alt="Photo"
41
+ />
42
+ )
43
+
44
+ expect(screen.getByRole('img', { name: 'Photo' })).toHaveAttribute(
45
+ 'src',
46
+ 'https://assets.linktr.ee/photo.jpg?io=true&size=messaging-attachment-v1_0'
47
+ )
48
+
49
+ fireEvent.click(screen.getByLabelText('Open image'))
50
+ const viewer = expectViewerOpen('image-viewer')
51
+ expect(viewer.querySelector('img')?.getAttribute('src')).toBe(
52
+ 'https://assets.linktr.ee/photo.jpg'
53
+ )
54
+ })
55
+
36
56
  it('renders an image bubble with caption', () => {
37
57
  renderWithProviders(
38
58
  <MessageAttachment.Image.Sent
@@ -97,17 +97,6 @@ declare module 'stream-chat' {
97
97
  * Whether DM agent auto-replies are paused for this channel.
98
98
  */
99
99
  chatbot_paused?: boolean
100
- /**
101
- * Customer classification tag for this conversation (e.g. CUSTOMER_PAID).
102
- */
103
- customer_tag?: string
104
- }
105
-
106
- interface CustomMemberData {
107
- /**
108
- * Whether this member follows the channel's linker account.
109
- */
110
- is_follower?: boolean
111
100
  }
112
101
 
113
102
  interface CustomMessageData {
@@ -0,0 +1,32 @@
1
+ const LINKTREE_CDN_HOSTS = ['linktr.ee', 'cloudfront.net', 'cloudinary']
2
+
3
+ const ATTACHMENT_IMAGE_SIZE_PRESET = 'messaging-attachment-v1_0'
4
+
5
+ function hostnameFromUrl(url: string): string | null {
6
+ try {
7
+ return new URL(url).hostname.toLowerCase()
8
+ } catch {
9
+ return null
10
+ }
11
+ }
12
+
13
+ function isLinktreeCdnHost(hostname: string): boolean {
14
+ return LINKTREE_CDN_HOSTS.some((host) => hostname.includes(host))
15
+ }
16
+
17
+ export function optimizeMessagingAttachmentUrl(
18
+ url: string | undefined | null
19
+ ): string | undefined {
20
+ const trimmed = url?.trim()
21
+ if (!trimmed) return undefined
22
+
23
+ const hostname = hostnameFromUrl(trimmed)
24
+ if (!hostname || !isLinktreeCdnHost(hostname)) return trimmed
25
+
26
+ if (/[?&]io=true(?:&|$)/.test(trimmed) && /[?&]size=/.test(trimmed)) {
27
+ return trimmed
28
+ }
29
+
30
+ const separator = trimmed.includes('?') ? '&' : '?'
31
+ return `${trimmed}${separator}io=true&size=${ATTACHMENT_IMAGE_SIZE_PRESET}`
32
+ }