@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/dist/index.d.ts +19 -1
- package/dist/index.js +976 -1068
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/components/ChannelList/ChannelListContext.tsx +23 -0
- package/src/components/ChannelList/CustomChannelPreview.tsx +6 -19
- package/src/components/ChannelList/index.tsx +23 -22
- package/src/components/ChannelView.stories.tsx +53 -1
- package/src/components/ChannelView.tsx +23 -3
- package/src/components/MessagingShell/index.tsx +68 -102
- package/src/types.ts +22 -0
- package/src/components/MessagingShell/queryChannelsManager.test.ts +0 -244
- package/src/components/MessagingShell/queryChannelsManager.ts +0 -174
|
@@ -1,244 +0,0 @@
|
|
|
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
|
-
})
|
|
@@ -1,174 +0,0 @@
|
|
|
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
|
-
}
|