@planningcenter/chat-react-native 4.2.0-rc.5 → 4.2.1-qa-staging.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/build/components/conversation/message_form.d.ts +2 -1
- package/build/components/conversation/message_form.d.ts.map +1 -1
- package/build/components/conversation/message_form.js +54 -51
- package/build/components/conversation/message_form.js.map +1 -1
- package/build/components/conversation/message_list.d.ts +2 -2
- package/build/components/conversation/message_list.d.ts.map +1 -1
- package/build/components/conversation/message_list.js +19 -16
- package/build/components/conversation/message_list.js.map +1 -1
- package/build/hooks/index.d.ts +1 -0
- package/build/hooks/index.d.ts.map +1 -1
- package/build/hooks/index.js +1 -0
- package/build/hooks/index.js.map +1 -1
- package/build/hooks/paginator_meta.d.ts +3 -1
- package/build/hooks/paginator_meta.d.ts.map +1 -1
- package/build/hooks/paginator_meta.js +25 -8
- package/build/hooks/paginator_meta.js.map +1 -1
- package/build/hooks/use_api.d.ts.map +1 -1
- package/build/hooks/use_api.js +5 -8
- package/build/hooks/use_api.js.map +1 -1
- package/build/hooks/use_conversation_messages_jolt_events.d.ts +9 -1
- package/build/hooks/use_conversation_messages_jolt_events.d.ts.map +1 -1
- package/build/hooks/use_conversation_messages_jolt_events.js +88 -73
- package/build/hooks/use_conversation_messages_jolt_events.js.map +1 -1
- package/build/hooks/use_direct_message_eligibility.d.ts +14 -0
- package/build/hooks/use_direct_message_eligibility.d.ts.map +1 -0
- package/build/hooks/use_direct_message_eligibility.js +87 -0
- package/build/hooks/use_direct_message_eligibility.js.map +1 -0
- package/build/hooks/use_fetch_newer_messages.d.ts +8 -0
- package/build/hooks/use_fetch_newer_messages.d.ts.map +1 -0
- package/build/hooks/use_fetch_newer_messages.js +56 -0
- package/build/hooks/use_fetch_newer_messages.js.map +1 -0
- package/build/hooks/use_suspense_api.d.ts.map +1 -1
- package/build/hooks/use_suspense_api.js +5 -8
- package/build/hooks/use_suspense_api.js.map +1 -1
- package/build/screens/conversation_new/components/services_form.js +9 -8
- package/build/screens/conversation_new/components/services_form.js.map +1 -1
- package/build/screens/conversation_screen.d.ts.map +1 -1
- package/build/screens/conversation_screen.js +137 -322
- package/build/screens/conversation_screen.js.map +1 -1
- package/build/types/api_primitives.d.ts +2 -0
- package/build/types/api_primitives.d.ts.map +1 -1
- package/build/types/api_primitives.js.map +1 -1
- package/build/types/resources/member.d.ts +1 -0
- package/build/types/resources/member.d.ts.map +1 -1
- package/build/types/resources/member.js.map +1 -1
- package/build/utils/cache/prepend_newer_page.d.ts +7 -0
- package/build/utils/cache/prepend_newer_page.d.ts.map +1 -0
- package/build/utils/cache/prepend_newer_page.js +14 -0
- package/build/utils/cache/prepend_newer_page.js.map +1 -0
- package/build/utils/client/types.d.ts +1 -0
- package/build/utils/client/types.d.ts.map +1 -1
- package/build/utils/client/types.js.map +1 -1
- package/build/utils/gated_message_cache.d.ts +7 -0
- package/build/utils/gated_message_cache.d.ts.map +1 -0
- package/build/utils/gated_message_cache.js +13 -0
- package/build/utils/gated_message_cache.js.map +1 -0
- package/build/utils/group_messages.js +4 -1
- package/build/utils/group_messages.js.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/hooks/paginator_meta.test.ts +73 -2
- package/src/__tests__/hooks/use_conversation_messages_jolt_events.test.ts +36 -0
- package/src/__tests__/hooks/use_direct_message_eligibility.test.tsx +83 -0
- package/src/__tests__/screens/conversation_new/components/services_form.test.tsx +91 -0
- package/src/__tests__/utils/gated_message_cache.test.ts +26 -0
- package/src/components/conversation/message_form.tsx +4 -0
- package/src/components/conversation/message_list.tsx +8 -1
- package/src/hooks/index.ts +1 -0
- package/src/hooks/paginator_meta.ts +29 -5
- package/src/hooks/use_api.ts +4 -5
- package/src/hooks/use_conversation_messages_jolt_events.ts +57 -40
- package/src/hooks/use_direct_message_eligibility.ts +40 -0
- package/src/hooks/use_fetch_newer_messages.ts +37 -0
- package/src/hooks/use_suspense_api.ts +4 -5
- package/src/screens/conversation_new/components/services_form.tsx +5 -5
- package/src/screens/conversation_screen.tsx +36 -4
- package/src/types/api_primitives.ts +2 -0
- package/src/types/resources/member.ts +1 -0
- package/src/utils/__tests__/group_messages.test.ts +29 -0
- package/src/utils/cache/__tests__/prepend_newer_page.test.ts +35 -0
- package/src/utils/cache/prepend_newer_page.ts +22 -0
- package/src/utils/client/types.ts +1 -0
- package/src/utils/gated_message_cache.ts +19 -0
- package/src/utils/group_messages.ts +4 -1
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { QueryClientProvider } from '@tanstack/react-query'
|
|
2
|
+
import { renderHook, waitFor } from '@testing-library/react-native'
|
|
3
|
+
import React from 'react'
|
|
4
|
+
import { buildTestQueryClient } from '../../__utils__/query_client'
|
|
5
|
+
import { useApiClient } from '../../hooks/use_api_client'
|
|
6
|
+
import { useDirectMessageEligibility } from '../../hooks/use_direct_message_eligibility'
|
|
7
|
+
|
|
8
|
+
jest.mock('../../hooks/use_api_client')
|
|
9
|
+
|
|
10
|
+
const mockedUseApiClient = useApiClient as jest.MockedFunction<typeof useApiClient>
|
|
11
|
+
const mockGet = jest.fn()
|
|
12
|
+
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
mockGet.mockReset()
|
|
15
|
+
mockedUseApiClient.mockReturnValue({ chat: { get: mockGet } } as any)
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
const createWrapper = () => {
|
|
19
|
+
const queryClient = buildTestQueryClient()
|
|
20
|
+
return ({ children }: { children: React.ReactNode }) => (
|
|
21
|
+
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
|
22
|
+
)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const args = { groupId: 'Groups-Group-42', personId: 75179873 }
|
|
26
|
+
|
|
27
|
+
test("reads the member's eligibility from the chat group", async () => {
|
|
28
|
+
mockGet.mockResolvedValue({
|
|
29
|
+
data: {
|
|
30
|
+
id: '75179873',
|
|
31
|
+
type: 'Member',
|
|
32
|
+
canDirectMessage: false,
|
|
33
|
+
directMessagesDisabled: true,
|
|
34
|
+
},
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
const { result } = renderHook(() => useDirectMessageEligibility(args), {
|
|
38
|
+
wrapper: createWrapper(),
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
await waitFor(() => expect(result.current.eligibility).toBeDefined())
|
|
42
|
+
|
|
43
|
+
expect(mockGet).toHaveBeenCalledWith({
|
|
44
|
+
url: '/me/groups/Groups-Group-42/members/75179873',
|
|
45
|
+
data: { fields: { Member: ['can_direct_message', 'direct_messages_disabled'] } },
|
|
46
|
+
})
|
|
47
|
+
expect(result.current.eligibility).toEqual({
|
|
48
|
+
canDirectMessage: false,
|
|
49
|
+
directMessagesDisabled: true,
|
|
50
|
+
})
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test('has no answer until the member loads', () => {
|
|
54
|
+
mockGet.mockReturnValue(new Promise(() => {}))
|
|
55
|
+
|
|
56
|
+
const { result } = renderHook(() => useDirectMessageEligibility(args), {
|
|
57
|
+
wrapper: createWrapper(),
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
expect(result.current.eligibility).toBeUndefined()
|
|
61
|
+
expect(result.current.isLoading).toBe(true)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
test('reports a failed lookup without an answer', async () => {
|
|
65
|
+
mockGet.mockRejectedValue(new Error('404'))
|
|
66
|
+
|
|
67
|
+
const { result } = renderHook(() => useDirectMessageEligibility(args), {
|
|
68
|
+
wrapper: createWrapper(),
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
await waitFor(() => expect(result.current.isError).toBe(true))
|
|
72
|
+
|
|
73
|
+
expect(result.current.eligibility).toBeUndefined()
|
|
74
|
+
expect(result.current.isLoading).toBe(false)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
test('does not ask when disabled', () => {
|
|
78
|
+
renderHook(() => useDirectMessageEligibility({ ...args, enabled: false }), {
|
|
79
|
+
wrapper: createWrapper(),
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
expect(mockGet).not.toHaveBeenCalled()
|
|
83
|
+
})
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { DefaultTheme, ThemeProvider } from '@react-navigation/native'
|
|
2
|
+
import { render } from '@testing-library/react-native'
|
|
3
|
+
import React from 'react'
|
|
4
|
+
import { SafeAreaProvider } from 'react-native-safe-area-context'
|
|
5
|
+
import { useFindOrCreateServicesConversation } from '../../../../hooks/services/use_find_or_create_services_conversation'
|
|
6
|
+
import { useTeamMembersForNewConversation } from '../../../../hooks/services/use_team_members_for_new_conversation'
|
|
7
|
+
import { ServicesForm } from '../../../../screens/conversation_new/components/services_form'
|
|
8
|
+
|
|
9
|
+
jest.mock('react-native-reanimated', () => ({
|
|
10
|
+
...jest.requireActual('react-native-reanimated/mock'),
|
|
11
|
+
LayoutAnimationConfig: ({ children }: { children: React.ReactNode }) => children,
|
|
12
|
+
}))
|
|
13
|
+
jest.mock('@react-navigation/native', () => ({
|
|
14
|
+
...jest.requireActual('@react-navigation/native'),
|
|
15
|
+
useNavigation: () => ({ navigate: jest.fn(), dispatch: jest.fn(), getParent: () => undefined }),
|
|
16
|
+
}))
|
|
17
|
+
jest.mock('../../../../hooks/use_api_client', () => ({ useApiClient: () => ({}) }))
|
|
18
|
+
jest.mock('../../../../hooks/use_upload_client', () => ({ useUploadClient: () => ({}) }))
|
|
19
|
+
jest.mock('../../../../hooks/use_features', () => ({
|
|
20
|
+
...jest.requireActual('../../../../hooks/use_features'),
|
|
21
|
+
useFeatures: () => ({ features: [], featureEnabled: () => false }),
|
|
22
|
+
}))
|
|
23
|
+
jest.mock('../../../../hooks/use_conversation_validate', () => ({
|
|
24
|
+
useConversationValidate: () => ({ warnings: [], validationPending: false }),
|
|
25
|
+
}))
|
|
26
|
+
jest.mock('../../../../hooks/services/use_services_chat_conversation_payload', () => ({
|
|
27
|
+
useServicesChatConversationPayload: () => ({ payload: undefined, isLoading: false }),
|
|
28
|
+
}))
|
|
29
|
+
jest.mock('../../../../hooks/services/use_services_team', () => ({
|
|
30
|
+
useServicesTeams: () => [],
|
|
31
|
+
}))
|
|
32
|
+
jest.mock('../../../../hooks/services/use_team_plans', () => ({
|
|
33
|
+
useTeamPlans: () => ({
|
|
34
|
+
planOptions: [],
|
|
35
|
+
isFetching: false,
|
|
36
|
+
isError: false,
|
|
37
|
+
findPlan: () => undefined,
|
|
38
|
+
}),
|
|
39
|
+
}))
|
|
40
|
+
jest.mock('../../../../hooks/services/use_team_members_for_new_conversation', () => ({
|
|
41
|
+
useTeamMembersForNewConversation: jest.fn(() => ({ members: [], isError: false })),
|
|
42
|
+
}))
|
|
43
|
+
jest.mock('../../../../hooks/services/use_find_or_create_services_conversation', () => ({
|
|
44
|
+
useFindOrCreateServicesConversation: jest.fn(() => ({
|
|
45
|
+
mutate: jest.fn(),
|
|
46
|
+
isPending: false,
|
|
47
|
+
selectionHasConversation: false,
|
|
48
|
+
isLoadingConversationCheck: false,
|
|
49
|
+
})),
|
|
50
|
+
}))
|
|
51
|
+
|
|
52
|
+
const mockedFindOrCreate = jest.mocked(useFindOrCreateServicesConversation)
|
|
53
|
+
const mockedTeamMembers = jest.mocked(useTeamMembersForNewConversation)
|
|
54
|
+
|
|
55
|
+
const latestArgs = <T extends jest.Mock>(mock: T) => mock.mock.calls.at(-1)?.[0]
|
|
56
|
+
|
|
57
|
+
const safeAreaMetrics = {
|
|
58
|
+
frame: { x: 0, y: 0, width: 390, height: 844 },
|
|
59
|
+
insets: { top: 0, right: 0, bottom: 0, left: 0 },
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const renderForm = (props: { initialPlanId?: number } = {}) =>
|
|
63
|
+
render(
|
|
64
|
+
<SafeAreaProvider initialMetrics={safeAreaMetrics}>
|
|
65
|
+
<ThemeProvider value={DefaultTheme}>
|
|
66
|
+
<ServicesForm initialTeamIds={[1, 2]} {...props} />
|
|
67
|
+
</ThemeProvider>
|
|
68
|
+
</SafeAreaProvider>
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
describe('ServicesForm', () => {
|
|
72
|
+
beforeEach(() => {
|
|
73
|
+
jest.clearAllMocks()
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('filters by plan when opened from a plan', () => {
|
|
77
|
+
const { getByRole } = renderForm({ initialPlanId: 42 })
|
|
78
|
+
|
|
79
|
+
expect(getByRole('switch').props.value).toBe(true)
|
|
80
|
+
expect(latestArgs(mockedFindOrCreate)).toMatchObject({ teamIds: [1, 2], planId: 42 })
|
|
81
|
+
expect(latestArgs(mockedTeamMembers)).toMatchObject({ teamIds: [1, 2], planId: 42 })
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('does not filter by plan when opened without a plan', () => {
|
|
85
|
+
const { getByRole } = renderForm()
|
|
86
|
+
|
|
87
|
+
expect(getByRole('switch').props.value).toBe(false)
|
|
88
|
+
expect(latestArgs(mockedFindOrCreate)).toMatchObject({ teamIds: [1, 2], planId: undefined })
|
|
89
|
+
expect(latestArgs(mockedTeamMembers)).toMatchObject({ teamIds: [1, 2], planId: undefined })
|
|
90
|
+
})
|
|
91
|
+
})
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { gatedMessageCache } from '../../utils/gated_message_cache'
|
|
2
|
+
|
|
3
|
+
describe('gatedMessageCache', () => {
|
|
4
|
+
const queryKey = ['messages']
|
|
5
|
+
|
|
6
|
+
it('keeps new messages out while they are not accepted and reports the skip', () => {
|
|
7
|
+
const onSkippedNewMessage = jest.fn()
|
|
8
|
+
const cache = gatedMessageCache({ queryKey, acceptNewMessages: false, onSkippedNewMessage })
|
|
9
|
+
|
|
10
|
+
expect(cache.queryKey).toBe(queryKey)
|
|
11
|
+
expect(cache.canUpdate?.('message.created')).toBe(false)
|
|
12
|
+
expect(onSkippedNewMessage).toHaveBeenCalledTimes(1)
|
|
13
|
+
expect(cache.canUpdate?.('message.updated')).toBe(true)
|
|
14
|
+
expect(cache.canUpdate?.('message.destroyed')).toBe(true)
|
|
15
|
+
expect(cache.canUpdate?.('reaction')).toBe(true)
|
|
16
|
+
expect(onSkippedNewMessage).toHaveBeenCalledTimes(1)
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('lets new messages in once they are accepted', () => {
|
|
20
|
+
const onSkippedNewMessage = jest.fn()
|
|
21
|
+
const cache = gatedMessageCache({ queryKey, acceptNewMessages: true, onSkippedNewMessage })
|
|
22
|
+
|
|
23
|
+
expect(cache.canUpdate?.('message.created')).toBe(true)
|
|
24
|
+
expect(onSkippedNewMessage).not.toHaveBeenCalled()
|
|
25
|
+
})
|
|
26
|
+
})
|
|
@@ -71,6 +71,7 @@ interface MessagesFormRootProps extends ViewProps {
|
|
|
71
71
|
replyRootId?: string | null
|
|
72
72
|
replyRootAuthorFirstName?: string | null
|
|
73
73
|
onFlaggedInMessageForm?: (id: string) => void
|
|
74
|
+
onBeforeSubmit?: () => void
|
|
74
75
|
}
|
|
75
76
|
|
|
76
77
|
const MessageFormContext = React.createContext<{
|
|
@@ -113,6 +114,7 @@ function MessageFormRoot({
|
|
|
113
114
|
replyRootId,
|
|
114
115
|
replyRootAuthorFirstName,
|
|
115
116
|
onFlaggedInMessageForm,
|
|
117
|
+
onBeforeSubmit,
|
|
116
118
|
}: MessagesFormRootProps) {
|
|
117
119
|
const { giphyApiKey } = useContext(ChatContext)
|
|
118
120
|
const canGiphy = !!giphyApiKey && !currentlyEditingMessage
|
|
@@ -227,6 +229,8 @@ function MessageFormRoot({
|
|
|
227
229
|
const handleSubmit = () => {
|
|
228
230
|
if (!canSubmit) return
|
|
229
231
|
|
|
232
|
+
onBeforeSubmit?.()
|
|
233
|
+
|
|
230
234
|
if (canGiphy && usingGiphy) {
|
|
231
235
|
TextInput.State.blurTextInput(TextInput.State.currentlyFocusedInput())
|
|
232
236
|
Haptic.impactLight()
|
|
@@ -16,7 +16,12 @@ import { SystemMessage } from './system_message'
|
|
|
16
16
|
|
|
17
17
|
type ScrollHandlers = Pick<
|
|
18
18
|
FlatListProps<EnrichedMessage>,
|
|
19
|
-
|
|
19
|
+
| 'onScroll'
|
|
20
|
+
| 'onLayout'
|
|
21
|
+
| 'onContentSizeChange'
|
|
22
|
+
| 'onScrollEndDrag'
|
|
23
|
+
| 'onMomentumScrollEnd'
|
|
24
|
+
| 'maintainVisibleContentPosition'
|
|
20
25
|
>
|
|
21
26
|
|
|
22
27
|
interface MessageListProps extends ScrollHandlers {
|
|
@@ -45,6 +50,7 @@ export function MessageList({
|
|
|
45
50
|
onContentSizeChange,
|
|
46
51
|
onScrollEndDrag,
|
|
47
52
|
onMomentumScrollEnd,
|
|
53
|
+
maintainVisibleContentPosition,
|
|
48
54
|
}: MessageListProps) {
|
|
49
55
|
const styles = useStyles()
|
|
50
56
|
const inReplyScreen = !!replyRootId
|
|
@@ -102,6 +108,7 @@ export function MessageList({
|
|
|
102
108
|
)
|
|
103
109
|
}}
|
|
104
110
|
onEndReached={onEndReached}
|
|
111
|
+
maintainVisibleContentPosition={maintainVisibleContentPosition}
|
|
105
112
|
ListHeaderComponent={<View style={styles.listHeader} />}
|
|
106
113
|
/>
|
|
107
114
|
)
|
package/src/hooks/index.ts
CHANGED
|
@@ -8,6 +8,7 @@ export * from './use_chat_permissions'
|
|
|
8
8
|
export * from './use_create_android_ripple_color'
|
|
9
9
|
export * from './use_current_person'
|
|
10
10
|
export * from './use_deleting_ids'
|
|
11
|
+
export * from './use_direct_message_eligibility'
|
|
11
12
|
export * from './use_direct_messages_eligible'
|
|
12
13
|
export * from './use_font_scale'
|
|
13
14
|
export * from './use_has_direct_messages'
|
|
@@ -1,13 +1,37 @@
|
|
|
1
|
-
import { NextPageCursor } from '../types/api_primitives'
|
|
1
|
+
import { ApiCollection, NextPageCursor, ResourceObject } from '../types/api_primitives'
|
|
2
2
|
import { RequestData } from '../utils/client'
|
|
3
3
|
|
|
4
4
|
export const getNextPageParamFromMeta = (
|
|
5
5
|
next: NextPageCursor = {}
|
|
6
6
|
): Partial<RequestData> | undefined => {
|
|
7
|
-
if (next.idLt) return { where: { id_lt: next.idLt } }
|
|
8
|
-
if (next.idGt) return { where: { id_gt: next.idGt } }
|
|
9
|
-
if (next.idLte) return { where: { id_lte: next.idLte } }
|
|
10
|
-
if (next.idGte) return { where: { id_gte: next.idGte } }
|
|
7
|
+
if (next.idLt) return withOrder({ where: { id_lt: next.idLt } }, next.order)
|
|
8
|
+
if (next.idGt) return withOrder({ where: { id_gt: next.idGt } }, next.order)
|
|
9
|
+
if (next.idLte) return withOrder({ where: { id_lte: next.idLte } }, next.order)
|
|
10
|
+
if (next.idGte) return withOrder({ where: { id_gte: next.idGte } }, next.order)
|
|
11
11
|
if (next.offset) return { offset: Number(next.offset) }
|
|
12
12
|
return undefined
|
|
13
13
|
}
|
|
14
|
+
|
|
15
|
+
const withOrder = (param: Partial<RequestData>, order: NextPageCursor['order']) =>
|
|
16
|
+
order ? { ...param, order } : param
|
|
17
|
+
|
|
18
|
+
const BOUND_KEYS = ['id_lt', 'id_lte', 'id_gt', 'id_gte', 'id_around']
|
|
19
|
+
|
|
20
|
+
export const mergePageWhere = (
|
|
21
|
+
argsWhere: RequestData['where'] = {},
|
|
22
|
+
pageWhere: RequestData['where'] = {}
|
|
23
|
+
): RequestData['where'] => {
|
|
24
|
+
const pageHasCursor = BOUND_KEYS.some(key => key in pageWhere)
|
|
25
|
+
const base = pageHasCursor
|
|
26
|
+
? Object.fromEntries(Object.entries(argsWhere).filter(([key]) => !BOUND_KEYS.includes(key)))
|
|
27
|
+
: argsWhere
|
|
28
|
+
return { ...base, ...pageWhere }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const getNewerPageParam = (
|
|
32
|
+
page: ApiCollection<ResourceObject>,
|
|
33
|
+
pageParam: Partial<RequestData> | undefined
|
|
34
|
+
): Partial<RequestData> | undefined => {
|
|
35
|
+
const isAscendingPage = pageParam?.order === 'asc'
|
|
36
|
+
return getNextPageParamFromMeta(isAscendingPage ? page.meta?.next : page.meta?.prev)
|
|
37
|
+
}
|
package/src/hooks/use_api.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { ChatContext } from '../contexts/chat_context'
|
|
|
4
4
|
import { ApiCollection, ApiResource, ResourceObject } from '../types'
|
|
5
5
|
import { GetRequest, RequestData } from '../utils/client'
|
|
6
6
|
import { ResponseError, throwResponseError } from '../utils/response_error'
|
|
7
|
-
import { getNextPageParamFromMeta } from './paginator_meta'
|
|
7
|
+
import { getNextPageParamFromMeta, mergePageWhere } from './paginator_meta'
|
|
8
8
|
import { App, useApiClient } from './use_api_client'
|
|
9
9
|
import { getRequestQueryKey, RequestQueryKey } from './use_suspense_api'
|
|
10
10
|
|
|
@@ -61,12 +61,11 @@ export const useApiPaginator = <T extends ResourceObject>(
|
|
|
61
61
|
>({
|
|
62
62
|
queryKey: getRequestQueryKey(args),
|
|
63
63
|
queryFn: ({ pageParam }) => {
|
|
64
|
-
const
|
|
65
|
-
const argsWhere = args.data.where || {}
|
|
66
|
-
const where = { ...argsWhere, ...pageParmWhere }
|
|
64
|
+
const where = mergePageWhere(args.data.where, pageParam?.where)
|
|
67
65
|
const app = args.app || 'chat'
|
|
68
66
|
const offset = pageParam?.offset || args.data.offset
|
|
69
|
-
const
|
|
67
|
+
const order = pageParam?.order || args.data.order
|
|
68
|
+
const data = { ...args.data, where, offset, order }
|
|
70
69
|
|
|
71
70
|
return apiClient[app].get<ApiCollection<T>>({ url: args.url, data }).catch(throwResponseError)
|
|
72
71
|
},
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { InfiniteData, useQueryClient } from '@tanstack/react-query'
|
|
1
|
+
import { hashKey, InfiniteData, useQueryClient } from '@tanstack/react-query'
|
|
2
2
|
import { ApiCollection, MessageResource } from '../types'
|
|
3
3
|
import { JoltReactionEvent, JoltTypingEvent } from '../types/jolt_events'
|
|
4
4
|
import {
|
|
@@ -24,16 +24,49 @@ import { useJoltChannel, useJoltEvent } from './use_jolt'
|
|
|
24
24
|
import { getRequestQueryKey } from './use_suspense_api'
|
|
25
25
|
import { TYPING_TIMEOUT_INTERVAL, useTypingStatusCache } from './use_typing_status_cache'
|
|
26
26
|
|
|
27
|
+
export type MessagesJoltEvent =
|
|
28
|
+
| 'message.created'
|
|
29
|
+
| 'message.updated'
|
|
30
|
+
| 'message.destroyed'
|
|
31
|
+
| 'reaction'
|
|
32
|
+
|
|
33
|
+
export interface MessagesCache {
|
|
34
|
+
queryKey: unknown[]
|
|
35
|
+
canUpdate?: (event: MessagesJoltEvent) => boolean
|
|
36
|
+
}
|
|
37
|
+
|
|
27
38
|
interface Props {
|
|
28
39
|
conversationId: number
|
|
40
|
+
messageCaches?: MessagesCache[]
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const resolveMessageCaches = (defaults: unknown[][], overrides: MessagesCache[]) => {
|
|
44
|
+
const overrideByHash = new Map(overrides.map(cache => [hashKey(cache.queryKey), cache]))
|
|
45
|
+
const defaultHashes = new Set(defaults.map(hashKey))
|
|
46
|
+
const resolved = defaults.map(queryKey => overrideByHash.get(hashKey(queryKey)) ?? { queryKey })
|
|
47
|
+
const extras = overrides.filter(cache => !defaultHashes.has(hashKey(cache.queryKey)))
|
|
48
|
+
return [...resolved, ...extras]
|
|
29
49
|
}
|
|
30
50
|
|
|
31
|
-
export
|
|
51
|
+
export const queryKeysForEvent = (
|
|
52
|
+
event: MessagesJoltEvent,
|
|
53
|
+
defaults: unknown[][],
|
|
54
|
+
overrides: MessagesCache[]
|
|
55
|
+
) =>
|
|
56
|
+
resolveMessageCaches(defaults, overrides)
|
|
57
|
+
.filter(cache => !cache.canUpdate || cache.canUpdate(event))
|
|
58
|
+
.map(cache => cache.queryKey)
|
|
59
|
+
|
|
60
|
+
export function useConversationMessagesJoltEvents({ conversationId, messageCaches = [] }: Props) {
|
|
32
61
|
const queryClient = useQueryClient()
|
|
33
62
|
const currentPerson = useCurrentPerson()
|
|
34
63
|
const joltChannel = useJoltChannel(`chat.conversations.${conversationId}`)
|
|
35
64
|
const messagesRequestArgs = getMessagesRequestArgs({ conversation_id: conversationId })
|
|
36
65
|
const messagesQueryKey = getRequestQueryKey(messagesRequestArgs)
|
|
66
|
+
const cachesTouchedBy = (replyRootId?: string | null): unknown[][] =>
|
|
67
|
+
replyRootId
|
|
68
|
+
? [messagesQueryKey, getThreadedMessagesQueryKey(conversationId, replyRootId)]
|
|
69
|
+
: [messagesQueryKey]
|
|
37
70
|
const apiClient = useApiClient()
|
|
38
71
|
|
|
39
72
|
const { addTypingEvent, removeTypingEventById, removeAllTypingEventsByAuthorId } =
|
|
@@ -54,21 +87,16 @@ export function useConversationMessagesJoltEvents({ conversationId }: Props) {
|
|
|
54
87
|
}
|
|
55
88
|
}
|
|
56
89
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
if (isReplyRootUpdate) {
|
|
69
|
-
updateCacheReplyRoots(queryClient, messagesQueryKey, message)
|
|
70
|
-
updateCacheReplyRoots(queryClient, threadedMessagesQueryKey, message)
|
|
71
|
-
}
|
|
90
|
+
const targetQueryKeys = queryKeysForEvent(
|
|
91
|
+
e.event,
|
|
92
|
+
cachesTouchedBy(data.reply_root_id),
|
|
93
|
+
messageCaches
|
|
94
|
+
)
|
|
95
|
+
targetQueryKeys.forEach(queryKey =>
|
|
96
|
+
updateCacheWithMessage(queryClient, queryKey, message, e.event)
|
|
97
|
+
)
|
|
98
|
+
if (isReplyRootUpdate) {
|
|
99
|
+
targetQueryKeys.forEach(queryKey => updateCacheReplyRoots(queryClient, queryKey, message))
|
|
72
100
|
}
|
|
73
101
|
|
|
74
102
|
// Update individual message query cache so reply shadow messages update immediately
|
|
@@ -88,34 +116,23 @@ export function useConversationMessagesJoltEvents({ conversationId }: Props) {
|
|
|
88
116
|
currentPersonId: currentPerson.id,
|
|
89
117
|
})
|
|
90
118
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
if (data.reply_root_id) {
|
|
98
|
-
const threadedMessagesQueryKey = getThreadedMessagesQueryKey(
|
|
99
|
-
conversationId,
|
|
100
|
-
data.reply_root_id
|
|
101
|
-
)
|
|
102
|
-
queryClient.setQueryData<QueryData>(threadedMessagesQueryKey, prev =>
|
|
119
|
+
queryKeysForEvent(
|
|
120
|
+
'message.destroyed',
|
|
121
|
+
cachesTouchedBy(data.reply_root_id),
|
|
122
|
+
messageCaches
|
|
123
|
+
).forEach(queryKey =>
|
|
124
|
+
queryClient.setQueryData<QueryData>(queryKey, prev =>
|
|
103
125
|
deleteRecordInPagesData({ data: prev, record: message })
|
|
104
126
|
)
|
|
105
|
-
|
|
127
|
+
)
|
|
106
128
|
}
|
|
107
129
|
|
|
108
130
|
const handleReactionJoltEvent = async (e: JoltReactionEvent) => {
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
// If the message has a reply_root_id, also update the threaded cache
|
|
115
|
-
if (replyRootId) {
|
|
116
|
-
const threadedMessagesQueryKey = getThreadedMessagesQueryKey(conversationId, replyRootId)
|
|
117
|
-
updateCacheWithReaction(queryClient, threadedMessagesQueryKey, e, currentPerson.id)
|
|
118
|
-
}
|
|
131
|
+
queryKeysForEvent(
|
|
132
|
+
'reaction',
|
|
133
|
+
cachesTouchedBy(e.data.data.reply_root_id),
|
|
134
|
+
messageCaches
|
|
135
|
+
).forEach(queryKey => updateCacheWithReaction(queryClient, queryKey, e, currentPerson.id))
|
|
119
136
|
}
|
|
120
137
|
|
|
121
138
|
const handleTypingEvent = async (e: JoltTypingEvent) => {
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { useQuery } from '@tanstack/react-query'
|
|
2
|
+
import { ApiResource, MemberResource } from '../types'
|
|
3
|
+
import { useApiClient } from './use_api_client'
|
|
4
|
+
|
|
5
|
+
export interface DirectMessageEligibility {
|
|
6
|
+
canDirectMessage: boolean
|
|
7
|
+
directMessagesDisabled: boolean
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function useDirectMessageEligibility({
|
|
11
|
+
groupId,
|
|
12
|
+
personId,
|
|
13
|
+
enabled = true,
|
|
14
|
+
}: {
|
|
15
|
+
groupId: string
|
|
16
|
+
personId: number
|
|
17
|
+
enabled?: boolean
|
|
18
|
+
}) {
|
|
19
|
+
const apiClient = useApiClient()
|
|
20
|
+
const url = `/me/groups/${groupId}/members/${personId}`
|
|
21
|
+
|
|
22
|
+
const { data, isLoading, isError } = useQuery({
|
|
23
|
+
queryKey: ['chat', url, 'direct_message_eligibility'],
|
|
24
|
+
queryFn: () =>
|
|
25
|
+
apiClient.chat.get({
|
|
26
|
+
url,
|
|
27
|
+
data: { fields: { Member: ['can_direct_message', 'direct_messages_disabled'] } },
|
|
28
|
+
}) as Promise<ApiResource<MemberResource>>,
|
|
29
|
+
enabled,
|
|
30
|
+
retry: false,
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
const member = data?.data
|
|
34
|
+
const eligibility: DirectMessageEligibility | undefined = member && {
|
|
35
|
+
canDirectMessage: member.canDirectMessage ?? false,
|
|
36
|
+
directMessagesDisabled: member.directMessagesDisabled ?? false,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return { eligibility, isLoading, isError }
|
|
40
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { useQueryClient } from '@tanstack/react-query'
|
|
2
|
+
import { useCallback } from 'react'
|
|
3
|
+
import { ApiCollection, MessageResource } from '../types'
|
|
4
|
+
import { prependNewerPage } from '../utils/cache/prepend_newer_page'
|
|
5
|
+
import { RequestData } from '../utils/client'
|
|
6
|
+
import { getMessagesRequestArgs } from '../utils/request/get_messages'
|
|
7
|
+
import { useApiClient } from './use_api_client'
|
|
8
|
+
|
|
9
|
+
type Options = {
|
|
10
|
+
conversation_id: number
|
|
11
|
+
reply_root_id?: string | null
|
|
12
|
+
queryKey: unknown[]
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const useFetchNewerMessages = ({ conversation_id, reply_root_id, queryKey }: Options) => {
|
|
16
|
+
const queryClient = useQueryClient()
|
|
17
|
+
const apiClient = useApiClient()
|
|
18
|
+
|
|
19
|
+
return useCallback(
|
|
20
|
+
(newestMessageId: string) => {
|
|
21
|
+
const { url, data } = getMessagesRequestArgs({ conversation_id, reply_root_id })
|
|
22
|
+
const where = { id_gt: newestMessageId }
|
|
23
|
+
const pageParam: Partial<RequestData> = { where, order: 'asc' }
|
|
24
|
+
|
|
25
|
+
return apiClient.chat
|
|
26
|
+
.get<ApiCollection<MessageResource>>({ url, data: { ...data, where, order: 'asc' } })
|
|
27
|
+
.then(page => {
|
|
28
|
+
queryClient.setQueryData<Parameters<typeof prependNewerPage<MessageResource>>[0]>(
|
|
29
|
+
queryKey,
|
|
30
|
+
prev => prependNewerPage(prev, page, pageParam)
|
|
31
|
+
)
|
|
32
|
+
})
|
|
33
|
+
.catch(() => {})
|
|
34
|
+
},
|
|
35
|
+
[apiClient, conversation_id, queryClient, queryKey, reply_root_id]
|
|
36
|
+
)
|
|
37
|
+
}
|
|
@@ -10,7 +10,7 @@ import { FailedResponse } from '../types/api_primitives'
|
|
|
10
10
|
import { Log } from '../utils'
|
|
11
11
|
import { GetRequest, RequestData } from '../utils/client'
|
|
12
12
|
import { throwResponseError } from '../utils/response_error'
|
|
13
|
-
import { getNextPageParamFromMeta } from './paginator_meta'
|
|
13
|
+
import { getNextPageParamFromMeta, mergePageWhere } from './paginator_meta'
|
|
14
14
|
import { App, useApiClient } from './use_api_client'
|
|
15
15
|
|
|
16
16
|
interface SuspenseGetOptions extends GetRequest {
|
|
@@ -65,12 +65,11 @@ export const useSuspensePaginator = <T extends ResourceObject>(
|
|
|
65
65
|
queryFn: ({ pageParam }) => {
|
|
66
66
|
Log.info('useSuspenseGet', { pageParam })
|
|
67
67
|
|
|
68
|
-
const
|
|
69
|
-
const argsWhere = args.data.where || {}
|
|
70
|
-
const where = { ...argsWhere, ...pageParmWhere }
|
|
68
|
+
const where = mergePageWhere(args.data.where, pageParam?.where)
|
|
71
69
|
const app = args.app || 'chat'
|
|
72
70
|
const offset = pageParam?.offset || args.data.offset
|
|
73
|
-
const
|
|
71
|
+
const order = pageParam?.order || args.data.order
|
|
72
|
+
const data = { ...args.data, where, offset, order }
|
|
74
73
|
|
|
75
74
|
return apiClient[app]
|
|
76
75
|
.get<ApiCollection<T>>({
|
|
@@ -45,6 +45,7 @@ export const ServicesForm = ({
|
|
|
45
45
|
const customAvatarsEnabled = featureEnabled(availableFeatures.custom_conversation_avatars)
|
|
46
46
|
const safetyLockEnabled = featureEnabled(availableFeatures.conversation_safety_lock)
|
|
47
47
|
const [selectedPlanId, setSelectedPlanId] = useState<number | undefined>(initialPlanId)
|
|
48
|
+
const [filterByPlan, setFilterByPlan] = useState(!!initialPlanId)
|
|
48
49
|
const initialState = useMemo(() => uniq(initialTeamIds) || [], [initialTeamIds]) // Uniq here because services can send duplicates in the teams_i_lead response.
|
|
49
50
|
const [selectedTeamIds, setSelectedTeamIds] = useState<number[]>(initialState)
|
|
50
51
|
|
|
@@ -52,6 +53,7 @@ export const ServicesForm = ({
|
|
|
52
53
|
useEffect(() => {
|
|
53
54
|
setSelectedTeamIds(initialState)
|
|
54
55
|
setSelectedPlanId(initialPlanId)
|
|
56
|
+
setFilterByPlan(!!initialPlanId)
|
|
55
57
|
}, [initialState, initialPlanId])
|
|
56
58
|
|
|
57
59
|
const removeSelection = useCallback(
|
|
@@ -61,9 +63,7 @@ export const ServicesForm = ({
|
|
|
61
63
|
[selectedTeamIds]
|
|
62
64
|
)
|
|
63
65
|
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
const activePlanId = filerByPlan ? selectedPlanId : undefined
|
|
66
|
+
const activePlanId = filterByPlan ? selectedPlanId : undefined
|
|
67
67
|
|
|
68
68
|
const { payload, isLoading: isLoadingPayload } = useServicesChatConversationPayload({
|
|
69
69
|
teamIds: selectedTeamIds,
|
|
@@ -79,7 +79,7 @@ export const ServicesForm = ({
|
|
|
79
79
|
|
|
80
80
|
const { members, isError: isMemberError } = useTeamMembersForNewConversation({
|
|
81
81
|
teamIds: selectedTeamIds,
|
|
82
|
-
planId:
|
|
82
|
+
planId: activePlanId,
|
|
83
83
|
})
|
|
84
84
|
const adultMembers = useMemo(() => members.filter(member => !member.child), [members])
|
|
85
85
|
|
|
@@ -118,7 +118,7 @@ export const ServicesForm = ({
|
|
|
118
118
|
removeSelection={removeSelection}
|
|
119
119
|
selectedPlanId={selectedPlanId}
|
|
120
120
|
setSelectedPlanId={setSelectedPlanId}
|
|
121
|
-
filterByPlan={
|
|
121
|
+
filterByPlan={filterByPlan}
|
|
122
122
|
setFilterByPlan={setFilterByPlan}
|
|
123
123
|
members={members}
|
|
124
124
|
isMemberError={isMemberError}
|