@planningcenter/chat-react-native 3.35.0-rc.3 → 3.35.0-rc.5
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/README.md +1 -1
- package/build/components/conversation/jump_to_bottom_button.d.ts +2 -1
- package/build/components/conversation/jump_to_bottom_button.d.ts.map +1 -1
- package/build/components/conversation/jump_to_bottom_button.js +39 -7
- package/build/components/conversation/jump_to_bottom_button.js.map +1 -1
- package/build/components/conversation/reply_shadow_message.d.ts +1 -2
- package/build/components/conversation/reply_shadow_message.d.ts.map +1 -1
- package/build/components/conversation/reply_shadow_message.js.map +1 -1
- package/build/components/conversation/unread_divider.d.ts +6 -0
- package/build/components/conversation/unread_divider.d.ts.map +1 -0
- package/build/components/conversation/unread_divider.js +59 -0
- package/build/components/conversation/unread_divider.js.map +1 -0
- package/build/contexts/conversation_context.d.ts +2 -0
- package/build/contexts/conversation_context.d.ts.map +1 -1
- package/build/contexts/conversation_context.js +13 -5
- package/build/contexts/conversation_context.js.map +1 -1
- package/build/hooks/use_conversation_messages.d.ts +2 -0
- package/build/hooks/use_conversation_messages.d.ts.map +1 -1
- package/build/hooks/use_conversation_messages.js +9 -5
- 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 +4 -4
- package/build/hooks/use_conversation_messages_jolt_events.js.map +1 -1
- package/build/hooks/use_conversations_actions.d.ts +5 -0
- package/build/hooks/use_conversations_actions.d.ts.map +1 -1
- package/build/hooks/use_conversations_actions.js +12 -0
- package/build/hooks/use_conversations_actions.js.map +1 -1
- package/build/hooks/use_flat_list_viewability.d.ts +20 -0
- package/build/hooks/use_flat_list_viewability.d.ts.map +1 -0
- package/build/hooks/use_flat_list_viewability.js +30 -0
- package/build/hooks/use_flat_list_viewability.js.map +1 -0
- package/build/hooks/use_jump_to_bottom_action.d.ts +9 -0
- package/build/hooks/use_jump_to_bottom_action.d.ts.map +1 -0
- package/build/hooks/use_jump_to_bottom_action.js +62 -0
- package/build/hooks/use_jump_to_bottom_action.js.map +1 -0
- package/build/hooks/use_jump_to_unread_anchor.d.ts +20 -0
- package/build/hooks/use_jump_to_unread_anchor.d.ts.map +1 -0
- package/build/hooks/use_jump_to_unread_anchor.js +53 -0
- package/build/hooks/use_jump_to_unread_anchor.js.map +1 -0
- package/build/hooks/use_jump_to_unread_gates.d.ts +5 -0
- package/build/hooks/use_jump_to_unread_gates.d.ts.map +1 -0
- package/build/hooks/use_jump_to_unread_gates.js +10 -0
- package/build/hooks/use_jump_to_unread_gates.js.map +1 -0
- package/build/hooks/use_mark_latest_message_read.d.ts +1 -1
- package/build/hooks/use_mark_latest_message_read.d.ts.map +1 -1
- package/build/hooks/use_mark_latest_message_read.js +17 -1
- package/build/hooks/use_mark_latest_message_read.js.map +1 -1
- package/build/hooks/use_scroll_tracking.d.ts +13 -0
- package/build/hooks/use_scroll_tracking.d.ts.map +1 -0
- package/build/hooks/use_scroll_tracking.js +45 -0
- package/build/hooks/use_scroll_tracking.js.map +1 -0
- package/build/hooks/use_track_highest_seen_message.d.ts +4 -0
- package/build/hooks/use_track_highest_seen_message.d.ts.map +1 -0
- package/build/hooks/use_track_highest_seen_message.js +35 -0
- package/build/hooks/use_track_highest_seen_message.js.map +1 -0
- package/build/navigation/index.d.ts.map +1 -1
- package/build/screens/conversation_screen.d.ts.map +1 -1
- package/build/screens/conversation_screen.js +87 -44
- package/build/screens/conversation_screen.js.map +1 -1
- package/build/screens/group_notification_settings_screen.d.ts.map +1 -1
- package/build/screens/group_notification_settings_screen.js +6 -3
- package/build/screens/group_notification_settings_screen.js.map +1 -1
- package/build/screens/notification_settings_screen.js +2 -2
- package/build/screens/notification_settings_screen.js.map +1 -1
- package/build/screens/preferred_app_selection_screen.js +3 -3
- package/build/screens/preferred_app_selection_screen.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 +4 -0
- package/build/utils/cache/messages_cache.js.map +1 -1
- package/build/utils/group_messages.d.ts +9 -2
- package/build/utils/group_messages.d.ts.map +1 -1
- package/build/utils/group_messages.js +20 -1
- package/build/utils/group_messages.js.map +1 -1
- package/build/utils/highest_seen_tracker.d.ts +12 -0
- package/build/utils/highest_seen_tracker.d.ts.map +1 -0
- package/build/utils/highest_seen_tracker.js +37 -0
- package/build/utils/highest_seen_tracker.js.map +1 -0
- package/build/utils/message_viewability.d.ts +24 -0
- package/build/utils/message_viewability.d.ts.map +1 -0
- package/build/utils/message_viewability.js +29 -0
- package/build/utils/message_viewability.js.map +1 -0
- package/build/utils/unread_divider_helpers.d.ts +18 -0
- package/build/utils/unread_divider_helpers.d.ts.map +1 -0
- package/build/utils/unread_divider_helpers.js +13 -0
- package/build/utils/unread_divider_helpers.js.map +1 -0
- package/package.json +10 -4
- package/src/__tests__/contexts/session_context.tsx +1 -1
- package/src/__tests__/hooks/use_async_storage.test.tsx +1 -1
- package/src/__tests__/hooks/use_attachment_uploader.test.tsx +1 -1
- package/src/__tests__/hooks/use_chat_configuration.test.tsx +1 -1
- package/src/__tests__/hooks/use_conversation_messages.test.tsx +1 -1
- package/src/__tests__/hooks/use_mark_latest_message_read.test.tsx +154 -0
- package/src/__tests__/utils/cache/messages_cache.test.ts +54 -0
- package/src/components/conversation/jump_to_bottom_button.tsx +57 -8
- package/src/components/conversation/reply_shadow_message.tsx +4 -2
- package/src/components/conversation/unread_divider.tsx +90 -0
- package/src/contexts/conversation_context.tsx +15 -13
- package/src/hooks/use_conversation_messages.ts +19 -3
- package/src/hooks/use_conversation_messages_jolt_events.ts +4 -3
- package/src/hooks/use_conversations_actions.ts +15 -0
- package/src/hooks/use_flat_list_viewability.ts +50 -0
- package/src/hooks/use_jump_to_bottom_action.ts +75 -0
- package/src/hooks/use_jump_to_unread_anchor.ts +68 -0
- package/src/hooks/use_jump_to_unread_gates.ts +10 -0
- package/src/hooks/use_mark_latest_message_read.ts +16 -2
- package/src/hooks/use_scroll_tracking.ts +64 -0
- package/src/hooks/use_track_highest_seen_message.ts +43 -0
- package/src/screens/conversation_screen.tsx +173 -70
- package/src/screens/group_notification_settings_screen.tsx +6 -3
- package/src/screens/notification_settings_screen.tsx +2 -2
- package/src/screens/preferred_app_selection_screen.tsx +3 -3
- package/src/utils/__tests__/group_messages.test.ts +71 -0
- package/src/utils/__tests__/highest_seen_tracker.test.ts +82 -0
- package/src/utils/__tests__/message_viewability.test.ts +168 -0
- package/src/utils/__tests__/unread_divider_helpers.test.ts +85 -0
- package/src/utils/cache/messages_cache.ts +5 -0
- package/src/utils/group_messages.ts +42 -2
- package/src/utils/highest_seen_tracker.ts +42 -0
- package/src/utils/message_viewability.ts +49 -0
- package/src/utils/unread_divider_helpers.ts +25 -0
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { useQueryClient } from '@tanstack/react-query'
|
|
2
|
+
import { RefObject, useCallback, useEffect, useRef, useState } from 'react'
|
|
3
|
+
import type { FlatList } from 'react-native'
|
|
4
|
+
import { useConversationContext } from '../contexts/conversation_context'
|
|
5
|
+
import { ApiCollection, MessageResource } from '../types'
|
|
6
|
+
import { Haptic } from '../utils/native_adapters'
|
|
7
|
+
import { getMessagesQueryKey, getMessagesRequestArgs } from '../utils/request/get_messages'
|
|
8
|
+
import { useApiClient } from './use_api_client'
|
|
9
|
+
import { useJumpToUnreadGates } from './use_jump_to_unread_gates'
|
|
10
|
+
|
|
11
|
+
const LATEST_PAGE_SIZE = 25
|
|
12
|
+
|
|
13
|
+
export function useJumpToBottomAction({ listRef }: { listRef: RefObject<FlatList | null> }) {
|
|
14
|
+
const { jumpToUnreadEnabled } = useJumpToUnreadGates()
|
|
15
|
+
const { conversationId, currentPageReplyRootId, initialMessageId, setInitialMessageId } =
|
|
16
|
+
useConversationContext()
|
|
17
|
+
const queryClient = useQueryClient()
|
|
18
|
+
const apiClient = useApiClient()
|
|
19
|
+
const [isJumpingToBottom, setIsJumpingToBottom] = useState(false)
|
|
20
|
+
|
|
21
|
+
const mountedRef = useRef(true)
|
|
22
|
+
useEffect(
|
|
23
|
+
() => () => {
|
|
24
|
+
mountedRef.current = false
|
|
25
|
+
},
|
|
26
|
+
[]
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
const handleJumpToBottom = useCallback(() => {
|
|
30
|
+
Haptic.impactLight()
|
|
31
|
+
listRef.current?.scrollToOffset({ offset: 0, animated: true })
|
|
32
|
+
|
|
33
|
+
if (!jumpToUnreadEnabled || !initialMessageId) return
|
|
34
|
+
|
|
35
|
+
const queryKey = getMessagesQueryKey({
|
|
36
|
+
conversation_id: conversationId,
|
|
37
|
+
reply_root_id: currentPageReplyRootId,
|
|
38
|
+
})
|
|
39
|
+
const args = getMessagesRequestArgs({
|
|
40
|
+
conversation_id: conversationId,
|
|
41
|
+
reply_root_id: currentPageReplyRootId,
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
setIsJumpingToBottom(true)
|
|
45
|
+
|
|
46
|
+
queryClient
|
|
47
|
+
.cancelQueries({ queryKey })
|
|
48
|
+
.then(() =>
|
|
49
|
+
apiClient.chat.get<ApiCollection<MessageResource>>({
|
|
50
|
+
url: args.url,
|
|
51
|
+
data: { ...args.data, perPage: LATEST_PAGE_SIZE },
|
|
52
|
+
})
|
|
53
|
+
)
|
|
54
|
+
.then(latest => {
|
|
55
|
+
if (!mountedRef.current) return
|
|
56
|
+
queryClient.setQueryData(queryKey, { pages: [latest], pageParams: [{}] })
|
|
57
|
+
setInitialMessageId(null)
|
|
58
|
+
listRef.current?.scrollToOffset({ offset: 0, animated: false })
|
|
59
|
+
})
|
|
60
|
+
.finally(() => {
|
|
61
|
+
if (mountedRef.current) setIsJumpingToBottom(false)
|
|
62
|
+
})
|
|
63
|
+
}, [
|
|
64
|
+
jumpToUnreadEnabled,
|
|
65
|
+
initialMessageId,
|
|
66
|
+
setInitialMessageId,
|
|
67
|
+
queryClient,
|
|
68
|
+
apiClient,
|
|
69
|
+
conversationId,
|
|
70
|
+
currentPageReplyRootId,
|
|
71
|
+
listRef,
|
|
72
|
+
])
|
|
73
|
+
|
|
74
|
+
return { handleJumpToBottom, isJumpingToBottom }
|
|
75
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { RefObject, useCallback, useEffect, useRef } from 'react'
|
|
2
|
+
import type { FlatList } from 'react-native'
|
|
3
|
+
import { useConversationContext } from '../contexts/conversation_context'
|
|
4
|
+
import { useJumpToUnreadGates } from './use_jump_to_unread_gates'
|
|
5
|
+
|
|
6
|
+
interface UseJumpToUnreadAnchorArgs<T> {
|
|
7
|
+
listRef: RefObject<FlatList<T> | null>
|
|
8
|
+
items: T[]
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface ScrollToIndexFailInfo {
|
|
12
|
+
index: number
|
|
13
|
+
highestMeasuredFrameIndex: number
|
|
14
|
+
averageItemLength: number
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function useJumpToUnreadAnchor<T extends { id?: string | number }>({
|
|
18
|
+
listRef,
|
|
19
|
+
items,
|
|
20
|
+
}: UseJumpToUnreadAnchorArgs<T>) {
|
|
21
|
+
const { jumpToUnreadActive } = useJumpToUnreadGates()
|
|
22
|
+
const { initialMessageId } = useConversationContext()
|
|
23
|
+
const hasAnchoredRef = useRef(false)
|
|
24
|
+
const userTouchedRef = useRef(false)
|
|
25
|
+
const retryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
26
|
+
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
return () => {
|
|
29
|
+
if (retryTimerRef.current) clearTimeout(retryTimerRef.current)
|
|
30
|
+
retryTimerRef.current = null
|
|
31
|
+
}
|
|
32
|
+
}, [])
|
|
33
|
+
|
|
34
|
+
const onScrollBeginDrag = useCallback(() => {
|
|
35
|
+
userTouchedRef.current = true
|
|
36
|
+
if (retryTimerRef.current) {
|
|
37
|
+
clearTimeout(retryTimerRef.current)
|
|
38
|
+
retryTimerRef.current = null
|
|
39
|
+
}
|
|
40
|
+
}, [])
|
|
41
|
+
|
|
42
|
+
const onContentSizeChange = useCallback(() => {
|
|
43
|
+
if (hasAnchoredRef.current) return
|
|
44
|
+
if (!jumpToUnreadActive || !initialMessageId) return
|
|
45
|
+
if (userTouchedRef.current) return
|
|
46
|
+
const index = items.findIndex(item => String(item.id ?? '') === initialMessageId)
|
|
47
|
+
if (index < 0) return
|
|
48
|
+
hasAnchoredRef.current = true
|
|
49
|
+
listRef.current?.scrollToIndex({ index, viewPosition: 0.25, animated: false })
|
|
50
|
+
}, [jumpToUnreadActive, initialMessageId, items, listRef])
|
|
51
|
+
|
|
52
|
+
const onScrollToIndexFailed = useCallback(
|
|
53
|
+
(info: ScrollToIndexFailInfo) => {
|
|
54
|
+
if (userTouchedRef.current) return
|
|
55
|
+
const offset = info.averageItemLength * info.index
|
|
56
|
+
listRef.current?.scrollToOffset({ offset, animated: false })
|
|
57
|
+
if (retryTimerRef.current) clearTimeout(retryTimerRef.current)
|
|
58
|
+
retryTimerRef.current = setTimeout(() => {
|
|
59
|
+
retryTimerRef.current = null
|
|
60
|
+
if (userTouchedRef.current) return
|
|
61
|
+
listRef.current?.scrollToIndex({ index: info.index, viewPosition: 0.25, animated: false })
|
|
62
|
+
}, 50)
|
|
63
|
+
},
|
|
64
|
+
[listRef]
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
return { onScrollBeginDrag, onContentSizeChange, onScrollToIndexFailed }
|
|
68
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { useConversationContext } from '../contexts/conversation_context'
|
|
2
|
+
import { useFeatures } from './use_features'
|
|
3
|
+
|
|
4
|
+
export function useJumpToUnreadGates() {
|
|
5
|
+
const { featureEnabled } = useFeatures()
|
|
6
|
+
const { initialMessageIdIsAnchor } = useConversationContext()
|
|
7
|
+
const jumpToUnreadEnabled = featureEnabled('jump_to_unread')
|
|
8
|
+
const jumpToUnreadActive = jumpToUnreadEnabled && initialMessageIdIsAnchor
|
|
9
|
+
return { jumpToUnreadEnabled, jumpToUnreadActive }
|
|
10
|
+
}
|
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
import { debounce } from 'lodash'
|
|
2
2
|
import { useEffect, useMemo, useRef } from 'react'
|
|
3
|
+
import { useConversationContext } from '../contexts/conversation_context'
|
|
3
4
|
import { ConversationResource, MessageResource } from '../types'
|
|
4
5
|
import { useAppState } from './use_app_state'
|
|
5
6
|
import { useConversationsMarkRead } from './use_conversations_actions'
|
|
7
|
+
import { useJumpToUnreadGates } from './use_jump_to_unread_gates'
|
|
6
8
|
|
|
7
9
|
interface Props {
|
|
8
10
|
conversation: ConversationResource
|
|
9
|
-
messages
|
|
11
|
+
messages?: MessageResource[]
|
|
10
12
|
}
|
|
11
13
|
|
|
12
14
|
export function useMarkLatestMessageRead({ conversation }: Props) {
|
|
15
|
+
const { jumpToUnreadActive } = useJumpToUnreadGates()
|
|
16
|
+
const { currentPageReplyRootId, atEndOfMessageHistory } = useConversationContext()
|
|
13
17
|
const firedOnce = useRef<boolean>(false)
|
|
14
18
|
const { markRead } = useConversationsMarkRead({ conversation })
|
|
15
19
|
const debouncedMarkRead = useMemo(
|
|
@@ -25,10 +29,20 @@ export function useMarkLatestMessageRead({ conversation }: Props) {
|
|
|
25
29
|
|
|
26
30
|
useEffect(() => {
|
|
27
31
|
if (!isActive || !shouldMarkRead) return
|
|
32
|
+
if (currentPageReplyRootId) return
|
|
33
|
+
if (jumpToUnreadActive && !atEndOfMessageHistory) return
|
|
28
34
|
|
|
29
35
|
firedOnce.current = true
|
|
30
36
|
|
|
31
37
|
debouncedMarkRead(true)
|
|
32
38
|
// keeping unreadReactionCount in the dependency array to watch for changes
|
|
33
|
-
}, [
|
|
39
|
+
}, [
|
|
40
|
+
debouncedMarkRead,
|
|
41
|
+
isActive,
|
|
42
|
+
shouldMarkRead,
|
|
43
|
+
unreadReactionCount,
|
|
44
|
+
currentPageReplyRootId,
|
|
45
|
+
jumpToUnreadActive,
|
|
46
|
+
atEndOfMessageHistory,
|
|
47
|
+
])
|
|
34
48
|
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
2
|
+
import type { NativeScrollEvent, NativeSyntheticEvent } from 'react-native'
|
|
3
|
+
import { useConversationContext } from '../contexts/conversation_context'
|
|
4
|
+
|
|
5
|
+
const JUMP_TO_BOTTOM_OFFSET_THRESHOLD = 200
|
|
6
|
+
const AT_BOTTOM_OFFSET_TOLERANCE = 5
|
|
7
|
+
const FETCH_NEWER_OFFSET_THRESHOLD = 600
|
|
8
|
+
|
|
9
|
+
interface UseScrollTrackingArgs {
|
|
10
|
+
hasMoreNewerMessages: boolean
|
|
11
|
+
isFetchingNewerMessages: boolean
|
|
12
|
+
fetchNewerMessages: () => void
|
|
13
|
+
cancelFetchNewerMessages: () => void
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function useScrollTracking({
|
|
17
|
+
hasMoreNewerMessages,
|
|
18
|
+
isFetchingNewerMessages,
|
|
19
|
+
fetchNewerMessages,
|
|
20
|
+
cancelFetchNewerMessages,
|
|
21
|
+
}: UseScrollTrackingArgs) {
|
|
22
|
+
const { atEndOfMessageHistory, setAtEndOfMessageHistory } = useConversationContext()
|
|
23
|
+
const [showJumpToBottomButton, setShowJumpToBottomButton] = useState(false)
|
|
24
|
+
const withinBoundaryRef = useRef(false)
|
|
25
|
+
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
return () => {
|
|
28
|
+
cancelFetchNewerMessages()
|
|
29
|
+
}
|
|
30
|
+
}, [cancelFetchNewerMessages])
|
|
31
|
+
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
if (!isFetchingNewerMessages) withinBoundaryRef.current = false
|
|
34
|
+
}, [isFetchingNewerMessages])
|
|
35
|
+
|
|
36
|
+
const onScroll = useCallback(
|
|
37
|
+
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
|
38
|
+
const offsetY = event.nativeEvent.contentOffset.y
|
|
39
|
+
setShowJumpToBottomButton(offsetY > JUMP_TO_BOTTOM_OFFSET_THRESHOLD)
|
|
40
|
+
|
|
41
|
+
const atBottom = offsetY < AT_BOTTOM_OFFSET_TOLERANCE
|
|
42
|
+
const atEnd = atBottom && !hasMoreNewerMessages
|
|
43
|
+
if (atEnd !== atEndOfMessageHistory) setAtEndOfMessageHistory(atEnd)
|
|
44
|
+
|
|
45
|
+
if (offsetY >= FETCH_NEWER_OFFSET_THRESHOLD) {
|
|
46
|
+
withinBoundaryRef.current = false
|
|
47
|
+
return
|
|
48
|
+
}
|
|
49
|
+
if (withinBoundaryRef.current) return
|
|
50
|
+
if (!hasMoreNewerMessages || isFetchingNewerMessages) return
|
|
51
|
+
withinBoundaryRef.current = true
|
|
52
|
+
fetchNewerMessages()
|
|
53
|
+
},
|
|
54
|
+
[
|
|
55
|
+
hasMoreNewerMessages,
|
|
56
|
+
isFetchingNewerMessages,
|
|
57
|
+
fetchNewerMessages,
|
|
58
|
+
atEndOfMessageHistory,
|
|
59
|
+
setAtEndOfMessageHistory,
|
|
60
|
+
]
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
return { onScroll, showJumpToBottomButton }
|
|
64
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo } from 'react'
|
|
2
|
+
import { AppState } from 'react-native'
|
|
3
|
+
import { useConversationContext } from '../contexts/conversation_context'
|
|
4
|
+
import { makeHighestSeenTracker } from '../utils/highest_seen_tracker'
|
|
5
|
+
import { useConversationsMarkReadUpTo } from './use_conversations_actions'
|
|
6
|
+
import { useJumpToUnreadGates } from './use_jump_to_unread_gates'
|
|
7
|
+
|
|
8
|
+
export function useTrackHighestSeenMessage() {
|
|
9
|
+
const { conversationId, currentPageReplyRootId } = useConversationContext()
|
|
10
|
+
const { jumpToUnreadActive } = useJumpToUnreadGates()
|
|
11
|
+
const enabled = jumpToUnreadActive && !currentPageReplyRootId
|
|
12
|
+
const { mutate: markReadUpTo } = useConversationsMarkReadUpTo({ conversationId })
|
|
13
|
+
|
|
14
|
+
const tracker = useMemo(
|
|
15
|
+
() => makeHighestSeenTracker(conversationId, ({ sortKey }) => markReadUpTo({ sortKey })),
|
|
16
|
+
[conversationId, markReadUpTo]
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
const onMessageSeen = useCallback(
|
|
20
|
+
(sortKey: string) => {
|
|
21
|
+
if (!enabled) return
|
|
22
|
+
tracker.onSeen(sortKey)
|
|
23
|
+
},
|
|
24
|
+
[enabled, tracker]
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
if (!enabled) return
|
|
29
|
+
const sub = AppState.addEventListener('change', state => {
|
|
30
|
+
if (state !== 'active') tracker.flushNow()
|
|
31
|
+
})
|
|
32
|
+
return () => sub.remove()
|
|
33
|
+
}, [enabled, tracker])
|
|
34
|
+
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
return () => {
|
|
37
|
+
tracker.flushNow()
|
|
38
|
+
tracker.cancel()
|
|
39
|
+
}
|
|
40
|
+
}, [tracker])
|
|
41
|
+
|
|
42
|
+
return { onMessageSeen }
|
|
43
|
+
}
|
|
@@ -8,8 +8,9 @@ import {
|
|
|
8
8
|
useTheme as useNavigationTheme,
|
|
9
9
|
useRoute,
|
|
10
10
|
} from '@react-navigation/native'
|
|
11
|
-
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
|
12
|
-
import { FlatList, Platform, StyleSheet, View } from 'react-native'
|
|
11
|
+
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
12
|
+
import { ActivityIndicator, FlatList, Platform, StyleSheet, View } from 'react-native'
|
|
13
|
+
import Animated, { FadeIn, FadeOut } from 'react-native-reanimated'
|
|
13
14
|
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
|
14
15
|
import { Badge, Icon, Text } from '../components'
|
|
15
16
|
import { EmptyConversationBlankState } from '../components/conversation/empty_conversation_blank_state'
|
|
@@ -23,23 +24,43 @@ import {
|
|
|
23
24
|
import { ReplyShadowMessage } from '../components/conversation/reply_shadow_message'
|
|
24
25
|
import { SystemMessage } from '../components/conversation/system_message'
|
|
25
26
|
import { TypingIndicator } from '../components/conversation/typing_indicator'
|
|
27
|
+
import { UnreadDivider } from '../components/conversation/unread_divider'
|
|
26
28
|
import { KeyboardView } from '../components/display/keyboard_view'
|
|
27
29
|
import BlankState from '../components/primitive/blank_state_primitive'
|
|
28
|
-
import {
|
|
30
|
+
import {
|
|
31
|
+
ConversationContextProvider,
|
|
32
|
+
useConversationContext,
|
|
33
|
+
} from '../contexts/conversation_context'
|
|
29
34
|
import { useTheme } from '../hooks'
|
|
30
35
|
import { useConversation } from '../hooks/use_conversation'
|
|
31
36
|
import { useConversationJoltEvents } from '../hooks/use_conversation_jolt_events'
|
|
32
37
|
import { useConversationMessages } from '../hooks/use_conversation_messages'
|
|
33
38
|
import { useConversationMessagesJoltEvents } from '../hooks/use_conversation_messages_jolt_events'
|
|
34
39
|
import { useFeatures } from '../hooks/use_features'
|
|
40
|
+
import { useFlatListViewability } from '../hooks/use_flat_list_viewability'
|
|
41
|
+
import { useJumpToBottomAction } from '../hooks/use_jump_to_bottom_action'
|
|
42
|
+
import { useJumpToUnreadAnchor } from '../hooks/use_jump_to_unread_anchor'
|
|
43
|
+
import { useJumpToUnreadGates } from '../hooks/use_jump_to_unread_gates'
|
|
35
44
|
import { useMarkLatestMessageRead } from '../hooks/use_mark_latest_message_read'
|
|
36
45
|
import {
|
|
37
46
|
normalizeAnalyticsMetadata,
|
|
38
47
|
usePublishProductAnalyticsEvent,
|
|
39
48
|
} from '../hooks/use_product_analytics'
|
|
49
|
+
import { useScrollTracking } from '../hooks/use_scroll_tracking'
|
|
50
|
+
import { useTrackHighestSeenMessage } from '../hooks/use_track_highest_seen_message'
|
|
40
51
|
import { ConversationBadgeResource } from '../types/resources/conversation_badge'
|
|
41
52
|
import { getRelativeDateStatus } from '../utils/date'
|
|
42
|
-
import {
|
|
53
|
+
import {
|
|
54
|
+
groupMessages,
|
|
55
|
+
UNREAD_DIVIDER_KEY,
|
|
56
|
+
type DateSeparator,
|
|
57
|
+
type EnrichedMessage,
|
|
58
|
+
} from '../utils/group_messages'
|
|
59
|
+
import {
|
|
60
|
+
detectDividerExitTowardNewer,
|
|
61
|
+
reportViewableMessages,
|
|
62
|
+
type ViewabilityObserver,
|
|
63
|
+
} from '../utils/message_viewability'
|
|
43
64
|
import { CONVERSATION_MESSAGE_LIST_PADDING_HORIZONTAL } from '../utils/styles'
|
|
44
65
|
import { isSystemMessage } from '../utils/system_messages'
|
|
45
66
|
|
|
@@ -60,6 +81,9 @@ export type ConversationRouteProps = {
|
|
|
60
81
|
|
|
61
82
|
export type ConversationScreenProps = StaticScreenProps<ConversationRouteProps>
|
|
62
83
|
|
|
84
|
+
const extractItemKey = (item: EnrichedMessage) => String(item.id)
|
|
85
|
+
const maintainVisibleContentPosition = { minIndexForVisible: 0 }
|
|
86
|
+
|
|
63
87
|
export function ConversationScreen({ route }: ConversationScreenProps) {
|
|
64
88
|
const { conversation_id, message_id, reply_root_id } = route.params
|
|
65
89
|
|
|
@@ -72,7 +96,8 @@ export function ConversationScreen({ route }: ConversationScreenProps) {
|
|
|
72
96
|
})
|
|
73
97
|
|
|
74
98
|
const lastReadMessageSortKey = conversation.conversationMembership?.lastReadMessageSortKey ?? null
|
|
75
|
-
const jumpToUnreadAnchor =
|
|
99
|
+
const jumpToUnreadAnchor =
|
|
100
|
+
featureEnabled('jump_to_unread') && !reply_root_id ? lastReadMessageSortKey : null
|
|
76
101
|
const initialMessageId = message_id ?? jumpToUnreadAnchor
|
|
77
102
|
const initialMessageIdIsAnchor = !!initialMessageId && !message_id
|
|
78
103
|
|
|
@@ -91,29 +116,49 @@ export function ConversationScreen({ route }: ConversationScreenProps) {
|
|
|
91
116
|
function ConversationScreenContent({ route }: ConversationScreenProps) {
|
|
92
117
|
const styles = useStyles()
|
|
93
118
|
const navigation = useNavigation()
|
|
94
|
-
const {
|
|
95
|
-
|
|
119
|
+
const {
|
|
120
|
+
conversation_id: conversationId,
|
|
121
|
+
editing_message_id: editingMessageId,
|
|
122
|
+
reply_root_id: replyRootId,
|
|
123
|
+
reply_root_author_name: replyRootAuthorName,
|
|
124
|
+
} = route.params
|
|
96
125
|
const { data: conversation } = useConversation(route.params)
|
|
97
|
-
const {
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
126
|
+
const {
|
|
127
|
+
messages,
|
|
128
|
+
fetchOlderMessages,
|
|
129
|
+
fetchNewerMessages,
|
|
130
|
+
hasMoreNewerMessages,
|
|
131
|
+
isFetchingNewerMessages,
|
|
132
|
+
cancelFetchNewerMessages,
|
|
133
|
+
} = useConversationMessages({ conversation_id: conversationId, reply_root_id: replyRootId })
|
|
134
|
+
|
|
135
|
+
const { jumpToUnreadActive } = useJumpToUnreadGates()
|
|
136
|
+
const { initialMessageId } = useConversationContext()
|
|
137
|
+
|
|
138
|
+
useConversationJoltEvents({ conversationId })
|
|
139
|
+
useConversationMessagesJoltEvents({ conversationId })
|
|
103
140
|
useEnsureConversationsRouteExists()
|
|
104
141
|
useMarkLatestMessageRead({ conversation, messages })
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
142
|
+
const { onMessageSeen } = useTrackHighestSeenMessage()
|
|
143
|
+
|
|
144
|
+
const items = useMemo(
|
|
145
|
+
() =>
|
|
146
|
+
groupMessages({
|
|
147
|
+
ms: messages,
|
|
148
|
+
inReplyScreen: !!replyRootId,
|
|
149
|
+
jumpToUnreadActive,
|
|
150
|
+
initialMessageId,
|
|
151
|
+
}),
|
|
152
|
+
[messages, replyRootId, jumpToUnreadActive, initialMessageId]
|
|
153
|
+
)
|
|
154
|
+
const noMessages = items.length === 0
|
|
110
155
|
|
|
111
156
|
const { repliesDisabled, memberAbility, badges, title } = conversation
|
|
112
157
|
const canReply = memberAbility?.canReply
|
|
113
158
|
const showLeaderDisabledReplyBanner = canReply && repliesDisabled
|
|
114
159
|
const canDeleteNonAuthoredMessages = memberAbility?.canDeleteNonAuthoredMessages ?? false
|
|
115
|
-
const currentlyEditingMessage = messages.find(m => String(m.id) === String(
|
|
116
|
-
const replyRootAuthorFirstName =
|
|
160
|
+
const currentlyEditingMessage = messages.find(m => String(m.id) === String(editingMessageId))
|
|
161
|
+
const replyRootAuthorFirstName = replyRootAuthorName?.split(' ')[0]
|
|
117
162
|
const replyHeaderTitle = replyRootAuthorFirstName
|
|
118
163
|
? `Reply to ${replyRootAuthorFirstName}`
|
|
119
164
|
: 'Reply'
|
|
@@ -121,21 +166,96 @@ function ConversationScreenContent({ route }: ConversationScreenProps) {
|
|
|
121
166
|
const muted = conversation.conversationMembership?.muted ?? conversation.muted
|
|
122
167
|
|
|
123
168
|
const listRef = useRef<FlatList>(null)
|
|
124
|
-
const [
|
|
169
|
+
const [dividerScrolledPast, setDividerScrolledPast] = useState(false)
|
|
170
|
+
|
|
171
|
+
const observers = useMemo<ViewabilityObserver<EnrichedMessage>[]>(
|
|
172
|
+
() => [
|
|
173
|
+
reportViewableMessages(onMessageSeen),
|
|
174
|
+
detectDividerExitTowardNewer({
|
|
175
|
+
dividerKey: UNREAD_DIVIDER_KEY,
|
|
176
|
+
initialMessageId,
|
|
177
|
+
onExited: () => setDividerScrolledPast(true),
|
|
178
|
+
}),
|
|
179
|
+
],
|
|
180
|
+
[onMessageSeen, initialMessageId]
|
|
181
|
+
)
|
|
125
182
|
|
|
126
|
-
const
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
183
|
+
const { viewabilityConfigCallbackPairs, onScrollBeginDrag: viewabilityOnScrollBeginDrag } =
|
|
184
|
+
useFlatListViewability({ observers })
|
|
185
|
+
const {
|
|
186
|
+
onContentSizeChange,
|
|
187
|
+
onScrollToIndexFailed,
|
|
188
|
+
onScrollBeginDrag: anchorOnScrollBeginDrag,
|
|
189
|
+
} = useJumpToUnreadAnchor({ listRef, items })
|
|
190
|
+
const onScrollBeginDrag = useCallback(() => {
|
|
191
|
+
viewabilityOnScrollBeginDrag()
|
|
192
|
+
anchorOnScrollBeginDrag()
|
|
193
|
+
}, [viewabilityOnScrollBeginDrag, anchorOnScrollBeginDrag])
|
|
194
|
+
const { onScroll, showJumpToBottomButton } = useScrollTracking({
|
|
195
|
+
hasMoreNewerMessages,
|
|
196
|
+
isFetchingNewerMessages,
|
|
197
|
+
fetchNewerMessages,
|
|
198
|
+
cancelFetchNewerMessages,
|
|
199
|
+
})
|
|
200
|
+
const { handleJumpToBottom, isJumpingToBottom } = useJumpToBottomAction({ listRef })
|
|
201
|
+
|
|
202
|
+
const listHeader = useMemo(
|
|
203
|
+
() => (
|
|
204
|
+
<View>
|
|
205
|
+
{isFetchingNewerMessages && (
|
|
206
|
+
<Animated.View
|
|
207
|
+
entering={FadeIn.duration(750)}
|
|
208
|
+
exiting={FadeOut.duration(750)}
|
|
209
|
+
style={styles.loadingFooter}
|
|
210
|
+
accessibilityRole="progressbar"
|
|
211
|
+
accessibilityLabel="Loading more messages"
|
|
212
|
+
>
|
|
213
|
+
<ActivityIndicator />
|
|
214
|
+
</Animated.View>
|
|
215
|
+
)}
|
|
216
|
+
<View style={styles.listHeader} />
|
|
217
|
+
</View>
|
|
218
|
+
),
|
|
219
|
+
[isFetchingNewerMessages, styles.loadingFooter, styles.listHeader]
|
|
220
|
+
)
|
|
130
221
|
|
|
131
|
-
const
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
222
|
+
const renderItem = useCallback(
|
|
223
|
+
({ item }: { item: EnrichedMessage }) => {
|
|
224
|
+
if (item.type === 'DateSeparator') return <InlineDateSeparator {...item} />
|
|
225
|
+
if (item.type === 'UnreadDivider') return <UnreadDivider scrolledPast={dividerScrolledPast} />
|
|
226
|
+
if (item.type === 'ReplyShadowMessage') {
|
|
227
|
+
return (
|
|
228
|
+
<ReplyShadowMessage
|
|
229
|
+
{...item}
|
|
230
|
+
conversation_id={conversationId}
|
|
231
|
+
inReplyScreen={!!replyRootId}
|
|
232
|
+
/>
|
|
233
|
+
)
|
|
234
|
+
}
|
|
235
|
+
if (isSystemMessage(item)) {
|
|
236
|
+
return <SystemMessage message={item} conversationId={conversationId} />
|
|
237
|
+
}
|
|
238
|
+
return (
|
|
239
|
+
<Message
|
|
240
|
+
{...item}
|
|
241
|
+
canDeleteNonAuthoredMessages={canDeleteNonAuthoredMessages}
|
|
242
|
+
conversation_id={conversationId}
|
|
243
|
+
latestReadMessageSortKey={conversation?.latestReadMessageSortKey}
|
|
244
|
+
inReplyScreen={!!replyRootId}
|
|
245
|
+
/>
|
|
246
|
+
)
|
|
247
|
+
},
|
|
248
|
+
[
|
|
249
|
+
dividerScrolledPast,
|
|
250
|
+
conversationId,
|
|
251
|
+
replyRootId,
|
|
252
|
+
canDeleteNonAuthoredMessages,
|
|
253
|
+
conversation?.latestReadMessageSortKey,
|
|
254
|
+
]
|
|
255
|
+
)
|
|
136
256
|
|
|
137
257
|
useEffect(() => {
|
|
138
|
-
if (
|
|
258
|
+
if (replyRootId) {
|
|
139
259
|
navigation.setParams({
|
|
140
260
|
title: replyHeaderTitle,
|
|
141
261
|
})
|
|
@@ -147,7 +267,7 @@ function ConversationScreenContent({ route }: ConversationScreenProps) {
|
|
|
147
267
|
muted,
|
|
148
268
|
})
|
|
149
269
|
}
|
|
150
|
-
}, [navigation, title, badges, conversation?.deleted,
|
|
270
|
+
}, [navigation, title, badges, conversation?.deleted, replyRootId, replyHeaderTitle, muted])
|
|
151
271
|
|
|
152
272
|
if (!conversation || conversation.deleted) {
|
|
153
273
|
return (
|
|
@@ -178,53 +298,32 @@ function ConversationScreenContent({ route }: ConversationScreenProps) {
|
|
|
178
298
|
inverted
|
|
179
299
|
ref={listRef}
|
|
180
300
|
contentContainerStyle={styles.listContainer}
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
scrollEventThrottle={
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
if (item.type === 'ReplyShadowMessage') {
|
|
193
|
-
return (
|
|
194
|
-
<ReplyShadowMessage
|
|
195
|
-
{...item}
|
|
196
|
-
conversation_id={conversation_id}
|
|
197
|
-
inReplyScreen={!!reply_root_id}
|
|
198
|
-
/>
|
|
199
|
-
)
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
if (isSystemMessage(item)) {
|
|
203
|
-
return <SystemMessage message={item} conversationId={conversation_id} />
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
return (
|
|
207
|
-
<Message
|
|
208
|
-
{...item}
|
|
209
|
-
canDeleteNonAuthoredMessages={canDeleteNonAuthoredMessages}
|
|
210
|
-
conversation_id={conversation_id}
|
|
211
|
-
latestReadMessageSortKey={conversation?.latestReadMessageSortKey}
|
|
212
|
-
inReplyScreen={!!reply_root_id}
|
|
213
|
-
/>
|
|
214
|
-
)
|
|
215
|
-
}}
|
|
301
|
+
maintainVisibleContentPosition={maintainVisibleContentPosition}
|
|
302
|
+
data={items}
|
|
303
|
+
keyExtractor={extractItemKey}
|
|
304
|
+
onScroll={onScroll}
|
|
305
|
+
onScrollBeginDrag={onScrollBeginDrag}
|
|
306
|
+
scrollEventThrottle={64}
|
|
307
|
+
viewabilityConfigCallbackPairs={viewabilityConfigCallbackPairs}
|
|
308
|
+
onContentSizeChange={onContentSizeChange}
|
|
309
|
+
onScrollToIndexFailed={onScrollToIndexFailed}
|
|
310
|
+
renderItem={renderItem}
|
|
216
311
|
onEndReached={() => fetchOlderMessages()}
|
|
217
|
-
ListHeaderComponent={
|
|
312
|
+
ListHeaderComponent={listHeader}
|
|
218
313
|
/>
|
|
219
314
|
)}
|
|
220
|
-
<JumpToBottomButton
|
|
315
|
+
<JumpToBottomButton
|
|
316
|
+
onPress={handleJumpToBottom}
|
|
317
|
+
visible={showJumpToBottomButton}
|
|
318
|
+
loading={isJumpingToBottom}
|
|
319
|
+
/>
|
|
221
320
|
{!noMessages && <TypingIndicator />}
|
|
222
321
|
{showLeaderDisabledReplyBanner && <LeaderMessagesDisabledBanner />}
|
|
223
322
|
{canReply ? (
|
|
224
323
|
<MessageForm.Root
|
|
225
324
|
replyRootAuthorFirstName={replyRootAuthorFirstName}
|
|
226
325
|
conversation={conversation}
|
|
227
|
-
replyRootId={
|
|
326
|
+
replyRootId={replyRootId}
|
|
228
327
|
currentlyEditingMessage={currentlyEditingMessage}
|
|
229
328
|
// We use a separate key so that it remounts component when switching between new
|
|
230
329
|
// and edit message. This simplifies internal state handling.
|
|
@@ -379,6 +478,10 @@ const useStyles = () => {
|
|
|
379
478
|
// Just whitespace to provide space where the typing indicator can be
|
|
380
479
|
height: 16,
|
|
381
480
|
},
|
|
481
|
+
loadingFooter: {
|
|
482
|
+
paddingVertical: 12,
|
|
483
|
+
alignItems: 'center',
|
|
484
|
+
},
|
|
382
485
|
})
|
|
383
486
|
}
|
|
384
487
|
|
|
@@ -41,12 +41,14 @@ export function GroupNotificationSettingsScreen({ route }: GroupNotificationSett
|
|
|
41
41
|
<View style={styles.container}>
|
|
42
42
|
<View style={styles.sectionOuter}>
|
|
43
43
|
<View style={styles.sectionInner}>
|
|
44
|
-
<Heading variant="
|
|
44
|
+
<Heading variant="h2" style={styles.sectionHeading}>
|
|
45
45
|
Group notification settings
|
|
46
46
|
</Heading>
|
|
47
47
|
<Text variant="tertiary" style={styles.sectionSubtitle}>
|
|
48
48
|
The settings are applied to all conversations in{' '}
|
|
49
|
-
<Text style={styles.groupNameBold}>
|
|
49
|
+
<Text variant="tertiary" style={styles.groupNameBold}>
|
|
50
|
+
{group.name || title}
|
|
51
|
+
</Text>
|
|
50
52
|
</Text>
|
|
51
53
|
</View>
|
|
52
54
|
</View>
|
|
@@ -98,10 +100,11 @@ const useStyles = () => {
|
|
|
98
100
|
borderBottomColor: colors.borderColorDefaultBase,
|
|
99
101
|
},
|
|
100
102
|
sectionHeading: {
|
|
101
|
-
paddingBottom:
|
|
103
|
+
paddingBottom: 12,
|
|
102
104
|
},
|
|
103
105
|
sectionSubtitle: {
|
|
104
106
|
color: colors.textColorDefaultSecondary,
|
|
107
|
+
paddingBottom: 2,
|
|
105
108
|
},
|
|
106
109
|
groupNameBold: {
|
|
107
110
|
fontWeight: platformFontWeightBold,
|