@planningcenter/chat-react-native 4.1.0 → 4.1.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.
- package/build/components/conversation/message.d.ts +3 -2
- package/build/components/conversation/message.d.ts.map +1 -1
- package/build/components/conversation/message.js +23 -22
- package/build/components/conversation/message.js.map +1 -1
- package/build/components/conversation/message_list.d.ts +21 -0
- package/build/components/conversation/message_list.d.ts.map +1 -0
- package/build/components/conversation/message_list.js +273 -0
- package/build/components/conversation/message_list.js.map +1 -0
- package/build/components/conversation/message_read_receipts.d.ts +2 -4
- package/build/components/conversation/message_read_receipts.d.ts.map +1 -1
- package/build/components/conversation/message_read_receipts.js +2 -3
- package/build/components/conversation/message_read_receipts.js.map +1 -1
- package/build/components/conversation/reply_shadow_message.d.ts +2 -1
- package/build/components/conversation/reply_shadow_message.d.ts.map +1 -1
- package/build/components/conversation/reply_shadow_message.js +47 -46
- package/build/components/conversation/reply_shadow_message.js.map +1 -1
- package/build/contexts/api_provider.d.ts.map +1 -1
- package/build/contexts/api_provider.js +12 -1
- package/build/contexts/api_provider.js.map +1 -1
- package/build/hooks/use_conversation_messages.d.ts.map +1 -1
- package/build/hooks/use_conversation_messages.js +40 -28
- package/build/hooks/use_conversation_messages.js.map +1 -1
- package/build/hooks/use_conversation_messages_jolt_events.d.ts.map +1 -1
- package/build/hooks/use_conversation_messages_jolt_events.js +6 -1
- package/build/hooks/use_conversation_messages_jolt_events.js.map +1 -1
- package/build/hooks/use_features.d.ts +16 -0
- package/build/hooks/use_features.d.ts.map +1 -1
- package/build/hooks/use_features.js +43 -47
- package/build/hooks/use_features.js.map +1 -1
- package/build/hooks/use_message_create_or_update.d.ts +99 -4
- package/build/hooks/use_message_create_or_update.d.ts.map +1 -1
- package/build/hooks/use_message_create_or_update.js +47 -6
- package/build/hooks/use_message_create_or_update.js.map +1 -1
- package/build/screens/conversation_screen.d.ts.map +1 -1
- package/build/screens/conversation_screen.js +184 -365
- package/build/screens/conversation_screen.js.map +1 -1
- package/build/screens/conversations/components/search_list_header_component.d.ts +2 -0
- package/build/screens/conversations/components/search_list_header_component.d.ts.map +1 -0
- package/build/screens/conversations/components/search_list_header_component.js +496 -0
- package/build/screens/conversations/components/search_list_header_component.js.map +1 -0
- package/build/screens/conversations/conversations_screen.d.ts.map +1 -1
- package/build/screens/conversations/conversations_screen.js +28 -16
- package/build/screens/conversations/conversations_screen.js.map +1 -1
- package/build/types/resources/message.d.ts +2 -0
- package/build/types/resources/message.d.ts.map +1 -1
- package/build/types/resources/message.js.map +1 -1
- package/build/utils/cache/messages_cache.d.ts +1 -0
- package/build/utils/cache/messages_cache.d.ts.map +1 -1
- package/build/utils/cache/messages_cache.js +14 -7
- package/build/utils/cache/messages_cache.js.map +1 -1
- package/build/utils/cache/optimistically_create_message.d.ts +2 -1
- package/build/utils/cache/optimistically_create_message.d.ts.map +1 -1
- package/build/utils/cache/optimistically_create_message.js +4 -2
- package/build/utils/cache/optimistically_create_message.js.map +1 -1
- package/build/utils/client/transform_response.d.ts.map +1 -1
- package/build/utils/client/transform_response.js +9 -5
- package/build/utils/client/transform_response.js.map +1 -1
- package/build/utils/group_messages.d.ts +1 -0
- package/build/utils/group_messages.d.ts.map +1 -1
- package/build/utils/group_messages.js +10 -1
- package/build/utils/group_messages.js.map +1 -1
- package/build/utils/jolt/transform_message_event_data_to_message_resource.d.ts.map +1 -1
- package/build/utils/jolt/transform_message_event_data_to_message_resource.js +1 -0
- package/build/utils/jolt/transform_message_event_data_to_message_resource.js.map +1 -1
- package/build/utils/replace_equal_by_id.d.ts +6 -0
- package/build/utils/replace_equal_by_id.d.ts.map +1 -0
- package/build/utils/replace_equal_by_id.js +41 -0
- package/build/utils/replace_equal_by_id.js.map +1 -0
- package/build/utils/request/messages_data_options.d.ts.map +1 -1
- package/build/utils/request/messages_data_options.js +2 -2
- package/build/utils/request/messages_data_options.js.map +1 -1
- package/build/utils/time.d.ts +1 -0
- package/build/utils/time.d.ts.map +1 -1
- package/build/utils/time.js +1 -0
- package/build/utils/time.js.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/hooks/use_features.test.ts +61 -0
- package/src/components/conversation/__tests__/message_list.test.tsx +271 -0
- package/src/components/conversation/__tests__/reply_shadow_message.test.tsx +119 -0
- package/src/components/conversation/message.tsx +15 -18
- package/src/components/conversation/message_list.tsx +157 -0
- package/src/components/conversation/message_read_receipts.tsx +3 -5
- package/src/components/conversation/reply_shadow_message.tsx +9 -7
- package/src/contexts/api_provider.tsx +4 -0
- package/src/hooks/__tests__/use_message_create_or_update.test.tsx +110 -0
- package/src/hooks/use_conversation_messages.ts +5 -1
- package/src/hooks/use_conversation_messages_jolt_events.ts +7 -0
- package/src/hooks/use_features.ts +26 -14
- package/src/hooks/use_message_create_or_update.ts +29 -16
- package/src/screens/conversation_screen.tsx +11 -104
- package/src/screens/conversations/components/search_list_header_component.tsx +291 -0
- package/src/screens/conversations/conversations_screen.tsx +6 -1
- package/src/types/resources/message.ts +2 -0
- package/src/utils/__tests__/group_messages.test.ts +48 -0
- package/src/utils/__tests__/replace_equal_by_id.test.ts +73 -0
- package/src/utils/cache/__tests__/messages_cache.test.ts +89 -0
- package/src/utils/cache/__tests__/messages_cache_reply_roots.test.ts +70 -0
- package/src/utils/cache/messages_cache.ts +27 -11
- package/src/utils/cache/optimistically_create_message.ts +3 -0
- package/src/utils/client/__tests__/transform_response.test.ts +62 -0
- package/src/utils/client/transform_response.ts +7 -5
- package/src/utils/group_messages.ts +18 -1
- package/src/utils/jolt/transform_message_event_data_to_message_resource.ts +1 -0
- package/src/utils/replace_equal_by_id.ts +42 -0
- package/src/utils/request/messages_data_options.ts +2 -1
- 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
|
-
|
|
9
|
-
latestReadMessageSortKey?: string
|
|
7
|
+
read?: boolean
|
|
10
8
|
}
|
|
11
9
|
|
|
12
|
-
export function MessageReadReceipts({
|
|
10
|
+
export function MessageReadReceipts({ read }: Props) {
|
|
13
11
|
const styles = useStyles()
|
|
14
|
-
if (!
|
|
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
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
/**
|