@planningcenter/chat-react-native 4.0.1-qa-staging.0 → 4.0.1-qa-staging.1

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 (91) 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 +23 -22
  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 +47 -46
  16. package/build/components/conversation/reply_shadow_message.js.map +1 -1
  17. package/build/components/primitive/form_sheet.d.ts +2 -1
  18. package/build/components/primitive/form_sheet.d.ts.map +1 -1
  19. package/build/components/primitive/form_sheet.js +68 -53
  20. package/build/components/primitive/form_sheet.js.map +1 -1
  21. package/build/hooks/use_conversation_messages.d.ts.map +1 -1
  22. package/build/hooks/use_conversation_messages.js +40 -28
  23. package/build/hooks/use_conversation_messages.js.map +1 -1
  24. package/build/hooks/use_conversation_messages_jolt_events.d.ts.map +1 -1
  25. package/build/hooks/use_conversation_messages_jolt_events.js +6 -1
  26. package/build/hooks/use_conversation_messages_jolt_events.js.map +1 -1
  27. package/build/hooks/use_message_create_or_update.d.ts +99 -4
  28. package/build/hooks/use_message_create_or_update.d.ts.map +1 -1
  29. package/build/hooks/use_message_create_or_update.js +47 -6
  30. package/build/hooks/use_message_create_or_update.js.map +1 -1
  31. package/build/screens/conversation_screen.d.ts.map +1 -1
  32. package/build/screens/conversation_screen.js +184 -365
  33. package/build/screens/conversation_screen.js.map +1 -1
  34. package/build/screens/conversation_select_type_screen.d.ts.map +1 -1
  35. package/build/screens/conversation_select_type_screen.js +82 -98
  36. package/build/screens/conversation_select_type_screen.js.map +1 -1
  37. package/build/types/resources/message.d.ts +2 -0
  38. package/build/types/resources/message.d.ts.map +1 -1
  39. package/build/types/resources/message.js.map +1 -1
  40. package/build/utils/cache/messages_cache.d.ts +1 -0
  41. package/build/utils/cache/messages_cache.d.ts.map +1 -1
  42. package/build/utils/cache/messages_cache.js +14 -7
  43. package/build/utils/cache/messages_cache.js.map +1 -1
  44. package/build/utils/cache/optimistically_create_message.d.ts +2 -1
  45. package/build/utils/cache/optimistically_create_message.d.ts.map +1 -1
  46. package/build/utils/cache/optimistically_create_message.js +4 -2
  47. package/build/utils/cache/optimistically_create_message.js.map +1 -1
  48. package/build/utils/client/transform_response.d.ts.map +1 -1
  49. package/build/utils/client/transform_response.js +9 -5
  50. package/build/utils/client/transform_response.js.map +1 -1
  51. package/build/utils/group_messages.d.ts +1 -0
  52. package/build/utils/group_messages.d.ts.map +1 -1
  53. package/build/utils/group_messages.js +10 -1
  54. package/build/utils/group_messages.js.map +1 -1
  55. package/build/utils/jolt/transform_message_event_data_to_message_resource.d.ts.map +1 -1
  56. package/build/utils/jolt/transform_message_event_data_to_message_resource.js +1 -0
  57. package/build/utils/jolt/transform_message_event_data_to_message_resource.js.map +1 -1
  58. package/build/utils/replace_equal_by_id.d.ts +6 -0
  59. package/build/utils/replace_equal_by_id.d.ts.map +1 -0
  60. package/build/utils/replace_equal_by_id.js +41 -0
  61. package/build/utils/replace_equal_by_id.js.map +1 -0
  62. package/build/utils/request/messages_data_options.d.ts.map +1 -1
  63. package/build/utils/request/messages_data_options.js +2 -2
  64. package/build/utils/request/messages_data_options.js.map +1 -1
  65. package/package.json +3 -3
  66. package/src/components/conversation/__tests__/message_list.test.tsx +271 -0
  67. package/src/components/conversation/__tests__/reply_shadow_message.test.tsx +119 -0
  68. package/src/components/conversation/message.tsx +15 -18
  69. package/src/components/conversation/message_list.tsx +157 -0
  70. package/src/components/conversation/message_read_receipts.tsx +3 -5
  71. package/src/components/conversation/reply_shadow_message.tsx +9 -7
  72. package/src/components/primitive/form_sheet.tsx +10 -3
  73. package/src/hooks/__tests__/use_message_create_or_update.test.tsx +110 -0
  74. package/src/hooks/use_conversation_messages.ts +5 -1
  75. package/src/hooks/use_conversation_messages_jolt_events.ts +7 -0
  76. package/src/hooks/use_message_create_or_update.ts +29 -16
  77. package/src/screens/conversation_screen.tsx +11 -104
  78. package/src/screens/conversation_select_type_screen.tsx +3 -9
  79. package/src/types/resources/message.ts +2 -0
  80. package/src/utils/__tests__/group_messages.test.ts +48 -0
  81. package/src/utils/__tests__/replace_equal_by_id.test.ts +73 -0
  82. package/src/utils/cache/__tests__/messages_cache.test.ts +89 -0
  83. package/src/utils/cache/__tests__/messages_cache_reply_roots.test.ts +70 -0
  84. package/src/utils/cache/messages_cache.ts +27 -11
  85. package/src/utils/cache/optimistically_create_message.ts +3 -0
  86. package/src/utils/client/__tests__/transform_response.test.ts +62 -0
  87. package/src/utils/client/transform_response.ts +7 -5
  88. package/src/utils/group_messages.ts +18 -1
  89. package/src/utils/jolt/transform_message_event_data_to_message_resource.ts +1 -0
  90. package/src/utils/replace_equal_by_id.ts +42 -0
  91. package/src/utils/request/messages_data_options.ts +2 -1
