@linktr.ee/messaging-react 1.22.2 → 1.23.0-rc-1772427007

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.22.2",
3
+ "version": "1.23.0-rc-1772427007",
4
4
  "description": "React messaging components built on messaging-core for web applications",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -11,6 +11,7 @@ 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'
14
15
 
15
16
  /**
16
17
  * Main messaging interface component that combines channel list and channel view
@@ -94,64 +95,96 @@ export const MessagingShell: React.FC<MessagingShellProps> = ({
94
95
 
95
96
  // Track if we've already synced channels to prevent repeated API calls
96
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])
97
123
 
98
124
  // Function to sync channels (extracted for reuse)
99
- const syncChannels = useCallback(async () => {
100
- if (!client || !isConnected) return
125
+ const syncChannels = useCallback(
126
+ async ({ forceFresh = false }: { forceFresh?: boolean } = {}) => {
127
+ if (!client || !isConnected || !queryChannelsManager) return
101
128
 
102
- const userId = client.userID
103
- if (!userId) return
129
+ const userId = client.userID
130
+ if (!userId) return
104
131
 
105
- try {
106
- if (debug) {
107
- console.log('[MessagingShell] Syncing channels for user:', userId)
108
- }
132
+ try {
133
+ if (debug) {
134
+ console.log('[MessagingShell] Syncing channels for user:', userId)
135
+ }
109
136
 
110
- const channels = await client.queryChannels(
111
- {
112
- type: 'messaging',
113
- members: { $in: [userId] },
114
- },
115
- {},
116
- { limit: 100 }
117
- )
118
-
119
- const memberIds = new Set<string>()
120
- channels.forEach((channel: Channel) => {
121
- const members = channel.state.members
122
- Object.values(members).forEach((member) => {
123
- const memberId = member.user?.id
124
- if (memberId && memberId !== userId) {
125
- memberIds.add(memberId)
126
- }
137
+ const requestKey = forceFresh
138
+ ? `sync:${userId}:refresh:${syncRequestNonceRef.current++}`
139
+ : `sync:${userId}`
140
+
141
+ const channels = await queryChannelsManager.query({
142
+ requestKey,
143
+ filters: {
144
+ type: 'messaging',
145
+ members: { $in: [userId] },
146
+ },
147
+ options: { limit: 100 },
127
148
  })
128
- })
129
-
130
- // Only update if the set contents have changed to prevent re-renders
131
- setExistingParticipantIds((prev) => {
132
- if (
133
- prev.size === memberIds.size &&
134
- [...prev].every((id) => memberIds.has(id))
135
- ) {
136
- return prev
137
- }
138
- return memberIds
139
- })
140
- setHasChannels(channels.length > 0)
141
- setChannelsLoaded(true)
142
- syncedRef.current = userId // Mark as synced for this user
143
149
 
144
- if (debug) {
145
- console.log('[MessagingShell] Channels synced successfully:', {
146
- channelCount: channels.length,
147
- memberCount: memberIds.size,
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
+ })
160
+
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
168
+ }
169
+ return memberIds
148
170
  })
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
+ })
180
+ }
181
+ } catch (error) {
182
+ console.error('[MessagingShell] Failed to sync channels:', error)
183
+ // Don't mark as synced on error, allow retry
149
184
  }
150
- } catch (error) {
151
- console.error('[MessagingShell] Failed to sync channels:', error)
152
- // Don't mark as synced on error, allow retry
153
- }
154
- }, [client, isConnected, debug])
185
+ },
186
+ [client, isConnected, debug, queryChannelsManager]
187
+ )
155
188
 
156
189
  // Sync existing channels to track which participants we can already message
157
190
  useEffect(() => {
@@ -163,12 +196,19 @@ export const MessagingShell: React.FC<MessagingShellProps> = ({
163
196
  // Prevent repeated sync for the same user
164
197
  if (syncedRef.current === userId) return
165
198
 
166
- syncChannels()
199
+ void syncChannels()
167
200
  }, [client, isConnected, syncChannels])
168
201
 
169
202
  // Load initial channel for direct conversation mode
170
203
  useEffect(() => {
171
- if (!initialParticipantFilter || !client || !isConnected) return
204
+ if (
205
+ !initialParticipantFilter ||
206
+ !client ||
207
+ !isConnected ||
208
+ !queryChannelsManager
209
+ ) {
210
+ return
211
+ }
172
212
 
173
213
  const loadInitialChannel = async () => {
174
214
  const userId = client.userID
@@ -182,14 +222,14 @@ export const MessagingShell: React.FC<MessagingShellProps> = ({
182
222
  )
183
223
  }
184
224
 
185
- const channels = await client.queryChannels(
186
- {
225
+ const channels = await queryChannelsManager.query({
226
+ requestKey: `direct:${userId}:${initialParticipantFilter}`,
227
+ filters: {
187
228
  type: 'messaging',
188
229
  members: { $eq: [userId, initialParticipantFilter] },
189
230
  },
190
- {},
191
- { limit: 1 }
192
- )
231
+ options: { limit: 1 },
232
+ })
193
233
 
194
234
  if (channels.length > 0) {
195
235
  setSelectedChannel(channels[0])
@@ -197,9 +237,7 @@ export const MessagingShell: React.FC<MessagingShellProps> = ({
197
237
  setDirectConversationError(null)
198
238
 
199
239
  // Notify parent component of channel selection
200
- if (onChannelSelect) {
201
- onChannelSelect(channels[0])
202
- }
240
+ onChannelSelectRef.current?.(channels[0])
203
241
 
204
242
  if (debug) {
205
243
  console.log(
@@ -231,9 +269,7 @@ export const MessagingShell: React.FC<MessagingShellProps> = ({
231
269
  setDirectConversationError(null)
232
270
 
233
271
  // Notify parent component of channel selection
234
- if (onChannelSelect) {
235
- onChannelSelect(channel)
236
- }
272
+ onChannelSelectRef.current?.(channel)
237
273
 
238
274
  if (debug) {
239
275
  console.log(
@@ -271,7 +307,7 @@ export const MessagingShell: React.FC<MessagingShellProps> = ({
271
307
  }
272
308
  }
273
309
 
274
- loadInitialChannel()
310
+ void loadInitialChannel()
275
311
  }, [
276
312
  initialParticipantFilter,
277
313
  initialParticipantData,
@@ -279,7 +315,7 @@ export const MessagingShell: React.FC<MessagingShellProps> = ({
279
315
  isConnected,
280
316
  service,
281
317
  debug,
282
- onChannelSelect,
318
+ queryChannelsManager,
283
319
  ])
284
320
 
285
321
  const handleChannelSelect = useCallback(
@@ -360,7 +396,7 @@ export const MessagingShell: React.FC<MessagingShellProps> = ({
360
396
 
361
397
  // Force re-sync to update the existing participants list
362
398
  syncedRef.current = null
363
- await syncChannels()
399
+ await syncChannels({ forceFresh: true })
364
400
  },
365
401
  [syncChannels, debug]
366
402
  )
@@ -375,7 +411,7 @@ export const MessagingShell: React.FC<MessagingShellProps> = ({
375
411
 
376
412
  // Force re-sync to update the existing participants list
377
413
  syncedRef.current = null
378
- await syncChannels()
414
+ await syncChannels({ forceFresh: true })
379
415
  },
380
416
  [syncChannels, debug]
381
417
  )
@@ -0,0 +1,244 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+
3
+ import {
4
+ createQueryChannelsManager,
5
+ getRetryDelayMsFromRateLimitError,
6
+ isRateLimitError,
7
+ } from './queryChannelsManager'
8
+
9
+ type MockChannel = { id: string }
10
+
11
+ describe('queryChannelsManager', () => {
12
+ it('dedupes in-flight requests for the same request key', async () => {
13
+ const resolverRef: { current: ((value: MockChannel[]) => void) | null } = {
14
+ current: null,
15
+ }
16
+
17
+ const queryChannels = vi.fn(
18
+ () =>
19
+ new Promise<MockChannel[]>((resolve) => {
20
+ resolverRef.current = resolve
21
+ })
22
+ )
23
+
24
+ const manager = createQueryChannelsManager({
25
+ client: { queryChannels } as never,
26
+ wait: async () => {},
27
+ })
28
+
29
+ const firstPromise = manager.query({
30
+ requestKey: 'direct:viewer:participant',
31
+ filters: {
32
+ type: 'messaging',
33
+ },
34
+ options: { limit: 1 },
35
+ })
36
+ const secondPromise = manager.query({
37
+ requestKey: 'direct:viewer:participant',
38
+ filters: {
39
+ type: 'messaging',
40
+ },
41
+ options: { limit: 1 },
42
+ })
43
+
44
+ expect(queryChannels).toHaveBeenCalledTimes(1)
45
+
46
+ const resolveQuery = resolverRef.current
47
+ expect(resolveQuery).toBeTypeOf('function')
48
+ if (!resolveQuery) {
49
+ throw new Error('Expected in-flight query resolver to be set')
50
+ }
51
+ resolveQuery([{ id: 'channel-1' }])
52
+
53
+ await expect(firstPromise).resolves.toEqual([{ id: 'channel-1' }])
54
+ await expect(secondPromise).resolves.toEqual([{ id: 'channel-1' }])
55
+ })
56
+
57
+ it('does not dedupe requests that use different request keys', async () => {
58
+ const queryChannels = vi.fn().mockResolvedValue([{ id: 'channel-1' }])
59
+
60
+ const manager = createQueryChannelsManager({
61
+ client: { queryChannels } as never,
62
+ wait: async () => {},
63
+ })
64
+
65
+ await manager.query({
66
+ requestKey: 'sync:user',
67
+ filters: {
68
+ type: 'messaging',
69
+ },
70
+ options: { limit: 1 },
71
+ })
72
+
73
+ await manager.query({
74
+ requestKey: 'sync:user:refresh:1',
75
+ filters: {
76
+ type: 'messaging',
77
+ },
78
+ options: { limit: 1 },
79
+ })
80
+
81
+ expect(queryChannels).toHaveBeenCalledTimes(2)
82
+ })
83
+
84
+ it('retries with retry-after header when queryChannels returns a 429 response', async () => {
85
+ const wait = vi.fn(async () => {})
86
+ const queryChannels = vi
87
+ .fn()
88
+ .mockRejectedValueOnce({
89
+ response: {
90
+ status: 429,
91
+ headers: {
92
+ 'retry-after': '2',
93
+ },
94
+ },
95
+ })
96
+ .mockResolvedValueOnce([{ id: 'channel-1' }])
97
+
98
+ const manager = createQueryChannelsManager({
99
+ client: { queryChannels } as never,
100
+ wait,
101
+ maxRetries: 2,
102
+ })
103
+
104
+ const channels = await manager.query({
105
+ requestKey: 'direct:viewer:participant',
106
+ filters: {
107
+ type: 'messaging',
108
+ },
109
+ options: { limit: 1 },
110
+ })
111
+
112
+ expect(wait).toHaveBeenCalledWith(2000)
113
+ expect(queryChannels).toHaveBeenCalledTimes(2)
114
+ expect(channels).toEqual([{ id: 'channel-1' }])
115
+ })
116
+
117
+ it('falls back to x-ratelimit-reset header when retry-after is absent', () => {
118
+ const delay = getRetryDelayMsFromRateLimitError(
119
+ {
120
+ response: {
121
+ status: 429,
122
+ headers: {
123
+ 'x-ratelimit-reset': '12',
124
+ },
125
+ },
126
+ },
127
+ 0,
128
+ 10_000
129
+ )
130
+
131
+ expect(delay).toBe(2_000)
132
+ })
133
+
134
+ it('retries for stream error code 9 even if status is unavailable', async () => {
135
+ const wait = vi.fn(async () => {})
136
+ const queryChannels = vi
137
+ .fn()
138
+ .mockRejectedValueOnce({
139
+ code: 9,
140
+ message:
141
+ 'StreamChat error code 9: QueryChannels failed with error: "Too many requests"',
142
+ })
143
+ .mockResolvedValueOnce([{ id: 'channel-1' }])
144
+
145
+ const manager = createQueryChannelsManager({
146
+ client: { queryChannels } as never,
147
+ wait,
148
+ maxRetries: 2,
149
+ })
150
+
151
+ const channels = await manager.query({
152
+ requestKey: 'direct:viewer:participant',
153
+ filters: {
154
+ type: 'messaging',
155
+ },
156
+ options: { limit: 1 },
157
+ })
158
+
159
+ expect(wait).toHaveBeenCalledTimes(1)
160
+ expect(queryChannels).toHaveBeenCalledTimes(2)
161
+ expect(channels).toEqual([{ id: 'channel-1' }])
162
+ })
163
+
164
+ it('does not retry non-rate-limit errors', async () => {
165
+ const wait = vi.fn(async () => {})
166
+ const queryChannels = vi
167
+ .fn()
168
+ .mockRejectedValueOnce(new Error('network down'))
169
+
170
+ const manager = createQueryChannelsManager({
171
+ client: { queryChannels } as never,
172
+ wait,
173
+ maxRetries: 2,
174
+ })
175
+
176
+ await expect(
177
+ manager.query({
178
+ requestKey: 'direct:viewer:participant',
179
+ filters: {
180
+ type: 'messaging',
181
+ },
182
+ options: { limit: 1 },
183
+ })
184
+ ).rejects.toThrow('network down')
185
+
186
+ expect(wait).not.toHaveBeenCalled()
187
+ expect(queryChannels).toHaveBeenCalledTimes(1)
188
+ })
189
+
190
+ it('throws after max retries are exhausted', async () => {
191
+ const wait = vi.fn(async () => {})
192
+ const rateLimitError = {
193
+ response: {
194
+ status: 429,
195
+ headers: {},
196
+ },
197
+ }
198
+
199
+ const queryChannels = vi.fn().mockRejectedValue(rateLimitError)
200
+
201
+ const manager = createQueryChannelsManager({
202
+ client: { queryChannels } as never,
203
+ wait,
204
+ maxRetries: 1,
205
+ })
206
+
207
+ await expect(
208
+ manager.query({
209
+ requestKey: 'direct:viewer:participant',
210
+ filters: {
211
+ type: 'messaging',
212
+ },
213
+ options: { limit: 1 },
214
+ })
215
+ ).rejects.toBe(rateLimitError)
216
+
217
+ expect(wait).toHaveBeenCalledTimes(1)
218
+ expect(queryChannels).toHaveBeenCalledTimes(2)
219
+ })
220
+ })
221
+
222
+ describe('isRateLimitError', () => {
223
+ it('returns true for response status 429', () => {
224
+ expect(
225
+ isRateLimitError({
226
+ response: {
227
+ status: 429,
228
+ },
229
+ })
230
+ ).toBe(true)
231
+ })
232
+
233
+ it('returns true for stream code 9', () => {
234
+ expect(
235
+ isRateLimitError({
236
+ code: 9,
237
+ })
238
+ ).toBe(true)
239
+ })
240
+
241
+ it('returns false for non-rate-limit errors', () => {
242
+ expect(isRateLimitError(new Error('oops'))).toBe(false)
243
+ })
244
+ })
@@ -0,0 +1,174 @@
1
+ import type { StreamChat } from 'stream-chat'
2
+
3
+ type QueryChannelsFilters = Parameters<StreamChat['queryChannels']>[0]
4
+ type QueryChannelsSort = Parameters<StreamChat['queryChannels']>[1]
5
+ type QueryChannelsOptions = Parameters<StreamChat['queryChannels']>[2]
6
+ type QueryChannelsResult = Awaited<ReturnType<StreamChat['queryChannels']>>
7
+
8
+ const DEFAULT_MAX_RETRIES = 2
9
+ const DEFAULT_BASE_DELAY_MS = 300
10
+ const MAX_DELAY_MS = 10_000
11
+
12
+ const sleep = async (ms: number): Promise<void> =>
13
+ await new Promise((resolve) => setTimeout(resolve, ms))
14
+
15
+ type HeaderValue = string | number | string[] | undefined
16
+
17
+ const getHeaderValue = (
18
+ headers: Record<string, unknown> | undefined,
19
+ headerName: string
20
+ ): HeaderValue => {
21
+ if (!headers) return undefined
22
+
23
+ const match = Object.entries(headers).find(
24
+ ([key]) => key.toLowerCase() === headerName.toLowerCase()
25
+ )
26
+ return match ? (match[1] as HeaderValue) : undefined
27
+ }
28
+
29
+ const toNumber = (value: unknown): number | null => {
30
+ const parsed = Number(value)
31
+ return Number.isFinite(parsed) ? parsed : null
32
+ }
33
+
34
+ const getErrorResponse = (
35
+ error: unknown
36
+ ): { status?: number; headers?: Record<string, unknown> } | null => {
37
+ if (!error || typeof error !== 'object') return null
38
+
39
+ const maybeResponse = (error as { response?: unknown }).response
40
+ if (!maybeResponse || typeof maybeResponse !== 'object') return null
41
+
42
+ return maybeResponse as { status?: number; headers?: Record<string, unknown> }
43
+ }
44
+
45
+ export const isRateLimitError = (error: unknown): boolean => {
46
+ const response = getErrorResponse(error)
47
+ if (response?.status === 429) return true
48
+
49
+ if (error && typeof error === 'object') {
50
+ const code = toNumber((error as { code?: unknown }).code)
51
+ if (code === 9) return true
52
+
53
+ const status = toNumber((error as { status?: unknown }).status)
54
+ if (status === 429) return true
55
+
56
+ const message = (error as { message?: unknown }).message
57
+ if (typeof message === 'string') {
58
+ const normalizedMessage = message.toLowerCase()
59
+ if (
60
+ normalizedMessage.includes('too many requests') ||
61
+ normalizedMessage.includes('rate limit')
62
+ ) {
63
+ return true
64
+ }
65
+ }
66
+ }
67
+
68
+ return false
69
+ }
70
+
71
+ export const getRetryDelayMsFromRateLimitError = (
72
+ error: unknown,
73
+ attempt: number,
74
+ nowMs = Date.now()
75
+ ): number => {
76
+ const headers = getErrorResponse(error)?.headers
77
+
78
+ const retryAfterHeader = getHeaderValue(headers, 'retry-after')
79
+ const retryAfterSeconds = toNumber(
80
+ Array.isArray(retryAfterHeader) ? retryAfterHeader[0] : retryAfterHeader
81
+ )
82
+ if (retryAfterSeconds !== null && retryAfterSeconds >= 0) {
83
+ return Math.min(Math.round(retryAfterSeconds * 1000), MAX_DELAY_MS)
84
+ }
85
+
86
+ const resetHeader = getHeaderValue(headers, 'x-ratelimit-reset')
87
+ const resetEpochSeconds = toNumber(
88
+ Array.isArray(resetHeader) ? resetHeader[0] : resetHeader
89
+ )
90
+ if (resetEpochSeconds !== null && resetEpochSeconds > 0) {
91
+ const delayMs = Math.round(resetEpochSeconds * 1000 - nowMs)
92
+ return Math.min(Math.max(delayMs, 0), MAX_DELAY_MS)
93
+ }
94
+
95
+ const exponentialBackoffMs = DEFAULT_BASE_DELAY_MS * 2 ** attempt
96
+ return Math.min(exponentialBackoffMs, MAX_DELAY_MS)
97
+ }
98
+
99
+ interface QueryRequest {
100
+ requestKey: string
101
+ filters: QueryChannelsFilters
102
+ sort?: QueryChannelsSort
103
+ options?: QueryChannelsOptions
104
+ }
105
+
106
+ interface QueryChannelsManagerConfig {
107
+ client: Pick<StreamChat, 'queryChannels'>
108
+ maxRetries?: number
109
+ wait?: (ms: number) => Promise<void>
110
+ now?: () => number
111
+ onRetry?: (details: {
112
+ requestKey: string
113
+ attempt: number
114
+ delayMs: number
115
+ error: unknown
116
+ }) => void
117
+ }
118
+
119
+ export const createQueryChannelsManager = ({
120
+ client,
121
+ maxRetries = DEFAULT_MAX_RETRIES,
122
+ wait = sleep,
123
+ now = Date.now,
124
+ onRetry,
125
+ }: QueryChannelsManagerConfig) => {
126
+ const inFlightByRequestKey = new Map<string, Promise<QueryChannelsResult>>()
127
+
128
+ const query = async ({
129
+ requestKey,
130
+ filters,
131
+ sort = {} as QueryChannelsSort,
132
+ options = {} as QueryChannelsOptions,
133
+ }: QueryRequest): Promise<QueryChannelsResult> => {
134
+ const inFlightRequest = inFlightByRequestKey.get(requestKey)
135
+ if (inFlightRequest) {
136
+ return await inFlightRequest
137
+ }
138
+
139
+ const requestPromise = (async () => {
140
+ for (let attempt = 0; ; attempt += 1) {
141
+ try {
142
+ return await client.queryChannels(filters, sort, options)
143
+ } catch (error) {
144
+ if (!isRateLimitError(error) || attempt >= maxRetries) {
145
+ throw error
146
+ }
147
+
148
+ const delayMs = getRetryDelayMsFromRateLimitError(
149
+ error,
150
+ attempt,
151
+ now()
152
+ )
153
+ onRetry?.({
154
+ requestKey,
155
+ attempt: attempt + 1,
156
+ delayMs,
157
+ error,
158
+ })
159
+ await wait(delayMs)
160
+ }
161
+ }
162
+ })()
163
+
164
+ inFlightByRequestKey.set(requestKey, requestPromise)
165
+
166
+ try {
167
+ return await requestPromise
168
+ } finally {
169
+ inFlightByRequestKey.delete(requestKey)
170
+ }
171
+ }
172
+
173
+ return { query }
174
+ }