@planningcenter/chat-react-native 4.4.1-qa-staging.12 → 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/jump_to_bottom_button.d.ts.map +1 -1
- package/build/components/conversation/jump_to_bottom_button.js +42 -40
- package/build/components/conversation/jump_to_bottom_button.js.map +1 -1
- 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/action_button.d.ts.map +1 -1
- package/build/components/display/action_button.js +18 -16
- package/build/components/display/action_button.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/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/use_animated_message_background_color.d.ts.map +1 -1
- package/build/hooks/use_animated_message_background_color.js +5 -3
- package/build/hooks/use_animated_message_background_color.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_reduce_motion.d.ts +2 -0
- package/build/hooks/use_reduce_motion.d.ts.map +1 -0
- package/build/hooks/use_reduce_motion.js +27 -0
- package/build/hooks/use_reduce_motion.js.map +1 -0
- 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/jump_to_bottom_button.tsx +4 -3
- 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/action_button.tsx +4 -3
- 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_reduce_motion.test.tsx +49 -0
- package/src/hooks/__tests__/use_unarchive_conversation_action.test.tsx +67 -0
- package/src/hooks/index.ts +1 -0
- package/src/hooks/use_animated_message_background_color.ts +6 -3
- package/src/hooks/use_conversation.ts +41 -3
- package/src/hooks/use_reduce_motion.ts +18 -0
- 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
|
+
})
|
|
@@ -7,7 +7,7 @@ import Animated, {
|
|
|
7
7
|
ReduceMotion,
|
|
8
8
|
} from 'react-native-reanimated'
|
|
9
9
|
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
|
10
|
-
import { useTheme } from '../../hooks'
|
|
10
|
+
import { useReduceMotion, useTheme } from '../../hooks'
|
|
11
11
|
import { ConversationWarning } from '../../types'
|
|
12
12
|
import { MAX_FONT_SIZE_MULTIPLIER_LANDMARK } from '../../utils'
|
|
13
13
|
import { Banner } from './banner'
|
|
@@ -37,13 +37,14 @@ export const ActionButton = ({
|
|
|
37
37
|
warnings?: ConversationWarning[]
|
|
38
38
|
}) => {
|
|
39
39
|
const styles = useStyles()
|
|
40
|
+
const reduceMotion = useReduceMotion() ? ReduceMotion.Always : ReduceMotion.Never
|
|
40
41
|
|
|
41
42
|
return (
|
|
42
43
|
<LayoutAnimationConfig skipEntering>
|
|
43
44
|
{visible && (
|
|
44
45
|
<Animated.View
|
|
45
|
-
entering={FadeIn.duration(300).reduceMotion(
|
|
46
|
-
exiting={FadeOut.duration(300).reduceMotion(
|
|
46
|
+
entering={FadeIn.duration(300).reduceMotion(reduceMotion)}
|
|
47
|
+
exiting={FadeOut.duration(300).reduceMotion(reduceMotion)}
|
|
47
48
|
style={styles.container}
|
|
48
49
|
>
|
|
49
50
|
{warnings.length > 0 && (
|
|
@@ -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,49 @@
|
|
|
1
|
+
import { act, renderHook, waitFor } from '@testing-library/react-native'
|
|
2
|
+
import { AccessibilityInfo, EmitterSubscription } from 'react-native'
|
|
3
|
+
import { useReduceMotion } from '../use_reduce_motion'
|
|
4
|
+
|
|
5
|
+
type ReduceMotionListener = (enabled: boolean) => void
|
|
6
|
+
|
|
7
|
+
describe('useReduceMotion', () => {
|
|
8
|
+
let listener: ReduceMotionListener | undefined
|
|
9
|
+
const remove = jest.fn()
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
listener = undefined
|
|
13
|
+
remove.mockClear()
|
|
14
|
+
jest.spyOn(AccessibilityInfo, 'isReduceMotionEnabled').mockResolvedValue(true)
|
|
15
|
+
const addEventListener: typeof AccessibilityInfo.addEventListener = (_event, handler) => {
|
|
16
|
+
listener = handler as ReduceMotionListener
|
|
17
|
+
return { remove } as unknown as EmitterSubscription
|
|
18
|
+
}
|
|
19
|
+
jest.spyOn(AccessibilityInfo, 'addEventListener').mockImplementation(addEventListener)
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
afterEach(() => jest.restoreAllMocks())
|
|
23
|
+
|
|
24
|
+
it('seeds from the system setting', async () => {
|
|
25
|
+
const { result } = renderHook(() => useReduceMotion())
|
|
26
|
+
|
|
27
|
+
expect(result.current).toBe(false)
|
|
28
|
+
await waitFor(() => expect(result.current).toBe(true))
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('follows reduceMotionChanged while mounted', async () => {
|
|
32
|
+
const { result } = renderHook(() => useReduceMotion())
|
|
33
|
+
await waitFor(() => expect(result.current).toBe(true))
|
|
34
|
+
|
|
35
|
+
act(() => listener?.(false))
|
|
36
|
+
expect(result.current).toBe(false)
|
|
37
|
+
|
|
38
|
+
act(() => listener?.(true))
|
|
39
|
+
expect(result.current).toBe(true)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('removes the listener on unmount', () => {
|
|
43
|
+
const { unmount } = renderHook(() => useReduceMotion())
|
|
44
|
+
|
|
45
|
+
expect(remove).not.toHaveBeenCalled()
|
|
46
|
+
unmount()
|
|
47
|
+
expect(remove).toHaveBeenCalledTimes(1)
|
|
48
|
+
})
|
|
49
|
+
})
|
|
@@ -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
|
+
})
|
package/src/hooks/index.ts
CHANGED
|
@@ -23,6 +23,7 @@ export * from './use_people_person'
|
|
|
23
23
|
export * from './use_preview_avatar_diameter'
|
|
24
24
|
export * from './use_product_analytics'
|
|
25
25
|
export * from './use_qualified_by_age'
|
|
26
|
+
export * from './use_reduce_motion'
|
|
26
27
|
export * from './use_scalable_number_of_lines'
|
|
27
28
|
export * from './use_submit_age_check'
|
|
28
29
|
export * from './use_suspense_api'
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
Easing,
|
|
11
11
|
ReduceMotion,
|
|
12
12
|
} from 'react-native-reanimated'
|
|
13
|
+
import { useReduceMotion } from './use_reduce_motion'
|
|
13
14
|
import { useTheme } from './use_theme'
|
|
14
15
|
|
|
15
16
|
const FLASH_IN_MS = 150
|
|
@@ -18,10 +19,12 @@ const FLASH_OUT_MS = 1500
|
|
|
18
19
|
|
|
19
20
|
export function useAnimatedMessageBackgroundColor() {
|
|
20
21
|
const { colors } = useTheme()
|
|
22
|
+
const reduceMotion = useReduceMotion()
|
|
21
23
|
const pressProgress = useSharedValue(0)
|
|
22
24
|
const flashProgress = useSharedValue(0)
|
|
23
25
|
|
|
24
|
-
const tintColor =
|
|
26
|
+
const tintColor =
|
|
27
|
+
colors.name === 'dark' ? colors.fillColorNeutral040 : colors.fillColorNeutral050Base
|
|
25
28
|
const tintsOnPress = Platform.OS === 'ios'
|
|
26
29
|
|
|
27
30
|
const animatedBackgroundColor = useAnimatedStyle(() => {
|
|
@@ -53,12 +56,12 @@ export function useAnimatedMessageBackgroundColor() {
|
|
|
53
56
|
withTiming(0, {
|
|
54
57
|
duration: FLASH_OUT_MS,
|
|
55
58
|
easing: Easing.out(Easing.ease),
|
|
56
|
-
reduceMotion: ReduceMotion.
|
|
59
|
+
reduceMotion: reduceMotion ? ReduceMotion.Always : ReduceMotion.Never,
|
|
57
60
|
}),
|
|
58
61
|
ReduceMotion.Never
|
|
59
62
|
)
|
|
60
63
|
)
|
|
61
|
-
}, [flashProgress])
|
|
64
|
+
}, [flashProgress, reduceMotion])
|
|
62
65
|
|
|
63
66
|
return {
|
|
64
67
|
animatedBackgroundColor,
|