@@ -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,18 +37,20 @@ 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,
44
+ replyRoot,
44
45
  isReplyShadowMessage,
45
46
  nextRendersAuthor,
46
47
  }: ReplyShadowMessageProps) {
47
- const { message, isError, isLoading } = useConversationMessage({ conversation_id, messageId })
48
+ const fetched = useConversationMessage({ conversation_id, messageId, enabled: !replyRoot })
49
+ const message = replyRoot ?? fetched.message
48
50
 
49
51
  if (inReplyScreen) return null
50
- if (isLoading) return <ShadowMessageFallback text="Loading..." />
51
- if (isError || !message) return <ShadowMessageFallback text="Message deleted" />
52
+ if (!message && fetched.isLoading) return <ShadowMessageFallback text="Loading..." />
53
+ if (!message) return <ShadowMessageFallback text="Message deleted" />
52
54
 
53
55
  const enrichedMessage = {
54
56
  ...message,
@@ -57,7 +59,7 @@ export function ReplyShadowMessage({
57
59
  }
58
60
 
59
61
  return <ShadowMessageContent conversation_id={conversation_id} {...enrichedMessage} />
60
- }
62
+ })
61
63
 
62
64
  interface ShadowMessageContentProps extends MessageResource {
63
65
  conversation_id: number
@@ -82,7 +84,7 @@ function ShadowMessageContent({ conversation_id, ...message }: ShadowMessageCont
82
84
  const handleNavigateToReplies = () => {
83
85
  navigation.navigate('ConversationReply', {
84
86
  conversation_id,
85
- reply_root_id: message.replyRootId,
87
+ reply_root_id: message.replyRootId ?? message.id,
86
88
  reply_root_author_name: message.author.name,
87
89
  })
88
90
  }
@@ -85,6 +85,7 @@ interface FormSheetRootProps {
85
85
  style?: StyleProp<ViewStyle>
86
86
  contentStyle?: StyleProp<ViewStyle>
87
87
  showAndroidSheetGrabber?: boolean
88
+ fitToContents?: boolean
88
89
  }
89
90
 
90
91
  export function FormSheetRoot({
@@ -92,8 +93,9 @@ export function FormSheetRoot({
92
93
  style,
93
94
  contentStyle,
94
95
  showAndroidSheetGrabber = true,
96
+ fitToContents = false,
95
97
  }: FormSheetRootProps) {
96
- const styles = useStyles()
98
+ const styles = useStyles({ fitToContents })
97
99
 
98
100
  return (
99
101
  <View style={[styles.container, style]} collapsable={false}>
@@ -298,16 +300,21 @@ FormSheetAction.displayName = 'FormSheet.Action'
298
300
  interface Styles {
299
301
  appearance?: FormSheetActionAppearanceUnion
300
302
  disabled?: boolean
303
+ fitToContents?: boolean
301
304
  }
302
305
 
303
- const useStyles = ({ appearance = 'neutral', disabled = false }: Styles = {}) => {
306
+ const useStyles = ({
307
+ appearance = 'neutral',
308
+ disabled = false,
309
+ fitToContents = false,
310
+ }: Styles = {}) => {
304
311
  const { colors } = useTheme()
305
312
  const { height } = useWindowDimensions()
306
313
  const { bottom, top } = useSafeAreaInsets()
307
314
  const headerHeight = useHeaderHeight()
308
315
 
309
316
  const containerHeight = Platform.select({
310
- ios: height - top - headerHeight,
317
+ ios: fitToContents ? undefined : height - top - headerHeight,
311
318
  default: null,
312
319
  })
313
320
 
@@ -0,0 +1,110 @@
1
+ import { InfiniteData, QueryClientProvider, notifyManager } from '@tanstack/react-query'
2
+ import { act, renderHook } from '@testing-library/react-native'
3
+ import React from 'react'
4
+ import { chatQueryClient } from '../../contexts/api_provider'
5
+ import type { ApiCollection, MessageResource } from '../../types'
6
+ import { getMessagesQueryKey } from '../../utils/request/get_messages'
7
+ import { useMessageCreateOrUpdate } from '../use_message_create_or_update'
8
+
9
+ jest.mock('../../contexts/api_provider', () => {
10
+ const { QueryClient } = require('@tanstack/react-query')
11
+ // gcTime 0 so nothing outlives the test and keeps jest from exiting
12
+ return {
13
+ chatQueryClient: new QueryClient({
14
+ defaultOptions: { queries: { gcTime: 0 }, mutations: { gcTime: 0 } },
15
+ }),
16
+ }
17
+ })
18
+
19
+ const mockPost = jest.fn()
20
+ jest.mock('../use_api_client', () => ({
21
+ useApiClient: () => ({ chat: { post: mockPost, patch: jest.fn() } }),
22
+ }))
23
+
24
+ jest.mock('../use_current_person', () => ({
25
+ useCurrentPerson: () => ({ type: 'Person', id: 1, name: 'Me', avatar: null }),
26
+ }))
27
+
28
+ // Flush TanStack's batched state updates inside act, as its own tests do
29
+ notifyManager.setNotifyFunction(fn => act(fn))
30
+
31
+ type MessagesQueryData = InfiniteData<ApiCollection<MessageResource>>
32
+
33
+ const conversationId = 7
34
+ const queryKey = getMessagesQueryKey({ conversation_id: conversationId })
35
+
36
+ const wrapper = ({ children }: { children: React.ReactNode }) => (
37
+ <QueryClientProvider client={chatQueryClient}>{children}</QueryClientProvider>
38
+ )
39
+
40
+ const pendingMessagesInCache = () =>
41
+ (chatQueryClient.getQueryData<MessagesQueryData>(queryKey)?.pages[0].data ?? []).filter(
42
+ m => m.pending
43
+ )
44
+
45
+ const sentKey = (call: number): string =>
46
+ mockPost.mock.calls[call][0].data.data.attributes.idempotent_key
47
+
48
+ // What the cache held when each request went out, i.e. after onMutate ran
49
+ let pendingAtSend: MessageResource[][]
50
+
51
+ describe('useMessageCreateOrUpdate idempotent key', () => {
52
+ beforeEach(() => {
53
+ chatQueryClient.clear()
54
+ chatQueryClient.setQueryData<MessagesQueryData>(queryKey, {
55
+ pages: [{ data: [], links: {}, meta: { count: 0, totalCount: 0 } }],
56
+ pageParams: [],
57
+ })
58
+ pendingAtSend = []
59
+ mockPost.mockReset()
60
+ mockPost.mockImplementation(async request => {
61
+ pendingAtSend.push(pendingMessagesInCache())
62
+ const key = request.data.data.attributes.idempotent_key
63
+ return { data: { type: 'Message', id: `real-${key}`, idempotentKey: key, mine: true } }
64
+ })
65
+ })
66
+
67
+ it('gives the optimistic message the same key that is sent to the server', async () => {
68
+ const { result } = renderHook(() => useMessageCreateOrUpdate({ conversationId }), { wrapper })
69
+
70
+ await act(() => result.current.mutateAsync({ text: 'ok' }))
71
+
72
+ expect(sentKey(0)).toEqual(expect.any(String))
73
+ expect(pendingAtSend[0].map(m => m.idempotentKey)).toEqual([sentKey(0)])
74
+ })
75
+
76
+ it('mints a distinct key for each send, even with identical text', async () => {
77
+ const { result } = renderHook(() => useMessageCreateOrUpdate({ conversationId }), { wrapper })
78
+
79
+ await act(() =>
80
+ Promise.all([
81
+ result.current.mutateAsync({ text: 'ok' }),
82
+ result.current.mutateAsync({ text: 'ok' }),
83
+ ])
84
+ )
85
+
86
+ expect(sentKey(0)).not.toBe(sentKey(1))
87
+ expect(pendingAtSend[1].map(m => m.idempotentKey).sort()).toEqual(
88
+ [sentKey(0), sentKey(1)].sort()
89
+ )
90
+ })
91
+
92
+ it('reuses the original key when retrying a failed send', async () => {
93
+ const failed = {
94
+ type: 'Message',
95
+ id: '01FAILED-temp',
96
+ text: 'ok',
97
+ idempotentKey: 'original-key',
98
+ error: 'Failed to send message',
99
+ } as MessageResource
100
+ const { result } = renderHook(
101
+ () => useMessageCreateOrUpdate({ conversationId, message: failed }),
102
+ { wrapper }
103
+ )
104
+
105
+ await act(() => result.current.mutateAsync({ text: 'ok' }))
106
+
107
+ expect(sentKey(0)).toBe('original-key')
108
+ expect(pendingAtSend[0].map(m => m.idempotentKey)).toEqual(['original-key'])
109
+ })
110
+ })
@@ -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(
@@ -11,6 +11,7 @@ import {
11
11
  updateCacheWithMessage,
12
12
  updateCacheWithIndividualMessage,
13
13
  updateCacheWithReaction,
14
+ updateCacheReplyRoots,
14
15
  getThreadedMessagesQueryKey,
15
16
  } from '../utils/cache/messages_cache'
16
17
  import { transformMessageEventDataToMessageResource } from '../utils/jolt/transform_message_event_data_to_message_resource'
@@ -44,6 +45,7 @@ export function useConversationMessagesJoltEvents({ conversationId }: Props) {
44
45
  data,
45
46
  currentPersonId: currentPerson.id,
46
47
  })
48
+ const isReplyRootUpdate = e.event === 'message.updated' && data.reply_root_id === data.sort_key
47
49
 
48
50
  if (e.event === 'message.created' && data.author_id) {
49
51
  removeAllTypingEventsByAuthorId(data.author_id)
@@ -62,6 +64,11 @@ export function useConversationMessagesJoltEvents({ conversationId }: Props) {
62
64
  data.reply_root_id
63
65
  )
64
66
  updateCacheWithMessage(queryClient, threadedMessagesQueryKey, message, e.event)
67
+
68
+ if (isReplyRootUpdate) {
69
+ updateCacheReplyRoots(queryClient, messagesQueryKey, message)
70
+ updateCacheReplyRoots(queryClient, threadedMessagesQueryKey, message)
71
+ }
65
72
  }
66
73
 
67
74
  // Update individual message query cache so reply shadow messages update immediately
@@ -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
  /**
@@ -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
 
@@ -1,5 +1,5 @@
1
1
  import { StaticScreenProps, useNavigation } from '@react-navigation/native'
2
- import React, { useLayoutEffect } from 'react'
2
+ import React from 'react'
3
3
  import FormSheet, { getFormSheetScreenOptions } from '../components/primitive/form_sheet'
4
4
  import { useCurrentPerson } from '../hooks/use_current_person'
5
5
  import { useConversationTypeAccess } from '../hooks/use_new_conversation_entry'
@@ -10,6 +10,7 @@ import { Haptic } from '../utils/native_adapters'
10
10
 
11
11
  export const ConversationSelectTypeScreenOptions = getFormSheetScreenOptions({
12
12
  headerTitle: 'New conversation',
13
+ sheetAllowedDetents: 'fitToContents',
13
14
  })
14
15
 
15
16
  export type ConversationSelectTypeScreenProps = StaticScreenProps<{
@@ -34,13 +35,6 @@ export function ConversationSelectTypeScreen({ route }: ConversationSelectTypeSc
34
35
  const filterParams = { chat_group_graph_id, group_source_app_name }
35
36
  const showDirectMessageOption = directMessagesEligible
36
37
 
37
- // sheetAllowedDetents depends on DM eligibility, which isn't known until render, so it's set
38
- // here instead of in the static ConversationSelectTypeScreenOptions. useLayoutEffect applies
39
- // it before paint so the sheet never visibly resizes after opening.
40
- useLayoutEffect(() => {
41
- navigation.setOptions({ sheetAllowedDetents: showDirectMessageOption ? [0.3] : [0.25] })
42
- }, [navigation, showDirectMessageOption])
43
-
44
38
  const handleFilterPress = () => {
45
39
  if (!newConversationFromFilter) return
46
40
 
@@ -82,7 +76,7 @@ export function ConversationSelectTypeScreen({ route }: ConversationSelectTypeSc
82
76
  navigateToNewConversationType('ConversationSelectDirectMessageRecipients')
83
77
 
84
78
  return (
85
- <FormSheet.Root>
79
+ <FormSheet.Root fitToContents>
86
80
  <FormSheet.Header>
87
81
  <FormSheet.HeaderTitle>Start a conversation by</FormSheet.HeaderTitle>
88
82
  </FormSheet.Header>
@@ -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 = [