@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,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
  },
@@ -40,6 +40,7 @@ import {
40
40
  usePublishProductAnalyticsEvent,
41
41
  } from '../hooks/use_product_analytics'
42
42
  import { useRestAwayFromDismissEdge } from '../hooks/use_rest_away_from_dismiss_edge'
43
+ import { useUnarchiveConversationAction } from '../hooks/use_unarchive_conversation_action'
43
44
  import { useVideoModerationJoltEvents } from '../hooks/use_video_moderation_jolt_events'
44
45
  import { ConversationResource } from '../types/resources/conversation'
45
46
  import { ConversationBadgeResource } from '../types/resources/conversation_badge'
@@ -265,12 +266,13 @@ function ConversationBottomBar({
265
266
  onBeforeSubmit,
266
267
  }: ConversationBottomBarProps) {
267
268
  const messageLink = useMessageAvailableMembersAction(conversation)
269
+ const unarchiveAction = useUnarchiveConversationAction(conversation)
268
270
 
269
271
  if (conversation.disabled)
270
272
  return (
271
273
  <ConversationDisabledBanner
272
274
  message={conversation.disabledMessage ?? null}
273
- messageLink={messageLink}
275
+ action={unarchiveAction ?? messageLink}
274
276
  />
275
277
  )
276
278
  if (!canReply) return <MemberMessagesDisabledBanner />
@@ -12,6 +12,7 @@ import {
12
12
  Button,
13
13
  ConversationAvatar,
14
14
  EmojiAvatar,
15
+ FilterDropdown,
15
16
  IconAvatar,
16
17
  ToggleButton,
17
18
  Heading,
@@ -82,6 +83,14 @@ const personChild = {
82
83
 
83
84
  const buttonPress = () => Alert.alert('Button clicked')
84
85
 
86
+ const filterDropdownOptions = [
87
+ { id: 'all', label: 'All conversations' },
88
+ { id: 'groups', label: 'Groups' },
89
+ { id: 'teams', label: 'Teams' },
90
+ { id: 'dms', label: 'DMs' },
91
+ { id: 'unread', label: 'Unread' },
92
+ ]
93
+
85
94
  // =================================
86
95
  // ====== Component ================
87
96
  // =================================
@@ -213,6 +222,7 @@ function HeadingTextSection({ isLast }: SectionProps) {
213
222
  function PressablesSection({ isLast }: SectionProps) {
214
223
  const styles = useStyles()
215
224
  const [filterSelected, setFilterSelected] = useState('Groups')
225
+ const [dropdownSelected, setDropdownSelected] = useState('all')
216
226
 
217
227
  return (
218
228
  <CollapsableSection title="Pressables" isLast={isLast}>
@@ -388,6 +398,22 @@ function PressablesSection({ isLast }: SectionProps) {
388
398
  />
389
399
  </Row>
390
400
  </Group>
401
+ <Group
402
+ title="FilterDropdown"
403
+ description="Trigger that opens the host's native menu of filter options plus a More filters action. Needs a menu adapter."
404
+ >
405
+ <Row style={styles.alignRowLeft}>
406
+ <FilterDropdown
407
+ options={filterDropdownOptions}
408
+ selectedId={dropdownSelected}
409
+ onSelect={setDropdownSelected}
410
+ onMoreFilters={() => Alert.alert('More filters')}
411
+ triggerLabel={
412
+ filterDropdownOptions.find(option => option.id === dropdownSelected)?.label ?? ''
413
+ }
414
+ />
415
+ </Row>
416
+ </Group>
391
417
  <Group
392
418
  title="IconButton"
393
419
  description="Supports different appearances, sizes, along with loading & disabled states. Use `iconStyle` for custom colors and font sizes. Requires `accessibilityLabel` as icon's don't provide context to screen readers."