@planningcenter/chat-react-native 4.4.1-qa-staging.13 → 4.4.1-qa-staging.14
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.d.ts +2 -1
- package/build/components/conversation/message.d.ts.map +1 -1
- package/build/components/conversation/message.js +3 -1
- package/build/components/conversation/message.js.map +1 -1
- package/build/components/conversation/message_list.d.ts.map +1 -1
- package/build/components/conversation/message_list.js +35 -31
- package/build/components/conversation/message_list.js.map +1 -1
- package/build/components/conversation/message_reaction.d.ts +2 -1
- package/build/components/conversation/message_reaction.d.ts.map +1 -1
- package/build/components/conversation/message_reaction.js +67 -60
- package/build/components/conversation/message_reaction.js.map +1 -1
- package/build/components/conversation/messages_disabled_banners.d.ts +7 -5
- package/build/components/conversation/messages_disabled_banners.d.ts.map +1 -1
- package/build/components/conversation/messages_disabled_banners.js +15 -14
- package/build/components/conversation/messages_disabled_banners.js.map +1 -1
- package/build/components/conversation/system_message.d.ts +2 -1
- package/build/components/conversation/system_message.d.ts.map +1 -1
- package/build/components/conversation/system_message.js +87 -65
- package/build/components/conversation/system_message.js.map +1 -1
- package/build/components/display/filter_dropdown.d.ts +13 -0
- package/build/components/display/filter_dropdown.d.ts.map +1 -0
- package/build/components/display/filter_dropdown.js +188 -0
- package/build/components/display/filter_dropdown.js.map +1 -0
- package/build/components/display/index.d.ts +1 -0
- package/build/components/display/index.d.ts.map +1 -1
- package/build/components/display/index.js +1 -0
- package/build/components/display/index.js.map +1 -1
- package/build/hooks/use_conversation.d.ts +73 -1
- package/build/hooks/use_conversation.d.ts.map +1 -1
- package/build/hooks/use_conversation.js +122 -3
- package/build/hooks/use_conversation.js.map +1 -1
- package/build/hooks/use_unarchive_conversation_action.d.ts +7 -0
- package/build/hooks/use_unarchive_conversation_action.d.ts.map +1 -0
- package/build/hooks/use_unarchive_conversation_action.js +52 -0
- package/build/hooks/use_unarchive_conversation_action.js.map +1 -0
- package/build/screens/attachment_actions/attachment_actions_screen.d.ts.map +1 -1
- package/build/screens/attachment_actions/attachment_actions_screen.js +84 -61
- package/build/screens/attachment_actions/attachment_actions_screen.js.map +1 -1
- package/build/screens/conversation_details_screen.d.ts.map +1 -1
- package/build/screens/conversation_details_screen.js +487 -393
- package/build/screens/conversation_details_screen.js.map +1 -1
- package/build/screens/conversation_screen.d.ts.map +1 -1
- package/build/screens/conversation_screen.js +12 -9
- package/build/screens/conversation_screen.js.map +1 -1
- package/build/screens/design_system_screen.d.ts.map +1 -1
- package/build/screens/design_system_screen.js +275 -215
- package/build/screens/design_system_screen.js.map +1 -1
- package/build/screens/message_actions_screen.d.ts.map +1 -1
- package/build/screens/message_actions_screen.js +112 -92
- package/build/screens/message_actions_screen.js.map +1 -1
- package/build/types/resources/member_ability.d.ts +2 -0
- package/build/types/resources/member_ability.d.ts.map +1 -1
- package/build/types/resources/member_ability.js.map +1 -1
- package/build/utils/archived_conversation.d.ts +5 -0
- package/build/utils/archived_conversation.d.ts.map +1 -0
- package/build/utils/archived_conversation.js +5 -0
- package/build/utils/archived_conversation.js.map +1 -0
- package/build/utils/index.d.ts +1 -0
- package/build/utils/index.d.ts.map +1 -1
- package/build/utils/index.js +1 -0
- package/build/utils/index.js.map +1 -1
- package/build/utils/native_adapters/configuration.d.ts +3 -0
- package/build/utils/native_adapters/configuration.d.ts.map +1 -1
- package/build/utils/native_adapters/configuration.js +3 -0
- package/build/utils/native_adapters/configuration.js.map +1 -1
- package/build/utils/native_adapters/index.d.ts +1 -0
- package/build/utils/native_adapters/index.d.ts.map +1 -1
- package/build/utils/native_adapters/index.js +1 -0
- package/build/utils/native_adapters/index.js.map +1 -1
- package/build/utils/native_adapters/menu.d.ts +40 -0
- package/build/utils/native_adapters/menu.d.ts.map +1 -0
- package/build/utils/native_adapters/menu.js +55 -0
- package/build/utils/native_adapters/menu.js.map +1 -0
- package/package.json +3 -3
- package/src/__tests__/screens/message_actions_screen.test.tsx +65 -2
- package/src/components/conversation/__tests__/message_list.test.tsx +68 -2
- package/src/components/conversation/__tests__/system_message.test.tsx +3 -1
- package/src/components/conversation/message.tsx +3 -0
- package/src/components/conversation/message_list.tsx +9 -1
- package/src/components/conversation/message_reaction.tsx +7 -2
- package/src/components/conversation/messages_disabled_banners.tsx +17 -18
- package/src/components/conversation/system_message.tsx +24 -10
- package/src/components/display/__tests__/filter_dropdown.test.tsx +106 -0
- package/src/components/display/filter_dropdown.tsx +123 -0
- package/src/components/display/index.ts +1 -0
- package/src/hooks/__tests__/use_conversation_archive.test.tsx +130 -0
- package/src/hooks/__tests__/use_conversation_jolt_events.test.tsx +74 -0
- package/src/hooks/__tests__/use_unarchive_conversation_action.test.tsx +67 -0
- package/src/hooks/use_conversation.ts +41 -3
- package/src/hooks/use_unarchive_conversation_action.ts +24 -0
- package/src/screens/__tests__/conversation_details_screen.test.tsx +289 -0
- package/src/screens/attachment_actions/__tests__/attachment_actions_screen.test.tsx +72 -0
- package/src/screens/attachment_actions/attachment_actions_screen.tsx +3 -1
- package/src/screens/conversation_details_screen.tsx +78 -14
- package/src/screens/conversation_screen.tsx +3 -1
- package/src/screens/design_system_screen.tsx +26 -0
- package/src/screens/message_actions_screen.tsx +29 -20
- package/src/types/resources/member_ability.ts +2 -0
- package/src/utils/archived_conversation.ts +13 -0
- package/src/utils/index.ts +1 -0
- package/src/utils/native_adapters/__tests__/menu.test.tsx +42 -0
- package/src/utils/native_adapters/configuration.ts +5 -0
- package/src/utils/native_adapters/index.ts +1 -0
- package/src/utils/native_adapters/menu.tsx +68 -0
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { fireEvent, render, screen } from '@testing-library/react-native'
|
|
2
|
+
import React from 'react'
|
|
3
|
+
import { Pressable, Text } from 'react-native'
|
|
4
|
+
import {
|
|
5
|
+
AudioAdapter,
|
|
6
|
+
ChatAdapters,
|
|
7
|
+
ClipboardAdapter,
|
|
8
|
+
ImagePickerAdapter,
|
|
9
|
+
MenuAction,
|
|
10
|
+
MenuAdapter,
|
|
11
|
+
MenuViewProps,
|
|
12
|
+
VideoAdapter,
|
|
13
|
+
} from '../../../utils/native_adapters'
|
|
14
|
+
import { FilterDropdown } from '../filter_dropdown'
|
|
15
|
+
|
|
16
|
+
// Stands in for the host's native menu: every action, inline sections
|
|
17
|
+
// included, becomes a pressable menu item.
|
|
18
|
+
const FakeMenuView = ({ actions, onPressAction, children }: MenuViewProps) => {
|
|
19
|
+
const flatten = (items: MenuAction[]): MenuAction[] =>
|
|
20
|
+
items.flatMap(item => (item.displayInline && item.subactions ? item.subactions : [item]))
|
|
21
|
+
|
|
22
|
+
return (
|
|
23
|
+
<>
|
|
24
|
+
{children}
|
|
25
|
+
{flatten(actions).map(action => (
|
|
26
|
+
<Pressable
|
|
27
|
+
key={action.id}
|
|
28
|
+
accessibilityRole="menuitem"
|
|
29
|
+
accessibilityState={{ selected: action.state === 'on' }}
|
|
30
|
+
onPress={() => onPressAction?.({ nativeEvent: { event: action.id ?? action.title } })}
|
|
31
|
+
>
|
|
32
|
+
<Text>{action.title}</Text>
|
|
33
|
+
</Pressable>
|
|
34
|
+
))}
|
|
35
|
+
</>
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
beforeAll(() => {
|
|
40
|
+
ChatAdapters.configure({
|
|
41
|
+
clipboard: new ClipboardAdapter({
|
|
42
|
+
getStringAsync: async () => '',
|
|
43
|
+
setStringAsync: async () => {},
|
|
44
|
+
}),
|
|
45
|
+
audio: new AudioAdapter({ useAudio: () => ({}) as any }),
|
|
46
|
+
video: new VideoAdapter({ Player: (() => null) as any }),
|
|
47
|
+
imagePicker: new ImagePickerAdapter({
|
|
48
|
+
openCameraAsync: async () => ({ canceled: true, assets: null }),
|
|
49
|
+
openImageLibraryAsync: async () => ({ canceled: true, assets: null }),
|
|
50
|
+
}),
|
|
51
|
+
menu: new MenuAdapter({ View: FakeMenuView }),
|
|
52
|
+
})
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
const options = [
|
|
56
|
+
{ id: 'all', label: 'All' },
|
|
57
|
+
{ id: 'groups', label: 'Groups' },
|
|
58
|
+
{ id: 'teams', label: 'Teams' },
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
const renderFilterDropdown = () => {
|
|
62
|
+
const onSelect = jest.fn()
|
|
63
|
+
const onMoreFilters = jest.fn()
|
|
64
|
+
|
|
65
|
+
render(
|
|
66
|
+
<FilterDropdown
|
|
67
|
+
options={options}
|
|
68
|
+
selectedId="groups"
|
|
69
|
+
onSelect={onSelect}
|
|
70
|
+
onMoreFilters={onMoreFilters}
|
|
71
|
+
triggerLabel="Groups"
|
|
72
|
+
/>
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
return { onSelect, onMoreFilters }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
describe('FilterDropdown', () => {
|
|
79
|
+
it('hands the menu every option, the selected one checked, then More filters', () => {
|
|
80
|
+
renderFilterDropdown()
|
|
81
|
+
|
|
82
|
+
expect(screen.getByRole('button', { name: 'Groups' })).toBeTruthy()
|
|
83
|
+
expect(
|
|
84
|
+
screen.getAllByRole('menuitem').map(item => [item.props.accessibilityState.selected])
|
|
85
|
+
).toEqual([[false], [true], [false], [false]])
|
|
86
|
+
expect(screen.getByRole('menuitem', { name: 'More filters' })).toBeTruthy()
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('calls onSelect with the id of the pressed option', () => {
|
|
90
|
+
const { onSelect, onMoreFilters } = renderFilterDropdown()
|
|
91
|
+
|
|
92
|
+
fireEvent.press(screen.getByRole('menuitem', { name: 'Teams' }))
|
|
93
|
+
|
|
94
|
+
expect(onSelect).toHaveBeenCalledWith('teams')
|
|
95
|
+
expect(onMoreFilters).not.toHaveBeenCalled()
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('calls onMoreFilters for the More filters action', () => {
|
|
99
|
+
const { onSelect, onMoreFilters } = renderFilterDropdown()
|
|
100
|
+
|
|
101
|
+
fireEvent.press(screen.getByRole('menuitem', { name: 'More filters' }))
|
|
102
|
+
|
|
103
|
+
expect(onMoreFilters).toHaveBeenCalledTimes(1)
|
|
104
|
+
expect(onSelect).not.toHaveBeenCalled()
|
|
105
|
+
})
|
|
106
|
+
})
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { StyleSheet, View } from 'react-native'
|
|
3
|
+
import { useTheme } from '../../hooks'
|
|
4
|
+
import {
|
|
5
|
+
MAX_FONT_SIZE_MULTIPLIER_LANDMARK,
|
|
6
|
+
Menu,
|
|
7
|
+
MenuAction,
|
|
8
|
+
MenuActionEvent,
|
|
9
|
+
platformFontWeightBold,
|
|
10
|
+
} from '../../utils'
|
|
11
|
+
import { tokens } from '../../vendor/tapestry/tokens'
|
|
12
|
+
import { Icon } from './icon'
|
|
13
|
+
import { Text } from './text'
|
|
14
|
+
|
|
15
|
+
// =================================
|
|
16
|
+
// ====== Constants ================
|
|
17
|
+
// =================================
|
|
18
|
+
|
|
19
|
+
const MORE_FILTERS_ID = 'more_filters'
|
|
20
|
+
const MORE_FILTERS_LABEL = 'More filters'
|
|
21
|
+
|
|
22
|
+
// =================================
|
|
23
|
+
// ====== Component ================
|
|
24
|
+
// =================================
|
|
25
|
+
|
|
26
|
+
export interface FilterDropdownOption {
|
|
27
|
+
id: string
|
|
28
|
+
label: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface FilterDropdownProps {
|
|
32
|
+
options: FilterDropdownOption[]
|
|
33
|
+
selectedId: string
|
|
34
|
+
onSelect: (id: string) => void
|
|
35
|
+
onMoreFilters: () => void
|
|
36
|
+
triggerLabel: string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function FilterDropdown({
|
|
40
|
+
options,
|
|
41
|
+
selectedId,
|
|
42
|
+
onSelect,
|
|
43
|
+
onMoreFilters,
|
|
44
|
+
triggerLabel,
|
|
45
|
+
}: FilterDropdownProps) {
|
|
46
|
+
const styles = useStyles()
|
|
47
|
+
|
|
48
|
+
const actions: MenuAction[] = [
|
|
49
|
+
...options.map(option => ({
|
|
50
|
+
id: option.id,
|
|
51
|
+
title: option.label,
|
|
52
|
+
state: option.id === selectedId ? ('on' as const) : ('off' as const),
|
|
53
|
+
})),
|
|
54
|
+
{
|
|
55
|
+
id: MORE_FILTERS_ID,
|
|
56
|
+
title: '',
|
|
57
|
+
displayInline: true,
|
|
58
|
+
subactions: [{ id: MORE_FILTERS_ID, title: MORE_FILTERS_LABEL }],
|
|
59
|
+
},
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
const handlePressAction = ({ nativeEvent }: MenuActionEvent) => {
|
|
63
|
+
if (nativeEvent.event === MORE_FILTERS_ID) {
|
|
64
|
+
onMoreFilters()
|
|
65
|
+
} else {
|
|
66
|
+
onSelect(nativeEvent.event)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<Menu.View actions={actions} onPressAction={handlePressAction} style={styles.container}>
|
|
72
|
+
<View
|
|
73
|
+
style={styles.trigger}
|
|
74
|
+
accessible
|
|
75
|
+
accessibilityRole="button"
|
|
76
|
+
accessibilityHint="Opens filter options"
|
|
77
|
+
>
|
|
78
|
+
<Text
|
|
79
|
+
style={styles.triggerLabel}
|
|
80
|
+
numberOfLines={1}
|
|
81
|
+
maxFontSizeMultiplier={MAX_FONT_SIZE_MULTIPLIER_LANDMARK}
|
|
82
|
+
>
|
|
83
|
+
{triggerLabel}
|
|
84
|
+
</Text>
|
|
85
|
+
<Icon
|
|
86
|
+
name="general.downChevron"
|
|
87
|
+
style={styles.triggerIcon}
|
|
88
|
+
maxFontSizeMultiplier={MAX_FONT_SIZE_MULTIPLIER_LANDMARK}
|
|
89
|
+
accessibilityElementsHidden
|
|
90
|
+
/>
|
|
91
|
+
</View>
|
|
92
|
+
</Menu.View>
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// =================================
|
|
97
|
+
// ====== Styles ===================
|
|
98
|
+
// =================================
|
|
99
|
+
|
|
100
|
+
const useStyles = () => {
|
|
101
|
+
const { colors } = useTheme()
|
|
102
|
+
|
|
103
|
+
return StyleSheet.create({
|
|
104
|
+
container: {
|
|
105
|
+
alignSelf: 'flex-start',
|
|
106
|
+
},
|
|
107
|
+
trigger: {
|
|
108
|
+
flexDirection: 'row',
|
|
109
|
+
alignItems: 'center',
|
|
110
|
+
gap: 6,
|
|
111
|
+
paddingVertical: 4,
|
|
112
|
+
},
|
|
113
|
+
triggerLabel: {
|
|
114
|
+
flexShrink: 1,
|
|
115
|
+
fontWeight: platformFontWeightBold,
|
|
116
|
+
color: colors.textColorDefaultPrimary,
|
|
117
|
+
},
|
|
118
|
+
triggerIcon: {
|
|
119
|
+
fontSize: tokens.fontSizeSm,
|
|
120
|
+
color: colors.iconColorDefaultPrimary,
|
|
121
|
+
},
|
|
122
|
+
})
|
|
123
|
+
}
|
|
@@ -23,6 +23,7 @@ export * from './text_button'
|
|
|
23
23
|
export * from './text_inline_button'
|
|
24
24
|
export * from './text'
|
|
25
25
|
export * from './toggle_button'
|
|
26
|
+
export * from './filter_dropdown'
|
|
26
27
|
export * from './keyboard_scroll_view'
|
|
27
28
|
export * from './keyboard_view'
|
|
28
29
|
export * from './platform_modal_header_buttons'
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { QueryClientProvider, useQuery } from '@tanstack/react-query'
|
|
2
|
+
import { act, renderHook, waitFor } from '@testing-library/react-native'
|
|
3
|
+
import React from 'react'
|
|
4
|
+
import { Alert } from 'react-native'
|
|
5
|
+
import { buildTestQueryClient } from '../../__utils__/query_client'
|
|
6
|
+
import { getConversationsRequestArgs } from '../../utils/request/conversation'
|
|
7
|
+
import { getConversationRequestArgs, useConversationArchive } from '../use_conversation'
|
|
8
|
+
import { getRequestQueryKey } from '../use_suspense_api'
|
|
9
|
+
|
|
10
|
+
const mockPost = jest.fn()
|
|
11
|
+
jest.mock('../use_api_client', () => ({
|
|
12
|
+
useApiClient: () => ({ chat: { post: mockPost } }),
|
|
13
|
+
}))
|
|
14
|
+
|
|
15
|
+
const conversation_id = 7
|
|
16
|
+
const conversationKey = getRequestQueryKey(getConversationRequestArgs({ conversation_id }))
|
|
17
|
+
const conversationsKey = getRequestQueryKey(getConversationsRequestArgs({ gids: 'all' }))
|
|
18
|
+
|
|
19
|
+
const buildQueryClient = () =>
|
|
20
|
+
buildTestQueryClient({ defaultOptions: { queries: { gcTime: Infinity } } })
|
|
21
|
+
|
|
22
|
+
let queryClient = buildQueryClient()
|
|
23
|
+
|
|
24
|
+
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
|
25
|
+
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
const isInvalidated = (queryKey: unknown[]) =>
|
|
29
|
+
queryClient.getQueryCache().find({ queryKey, exact: true })?.state.isInvalidated
|
|
30
|
+
|
|
31
|
+
describe('useConversationArchive', () => {
|
|
32
|
+
beforeEach(() => {
|
|
33
|
+
queryClient = buildQueryClient()
|
|
34
|
+
queryClient.setQueryData(conversationKey, { data: { id: conversation_id } })
|
|
35
|
+
queryClient.setQueryData(conversationsKey, { data: [] })
|
|
36
|
+
mockPost.mockReset()
|
|
37
|
+
jest.spyOn(Alert, 'alert').mockImplementation(() => {})
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
afterEach(() => {
|
|
41
|
+
jest.restoreAllMocks()
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it.each([
|
|
45
|
+
[true, 'archive'],
|
|
46
|
+
[false, 'unarchive'],
|
|
47
|
+
])(
|
|
48
|
+
'posts archived=%s to the %s action, then invalidates the conversation and list',
|
|
49
|
+
async (archived, action) => {
|
|
50
|
+
mockPost.mockResolvedValue({ data: { id: conversation_id } })
|
|
51
|
+
const { result } = renderHook(() => useConversationArchive({ conversation_id }), { wrapper })
|
|
52
|
+
|
|
53
|
+
await act(() => result.current.mutateAsync(archived))
|
|
54
|
+
|
|
55
|
+
expect(mockPost).toHaveBeenCalledWith({
|
|
56
|
+
url: `/me/conversations/7/${action}`,
|
|
57
|
+
data: { data: { type: 'Conversation', attributes: {} } },
|
|
58
|
+
})
|
|
59
|
+
expect(isInvalidated(conversationKey)).toBe(true)
|
|
60
|
+
expect(isInvalidated(conversationsKey)).toBe(true)
|
|
61
|
+
}
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
it('stays pending until the conversation refetch finishes', async () => {
|
|
65
|
+
mockPost.mockResolvedValue({ data: { id: conversation_id } })
|
|
66
|
+
let finishRefetch = () => {}
|
|
67
|
+
const { result } = renderHook(
|
|
68
|
+
() => {
|
|
69
|
+
useQuery({
|
|
70
|
+
queryKey: conversationKey,
|
|
71
|
+
queryFn: () =>
|
|
72
|
+
new Promise(resolve => {
|
|
73
|
+
finishRefetch = () => resolve({ data: { id: conversation_id } })
|
|
74
|
+
}),
|
|
75
|
+
staleTime: Infinity,
|
|
76
|
+
})
|
|
77
|
+
return useConversationArchive({ conversation_id })
|
|
78
|
+
},
|
|
79
|
+
{ wrapper }
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
let archiving = Promise.resolve()
|
|
83
|
+
await act(async () => {
|
|
84
|
+
archiving = result.current.mutateAsync(true).then(() => {})
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
await waitFor(() => expect(result.current.isPending).toBe(true))
|
|
88
|
+
|
|
89
|
+
await act(async () => {
|
|
90
|
+
finishRefetch()
|
|
91
|
+
await archiving
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
await waitFor(() => expect(result.current.isPending).toBe(false))
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it("doesn't wait on the list refetch", async () => {
|
|
98
|
+
mockPost.mockResolvedValue({ data: { id: conversation_id } })
|
|
99
|
+
const { result } = renderHook(
|
|
100
|
+
() => {
|
|
101
|
+
useQuery({
|
|
102
|
+
queryKey: conversationsKey,
|
|
103
|
+
queryFn: () => new Promise(() => {}),
|
|
104
|
+
staleTime: Infinity,
|
|
105
|
+
})
|
|
106
|
+
return useConversationArchive({ conversation_id })
|
|
107
|
+
},
|
|
108
|
+
{ wrapper }
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
await act(() => result.current.mutateAsync(true))
|
|
112
|
+
|
|
113
|
+
expect(result.current.isPending).toBe(false)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it.each([
|
|
117
|
+
[true, 'archive'],
|
|
118
|
+
[false, 'unarchive'],
|
|
119
|
+
])('alerts when archived=%s fails', async (archived, action) => {
|
|
120
|
+
mockPost.mockRejectedValue(new Error('nope'))
|
|
121
|
+
const { result } = renderHook(() => useConversationArchive({ conversation_id }), { wrapper })
|
|
122
|
+
|
|
123
|
+
await act(() => result.current.mutateAsync(archived).catch(() => {}))
|
|
124
|
+
|
|
125
|
+
expect(Alert.alert).toHaveBeenCalledWith(
|
|
126
|
+
'Oops',
|
|
127
|
+
`Couldn't ${action} this conversation. Please try again.`
|
|
128
|
+
)
|
|
129
|
+
})
|
|
130
|
+
})
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
|
2
|
+
import { renderHook } from '@testing-library/react-native'
|
|
3
|
+
import React from 'react'
|
|
4
|
+
import { buildTestQueryClient } from '../../__utils__/query_client'
|
|
5
|
+
import type { ApiResource, ConversationResource } from '../../types'
|
|
6
|
+
import { getConversationRequestArgs } from '../use_conversation'
|
|
7
|
+
import { useConversationJoltEvents } from '../use_conversation_jolt_events'
|
|
8
|
+
import { getRequestQueryKey } from '../use_suspense_api'
|
|
9
|
+
|
|
10
|
+
const handlers: Record<string, (event: unknown) => void> = {}
|
|
11
|
+
jest.mock('../use_jolt', () => ({
|
|
12
|
+
useJoltChannel: () => 'channel',
|
|
13
|
+
useJoltEvent: (_channel: unknown, name: string, handler: (event: unknown) => void) => {
|
|
14
|
+
handlers[name] = handler
|
|
15
|
+
},
|
|
16
|
+
}))
|
|
17
|
+
jest.mock('../use_current_person', () => ({ useCurrentPerson: () => ({ id: 1 }) }))
|
|
18
|
+
jest.mock('../use_api_client', () => ({ useApiClient: () => ({ chat: {} }) }))
|
|
19
|
+
|
|
20
|
+
const conversationId = 7
|
|
21
|
+
const queryKey = getRequestQueryKey(getConversationRequestArgs({ conversation_id: conversationId }))
|
|
22
|
+
let queryClient: QueryClient
|
|
23
|
+
|
|
24
|
+
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
|
25
|
+
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
const archivedAttributes = {
|
|
29
|
+
disabled: true,
|
|
30
|
+
disabled_reason: 'archived',
|
|
31
|
+
disabled_message: 'This conversation is archived.',
|
|
32
|
+
}
|
|
33
|
+
const activeAttributes = { disabled: false, disabled_reason: null, disabled_message: null }
|
|
34
|
+
|
|
35
|
+
const seed = (conversation: Partial<ConversationResource>) =>
|
|
36
|
+
queryClient.setQueryData<ApiResource<ConversationResource>>(queryKey, {
|
|
37
|
+
data: { type: 'Conversation', id: conversationId, title: 'Youth', ...conversation },
|
|
38
|
+
} as ApiResource<ConversationResource>)
|
|
39
|
+
|
|
40
|
+
const cached = () => queryClient.getQueryData<ApiResource<ConversationResource>>(queryKey)!.data
|
|
41
|
+
|
|
42
|
+
const conversationUpdated = (attributes: Record<string, unknown>) =>
|
|
43
|
+
handlers['conversation.updated']({
|
|
44
|
+
event: 'conversation.updated',
|
|
45
|
+
data: { data: { id: conversationId, ...attributes } },
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
beforeEach(() => {
|
|
49
|
+
queryClient = buildTestQueryClient()
|
|
50
|
+
renderHook(() => useConversationJoltEvents({ conversationId }), { wrapper })
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
describe('useConversationJoltEvents on conversation.updated', () => {
|
|
54
|
+
it('switches the cached conversation into the archived state', () => {
|
|
55
|
+
seed({ disabled: false, disabledReason: null, disabledMessage: null })
|
|
56
|
+
|
|
57
|
+
conversationUpdated(archivedAttributes)
|
|
58
|
+
|
|
59
|
+
expect(cached()).toMatchObject({
|
|
60
|
+
title: 'Youth',
|
|
61
|
+
disabled: true,
|
|
62
|
+
disabledReason: 'archived',
|
|
63
|
+
disabledMessage: 'This conversation is archived.',
|
|
64
|
+
})
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('switches the cached conversation back out of the archived state', () => {
|
|
68
|
+
seed({ disabled: true, disabledReason: 'archived', disabledMessage: 'Archived' })
|
|
69
|
+
|
|
70
|
+
conversationUpdated(activeAttributes)
|
|
71
|
+
|
|
72
|
+
expect(cached()).toMatchObject({ disabled: false, disabledReason: null, disabledMessage: null })
|
|
73
|
+
})
|
|
74
|
+
})
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { act, renderHook } from '@testing-library/react-native'
|
|
2
|
+
import type { ConversationResource } from '../../types'
|
|
3
|
+
import { availableFeatures } from '../use_features'
|
|
4
|
+
import { useUnarchiveConversationAction } from '../use_unarchive_conversation_action'
|
|
5
|
+
|
|
6
|
+
const mockSetArchived = jest.fn()
|
|
7
|
+
jest.mock('../use_conversation', () => ({
|
|
8
|
+
useConversationArchive: () => ({ setArchived: mockSetArchived, isPending: false }),
|
|
9
|
+
}))
|
|
10
|
+
|
|
11
|
+
let mockEnabledFeatures: string[] = []
|
|
12
|
+
jest.mock('../use_features', () => ({
|
|
13
|
+
...jest.requireActual('../use_features'),
|
|
14
|
+
useFeatures: () => ({
|
|
15
|
+
features: [],
|
|
16
|
+
featureEnabled: (name: string) => mockEnabledFeatures.includes(name),
|
|
17
|
+
}),
|
|
18
|
+
}))
|
|
19
|
+
|
|
20
|
+
const conversation = ({
|
|
21
|
+
disabledReason = 'archived',
|
|
22
|
+
canUnarchive = true,
|
|
23
|
+
}: {
|
|
24
|
+
disabledReason?: string | null
|
|
25
|
+
canUnarchive?: boolean
|
|
26
|
+
} = {}) =>
|
|
27
|
+
({
|
|
28
|
+
type: 'Conversation',
|
|
29
|
+
id: 7,
|
|
30
|
+
disabled: !!disabledReason,
|
|
31
|
+
disabledReason,
|
|
32
|
+
memberAbility: { type: 'MemberAbility', canUnarchive },
|
|
33
|
+
}) as unknown as ConversationResource
|
|
34
|
+
|
|
35
|
+
const renderAction = (resource: ConversationResource) =>
|
|
36
|
+
renderHook(() => useUnarchiveConversationAction(resource)).result.current
|
|
37
|
+
|
|
38
|
+
describe('useUnarchiveConversationAction', () => {
|
|
39
|
+
beforeEach(() => {
|
|
40
|
+
mockEnabledFeatures = [availableFeatures.manual_archiving_rollout]
|
|
41
|
+
jest.clearAllMocks()
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('unarchives an archived conversation the person can unarchive', () => {
|
|
45
|
+
const action = renderAction(conversation())
|
|
46
|
+
|
|
47
|
+
expect(action?.label).toBe('Unarchive')
|
|
48
|
+
|
|
49
|
+
act(() => action?.onPress())
|
|
50
|
+
|
|
51
|
+
expect(mockSetArchived).toHaveBeenCalledWith(false)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it.each([
|
|
55
|
+
['neither mobile flag is on', () => (mockEnabledFeatures = []), conversation()],
|
|
56
|
+
['the person cannot unarchive', () => {}, conversation({ canUnarchive: false })],
|
|
57
|
+
[
|
|
58
|
+
'the conversation is disabled for another reason',
|
|
59
|
+
() => {},
|
|
60
|
+
conversation({ disabledReason: 'safety_check' }),
|
|
61
|
+
],
|
|
62
|
+
])('offers nothing when %s', (_case, setup, resource) => {
|
|
63
|
+
setup()
|
|
64
|
+
|
|
65
|
+
expect(renderAction(resource)).toBeUndefined()
|
|
66
|
+
})
|
|
67
|
+
})
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
|
2
2
|
import { useState } from 'react'
|
|
3
|
+
import { Alert } from 'react-native'
|
|
3
4
|
import { ApiResource, ConversationResource } from '../types'
|
|
4
5
|
import { transformGetToPost } from '../utils/client/request_helpers'
|
|
5
6
|
import { Haptic } from '../utils/native_adapters'
|
|
6
7
|
import { getConversationsRequestArgs } from '../utils/request/conversation'
|
|
7
8
|
import { useApiClient } from './use_api_client'
|
|
8
|
-
import { getRequestQueryKey, useSuspenseGet } from './use_suspense_api'
|
|
9
|
+
import { getRequestQueryKey, SuspenseGetQueryOptions, useSuspenseGet } from './use_suspense_api'
|
|
9
10
|
|
|
10
11
|
export const getConversationRequestArgs = ({ conversation_id }: { conversation_id: number }) => ({
|
|
11
12
|
url: `/me/conversations/${conversation_id}`,
|
|
@@ -49,6 +50,8 @@ export const getConversationRequestArgs = ({ conversation_id }: { conversation_i
|
|
|
49
50
|
'can_reply',
|
|
50
51
|
'can_delete_non_authored_messages',
|
|
51
52
|
'can_message_available_members',
|
|
53
|
+
'can_archive',
|
|
54
|
+
'can_unarchive',
|
|
52
55
|
],
|
|
53
56
|
ConversationMembership: [
|
|
54
57
|
'last_read_message_sort_key',
|
|
@@ -70,10 +73,13 @@ export const getConversationRequestArgs = ({ conversation_id }: { conversation_i
|
|
|
70
73
|
},
|
|
71
74
|
})
|
|
72
75
|
|
|
73
|
-
export const useConversation = (
|
|
76
|
+
export const useConversation = (
|
|
77
|
+
{ conversation_id }: { conversation_id: number },
|
|
78
|
+
options?: SuspenseGetQueryOptions<ConversationResource>
|
|
79
|
+
) => {
|
|
74
80
|
const args = getConversationRequestArgs({ conversation_id })
|
|
75
81
|
|
|
76
|
-
return useSuspenseGet<ConversationResource>(args)
|
|
82
|
+
return useSuspenseGet<ConversationResource>(args, options)
|
|
77
83
|
}
|
|
78
84
|
|
|
79
85
|
export const useConversationMute = ({ conversation_id }: { conversation_id: number }) => {
|
|
@@ -179,6 +185,38 @@ export const useConversationDelete = ({ conversation_id }: { conversation_id: nu
|
|
|
179
185
|
})
|
|
180
186
|
}
|
|
181
187
|
|
|
188
|
+
export const useConversationArchive = ({ conversation_id }: { conversation_id: number }) => {
|
|
189
|
+
const apiClient = useApiClient()
|
|
190
|
+
const queryClient = useQueryClient()
|
|
191
|
+
const conversationQueryKey = getRequestQueryKey(getConversationRequestArgs({ conversation_id }))
|
|
192
|
+
const { url } = getConversationsRequestArgs()
|
|
193
|
+
|
|
194
|
+
const { mutate: setArchived, ...mutation } = useMutation({
|
|
195
|
+
mutationKey: ['archiveConversation'],
|
|
196
|
+
mutationFn: async (archived: boolean) => {
|
|
197
|
+
const action = archived ? 'archive' : 'unarchive'
|
|
198
|
+
|
|
199
|
+
return apiClient.chat.post({
|
|
200
|
+
url: `/me/conversations/${conversation_id}/${action}`,
|
|
201
|
+
data: { data: { type: 'Conversation', attributes: {} } },
|
|
202
|
+
})
|
|
203
|
+
},
|
|
204
|
+
onSuccess: () => {
|
|
205
|
+
Haptic.notificationSuccess()
|
|
206
|
+
queryClient.invalidateQueries({ queryKey: [url] })
|
|
207
|
+
return queryClient.invalidateQueries({ queryKey: conversationQueryKey })
|
|
208
|
+
},
|
|
209
|
+
onError: (_error, archived) => {
|
|
210
|
+
Alert.alert(
|
|
211
|
+
'Oops',
|
|
212
|
+
`Couldn't ${archived ? 'archive' : 'unarchive'} this conversation. Please try again.`
|
|
213
|
+
)
|
|
214
|
+
},
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
return { setArchived, ...mutation }
|
|
218
|
+
}
|
|
219
|
+
|
|
182
220
|
export const useConversationDisableReplies = ({ conversation_id }: { conversation_id: number }) => {
|
|
183
221
|
const apiClient = useApiClient()
|
|
184
222
|
const queryClient = useQueryClient()
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { ConversationResource } from '../types'
|
|
2
|
+
import { isArchivedConversation } from '../utils/archived_conversation'
|
|
3
|
+
import { useConversationArchive } from './use_conversation'
|
|
4
|
+
import { availableFeatures, useFeatures } from './use_features'
|
|
5
|
+
|
|
6
|
+
export function useUnarchiveConversationAction(conversation: ConversationResource) {
|
|
7
|
+
const { featureEnabled } = useFeatures()
|
|
8
|
+
const { setArchived, isPending } = useConversationArchive({ conversation_id: conversation.id })
|
|
9
|
+
const manualArchivingEnabled =
|
|
10
|
+
featureEnabled(availableFeatures.manual_archiving) ||
|
|
11
|
+
featureEnabled(availableFeatures.manual_archiving_rollout)
|
|
12
|
+
const canUnarchive =
|
|
13
|
+
manualArchivingEnabled &&
|
|
14
|
+
isArchivedConversation(conversation) &&
|
|
15
|
+
!!conversation.memberAbility?.canUnarchive
|
|
16
|
+
|
|
17
|
+
if (!canUnarchive) return undefined
|
|
18
|
+
|
|
19
|
+
return {
|
|
20
|
+
label: 'Unarchive',
|
|
21
|
+
onPress: () => setArchived(false),
|
|
22
|
+
loading: isPending,
|
|
23
|
+
}
|
|
24
|
+
}
|