@linktr.ee/messaging-react 1.23.0-rc-1772427007 → 1.24.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.23.0-rc-1772427007",
3
+ "version": "1.24.0",
4
4
  "description": "React messaging components built on messaging-core for web applications",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -0,0 +1,23 @@
1
+ import React from 'react'
2
+ import type { Channel, LocalMessage } from 'stream-chat'
3
+
4
+ type ChannelListContextValue = {
5
+ selectedChannel?: Channel | null
6
+ onChannelSelect: (channel: Channel) => void
7
+ debug: boolean
8
+ renderMessagePreview?: (
9
+ message: LocalMessage | undefined,
10
+ defaultPreview?: string
11
+ ) => React.ReactNode
12
+ }
13
+
14
+ const ChannelListContext = React.createContext<ChannelListContextValue>({
15
+ selectedChannel: undefined,
16
+ onChannelSelect: () => {},
17
+ debug: false,
18
+ renderMessagePreview: undefined,
19
+ })
20
+
21
+ export const ChannelListProvider = ChannelListContext.Provider
22
+
23
+ export const useChannelListContext = () => React.useContext(ChannelListContext)
@@ -1,34 +1,21 @@
1
1
  import classNames from 'classnames'
2
2
  import React from 'react'
3
- import { Channel, LocalMessage } from 'stream-chat'
4
3
  import { ChannelPreviewUIComponentProps } from 'stream-chat-react'
5
4
 
6
5
  import { formatRelativeTime } from '../../utils/formatRelativeTime'
7
6
  import { Avatar } from '../Avatar'
8
7
  import { isChatbotMessage } from '../CustomMessage/MessageTag'
9
8
 
10
- type CustomChannelPreviewProps = ChannelPreviewUIComponentProps & {
11
- selectedChannel?: Channel | null
12
- onChannelSelect: (channel: Channel) => void
13
- debug?: boolean
14
- renderMessagePreview?: (
15
- message: LocalMessage | undefined,
16
- defaultPreview?: string
17
- ) => React.ReactNode
18
- }
9
+ import { useChannelListContext } from './ChannelListContext'
19
10
 
20
11
  /**
21
12
  * Custom channel preview that handles selection
22
13
  */
