@planningcenter/chat-react-native 3.38.0-rc.10 → 3.38.0-rc.11
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/build/components/conversation/jump_to_bottom_button.d.ts +1 -2
- package/build/components/conversation/jump_to_bottom_button.d.ts.map +1 -1
- package/build/components/conversation/jump_to_bottom_button.js +7 -39
- package/build/components/conversation/jump_to_bottom_button.js.map +1 -1
- package/build/components/conversation/reply_shadow_message.d.ts +2 -1
- 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/contexts/conversation_context.d.ts +1 -8
- package/build/contexts/conversation_context.d.ts.map +1 -1
- package/build/contexts/conversation_context.js +3 -21
- package/build/contexts/conversation_context.js.map +1 -1
- package/build/hooks/use_conversation_messages.d.ts +6 -15
- package/build/hooks/use_conversation_messages.d.ts.map +1 -1
- package/build/hooks/use_conversation_messages.js +9 -62
- 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 +0 -5
- package/build/hooks/use_conversations_actions.d.ts.map +1 -1
- package/build/hooks/use_conversations_actions.js +0 -12
- package/build/hooks/use_conversations_actions.js.map +1 -1
- package/build/hooks/use_features.d.ts +0 -1
- package/build/hooks/use_features.d.ts.map +1 -1
- package/build/hooks/use_features.js +0 -1
- package/build/hooks/use_features.js.map +1 -1
- 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 +1 -17
- package/build/hooks/use_mark_latest_message_read.js.map +1 -1
- package/build/hooks/use_suspense_api.d.ts +0 -1
- package/build/hooks/use_suspense_api.d.ts.map +1 -1
- package/build/hooks/use_suspense_api.js +1 -1
- package/build/hooks/use_suspense_api.js.map +1 -1
- package/build/screens/conversation_screen.d.ts +0 -1
- package/build/screens/conversation_screen.d.ts.map +1 -1
- package/build/screens/conversation_screen.js +48 -95
- package/build/screens/conversation_screen.js.map +1 -1
- package/build/utils/cache/messages_cache.d.ts +0 -1
- package/build/utils/cache/messages_cache.d.ts.map +1 -1
- package/build/utils/cache/messages_cache.js +0 -4
- package/build/utils/cache/messages_cache.js.map +1 -1
- package/build/utils/group_messages.d.ts +2 -9
- package/build/utils/group_messages.d.ts.map +1 -1
- package/build/utils/group_messages.js +1 -20
- package/build/utils/group_messages.js.map +1 -1
- package/package.json +3 -3
- package/src/components/conversation/jump_to_bottom_button.tsx +8 -57
- package/src/components/conversation/reply_shadow_message.tsx +1 -1
- package/src/contexts/conversation_context.tsx +2 -30
- package/src/hooks/use_conversation_messages.ts +20 -120
- package/src/hooks/use_conversation_messages_jolt_events.ts +3 -4
- package/src/hooks/use_conversations_actions.ts +0 -15
- package/src/hooks/use_features.ts +0 -1
- package/src/hooks/use_mark_latest_message_read.ts +2 -16
- package/src/hooks/use_suspense_api.ts +1 -1
- package/src/screens/conversation_screen.tsx +76 -184
- package/src/utils/__tests__/group_messages.test.ts +0 -71
- package/src/utils/cache/messages_cache.ts +0 -5
- package/src/utils/group_messages.ts +2 -42
- package/build/components/conversation/unread_divider.d.ts +0 -6
- package/build/components/conversation/unread_divider.d.ts.map +0 -1
- package/build/components/conversation/unread_divider.js +0 -59
- package/build/components/conversation/unread_divider.js.map +0 -1
- package/build/hooks/use_flat_list_viewability.d.ts +0 -20
- package/build/hooks/use_flat_list_viewability.d.ts.map +0 -1
- package/build/hooks/use_flat_list_viewability.js +0 -30
- package/build/hooks/use_flat_list_viewability.js.map +0 -1
- package/build/hooks/use_jump_to_bottom_action.d.ts +0 -9
- package/build/hooks/use_jump_to_bottom_action.d.ts.map +0 -1
- package/build/hooks/use_jump_to_bottom_action.js +0 -62
- package/build/hooks/use_jump_to_bottom_action.js.map +0 -1
- package/build/hooks/use_jump_to_unread_anchor.d.ts +0 -20
- package/build/hooks/use_jump_to_unread_anchor.d.ts.map +0 -1
- package/build/hooks/use_jump_to_unread_anchor.js +0 -53
- package/build/hooks/use_jump_to_unread_anchor.js.map +0 -1
- package/build/hooks/use_jump_to_unread_gates.d.ts +0 -5
- package/build/hooks/use_jump_to_unread_gates.d.ts.map +0 -1
- package/build/hooks/use_jump_to_unread_gates.js +0 -10
- package/build/hooks/use_jump_to_unread_gates.js.map +0 -1
- package/build/hooks/use_scroll_tracking.d.ts +0 -13
- package/build/hooks/use_scroll_tracking.d.ts.map +0 -1
- package/build/hooks/use_scroll_tracking.js +0 -45
- package/build/hooks/use_scroll_tracking.js.map +0 -1
- package/build/hooks/use_track_highest_seen_message.d.ts +0 -4
- package/build/hooks/use_track_highest_seen_message.d.ts.map +0 -1
- package/build/hooks/use_track_highest_seen_message.js +0 -35
- package/build/hooks/use_track_highest_seen_message.js.map +0 -1
- package/build/utils/conversation_messages.d.ts +0 -10
- package/build/utils/conversation_messages.d.ts.map +0 -1
- package/build/utils/conversation_messages.js +0 -22
- package/build/utils/conversation_messages.js.map +0 -1
- package/build/utils/highest_seen_tracker.d.ts +0 -12
- package/build/utils/highest_seen_tracker.d.ts.map +0 -1
- package/build/utils/highest_seen_tracker.js +0 -37
- package/build/utils/highest_seen_tracker.js.map +0 -1
- package/build/utils/message_viewability.d.ts +0 -24
- package/build/utils/message_viewability.d.ts.map +0 -1
- package/build/utils/message_viewability.js +0 -29
- package/build/utils/message_viewability.js.map +0 -1
- package/build/utils/unread_divider_helpers.d.ts +0 -18
- package/build/utils/unread_divider_helpers.d.ts.map +0 -1
- package/build/utils/unread_divider_helpers.js +0 -13
- package/build/utils/unread_divider_helpers.js.map +0 -1
- package/src/__tests__/hooks/use_conversation_messages.test.tsx +0 -109
- package/src/__tests__/hooks/use_mark_latest_message_read.test.tsx +0 -154
- package/src/__tests__/utils/cache/messages_cache.test.ts +0 -54
- package/src/components/conversation/unread_divider.tsx +0 -90
- package/src/hooks/use_flat_list_viewability.ts +0 -50
- package/src/hooks/use_jump_to_bottom_action.ts +0 -75
- package/src/hooks/use_jump_to_unread_anchor.ts +0 -68
- package/src/hooks/use_jump_to_unread_gates.ts +0 -10
- package/src/hooks/use_scroll_tracking.ts +0 -64
- package/src/hooks/use_track_highest_seen_message.ts +0 -43
- package/src/utils/__tests__/conversation_messages.test.ts +0 -105
- package/src/utils/__tests__/highest_seen_tracker.test.ts +0 -82
- package/src/utils/__tests__/message_viewability.test.ts +0 -168
- package/src/utils/__tests__/unread_divider_helpers.test.ts +0 -85
- package/src/utils/conversation_messages.ts +0 -37
- package/src/utils/highest_seen_tracker.ts +0 -42
- package/src/utils/message_viewability.ts +0 -49
- package/src/utils/unread_divider_helpers.ts +0 -25
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
export function dividerExitedTowardNewer({ changed, viewableItems, dividerKey, initialMessageId, }) {
|
|
2
|
-
const dividerExited = changed.some(c => c.key === dividerKey && !c.isViewable);
|
|
3
|
-
if (!dividerExited)
|
|
4
|
-
return false;
|
|
5
|
-
const visibleMessageIds = viewableItems
|
|
6
|
-
.filter(v => v.item?.type === 'Message')
|
|
7
|
-
.map(v => v.item?.id)
|
|
8
|
-
.filter((id) => !!id);
|
|
9
|
-
if (visibleMessageIds.length === 0)
|
|
10
|
-
return false;
|
|
11
|
-
return visibleMessageIds.every(id => id.localeCompare(initialMessageId) > 0);
|
|
12
|
-
}
|
|
13
|
-
//# sourceMappingURL=unread_divider_helpers.js.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"unread_divider_helpers.js","sourceRoot":"","sources":["../../src/utils/unread_divider_helpers.ts"],"names":[],"mappings":"AAGA,MAAM,UAAU,wBAAwB,CAAC,EACvC,OAAO,EACP,aAAa,EACb,UAAU,EACV,gBAAgB,GAMjB;IACC,MAAM,aAAa,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,GAAG,KAAK,UAAU,IAAI,CAAC,CAAC,CAAC,UAAU,CAAC,CAAA;IAC9E,IAAI,CAAC,aAAa;QAAE,OAAO,KAAK,CAAA;IAEhC,MAAM,iBAAiB,GAAG,aAAa;SACpC,MAAM,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,IAAI,EAAE,IAAI,KAAK,SAAS,CAAC;SACvC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC;SACpB,MAAM,CAAC,CAAC,EAAE,EAAgB,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,CAAA;IACrC,IAAI,iBAAiB,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,KAAK,CAAA;IAEhD,OAAO,iBAAiB,CAAC,KAAK,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,aAAa,CAAC,gBAAgB,CAAC,GAAG,CAAC,CAAC,CAAA;AAC9E,CAAC","sourcesContent":["type ViewableChangeEntry = { key: string; isViewable: boolean }\ntype ViewableItem = { item: { id?: string; type?: string } }\n\nexport function dividerExitedTowardNewer({\n changed,\n viewableItems,\n dividerKey,\n initialMessageId,\n}: {\n changed: ViewableChangeEntry[]\n viewableItems: ViewableItem[]\n dividerKey: string\n initialMessageId: string\n}): boolean {\n const dividerExited = changed.some(c => c.key === dividerKey && !c.isViewable)\n if (!dividerExited) return false\n\n const visibleMessageIds = viewableItems\n .filter(v => v.item?.type === 'Message')\n .map(v => v.item?.id)\n .filter((id): id is string => !!id)\n if (visibleMessageIds.length === 0) return false\n\n return visibleMessageIds.every(id => id.localeCompare(initialMessageId) > 0)\n}\n"]}
|
|
@@ -1,109 +0,0 @@
|
|
|
1
|
-
import { QueryClientProvider } from '@tanstack/react-query'
|
|
2
|
-
import { act, renderHook } from '@testing-library/react-native'
|
|
3
|
-
import React, { Suspense } from 'react'
|
|
4
|
-
import { buildTestQueryClient } from '../../__utils__/query_client'
|
|
5
|
-
import { ConversationContextProvider } from '../../contexts/conversation_context'
|
|
6
|
-
import * as useApiClientModule from '../../hooks/use_api_client'
|
|
7
|
-
import { useConversationMessages } from '../../hooks/use_conversation_messages'
|
|
8
|
-
import { ApiCollection, MessageResource } from '../../types'
|
|
9
|
-
|
|
10
|
-
const mockMessage = (id: string): MessageResource =>
|
|
11
|
-
({
|
|
12
|
-
id,
|
|
13
|
-
type: 'Message',
|
|
14
|
-
text: `msg ${id}`,
|
|
15
|
-
attachments: [],
|
|
16
|
-
deletedAt: null,
|
|
17
|
-
replyRootId: null,
|
|
18
|
-
}) as MessageResource
|
|
19
|
-
|
|
20
|
-
const apiResponse = (data: MessageResource[]): ApiCollection<MessageResource> => ({
|
|
21
|
-
data,
|
|
22
|
-
links: {},
|
|
23
|
-
meta: { count: data.length, totalCount: data.length, next: {} },
|
|
24
|
-
})
|
|
25
|
-
|
|
26
|
-
const createWrapper = (initialMessageId: string | null) => {
|
|
27
|
-
const queryClient = buildTestQueryClient()
|
|
28
|
-
|
|
29
|
-
return ({ children }: { children: React.ReactNode }) => (
|
|
30
|
-
<QueryClientProvider client={queryClient}>
|
|
31
|
-
<Suspense fallback={null}>
|
|
32
|
-
<ConversationContextProvider
|
|
33
|
-
conversationId={123}
|
|
34
|
-
currentPageReplyRootId={null}
|
|
35
|
-
initialMessageId={initialMessageId}
|
|
36
|
-
initialMessageIdIsAnchor={!!initialMessageId}
|
|
37
|
-
>
|
|
38
|
-
{children}
|
|
39
|
-
</ConversationContextProvider>
|
|
40
|
-
</Suspense>
|
|
41
|
-
</QueryClientProvider>
|
|
42
|
-
)
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
const flushPromises = async () => {
|
|
46
|
-
await act(async () => {
|
|
47
|
-
await Promise.resolve()
|
|
48
|
-
await Promise.resolve()
|
|
49
|
-
await Promise.resolve()
|
|
50
|
-
await Promise.resolve()
|
|
51
|
-
})
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
const mockApiClient = (get: jest.Mock) => {
|
|
55
|
-
jest.spyOn(useApiClientModule, 'useApiClient').mockReturnValue({
|
|
56
|
-
chat: { get },
|
|
57
|
-
} as unknown as ReturnType<typeof useApiClientModule.useApiClient>)
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
describe('useConversationMessages', () => {
|
|
61
|
-
afterEach(() => {
|
|
62
|
-
jest.restoreAllMocks()
|
|
63
|
-
})
|
|
64
|
-
|
|
65
|
-
it('fires two parallel seed requests with id_gte/asc and id_lt/desc when anchored', async () => {
|
|
66
|
-
const get = jest.fn(({ data }: { data: { where?: Record<string, string> } }) => {
|
|
67
|
-
if (data.where?.id_gte === '01B') {
|
|
68
|
-
return Promise.resolve(apiResponse([mockMessage('01C'), mockMessage('01B')]))
|
|
69
|
-
}
|
|
70
|
-
if (data.where?.id_lt === '01B') {
|
|
71
|
-
return Promise.resolve(apiResponse([mockMessage('01A')]))
|
|
72
|
-
}
|
|
73
|
-
return Promise.resolve(apiResponse([]))
|
|
74
|
-
})
|
|
75
|
-
mockApiClient(get)
|
|
76
|
-
|
|
77
|
-
renderHook(() => useConversationMessages({ conversation_id: 123 }), {
|
|
78
|
-
wrapper: createWrapper('01B'),
|
|
79
|
-
})
|
|
80
|
-
await flushPromises()
|
|
81
|
-
|
|
82
|
-
expect(get).toHaveBeenCalledTimes(2)
|
|
83
|
-
const requested = get.mock.calls.map(
|
|
84
|
-
([req]: [{ data: { where?: Record<string, string>; order?: string } }]) => ({
|
|
85
|
-
where: req.data.where,
|
|
86
|
-
order: req.data.order,
|
|
87
|
-
})
|
|
88
|
-
)
|
|
89
|
-
expect(requested).toEqual(
|
|
90
|
-
expect.arrayContaining([
|
|
91
|
-
{ where: { id_gte: '01B' }, order: 'asc' },
|
|
92
|
-
{ where: { id_lt: '01B' }, order: 'desc' },
|
|
93
|
-
])
|
|
94
|
-
)
|
|
95
|
-
})
|
|
96
|
-
|
|
97
|
-
it('fires one fetch with no cursor when not anchored', async () => {
|
|
98
|
-
const get = jest.fn(() => Promise.resolve(apiResponse([mockMessage('01A')])))
|
|
99
|
-
mockApiClient(get)
|
|
100
|
-
|
|
101
|
-
renderHook(() => useConversationMessages({ conversation_id: 123 }), {
|
|
102
|
-
wrapper: createWrapper(null),
|
|
103
|
-
})
|
|
104
|
-
await flushPromises()
|
|
105
|
-
|
|
106
|
-
expect(get).toHaveBeenCalledTimes(1)
|
|
107
|
-
expect(get.mock.calls[0][0].data.where).toBeUndefined()
|
|
108
|
-
})
|
|
109
|
-
})
|
|
@@ -1,154 +0,0 @@
|
|
|
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
|
-
})
|
|
@@ -1,54 +0,0 @@
|
|
|
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,90 +0,0 @@
|
|
|
1
|
-
import { StyleSheet, View } from 'react-native'
|
|
2
|
-
import Animated, { FadeIn, FadeOut } from 'react-native-reanimated'
|
|
3
|
-
import Svg, { Defs, Path, Pattern, Rect } from 'react-native-svg'
|
|
4
|
-
import { useConversationContext } from '../../contexts/conversation_context'
|
|
5
|
-
import { useTheme } from '../../hooks'
|
|
6
|
-
import { CONVERSATION_MESSAGE_LIST_PADDING_HORIZONTAL } from '../../utils/styles'
|
|
7
|
-
import { Text } from '../display'
|
|
8
|
-
|
|
9
|
-
interface UnreadDividerProps {
|
|
10
|
-
scrolledPast?: boolean
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
const WAVE_WIDTH = 16
|
|
14
|
-
const WAVE_HEIGHT = 8
|
|
15
|
-
const FADE_DURATION = 750
|
|
16
|
-
|
|
17
|
-
export function UnreadDivider({ scrolledPast = false }: UnreadDividerProps) {
|
|
18
|
-
const styles = useStyles()
|
|
19
|
-
const { atEndOfMessageHistory } = useConversationContext()
|
|
20
|
-
|
|
21
|
-
if (scrolledPast || atEndOfMessageHistory) return null
|
|
22
|
-
|
|
23
|
-
return (
|
|
24
|
-
<Animated.View
|
|
25
|
-
entering={FadeIn.duration(FADE_DURATION)}
|
|
26
|
-
exiting={FadeOut.duration(FADE_DURATION)}
|
|
27
|
-
style={styles.container}
|
|
28
|
-
accessibilityRole="header"
|
|
29
|
-
accessibilityLabel="Unread messages start here"
|
|
30
|
-
>
|
|
31
|
-
<SquigglyLine />
|
|
32
|
-
<Text variant="footnote" style={styles.label}>
|
|
33
|
-
New
|
|
34
|
-
</Text>
|
|
35
|
-
<SquigglyLine />
|
|
36
|
-
</Animated.View>
|
|
37
|
-
)
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
function SquigglyLine() {
|
|
41
|
-
const { colors } = useTheme()
|
|
42
|
-
return (
|
|
43
|
-
<View style={squigglyStyle.container}>
|
|
44
|
-
<Svg width="100%" height={WAVE_HEIGHT}>
|
|
45
|
-
<Defs>
|
|
46
|
-
<Pattern
|
|
47
|
-
id="wave"
|
|
48
|
-
x="0"
|
|
49
|
-
y="0"
|
|
50
|
-
width={WAVE_WIDTH}
|
|
51
|
-
height={WAVE_HEIGHT}
|
|
52
|
-
patternUnits="userSpaceOnUse"
|
|
53
|
-
>
|
|
54
|
-
<Path
|
|
55
|
-
d="M 0 4 Q 4 0 8 4 T 16 4"
|
|
56
|
-
stroke={colors.interaction}
|
|
57
|
-
strokeWidth={1.5}
|
|
58
|
-
fill="none"
|
|
59
|
-
/>
|
|
60
|
-
</Pattern>
|
|
61
|
-
</Defs>
|
|
62
|
-
<Rect x="0" y="0" width="100%" height={WAVE_HEIGHT} fill="url(#wave)" />
|
|
63
|
-
</Svg>
|
|
64
|
-
</View>
|
|
65
|
-
)
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
const squigglyStyle = StyleSheet.create({
|
|
69
|
-
container: {
|
|
70
|
-
flex: 1,
|
|
71
|
-
height: WAVE_HEIGHT,
|
|
72
|
-
},
|
|
73
|
-
})
|
|
74
|
-
|
|
75
|
-
const useStyles = () => {
|
|
76
|
-
const { colors } = useTheme()
|
|
77
|
-
return StyleSheet.create({
|
|
78
|
-
container: {
|
|
79
|
-
alignItems: 'center',
|
|
80
|
-
flexDirection: 'row',
|
|
81
|
-
paddingHorizontal: CONVERSATION_MESSAGE_LIST_PADDING_HORIZONTAL,
|
|
82
|
-
paddingVertical: 8,
|
|
83
|
-
gap: 8,
|
|
84
|
-
},
|
|
85
|
-
label: {
|
|
86
|
-
color: colors.interaction,
|
|
87
|
-
fontWeight: '600',
|
|
88
|
-
},
|
|
89
|
-
})
|
|
90
|
-
}
|
|
@@ -1,50 +0,0 @@
|
|
|
1
|
-
import { useCallback, useEffect, useRef } from 'react'
|
|
2
|
-
import type { ViewToken } from 'react-native'
|
|
3
|
-
import type { ViewabilityObserver } from '../utils/message_viewability'
|
|
4
|
-
|
|
5
|
-
interface UseFlatListViewabilityArgs<Item> {
|
|
6
|
-
observers: ViewabilityObserver<Item>[]
|
|
7
|
-
itemVisiblePercentThreshold?: number
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
export function useFlatListViewability<Item>({
|
|
11
|
-
observers,
|
|
12
|
-
itemVisiblePercentThreshold = 50,
|
|
13
|
-
}: UseFlatListViewabilityArgs<Item>) {
|
|
14
|
-
const userHasScrolledRef = useRef(false)
|
|
15
|
-
const observersRef = useRef(observers)
|
|
16
|
-
|
|
17
|
-
useEffect(() => {
|
|
18
|
-
observersRef.current = observers
|
|
19
|
-
})
|
|
20
|
-
|
|
21
|
-
const onScrollBeginDrag = useCallback(() => {
|
|
22
|
-
userHasScrolledRef.current = true
|
|
23
|
-
}, [])
|
|
24
|
-
|
|
25
|
-
const viewabilityConfigCallbackPairs = useRef([
|
|
26
|
-
{
|
|
27
|
-
viewabilityConfig: { itemVisiblePercentThreshold },
|
|
28
|
-
onViewableItemsChanged: ({
|
|
29
|
-
viewableItems,
|
|
30
|
-
changed,
|
|
31
|
-
}: {
|
|
32
|
-
viewableItems: ViewToken[]
|
|
33
|
-
changed: ViewToken[]
|
|
34
|
-
}) => {
|
|
35
|
-
const event = {
|
|
36
|
-
viewableItems: viewableItems.map(toEntry<Item>),
|
|
37
|
-
changed: changed.map(toEntry<Item>),
|
|
38
|
-
userHasScrolled: userHasScrolledRef.current,
|
|
39
|
-
}
|
|
40
|
-
for (const observer of observersRef.current) observer(event)
|
|
41
|
-
},
|
|
42
|
-
},
|
|
43
|
-
]).current
|
|
44
|
-
|
|
45
|
-
return { viewabilityConfigCallbackPairs, onScrollBeginDrag }
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
function toEntry<Item>(token: ViewToken) {
|
|
49
|
-
return { key: token.key, isViewable: !!token.isViewable, item: token.item as Item }
|
|
50
|
-
}
|
|
@@ -1,75 +0,0 @@
|
|
|
1
|
-
import { useQueryClient } from '@tanstack/react-query'
|
|
2
|
-
import { RefObject, useCallback, useEffect, useRef, useState } from 'react'
|
|
3
|
-
import type { FlatList } from 'react-native'
|
|
4
|
-
import { useConversationContext } from '../contexts/conversation_context'
|
|
5
|
-
import { ApiCollection, MessageResource } from '../types'
|
|
6
|
-
import { Haptic } from '../utils/native_adapters'
|
|
7
|
-
import { getMessagesQueryKey, getMessagesRequestArgs } from '../utils/request/get_messages'
|
|
8
|
-
import { useApiClient } from './use_api_client'
|
|
9
|
-
import { useJumpToUnreadGates } from './use_jump_to_unread_gates'
|
|
10
|
-
|
|
11
|
-
const LATEST_PAGE_SIZE = 25
|
|
12
|
-
|
|
13
|
-
export function useJumpToBottomAction({ listRef }: { listRef: RefObject<FlatList | null> }) {
|
|
14
|
-
const { jumpToUnreadEnabled } = useJumpToUnreadGates()
|
|
15
|
-
const { conversationId, currentPageReplyRootId, initialMessageId, setInitialMessageId } =
|
|
16
|
-
useConversationContext()
|
|
17
|
-
const queryClient = useQueryClient()
|
|
18
|
-
const apiClient = useApiClient()
|
|
19
|
-
const [isJumpingToBottom, setIsJumpingToBottom] = useState(false)
|
|
20
|
-
|
|
21
|
-
const mountedRef = useRef(true)
|
|
22
|
-
useEffect(
|
|
23
|
-
() => () => {
|
|
24
|
-
mountedRef.current = false
|
|
25
|
-
},
|
|
26
|
-
[]
|
|
27
|
-
)
|
|
28
|
-
|
|
29
|
-
const handleJumpToBottom = useCallback(() => {
|
|
30
|
-
Haptic.impactLight()
|
|
31
|
-
listRef.current?.scrollToOffset({ offset: 0, animated: true })
|
|
32
|
-
|
|
33
|
-
if (!jumpToUnreadEnabled || !initialMessageId) return
|
|
34
|
-
|
|
35
|
-
const queryKey = getMessagesQueryKey({
|
|
36
|
-
conversation_id: conversationId,
|
|
37
|
-
reply_root_id: currentPageReplyRootId,
|
|
38
|
-
})
|
|
39
|
-
const args = getMessagesRequestArgs({
|
|
40
|
-
conversation_id: conversationId,
|
|
41
|
-
reply_root_id: currentPageReplyRootId,
|
|
42
|
-
})
|
|
43
|
-
|
|
44
|
-
setIsJumpingToBottom(true)
|
|
45
|
-
|
|
46
|
-
queryClient
|
|
47
|
-
.cancelQueries({ queryKey })
|
|
48
|
-
.then(() =>
|
|
49
|
-
apiClient.chat.get<ApiCollection<MessageResource>>({
|
|
50
|
-
url: args.url,
|
|
51
|
-
data: { ...args.data, perPage: LATEST_PAGE_SIZE },
|
|
52
|
-
})
|
|
53
|
-
)
|
|
54
|
-
.then(latest => {
|
|
55
|
-
if (!mountedRef.current) return
|
|
56
|
-
queryClient.setQueryData(queryKey, { pages: [latest], pageParams: [{}] })
|
|
57
|
-
setInitialMessageId(null)
|
|
58
|
-
listRef.current?.scrollToOffset({ offset: 0, animated: false })
|
|
59
|
-
})
|
|
60
|
-
.finally(() => {
|
|
61
|
-
if (mountedRef.current) setIsJumpingToBottom(false)
|
|
62
|
-
})
|
|
63
|
-
}, [
|
|
64
|
-
jumpToUnreadEnabled,
|
|
65
|
-
initialMessageId,
|
|
66
|
-
setInitialMessageId,
|
|
67
|
-
queryClient,
|
|
68
|
-
apiClient,
|
|
69
|
-
conversationId,
|
|
70
|
-
currentPageReplyRootId,
|
|
71
|
-
listRef,
|
|
72
|
-
])
|
|
73
|
-
|
|
74
|
-
return { handleJumpToBottom, isJumpingToBottom }
|
|
75
|
-
}
|
|
@@ -1,68 +0,0 @@
|
|
|
1
|
-
import { RefObject, useCallback, useEffect, useRef } from 'react'
|
|
2
|
-
import type { FlatList } from 'react-native'
|
|
3
|
-
import { useConversationContext } from '../contexts/conversation_context'
|
|
4
|
-
import { useJumpToUnreadGates } from './use_jump_to_unread_gates'
|
|
5
|
-
|
|
6
|
-
interface UseJumpToUnreadAnchorArgs<T> {
|
|
7
|
-
listRef: RefObject<FlatList<T> | null>
|
|
8
|
-
items: T[]
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
interface ScrollToIndexFailInfo {
|
|
12
|
-
index: number
|
|
13
|
-
highestMeasuredFrameIndex: number
|
|
14
|
-
averageItemLength: number
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
export function useJumpToUnreadAnchor<T extends { id?: string | number }>({
|
|
18
|
-
listRef,
|
|
19
|
-
items,
|
|
20
|
-
}: UseJumpToUnreadAnchorArgs<T>) {
|
|
21
|
-
const { jumpToUnreadActive } = useJumpToUnreadGates()
|
|
22
|
-
const { initialMessageId } = useConversationContext()
|
|
23
|
-
const hasAnchoredRef = useRef(false)
|
|
24
|
-
const userTouchedRef = useRef(false)
|
|
25
|
-
const retryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
26
|
-
|
|
27
|
-
useEffect(() => {
|
|
28
|
-
return () => {
|
|
29
|
-
if (retryTimerRef.current) clearTimeout(retryTimerRef.current)
|
|
30
|
-
retryTimerRef.current = null
|
|
31
|
-
}
|
|
32
|
-
}, [])
|
|
33
|
-
|
|
34
|
-
const onScrollBeginDrag = useCallback(() => {
|
|
35
|
-
userTouchedRef.current = true
|
|
36
|
-
if (retryTimerRef.current) {
|
|
37
|
-
clearTimeout(retryTimerRef.current)
|
|
38
|
-
retryTimerRef.current = null
|
|
39
|
-
}
|
|
40
|
-
}, [])
|
|
41
|
-
|
|
42
|
-
const onContentSizeChange = useCallback(() => {
|
|
43
|
-
if (hasAnchoredRef.current) return
|
|
44
|
-
if (!jumpToUnreadActive || !initialMessageId) return
|
|
45
|
-
if (userTouchedRef.current) return
|
|
46
|
-
const index = items.findIndex(item => String(item.id ?? '') === initialMessageId)
|
|
47
|
-
if (index < 0) return
|
|
48
|
-
hasAnchoredRef.current = true
|
|
49
|
-
listRef.current?.scrollToIndex({ index, viewPosition: 0.25, animated: false })
|
|
50
|
-
}, [jumpToUnreadActive, initialMessageId, items, listRef])
|
|
51
|
-
|
|
52
|
-
const onScrollToIndexFailed = useCallback(
|
|
53
|
-
(info: ScrollToIndexFailInfo) => {
|
|
54
|
-
if (userTouchedRef.current) return
|
|
55
|
-
const offset = info.averageItemLength * info.index
|
|
56
|
-
listRef.current?.scrollToOffset({ offset, animated: false })
|
|
57
|
-
if (retryTimerRef.current) clearTimeout(retryTimerRef.current)
|
|
58
|
-
retryTimerRef.current = setTimeout(() => {
|
|
59
|
-
retryTimerRef.current = null
|
|
60
|
-
if (userTouchedRef.current) return
|
|
61
|
-
listRef.current?.scrollToIndex({ index: info.index, viewPosition: 0.25, animated: false })
|
|
62
|
-
}, 50)
|
|
63
|
-
},
|
|
64
|
-
[listRef]
|
|
65
|
-
)
|
|
66
|
-
|
|
67
|
-
return { onScrollBeginDrag, onContentSizeChange, onScrollToIndexFailed }
|
|
68
|
-
}
|
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
import { useConversationContext } from '../contexts/conversation_context'
|
|
2
|
-
import { availableFeatures, useFeatures } from './use_features'
|
|
3
|
-
|
|
4
|
-
export function useJumpToUnreadGates() {
|
|
5
|
-
const { featureEnabled } = useFeatures()
|
|
6
|
-
const { initialMessageIdIsAnchor } = useConversationContext()
|
|
7
|
-
const jumpToUnreadEnabled = featureEnabled(availableFeatures.jump_to_unread)
|
|
8
|
-
const jumpToUnreadActive = jumpToUnreadEnabled && initialMessageIdIsAnchor
|
|
9
|
-
return { jumpToUnreadEnabled, jumpToUnreadActive }
|
|
10
|
-
}
|