@linktr.ee/messaging-react 1.13.1 → 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.1",
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'
@@ -23,6 +25,7 @@ import ActionButton from './ActionButton'
23
25
  import { Avatar } from './Avatar'
24
26
  import { CloseButton } from './CloseButton'
25
27
  import { CustomDateSeparator } from './CustomDateSeparator'
28
+ import { CustomMessage } from './CustomMessage'
26
29
  import { CustomMessageInput } from './CustomMessageInput'
27
30
  import { CustomSystemMessage } from './CustomSystemMessage'
28
31
  import { ChannelEmptyState } from './MessagingShell/ChannelEmptyState'
@@ -488,31 +491,39 @@ const ChannelViewInner: React.FC<{
488
491
 
489
492
  return (
490
493
  <>
491
- <Window>
492
- {/* Custom Channel Header */}
493
- <div className="p-4">
494
- <CustomChannelHeader
495
- onBack={onBack}
496
- showBackButton={showBackButton}
497
- onShowInfo={handleShowInfo}
498
- canShowInfo={Boolean(participant)}
499
- />
500
- </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>
501
511
 
502
- {/* Message List */}
503
- <div className="flex-1 overflow-hidden relative">
504
- <MessageList
505
- hideDeletedMessages
506
- hideNewMessageSeparator={false}
507
- messageActions={[]}
508
- />
509
- </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>
510
520
 
511
- {/* Message Input */}
512
- <CustomMessageInput
513
- renderActions={() => renderMessageInputActions?.(channel)}
514
- />
515
- </Window>
521
+ {/* Message Input */}
522
+ <CustomMessageInput
523
+ renderActions={() => renderMessageInputActions?.(channel)}
524
+ />
525
+ </Window>
526
+ </WithComponents>
516
527
 
517
528
  {/* Channel Info Dialog */}
518
529
  <ChannelInfoDialog
@@ -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
+ }