@planningcenter/chat-react-native 3.38.0-rc.9 → 3.38.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (149) hide show
  1. package/build/components/conversation/jump_to_bottom_button.d.ts +1 -2
  2. package/build/components/conversation/jump_to_bottom_button.d.ts.map +1 -1
  3. package/build/components/conversation/jump_to_bottom_button.js +7 -39
  4. package/build/components/conversation/jump_to_bottom_button.js.map +1 -1
  5. package/build/components/conversation/reply_shadow_message.d.ts +2 -1
  6. package/build/components/conversation/reply_shadow_message.d.ts.map +1 -1
  7. package/build/components/conversation/reply_shadow_message.js.map +1 -1
  8. package/build/contexts/conversation_context.d.ts +1 -8
  9. package/build/contexts/conversation_context.d.ts.map +1 -1
  10. package/build/contexts/conversation_context.js +3 -21
  11. package/build/contexts/conversation_context.js.map +1 -1
  12. package/build/hooks/use_conversation_messages.d.ts +6 -15
  13. package/build/hooks/use_conversation_messages.d.ts.map +1 -1
  14. package/build/hooks/use_conversation_messages.js +9 -62
  15. package/build/hooks/use_conversation_messages.js.map +1 -1
  16. package/build/hooks/use_conversation_messages_jolt_events.d.ts.map +1 -1
  17. package/build/hooks/use_conversation_messages_jolt_events.js +4 -4
  18. package/build/hooks/use_conversation_messages_jolt_events.js.map +1 -1
  19. package/build/hooks/use_conversations_actions.d.ts +0 -5
  20. package/build/hooks/use_conversations_actions.d.ts.map +1 -1
  21. package/build/hooks/use_conversations_actions.js +0 -12
  22. package/build/hooks/use_conversations_actions.js.map +1 -1
  23. package/build/hooks/use_features.d.ts +0 -1
  24. package/build/hooks/use_features.d.ts.map +1 -1
  25. package/build/hooks/use_features.js +0 -1
  26. package/build/hooks/use_features.js.map +1 -1
  27. package/build/hooks/use_mark_latest_message_read.d.ts +1 -1
  28. package/build/hooks/use_mark_latest_message_read.d.ts.map +1 -1
  29. package/build/hooks/use_mark_latest_message_read.js +1 -17
  30. package/build/hooks/use_mark_latest_message_read.js.map +1 -1
  31. package/build/hooks/use_suspense_api.d.ts +0 -1
  32. package/build/hooks/use_suspense_api.d.ts.map +1 -1
  33. package/build/hooks/use_suspense_api.js +1 -1
  34. package/build/hooks/use_suspense_api.js.map +1 -1
  35. package/build/screens/conversation_filter_recipients/components/header_row.d.ts.map +1 -1
  36. package/build/screens/conversation_filter_recipients/components/header_row.js +3 -2
  37. package/build/screens/conversation_filter_recipients/components/header_row.js.map +1 -1
  38. package/build/screens/conversation_filter_recipients/hooks/use_flattened_array_of_service_types_with_teams.d.ts.map +1 -1
  39. package/build/screens/conversation_filter_recipients/hooks/use_flattened_array_of_service_types_with_teams.js +47 -18
  40. package/build/screens/conversation_filter_recipients/hooks/use_flattened_array_of_service_types_with_teams.js.map +1 -1
  41. package/build/screens/conversation_filter_recipients/hooks/use_service_types_with_teams.d.ts +2 -1
  42. package/build/screens/conversation_filter_recipients/hooks/use_service_types_with_teams.d.ts.map +1 -1
  43. package/build/screens/conversation_filter_recipients/hooks/use_service_types_with_teams.js +23 -26
  44. package/build/screens/conversation_filter_recipients/hooks/use_service_types_with_teams.js.map +1 -1
  45. package/build/screens/conversation_filter_recipients/types.d.ts +1 -1
  46. package/build/screens/conversation_filter_recipients/types.d.ts.map +1 -1
  47. package/build/screens/conversation_filter_recipients/types.js.map +1 -1
  48. package/build/screens/conversation_screen.d.ts +0 -1
  49. package/build/screens/conversation_screen.d.ts.map +1 -1
  50. package/build/screens/conversation_screen.js +48 -95
  51. package/build/screens/conversation_screen.js.map +1 -1
  52. package/build/screens/conversation_select_recipients/components/recipient_link_row.d.ts +1 -1
  53. package/build/screens/conversation_select_recipients/components/recipient_link_row.d.ts.map +1 -1
  54. package/build/screens/conversation_select_recipients/components/recipient_link_row.js +3 -3
  55. package/build/screens/conversation_select_recipients/components/recipient_link_row.js.map +1 -1
  56. package/build/screens/conversation_select_recipients/components/team_recipient_row.d.ts.map +1 -1
  57. package/build/screens/conversation_select_recipients/components/team_recipient_row.js +1 -1
  58. package/build/screens/conversation_select_recipients/components/team_recipient_row.js.map +1 -1
  59. package/build/utils/cache/messages_cache.d.ts +0 -1
  60. package/build/utils/cache/messages_cache.d.ts.map +1 -1
  61. package/build/utils/cache/messages_cache.js +0 -4
  62. package/build/utils/cache/messages_cache.js.map +1 -1
  63. package/build/utils/group_messages.d.ts +2 -9
  64. package/build/utils/group_messages.d.ts.map +1 -1
  65. package/build/utils/group_messages.js +1 -20
  66. package/build/utils/group_messages.js.map +1 -1
  67. package/package.json +3 -3
  68. package/src/components/conversation/jump_to_bottom_button.tsx +8 -57
  69. package/src/components/conversation/reply_shadow_message.tsx +1 -1
  70. package/src/contexts/conversation_context.tsx +2 -30
  71. package/src/hooks/use_conversation_messages.ts +20 -120
  72. package/src/hooks/use_conversation_messages_jolt_events.ts +3 -4
  73. package/src/hooks/use_conversations_actions.ts +0 -15
  74. package/src/hooks/use_features.ts +0 -1
  75. package/src/hooks/use_mark_latest_message_read.ts +2 -16
  76. package/src/hooks/use_suspense_api.ts +1 -1
  77. package/src/screens/conversation_filter_recipients/components/header_row.tsx +3 -2
  78. package/src/screens/conversation_filter_recipients/hooks/__tests__/use_service_types_with_teams.test.ts +108 -0
  79. package/src/screens/conversation_filter_recipients/hooks/use_flattened_array_of_service_types_with_teams.tsx +46 -19
  80. package/src/screens/conversation_filter_recipients/hooks/use_service_types_with_teams.ts +31 -29
  81. package/src/screens/conversation_filter_recipients/types.tsx +1 -1
  82. package/src/screens/conversation_screen.tsx +76 -184
  83. package/src/screens/conversation_select_recipients/components/recipient_link_row.tsx +6 -4
  84. package/src/screens/conversation_select_recipients/components/team_recipient_row.tsx +2 -1
  85. package/src/utils/__tests__/group_messages.test.ts +0 -71
  86. package/src/utils/cache/messages_cache.ts +0 -5
  87. package/src/utils/group_messages.ts +2 -42
  88. package/build/components/conversation/unread_divider.d.ts +0 -6
  89. package/build/components/conversation/unread_divider.d.ts.map +0 -1
  90. package/build/components/conversation/unread_divider.js +0 -59
  91. package/build/components/conversation/unread_divider.js.map +0 -1
  92. package/build/hooks/use_flat_list_viewability.d.ts +0 -20
  93. package/build/hooks/use_flat_list_viewability.d.ts.map +0 -1
  94. package/build/hooks/use_flat_list_viewability.js +0 -30
  95. package/build/hooks/use_flat_list_viewability.js.map +0 -1
  96. package/build/hooks/use_jump_to_bottom_action.d.ts +0 -9
  97. package/build/hooks/use_jump_to_bottom_action.d.ts.map +0 -1
  98. package/build/hooks/use_jump_to_bottom_action.js +0 -62
  99. package/build/hooks/use_jump_to_bottom_action.js.map +0 -1
  100. package/build/hooks/use_jump_to_unread_anchor.d.ts +0 -20
  101. package/build/hooks/use_jump_to_unread_anchor.d.ts.map +0 -1
  102. package/build/hooks/use_jump_to_unread_anchor.js +0 -53
  103. package/build/hooks/use_jump_to_unread_anchor.js.map +0 -1
  104. package/build/hooks/use_jump_to_unread_gates.d.ts +0 -5
  105. package/build/hooks/use_jump_to_unread_gates.d.ts.map +0 -1
  106. package/build/hooks/use_jump_to_unread_gates.js +0 -10
  107. package/build/hooks/use_jump_to_unread_gates.js.map +0 -1
  108. package/build/hooks/use_scroll_tracking.d.ts +0 -13
  109. package/build/hooks/use_scroll_tracking.d.ts.map +0 -1
  110. package/build/hooks/use_scroll_tracking.js +0 -45
  111. package/build/hooks/use_scroll_tracking.js.map +0 -1
  112. package/build/hooks/use_track_highest_seen_message.d.ts +0 -4
  113. package/build/hooks/use_track_highest_seen_message.d.ts.map +0 -1
  114. package/build/hooks/use_track_highest_seen_message.js +0 -35
  115. package/build/hooks/use_track_highest_seen_message.js.map +0 -1
  116. package/build/utils/conversation_messages.d.ts +0 -10
  117. package/build/utils/conversation_messages.d.ts.map +0 -1
  118. package/build/utils/conversation_messages.js +0 -22
  119. package/build/utils/conversation_messages.js.map +0 -1
  120. package/build/utils/highest_seen_tracker.d.ts +0 -12
  121. package/build/utils/highest_seen_tracker.d.ts.map +0 -1
  122. package/build/utils/highest_seen_tracker.js +0 -37
  123. package/build/utils/highest_seen_tracker.js.map +0 -1
  124. package/build/utils/message_viewability.d.ts +0 -24
  125. package/build/utils/message_viewability.d.ts.map +0 -1
  126. package/build/utils/message_viewability.js +0 -29
  127. package/build/utils/message_viewability.js.map +0 -1
  128. package/build/utils/unread_divider_helpers.d.ts +0 -18
  129. package/build/utils/unread_divider_helpers.d.ts.map +0 -1
  130. package/build/utils/unread_divider_helpers.js +0 -13
  131. package/build/utils/unread_divider_helpers.js.map +0 -1
  132. package/src/__tests__/hooks/use_conversation_messages.test.tsx +0 -109
  133. package/src/__tests__/hooks/use_mark_latest_message_read.test.tsx +0 -154
  134. package/src/__tests__/utils/cache/messages_cache.test.ts +0 -54
  135. package/src/components/conversation/unread_divider.tsx +0 -90
  136. package/src/hooks/use_flat_list_viewability.ts +0 -50
  137. package/src/hooks/use_jump_to_bottom_action.ts +0 -75
  138. package/src/hooks/use_jump_to_unread_anchor.ts +0 -68
  139. package/src/hooks/use_jump_to_unread_gates.ts +0 -10
  140. package/src/hooks/use_scroll_tracking.ts +0 -64
  141. package/src/hooks/use_track_highest_seen_message.ts +0 -43
  142. package/src/utils/__tests__/conversation_messages.test.ts +0 -105
  143. package/src/utils/__tests__/highest_seen_tracker.test.ts +0 -82
  144. package/src/utils/__tests__/message_viewability.test.ts +0 -168
  145. package/src/utils/__tests__/unread_divider_helpers.test.ts +0 -85
  146. package/src/utils/conversation_messages.ts +0 -37
  147. package/src/utils/highest_seen_tracker.ts +0 -42
  148. package/src/utils/message_viewability.ts +0 -49
  149. package/src/utils/unread_divider_helpers.ts +0 -25
