@planningcenter/chat-react-native 4.0.1-qa-staging.2 → 4.0.1-rc.1
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/build/components/conversation/message.d.ts +2 -3
- package/build/components/conversation/message.d.ts.map +1 -1
- package/build/components/conversation/message.js +22 -23
- package/build/components/conversation/message.js.map +1 -1
- package/build/components/conversation/message_read_receipts.d.ts +4 -2
- package/build/components/conversation/message_read_receipts.d.ts.map +1 -1
- package/build/components/conversation/message_read_receipts.js +3 -2
- package/build/components/conversation/message_read_receipts.js.map +1 -1
- package/build/components/conversation/reply_shadow_message.d.ts +1 -2
- package/build/components/conversation/reply_shadow_message.d.ts.map +1 -1
- package/build/components/conversation/reply_shadow_message.js +46 -47
- package/build/components/conversation/reply_shadow_message.js.map +1 -1
- package/build/hooks/use_conversation_messages.d.ts.map +1 -1
- package/build/hooks/use_conversation_messages.js +28 -40
- package/build/hooks/use_conversation_messages.js.map +1 -1
- package/build/hooks/use_conversation_messages_jolt_events.d.ts.map +1 -1
- package/build/hooks/use_conversation_messages_jolt_events.js +1 -6
- package/build/hooks/use_conversation_messages_jolt_events.js.map +1 -1
- package/build/hooks/use_message_create_or_update.d.ts +4 -99
- package/build/hooks/use_message_create_or_update.d.ts.map +1 -1
- package/build/hooks/use_message_create_or_update.js +6 -47
- package/build/hooks/use_message_create_or_update.js.map +1 -1
- package/build/screens/conversation_new/components/groups_form.js +80 -71
- package/build/screens/conversation_new/components/groups_form.js.map +1 -1
- package/build/screens/conversation_screen.d.ts.map +1 -1
- package/build/screens/conversation_screen.js +365 -184
- package/build/screens/conversation_screen.js.map +1 -1
- package/build/types/resources/message.d.ts +0 -2
- package/build/types/resources/message.d.ts.map +1 -1
- package/build/types/resources/message.js.map +1 -1
- package/build/utils/cache/messages_cache.d.ts +0 -1
- package/build/utils/cache/messages_cache.d.ts.map +1 -1
- package/build/utils/cache/messages_cache.js +7 -14
- package/build/utils/cache/messages_cache.js.map +1 -1
- package/build/utils/cache/optimistically_create_message.d.ts +1 -2
- package/build/utils/cache/optimistically_create_message.d.ts.map +1 -1
- package/build/utils/cache/optimistically_create_message.js +2 -4
- package/build/utils/cache/optimistically_create_message.js.map +1 -1
- package/build/utils/client/transform_response.d.ts.map +1 -1
- package/build/utils/client/transform_response.js +5 -9
- package/build/utils/client/transform_response.js.map +1 -1
- package/build/utils/group_messages.d.ts +0 -1
- package/build/utils/group_messages.d.ts.map +1 -1
- package/build/utils/group_messages.js +1 -10
- package/build/utils/group_messages.js.map +1 -1
- package/build/utils/jolt/transform_message_event_data_to_message_resource.d.ts.map +1 -1
- package/build/utils/jolt/transform_message_event_data_to_message_resource.js +0 -1
- package/build/utils/jolt/transform_message_event_data_to_message_resource.js.map +1 -1
- package/build/utils/request/messages_data_options.d.ts.map +1 -1
- package/build/utils/request/messages_data_options.js +2 -2
- package/build/utils/request/messages_data_options.js.map +1 -1
- package/package.json +3 -3
- package/src/components/conversation/message.tsx +18 -15
- package/src/components/conversation/message_read_receipts.tsx +5 -3
- package/src/components/conversation/reply_shadow_message.tsx +7 -9
- package/src/hooks/use_conversation_messages.ts +1 -5
- package/src/hooks/use_conversation_messages_jolt_events.ts +0 -7
- package/src/hooks/use_message_create_or_update.ts +16 -29
- package/src/screens/conversation_new/components/groups_form.tsx +7 -2
- package/src/screens/conversation_screen.tsx +104 -11
- package/src/types/resources/message.ts +0 -2
- package/src/utils/__tests__/group_messages.test.ts +0 -48
- package/src/utils/cache/messages_cache.ts +11 -27
- package/src/utils/cache/optimistically_create_message.ts +0 -3
- package/src/utils/client/transform_response.ts +5 -7
- package/src/utils/group_messages.ts +1 -18
- package/src/utils/jolt/transform_message_event_data_to_message_resource.ts +0 -1
- package/src/utils/request/messages_data_options.ts +1 -2
- package/build/components/conversation/message_list.d.ts +0 -21
- package/build/components/conversation/message_list.d.ts.map +0 -1
- package/build/components/conversation/message_list.js +0 -273
- package/build/components/conversation/message_list.js.map +0 -1
- package/build/utils/replace_equal_by_id.d.ts +0 -6
- package/build/utils/replace_equal_by_id.d.ts.map +0 -1
- package/build/utils/replace_equal_by_id.js +0 -41
- package/build/utils/replace_equal_by_id.js.map +0 -1
- package/src/components/conversation/__tests__/message_list.test.tsx +0 -271
- package/src/components/conversation/__tests__/reply_shadow_message.test.tsx +0 -119
- package/src/components/conversation/message_list.tsx +0 -157
- package/src/hooks/__tests__/use_message_create_or_update.test.tsx +0 -110
- package/src/utils/__tests__/replace_equal_by_id.test.ts +0 -73
- package/src/utils/cache/__tests__/messages_cache.test.ts +0 -89
- package/src/utils/cache/__tests__/messages_cache_reply_roots.test.ts +0 -70
- package/src/utils/client/__tests__/transform_response.test.ts +0 -62
- package/src/utils/replace_equal_by_id.ts +0 -42
|
@@ -1,271 +0,0 @@
|
|
|
1
|
-
import { DefaultTheme, ThemeProvider } from '@react-navigation/native'
|
|
2
|
-
import {
|
|
3
|
-
InfiniteData,
|
|
4
|
-
notifyManager,
|
|
5
|
-
QueryClient,
|
|
6
|
-
QueryClientProvider,
|
|
7
|
-
} from '@tanstack/react-query'
|
|
8
|
-
import { act, render, screen } from '@testing-library/react-native'
|
|
9
|
-
import React from 'react'
|
|
10
|
-
import { FlatList } from 'react-native'
|
|
11
|
-
import { buildTestQueryClient } from '../../../__utils__/query_client'
|
|
12
|
-
import { ChatProvider } from '../../../contexts/chat_context'
|
|
13
|
-
import { ApiClient, useApiClient } from '../../../hooks/use_api_client'
|
|
14
|
-
import { useConversationMessages } from '../../../hooks/use_conversation_messages'
|
|
15
|
-
import { currentPersonQueryKey } from '../../../hooks/use_current_person'
|
|
16
|
-
import { useLiveRelativeTime } from '../../../hooks/use_live_relative_time'
|
|
17
|
-
import type { ApiCollection } from '../../../types'
|
|
18
|
-
import type { JoltReactionEvent } from '../../../types/jolt_events'
|
|
19
|
-
import type { ConversationResource } from '../../../types/resources/conversation'
|
|
20
|
-
import type { MessageResource } from '../../../types/resources/message'
|
|
21
|
-
import type { PersonResource } from '../../../types/resources/person'
|
|
22
|
-
import {
|
|
23
|
-
updateCacheWithMessage,
|
|
24
|
-
updateCacheWithReaction,
|
|
25
|
-
} from '../../../utils/cache/messages_cache'
|
|
26
|
-
import { addRecordInPagesData, deleteRecordInPagesData } from '../../../utils/cache/page_mutations'
|
|
27
|
-
import { getMessagesQueryKey } from '../../../utils/request/get_messages'
|
|
28
|
-
import { MessageList } from '../message_list'
|
|
29
|
-
|
|
30
|
-
jest.mock('../../../hooks/use_api_client')
|
|
31
|
-
jest.mock('@react-navigation/native', () => ({
|
|
32
|
-
...jest.requireActual('@react-navigation/native'),
|
|
33
|
-
useNavigation: () => ({ navigate: jest.fn() }),
|
|
34
|
-
}))
|
|
35
|
-
// Every row calls this hook on each render, so its calls count row renders.
|
|
36
|
-
jest.mock('../../../hooks/use_live_relative_time', () => {
|
|
37
|
-
const actual = jest.requireActual('../../../hooks/use_live_relative_time')
|
|
38
|
-
return { useLiveRelativeTime: jest.fn(actual.useLiveRelativeTime) }
|
|
39
|
-
})
|
|
40
|
-
|
|
41
|
-
const CONVERSATION_ID = 1
|
|
42
|
-
const ME = 99
|
|
43
|
-
const OTHER = 7
|
|
44
|
-
const messagesQueryKey = getMessagesQueryKey({ conversation_id: CONVERSATION_ID })
|
|
45
|
-
const renderSpy = useLiveRelativeTime as jest.MockedFunction<typeof useLiveRelativeTime>
|
|
46
|
-
type MessagesQueryData = InfiniteData<ApiCollection<MessageResource>>
|
|
47
|
-
|
|
48
|
-
const author = { id: OTHER, type: 'Person', name: 'Ann', avatar: null } as unknown as PersonResource
|
|
49
|
-
const me = { id: ME, type: 'Person', name: 'Me', avatar: null } as unknown as PersonResource
|
|
50
|
-
|
|
51
|
-
const messageId = (n: number) => `01J${String(n).padStart(23, '0')}`
|
|
52
|
-
|
|
53
|
-
const idByCreatedAt = new Map<string, string>()
|
|
54
|
-
|
|
55
|
-
// Dated well in the past so useLiveRelativeTime schedules no timers.
|
|
56
|
-
const buildMessage = (n: number, overrides: Partial<MessageResource> = {}): MessageResource => {
|
|
57
|
-
const message: MessageResource = {
|
|
58
|
-
type: 'Message',
|
|
59
|
-
id: messageId(n),
|
|
60
|
-
text: `message ${n}`,
|
|
61
|
-
html: '',
|
|
62
|
-
createdAt: new Date(Date.UTC(2026, 0, 1, 0, n)).toISOString(),
|
|
63
|
-
deletedAt: null,
|
|
64
|
-
textEditedAt: null,
|
|
65
|
-
mine: false,
|
|
66
|
-
attachments: [],
|
|
67
|
-
author,
|
|
68
|
-
reactionCounts: [],
|
|
69
|
-
replyCount: 0,
|
|
70
|
-
replyRootId: null,
|
|
71
|
-
messageType: 'message',
|
|
72
|
-
...overrides,
|
|
73
|
-
}
|
|
74
|
-
idByCreatedAt.set(message.createdAt, message.id)
|
|
75
|
-
return message
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
const messages = Array.from({ length: 8 }, (_, i) => buildMessage(i + 1))
|
|
79
|
-
|
|
80
|
-
const conversation = {
|
|
81
|
-
id: CONVERSATION_ID,
|
|
82
|
-
memberAbility: { canDeleteNonAuthoredMessages: false },
|
|
83
|
-
} as unknown as ConversationResource
|
|
84
|
-
|
|
85
|
-
const reactionEvent = (message: MessageResource): JoltReactionEvent =>
|
|
86
|
-
({
|
|
87
|
-
event: 'reaction.created',
|
|
88
|
-
data: {
|
|
89
|
-
data: {
|
|
90
|
-
author_id: OTHER,
|
|
91
|
-
conversation_id: CONVERSATION_ID,
|
|
92
|
-
message_sort_key: message.id,
|
|
93
|
-
message_author_id: OTHER,
|
|
94
|
-
reply_root_id: null,
|
|
95
|
-
created_at: '2026-01-02T00:00:00Z',
|
|
96
|
-
organization_id: 1,
|
|
97
|
-
value: 'heart',
|
|
98
|
-
count: 1,
|
|
99
|
-
},
|
|
100
|
-
},
|
|
101
|
-
}) as unknown as JoltReactionEvent
|
|
102
|
-
|
|
103
|
-
let queryClient: QueryClient
|
|
104
|
-
|
|
105
|
-
// A top-level rerender would rebuild the wrapper context and redraw every row.
|
|
106
|
-
let setHarnessConversation: (next: ConversationResource) => void = () => {}
|
|
107
|
-
|
|
108
|
-
function Harness() {
|
|
109
|
-
const [current, setCurrent] = React.useState(conversation)
|
|
110
|
-
React.useEffect(() => {
|
|
111
|
-
setHarnessConversation = setCurrent
|
|
112
|
-
}, [])
|
|
113
|
-
const { messages: loaded } = useConversationMessages({ conversation_id: CONVERSATION_ID })
|
|
114
|
-
return (
|
|
115
|
-
<MessageList
|
|
116
|
-
listRef={React.createRef<FlatList>()}
|
|
117
|
-
conversation={current}
|
|
118
|
-
conversationId={CONVERSATION_ID}
|
|
119
|
-
messages={loaded}
|
|
120
|
-
hasNextPage
|
|
121
|
-
onEndReached={() => {}}
|
|
122
|
-
/>
|
|
123
|
-
)
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
const Wrapper = ({ children }: { children: React.ReactNode }) => (
|
|
127
|
-
<QueryClientProvider client={queryClient}>
|
|
128
|
-
<ChatProvider
|
|
129
|
-
value={{
|
|
130
|
-
token: {
|
|
131
|
-
access_token: 'token',
|
|
132
|
-
created_at: 0,
|
|
133
|
-
expires_in: 0,
|
|
134
|
-
scope: '',
|
|
135
|
-
refresh_token: '',
|
|
136
|
-
token_type: '',
|
|
137
|
-
},
|
|
138
|
-
onUnauthorizedResponse: () => {},
|
|
139
|
-
theme: {},
|
|
140
|
-
}}
|
|
141
|
-
>
|
|
142
|
-
<ThemeProvider value={DefaultTheme}>
|
|
143
|
-
<React.Suspense fallback={null}>{children}</React.Suspense>
|
|
144
|
-
</ThemeProvider>
|
|
145
|
-
</ChatProvider>
|
|
146
|
-
</QueryClientProvider>
|
|
147
|
-
)
|
|
148
|
-
|
|
149
|
-
const renderedRowIds = () => {
|
|
150
|
-
const ids = renderSpy.mock.calls.map(([date]) => idByCreatedAt.get(date as string) ?? date)
|
|
151
|
-
return Array.from(new Set(ids)).sort()
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
const renderList = (seed: MessageResource[] = messages) => {
|
|
155
|
-
queryClient.setQueryData<MessagesQueryData>(messagesQueryKey, {
|
|
156
|
-
pages: [{ data: seed, links: {}, meta: { count: seed.length, totalCount: seed.length } }],
|
|
157
|
-
pageParams: [{}],
|
|
158
|
-
})
|
|
159
|
-
render(<Harness />, { wrapper: Wrapper })
|
|
160
|
-
seed.forEach(m => screen.getByTestId(`message-${m.id}`))
|
|
161
|
-
renderSpy.mockClear()
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
// TanStack notifies observers on a setTimeout; deliver inline so one write is one act().
|
|
165
|
-
beforeAll(() => notifyManager.setScheduler(callback => callback()))
|
|
166
|
-
afterAll(() => notifyManager.setScheduler(callback => setTimeout(callback, 0)))
|
|
167
|
-
|
|
168
|
-
beforeEach(() => {
|
|
169
|
-
queryClient = buildTestQueryClient({ defaultOptions: { queries: { staleTime: Infinity } } })
|
|
170
|
-
queryClient.setQueryData(currentPersonQueryKey, { data: me, links: {}, meta: {} })
|
|
171
|
-
;(useApiClient as jest.MockedFunction<typeof useApiClient>).mockReturnValue({
|
|
172
|
-
chat: { get: jest.fn().mockRejectedValue(new Error('unexpected fetch')) },
|
|
173
|
-
} as unknown as ApiClient)
|
|
174
|
-
})
|
|
175
|
-
|
|
176
|
-
afterEach(() => {
|
|
177
|
-
queryClient.clear()
|
|
178
|
-
jest.clearAllMocks()
|
|
179
|
-
})
|
|
180
|
-
|
|
181
|
-
describe('MessageList', () => {
|
|
182
|
-
it('redraws only the reacted row when a reaction arrives', () => {
|
|
183
|
-
renderList()
|
|
184
|
-
const target = messages[3]
|
|
185
|
-
|
|
186
|
-
act(() => updateCacheWithReaction(queryClient, messagesQueryKey, reactionEvent(target), ME))
|
|
187
|
-
|
|
188
|
-
expect(screen.getByTestId(`reaction-${target.id}-heart`)).toBeTruthy()
|
|
189
|
-
expect(renderedRowIds()).toEqual([target.id])
|
|
190
|
-
})
|
|
191
|
-
|
|
192
|
-
it('redraws only the edited row and shows the new text', () => {
|
|
193
|
-
renderList()
|
|
194
|
-
const target = messages[5]
|
|
195
|
-
|
|
196
|
-
act(() =>
|
|
197
|
-
updateCacheWithMessage(
|
|
198
|
-
queryClient,
|
|
199
|
-
messagesQueryKey,
|
|
200
|
-
{ ...target, text: 'edited text', textEditedAt: '2026-01-02T00:00:00Z' },
|
|
201
|
-
'message.updated'
|
|
202
|
-
)
|
|
203
|
-
)
|
|
204
|
-
|
|
205
|
-
expect(screen.getByText('edited text')).toBeTruthy()
|
|
206
|
-
expect(renderedRowIds()).toEqual([target.id])
|
|
207
|
-
})
|
|
208
|
-
|
|
209
|
-
it('draws a new message plus the neighbor whose grouping changed', () => {
|
|
210
|
-
renderList()
|
|
211
|
-
const incoming = buildMessage(9)
|
|
212
|
-
const previousNewest = messages[7]
|
|
213
|
-
|
|
214
|
-
act(() => updateCacheWithMessage(queryClient, messagesQueryKey, incoming, 'message.created'))
|
|
215
|
-
|
|
216
|
-
expect(screen.getByTestId(`message-${incoming.id}`)).toBeTruthy()
|
|
217
|
-
expect(renderedRowIds()).toEqual([previousNewest.id, incoming.id].sort())
|
|
218
|
-
})
|
|
219
|
-
|
|
220
|
-
it('swaps a pending message for its echo without touching other rows', () => {
|
|
221
|
-
renderList()
|
|
222
|
-
const pending = buildMessage(50, {
|
|
223
|
-
id: `${messageId(50)}-temp`,
|
|
224
|
-
text: 'hello there',
|
|
225
|
-
mine: true,
|
|
226
|
-
author: me,
|
|
227
|
-
pending: true,
|
|
228
|
-
})
|
|
229
|
-
|
|
230
|
-
act(() =>
|
|
231
|
-
queryClient.setQueryData<MessagesQueryData>(messagesQueryKey, prev =>
|
|
232
|
-
addRecordInPagesData({ data: prev, record: pending })
|
|
233
|
-
)
|
|
234
|
-
)
|
|
235
|
-
expect(screen.getByTestId(`message-${pending.id}`)).toBeTruthy()
|
|
236
|
-
renderSpy.mockClear()
|
|
237
|
-
|
|
238
|
-
const echo = buildMessage(50, { text: 'hello there', mine: true, author: me })
|
|
239
|
-
act(() => updateCacheWithMessage(queryClient, messagesQueryKey, echo, 'message.created'))
|
|
240
|
-
|
|
241
|
-
expect(screen.queryByTestId(`message-${pending.id}`)).toBeNull()
|
|
242
|
-
expect(screen.getByTestId(`message-${echo.id}`)).toBeTruthy()
|
|
243
|
-
expect(renderedRowIds()).toEqual([echo.id])
|
|
244
|
-
})
|
|
245
|
-
|
|
246
|
-
it('redraws only my messages whose read state changed when someone reads', () => {
|
|
247
|
-
const mine = (n: number) => buildMessage(n, { mine: true, author: me })
|
|
248
|
-
const seed = [mine(1), buildMessage(2), mine(3), buildMessage(4), mine(5), buildMessage(6)]
|
|
249
|
-
renderList(seed)
|
|
250
|
-
expect(screen.getByText('Sent')).toBeTruthy()
|
|
251
|
-
|
|
252
|
-
act(() => setHarnessConversation({ ...conversation, latestReadMessageSortKey: messageId(6) }))
|
|
253
|
-
|
|
254
|
-
expect(screen.getByText('Read')).toBeTruthy()
|
|
255
|
-
expect(renderedRowIds()).toEqual([messageId(1), messageId(3), messageId(5)].sort())
|
|
256
|
-
})
|
|
257
|
-
|
|
258
|
-
it('removes a deleted row and leaves the others alone', () => {
|
|
259
|
-
renderList()
|
|
260
|
-
const target = messages[2]
|
|
261
|
-
|
|
262
|
-
act(() =>
|
|
263
|
-
queryClient.setQueryData<MessagesQueryData>(messagesQueryKey, prev =>
|
|
264
|
-
deleteRecordInPagesData({ data: prev, record: target })
|
|
265
|
-
)
|
|
266
|
-
)
|
|
267
|
-
|
|
268
|
-
expect(screen.queryByTestId(`message-${target.id}`)).toBeNull()
|
|
269
|
-
expect(renderedRowIds()).toEqual([])
|
|
270
|
-
})
|
|
271
|
-
})
|
|
@@ -1,119 +0,0 @@
|
|
|
1
|
-
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
|
2
|
-
import { render } from '@testing-library/react-native'
|
|
3
|
-
import React from 'react'
|
|
4
|
-
import { buildTestQueryClient } from '../../../__utils__/query_client'
|
|
5
|
-
import { ChatProvider } from '../../../contexts/chat_context'
|
|
6
|
-
import { useConversationMessage } from '../../../hooks/use_conversation_message'
|
|
7
|
-
import type { MessageResource } from '../../../types/resources/message'
|
|
8
|
-
import type { PersonResource } from '../../../types/resources/person'
|
|
9
|
-
import { ReplyShadowMessage } from '../reply_shadow_message'
|
|
10
|
-
|
|
11
|
-
jest.mock('../../../hooks/use_conversation_message')
|
|
12
|
-
jest.mock('@react-navigation/native', () => ({
|
|
13
|
-
useNavigation: () => ({ navigate: jest.fn() }),
|
|
14
|
-
}))
|
|
15
|
-
|
|
16
|
-
const mockedUseConversationMessage = useConversationMessage as jest.MockedFunction<
|
|
17
|
-
typeof useConversationMessage
|
|
18
|
-
>
|
|
19
|
-
|
|
20
|
-
const author = { id: 1, type: 'Person', name: 'A', avatar: null } as unknown as PersonResource
|
|
21
|
-
|
|
22
|
-
const root = (text: string): MessageResource =>
|
|
23
|
-
({
|
|
24
|
-
type: 'Message',
|
|
25
|
-
id: 'rootA',
|
|
26
|
-
text,
|
|
27
|
-
html: '',
|
|
28
|
-
createdAt: '2026-01-01T00:00:00Z',
|
|
29
|
-
deletedAt: null,
|
|
30
|
-
textEditedAt: null,
|
|
31
|
-
mine: false,
|
|
32
|
-
attachments: [],
|
|
33
|
-
author,
|
|
34
|
-
reactionCounts: [],
|
|
35
|
-
replyCount: 1,
|
|
36
|
-
replyRootId: 'rootA',
|
|
37
|
-
messageType: 'message',
|
|
38
|
-
}) as MessageResource
|
|
39
|
-
|
|
40
|
-
let queryClient: QueryClient
|
|
41
|
-
|
|
42
|
-
const hookResult = (overrides: { message?: MessageResource; isError?: boolean }) =>
|
|
43
|
-
({ message: undefined, isError: false, isLoading: false, ...overrides }) as ReturnType<
|
|
44
|
-
typeof useConversationMessage
|
|
45
|
-
>
|
|
46
|
-
|
|
47
|
-
const Wrapper = ({ children }: { children: React.ReactNode }) => (
|
|
48
|
-
<QueryClientProvider client={queryClient}>
|
|
49
|
-
<ChatProvider
|
|
50
|
-
value={{
|
|
51
|
-
token: {
|
|
52
|
-
access_token: '',
|
|
53
|
-
created_at: 0,
|
|
54
|
-
expires_in: 0,
|
|
55
|
-
scope: '',
|
|
56
|
-
refresh_token: '',
|
|
57
|
-
token_type: '',
|
|
58
|
-
},
|
|
59
|
-
onUnauthorizedResponse: () => {},
|
|
60
|
-
theme: { theme: { colors: { interaction: 'rgb(0, 0, 255)' } } },
|
|
61
|
-
}}
|
|
62
|
-
>
|
|
63
|
-
{children}
|
|
64
|
-
</ChatProvider>
|
|
65
|
-
</QueryClientProvider>
|
|
66
|
-
)
|
|
67
|
-
|
|
68
|
-
const renderShadow = (replyRoot: MessageResource | null) =>
|
|
69
|
-
render(
|
|
70
|
-
<ReplyShadowMessage
|
|
71
|
-
{...({} as MessageResource)}
|
|
72
|
-
conversation_id={1}
|
|
73
|
-
messageId="rootA"
|
|
74
|
-
replyRoot={replyRoot}
|
|
75
|
-
isReplyShadowMessage
|
|
76
|
-
nextRendersAuthor={false}
|
|
77
|
-
/>,
|
|
78
|
-
{ wrapper: Wrapper }
|
|
79
|
-
)
|
|
80
|
-
|
|
81
|
-
beforeEach(() => {
|
|
82
|
-
queryClient = buildTestQueryClient()
|
|
83
|
-
mockedUseConversationMessage.mockReturnValue(hookResult({}))
|
|
84
|
-
})
|
|
85
|
-
|
|
86
|
-
afterEach(() => {
|
|
87
|
-
queryClient.clear()
|
|
88
|
-
jest.clearAllMocks()
|
|
89
|
-
})
|
|
90
|
-
|
|
91
|
-
describe('ReplyShadowMessage', () => {
|
|
92
|
-
it('renders the inline root without enabling the root fetch', () => {
|
|
93
|
-
const view = renderShadow(root('inline root'))
|
|
94
|
-
|
|
95
|
-
expect(view.getByText('inline root')).toBeTruthy()
|
|
96
|
-
expect(mockedUseConversationMessage).toHaveBeenCalledWith(
|
|
97
|
-
expect.objectContaining({ enabled: false })
|
|
98
|
-
)
|
|
99
|
-
})
|
|
100
|
-
|
|
101
|
-
it('falls back to fetching the root when none is inline', () => {
|
|
102
|
-
mockedUseConversationMessage.mockReturnValue(hookResult({ message: root('fetched root') }))
|
|
103
|
-
|
|
104
|
-
const view = renderShadow(null)
|
|
105
|
-
|
|
106
|
-
expect(view.getByText('fetched root')).toBeTruthy()
|
|
107
|
-
expect(mockedUseConversationMessage).toHaveBeenCalledWith(
|
|
108
|
-
expect.objectContaining({ messageId: 'rootA', enabled: true })
|
|
109
|
-
)
|
|
110
|
-
})
|
|
111
|
-
|
|
112
|
-
it('shows the deleted placeholder when the fallback fetch finds nothing', () => {
|
|
113
|
-
mockedUseConversationMessage.mockReturnValue(hookResult({ isError: true }))
|
|
114
|
-
|
|
115
|
-
const view = renderShadow(null)
|
|
116
|
-
|
|
117
|
-
expect(view.getByText('Message deleted')).toBeTruthy()
|
|
118
|
-
})
|
|
119
|
-
})
|
|
@@ -1,157 +0,0 @@
|
|
|
1
|
-
import { date as formatDate } from '@planningcenter/datetime-fmt'
|
|
2
|
-
import React, { RefObject } from 'react'
|
|
3
|
-
import { FlatList, FlatListProps, StyleSheet, View } from 'react-native'
|
|
4
|
-
import { useTheme } from '../../hooks'
|
|
5
|
-
import { ConversationResource } from '../../types/resources/conversation'
|
|
6
|
-
import { MessageResource } from '../../types/resources/message'
|
|
7
|
-
import { getRelativeDateStatus } from '../../utils/date'
|
|
8
|
-
import { groupMessages, type DateSeparator, type EnrichedMessage } from '../../utils/group_messages'
|
|
9
|
-
import { CONVERSATION_MESSAGE_LIST_PADDING_HORIZONTAL } from '../../utils/styles'
|
|
10
|
-
import { isSystemMessage } from '../../utils/system_messages'
|
|
11
|
-
import { Text } from '../display'
|
|
12
|
-
import { ConversationStartBanner } from './conversation_start_banner'
|
|
13
|
-
import { Message } from './message'
|
|
14
|
-
import { ReplyShadowMessage } from './reply_shadow_message'
|
|
15
|
-
import { SystemMessage } from './system_message'
|
|
16
|
-
|
|
17
|
-
type ScrollHandlers = Pick<
|
|
18
|
-
FlatListProps<EnrichedMessage>,
|
|
19
|
-
'onScroll' | 'onLayout' | 'onContentSizeChange' | 'onScrollEndDrag' | 'onMomentumScrollEnd'
|
|
20
|
-
>
|
|
21
|
-
|
|
22
|
-
interface MessageListProps extends ScrollHandlers {
|
|
23
|
-
listRef: RefObject<FlatList | null>
|
|
24
|
-
conversation: ConversationResource
|
|
25
|
-
conversationId: number
|
|
26
|
-
messages: MessageResource[]
|
|
27
|
-
replyRootId?: string | null
|
|
28
|
-
hasNextPage: boolean
|
|
29
|
-
onEndReached: () => void
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/** MessageList
|
|
33
|
-
* The inverted list of messages, reply previews, and date separators for a conversation
|
|
34
|
-
*/
|
|
35
|
-
export function MessageList({
|
|
36
|
-
listRef,
|
|
37
|
-
conversation,
|
|
38
|
-
conversationId,
|
|
39
|
-
messages,
|
|
40
|
-
replyRootId,
|
|
41
|
-
hasNextPage,
|
|
42
|
-
onEndReached,
|
|
43
|
-
onScroll,
|
|
44
|
-
onLayout,
|
|
45
|
-
onContentSizeChange,
|
|
46
|
-
onScrollEndDrag,
|
|
47
|
-
onMomentumScrollEnd,
|
|
48
|
-
}: MessageListProps) {
|
|
49
|
-
const styles = useStyles()
|
|
50
|
-
const inReplyScreen = !!replyRootId
|
|
51
|
-
const canDeleteNonAuthoredMessages =
|
|
52
|
-
conversation.memberAbility?.canDeleteNonAuthoredMessages ?? false
|
|
53
|
-
const latestRead = conversation.latestReadMessageSortKey
|
|
54
|
-
const messagesWithSeparators = groupMessages({ ms: messages, inReplyScreen })
|
|
55
|
-
|
|
56
|
-
return (
|
|
57
|
-
<FlatList
|
|
58
|
-
inverted
|
|
59
|
-
ref={listRef}
|
|
60
|
-
contentContainerStyle={styles.listContainer}
|
|
61
|
-
data={messagesWithSeparators}
|
|
62
|
-
keyExtractor={item => item.id}
|
|
63
|
-
onScroll={onScroll}
|
|
64
|
-
onLayout={onLayout}
|
|
65
|
-
onContentSizeChange={onContentSizeChange}
|
|
66
|
-
onScrollEndDrag={onScrollEndDrag}
|
|
67
|
-
onMomentumScrollEnd={onMomentumScrollEnd}
|
|
68
|
-
scrollEventThrottle={10}
|
|
69
|
-
ListFooterComponent={
|
|
70
|
-
hasNextPage ? null : (
|
|
71
|
-
<ConversationStartBanner conversation={conversation} hasMessages={true} />
|
|
72
|
-
)
|
|
73
|
-
}
|
|
74
|
-
renderItem={({ item }) => {
|
|
75
|
-
if (item.type === 'DateSeparator') {
|
|
76
|
-
return <InlineDateSeparator {...item} />
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
if (item.type === 'ReplyShadowMessage') {
|
|
80
|
-
return (
|
|
81
|
-
<ReplyShadowMessage
|
|
82
|
-
{...(item as any)}
|
|
83
|
-
conversation_id={conversationId}
|
|
84
|
-
inReplyScreen={inReplyScreen}
|
|
85
|
-
/>
|
|
86
|
-
)
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
if (isSystemMessage(item)) {
|
|
90
|
-
return <SystemMessage message={item} conversationId={conversationId} />
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
const readByOthers = item.mine && !!latestRead && item.id <= latestRead
|
|
94
|
-
return (
|
|
95
|
-
<Message
|
|
96
|
-
{...item}
|
|
97
|
-
canDeleteNonAuthoredMessages={canDeleteNonAuthoredMessages}
|
|
98
|
-
conversation_id={conversationId}
|
|
99
|
-
readByOthers={readByOthers}
|
|
100
|
-
inReplyScreen={inReplyScreen}
|
|
101
|
-
/>
|
|
102
|
-
)
|
|
103
|
-
}}
|
|
104
|
-
onEndReached={onEndReached}
|
|
105
|
-
ListHeaderComponent={<View style={styles.listHeader} />}
|
|
106
|
-
/>
|
|
107
|
-
)
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
function InlineDateSeparator({ date }: DateSeparator) {
|
|
111
|
-
const styles = useDateSeparatorStyles()
|
|
112
|
-
const { isThisYear } = getRelativeDateStatus(date)
|
|
113
|
-
const showYear = !isThisYear
|
|
114
|
-
const dateStamp = formatDate(date, { style: 'long', year: showYear })
|
|
115
|
-
|
|
116
|
-
return (
|
|
117
|
-
<View style={styles.container}>
|
|
118
|
-
<View style={styles.separator} />
|
|
119
|
-
<Text variant="footnote" style={styles.dateText}>
|
|
120
|
-
{dateStamp}
|
|
121
|
-
</Text>
|
|
122
|
-
<View style={styles.separator} />
|
|
123
|
-
</View>
|
|
124
|
-
)
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
const useDateSeparatorStyles = () => {
|
|
128
|
-
const theme = useTheme()
|
|
129
|
-
return StyleSheet.create({
|
|
130
|
-
container: {
|
|
131
|
-
alignItems: 'center',
|
|
132
|
-
flexDirection: 'row',
|
|
133
|
-
paddingHorizontal: CONVERSATION_MESSAGE_LIST_PADDING_HORIZONTAL,
|
|
134
|
-
paddingVertical: 16,
|
|
135
|
-
},
|
|
136
|
-
separator: {
|
|
137
|
-
flex: 1,
|
|
138
|
-
height: 1,
|
|
139
|
-
borderTopWidth: 1,
|
|
140
|
-
borderTopColor: theme.colors.borderColorDefaultBase,
|
|
141
|
-
},
|
|
142
|
-
dateText: {
|
|
143
|
-
paddingHorizontal: 8,
|
|
144
|
-
},
|
|
145
|
-
})
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
const useStyles = () =>
|
|
149
|
-
StyleSheet.create({
|
|
150
|
-
listContainer: {
|
|
151
|
-
paddingVertical: 12,
|
|
152
|
-
},
|
|
153
|
-
listHeader: {
|
|
154
|
-
// Just whitespace to provide space where the typing indicator can be
|
|
155
|
-
height: 16,
|
|
156
|
-
},
|
|
157
|
-
})
|
|
@@ -1,110 +0,0 @@
|
|
|
1
|
-
import { InfiniteData, QueryClientProvider, notifyManager } from '@tanstack/react-query'
|
|
2
|
-
import { act, renderHook } from '@testing-library/react-native'
|
|
3
|
-
import React from 'react'
|
|
4
|
-
import { chatQueryClient } from '../../contexts/api_provider'
|
|
5
|
-
import type { ApiCollection, MessageResource } from '../../types'
|
|
6
|
-
import { getMessagesQueryKey } from '../../utils/request/get_messages'
|
|
7
|
-
import { useMessageCreateOrUpdate } from '../use_message_create_or_update'
|
|
8
|
-
|
|
9
|
-
jest.mock('../../contexts/api_provider', () => {
|
|
10
|
-
const { QueryClient } = require('@tanstack/react-query')
|
|
11
|
-
// gcTime 0 so nothing outlives the test and keeps jest from exiting
|
|
12
|
-
return {
|
|
13
|
-
chatQueryClient: new QueryClient({
|
|
14
|
-
defaultOptions: { queries: { gcTime: 0 }, mutations: { gcTime: 0 } },
|
|
15
|
-
}),
|
|
16
|
-
}
|
|
17
|
-
})
|
|
18
|
-
|
|
19
|
-
const mockPost = jest.fn()
|
|
20
|
-
jest.mock('../use_api_client', () => ({
|
|
21
|
-
useApiClient: () => ({ chat: { post: mockPost, patch: jest.fn() } }),
|
|
22
|
-
}))
|
|
23
|
-
|
|
24
|
-
jest.mock('../use_current_person', () => ({
|
|
25
|
-
useCurrentPerson: () => ({ type: 'Person', id: 1, name: 'Me', avatar: null }),
|
|
26
|
-
}))
|
|
27
|
-
|
|
28
|
-
// Flush TanStack's batched state updates inside act, as its own tests do
|
|
29
|
-
notifyManager.setNotifyFunction(fn => act(fn))
|
|
30
|
-
|
|
31
|
-
type MessagesQueryData = InfiniteData<ApiCollection<MessageResource>>
|
|
32
|
-
|
|
33
|
-
const conversationId = 7
|
|
34
|
-
const queryKey = getMessagesQueryKey({ conversation_id: conversationId })
|
|
35
|
-
|
|
36
|
-
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
|
37
|
-
<QueryClientProvider client={chatQueryClient}>{children}</QueryClientProvider>
|
|
38
|
-
)
|
|
39
|
-
|
|
40
|
-
const pendingMessagesInCache = () =>
|
|
41
|
-
(chatQueryClient.getQueryData<MessagesQueryData>(queryKey)?.pages[0].data ?? []).filter(
|
|
42
|
-
m => m.pending
|
|
43
|
-
)
|
|
44
|
-
|
|
45
|
-
const sentKey = (call: number): string =>
|
|
46
|
-
mockPost.mock.calls[call][0].data.data.attributes.idempotent_key
|
|
47
|
-
|
|
48
|
-
// What the cache held when each request went out, i.e. after onMutate ran
|
|
49
|
-
let pendingAtSend: MessageResource[][]
|
|
50
|
-
|
|
51
|
-
describe('useMessageCreateOrUpdate idempotent key', () => {
|
|
52
|
-
beforeEach(() => {
|
|
53
|
-
chatQueryClient.clear()
|
|
54
|
-
chatQueryClient.setQueryData<MessagesQueryData>(queryKey, {
|
|
55
|
-
pages: [{ data: [], links: {}, meta: { count: 0, totalCount: 0 } }],
|
|
56
|
-
pageParams: [],
|
|
57
|
-
})
|
|
58
|
-
pendingAtSend = []
|
|
59
|
-
mockPost.mockReset()
|
|
60
|
-
mockPost.mockImplementation(async request => {
|
|
61
|
-
pendingAtSend.push(pendingMessagesInCache())
|
|
62
|
-
const key = request.data.data.attributes.idempotent_key
|
|
63
|
-
return { data: { type: 'Message', id: `real-${key}`, idempotentKey: key, mine: true } }
|
|
64
|
-
})
|
|
65
|
-
})
|
|
66
|
-
|
|
67
|
-
it('gives the optimistic message the same key that is sent to the server', async () => {
|
|
68
|
-
const { result } = renderHook(() => useMessageCreateOrUpdate({ conversationId }), { wrapper })
|
|
69
|
-
|
|
70
|
-
await act(() => result.current.mutateAsync({ text: 'ok' }))
|
|
71
|
-
|
|
72
|
-
expect(sentKey(0)).toEqual(expect.any(String))
|
|
73
|
-
expect(pendingAtSend[0].map(m => m.idempotentKey)).toEqual([sentKey(0)])
|
|
74
|
-
})
|
|
75
|
-
|
|
76
|
-
it('mints a distinct key for each send, even with identical text', async () => {
|
|
77
|
-
const { result } = renderHook(() => useMessageCreateOrUpdate({ conversationId }), { wrapper })
|
|
78
|
-
|
|
79
|
-
await act(() =>
|
|
80
|
-
Promise.all([
|
|
81
|
-
result.current.mutateAsync({ text: 'ok' }),
|
|
82
|
-
result.current.mutateAsync({ text: 'ok' }),
|
|
83
|
-
])
|
|
84
|
-
)
|
|
85
|
-
|
|
86
|
-
expect(sentKey(0)).not.toBe(sentKey(1))
|
|
87
|
-
expect(pendingAtSend[1].map(m => m.idempotentKey).sort()).toEqual(
|
|
88
|
-
[sentKey(0), sentKey(1)].sort()
|
|
89
|
-
)
|
|
90
|
-
})
|
|
91
|
-
|
|
92
|
-
it('reuses the original key when retrying a failed send', async () => {
|
|
93
|
-
const failed = {
|
|
94
|
-
type: 'Message',
|
|
95
|
-
id: '01FAILED-temp',
|
|
96
|
-
text: 'ok',
|
|
97
|
-
idempotentKey: 'original-key',
|
|
98
|
-
error: 'Failed to send message',
|
|
99
|
-
} as MessageResource
|
|
100
|
-
const { result } = renderHook(
|
|
101
|
-
() => useMessageCreateOrUpdate({ conversationId, message: failed }),
|
|
102
|
-
{ wrapper }
|
|
103
|
-
)
|
|
104
|
-
|
|
105
|
-
await act(() => result.current.mutateAsync({ text: 'ok' }))
|
|
106
|
-
|
|
107
|
-
expect(sentKey(0)).toBe('original-key')
|
|
108
|
-
expect(pendingAtSend[0].map(m => m.idempotentKey)).toEqual(['original-key'])
|
|
109
|
-
})
|
|
110
|
-
})
|