@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.
Files changed (127) hide show
  1. package/build/components/conversation/jump_to_bottom_button.d.ts.map +1 -1
  2. package/build/components/conversation/jump_to_bottom_button.js +42 -40
  3. package/build/components/conversation/jump_to_bottom_button.js.map +1 -1
  4. package/build/components/conversation/message.d.ts +2 -1
  5. package/build/components/conversation/message.d.ts.map +1 -1
  6. package/build/components/conversation/message.js +3 -1
  7. package/build/components/conversation/message.js.map +1 -1
  8. package/build/components/conversation/message_list.d.ts.map +1 -1
  9. package/build/components/conversation/message_list.js +35 -31
  10. package/build/components/conversation/message_list.js.map +1 -1
  11. package/build/components/conversation/message_reaction.d.ts +2 -1
  12. package/build/components/conversation/message_reaction.d.ts.map +1 -1
  13. package/build/components/conversation/message_reaction.js +67 -60
  14. package/build/components/conversation/message_reaction.js.map +1 -1
  15. package/build/components/conversation/messages_disabled_banners.d.ts +7 -5
  16. package/build/components/conversation/messages_disabled_banners.d.ts.map +1 -1
  17. package/build/components/conversation/messages_disabled_banners.js +15 -14
  18. package/build/components/conversation/messages_disabled_banners.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/action_button.d.ts.map +1 -1
  24. package/build/components/display/action_button.js +18 -16
  25. package/build/components/display/action_button.js.map +1 -1
  26. package/build/components/display/filter_dropdown.d.ts +13 -0
  27. package/build/components/display/filter_dropdown.d.ts.map +1 -0
  28. package/build/components/display/filter_dropdown.js +188 -0
  29. package/build/components/display/filter_dropdown.js.map +1 -0
  30. package/build/components/display/index.d.ts +1 -0
  31. package/build/components/display/index.d.ts.map +1 -1
  32. package/build/components/display/index.js +1 -0
  33. package/build/components/display/index.js.map +1 -1
  34. package/build/hooks/index.d.ts +1 -0
  35. package/build/hooks/index.d.ts.map +1 -1
  36. package/build/hooks/index.js +1 -0
  37. package/build/hooks/index.js.map +1 -1
  38. package/build/hooks/use_animated_message_background_color.d.ts.map +1 -1
  39. package/build/hooks/use_animated_message_background_color.js +5 -3
  40. package/build/hooks/use_animated_message_background_color.js.map +1 -1
  41. package/build/hooks/use_conversation.d.ts +73 -1
  42. package/build/hooks/use_conversation.d.ts.map +1 -1
  43. package/build/hooks/use_conversation.js +122 -3
  44. package/build/hooks/use_conversation.js.map +1 -1
  45. package/build/hooks/use_reduce_motion.d.ts +2 -0
  46. package/build/hooks/use_reduce_motion.d.ts.map +1 -0
  47. package/build/hooks/use_reduce_motion.js +27 -0
  48. package/build/hooks/use_reduce_motion.js.map +1 -0
  49. package/build/hooks/use_unarchive_conversation_action.d.ts +7 -0
  50. package/build/hooks/use_unarchive_conversation_action.d.ts.map +1 -0
  51. package/build/hooks/use_unarchive_conversation_action.js +52 -0
  52. package/build/hooks/use_unarchive_conversation_action.js.map +1 -0
  53. package/build/screens/attachment_actions/attachment_actions_screen.d.ts.map +1 -1
  54. package/build/screens/attachment_actions/attachment_actions_screen.js +84 -61
  55. package/build/screens/attachment_actions/attachment_actions_screen.js.map +1 -1
  56. package/build/screens/conversation_details_screen.d.ts.map +1 -1
  57. package/build/screens/conversation_details_screen.js +487 -393
  58. package/build/screens/conversation_details_screen.js.map +1 -1
  59. package/build/screens/conversation_screen.d.ts.map +1 -1
  60. package/build/screens/conversation_screen.js +12 -9
  61. package/build/screens/conversation_screen.js.map +1 -1
  62. package/build/screens/design_system_screen.d.ts.map +1 -1
  63. package/build/screens/design_system_screen.js +275 -215
  64. package/build/screens/design_system_screen.js.map +1 -1
  65. package/build/screens/message_actions_screen.d.ts.map +1 -1
  66. package/build/screens/message_actions_screen.js +112 -92
  67. package/build/screens/message_actions_screen.js.map +1 -1
  68. package/build/types/resources/member_ability.d.ts +2 -0
  69. package/build/types/resources/member_ability.d.ts.map +1 -1
  70. package/build/types/resources/member_ability.js.map +1 -1
  71. package/build/utils/archived_conversation.d.ts +5 -0
  72. package/build/utils/archived_conversation.d.ts.map +1 -0
  73. package/build/utils/archived_conversation.js +5 -0
  74. package/build/utils/archived_conversation.js.map +1 -0
  75. package/build/utils/index.d.ts +1 -0
  76. package/build/utils/index.d.ts.map +1 -1
  77. package/build/utils/index.js +1 -0
  78. package/build/utils/index.js.map +1 -1
  79. package/build/utils/native_adapters/configuration.d.ts +3 -0
  80. package/build/utils/native_adapters/configuration.d.ts.map +1 -1
  81. package/build/utils/native_adapters/configuration.js +3 -0
  82. package/build/utils/native_adapters/configuration.js.map +1 -1
  83. package/build/utils/native_adapters/index.d.ts +1 -0
  84. package/build/utils/native_adapters/index.d.ts.map +1 -1
  85. package/build/utils/native_adapters/index.js +1 -0
  86. package/build/utils/native_adapters/index.js.map +1 -1
  87. package/build/utils/native_adapters/menu.d.ts +40 -0
  88. package/build/utils/native_adapters/menu.d.ts.map +1 -0
  89. package/build/utils/native_adapters/menu.js +55 -0
  90. package/build/utils/native_adapters/menu.js.map +1 -0
  91. package/package.json +3 -3
  92. package/src/__tests__/screens/message_actions_screen.test.tsx +65 -2
  93. package/src/components/conversation/__tests__/message_list.test.tsx +68 -2
  94. package/src/components/conversation/__tests__/system_message.test.tsx +3 -1
  95. package/src/components/conversation/jump_to_bottom_button.tsx +4 -3
  96. package/src/components/conversation/message.tsx +3 -0
  97. package/src/components/conversation/message_list.tsx +9 -1
  98. package/src/components/conversation/message_reaction.tsx +7 -2
  99. package/src/components/conversation/messages_disabled_banners.tsx +17 -18
  100. package/src/components/conversation/system_message.tsx +24 -10
  101. package/src/components/display/__tests__/filter_dropdown.test.tsx +106 -0
  102. package/src/components/display/action_button.tsx +4 -3
  103. package/src/components/display/filter_dropdown.tsx +123 -0
  104. package/src/components/display/index.ts +1 -0
  105. package/src/hooks/__tests__/use_conversation_archive.test.tsx +130 -0
  106. package/src/hooks/__tests__/use_conversation_jolt_events.test.tsx +74 -0
  107. package/src/hooks/__tests__/use_reduce_motion.test.tsx +49 -0
  108. package/src/hooks/__tests__/use_unarchive_conversation_action.test.tsx +67 -0
  109. package/src/hooks/index.ts +1 -0
  110. package/src/hooks/use_animated_message_background_color.ts +6 -3
  111. package/src/hooks/use_conversation.ts +41 -3
  112. package/src/hooks/use_reduce_motion.ts +18 -0
  113. package/src/hooks/use_unarchive_conversation_action.ts +24 -0
  114. package/src/screens/__tests__/conversation_details_screen.test.tsx +289 -0
  115. package/src/screens/attachment_actions/__tests__/attachment_actions_screen.test.tsx +72 -0
  116. package/src/screens/attachment_actions/attachment_actions_screen.tsx +3 -1
  117. package/src/screens/conversation_details_screen.tsx +78 -14
  118. package/src/screens/conversation_screen.tsx +3 -1
  119. package/src/screens/design_system_screen.tsx +26 -0
  120. package/src/screens/message_actions_screen.tsx +29 -20
  121. package/src/types/resources/member_ability.ts +2 -0
  122. package/src/utils/archived_conversation.ts +13 -0
  123. package/src/utils/index.ts +1 -0
  124. package/src/utils/native_adapters/__tests__/menu.test.tsx +42 -0
  125. package/src/utils/native_adapters/configuration.ts +5 -0
  126. package/src/utils/native_adapters/index.ts +1 -0
  127. package/src/utils/native_adapters/menu.tsx +68 -0
