@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.
Files changed (108) hide show
  1. package/build/components/conversation/message.d.ts +2 -1
  2. package/build/components/conversation/message.d.ts.map +1 -1
  3. package/build/components/conversation/message.js +190 -161
  4. package/build/components/conversation/message.js.map +1 -1
  5. package/build/components/conversation/message_list.d.ts.map +1 -1
  6. package/build/components/conversation/message_list.js +35 -31
  7. package/build/components/conversation/message_list.js.map +1 -1
  8. package/build/components/conversation/message_reaction.d.ts +2 -1
  9. package/build/components/conversation/message_reaction.d.ts.map +1 -1
  10. package/build/components/conversation/message_reaction.js +67 -60
  11. package/build/components/conversation/message_reaction.js.map +1 -1
  12. package/build/components/conversation/messages_disabled_banners.d.ts +7 -5
  13. package/build/components/conversation/messages_disabled_banners.d.ts.map +1 -1
  14. package/build/components/conversation/messages_disabled_banners.js +15 -14
  15. package/build/components/conversation/messages_disabled_banners.js.map +1 -1
  16. package/build/components/conversation/reply_shadow_message.d.ts.map +1 -1
  17. package/build/components/conversation/reply_shadow_message.js +84 -83
  18. package/build/components/conversation/reply_shadow_message.js.map +1 -1
  19. package/build/components/conversation/system_message.d.ts +2 -1
  20. package/build/components/conversation/system_message.d.ts.map +1 -1
  21. package/build/components/conversation/system_message.js +87 -65
  22. package/build/components/conversation/system_message.js.map +1 -1
  23. package/build/components/display/filter_dropdown.d.ts +13 -0
  24. package/build/components/display/filter_dropdown.d.ts.map +1 -0
  25. package/build/components/display/filter_dropdown.js +188 -0
  26. package/build/components/display/filter_dropdown.js.map +1 -0
  27. package/build/components/display/index.d.ts +1 -0
  28. package/build/components/display/index.d.ts.map +1 -1
  29. package/build/components/display/index.js +1 -0
  30. package/build/components/display/index.js.map +1 -1
  31. package/build/hooks/use_conversation.d.ts +73 -1
  32. package/build/hooks/use_conversation.d.ts.map +1 -1
  33. package/build/hooks/use_conversation.js +122 -3
  34. package/build/hooks/use_conversation.js.map +1 -1
  35. package/build/hooks/use_unarchive_conversation_action.d.ts +7 -0
  36. package/build/hooks/use_unarchive_conversation_action.d.ts.map +1 -0
  37. package/build/hooks/use_unarchive_conversation_action.js +52 -0
  38. package/build/hooks/use_unarchive_conversation_action.js.map +1 -0
  39. package/build/screens/attachment_actions/attachment_actions_screen.d.ts.map +1 -1
  40. package/build/screens/attachment_actions/attachment_actions_screen.js +84 -61
  41. package/build/screens/attachment_actions/attachment_actions_screen.js.map +1 -1
  42. package/build/screens/conversation_details_screen.d.ts.map +1 -1
  43. package/build/screens/conversation_details_screen.js +487 -393
  44. package/build/screens/conversation_details_screen.js.map +1 -1
  45. package/build/screens/conversation_screen.d.ts.map +1 -1
  46. package/build/screens/conversation_screen.js +12 -9
  47. package/build/screens/conversation_screen.js.map +1 -1
  48. package/build/screens/design_system_screen.d.ts.map +1 -1
  49. package/build/screens/design_system_screen.js +275 -215
  50. package/build/screens/design_system_screen.js.map +1 -1
  51. package/build/screens/message_actions_screen.d.ts.map +1 -1
  52. package/build/screens/message_actions_screen.js +112 -92
  53. package/build/screens/message_actions_screen.js.map +1 -1
  54. package/build/types/resources/member_ability.d.ts +2 -0
  55. package/build/types/resources/member_ability.d.ts.map +1 -1
  56. package/build/types/resources/member_ability.js.map +1 -1
  57. package/build/utils/archived_conversation.d.ts +5 -0
  58. package/build/utils/archived_conversation.d.ts.map +1 -0
  59. package/build/utils/archived_conversation.js +5 -0
  60. package/build/utils/archived_conversation.js.map +1 -0
  61. package/build/utils/index.d.ts +1 -0
  62. package/build/utils/index.d.ts.map +1 -1
  63. package/build/utils/index.js +1 -0
  64. package/build/utils/index.js.map +1 -1
  65. package/build/utils/native_adapters/configuration.d.ts +3 -0
  66. package/build/utils/native_adapters/configuration.d.ts.map +1 -1
  67. package/build/utils/native_adapters/configuration.js +3 -0
  68. package/build/utils/native_adapters/configuration.js.map +1 -1
  69. package/build/utils/native_adapters/index.d.ts +1 -0
  70. package/build/utils/native_adapters/index.d.ts.map +1 -1
  71. package/build/utils/native_adapters/index.js +1 -0
  72. package/build/utils/native_adapters/index.js.map +1 -1
  73. package/build/utils/native_adapters/menu.d.ts +40 -0
  74. package/build/utils/native_adapters/menu.d.ts.map +1 -0
  75. package/build/utils/native_adapters/menu.js +55 -0
  76. package/build/utils/native_adapters/menu.js.map +1 -0
  77. package/package.json +3 -3
  78. package/src/__tests__/screens/message_actions_screen.test.tsx +65 -2
  79. package/src/components/conversation/__tests__/message_list.test.tsx +68 -2
  80. package/src/components/conversation/__tests__/system_message.test.tsx +3 -1
  81. package/src/components/conversation/message.tsx +11 -14
  82. package/src/components/conversation/message_list.tsx +9 -1
  83. package/src/components/conversation/message_reaction.tsx +7 -2
  84. package/src/components/conversation/messages_disabled_banners.tsx +17 -18
  85. package/src/components/conversation/reply_shadow_message.tsx +9 -8
  86. package/src/components/conversation/system_message.tsx +24 -10
  87. package/src/components/display/__tests__/filter_dropdown.test.tsx +106 -0
  88. package/src/components/display/filter_dropdown.tsx +123 -0
  89. package/src/components/display/index.ts +1 -0
  90. package/src/hooks/__tests__/use_conversation_archive.test.tsx +130 -0
  91. package/src/hooks/__tests__/use_conversation_jolt_events.test.tsx +74 -0
  92. package/src/hooks/__tests__/use_unarchive_conversation_action.test.tsx +67 -0
  93. package/src/hooks/use_conversation.ts +41 -3
  94. package/src/hooks/use_unarchive_conversation_action.ts +24 -0
  95. package/src/screens/__tests__/conversation_details_screen.test.tsx +289 -0
  96. package/src/screens/attachment_actions/__tests__/attachment_actions_screen.test.tsx +72 -0
  97. package/src/screens/attachment_actions/attachment_actions_screen.tsx +3 -1
  98. package/src/screens/conversation_details_screen.tsx +78 -14
  99. package/src/screens/conversation_screen.tsx +3 -1
  100. package/src/screens/design_system_screen.tsx +26 -0
  101. package/src/screens/message_actions_screen.tsx +29 -20
  102. package/src/types/resources/member_ability.ts +2 -0
  103. package/src/utils/archived_conversation.ts +13 -0
  104. package/src/utils/index.ts +1 -0
  105. package/src/utils/native_adapters/__tests__/menu.test.tsx +42 -0
  106. package/src/utils/native_adapters/configuration.ts +5 -0
  107. package/src/utils/native_adapters/index.ts +1 -0
  108. 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({ message, conversationId }: SystemMessageProps) {
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={{ color: colors.androidRippleNeutral, borderless: false, foreground: true }}
64
- accessibilityRole="button"
65
- accessibilityHint={
66
- isTruncated
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
+ })