@planningcenter/chat-react-native 3.45.1-qa-staging.8 → 3.45.1-qa-staging.10
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 +15 -15
- 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 +3 -3
- package/build/components/conversation/reply_shadow_message.js.map +1 -1
- package/build/hooks/use_conversation_messages.d.ts.map +1 -1
- package/build/hooks/use_conversation_messages.js +40 -28
- package/build/hooks/use_conversation_messages.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/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/package.json +3 -3
- package/src/components/conversation/__tests__/message_list.test.tsx +271 -0
- package/src/components/conversation/message.tsx +7 -11
- 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 +3 -3
- package/src/hooks/use_conversation_messages.ts +5 -1
- package/src/screens/conversation_screen.tsx +11 -104
- package/src/utils/__tests__/replace_equal_by_id.test.ts +73 -0
- package/src/utils/cache/__tests__/messages_cache_reply_roots.test.ts +3 -4
- package/src/utils/replace_equal_by_id.ts +42 -0
|
@@ -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} />
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useNavigation } from '@react-navigation/native'
|
|
2
2
|
import { some } from 'lodash'
|
|
3
|
-
import React from 'react'
|
|
3
|
+
import React, { memo } from 'react'
|
|
4
4
|
import { Pressable, StyleSheet, useWindowDimensions, View } from 'react-native'
|
|
5
5
|
import Animated from 'react-native-reanimated'
|
|
6
6
|
import {
|
|
@@ -37,7 +37,7 @@ interface ReplyShadowMessageProps extends MessageResource {
|
|
|
37
37
|
nextRendersAuthor: boolean
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
export
|
|
40
|
+
export const ReplyShadowMessage = memo(function ReplyShadowMessageRow({
|
|
41
41
|
conversation_id,
|
|
42
42
|
inReplyScreen,
|
|
43
43
|
messageId,
|
|
@@ -59,7 +59,7 @@ export function ReplyShadowMessage({
|
|
|
59
59
|
}
|
|
60
60
|
|
|
61
61
|
return <ShadowMessageContent conversation_id={conversation_id} {...enrichedMessage} />
|
|
62
|
-
}
|
|
62
|
+
})
|
|
63
63
|
|
|
64
64
|
interface ShadowMessageContentProps extends MessageResource {
|
|
65
65
|
conversation_id: number
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useMemo } from 'react'
|
|
2
2
|
import { MessageResource } from '../types'
|
|
3
|
+
import { replaceEqualById } from '../utils/replace_equal_by_id'
|
|
3
4
|
import { getMessagesQueryKey, getMessagesRequestArgs } from '../utils/request/get_messages'
|
|
4
5
|
import { SuspensePaginatorOptions, useSuspensePaginator } from './use_suspense_api'
|
|
5
6
|
|
|
@@ -10,7 +11,10 @@ export const useConversationMessages = (
|
|
|
10
11
|
const { data, refetch, isRefetching, fetchNextPage, hasNextPage } =
|
|
11
12
|
useSuspensePaginator<MessageResource>(
|
|
12
13
|
getMessagesRequestArgs({ conversation_id, reply_root_id }),
|
|
13
|
-
|
|
14
|
+
{
|
|
15
|
+
structuralSharing: replaceEqualById,
|
|
16
|
+
...opts,
|
|
17
|
+
}
|
|
14
18
|
)
|
|
15
19
|
const queryKey = getMessagesQueryKey({ conversation_id, reply_root_id })
|
|
16
20
|
const messages = useMemo(
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { date as formatDate } from '@planningcenter/datetime-fmt'
|
|
2
1
|
import { HeaderTitle, HeaderTitleProps, PlatformPressable } from '@react-navigation/elements'
|
|
3
2
|
import {
|
|
4
3
|
CommonActions,
|
|
@@ -11,24 +10,21 @@ import {
|
|
|
11
10
|
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
|
12
11
|
import { FlatList, Platform, StyleSheet, useWindowDimensions, View } from 'react-native'
|
|
13
12
|
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
|
14
|
-
import { Badge, Icon
|
|
13
|
+
import { Badge, Icon } from '../components'
|
|
15
14
|
import { ConversationStartBanner } from '../components/conversation/conversation_start_banner'
|
|
16
15
|
import { JumpToBottomButton } from '../components/conversation/jump_to_bottom_button'
|
|
17
|
-
import { Message } from '../components/conversation/message'
|
|
18
16
|
import { MessageForm } from '../components/conversation/message_form'
|
|
17
|
+
import { MessageList } from '../components/conversation/message_list'
|
|
19
18
|
import {
|
|
20
19
|
ConversationDisabledBanner,
|
|
21
20
|
LeaderMessagesDisabledBanner,
|
|
22
21
|
MemberMessagesDisabledBanner,
|
|
23
22
|
} from '../components/conversation/messages_disabled_banners'
|
|
24
|
-
import { ReplyShadowMessage } from '../components/conversation/reply_shadow_message'
|
|
25
|
-
import { SystemMessage } from '../components/conversation/system_message'
|
|
26
23
|
import { TypingIndicator } from '../components/conversation/typing_indicator'
|
|
27
24
|
import { KeyboardView } from '../components/display/keyboard_view'
|
|
28
25
|
import { useHeaderSlotReserve } from '../components/display/platform_modal_header_buttons'
|
|
29
26
|
import BlankState from '../components/primitive/blank_state_primitive'
|
|
30
27
|
import { ConversationContextProvider } from '../contexts/conversation_context'
|
|
31
|
-
import { useTheme } from '../hooks'
|
|
32
28
|
import { useMessageAvailableMembersAction } from '../hooks/direct_messages/use_message_available_members_action'
|
|
33
29
|
import { useConversation } from '../hooks/use_conversation'
|
|
34
30
|
import { useConversationJoltEvents } from '../hooks/use_conversation_jolt_events'
|
|
@@ -45,10 +41,6 @@ import { useVideoModerationJoltEvents } from '../hooks/use_video_moderation_jolt
|
|
|
45
41
|
import { ConversationResource } from '../types/resources/conversation'
|
|
46
42
|
import { ConversationBadgeResource } from '../types/resources/conversation_badge'
|
|
47
43
|
import { MessageResource } from '../types/resources/message'
|
|
48
|
-
import { getRelativeDateStatus } from '../utils/date'
|
|
49
|
-
import { groupMessages, type DateSeparator } from '../utils/group_messages'
|
|
50
|
-
import { CONVERSATION_MESSAGE_LIST_PADDING_HORIZONTAL } from '../utils/styles'
|
|
51
|
-
import { isSystemMessage } from '../utils/system_messages'
|
|
52
44
|
|
|
53
45
|
export type ConversationRouteProps = {
|
|
54
46
|
conversation_id: number
|
|
@@ -101,16 +93,11 @@ function ConversationScreenContent({ route }: ConversationScreenProps) {
|
|
|
101
93
|
const { markHandledByMessageForm } = useVideoModerationJoltEvents()
|
|
102
94
|
useEnsureConversationsRouteExists()
|
|
103
95
|
useMarkLatestMessageRead({ conversation, messages })
|
|
104
|
-
const
|
|
105
|
-
ms: messages,
|
|
106
|
-
inReplyScreen: !!reply_root_id,
|
|
107
|
-
})
|
|
108
|
-
const noMessages = messagesWithSeparators.length === 0
|
|
96
|
+
const noMessages = messages.length === 0
|
|
109
97
|
|
|
110
98
|
const { repliesDisabled, memberAbility, badges, title } = conversation
|
|
111
99
|
const canReply = memberAbility?.canReply
|
|
112
100
|
const showLeaderDisabledReplyBanner = canReply && repliesDisabled
|
|
113
|
-
const canDeleteNonAuthoredMessages = memberAbility?.canDeleteNonAuthoredMessages ?? false
|
|
114
101
|
const currentlyEditingMessage = messages.find(m => String(m.id) === String(editing_message_id))
|
|
115
102
|
const replyRootAuthorFirstName = reply_root_author_name?.split(' ')[0]
|
|
116
103
|
const replyHeaderTitle = replyRootAuthorFirstName
|
|
@@ -181,54 +168,19 @@ function ConversationScreenContent({ route }: ConversationScreenProps) {
|
|
|
181
168
|
<ConversationStartBanner conversation={conversation} hasMessages={false} />
|
|
182
169
|
</View>
|
|
183
170
|
) : (
|
|
184
|
-
<
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
171
|
+
<MessageList
|
|
172
|
+
listRef={listRef}
|
|
173
|
+
conversation={conversation}
|
|
174
|
+
conversationId={conversation_id}
|
|
175
|
+
messages={messages}
|
|
176
|
+
replyRootId={reply_root_id}
|
|
177
|
+
hasNextPage={hasNextPage}
|
|
178
|
+
onEndReached={() => fetchNextPage()}
|
|
190
179
|
onScroll={trackScroll}
|
|
191
180
|
onLayout={onLayout}
|
|
192
181
|
onContentSizeChange={onContentSizeChange}
|
|
193
182
|
onScrollEndDrag={onScrollEndDrag}
|
|
194
183
|
onMomentumScrollEnd={onMomentumScrollEnd}
|
|
195
|
-
scrollEventThrottle={10}
|
|
196
|
-
ListFooterComponent={
|
|
197
|
-
hasNextPage ? null : (
|
|
198
|
-
<ConversationStartBanner conversation={conversation} hasMessages={true} />
|
|
199
|
-
)
|
|
200
|
-
}
|
|
201
|
-
renderItem={({ item }) => {
|
|
202
|
-
if (item.type === 'DateSeparator') {
|
|
203
|
-
return <InlineDateSeparator {...item} />
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
if (item.type === 'ReplyShadowMessage') {
|
|
207
|
-
return (
|
|
208
|
-
<ReplyShadowMessage
|
|
209
|
-
{...(item as any)}
|
|
210
|
-
conversation_id={conversation_id}
|
|
211
|
-
inReplyScreen={!!reply_root_id}
|
|
212
|
-
/>
|
|
213
|
-
)
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
if (isSystemMessage(item)) {
|
|
217
|
-
return <SystemMessage message={item} conversationId={conversation_id} />
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
return (
|
|
221
|
-
<Message
|
|
222
|
-
{...item}
|
|
223
|
-
canDeleteNonAuthoredMessages={canDeleteNonAuthoredMessages}
|
|
224
|
-
conversation_id={conversation_id}
|
|
225
|
-
latestReadMessageSortKey={conversation?.latestReadMessageSortKey}
|
|
226
|
-
inReplyScreen={!!reply_root_id}
|
|
227
|
-
/>
|
|
228
|
-
)
|
|
229
|
-
}}
|
|
230
|
-
onEndReached={() => fetchNextPage()}
|
|
231
|
-
ListHeaderComponent={<View style={styles.listHeader} />}
|
|
232
184
|
/>
|
|
233
185
|
)}
|
|
234
186
|
<JumpToBottomButton onPress={handleReturnToBottom} visible={showJumpToBottomButton} />
|
|
@@ -295,44 +247,6 @@ function ConversationBottomBar({
|
|
|
295
247
|
)
|
|
296
248
|
}
|
|
297
249
|
|
|
298
|
-
function InlineDateSeparator({ date }: DateSeparator) {
|
|
299
|
-
const styles = useDateSeparatorStyles()
|
|
300
|
-
const { isThisYear } = getRelativeDateStatus(date)
|
|
301
|
-
const showYear = !isThisYear
|
|
302
|
-
const dateStamp = formatDate(date, { style: 'long', year: showYear })
|
|
303
|
-
|
|
304
|
-
return (
|
|
305
|
-
<View style={styles.container}>
|
|
306
|
-
<View style={styles.separator} />
|
|
307
|
-
<Text variant="footnote" style={styles.dateText}>
|
|
308
|
-
{dateStamp}
|
|
309
|
-
</Text>
|
|
310
|
-
<View style={styles.separator} />
|
|
311
|
-
</View>
|
|
312
|
-
)
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
const useDateSeparatorStyles = () => {
|
|
316
|
-
const theme = useTheme()
|
|
317
|
-
return StyleSheet.create({
|
|
318
|
-
container: {
|
|
319
|
-
alignItems: 'center',
|
|
320
|
-
flexDirection: 'row',
|
|
321
|
-
paddingHorizontal: CONVERSATION_MESSAGE_LIST_PADDING_HORIZONTAL,
|
|
322
|
-
paddingVertical: 16,
|
|
323
|
-
},
|
|
324
|
-
separator: {
|
|
325
|
-
flex: 1,
|
|
326
|
-
height: 1,
|
|
327
|
-
borderTopWidth: 1,
|
|
328
|
-
borderTopColor: theme.colors.borderColorDefaultBase,
|
|
329
|
-
},
|
|
330
|
-
dateText: {
|
|
331
|
-
paddingHorizontal: 8,
|
|
332
|
-
},
|
|
333
|
-
})
|
|
334
|
-
}
|
|
335
|
-
|
|
336
250
|
interface ConversationScreenTitleProps extends HeaderTitleProps {
|
|
337
251
|
conversation_id: number
|
|
338
252
|
badge?: ConversationBadgeResource
|
|
@@ -429,13 +343,6 @@ const useStyles = () => {
|
|
|
429
343
|
flex: 1,
|
|
430
344
|
justifyContent: 'center',
|
|
431
345
|
},
|
|
432
|
-
listContainer: {
|
|
433
|
-
paddingVertical: 12,
|
|
434
|
-
},
|
|
435
|
-
listHeader: {
|
|
436
|
-
// Just whitespace to provide space where the typing indicator can be
|
|
437
|
-
height: 16,
|
|
438
|
-
},
|
|
439
346
|
})
|
|
440
347
|
}
|
|
441
348
|
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { replaceEqualById } from '../replace_equal_by_id'
|
|
2
|
+
|
|
3
|
+
type Record = { type: 'Message'; id: string; text: string; attachments: string[] }
|
|
4
|
+
type Pages = { pages: { data: Record[]; links: {}; meta: {} }[]; pageParams: unknown[] }
|
|
5
|
+
|
|
6
|
+
const record = (id: string, text = id): Record => ({ type: 'Message', id, text, attachments: [] })
|
|
7
|
+
const pagesOf = (...pages: Record[][]): Pages => ({
|
|
8
|
+
pages: pages.map(data => ({ data, links: {}, meta: {} })),
|
|
9
|
+
pageParams: pages.map(() => ({})),
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
const share = (prev: Pages, next: Pages) => replaceEqualById(prev, next) as Pages
|
|
13
|
+
|
|
14
|
+
describe('replaceEqualById', () => {
|
|
15
|
+
it('returns the previous data when nothing changed', () => {
|
|
16
|
+
const prev = pagesOf([record('c'), record('b')], [record('a')])
|
|
17
|
+
const next = pagesOf([record('c'), record('b')], [record('a')])
|
|
18
|
+
|
|
19
|
+
expect(share(prev, next)).toBe(prev)
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('keeps every existing record when one is inserted at the head', () => {
|
|
23
|
+
const [c, b] = [record('c'), record('b')]
|
|
24
|
+
const prev = pagesOf([c, b])
|
|
25
|
+
const next = pagesOf([record('d'), record('c'), record('b')])
|
|
26
|
+
|
|
27
|
+
const result = share(prev, next)
|
|
28
|
+
|
|
29
|
+
expect(result.pages[0].data[1]).toBe(c)
|
|
30
|
+
expect(result.pages[0].data[2]).toBe(b)
|
|
31
|
+
expect(result.pages[0].data[0].id).toBe('d')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('keeps the survivors when a record is removed', () => {
|
|
35
|
+
const [c, b, a] = [record('c'), record('b'), record('a')]
|
|
36
|
+
const prev = pagesOf([c, b, a])
|
|
37
|
+
const next = pagesOf([record('c'), record('a')])
|
|
38
|
+
|
|
39
|
+
const result = share(prev, next)
|
|
40
|
+
|
|
41
|
+
expect(result.pages[0].data).toEqual([c, a])
|
|
42
|
+
expect(result.pages[0].data[0]).toBe(c)
|
|
43
|
+
expect(result.pages[0].data[1]).toBe(a)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('replaces only the changed record and keeps its untouched nested references', () => {
|
|
47
|
+
const b = record('b')
|
|
48
|
+
const prev = pagesOf([record('c'), b])
|
|
49
|
+
const next = pagesOf([record('c'), { ...record('b', 'edited'), attachments: [] }])
|
|
50
|
+
|
|
51
|
+
const result = share(prev, next)
|
|
52
|
+
|
|
53
|
+
expect(result.pages[0].data[0]).toBe(prev.pages[0].data[0])
|
|
54
|
+
expect(result.pages[0].data[1]).not.toBe(b)
|
|
55
|
+
expect(result.pages[0].data[1].text).toBe('edited')
|
|
56
|
+
expect(result.pages[0].data[1].attachments).toBe(b.attachments)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('keeps untouched pages when only another page changed', () => {
|
|
60
|
+
const prev = pagesOf([record('c')], [record('a')])
|
|
61
|
+
const next = pagesOf([record('d'), record('c')], [record('a')])
|
|
62
|
+
|
|
63
|
+
const result = share(prev, next)
|
|
64
|
+
|
|
65
|
+
expect(result.pages[1]).toBe(prev.pages[1])
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('returns the new data when there is nothing to share with', () => {
|
|
69
|
+
const next = pagesOf([record('a')])
|
|
70
|
+
|
|
71
|
+
expect(replaceEqualById(undefined, next)).toBe(next)
|
|
72
|
+
})
|
|
73
|
+
})
|
|
@@ -59,13 +59,12 @@ describe('updateCacheReplyRoots', () => {
|
|
|
59
59
|
expect(other.replyRoot?.text).toBe('other')
|
|
60
60
|
})
|
|
61
61
|
|
|
62
|
-
it('
|
|
62
|
+
it('ignores replies without an inline root', () => {
|
|
63
63
|
const queryClient = new QueryClient()
|
|
64
|
-
seedPages(queryClient, [message('01', { replyRootId: '
|
|
65
|
-
const before = pageMessages(queryClient)
|
|
64
|
+
seedPages(queryClient, [message('01', { replyRootId: 'rootA' })])
|
|
66
65
|
|
|
67
66
|
updateCacheReplyRoots(queryClient, queryKey, message('rootA', { replyRootId: 'rootA' }))
|
|
68
67
|
|
|
69
|
-
expect(pageMessages(queryClient)).
|
|
68
|
+
expect(pageMessages(queryClient)[0].replyRoot).toBeUndefined()
|
|
70
69
|
})
|
|
71
70
|
})
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { InfiniteData, replaceEqualDeep } from '@tanstack/react-query'
|
|
2
|
+
import { ApiCollection, ResourceObject } from '../types'
|
|
3
|
+
|
|
4
|
+
type Pages = InfiniteData<ApiCollection<ResourceObject>>
|
|
5
|
+
|
|
6
|
+
/** replaceEqualById
|
|
7
|
+
* TanStack's default structural sharing compares arrays by index, so an insert or
|
|
8
|
+
* delete gives every shifted record a new identity. Match by id instead.
|
|
9
|
+
*/
|
|
10
|
+
export function replaceEqualById(oldData: unknown, newData: unknown): unknown {
|
|
11
|
+
const prev = oldData as Pages | undefined
|
|
12
|
+
const next = newData as Pages
|
|
13
|
+
if (!prev?.pages || !next?.pages) return replaceEqualDeep(prev, next)
|
|
14
|
+
|
|
15
|
+
const prevById = new Map<ResourceObject['id'], ResourceObject>()
|
|
16
|
+
prev.pages.forEach(page => page.data.forEach(record => prevById.set(record.id, record)))
|
|
17
|
+
|
|
18
|
+
let changed = prev.pages.length !== next.pages.length
|
|
19
|
+
const pages = next.pages.map((page, index) => {
|
|
20
|
+
const prevPage = prev.pages[index]
|
|
21
|
+
const data = page.data.map(record => {
|
|
22
|
+
const previous = prevById.get(record.id)
|
|
23
|
+
return previous ? replaceEqualDeep(previous, record) : record
|
|
24
|
+
})
|
|
25
|
+
const links = replaceEqualDeep(prevPage?.links, page.links)
|
|
26
|
+
const meta = replaceEqualDeep(prevPage?.meta, page.meta)
|
|
27
|
+
const sameRecords =
|
|
28
|
+
!!prevPage &&
|
|
29
|
+
data.length === prevPage.data.length &&
|
|
30
|
+
data.every((record, i) => record === prevPage.data[i])
|
|
31
|
+
|
|
32
|
+
if (prevPage && sameRecords && links === prevPage.links && meta === prevPage.meta) {
|
|
33
|
+
return prevPage
|
|
34
|
+
}
|
|
35
|
+
changed = true
|
|
36
|
+
return { ...page, data, links, meta }
|
|
37
|
+
})
|
|
38
|
+
const pageParams = replaceEqualDeep(prev.pageParams, next.pageParams)
|
|
39
|
+
|
|
40
|
+
if (!changed && pageParams === prev.pageParams) return prev
|
|
41
|
+
return { ...next, pages, pageParams }
|
|
42
|
+
}
|