@linktr.ee/messaging-react 3.9.1 → 3.9.2-rc-1783581484

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-Dau41drB.js → Card-0f6UozSj.js} +2 -2
  2. package/dist/{Card-Dau41drB.js.map → Card-0f6UozSj.js.map} +1 -1
  3. package/dist/{Card-D3QNullZ.js → Card-B4Rgburk.js} +2 -2
  4. package/dist/{Card-D3QNullZ.js.map → Card-B4Rgburk.js.map} +1 -1
  5. package/dist/{Card-BMau-mI5.cjs → Card-BTlJZXuO.cjs} +2 -2
  6. package/dist/{Card-BMau-mI5.cjs.map → Card-BTlJZXuO.cjs.map} +1 -1
  7. package/dist/{Card-BLLADEC_.js → Card-DBsTWA91.js} +3 -3
  8. package/dist/{Card-BLLADEC_.js.map → Card-DBsTWA91.js.map} +1 -1
  9. package/dist/{Card-MJ0x2roP.cjs → Card-DQSjcIX_.cjs} +2 -2
  10. package/dist/{Card-MJ0x2roP.cjs.map → Card-DQSjcIX_.cjs.map} +1 -1
  11. package/dist/{Card-C3rbCPM7.cjs → Card-QNGgvs9y.cjs} +2 -2
  12. package/dist/{Card-C3rbCPM7.cjs.map → Card-QNGgvs9y.cjs.map} +1 -1
  13. package/dist/{LockedThumbnail-Bq54AT8J.js → LockedThumbnail-CYzXhG5H.js} +2 -2
  14. package/dist/{LockedThumbnail-Bq54AT8J.js.map → LockedThumbnail-CYzXhG5H.js.map} +1 -1
  15. package/dist/{LockedThumbnail-a03GYGJz.cjs → LockedThumbnail-NlYrnJWD.cjs} +2 -2
  16. package/dist/{LockedThumbnail-a03GYGJz.cjs.map → LockedThumbnail-NlYrnJWD.cjs.map} +1 -1
  17. package/dist/index-D0lSJc9F.cjs +2 -0
  18. package/dist/index-D0lSJc9F.cjs.map +1 -0
  19. package/dist/{index-CBm9au-T.js → index-DYp35Tv3.js} +1177 -1195
  20. package/dist/index-DYp35Tv3.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/ChannelHeaderRedesign.tsx +1 -8
  25. package/src/components/ChannelView.tsx +9 -12
  26. package/src/components/CustomMessage/SentMessageDeliveryStatus.test.tsx +51 -11
  27. package/src/components/CustomMessage/SentMessageDeliveryStatus.tsx +39 -25
  28. package/src/components/CustomMessage/StreamAttachmentMessage.stories.tsx +12 -19
  29. package/src/components/CustomMessage/StreamAttachmentMessage.test.tsx +26 -44
  30. package/src/components/CustomMessage/StreamAttachmentMessage.tsx +43 -37
  31. package/src/components/CustomMessage/index.tsx +21 -13
  32. package/dist/index-CBm9au-T.js.map +0 -1
  33. package/dist/index-CWopIWxB.cjs +0 -2
  34. package/dist/index-CWopIWxB.cjs.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-CWopIWxB.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-D0lSJc9F.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-CBm9au-T.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-DYp35Tv3.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",
3
+ "version": "3.9.2-rc-1783581484",
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.2.0",
53
+ "@linktr.ee/messaging-core": "2.3.0-rc-1783581484",
54
54
  "@phosphor-icons/react": "^2.1.10"
55
55
  },
