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

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 (94) hide show
  1. package/build/components/conversation/message.d.ts +2 -3
  2. package/build/components/conversation/message.d.ts.map +1 -1
  3. package/build/components/conversation/message.js +22 -23
  4. package/build/components/conversation/message.js.map +1 -1
  5. package/build/components/conversation/message_read_receipts.d.ts +4 -2
  6. package/build/components/conversation/message_read_receipts.d.ts.map +1 -1
  7. package/build/components/conversation/message_read_receipts.js +3 -2
  8. package/build/components/conversation/message_read_receipts.js.map +1 -1
  9. package/build/components/conversation/reply_shadow_message.d.ts +1 -2
  10. package/build/components/conversation/reply_shadow_message.d.ts.map +1 -1
  11. package/build/components/conversation/reply_shadow_message.js +46 -47
  12. package/build/components/conversation/reply_shadow_message.js.map +1 -1
  13. package/build/components/primitive/form_sheet.d.ts +1 -2
  14. package/build/components/primitive/form_sheet.d.ts.map +1 -1
  15. package/build/components/primitive/form_sheet.js +53 -68
  16. package/build/components/primitive/form_sheet.js.map +1 -1
  17. package/build/hooks/use_conversation_messages.d.ts.map +1 -1
  18. package/build/hooks/use_conversation_messages.js +28 -40
  19. package/build/hooks/use_conversation_messages.js.map +1 -1
  20. package/build/hooks/use_conversation_messages_jolt_events.d.ts.map +1 -1
  21. package/build/hooks/use_conversation_messages_jolt_events.js +1 -6
  22. package/build/hooks/use_conversation_messages_jolt_events.js.map +1 -1
  23. package/build/hooks/use_message_create_or_update.d.ts +4 -99
  24. package/build/hooks/use_message_create_or_update.d.ts.map +1 -1
  25. package/build/hooks/use_message_create_or_update.js +6 -47
  26. package/build/hooks/use_message_create_or_update.js.map +1 -1
  27. package/build/screens/conversation_new/components/groups_form.js +80 -71
  28. package/build/screens/conversation_new/components/groups_form.js.map +1 -1
  29. package/build/screens/conversation_screen.d.ts.map +1 -1
  30. package/build/screens/conversation_screen.js +365 -184
  31. package/build/screens/conversation_screen.js.map +1 -1
  32. package/build/screens/conversation_select_type_screen.d.ts.map +1 -1
  33. package/build/screens/conversation_select_type_screen.js +98 -82
  34. package/build/screens/conversation_select_type_screen.js.map +1 -1
  35. package/build/types/resources/message.d.ts +0 -2
  36. package/build/types/resources/message.d.ts.map +1 -1
  37. package/build/types/resources/message.js.map +1 -1
  38. package/build/utils/cache/messages_cache.d.ts +0 -1
  39. package/build/utils/cache/messages_cache.d.ts.map +1 -1
  40. package/build/utils/cache/messages_cache.js +7 -14
  41. package/build/utils/cache/messages_cache.js.map +1 -1
  42. package/build/utils/cache/optimistically_create_message.d.ts +1 -2
  43. package/build/utils/cache/optimistically_create_message.d.ts.map +1 -1
  44. package/build/utils/cache/optimistically_create_message.js +2 -4
  45. package/build/utils/cache/optimistically_create_message.js.map +1 -1
  46. package/build/utils/client/transform_response.d.ts.map +1 -1
  47. package/build/utils/client/transform_response.js +5 -9
  48. package/build/utils/client/transform_response.js.map +1 -1
  49. package/build/utils/group_messages.d.ts +0 -1
  50. package/build/utils/group_messages.d.ts.map +1 -1
  51. package/build/utils/group_messages.js +1 -10
  52. package/build/utils/group_messages.js.map +1 -1
  53. package/build/utils/jolt/transform_message_event_data_to_message_resource.d.ts.map +1 -1
  54. package/build/utils/jolt/transform_message_event_data_to_message_resource.js +0 -1
  55. package/build/utils/jolt/transform_message_event_data_to_message_resource.js.map +1 -1
  56. package/build/utils/request/messages_data_options.d.ts.map +1 -1
  57. package/build/utils/request/messages_data_options.js +2 -2
  58. package/build/utils/request/messages_data_options.js.map +1 -1
  59. package/package.json +3 -3
  60. package/src/components/conversation/message.tsx +18 -15
  61. package/src/components/conversation/message_read_receipts.tsx +5 -3
  62. package/src/components/conversation/reply_shadow_message.tsx +7 -9
  63. package/src/components/primitive/form_sheet.tsx +3 -10
  64. package/src/hooks/use_conversation_messages.ts +1 -5
  65. package/src/hooks/use_conversation_messages_jolt_events.ts +0 -7
  66. package/src/hooks/use_message_create_or_update.ts +16 -29
  67. package/src/screens/conversation_new/components/groups_form.tsx +7 -2
  68. package/src/screens/conversation_screen.tsx +104 -11
  69. package/src/screens/conversation_select_type_screen.tsx +9 -3
  70. package/src/types/resources/message.ts +0 -2
  71. package/src/utils/__tests__/group_messages.test.ts +0 -48
  72. package/src/utils/cache/messages_cache.ts +11 -27
  73. package/src/utils/cache/optimistically_create_message.ts +0 -3
  74. package/src/utils/client/transform_response.ts +5 -7
  75. package/src/utils/group_messages.ts +1 -18
  76. package/src/utils/jolt/transform_message_event_data_to_message_resource.ts +0 -1
  77. package/src/utils/request/messages_data_options.ts +1 -2
  78. package/build/components/conversation/message_list.d.ts +0 -21
  79. package/build/components/conversation/message_list.d.ts.map +0 -1
  80. package/build/components/conversation/message_list.js +0 -273
  81. package/build/components/conversation/message_list.js.map +0 -1
  82. package/build/utils/replace_equal_by_id.d.ts +0 -6
  83. package/build/utils/replace_equal_by_id.d.ts.map +0 -1
  84. package/build/utils/replace_equal_by_id.js +0 -41
  85. package/build/utils/replace_equal_by_id.js.map +0 -1
  86. package/src/components/conversation/__tests__/message_list.test.tsx +0 -271
  87. package/src/components/conversation/__tests__/reply_shadow_message.test.tsx +0 -119
  88. package/src/components/conversation/message_list.tsx +0 -157
  89. package/src/hooks/__tests__/use_message_create_or_update.test.tsx +0 -110
  90. package/src/utils/__tests__/replace_equal_by_id.test.ts +0 -73
  91. package/src/utils/cache/__tests__/messages_cache.test.ts +0 -89
  92. package/src/utils/cache/__tests__/messages_cache_reply_roots.test.ts +0 -70
  93. package/src/utils/client/__tests__/transform_response.test.ts +0 -62
  94. package/src/utils/replace_equal_by_id.ts +0 -42
