@linktr.ee/messaging-react 3.9.1-rc-1783576271 → 3.9.1-rc-1783580113

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 (34) hide show
  1. package/dist/{Card-DmfP-4oF.cjs → Card-BAWe1nw6.cjs} +2 -2
  2. package/dist/{Card-DmfP-4oF.cjs.map → Card-BAWe1nw6.cjs.map} +1 -1
  3. package/dist/{Card-PBfzY620.cjs → Card-BacmKRPX.cjs} +2 -2
  4. package/dist/{Card-PBfzY620.cjs.map → Card-BacmKRPX.cjs.map} +1 -1
  5. package/dist/{Card-CyBRO0Ex.cjs → Card-Bm6-f8ki.cjs} +2 -2
  6. package/dist/{Card-CyBRO0Ex.cjs.map → Card-Bm6-f8ki.cjs.map} +1 -1
  7. package/dist/{Card-xR3OTXRB.js → Card-CT51GZA-.js} +2 -2
  8. package/dist/{Card-xR3OTXRB.js.map → Card-CT51GZA-.js.map} +1 -1
  9. package/dist/{Card-DYNxwKX6.js → Card-CbRJaszK.js} +2 -2
  10. package/dist/{Card-DYNxwKX6.js.map → Card-CbRJaszK.js.map} +1 -1
  11. package/dist/{Card-dZkL-om-.js → Card-t7hyYqE2.js} +3 -3
  12. package/dist/{Card-dZkL-om-.js.map → Card-t7hyYqE2.js.map} +1 -1
  13. package/dist/{LockedThumbnail-eE8ljauK.cjs → LockedThumbnail-D2AyOLj7.cjs} +2 -2
  14. package/dist/{LockedThumbnail-eE8ljauK.cjs.map → LockedThumbnail-D2AyOLj7.cjs.map} +1 -1
  15. package/dist/{LockedThumbnail-BblFdDJp.js → LockedThumbnail-DA2ypEZM.js} +2 -2
  16. package/dist/{LockedThumbnail-BblFdDJp.js.map → LockedThumbnail-DA2ypEZM.js.map} +1 -1
  17. package/dist/{index-FY6hmphi.js → index-C2r41RoF.js} +1168 -1153
  18. package/dist/index-C2r41RoF.js.map +1 -0
  19. package/dist/index-DgqwF0XA.cjs +2 -0
  20. package/dist/index-DgqwF0XA.cjs.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/ChannelHeaderRedesign.tsx +8 -1
  25. package/src/components/ChannelView.tsx +12 -9
  26. package/src/components/CustomMessage/SentMessageDeliveryStatus.test.tsx +11 -29
  27. package/src/components/CustomMessage/SentMessageDeliveryStatus.tsx +26 -22
  28. package/src/components/CustomMessage/StreamAttachmentMessage.stories.tsx +19 -12
  29. package/src/components/CustomMessage/StreamAttachmentMessage.test.tsx +44 -26
  30. package/src/components/CustomMessage/StreamAttachmentMessage.tsx +37 -43
  31. package/src/components/CustomMessage/index.tsx +9 -21
  32. package/dist/index-Bxezkdl6.cjs +0 -2
  33. package/dist/index-Bxezkdl6.cjs.map +0 -1
  34. package/dist/index-FY6hmphi.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-Bxezkdl6.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.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-DgqwF0XA.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.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.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 c, i as u, 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 P, u as b, v as f, w as x, x as y, y as S, z as q, B as z, D as B, E as D, G as E } from "./index-FY6hmphi.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 c, i as u, 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 P, u as b, v as f, w as x, x as y, y as S, z as q, B as z, D as B, E as D, G as E } from "./index-C2r41RoF.js";
2
2
  export {
3
3
  e as ActionButton,
4
4
  t as Avatar,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@linktr.ee/messaging-react",
3
- "version": "3.9.1-rc-1783576271",
3
+ "version": "3.9.1-rc-1783580113",
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-1783576271",
53
+ "@linktr.ee/messaging-core": "2.3.0-rc-1783580113",
54
54
  "@phosphor-icons/react": "^2.1.10"
55
55
  },
