@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,154 @@
1
+ import { QueryClientProvider } from '@tanstack/react-query'
2
+ import { renderHook } from '@testing-library/react-native'
3
+ import React, { useEffect } from 'react'
4
+ import { buildTestQueryClient } from '../../__utils__/query_client'
5
+ import {
6
+ ConversationContextProvider,
7
+ useConversationContext,
8
+ } from '../../contexts/conversation_context'
9
+ import * as appStateModule from '../../hooks/use_app_state'
10
+ import * as conversationsActionsModule from '../../hooks/use_conversations_actions'
11
+ import * as featuresModule from '../../hooks/use_features'
12
+ import { useMarkLatestMessageRead } from '../../hooks/use_mark_latest_message_read'
13
+ import { ConversationResource } from '../../types'
14
+
15
+ const conversation = {
16
+ id: 1,
17
+ type: 'Conversation',
18
+ unreadCount: 3,
19
+ unreadReactionCount: 0,
20
+ conversationMembership: { lastReadMessageSortKey: '01A' },
21
+ } as unknown as ConversationResource
22
+
23
+ interface WrapperProps {
24
+ replyRootId?: string | null
25
+ initialMessageIdIsAnchor?: boolean
26
+ atEndOfMessageHistory?: boolean
27
+ }
28
+
29
+ const createWrapper = ({
30
+ replyRootId = null,
31
+ initialMessageIdIsAnchor = false,
32
+ atEndOfMessageHistory = !initialMessageIdIsAnchor,
33
+ }: WrapperProps = {}) => {
34
+ const queryClient = buildTestQueryClient()
35
+ const Wrapper = ({ children }: { children: React.ReactNode }) => (
36
+ <QueryClientProvider client={queryClient}>
37
+ <ConversationContextProvider
38
+ conversationId={1}
39
+ currentPageReplyRootId={replyRootId}
40
+ initialMessageId={initialMessageIdIsAnchor ? '01A' : null}
41
+ initialMessageIdIsAnchor={initialMessageIdIsAnchor}
42
+ >
43
+ <AtEndPrimer atEndOfMessageHistory={atEndOfMessageHistory}>{children}</AtEndPrimer>
44
+ </ConversationContextProvider>
45
+ </QueryClientProvider>
46
+ )
47
+ return Wrapper
48
+ }
49
+
50
+ const AtEndPrimer = ({
51
+ atEndOfMessageHistory,
52
+ children,
53
+ }: {
54
+ atEndOfMessageHistory: boolean
55
+ children: React.ReactNode
56
+ }) => {
57
+ const { setAtEndOfMessageHistory } = useConversationContext()
58
+ useEffect(() => {
59
+ setAtEndOfMessageHistory(atEndOfMessageHistory)
60
+ }, [atEndOfMessageHistory, setAtEndOfMessageHistory])
61
+ return <>{children}</>
62
+ }
63
+
64
+ const mockFeatures = (enabled: boolean) => {
65
+ jest.spyOn(featuresModule, 'useFeatures').mockReturnValue({
66
+ features: [],
67
+ featureEnabled: () => enabled,
68
+ })
69
+ }
70
+
71
+ const mockMarkRead = () => {
72
+ const markRead = jest.fn()
73
+ jest.spyOn(conversationsActionsModule, 'useConversationsMarkRead').mockReturnValue({
74
+ markRead,
75
+ read: false,
76
+ } as unknown as ReturnType<typeof conversationsActionsModule.useConversationsMarkRead>)
77
+ return markRead
78
+ }
79
+
80
+ const mockAppState = (state: string = 'active') => {
81
+ jest.spyOn(appStateModule, 'useAppState').mockReturnValue(state)
82
+ }
83
+
84
+ describe('useMarkLatestMessageRead', () => {
85
+ afterEach(() => {
86
+ jest.restoreAllMocks()
87
+ })
88
+
89
+ it('fires markRead when JTU is off (backward compat preserved)', () => {
90
+ mockAppState()
91
+ mockFeatures(false)
92
+ const markRead = mockMarkRead()
93
+
94
+ renderHook(() => useMarkLatestMessageRead({ conversation }), {
95
+ wrapper: createWrapper(),
96
+ })
97
+
98
+ expect(markRead).toHaveBeenCalledWith(true)
99
+ })
100
+
101
+ it('does NOT fire markRead when in a reply view (parity fix, ships flag-off)', () => {
102
+ mockAppState()
103
+ mockFeatures(false)
104
+ const markRead = mockMarkRead()
105
+
106
+ renderHook(() => useMarkLatestMessageRead({ conversation }), {
107
+ wrapper: createWrapper({ replyRootId: 'root-1' }),
108
+ })
109
+
110
+ expect(markRead).not.toHaveBeenCalled()
111
+ })
112
+
113
+ it('does NOT fire markRead when JTU is active and user is not at end of history', () => {
114
+ mockAppState()
115
+ mockFeatures(true)
116
+ const markRead = mockMarkRead()
117
+
118
+ renderHook(() => useMarkLatestMessageRead({ conversation }), {
119
+ wrapper: createWrapper({
120
+ initialMessageIdIsAnchor: true,
121
+ atEndOfMessageHistory: false,
122
+ }),
123
+ })
124
+
125
+ expect(markRead).not.toHaveBeenCalled()
126
+ })
127
+
128
+ it('fires markRead when JTU is active and user reaches end of history', () => {
129
+ mockAppState()
130
+ mockFeatures(true)
131
+ const markRead = mockMarkRead()
132
+
133
+ renderHook(() => useMarkLatestMessageRead({ conversation }), {
134
+ wrapper: createWrapper({
135
+ initialMessageIdIsAnchor: true,
136
+ atEndOfMessageHistory: true,
137
+ }),
138
+ })
139
+
140
+ expect(markRead).toHaveBeenCalledWith(true)
141
+ })
142
+
143
+ it('does NOT fire when app state is not active', () => {
144
+ mockAppState('background')
145
+ mockFeatures(false)
146
+ const markRead = mockMarkRead()
147
+
148
+ renderHook(() => useMarkLatestMessageRead({ conversation }), {
149
+ wrapper: createWrapper(),
150
+ })
151
+
152
+ expect(markRead).not.toHaveBeenCalled()
153
+ })
154
+ })
@@ -0,0 +1,54 @@
1
+ import { buildTestQueryClient } from '../../../__utils__/query_client'
2
+ import { hasUnloadedNewerPages } from '../../../utils/cache/messages_cache'
3
+
4
+ const queryKey = ['messages-test']
5
+
6
+ const buildClient = (data: unknown) => {
7
+ const client = buildTestQueryClient()
8
+ client.setQueryData(queryKey, data)
9
+ return client
10
+ }
11
+
12
+ describe('hasUnloadedNewerPages', () => {
13
+ it('returns false when the query has no cached data', () => {
14
+ const client = buildTestQueryClient()
15
+ expect(hasUnloadedNewerPages(client, queryKey)).toBe(false)
16
+ })
17
+
18
+ it('returns false when pages[0] has no next.idGt cursor', () => {
19
+ const client = buildClient({
20
+ pageParams: [{}],
21
+ pages: [{ data: [], links: {}, meta: { count: 0, totalCount: 0 } }],
22
+ })
23
+ expect(hasUnloadedNewerPages(client, queryKey)).toBe(false)
24
+ })
25
+
26
+ it('returns false when next exists but idGt is absent', () => {
27
+ const client = buildClient({
28
+ pageParams: [{}],
29
+ pages: [{ data: [], links: {}, meta: { count: 0, totalCount: 0, next: { idLt: '01A' } } }],
30
+ })
31
+ expect(hasUnloadedNewerPages(client, queryKey)).toBe(false)
32
+ })
33
+
34
+ it('returns true when pages[0].meta.next.idGt is set', () => {
35
+ const client = buildClient({
36
+ pageParams: [{}],
37
+ pages: [
38
+ { data: [], links: {}, meta: { count: 0, totalCount: 0, next: { idGt: '01KQSTAY' } } },
39
+ ],
40
+ })
41
+ expect(hasUnloadedNewerPages(client, queryKey)).toBe(true)
42
+ })
43
+
44
+ it('inspects only the first page (the newest side after sort)', () => {
45
+ const client = buildClient({
46
+ pageParams: [{}, {}],
47
+ pages: [
48
+ { data: [], links: {}, meta: { count: 0, totalCount: 0 } },
49
+ { data: [], links: {}, meta: { count: 0, totalCount: 0, next: { idGt: '01KQSTAY' } } },
50
+ ],
51
+ })
52
+ expect(hasUnloadedNewerPages(client, queryKey)).toBe(false)
53
+ })
54
+ })
@@ -1,12 +1,13 @@
1
1
  import { useEffect } from 'react'
