@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.
Files changed (56) hide show
  1. package/build/components/conversation/message.d.ts.map +1 -1
  2. package/build/components/conversation/message.js +8 -7
  3. package/build/components/conversation/message.js.map +1 -1
  4. package/build/components/conversation/reply_shadow_message.d.ts +1 -1
  5. package/build/components/conversation/reply_shadow_message.d.ts.map +1 -1
  6. package/build/components/conversation/reply_shadow_message.js +44 -43
  7. package/build/components/conversation/reply_shadow_message.js.map +1 -1
  8. package/build/hooks/use_conversation_messages_jolt_events.d.ts.map +1 -1
  9. package/build/hooks/use_conversation_messages_jolt_events.js +6 -1
  10. package/build/hooks/use_conversation_messages_jolt_events.js.map +1 -1
  11. package/build/hooks/use_message_create_or_update.d.ts +99 -4
  12. package/build/hooks/use_message_create_or_update.d.ts.map +1 -1
  13. package/build/hooks/use_message_create_or_update.js +47 -6
  14. package/build/hooks/use_message_create_or_update.js.map +1 -1
  15. package/build/types/resources/message.d.ts +2 -0
  16. package/build/types/resources/message.d.ts.map +1 -1
  17. package/build/types/resources/message.js.map +1 -1
  18. package/build/utils/cache/messages_cache.d.ts +1 -0
  19. package/build/utils/cache/messages_cache.d.ts.map +1 -1
  20. package/build/utils/cache/messages_cache.js +14 -7
  21. package/build/utils/cache/messages_cache.js.map +1 -1
  22. package/build/utils/cache/optimistically_create_message.d.ts +2 -1
  23. package/build/utils/cache/optimistically_create_message.d.ts.map +1 -1
  24. package/build/utils/cache/optimistically_create_message.js +4 -2
  25. package/build/utils/cache/optimistically_create_message.js.map +1 -1
  26. package/build/utils/client/transform_response.d.ts.map +1 -1
  27. package/build/utils/client/transform_response.js +9 -5
  28. package/build/utils/client/transform_response.js.map +1 -1
  29. package/build/utils/group_messages.d.ts +1 -0
  30. package/build/utils/group_messages.d.ts.map +1 -1
  31. package/build/utils/group_messages.js +10 -1
  32. package/build/utils/group_messages.js.map +1 -1
  33. package/build/utils/jolt/transform_message_event_data_to_message_resource.d.ts.map +1 -1
  34. package/build/utils/jolt/transform_message_event_data_to_message_resource.js +1 -0
  35. package/build/utils/jolt/transform_message_event_data_to_message_resource.js.map +1 -1
  36. package/build/utils/request/messages_data_options.d.ts.map +1 -1
  37. package/build/utils/request/messages_data_options.js +2 -2
  38. package/build/utils/request/messages_data_options.js.map +1 -1
  39. package/package.json +3 -3
  40. package/src/components/conversation/__tests__/reply_shadow_message.test.tsx +119 -0
  41. package/src/components/conversation/message.tsx +8 -7
  42. package/src/components/conversation/reply_shadow_message.tsx +6 -4
  43. package/src/hooks/__tests__/use_message_create_or_update.test.tsx +110 -0
  44. package/src/hooks/use_conversation_messages_jolt_events.ts +7 -0
  45. package/src/hooks/use_message_create_or_update.ts +29 -16
  46. package/src/types/resources/message.ts +2 -0
  47. package/src/utils/__tests__/group_messages.test.ts +48 -0
  48. package/src/utils/cache/__tests__/messages_cache.test.ts +89 -0
  49. package/src/utils/cache/__tests__/messages_cache_reply_roots.test.ts +71 -0
  50. package/src/utils/cache/messages_cache.ts +27 -11
  51. package/src/utils/cache/optimistically_create_message.ts +3 -0
  52. package/src/utils/client/__tests__/transform_response.test.ts +62 -0
  53. package/src/utils/client/transform_response.ts +7 -5
  54. package/src/utils/group_messages.ts +18 -1
  55. package/src/utils/jolt/transform_message_event_data_to_message_resource.ts +1 -0
  56. 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
- return mutation
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 { updateOrCreateRecordInPagesData, updateRecordInPagesData } from './page_mutations'
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 any pending temporary messages
19
- // with matching text to prevent duplicates from race conditions
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.text && message.mine) {
25
+ if (prev && message.mine && message.idempotentKey) {
22
26
  dataAfterTempRemoval = deleteRecordInPagesData({
23
27
  data: prev,
24
28
  record: message,
25
- matchFn: (existingMessage, _record) => {
26
- return (
27
- isTemporaryMessageId(existingMessage.id) &&
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 = (relationships, included, seen, parent) => {
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, record => transformRecord(record, included, seen))
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 record = _.find(included, { id, type })
52
- return _.set(acc, key, record ? transformRecord(record, included, seen) : null)
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(relationships, included, seen, transformed)
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(message, prev, next, isMyLatest, !!inReplyScreen)
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']