@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.
Files changed (90) hide show
  1. package/build/components/conversation/message.d.ts +3 -2
  2. package/build/components/conversation/message.d.ts.map +1 -1
  3. package/build/components/conversation/message.js +23 -22
  4. package/build/components/conversation/message.js.map +1 -1
  5. package/build/components/conversation/message_list.d.ts +21 -0
  6. package/build/components/conversation/message_list.d.ts.map +1 -0
  7. package/build/components/conversation/message_list.js +273 -0
  8. package/build/components/conversation/message_list.js.map +1 -0
  9. package/build/components/conversation/message_read_receipts.d.ts +2 -4
  10. package/build/components/conversation/message_read_receipts.d.ts.map +1 -1
  11. package/build/components/conversation/message_read_receipts.js +2 -3
  12. package/build/components/conversation/message_read_receipts.js.map +1 -1
  13. package/build/components/conversation/reply_shadow_message.d.ts +2 -1
  14. package/build/components/conversation/reply_shadow_message.d.ts.map +1 -1
  15. package/build/components/conversation/reply_shadow_message.js +47 -46
  16. package/build/components/conversation/reply_shadow_message.js.map +1 -1
  17. package/build/contexts/api_provider.d.ts.map +1 -1
  18. package/build/contexts/api_provider.js +12 -1
  19. package/build/contexts/api_provider.js.map +1 -1
  20. package/build/hooks/use_conversation_messages.d.ts.map +1 -1
  21. package/build/hooks/use_conversation_messages.js +40 -28
  22. package/build/hooks/use_conversation_messages.js.map +1 -1
  23. package/build/hooks/use_conversation_messages_jolt_events.d.ts.map +1 -1
  24. package/build/hooks/use_conversation_messages_jolt_events.js +6 -1
  25. package/build/hooks/use_conversation_messages_jolt_events.js.map +1 -1
  26. package/build/hooks/use_features.d.ts +16 -0
  27. package/build/hooks/use_features.d.ts.map +1 -1
  28. package/build/hooks/use_features.js +43 -47
  29. package/build/hooks/use_features.js.map +1 -1
  30. package/build/screens/conversation_screen.d.ts.map +1 -1
  31. package/build/screens/conversation_screen.js +184 -365
  32. package/build/screens/conversation_screen.js.map +1 -1
  33. package/build/screens/conversations/components/search_list_header_component.d.ts +2 -0
  34. package/build/screens/conversations/components/search_list_header_component.d.ts.map +1 -0
  35. package/build/screens/conversations/components/search_list_header_component.js +496 -0
  36. package/build/screens/conversations/components/search_list_header_component.js.map +1 -0
  37. package/build/screens/conversations/conversations_screen.d.ts.map +1 -1
  38. package/build/screens/conversations/conversations_screen.js +28 -16
  39. package/build/screens/conversations/conversations_screen.js.map +1 -1
  40. package/build/types/resources/message.d.ts +1 -0
  41. package/build/types/resources/message.d.ts.map +1 -1
  42. package/build/types/resources/message.js.map +1 -1
  43. package/build/utils/cache/messages_cache.d.ts +1 -0
  44. package/build/utils/cache/messages_cache.d.ts.map +1 -1
  45. package/build/utils/cache/messages_cache.js +10 -1
  46. package/build/utils/cache/messages_cache.js.map +1 -1
  47. package/build/utils/client/transform_response.d.ts.map +1 -1
  48. package/build/utils/client/transform_response.js +9 -5
  49. package/build/utils/client/transform_response.js.map +1 -1
  50. package/build/utils/group_messages.d.ts +1 -0
  51. package/build/utils/group_messages.d.ts.map +1 -1
  52. package/build/utils/group_messages.js +10 -1
  53. package/build/utils/group_messages.js.map +1 -1
  54. package/build/utils/replace_equal_by_id.d.ts +6 -0
  55. package/build/utils/replace_equal_by_id.d.ts.map +1 -0
  56. package/build/utils/replace_equal_by_id.js +41 -0
  57. package/build/utils/replace_equal_by_id.js.map +1 -0
  58. package/build/utils/request/messages_data_options.d.ts.map +1 -1
  59. package/build/utils/request/messages_data_options.js +1 -1
  60. package/build/utils/request/messages_data_options.js.map +1 -1
  61. package/build/utils/time.d.ts +1 -0
  62. package/build/utils/time.d.ts.map +1 -1
  63. package/build/utils/time.js +1 -0
  64. package/build/utils/time.js.map +1 -1
  65. package/package.json +2 -2
  66. package/src/__tests__/hooks/use_features.test.ts +61 -0
  67. package/src/components/conversation/__tests__/message_list.test.tsx +277 -0
  68. package/src/components/conversation/__tests__/reply_shadow_message.test.tsx +119 -0
  69. package/src/components/conversation/message.tsx +15 -18
  70. package/src/components/conversation/message_list.tsx +157 -0
  71. package/src/components/conversation/message_read_receipts.tsx +3 -5
  72. package/src/components/conversation/reply_shadow_message.tsx +9 -7
  73. package/src/contexts/api_provider.tsx +4 -0
  74. package/src/hooks/use_conversation_messages.ts +5 -1
  75. package/src/hooks/use_conversation_messages_jolt_events.ts +7 -0
  76. package/src/hooks/use_features.ts +26 -14
  77. package/src/screens/conversation_screen.tsx +11 -104
  78. package/src/screens/conversations/components/search_list_header_component.tsx +291 -0
  79. package/src/screens/conversations/conversations_screen.tsx +6 -1
  80. package/src/types/resources/message.ts +1 -0
  81. package/src/utils/__tests__/group_messages.test.ts +48 -0
  82. package/src/utils/__tests__/replace_equal_by_id.test.ts +73 -0
  83. package/src/utils/cache/__tests__/messages_cache_reply_roots.test.ts +70 -0
  84. package/src/utils/cache/messages_cache.ts +21 -1
  85. package/src/utils/client/__tests__/transform_response.test.ts +62 -0
  86. package/src/utils/client/transform_response.ts +7 -5
  87. package/src/utils/group_messages.ts +18 -1
  88. package/src/utils/replace_equal_by_id.ts +42 -0
  89. package/src/utils/request/messages_data_options.ts +1 -1
  90. 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
- latestReadMessageSortKey?: string
47
+ readByOthers?: boolean
48
48
  inReplyScreen?: boolean
49
49
  }
50
50
 
51
- export function Message({
51
+ export const Message = memo(function MessageRow({
52
52
  canDeleteNonAuthoredMessages,
53
53
  conversation_id,
54
- latestReadMessageSortKey,
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 { message: replyRootMessage } = useConversationMessage({
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: !!message.replyRootId,
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 && message.replyRootId === message.id
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 = message.replyRootId ? replyRootMessage?.author.name : author.name
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
- {message.mine && !pending && !error && (
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
- message: MessageResource
9
- latestReadMessageSortKey?: string
7
+ read?: boolean
10
8
  }
11
9
 
12
- export function MessageReadReceipts({ message, latestReadMessageSortKey }: Props) {
10
+ export function MessageReadReceipts({ read }: Props) {
13
11
  const styles = useStyles()
14
- if (!latestReadMessageSortKey || message.id > latestReadMessageSortKey) {
12
+ if (!read) {
15
13
  return (
16
14
  <View style={styles.badgeContainer}>
17
15
  <Icon name="general.check" style={styles.icon} />