@planningcenter/chat-react-native 4.1.0 → 4.1.1-qa-staging.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 (106) 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/contexts/api_provider.d.ts.map +1 -1
  18. package/build/contexts/api_provider.js +12 -1
  19. package/build/contexts/api_provider.js.map +1 -1
  20. package/build/hooks/use_conversation_messages.d.ts.map +1 -1
  21. package/build/hooks/use_conversation_messages.js +40 -28
  22. package/build/hooks/use_conversation_messages.js.map +1 -1
  23. package/build/hooks/use_conversation_messages_jolt_events.d.ts.map +1 -1
  24. package/build/hooks/use_conversation_messages_jolt_events.js +6 -1
  25. package/build/hooks/use_conversation_messages_jolt_events.js.map +1 -1
  26. package/build/hooks/use_features.d.ts +16 -0
  27. package/build/hooks/use_features.d.ts.map +1 -1
  28. package/build/hooks/use_features.js +43 -47
  29. package/build/hooks/use_features.js.map +1 -1
  30. package/build/hooks/use_message_create_or_update.d.ts +99 -4
  31. package/build/hooks/use_message_create_or_update.d.ts.map +1 -1
  32. package/build/hooks/use_message_create_or_update.js +47 -6
  33. package/build/hooks/use_message_create_or_update.js.map +1 -1
  34. package/build/screens/conversation_screen.d.ts.map +1 -1
  35. package/build/screens/conversation_screen.js +184 -365
  36. package/build/screens/conversation_screen.js.map +1 -1
  37. package/build/screens/conversations/components/search_list_header_component.d.ts +2 -0
  38. package/build/screens/conversations/components/search_list_header_component.d.ts.map +1 -0
  39. package/build/screens/conversations/components/search_list_header_component.js +496 -0
  40. package/build/screens/conversations/components/search_list_header_component.js.map +1 -0
  41. package/build/screens/conversations/conversations_screen.d.ts.map +1 -1
  42. package/build/screens/conversations/conversations_screen.js +28 -16
  43. package/build/screens/conversations/conversations_screen.js.map +1 -1
  44. package/build/types/resources/message.d.ts +2 -0
  45. package/build/types/resources/message.d.ts.map +1 -1
  46. package/build/types/resources/message.js.map +1 -1
  47. package/build/utils/cache/messages_cache.d.ts +1 -0
  48. package/build/utils/cache/messages_cache.d.ts.map +1 -1
  49. package/build/utils/cache/messages_cache.js +14 -7
  50. package/build/utils/cache/messages_cache.js.map +1 -1
  51. package/build/utils/cache/optimistically_create_message.d.ts +2 -1
  52. package/build/utils/cache/optimistically_create_message.d.ts.map +1 -1
  53. package/build/utils/cache/optimistically_create_message.js +4 -2
  54. package/build/utils/cache/optimistically_create_message.js.map +1 -1
  55. package/build/utils/client/transform_response.d.ts.map +1 -1
  56. package/build/utils/client/transform_response.js +9 -5
  57. package/build/utils/client/transform_response.js.map +1 -1
  58. package/build/utils/group_messages.d.ts +1 -0
  59. package/build/utils/group_messages.d.ts.map +1 -1
  60. package/build/utils/group_messages.js +10 -1
  61. package/build/utils/group_messages.js.map +1 -1
  62. package/build/utils/jolt/transform_message_event_data_to_message_resource.d.ts.map +1 -1
  63. package/build/utils/jolt/transform_message_event_data_to_message_resource.js +1 -0
  64. package/build/utils/jolt/transform_message_event_data_to_message_resource.js.map +1 -1
  65. package/build/utils/replace_equal_by_id.d.ts +6 -0
  66. package/build/utils/replace_equal_by_id.d.ts.map +1 -0
  67. package/build/utils/replace_equal_by_id.js +41 -0
  68. package/build/utils/replace_equal_by_id.js.map +1 -0
  69. package/build/utils/request/messages_data_options.d.ts.map +1 -1
  70. package/build/utils/request/messages_data_options.js +2 -2
  71. package/build/utils/request/messages_data_options.js.map +1 -1
  72. package/build/utils/time.d.ts +1 -0
  73. package/build/utils/time.d.ts.map +1 -1
  74. package/build/utils/time.js +1 -0
  75. package/build/utils/time.js.map +1 -1
  76. package/package.json +3 -3
  77. package/src/__tests__/hooks/use_features.test.ts +61 -0
  78. package/src/components/conversation/__tests__/message_list.test.tsx +271 -0
  79. package/src/components/conversation/__tests__/reply_shadow_message.test.tsx +119 -0
  80. package/src/components/conversation/message.tsx +15 -18
  81. package/src/components/conversation/message_list.tsx +157 -0
  82. package/src/components/conversation/message_read_receipts.tsx +3 -5
  83. package/src/components/conversation/reply_shadow_message.tsx +9 -7
  84. package/src/contexts/api_provider.tsx +4 -0
  85. package/src/hooks/__tests__/use_message_create_or_update.test.tsx +110 -0
  86. package/src/hooks/use_conversation_messages.ts +5 -1
  87. package/src/hooks/use_conversation_messages_jolt_events.ts +7 -0
  88. package/src/hooks/use_features.ts +26 -14
  89. package/src/hooks/use_message_create_or_update.ts +29 -16
  90. package/src/screens/conversation_screen.tsx +11 -104
  91. package/src/screens/conversations/components/search_list_header_component.tsx +291 -0
  92. package/src/screens/conversations/conversations_screen.tsx +6 -1
  93. package/src/types/resources/message.ts +2 -0
  94. package/src/utils/__tests__/group_messages.test.ts +48 -0
  95. package/src/utils/__tests__/replace_equal_by_id.test.ts +73 -0
  96. package/src/utils/cache/__tests__/messages_cache.test.ts +89 -0
  97. package/src/utils/cache/__tests__/messages_cache_reply_roots.test.ts +70 -0
  98. package/src/utils/cache/messages_cache.ts +27 -11
  99. package/src/utils/cache/optimistically_create_message.ts +3 -0
  100. package/src/utils/client/__tests__/transform_response.test.ts +62 -0
  101. package/src/utils/client/transform_response.ts +7 -5
  102. package/src/utils/group_messages.ts +18 -1
  103. package/src/utils/jolt/transform_message_event_data_to_message_resource.ts +1 -0
  104. package/src/utils/replace_equal_by_id.ts +42 -0
  105. package/src/utils/request/messages_data_options.ts +2 -1
  106. package/src/utils/time.ts +1 -0
