@planningcenter/chat-react-native 4.4.1-qa-staging.29 → 4.4.1-qa-staging.31

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 (82) 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 +16 -0
  8. package/build/components/display/filter_dropdown.d.ts.map +1 -1
  9. package/build/components/display/filter_dropdown.js +103 -57
  10. package/build/components/display/filter_dropdown.js.map +1 -1
  11. package/build/components/display/keyboard_scroll_view.d.ts.map +1 -1
  12. package/build/components/display/keyboard_scroll_view.js +1 -0
  13. package/build/components/display/keyboard_scroll_view.js.map +1 -1
  14. package/build/hooks/direct_messages/use_direct_message_candidates.d.ts +3 -1
  15. package/build/hooks/direct_messages/use_direct_message_candidates.d.ts.map +1 -1
  16. package/build/hooks/direct_messages/use_direct_message_candidates.js +42 -29
  17. package/build/hooks/direct_messages/use_direct_message_candidates.js.map +1 -1
  18. package/build/navigation/index.d.ts.map +1 -1
  19. package/build/navigation/index.js +11 -3
  20. package/build/navigation/index.js.map +1 -1
  21. package/build/screens/conversation_details_screen.d.ts.map +1 -1
  22. package/build/screens/conversation_details_screen.js +1 -0
  23. package/build/screens/conversation_details_screen.js.map +1 -1
  24. package/build/screens/conversation_select_recipients/conversation_new_entry_screen.d.ts.map +1 -1
  25. package/build/screens/conversation_select_recipients/conversation_new_entry_screen.js +1 -0
  26. package/build/screens/conversation_select_recipients/conversation_new_entry_screen.js.map +1 -1
  27. package/build/screens/conversation_select_recipients/conversation_select_direct_message_recipients_screen.d.ts.map +1 -1
  28. package/build/screens/conversation_select_recipients/conversation_select_direct_message_recipients_screen.js +409 -308
  29. package/build/screens/conversation_select_recipients/conversation_select_direct_message_recipients_screen.js.map +1 -1
  30. package/build/screens/conversation_select_recipients/conversation_select_group_recipients_screen.d.ts.map +1 -1
  31. package/build/screens/conversation_select_recipients/conversation_select_group_recipients_screen.js +1 -0
  32. package/build/screens/conversation_select_recipients/conversation_select_group_recipients_screen.js.map +1 -1
  33. package/build/screens/conversation_select_recipients/conversation_select_teams_i_lead_recipients_screen.d.ts.map +1 -1
  34. package/build/screens/conversation_select_recipients/conversation_select_teams_i_lead_recipients_screen.js +1 -0
  35. package/build/screens/conversation_select_recipients/conversation_select_teams_i_lead_recipients_screen.js.map +1 -1
  36. package/build/screens/conversations/components/filter_dropdown_list_header_component.d.ts.map +1 -1
  37. package/build/screens/conversations/components/filter_dropdown_list_header_component.js +146 -81
  38. package/build/screens/conversations/components/filter_dropdown_list_header_component.js.map +1 -1
  39. package/build/screens/get_help_screen.js +56 -62
  40. package/build/screens/get_help_screen.js.map +1 -1
  41. package/build/screens/group_notification_settings_screen.js +3 -2
  42. package/build/screens/group_notification_settings_screen.js.map +1 -1
  43. package/build/screens/notification_settings_screen.d.ts.map +1 -1
  44. package/build/screens/notification_settings_screen.js +1 -0
  45. package/build/screens/notification_settings_screen.js.map +1 -1
  46. package/build/screens/preferred_app_selection_screen.js +45 -39
  47. package/build/screens/preferred_app_selection_screen.js.map +1 -1
  48. package/build/screens/search/components/search_results_list.d.ts +4 -1
  49. package/build/screens/search/components/search_results_list.d.ts.map +1 -1
  50. package/build/screens/search/components/search_results_list.js +176 -138
  51. package/build/screens/search/components/search_results_list.js.map +1 -1
  52. package/build/screens/search/search_screen.d.ts.map +1 -1
  53. package/build/screens/search/search_screen.js +21 -28
  54. package/build/screens/search/search_screen.js.map +1 -1
  55. package/build/screens/settings_screen.d.ts.map +1 -1
  56. package/build/screens/settings_screen.js +7 -6
  57. package/build/screens/settings_screen.js.map +1 -1
  58. package/package.json +3 -3
  59. package/src/components/conversation/__tests__/message_list.test.tsx +4 -0
  60. package/src/components/conversation/message_list.tsx +6 -0
  61. package/src/components/conversations/conversations.tsx +1 -0
  62. package/src/components/display/filter_dropdown.tsx +50 -18
  63. package/src/components/display/keyboard_scroll_view.tsx +1 -0
  64. package/src/hooks/direct_messages/use_direct_message_candidates.ts +2 -2
  65. package/src/navigation/index.tsx +11 -2
  66. package/src/screens/conversation_details_screen.tsx +1 -0
  67. package/src/screens/conversation_select_recipients/__tests__/conversation_select_direct_message_recipients_screen.test.tsx +83 -0
  68. package/src/screens/conversation_select_recipients/conversation_new_entry_screen.tsx +4 -1
  69. package/src/screens/conversation_select_recipients/conversation_select_direct_message_recipients_screen.tsx +104 -69
  70. package/src/screens/conversation_select_recipients/conversation_select_group_recipients_screen.tsx +1 -0
  71. package/src/screens/conversation_select_recipients/conversation_select_teams_i_lead_recipients_screen.tsx +1 -0
  72. package/src/screens/conversations/components/__tests__/filter_dropdown_list_header_component.test.tsx +0 -21
  73. package/src/screens/conversations/components/filter_dropdown_list_header_component.tsx +39 -8
  74. package/src/screens/get_help_screen.tsx +4 -4
  75. package/src/screens/group_notification_settings_screen.tsx +3 -3
  76. package/src/screens/notification_settings_screen.tsx +1 -0
  77. package/src/screens/preferred_app_selection_screen.tsx +4 -4
  78. package/src/screens/search/__tests__/search_screen.test.tsx +5 -7
  79. package/src/screens/search/components/__tests__/search_results_list.test.tsx +6 -2
  80. package/src/screens/search/components/search_results_list.tsx +29 -8
  81. package/src/screens/search/search_screen.tsx +8 -18
  82. package/src/screens/settings_screen.tsx +25 -25
