@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.
- package/dist/{Card-DmfP-4oF.cjs → Card-BAWe1nw6.cjs} +2 -2
- package/dist/{Card-DmfP-4oF.cjs.map → Card-BAWe1nw6.cjs.map} +1 -1
- package/dist/{Card-PBfzY620.cjs → Card-BacmKRPX.cjs} +2 -2
- package/dist/{Card-PBfzY620.cjs.map → Card-BacmKRPX.cjs.map} +1 -1
- package/dist/{Card-CyBRO0Ex.cjs → Card-Bm6-f8ki.cjs} +2 -2
- package/dist/{Card-CyBRO0Ex.cjs.map → Card-Bm6-f8ki.cjs.map} +1 -1
- package/dist/{Card-xR3OTXRB.js → Card-CT51GZA-.js} +2 -2
- package/dist/{Card-xR3OTXRB.js.map → Card-CT51GZA-.js.map} +1 -1
- package/dist/{Card-DYNxwKX6.js → Card-CbRJaszK.js} +2 -2
- package/dist/{Card-DYNxwKX6.js.map → Card-CbRJaszK.js.map} +1 -1
- package/dist/{Card-dZkL-om-.js → Card-t7hyYqE2.js} +3 -3
- package/dist/{Card-dZkL-om-.js.map → Card-t7hyYqE2.js.map} +1 -1
- package/dist/{LockedThumbnail-eE8ljauK.cjs → LockedThumbnail-D2AyOLj7.cjs} +2 -2
- package/dist/{LockedThumbnail-eE8ljauK.cjs.map → LockedThumbnail-D2AyOLj7.cjs.map} +1 -1
- package/dist/{LockedThumbnail-BblFdDJp.js → LockedThumbnail-DA2ypEZM.js} +2 -2
- package/dist/{LockedThumbnail-BblFdDJp.js.map → LockedThumbnail-DA2ypEZM.js.map} +1 -1
- package/dist/{index-FY6hmphi.js → index-C2r41RoF.js} +1168 -1153
- package/dist/index-C2r41RoF.js.map +1 -0
- package/dist/index-DgqwF0XA.cjs +2 -0
- package/dist/index-DgqwF0XA.cjs.map +1 -0
- package/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/package.json +2 -2
- package/src/components/ChannelHeaderRedesign.tsx +8 -1
- package/src/components/ChannelView.tsx +12 -9
- package/src/components/CustomMessage/SentMessageDeliveryStatus.test.tsx +11 -29
- package/src/components/CustomMessage/SentMessageDeliveryStatus.tsx +26 -22
- package/src/components/CustomMessage/StreamAttachmentMessage.stories.tsx +19 -12
- package/src/components/CustomMessage/StreamAttachmentMessage.test.tsx +44 -26
- package/src/components/CustomMessage/StreamAttachmentMessage.tsx +37 -43
- package/src/components/CustomMessage/index.tsx +9 -21
- package/dist/index-Bxezkdl6.cjs +0 -2
- package/dist/index-Bxezkdl6.cjs.map +0 -1
- 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-
|
|
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-
|
|
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-
|
|
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-
|
|
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
|
|
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 "
|
|
16
|
-
// message
|
|
17
|
-
//
|
|
18
|
-
//
|
|
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
|
|
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
|
|
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
|
|
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 "
|
|
20
|
-
* message
|
|
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 "
|
|
25
|
-
* context's `lastOwnMessage
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
* `useChannelStateContext` gets a fresh
|
|
31
|
-
* message and, being a context, re-renders
|
|
32
|
-
* stale `Message`/`MessageStatus` memos —
|
|
33
|
-
*
|
|
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
|
|
43
|
-
|
|
44
|
-
|
|
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 !==
|
|
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
|
|
73
|
-
*
|
|
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
|
|
27
|
-
* the
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
104
|
-
}
|
|
109
|
+
'https://linktr.ee/someone 😎'
|
|
110
|
+
)}
|
|
105
111
|
/>
|
|
106
112
|
)
|
|
107
113
|
|
|
108
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
159
|
-
}
|
|
168
|
+
'you have to see this'
|
|
169
|
+
)}
|
|
160
170
|
/>
|
|
161
171
|
)
|
|
162
172
|
|
|
163
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
-
<
|
|
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
|
|
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
|