@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.
Files changed (37) 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 +15 -15
  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 +3 -3
  16. package/build/components/conversation/reply_shadow_message.js.map +1 -1
  17. package/build/hooks/use_conversation_messages.d.ts.map +1 -1
  18. package/build/hooks/use_conversation_messages.js +40 -28
  19. package/build/hooks/use_conversation_messages.js.map +1 -1
  20. package/build/screens/conversation_screen.d.ts.map +1 -1
  21. package/build/screens/conversation_screen.js +184 -365
  22. package/build/screens/conversation_screen.js.map +1 -1
  23. package/build/utils/replace_equal_by_id.d.ts +6 -0
  24. package/build/utils/replace_equal_by_id.d.ts.map +1 -0
  25. package/build/utils/replace_equal_by_id.js +41 -0
  26. package/build/utils/replace_equal_by_id.js.map +1 -0
  27. package/package.json +3 -3
  28. package/src/components/conversation/__tests__/message_list.test.tsx +271 -0
  29. package/src/components/conversation/message.tsx +7 -11
  30. package/src/components/conversation/message_list.tsx +157 -0
  31. package/src/components/conversation/message_read_receipts.tsx +3 -5
  32. package/src/components/conversation/reply_shadow_message.tsx +3 -3
  33. package/src/hooks/use_conversation_messages.ts +5 -1
  34. package/src/screens/conversation_screen.tsx +11 -104
  35. package/src/utils/__tests__/replace_equal_by_id.test.ts +73 -0
  36. package/src/utils/cache/__tests__/messages_cache_reply_roots.test.ts +3 -4
  37. 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
- 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} />
@@ -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 function ReplyShadowMessage({
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
- opts
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, Text } from '../components'
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 messagesWithSeparators = groupMessages({
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
- <FlatList
185
- inverted
186
- ref={listRef}
187
- contentContainerStyle={styles.listContainer}
188
- data={messagesWithSeparators}
189
- keyExtractor={item => item.id}
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('leaves the cache untouched when no reply carries the root', () => {
62
+ it('ignores replies without an inline root', () => {
63
63
  const queryClient = new QueryClient()
64
- seedPages(queryClient, [message('01', { replyRootId: 'rootB' })])
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)).toBe(before)
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
+ }