@linktr.ee/messaging-react 1.13.0 → 1.14.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@linktr.ee/messaging-react",
3
- "version": "1.13.0",
3
+ "version": "1.14.0",
4
4
  "description": "React messaging components built on messaging-core for web applications",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -14,6 +14,8 @@ import {
14
14
  Window,
15
15
  MessageList,
16
16
  useChannelStateContext,
17
+ WithComponents,
18
+ MessageUIComponentProps,
17
19
  } from 'stream-chat-react'
18
20
 
19
21
  import { useMessagingContext } from '../providers/MessagingProvider'
@@ -22,6 +24,8 @@ import type { ChannelViewProps } from '../types'
22
24
  import ActionButton from './ActionButton'
23
25
  import { Avatar } from './Avatar'
24
26
  import { CloseButton } from './CloseButton'
27
+ import { CustomDateSeparator } from './CustomDateSeparator'
28
+ import { CustomMessage } from './CustomMessage'
25
29
  import { CustomMessageInput } from './CustomMessageInput'
26
30
  import { CustomSystemMessage } from './CustomSystemMessage'
27
31
  import { ChannelEmptyState } from './MessagingShell/ChannelEmptyState'
@@ -487,31 +491,39 @@ const ChannelViewInner: React.FC<{
487
491
 
488
492
  return (
489
493
  <>
490
- <Window>
491
- {/* Custom Channel Header */}
492
- <div className="p-4">
493
- <CustomChannelHeader
494
- onBack={onBack}
495
- showBackButton={showBackButton}
496
- onShowInfo={handleShowInfo}
497
- canShowInfo={Boolean(participant)}
498
- />
499
- </div>
494
+ <WithComponents
495
+ overrides={{
496
+ Message: (props: MessageUIComponentProps) => (
497
+ <CustomMessage {...props} />
498
+ ),
499
+ }}
500
+ >
501
+ <Window>
502
+ {/* Custom Channel Header */}
503
+ <div className="p-4">
504
+ <CustomChannelHeader
505
+ onBack={onBack}
506
+ showBackButton={showBackButton}
507
+ onShowInfo={handleShowInfo}
508
+ canShowInfo={Boolean(participant)}
509
+ />
510
+ </div>
500
511
 
501
- {/* Message List */}
502
- <div className="flex-1 overflow-hidden relative">
503
- <MessageList
504
- hideDeletedMessages
505
- hideNewMessageSeparator={false}
506
- messageActions={[]}
507
- />
508
- </div>
512
+ {/* Message List */}
513
+ <div className="flex-1 overflow-hidden relative">
514
+ <MessageList
515
+ hideDeletedMessages
516
+ hideNewMessageSeparator={false}
517
+ messageActions={undefined}
518
+ />
519
+ </div>
509
520
 
510
- {/* Message Input */}
511
- <CustomMessageInput
512
- renderActions={() => renderMessageInputActions?.(channel)}
513
- />
514
- </Window>
521
+ {/* Message Input */}
522
+ <CustomMessageInput
523
+ renderActions={() => renderMessageInputActions?.(channel)}
524
+ />
525
+ </Window>
526
+ </WithComponents>
515
527
 
516
528
  {/* Channel Info Dialog */}
517
529
  <ChannelInfoDialog
@@ -561,6 +573,7 @@ export const ChannelView = React.memo<ChannelViewProps>(
561
573
  MessageSystem={CustomSystemMessage}
562
574
  EmptyStateIndicator={CustomChannelEmptyState}
563
575
  LoadingIndicator={LoadingState}
576
+ DateSeparator={CustomDateSeparator}
564
577
  >
565
578
  <ChannelViewInner
566
579
  onBack={onBack}
@@ -0,0 +1,5 @@
1
+ import { DateSeparator, type DateSeparatorProps } from 'stream-chat-react'
2
+
3
+ export const CustomDateSeparator = (props: DateSeparatorProps) => (
4
+ <DateSeparator {...props} position="center" />
5
+ )
@@ -84,7 +84,7 @@ export const CustomLinkPreviewList = () => {
84
84
  if (!showLinkPreviews) return null
85
85
 
86
86
  return (
87
- <div className="str-chat__link-preview-list p-0 gap-2 mb-4">
87
+ <div className="flex flex-col items-center w-full gap-2 mb-4">
88
88
  {stateLinkPreviews.map((linkPreview) => (
89
89
  <CustomLinkPreviewCard
90
90
  key={linkPreview.og_scrape_url}
@@ -0,0 +1,208 @@
1
+ import type { Meta, StoryFn } from '@storybook/react'
2
+ import React, { useEffect } from 'react'
3
+ import {
4
+ Channel as ChannelType,
5
+ QueryChannelAPIResponse,
6
+ StreamChat,
7
+ } from 'stream-chat'
8
+ import { Channel, Chat, MessageList, Window } from 'stream-chat-react'
9
+
10
+ import { mockParticipants } from '../../stories/mocks'
11
+
12
+ import { CustomMessage } from './index'
13
+
14
+ const meta: Meta = {
15
+ title: 'Components/CustomMessage',
16
+ component: CustomMessage,
17
+ parameters: {
18
+ layout: 'fullscreen',
19
+ },
20
+ }
21
+ export default meta
22
+
23
+ const mockUser = {
24
+ id: 'storybook-user',
25
+ name: 'Storybook User',
26
+ image: 'https://i.pravatar.cc/150?img=1',
27
+ }
28
+
29
+ const createMockChannel = async (
30
+ client: StreamChat,
31
+ messages: TemplateProps['messages']
32
+ ) => {
33
+ const participant = mockParticipants[0]
34
+
35
+ const mockMessages = messages.map((msg, index) => ({
36
+ ...msg,
37
+ type: msg.type ?? ('regular' as const),
38
+ created_at: new Date(Date.now() - 1000 * 60 * (messages.length - index)),
39
+ updated_at: new Date(Date.now() - 1000 * 60 * (messages.length - index)),
40
+ html: `<p>${msg.text}</p>`,
41
+ attachments: [],
42
+ latest_reactions: [],
43
+ own_reactions: [],
44
+ reaction_counts: {},
45
+ reaction_scores: {},
46
+ reply_count: 0,
47
+ status: 'received',
48
+ cid: 'messaging:storybook-channel-1',
49
+ mentioned_users: [],
50
+ }))
51
+
52
+ const channelData = {
53
+ members: [mockUser.id, participant.id],
54
+ }
55
+
56
+ const channel = client.channel(
57
+ 'messaging',
58
+ 'storybook-channel-1',
59
+ channelData
60
+ )
61
+
62
+ channel.watch = async () => {
63
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
64
+ channel.state.messages = mockMessages as unknown as any[]
65
+ channel.state.members = {
66
+ [mockUser.id]: { user: mockUser, user_id: mockUser.id },
67
+ [participant.id]: { user: participant, user_id: participant.id },
68
+ }
69
+ return {
70
+ channel: channelData,
71
+ members: [],
72
+ messages: mockMessages,
73
+ watchers: [],
74
+ pinned_messages: [],
75
+ duration: '0ms',
76
+ } as unknown as QueryChannelAPIResponse
77
+ }
78
+
79
+ try {
80
+ await channel.watch()
81
+ } catch {
82
+ // Ignore errors in mock mode
83
+ }
84
+
85
+ return channel
86
+ }
87
+
88
+ interface TemplateProps {
89
+ messages: Array<{
90
+ id: string
91
+ text: string
92
+ user: typeof mockUser | { id: string; name: string }
93
+ type?: 'regular' | 'system'
94
+ metadata?: {
95
+ custom_type?: 'MESSAGE_TIP' | 'MESSAGE_PAID' | 'MESSAGE_CHATBOT'
96
+ amount_text?: string
97
+ }
98
+ }>
99
+ }
100
+
101
+ const Template: StoryFn<TemplateProps> = ({ messages }) => {
102
+ const [client] = React.useState(() => {
103
+ const c = new StreamChat('mock-api-key', { allowServerSideConnect: true })
104
+ c.userID = mockUser.id
105
+ c.user = mockUser
106
+ return c
107
+ })
108
+
109
+ const [channel, setChannel] = React.useState<ChannelType | null>(null)
110
+
111
+ useEffect(() => {
112
+ createMockChannel(client, messages).then(setChannel)
113
+ }, [client, messages])
114
+
115
+ if (!channel) {
116
+ return <div className="p-4">Loading...</div>
117
+ }
118
+
119
+ return (
120
+ <Chat client={client}>
121
+ <div className="h-screen w-full bg-white">
122
+ <Channel channel={channel} Message={CustomMessage}>
123
+ <Window>
124
+ <MessageList />
125
+ </Window>
126
+ </Channel>
127
+ </div>
128
+ </Chat>
129
+ )
130
+ }
131
+
132
+ const participant = mockParticipants[0]
133
+
134
+ export const Default: StoryFn<TemplateProps> = Template.bind({})
135
+ Default.args = {
136
+ messages: [
137
+ { id: 'msg-1', text: 'Hey, how are you?', user: participant },
138
+ { id: 'msg-2', text: "I'm doing great, thanks!", user: mockUser },
139
+ { id: 'msg-3', text: 'Awesome! Have a good day.', user: participant },
140
+ ],
141
+ }
142
+
143
+ export const WithTipTag: StoryFn<TemplateProps> = Template.bind({})
144
+ WithTipTag.args = {
145
+ messages: [
146
+ { id: 'msg-1', text: 'Love your content!', user: participant },
147
+ {
148
+ id: 'msg-2',
149
+ text: "Here's a tip for you! Keep up the great work.",
150
+ user: participant,
151
+ metadata: { custom_type: 'MESSAGE_TIP', amount_text: '$5.50' },
152
+ },
153
+ { id: 'msg-3', text: 'Thank you so much! 🙏', user: mockUser },
154
+ ],
155
+ }
156
+
157
+ export const TipOnly: StoryFn<TemplateProps> = Template.bind({})
158
+ TipOnly.args = {
159
+ messages: [
160
+ { id: 'msg-1', text: 'Hello!', user: participant },
161
+ {
162
+ id: 'msg-2',
163
+ text: '',
164
+ user: participant,
165
+ metadata: { custom_type: 'MESSAGE_TIP', amount_text: '$50.00' },
166
+ },
167
+ { id: 'msg-3', text: 'Wow, thank you for the tip!', user: mockUser },
168
+ ],
169
+ }
170
+
171
+ export const MixedTags: StoryFn<TemplateProps> = Template.bind({})
172
+ MixedTags.args = {
173
+ messages: [
174
+ { id: 'msg-1', text: 'Regular message from a fan', user: participant },
175
+ {
176
+ id: 'msg-2',
177
+ text: 'I wanted to tip you for your amazing work!',
178
+ user: participant,
179
+ metadata: { custom_type: 'MESSAGE_TIP', amount_text: '$5.50' },
180
+ },
181
+ { id: 'msg-3', text: 'Thank you!', user: mockUser },
182
+ {
183
+ id: 'msg-4',
184
+ text: '',
185
+ user: participant,
186
+ metadata: { custom_type: 'MESSAGE_TIP', amount_text: '$25.00' },
187
+ },
188
+ {
189
+ id: 'msg-5',
190
+ text: 'Wow, a tip with no message! Thanks!',
191
+ user: mockUser,
192
+ },
193
+ {
194
+ id: 'msg-6',
195
+ text: 'This is a paid message!',
196
+ user: participant,
197
+ metadata: { custom_type: 'MESSAGE_PAID', amount_text: '$10.00' },
198
+ },
199
+ { id: 'msg-7', text: 'Got it, thanks!', user: mockUser },
200
+ {
201
+ id: 'msg-8',
202
+ text: 'This is from a chatbot!',
203
+ user: participant,
204
+ metadata: { custom_type: 'MESSAGE_CHATBOT' },
205
+ },
206
+ { id: 'msg-9', text: 'Thanks for letting me know.', user: mockUser },
207
+ ],
208
+ }
@@ -0,0 +1,119 @@
1
+ import type { Meta, StoryFn } from '@storybook/react'
2
+ import React from 'react'
3
+ import { LocalMessage } from 'stream-chat'
4
+
5
+ import { MessageTag } from './MessageTag'
6
+
7
+ type ComponentProps = React.ComponentProps<typeof MessageTag>
8
+
9
+ const meta: Meta<ComponentProps> = {
10
+ title: 'Components/MessageTag',
11
+ component: MessageTag,
12
+ parameters: {
13
+ layout: 'centered',
14
+ },
15
+ }
16
+ export default meta
17
+
18
+ interface MockMessageOptions {
19
+ metadata?: {
20
+ custom_type?: 'MESSAGE_TIP' | 'MESSAGE_PAID' | 'MESSAGE_CHATBOT'
21
+ amount_text?: string
22
+ }
23
+ text?: string
24
+ }
25
+
26
+ const createMockMessage = (options?: MockMessageOptions): LocalMessage =>
27
+ ({
28
+ id: 'msg-1',
29
+ text: options?.text ?? 'Hello world',
30
+ type: 'regular',
31
+ created_at: new Date(),
32
+ updated_at: new Date(),
33
+ metadata: options?.metadata,
34
+ }) as LocalMessage
35
+
36
+ const Template: StoryFn<ComponentProps> = (args) => {
37
+ return (
38
+ <div className="p-12">
39
+ <MessageTag {...args} />
40
+ </div>
41
+ )
42
+ }
43
+
44
+ export const Tip: StoryFn<ComponentProps> = Template.bind({})
45
+ Tip.args = {
46
+ message: createMockMessage({
47
+ metadata: { custom_type: 'MESSAGE_TIP', amount_text: '$5.50' },
48
+ }),
49
+ }
50
+
51
+ export const TipStandalone: StoryFn<ComponentProps> = Template.bind({})
52
+ TipStandalone.args = {
53
+ message: createMockMessage({
54
+ text: '',
55
+ metadata: { custom_type: 'MESSAGE_TIP', amount_text: '$50.00' },
56
+ }),
57
+ standalone: true,
58
+ }
59
+
60
+ export const Paid: StoryFn<ComponentProps> = Template.bind({})
61
+ Paid.args = {
62
+ message: createMockMessage({
63
+ metadata: { custom_type: 'MESSAGE_PAID', amount_text: '$25.00' },
64
+ }),
65
+ }
66
+
67
+ export const Chatbot: StoryFn<ComponentProps> = Template.bind({})
68
+ Chatbot.args = {
69
+ message: createMockMessage({ metadata: { custom_type: 'MESSAGE_CHATBOT' } }),
70
+ }
71
+
72
+ export const NoTag: StoryFn<ComponentProps> = Template.bind({})
73
+ NoTag.args = {
74
+ message: createMockMessage(),
75
+ }
76
+
77
+ export const AllVariants: StoryFn = () => {
78
+ return (
79
+ <div className="p-12 flex flex-col gap-4">
80
+ <div className="flex items-center gap-4">
81
+ <span className="text-sm w-32">Tip:</span>
82
+ <MessageTag
83
+ message={createMockMessage({
84
+ metadata: { custom_type: 'MESSAGE_TIP', amount_text: '$10.50' },
85
+ })}
86
+ />
87
+ </div>
88
+ <div className="flex items-center gap-4">
89
+ <span className="text-sm w-32">Paid:</span>
90
+ <MessageTag
91
+ message={createMockMessage({
92
+ metadata: { custom_type: 'MESSAGE_PAID', amount_text: '$25.00' },
93
+ })}
94
+ />
95
+ </div>
96
+ <div className="flex items-center gap-4">
97
+ <span className="text-sm w-32">Tip (standalone):</span>
98
+ <MessageTag
99
+ message={createMockMessage({
100
+ text: '',
101
+ metadata: { custom_type: 'MESSAGE_TIP', amount_text: '$50.00' },
102
+ })}
103
+ standalone
104
+ />
105
+ </div>
106
+ <div className="flex items-center gap-4">
107
+ <span className="text-sm w-32">Chatbot:</span>
108
+ <MessageTag
109
+ message={createMockMessage({ metadata: { custom_type: 'MESSAGE_CHATBOT' } })}
110
+ />
111
+ </div>
112
+ <div className="flex items-center gap-4">
113
+ <span className="text-sm w-32">No tag:</span>
114
+ <MessageTag message={createMockMessage()} />
115
+ <span className="text-xs text-stone">(renders nothing)</span>
116
+ </div>
117
+ </div>
118
+ )
119
+ }
@@ -0,0 +1,84 @@
1
+ import { GiftIcon } from '@phosphor-icons/react'
2
+ import { LocalMessage } from 'stream-chat'
3
+
4
+ interface MessageTagProps {
5
+ message: LocalMessage
6
+ /** When true, renders as a standalone bubble instead of a small tag */
7
+ standalone?: boolean
8
+ }
9
+
10
+ const SparkleIcon = () => (
11
+ <svg width="12" height="12" viewBox="0 0 10 10" fill="none">
12
+ <path
13
+ d="M10.003 5a.705.705 0 0 1-.469.67L6.7 6.7 5.67 9.535a.715.715 0 0 1-1.34 0L3.3 6.7.466 5.67a.715.715 0 0 1 0-1.34L3.3 3.3 4.33.466a.715.715 0 0 1 1.34 0L6.7 3.3l2.834 1.03a.705.705 0 0 1 .469.67"
14
+ fill="currentColor"
15
+ />
16
+ </svg>
17
+ )
18
+
19
+ /** Check if a message is a tip based on metadata */
20
+ export const isTipMessage = (message: LocalMessage): boolean => {
21
+ return message.metadata?.custom_type === 'MESSAGE_TIP'
22
+ }
23
+
24
+ /** Check if a message is a paid message based on metadata */
25
+ export const isPaidMessage = (message: LocalMessage): boolean => {
26
+ return message.metadata?.custom_type === 'MESSAGE_PAID'
27
+ }
28
+
29
+ /** Check if a message is a chatbot message based on metadata */
30
+ export const isChatbotMessage = (message: LocalMessage): boolean => {
31
+ return message.metadata?.custom_type === 'MESSAGE_CHATBOT'
32
+ }
33
+
34
+ /** Check if a message has a tip/paid tag (both render the same) */
35
+ export const isTipOrPaidMessage = (message: LocalMessage): boolean => {
36
+ return isTipMessage(message) || isPaidMessage(message)
37
+ }
38
+
39
+ /** Check if a message is a tip/paid-only message (no text) */
40
+ export const isTipOnlyMessage = (message: LocalMessage): boolean => {
41
+ return isTipOrPaidMessage(message) && !message.text?.trim()
42
+ }
43
+
44
+ export const MessageTag = ({
45
+ message,
46
+ standalone = false,
47
+ }: MessageTagProps) => {
48
+ const isTipOrPaid = isTipOrPaidMessage(message)
49
+ const isChatbot = isChatbotMessage(message)
50
+
51
+ if (!isTipOrPaid && !isChatbot) {
52
+ return null
53
+ }
54
+
55
+ if (isTipOrPaid) {
56
+ const amountText = message.metadata?.amount_text
57
+ if (!amountText) return null
58
+
59
+ const className = standalone
60
+ ? 'message-tip-standalone'
61
+ : 'message-tag message-tag--tip'
62
+
63
+ const label = standalone
64
+ ? `${amountText} tip`
65
+ : `Delivered with ${amountText} tip`
66
+
67
+ return (
68
+ <div className={className}>
69
+ <GiftIcon size={standalone ? 14 : 12} />
70
+ <span>{label}</span>
71
+ </div>
72
+ )
73
+ }
74
+
75
+ // Chatbot tag
76
+ return (
77
+ <div className="message-tag message-tag--chatbot">
78
+ <span className="message-tag__icon" style={{ marginTop: -1 }}>
79
+ <SparkleIcon />
80
+ </span>
81
+ <span className="message-tag__label">Chatbot</span>
82
+ </div>
83
+ )
84
+ }