@planningcenter/chat-react-native 4.4.1-qa-1036.2 → 4.4.1-qa-staging.42

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 (113) hide show
  1. package/build/components/conversation/message_list.d.ts.map +1 -1
  2. package/build/components/conversation/message_list.js +155 -123
  3. package/build/components/conversation/message_list.js.map +1 -1
  4. package/build/components/conversations/conversations.d.ts.map +1 -1
  5. package/build/components/conversations/conversations.js +1 -0
  6. package/build/components/conversations/conversations.js.map +1 -1
  7. package/build/components/display/filter_dropdown.d.ts +1 -0
  8. package/build/components/display/filter_dropdown.d.ts.map +1 -1
  9. package/build/components/display/filter_dropdown.js.map +1 -1
  10. package/build/components/display/keyboard_scroll_view.d.ts.map +1 -1
  11. package/build/components/display/keyboard_scroll_view.js +1 -0
  12. package/build/components/display/keyboard_scroll_view.js.map +1 -1
  13. package/build/components/page/page_error_boundary.d.ts +9 -1
  14. package/build/components/page/page_error_boundary.d.ts.map +1 -1
  15. package/build/components/page/page_error_boundary.js +56 -31
  16. package/build/components/page/page_error_boundary.js.map +1 -1
  17. package/build/navigation/index.d.ts.map +1 -1
  18. package/build/navigation/index.js +11 -3
  19. package/build/navigation/index.js.map +1 -1
  20. package/build/navigation/page_error_fallbacks.d.ts +7 -0
  21. package/build/navigation/page_error_fallbacks.d.ts.map +1 -0
  22. package/build/navigation/page_error_fallbacks.js +7 -0
  23. package/build/navigation/page_error_fallbacks.js.map +1 -0
  24. package/build/navigation/screenLayout.d.ts.map +1 -1
  25. package/build/navigation/screenLayout.js +21 -13
  26. package/build/navigation/screenLayout.js.map +1 -1
  27. package/build/screens/age_check/age_check_required_screen.d.ts.map +1 -1
  28. package/build/screens/age_check/age_check_required_screen.js +1 -0
  29. package/build/screens/age_check/age_check_required_screen.js.map +1 -1
  30. package/build/screens/conversation_details_screen.d.ts.map +1 -1
  31. package/build/screens/conversation_details_screen.js +1 -0
  32. package/build/screens/conversation_details_screen.js.map +1 -1
  33. package/build/screens/conversation_new/components/groups_form.js +25 -18
  34. package/build/screens/conversation_new/components/groups_form.js.map +1 -1
  35. package/build/screens/conversation_new/components/services_form.js +51 -40
  36. package/build/screens/conversation_new/components/services_form.js.map +1 -1
  37. package/build/screens/conversation_select_recipients/conversation_new_entry_screen.d.ts.map +1 -1
  38. package/build/screens/conversation_select_recipients/conversation_new_entry_screen.js +1 -0
  39. package/build/screens/conversation_select_recipients/conversation_new_entry_screen.js.map +1 -1
  40. package/build/screens/conversation_select_recipients/conversation_select_direct_message_recipients_screen.d.ts.map +1 -1
  41. package/build/screens/conversation_select_recipients/conversation_select_direct_message_recipients_screen.js +332 -291
  42. package/build/screens/conversation_select_recipients/conversation_select_direct_message_recipients_screen.js.map +1 -1
  43. package/build/screens/conversation_select_recipients/conversation_select_group_recipients_screen.d.ts.map +1 -1
  44. package/build/screens/conversation_select_recipients/conversation_select_group_recipients_screen.js +1 -0
  45. package/build/screens/conversation_select_recipients/conversation_select_group_recipients_screen.js.map +1 -1
  46. package/build/screens/conversation_select_recipients/conversation_select_teams_i_lead_recipients_screen.d.ts.map +1 -1
  47. package/build/screens/conversation_select_recipients/conversation_select_teams_i_lead_recipients_screen.js +1 -0
  48. package/build/screens/conversation_select_recipients/conversation_select_teams_i_lead_recipients_screen.js.map +1 -1
  49. package/build/screens/conversations/components/filter_dropdown_list_header_component.d.ts.map +1 -1
  50. package/build/screens/conversations/components/filter_dropdown_list_header_component.js +22 -7
  51. package/build/screens/conversations/components/filter_dropdown_list_header_component.js.map +1 -1
  52. package/build/screens/get_help_screen.js +56 -62
  53. package/build/screens/get_help_screen.js.map +1 -1
  54. package/build/screens/group_notification_settings_screen.js +3 -2
  55. package/build/screens/group_notification_settings_screen.js.map +1 -1
  56. package/build/screens/notification_settings_screen.d.ts.map +1 -1
  57. package/build/screens/notification_settings_screen.js +1 -0
  58. package/build/screens/notification_settings_screen.js.map +1 -1
  59. package/build/screens/preferred_app_selection_screen.js +45 -39
  60. package/build/screens/preferred_app_selection_screen.js.map +1 -1
  61. package/build/screens/search/components/search_results_list.d.ts +4 -1
  62. package/build/screens/search/components/search_results_list.d.ts.map +1 -1
  63. package/build/screens/search/components/search_results_list.js +136 -110
  64. package/build/screens/search/components/search_results_list.js.map +1 -1
  65. package/build/screens/search/search_screen.d.ts.map +1 -1
  66. package/build/screens/search/search_screen.js +21 -28
  67. package/build/screens/search/search_screen.js.map +1 -1
  68. package/build/screens/settings_screen.d.ts.map +1 -1
  69. package/build/screens/settings_screen.js +7 -6
  70. package/build/screens/settings_screen.js.map +1 -1
  71. package/build/utils/index.d.ts +1 -0
  72. package/build/utils/index.d.ts.map +1 -1
  73. package/build/utils/index.js +1 -0
  74. package/build/utils/index.js.map +1 -1
  75. package/build/utils/membership_sync_info_text.d.ts +2 -0
  76. package/build/utils/membership_sync_info_text.d.ts.map +1 -0
  77. package/build/utils/membership_sync_info_text.js +4 -0
  78. package/build/utils/membership_sync_info_text.js.map +1 -0
  79. package/package.json +3 -3
  80. package/src/__tests__/navigation/screen_layout.test.tsx +55 -0
  81. package/src/components/conversation/__tests__/message_list.test.tsx +4 -0
  82. package/src/components/conversation/message_list.tsx +6 -0
  83. package/src/components/conversations/conversations.tsx +1 -0
  84. package/src/components/display/filter_dropdown.tsx +1 -0
  85. package/src/components/display/keyboard_scroll_view.tsx +1 -0
  86. package/src/components/page/__tests__/page_error_boundary.test.tsx +33 -0
  87. package/src/components/page/page_error_boundary.tsx +43 -12
  88. package/src/navigation/index.tsx +11 -2
  89. package/src/navigation/page_error_fallbacks.ts +14 -0
  90. package/src/navigation/screenLayout.tsx +6 -3
  91. package/src/screens/age_check/age_check_required_screen.tsx +5 -1
  92. package/src/screens/conversation_details_screen.tsx +1 -0
  93. package/src/screens/conversation_new/components/groups_form.tsx +2 -2
  94. package/src/screens/conversation_new/components/services_form.tsx +2 -2
  95. package/src/screens/conversation_select_recipients/__tests__/conversation_select_direct_message_recipients_screen.test.tsx +100 -0
  96. package/src/screens/conversation_select_recipients/conversation_new_entry_screen.tsx +4 -1
  97. package/src/screens/conversation_select_recipients/conversation_select_direct_message_recipients_screen.tsx +78 -68
  98. package/src/screens/conversation_select_recipients/conversation_select_group_recipients_screen.tsx +1 -0
  99. package/src/screens/conversation_select_recipients/conversation_select_teams_i_lead_recipients_screen.tsx +1 -0
  100. package/src/screens/conversations/components/__tests__/filter_dropdown_list_header_component.test.tsx +59 -1
  101. package/src/screens/conversations/components/filter_dropdown_list_header_component.tsx +24 -9
  102. package/src/screens/get_help_screen.tsx +4 -4
  103. package/src/screens/group_notification_settings_screen.tsx +3 -3
  104. package/src/screens/notification_settings_screen.tsx +1 -0
  105. package/src/screens/preferred_app_selection_screen.tsx +4 -4
  106. package/src/screens/search/__tests__/search_screen.test.tsx +5 -7
  107. package/src/screens/search/components/__tests__/search_results_list.test.tsx +6 -2
  108. package/src/screens/search/components/search_results_list.tsx +34 -13
  109. package/src/screens/search/search_screen.tsx +8 -18
  110. package/src/screens/settings_screen.tsx +25 -25
  111. package/src/utils/__tests__/membership_sync_info_text.test.ts +15 -0
  112. package/src/utils/index.ts +1 -0
  113. package/src/utils/membership_sync_info_text.ts +3 -0
