@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.
Files changed (121) hide show
  1. package/README.md +1 -1
  2. package/build/components/conversation/jump_to_bottom_button.d.ts +2 -1
  3. package/build/components/conversation/jump_to_bottom_button.d.ts.map +1 -1
  4. package/build/components/conversation/jump_to_bottom_button.js +39 -7
  5. package/build/components/conversation/jump_to_bottom_button.js.map +1 -1
  6. package/build/components/conversation/reply_shadow_message.d.ts +1 -2
  7. package/build/components/conversation/reply_shadow_message.d.ts.map +1 -1
  8. package/build/components/conversation/reply_shadow_message.js.map +1 -1
  9. package/build/components/conversation/unread_divider.d.ts +6 -0
  10. package/build/components/conversation/unread_divider.d.ts.map +1 -0
  11. package/build/components/conversation/unread_divider.js +59 -0
  12. package/build/components/conversation/unread_divider.js.map +1 -0
  13. package/build/contexts/conversation_context.d.ts +2 -0
  14. package/build/contexts/conversation_context.d.ts.map +1 -1
  15. package/build/contexts/conversation_context.js +13 -5
  16. package/build/contexts/conversation_context.js.map +1 -1
  17. package/build/hooks/use_conversation_messages.d.ts +2 -0
  18. package/build/hooks/use_conversation_messages.d.ts.map +1 -1
  19. package/build/hooks/use_conversation_messages.js +9 -5
  20. package/build/hooks/use_conversation_messages.js.map +1 -1
  21. package/build/hooks/use_conversation_messages_jolt_events.d.ts.map +1 -1
  22. package/build/hooks/use_conversation_messages_jolt_events.js +4 -4
  23. package/build/hooks/use_conversation_messages_jolt_events.js.map +1 -1
  24. package/build/hooks/use_conversations_actions.d.ts +5 -0
  25. package/build/hooks/use_conversations_actions.d.ts.map +1 -1
  26. package/build/hooks/use_conversations_actions.js +12 -0
  27. package/build/hooks/use_conversations_actions.js.map +1 -1
  28. package/build/hooks/use_flat_list_viewability.d.ts +20 -0
  29. package/build/hooks/use_flat_list_viewability.d.ts.map +1 -0
  30. package/build/hooks/use_flat_list_viewability.js +30 -0
  31. package/build/hooks/use_flat_list_viewability.js.map +1 -0
  32. package/build/hooks/use_jump_to_bottom_action.d.ts +9 -0
  33. package/build/hooks/use_jump_to_bottom_action.d.ts.map +1 -0
  34. package/build/hooks/use_jump_to_bottom_action.js +62 -0
  35. package/build/hooks/use_jump_to_bottom_action.js.map +1 -0
  36. package/build/hooks/use_jump_to_unread_anchor.d.ts +20 -0
  37. package/build/hooks/use_jump_to_unread_anchor.d.ts.map +1 -0
  38. package/build/hooks/use_jump_to_unread_anchor.js +53 -0
  39. package/build/hooks/use_jump_to_unread_anchor.js.map +1 -0
  40. package/build/hooks/use_jump_to_unread_gates.d.ts +5 -0
  41. package/build/hooks/use_jump_to_unread_gates.d.ts.map +1 -0
  42. package/build/hooks/use_jump_to_unread_gates.js +10 -0
  43. package/build/hooks/use_jump_to_unread_gates.js.map +1 -0
  44. package/build/hooks/use_mark_latest_message_read.d.ts +1 -1
  45. package/build/hooks/use_mark_latest_message_read.d.ts.map +1 -1
  46. package/build/hooks/use_mark_latest_message_read.js +17 -1
  47. package/build/hooks/use_mark_latest_message_read.js.map +1 -1
  48. package/build/hooks/use_scroll_tracking.d.ts +13 -0
  49. package/build/hooks/use_scroll_tracking.d.ts.map +1 -0
  50. package/build/hooks/use_scroll_tracking.js +45 -0
  51. package/build/hooks/use_scroll_tracking.js.map +1 -0
  52. package/build/hooks/use_track_highest_seen_message.d.ts +4 -0
  53. package/build/hooks/use_track_highest_seen_message.d.ts.map +1 -0
  54. package/build/hooks/use_track_highest_seen_message.js +35 -0
  55. package/build/hooks/use_track_highest_seen_message.js.map +1 -0
  56. package/build/navigation/index.d.ts.map +1 -1
  57. package/build/screens/conversation_screen.d.ts.map +1 -1
  58. package/build/screens/conversation_screen.js +87 -44
  59. package/build/screens/conversation_screen.js.map +1 -1
  60. package/build/screens/group_notification_settings_screen.d.ts.map +1 -1
  61. package/build/screens/group_notification_settings_screen.js +6 -3
  62. package/build/screens/group_notification_settings_screen.js.map +1 -1
  63. package/build/screens/notification_settings_screen.js +2 -2
  64. package/build/screens/notification_settings_screen.js.map +1 -1
  65. package/build/screens/preferred_app_selection_screen.js +3 -3
  66. package/build/screens/preferred_app_selection_screen.js.map +1 -1
  67. package/build/utils/cache/messages_cache.d.ts +1 -0
  68. package/build/utils/cache/messages_cache.d.ts.map +1 -1
  69. package/build/utils/cache/messages_cache.js +4 -0
  70. package/build/utils/cache/messages_cache.js.map +1 -1
  71. package/build/utils/group_messages.d.ts +9 -2
  72. package/build/utils/group_messages.d.ts.map +1 -1
  73. package/build/utils/group_messages.js +20 -1
  74. package/build/utils/group_messages.js.map +1 -1
  75. package/build/utils/highest_seen_tracker.d.ts +12 -0
  76. package/build/utils/highest_seen_tracker.d.ts.map +1 -0
  77. package/build/utils/highest_seen_tracker.js +37 -0
  78. package/build/utils/highest_seen_tracker.js.map +1 -0
  79. package/build/utils/message_viewability.d.ts +24 -0
  80. package/build/utils/message_viewability.d.ts.map +1 -0
  81. package/build/utils/message_viewability.js +29 -0
  82. package/build/utils/message_viewability.js.map +1 -0
  83. package/build/utils/unread_divider_helpers.d.ts +18 -0
  84. package/build/utils/unread_divider_helpers.d.ts.map +1 -0
  85. package/build/utils/unread_divider_helpers.js +13 -0
  86. package/build/utils/unread_divider_helpers.js.map +1 -0
  87. package/package.json +10 -4
  88. package/src/__tests__/contexts/session_context.tsx +1 -1
  89. package/src/__tests__/hooks/use_async_storage.test.tsx +1 -1
  90. package/src/__tests__/hooks/use_attachment_uploader.test.tsx +1 -1
  91. package/src/__tests__/hooks/use_chat_configuration.test.tsx +1 -1
  92. package/src/__tests__/hooks/use_conversation_messages.test.tsx +1 -1
  93. package/src/__tests__/hooks/use_mark_latest_message_read.test.tsx +154 -0
  94. package/src/__tests__/utils/cache/messages_cache.test.ts +54 -0
  95. package/src/components/conversation/jump_to_bottom_button.tsx +57 -8
  96. package/src/components/conversation/reply_shadow_message.tsx +4 -2
  97. package/src/components/conversation/unread_divider.tsx +90 -0
  98. package/src/contexts/conversation_context.tsx +15 -13
  99. package/src/hooks/use_conversation_messages.ts +19 -3
  100. package/src/hooks/use_conversation_messages_jolt_events.ts +4 -3
  101. package/src/hooks/use_conversations_actions.ts +15 -0
  102. package/src/hooks/use_flat_list_viewability.ts +50 -0
  103. package/src/hooks/use_jump_to_bottom_action.ts +75 -0
  104. package/src/hooks/use_jump_to_unread_anchor.ts +68 -0
  105. package/src/hooks/use_jump_to_unread_gates.ts +10 -0
  106. package/src/hooks/use_mark_latest_message_read.ts +16 -2
  107. package/src/hooks/use_scroll_tracking.ts +64 -0
  108. package/src/hooks/use_track_highest_seen_message.ts +43 -0
  109. package/src/screens/conversation_screen.tsx +173 -70
  110. package/src/screens/group_notification_settings_screen.tsx +6 -3
  111. package/src/screens/notification_settings_screen.tsx +2 -2
  112. package/src/screens/preferred_app_selection_screen.tsx +3 -3
  113. package/src/utils/__tests__/group_messages.test.ts +71 -0
  114. package/src/utils/__tests__/highest_seen_tracker.test.ts +82 -0
  115. package/src/utils/__tests__/message_viewability.test.ts +168 -0
  116. package/src/utils/__tests__/unread_divider_helpers.test.ts +85 -0
  117. package/src/utils/cache/messages_cache.ts +5 -0
  118. package/src/utils/group_messages.ts +42 -2
  119. package/src/utils/highest_seen_tracker.ts +42 -0
  120. package/src/utils/message_viewability.ts +49 -0
  121. 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: MessageResource[]
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
- }, [debouncedMarkRead, isActive, shouldMarkRead, unreadReactionCount])
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 { ConversationContextProvider } from '../contexts/conversation_context'
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 { groupMessages, type DateSeparator } from '../utils/group_messages'
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 = featureEnabled('jump_to_unread') ? lastReadMessageSortKey : null
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 { conversation_id, editing_message_id, reply_root_id, reply_root_author_name } =
95
- route.params
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 { messages, refetch, isRefetching, fetchOlderMessages } = useConversationMessages({
98
- conversation_id,
99
- reply_root_id,
100
- })
101
- useConversationJoltEvents({ conversationId: conversation_id })
102
- useConversationMessagesJoltEvents({ conversationId: conversation_id })
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 messagesWithSeparators = groupMessages({
106
- ms: messages,
107
- inReplyScreen: !!reply_root_id,
108
- })
109
- const noMessages = messagesWithSeparators.length === 0
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(editing_message_id))
116
- const replyRootAuthorFirstName = reply_root_author_name?.split(' ')[0]
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 [showJumpToBottomButton, setShowJumpToBottomButton] = useState(false)
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 trackScroll = (event: any) => {
127
- const offsetY = event.nativeEvent.contentOffset.y
128
- setShowJumpToBottomButton(offsetY > 200)
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 handleReturnToBottom = useCallback(() => {
132
- listRef.current?.scrollToOffset({
133
- offset: 0,
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 (reply_root_id) {
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, reply_root_id, replyHeaderTitle, muted])
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
- refreshing={isRefetching}
182
- onRefresh={refetch}
183
- data={messagesWithSeparators}
184
- keyExtractor={item => item.id}
185
- onScroll={trackScroll}
186
- scrollEventThrottle={10}
187
- renderItem={({ item }) => {
188
- if (item.type === 'DateSeparator') {
189
- return <InlineDateSeparator {...item} />
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={<View style={styles.listHeader} />}
312
+ ListHeaderComponent={listHeader}
218
313
  />
219
314
  )}
220
- <JumpToBottomButton onPress={handleReturnToBottom} visible={showJumpToBottomButton} />
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={reply_root_id}
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="h3" style={styles.sectionHeading}>
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}>{group.name || title}</Text>
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: 4,
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,