@@ -0,0 +1,157 @@
1
+ import { date as formatDate } from '@planningcenter/datetime-fmt'
2
+ import React, { RefObject } from 'react'
3
+ import { FlatList, FlatListProps, StyleSheet, View } from 'react-native'
4
+ import { useTheme } from '../../hooks'
5
+ import { ConversationResource } from '../../types/resources/conversation'
6
+ import { MessageResource } from '../../types/resources/message'
7
+ import { getRelativeDateStatus } from '../../utils/date'
8
+ import { groupMessages, type DateSeparator, type EnrichedMessage } from '../../utils/group_messages'
9
+ import { CONVERSATION_MESSAGE_LIST_PADDING_HORIZONTAL } from '../../utils/styles'
10
+ import { isSystemMessage } from '../../utils/system_messages'
11
+ import { Text } from '../display'
12
+ import { ConversationStartBanner } from './conversation_start_banner'
13
+ import { Message } from './message'
14
+ import { ReplyShadowMessage } from './reply_shadow_message'
15
+ import { SystemMessage } from './system_message'
16
+
17
+ type ScrollHandlers = Pick<
18
+ FlatListProps<EnrichedMessage>,
19
+ 'onScroll' | 'onLayout' | 'onContentSizeChange' | 'onScrollEndDrag' | 'onMomentumScrollEnd'
20
+ >
21
+
22
+ interface MessageListProps extends ScrollHandlers {
23
+ listRef: RefObject<FlatList | null>
24
+ conversation: ConversationResource
25
+ conversationId: number
26
+ messages: MessageResource[]
27
+ replyRootId?: string | null
28
+ hasNextPage: boolean
29
+ onEndReached: () => void
30
+ }
31
+
32
+ /** MessageList
33
+ * The inverted list of messages, reply previews, and date separators for a conversation
34
+ */
35
+ export function MessageList({
36
+ listRef,
37
+ conversation,
38
+ conversationId,
39
+ messages,
40
+ replyRootId,
41
+ hasNextPage,
42
+ onEndReached,
43
+ onScroll,
44
+ onLayout,
45
+ onContentSizeChange,
46
+ onScrollEndDrag,
47
+ onMomentumScrollEnd,
48
+ }: MessageListProps) {
49
+ const styles = useStyles()
50
+ const inReplyScreen = !!replyRootId
51
+ const canDeleteNonAuthoredMessages =
52
+ conversation.memberAbility?.canDeleteNonAuthoredMessages ?? false
53
+ const latestRead = conversation.latestReadMessageSortKey
54
+ const messagesWithSeparators = groupMessages({ ms: messages, inReplyScreen })
55
+
56
+ return (
57
+ <FlatList
58
+ inverted
59
+ ref={listRef}
60
+ contentContainerStyle={styles.listContainer}
61
+ data={messagesWithSeparators}
62
+ keyExtractor={item => item.id}
63
+ onScroll={onScroll}
64
+ onLayout={onLayout}
65
+ onContentSizeChange={onContentSizeChange}
66
+ onScrollEndDrag={onScrollEndDrag}
67
+ onMomentumScrollEnd={onMomentumScrollEnd}
68
+ scrollEventThrottle={10}
69
+ ListFooterComponent={
70
+ hasNextPage ? null : (
71
+ <ConversationStartBanner conversation={conversation} hasMessages={true} />
72
+ )
73
+ }
74
+ renderItem={({ item }) => {
75
+ if (item.type === 'DateSeparator') {
76
+ return <InlineDateSeparator {...item} />
77
+ }
78
+
79
+ if (item.type === 'ReplyShadowMessage') {
80
+ return (
81
+ <ReplyShadowMessage
82
+ {...(item as any)}
83
+ conversation_id={conversationId}
84
+ inReplyScreen={inReplyScreen}
85
+ />
86
+ )
87
+ }
88
+
89
+ if (isSystemMessage(item)) {
90
+ return <SystemMessage message={item} conversationId={conversationId} />
91
+ }
92
+
93
+ const readByOthers = item.mine && !!latestRead && item.id <= latestRead
94
+ return (
95
+ <Message
96
+ {...item}
97
+ canDeleteNonAuthoredMessages={canDeleteNonAuthoredMessages}
98
+ conversation_id={conversationId}
99
+ readByOthers={readByOthers}
100
+ inReplyScreen={inReplyScreen}
101
+ />
102
+ )
103
+ }}
104
+ onEndReached={onEndReached}
105
+ ListHeaderComponent={<View style={styles.listHeader} />}
106
+ />
107
+ )
108
+ }
109
+
110
+ function InlineDateSeparator({ date }: DateSeparator) {
111
+ const styles = useDateSeparatorStyles()
112
+ const { isThisYear } = getRelativeDateStatus(date)
113
+ const showYear = !isThisYear
114
+ const dateStamp = formatDate(date, { style: 'long', year: showYear })
115
+
116
+ return (
117
+ <View style={styles.container}>
118
+ <View style={styles.separator} />
119
+ <Text variant="footnote" style={styles.dateText}>
120
+ {dateStamp}
121
+ </Text>
122
+ <View style={styles.separator} />
123
+ </View>
124
+ )
125
+ }
126
+
127
+ const useDateSeparatorStyles = () => {
128
+ const theme = useTheme()
129
+ return StyleSheet.create({
130
+ container: {
131
+ alignItems: 'center',
132
+ flexDirection: 'row',
133
+ paddingHorizontal: CONVERSATION_MESSAGE_LIST_PADDING_HORIZONTAL,
134
+ paddingVertical: 16,
135
+ },
136
+ separator: {
137
+ flex: 1,
138
+ height: 1,
139
+ borderTopWidth: 1,
140
+ borderTopColor: theme.colors.borderColorDefaultBase,
141
+ },
142
+ dateText: {
143
+ paddingHorizontal: 8,
144
+ },
145
+ })
146
+ }
147
+
148
+ const useStyles = () =>
149
+ StyleSheet.create({
150
+ listContainer: {
151
+ paddingVertical: 12,
152
+ },
153
+ listHeader: {
154
+ // Just whitespace to provide space where the typing indicator can be
155
+ height: 16,
156
+ },
157
+ })
@@ -1,17 +1,15 @@
1
1
  import React from 'react'