56
56
  "devDependencies": {
@@ -42,6 +42,7 @@ const hasCustomerPaidTag = (participant?: ChannelMemberResponse) => {
42
42
  }
43
43
 
44
44
  interface ChannelHeaderRedesignProps {
45
+ className?: string
45
46
  onBack?: () => void
46
47
  showBackButton: boolean
47
48
  showStarButton?: boolean
@@ -61,6 +62,7 @@ interface ChannelHeaderRedesignProps {
61
62
  }
62
63
 
63
64
  export const ChannelHeaderRedesign: React.FC<ChannelHeaderRedesignProps> = ({
65
+ className,
64
66
  onBack,
65
67
  showBackButton,
66
68
  showStarButton = false,
@@ -112,7 +114,12 @@ export const ChannelHeaderRedesign: React.FC<ChannelHeaderRedesignProps> = ({
112
114
  }
113
115
 
114
116
  return (
115
- <div className="@container w-full bg-[linear-gradient(180deg,#FBFAF9_0%,rgba(251,250,249,0.92)_58%,rgba(251,250,249,0)_100%)]">
117
+ <div
118
+ className={classNames(
119
+ '@container bg-[linear-gradient(180deg,#FBFAF9_0%,rgba(251,250,249,0.92)_58%,rgba(251,250,249,0)_100%)]',
120
+ className
121
+ )}
122
+ >
116
123
  <div className="grid min-h-[100px] grid-cols-[minmax(40px,1fr)_auto_minmax(40px,1fr)] items-start pb-2 pt-5 @lg:min-h-[104px] @lg:pt-6">
117
124
  <div className="flex items-start">
118
125
  {showBackButton && (
@@ -475,6 +475,7 @@ const ChannelViewInner: React.FC<{
475
475
  ) : null
476
476
  const redesignedHeader = onParticipantNameClick ? (
477
477
  <ChannelHeaderRedesign
478
+ className="-mx-[var(--str-chat\_\_spacing-2)] px-[var(--str-chat\_\_spacing-2)] md:-mx-[min(var(--str-chat\_\_spacing-10),4%)] md:px-[min(var(--str-chat\_\_spacing-10),4%)]"
478
479
  onBack={onBack}
479
480
  showBackButton={showBackButton}
480
481
  showChannelInfo={showChannelInfo}
@@ -552,20 +553,22 @@ const ChannelViewInner: React.FC<{
552
553
  </>
553
554
  )}
554
555
 
555
- {/* Stream only renders MessageList.head when there are messages, so
556
- empty redesigned channels need the header and banner in normal flow. */}
557
- {onParticipantNameClick && !hasVisibleMessages && (
558
- <div className="px-4 @lg:px-6">
559
- {redesignedHeader}
560
- {channelBanner}
561
- </div>
562
- )}
563
-
564
556
  {/* Message List */}
565
557
  <div
566
558
  key="lt-channel-message-list"
567
559
  className="flex-1 overflow-hidden relative"
568
560
  >
561
+ {/* Stream only renders MessageList.head when there are messages, so
562
+ empty redesigned channels need the header and banner in normal flow. */}
563
+ {onParticipantNameClick && !hasVisibleMessages && (
564
+ <div className="str-chat__list">
565
+ <div className="str-chat__message-list-scroll">
566
+ {redesignedHeader}
567
+ {channelBanner}
568
+ </div>
569
+ </div>
570
+ )}
571
+
569
572
  <MessageList
570
573
  head={
571
574
  onParticipantNameClick && hasVisibleMessages ? (
@@ -1,6 +1,7 @@
1
1
  import React from 'react'
2
2
  import {
3
3
  useChannelStateContext,
4
+ useChatContext,
4
5
  useMessageContext,
5
6
  } from 'stream-chat-react'
6
7
  import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -12,13 +13,14 @@ import { SentMessageDeliveryStatus } from './SentMessageDeliveryStatus'
12
13
  // stream-chat-react's `MessageStatus` owns the outer gating (own message, not
13
14
  // error, not in a thread) and only exposes a terminal state once a message is
14
15
  // stored — Stream's tested code. What this component owns is the "Delivered"
15
- // label wired into the terminal slots (sent/delivered/read), its "latest
16
- // message in the thread" gate derived from live channel state, and suppressing
17
- // the sending spinner. Mock `MessageStatus` to render the slots it was handed,
18
- // and the context hooks the label reads.
16
+ // label wired into the terminal slots (sent/delivered/read), its "last own
17
+ // message" gate derived from live channel state, and suppressing the sending
18
+ // spinner. Mock `MessageStatus` to render the slots it was handed, and the
19
+ // context hooks the label reads.
19
20
  vi.mock('stream-chat-react', () => ({
20
21
  useMessageContext: vi.fn(),
21
22
  useChannelStateContext: vi.fn(),
23
+ useChatContext: vi.fn(),
22
24
  MessageStatus: ({
23
25
  MessageSentStatus,
24
26
  MessageDeliveredStatus,
@@ -44,10 +46,10 @@ vi.mock('stream-chat-react', () => ({
44
46
 
45
47
  const mockedUseMessageContext = vi.mocked(useMessageContext)
46
48
  const mockedUseChannelStateContext = vi.mocked(useChannelStateContext)
49
+ const mockedUseChatContext = vi.mocked(useChatContext)
47
50
 
48
51
  // A run where the viewer ('me') sent the first and last messages, with an
49
- // incoming message in between — the label must sit on the last message ('own-2')
50
- // and clear from the earlier one.
52
+ // incoming message in between — the case that broke the memo-based approach.
51
53
  const CHANNEL_MESSAGES = [
52
54
  { id: 'own-1', user: { id: 'me' } },
53
55
  { id: 'their-1', user: { id: 'them' } },
@@ -58,12 +60,13 @@ const TERMINAL_SLOTS = ['sent-slot', 'delivered-slot', 'read-slot'] as const
58
60
 
59
61
  describe('SentMessageDeliveryStatus', () => {
60
62
  beforeEach(() => {
63
+ mockedUseChatContext.mockReturnValue({ client: { user: { id: 'me' } } } as never)
61
64
  mockedUseChannelStateContext.mockReturnValue({
62
65
  messages: CHANNEL_MESSAGES,
63
66
  } as never)
64
67
  })
65
68
 
66
- it('renders "Delivered" in every terminal slot for the last message in the thread', () => {
69
+ it('renders "Delivered" in every terminal slot for the last own message', () => {
67
70
  // delivered/read fire ahead of sent in receipt channels, so all three
68
71
  // must show the label — never Stream's default delivered icon / read
69
72
  // avatars.
@@ -80,7 +83,7 @@ describe('SentMessageDeliveryStatus', () => {
80
83
  }
81
84
  })
82
85
 
83
- it('renders nothing for an earlier own message once a later message exists', () => {
86
+ it('renders nothing for an earlier own message even though one is the last own message', () => {
84
87
  mockedUseMessageContext.mockReturnValue({
85
88
  message: { id: 'own-1' },
86
89
  } as never)
@@ -92,27 +95,6 @@ describe('SentMessageDeliveryStatus', () => {
92
95
  }
93
96
  })
94
97
 
95
- it('renders nothing for an own message once a reply arrives after it', () => {
96
- // 'own-2' was the viewer's last sent message, but 'their-1' is now the
97
- // latest message in the thread — the label must clear from the bubble.
98
- mockedUseChannelStateContext.mockReturnValue({
99
- messages: [
100
- { id: 'own-1', user: { id: 'me' } },
101
- { id: 'own-2', user: { id: 'me' } },
102
- { id: 'their-1', user: { id: 'them' } },
103
- ],
104
- } as never)
105
- mockedUseMessageContext.mockReturnValue({
106
- message: { id: 'own-2' },
107
- } as never)
108
-
109
- renderWithProviders(<SentMessageDeliveryStatus />)
110
-
111
- for (const slot of TERMINAL_SLOTS) {
112
- expect(screen.getByTestId(slot)).toBeEmptyDOMElement()
113
- }
114
- })
115
-
116
98
  it('always suppresses the sending spinner', () => {
117
99
  mockedUseMessageContext.mockReturnValue({
118
100
  message: { id: 'own-2' },
@@ -3,6 +3,7 @@ import { useMemo } from 'react'
3
3
  import {
4
4
  MessageStatus,
5
5
  useChannelStateContext,
6
+ useChatContext,
6
7
  useMessageContext,
7
8
  } from 'stream-chat-react'
8
9
 
@@ -14,23 +15,21 @@ import {
14
15
  *
15
16
  * 1. `MessageStatus` picks its `delivered`/`read` branches ahead of `sent`
16
17
  * whenever Stream has `deliveredTo`/`readBy`, and — unlike `sent` — those
17
- * branches aren't gated to the last message. So we override all three
18
+ * branches aren't gated to the last own message. So we override all three
18
19
  * terminal slots with this same label (never Stream's default delivered
19
- * icon / read avatars — Read is out of scope) and re-apply the "latest
20
- * message in the thread" gate ourselves: the label only shows beneath the
21
- * viewer's sent message while it is still the last message in the channel,
22
- * and disappears as soon as a reply arrives after it.
20
+ * icon / read avatars — Read is out of scope) and re-apply the "last own
21
+ * message" gate ourselves.
23
22
  *
24
- * 2. We derive "latest message" from **live channel state**, not the message
25
- * context's `lastOwnMessage`/`lastReceivedId`. Stream's `Message` is
26
- * memoised by `areMessagePropsEqual`, which does not compare those props;
27
- * a previous own row whose message/grouping props are unchanged (e.g.
28
- * after an intervening incoming message) never re-renders, so the old
29
- * bubble would keep showing "Delivered" beside the new one.
30
- * `useChannelStateContext` gets a fresh `messages` array on every new
31
- * message and, being a context, re-renders this leaf straight through the
32
- * stale `Message`/`MessageStatus` memos — so the label reliably clears
33
- * once a reply lands.
23
+ * 2. We derive "last own message" from **live channel state**, not the
24
+ * message context's `lastOwnMessage`. Stream's `Message` is memoised by
25
+ * `areMessagePropsEqual`, which does not compare `lastOwnMessage`; a
26
+ * previous own row whose message/grouping props are unchanged (e.g. after
27
+ * an intervening incoming message) never re-renders, so its context
28
+ * `lastOwnMessage` goes stale and the old bubble would keep showing
29
+ * "Delivered" beside the new one. `useChannelStateContext` gets a fresh
30
+ * `messages` array on every new message and, being a context, re-renders
31
+ * this leaf straight through the stale `Message`/`MessageStatus` memos —
32
+ * so the label reliably hops to the newest own message.
34
33
  *
35
34
  * Rendered inside stream's `.str-chat__message-status` span, which supplies
36
35
  * the flex layout; icon and copy are styled via that span in `styles.css`.
@@ -38,13 +37,18 @@ import {
38
37
  const DeliveredStatus = () => {
39
38
  const { message } = useMessageContext('SentMessageDeliveryStatus')
40
39
  const { messages } = useChannelStateContext('SentMessageDeliveryStatus')
40
+ const { client } = useChatContext('SentMessageDeliveryStatus')
41
41
 
42
- const lastMessageId = useMemo(
43
- () => messages?.[messages.length - 1]?.id,
44
- [messages]
45
- )
42
+ const ownUserId = client.user?.id
43
+ const lastOwnMessageId = useMemo(() => {
44
+ if (!ownUserId || !messages) return undefined
45
+ for (let i = messages.length - 1; i >= 0; i -= 1) {
46
+ if (messages[i].user?.id === ownUserId) return messages[i].id
47
+ }
48
+ return undefined
49
+ }, [messages, ownUserId])
46
50
 
47
- if (message.id !== lastMessageId) {
51
+ if (message.id !== lastOwnMessageId) {
48
52
  return null
49
53
  }
50
54
 
@@ -69,8 +73,8 @@ const NoStatus = () => null
69
73
  * terminal (sending/sent/delivered/read) state once the message is stored.
70
74
  * Scope is "Delivered" only: the sending spinner is suppressed and all
71
75
  * terminal states render {@link DeliveredStatus}, which supplies the label
72
- * and the latest-message-in-thread gate (see there for why the gate is
73
- * computed from live channel state rather than the context's props).
76
+ * and the last-own-message gate (see there for why the gate is computed from
77
+ * live channel state rather than the context's `lastOwnMessage`).
74
78
  */
75
79
  export const SentMessageDeliveryStatus = () => (
76
80
  <MessageStatus
@@ -1,7 +1,7 @@
1
1
  import type { Meta, StoryFn } from '@storybook/react'
2
2
  import classNames from 'classnames'
3
3
  import React from 'react'
4
- import type { Attachment } from 'stream-chat'
4
+ import type { Attachment, LocalMessage } from 'stream-chat'
5
5
 
6
6
  import {
7
7
  currentUserArgType,
@@ -23,22 +23,29 @@ import StreamAttachmentMessage from './StreamAttachmentMessage'
23
23
  * Stream-attachment → `LinkAttachment` prop mapping, caption
24
24
  * de-duplication (including emoji preservation), and media clustering.
25
25
  *
26
- * The component renders bare rows; this wrapper just anchors the run to
27
- * the correct chat edge so sent/received read the way they do in-app.
26
+ * The component owns the Stream bubble-wrapper markup. Stories only supply
27
+ * the `.str-chat` / `.str-chat__message--me|other` / `.str-chat__li--single`
28
+ * ancestry that Stream bubble chrome and the MES-1031 tails are scoped under.
28
29
  * ────────────────────────────────────────────────────────────────── */
29
30
 
30
31
  const Bubbles: React.FC<{ isMyMessage: boolean; children: React.ReactNode }> = ({
31
32
  isMyMessage,
32
33
  children,
33
34
  }) => (
34
- <div className="mx-auto max-w-2xl p-12">
35
- <div
36
- className={classNames(
37
- 'flex flex-col gap-[2px]',
38
- isMyMessage ? 'items-end' : 'items-start'
39
- )}
40
- >
41
- {children}
35
+ <div className="str-chat mx-auto max-w-2xl p-12">
36
+ {/* Tail CSS is scoped to `.str-chat__li--single|--bottom`; bubble surface
37
+ styles need `.str-chat__message--me|other`. */}
38
+ <div className="str-chat__li str-chat__li--single">
39
+ <div
40
+ className={classNames(
41
+ 'str-chat__message str-chat__message-simple',
42
+ isMyMessage
43
+ ? 'str-chat__message--me str-chat__message-simple--me'
44
+ : 'str-chat__message--other'
45
+ )}
46
+ >
47
+ <div className="str-chat__message-inner">{children}</div>
48
+ </div>
42
49
  </div>
43
50
  </div>
44
51
  )
@@ -51,7 +58,7 @@ const Render = (attachments: Attachment[], text?: string): StoryFn<StoryArgs> =>
51
58
  <StreamAttachmentMessage
52
59
  groupPosition="single"
53
60
  isMyMessage={isMyMessage}
54
- message={{ attachments, text }}
61
+ message={{ attachments, text } as LocalMessage}
55
62
  />
56
63
  </Bubbles>
57
64
  )
@@ -1,4 +1,5 @@
1
1
  import React from 'react'
2
+ import type { Attachment, LocalMessage } from 'stream-chat'
2
3
  import { describe, expect, it } from 'vitest'
3
4
 
4
5
  import { renderWithProviders, screen } from '../../test/utils'
@@ -8,6 +9,11 @@ import StreamAttachmentMessage, {
8
9
  linkCardPropsFromStreamAttachment,
9
10
  } from './StreamAttachmentMessage'
10
11
 
12
+ const message = (
13
+ attachments: Attachment[],
14
+ text?: string
15
+ ): LocalMessage => ({ attachments, text }) as LocalMessage
16
+
11
17
  describe('linkCardPropsFromStreamAttachment', () => {
12
18
  it('uses the featured layout when a thumbnail is present', () => {
13
19
  expect(
@@ -66,8 +72,8 @@ describe('StreamAttachmentMessage', () => {
66
72
  <StreamAttachmentMessage
67
73
  groupPosition="single"
68
74
  isMyMessage={true}
69
- message={{
70
- attachments: [
75
+ message={message(
76
+ [
71
77
  {
72
78
  type: 'link',
73
79
  og_scrape_url: 'https://linktr.ee/someone',
@@ -76,8 +82,8 @@ describe('StreamAttachmentMessage', () => {
76
82
  image_url: 'https://cdn.example.com/thumb.jpg',
77
83
  },
78
84
  ],
79
- text: 'https://linktr.ee/someone !',
80
- }}
85
+ 'https://linktr.ee/someone !'
86
+ )}
81
87
  />
82
88
  )
83
89
 
@@ -88,24 +94,30 @@ describe('StreamAttachmentMessage', () => {
88
94
  })
89
95
 
90
96
  it('keeps an emoji-only caption instead of dropping it', () => {
91
- renderWithProviders(
97
+ const { container } = renderWithProviders(
92
98
  <StreamAttachmentMessage
93
99
  groupPosition="single"
94
100
  isMyMessage={false}
95
- message={{
96
- attachments: [
101
+ message={message(
102
+ [
97
103
  {
98
104
  type: 'link',
99
105
  og_scrape_url: 'https://linktr.ee/someone',
100
106
  title: 'My Linktree',
101
107
  },
102
108
  ],
103
- text: 'https://linktr.ee/someone 😎',
104
- }}
109
+ 'https://linktr.ee/someone 😎'
110
+ )}
105
111
  />
106
112
  )
107
113
 
108
- expect(screen.getByText('https://linktr.ee/someone 😎')).toBeInTheDocument()
114
+ // MessageText linkifies the URL (display text drops the scheme), so the
115
+ // caption is split across a link node and trailing emoji text.
116
+ const captionBubble = container.querySelector(
117
+ '.str-chat__message-bubble:not([data-attachment-bubble])'
118
+ )
119
+ expect(captionBubble).toHaveTextContent('linktr.ee/someone')
120
+ expect(captionBubble).toHaveTextContent('😎')
109
121
  })
110
122
 
111
123
  it('renders mixed link and media attachments in order', () => {
@@ -113,8 +125,8 @@ describe('StreamAttachmentMessage', () => {
113
125
  <StreamAttachmentMessage
114
126
  groupPosition="single"
115
127
  isMyMessage={true}
116
- message={{
117
- attachments: [
128
+ message={message(
129
+ [
118
130
  {
119
131
  type: 'link',
120
132
  og_scrape_url: 'https://linktr.ee/someone',
@@ -126,8 +138,8 @@ describe('StreamAttachmentMessage', () => {
126
138
  title: 'Vacation',
127
139
  },
128
140
  ],
129
- text: 'caption',
130
- }}
141
+ 'caption'
142
+ )}
131
143
  />
132
144
  )
133
145
 
@@ -140,27 +152,28 @@ describe('StreamAttachmentMessage', () => {
140
152
  expect(container).toHaveTextContent('caption')
141
153
  })
142
154
 
143
- // The message text must never be dropped when a message carries an
144
- // attachment — only genuine URL-only captions are suppressed.
145
155
  it('renders the accompanying message text next to a link attachment', () => {
146
- renderWithProviders(
156
+ const { container } = renderWithProviders(
147
157
  <StreamAttachmentMessage
148
158
  groupPosition="single"
149
159
  isMyMessage={true}
150
- message={{
151
- attachments: [
160
+ message={message(
161
+ [
152
162
  {
153
163
  type: 'link',
154
164
  og_scrape_url: 'https://linktr.ee/someone',
155
165
  title: 'My Linktree',
156
166
  },
157
167
  ],
158
- text: 'you have to see this',
159
- }}
168
+ 'you have to see this'
169
+ )}
160
170
  />
161
171
  )
162
172
 
163
- expect(screen.getByText('you have to see this')).toBeInTheDocument()
173
+ const bubbles = container.querySelectorAll('.str-chat__message-bubble')
174
+ expect(bubbles).toHaveLength(2)
175
+ expect(bubbles[0]).toHaveAttribute('data-attachment-bubble', 'true')
176
+ expect(bubbles[1]).toHaveTextContent('you have to see this')
164
177
  })
165
178
 
166
179
  it('renders the accompanying message text next to a media attachment', () => {
@@ -168,19 +181,24 @@ describe('StreamAttachmentMessage', () => {
168
181
  <StreamAttachmentMessage
169
182
  groupPosition="single"
170
183
  isMyMessage={false}
171
- message={{
172
- attachments: [
184
+ message={message(
185
+ [
173
186
  {
174
187
  type: 'image',
175
188
  image_url: 'https://cdn.example.com/photo.jpg',
176
189
  title: 'Vacation',
177
190
  },
178
191
  ],
179
- text: 'from the trip',
180
- }}
192
+ 'from the trip'
193
+ )}
181
194
  />
182
195
  )
183
196
 
184
197
  expect(container).toHaveTextContent('from the trip')
198
+ expect(
199
+ container.querySelector(
200
+ '.str-chat__message-bubble:not([data-attachment-bubble])'
201
+ )
202
+ ).toHaveTextContent('from the trip')
185
203
  })
186
204
  })
@@ -1,5 +1,10 @@
1
1
  import React from 'react'
2
- import type { Attachment } from 'stream-chat'
2
+ import type { Attachment, LocalMessage } from 'stream-chat'
3
+ import {
4
+ MessageErrorIcon,
5
+ MessageText,
6
+ type MessageContextValue,
7
+ } from 'stream-chat-react'
3
8
 
4
9
  import LinkAttachment from '../LinkAttachment'
5
10
  import { isPlayableMedia } from '../LinkAttachment/components/_shared/CardThumbnail'
@@ -12,16 +17,6 @@ export type AttachmentSegment =
12
17
  | { type: 'link'; attachments: Attachment[] }
13
18
  | { type: 'media'; kind: StreamMediaKind; attachments: Attachment[] }
14
19
 
15
- type StreamThreadMessage = {
16
- attachments?: Attachment[]
17
- text?: string
18
- }
19
-
20
- const SENT_CAPTION_CLASS =
21
- 'mt-[2px] max-w-[280px] whitespace-pre-wrap break-words rounded-[18px] bg-[#121110] px-4 py-2 text-white'
22
- const RECEIVED_CAPTION_CLASS =
23
- 'mt-[2px] max-w-[280px] whitespace-pre-wrap break-words rounded-[18px] bg-[#e9eaed] px-4 py-2 text-[#080707]'
24
-
25
20
  function trimToUndefined(value?: string | null): string | undefined {
26
21
  const trimmed = value?.trim()
27
22
  return trimmed ? trimmed : undefined
@@ -152,19 +147,9 @@ export function buildOrderedAttachmentSegments(
152
147
  return segments
153
148
  }
154
149
 
155
- export function countAttachmentSegmentBubbles(
150
+ export function countMediaSegmentBubbles(
156
151
  segments: AttachmentSegment[]
157
152
  ): number {
158
- let count = 0
159
-
160
- for (const segment of segments) {
161
- count += segment.type === 'link' ? segment.attachments.length : 1
162
- }
163
-
164
- return count
165
- }
166
-
167
- export function countMediaSegmentBubbles(segments: AttachmentSegment[]): number {
168
153
  return segments.filter((segment) => segment.type === 'media').length
169
154
  }
170
155
 
@@ -304,18 +289,15 @@ function MediaClusterBubble({
304
289
  groupPosition,
305
290
  isMyMessage,
306
291
  segment,
307
- text,
308
292
  }: {
309
293
  groupPosition: BubbleGroupPosition
310
294
  isMyMessage: boolean
311
295
  segment: Extract<AttachmentSegment, { type: 'media' }>
312
- text?: string
313
296
  }) {
314
297
  const TypeNamespace = messageAttachmentNamespaceForKind(segment.kind)
315
298
  const props = {
316
299
  ...buildMediaClusterRenderProps(segment.kind, segment.attachments),
317
300
  groupPosition,
318
- ...(text ? { text } : {}),
319
301
  }
320
302
 
321
303
  if (isMyMessage) {
@@ -328,18 +310,19 @@ function MediaClusterBubble({
328
310
  export interface StreamAttachmentMessageProps {
329
311
  groupPosition: BubbleGroupPosition
330
312
  isMyMessage: boolean
331
- message: StreamThreadMessage
313
+ message: LocalMessage
314
+ renderText?: MessageContextValue['renderText']
332
315
  }
333
316
 
334
317
  const StreamAttachmentMessage: React.FC<StreamAttachmentMessageProps> = ({
335
318
  groupPosition,
336
319
  isMyMessage,
337
320
  message,
321
+ renderText,
338
322
  }) => {
339
323
  const segments = buildOrderedAttachmentSegments(message.attachments)
340
324
  if (segments.length === 0) return null
341
325
 
342
- const totalBubbles = countAttachmentSegmentBubbles(segments)
343
326
  const totalMediaBubbles = countMediaSegmentBubbles(segments)
344
327
  const rawCaption = message.text?.trim()
345
328
  const linkSegment = segments.find((segment) => segment.type === 'link')
@@ -353,25 +336,14 @@ const StreamAttachmentMessage: React.FC<StreamAttachmentMessageProps> = ({
353
336
  ? undefined
354
337
  : rawCaption
355
338
 
356
- let bubbleIndex = 0
357
339
  let mediaBubbleIndex = 0
358
340
  const rows: React.ReactNode[] = []
359
341
 
360
342
  const pushLinkRow = (attachment: Attachment, key: string) => {
361
- const isLastBubble = bubbleIndex === totalBubbles - 1
362
- bubbleIndex += 1
363
-
364
343
  const Card = isMyMessage ? LinkAttachment.Sent : LinkAttachment.Received
365
344
 
366
345
  rows.push(
367
- <React.Fragment key={key}>
368
- <Card {...linkCardPropsFromStreamAttachment(attachment)} />
369
- {isLastBubble && caption ? (
370
- <div className={isMyMessage ? SENT_CAPTION_CLASS : RECEIVED_CAPTION_CLASS}>
371
- {caption}
372
- </div>
373
- ) : null}
374
- </React.Fragment>
346
+ <Card key={key} {...linkCardPropsFromStreamAttachment(attachment)} />
375
347
  )
376
348
  }
377
349
 
@@ -379,14 +351,12 @@ const StreamAttachmentMessage: React.FC<StreamAttachmentMessageProps> = ({
379
351
  segment: Extract<AttachmentSegment, { type: 'media' }>,
380
352
  key: string
381
353
  ) => {
382
- const isLastBubble = bubbleIndex === totalBubbles - 1
383
354
  const position = bubblePositionInRun(
384
355
  mediaBubbleIndex,
385
356
  totalMediaBubbles,
386
357
  groupPosition
387
358
  )
388
359
 
389
- bubbleIndex += 1
390
360
  mediaBubbleIndex += 1
391
361
 
392
362
  rows.push(
@@ -395,7 +365,6 @@ const StreamAttachmentMessage: React.FC<StreamAttachmentMessageProps> = ({
395
365
  groupPosition={position}
396
366
  isMyMessage={isMyMessage}
397
367
  segment={segment}
398
- text={isLastBubble ? caption : undefined}
399
368
  />
400
369
  )
401
370
  }
@@ -411,7 +380,32 @@ const StreamAttachmentMessage: React.FC<StreamAttachmentMessageProps> = ({
411
380
  pushMediaCluster(segment, `media-${segmentIndex}`)
412
381
  })
413
382
 
414
- return <>{rows}</>
383
+ return (
384
+ <div className="str-chat__message-bubble-wrapper">
385
+ <div
386
+ className="str-chat__message-bubble"
387
+ data-attachment-bubble="true"
388
+ style={{
389
+ background: 'transparent',
390
+ borderRadius: 0,
391
+ overflow: 'visible',
392
+ padding: 0,
393
+ }}
394
+ >
395
+ {rows}
396
+ {!caption && <MessageErrorIcon />}
397
+ </div>
398
+ {caption ? (
399
+ <div className="str-chat__message-bubble">
400
+ <MessageText
401
+ message={message}
402
+ renderText={renderText}
403
+ />
404
+ <MessageErrorIcon />
405
+ </div>
406
+ ) : null}
407
+ </div>
408
+ )
415
409
  }
416
410
 
417
411
  export default StreamAttachmentMessage