@@ -1,6 +1,6 @@
1
1
  import { useNavigation } from '@react-navigation/native'
2
2
  import { some } from 'lodash'
3
- import React, { memo } from 'react'
3
+ import React from 'react'
4
4
  import { Pressable, StyleSheet, useWindowDimensions, View } from 'react-native'
5
5
  import Animated from 'react-native-reanimated'
6
6
  import {
@@ -37,20 +37,18 @@ interface ReplyShadowMessageProps extends MessageResource {
37
37
  nextRendersAuthor: boolean
38
38
  }
39
39
 
40
- export const ReplyShadowMessage = memo(function ReplyShadowMessageRow({
40
+ export function ReplyShadowMessage({
41
41
  conversation_id,
42
42
  inReplyScreen,
43
43
  messageId,
44
- replyRoot,
45
44
  isReplyShadowMessage,
46
45
  nextRendersAuthor,
47
46
  }: ReplyShadowMessageProps) {
48
- const fetched = useConversationMessage({ conversation_id, messageId, enabled: !replyRoot })
49
- const message = replyRoot ?? fetched.message
47
+ const { message, isError, isLoading } = useConversationMessage({ conversation_id, messageId })
50
48
 
51
49
  if (inReplyScreen) return null
52
- if (!message && fetched.isLoading) return <ShadowMessageFallback text="Loading..." />
53
- if (!message) return <ShadowMessageFallback text="Message deleted" />
50
+ if (isLoading) return <ShadowMessageFallback text="Loading..." />
51
+ if (isError || !message) return <ShadowMessageFallback text="Message deleted" />
54
52
 
55
53
  const enrichedMessage = {
56
54
  ...message,
@@ -59,7 +57,7 @@ export const ReplyShadowMessage = memo(function ReplyShadowMessageRow({
59
57
  }
60
58
 
61
59
  return <ShadowMessageContent conversation_id={conversation_id} {...enrichedMessage} />
62
- })
60
+ }
63
61
 
64
62
  interface ShadowMessageContentProps extends MessageResource {
65
63
  conversation_id: number
@@ -84,7 +82,7 @@ function ShadowMessageContent({ conversation_id, ...message }: ShadowMessageCont
84
82
  const handleNavigateToReplies = () => {
85
83
  navigation.navigate('ConversationReply', {
86
84
  conversation_id,
87
- reply_root_id: message.replyRootId ?? message.id,
85
+ reply_root_id: message.replyRootId,
88
86
  reply_root_author_name: message.author.name,
89
87
  })
90
88
  }
@@ -85,7 +85,6 @@ interface FormSheetRootProps {
85
85
  style?: StyleProp<ViewStyle>
86
86
  contentStyle?: StyleProp<ViewStyle>
87
87
  showAndroidSheetGrabber?: boolean
88
- fitToContents?: boolean
89
88
  }
90
89
 
91
90
  export function FormSheetRoot({
@@ -93,9 +92,8 @@ export function FormSheetRoot({
93
92
  style,
94
93
  contentStyle,
95
94
  showAndroidSheetGrabber = true,
96
- fitToContents = false,
97
95
  }: FormSheetRootProps) {
98
- const styles = useStyles({ fitToContents })
96
+ const styles = useStyles()
99
97
 
100
98
  return (
101
99
  <View style={[styles.container, style]} collapsable={false}>
@@ -300,21 +298,16 @@ FormSheetAction.displayName = 'FormSheet.Action'
300
298
  interface Styles {
301
299
  appearance?: FormSheetActionAppearanceUnion
302
300
  disabled?: boolean
303
- fitToContents?: boolean
304
301
  }
305
302
 
306
- const useStyles = ({
307
- appearance = 'neutral',
308
- disabled = false,
309
- fitToContents = false,
310
- }: Styles = {}) => {
303
+ const useStyles = ({ appearance = 'neutral', disabled = false }: Styles = {}) => {
311
304
  const { colors } = useTheme()
312
305
  const { height } = useWindowDimensions()
313
306
  const { bottom, top } = useSafeAreaInsets()
314
307
  const headerHeight = useHeaderHeight()
315
308
 
316
309
  const containerHeight = Platform.select({
317
- ios: fitToContents ? undefined : height - top - headerHeight,
310
+ ios: height - top - headerHeight,
318
311
  default: null,
319
312
  })
320
313
 
@@ -1,6 +1,5 @@
1
1
  import { useMemo } from 'react'
2
2
  import { MessageResource } from '../types'
3
- import { replaceEqualById } from '../utils/replace_equal_by_id'
4
3
  import { getMessagesQueryKey, getMessagesRequestArgs } from '../utils/request/get_messages'
5
4
  import { SuspensePaginatorOptions, useSuspensePaginator } from './use_suspense_api'
6
5
 
@@ -11,10 +10,7 @@ export const useConversationMessages = (
11
10
  const { data, refetch, isRefetching, fetchNextPage, hasNextPage } =
12
11
  useSuspensePaginator<MessageResource>(
13
12
  getMessagesRequestArgs({ conversation_id, reply_root_id }),
14
- {
15
- structuralSharing: replaceEqualById,
16
- ...opts,
17
- }
13
+ opts
18
14
  )
19
15
  const queryKey = getMessagesQueryKey({ conversation_id, reply_root_id })
20
16
  const messages = useMemo(
@@ -11,7 +11,6 @@ import {
11
11
  updateCacheWithMessage,
12
12
  updateCacheWithIndividualMessage,
13
13
  updateCacheWithReaction,
14
- updateCacheReplyRoots,
15
14
  getThreadedMessagesQueryKey,
16
15
  } from '../utils/cache/messages_cache'
17
16
  import { transformMessageEventDataToMessageResource } from '../utils/jolt/transform_message_event_data_to_message_resource'
@@ -45,7 +44,6 @@ export function useConversationMessagesJoltEvents({ conversationId }: Props) {
45
44
  data,
46
45
  currentPersonId: currentPerson.id,
47
46
  })
48
- const isReplyRootUpdate = e.event === 'message.updated' && data.reply_root_id === data.sort_key
49
47
 
50
48
  if (e.event === 'message.created' && data.author_id) {
51
49
  removeAllTypingEventsByAuthorId(data.author_id)
@@ -64,11 +62,6 @@ export function useConversationMessagesJoltEvents({ conversationId }: Props) {
64
62
  data.reply_root_id
65
63
  )
66
64
  updateCacheWithMessage(queryClient, threadedMessagesQueryKey, message, e.event)
67
-
68
- if (isReplyRootUpdate) {
69
- updateCacheReplyRoots(queryClient, messagesQueryKey, message)
70
- updateCacheReplyRoots(queryClient, threadedMessagesQueryKey, message)
71
- }
72
65
  }
73
66
 
74
67
  // Update individual message query cache so reply shadow messages update immediately
@@ -22,14 +22,6 @@ 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
-
33
25
  export function useMessageCreateOrUpdate({ conversationId, message, replyRootId }: Props) {
34
26
  const messageId = message?.id || null
35
27
  const isEditing = !isNewMessage(message)
@@ -37,7 +29,13 @@ export function useMessageCreateOrUpdate({ conversationId, message, replyRootId
37
29
  const currentPerson = useCurrentPerson()
38
30
 
39
31
  const mutation = useMutation({
40
- mutationFn: ({ text, attachments, idempotentKey }: ResolvedMessageMutationVariables) => {
32
+ mutationFn: ({
33
+ text,
34
+ attachments,
35
+ }: {
36
+ text: string
37
+ attachments?: DenormalizedAttachmentResourceForCreate[]
38
+ }) => {
41
39
  const requestParams = getMessagesRequestArgs({ conversation_id: conversationId })
42
40
  const fieldsWithValueJoined = Object.fromEntries(
43
41
  Object.entries(requestParams.data.fields).map(([k, v]) => [k, v.join(',')])
@@ -48,6 +46,7 @@ export function useMessageCreateOrUpdate({ conversationId, message, replyRootId
48
46
  ...(!isEditing && replyRootId ? { reply_root: { id: replyRootId } } : {}),
49
47
  }
50
48
  if (!isEditing) {
49
+ const idempotentKey = insecureUUID()
51
50
  attributes.idempotent_key = idempotentKey
52
51
  startMessageCreationTracking(idempotentKey)
53
52
  }
@@ -72,7 +71,13 @@ export function useMessageCreateOrUpdate({ conversationId, message, replyRootId
72
71
  })
73
72
  }
74
73
  },
75
- onMutate: async ({ text, attachments, idempotentKey }: ResolvedMessageMutationVariables) => {
74
+ onMutate: async ({
75
+ text,
76
+ attachments,
77
+ }: {
78
+ text: string
79
+ attachments?: DenormalizedAttachmentResourceForCreate[]
80
+ }) => {
76
81
  if (message && isEditing) {
77
82
  const optimisticMessage = optimisticallyUpdateMessage({
78
83
  conversationId,
@@ -91,7 +96,6 @@ export function useMessageCreateOrUpdate({ conversationId, message, replyRootId
91
96
  attachments,
92
97
  currentPerson,
93
98
  replyRootId,
94
- idempotentKey,
95
99
  })
96
100
 
97
101
  return { message: optimisticMessage }
@@ -151,24 +155,7 @@ export function useMessageCreateOrUpdate({ conversationId, message, replyRootId
151
155
  },
152
156
  })
153
157
 
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 }
158
+ return mutation
172
159
  }
173
160
 
174
161
  /**
@@ -212,9 +212,11 @@ function FormContent({
212
212
  </View>
213
213
  <Divider />
214
214
  <Pressable style={styles.titleSection} onPress={handleTitleSectionPress}>
215
- <Heading variant="h3">Title</Heading>
215
+ <Heading variant="h3">
216
+ Title <Text style={styles.required}>*</Text>
217
+ </Heading>
216
218
  <AutoFocusAwareTextInput
217
- placeholder="Topic of conversation (required)"
219
+ placeholder="Topic of conversation"
218
220
  value={title}
219
221
  onChangeText={setTitle}
220
222
  style={styles.titleInput}
@@ -291,6 +293,9 @@ const useStyles = () => {
291
293
  fontSize: 18,
292
294
  color: colors.textColorDefaultPrimary,
293
295
  },
296
+ required: {
297
+ color: colors.statusErrorText,
298
+ },
294
299
  memberSection: {
295
300
  padding: sectionPadding,
296
301
  paddingBottom: 0,
@@ -1,3 +1,4 @@
1
+ import { date as formatDate } from '@planningcenter/datetime-fmt'
1
2
  import { HeaderTitle, HeaderTitleProps, PlatformPressable } from '@react-navigation/elements'
2
3
  import {
3
4
  CommonActions,
@@ -10,21 +11,24 @@ import {
10
11
  import React, { useCallback, useEffect, useRef, useState } from 'react'
11
12
  import { FlatList, Platform, StyleSheet, useWindowDimensions, View } from 'react-native'
12
13
  import { useSafeAreaInsets } from 'react-native-safe-area-context'
13
- import { Badge, Icon } from '../components'
14
+ import { Badge, Icon, Text } from '../components'
14
15
  import { ConversationStartBanner } from '../components/conversation/conversation_start_banner'
15
16
  import { JumpToBottomButton } from '../components/conversation/jump_to_bottom_button'
17
+ import { Message } from '../components/conversation/message'
16
18
  import { MessageForm } from '../components/conversation/message_form'
17
- import { MessageList } from '../components/conversation/message_list'
18
19
  import {
19
20
  ConversationDisabledBanner,
20
21
  LeaderMessagesDisabledBanner,
21
22
  MemberMessagesDisabledBanner,
22
23
  } from '../components/conversation/messages_disabled_banners'
24
+ import { ReplyShadowMessage } from '../components/conversation/reply_shadow_message'
25
+ import { SystemMessage } from '../components/conversation/system_message'
23
26
  import { TypingIndicator } from '../components/conversation/typing_indicator'
24
27
  import { KeyboardView } from '../components/display/keyboard_view'
25
28
  import { useHeaderSlotReserve } from '../components/display/platform_modal_header_buttons'
26
29
  import BlankState from '../components/primitive/blank_state_primitive'
27
30
  import { ConversationContextProvider } from '../contexts/conversation_context'
31
+ import { useTheme } from '../hooks'
28
32
  import { useMessageAvailableMembersAction } from '../hooks/direct_messages/use_message_available_members_action'
29
33
  import { useConversation } from '../hooks/use_conversation'
30
34
  import { useConversationJoltEvents } from '../hooks/use_conversation_jolt_events'
@@ -41,6 +45,10 @@ import { useVideoModerationJoltEvents } from '../hooks/use_video_moderation_jolt
41
45
  import { ConversationResource } from '../types/resources/conversation'
42
46
  import { ConversationBadgeResource } from '../types/resources/conversation_badge'
43
47
  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'
44
52
 
45
53
  export type ConversationRouteProps = {
46
54
  conversation_id: number
@@ -93,11 +101,16 @@ function ConversationScreenContent({ route }: ConversationScreenProps) {
93
101
  const { markHandledByMessageForm } = useVideoModerationJoltEvents()
94
102
  useEnsureConversationsRouteExists()
95
103
  useMarkLatestMessageRead({ conversation, messages })
96
- const noMessages = messages.length === 0
104
+ const messagesWithSeparators = groupMessages({
105
+ ms: messages,
106
+ inReplyScreen: !!reply_root_id,
107
+ })
108
+ const noMessages = messagesWithSeparators.length === 0
97
109
 
98
110
  const { repliesDisabled, memberAbility, badges, title } = conversation
99
111
  const canReply = memberAbility?.canReply
100
112
  const showLeaderDisabledReplyBanner = canReply && repliesDisabled
113
+ const canDeleteNonAuthoredMessages = memberAbility?.canDeleteNonAuthoredMessages ?? false
101
114
  const currentlyEditingMessage = messages.find(m => String(m.id) === String(editing_message_id))
102
115
  const replyRootAuthorFirstName = reply_root_author_name?.split(' ')[0]
103
116
  const replyHeaderTitle = replyRootAuthorFirstName
@@ -168,19 +181,54 @@ function ConversationScreenContent({ route }: ConversationScreenProps) {
168
181
  <ConversationStartBanner conversation={conversation} hasMessages={false} />
169
182
  </View>
170
183
  ) : (
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()}
184
+ <FlatList
185
+ inverted
186
+ ref={listRef}
187
+ contentContainerStyle={styles.listContainer}
188
+ data={messagesWithSeparators}
189
+ keyExtractor={item => item.id}
179
190
  onScroll={trackScroll}
180
191
  onLayout={onLayout}
181
192
  onContentSizeChange={onContentSizeChange}
182
193
  onScrollEndDrag={onScrollEndDrag}
183
194
  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} />}
184
232
  />
185
233
  )}
186
234
  <JumpToBottomButton onPress={handleReturnToBottom} visible={showJumpToBottomButton} />
@@ -247,6 +295,44 @@ function ConversationBottomBar({
247
295
  )
248
296
  }
249
297
 
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
+
250
336
  interface ConversationScreenTitleProps extends HeaderTitleProps {
251
337
  conversation_id: number
252
338
  badge?: ConversationBadgeResource
@@ -343,6 +429,13 @@ const useStyles = () => {
343
429
  flex: 1,
344
430
  justifyContent: 'center',
345
431
  },
432
+ listContainer: {
433
+ paddingVertical: 12,
434
+ },
435
+ listHeader: {
436
+ // Just whitespace to provide space where the typing indicator can be
437
+ height: 16,
438
+ },
346
439
  })
347
440
  }
348
441
 
@@ -1,5 +1,5 @@
1
1
  import { StaticScreenProps, useNavigation } from '@react-navigation/native'
2
- import React from 'react'
2
+ import React, { useLayoutEffect } 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,7 +10,6 @@ import { Haptic } from '../utils/native_adapters'
10
10
 
11
11
  export const ConversationSelectTypeScreenOptions = getFormSheetScreenOptions({
12
12
  headerTitle: 'New conversation',
13
- sheetAllowedDetents: 'fitToContents',
14
13
  })
15
14
 
16
15
  export type ConversationSelectTypeScreenProps = StaticScreenProps<{
@@ -35,6 +34,13 @@ export function ConversationSelectTypeScreen({ route }: ConversationSelectTypeSc
35
34
  const filterParams = { chat_group_graph_id, group_source_app_name }
36
35
  const showDirectMessageOption = directMessagesEligible
37
36
 
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
+
38
44
  const handleFilterPress = () => {
39
45
  if (!newConversationFromFilter) return
40
46
 
@@ -76,7 +82,7 @@ export function ConversationSelectTypeScreen({ route }: ConversationSelectTypeSc
76
82
  navigateToNewConversationType('ConversationSelectDirectMessageRecipients')
77
83
 
78
84
  return (
79
- <FormSheet.Root fitToContents>
85
+ <FormSheet.Root>
80
86
  <FormSheet.Header>
81
87
  <FormSheet.HeaderTitle>Start a conversation by</FormSheet.HeaderTitle>
82
88
  </FormSheet.Header>
@@ -17,8 +17,6 @@ export interface MessageResource {
17
17
  reactionCounts: ReactionCountResource[]
18
18
  replyCount: number
19
19
  replyRootId?: string | null
20
- replyRoot?: MessageResource | null
21
- idempotentKey?: string | null
22
20
  messageType: string
23
21
  personIdsForSystemEvent?: number[] | null
24
22
  systemTextParts?: { names: string[]; overflowCount: number; action: string } | null
@@ -124,54 +124,6 @@ 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
-
175
127
  describe('groupMessages — system messages', () => {
176
128
  it('flags lastInGroup true and renderAuthor false on system messages', () => {
177
129
  const messages = [
@@ -4,11 +4,7 @@ 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 {
8
- updateAllRecordsInPagesData,
9
- updateOrCreateRecordInPagesData,
10
- updateRecordInPagesData,
11
- } from './page_mutations'
7
+ import { updateOrCreateRecordInPagesData, updateRecordInPagesData } from './page_mutations'
12
8
  import { deleteRecordInPagesData } from './page_mutations'
13
9
 
14
10
  export function updateCacheWithMessage(
@@ -19,16 +15,20 @@ export function updateCacheWithMessage(
19
15
  ) {
20
16
  queryClient.setQueryData<MessagesQueryData>(queryKey, prev => {
21
17
  if (event === 'message.created') {
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.
18
+ // Before adding the new message, remove any pending temporary messages
19
+ // with matching text to prevent duplicates from race conditions
24
20
  let dataAfterTempRemoval = prev
25
- if (prev && message.mine && message.idempotentKey) {
21
+ if (prev && message.text && message.mine) {
26
22
  dataAfterTempRemoval = deleteRecordInPagesData({
27
23
  data: prev,
28
24
  record: message,
29
- matchFn: existingMessage =>
30
- isTemporaryMessageId(existingMessage.id) &&
31
- existingMessage.idempotentKey === message.idempotentKey,
25
+ matchFn: (existingMessage, _record) => {
26
+ return (
27
+ isTemporaryMessageId(existingMessage.id) &&
28
+ existingMessage.text === message.text &&
29
+ existingMessage.mine
30
+ )
31
+ },
32
32
  })
33
33
  }
34
34
 
@@ -70,22 +70,6 @@ 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
-
89
73
  export function updateCacheWithReaction(
90
74
  queryClient: QueryClient,
91
75
  queryKey: unknown[],
@@ -15,7 +15,6 @@ export function optimisticallyCreateMessage({
15
15
  currentPerson,
16
16
  message,
17
17
  replyRootId,
18
- idempotentKey,
19
18
  }: {
20
19
  conversationId: number
21
20
  text: string
@@ -23,7 +22,6 @@ export function optimisticallyCreateMessage({
23
22
  currentPerson: CurrentPersonResource
24
23
  message?: MessageResource
25
24
  replyRootId?: string | null
26
- idempotentKey?: string
27
25
  }) {
28
26
  const id = message?.id || generateTempMessageId()
29
27
 
@@ -55,7 +53,6 @@ export function optimisticallyCreateMessage({
55
53
  pending: true,
56
54
  replyCount: 0,
57
55
  replyRootId: replyRootId || null,
58
- idempotentKey,
59
56
  }
60
57
 
61
58
  // Add the optimistic message to the cache
@@ -32,8 +32,7 @@ const transformRelationships = relationships => {
32
32
  }, {})
33
33
  }
34
34
 
35
- const addIncludedRecords = (record, included, seen, parent) => {
36
- const { relationships } = record
35
+ const addIncludedRecords = (relationships, included, seen, parent) => {
37
36
  return Object.keys(relationships || {}).reduce((acc, key) => {
38
37
  const relationship = relationships[key]
39
38
  if (_.isArray(relationship.data)) {
@@ -44,14 +43,13 @@ const addIncludedRecords = (record, included, seen, parent) => {
44
43
  return _.set(
45
44
  acc,
46
45
  _.pluralize(key),
47
- _.map(records, related => transformRecord(related, included, seen))
46
+ _.map(records, record => transformRecord(record, included, seen))
48
47
  )
49
48
  } else {
50
49
  const [id, type] = _.at(relationship, ['data.id', 'data.type'])
51
50
  if (id && type) {
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)
51
+ const record = _.find(included, { id, type })
52
+ return _.set(acc, key, record ? transformRecord(record, included, seen) : null)
55
53
  } else {
56
54
  return acc
57
55
  }
@@ -80,7 +78,7 @@ const transformRecord = (record, included, seen) => {
80
78
 
81
79
  seen.set(record, transformed)
82
80
 
83
- return addIncludedRecords(record, included, seen, transformed)
81
+ return addIncludedRecords(relationships, included, seen, transformed)
84
82
  }
85
83
  }
86
84