@@ -0,0 +1,100 @@
1
+ import { fireEvent, render } from '@testing-library/react-native'
2
+ import React from 'react'
3
+ import { SafeAreaProvider } from 'react-native-safe-area-context'
4
+ import { ScreenLayout } from '../../../navigation/screenLayout'
5
+ import { Log } from '../../../utils/native_adapters/configuration'
6
+ import { ResponseError } from '../../../utils/response_error'
7
+ import { ConversationSelectDirectMessageRecipientsScreen } from '../conversation_select_direct_message_recipients_screen'
8
+
9
+ jest.mock('@react-navigation/native', () => ({
10
+ ...jest.requireActual('@react-navigation/native'),
11
+ useNavigation: () => ({ getParent: jest.fn(), goBack: jest.fn(), navigate: jest.fn() }),
12
+ useRoute: () => ({ name: 'ConversationSelectDirectMessageRecipients' }),
13
+ }))
14
+ jest.mock('../../../hooks/use_features', () => ({
15
+ ...jest.requireActual('../../../hooks/use_features'),
16
+ useFeatures: () => ({ featureEnabled: () => false }),
17
+ }))
18
+ jest.mock('../../../hooks/use_debounced_search_term', () => ({
19
+ useDebouncedSearchTerm: (term: string) => term,
20
+ }))
21
+ jest.mock('../../../hooks/direct_messages/use_direct_message_conversation_validate', () => ({
22
+ useDirectMessageConversationValidate: () => ({ warnings: [], validationPending: false }),
23
+ }))
24
+ jest.mock('../../../hooks/direct_messages/use_find_or_create_direct_message_conversation', () => ({
25
+ useFindOrCreateDirectMessageConversation: () => ({
26
+ findOrCreateConversation: jest.fn(),
27
+ directMessageExists: false,
28
+ isFinding: false,
29
+ isError: false,
30
+ }),
31
+ }))
32
+ jest.mock('../../../hooks/direct_messages/use_direct_message_affiliations', () => ({
33
+ useDirectMessageAffiliations: () => ({
34
+ groups: new Map(),
35
+ teams: new Map(),
36
+ resultsDescription: null,
37
+ }),
38
+ }))
39
+ let mockSearchFailure: Error = new TypeError('Network request failed')
40
+ jest.mock('../../../hooks/direct_messages/use_direct_message_candidates', () => ({
41
+ useDirectMessageCandidates: (term: string) => {
42
+ if (term) throw mockSearchFailure
43
+ return { data: undefined, isFetching: false }
44
+ },
45
+ }))
46
+ jest.mock('../../../components/display/action_button', () => ({ ActionButton: () => null }))
47
+ jest.mock('../../../components/display/keyboard_view', () => ({
48
+ ...jest.requireActual('../../../components/display/keyboard_view'),
49
+ KeyboardView: ({ children }: { children: React.ReactNode }) => children,
50
+ }))
51
+
52
+ const renderScreen = () =>
53
+ render(
54
+ <SafeAreaProvider
55
+ initialMetrics={{
56
+ frame: { x: 0, y: 0, width: 390, height: 844 },
57
+ insets: { top: 0, left: 0, right: 0, bottom: 0 },
58
+ }}
59
+ >
60
+ <ScreenLayout>
61
+ <ConversationSelectDirectMessageRecipientsScreen route={{ params: {} } as never} />
62
+ </ScreenLayout>
63
+ </SafeAreaProvider>
64
+ )
65
+
66
+ describe('ConversationSelectDirectMessageRecipientsScreen', () => {
67
+ let reportError: jest.SpyInstance
68
+ let consoleError: jest.SpyInstance
69
+
70
+ beforeEach(() => {
71
+ reportError = jest.spyOn(Log, 'reportError').mockImplementation(() => {})
72
+ consoleError = jest.spyOn(console, 'error').mockImplementation(() => {})
73
+ })
74
+
75
+ afterEach(() => {
76
+ reportError.mockRestore()
77
+ consoleError.mockRestore()
78
+ })
79
+
80
+ it('hands a failed search to the page error view', () => {
81
+ mockSearchFailure = new TypeError('Network request failed')
82
+ const view = renderScreen()
83
+
84
+ fireEvent.changeText(view.getByPlaceholderText('Search'), 'pi')
85
+
86
+ expect(view.getByText('Problem connecting!')).toBeTruthy()
87
+ expect(view.queryByPlaceholderText('Search')).toBeNull()
88
+ expect(reportError).toHaveBeenCalledTimes(1)
89
+ })
90
+
91
+ it("uses the screen's own wording when the search fails for no known reason", () => {
92
+ mockSearchFailure = new ResponseError({ status: 500, statusText: 'x', errors: [] } as never)
93
+ const view = renderScreen()
94
+
95
+ fireEvent.changeText(view.getByPlaceholderText('Search'), 'pi')
96
+
97
+ expect(view.getByText("Couldn't load people right now")).toBeTruthy()
98
+ expect(view.queryByText('Oops!')).toBeNull()
99
+ })
100
+ })
@@ -59,7 +59,10 @@ export const ConversationNewEntryScreen = ({ route }: ConversationSelectRecipien
59
59
  }
