@planningcenter/chat-react-native 4.4.1-qa-staging.13 → 4.4.1-qa-staging.14

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