@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.
Files changed (85) hide show
  1. package/build/components/conversation/message.d.ts +2 -3
  2. package/build/components/conversation/message.d.ts.map +1 -1
  3. package/build/components/conversation/message.js +22 -23
  4. package/build/components/conversation/message.js.map +1 -1
  5. package/build/components/conversation/message_read_receipts.d.ts +4 -2
  6. package/build/components/conversation/message_read_receipts.d.ts.map +1 -1
  7. package/build/components/conversation/message_read_receipts.js +3 -2
  8. package/build/components/conversation/message_read_receipts.js.map +1 -1
  9. package/build/components/conversation/reply_shadow_message.d.ts +1 -2
  10. package/build/components/conversation/reply_shadow_message.d.ts.map +1 -1
  11. package/build/components/conversation/reply_shadow_message.js +46 -47
  12. package/build/components/conversation/reply_shadow_message.js.map +1 -1
  13. package/build/hooks/use_conversation_messages.d.ts.map +1 -1
  14. package/build/hooks/use_conversation_messages.js +28 -40
  15. package/build/hooks/use_conversation_messages.js.map +1 -1
  16. package/build/hooks/use_conversation_messages_jolt_events.d.ts.map +1 -1
  17. package/build/hooks/use_conversation_messages_jolt_events.js +1 -6
  18. package/build/hooks/use_conversation_messages_jolt_events.js.map +1 -1
  19. package/build/hooks/use_message_create_or_update.d.ts +4 -99
  20. package/build/hooks/use_message_create_or_update.d.ts.map +1 -1
  21. package/build/hooks/use_message_create_or_update.js +6 -47
  22. package/build/hooks/use_message_create_or_update.js.map +1 -1
  23. package/build/screens/conversation_new/components/groups_form.js +80 -71
  24. package/build/screens/conversation_new/components/groups_form.js.map +1 -1
  25. package/build/screens/conversation_screen.d.ts.map +1 -1
  26. package/build/screens/conversation_screen.js +365 -184
  27. package/build/screens/conversation_screen.js.map +1 -1
  28. package/build/types/resources/message.d.ts +0 -2
  29. package/build/types/resources/message.d.ts.map +1 -1
  30. package/build/types/resources/message.js.map +1 -1
  31. package/build/utils/cache/messages_cache.d.ts +0 -1
  32. package/build/utils/cache/messages_cache.d.ts.map +1 -1
  33. package/build/utils/cache/messages_cache.js +7 -14
  34. package/build/utils/cache/messages_cache.js.map +1 -1
  35. package/build/utils/cache/optimistically_create_message.d.ts +1 -2
  36. package/build/utils/cache/optimistically_create_message.d.ts.map +1 -1
  37. package/build/utils/cache/optimistically_create_message.js +2 -4
  38. package/build/utils/cache/optimistically_create_message.js.map +1 -1
  39. package/build/utils/client/transform_response.d.ts.map +1 -1
  40. package/build/utils/client/transform_response.js +5 -9
  41. package/build/utils/client/transform_response.js.map +1 -1
  42. package/build/utils/group_messages.d.ts +0 -1
  43. package/build/utils/group_messages.d.ts.map +1 -1
  44. package/build/utils/group_messages.js +1 -10
  45. package/build/utils/group_messages.js.map +1 -1
  46. package/build/utils/jolt/transform_message_event_data_to_message_resource.d.ts.map +1 -1
  47. package/build/utils/jolt/transform_message_event_data_to_message_resource.js +0 -1
  48. package/build/utils/jolt/transform_message_event_data_to_message_resource.js.map +1 -1
  49. package/build/utils/request/messages_data_options.d.ts.map +1 -1
  50. package/build/utils/request/messages_data_options.js +2 -2
  51. package/build/utils/request/messages_data_options.js.map +1 -1
  52. package/package.json +3 -3
  53. package/src/components/conversation/message.tsx +18 -15
  54. package/src/components/conversation/message_read_receipts.tsx +5 -3
  55. package/src/components/conversation/reply_shadow_message.tsx +7 -9
  56. package/src/hooks/use_conversation_messages.ts +1 -5
  57. package/src/hooks/use_conversation_messages_jolt_events.ts +0 -7
  58. package/src/hooks/use_message_create_or_update.ts +16 -29
  59. package/src/screens/conversation_new/components/groups_form.tsx +7 -2
  60. package/src/screens/conversation_screen.tsx +104 -11
  61. package/src/types/resources/message.ts +0 -2
  62. package/src/utils/__tests__/group_messages.test.ts +0 -48
  63. package/src/utils/cache/messages_cache.ts +11 -27
  64. package/src/utils/cache/optimistically_create_message.ts +0 -3
  65. package/src/utils/client/transform_response.ts +5 -7
  66. package/src/utils/group_messages.ts +1 -18
  67. package/src/utils/jolt/transform_message_event_data_to_message_resource.ts +0 -1
  68. package/src/utils/request/messages_data_options.ts +1 -2
  69. package/build/components/conversation/message_list.d.ts +0 -21
  70. package/build/components/conversation/message_list.d.ts.map +0 -1
  71. package/build/components/conversation/message_list.js +0 -273
  72. package/build/components/conversation/message_list.js.map +0 -1
  73. package/build/utils/replace_equal_by_id.d.ts +0 -6
  74. package/build/utils/replace_equal_by_id.d.ts.map +0 -1
  75. package/build/utils/replace_equal_by_id.js +0 -41
  76. package/build/utils/replace_equal_by_id.js.map +0 -1
  77. package/src/components/conversation/__tests__/message_list.test.tsx +0 -271
  78. package/src/components/conversation/__tests__/reply_shadow_message.test.tsx +0 -119
  79. package/src/components/conversation/message_list.tsx +0 -157
  80. package/src/hooks/__tests__/use_message_create_or_update.test.tsx +0 -110
  81. package/src/utils/__tests__/replace_equal_by_id.test.ts +0 -73
  82. package/src/utils/cache/__tests__/messages_cache.test.ts +0 -89
  83. package/src/utils/cache/__tests__/messages_cache_reply_roots.test.ts +0 -70
  84. package/src/utils/client/__tests__/transform_response.test.ts +0 -62
  85. 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
- })