@planningcenter/chat-react-native 3.35.0-rc.3 → 3.35.0-rc.4
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/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/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,90 @@
|
|
|
1
|
+
import { StyleSheet, View } from 'react-native'
|
|
2
|
+
import Animated, { FadeIn, FadeOut } from 'react-native-reanimated'
|
|
3
|
+
import Svg, { Defs, Path, Pattern, Rect } from 'react-native-svg'
|
|
4
|
+
import { useConversationContext } from '../../contexts/conversation_context'
|
|
5
|
+
import { useTheme } from '../../hooks'
|
|
6
|
+
import { CONVERSATION_MESSAGE_LIST_PADDING_HORIZONTAL } from '../../utils/styles'
|
|
7
|
+
import { Text } from '../display'
|
|
8
|
+
|
|
9
|
+
interface UnreadDividerProps {
|
|
10
|
+
scrolledPast?: boolean
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const WAVE_WIDTH = 16
|
|
14
|
+
const WAVE_HEIGHT = 8
|
|
15
|
+
const FADE_DURATION = 750
|
|
16
|
+
|
|
17
|
+
export function UnreadDivider({ scrolledPast = false }: UnreadDividerProps) {
|
|
18
|
+
const styles = useStyles()
|
|
19
|
+
const { atEndOfMessageHistory } = useConversationContext()
|
|
20
|
+
|
|
21
|
+
if (scrolledPast || atEndOfMessageHistory) return null
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<Animated.View
|
|
25
|
+
entering={FadeIn.duration(FADE_DURATION)}
|
|
26
|
+
exiting={FadeOut.duration(FADE_DURATION)}
|
|
27
|
+
style={styles.container}
|
|
28
|
+
accessibilityRole="header"
|
|
29
|
+
accessibilityLabel="Unread messages start here"
|
|
30
|
+
>
|
|
31
|
+
<SquigglyLine />
|
|
32
|
+
<Text variant="footnote" style={styles.label}>
|
|
33
|
+
New
|
|
34
|
+
</Text>
|
|
35
|
+
<SquigglyLine />
|
|
36
|
+
</Animated.View>
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function SquigglyLine() {
|
|
41
|
+
const { colors } = useTheme()
|
|
42
|
+
return (
|
|
43
|
+
<View style={squigglyStyle.container}>
|
|
44
|
+
<Svg width="100%" height={WAVE_HEIGHT}>
|
|
45
|
+
<Defs>
|
|
46
|
+
<Pattern
|
|
47
|
+
id="wave"
|
|
48
|
+
x="0"
|
|
49
|
+
y="0"
|
|
50
|
+
width={WAVE_WIDTH}
|
|
51
|
+
height={WAVE_HEIGHT}
|
|
52
|
+
patternUnits="userSpaceOnUse"
|
|
53
|
+
>
|
|
54
|
+
<Path
|
|
55
|
+
d="M 0 4 Q 4 0 8 4 T 16 4"
|
|
56
|
+
stroke={colors.interaction}
|
|
57
|
+
strokeWidth={1.5}
|
|
58
|
+
fill="none"
|
|
59
|
+
/>
|
|
60
|
+
</Pattern>
|
|
61
|
+
</Defs>
|
|
62
|
+
<Rect x="0" y="0" width="100%" height={WAVE_HEIGHT} fill="url(#wave)" />
|
|
63
|
+
</Svg>
|
|
64
|
+
</View>
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const squigglyStyle = StyleSheet.create({
|
|
69
|
+
container: {
|
|
70
|
+
flex: 1,
|
|
71
|
+
height: WAVE_HEIGHT,
|
|
72
|
+
},
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
const useStyles = () => {
|
|
76
|
+
const { colors } = useTheme()
|
|
77
|
+
return StyleSheet.create({
|
|
78
|
+
container: {
|
|
79
|
+
alignItems: 'center',
|
|
80
|
+
flexDirection: 'row',
|
|
81
|
+
paddingHorizontal: CONVERSATION_MESSAGE_LIST_PADDING_HORIZONTAL,
|
|
82
|
+
paddingVertical: 8,
|
|
83
|
+
gap: 8,
|
|
84
|
+
},
|
|
85
|
+
label: {
|
|
86
|
+
color: colors.interaction,
|
|
87
|
+
fontWeight: '600',
|
|
88
|
+
},
|
|
89
|
+
})
|
|
90
|
+
}
|
|
@@ -1,11 +1,4 @@
|
|
|
1
|
-
import React, {
|
|
2
|
-
createContext,
|
|
3
|
-
PropsWithChildren,
|
|
4
|
-
useContext,
|
|
5
|
-
useEffect,
|
|
6
|
-
useMemo,
|
|
7
|
-
useState,
|
|
8
|
-
} from 'react'
|
|
1
|
+
import React, { createContext, PropsWithChildren, useContext, useMemo, useState } from 'react'
|
|
9
2
|
|
|
10
3
|
interface ConversationContextValue {
|
|
11
4
|
conversationId: number
|
|
@@ -13,6 +6,8 @@ interface ConversationContextValue {
|
|
|
13
6
|
initialMessageId: string | null
|
|
14
7
|
setInitialMessageId: (id: string | null) => void
|
|
15
8
|
initialMessageIdIsAnchor: boolean
|
|
9
|
+
atEndOfMessageHistory: boolean
|
|
10
|
+
setAtEndOfMessageHistory: (atEnd: boolean) => void
|
|
16
11
|
}
|
|
17
12
|
|
|
18
13
|
interface ConversationContextProviderProps extends PropsWithChildren {
|
|
@@ -28,6 +23,8 @@ const ConversationContext = createContext<ConversationContextValue>({
|
|
|
28
23
|
initialMessageId: null,
|
|
29
24
|
setInitialMessageId: () => {},
|
|
30
25
|
initialMessageIdIsAnchor: false,
|
|
26
|
+
atEndOfMessageHistory: false,
|
|
27
|
+
setAtEndOfMessageHistory: () => {},
|
|
31
28
|
})
|
|
32
29
|
|
|
33
30
|
export const ConversationContextProvider = ({
|
|
@@ -38,10 +35,7 @@ export const ConversationContextProvider = ({
|
|
|
38
35
|
initialMessageIdIsAnchor = false,
|
|
39
36
|
}: ConversationContextProviderProps) => {
|
|
40
37
|
const [initialMessageId, setInitialMessageId] = useState(initialMessageIdProp)
|
|
41
|
-
|
|
42
|
-
useEffect(() => {
|
|
43
|
-
setInitialMessageId(initialMessageIdProp)
|
|
44
|
-
}, [initialMessageIdProp])
|
|
38
|
+
const [atEndOfMessageHistory, setAtEndOfMessageHistory] = useState(false)
|
|
45
39
|
|
|
46
40
|
const value = useMemo(
|
|
47
41
|
() => ({
|
|
@@ -50,8 +44,16 @@ export const ConversationContextProvider = ({
|
|
|
50
44
|
initialMessageId,
|
|
51
45
|
setInitialMessageId,
|
|
52
46
|
initialMessageIdIsAnchor,
|
|
47
|
+
atEndOfMessageHistory,
|
|
48
|
+
setAtEndOfMessageHistory,
|
|
53
49
|
}),
|
|
54
|
-
[
|
|
50
|
+
[
|
|
51
|
+
conversationId,
|
|
52
|
+
currentPageReplyRootId,
|
|
53
|
+
initialMessageId,
|
|
54
|
+
initialMessageIdIsAnchor,
|
|
55
|
+
atEndOfMessageHistory,
|
|
56
|
+
]
|
|
55
57
|
)
|
|
56
58
|
|
|
57
59
|
return <ConversationContext.Provider value={value}>{children}</ConversationContext.Provider>
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import {
|
|
2
2
|
AnyUseSuspenseInfiniteQueryOptions,
|
|
3
3
|
InfiniteData,
|
|
4
|
+
useQueryClient,
|
|
4
5
|
useSuspenseInfiniteQuery,
|
|
5
6
|
useSuspenseQueries,
|
|
6
7
|
} from '@tanstack/react-query'
|
|
7
|
-
import { useMemo } from 'react'
|
|
8
|
+
import { useCallback, useMemo } from 'react'
|
|
8
9
|
import { useConversationContext } from '../contexts/conversation_context'
|
|
9
10
|
import { ApiCollection, MessageResource } from '../types'
|
|
10
11
|
import {
|
|
@@ -38,8 +39,14 @@ export const useConversationMessages = (
|
|
|
38
39
|
const { initialMessageId } = useConversationContext()
|
|
39
40
|
const anchored = !reply_root_id && !!initialMessageId
|
|
40
41
|
|
|
41
|
-
const requestArgs =
|
|
42
|
-
|
|
42
|
+
const requestArgs = useMemo(
|
|
43
|
+
() => getMessagesRequestArgs({ conversation_id, reply_root_id }),
|
|
44
|
+
[conversation_id, reply_root_id]
|
|
45
|
+
)
|
|
46
|
+
const queryKey = useMemo(
|
|
47
|
+
() => getMessagesQueryKey({ conversation_id, reply_root_id }),
|
|
48
|
+
[conversation_id, reply_root_id]
|
|
49
|
+
)
|
|
43
50
|
|
|
44
51
|
const fetchPage = (pageParam: MessagesPageParam) => {
|
|
45
52
|
const data = {
|
|
@@ -80,6 +87,7 @@ export const useConversationMessages = (
|
|
|
80
87
|
hasNextPage,
|
|
81
88
|
fetchPreviousPage,
|
|
82
89
|
hasPreviousPage,
|
|
90
|
+
isFetchingPreviousPage,
|
|
83
91
|
} = useSuspenseInfiniteQuery<
|
|
84
92
|
ApiCollection<MessageResource>,
|
|
85
93
|
Response,
|
|
@@ -99,6 +107,12 @@ export const useConversationMessages = (
|
|
|
99
107
|
|
|
100
108
|
const messages = useMemo(() => sortAndFilterMessages(data.pages), [data.pages])
|
|
101
109
|
|
|
110
|
+
const queryClient = useQueryClient()
|
|
111
|
+
const cancelFetchNewerMessages = useCallback(
|
|
112
|
+
() => queryClient.cancelQueries({ queryKey }),
|
|
113
|
+
[queryClient, queryKey]
|
|
114
|
+
)
|
|
115
|
+
|
|
102
116
|
return {
|
|
103
117
|
messages,
|
|
104
118
|
refetch,
|
|
@@ -107,6 +121,8 @@ export const useConversationMessages = (
|
|
|
107
121
|
hasMoreOlderMessages: hasNextPage,
|
|
108
122
|
fetchNewerMessages: fetchPreviousPage,
|
|
109
123
|
hasMoreNewerMessages: hasPreviousPage,
|
|
124
|
+
isFetchingNewerMessages: isFetchingPreviousPage,
|
|
125
|
+
cancelFetchNewerMessages,
|
|
110
126
|
queryKey,
|
|
111
127
|
}
|
|
112
128
|
}
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
updateCacheWithIndividualMessage,
|
|
13
13
|
updateCacheWithReaction,
|
|
14
14
|
getThreadedMessagesQueryKey,
|
|
15
|
+
hasUnloadedNewerPages,
|
|
15
16
|
} from '../utils/cache/messages_cache'
|
|
16
17
|
import { transformMessageEventDataToMessageResource } from '../utils/jolt/transform_message_event_data_to_message_resource'
|
|
17
18
|
import { completeMessageCreationTracking } from '../utils/performance_tracking'
|
|
@@ -52,10 +53,10 @@ export function useConversationMessagesJoltEvents({ conversationId }: Props) {
|
|
|
52
53
|
}
|
|
53
54
|
}
|
|
54
55
|
|
|
55
|
-
|
|
56
|
-
|
|
56
|
+
if (e.event === 'message.updated' || !hasUnloadedNewerPages(queryClient, messagesQueryKey)) {
|
|
57
|
+
updateCacheWithMessage(queryClient, messagesQueryKey, message, e.event)
|
|
58
|
+
}
|
|
57
59
|
|
|
58
|
-
// If message has a reply_root_id, also update the threaded cache
|
|
59
60
|
if (data.reply_root_id) {
|
|
60
61
|
const threadedMessagesQueryKey = getThreadedMessagesQueryKey(
|
|
61
62
|
conversationId,
|
|
@@ -98,6 +98,21 @@ export const useConversationsMute = ({ conversation }: { conversation: Conversat
|
|
|
98
98
|
}
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
+
export const useConversationsMarkReadUpTo = ({ conversationId }: { conversationId: number }) => {
|
|
102
|
+
const apiClient = useApiClient()
|
|
103
|
+
|
|
104
|
+
return useMutation({
|
|
105
|
+
mutationKey: ['markReadUpTo', conversationId],
|
|
106
|
+
mutationFn: async ({ sortKey }: { sortKey: string }) =>
|
|
107
|
+
apiClient.chat.post({
|
|
108
|
+
url: `/me/conversations/${conversationId}/mark_read_up_to`,
|
|
109
|
+
data: {
|
|
110
|
+
data: { type: 'Conversation', attributes: { sort_key: sortKey } },
|
|
111
|
+
},
|
|
112
|
+
}),
|
|
113
|
+
})
|
|
114
|
+
}
|
|
115
|
+
|
|
101
116
|
export const useMarkAllRead = () => {
|
|
102
117
|
const apiClient = useApiClient()
|
|
103
118
|
const { args } = useConversationsContext()
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef } from 'react'
|
|
2
|
+
import type { ViewToken } from 'react-native'
|
|
3
|
+
import type { ViewabilityObserver } from '../utils/message_viewability'
|
|
4
|
+
|
|
5
|
+
interface UseFlatListViewabilityArgs<Item> {
|
|
6
|
+
observers: ViewabilityObserver<Item>[]
|
|
7
|
+
itemVisiblePercentThreshold?: number
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function useFlatListViewability<Item>({
|
|
11
|
+
observers,
|
|
12
|
+
itemVisiblePercentThreshold = 50,
|
|
13
|
+
}: UseFlatListViewabilityArgs<Item>) {
|
|
14
|
+
const userHasScrolledRef = useRef(false)
|
|
15
|
+
const observersRef = useRef(observers)
|
|
16
|
+
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
observersRef.current = observers
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
const onScrollBeginDrag = useCallback(() => {
|
|
22
|
+
userHasScrolledRef.current = true
|
|
23
|
+
}, [])
|
|
24
|
+
|
|
25
|
+
const viewabilityConfigCallbackPairs = useRef([
|
|
26
|
+
{
|
|
27
|
+
viewabilityConfig: { itemVisiblePercentThreshold },
|
|
28
|
+
onViewableItemsChanged: ({
|
|
29
|
+
viewableItems,
|
|
30
|
+
changed,
|
|
31
|
+
}: {
|
|
32
|
+
viewableItems: ViewToken[]
|
|
33
|
+
changed: ViewToken[]
|
|
34
|
+
}) => {
|
|
35
|
+
const event = {
|
|
36
|
+
viewableItems: viewableItems.map(toEntry<Item>),
|
|
37
|
+
changed: changed.map(toEntry<Item>),
|
|
38
|
+
userHasScrolled: userHasScrolledRef.current,
|
|
39
|
+
}
|
|
40
|
+
for (const observer of observersRef.current) observer(event)
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
]).current
|
|
44
|
+
|
|
45
|
+
return { viewabilityConfigCallbackPairs, onScrollBeginDrag }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function toEntry<Item>(token: ViewToken) {
|
|
49
|
+
return { key: token.key, isViewable: !!token.isViewable, item: token.item as Item }
|
|
50
|
+
}
|
|
@@ -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
|
+
}
|