2
2
  import { StyleSheet, View } from 'react-native'
3
3
  import { useTheme } from '../../hooks'
4
- import { MessageResource } from '../../types'
5
4
  import { Icon, Text } from '../display'
6
5
 
7
6
  interface Props {
8
- message: MessageResource
9
- latestReadMessageSortKey?: string
7
+ read?: boolean
10
8
  }
11
9
 
12
- export function MessageReadReceipts({ message, latestReadMessageSortKey }: Props) {
10
+ export function MessageReadReceipts({ read }: Props) {
13
11
  const styles = useStyles()
14
- if (!latestReadMessageSortKey || message.id > latestReadMessageSortKey) {
12
+ if (!read) {
15
13
  return (
16
14
  <View style={styles.badgeContainer}>
17
15
  <Icon name="general.check" style={styles.icon} />
@@ -1,6 +1,6 @@
1
1
  import { useNavigation } from '@react-navigation/native'
2
2
  import { some } from 'lodash'
3
- import React from 'react'
3
+ import React, { memo } from 'react'
4
4
  import { Pressable, StyleSheet, useWindowDimensions, View } from 'react-native'
5
5
  import Animated from 'react-native-reanimated'
6
6
  import {
@@ -37,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
  }
@@ -10,6 +10,7 @@ import { ViewProps } from 'react-native'
10
10
  import { ApiClient, useApiClient } from '../hooks/use_api_client'
11
11
  import { useAppState } from '../hooks/use_app_state'
12
12
  import { appGrantsRequestArgs } from '../hooks/use_chat_permissions'
13
+ import { featuresQueryOptions } from '../hooks/use_features'
13
14
  import { getRequestQueryKey, RequestQueryKey } from '../hooks/use_suspense_api'
14
15
  import { emitAuthRefresh } from '../utils/auth_events'
15
16
  import { ResponseError } from '../utils/response_error'
@@ -81,10 +82,13 @@ export function ApiProvider({ children }: ViewProps) {
81
82
  // This needs to live in the provider so that it can access the api client
82
83
  // and the chat query client
83
84
  const PrefetchQueries = () => {
85
+ const client = useApiClient()
86
+
84
87
  usePrefetchQuery({
85
88
  queryKey: getRequestQueryKey(appGrantsRequestArgs),
86
89
  queryFn: defaultQueryFn,
87
90
  })
91
+ usePrefetchQuery(featuresQueryOptions(client))
88
92
 
89
93
  return null
90
94
  }
@@ -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
@@ -1,26 +1,20 @@
1
- import { useSuspenseQuery } from '@tanstack/react-query'
1
+ import { queryOptions, useSuspenseQuery } from '@tanstack/react-query'
2
2
  import { useCallback } from 'react'
3
3
  import { ApiCollection } from '../types'
4
4
  import type { FeatureResource } from '../types/resources/feature_resource'
5
5
  import { getFeaturesRequestArgs, getFeaturesQueryKey } from '../utils/request/get_features'
6
- import { FIVE_MINUTES } from '../utils/time'
7
- import { useApiClient } from './use_api_client'
6
+ import { FIVE_MINUTES, ONE_MINUTE } from '../utils/time'
7
+ import { ApiClient, useApiClient } from './use_api_client'
8
8
 
9
9
  type FeatureName = (typeof availableFeatures)[keyof typeof availableFeatures]
10
10
 
11
+ type FeaturesCollection = ApiCollection<FeatureResource> & {
12
+ failure?: { status?: number }
13
+ }
14
+
11
15
  export function useFeatures() {
12
16
  const apiClient = useApiClient()
13
- const requestArgs = getFeaturesRequestArgs()
14
-
15
- const { data } = useSuspenseQuery({
16
- queryKey: getFeaturesQueryKey(),
17
- queryFn: () => {
18
- return apiClient.chat
19
- .get<ApiCollection<FeatureResource>>(requestArgs)
20
- .catch(() => stableEmptyFeatures)
21
- },
22
- staleTime: FIVE_MINUTES,
23
- })
17
+ const { data } = useSuspenseQuery(featuresQueryOptions(apiClient))
24
18
 
25
19
  const features = data.data
26
20
 
@@ -36,6 +30,23 @@ export function useFeatures() {
36
30
  }
37
31
  }
38
32
 
33
+ export const featuresQueryOptions = (apiClient: ApiClient) =>
34
+ queryOptions({
35
+ queryKey: getFeaturesQueryKey(),
36
+ queryFn: (): Promise<FeaturesCollection> => {
37
+ return apiClient.chat
38
+ .get<ApiCollection<FeatureResource>>(getFeaturesRequestArgs())
39
+ .catch(error => ({ ...stableEmptyFeatures, failure: { status: error?.status } }))
40
+ },
41
+ staleTime: query => {
42
+ const failure = query.state.data?.failure
43
+ if (!failure) return FIVE_MINUTES
44
+
45
+ return failure.status === 429 ? ONE_MINUTE : 0
46
+ },
47
+ gcTime: Infinity,
48
+ })
49
+
39
50
  export const availableFeatures = {
40
51
  gender_specific_conversations: 'ROLLOUT_gender_specific_conversations',
41
52
  message_reporting: 'ROLLOUT_MOBILE_message_reporting',
@@ -44,6 +55,7 @@ export const availableFeatures = {
44
55
  conversation_safety_lock: 'ROLLOUT_conversation_safety_lock',
45
56
  video_moderation: 'ROLLOUT_MOBILE_video_moderation',
46
57
  dm_search_clear_fix: 'ROLLOUT_MOBILE_dm_search_clear_fix',
58
+ search: 'ROLLOUT_search',
47
59
  } as const satisfies Record<string, `ROLLOUT_${string}`>
48
60
 
49
61
  const stableEmptyFeatures: ApiCollection<FeatureResource> = {
@@ -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
  /**