23
- const CustomChannelPreview = React.memo<CustomChannelPreviewProps>(
24
- ({
25
- channel,
26
- selectedChannel,
27
- onChannelSelect,
28
- debug = false,
29
- unread,
30
- renderMessagePreview,
31
- }) => {
14
+ const CustomChannelPreview = React.memo<ChannelPreviewUIComponentProps>(
15
+ ({ channel, unread }) => {
16
+ const { selectedChannel, onChannelSelect, debug, renderMessagePreview } =
17
+ useChannelListContext()
18
+
32
19
  const isSelected = selectedChannel?.id === channel?.id
33
20
 
34
21
  const handleClick = () => {
@@ -1,13 +1,15 @@
1
1
  import classNames from 'classnames'
2
2
  import React from 'react'
3
- import type { ChannelPreviewUIComponentProps } from 'stream-chat-react'
4
3
  import { ChannelList as StreamChannelList } from 'stream-chat-react'
5
4
 
6
5
  import { useMessagingContext } from '../../providers/MessagingProvider'
7
6
  import type { ChannelListProps } from '../../types'
8
7
 
8
+ import { ChannelListProvider } from './ChannelListContext'
9
9
  import CustomChannelPreview from './CustomChannelPreview'
10
10
 
11
+ const DEFAULT_SORT = { last_message_at: -1 } as const
12
+
11
13
  /**
12
14
  * Channel list component with customizable header and actions
13
15
  */
@@ -16,6 +18,7 @@ export const ChannelList = React.memo<ChannelListProps>(
16
18
  onChannelSelect,
17
19
  selectedChannel,
18
20
  filters,
21
+ sort = DEFAULT_SORT,
19
22
  className,
20
23
  customEmptyStateIndicator,
21
24
  renderMessagePreview,
@@ -35,19 +38,15 @@ export const ChannelList = React.memo<ChannelListProps>(
35
38
  })
36
39
  }
37
40
 
38
- // Memoize Preview component to prevent re-renders
39
- const PreviewComponent = React.useMemo(() => {
40
- const Preview = (props: ChannelPreviewUIComponentProps) => (
41
- <CustomChannelPreview
42
- {...props}
43
- selectedChannel={selectedChannel}
44
- onChannelSelect={onChannelSelect}
45
- debug={debug}
46
- renderMessagePreview={renderMessagePreview}
47
- />
48
- )
49
- return Preview
50
- }, [selectedChannel, onChannelSelect, debug, renderMessagePreview])
41
+ const contextValue = React.useMemo(
42
+ () => ({
43
+ selectedChannel,
44
+ onChannelSelect,
45
+ debug,
46
+ renderMessagePreview,
47
+ }),
48
+ [selectedChannel, onChannelSelect, debug, renderMessagePreview]
49
+ )
51
50
 
52
51
  return (
53
52
  <div
@@ -58,14 +57,16 @@ export const ChannelList = React.memo<ChannelListProps>(
58
57
  >
59
58
  {/* Channel List */}
60
59
  <div className="flex-1 overflow-hidden min-w-0">
61
- <StreamChannelList
62
- key={JSON.stringify(filters)}
63
- filters={filters}
64
- sort={{ last_message_at: -1 }}
65
- options={{ limit: 30 }}
66
- Preview={PreviewComponent}
67
- EmptyStateIndicator={customEmptyStateIndicator}
68
- />
60
+ <ChannelListProvider value={contextValue}>
61
+ <StreamChannelList
62
+ key={`${JSON.stringify(filters)}:${JSON.stringify(sort)}`}
63
+ filters={filters}
64
+ sort={sort}
65
+ options={{ limit: 30 }}
66
+ Preview={CustomChannelPreview}
67
+ EmptyStateIndicator={customEmptyStateIndicator}
68
+ />
69
+ </ChannelListProvider>
69
70
  </div>
70
71
  </div>
71
72
  )
@@ -1,6 +1,12 @@
1
1
  import type { Meta, StoryFn } from '@storybook/react'
2
2
  import React, { useEffect } from 'react'
3
- import { Channel as ChannelType, QueryChannelAPIResponse, StreamChat } from 'stream-chat'
3
+ import {
4
+ Channel as ChannelType,
5
+ ChannelMemberResponse,
6
+ Event,
7
+ QueryChannelAPIResponse,
8
+ StreamChat,
9
+ } from 'stream-chat'
4
10
  import { Chat } from 'stream-chat-react'
5
11
 
6
12
  import { mockParticipants } from '../stories/mocks'
@@ -248,6 +254,52 @@ WithMessageActions.parameters = {
248
254
  },
249
255
  }
250
256
 
257
+ export const WithMessageDecoration: StoryFn<TemplateProps> = Template.bind({})
258
+ WithMessageDecoration.args = {
259
+ showBackButton: false,
260
+ renderMessage: (messageNode, message) => {
261
+ const isPriorityMessage = message.id === 'msg-3'
262
+
263
+ if (!isPriorityMessage) {
264
+ return messageNode
265
+ }
266
+
267
+ return (
268
+ <section className="bg-[#F6F2FF] px-4 py-3 my-4 [&_.str-chat\_\_message-bubble]:bg-white">
269
+ <header className="mb-3 flex items-center justify-between gap-3">
270
+ <span className="text-sm font-semibold text-[#6D28D9]">
271
+ ✧ Marked as a priority by AI
272
+ </span>
273
+ <span className="flex items-center gap-3">
274
+ <button
275
+ className="text-sm text-[#5B5662] underline underline-offset-2 hover:text-[#2E2A34]"
276
+ type="button"
277
+ >
278
+ Not a priority
279
+ </button>
280
+ <button
281
+ aria-label="Dismiss priority banner"
282
+ className="text-base text-[#5B5662] hover:text-[#2E2A34]"
283
+ type="button"
284
+ >
285
+ ×
286
+ </button>
287
+ </span>
288
+ </header>
289
+ {messageNode}
290
+ </section>
291
+ )
292
+ },
293
+ }
294
+ WithMessageDecoration.parameters = {
295
+ docs: {
296
+ description: {
297
+ story:
298
+ 'Decorates one message with a priority treatment using ChannelView renderMessage(messageNode, message).',
299
+ },
300
+ },
301
+ }
302
+
251
303
  const WithStarButtonTemplate: StoryFn<ComponentProps> = (args) => {
252
304
  const [client] = React.useState(() => {
253
305
  const client = new StreamChat('mock-api-key', {
@@ -18,6 +18,7 @@ import {
18
18
  Channel,
19
19
  Window,
20
20
  MessageList,
21
+ useMessageContext,
21
22
  useChannelStateContext,
22
23
  WithComponents,
23
24
  MessageUIComponentProps,
@@ -534,6 +535,10 @@ const ChannelViewInner: React.FC<{
534
535
  chatbotVotingEnabled?: boolean
535
536
  renderChannelBanner?: () => React.ReactNode
536
537
  customChannelActions?: React.ReactNode
538
+ renderMessage?: (
539
+ messageNode: React.ReactElement,
540
+ message: NonNullable<MessageUIComponentProps['message']>
541
+ ) => React.ReactNode
537
542
  }> = ({
538
543
  onBack,
539
544
  showBackButton,
@@ -548,6 +553,7 @@ const ChannelViewInner: React.FC<{
548
553
  chatbotVotingEnabled = false,
549
554
  renderChannelBanner,
550
555
  customChannelActions,
556
+ renderMessage,
551
557
  }) => {
552
558
  const { channel } = useChannelStateContext()
553
559
  const infoDialogRef = useRef<HTMLDialogElement>(null)
@@ -593,9 +599,21 @@ const ChannelViewInner: React.FC<{
593
599
  <>
594
600
  <WithComponents
595
601
  overrides={{
596
- Message: (props: MessageUIComponentProps) => (
597
- <CustomMessage {...props} chatbotVotingEnabled={chatbotVotingEnabled} />
598
- ),
602
+ Message: (props: MessageUIComponentProps) => {
603
+ const { message } = useMessageContext('ChannelView')
604
+ const messageNode = (
605
+ <CustomMessage
606
+ {...props}
607
+ chatbotVotingEnabled={chatbotVotingEnabled}
608
+ />
609
+ )
610
+
611
+ if (!renderMessage || !message) {
612
+ return messageNode
613
+ }
614
+
615
+ return renderMessage(messageNode, message)
616
+ },
599
617
  }}
600
618
  >
601
619
  <Window>
@@ -672,6 +690,7 @@ export const ChannelView = React.memo<ChannelViewProps>(
672
690
  chatbotVotingEnabled = false,
673
691
  renderChannelBanner,
674
692
  customChannelActions,
693
+ renderMessage,
675
694
  }) => {
676
695
  // Custom send message handler that:
677
696
  // 1. Applies messageMetadata if provided
@@ -747,6 +766,7 @@ export const ChannelView = React.memo<ChannelViewProps>(
747
766
  chatbotVotingEnabled={chatbotVotingEnabled}
748
767
  renderChannelBanner={renderChannelBanner}
749
768
  customChannelActions={customChannelActions}
769
+ renderMessage={renderMessage}
750
770
  />
751
771
  </Channel>
752
772
  </div>
@@ -11,7 +11,6 @@ import { ParticipantPicker } from '../ParticipantPicker'
11
11
  import { EmptyState } from './EmptyState'
12
12
  import { ErrorState } from './ErrorState'
13
13
  import { LoadingState } from './LoadingState'
14
- import { createQueryChannelsManager } from './queryChannelsManager'
15
14
 
16
15
  /**
17
16
  * Main messaging interface component that combines channel list and channel view
@@ -39,6 +38,7 @@ export const MessagingShell: React.FC<MessagingShellProps> = ({
39
38
  renderMessagePreview,
40
39
  renderChannelBanner,
41
40
  customChannelActions,
41
+ renderMessage,
42
42
  }) => {
43
43
  const {
44
44
  service,
@@ -95,96 +95,64 @@ export const MessagingShell: React.FC<MessagingShellProps> = ({
95
95
 
96
96
  // Track if we've already synced channels to prevent repeated API calls
97
97
  const syncedRef = useRef<string | null>(null)
98
- const syncRequestNonceRef = useRef(0)
99
- const onChannelSelectRef = useRef(onChannelSelect)
100
- const debugEnabledRef = useRef(debug)
101
-
102
- useEffect(() => {
103
- onChannelSelectRef.current = onChannelSelect
104
- }, [onChannelSelect])
105
-
106
- const queryChannelsManager = React.useMemo(() => {
107
- if (!client) return null
108
-
109
- return createQueryChannelsManager({
110
- client,
111
- onRetry: ({ requestKey, attempt, delayMs, error }) => {
112
- if (!debugEnabledRef.current) return
113
-
114
- console.warn('[MessagingShell] queryChannels rate limited; retrying', {
115
- requestKey,
116
- attempt,
117
- delayMs,
118
- error,
119
- })
120
- },
121
- })
122
- }, [client])
123
98
 
124
99
  // Function to sync channels (extracted for reuse)
125
- const syncChannels = useCallback(
126
- async ({ forceFresh = false }: { forceFresh?: boolean } = {}) => {
127
- if (!client || !isConnected || !queryChannelsManager) return
128
-
129
- const userId = client.userID
130
- if (!userId) return
131
-
132
- try {
133
- if (debug) {
134
- console.log('[MessagingShell] Syncing channels for user:', userId)
135
- }
136
-
137
- const requestKey = forceFresh
138
- ? `sync:${userId}:refresh:${syncRequestNonceRef.current++}`
139
- : `sync:${userId}`
100
+ const syncChannels = useCallback(async () => {
101
+ if (!client || !isConnected) return
140
102
 
141
- const channels = await queryChannelsManager.query({
142
- requestKey,
143
- filters: {
144
- type: 'messaging',
145
- members: { $in: [userId] },
146
- },
147
- options: { limit: 100 },
148
- })
103
+ const userId = client.userID
104
+ if (!userId) return
149
105
 
150
- const memberIds = new Set<string>()
151
- channels.forEach((channel: Channel) => {
152
- const members = channel.state.members
153
- Object.values(members).forEach((member) => {
154
- const memberId = member.user?.id
155
- if (memberId && memberId !== userId) {
156
- memberIds.add(memberId)
157
- }
158
- })
159
- })
106
+ try {
107
+ if (debug) {
108
+ console.log('[MessagingShell] Syncing channels for user:', userId)
109
+ }
160
110
 
161
- // Only update if the set contents have changed to prevent re-renders
162
- setExistingParticipantIds((prev) => {
163
- if (
164
- prev.size === memberIds.size &&
165
- [...prev].every((id) => memberIds.has(id))
166
- ) {
167
- return prev
111
+ const channels = await client.queryChannels(
112
+ {
113
+ type: 'messaging',
114
+ members: { $in: [userId] },
115
+ },
116
+ {},
117
+ { limit: 100 }
118
+ )
119
+
120
+ const memberIds = new Set<string>()
121
+ channels.forEach((channel: Channel) => {
122
+ const members = channel.state.members
123
+ Object.values(members).forEach((member) => {
124
+ const memberId = member.user?.id
125
+ if (memberId && memberId !== userId) {
126
+ memberIds.add(memberId)
168
127
  }
169
- return memberIds
170
128
  })
171
- setHasChannels(channels.length > 0)
172
- setChannelsLoaded(true)
173
- syncedRef.current = userId // Mark as synced for this user
174
-
175
- if (debug) {
176
- console.log('[MessagingShell] Channels synced successfully:', {
177
- channelCount: channels.length,
178
- memberCount: memberIds.size,
179
- })
129
+ })
130
+
131
+ // Only update if the set contents have changed to prevent re-renders
132
+ setExistingParticipantIds((prev) => {
133
+ if (
134
+ prev.size === memberIds.size &&
135
+ [...prev].every((id) => memberIds.has(id))
136
+ ) {
137
+ return prev
180
138
  }
181
- } catch (error) {
182
- console.error('[MessagingShell] Failed to sync channels:', error)
183
- // Don't mark as synced on error, allow retry
139
+ return memberIds
140
+ })
141
+ setHasChannels(channels.length > 0)
142
+ setChannelsLoaded(true)
143
+ syncedRef.current = userId // Mark as synced for this user
144
+
145
+ if (debug) {
146
+ console.log('[MessagingShell] Channels synced successfully:', {
147
+ channelCount: channels.length,
148
+ memberCount: memberIds.size,
149
+ })
184
150
  }
185
- },
186
- [client, isConnected, debug, queryChannelsManager]
187
- )
151
+ } catch (error) {
152
+ console.error('[MessagingShell] Failed to sync channels:', error)
153
+ // Don't mark as synced on error, allow retry
154
+ }
155
+ }, [client, isConnected, debug])
188
156
 
189
157
  // Sync existing channels to track which participants we can already message
190
158
  useEffect(() => {
@@ -196,19 +164,12 @@ export const MessagingShell: React.FC<MessagingShellProps> = ({
196
164
  // Prevent repeated sync for the same user
197
165
  if (syncedRef.current === userId) return
198
166
 
199
- void syncChannels()
167
+ syncChannels()
200
168
  }, [client, isConnected, syncChannels])
201
169
 
202
170
  // Load initial channel for direct conversation mode
203
171
  useEffect(() => {
204
- if (
205
- !initialParticipantFilter ||
206
- !client ||
207
- !isConnected ||
208
- !queryChannelsManager
209
- ) {
210
- return
211
- }
172
+ if (!initialParticipantFilter || !client || !isConnected) return
212
173
 
213
174
  const loadInitialChannel = async () => {
214
175
  const userId = client.userID
@@ -222,14 +183,14 @@ export const MessagingShell: React.FC<MessagingShellProps> = ({
222
183
  )
223
184
  }
224
185
 
225
- const channels = await queryChannelsManager.query({
226
- requestKey: `direct:${userId}:${initialParticipantFilter}`,
227
- filters: {
186
+ const channels = await client.queryChannels(
187
+ {
228
188
  type: 'messaging',
229
189
  members: { $eq: [userId, initialParticipantFilter] },
230
190
  },
231
- options: { limit: 1 },
232
- })
191
+ {},
192
+ { limit: 1 }
193
+ )
233
194
 
234
195
  if (channels.length > 0) {
235
196
  setSelectedChannel(channels[0])
@@ -237,7 +198,9 @@ export const MessagingShell: React.FC<MessagingShellProps> = ({
237
198
  setDirectConversationError(null)
238
199
 
239
200
  // Notify parent component of channel selection
240
- onChannelSelectRef.current?.(channels[0])
201
+ if (onChannelSelect) {
202
+ onChannelSelect(channels[0])
203
+ }
241
204
 
242
205
  if (debug) {
243
206
  console.log(
@@ -269,7 +232,9 @@ export const MessagingShell: React.FC<MessagingShellProps> = ({
269
232
  setDirectConversationError(null)
270
233
 
271
234
  // Notify parent component of channel selection
272
- onChannelSelectRef.current?.(channel)
235
+ if (onChannelSelect) {
236
+ onChannelSelect(channel)
237
+ }
273
238
 
274
239
  if (debug) {
275
240
  console.log(
@@ -307,7 +272,7 @@ export const MessagingShell: React.FC<MessagingShellProps> = ({
307
272
  }
308
273
  }
309
274
 
310
- void loadInitialChannel()
275
+ loadInitialChannel()
311
276
  }, [
312
277
  initialParticipantFilter,
313
278
  initialParticipantData,
@@ -315,7 +280,7 @@ export const MessagingShell: React.FC<MessagingShellProps> = ({
315
280
  isConnected,
316
281
  service,
317
282
  debug,
318
- queryChannelsManager,
283
+ onChannelSelect,
319
284
  ])
320
285
 
321
286
  const handleChannelSelect = useCallback(
@@ -396,7 +361,7 @@ export const MessagingShell: React.FC<MessagingShellProps> = ({
396
361
 
397
362
  // Force re-sync to update the existing participants list
398
363
  syncedRef.current = null
399
- await syncChannels({ forceFresh: true })
364
+ await syncChannels()
400
365
  },
401
366
  [syncChannels, debug]
402
367
  )
@@ -411,7 +376,7 @@ export const MessagingShell: React.FC<MessagingShellProps> = ({
411
376
 
412
377
  // Force re-sync to update the existing participants list
413
378
  syncedRef.current = null
414
- await syncChannels({ forceFresh: true })
379
+ await syncChannels()
415
380
  },
416
381
  [syncChannels, debug]
417
382
  )
@@ -533,6 +498,7 @@ export const MessagingShell: React.FC<MessagingShellProps> = ({
533
498
  showStarButton={showStarButton}
534
499
  chatbotVotingEnabled={chatbotVotingEnabled}
535
500
  customChannelActions={customChannelActions}
501
+ renderMessage={renderMessage}
536
502
  />
537
503
  </div>
538
504
  ) : initialParticipantFilter ? (
package/src/types.ts CHANGED
@@ -5,6 +5,7 @@ import type {
5
5
  import type {
6
6
  Channel,
7
7
  ChannelFilters,
8
+ ChannelSort,
8
9
  LocalMessage,
9
10
  SendMessageAPIResponse,
10
11
  } from 'stream-chat'
@@ -67,6 +68,11 @@ export interface ChannelListProps {
67
68
  participantLabel?: string
68
69
  className?: string
69
70
  filters: ChannelFilters
71
+ /**
72
+ * Sort order for the channel list query.
73
+ * Defaults to `{ last_message_at: -1 }` (most recent first).
74
+ */
75
+ sort?: ChannelSort
70
76
  customEmptyStateIndicator?: React.ComponentType<EmptyStateIndicatorProps>
71
77
  renderMessagePreview?: (
72
78
  message: LocalMessage | undefined,
@@ -162,6 +168,21 @@ export interface ChannelViewProps {
162
168
  * Use the exported ActionButton for consistent styling.
163
169
  */
164
170
  customChannelActions?: React.ReactNode
171
+ /**
172
+ * Custom render function for decorating each message in the message list.
173
+ * Receives the default message node and the message object.
174
+ *
175
+ * @example
176
+ * renderMessage={(messageNode, message) => (
177
+ * <MessageDecorator isStarred={Boolean(message.pinned)}>
178
+ * {messageNode}
179
+ * </MessageDecorator>
180
+ * )}
181
+ */
182
+ renderMessage?: (
183
+ messageNode: React.ReactElement,
184
+ message: LocalMessage
185
+ ) => React.ReactNode
165
186
  }
166
187
 
167
188
  /**
@@ -182,6 +203,7 @@ export type ChannelViewPassthroughProps = Pick<
182
203
  | 'chatbotVotingEnabled'
183
204
  | 'renderChannelBanner'
184
205
  | 'customChannelActions'
206
+ | 'renderMessage'
185
207
  >
186
208
 
187
209
  /**