2
- import { View, Pressable, StyleSheet } from 'react-native'
2
+ import { ActivityIndicator, Pressable, StyleSheet, View } from 'react-native'
3
3
  import Animated, {
4
- useSharedValue,
5
- useAnimatedStyle,
6
- interpolate,
7
4
  Extrapolation,
5
+ interpolate,
8
6
  ReduceMotion,
7
+ useAnimatedStyle,
8
+ useSharedValue,
9
9
  withSpring,
10
+ withTiming,
10
11
  } from 'react-native-reanimated'
11
12
  import { useTheme } from '../../hooks'
12
13
  import { platformFontWeightMedium } from '../../utils'
@@ -15,11 +16,17 @@ import { Icon, Text } from '../display'
15
16
  interface JumpToBottomButtonProps {
16
17
  onPress: () => void
17
18
  visible: boolean
19
+ loading?: boolean
18
20
  }
19
21
 
20
- export const JumpToBottomButton = ({ onPress, visible }: JumpToBottomButtonProps) => {
22
+ export const JumpToBottomButton = ({
23
+ onPress,
24
+ visible,
25
+ loading = false,
26
+ }: JumpToBottomButtonProps) => {
21
27
  const styles = useStyles()
22
28
  const progress = useSharedValue(0)
29
+ const loadingProgress = useSharedValue(0)
23
30
 
24
31
  useEffect(() => {
25
32
  progress.value = withSpring(visible ? 1 : 0, {
@@ -31,6 +38,13 @@ export const JumpToBottomButton = ({ onPress, visible }: JumpToBottomButtonProps
31
38
  })
32
39
  }, [visible, progress])
33
40
 
41
+ useEffect(() => {
42
+ loadingProgress.value = withTiming(loading ? 1 : 0, {
43
+ duration: 750,
44
+ reduceMotion: ReduceMotion.System,
45
+ })
46
+ }, [loading, loadingProgress])
47
+
34
48
  const animatedStyle = useAnimatedStyle(() => {
35
49
  return {
36
50
  opacity: progress.value,
@@ -45,16 +59,34 @@ export const JumpToBottomButton = ({ onPress, visible }: JumpToBottomButtonProps
45
59
  }
46
60
  })
47
61
 
62
+ const iconStyle = useAnimatedStyle(() => ({ opacity: 1 - loadingProgress.value }))
63
+ const spinnerStyle = useAnimatedStyle(() => ({ opacity: loadingProgress.value }))
64
+
48
65
  return (
49
66
  <View>
50
- <Animated.View style={[styles.container, animatedStyle]}>
67
+ <Animated.View
68
+ style={[styles.container, animatedStyle]}
69
+ pointerEvents={visible ? 'auto' : 'none'}
70
+ >
51
71
  <Pressable
52
72
  onPress={onPress}
73
+ disabled={loading}
74
+ accessibilityRole="button"
75
+ accessibilityLabel="Jump to most recent message"
76
+ accessibilityState={{ busy: loading }}
77
+ hitSlop={hitSlop}
53
78
  style={({ pressed }) => [styles.button, pressed && styles.pressed]}
54
79
  >
55
- <Icon name="general.downArrow" style={styles.icon} />
80
+ <View style={styles.glyph}>
81
+ <Animated.View style={[styles.glyphLayer, iconStyle]}>
82
+ <Icon name="general.downArrow" style={styles.icon} />
83
+ </Animated.View>
84
+ <Animated.View style={[styles.glyphLayer, spinnerStyle]}>
85
+ <ActivityIndicator size="small" color={styles.icon.color} style={styles.spinner} />
86
+ </Animated.View>
87
+ </View>
56
88
  <Text variant="tertiary" style={styles.text}>
57
- Jump to bottom
89
+ {loading ? 'Jumping to latest…' : 'Jump to bottom'}
58
90
  </Text>
59
91
  </Pressable>
60
92
  </Animated.View>
@@ -62,6 +94,8 @@ export const JumpToBottomButton = ({ onPress, visible }: JumpToBottomButtonProps
62
94
  )
63
95
  }
64
96
 
97
+ const hitSlop = { top: 12, bottom: 12, left: 12, right: 12 }
98
+
65
99
  const useStyles = () => {
66
100
  const { colors } = useTheme()
67
101
 
@@ -93,10 +127,25 @@ const useStyles = () => {
93
127
  color: colors.fillColorNeutral100Inverted,
94
128
  fontWeight: platformFontWeightMedium,
95
129
  },
130
+ glyph: {
131
+ width: 14,
132
+ height: 14,
133
+ alignItems: 'center',
134
+ justifyContent: 'center',
135
+ },
136
+ glyphLayer: {
137
+ position: 'absolute',
138
+ alignItems: 'center',
139
+ justifyContent: 'center',
140
+ },
96
141
  icon: {
97
142
  color: colors.fillColorNeutral100Inverted,
98
143
  fontSize: 14,
99
144
  },
145
+ spinner: {
146
+ width: 14,
147
+ height: 14,
148
+ },
100
149
  pressed: {
101
150
  transform: [{ scale: 0.95 }],
102
151
  },
@@ -28,7 +28,7 @@ import {
28
28
  import { Avatar, Icon, IconProps, Image, Text } from '../display'
29
29
  import { TheirReplyConnector, MyReplyConnector } from './reply_connectors'
30
30
 
31
- interface ReplyShadowMessageProps extends MessageResource {
31
+ interface ReplyShadowMessageProps {
32
32
  messageId: string
33
33
  conversation_id: number
34
34
  inReplyScreen?: boolean
@@ -86,7 +86,9 @@ function ShadowMessageContent({ conversation_id, ...message }: ShadowMessageCont
86
86
  }
87
87
 
88
88
  const attachmentLabel = some(attachments) ? pluralize(attachments.length, 'attachment') : ''
89
- const accessibilityLabel = `${author?.name || ''} Reply Preview ${attachmentLabel} ${messageText || ''} ${timestamp} ${replyCountText}`
89
+ const accessibilityLabel = `${author?.name || ''} Reply Preview ${attachmentLabel} ${
90
+ messageText || ''
91
+ } ${timestamp} ${replyCountText}`
90
92
 
91
93
  return (
92
94
  <Pressable
@@ -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
- [conversationId, currentPageReplyRootId, initialMessageId, initialMessageIdIsAnchor]
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 = getMessagesRequestArgs({ conversation_id, reply_root_id })
42
- const queryKey = getMessagesQueryKey({ conversation_id, reply_root_id })
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
- // Update the main conversation cache
56
- updateCacheWithMessage(queryClient, messagesQueryKey, message, e.event)
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
+ }