@linktr.ee/messaging-react 3.6.2 → 3.7.0-rc-1783305846

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 (43) hide show
  1. package/dist/{Card-NDZLmjxS.js → Card-BAt0xifq.js} +2 -2
  2. package/dist/{Card-NDZLmjxS.js.map → Card-BAt0xifq.js.map} +1 -1
  3. package/dist/{Card-DSQclaiH.js → Card-BB34_MDl.js} +3 -3
  4. package/dist/{Card-DSQclaiH.js.map → Card-BB34_MDl.js.map} +1 -1
  5. package/dist/{Card-Jc1n2Iqu.cjs → Card-BDKGSydN.cjs} +2 -2
  6. package/dist/{Card-Jc1n2Iqu.cjs.map → Card-BDKGSydN.cjs.map} +1 -1
  7. package/dist/{Card-DEk34Twe.cjs → Card-BGJY5HWf.cjs} +2 -2
  8. package/dist/{Card-DEk34Twe.cjs.map → Card-BGJY5HWf.cjs.map} +1 -1
  9. package/dist/{Card-Cz8tanga.js → Card-C3-Ea5ho.js} +2 -2
  10. package/dist/{Card-Cz8tanga.js.map → Card-C3-Ea5ho.js.map} +1 -1
  11. package/dist/{Card-BThi6-oD.cjs → Card-DnFtpG1h.cjs} +2 -2
  12. package/dist/{Card-BThi6-oD.cjs.map → Card-DnFtpG1h.cjs.map} +1 -1
  13. package/dist/{LockedThumbnail-CqcnnPXB.cjs → LockedThumbnail-Bl9UC9sQ.cjs} +2 -2
  14. package/dist/{LockedThumbnail-CqcnnPXB.cjs.map → LockedThumbnail-Bl9UC9sQ.cjs.map} +1 -1
  15. package/dist/{LockedThumbnail-t9fLaFmy.js → LockedThumbnail-so7ftBnG.js} +2 -2
  16. package/dist/{LockedThumbnail-t9fLaFmy.js.map → LockedThumbnail-so7ftBnG.js.map} +1 -1
  17. package/dist/{index-bPlX3Oo5.js → index-2HMjfjM-.js} +1559 -1744
  18. package/dist/index-2HMjfjM-.js.map +1 -0
  19. package/dist/index-BgzqZorr.cjs +2 -0
  20. package/dist/index-BgzqZorr.cjs.map +1 -0
  21. package/dist/index.cjs +1 -1
  22. package/dist/index.d.ts +14 -2
  23. package/dist/index.js +1 -1
  24. package/dist/testing.cjs +2 -0
  25. package/dist/testing.cjs.map +1 -0
  26. package/dist/testing.d.ts +116 -0
  27. package/dist/testing.js +107 -0
  28. package/dist/testing.js.map +1 -0
  29. package/package.json +14 -2
  30. package/src/components/ChannelView.stories.tsx +11 -68
  31. package/src/components/ChannelView.test.tsx +13 -54
  32. package/src/components/ChannelView.tsx +28 -74
  33. package/src/providers/MessagingProvider.test.tsx +30 -0
  34. package/src/providers/MessagingProvider.tsx +24 -5
  35. package/src/testing/createMockMessagingClient.stories.tsx +77 -0
  36. package/src/testing/createMockMessagingClient.ts +231 -0
  37. package/src/testing/index.ts +7 -0
  38. package/src/types.ts +15 -2
  39. package/dist/index-CPCX_7gP.cjs +0 -2
  40. package/dist/index-CPCX_7gP.cjs.map +0 -1
  41. package/dist/index-bPlX3Oo5.js.map +0 -1
  42. package/src/components/ChannelHeaderRedesign.tsx +0 -231
  43. /package/dist/assets/{index.css → messaging-react.css} +0 -0
@@ -25,7 +25,6 @@ import { resolveParticipantDisplayName } from '../utils/resolveParticipantDispla
25
25
 
26
26
  import { Avatar } from './Avatar'
27
27
  import { ChannelActionsMenu } from './ChannelActionsMenu'