60
60
 
61
61
  return (
62
- <ScrollView contentContainerStyle={styles.contentContainer}>
62
+ <ScrollView
63
+ contentInsetAdjustmentBehavior="automatic"
64
+ contentContainerStyle={styles.contentContainer}
65
+ >
63
66
  <View style={styles.header}>
64
67
  <Heading variant="h2">Start a conversation by</Heading>
65
68
  </View>
@@ -1,11 +1,10 @@
1
1
  import { PlatformPressable } from '@react-navigation/elements'
2
2
  import { StackActions, useNavigation } from '@react-navigation/native'
3
- import { RefObject, useRef, useState } from 'react'
3
+ import { ReactElement, RefObject, useRef, useState } from 'react'
4
4
  import { FlatList, Pressable, StyleSheet, TextInput, View } from 'react-native'
5
5
  import { Avatar, Badge, Banner, Icon, KeyboardView, Spinner, Text } from '../../components'
6
6
  import { ActionButton } from '../../components/display/action_button'
7
7
  import { AutoFocusAwareTextInput } from '../../components/display/text_input'
8
- import { ComponentErrorBoundary } from '../../components/page/component_error_boundary'
9
8
  import { useTheme } from '../../hooks'
10
9
  import { useDirectMessageAffiliations } from '../../hooks/direct_messages/use_direct_message_affiliations'
11
10
  import { useDirectMessageCandidates } from '../../hooks/direct_messages/use_direct_message_candidates'
@@ -92,38 +91,31 @@ export function ConversationSelectDirectMessageRecipientsScreen({
92
91
 
93
92
  return (
94
93
  <KeyboardView>
95
- {isError && (
96
- <Banner
97
- appearance="error"
98
- description="There was an issue starting the conversation. Please try again."
99
- style={styles.errorBanner}
100
- />
101
- )}
102
- <ToField
103
- selectedPeople={selectedPeople}
104
- searchTerm={searchTerm}
105
- onSearchChange={setSearchTerm}
106
- onRemove={removePerson}
107
- onClear={() => setSelectedPeople(new Map())}
108
- searchInputRef={searchInputRef}
109
- />
110
94
  <View style={styles.candidateResultsContainer}>
111
- <ComponentErrorBoundary
112
- key={debouncedTerm}
113
- fallback={
114
- <Banner
115
- appearance="error"
116
- description="Couldn't load people right now. Try again."
117
- style={styles.errorBanner}
118
- />
95
+ <CandidateResults
96
+ term={debouncedTerm}
97
+ selectedPeople={selectedPeople}
98
+ onSelect={selectPerson}
99
+ header={
100
+ <>
101
+ {isError && (
102
+ <Banner
103
+ appearance="error"
104
+ description="There was an issue starting the conversation. Please try again."
105
+ style={styles.errorBanner}
106
+ />
107
+ )}
108
+ <ToField
109
+ selectedPeople={selectedPeople}
110
+ searchTerm={searchTerm}
111
+ onSearchChange={setSearchTerm}
112
+ onRemove={removePerson}
113
+ onClear={() => setSelectedPeople(new Map())}
114
+ searchInputRef={searchInputRef}
115
+ />
116
+ </>
119
117
  }
120
- >
121
- <CandidateResults
122
- term={debouncedTerm}
123
- selectedPeople={selectedPeople}
124
- onSelect={selectPerson}
125
- />
126
- </ComponentErrorBoundary>
118
+ />
127
119
  </View>
128
120
  <ActionButton
129
121
  title={buttonText}
@@ -214,53 +206,57 @@ function CandidateResults({
214
206
  term,
215
207
  selectedPeople,
216
208
  onSelect,
209
+ header,
217
210
  }: {
218
211
  term: string
219
212
  selectedPeople: Map<string, DirectMessageCandidateResource>
220
213
  onSelect: (candidate: DirectMessageCandidateResource) => void
214
+ header: ReactElement
221
215
  }) {
222
216
  const styles = useStyles()
223
217
  const { data, isFetching } = useDirectMessageCandidates(term)
224
218
  const { groups, teams, resultsDescription } = useDirectMessageAffiliations()
225
219
 
226
- if (term.length < 1) {
227
- if (selectedPeople.size > 0) return null
220
+ const unselectedCandidates = (data?.data ?? []).filter(c => !selectedPeople.has(c.id))
221
+ const hasTruncated = (data?.meta.totalCount ?? 0) > (data?.data.length ?? 0)
222
+ const isAtLimit = selectedPeople.size >= MAX_RECIPIENTS
223
+ const showsResults = term.length >= 1 && !isAtLimit && unselectedCandidates.length > 0
228
224
 
229
- return (
230
- <View style={styles.emptyState}>
231
- <Icon name="churchCenter.people" size={40} style={styles.emptyStateIcon} />
232
- <Text style={styles.emptyStateTitle}>Search for people to direct message</Text>
233
- <Text variant="secondary" style={styles.emptyStateSubtitle}>
234
- Start a conversation with people in your groups and teams.
235
- </Text>
236
- </View>
237
- )
238
- }
225
+ const emptyState = (() => {
226
+ if (showsResults) return null
239
227
 
240
- const isAtLimit = selectedPeople.size >= MAX_RECIPIENTS
228
+ if (term.length < 1) {
229
+ if (selectedPeople.size > 0) return null
241
230
 
242
- if (isAtLimit) {
243
- return (
244
- <View style={styles.emptyState}>
245
- <Text variant="secondary" style={styles.emptyStateSubtitle}>
246
- You can add up to {MAX_RECIPIENTS} people to a direct message.
247
- </Text>
248
- </View>
249
- )
250
- }
231
+ return (
232
+ <View style={styles.emptyState}>
233
+ <Icon name="churchCenter.people" size={40} style={styles.emptyStateIcon} />
234
+ <Text style={styles.emptyStateTitle}>Search for people to direct message</Text>
235
+ <Text variant="secondary" style={styles.emptyStateSubtitle}>
236
+ Start a conversation with people in your groups and teams.
237
+ </Text>
238
+ </View>
239
+ )
240
+ }
251
241
 
252
- const unselectedCandidates = (data?.data ?? []).filter(c => !selectedPeople.has(c.id))
253
- const hasTruncated = (data?.meta.totalCount ?? 0) > (data?.data.length ?? 0)
242
+ if (isAtLimit) {
243
+ return (
244
+ <View style={styles.emptyState}>
245
+ <Text variant="secondary" style={styles.emptyStateSubtitle}>
246
+ You can add up to {MAX_RECIPIENTS} people to a direct message.
247
+ </Text>
248
+ </View>
249
+ )
250
+ }
254
251
 
255
- if (isFetching && unselectedCandidates.length === 0) {
256
- return (
257
- <View style={styles.emptyState}>
258
- <Spinner />
259
- </View>
260
- )
261
- }
252
+ if (isFetching) {
253
+ return (
254
+ <View style={styles.emptyState}>
255
+ <Spinner />
256
+ </View>
257
+ )
258
+ }
262
259
 
263
- if (!isFetching && unselectedCandidates.length === 0) {
264
260
  return (
265
261
  <View style={styles.emptyState}>
266
262
  <Icon name="general.search" size={32} style={styles.emptyStateIcon} />
@@ -270,13 +266,15 @@ function CandidateResults({
270
266
  </Text>
271
267
  </View>
272
268
  )
273
- }
269
+ })()
274
270
 
275
271
  return (
276
272
  <CandidateList
277
- candidates={unselectedCandidates}
273
+ header={header}
274
+ candidates={showsResults ? unselectedCandidates : []}
278
275
  hasTruncated={hasTruncated}
279
276
  description={resultsDescription}
277
+ emptyState={emptyState}
280
278
  groups={groups}
281
279
  teams={teams}
282
280
  onSelect={onSelect}
@@ -309,27 +307,33 @@ function candidateAffiliationNames(
309
307
  }
310
308
 
311
309
  function CandidateList({
310
+ header,
312
311
  candidates,
313
312
  hasTruncated,
314
313
  description,
314
+ emptyState,
315
315
  groups,
316
316
  teams,
317
317
  onSelect,
318
318
  }: {
319
+ header: ReactElement
319
320
  candidates: DirectMessageCandidateResource[]
320
321
  hasTruncated: boolean
321
322
  description: string | null
323
+ emptyState: ReactElement | null
322
324
  groups: Map<number, string>
323
325
  teams: Map<number, string>
324
326
  onSelect: (candidate: DirectMessageCandidateResource) => void
325
327
  }) {
326
328
  const styles = useStyles()
329
+ const hasCandidates = candidates.length > 0
327
330
  const available = candidates.filter(c => !c.directMessagesDisabled)
328
331
  const dmDisabled = candidates.filter(c => c.directMessagesDisabled)
329
332
 
330
333
  return (
331
334
  <FlatList<DirectMessageCandidateResource>
332
335
  data={available}
336
+ contentInsetAdjustmentBehavior="automatic"
333
337
  keyboardShouldPersistTaps="handled"
334
338
  keyExtractor={item => item.id}
335
339
  renderItem={({ item }) => (
@@ -340,7 +344,13 @@ function CandidateList({
340
344
  onSelect={() => onSelect(item)}
341
345
  />
342
346
  )}
343
- ListHeaderComponent={<ResultsHeading description={description} />}
347
+ ListHeaderComponent={
348
+ <>
349
+ {header}
350
+ {hasCandidates && <ResultsHeading description={description} />}
351
+ </>
352
+ }
353
+ ListEmptyComponent={emptyState}
344
354
  ListFooterComponent={
345
355
  <>
346
356
  {dmDisabled.length > 0 && (
@@ -361,7 +371,7 @@ function CandidateList({
361
371
  ))}
362
372
  </>
363
373
  )}
364
- {hasTruncated && (
374
+ {hasCandidates && hasTruncated && (
365
375
  <View style={styles.truncatedFooter}>
366
376
  <Text variant="secondary" style={styles.truncatedText}>
367
377
  Keep typing to narrow your results.
@@ -60,6 +60,7 @@ export const ConversationSelectGroupRecipientsScreen = ({
60
60
 
61
61
  return (
62
62
  <SectionList<GroupsGroupResource, GroupsSection>
63
+ contentInsetAdjustmentBehavior="automatic"
63
64
  sections={sections}
64
65
  keyExtractor={item => item.id.toString()}
65
66
  stickySectionHeadersEnabled={false}
@@ -32,6 +32,7 @@ export const ConversationSelectTeamsILeadRecipientsScreen = ({
32
32
 
33
33
  return (
34
34
  <FlatList
35
+ contentInsetAdjustmentBehavior="automatic"
35
36
  data={serviceTypes}
36
37
  keyExtractor={item => item.id.toString()}
37
38
  contentContainerStyle={styles.contentContainer}
@@ -1,6 +1,6 @@
1
1
  import { fireEvent, render, screen } from '@testing-library/react-native'
2
2
  import React from 'react'
3
- import { Pressable, Text } from 'react-native'
3
+ import { Pressable, StyleSheet, Text } from 'react-native'
4
4
  import {
5
5
  AudioAdapter,
6
6
  ChatAdapters,
@@ -115,6 +115,25 @@ const setRouteParams = (params: Record<string, unknown>) => {
115
115
  const menuItemNames = () =>
116
116
  screen.getAllByRole('menuitem').map(item => item.props.accessibilityLabel)
117
117
 
118
+ const ROW_WIDTH = 360
119
+ const ROW_CONTENT_WIDTH = ROW_WIDTH - 16 * 2
120
+ const HEADING_ROW_GAP = 12
121
+ const WIDEST_LABEL_WIDTH = 150
122
+
123
+ const layOut = (testID: string, width: number) =>
124
+ fireEvent(screen.getByTestId(testID, { includeHiddenElements: true }), 'layout', {
125
+ nativeEvent: { layout: { width } },
126
+ })
127
+
128
+ const layOutHeader = ({ actionsWidth }: { actionsWidth: number }) => {
129
+ layOut('conversations-filter-heading-row', ROW_WIDTH)
130
+ layOut('conversations-filter-widest-label', WIDEST_LABEL_WIDTH)
131
+ layOut('conversations-filter-heading-actions', actionsWidth)
132
+ }
133
+
134
+ const triggerMaxWidth = (): number =>
135
+ StyleSheet.flatten(screen.getByRole('button', { name: 'All conversations' }).props.style).maxWidth
136
+
118
137
  describe('FilterDropdownListHeaderComponent', () => {
119
138
  beforeEach(() => {
120
139
  mockNavigate.mockClear()
@@ -286,6 +305,45 @@ describe('FilterDropdownListHeaderComponent', () => {
286
305
  )
287
306
  })
288
307
 
308
+ it('caps the trigger short of the room beside the actions so a full label never wraps them', () => {
309
+ render(<FilterDropdownListHeaderComponent />)
310
+
311
+ layOutHeader({ actionsWidth: 120.4 })
312
+
313
+ expect(triggerMaxWidth()).toBeGreaterThanOrEqual(WIDEST_LABEL_WIDTH)
314
+ expect(triggerMaxWidth() + HEADING_ROW_GAP + 120.4).toBeLessThan(ROW_CONTENT_WIDTH)
315
+ })
316
+
317
+ it('holds the cap when the actions remeasure by less than a point', () => {
318
+ render(<FilterDropdownListHeaderComponent />)
319
+
320
+ layOutHeader({ actionsWidth: 120.4 })
321
+ const capBefore = triggerMaxWidth()
322
+
323
+ layOut('conversations-filter-heading-actions', 120.9)
324
+
325
+ expect(triggerMaxWidth()).toBe(capBefore)
326
+ })
327
+
328
+ it('recomputes the cap when the actions grow by a button', () => {
329
+ render(<FilterDropdownListHeaderComponent />)
330
+
331
+ layOutHeader({ actionsWidth: 120.4 })
332
+ const capBefore = triggerMaxWidth()
333
+
334
+ layOut('conversations-filter-heading-actions', 160)
335
+
336
+ expect(triggerMaxWidth()).toBeLessThan(capBefore)
337
+ })
338
+
339
+ it('caps the trigger to the row once All conversations no longer fits beside the actions', () => {
340
+ render(<FilterDropdownListHeaderComponent />)
341
+
342
+ layOutHeader({ actionsWidth: 200 })
343
+
344
+ expect(triggerMaxWidth()).toBe(ROW_CONTENT_WIDTH)
345
+ })
346
+
289
347
  it('shows the search button only when search is enabled', () => {
290
348
  mockSearchEnabled.mockReturnValue(false)
291
349
  const view = render(<FilterDropdownListHeaderComponent />)
@@ -30,6 +30,8 @@ import { ListHeaderActionButton } from './list_header_action_button'
30
30
 
31
31
  const ROW_PADDING_HORIZONTAL = 16
32
32
  const HEADING_ROW_GAP = 12
33
+ const TRIGGER_CAP_SLACK = 2
34
+ const LAYOUT_JITTER = 1
33
35
  const ALL_CONVERSATIONS_LABEL = 'All conversations'
34
36
  const ARCHIVED_LABEL = 'Archived'
35
37
 
@@ -42,6 +44,9 @@ enum FilterTypes {
42
44
  More = 'More',
43
45
  }
44
46
 
47
+ const holdSubPointJitter = (previous: number | undefined, next: number) =>
48
+ previous !== undefined && Math.abs(next - previous) < LAYOUT_JITTER ? previous : next
49
+
45
50
  const FILTER_PARAMS: Record<Exclude<FilterTypes, FilterTypes.More>, ConversationFiltersParams> = {
46
51
  [FilterTypes.All]: {},
47
52
  [FilterTypes.Groups]: { group_source_app_name: 'Groups' },
@@ -217,14 +222,14 @@ export const FilterDropdownListHeaderComponent = () => {
217
222
  const [rowWidth, setRowWidth] = useState<number>()
218
223
  const [actionsWidth, setActionsWidth] = useState<number>()
219
224
  const [widestFixedLabelWidth, setWidestFixedLabelWidth] = useState<number>()
220
- const handleRowLayout = useCallback((event: LayoutChangeEvent) => {
221
- setRowWidth(event.nativeEvent.layout.width)
225
+ const handleRowLayout = useCallback(({ nativeEvent }: LayoutChangeEvent) => {
226
+ setRowWidth(previous => holdSubPointJitter(previous, nativeEvent.layout.width))
222
227
  }, [])
223
- const handleActionsLayout = useCallback((event: LayoutChangeEvent) => {
224
- setActionsWidth(event.nativeEvent.layout.width)
228
+ const handleActionsLayout = useCallback(({ nativeEvent }: LayoutChangeEvent) => {
229
+ setActionsWidth(previous => holdSubPointJitter(previous, nativeEvent.layout.width))
225
230
  }, [])
226
- const handleWidestFixedLabelLayout = useCallback((event: LayoutChangeEvent) => {
227
- setWidestFixedLabelWidth(event.nativeEvent.layout.width)
231
+ const handleWidestFixedLabelLayout = useCallback(({ nativeEvent }: LayoutChangeEvent) => {
232
+ setWidestFixedLabelWidth(previous => holdSubPointJitter(previous, nativeEvent.layout.width))
228
233
  }, [])
229
234
  const triggerMaxWidth = useMemo(() => {
230
235
  if (rowWidth === undefined) return undefined
@@ -232,7 +237,8 @@ export const FilterDropdownListHeaderComponent = () => {
232
237
  const rowContentWidth = rowWidth - ROW_PADDING_HORIZONTAL * 2
233
238
  if (actionsWidth === undefined || widestFixedLabelWidth === undefined) return rowContentWidth
234
239
 
235
- const roomBesideActions = rowContentWidth - actionsWidth - HEADING_ROW_GAP
240
+ const roomBesideActions =
241
+ Math.floor(rowContentWidth - actionsWidth - HEADING_ROW_GAP) - TRIGGER_CAP_SLACK
236
242
  return roomBesideActions >= widestFixedLabelWidth ? roomBesideActions : rowContentWidth
237
243
  }, [rowWidth, actionsWidth, widestFixedLabelWidth])
238
244
 
@@ -242,11 +248,16 @@ export const FilterDropdownListHeaderComponent = () => {
242
248
  <FilterDropdownTrigger
243
249
  label={ALL_CONVERSATIONS_LABEL}
244
250
  onLayout={handleWidestFixedLabelLayout}
251
+ testID="conversations-filter-widest-label"
245
252
  accessibilityElementsHidden
246
253
  importantForAccessibility="no-hide-descendants"
247
254
  />
248
255
  </View>
249
- <View style={styles.headingRow} onLayout={handleRowLayout}>
256
+ <View
257
+ style={styles.headingRow}
258
+ onLayout={handleRowLayout}
259
+ testID="conversations-filter-heading-row"
260
+ >
250
261
  <View style={styles.titleContainer}>
251
262
  <FilterDropdown
252
263
  options={options}
@@ -258,7 +269,11 @@ export const FilterDropdownListHeaderComponent = () => {
258
269
  maxWidth={triggerMaxWidth}
259
270
  />
260
271
  </View>
261
- <View style={styles.actionsContainer} onLayout={handleActionsLayout}>
272
+ <View
273
+ style={styles.actionsContainer}
274
+ onLayout={handleActionsLayout}
275
+ testID="conversations-filter-heading-actions"
276
+ >
262
277
  <ListHeaderActionButton
263
278
  name="general.cog"
264
279
  onPress={handleChatSettings}
@@ -1,6 +1,6 @@
1
1
  import { StaticScreenProps, useNavigation } from '@react-navigation/native'
2
2
  import { useCallback, useMemo } from 'react'
3
- import { Alert, Linking, StyleSheet, View } from 'react-native'
3
+ import { Alert, Linking, ScrollView, StyleSheet, View } from 'react-native'
4
4
  import { Heading, PressableRow, Text, TextInlineButton } from '../components'
5
5
  import { useApiGet, useTheme } from '../hooks'
6
6
  import { useAppName } from '../hooks/use_app_name'
@@ -56,7 +56,7 @@ export const GetHelpScreen = ({ route }: GetHelpScreenProps) => {
56
56
  }, [navigation])
57
57
 
58
58
  return (
59
- <View style={[styles.container]}>
59
+ <ScrollView contentInsetAdjustmentBehavior="automatic" contentContainerStyle={styles.container}>
60
60
  {appSpecificHelpUrls.length > 0 && (
61
61
  <Heading variant="h2" style={[styles.heading, styles.headingBottomBorder]}>
62
62
  How-to articles
@@ -90,7 +90,7 @@ export const GetHelpScreen = ({ route }: GetHelpScreenProps) => {
90
90
  to the development team.
91
91
  </Text>
92
92
  </View>
93
- </View>
93
+ </ScrollView>
94
94
  )
95
95
  }
96
96
 
@@ -145,7 +145,7 @@ const useStyles = () => {
145
145
 
146
146
  return StyleSheet.create({
147
147
  container: {
148
- flex: 1,
148
+ flexGrow: 1,
149
149
  },
150
150
  text: {
151
151
  paddingVertical: 4,
@@ -2,7 +2,7 @@ import { PlatformPressable } from '@react-navigation/elements'
2
2
  import { StaticScreenProps, useNavigation } from '@react-navigation/native'
3
3
  import type { NativeStackNavigationProp } from '@react-navigation/native-stack'
4
4
  import React, { useEffect } from 'react'
5
- import { Platform, StyleSheet, View } from 'react-native'
5
+ import { Platform, ScrollView, StyleSheet, View } from 'react-native'
6
6
  import { Heading, Icon, Text } from '../components'
7
7
  import { useTheme } from '../hooks'
8
8
  import { platformFontWeightBold } from '../utils/styles'
@@ -38,7 +38,7 @@ export function GroupNotificationSettingsScreen({ route }: GroupNotificationSett
38
38
  }
39
39
 
40
40
  return (
41
- <View style={styles.container}>
41
+ <ScrollView contentInsetAdjustmentBehavior="automatic" style={styles.container}>
42
42
  <View style={styles.sectionOuter}>
43
43
  <View style={styles.sectionInner}>
44
44
  <Heading variant="h2" style={styles.sectionHeading}>
@@ -76,7 +76,7 @@ export function GroupNotificationSettingsScreen({ route }: GroupNotificationSett
76
76
  )}
77
77
  </View>
78
78
  </PlatformPressable>
79
- </View>
79
+ </ScrollView>
80
80
  )
81
81
  }
82
82
 
@@ -123,6 +123,7 @@ export function NotificationSettingsScreen({}: NotificationSettingsScreenProps)
123
123
  return (
124
124
  <View style={styles.listContainer}>
125
125
  <FlatList
126
+ contentInsetAdjustmentBehavior="automatic"
126
127
  data={listData as SectionListData}
127
128
  contentContainerStyle={styles.contentContainer}
128
129
  renderItem={({ item, index }) => {
@@ -2,7 +2,7 @@ import { PlatformPressable } from '@react-navigation/elements'
2
2
  import { StaticScreenProps } from '@react-navigation/native'
3
3
  import { useMutation } from '@tanstack/react-query'
4
4
  import React, { type PropsWithChildren } from 'react'
5
- import { Image, StyleSheet, View, type ViewStyle } from 'react-native'
5
+ import { Image, ScrollView, StyleSheet, View, type ViewStyle } from 'react-native'
6
6
  import { Heading, Icon, Text } from '../components'
7
7
  import { useApiClient, useTheme } from '../hooks'
8
8
  import { useAppName } from '../hooks/use_app_name'
@@ -45,7 +45,7 @@ export function PreferredAppSelectionScreen({ route }: PreferredAppSelectionScre
45
45
  const sectionTitle = chatType ? chatTypeLabel(chatType.title) : undefined
46
46
 
47
47
  return (
48
- <View style={styles.container}>
48
+ <ScrollView contentInsetAdjustmentBehavior="automatic" contentContainerStyle={styles.container}>
49
49
  <View style={styles.section}>
50
50
  <Heading variant="h2" style={styles.sectionHeading}>
51
51
  {sectionTitle}
@@ -64,7 +64,7 @@ export function PreferredAppSelectionScreen({ route }: PreferredAppSelectionScre
64
64
  </PressableRow>
65
65
  ))}
66
66
  </View>
67
- </View>
67
+ </ScrollView>
68
68
  )
69
69
  }
70
70
 
@@ -118,7 +118,7 @@ const PressableRow = ({
118
118
 
119
119
  const styles = StyleSheet.create({
120
120
  container: {
121
- flex: 1,
121
+ flexGrow: 1,
122
122
  },
123
123
  section: {
124
124
  paddingTop: 24,
@@ -106,12 +106,13 @@ describe('SearchScreen', () => {
106
106
  fireEvent.changeText(input, ' budget ')
107
107
 
108
108
  expect(view.getByText('Search chat')).toBeTruthy()
109
- expect(mockUseSearchResults).not.toHaveBeenCalled()
109
+ const searchedTerms = () =>
110
+ new Set(mockUseSearchResults.mock.calls.map(([term]) => term).filter(term => term.trim()))
111
+ expect([...searchedTerms()]).toEqual([])
110
112
 
111
113
  act(() => jest.advanceTimersByTime(200))
112
114
 
113
- const searchedTerms = new Set(mockUseSearchResults.mock.calls.map(([term]) => term))
114
- expect([...searchedTerms]).toEqual(['budget'])
115
+ expect([...searchedTerms()]).toEqual(['budget'])
115
116
  expect(view.queryByText('Search chat')).toBeNull()
116
117
  expect(view.getByLabelText('Loading results')).toBeTruthy()
117
118
  })
@@ -121,10 +122,7 @@ describe('SearchScreen', () => {
121
122
 
122
123
  const scrollView = view.UNSAFE_getByType(ScrollView)
123
124
  expect(scrollView.props.keyboardShouldPersistTaps).toBe('handled')
124
- expect(scrollView.props.contentContainerStyle).toMatchObject({
125
- flexGrow: 1,
126
- justifyContent: 'center',
127
- })
125
+ expect(scrollView.props.contentContainerStyle).toMatchObject({ flexGrow: 1 })
128
126
  expect(view.getByText('Search chat')).toBeTruthy()
129
127
  })
130
128