@planningcenter/chat-react-native 4.0.1-qa-staging.0 → 4.0.1-qa-staging.2
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/components/primitive/form_sheet.d.ts +2 -1
- package/build/components/primitive/form_sheet.d.ts.map +1 -1
- package/build/components/primitive/form_sheet.js +68 -53
- package/build/components/primitive/form_sheet.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_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/conversation_select_type_screen.d.ts.map +1 -1
- package/build/screens/conversation_select_type_screen.js +82 -98
- package/build/screens/conversation_select_type_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/package.json +3 -3
- 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/components/primitive/form_sheet.tsx +10 -3
- 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_message_create_or_update.ts +29 -16
- package/src/screens/conversation_screen.tsx +11 -104
- package/src/screens/conversation_select_type_screen.tsx +3 -9
- 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
|
@@ -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
|
}
|
|
@@ -85,6 +85,7 @@ interface FormSheetRootProps {
|
|
|
85
85
|
style?: StyleProp<ViewStyle>
|
|
86
86
|
contentStyle?: StyleProp<ViewStyle>
|
|
87
87
|
showAndroidSheetGrabber?: boolean
|
|
88
|
+
fitToContents?: boolean
|
|
88
89
|
}
|
|
89
90
|
|
|
90
91
|
export function FormSheetRoot({
|
|
@@ -92,8 +93,9 @@ export function FormSheetRoot({
|
|
|
92
93
|
style,
|
|
93
94
|
contentStyle,
|
|
94
95
|
showAndroidSheetGrabber = true,
|
|
96
|
+
fitToContents = false,
|
|
95
97
|
}: FormSheetRootProps) {
|
|
96
|
-
const styles = useStyles()
|
|
98
|
+
const styles = useStyles({ fitToContents })
|
|
97
99
|
|
|
98
100
|
return (
|
|
99
101
|
<View style={[styles.container, style]} collapsable={false}>
|
|
@@ -298,16 +300,21 @@ FormSheetAction.displayName = 'FormSheet.Action'
|
|
|
298
300
|
interface Styles {
|
|
299
301
|
appearance?: FormSheetActionAppearanceUnion
|
|
300
302
|
disabled?: boolean
|
|
303
|
+
fitToContents?: boolean
|
|
301
304
|
}
|
|
302
305
|
|
|
303
|
-
const useStyles = ({
|
|
306
|
+
const useStyles = ({
|
|
307
|
+
appearance = 'neutral',
|
|
308
|
+
disabled = false,
|
|
309
|
+
fitToContents = false,
|
|
310
|
+
}: Styles = {}) => {
|
|
304
311
|
const { colors } = useTheme()
|
|
305
312
|
const { height } = useWindowDimensions()
|
|
306
313
|
const { bottom, top } = useSafeAreaInsets()
|
|
307
314
|
const headerHeight = useHeaderHeight()
|
|
308
315
|
|
|
309
316
|
const containerHeight = Platform.select({
|
|
310
|
-
ios: height - top - headerHeight,
|
|
317
|
+
ios: fitToContents ? undefined : height - top - headerHeight,
|
|
311
318
|
default: null,
|
|
312
319
|
})
|
|
313
320
|
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { InfiniteData, QueryClientProvider, notifyManager } from '@tanstack/react-query'
|
|
2
|
+
import { act, renderHook } from '@testing-library/react-native'
|
|
3
|
+
import React from 'react'
|
|
4
|
+
import { chatQueryClient } from '../../contexts/api_provider'
|
|
5
|
+
import type { ApiCollection, MessageResource } from '../../types'
|
|
6
|
+
import { getMessagesQueryKey } from '../../utils/request/get_messages'
|
|
7
|
+
import { useMessageCreateOrUpdate } from '../use_message_create_or_update'
|
|
8
|
+
|
|
9
|
+
jest.mock('../../contexts/api_provider', () => {
|
|
10
|
+
const { QueryClient } = require('@tanstack/react-query')
|
|
11
|
+
// gcTime 0 so nothing outlives the test and keeps jest from exiting
|
|
12
|
+
return {
|
|
13
|
+
chatQueryClient: new QueryClient({
|
|
14
|
+
defaultOptions: { queries: { gcTime: 0 }, mutations: { gcTime: 0 } },
|
|
15
|
+
}),
|
|
16
|
+
}
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
const mockPost = jest.fn()
|
|
20
|
+
jest.mock('../use_api_client', () => ({
|
|
21
|
+
useApiClient: () => ({ chat: { post: mockPost, patch: jest.fn() } }),
|
|
22
|
+
}))
|
|
23
|
+
|
|
24
|
+
jest.mock('../use_current_person', () => ({
|
|
25
|
+
useCurrentPerson: () => ({ type: 'Person', id: 1, name: 'Me', avatar: null }),
|
|
26
|
+
}))
|
|
27
|
+
|
|
28
|
+
// Flush TanStack's batched state updates inside act, as its own tests do
|
|
29
|
+
notifyManager.setNotifyFunction(fn => act(fn))
|
|
30
|
+
|
|
31
|
+
type MessagesQueryData = InfiniteData<ApiCollection<MessageResource>>
|
|
32
|
+
|
|
33
|
+
const conversationId = 7
|
|
34
|
+
const queryKey = getMessagesQueryKey({ conversation_id: conversationId })
|
|
35
|
+
|
|
36
|
+
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
|
37
|
+
<QueryClientProvider client={chatQueryClient}>{children}</QueryClientProvider>
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
const pendingMessagesInCache = () =>
|
|
41
|
+
(chatQueryClient.getQueryData<MessagesQueryData>(queryKey)?.pages[0].data ?? []).filter(
|
|
42
|
+
m => m.pending
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
const sentKey = (call: number): string =>
|
|
46
|
+
mockPost.mock.calls[call][0].data.data.attributes.idempotent_key
|
|
47
|
+
|
|
48
|
+
// What the cache held when each request went out, i.e. after onMutate ran
|
|
49
|
+
let pendingAtSend: MessageResource[][]
|
|
50
|
+
|
|
51
|
+
describe('useMessageCreateOrUpdate idempotent key', () => {
|
|
52
|
+
beforeEach(() => {
|
|
53
|
+
chatQueryClient.clear()
|
|
54
|
+
chatQueryClient.setQueryData<MessagesQueryData>(queryKey, {
|
|
55
|
+
pages: [{ data: [], links: {}, meta: { count: 0, totalCount: 0 } }],
|
|
56
|
+
pageParams: [],
|
|
57
|
+
})
|
|
58
|
+
pendingAtSend = []
|
|
59
|
+
mockPost.mockReset()
|
|
60
|
+
mockPost.mockImplementation(async request => {
|
|
61
|
+
pendingAtSend.push(pendingMessagesInCache())
|
|
62
|
+
const key = request.data.data.attributes.idempotent_key
|
|
63
|
+
return { data: { type: 'Message', id: `real-${key}`, idempotentKey: key, mine: true } }
|
|
64
|
+
})
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('gives the optimistic message the same key that is sent to the server', async () => {
|
|
68
|
+
const { result } = renderHook(() => useMessageCreateOrUpdate({ conversationId }), { wrapper })
|
|
69
|
+
|
|
70
|
+
await act(() => result.current.mutateAsync({ text: 'ok' }))
|
|
71
|
+
|
|
72
|
+
expect(sentKey(0)).toEqual(expect.any(String))
|
|
73
|
+
expect(pendingAtSend[0].map(m => m.idempotentKey)).toEqual([sentKey(0)])
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('mints a distinct key for each send, even with identical text', async () => {
|
|
77
|
+
const { result } = renderHook(() => useMessageCreateOrUpdate({ conversationId }), { wrapper })
|
|
78
|
+
|
|
79
|
+
await act(() =>
|
|
80
|
+
Promise.all([
|
|
81
|
+
result.current.mutateAsync({ text: 'ok' }),
|
|
82
|
+
result.current.mutateAsync({ text: 'ok' }),
|
|
83
|
+
])
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
expect(sentKey(0)).not.toBe(sentKey(1))
|
|
87
|
+
expect(pendingAtSend[1].map(m => m.idempotentKey).sort()).toEqual(
|
|
88
|
+
[sentKey(0), sentKey(1)].sort()
|
|
89
|
+
)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('reuses the original key when retrying a failed send', async () => {
|
|
93
|
+
const failed = {
|
|
94
|
+
type: 'Message',
|
|
95
|
+
id: '01FAILED-temp',
|
|
96
|
+
text: 'ok',
|
|
97
|
+
idempotentKey: 'original-key',
|
|
98
|
+
error: 'Failed to send message',
|
|
99
|
+
} as MessageResource
|
|
100
|
+
const { result } = renderHook(
|
|
101
|
+
() => useMessageCreateOrUpdate({ conversationId, message: failed }),
|
|
102
|
+
{ wrapper }
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
await act(() => result.current.mutateAsync({ text: 'ok' }))
|
|
106
|
+
|
|
107
|
+
expect(sentKey(0)).toBe('original-key')
|
|
108
|
+
expect(pendingAtSend[0].map(m => m.idempotentKey)).toEqual(['original-key'])
|
|
109
|
+
})
|
|
110
|
+
})
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useMemo } from 'react'
|
|
2
2
|
import { MessageResource } from '../types'
|
|
3
|
+
import { replaceEqualById } from '../utils/replace_equal_by_id'
|
|
3
4
|
import { getMessagesQueryKey, getMessagesRequestArgs } from '../utils/request/get_messages'
|
|
4
5
|
import { SuspensePaginatorOptions, useSuspensePaginator } from './use_suspense_api'
|
|
5
6
|
|
|
@@ -10,7 +11,10 @@ export const useConversationMessages = (
|
|
|
10
11
|
const { data, refetch, isRefetching, fetchNextPage, hasNextPage } =
|
|
11
12
|
useSuspensePaginator<MessageResource>(
|
|
12
13
|
getMessagesRequestArgs({ conversation_id, reply_root_id }),
|
|
13
|
-
|
|
14
|
+
{
|
|
15
|
+
structuralSharing: replaceEqualById,
|
|
16
|
+
...opts,
|
|
17
|
+
}
|
|
14
18
|
)
|
|
15
19
|
const queryKey = getMessagesQueryKey({ conversation_id, reply_root_id })
|
|
16
20
|
const messages = useMemo(
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
updateCacheWithMessage,
|
|
12
12
|
updateCacheWithIndividualMessage,
|
|
13
13
|
updateCacheWithReaction,
|
|
14
|
+
updateCacheReplyRoots,
|
|
14
15
|
getThreadedMessagesQueryKey,
|
|
15
16
|
} from '../utils/cache/messages_cache'
|
|
16
17
|
import { transformMessageEventDataToMessageResource } from '../utils/jolt/transform_message_event_data_to_message_resource'
|
|
@@ -44,6 +45,7 @@ export function useConversationMessagesJoltEvents({ conversationId }: Props) {
|
|
|
44
45
|
data,
|
|
45
46
|
currentPersonId: currentPerson.id,
|
|
46
47
|
})
|
|
48
|
+
const isReplyRootUpdate = e.event === 'message.updated' && data.reply_root_id === data.sort_key
|
|
47
49
|
|
|
48
50
|
if (e.event === 'message.created' && data.author_id) {
|
|
49
51
|
removeAllTypingEventsByAuthorId(data.author_id)
|
|
@@ -62,6 +64,11 @@ export function useConversationMessagesJoltEvents({ conversationId }: Props) {
|
|
|
62
64
|
data.reply_root_id
|
|
63
65
|
)
|
|
64
66
|
updateCacheWithMessage(queryClient, threadedMessagesQueryKey, message, e.event)
|
|
67
|
+
|
|
68
|
+
if (isReplyRootUpdate) {
|
|
69
|
+
updateCacheReplyRoots(queryClient, messagesQueryKey, message)
|
|
70
|
+
updateCacheReplyRoots(queryClient, threadedMessagesQueryKey, message)
|
|
71
|
+
}
|
|
65
72
|
}
|
|
66
73
|
|
|
67
74
|
// Update individual message query cache so reply shadow messages update immediately
|
|
@@ -22,6 +22,14 @@ interface Props {
|
|
|
22
22
|
replyRootId?: string | null
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
interface MessageMutationVariables {
|
|
26
|
+
text: string
|
|
27
|
+
attachments?: DenormalizedAttachmentResourceForCreate[]
|
|
28
|
+
idempotentKey?: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
type ResolvedMessageMutationVariables = MessageMutationVariables & { idempotentKey: string }
|
|
32
|
+
|
|
25
33
|
export function useMessageCreateOrUpdate({ conversationId, message, replyRootId }: Props) {
|
|
26
34
|
const messageId = message?.id || null
|
|
27
35
|
const isEditing = !isNewMessage(message)
|
|
@@ -29,13 +37,7 @@ export function useMessageCreateOrUpdate({ conversationId, message, replyRootId
|
|
|
29
37
|
const currentPerson = useCurrentPerson()
|
|
30
38
|
|
|
31
39
|
const mutation = useMutation({
|
|
32
|
-
mutationFn: ({
|
|
33
|
-
text,
|
|
34
|
-
attachments,
|
|
35
|
-
}: {
|
|
36
|
-
text: string
|
|
37
|
-
attachments?: DenormalizedAttachmentResourceForCreate[]
|
|
38
|
-
}) => {
|
|
40
|
+
mutationFn: ({ text, attachments, idempotentKey }: ResolvedMessageMutationVariables) => {
|
|
39
41
|
const requestParams = getMessagesRequestArgs({ conversation_id: conversationId })
|
|
40
42
|
const fieldsWithValueJoined = Object.fromEntries(
|
|
41
43
|
Object.entries(requestParams.data.fields).map(([k, v]) => [k, v.join(',')])
|
|
@@ -46,7 +48,6 @@ export function useMessageCreateOrUpdate({ conversationId, message, replyRootId
|
|
|
46
48
|
...(!isEditing && replyRootId ? { reply_root: { id: replyRootId } } : {}),
|
|
47
49
|
}
|
|
48
50
|
if (!isEditing) {
|
|
49
|
-
const idempotentKey = insecureUUID()
|
|
50
51
|
attributes.idempotent_key = idempotentKey
|
|
51
52
|
startMessageCreationTracking(idempotentKey)
|
|
52
53
|
}
|
|
@@ -71,13 +72,7 @@ export function useMessageCreateOrUpdate({ conversationId, message, replyRootId
|
|
|
71
72
|
})
|
|
72
73
|
}
|
|
73
74
|
},
|
|
74
|
-
onMutate: async ({
|
|
75
|
-
text,
|
|
76
|
-
attachments,
|
|
77
|
-
}: {
|
|
78
|
-
text: string
|
|
79
|
-
attachments?: DenormalizedAttachmentResourceForCreate[]
|
|
80
|
-
}) => {
|
|
75
|
+
onMutate: async ({ text, attachments, idempotentKey }: ResolvedMessageMutationVariables) => {
|
|
81
76
|
if (message && isEditing) {
|
|
82
77
|
const optimisticMessage = optimisticallyUpdateMessage({
|
|
83
78
|
conversationId,
|
|
@@ -96,6 +91,7 @@ export function useMessageCreateOrUpdate({ conversationId, message, replyRootId
|
|
|
96
91
|
attachments,
|
|
97
92
|
currentPerson,
|
|
98
93
|
replyRootId,
|
|
94
|
+
idempotentKey,
|
|
99
95
|
})
|
|
100
96
|
|
|
101
97
|
return { message: optimisticMessage }
|
|
@@ -155,7 +151,24 @@ export function useMessageCreateOrUpdate({ conversationId, message, replyRootId
|
|
|
155
151
|
},
|
|
156
152
|
})
|
|
157
153
|
|
|
158
|
-
|
|
154
|
+
const resolveVariables = (
|
|
155
|
+
variables: MessageMutationVariables
|
|
156
|
+
): ResolvedMessageMutationVariables => ({
|
|
157
|
+
...variables,
|
|
158
|
+
idempotentKey: variables.idempotentKey ?? message?.idempotentKey ?? insecureUUID(),
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
const mutate = (
|
|
162
|
+
variables: MessageMutationVariables,
|
|
163
|
+
options?: Parameters<typeof mutation.mutate>[1]
|
|
164
|
+
) => mutation.mutate(resolveVariables(variables), options)
|
|
165
|
+
|
|
166
|
+
const mutateAsync = (
|
|
167
|
+
variables: MessageMutationVariables,
|
|
168
|
+
options?: Parameters<typeof mutation.mutateAsync>[1]
|
|
169
|
+
) => mutation.mutateAsync(resolveVariables(variables), options)
|
|
170
|
+
|
|
171
|
+
return { ...mutation, mutate, mutateAsync }
|
|
159
172
|
}
|
|
160
173
|
|
|
161
174
|
/**
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { date as formatDate } from '@planningcenter/datetime-fmt'
|
|
2
1
|
import { HeaderTitle, HeaderTitleProps, PlatformPressable } from '@react-navigation/elements'
|
|
3
2
|
import {
|
|
4
3
|
CommonActions,
|
|
@@ -11,24 +10,21 @@ import {
|
|
|
11
10
|
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
|
12
11
|
import { FlatList, Platform, StyleSheet, useWindowDimensions, View } from 'react-native'
|
|
13
12
|
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
|
14
|
-
import { Badge, Icon
|
|
13
|
+
import { Badge, Icon } from '../components'
|
|
15
14
|
import { ConversationStartBanner } from '../components/conversation/conversation_start_banner'
|
|
16
15
|
import { JumpToBottomButton } from '../components/conversation/jump_to_bottom_button'
|
|
17
|
-
import { Message } from '../components/conversation/message'
|
|
18
16
|
import { MessageForm } from '../components/conversation/message_form'
|
|
17
|
+
import { MessageList } from '../components/conversation/message_list'
|
|
19
18
|
import {
|
|
20
19
|
ConversationDisabledBanner,
|
|
21
20
|
LeaderMessagesDisabledBanner,
|
|
22
21
|
MemberMessagesDisabledBanner,
|
|
23
22
|
} from '../components/conversation/messages_disabled_banners'
|
|
24
|
-
import { ReplyShadowMessage } from '../components/conversation/reply_shadow_message'
|
|
25
|
-
import { SystemMessage } from '../components/conversation/system_message'
|
|
26
23
|
import { TypingIndicator } from '../components/conversation/typing_indicator'
|
|
27
24
|
import { KeyboardView } from '../components/display/keyboard_view'
|
|
28
25
|
import { useHeaderSlotReserve } from '../components/display/platform_modal_header_buttons'
|
|
29
26
|
import BlankState from '../components/primitive/blank_state_primitive'
|
|
30
27
|
import { ConversationContextProvider } from '../contexts/conversation_context'
|
|
31
|
-
import { useTheme } from '../hooks'
|
|
32
28
|
import { useMessageAvailableMembersAction } from '../hooks/direct_messages/use_message_available_members_action'
|
|
33
29
|
import { useConversation } from '../hooks/use_conversation'
|
|
34
30
|
import { useConversationJoltEvents } from '../hooks/use_conversation_jolt_events'
|
|
@@ -45,10 +41,6 @@ import { useVideoModerationJoltEvents } from '../hooks/use_video_moderation_jolt
|
|
|
45
41
|
import { ConversationResource } from '../types/resources/conversation'
|
|
46
42
|
import { ConversationBadgeResource } from '../types/resources/conversation_badge'
|
|
47
43
|
import { MessageResource } from '../types/resources/message'
|
|
48
|
-
import { getRelativeDateStatus } from '../utils/date'
|
|
49
|
-
import { groupMessages, type DateSeparator } from '../utils/group_messages'
|
|
50
|
-
import { CONVERSATION_MESSAGE_LIST_PADDING_HORIZONTAL } from '../utils/styles'
|
|
51
|
-
import { isSystemMessage } from '../utils/system_messages'
|
|
52
44
|
|
|
53
45
|
export type ConversationRouteProps = {
|
|
54
46
|
conversation_id: number
|
|
@@ -101,16 +93,11 @@ function ConversationScreenContent({ route }: ConversationScreenProps) {
|
|
|
101
93
|
const { markHandledByMessageForm } = useVideoModerationJoltEvents()
|
|
102
94
|
useEnsureConversationsRouteExists()
|
|
103
95
|
useMarkLatestMessageRead({ conversation, messages })
|
|
104
|
-
const
|
|
105
|
-
ms: messages,
|
|
106
|
-
inReplyScreen: !!reply_root_id,
|
|
107
|
-
})
|
|
108
|
-
const noMessages = messagesWithSeparators.length === 0
|
|
96
|
+
const noMessages = messages.length === 0
|
|
109
97
|
|
|
110
98
|
const { repliesDisabled, memberAbility, badges, title } = conversation
|
|
111
99
|
const canReply = memberAbility?.canReply
|
|
112
100
|
const showLeaderDisabledReplyBanner = canReply && repliesDisabled
|
|
113
|
-
const canDeleteNonAuthoredMessages = memberAbility?.canDeleteNonAuthoredMessages ?? false
|
|
114
101
|
const currentlyEditingMessage = messages.find(m => String(m.id) === String(editing_message_id))
|
|
115
102
|
const replyRootAuthorFirstName = reply_root_author_name?.split(' ')[0]
|
|
116
103
|
const replyHeaderTitle = replyRootAuthorFirstName
|
|
@@ -181,54 +168,19 @@ function ConversationScreenContent({ route }: ConversationScreenProps) {
|
|
|
181
168
|
<ConversationStartBanner conversation={conversation} hasMessages={false} />
|
|
182
169
|
</View>
|
|
183
170
|
) : (
|
|
184
|
-
<
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
171
|
+
<MessageList
|
|
172
|
+
listRef={listRef}
|
|
173
|
+
conversation={conversation}
|
|
174
|
+
conversationId={conversation_id}
|
|
175
|
+
messages={messages}
|
|
176
|
+
replyRootId={reply_root_id}
|
|
177
|
+
hasNextPage={hasNextPage}
|
|
178
|
+
onEndReached={() => fetchNextPage()}
|
|
190
179
|
onScroll={trackScroll}
|
|
191
180
|
onLayout={onLayout}
|
|
192
181
|
onContentSizeChange={onContentSizeChange}
|
|
193
182
|
onScrollEndDrag={onScrollEndDrag}
|
|
194
183
|
onMomentumScrollEnd={onMomentumScrollEnd}
|
|
195
|
-
scrollEventThrottle={10}
|
|
196
|
-
ListFooterComponent={
|
|
197
|
-
hasNextPage ? null : (
|
|
198
|
-
<ConversationStartBanner conversation={conversation} hasMessages={true} />
|
|
199
|
-
)
|
|
200
|
-
}
|
|
201
|
-
renderItem={({ item }) => {
|
|
202
|
-
if (item.type === 'DateSeparator') {
|
|
203
|
-
return <InlineDateSeparator {...item} />
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
if (item.type === 'ReplyShadowMessage') {
|
|
207
|
-
return (
|
|
208
|
-
<ReplyShadowMessage
|
|
209
|
-
{...(item as any)}
|
|
210
|
-
conversation_id={conversation_id}
|
|
211
|
-
inReplyScreen={!!reply_root_id}
|
|
212
|
-
/>
|
|
213
|
-
)
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
if (isSystemMessage(item)) {
|
|
217
|
-
return <SystemMessage message={item} conversationId={conversation_id} />
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
return (
|
|
221
|
-
<Message
|
|
222
|
-
{...item}
|
|
223
|
-
canDeleteNonAuthoredMessages={canDeleteNonAuthoredMessages}
|
|
224
|
-
conversation_id={conversation_id}
|
|
225
|
-
latestReadMessageSortKey={conversation?.latestReadMessageSortKey}
|
|
226
|
-
inReplyScreen={!!reply_root_id}
|
|
227
|
-
/>
|
|
228
|
-
)
|
|
229
|
-
}}
|
|
230
|
-
onEndReached={() => fetchNextPage()}
|
|
231
|
-
ListHeaderComponent={<View style={styles.listHeader} />}
|
|
232
184
|
/>
|
|
233
185
|
)}
|
|
234
186
|
<JumpToBottomButton onPress={handleReturnToBottom} visible={showJumpToBottomButton} />
|
|
@@ -295,44 +247,6 @@ function ConversationBottomBar({
|
|
|
295
247
|
)
|
|
296
248
|
}
|
|
297
249
|
|
|
298
|
-
function InlineDateSeparator({ date }: DateSeparator) {
|
|
299
|
-
const styles = useDateSeparatorStyles()
|
|
300
|
-
const { isThisYear } = getRelativeDateStatus(date)
|
|
301
|
-
const showYear = !isThisYear
|
|
302
|
-
const dateStamp = formatDate(date, { style: 'long', year: showYear })
|
|
303
|
-
|
|
304
|
-
return (
|
|
305
|
-
<View style={styles.container}>
|
|
306
|
-
<View style={styles.separator} />
|
|
307
|
-
<Text variant="footnote" style={styles.dateText}>
|
|
308
|
-
{dateStamp}
|
|
309
|
-
</Text>
|
|
310
|
-
<View style={styles.separator} />
|
|
311
|
-
</View>
|
|
312
|
-
)
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
const useDateSeparatorStyles = () => {
|
|
316
|
-
const theme = useTheme()
|
|
317
|
-
return StyleSheet.create({
|
|
318
|
-
container: {
|
|
319
|
-
alignItems: 'center',
|
|
320
|
-
flexDirection: 'row',
|
|
321
|
-
paddingHorizontal: CONVERSATION_MESSAGE_LIST_PADDING_HORIZONTAL,
|
|
322
|
-
paddingVertical: 16,
|
|
323
|
-
},
|
|
324
|
-
separator: {
|
|
325
|
-
flex: 1,
|
|
326
|
-
height: 1,
|
|
327
|
-
borderTopWidth: 1,
|
|
328
|
-
borderTopColor: theme.colors.borderColorDefaultBase,
|
|
329
|
-
},
|
|
330
|
-
dateText: {
|
|
331
|
-
paddingHorizontal: 8,
|
|
332
|
-
},
|
|
333
|
-
})
|
|
334
|
-
}
|
|
335
|
-
|
|
336
250
|
interface ConversationScreenTitleProps extends HeaderTitleProps {
|
|
337
251
|
conversation_id: number
|
|
338
252
|
badge?: ConversationBadgeResource
|
|
@@ -429,13 +343,6 @@ const useStyles = () => {
|
|
|
429
343
|
flex: 1,
|
|
430
344
|
justifyContent: 'center',
|
|
431
345
|
},
|
|
432
|
-
listContainer: {
|
|
433
|
-
paddingVertical: 12,
|
|
434
|
-
},
|
|
435
|
-
listHeader: {
|
|
436
|
-
// Just whitespace to provide space where the typing indicator can be
|
|
437
|
-
height: 16,
|
|
438
|
-
},
|
|
439
346
|
})
|
|
440
347
|
}
|
|
441
348
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { StaticScreenProps, useNavigation } from '@react-navigation/native'
|
|
2
|
-
import React
|
|
2
|
+
import React from 'react'
|
|
3
3
|
import FormSheet, { getFormSheetScreenOptions } from '../components/primitive/form_sheet'
|
|
4
4
|
import { useCurrentPerson } from '../hooks/use_current_person'
|
|
5
5
|
import { useConversationTypeAccess } from '../hooks/use_new_conversation_entry'
|
|
@@ -10,6 +10,7 @@ import { Haptic } from '../utils/native_adapters'
|
|
|
10
10
|
|
|
11
11
|
export const ConversationSelectTypeScreenOptions = getFormSheetScreenOptions({
|
|
12
12
|
headerTitle: 'New conversation',
|
|
13
|
+
sheetAllowedDetents: 'fitToContents',
|
|
13
14
|
})
|
|
14
15
|
|
|
15
16
|
export type ConversationSelectTypeScreenProps = StaticScreenProps<{
|
|
@@ -34,13 +35,6 @@ export function ConversationSelectTypeScreen({ route }: ConversationSelectTypeSc
|
|
|
34
35
|
const filterParams = { chat_group_graph_id, group_source_app_name }
|
|
35
36
|
const showDirectMessageOption = directMessagesEligible
|
|
36
37
|
|
|
37
|
-
// sheetAllowedDetents depends on DM eligibility, which isn't known until render, so it's set
|
|
38
|
-
// here instead of in the static ConversationSelectTypeScreenOptions. useLayoutEffect applies
|
|
39
|
-
// it before paint so the sheet never visibly resizes after opening.
|
|
40
|
-
useLayoutEffect(() => {
|
|
41
|
-
navigation.setOptions({ sheetAllowedDetents: showDirectMessageOption ? [0.3] : [0.25] })
|
|
42
|
-
}, [navigation, showDirectMessageOption])
|
|
43
|
-
|
|
44
38
|
const handleFilterPress = () => {
|
|
45
39
|
if (!newConversationFromFilter) return
|
|
46
40
|
|
|
@@ -82,7 +76,7 @@ export function ConversationSelectTypeScreen({ route }: ConversationSelectTypeSc
|
|
|
82
76
|
navigateToNewConversationType('ConversationSelectDirectMessageRecipients')
|
|
83
77
|
|
|
84
78
|
return (
|
|
85
|
-
<FormSheet.Root>
|
|
79
|
+
<FormSheet.Root fitToContents>
|
|
86
80
|
<FormSheet.Header>
|
|
87
81
|
<FormSheet.HeaderTitle>Start a conversation by</FormSheet.HeaderTitle>
|
|
88
82
|
</FormSheet.Header>
|
|
@@ -17,6 +17,8 @@ export interface MessageResource {
|
|
|
17
17
|
reactionCounts: ReactionCountResource[]
|
|
18
18
|
replyCount: number
|
|
19
19
|
replyRootId?: string | null
|
|
20
|
+
replyRoot?: MessageResource | null
|
|
21
|
+
idempotentKey?: string | null
|
|
20
22
|
messageType: string
|
|
21
23
|
personIdsForSystemEvent?: number[] | null
|
|
22
24
|
systemTextParts?: { names: string[]; overflowCount: number; action: string } | null
|
|
@@ -124,6 +124,54 @@ describe('groupMessages — nextRendersAuthor mirrors the newer enriched neighbo
|
|
|
124
124
|
})
|
|
125
125
|
})
|
|
126
126
|
|
|
127
|
+
describe('groupMessages — reply roots', () => {
|
|
128
|
+
const findShadow = (items: ReturnType<typeof groupMessages>, id: string) =>
|
|
129
|
+
items.find(item => 'type' in item && item.type === 'ReplyShadowMessage' && item.id === id)
|
|
130
|
+
|
|
131
|
+
it('carries the root from the page onto the reply and its shadow', () => {
|
|
132
|
+
const messages = [
|
|
133
|
+
message('03', { replyRootId: 'rootA' }),
|
|
134
|
+
message('02'),
|
|
135
|
+
message('rootA', { replyRootId: 'rootA', text: 'fresh' }),
|
|
136
|
+
]
|
|
137
|
+
|
|
138
|
+
const enriched = groupMessages({ ms: messages })
|
|
139
|
+
|
|
140
|
+
expect((findEnriched(enriched, '03') as MessageResource).replyRoot?.text).toBe('fresh')
|
|
141
|
+
expect(findShadow(enriched, '03-rootA')).toMatchObject({ replyRoot: { id: 'rootA' } })
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('prefers the page copy of the root over the reply’s included snapshot', () => {
|
|
145
|
+
const snapshot = message('rootA', { replyRootId: 'rootA', text: 'stale' })
|
|
146
|
+
const messages = [
|
|
147
|
+
message('03', { replyRootId: 'rootA', replyRoot: snapshot }),
|
|
148
|
+
message('02'),
|
|
149
|
+
message('rootA', { replyRootId: 'rootA', text: 'fresh' }),
|
|
150
|
+
]
|
|
151
|
+
|
|
152
|
+
const enriched = groupMessages({ ms: messages })
|
|
153
|
+
|
|
154
|
+
expect(findShadow(enriched, '03-rootA')).toMatchObject({ replyRoot: { text: 'fresh' } })
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('falls back to the included snapshot when the root is not on the page', () => {
|
|
158
|
+
const snapshot = message('rootA', { replyRootId: 'rootA', text: 'included' })
|
|
159
|
+
const messages = [message('02', { replyRootId: 'rootA', replyRoot: snapshot })]
|
|
160
|
+
|
|
161
|
+
const enriched = groupMessages({ ms: messages })
|
|
162
|
+
|
|
163
|
+
expect(findShadow(enriched, '02-rootA')).toMatchObject({ replyRoot: { text: 'included' } })
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it('leaves replyRoot null when the root is neither on the page nor included', () => {
|
|
167
|
+
const messages = [message('02', { replyRootId: 'rootA' })]
|
|
168
|
+
|
|
169
|
+
const enriched = groupMessages({ ms: messages })
|
|
170
|
+
|
|
171
|
+
expect(findShadow(enriched, '02-rootA')).toMatchObject({ replyRoot: null })
|
|
172
|
+
})
|
|
173
|
+
})
|
|
174
|
+
|
|
127
175
|
describe('groupMessages — system messages', () => {
|
|
128
176
|
it('flags lastInGroup true and renderAuthor false on system messages', () => {
|
|
129
177
|
const messages = [
|