@planningcenter/chat-react-native 4.1.0 → 4.1.1-qa-staging.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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/hooks/use_message_create_or_update.d.ts +99 -4
- package/build/hooks/use_message_create_or_update.d.ts.map +1 -1
- package/build/hooks/use_message_create_or_update.js +47 -6
- package/build/hooks/use_message_create_or_update.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 +2 -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 +14 -7
- package/build/utils/cache/messages_cache.js.map +1 -1
- package/build/utils/cache/optimistically_create_message.d.ts +2 -1
- package/build/utils/cache/optimistically_create_message.d.ts.map +1 -1
- package/build/utils/cache/optimistically_create_message.js +4 -2
- 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 +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/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 +1 -0
- package/build/utils/jolt/transform_message_event_data_to_message_resource.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 +2 -2
- 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 +3 -3
- package/src/__tests__/hooks/use_features.test.ts +61 -0
- package/src/components/conversation/__tests__/message_list.test.tsx +271 -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/__tests__/use_message_create_or_update.test.tsx +110 -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/hooks/use_message_create_or_update.ts +29 -16
- 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 +2 -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.test.ts +89 -0
- package/src/utils/cache/__tests__/messages_cache_reply_roots.test.ts +70 -0
- package/src/utils/cache/messages_cache.ts +27 -11
- package/src/utils/cache/optimistically_create_message.ts +3 -0
- 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/jolt/transform_message_event_data_to_message_resource.ts +1 -0
- package/src/utils/replace_equal_by_id.ts +42 -0
- package/src/utils/request/messages_data_options.ts +2 -1
- package/src/utils/time.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@planningcenter/chat-react-native",
|
|
3
|
-
"version": "4.1.0",
|
|
3
|
+
"version": "4.1.1-qa-staging.0",
|
|
4
4
|
"description": "",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
"dependencies": {
|
|
34
34
|
"@fortawesome/fontawesome-svg-core": "^7.2.0",
|
|
35
35
|
"@fortawesome/react-native-fontawesome": "^0.3.2",
|
|
36
|
-
"@planningcenter/emoji-keyboard": "4.
|
|
36
|
+
"@planningcenter/emoji-keyboard": "4.1.1-qa-staging.0",
|
|
37
37
|
"lodash-inflection": "^1.5.0"
|
|
38
38
|
},
|
|
39
39
|
"peerDependencies": {
|
|
@@ -84,5 +84,5 @@
|
|
|
84
84
|
"sf-symbols-typescript": "^2.2.0",
|
|
85
85
|
"typescript": "~6.0.3"
|
|
86
86
|
},
|
|
87
|
-
"gitHead": "
|
|
87
|
+
"gitHead": "13285204c04d26c627cb11013d28df5f834fd6aa"
|
|
88
88
|
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { buildTestQueryClient } from '../../__utils__/query_client'
|
|
2
|
+
import type { ApiClient } from '../../hooks/use_api_client'
|
|
3
|
+
import { featuresQueryOptions } from '../../hooks/use_features'
|
|
4
|
+
|
|
5
|
+
const features = {
|
|
6
|
+
data: [{ type: 'Feature', id: '1', name: 'ROLLOUT_search', enabled: true }],
|
|
7
|
+
links: {},
|
|
8
|
+
meta: { count: 1, totalCount: 1 },
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const buildClient = (get: jest.Mock) => ({ chat: { get } }) as unknown as ApiClient
|
|
12
|
+
|
|
13
|
+
const fetchTwice = async (get: jest.Mock, secondsBetween: number) => {
|
|
14
|
+
const queryClient = buildTestQueryClient()
|
|
15
|
+
const options = featuresQueryOptions(buildClient(get))
|
|
16
|
+
|
|
17
|
+
await queryClient.fetchQuery(options)
|
|
18
|
+
jest.advanceTimersByTime(secondsBetween * 1000)
|
|
19
|
+
await queryClient.fetchQuery(options)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe('featuresQueryOptions', () => {
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
jest.useFakeTimers()
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
jest.useRealTimers()
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('keeps successful flags fresh for five minutes', async () => {
|
|
32
|
+
const get = jest.fn().mockResolvedValue(features)
|
|
33
|
+
|
|
34
|
+
await fetchTwice(get, 4 * 60)
|
|
35
|
+
|
|
36
|
+
expect(get).toHaveBeenCalledTimes(1)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('refetches right away after a failed fetch', async () => {
|
|
40
|
+
const get = jest.fn().mockRejectedValue({ status: 503 })
|
|
41
|
+
|
|
42
|
+
await fetchTwice(get, 1)
|
|
43
|
+
|
|
44
|
+
expect(get).toHaveBeenCalledTimes(2)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('backs off for a minute after a 429', async () => {
|
|
48
|
+
const get = jest.fn().mockRejectedValue({ status: 429 })
|
|
49
|
+
const queryClient = buildTestQueryClient()
|
|
50
|
+
const options = featuresQueryOptions(buildClient(get))
|
|
51
|
+
|
|
52
|
+
await queryClient.fetchQuery(options)
|
|
53
|
+
jest.advanceTimersByTime(30 * 1000)
|
|
54
|
+
await queryClient.fetchQuery(options)
|
|
55
|
+
expect(get).toHaveBeenCalledTimes(1)
|
|
56
|
+
|
|
57
|
+
jest.advanceTimersByTime(31 * 1000)
|
|
58
|
+
await queryClient.fetchQuery(options)
|
|
59
|
+
expect(get).toHaveBeenCalledTimes(2)
|
|
60
|
+
})
|
|
61
|
+
})
|
|
@@ -0,0 +1,271 @@
|
|
|
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
|
+
})
|
|
@@ -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()
|