@@ -1,90 +0,0 @@
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,50 +0,0 @@
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
- }
@@ -1,75 +0,0 @@
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
- }
@@ -1,68 +0,0 @@
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
- }
@@ -1,10 +0,0 @@
1
- import { useConversationContext } from '../contexts/conversation_context'
2
- import { availableFeatures, useFeatures } from './use_features'
3
-
4
- export function useJumpToUnreadGates() {
5
- const { featureEnabled } = useFeatures()
6
- const { initialMessageIdIsAnchor } = useConversationContext()
7
- const jumpToUnreadEnabled = featureEnabled(availableFeatures.jump_to_unread)
8
- const jumpToUnreadActive = jumpToUnreadEnabled && initialMessageIdIsAnchor
9
- return { jumpToUnreadEnabled, jumpToUnreadActive }
10
- }
@@ -1,64 +0,0 @@
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
- }
@@ -1,43 +0,0 @@
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
- }
@@ -1,105 +0,0 @@
1
- import { ApiCollection, MessageResource } from '../../types'
2
- import {
3
- anchoredSeedPageParams,
4
- newerPageParam,
5
- olderPageParam,
6
- sortAndFilterMessages,
7
- } from '../conversation_messages'
8
-
9
- const message = (id: string, overrides: Partial<MessageResource> = {}): MessageResource =>
10
- ({
11
- id,
12
- type: 'Message',
13
- text: `msg ${id}`,
14
- attachments: [],
15
- deletedAt: null,
16
- replyRootId: null,
17
- ...overrides,
18
- }) as MessageResource
19
-
20
- const page = (
21
- data: MessageResource[],
22
- next?: { idLt?: string; idGt?: string }
23
- ): ApiCollection<MessageResource> => ({
24
- data,
25
- links: {},
26
- meta: { count: data.length, totalCount: data.length, next },
27
- })
28
-
29
- describe('anchoredSeedPageParams', () => {
30
- it('returns one ascending after-page and one descending before-page', () => {
31
- expect(anchoredSeedPageParams('01ABC')).toEqual([
32
- { where: { id_gte: '01ABC' }, order: 'asc' },
33
- { where: { id_lt: '01ABC' }, order: 'desc' },
34
- ])
35
- })
36
- })
37
-
38
- describe('olderPageParam', () => {
39
- it('returns id_lt cursor when meta.next.idLt is present', () => {
40
- expect(olderPageParam(page([], { idLt: '01XYZ' }))).toEqual({
41
- where: { id_lt: '01XYZ' },
42
- order: 'desc',
43
- })
44
- })
45
-
46
- it('returns undefined when meta.next.idLt is missing', () => {
47
- expect(olderPageParam(page([], {}))).toBeUndefined()
48
- expect(olderPageParam(page([], { idGt: '01XYZ' }))).toBeUndefined()
49
- })
50
- })
51
-
52
- describe('newerPageParam', () => {
53
- it('returns id_gt cursor when meta.next.idGt is present', () => {
54
- expect(newerPageParam(page([], { idGt: '01XYZ' }))).toEqual({
55
- where: { id_gt: '01XYZ' },
56
- order: 'asc',
57
- })
58
- })
59
-
60
- it('returns undefined when meta.next.idGt is missing', () => {
61
- expect(newerPageParam(page([], {}))).toBeUndefined()
62
- expect(newerPageParam(page([], { idLt: '01XYZ' }))).toBeUndefined()
63
- })
64
- })
65
-
66
- describe('sortAndFilterMessages', () => {
67
- it('flattens pages and sorts descending by id (newest first)', () => {
68
- const pages = [page([message('01B'), message('01A')]), page([message('01D'), message('01C')])]
69
-
70
- expect(sortAndFilterMessages(pages).map(m => m.id)).toEqual(['01D', '01C', '01B', '01A'])
71
- })
72
-
73
- it('drops empty messages (no text, no attachments)', () => {
74
- const pages = [page([message('01A'), message('01B', { text: '' })])]
75
-
76
- expect(sortAndFilterMessages(pages).map(m => m.id)).toEqual(['01A'])
77
- })
78
-
79
- it('drops deleted messages outside reply threads', () => {
80
- const pages = [page([message('01A'), message('01B', { deletedAt: '2026-01-01T00:00:00Z' })])]
81
-
82
- expect(sortAndFilterMessages(pages).map(m => m.id)).toEqual(['01A'])
83
- })
84
-
85
- it('keeps deleted reply-thread messages so threads do not break', () => {
86
- const pages = [
87
- page([
88
- message('01A'),
89
- message('01B', { deletedAt: '2026-01-01T00:00:00Z', replyRootId: '01A' }),
90
- ]),
91
- ]
92
-
93
- expect(sortAndFilterMessages(pages).map(m => m.id)).toEqual(['01B', '01A'])
94
- })
95
-
96
- it('keeps messages with attachments even when text is empty', () => {
97
- const pages = [
98
- page([
99
- message('01A', { text: '', attachments: [{ id: '1' }] as MessageResource['attachments'] }),
100
- ]),
101
- ]
102
-
103
- expect(sortAndFilterMessages(pages).map(m => m.id)).toEqual(['01A'])
104
- })
105
- })
@@ -1,82 +0,0 @@
1
- import { makeHighestSeenTracker } from '../highest_seen_tracker'
2
-
3
- describe('makeHighestSeenTracker', () => {
4
- beforeEach(() => {
5
- jest.useFakeTimers()
6
- })
7
-
8
- afterEach(() => {
9
- jest.useRealTimers()
10
- })
11
-
12
- it('debounces and sends the highest sort key seen so far', () => {
13
- const send = jest.fn()
14
- const tracker = makeHighestSeenTracker(42, send, 2000)
15
-
16
- tracker.onSeen('10')
17
- tracker.onSeen('15')
18
- tracker.onSeen('12')
19
-
20
- expect(send).not.toHaveBeenCalled()
21
- jest.advanceTimersByTime(2000)
22
-
23
- expect(send).toHaveBeenCalledWith({ conversationId: 42, sortKey: '15' })
24
- expect(send).toHaveBeenCalledTimes(1)
25
- })
26
-
27
- it('ignores sort keys that are not strictly greater than the current highest', () => {
28
- const send = jest.fn()
29
- const tracker = makeHighestSeenTracker(1, send, 1000)
30
-
31
- tracker.onSeen('20')
32
- tracker.onSeen('20')
33
- tracker.onSeen('19')
34
- jest.advanceTimersByTime(1000)
35
-
36
- expect(send).toHaveBeenCalledTimes(1)
37
- expect(send).toHaveBeenCalledWith({ conversationId: 1, sortKey: '20' })
38
- })
39
-
40
- it('does not re-send the same sort key on subsequent flushes', () => {
41
- const send = jest.fn()
42
- const tracker = makeHighestSeenTracker(1, send, 1000)
43
-
44
- tracker.onSeen('5')
45
- jest.advanceTimersByTime(1000)
46
- tracker.flushNow()
47
-
48
- expect(send).toHaveBeenCalledTimes(1)
49
- })
50
-
51
- it('flushNow fires immediately without waiting for the debounce', () => {
52
- const send = jest.fn()
53
- const tracker = makeHighestSeenTracker(1, send, 5000)
54
-
55
- tracker.onSeen('3')
56
- tracker.flushNow()
57
-
58
- expect(send).toHaveBeenCalledWith({ conversationId: 1, sortKey: '3' })
59
- })
60
-
61
- it('cancel prevents a pending fire', () => {
62
- const send = jest.fn()
63
- const tracker = makeHighestSeenTracker(1, send, 1000)
64
-
65
- tracker.onSeen('7')
66
- tracker.cancel()
67
- jest.advanceTimersByTime(1000)
68
-
69
- expect(send).not.toHaveBeenCalled()
70
- })
71
-
72
- it('orders fixed-width sort keys via lexicographic comparison', () => {
73
- const send = jest.fn()
74
- const tracker = makeHighestSeenTracker(1, send, 100)
75
-
76
- tracker.onSeen('09')
77
- tracker.onSeen('10')
78
- jest.advanceTimersByTime(100)
79
-
80
- expect(send).toHaveBeenCalledWith({ conversationId: 1, sortKey: '10' })
81
- })
82
- })