@@ -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,18 @@
1
+ import { useEffect, useState } from 'react'
2
+ import { AccessibilityInfo } from 'react-native'
3
+
4
+ export const useReduceMotion = () => {
5
+ const [reduceMotion, setReduceMotion] = useState(false)
6
+
7
+ useEffect(() => {
8
+ AccessibilityInfo.isReduceMotionEnabled().then(setReduceMotion)
9
+
10
+ const subscription = AccessibilityInfo.addEventListener('reduceMotionChanged', setReduceMotion)
11
+
12
+ return () => {
13
+ subscription.remove()
14
+ }
15
+ }, [])
16
+
17
+ return reduceMotion
18
+ }
@@ -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
+ }
@@ -0,0 +1,289 @@
1
+ import { DefaultTheme, ThemeProvider } from '@react-navigation/native'
2
+ import { QueryClientProvider } from '@tanstack/react-query'
3
+ import { fireEvent, render } from '@testing-library/react-native'
4
+ import React from 'react'
5
+ import { Alert } from 'react-native'
6
+ import { SafeAreaProvider } from 'react-native-safe-area-context'
7
+ import { buildTestQueryClient } from '../../__utils__/query_client'
8
+ import type { ConversationResource } from '../../types'
9
+ import { ConversationDetailsScreen } from '../conversation_details_screen'
10
+
11
+ const baseConversation = {
12
+ type: 'Conversation',
13
+ id: 7,
14
+ title: 'Youth Group',
15
+ muted: false,
16
+ repliesDisabled: false,
17
+ unreadCount: 0,
18
+ unreadReaction: false,
19
+ conversationPreview: null,
20
+ createdAt: '2026-01-01T00:00:00Z',
21
+ updatedAt: '2026-01-01T00:00:00Z',
22
+ badges: [],
23
+ groups: [],
24
+ disabled: false,
25
+ disabledReason: null,
26
+ disabledMessage: null,
27
+ memberAbility: {
28
+ type: 'MemberAbility',
29
+ canUpdate: true,
30
+ canDelete: true,
31
+ canDeleteNonAuthoredMessages: true,
32
+ leader: true,
33
+ canReply: true,
34
+ canMessageAvailableMembers: false,
35
+ },
36
+ conversationMembership: { notificationLevelDescription: 'All messages' },
37
+ } as unknown as ConversationResource
38
+
39
+ const archivedConversation = {
40
+ ...baseConversation,
41
+ disabled: true,
42
+ disabledReason: 'archived',
43
+ disabledMessage: 'This conversation is archived. Only you and other leaders can unarchive.',
44
+ memberAbility: { ...baseConversation.memberAbility, canUpdate: false },
45
+ } as ConversationResource
46
+
47
+ const mockNavigation = {
48
+ navigate: jest.fn(),
49
+ dispatch: jest.fn(),
50
+ goBack: jest.fn(),
51
+ setOptions: jest.fn(),
52
+ }
53
+ jest.mock('@react-navigation/native', () => ({
54
+ ...jest.requireActual('@react-navigation/native'),
55
+ useNavigation: () => mockNavigation,
56
+ }))
57
+ jest.mock('../../hooks/use_api_client', () => ({ useApiClient: () => ({}) }))
58
+
59
+ let mockConversation: ConversationResource
60
+ const mockArchive = jest.fn()
61
+ const mockSaveTitle = jest.fn()
62
+ jest.mock('../../hooks/use_conversation', () => ({
63
+ useConversation: () => ({ data: mockConversation }),
64
+ useConversationArchive: () => ({ setArchived: mockArchive, isPending: false }),
65
+ useConversationDelete: () => ({ mutate: jest.fn() }),
66
+ useConversationDisableReplies: () => ({ repliesDisabled: false, setRepliesDisabled: jest.fn() }),
67
+ useConversationMute: () => ({ muted: false, setMuted: jest.fn() }),
68
+ useConversationUpdate: () => ({ mutate: mockSaveTitle }),
69
+ }))
70
+
71
+ let mockEnabledFeatures: string[] = []
72
+ jest.mock('../../hooks/use_features', () => ({
73
+ ...jest.requireActual('../../hooks/use_features'),
74
+ useFeatures: () => ({
75
+ features: [],
76
+ featureEnabled: (name: string) => mockEnabledFeatures.includes(name),
77
+ }),
78
+ }))
79
+
80
+ jest.mock('../../hooks/use_suspense_api', () => ({
81
+ ...jest.requireActual('../../hooks/use_suspense_api'),
82
+ useSuspensePaginator: () => ({ data: [], fetchNextPage: jest.fn(), isFetchingNextPage: false }),
83
+ }))
84
+
85
+ let mockPressDone = () => {}
86
+ jest.mock('../../components/display/platform_modal_header_buttons', () => ({
87
+ ...jest.requireActual('../../components/display/platform_modal_header_buttons'),
88
+ useHeaderRight: ({ onPress }: { onPress: () => void }) => {
89
+ mockPressDone = onPress
90
+ },
91
+ }))
92
+ jest.mock('../../components/display/conversation_avatar', () => ({
93
+ ConversationAvatar: () => null,
94
+ }))
95
+
96
+ const buildConversation = (canArchive: boolean) =>
97
+ ({
98
+ type: 'Conversation',
99
+ id: 7,
100
+ title: 'Youth Group',
101
+ memberAbility: { type: 'MemberAbility', canArchive, canDelete: false },
102
+ groups: [],
103
+ badges: [],
104
+ }) as unknown as ConversationResource
105
+
106
+ const safeAreaMetrics = {
107
+ frame: { x: 0, y: 0, width: 390, height: 844 },
108
+ insets: { top: 0, right: 0, bottom: 0, left: 0 },
109
+ }
110
+
111
+ const renderScreen = () =>
112
+ render(
113
+ <QueryClientProvider client={buildTestQueryClient()}>
114
+ <SafeAreaProvider initialMetrics={safeAreaMetrics}>
115
+ <ThemeProvider value={DefaultTheme}>
116
+ <ConversationDetailsScreen route={{ params: { conversation_id: 7 } }} />
117
+ </ThemeProvider>
118
+ </SafeAreaProvider>
119
+ </QueryClientProvider>
120
+ )
121
+
122
+ const alertButton = (text: string) => {
123
+ const buttons = jest.mocked(Alert.alert).mock.calls[0][2] ?? []
124
+ return buttons.find(button => button.text === text)
125
+ }
126
+
127
+ describe('ConversationDetailsScreen', () => {
128
+ beforeEach(() => {
129
+ mockConversation = baseConversation
130
+ mockEnabledFeatures = []
131
+ jest.clearAllMocks()
132
+ })
133
+
134
+ describe('for an active conversation', () => {
135
+ it('offers mute and the leader settings', () => {
136
+ mockEnabledFeatures = ['ROLLOUT_custom_conversation_avatars']
137
+ const { getByText } = renderScreen()
138
+
139
+ expect(getByText('Mute')).toBeTruthy()
140
+ expect(getByText('Only leaders can message')).toBeTruthy()
141
+ expect(getByText('Update avatar')).toBeTruthy()
142
+ })
143
+
144
+ it('offers notification levels when granular notifications are on', () => {
145
+ mockEnabledFeatures = ['ROLLOUT_granular_notification_preferences_ui']
146
+ const { getByText } = renderScreen()
147
+
148
+ expect(getByText('Notifications')).toBeTruthy()
149
+ })
150
+
151
+ it('saves the title on done', () => {
152
+ renderScreen()
153
+ mockPressDone()
154
+
155
+ expect(mockSaveTitle).toHaveBeenCalledWith({ title: 'Youth Group' })
156
+ })
157
+ })
158
+
159
+ describe('for an archived conversation', () => {
160
+ beforeEach(() => {
161
+ mockConversation = archivedConversation
162
+ })
163
+
164
+ it('replaces mute and the leader settings with the archived notice', () => {
165
+ mockEnabledFeatures = ['ROLLOUT_custom_conversation_avatars']
166
+ const { getByText, queryByText } = renderScreen()
167
+
168
+ expect(
169
+ getByText('This conversation is archived. Only you and other leaders can unarchive.')
170
+ ).toBeTruthy()
171
+ expect(queryByText('Mute')).toBeNull()
172
+ expect(queryByText('Only leaders can message')).toBeNull()
173
+ expect(queryByText('Update avatar')).toBeNull()
174
+ expect(getByText('Delete conversation…')).toBeTruthy()
175
+ })
176
+
177
+ it('falls back to a default notice when the server sends no message', () => {
178
+ mockConversation = { ...archivedConversation, disabledMessage: null }
179
+ const { getByText } = renderScreen()
180
+
181
+ expect(getByText('This conversation is archived.')).toBeTruthy()
182
+ })
183
+
184
+ it('hides notification levels even when granular notifications are on', () => {
185
+ mockEnabledFeatures = ['ROLLOUT_granular_notification_preferences_ui']
186
+ const { queryByText } = renderScreen()
187
+
188
+ expect(queryByText('Notifications')).toBeNull()
189
+ })
190
+
191
+ it('locks the title', () => {
192
+ const { getByDisplayValue } = renderScreen()
193
+
194
+ expect(getByDisplayValue('Youth Group').props.editable).toBe(false)
195
+ })
196
+
197
+ it('does not save the title on done', () => {
198
+ renderScreen()
199
+ mockPressDone()
200
+
201
+ expect(mockSaveTitle).not.toHaveBeenCalled()
202
+ })
203
+
204
+ it('unarchives from the notice for someone who can unarchive', () => {
205
+ mockEnabledFeatures = ['ROLLOUT_MOBILE_manual_archiving']
206
+ mockConversation = {
207
+ ...archivedConversation,
208
+ memberAbility: { ...archivedConversation.memberAbility!, canUnarchive: true },
209
+ }
210
+ const { getByText } = renderScreen()
211
+
212
+ fireEvent.press(getByText('Unarchive'))
213
+
214
+ expect(mockArchive).toHaveBeenCalledWith(false)
215
+ })
216
+ })
217
+ })
218
+
219
+ describe('ConversationDetailsScreen archive', () => {
220
+ beforeEach(() => {
221
+ jest.clearAllMocks()
222
+ jest.spyOn(Alert, 'alert').mockImplementation(() => {})
223
+ mockConversation = buildConversation(true)
224
+ mockEnabledFeatures = ['ROLLOUT_MOBILE_manual_archiving']
225
+ })
226
+
227
+ afterEach(() => {
228
+ jest.restoreAllMocks()
229
+ })
230
+
231
+ it('hides archiving when the member cannot archive', () => {
232
+ mockConversation = buildConversation(false)
233
+ const { queryByText } = renderScreen()
234
+
235
+ expect(queryByText('Archive conversation…')).toBeNull()
236
+ })
237
+
238
+ it.each(['QA_MOBILE_manual_archiving', 'ROLLOUT_MOBILE_manual_archiving'])(
239
+ 'offers archiving with only %s on',
240
+ flag => {
241
+ mockEnabledFeatures = [flag]
242
+ const { getByText } = renderScreen()
243
+
244
+ expect(getByText('Archive conversation…')).toBeTruthy()
245
+ }
246
+ )
247
+
248
+ it('hides archiving when neither mobile flag is on', () => {
249
+ mockEnabledFeatures = ['ROLLOUT_WEB_manual_archiving']
250
+ const { queryByText } = renderScreen()
251
+
252
+ expect(queryByText('Archive conversation…')).toBeNull()
253
+ })
254
+
255
+ it('asks for confirmation before archiving', () => {
256
+ const { getByText } = renderScreen()
257
+
258
+ fireEvent.press(getByText('Archive conversation…'))
259
+
260
+ expect(Alert.alert).toHaveBeenCalledWith(
261
+ 'Archive conversation',
262
+ 'This conversation will be archived for everyone. Only leaders can access and unarchive conversations.',
263
+ expect.any(Array)
264
+ )
265
+ expect(mockArchive).not.toHaveBeenCalled()
266
+ })
267
+
268
+ it('archives once confirmed and stays on the details', () => {
269
+ const { getByText } = renderScreen()
270
+ fireEvent.press(getByText('Archive conversation…'))
271
+
272
+ alertButton('Archive')?.onPress?.()
273
+
274
+ expect(mockArchive).toHaveBeenCalledTimes(1)
275
+ expect(mockArchive).toHaveBeenCalledWith(true)
276
+ expect(mockNavigation.dispatch).not.toHaveBeenCalled()
277
+ expect(mockNavigation.goBack).not.toHaveBeenCalled()
278
+ expect(mockNavigation.navigate).not.toHaveBeenCalled()
279
+ })
280
+
281
+ it('does nothing when the confirmation is cancelled', () => {
282
+ const { getByText } = renderScreen()
283
+ fireEvent.press(getByText('Archive conversation…'))
284
+
285
+ alertButton('Cancel')?.onPress?.()
286
+
287
+ expect(mockArchive).not.toHaveBeenCalled()
288
+ })
289
+ })
@@ -0,0 +1,72 @@
1
+ import { HeaderHeightContext } from '@react-navigation/elements'
2
+ import { DefaultTheme, ThemeProvider } from '@react-navigation/native'
3
+ import { render } from '@testing-library/react-native'
4
+ import React from 'react'
5
+ import { SafeAreaProvider } from 'react-native-safe-area-context'
6
+ import type { ConversationResource } from '../../../types'
7
+ import { AttachmentActionsScreen, AttachmentActionsScreenProps } from '../attachment_actions_screen'
8
+
9
+ let mockConversation = { id: 1, disabled: false } as ConversationResource
10
+
11
+ jest.mock('@react-navigation/native', () => ({
12
+ ...jest.requireActual('@react-navigation/native'),
13
+ useNavigation: () => ({ goBack: jest.fn() }),
14
+ }))
15
+ jest.mock('../../../hooks/use_conversation', () => ({
16
+ useConversation: () => ({ data: mockConversation }),
17
+ }))
18
+ jest.mock('../hooks/useDeleteAttachment', () => ({
19
+ useDeleteAttachment: () => ({ handleDeleteAttachment: jest.fn() }),
20
+ }))
21
+
22
+ const safeAreaMetrics = {
23
+ frame: { x: 0, y: 0, width: 390, height: 844 },
24
+ insets: { top: 0, right: 0, bottom: 0, left: 0 },
25
+ }
26
+
27
+ const renderScreen = (params: Partial<AttachmentActionsScreenProps['route']['params']> = {}) => {
28
+ const route = {
29
+ key: 'AttachmentActions',
30
+ name: 'AttachmentActions',
31
+ params: {
32
+ attachmentId: 'a1',
33
+ attachmentContentType: 'image/png',
34
+ attachmentUrl: 'https://example.com/a1.png',
35
+ conversation_id: 1,
36
+ canDeleteNonAuthoredMessages: false,
37
+ myMessage: true,
38
+ ...params,
39
+ },
40
+ } as AttachmentActionsScreenProps['route']
41
+
42
+ return render(
43
+ <SafeAreaProvider initialMetrics={safeAreaMetrics}>
44
+ <ThemeProvider value={DefaultTheme}>
45
+ <HeaderHeightContext.Provider value={0}>
46
+ <AttachmentActionsScreen route={route} />
47
+ </HeaderHeightContext.Provider>
48
+ </ThemeProvider>
49
+ </SafeAreaProvider>
50
+ )
51
+ }
52
+
53
+ describe('AttachmentActionsScreen', () => {
54
+ beforeEach(() => {
55
+ mockConversation = { id: 1, disabled: false } as ConversationResource
56
+ })
57
+
58
+ it('offers Delete on my own attachment', () => {
59
+ const { getByText } = renderScreen()
60
+
61
+ expect(getByText('Delete image')).toBeTruthy()
62
+ })
63
+
64
+ it('hides Delete when the conversation is disabled', () => {
65
+ mockConversation = { id: 1, disabled: true, disabledReason: 'locked' } as ConversationResource
66
+
67
+ const { queryByText, getByText } = renderScreen({ canDeleteNonAuthoredMessages: true })
68
+
69
+ expect(queryByText('Delete image')).toBeNull()
70
+ expect(getByText('Open in browser')).toBeTruthy()
71
+ })
72
+ })
@@ -2,6 +2,7 @@ import { StaticScreenProps, useNavigation } from '@react-navigation/native'
2
2
  import React, { useCallback } from 'react'