56
56
  "devDependencies": {
@@ -42,7 +42,6 @@ const hasCustomerPaidTag = (participant?: ChannelMemberResponse) => {
42
42
  }
43
43
 
44
44
  interface ChannelHeaderRedesignProps {
45
- className?: string
46
45
  onBack?: () => void
47
46
  showBackButton: boolean
48
47
  showStarButton?: boolean
@@ -62,7 +61,6 @@ interface ChannelHeaderRedesignProps {
62
61
  }
63
62
 
64
63
  export const ChannelHeaderRedesign: React.FC<ChannelHeaderRedesignProps> = ({
65
- className,
66
64
  onBack,
67
65
  showBackButton,
68
66
  showStarButton = false,
@@ -114,12 +112,7 @@ export const ChannelHeaderRedesign: React.FC<ChannelHeaderRedesignProps> = ({
114
112
  }
115
113
 
116
114
  return (
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
- >
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%)]">
123
116
  <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">
124
117
  <div className="flex items-start">
125
118
  {showBackButton && (
@@ -475,7 +475,6 @@ 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%)]"
479
478
  onBack={onBack}
480
479
  showBackButton={showBackButton}
481
480
  showChannelInfo={showChannelInfo}
@@ -553,22 +552,20 @@ const ChannelViewInner: React.FC<{
553
552
  </>
554
553
  )}
555
554
 
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
+
556
564
  {/* Message List */}
557
565
  <div
558
566
  key="lt-channel-message-list"
559
567
  className="flex-1 overflow-hidden relative"
560
568
  >
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
-
572
569
  <MessageList
573
570
  head={
574
571
  onParticipantNameClick && hasVisibleMessages ? (
@@ -1,7 +1,6 @@
1
1
  import React from 'react'
2
2
  import {
3
3
  useChannelStateContext,
4
- useChatContext,
5
4
  useMessageContext,
6
5
  } from 'stream-chat-react'
7
6
  import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -13,14 +12,13 @@ import { SentMessageDeliveryStatus } from './SentMessageDeliveryStatus'
13
12
  // stream-chat-react's `MessageStatus` owns the outer gating (own message, not
14
13
  // error, not in a thread) and only exposes a terminal state once a message is
15
14
  // stored — Stream's tested code. What this component owns is the "Delivered"
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.
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.
20
19
  vi.mock('stream-chat-react', () => ({
21
20
  useMessageContext: vi.fn(),
22
21
  useChannelStateContext: vi.fn(),
23
- useChatContext: vi.fn(),
24
22
  MessageStatus: ({
25
23
  MessageSentStatus,
26
24
  MessageDeliveredStatus,
@@ -46,10 +44,10 @@ vi.mock('stream-chat-react', () => ({
46
44
 
47
45
  const mockedUseMessageContext = vi.mocked(useMessageContext)
48
46
  const mockedUseChannelStateContext = vi.mocked(useChannelStateContext)
49
- const mockedUseChatContext = vi.mocked(useChatContext)
50
47
 
51
48
  // A run where the viewer ('me') sent the first and last messages, with an
52
- // incoming message in between — the case that broke the memo-based approach.
49
+ // incoming message in between — the label must sit on the last message ('own-2')
50
+ // and clear from the earlier one.
53
51
  const CHANNEL_MESSAGES = [
54
52
  { id: 'own-1', user: { id: 'me' } },
55
53
  { id: 'their-1', user: { id: 'them' } },
@@ -60,13 +58,12 @@ const TERMINAL_SLOTS = ['sent-slot', 'delivered-slot', 'read-slot'] as const
60
58
 
61
59
  describe('SentMessageDeliveryStatus', () => {
62
60
  beforeEach(() => {
63
- mockedUseChatContext.mockReturnValue({ client: { user: { id: 'me' } } } as never)
64
61
  mockedUseChannelStateContext.mockReturnValue({
65
62
  messages: CHANNEL_MESSAGES,
66
63
  } as never)
67
64
  })
68
65
 
69
- it('renders "Delivered" in every terminal slot for the last own message', () => {
66
+ it('renders "Delivered" in every terminal slot for the last message in the thread', () => {
70
67
  // delivered/read fire ahead of sent in receipt channels, so all three
71
68
  // must show the label — never Stream's default delivered icon / read
72
69
  // avatars.
@@ -83,7 +80,7 @@ describe('SentMessageDeliveryStatus', () => {
83
80
  }
84
81
  })
85
82
 
86
- it('renders nothing for an earlier own message even though one is the last own message', () => {
83
+ it('renders nothing for an earlier own message once a later message exists', () => {
87
84
  mockedUseMessageContext.mockReturnValue({
88
85
  message: { id: 'own-1' },
89
86
  } as never)
@@ -95,6 +92,49 @@ describe('SentMessageDeliveryStatus', () => {
95
92
  }
96
93
  })
97
94
 
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
+ it('ignores a trailing system row when the last real message is still the viewer’s own', () => {
117
+ mockedUseChannelStateContext.mockReturnValue({
118
+ messages: [
119
+ { id: 'own-1', user: { id: 'me' } },
120
+ { id: 'their-1', user: { id: 'them' } },
121
+ { id: 'own-2', user: { id: 'me' } },
122
+ { id: 'system-1', type: 'system' },
123
+ ],
124
+ } as never)
125
+ mockedUseMessageContext.mockReturnValue({
126
+ message: { id: 'own-2' },
127
+ } as never)
128
+
129
+ renderWithProviders(<SentMessageDeliveryStatus />)
130
+
131
+ for (const slot of TERMINAL_SLOTS) {
132
+ expect(
133
+ within(screen.getByTestId(slot)).getByText('Delivered')
134
+ ).toBeInTheDocument()
135
+ }
136
+ })
137
+
98
138
  it('always suppresses the sending spinner', () => {
99
139
  mockedUseMessageContext.mockReturnValue({
100
140
  message: { id: 'own-2' },
@@ -1,9 +1,7 @@
1
1
  import { ChecksIcon } from '@phosphor-icons/react'
2
- import { useMemo } from 'react'
3
2
  import {
4
3
  MessageStatus,
5
4
  useChannelStateContext,
6
- useChatContext,
7
5
  useMessageContext,
8
6
  } from 'stream-chat-react'
9
7
 
@@ -15,21 +13,25 @@ import {
15
13
  *
16
14
  * 1. `MessageStatus` picks its `delivered`/`read` branches ahead of `sent`
17
15
  * whenever Stream has `deliveredTo`/`readBy`, and — unlike `sent` — those
18
- * branches aren't gated to the last own message. So we override all three
16
+ * branches aren't gated to the last message. So we override all three
19
17
  * terminal slots with this same label (never Stream's default delivered
20
- * icon / read avatars — Read is out of scope) and re-apply the "last own
21
- * message" gate ourselves.
18
+ * icon / read avatars — Read is out of scope) and re-apply the "latest
19
+ * message in the thread" gate ourselves: the label only shows beneath the
20
+ * viewer's sent message while it is still the last message in the channel,
21
+ * and disappears as soon as a reply arrives after it.
22
22
  *
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.
23
+ * 2. We derive "latest message" from **live channel state**, not the message
24
+ * context's `lastOwnMessage`/`lastReceivedId`. Stream's `Message` is
25
+ * memoised by `areMessagePropsEqual`, which does not compare those props;
26
+ * a previous own row whose message/grouping props are unchanged (e.g.
27
+ * after an intervening incoming message) never re-renders, so the old
28
+ * bubble would keep showing "Delivered" beside the new one.
29
+ * `useChannelStateContext` gets a fresh `messages` array on every new
30
+ * message and, being a context, re-renders this leaf straight through the
31
+ * stale `Message`/`MessageStatus` memos — so the label reliably clears
32
+ * once a reply lands. We still skip non-participant rows (`system`,
33
+ * `ephemeral`, `deleted`) when finding the last real message so a trailing
34
+ * system event does not clear the label.
33
35
  *
34
36
  * Rendered inside stream's `.str-chat__message-status` span, which supplies
35
37
  * the flex layout; icon and copy are styled via that span in `styles.css`.
@@ -37,18 +39,30 @@ import {
37
39
  const DeliveredStatus = () => {
38
40
  const { message } = useMessageContext('SentMessageDeliveryStatus')
39
41
  const { messages } = useChannelStateContext('SentMessageDeliveryStatus')
40
- const { client } = useChatContext('SentMessageDeliveryStatus')
41
42
 
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
43
+ const lastMessageId = (() => {
44
+ if (!messages) {
45
+ return undefined
47
46
  }
47
+
48
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
49
+ const lastMessage = messages[index]
50
+
51
+ if (
52
+ lastMessage.type === 'system' ||
53
+ lastMessage.type === 'ephemeral' ||
54
+ lastMessage.type === 'deleted'
55
+ ) {
56
+ continue
57
+ }
58
+
59
+ return lastMessage.id
60
+ }
61
+
48
62
  return undefined
49
- }, [messages, ownUserId])
63
+ })()
50
64
 
51
- if (message.id !== lastOwnMessageId) {
65
+ if (message.id !== lastMessageId) {
52
66
  return null
53
67
  }
54
68
 
@@ -73,8 +87,8 @@ const NoStatus = () => null
73
87
  * terminal (sending/sent/delivered/read) state once the message is stored.
74
88
  * Scope is "Delivered" only: the sending spinner is suppressed and all
75
89
  * terminal states render {@link DeliveredStatus}, which supplies the label
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`).
90
+ * and the latest-message-in-thread gate (see there for why the gate is
91
+ * computed from live channel state rather than the context's props).
78
92
  */
79
93
  export const SentMessageDeliveryStatus = () => (
80
94
  <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, LocalMessage } from 'stream-chat'
4
+ import type { Attachment } from 'stream-chat'
5
5
 
6
6
  import {
7
7
  currentUserArgType,
@@ -23,29 +23,22 @@ 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 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.
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.
29
28
  * ────────────────────────────────────────────────────────────────── */
30
29
 
31
30
  const Bubbles: React.FC<{ isMyMessage: boolean; children: React.ReactNode }> = ({
32
31
  isMyMessage,
33
32
  children,
34
33
  }) => (
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>
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}
49
42
  </div>
50
43
  </div>
51
44
  )
@@ -58,7 +51,7 @@ const Render = (attachments: Attachment[], text?: string): StoryFn<StoryArgs> =>
58
51
  <StreamAttachmentMessage
59
52
  groupPosition="single"
60
53
  isMyMessage={isMyMessage}
61
- message={{ attachments, text } as LocalMessage}
54
+ message={{ attachments, text }}
62
55
  />
63
56
  </Bubbles>
64
57
  )
@@ -1,5 +1,4 @@
1
1
  import React from 'react'
2
- import type { Attachment, LocalMessage } from 'stream-chat'
3
2
  import { describe, expect, it } from 'vitest'
4
3
 
5
4
  import { renderWithProviders, screen } from '../../test/utils'
@@ -9,11 +8,6 @@ import StreamAttachmentMessage, {
9
8
  linkCardPropsFromStreamAttachment,
10
9
  } from './StreamAttachmentMessage'
11
10
 
12
- const message = (
13
- attachments: Attachment[],
14
- text?: string
15
- ): LocalMessage => ({ attachments, text }) as LocalMessage
16
-
17
11
  describe('linkCardPropsFromStreamAttachment', () => {
18
12
  it('uses the featured layout when a thumbnail is present', () => {
19
13
  expect(
@@ -72,8 +66,8 @@ describe('StreamAttachmentMessage', () => {
72
66
  <StreamAttachmentMessage
73
67
  groupPosition="single"
74
68
  isMyMessage={true}
75
- message={message(
76
- [
69
+ message={{
70
+ attachments: [
77
71
  {
78
72
  type: 'link',
79
73
  og_scrape_url: 'https://linktr.ee/someone',
@@ -82,8 +76,8 @@ describe('StreamAttachmentMessage', () => {
82
76
  image_url: 'https://cdn.example.com/thumb.jpg',
83
77
  },
84
78
  ],
85
- 'https://linktr.ee/someone !'
86
- )}
79
+ text: 'https://linktr.ee/someone !',
80
+ }}
87
81
  />
88
82
  )
89
83
 
@@ -94,30 +88,24 @@ describe('StreamAttachmentMessage', () => {
94
88
  })
95
89
 
96
90
  it('keeps an emoji-only caption instead of dropping it', () => {
97
- const { container } = renderWithProviders(
91
+ renderWithProviders(
98
92
  <StreamAttachmentMessage
99
93
  groupPosition="single"
100
94
  isMyMessage={false}
101
- message={message(
102
- [
95
+ message={{
96
+ attachments: [
103
97
  {
104
98
  type: 'link',
105
99
  og_scrape_url: 'https://linktr.ee/someone',
106
100
  title: 'My Linktree',
107
101
  },
108
102
  ],
109
- 'https://linktr.ee/someone 😎'
110
- )}
103
+ text: 'https://linktr.ee/someone 😎',
104
+ }}
111
105
  />
112
106
  )
113
107
 
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('😎')
108
+ expect(screen.getByText('https://linktr.ee/someone 😎')).toBeInTheDocument()
121
109
  })
122
110
 
123
111
  it('renders mixed link and media attachments in order', () => {
@@ -125,8 +113,8 @@ describe('StreamAttachmentMessage', () => {
125
113
  <StreamAttachmentMessage
126
114
  groupPosition="single"
127
115
  isMyMessage={true}
128
- message={message(
129
- [
116
+ message={{
117
+ attachments: [
130
118
  {
131
119
  type: 'link',
132
120
  og_scrape_url: 'https://linktr.ee/someone',
@@ -138,8 +126,8 @@ describe('StreamAttachmentMessage', () => {
138
126
  title: 'Vacation',
139
127
  },
140
128
  ],
141
- 'caption'
142
- )}
129
+ text: 'caption',
130
+ }}
143
131
  />
144
132
  )
145
133
 
@@ -152,28 +140,27 @@ describe('StreamAttachmentMessage', () => {
152
140
  expect(container).toHaveTextContent('caption')
153
141
  })
154
142
 
143
+ // The message text must never be dropped when a message carries an
144
+ // attachment — only genuine URL-only captions are suppressed.
155
145
  it('renders the accompanying message text next to a link attachment', () => {
156
- const { container } = renderWithProviders(
146
+ renderWithProviders(
157
147
  <StreamAttachmentMessage
158
148
  groupPosition="single"
159
149
  isMyMessage={true}
160
- message={message(
161
- [
150
+ message={{
151
+ attachments: [
162
152
  {
163
153
  type: 'link',
164
154
  og_scrape_url: 'https://linktr.ee/someone',
165
155
  title: 'My Linktree',
166
156
  },
167
157
  ],
168
- 'you have to see this'
169
- )}
158
+ text: 'you have to see this',
159
+ }}
170
160
  />
171
161
  )
172
162
 
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')
163
+ expect(screen.getByText('you have to see this')).toBeInTheDocument()
177
164
  })
178
165
 
179
166
  it('renders the accompanying message text next to a media attachment', () => {
@@ -181,24 +168,19 @@ describe('StreamAttachmentMessage', () => {
181
168
  <StreamAttachmentMessage
182
169
  groupPosition="single"
183
170
  isMyMessage={false}
184
- message={message(
185
- [
171
+ message={{
172
+ attachments: [
186
173
  {
187
174
  type: 'image',
188
175
  image_url: 'https://cdn.example.com/photo.jpg',
189
176
  title: 'Vacation',
190
177
  },
191
178
  ],
192
- 'from the trip'
193
- )}
179
+ text: 'from the trip',
180
+ }}
194
181
  />
195
182
  )
196
183
 
197
184
  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')
203
185
  })
204
186
  })