@planningcenter/chat-react-native 4.4.1-qa-staging.13 → 4.4.1-qa-staging.15
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 +190 -161
- 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/reply_shadow_message.d.ts.map +1 -1
- package/build/components/conversation/reply_shadow_message.js +84 -83
- package/build/components/conversation/reply_shadow_message.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 +11 -14
- 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/reply_shadow_message.tsx +9 -8
- 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
|
@@ -31,6 +31,7 @@ import { Avatar, Icon, IconProps, Image, Text } from '../display'
|
|
|
31
31
|
import { TheirReplyConnector, MyReplyConnector } from './reply_connectors'
|
|
32
32
|
|
|
33
33
|
const SPACE_BELOW_MESSAGE = 12
|
|
34
|
+
const SPACE_BELOW_PRESSABLE = SPACE_BELOW_MESSAGE - MESSAGE_BACKGROUND_OUTSET
|
|
34
35
|
|
|
35
36
|
interface ReplyShadowMessageProps extends MessageResource {
|
|
36
37
|
messageId: string
|
|
@@ -99,6 +100,7 @@ function ShadowMessageContent({ conversation_id, ...message }: ShadowMessageCont
|
|
|
99
100
|
|
|
100
101
|
return (
|
|
101
102
|
<Pressable
|
|
103
|
+
style={styles.pressable}
|
|
102
104
|
android_ripple={{ color: colors.androidRippleNeutral }}
|
|
103
105
|
onPress={handleNavigateToReplies}
|
|
104
106
|
onPressIn={handleMessagePressIn}
|
|
@@ -107,8 +109,8 @@ function ShadowMessageContent({ conversation_id, ...message }: ShadowMessageCont
|
|
|
107
109
|
accessibilityHint="Navigate to reply screen for this message"
|
|
108
110
|
accessibilityRole="link"
|
|
109
111
|
>
|
|
112
|
+
<Animated.View style={[StyleSheet.absoluteFill, animatedBackgroundColor]} />
|
|
110
113
|
<View style={styles.message}>
|
|
111
|
-
<Animated.View style={[styles.background, animatedBackgroundColor]} />
|
|
112
114
|
{!message.mine && (
|
|
113
115
|
<View>
|
|
114
116
|
<View style={[styles.avatarWrapper, !isDeleted && styles.avatarOpacity]}>
|
|
@@ -286,17 +288,16 @@ const useStyles = ({ mine, imageWidth = 32, imageHeight = 32 }: StylesProps = {}
|
|
|
286
288
|
const tabletWidth = width >= 744 // Smallest iPad Mini's width
|
|
287
289
|
|
|
288
290
|
return StyleSheet.create({
|
|
291
|
+
pressable: {
|
|
292
|
+
marginTop: -MESSAGE_BACKGROUND_OUTSET,
|
|
293
|
+
paddingTop: MESSAGE_BACKGROUND_OUTSET,
|
|
294
|
+
marginBottom: SPACE_BELOW_PRESSABLE,
|
|
295
|
+
},
|
|
289
296
|
message: {
|
|
290
297
|
gap: 8,
|
|
291
298
|
flexDirection: mine ? 'row-reverse' : 'row',
|
|
292
299
|
paddingHorizontal: CONVERSATION_MESSAGE_LIST_PADDING_HORIZONTAL,
|
|
293
|
-
|
|
294
|
-
background: {
|
|
295
|
-
position: 'absolute',
|
|
296
|
-
left: 0,
|
|
297
|
-
right: 0,
|
|
298
|
-
top: -MESSAGE_BACKGROUND_OUTSET,
|
|
299
|
-
bottom: SPACE_BELOW_MESSAGE - MESSAGE_BACKGROUND_OUTSET,
|
|
300
|
+
marginBottom: -SPACE_BELOW_PRESSABLE,
|
|
300
301
|
},
|
|
301
302
|
messageContent: {
|
|
302
303
|
flex: 1,
|
|
@@ -13,9 +13,14 @@ import { MessageReaction } from './message_reaction'
|
|
|
13
13
|
interface SystemMessageProps {
|
|
14
14
|
message: SystemMessageResource
|
|
15
15
|
conversationId: number
|
|
16
|
+
conversationDisabled: boolean
|
|
16
17
|
}
|
|
17
18
|
|
|
18
|
-
export function SystemMessage({
|
|
19
|
+
export function SystemMessage({
|
|
20
|
+
message,
|
|
21
|
+
conversationId,
|
|
22
|
+
conversationDisabled,
|
|
23
|
+
}: SystemMessageProps) {
|
|
19
24
|
const { reactionCounts } = message
|
|
20
25
|
const styles = useStyles()
|
|
21
26
|
const navigation = useNavigation()
|
|
@@ -23,6 +28,7 @@ export function SystemMessage({ message, conversationId }: SystemMessageProps) {
|
|
|
23
28
|
const text = systemMessageText(message)
|
|
24
29
|
const isTruncated =
|
|
25
30
|
message.systemTextFormat === 'people' && message.systemTextParts.overflowCount > 0
|
|
31
|
+
const pressable = isTruncated || !conversationDisabled
|
|
26
32
|
const hasReactions = reactionCounts.length > 0
|
|
27
33
|
const { animatedBackgroundColor, handleMessagePressIn, handleMessagePressOut } =
|
|
28
34
|
useAnimatedMessageBackgroundColor()
|
|
@@ -57,16 +63,16 @@ export function SystemMessage({ message, conversationId }: SystemMessageProps) {
|
|
|
57
63
|
return (
|
|
58
64
|
<Pressable
|
|
59
65
|
onPress={isTruncated ? handlePress : undefined}
|
|
60
|
-
onLongPress={handleLongPress}
|
|
61
|
-
onPressIn={handleMessagePressIn}
|
|
62
|
-
onPressOut={handleMessagePressOut}
|
|
63
|
-
android_ripple={
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
? 'Tap to see people. Long press for message actions.'
|
|
68
|
-
: 'Long press for message actions.'
|
|
66
|
+
onLongPress={conversationDisabled ? undefined : handleLongPress}
|
|
67
|
+
onPressIn={pressable ? handleMessagePressIn : undefined}
|
|
68
|
+
onPressOut={pressable ? handleMessagePressOut : undefined}
|
|
69
|
+
android_ripple={
|
|
70
|
+
pressable
|
|
71
|
+
? { color: colors.androidRippleNeutral, borderless: false, foreground: true }
|
|
72
|
+
: undefined
|
|
69
73
|
}
|
|
74
|
+
accessibilityRole={pressable ? 'button' : undefined}
|
|
75
|
+
accessibilityHint={systemMessageHint(isTruncated, conversationDisabled)}
|
|
70
76
|
>
|
|
71
77
|
<Animated.View style={[styles.container, animatedBackgroundColor]}>
|
|
72
78
|
<Text variant="tertiary" style={styles.text}>
|
|
@@ -81,6 +87,7 @@ export function SystemMessage({ message, conversationId }: SystemMessageProps) {
|
|
|
81
87
|
onLongPress={handleReactionLongPress}
|
|
82
88
|
message={message}
|
|
83
89
|
conversationId={conversationId}
|
|
90
|
+
disabled={conversationDisabled}
|
|
84
91
|
/>
|
|
85
92
|
))}
|
|
86
93
|
</View>
|
|
@@ -90,6 +97,13 @@ export function SystemMessage({ message, conversationId }: SystemMessageProps) {
|
|
|
90
97
|
)
|
|
91
98
|
}
|
|
92
99
|
|
|
100
|
+
function systemMessageHint(isTruncated: boolean, conversationDisabled: boolean) {
|
|
101
|
+
if (conversationDisabled) return isTruncated ? 'Tap to see people.' : undefined
|
|
102
|
+
return isTruncated
|
|
103
|
+
? 'Tap to see people. Long press for message actions.'
|
|
104
|
+
: 'Long press for message actions.'
|
|
105
|
+
}
|
|
106
|
+
|
|
93
107
|
function systemMessageText(message: SystemMessageResource): string {
|
|
94
108
|
switch (message.systemTextFormat) {
|
|
95
109
|
case 'people': {
|
|
@@ -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
|
+
})
|