@planningcenter/chat-react-native 3.35.0-rc.3 → 3.35.0-rc.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/build/components/conversation/jump_to_bottom_button.d.ts +2 -1
- package/build/components/conversation/jump_to_bottom_button.d.ts.map +1 -1
- package/build/components/conversation/jump_to_bottom_button.js +39 -7
- package/build/components/conversation/jump_to_bottom_button.js.map +1 -1
- package/build/components/conversation/reply_shadow_message.d.ts +1 -2
- package/build/components/conversation/reply_shadow_message.d.ts.map +1 -1
- package/build/components/conversation/reply_shadow_message.js.map +1 -1
- package/build/components/conversation/unread_divider.d.ts +6 -0
- package/build/components/conversation/unread_divider.d.ts.map +1 -0
- package/build/components/conversation/unread_divider.js +59 -0
- package/build/components/conversation/unread_divider.js.map +1 -0
- package/build/contexts/conversation_context.d.ts +2 -0
- package/build/contexts/conversation_context.d.ts.map +1 -1
- package/build/contexts/conversation_context.js +13 -5
- package/build/contexts/conversation_context.js.map +1 -1
- package/build/hooks/use_conversation_messages.d.ts +2 -0
- package/build/hooks/use_conversation_messages.d.ts.map +1 -1
- package/build/hooks/use_conversation_messages.js +9 -5
- package/build/hooks/use_conversation_messages.js.map +1 -1
- package/build/hooks/use_conversation_messages_jolt_events.d.ts.map +1 -1
- package/build/hooks/use_conversation_messages_jolt_events.js +4 -4
- package/build/hooks/use_conversation_messages_jolt_events.js.map +1 -1
- package/build/hooks/use_conversations_actions.d.ts +5 -0
- package/build/hooks/use_conversations_actions.d.ts.map +1 -1
- package/build/hooks/use_conversations_actions.js +12 -0
- package/build/hooks/use_conversations_actions.js.map +1 -1
- package/build/hooks/use_flat_list_viewability.d.ts +20 -0
- package/build/hooks/use_flat_list_viewability.d.ts.map +1 -0
- package/build/hooks/use_flat_list_viewability.js +30 -0
- package/build/hooks/use_flat_list_viewability.js.map +1 -0
- package/build/hooks/use_jump_to_bottom_action.d.ts +9 -0
- package/build/hooks/use_jump_to_bottom_action.d.ts.map +1 -0
- package/build/hooks/use_jump_to_bottom_action.js +62 -0
- package/build/hooks/use_jump_to_bottom_action.js.map +1 -0
- package/build/hooks/use_jump_to_unread_anchor.d.ts +20 -0
- package/build/hooks/use_jump_to_unread_anchor.d.ts.map +1 -0
- package/build/hooks/use_jump_to_unread_anchor.js +53 -0
- package/build/hooks/use_jump_to_unread_anchor.js.map +1 -0
- package/build/hooks/use_jump_to_unread_gates.d.ts +5 -0
- package/build/hooks/use_jump_to_unread_gates.d.ts.map +1 -0
- package/build/hooks/use_jump_to_unread_gates.js +10 -0
- package/build/hooks/use_jump_to_unread_gates.js.map +1 -0
- package/build/hooks/use_mark_latest_message_read.d.ts +1 -1
- package/build/hooks/use_mark_latest_message_read.d.ts.map +1 -1
- package/build/hooks/use_mark_latest_message_read.js +17 -1
- package/build/hooks/use_mark_latest_message_read.js.map +1 -1
- package/build/hooks/use_scroll_tracking.d.ts +13 -0
- package/build/hooks/use_scroll_tracking.d.ts.map +1 -0
- package/build/hooks/use_scroll_tracking.js +45 -0
- package/build/hooks/use_scroll_tracking.js.map +1 -0
- package/build/hooks/use_track_highest_seen_message.d.ts +4 -0
- package/build/hooks/use_track_highest_seen_message.d.ts.map +1 -0
- package/build/hooks/use_track_highest_seen_message.js +35 -0
- package/build/hooks/use_track_highest_seen_message.js.map +1 -0
- package/build/navigation/index.d.ts.map +1 -1
- package/build/screens/conversation_screen.d.ts.map +1 -1
- package/build/screens/conversation_screen.js +87 -44
- package/build/screens/conversation_screen.js.map +1 -1
- package/build/screens/group_notification_settings_screen.d.ts.map +1 -1
- package/build/screens/group_notification_settings_screen.js +6 -3
- package/build/screens/group_notification_settings_screen.js.map +1 -1
- package/build/screens/notification_settings_screen.js +2 -2
- package/build/screens/notification_settings_screen.js.map +1 -1
- package/build/screens/preferred_app_selection_screen.js +3 -3
- package/build/screens/preferred_app_selection_screen.js.map +1 -1
- package/build/utils/cache/messages_cache.d.ts +1 -0
- package/build/utils/cache/messages_cache.d.ts.map +1 -1
- package/build/utils/cache/messages_cache.js +4 -0
- package/build/utils/cache/messages_cache.js.map +1 -1
- package/build/utils/group_messages.d.ts +9 -2
- package/build/utils/group_messages.d.ts.map +1 -1
- package/build/utils/group_messages.js +20 -1
- package/build/utils/group_messages.js.map +1 -1
- package/build/utils/highest_seen_tracker.d.ts +12 -0
- package/build/utils/highest_seen_tracker.d.ts.map +1 -0
- package/build/utils/highest_seen_tracker.js +37 -0
- package/build/utils/highest_seen_tracker.js.map +1 -0
- package/build/utils/message_viewability.d.ts +24 -0
- package/build/utils/message_viewability.d.ts.map +1 -0
- package/build/utils/message_viewability.js +29 -0
- package/build/utils/message_viewability.js.map +1 -0
- package/build/utils/unread_divider_helpers.d.ts +18 -0
- package/build/utils/unread_divider_helpers.d.ts.map +1 -0
- package/build/utils/unread_divider_helpers.js +13 -0
- package/build/utils/unread_divider_helpers.js.map +1 -0
- package/package.json +10 -4
- package/src/__tests__/contexts/session_context.tsx +1 -1
- package/src/__tests__/hooks/use_async_storage.test.tsx +1 -1
- package/src/__tests__/hooks/use_attachment_uploader.test.tsx +1 -1
- package/src/__tests__/hooks/use_chat_configuration.test.tsx +1 -1
- package/src/__tests__/hooks/use_conversation_messages.test.tsx +1 -1
- package/src/__tests__/hooks/use_mark_latest_message_read.test.tsx +154 -0
- package/src/__tests__/utils/cache/messages_cache.test.ts +54 -0
- package/src/components/conversation/jump_to_bottom_button.tsx +57 -8
- package/src/components/conversation/reply_shadow_message.tsx +4 -2
- package/src/components/conversation/unread_divider.tsx +90 -0
- package/src/contexts/conversation_context.tsx +15 -13
- package/src/hooks/use_conversation_messages.ts +19 -3
- package/src/hooks/use_conversation_messages_jolt_events.ts +4 -3
- package/src/hooks/use_conversations_actions.ts +15 -0
- package/src/hooks/use_flat_list_viewability.ts +50 -0
- package/src/hooks/use_jump_to_bottom_action.ts +75 -0
- package/src/hooks/use_jump_to_unread_anchor.ts +68 -0
- package/src/hooks/use_jump_to_unread_gates.ts +10 -0
- package/src/hooks/use_mark_latest_message_read.ts +16 -2
- package/src/hooks/use_scroll_tracking.ts +64 -0
- package/src/hooks/use_track_highest_seen_message.ts +43 -0
- package/src/screens/conversation_screen.tsx +173 -70
- package/src/screens/group_notification_settings_screen.tsx +6 -3
- package/src/screens/notification_settings_screen.tsx +2 -2
- package/src/screens/preferred_app_selection_screen.tsx +3 -3
- package/src/utils/__tests__/group_messages.test.ts +71 -0
- package/src/utils/__tests__/highest_seen_tracker.test.ts +82 -0
- package/src/utils/__tests__/message_viewability.test.ts +168 -0
- package/src/utils/__tests__/unread_divider_helpers.test.ts +85 -0
- package/src/utils/cache/messages_cache.ts +5 -0
- package/src/utils/group_messages.ts +42 -2
- package/src/utils/highest_seen_tracker.ts +42 -0
- package/src/utils/message_viewability.ts +49 -0
- package/src/utils/unread_divider_helpers.ts +25 -0
|
@@ -0,0 +1,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 {
|
|
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 = ({
|
|
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
|
|
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
|
-
<
|
|
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
|
|
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} ${
|
|
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
|
-
[
|
|
50
|
+
[
|
|
51
|
+
conversationId,
|
|
52
|
+
currentPageReplyRootId,
|
|
53
|
+
initialMessageId,
|
|
54
|
+
initialMessageIdIsAnchor,
|
|
55
|
+
atEndOfMessageHistory,
|
|
56
|
+
]
|
|
55
57
|
)
|
|
56
58
|
|
|
57
59
|
return <ConversationContext.Provider value={value}>{children}</ConversationContext.Provider>
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import {
|
|
2
2
|
AnyUseSuspenseInfiniteQueryOptions,
|
|
3
3
|
InfiniteData,
|
|
4
|
+
useQueryClient,
|
|
4
5
|
useSuspenseInfiniteQuery,
|
|
5
6
|
useSuspenseQueries,
|
|
6
7
|
} from '@tanstack/react-query'
|
|
7
|
-
import { useMemo } from 'react'
|
|
8
|
+
import { useCallback, useMemo } from 'react'
|
|
8
9
|
import { useConversationContext } from '../contexts/conversation_context'
|
|
9
10
|
import { ApiCollection, MessageResource } from '../types'
|
|
10
11
|
import {
|
|
@@ -38,8 +39,14 @@ export const useConversationMessages = (
|
|
|
38
39
|
const { initialMessageId } = useConversationContext()
|
|
39
40
|
const anchored = !reply_root_id && !!initialMessageId
|
|
40
41
|
|
|
41
|
-
const requestArgs =
|
|
42
|
-
|
|
42
|
+
const requestArgs = useMemo(
|
|
43
|
+
() => getMessagesRequestArgs({ conversation_id, reply_root_id }),
|
|
44
|
+
[conversation_id, reply_root_id]
|
|
45
|
+
)
|
|
46
|
+
const queryKey = useMemo(
|
|
47
|
+
() => getMessagesQueryKey({ conversation_id, reply_root_id }),
|
|
48
|
+
[conversation_id, reply_root_id]
|
|
49
|
+
)
|
|
43
50
|
|
|
44
51
|
const fetchPage = (pageParam: MessagesPageParam) => {
|
|
45
52
|
const data = {
|
|
@@ -80,6 +87,7 @@ export const useConversationMessages = (
|
|
|
80
87
|
hasNextPage,
|
|
81
88
|
fetchPreviousPage,
|
|
82
89
|
hasPreviousPage,
|
|
90
|
+
isFetchingPreviousPage,
|
|
83
91
|
} = useSuspenseInfiniteQuery<
|
|
84
92
|
ApiCollection<MessageResource>,
|
|
85
93
|
Response,
|
|
@@ -99,6 +107,12 @@ export const useConversationMessages = (
|
|
|
99
107
|
|
|
100
108
|
const messages = useMemo(() => sortAndFilterMessages(data.pages), [data.pages])
|
|
101
109
|
|
|
110
|
+
const queryClient = useQueryClient()
|
|
111
|
+
const cancelFetchNewerMessages = useCallback(
|
|
112
|
+
() => queryClient.cancelQueries({ queryKey }),
|
|
113
|
+
[queryClient, queryKey]
|
|
114
|
+
)
|
|
115
|
+
|
|
102
116
|
return {
|
|
103
117
|
messages,
|
|
104
118
|
refetch,
|
|
@@ -107,6 +121,8 @@ export const useConversationMessages = (
|
|
|
107
121
|
hasMoreOlderMessages: hasNextPage,
|
|
108
122
|
fetchNewerMessages: fetchPreviousPage,
|
|
109
123
|
hasMoreNewerMessages: hasPreviousPage,
|
|
124
|
+
isFetchingNewerMessages: isFetchingPreviousPage,
|
|
125
|
+
cancelFetchNewerMessages,
|
|
110
126
|
queryKey,
|
|
111
127
|
}
|
|
112
128
|
}
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
updateCacheWithIndividualMessage,
|
|
13
13
|
updateCacheWithReaction,
|
|
14
14
|
getThreadedMessagesQueryKey,
|
|
15
|
+
hasUnloadedNewerPages,
|
|
15
16
|
} from '../utils/cache/messages_cache'
|
|
16
17
|
import { transformMessageEventDataToMessageResource } from '../utils/jolt/transform_message_event_data_to_message_resource'
|
|
17
18
|
import { completeMessageCreationTracking } from '../utils/performance_tracking'
|
|
@@ -52,10 +53,10 @@ export function useConversationMessagesJoltEvents({ conversationId }: Props) {
|
|
|
52
53
|
}
|
|
53
54
|
}
|
|
54
55
|
|
|
55
|
-
|
|
56
|
-
|
|
56
|
+
if (e.event === 'message.updated' || !hasUnloadedNewerPages(queryClient, messagesQueryKey)) {
|
|
57
|
+
updateCacheWithMessage(queryClient, messagesQueryKey, message, e.event)
|
|
58
|
+
}
|
|
57
59
|
|
|
58
|
-
// If message has a reply_root_id, also update the threaded cache
|
|
59
60
|
if (data.reply_root_id) {
|
|
60
61
|
const threadedMessagesQueryKey = getThreadedMessagesQueryKey(
|
|
61
62
|
conversationId,
|
|
@@ -98,6 +98,21 @@ export const useConversationsMute = ({ conversation }: { conversation: Conversat
|
|
|
98
98
|
}
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
+
export const useConversationsMarkReadUpTo = ({ conversationId }: { conversationId: number }) => {
|
|
102
|
+
const apiClient = useApiClient()
|
|
103
|
+
|
|
104
|
+
return useMutation({
|
|
105
|
+
mutationKey: ['markReadUpTo', conversationId],
|
|
106
|
+
mutationFn: async ({ sortKey }: { sortKey: string }) =>
|
|
107
|
+
apiClient.chat.post({
|
|
108
|
+
url: `/me/conversations/${conversationId}/mark_read_up_to`,
|
|
109
|
+
data: {
|
|
110
|
+
data: { type: 'Conversation', attributes: { sort_key: sortKey } },
|
|
111
|
+
},
|
|
112
|
+
}),
|
|
113
|
+
})
|
|
114
|
+
}
|
|
115
|
+
|
|
101
116
|
export const useMarkAllRead = () => {
|
|
102
117
|
const apiClient = useApiClient()
|
|
103
118
|
const { args } = useConversationsContext()
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef } from 'react'
|
|
2
|
+
import type { ViewToken } from 'react-native'
|
|
3
|
+
import type { ViewabilityObserver } from '../utils/message_viewability'
|
|
4
|
+
|
|
5
|
+
interface UseFlatListViewabilityArgs<Item> {
|
|
6
|
+
observers: ViewabilityObserver<Item>[]
|
|
7
|
+
itemVisiblePercentThreshold?: number
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function useFlatListViewability<Item>({
|
|
11
|
+
observers,
|
|
12
|
+
itemVisiblePercentThreshold = 50,
|
|
13
|
+
}: UseFlatListViewabilityArgs<Item>) {
|
|
14
|
+
const userHasScrolledRef = useRef(false)
|
|
15
|
+
const observersRef = useRef(observers)
|
|
16
|
+
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
observersRef.current = observers
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
const onScrollBeginDrag = useCallback(() => {
|
|
22
|
+
userHasScrolledRef.current = true
|
|
23
|
+
}, [])
|
|
24
|
+
|
|
25
|
+
const viewabilityConfigCallbackPairs = useRef([
|
|
26
|
+
{
|
|
27
|
+
viewabilityConfig: { itemVisiblePercentThreshold },
|
|
28
|
+
onViewableItemsChanged: ({
|
|
29
|
+
viewableItems,
|
|
30
|
+
changed,
|
|
31
|
+
}: {
|
|
32
|
+
viewableItems: ViewToken[]
|
|
33
|
+
changed: ViewToken[]
|
|
34
|
+
}) => {
|
|
35
|
+
const event = {
|
|
36
|
+
viewableItems: viewableItems.map(toEntry<Item>),
|
|
37
|
+
changed: changed.map(toEntry<Item>),
|
|
38
|
+
userHasScrolled: userHasScrolledRef.current,
|
|
39
|
+
}
|
|
40
|
+
for (const observer of observersRef.current) observer(event)
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
]).current
|
|
44
|
+
|
|
45
|
+
return { viewabilityConfigCallbackPairs, onScrollBeginDrag }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function toEntry<Item>(token: ViewToken) {
|
|
49
|
+
return { key: token.key, isViewable: !!token.isViewable, item: token.item as Item }
|
|
50
|
+
}
|