3
3
  import { Alert, Linking, StyleSheet } from 'react-native'
4
4
  import FormSheet, { getFormSheetScreenOptions } from '../../components/primitive/form_sheet'
5
+ import { useConversation } from '../../hooks/use_conversation'
5
6
  import { useDeleteAttachment } from './hooks/useDeleteAttachment'
6
7
 
7
8
  export const AttachmentActionsScreenOptions = getFormSheetScreenOptions({
@@ -28,8 +29,9 @@ export function AttachmentActionsScreen({ route }: AttachmentActionsScreenProps)
28
29
  myMessage,
29
30
  } = route.params
30
31
 
32
+ const { data: conversation } = useConversation({ conversation_id }, { refetchOnMount: false })
31
33
  const attachmentName = getAttachmentLabelName(attachmentContentType)
32
- const canDelete = myMessage || canDeleteNonAuthoredMessages
34
+ const canDelete = !conversation.disabled && (myMessage || canDeleteNonAuthoredMessages)
33
35
  const styles = useStyles()
34
36
 
35
37
  const { handleDeleteAttachment } = useDeleteAttachment({
@@ -43,6 +43,7 @@ import { ButtonAppearanceUnion } from '../components/display/utils/button_colors
43
43
  import { useSuspensePaginator, useTheme } from '../hooks'
44
44
  import {
45
45
  useConversation,
46
+ useConversationArchive,
46
47
  useConversationDelete,
47
48
  useConversationDisableReplies,
48
49
  useConversationMute,
@@ -50,9 +51,11 @@ import {
50
51
  } from '../hooks/use_conversation'
51
52
  import { availableFeatures, useFeatures } from '../hooks/use_features'
52
53
  import { usePreviewAvatarDiameter } from '../hooks/use_preview_avatar_diameter'
54
+ import { useUnarchiveConversationAction } from '../hooks/use_unarchive_conversation_action'
53
55
  import { type ConversationResource, MemberResource, isDefined } from '../types'
54
56
  import { GroupResource } from '../types/resources/group_resource'
55
57
  import { MAX_FONT_SIZE_MULTIPLIER_LANDMARK } from '../utils'
58
+ import { archivedConversationMessage, isArchivedConversation } from '../utils/archived_conversation'
56
59
  import { genderDisplayLabel } from '../utils/gender_display_label'
57
60
  import { tokens } from '../vendor/tapestry/tokens'
58
61
  // =========================================
@@ -105,9 +108,14 @@ export function ConversationDetailsScreen({ route }: ConversationDetailsScreenPr
105
108
  const { repliesDisabled, setRepliesDisabled } = useConversationDisableReplies(route.params)
106
109
  const { mutate: saveTitle } = useConversationUpdate(route.params)
107
110
  const { mutate: deleteConversation } = useConversationDelete(route.params)
111
+ const { setArchived, isPending: isArchiving } = useConversationArchive(route.params)
112
+ const unarchiveAction = useUnarchiveConversationAction(conversation)
108
113
  const { featureEnabled } = useFeatures()
109
114
  const granularNotificationsEnabled = featureEnabled(availableFeatures.granular_notifications_ui)
110
115
  const customAvatarsEnabled = featureEnabled(availableFeatures.custom_conversation_avatars)
116
+ const manualArchivingEnabled =
117
+ featureEnabled(availableFeatures.manual_archiving) ||
118
+ featureEnabled(availableFeatures.manual_archiving_rollout)
111
119
  const showGenderFilter =
112
120
  featureEnabled(availableFeatures.gender_specific_conversations) && !!conversation.genderOption
113
121
  const genderLabel = conversation.genderOption ? genderDisplayLabel(conversation.genderOption) : ''
@@ -118,8 +126,10 @@ export function ConversationDetailsScreen({ route }: ConversationDetailsScreenPr
118
126
 
119
127
  const notificationLevelDescription =
120
128
  conversation?.conversationMembership?.notificationLevelDescription
129
+ const archived = isArchivedConversation(conversation)
121
130
  const canUpdate = conversation.memberAbility?.canUpdate || false
122
131
  const canDelete = conversation.memberAbility?.canDelete || false
132
+ const canArchive = manualArchivingEnabled && (conversation.memberAbility?.canArchive || false)
123
133
  const isLeader = conversation.memberAbility?.leader || false
124
134
  const isDisabled = conversation.disabled ?? false
125
135
  const showAdultRequirementNotice = isDisabled && conversation.disabledReason === 'safety_check'
@@ -203,6 +213,23 @@ export function ConversationDetailsScreen({ route }: ConversationDetailsScreenPr
203
213
  )
204
214
  }, [deleteConversation, navigation, title])
205
215
 
216
+ const handleArchive = useCallback(() => {
217
+ Alert.alert(
218
+ 'Archive conversation',
219
+ 'This conversation will be archived for everyone. Only leaders can access and unarchive conversations.',
220
+ [
221
+ {
222
+ text: 'Cancel',
223
+ style: 'cancel',
224
+ },
225
+ {
226
+ text: 'Archive',
227
+ onPress: () => setArchived(true),
228
+ },
229
+ ]
230
+ )
231
+ }, [setArchived])
232
+
206
233
  const HeaderTitle = useCallback(
207
234
  (props: HeaderTitleProps) => {
208
235
  return (
@@ -226,7 +253,7 @@ export function ConversationDetailsScreen({ route }: ConversationDetailsScreenPr
226
253
  icon: 'general.check',
227
254
  accessibilityLabel: 'Done',
228
255
  onPress: () => {
229
- saveTitle({ title: trimmedTitle || conversation.title })
256
+ if (canUpdate) saveTitle({ title: trimmedTitle || conversation.title })
230
257
  navigation.goBack()
231
258
  },
232
259
  })
@@ -275,25 +302,47 @@ export function ConversationDetailsScreen({ route }: ConversationDetailsScreenPr
275
302
  showBottomBorder: true,
276
303
  sectionInnerStyle: styles.sectionInnerHeaderWithBottomBorder,
277
304
  },
278
- granularNotificationsEnabled
305
+ archived
279
306
  ? {
280
- type: SectionTypes.navigableSetting,
307
+ type: SectionTypes.view,
281
308
  data: {
282
- title: 'Notifications',
283
- subtitle: notificationLevelDescription,
284
- onPress: () =>
285
- navigation.navigate('ConversationNotificationLevelSelect', { conversation_id }),
309
+ children: (
310
+ <>
311
+ <Text variant="tertiary">{archivedConversationMessage(conversation)}</Text>
312
+ {unarchiveAction && (
313
+ <Button
314
+ variant="fill"
315
+ size="lg"
316
+ title={unarchiveAction.label}
317
+ onPress={unarchiveAction.onPress}
318
+ loading={unarchiveAction.loading}
319
+ style={styles.unarchiveButton}
320
+ />
321
+ )}
322
+ </>
323
+ ),
286
324
  },
287
325
  showBottomBorder: true,
288
326
  }
289
- : {
290
- type: SectionTypes.setting,
291
- data: {
292
- title: 'Mute',
293
- rightItem: <Switch value={muted} onValueChange={value => setMuted(value)} />,
327
+ : granularNotificationsEnabled
328
+ ? {
329
+ type: SectionTypes.navigableSetting,
330
+ data: {
331
+ title: 'Notifications',
332
+ subtitle: notificationLevelDescription,
333
+ onPress: () =>
334
+ navigation.navigate('ConversationNotificationLevelSelect', { conversation_id }),
335
+ },
336
+ showBottomBorder: true,
337
+ }
338
+ : {
339
+ type: SectionTypes.setting,
340
+ data: {
341
+ title: 'Mute',
342
+ rightItem: <Switch value={muted} onValueChange={value => setMuted(value)} />,
343
+ },
344
+ showBottomBorder: true,
294
345
  },
295
- showBottomBorder: true,
296
- },
297
346
  {
298
347
  type: canUpdate ? SectionTypes.setting : SectionTypes.hidden,
299
348
  data: {
@@ -304,6 +353,15 @@ export function ConversationDetailsScreen({ route }: ConversationDetailsScreenPr
304
353
  },
305
354
  showBottomBorder: true,
306
355
  },
356
+ {
357
+ type: canArchive ? SectionTypes.setting : SectionTypes.hidden,
358
+ data: {
359
+ title: 'Archive conversation…',
360
+ disabled: isArchiving,
361
+ onPress: handleArchive,
362
+ },
363
+ showBottomBorder: canDelete,
364
+ },
307
365
  {
308
366
  type: canDelete ? SectionTypes.setting : SectionTypes.hidden,
309
367
  data: {
@@ -557,6 +615,7 @@ interface SettingRowProps {
557
615
  subtitle?: string
558
616
  rightItemStyle?: ViewStyle
559
617
  appearance?: ButtonAppearanceUnion
618
+ disabled?: boolean
560
619
  onPress?: () => void
561
620
  }
562
621
 
@@ -569,6 +628,7 @@ function SettingRow({
569
628
  rightItem,
570
629
  rightItemStyle = {},
571
630
  appearance,
631
+ disabled,
572
632
  onPress,
573
633
  }: SettingRowProps) {
574
634
  const styles = useStyles()
@@ -579,6 +639,7 @@ function SettingRow({
579
639
  style={[styles.settingRow, style]}
580
640
  textStyle={buttonTextStyle}
581
641
  appearance={appearance}
642
+ disabled={disabled}
582
643
  onPress={onPress}
583
644
  >
584
645
  {title}
@@ -778,6 +839,9 @@ const useStyles = ({ isStart, isEnd }: { isStart?: boolean; isEnd?: boolean } =
778
839
  deleteButtonText: {
779
840
  fontWeight: 'normal',
780
841
  },
842
+ unarchiveButton: {
843
+ marginTop: 12,
844
+ },
781
845
  settingRowText: {
782
846
  lineHeight: 20,
783
847
  },