@planningcenter/chat-react-native 3.45.1-qa-staging.5 → 3.45.1-qa-staging.7
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.map +1 -1
- package/build/components/conversation/message.js +8 -7
- package/build/components/conversation/message.js.map +1 -1
- package/build/components/conversation/reply_shadow_message.d.ts +1 -1
- package/build/components/conversation/reply_shadow_message.d.ts.map +1 -1
- package/build/components/conversation/reply_shadow_message.js +44 -43
- package/build/components/conversation/reply_shadow_message.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_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/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/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/package.json +3 -3
- package/src/components/conversation/__tests__/reply_shadow_message.test.tsx +119 -0
- package/src/components/conversation/message.tsx +8 -7
- package/src/components/conversation/reply_shadow_message.tsx +6 -4
- package/src/hooks/__tests__/use_message_create_or_update.test.tsx +110 -0
- package/src/hooks/use_conversation_messages_jolt_events.ts +7 -0
- package/src/hooks/use_message_create_or_update.ts +29 -16
- package/src/types/resources/message.ts +2 -0
- package/src/utils/__tests__/group_messages.test.ts +48 -0
- package/src/utils/cache/__tests__/messages_cache.test.ts +89 -0
- package/src/utils/cache/__tests__/messages_cache_reply_roots.test.ts +71 -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/request/messages_data_options.ts +2 -1
|
@@ -22,6 +22,14 @@ interface Props {
|
|
|
22
22
|
replyRootId?: string | null
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
interface MessageMutationVariables {
|
|
26
|
+
text: string
|
|
27
|
+
attachments?: DenormalizedAttachmentResourceForCreate[]
|
|
28
|
+
idempotentKey?: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
type ResolvedMessageMutationVariables = MessageMutationVariables & { idempotentKey: string }
|
|
32
|
+
|
|
25
33
|
export function useMessageCreateOrUpdate({ conversationId, message, replyRootId }: Props) {
|
|
26
34
|
const messageId = message?.id || null
|
|
27
35
|
const isEditing = !isNewMessage(message)
|
|
@@ -29,13 +37,7 @@ export function useMessageCreateOrUpdate({ conversationId, message, replyRootId
|
|
|
29
37
|
const currentPerson = useCurrentPerson()
|
|
30
38
|
|
|
31
39
|
const mutation = useMutation({
|
|
32
|
-
mutationFn: ({
|
|
33
|
-
text,
|
|
34
|
-
attachments,
|
|
35
|
-
}: {
|
|
36
|
-
text: string
|
|
37
|
-
attachments?: DenormalizedAttachmentResourceForCreate[]
|
|
38
|
-
}) => {
|
|
40
|
+
mutationFn: ({ text, attachments, idempotentKey }: ResolvedMessageMutationVariables) => {
|
|
39
41
|
const requestParams = getMessagesRequestArgs({ conversation_id: conversationId })
|
|
40
42
|
const fieldsWithValueJoined = Object.fromEntries(
|
|
41
43
|
Object.entries(requestParams.data.fields).map(([k, v]) => [k, v.join(',')])
|
|
@@ -46,7 +48,6 @@ export function useMessageCreateOrUpdate({ conversationId, message, replyRootId
|
|
|
46
48
|
...(!isEditing && replyRootId ? { reply_root: { id: replyRootId } } : {}),
|
|
47
49
|
}
|
|
48
50
|
if (!isEditing) {
|
|
49
|
-
const idempotentKey = insecureUUID()
|
|
50
51
|
attributes.idempotent_key = idempotentKey
|
|
51
52
|
startMessageCreationTracking(idempotentKey)
|
|
52
53
|
}
|
|
@@ -71,13 +72,7 @@ export function useMessageCreateOrUpdate({ conversationId, message, replyRootId
|
|
|
71
72
|
})
|
|
72
73
|
}
|
|
73
74
|
},
|
|
74
|
-
onMutate: async ({
|
|
75
|
-
text,
|
|
76
|
-
attachments,
|
|
77
|
-
}: {
|
|
78
|
-
text: string
|
|
79
|
-
attachments?: DenormalizedAttachmentResourceForCreate[]
|
|
80
|
-
}) => {
|
|
75
|
+
onMutate: async ({ text, attachments, idempotentKey }: ResolvedMessageMutationVariables) => {
|
|
81
76
|
if (message && isEditing) {
|
|
82
77
|
const optimisticMessage = optimisticallyUpdateMessage({
|
|
83
78
|
conversationId,
|
|
@@ -96,6 +91,7 @@ export function useMessageCreateOrUpdate({ conversationId, message, replyRootId
|
|
|
96
91
|
attachments,
|
|
97
92
|
currentPerson,
|
|
98
93
|
replyRootId,
|
|
94
|
+
idempotentKey,
|
|
99
95
|
})
|
|
100
96
|
|
|
101
97
|
return { message: optimisticMessage }
|
|
@@ -155,7 +151,24 @@ export function useMessageCreateOrUpdate({ conversationId, message, replyRootId
|
|
|
155
151
|
},
|
|
156
152
|
})
|
|
157
153
|
|
|
158
|
-
|
|
154
|
+
const resolveVariables = (
|
|
155
|
+
variables: MessageMutationVariables
|
|
156
|
+
): ResolvedMessageMutationVariables => ({
|
|
157
|
+
...variables,
|
|
158
|
+
idempotentKey: variables.idempotentKey ?? message?.idempotentKey ?? insecureUUID(),
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
const mutate = (
|
|
162
|
+
variables: MessageMutationVariables,
|
|
163
|
+
options?: Parameters<typeof mutation.mutate>[1]
|
|
164
|
+
) => mutation.mutate(resolveVariables(variables), options)
|
|
165
|
+
|
|
166
|
+
const mutateAsync = (
|
|
167
|
+
variables: MessageMutationVariables,
|
|
168
|
+
options?: Parameters<typeof mutation.mutateAsync>[1]
|
|
169
|
+
) => mutation.mutateAsync(resolveVariables(variables), options)
|
|
170
|
+
|
|
171
|
+
return { ...mutation, mutate, mutateAsync }
|
|
159
172
|
}
|
|
160
173
|
|
|
161
174
|
/**
|
|
@@ -17,6 +17,8 @@ export interface MessageResource {
|
|
|
17
17
|
reactionCounts: ReactionCountResource[]
|
|
18
18
|
replyCount: number
|
|
19
19
|
replyRootId?: string | null
|
|
20
|
+
replyRoot?: MessageResource | null
|
|
21
|
+
idempotentKey?: string | null
|
|
20
22
|
messageType: string
|
|
21
23
|
personIdsForSystemEvent?: number[] | null
|
|
22
24
|
systemTextParts?: { names: string[]; overflowCount: number; action: string } | null
|
|
@@ -124,6 +124,54 @@ describe('groupMessages — nextRendersAuthor mirrors the newer enriched neighbo
|
|
|
124
124
|
})
|
|
125
125
|
})
|
|
126
126
|
|
|
127
|
+
describe('groupMessages — reply roots', () => {
|
|
128
|
+
const findShadow = (items: ReturnType<typeof groupMessages>, id: string) =>
|
|
129
|
+
items.find(item => 'type' in item && item.type === 'ReplyShadowMessage' && item.id === id)
|
|
130
|
+
|
|
131
|
+
it('carries the root from the page onto the reply and its shadow', () => {
|
|
132
|
+
const messages = [
|
|
133
|
+
message('03', { replyRootId: 'rootA' }),
|
|
134
|
+
message('02'),
|
|
135
|
+
message('rootA', { replyRootId: 'rootA', text: 'fresh' }),
|
|
136
|
+
]
|
|
137
|
+
|
|
138
|
+
const enriched = groupMessages({ ms: messages })
|
|
139
|
+
|
|
140
|
+
expect((findEnriched(enriched, '03') as MessageResource).replyRoot?.text).toBe('fresh')
|
|
141
|
+
expect(findShadow(enriched, '03-rootA')).toMatchObject({ replyRoot: { id: 'rootA' } })
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('prefers the page copy of the root over the reply’s included snapshot', () => {
|
|
145
|
+
const snapshot = message('rootA', { replyRootId: 'rootA', text: 'stale' })
|
|
146
|
+
const messages = [
|
|
147
|
+
message('03', { replyRootId: 'rootA', replyRoot: snapshot }),
|
|
148
|
+
message('02'),
|
|
149
|
+
message('rootA', { replyRootId: 'rootA', text: 'fresh' }),
|
|
150
|
+
]
|
|
151
|
+
|
|
152
|
+
const enriched = groupMessages({ ms: messages })
|
|
153
|
+
|
|
154
|
+
expect(findShadow(enriched, '03-rootA')).toMatchObject({ replyRoot: { text: 'fresh' } })
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('falls back to the included snapshot when the root is not on the page', () => {
|
|
158
|
+
const snapshot = message('rootA', { replyRootId: 'rootA', text: 'included' })
|
|
159
|
+
const messages = [message('02', { replyRootId: 'rootA', replyRoot: snapshot })]
|
|
160
|
+
|
|
161
|
+
const enriched = groupMessages({ ms: messages })
|
|
162
|
+
|
|
163
|
+
expect(findShadow(enriched, '02-rootA')).toMatchObject({ replyRoot: { text: 'included' } })
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it('leaves replyRoot null when the root is neither on the page nor included', () => {
|
|
167
|
+
const messages = [message('02', { replyRootId: 'rootA' })]
|
|
168
|
+
|
|
169
|
+
const enriched = groupMessages({ ms: messages })
|
|
170
|
+
|
|
171
|
+
expect(findShadow(enriched, '02-rootA')).toMatchObject({ replyRoot: null })
|
|
172
|
+
})
|
|
173
|
+
})
|
|
174
|
+
|
|
127
175
|
describe('groupMessages — system messages', () => {
|
|
128
176
|
it('flags lastInGroup true and renderAuthor false on system messages', () => {
|
|
129
177
|
const messages = [
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { InfiniteData, QueryClient } from '@tanstack/react-query'
|
|
2
|
+
import type { ApiCollection, MessageResource } from '../../../types'
|
|
3
|
+
import type { PersonResource } from '../../../types/resources/person'
|
|
4
|
+
import { updateCacheWithMessage } from '../messages_cache'
|
|
5
|
+
|
|
6
|
+
type MessagesQueryData = InfiniteData<ApiCollection<MessageResource>>
|
|
7
|
+
|
|
8
|
+
const queryKey = ['messages']
|
|
9
|
+
const author = { type: 'Person', id: 1, name: 'Me', avatar: null } as unknown as PersonResource
|
|
10
|
+
|
|
11
|
+
const message = (overrides: Partial<MessageResource>): MessageResource =>
|
|
12
|
+
({
|
|
13
|
+
type: 'Message',
|
|
14
|
+
text: 'ok',
|
|
15
|
+
html: '',
|
|
16
|
+
createdAt: '2026-01-01T00:00:00Z',
|
|
17
|
+
deletedAt: null,
|
|
18
|
+
textEditedAt: null,
|
|
19
|
+
mine: true,
|
|
20
|
+
attachments: [],
|
|
21
|
+
author,
|
|
22
|
+
reactionCounts: [],
|
|
23
|
+
replyCount: 0,
|
|
24
|
+
replyRootId: null,
|
|
25
|
+
messageType: 'user',
|
|
26
|
+
...overrides,
|
|
27
|
+
}) as MessageResource
|
|
28
|
+
|
|
29
|
+
const seed = (queryClient: QueryClient, ...messages: MessageResource[]) =>
|
|
30
|
+
queryClient.setQueryData<MessagesQueryData>(queryKey, {
|
|
31
|
+
pages: [
|
|
32
|
+
{ data: messages, links: {}, meta: { count: messages.length, totalCount: messages.length } },
|
|
33
|
+
],
|
|
34
|
+
pageParams: [],
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
const idsInCache = (queryClient: QueryClient) =>
|
|
38
|
+
queryClient.getQueryData<MessagesQueryData>(queryKey)!.pages[0].data.map(m => m.id)
|
|
39
|
+
|
|
40
|
+
const echo = (queryClient: QueryClient, overrides: Partial<MessageResource>) =>
|
|
41
|
+
updateCacheWithMessage(queryClient, queryKey, message(overrides), 'message.created')
|
|
42
|
+
|
|
43
|
+
describe('updateCacheWithMessage on message.created', () => {
|
|
44
|
+
let queryClient: QueryClient
|
|
45
|
+
|
|
46
|
+
beforeEach(() => {
|
|
47
|
+
queryClient = new QueryClient()
|
|
48
|
+
seed(
|
|
49
|
+
queryClient,
|
|
50
|
+
message({ id: '01B-temp', idempotentKey: 'key-b', pending: true }),
|
|
51
|
+
message({ id: '01A-temp', idempotentKey: 'key-a', pending: true })
|
|
52
|
+
)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('swaps out only the pending message with the same idempotent key', () => {
|
|
56
|
+
echo(queryClient, { id: '01A', idempotentKey: 'key-a' })
|
|
57
|
+
|
|
58
|
+
expect(idsInCache(queryClient)).toEqual(['01A', '01B-temp'])
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('does not swap out a pending message that only shares the same text', () => {
|
|
62
|
+
echo(queryClient, { id: '01C', idempotentKey: 'key-c', text: 'ok' })
|
|
63
|
+
|
|
64
|
+
expect(idsInCache(queryClient)).toEqual(['01C', '01B-temp', '01A-temp'])
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('swaps out an attachment-only pending message, which has no text to match', () => {
|
|
68
|
+
seed(
|
|
69
|
+
queryClient,
|
|
70
|
+
message({ id: '01D-temp', idempotentKey: 'key-d', text: null, pending: true })
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
echo(queryClient, { id: '01D', idempotentKey: 'key-d', text: null })
|
|
74
|
+
|
|
75
|
+
expect(idsInCache(queryClient)).toEqual(['01D'])
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('leaves pending messages alone when the echo carries no key', () => {
|
|
79
|
+
echo(queryClient, { id: '01E', idempotentKey: null })
|
|
80
|
+
|
|
81
|
+
expect(idsInCache(queryClient)).toEqual(['01E', '01B-temp', '01A-temp'])
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it("leaves pending messages alone when the echo is someone else's", () => {
|
|
85
|
+
echo(queryClient, { id: '01F', idempotentKey: 'key-a', mine: false })
|
|
86
|
+
|
|
87
|
+
expect(idsInCache(queryClient)).toEqual(['01F', '01B-temp', '01A-temp'])
|
|
88
|
+
})
|
|
89
|
+
})
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { InfiniteData, QueryClient } from '@tanstack/react-query'
|
|
2
|
+
import type { ApiCollection } from '../../../types'
|
|
3
|
+
import type { MessageResource } from '../../../types/resources/message'
|
|
4
|
+
import type { PersonResource } from '../../../types/resources/person'
|
|
5
|
+
import { updateCacheReplyRoots } from '../messages_cache'
|
|
6
|
+
|
|
7
|
+
const author = { id: 1, type: 'Person', name: 'A', avatar: null } as unknown as PersonResource
|
|
8
|
+
|
|
9
|
+
const message = (id: string, overrides: Partial<MessageResource> = {}): MessageResource =>
|
|
10
|
+
({
|
|
11
|
+
type: 'Message',
|
|
12
|
+
id,
|
|
13
|
+
text: `msg ${id}`,
|
|
14
|
+
html: '',
|
|
15
|
+
createdAt: '2026-01-01T00:00:00Z',
|
|
16
|
+
deletedAt: null,
|
|
17
|
+
textEditedAt: null,
|
|
18
|
+
mine: false,
|
|
19
|
+
attachments: [],
|
|
20
|
+
author,
|
|
21
|
+
reactionCounts: [],
|
|
22
|
+
replyCount: 0,
|
|
23
|
+
replyRootId: null,
|
|
24
|
+
messageType: 'message',
|
|
25
|
+
...overrides,
|
|
26
|
+
}) as MessageResource
|
|
27
|
+
|
|
28
|
+
const queryKey = ['messages']
|
|
29
|
+
|
|
30
|
+
const seedPages = (queryClient: QueryClient, messages: MessageResource[]) => {
|
|
31
|
+
queryClient.setQueryData<InfiniteData<ApiCollection<MessageResource>>>(queryKey, {
|
|
32
|
+
pageParams: [{}],
|
|
33
|
+
pages: [{ data: messages, links: {}, meta: { count: messages.length, totalCount: 1 } }],
|
|
34
|
+
})
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const pageMessages = (queryClient: QueryClient) =>
|
|
38
|
+
queryClient.getQueryData<InfiniteData<ApiCollection<MessageResource>>>(queryKey)!.pages[0].data
|
|
39
|
+
|
|
40
|
+
describe('updateCacheReplyRoots', () => {
|
|
41
|
+
it('merges a root update into every reply carrying that root', () => {
|
|
42
|
+
const queryClient = new QueryClient()
|
|
43
|
+
const root = message('rootA', { replyRootId: 'rootA', text: 'before' })
|
|
44
|
+
seedPages(queryClient, [
|
|
45
|
+
message('03', { replyRootId: 'rootA', replyRoot: root }),
|
|
46
|
+
message('02', { replyRootId: 'rootB', replyRoot: message('rootB', { text: 'other' }) }),
|
|
47
|
+
message('01', { replyRootId: 'rootA', replyRoot: root }),
|
|
48
|
+
])
|
|
49
|
+
|
|
50
|
+
updateCacheReplyRoots(
|
|
51
|
+
queryClient,
|
|
52
|
+
queryKey,
|
|
53
|
+
message('rootA', { replyRootId: 'rootA', text: 'after', deletedAt: '2026-01-02T00:00:00Z' })
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
const [newest, other, oldest] = pageMessages(queryClient)
|
|
57
|
+
expect(newest.replyRoot).toMatchObject({ text: 'after', deletedAt: '2026-01-02T00:00:00Z' })
|
|
58
|
+
expect(oldest.replyRoot).toMatchObject({ text: 'after' })
|
|
59
|
+
expect(other.replyRoot?.text).toBe('other')
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('leaves the cache untouched when no reply carries the root', () => {
|
|
63
|
+
const queryClient = new QueryClient()
|
|
64
|
+
seedPages(queryClient, [message('01', { replyRootId: 'rootB' })])
|
|
65
|
+
const before = pageMessages(queryClient)
|
|
66
|
+
|
|
67
|
+
updateCacheReplyRoots(queryClient, queryKey, message('rootA', { replyRootId: 'rootA' }))
|
|
68
|
+
|
|
69
|
+
expect(pageMessages(queryClient)).toBe(before)
|
|
70
|
+
})
|
|
71
|
+
})
|
|
@@ -4,7 +4,11 @@ import { ApiCollection, ApiResource, MessageResource } from '../../types'
|
|
|
4
4
|
import { JoltReactionEvent } from '../../types/jolt_events'
|
|
5
5
|
import { transformReactionEventDataToReactionCountResource } from '../jolt/transform_reaction_event_data_to_reaction_count_resource'
|
|
6
6
|
import { getMessagesRequestArgs } from '../request/get_messages'
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
updateAllRecordsInPagesData,
|
|
9
|
+
updateOrCreateRecordInPagesData,
|
|
10
|
+
updateRecordInPagesData,
|
|
11
|
+
} from './page_mutations'
|
|
8
12
|
import { deleteRecordInPagesData } from './page_mutations'
|
|
9
13
|
|
|
10
14
|
export function updateCacheWithMessage(
|
|
@@ -15,20 +19,16 @@ export function updateCacheWithMessage(
|
|
|
15
19
|
) {
|
|
16
20
|
queryClient.setQueryData<MessagesQueryData>(queryKey, prev => {
|
|
17
21
|
if (event === 'message.created') {
|
|
18
|
-
// Before adding the new message, remove
|
|
19
|
-
//
|
|
22
|
+
// Before adding the new message, remove its pending temporary counterpart.
|
|
23
|
+
// The Jolt echo can arrive before the HTTP response has swapped it out.
|
|
20
24
|
let dataAfterTempRemoval = prev
|
|
21
|
-
if (prev && message.
|
|
25
|
+
if (prev && message.mine && message.idempotentKey) {
|
|
22
26
|
dataAfterTempRemoval = deleteRecordInPagesData({
|
|
23
27
|
data: prev,
|
|
24
28
|
record: message,
|
|
25
|
-
matchFn:
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
existingMessage.text === message.text &&
|
|
29
|
-
existingMessage.mine
|
|
30
|
-
)
|
|
31
|
-
},
|
|
29
|
+
matchFn: existingMessage =>
|
|
30
|
+
isTemporaryMessageId(existingMessage.id) &&
|
|
31
|
+
existingMessage.idempotentKey === message.idempotentKey,
|
|
32
32
|
})
|
|
33
33
|
}
|
|
34
34
|
|
|
@@ -70,6 +70,22 @@ export function updateCacheWithIndividualMessage(
|
|
|
70
70
|
})
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
export function updateCacheReplyRoots(
|
|
74
|
+
queryClient: QueryClient,
|
|
75
|
+
queryKey: unknown[],
|
|
76
|
+
root: MessageResource
|
|
77
|
+
) {
|
|
78
|
+
queryClient.setQueryData<MessagesQueryData>(queryKey, prev =>
|
|
79
|
+
updateAllRecordsInPagesData({
|
|
80
|
+
data: prev,
|
|
81
|
+
processRecord: message =>
|
|
82
|
+
message.replyRoot?.id === root.id
|
|
83
|
+
? { ...message, replyRoot: mergeMessageUpdate(root, message.replyRoot) }
|
|
84
|
+
: message,
|
|
85
|
+
})
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
|
|
73
89
|
export function updateCacheWithReaction(
|
|
74
90
|
queryClient: QueryClient,
|
|
75
91
|
queryKey: unknown[],
|
|
@@ -15,6 +15,7 @@ export function optimisticallyCreateMessage({
|
|
|
15
15
|
currentPerson,
|
|
16
16
|
message,
|
|
17
17
|
replyRootId,
|
|
18
|
+
idempotentKey,
|
|
18
19
|
}: {
|
|
19
20
|
conversationId: number
|
|
20
21
|
text: string
|
|
@@ -22,6 +23,7 @@ export function optimisticallyCreateMessage({
|
|
|
22
23
|
currentPerson: CurrentPersonResource
|
|
23
24
|
message?: MessageResource
|
|
24
25
|
replyRootId?: string | null
|
|
26
|
+
idempotentKey?: string
|
|
25
27
|
}) {
|
|
26
28
|
const id = message?.id || generateTempMessageId()
|
|
27
29
|
|
|
@@ -53,6 +55,7 @@ export function optimisticallyCreateMessage({
|
|
|
53
55
|
pending: true,
|
|
54
56
|
replyCount: 0,
|
|
55
57
|
replyRootId: replyRootId || null,
|
|
58
|
+
idempotentKey,
|
|
56
59
|
}
|
|
57
60
|
|
|
58
61
|
// Add the optimistic message to the cache
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import transformResponse from '../transform_response'
|
|
2
|
+
|
|
3
|
+
const person = (id: string, name: string) => ({
|
|
4
|
+
type: 'Person',
|
|
5
|
+
id,
|
|
6
|
+
attributes: { name, avatar: null },
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
const message = (
|
|
10
|
+
id: string,
|
|
11
|
+
{ authorId, replyRootId }: { authorId: string; replyRootId: string | null }
|
|
12
|
+
) => ({
|
|
13
|
+
type: 'Message',
|
|
14
|
+
id,
|
|
15
|
+
attributes: { text: `msg ${id}` },
|
|
16
|
+
relationships: {
|
|
17
|
+
author: { data: { type: 'Person', id: authorId } },
|
|
18
|
+
reply_root: { data: replyRootId ? { type: 'Message', id: replyRootId } : null },
|
|
19
|
+
reaction_counts: { data: [] },
|
|
20
|
+
},
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
const root = message('01ROOT', { authorId: '2', replyRootId: '01ROOT' })
|
|
24
|
+
const reply = message('01REPLY', { authorId: '1', replyRootId: '01ROOT' })
|
|
25
|
+
|
|
26
|
+
describe('transformResponse — reply_root include', () => {
|
|
27
|
+
it('attaches the included root and its author to a reply', () => {
|
|
28
|
+
const result = transformResponse({
|
|
29
|
+
data: [reply],
|
|
30
|
+
included: [root, person('1', 'Replier'), person('2', 'Root Author')],
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
const [transformedReply] = result.data
|
|
34
|
+
expect(transformedReply.replyRootId).toBe('01ROOT')
|
|
35
|
+
expect(transformedReply.replyRoot.id).toBe('01ROOT')
|
|
36
|
+
expect(transformedReply.replyRoot.text).toBe('msg 01ROOT')
|
|
37
|
+
expect(transformedReply.replyRoot.author.name).toBe('Root Author')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('drops a record’s self reference so the result stays acyclic', () => {
|
|
41
|
+
const result = transformResponse({
|
|
42
|
+
data: [reply, root],
|
|
43
|
+
included: [root, person('1', 'Replier'), person('2', 'Root Author')],
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
const [transformedReply, transformedRoot] = result.data
|
|
47
|
+
expect(transformedReply.replyRoot.replyRoot).toBeNull()
|
|
48
|
+
expect(transformedRoot.replyRootId).toBe('01ROOT')
|
|
49
|
+
expect(transformedRoot.replyRoot).toBeNull()
|
|
50
|
+
expect(() => JSON.stringify(result)).not.toThrow()
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('leaves replyRoot null when the root is not included', () => {
|
|
54
|
+
const result = transformResponse({
|
|
55
|
+
data: [reply],
|
|
56
|
+
included: [person('1', 'Replier')],
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
expect(result.data[0].replyRootId).toBe('01ROOT')
|
|
60
|
+
expect(result.data[0].replyRoot).toBeNull()
|
|
61
|
+
})
|
|
62
|
+
})
|
|
@@ -32,7 +32,8 @@ const transformRelationships = relationships => {
|
|
|
32
32
|
}, {})
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
const addIncludedRecords = (
|
|
35
|
+
const addIncludedRecords = (record, included, seen, parent) => {
|
|
36
|
+
const { relationships } = record
|
|
36
37
|
return Object.keys(relationships || {}).reduce((acc, key) => {
|
|
37
38
|
const relationship = relationships[key]
|
|
38
39
|
if (_.isArray(relationship.data)) {
|
|
@@ -43,13 +44,14 @@ const addIncludedRecords = (relationships, included, seen, parent) => {
|
|
|
43
44
|
return _.set(
|
|
44
45
|
acc,
|
|
45
46
|
_.pluralize(key),
|
|
46
|
-
_.map(records,
|
|
47
|
+
_.map(records, related => transformRecord(related, included, seen))
|
|
47
48
|
)
|
|
48
49
|
} else {
|
|
49
50
|
const [id, type] = _.at(relationship, ['data.id', 'data.type'])
|
|
50
51
|
if (id && type) {
|
|
51
|
-
const
|
|
52
|
-
|
|
52
|
+
const isSelfReference = id === record.id && type === record.type
|
|
53
|
+
const related = isSelfReference ? undefined : _.find(included, { id, type })
|
|
54
|
+
return _.set(acc, key, related ? transformRecord(related, included, seen) : null)
|
|
53
55
|
} else {
|
|
54
56
|
return acc
|
|
55
57
|
}
|
|
@@ -78,7 +80,7 @@ const transformRecord = (record, included, seen) => {
|
|
|
78
80
|
|
|
79
81
|
seen.set(record, transformed)
|
|
80
82
|
|
|
81
|
-
return addIncludedRecords(
|
|
83
|
+
return addIncludedRecords(record, included, seen, transformed)
|
|
82
84
|
}
|
|
83
85
|
}
|
|
84
86
|
|
|
@@ -10,6 +10,7 @@ export type ReplyShadowMessage = {
|
|
|
10
10
|
type: 'ReplyShadowMessage'
|
|
11
11
|
id: string
|
|
12
12
|
messageId: string
|
|
13
|
+
replyRoot: MessageResource | null
|
|
13
14
|
isReplyShadowMessage: boolean
|
|
14
15
|
nextRendersAuthor: boolean
|
|
15
16
|
}
|
|
@@ -23,6 +24,7 @@ interface GroupMessagesProps {
|
|
|
23
24
|
|
|
24
25
|
export function groupMessages({ ms, inReplyScreen }: GroupMessagesProps): EnrichedMessage[] {
|
|
25
26
|
const items: EnrichedMessage[] = []
|
|
27
|
+
const messagesById = new Map(ms.map(message => [message.id, message]))
|
|
26
28
|
let myLatestSeen = false
|
|
27
29
|
let nextNeighborEnriched: MessageResource | undefined
|
|
28
30
|
|
|
@@ -41,7 +43,13 @@ export function groupMessages({ ms, inReplyScreen }: GroupMessagesProps): Enrich
|
|
|
41
43
|
const isMyLatest = !myLatestSeen && message.mine
|
|
42
44
|
if (isMyLatest) myLatestSeen = true
|
|
43
45
|
|
|
44
|
-
const enriched = enrichRegularMessage(
|
|
46
|
+
const enriched = enrichRegularMessage(
|
|
47
|
+
{ ...message, replyRoot: resolveReplyRoot(message, messagesById) },
|
|
48
|
+
prev,
|
|
49
|
+
next,
|
|
50
|
+
isMyLatest,
|
|
51
|
+
!!inReplyScreen
|
|
52
|
+
)
|
|
45
53
|
items.push(enriched)
|
|
46
54
|
|
|
47
55
|
const shadow = replyShadowFor(enriched, prev)
|
|
@@ -61,6 +69,14 @@ function neighborsOf<T>(arr: T[], i: number): { prev: T | undefined; next: T | u
|
|
|
61
69
|
return { prev: arr[i + 1], next: arr[i - 1] }
|
|
62
70
|
}
|
|
63
71
|
|
|
72
|
+
function resolveReplyRoot(
|
|
73
|
+
message: MessageResource,
|
|
74
|
+
messagesById: Map<string, MessageResource>
|
|
75
|
+
): MessageResource | null {
|
|
76
|
+
if (!message.replyRootId || message.replyRootId === message.id) return null
|
|
77
|
+
return messagesById.get(message.replyRootId) ?? message.replyRoot ?? null
|
|
78
|
+
}
|
|
79
|
+
|
|
64
80
|
function enrichSystemMessage(
|
|
65
81
|
message: MessageResource,
|
|
66
82
|
next: MessageResource | undefined
|
|
@@ -171,6 +187,7 @@ function replyShadowFor(
|
|
|
171
187
|
type: 'ReplyShadowMessage',
|
|
172
188
|
id: `${message.id}-${message.replyRootId}`,
|
|
173
189
|
messageId: message.replyRootId,
|
|
190
|
+
replyRoot: message.replyRoot ?? null,
|
|
174
191
|
isReplyShadowMessage: true,
|
|
175
192
|
nextRendersAuthor: message.renderAuthor ?? false,
|
|
176
193
|
}
|
|
@@ -29,6 +29,7 @@ export function transformMessageEventDataToMessageResource({
|
|
|
29
29
|
reactionCounts: [],
|
|
30
30
|
replyCount: data.reply_count || 0,
|
|
31
31
|
replyRootId: data.reply_root_id || null,
|
|
32
|
+
idempotentKey: data.idempotent_key ?? null,
|
|
32
33
|
messageType: data.message_type,
|
|
33
34
|
personIdsForSystemEvent: data.system_event_person_ids,
|
|
34
35
|
systemTextParts: data.system_text_parts
|
|
@@ -10,6 +10,7 @@ export const getMessageFields = {
|
|
|
10
10
|
'reaction_counts',
|
|
11
11
|
'reply_count',
|
|
12
12
|
'reply_root',
|
|
13
|
+
'idempotent_key',
|
|
13
14
|
'message_type',
|
|
14
15
|
'person_ids_for_system_event',
|
|
15
16
|
'system_text_parts',
|
|
@@ -18,4 +19,4 @@ export const getMessageFields = {
|
|
|
18
19
|
ReactionCount: ['value', 'count', 'mine', 'message_id', 'author_ids'],
|
|
19
20
|
}
|
|
20
21
|
|
|
21
|
-
export const getMessagesInclude = ['author', 'reaction_counts']
|
|
22
|
+
export const getMessagesInclude = ['author', 'reaction_counts', 'reply_root', 'reply_root.author']
|