28
- import { ChannelHeaderRedesign } from './ChannelHeaderRedesign'
29
28
  import { CustomDateSeparator } from './CustomDateSeparator'
30
29
  import { CustomMessage } from './CustomMessage'
31
30
  import { CustomMessageActions } from './CustomMessage/CustomMessageActions'
@@ -465,34 +464,6 @@ const ChannelViewInner: React.FC<{
465
464
  dmAgentEnabled &&
466
465
  currentUserIsAccount === false &&
467
466
  participantIsAccount === true
468
- const hasVisibleMessages = channel.state.messages.some(
469
- (message) => message.type !== 'deleted'
470
- )
471
- const channelBanner = renderChannelBanner ? (
472
- <React.Fragment key="lt-channel-banner">
473
- {renderChannelBanner()}
474
- </React.Fragment>
475
- ) : null
476
- const redesignedHeader = onParticipantNameClick ? (
477
- <ChannelHeaderRedesign
478
- onBack={onBack}
479
- showBackButton={showBackButton}
480
- showChannelInfo={showChannelInfo}
481
- showStarButton={showStarButton}
482
- dmAgentEnabled={showDmAgentHeader}
483
- onLeaveConversation={onLeaveConversation}
484
- onBlockParticipant={onBlockParticipant}
485
- showDeleteConversation={showDeleteConversation}
486
- showBlockParticipant={showBlockParticipant}
487
- showReportParticipant={showReportParticipant}
488
- onDeleteConversationClick={onDeleteConversationClick}
489
- onBlockParticipantClick={onBlockParticipantClick}
490
- onReportParticipantClick={onReportParticipantClick}
491
- customChannelActions={customChannelActions}
492
- renderChannelActions={renderChannelActions}
493
- onParticipantNameClick={onParticipantNameClick}
494
- />
495
- ) : null
496
467
 
497
468
  // Prevents all message instances from unmounting when ChannelViewInner re-renders
498
469
  const MessageOverride = useCallback(
@@ -526,58 +497,41 @@ const ChannelViewInner: React.FC<{
526
497
  >
527
498
  <Window>
528
499
  {/* Custom Channel Header */}
529
- {!onParticipantNameClick && (
530
- <>
531
- <div key="lt-channel-header">
532
- <CustomChannelHeader
533
- onBack={onBack}
534
- showBackButton={showBackButton}
535
- showChannelInfo={showChannelInfo}
536
- showStarButton={showStarButton}
537
- dmAgentEnabled={showDmAgentHeader}
538
- onLeaveConversation={onLeaveConversation}
539
- onBlockParticipant={onBlockParticipant}
540
- showDeleteConversation={showDeleteConversation}
541
- showBlockParticipant={showBlockParticipant}
542
- showReportParticipant={showReportParticipant}
543
- onDeleteConversationClick={onDeleteConversationClick}
544
- onBlockParticipantClick={onBlockParticipantClick}
545
- onReportParticipantClick={onReportParticipantClick}
546
- customChannelActions={customChannelActions}
547
- renderChannelActions={renderChannelActions}
548
- renderHeaderTitleBadges={renderHeaderTitleBadges}
549
- />
550
- </div>
551
- {channelBanner}
552
- </>
553
- )}
500
+ <div key="lt-channel-header">
501
+ <CustomChannelHeader
502
+ onBack={onBack}
503
+ showBackButton={showBackButton}
504
+ showChannelInfo={showChannelInfo}
505
+ showStarButton={showStarButton}
506
+ dmAgentEnabled={showDmAgentHeader}
507
+ onLeaveConversation={onLeaveConversation}
508
+ onBlockParticipant={onBlockParticipant}
509
+ showDeleteConversation={showDeleteConversation}
510
+ showBlockParticipant={showBlockParticipant}
511
+ showReportParticipant={showReportParticipant}
512
+ onDeleteConversationClick={onDeleteConversationClick}
513
+ onBlockParticipantClick={onBlockParticipantClick}
514
+ onReportParticipantClick={onReportParticipantClick}
515
+ customChannelActions={customChannelActions}
516
+ renderChannelActions={renderChannelActions}
517
+ onParticipantNameClick={onParticipantNameClick}
518
+ renderHeaderTitleBadges={renderHeaderTitleBadges}
519
+ />
520
+ </div>
554
521
 
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
- )}
522
+ {/* Custom Banner/Summary */}
523
+ {renderChannelBanner ? (
524
+ <React.Fragment key="lt-channel-banner">
525
+ {renderChannelBanner()}
526
+ </React.Fragment>
527
+ ) : null}
563
528
 
564
529
  {/* Message List */}
565
530
  <div
566
531
  key="lt-channel-message-list"
567
532
  className="flex-1 overflow-hidden relative"
568
533
  >
569
- <MessageList
570
- head={
571
- onParticipantNameClick && hasVisibleMessages ? (
572
- <div className="sticky top-0 z-10">
573
- {redesignedHeader}
574
- {channelBanner}
575
- </div>
576
- ) : undefined
577
- }
578
- hideDeletedMessages
579
- hideNewMessageSeparator={false}
580
- />
534
+ <MessageList hideDeletedMessages hideNewMessageSeparator={false} />
581
535
  </div>
582
536
 
583
537
  {renderConversationFooter ? (
@@ -258,4 +258,34 @@ describe('MessagingProvider', () => {
258
258
  await waitFor(() => expect(connectUser).toHaveBeenCalledTimes(2))
259
259
  expect(connectUser).toHaveBeenLastCalledWith(authedUser)
260
260
  })
261
+
262
+ it('uses an injected client directly and never connects a service', async () => {
263
+ const { connectUser, disconnectUser } = setupServiceMock()
264
+ const injectedClient = {
265
+ userID: 'injected-user',
266
+ } as unknown as StreamChat
267
+ const states: unknown[] = []
268
+
269
+ render(
270
+ <MessagingProvider client={injectedClient} user={null}>
271
+ <Probe onState={(s) => states.push(s)} />
272
+ </MessagingProvider>
273
+ )
274
+
275
+ // Injected client is reported connected immediately, and its instance is
276
+ // surfaced on the context.
277
+ await waitFor(() => {
278
+ const last = states[states.length - 1] as {
279
+ isConnected: boolean
280
+ hasClient: boolean
281
+ }
282
+ expect(last.isConnected).toBe(true)
283
+ expect(last.hasClient).toBe(true)
284
+ })
285
+
286
+ // The caller owns the injected client's lifecycle — the provider must not
287
+ // spin up a StreamChatService or connect/disconnect it.
288
+ expect(connectUser).not.toHaveBeenCalled()
289
+ expect(disconnectUser).not.toHaveBeenCalled()
290
+ })
261
291
  })
@@ -52,6 +52,7 @@ export const MessagingProvider: React.FC<MessagingProviderProps> = ({
52
52
  apiKey,
53
53
  capabilities = {},
54
54
  debug = false,
55
+ client: injectedClient,
55
56
  }) => {
56
57
  // Create debug logger that respects the debug prop
57
58
  const debugLog = useCallback(
@@ -137,10 +138,18 @@ export const MessagingProvider: React.FC<MessagingProviderProps> = ({
137
138
  // not the process-wide StreamChat.getInstance, which is shared across keys and
138
139
  // provider instances.
139
140
  const clientRef = useRef<{ apiKey: string; client: StreamChat } | null>(null)
140
- if (apiKey && clientRef.current?.apiKey !== apiKey) {
141
+ if (!injectedClient && apiKey && clientRef.current?.apiKey !== apiKey) {
141
142
  clientRef.current = { apiKey, client: new StreamChat(apiKey) }
142
143
  }
143
- const chatClient = apiKey ? (clientRef.current?.client ?? null) : null
144
+ // An injected client takes precedence and short-circuits the apiKey-derived
145
+ // client, the service, and the connect/disconnect lifecycle below — the
146
+ // caller owns that instance (offline/dev via `createMockMessagingClient`, or
147
+ // a test). Otherwise fall back to the apiKey-derived client as before.
148
+ const chatClient = injectedClient
149
+ ? injectedClient
150
+ : apiKey
151
+ ? (clientRef.current?.client ?? null)
152
+ : null
144
153
 
145
154
  // Create the service once per client lifetime (apiKey). Keyed on `chatClient`
146
155
  // — NOT on serviceConfig/debug — so those changing does not spin up a second
@@ -152,6 +161,12 @@ export const MessagingProvider: React.FC<MessagingProviderProps> = ({
152
161
  const currentRender = renderCountRef.current
153
162
  const currentConfig = serviceConfigRef.current
154
163
 
164
+ // An injected client owns its own connection — never create a service or
165
+ // connect for it.
166
+ if (injectedClient) {
167
+ return
168
+ }
169
+
155
170
  if (!apiKey || !chatClient || !currentConfig) {
156
171
  debugLog('⚠️ SERVICE INIT SKIPPED', {
157
172
  renderCount: currentRender,
@@ -182,7 +197,7 @@ export const MessagingProvider: React.FC<MessagingProviderProps> = ({
182
197
  // No disconnect on cleanup here — disconnection is owned by the
183
198
  // client-cleanup effect below, keyed on `chatClient` (apiKey) and unmount.
184
199
  // eslint-disable-next-line react-hooks/exhaustive-deps -- serviceConfig/debug/debugLog are read via refs by design (see above); recreating on them would re-adopt the live client
185
- }, [apiKey, chatClient])
200
+ }, [apiKey, chatClient, injectedClient])
186
201
 
187
202
  // Track if we've already connected this user with this service to prevent duplicate connections
188
203
  const connectedUserRef = useRef<{
@@ -324,8 +339,11 @@ export const MessagingProvider: React.FC<MessagingProviderProps> = ({
324
339
 
325
340
  return {
326
341
  service,
327
- client,
328
- isConnected,
342
+ // An injected client is already connected and owns its lifecycle, so
343
+ // surface it directly and report connected — the apiKey path's `client`
344
+ // state (set by connectUser) is never populated in that mode.
345
+ client: injectedClient ?? client,
346
+ isConnected: injectedClient ? true : isConnected,
329
347
  isLoading,
330
348
  error,
331
349
  capabilities,
@@ -335,6 +353,7 @@ export const MessagingProvider: React.FC<MessagingProviderProps> = ({
335
353
  }, [
336
354
  service,
337
355
  client,
356
+ injectedClient,
338
357
  isConnected,
339
358
  isLoading,
340
359
  error,
@@ -0,0 +1,77 @@
1
+ import type { Meta, StoryFn } from '@storybook/react'
2
+ import React from 'react'
3
+
4
+ import { MessagingShell } from '../components/MessagingShell'
5
+ import { MessagingProvider } from '../providers/MessagingProvider'
6
+
7
+ import { createMockMessagingClient } from './createMockMessagingClient'
8
+
9
+ /**
10
+ * Exercises the real MessagingProvider → MessagingShell → ChannelView stack
11
+ * against an injected mock client (the `dev:mock-messaging` path). Use this to
12
+ * visually verify send-echo, which the unit tests can't cover because they stub
13
+ * stream-chat-react.
14
+ */
15
+ const meta: Meta = {
16
+ title: 'testing/createMockMessagingClient',
17
+ parameters: { layout: 'fullscreen' },
18
+ }
19
+ export default meta
20
+
21
+ const currentUser = { id: 'viewer-1', name: 'You' }
22
+ const participant = {
23
+ id: 'creator-1',
24
+ name: 'Ava Creator',
25
+ image: 'https://i.pravatar.cc/150?img=5',
26
+ }
27
+
28
+ const Template: StoryFn = () => {
29
+ const [mock] = React.useState(() =>
30
+ createMockMessagingClient({
31
+ currentUser,
32
+ participant,
33
+ messages: [
34
+ {
35
+ id: 'w1',
36
+ from: 'them',
37
+ text: 'Welcome! Thanks for reaching out 👋',
38
+ metadata: { custom_type: 'MESSAGE_WELCOME' },
39
+ },
40
+ {
41
+ id: 'l1',
42
+ from: 'them',
43
+ text: 'Check out my latest drop:',
44
+ attachments: [
45
+ {
46
+ type: 'image',
47
+ og_scrape_url: 'https://tr.ee/example',
48
+ title: 'My latest drop',
49
+ text: 'Limited edition',
50
+ image_url: 'https://picsum.photos/seed/mockdrop/560/360',
51
+ },
52
+ ],
53
+ },
54
+ { id: 'm1', from: 'me', text: 'Love it — just grabbed one!' },
55
+ ],
56
+ onSend: (text) => `Thanks for saying "${text}"! 🎉`,
57
+ })
58
+ )
59
+
60
+ return (
61
+ <MessagingProvider client={mock.client} user={null}>
62
+ <div className="h-screen w-full bg-[#FBFAF9]">
63
+ <MessagingShell initialParticipantFilter={mock.participantFilterId} />
64
+ </div>
65
+ </MessagingProvider>
66
+ )
67
+ }
68
+
69
+ export const Default: StoryFn = Template.bind({})
70
+ Default.parameters = {
71
+ docs: {
72
+ description: {
73
+ story:
74
+ 'Seeded direct conversation rendered through the real shell. Type a message and send it — the mock echoes a canned reply (send-echo).',
75
+ },
76
+ },
77
+ }
@@ -0,0 +1,231 @@
1
+ import type {
2
+ Channel,
3
+ MessageResponse,
4
+ QueryChannelAPIResponse,
5
+ SendMessageAPIResponse,
6
+ } from 'stream-chat'
7
+ import { StreamChat } from 'stream-chat'
8
+
9
+ /** A participant in the mock conversation. */
10
+ export interface MockMessagingUser {
11
+ id: string
12
+ name?: string
13
+ image?: string
14
+ }
15
+
16
+ /**
17
+ * A seed message, authored from the viewer's perspective. `from: 'me'` is the
18
+ * connected user (the viewer); `from: 'them'` is the other participant.
19
+ */
20
+ export interface MockMessage {
21
+ id?: string
22
+ text?: string
23
+ from: 'me' | 'them'
24
+ /** Stream attachment payloads, rendered by the toolkit's attachment gate. */
25
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
26
+ attachments?: any[]
27
+ /** e.g. `{ custom_type: 'MESSAGE_WELCOME' }`. */
28
+ metadata?: Record<string, unknown>
29
+ }
30
+
31
+ export interface CreateMockMessagingClientOptions {
32
+ /** The connected/viewing user. */
33
+ currentUser: MockMessagingUser
34
+ /** The other party in the direct conversation (e.g. the creator/linker). */
35
+ participant: MockMessagingUser
36
+ /** Seed conversation, oldest-first. */
37
+ messages?: MockMessage[]
38
+ /** Channel id; defaults to `mock-dm`. */
39
+ channelId?: string
40
+ /**
41
+ * Optional canned reply. Called after the viewer sends a message; return the
42
+ * reply text (or a partial message) to have `participant` echo a response, or
43
+ * a falsy value for no reply. Drives the "send echo" in `dev:mock-messaging`.
44
+ */
45
+ onSend?: (
46
+ text: string
47
+ ) => string | { text?: string; attachments?: unknown[] } | null | undefined
48
+ }
49
+
50
+ export interface MockMessagingClient {
51
+ /** Pass to `<MessagingProvider client={...}>`. */
52
+ client: StreamChat
53
+ /** The seeded direct-conversation channel. */
54
+ channel: Channel
55
+ /** Pass to `<MessagingShell initialParticipantFilter={...}>`. */
56
+ participantFilterId: string
57
+ }
58
+
59
+ const REPLY_DELAY_MS = 600
60
+
61
+ /**
62
+ * Builds an offline Stream client seeded with a single direct conversation, so
63
+ * consumers can render the **real** messaging UI (MessagingProvider →
64
+ * MessagingShell → ChannelView) without a Stream backend — for `dev:mock-*`
65
+ * surfaces and Storybook. It is a real `StreamChat` whose network calls
66
+ * (`connectUser`, `queryChannels`, `channel.watch`, `channel.sendMessage`) are
67
+ * replaced with in-memory behaviour driven by Stream's own channel-state
68
+ * machinery, so rendering fidelity matches production.
69
+ *
70
+ * Ships from `@linktr.ee/messaging-react/testing` and must never be imported by
71
+ * production code.
72
+ */
73
+ export function createMockMessagingClient({
74
+ currentUser,
75
+ participant,
76
+ messages = [],
77
+ channelId = 'mock-dm',
78
+ onSend,
79
+ }: CreateMockMessagingClientOptions): MockMessagingClient {
80
+ const client = new StreamChat('mock-api-key', {
81
+ allowServerSideConnect: true,
82
+ })
83
+
84
+ // Mark connected without opening a WebSocket. `MessagingProvider` renders
85
+ // `<Chat client={client}>` directly for an injected client, so it never
86
+ // calls connectUser itself.
87
+ client.connectUser = async () => ({ me: currentUser }) as never
88
+ client.disconnectUser = async () => undefined
89
+ client.userID = currentUser.id
90
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
91
+ client.user = currentUser as any
92
+
93
+ const cid = `messaging:${channelId}`
94
+
95
+ const toStreamMessage = (
96
+ message: MockMessage,
97
+ index: number
98
+ ): MessageResponse => {
99
+ const author = message.from === 'me' ? currentUser : participant
100
+ // Stagger timestamps oldest-first so the list orders naturally.
101
+ const createdAt = new Date(
102
+ Date.now() - (messages.length - index) * 60_000
103
+ ).toISOString()
104
+ return {
105
+ id: message.id ?? `mock-msg-${index}`,
106
+ text: message.text ?? '',
107
+ type: 'regular',
108
+ html: message.text ? `<p>${message.text}</p>` : '',
109
+ user: author,
110
+ attachments: message.attachments ?? [],
111
+ latest_reactions: [],
112
+ own_reactions: [],
113
+ reaction_counts: {},
114
+ reaction_scores: {},
115
+ reply_count: 0,
116
+ status: 'received',
117
+ cid,
118
+ created_at: createdAt,
119
+ updated_at: createdAt,
120
+ mentioned_users: [],
121
+ ...(message.metadata ? { metadata: message.metadata } : {}),
122
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
123
+ } as any
124
+ }
125
+
126
+ const seededMessages = messages.map(toStreamMessage)
127
+
128
+ const memberState = {
129
+ [currentUser.id]: {
130
+ user: { ...currentUser, is_account: false },
131
+ user_id: currentUser.id,
132
+ role: 'owner',
133
+ is_account: false,
134
+ },
135
+ [participant.id]: {
136
+ user: { ...participant, is_account: true },
137
+ user_id: participant.id,
138
+ role: 'member',
139
+ is_account: true,
140
+ },
141
+ }
142
+
143
+ const channel = client.channel('messaging', channelId, {
144
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
145
+ members: [currentUser.id, participant.id] as any,
146
+ })
147
+
148
+ // Seed state locally instead of fetching. Mirrors the proven ChannelView
149
+ // story mock: override `watch` to populate `state` and return a canned
150
+ // QueryChannelAPIResponse.
151
+ channel.watch = async () => {
152
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
153
+ channel.state.messages = seededMessages as unknown as any[]
154
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
155
+ channel.state.members = memberState as unknown as any
156
+ return {
157
+ channel: { members: [currentUser.id, participant.id] },
158
+ members: [],
159
+ messages: seededMessages,
160
+ watchers: [],
161
+ pinned_messages: [],
162
+ duration: '0ms',
163
+ } as unknown as QueryChannelAPIResponse
164
+ }
165
+
166
+ const appendMessage = (message: MessageResponse) => {
167
+ // addMessageSorted is Stream's own state mutation (dedupes by id), so the
168
+ // toolkit re-renders through the real channel-state path.
169
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
170
+ channel.state.addMessageSorted(message as any)
171
+ client.dispatchEvent({
172
+ type: 'message.new',
173
+ cid,
174
+ message,
175
+ user: message.user ?? undefined,
176
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
177
+ } as any)
178
+ }
179
+
180
+ let sentCount = 0
181
+ channel.sendMessage = async (message) => {
182
+ const text =
183
+ typeof message === 'string'
184
+ ? message
185
+ : ((message as { text?: string })?.text ?? '')
186
+ const sent = {
187
+ ...toStreamMessage({ text, from: 'me', id: `mock-sent-${sentCount++}` }, seededMessages.length),
188
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
189
+ ...(typeof message === 'object' ? (message as any) : {}),
190
+ user: currentUser,
191
+ cid,
192
+ } as MessageResponse
193
+
194
+ // stream-chat-react adds the outgoing message optimistically; addMessageSorted
195
+ // dedupes by id, so confirming here is safe and idempotent.
196
+ appendMessage(sent)
197
+
198
+ const reply = onSend?.(text)
199
+ if (reply) {
200
+ const replyText = typeof reply === 'string' ? reply : reply.text
201
+ const replyAttachments =
202
+ typeof reply === 'string' ? undefined : reply.attachments
203
+ setTimeout(() => {
204
+ appendMessage(
205
+ toStreamMessage(
206
+ {
207
+ text: replyText,
208
+ from: 'them',
209
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
210
+ attachments: replyAttachments as any,
211
+ id: `mock-reply-${sentCount}`,
212
+ },
213
+ seededMessages.length
214
+ )
215
+ )
216
+ }, REPLY_DELAY_MS)
217
+ }
218
+
219
+ return { message: sent } as unknown as SendMessageAPIResponse
220
+ }
221
+
222
+ // MessagingShell finds the direct conversation via queryChannels; always
223
+ // return the one seeded channel regardless of the filter.
224
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
225
+ client.queryChannels = async () => [channel] as any
226
+
227
+ // Initialise state up-front so first render already has the conversation.
228
+ void channel.watch()
229
+
230
+ return { client, channel, participantFilterId: participant.id }
231
+ }
@@ -0,0 +1,7 @@
1
+ export {
2
+ createMockMessagingClient,
3
+ type CreateMockMessagingClientOptions,
4
+ type MockMessage,
5
+ type MockMessagingClient,
6
+ type MockMessagingUser,
7
+ } from './createMockMessagingClient'
package/src/types.ts CHANGED
@@ -10,6 +10,7 @@ import type {
10
10
  ChannelSort,
11
11
  LocalMessage,
12
12
  SendMessageAPIResponse,
13
+ StreamChat,
13
14
  } from 'stream-chat'
14
15
  import type {
15
16
  ChannelPreviewUIComponentProps,
@@ -448,8 +449,20 @@ export interface MessagingShellProps extends ChannelViewPassthroughProps {
448
449
  export interface MessagingProviderProps {
449
450
  children: React.ReactNode
450
451
  user: MessagingUser | null
451
- serviceConfig: Omit<StreamChatServiceConfig, 'apiKey'>
452
- apiKey: string
452
+ /**
453
+ * An already-constructed Stream client. When provided it takes precedence
454
+ * over `apiKey`: the provider renders `<Chat>` against this exact instance
455
+ * and does NOT create a StreamChatService or call `connectUser` — the caller
456
+ * owns the client's connection lifecycle. This is the seam for offline/dev
457
+ * (`createMockMessagingClient` from `@linktr.ee/messaging-react/testing`) and
458
+ * for tests, so consumers render the real UI instead of forking it.
459
+ * `apiKey`/`serviceConfig` are ignored while `client` is set.
460
+ */
461
+ client?: StreamChat
462
+ /** Required unless `client` is provided. */
463
+ serviceConfig?: Omit<StreamChatServiceConfig, 'apiKey'>
464
+ /** Required unless `client` is provided. */
465
+ apiKey?: string
453
466
  capabilities?: MessagingCapabilities
454
467
  debug?: boolean
455
468
  }