@planningcenter/chat-react-native 4.2.0-rc.0 → 4.2.0-rc.2
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 +3 -2
- package/build/components/conversation/message.d.ts.map +1 -1
- package/build/components/conversation/message.js +23 -22
- package/build/components/conversation/message.js.map +1 -1
- package/build/components/conversation/message_list.d.ts +21 -0
- package/build/components/conversation/message_list.d.ts.map +1 -0
- package/build/components/conversation/message_list.js +273 -0
- package/build/components/conversation/message_list.js.map +1 -0
- package/build/components/conversation/message_read_receipts.d.ts +2 -4
- package/build/components/conversation/message_read_receipts.d.ts.map +1 -1
- package/build/components/conversation/message_read_receipts.js +2 -3
- package/build/components/conversation/message_read_receipts.js.map +1 -1
- package/build/components/conversation/reply_shadow_message.d.ts +2 -1
- package/build/components/conversation/reply_shadow_message.d.ts.map +1 -1
- package/build/components/conversation/reply_shadow_message.js +47 -46
- package/build/components/conversation/reply_shadow_message.js.map +1 -1
- package/build/contexts/api_provider.d.ts.map +1 -1
- package/build/contexts/api_provider.js +12 -1
- package/build/contexts/api_provider.js.map +1 -1
- package/build/hooks/use_conversation_messages.d.ts.map +1 -1
- package/build/hooks/use_conversation_messages.js +40 -28
- 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 +6 -1
- package/build/hooks/use_conversation_messages_jolt_events.js.map +1 -1
- package/build/hooks/use_features.d.ts +16 -0
- package/build/hooks/use_features.d.ts.map +1 -1
- package/build/hooks/use_features.js +43 -47
- package/build/hooks/use_features.js.map +1 -1
- package/build/screens/conversation_screen.d.ts.map +1 -1
- package/build/screens/conversation_screen.js +184 -365
- package/build/screens/conversation_screen.js.map +1 -1
- package/build/screens/conversations/components/search_list_header_component.d.ts +2 -0
- package/build/screens/conversations/components/search_list_header_component.d.ts.map +1 -0
- package/build/screens/conversations/components/search_list_header_component.js +496 -0
- package/build/screens/conversations/components/search_list_header_component.js.map +1 -0
- package/build/screens/conversations/conversations_screen.d.ts.map +1 -1
- package/build/screens/conversations/conversations_screen.js +28 -16
- package/build/screens/conversations/conversations_screen.js.map +1 -1
- package/build/types/resources/message.d.ts +1 -0
- 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 +1 -0
- package/build/utils/cache/messages_cache.d.ts.map +1 -1
- package/build/utils/cache/messages_cache.js +10 -1
- package/build/utils/cache/messages_cache.js.map +1 -1
- package/build/utils/client/transform_response.d.ts.map +1 -1
- package/build/utils/client/transform_response.js +9 -5
- package/build/utils/client/transform_response.js.map +1 -1
- package/build/utils/group_messages.d.ts +1 -0
- package/build/utils/group_messages.d.ts.map +1 -1
- package/build/utils/group_messages.js +10 -1
- package/build/utils/group_messages.js.map +1 -1
- package/build/utils/replace_equal_by_id.d.ts +6 -0
- package/build/utils/replace_equal_by_id.d.ts.map +1 -0
- package/build/utils/replace_equal_by_id.js +41 -0
- package/build/utils/replace_equal_by_id.js.map +1 -0
- package/build/utils/request/messages_data_options.d.ts.map +1 -1
- package/build/utils/request/messages_data_options.js +1 -1
- package/build/utils/request/messages_data_options.js.map +1 -1
- package/build/utils/time.d.ts +1 -0
- package/build/utils/time.d.ts.map +1 -1
- package/build/utils/time.js +1 -0
- package/build/utils/time.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/hooks/use_features.test.ts +61 -0
- package/src/components/conversation/__tests__/message_list.test.tsx +277 -0
- package/src/components/conversation/__tests__/reply_shadow_message.test.tsx +119 -0
- package/src/components/conversation/message.tsx +15 -18
- package/src/components/conversation/message_list.tsx +157 -0
- package/src/components/conversation/message_read_receipts.tsx +3 -5
- package/src/components/conversation/reply_shadow_message.tsx +9 -7
- package/src/contexts/api_provider.tsx +4 -0
- package/src/hooks/use_conversation_messages.ts +5 -1
- package/src/hooks/use_conversation_messages_jolt_events.ts +7 -0
- package/src/hooks/use_features.ts +26 -14
- package/src/screens/conversation_screen.tsx +11 -104
- package/src/screens/conversations/components/search_list_header_component.tsx +291 -0
- package/src/screens/conversations/conversations_screen.tsx +6 -1
- package/src/types/resources/message.ts +1 -0
- package/src/utils/__tests__/group_messages.test.ts +48 -0
- package/src/utils/__tests__/replace_equal_by_id.test.ts +73 -0
- package/src/utils/cache/__tests__/messages_cache_reply_roots.test.ts +70 -0
- package/src/utils/cache/messages_cache.ts +21 -1
- package/src/utils/client/__tests__/transform_response.test.ts +62 -0
- package/src/utils/client/transform_response.ts +7 -5
- package/src/utils/group_messages.ts +18 -1
- package/src/utils/replace_equal_by_id.ts +42 -0
- package/src/utils/request/messages_data_options.ts +1 -1
- package/src/utils/time.ts +1 -0
|
@@ -0,0 +1,277 @@
|
|
|
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
|
+
idempotentKey: 'key-50',
|
|
226
|
+
mine: true,
|
|
227
|
+
author: me,
|
|
228
|
+
pending: true,
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
act(() =>
|
|
232
|
+
queryClient.setQueryData<MessagesQueryData>(messagesQueryKey, prev =>
|
|
233
|
+
addRecordInPagesData({ data: prev, record: pending })
|
|
234
|
+
)
|
|
235
|
+
)
|
|
236
|
+
expect(screen.getByTestId(`message-${pending.id}`)).toBeTruthy()
|
|
237
|
+
renderSpy.mockClear()
|
|
238
|
+
|
|
239
|
+
const echo = buildMessage(50, {
|
|
240
|
+
text: 'hello there',
|
|
241
|
+
idempotentKey: 'key-50',
|
|
242
|
+
mine: true,
|
|
243
|
+
author: me,
|
|
244
|
+
})
|
|
245
|
+
act(() => updateCacheWithMessage(queryClient, messagesQueryKey, echo, 'message.created'))
|
|
246
|
+
|
|
247
|
+
expect(screen.queryByTestId(`message-${pending.id}`)).toBeNull()
|
|
248
|
+
expect(screen.getByTestId(`message-${echo.id}`)).toBeTruthy()
|
|
249
|
+
expect(renderedRowIds()).toEqual([echo.id])
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
it('redraws only my messages whose read state changed when someone reads', () => {
|
|
253
|
+
const mine = (n: number) => buildMessage(n, { mine: true, author: me })
|
|
254
|
+
const seed = [mine(1), buildMessage(2), mine(3), buildMessage(4), mine(5), buildMessage(6)]
|
|
255
|
+
renderList(seed)
|
|
256
|
+
expect(screen.getByText('Sent')).toBeTruthy()
|
|
257
|
+
|
|
258
|
+
act(() => setHarnessConversation({ ...conversation, latestReadMessageSortKey: messageId(6) }))
|
|
259
|
+
|
|
260
|
+
expect(screen.getByText('Read')).toBeTruthy()
|
|
261
|
+
expect(renderedRowIds()).toEqual([messageId(1), messageId(3), messageId(5)].sort())
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
it('removes a deleted row and leaves the others alone', () => {
|
|
265
|
+
renderList()
|
|
266
|
+
const target = messages[2]
|
|
267
|
+
|
|
268
|
+
act(() =>
|
|
269
|
+
queryClient.setQueryData<MessagesQueryData>(messagesQueryKey, prev =>
|
|
270
|
+
deleteRecordInPagesData({ data: prev, record: target })
|
|
271
|
+
)
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
expect(screen.queryByTestId(`message-${target.id}`)).toBeNull()
|
|
275
|
+
expect(renderedRowIds()).toEqual([])
|
|
276
|
+
})
|
|
277
|
+
})
|
|
@@ -0,0 +1,119 @@
|
|
|
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,6 +1,6 @@
|
|
|
1
1
|
import { useNavigation } from '@react-navigation/native'
|
|
2
2
|
import { some } from 'lodash'
|
|
3
|
-
import React, { useEffect } from 'react'
|
|
3
|
+
import React, { memo, useEffect } from 'react'
|
|
4
4
|
import {
|
|
5
5
|
AccessibilityActionEvent,
|
|
6
6
|
Pressable,
|
|
@@ -44,14 +44,14 @@ import { TheirReplyConnector, MyReplyConnector, AVATAR_CONNECTOR_SPACING } from
|
|
|
44
44
|
interface MessageProps extends MessageResource {
|
|
45
45
|
canDeleteNonAuthoredMessages: boolean
|
|
46
46
|
conversation_id: number
|
|
47
|
-
|
|
47
|
+
readByOthers?: boolean
|
|
48
48
|
inReplyScreen?: boolean
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
export
|
|
51
|
+
export const Message = memo(function MessageRow({
|
|
52
52
|
canDeleteNonAuthoredMessages,
|
|
53
53
|
conversation_id,
|
|
54
|
-
|
|
54
|
+
readByOthers,
|
|
55
55
|
inReplyScreen,
|
|
56
56
|
...message
|
|
57
57
|
}: MessageProps) {
|
|
@@ -73,11 +73,16 @@ export function Message({
|
|
|
73
73
|
const [messageBubbleHeight, setMessageBubbleHeight] = React.useState(0)
|
|
74
74
|
const { animatedBackgroundColor, handleMessagePressIn, handleMessagePressOut } =
|
|
75
75
|
useAnimatedMessageBackgroundColor()
|
|
76
|
-
const
|
|
76
|
+
const isReplyRootMessage = message.replyRootId === message.id
|
|
77
|
+
const isDeletedReplyRootMessage = isReplyRootMessage && !!message.deletedAt
|
|
78
|
+
const showReadReceipts = message.mine && !pending && !error
|
|
79
|
+
const replyToReplyRootMessage = !isReplyRootMessage && !!message.replyRootId
|
|
80
|
+
const { message: fetchedReplyRoot } = useConversationMessage({
|
|
77
81
|
conversation_id,
|
|
78
82
|
messageId: message.replyRootId || '',
|
|
79
|
-
enabled:
|
|
83
|
+
enabled: replyToReplyRootMessage && !message.replyRoot,
|
|
80
84
|
})
|
|
85
|
+
const replyRoot = message.replyRoot ?? fetchedReplyRoot
|
|
81
86
|
|
|
82
87
|
const metaProps = {
|
|
83
88
|
authorName: author.name,
|
|
@@ -85,14 +90,11 @@ export function Message({
|
|
|
85
90
|
}
|
|
86
91
|
|
|
87
92
|
const renderAuthor = (!message.mine && message.renderAuthor) || false
|
|
88
|
-
const showReplyCountButton = !inReplyScreen &&
|
|
89
|
-
const isReplyRootMessage = message.replyRootId === message.id
|
|
90
|
-
const isDeletedReplyRootMessage = isReplyRootMessage && !!message.deletedAt
|
|
91
|
-
const replyToReplyRootMessage = !isReplyRootMessage && !!message.replyRootId
|
|
93
|
+
const showReplyCountButton = !inReplyScreen && isReplyRootMessage
|
|
92
94
|
|
|
93
95
|
const replyCountText = pluralize(message.replyCount, 'reply')
|
|
94
96
|
const messagePendingLabel = isPersisted ? 'Saving' : 'Sending'
|
|
95
|
-
const replyRootAuthorName =
|
|
97
|
+
const replyRootAuthorName = replyToReplyRootMessage ? replyRoot?.author.name : author.name
|
|
96
98
|
const messageText = isDeletedReplyRootMessage ? 'Message deleted' : text
|
|
97
99
|
|
|
98
100
|
const messageBottomMargin =
|
|
@@ -289,12 +291,7 @@ export function Message({
|
|
|
289
291
|
)}
|
|
290
292
|
{showMessageMeta && !isDeletedReplyRootMessage && (
|
|
291
293
|
<View style={styles.messageMeta}>
|
|
292
|
-
{
|
|
293
|
-
<MessageReadReceipts
|
|
294
|
-
message={message}
|
|
295
|
-
latestReadMessageSortKey={latestReadMessageSortKey}
|
|
296
|
-
/>
|
|
297
|
-
)}
|
|
294
|
+
{showReadReceipts && <MessageReadReceipts read={readByOthers} />}
|
|
298
295
|
{error ? (
|
|
299
296
|
<View style={styles.errorContainer}>
|
|
300
297
|
{retryFailedSaveMutation.isPending ? (
|
|
@@ -343,7 +340,7 @@ export function Message({
|
|
|
343
340
|
</Animated.View>
|
|
344
341
|
</Pressable>
|
|
345
342
|
)
|
|
346
|
-
}
|
|
343
|
+
})
|
|
347
344
|
|
|
348
345
|
const useMessageStyles = (mine: boolean, isDeleting: boolean) => {
|
|
349
346
|
const { colors } = useTheme()
|
|
@@ -0,0 +1,157 @@
|
|
|
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,17 +1,15 @@
|
|
|
1
1
|
import React from 'react'
|
|
2
2
|
import { StyleSheet, View } from 'react-native'
|
|
3
3
|
import { useTheme } from '../../hooks'
|
|
4
|
-
import { MessageResource } from '../../types'
|
|
5
4
|
import { Icon, Text } from '../display'
|
|
6
5
|
|
|
7
6
|
interface Props {
|
|
8
|
-
|
|
9
|
-
latestReadMessageSortKey?: string
|
|
7
|
+
read?: boolean
|
|
10
8
|
}
|
|
11
9
|
|
|
12
|
-
export function MessageReadReceipts({
|
|
10
|
+
export function MessageReadReceipts({ read }: Props) {
|
|
13
11
|
const styles = useStyles()
|
|
14
|
-
if (!
|
|
12
|
+
if (!read) {
|
|
15
13
|
return (
|
|
16
14
|
<View style={styles.badgeContainer}>
|
|
17
15
|
<Icon name="general.check" style={styles.icon} />
|