@@ -1,6 +1,6 @@
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'
@@ -92,38 +92,31 @@ export function ConversationSelectDirectMessageRecipientsScreen({
92
92
 
93
93
  return (
94
94
  <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
95
  <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
- />
96
+ <CandidateResults
97
+ term={debouncedTerm}
98
+ selectedPeople={selectedPeople}
99
+ onSelect={selectPerson}
100
+ header={
101
+ <>
102
+ {isError && (
103
+ <Banner
104
+ appearance="error"
105
+ description="There was an issue starting the conversation. Please try again."
106
+ style={styles.errorBanner}
107
+ />
108
+ )}
109
+ <ToField
110
+ selectedPeople={selectedPeople}
111
+ searchTerm={searchTerm}
112
+ onSearchChange={setSearchTerm}
113
+ onRemove={removePerson}
114
+ onClear={() => setSelectedPeople(new Map())}
115
+ searchInputRef={searchInputRef}
116
+ />
117
+ </>
119
118
  }
120
- >
121
- <CandidateResults
122
- term={debouncedTerm}
123
- selectedPeople={selectedPeople}
124
- onSelect={selectPerson}
125
- />
126
- </ComponentErrorBoundary>
119
+ />
127
120
  </View>
128
121
  <ActionButton
129
122
  title={buttonText}
@@ -214,53 +207,74 @@ function CandidateResults({
214
207
  term,
215
208
  selectedPeople,
216
209
  onSelect,
210
+ header,
217
211
  }: {
218
212
  term: string
219
213
  selectedPeople: Map<string, DirectMessageCandidateResource>
220
214
  onSelect: (candidate: DirectMessageCandidateResource) => void
215
+ header: ReactElement
221
216
  }) {
222
217
  const styles = useStyles()
223
- const { data, isFetching } = useDirectMessageCandidates(term)
218
+ const { data, isFetching, isError } = useDirectMessageCandidates(term, { throwOnError: false })
224
219
  const { groups, teams, resultsDescription } = useDirectMessageAffiliations()
225
220
 
226
- if (term.length < 1) {
227
- if (selectedPeople.size > 0) return null
221
+ const unselectedCandidates = (data?.data ?? []).filter(c => !selectedPeople.has(c.id))
222
+ const hasTruncated = (data?.meta.totalCount ?? 0) > (data?.data.length ?? 0)
223
+ const isAtLimit = selectedPeople.size >= MAX_RECIPIENTS
224
+ const showsResults = term.length >= 1 && !isAtLimit && !isError && unselectedCandidates.length > 0
228
225
 
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
- }
226
+ const emptyState = (() => {
227
+ if (showsResults) return null
239
228
 
240
- const isAtLimit = selectedPeople.size >= MAX_RECIPIENTS
229
+ if (term.length < 1) {
230
+ if (selectedPeople.size > 0) return null
241
231
 
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
- }
232
+ return (
233
+ <View style={styles.emptyState}>
234
+ <Icon name="churchCenter.people" size={40} style={styles.emptyStateIcon} />
235
+ <Text style={styles.emptyStateTitle}>Search for people to direct message</Text>
236
+ <Text variant="secondary" style={styles.emptyStateSubtitle}>
237
+ Start a conversation with people in your groups and teams.
238
+ </Text>
239
+ </View>
240
+ )
241
+ }
251
242
 
252
- const unselectedCandidates = (data?.data ?? []).filter(c => !selectedPeople.has(c.id))
253
- const hasTruncated = (data?.meta.totalCount ?? 0) > (data?.data.length ?? 0)
243
+ if (isError) {
244
+ return (
245
+ <ComponentErrorBoundary
246
+ key={term}
247
+ fallback={
248
+ <Banner
249
+ appearance="error"
250
+ description="Couldn't load people right now. Try again."
251
+ style={styles.errorBanner}
252
+ />
253
+ }
254
+ >
255
+ <CandidateSearchError term={term} />
256
+ </ComponentErrorBoundary>
257
+ )
258
+ }
254
259
 
255
- if (isFetching && unselectedCandidates.length === 0) {
256
- return (
257
- <View style={styles.emptyState}>
258
- <Spinner />
259
- </View>
260
- )
261
- }
260
+ if (isAtLimit) {
261
+ return (
262
+ <View style={styles.emptyState}>
263
+ <Text variant="secondary" style={styles.emptyStateSubtitle}>
264
+ You can add up to {MAX_RECIPIENTS} people to a direct message.
265
+ </Text>
266
+ </View>
267
+ )
268
+ }
269
+
270
+ if (isFetching) {
271
+ return (
272
+ <View style={styles.emptyState}>
273
+ <Spinner />
274
+ </View>
275
+ )
276
+ }
262
277
 
263
- if (!isFetching && unselectedCandidates.length === 0) {
264
278
  return (
265
279
  <View style={styles.emptyState}>
266
280
  <Icon name="general.search" size={32} style={styles.emptyStateIcon} />
@@ -270,13 +284,16 @@ function CandidateResults({
270
284
  </Text>
271
285
  </View>
272
286
  )
273
- }
287
+ })()
274
288
 
275
289
  return (
276
290
  <CandidateList
277
- candidates={unselectedCandidates}
278
- hasTruncated={hasTruncated}
279
- description={resultsDescription}
291
+ header={header}
292
+ candidates={showsResults ? unselectedCandidates : []}
293
+ hasTruncated={showsResults && hasTruncated}
294
+ description={showsResults ? resultsDescription : null}
295
+ showsHeading={showsResults}
296
+ emptyState={emptyState}
280
297
  groups={groups}
281
298
  teams={teams}
282
299
  onSelect={onSelect}
@@ -284,6 +301,11 @@ function CandidateResults({
284
301
  )
285
302
  }
286
303
 
304
+ function CandidateSearchError({ term }: { term: string }) {
305
+ useDirectMessageCandidates(term)
306
+ return null
307
+ }
308
+
287
309
  function candidateBadges(candidate: DirectMessageCandidateResource) {
288
310
  const badges = []
289
311
  if (candidate.directMessagesDisabled) badges.push({ title: 'DMs off' })
@@ -309,16 +331,22 @@ function candidateAffiliationNames(
309
331
  }
310
332
 
311
333
  function CandidateList({
334
+ header,
312
335
  candidates,
313
336
  hasTruncated,
314
337
  description,
338
+ showsHeading,
339
+ emptyState,
315
340
  groups,
316
341
  teams,
317
342
  onSelect,
318
343
  }: {
344
+ header: ReactElement
319
345
  candidates: DirectMessageCandidateResource[]
320
346
  hasTruncated: boolean
321
347
  description: string | null
348
+ showsHeading: boolean
349
+ emptyState: ReactElement | null
322
350
  groups: Map<number, string>
323
351
  teams: Map<number, string>
324
352
  onSelect: (candidate: DirectMessageCandidateResource) => void
@@ -330,6 +358,7 @@ function CandidateList({
330
358
  return (
331
359
  <FlatList<DirectMessageCandidateResource>
332
360
  data={available}
361
+ contentInsetAdjustmentBehavior="automatic"
333
362
  keyboardShouldPersistTaps="handled"
334
363
  keyExtractor={item => item.id}
335
364
  renderItem={({ item }) => (
@@ -340,7 +369,13 @@ function CandidateList({
340
369
  onSelect={() => onSelect(item)}
341
370
  />
342
371
  )}
343
- ListHeaderComponent={<ResultsHeading description={description} />}
372
+ ListHeaderComponent={
373
+ <>
374
+ {header}
375
+ {showsHeading && <ResultsHeading description={description} />}
376
+ </>
377
+ }
378
+ ListEmptyComponent={emptyState}
344
379
  ListFooterComponent={
345
380
  <>
346
381
  {dmDisabled.length > 0 && (
@@ -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}
@@ -125,18 +125,6 @@ describe('FilterDropdownListHeaderComponent', () => {
125
125
  setRouteParams({})
126
126
  })
127
127
 
128
- it('caps the trigger to the row so a long label truncates instead of overflowing', () => {
129
- render(<FilterDropdownListHeaderComponent />)
130
-
131
- fireEvent(screen.getByTestId('conversations-filter-heading-row'), 'layout', {
132
- nativeEvent: { layout: { width: 360 } },
133
- })
134
-
135
- expect(screen.getByRole('button', { name: 'All conversations' })).toHaveStyle({
136
- maxWidth: 328,
137
- })
138
- })
139
-
140
128
  it('offers every filter the person can use, then More filters', () => {
141
129
  render(<FilterDropdownListHeaderComponent />)
142
130
 
@@ -311,13 +299,4 @@ describe('FilterDropdownListHeaderComponent', () => {
311
299
 
312
300
  expect(mockNavigate).toHaveBeenCalledWith('Search')
313
301
  })
314
-
315
- it('renders the search button after the other actions', () => {
316
- const view = render(<FilterDropdownListHeaderComponent />)
317
-
318
- const buttons = view.getAllByRole('button').map(button => button.props.accessibilityLabel)
319
-
320
- expect(buttons.indexOf('Search')).toBeGreaterThan(buttons.indexOf('Chat settings'))
321
- expect(buttons.indexOf('Search')).toBeGreaterThan(buttons.indexOf('New conversation'))
322
- })
323
302
  })
@@ -1,7 +1,12 @@
1
1
  import { RouteProp, useNavigation, useRoute } from '@react-navigation/native'
2
2
  import React, { useCallback, useMemo, useState } from 'react'
3
3
  import { Alert, LayoutChangeEvent, StyleSheet, View } from 'react-native'
4
- import { FilterDropdown, FilterDropdownOption, TextButton } from '../../../components'
4
+ import {
5
+ FilterDropdown,
6
+ FilterDropdownOption,
7
+ FilterDropdownTrigger,
8
+ TextButton,
9
+ } from '../../../components'
5
10
  import {
6
11
  useCanBrowseArchivedConversations,
7
12
  useCanCreateConversations,
@@ -207,19 +212,41 @@ export const FilterDropdownListHeaderComponent = () => {
207
212
 
208
213
  const styles = useStyles()
209
214
 
215
+ // Cap the trigger beside the actions while the widest fixed label fits there, so only longer
216
+ // labels truncate; past that, cap it to the row and let the actions wrap.
210
217
  const [rowWidth, setRowWidth] = useState<number>()
218
+ const [actionsWidth, setActionsWidth] = useState<number>()
219
+ const [widestFixedLabelWidth, setWidestFixedLabelWidth] = useState<number>()
211
220
  const handleRowLayout = useCallback((event: LayoutChangeEvent) => {
212
221
  setRowWidth(event.nativeEvent.layout.width)
213
222
  }, [])
214
- const triggerMaxWidth = rowWidth !== undefined ? rowWidth - ROW_PADDING_HORIZONTAL * 2 : undefined
223
+ const handleActionsLayout = useCallback((event: LayoutChangeEvent) => {
224
+ setActionsWidth(event.nativeEvent.layout.width)
225
+ }, [])
226
+ const handleWidestFixedLabelLayout = useCallback((event: LayoutChangeEvent) => {
227
+ setWidestFixedLabelWidth(event.nativeEvent.layout.width)
228
+ }, [])
229
+ const triggerMaxWidth = useMemo(() => {
230
+ if (rowWidth === undefined) return undefined
231
+
232
+ const rowContentWidth = rowWidth - ROW_PADDING_HORIZONTAL * 2
233
+ if (actionsWidth === undefined || widestFixedLabelWidth === undefined) return rowContentWidth
234
+
235
+ const roomBesideActions = rowContentWidth - actionsWidth - HEADING_ROW_GAP
236
+ return roomBesideActions >= widestFixedLabelWidth ? roomBesideActions : rowContentWidth
237
+ }, [rowWidth, actionsWidth, widestFixedLabelWidth])
215
238
 
216
239
  return (
217
240
  <View style={styles.listHeader}>
218
- <View
219
- style={styles.headingRow}
220
- onLayout={handleRowLayout}
221
- testID="conversations-filter-heading-row"
222
- >
241
+ <View style={styles.widestFixedLabel} pointerEvents="none">
242
+ <FilterDropdownTrigger
243
+ label={ALL_CONVERSATIONS_LABEL}
244
+ onLayout={handleWidestFixedLabelLayout}
245
+ accessibilityElementsHidden
246
+ importantForAccessibility="no-hide-descendants"
247
+ />
248
+ </View>
249
+ <View style={styles.headingRow} onLayout={handleRowLayout}>
223
250
  <View style={styles.titleContainer}>
224
251
  <FilterDropdown
225
252
  options={options}
@@ -231,7 +258,7 @@ export const FilterDropdownListHeaderComponent = () => {
231
258
  maxWidth={triggerMaxWidth}
232
259
  />
233
260
  </View>
234
- <View style={styles.actionsContainer}>
261
+ <View style={styles.actionsContainer} onLayout={handleActionsLayout}>
235
262
  <ListHeaderActionButton
236
263
  name="general.cog"
237
264
  onPress={handleChatSettings}
@@ -297,6 +324,10 @@ const useStyles = () => {
297
324
  titleContainer: {
298
325
  flexShrink: 0,
299
326
  },
327
+ widestFixedLabel: {
328
+ position: 'absolute',
329
+ opacity: 0,
330
+ },
300
331
  actionsContainer: {
301
332
  flexDirection: 'row',
302
333
  gap: 8,
@@ -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
 
@@ -49,9 +49,13 @@ const messageResult = (id: string): MessageSearchResultResource => ({
49
49
  },
50
50
  })
51
51
 
52
+ const slots = { header: <></>, prompt: <></> }
53
+
52
54
  const renderList = (state: Partial<SearchState>, onSettled = jest.fn()) => {
53
55
  mockUseSearchResults.mockReturnValue(searchState(state))
54
- const list = () => <SearchResultsList term="potluck" onSelect={jest.fn()} onSettled={onSettled} />
56
+ const list = () => (
57
+ <SearchResultsList term="potluck" onSelect={jest.fn()} onSettled={onSettled} {...slots} />
58
+ )
55
59
  const view = render(list())
56
60
  const rerenderWith = (next: Partial<SearchState>) => {
57
61
  mockUseSearchResults.mockReturnValue(searchState(next))
@@ -130,7 +134,7 @@ describe('SearchResultsList', () => {
130
134
  const results = [conversationResult('1'), messageResult('1'), messageResult('2')]
131
135
  mockUseSearchResults.mockReturnValue(searchState({ results }))
132
136
  const view = render(
133
- <SearchResultsList term="potluck" onSelect={onSelect} onSettled={jest.fn()} />
137
+ <SearchResultsList term="potluck" onSelect={onSelect} onSettled={jest.fn()} {...slots} />
134
138
  )
135
139
 
136
140
  fireEvent.press(view.getByLabelText(/^Message 2/))
@@ -1,4 +1,4 @@
1
- import React, { useEffect, useRef } from 'react'
1
+ import React, { ReactElement, useEffect, useRef } from 'react'
2
2
  import { FlatList, StyleSheet, View } from 'react-native'
3
3
  import { Banner, Spinner, Text, TextButton } from '../../../components'
4
4
  import { useSearchResults } from '../../../hooks/use_search_results'
@@ -23,9 +23,17 @@ interface SearchResultsListProps {
23
23
  term: string
24
24
  onSelect: (result: SearchResultResource, position: number) => void
25
25
  onSettled: (term: string, counts: SearchResultCounts) => void
26
+ header: ReactElement
27
+ prompt: ReactElement
26
28
  }
27
29
 
28
- export function SearchResultsList({ term, onSelect, onSettled }: SearchResultsListProps) {
30
+ export function SearchResultsList({
31
+ term,
32
+ onSelect,
33
+ onSettled,
34
+ header,
35
+ prompt,
36
+ }: SearchResultsListProps) {
29
37
  const {
30
38
  results,
31
39
  failedTypes,
@@ -36,9 +44,10 @@ export function SearchResultsList({ term, onSelect, onSettled }: SearchResultsLi
36
44
  hasNextPage,
37
45
  isFetchingNextPage,
38
46
  } = useSearchResults(term)
39
- const conversationResults = results.filter(isConversationResult)
40
- const messageResults = results.filter(isMessageResult)
41
- const settled = !isPending && !isError
47
+ const searching = term.trim().length > 0
48
+ const conversationResults = searching ? results.filter(isConversationResult) : []
49
+ const messageResults = searching ? results.filter(isMessageResult) : []
50
+ const settled = searching && !isPending && !isError
42
51
 
43
52
  const settledTermRef = useRef<string | undefined>(undefined)
44
53
 
@@ -56,6 +65,7 @@ export function SearchResultsList({ term, onSelect, onSettled }: SearchResultsLi
56
65
  <FlatList
57
66
  data={messageResults}
58
67
  keyExtractor={item => item.id}
68
+ contentInsetAdjustmentBehavior="automatic"
59
69
  keyboardShouldPersistTaps="handled"
60
70
  keyboardDismissMode="on-drag"
61
71
  contentContainerStyle={styles.content}
@@ -64,16 +74,20 @@ export function SearchResultsList({ term, onSelect, onSettled }: SearchResultsLi
64
74
  )}
65
75
  ListHeaderComponent={
66
76
  <>
77
+ {header}
67
78
  {conversationResults.length > 0 && (
68
79
  <ConversationResults results={conversationResults} onSelect={onSelect} />
69
80
  )}
70
- {failedTypes.includes('conversation') && <SectionFailure type="conversation" />}
81
+ {searching && failedTypes.includes('conversation') && (
82
+ <SectionFailure type="conversation" />
83
+ )}
71
84
  {messageResults.length > 0 && <ResultsSection title="Messages" />}
72
85
  </>
73
86
  }
74
87
  ListEmptyComponent={
75
88
  <ListEmpty
76
89
  term={term}
90
+ prompt={prompt}
77
91
  isError={isError}
78
92
  isPending={isPending}
79
93
  noResults={results.length === 0 && failedTypes.length === 0}
@@ -91,7 +105,7 @@ export function SearchResultsList({ term, onSelect, onSettled }: SearchResultsLi
91
105
  Try again
92
106
  </TextButton>
93
107
  )}
94
- {failedTypes.includes('message') && <SectionFailure type="message" />}
108
+ {searching && failedTypes.includes('message') && <SectionFailure type="message" />}
95
109
  </>
96
110
  }
97
111
  onEndReached={() => {
@@ -104,12 +118,15 @@ export function SearchResultsList({ term, onSelect, onSettled }: SearchResultsLi
104
118
 
105
119
  interface ListEmptyProps {
106
120
  term: string
121
+ prompt: ReactElement
107
122
  isError: boolean
108
123
  isPending: boolean
109
124
  noResults: boolean
110
125
  }
111
126
 
112
- function ListEmpty({ term, isError, isPending, noResults }: ListEmptyProps) {
127
+ function ListEmpty({ term, prompt, isError, isPending, noResults }: ListEmptyProps) {
128
+ if (term.trim().length === 0) return <View style={styles.prompt}>{prompt}</View>
129
+
113
130
  if (isError) {
114
131
  return <Banner appearance="error" description={SEARCH_ERROR_MESSAGE} style={styles.banner} />
115
132
  }
@@ -149,6 +166,10 @@ const styles = StyleSheet.create({
149
166
  content: {
150
167
  flexGrow: 1,
151
168
  },
169
+ prompt: {
170
+ flexGrow: 1,
171
+ justifyContent: 'center',
172
+ },
152
173
  banner: {
153
174
  margin: 16,
154
175
  },