@planningcenter/chat-react-native 3.38.0-rc.0 → 3.38.0-rc.10

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 (118) hide show
  1. package/build/components/conversation/message_list.d.ts +10 -0
  2. package/build/components/conversation/message_list.d.ts.map +1 -0
  3. package/build/components/conversation/message_list.js +13 -0
  4. package/build/components/conversation/message_list.js.map +1 -0
  5. package/build/components/conversations/conversations.d.ts.map +1 -1
  6. package/build/components/conversations/conversations.js +6 -16
  7. package/build/components/conversations/conversations.js.map +1 -1
  8. package/build/components/conversations/conversations_blank_state.d.ts +8 -0
  9. package/build/components/conversations/conversations_blank_state.d.ts.map +1 -0
  10. package/build/components/conversations/conversations_blank_state.js +25 -0
  11. package/build/components/conversations/conversations_blank_state.js.map +1 -0
  12. package/build/components/display/conversation_avatar.d.ts +2 -1
  13. package/build/components/display/conversation_avatar.d.ts.map +1 -1
  14. package/build/components/display/conversation_avatar.js +6 -5
  15. package/build/components/display/conversation_avatar.js.map +1 -1
  16. package/build/components/display/emoji_avatar.d.ts +3 -1
  17. package/build/components/display/emoji_avatar.d.ts.map +1 -1
  18. package/build/components/display/emoji_avatar.js +2 -2
  19. package/build/components/display/emoji_avatar.js.map +1 -1
  20. package/build/components/display/icon_avatar.d.ts +3 -1
  21. package/build/components/display/icon_avatar.d.ts.map +1 -1
  22. package/build/components/display/icon_avatar.js +2 -2
  23. package/build/components/display/icon_avatar.js.map +1 -1
  24. package/build/hooks/groups/use_group_chat_conversation_payload.d.ts.map +1 -1
  25. package/build/hooks/groups/use_group_chat_conversation_payload.js +1 -0
  26. package/build/hooks/groups/use_group_chat_conversation_payload.js.map +1 -1
  27. package/build/hooks/index.d.ts +1 -0
  28. package/build/hooks/index.d.ts.map +1 -1
  29. package/build/hooks/index.js +1 -0
  30. package/build/hooks/index.js.map +1 -1
  31. package/build/hooks/use_preview_avatar_diameter.d.ts +2 -0
  32. package/build/hooks/use_preview_avatar_diameter.d.ts.map +1 -0
  33. package/build/hooks/use_preview_avatar_diameter.js +11 -0
  34. package/build/hooks/use_preview_avatar_diameter.js.map +1 -0
  35. package/build/jest.js +1 -1
  36. package/build/jest.js.map +1 -1
  37. package/build/screens/age_check/age_check_underage_screen.js +1 -1
  38. package/build/screens/age_check/age_check_underage_screen.js.map +1 -1
  39. package/build/screens/avatar_picker/avatar_picker_screen.d.ts.map +1 -1
  40. package/build/screens/avatar_picker/avatar_picker_screen.js +11 -9
  41. package/build/screens/avatar_picker/avatar_picker_screen.js.map +1 -1
  42. package/build/screens/avatar_picker/avatar_preview.d.ts.map +1 -1
  43. package/build/screens/avatar_picker/avatar_preview.js +13 -5
  44. package/build/screens/avatar_picker/avatar_preview.js.map +1 -1
  45. package/build/screens/avatar_picker/emoji_tab.d.ts.map +1 -1
  46. package/build/screens/avatar_picker/emoji_tab.js +3 -7
  47. package/build/screens/avatar_picker/emoji_tab.js.map +1 -1
  48. package/build/screens/avatar_picker/upload_tab.d.ts.map +1 -1
  49. package/build/screens/avatar_picker/upload_tab.js +2 -1
  50. package/build/screens/avatar_picker/upload_tab.js.map +1 -1
  51. package/build/screens/conversation_details_screen.d.ts.map +1 -1
  52. package/build/screens/conversation_details_screen.js +5 -2
  53. package/build/screens/conversation_details_screen.js.map +1 -1
  54. package/build/screens/conversation_filter_recipients/components/header_row.d.ts.map +1 -1
  55. package/build/screens/conversation_filter_recipients/components/header_row.js +3 -2
  56. package/build/screens/conversation_filter_recipients/components/header_row.js.map +1 -1
  57. package/build/screens/conversation_filter_recipients/hooks/use_flattened_array_of_service_types_with_teams.d.ts.map +1 -1
  58. package/build/screens/conversation_filter_recipients/hooks/use_flattened_array_of_service_types_with_teams.js +47 -18
  59. package/build/screens/conversation_filter_recipients/hooks/use_flattened_array_of_service_types_with_teams.js.map +1 -1
  60. package/build/screens/conversation_filter_recipients/hooks/use_service_types_with_teams.d.ts +2 -1
  61. package/build/screens/conversation_filter_recipients/hooks/use_service_types_with_teams.d.ts.map +1 -1
  62. package/build/screens/conversation_filter_recipients/hooks/use_service_types_with_teams.js +23 -26
  63. package/build/screens/conversation_filter_recipients/hooks/use_service_types_with_teams.js.map +1 -1
  64. package/build/screens/conversation_filter_recipients/types.d.ts +1 -1
  65. package/build/screens/conversation_filter_recipients/types.d.ts.map +1 -1
  66. package/build/screens/conversation_filter_recipients/types.js.map +1 -1
  67. package/build/screens/conversation_screen.d.ts.map +1 -1
  68. package/build/screens/conversation_screen.js +3 -7
  69. package/build/screens/conversation_screen.js.map +1 -1
  70. package/build/screens/conversation_select_recipients/components/recipient_link_row.d.ts +1 -1
  71. package/build/screens/conversation_select_recipients/components/recipient_link_row.d.ts.map +1 -1
  72. package/build/screens/conversation_select_recipients/components/recipient_link_row.js +3 -3
  73. package/build/screens/conversation_select_recipients/components/recipient_link_row.js.map +1 -1
  74. package/build/screens/conversation_select_recipients/components/team_recipient_row.d.ts.map +1 -1
  75. package/build/screens/conversation_select_recipients/components/team_recipient_row.js +1 -1
  76. package/build/screens/conversation_select_recipients/components/team_recipient_row.js.map +1 -1
  77. package/build/screens/team_conversation_screen.d.ts.map +1 -1
  78. package/build/screens/team_conversation_screen.js +24 -1
  79. package/build/screens/team_conversation_screen.js.map +1 -1
  80. package/build/utils/client/client.d.ts +1 -1
  81. package/build/utils/client/client.d.ts.map +1 -1
  82. package/build/utils/client/client.js +7 -6
  83. package/build/utils/client/client.js.map +1 -1
  84. package/build/utils/client/instrumented_fetch.js +3 -5
  85. package/build/utils/client/instrumented_fetch.js.map +1 -1
  86. package/package.json +4 -4
  87. package/src/__tests__/hooks/use_group_chat_conversation_payload.test.tsx +50 -0
  88. package/src/__tests__/jest.ts +1 -1
  89. package/src/__tests__/utils/client.ts +32 -0
  90. package/src/components/conversation/__tests__/message_list.test.tsx +14 -0
  91. package/src/components/conversation/message_list.tsx +42 -0
  92. package/src/components/conversations/conversations.tsx +9 -16
  93. package/src/components/conversations/conversations_blank_state.tsx +42 -0
  94. package/src/components/display/conversation_avatar.tsx +7 -5
  95. package/src/components/display/emoji_avatar.tsx +10 -2
  96. package/src/components/display/icon_avatar.tsx +10 -2
  97. package/src/hooks/groups/use_group_chat_conversation_payload.ts +1 -0
  98. package/src/hooks/index.ts +1 -0
  99. package/src/hooks/use_preview_avatar_diameter.ts +12 -0
  100. package/src/jest.ts +1 -1
  101. package/src/screens/age_check/age_check_underage_screen.tsx +1 -1
  102. package/src/screens/avatar_picker/avatar_picker_screen.tsx +25 -9
  103. package/src/screens/avatar_picker/avatar_preview.tsx +14 -5
  104. package/src/screens/avatar_picker/emoji_tab.tsx +3 -6
  105. package/src/screens/avatar_picker/upload_tab.tsx +2 -0
  106. package/src/screens/conversation_details_screen.tsx +10 -1
  107. package/src/screens/conversation_filter_recipients/components/header_row.tsx +3 -2
  108. package/src/screens/conversation_filter_recipients/hooks/__tests__/use_service_types_with_teams.test.ts +108 -0
  109. package/src/screens/conversation_filter_recipients/hooks/use_flattened_array_of_service_types_with_teams.tsx +46 -19
  110. package/src/screens/conversation_filter_recipients/hooks/use_service_types_with_teams.ts +31 -29
  111. package/src/screens/conversation_filter_recipients/types.tsx +1 -1
  112. package/src/screens/conversation_screen.tsx +5 -14
  113. package/src/screens/conversation_select_recipients/components/recipient_link_row.tsx +6 -4
  114. package/src/screens/conversation_select_recipients/components/team_recipient_row.tsx +2 -1
  115. package/src/screens/team_conversation_screen.tsx +33 -1
  116. package/src/utils/client/__tests__/instrumented_fetch.test.ts +9 -5
  117. package/src/utils/client/client.ts +9 -7
  118. package/src/utils/client/instrumented_fetch.ts +3 -6
@@ -26,13 +26,21 @@ interface IconAvatarProps {
26
26
  iconKey: string
27
27
  color?: string | null
28
28
  size?: AvatarRootProps['size']
29
+ maxFontSizeMultiplier?: AvatarRootProps['maxFontSizeMultiplier']
30
+ style?: AvatarRootProps['style']
29
31
  }
30
32
 
31
33
  function findIcon(iconKey: string): IconDefinition | undefined {
32
34
  return findIconDefinition({ prefix: 'fas', iconName: iconKey as IconName })
33
35
  }
34
36
 
35
- export function IconAvatar({ iconKey, color, size = 'lg' }: IconAvatarProps) {
37
+ export function IconAvatar({
38
+ iconKey,
39
+ color,
40
+ size = 'lg',
41
+ maxFontSizeMultiplier,
42
+ style,
43
+ }: IconAvatarProps) {
36
44
  const gradientProps = getAvatarGradientProps(color)
37
45
  const iconDef = findIcon(iconKey)
38
46
 
@@ -41,7 +49,7 @@ export function IconAvatar({ iconKey, color, size = 'lg' }: IconAvatarProps) {
41
49
  }
42
50
 
43
51
  return (
44
- <AvatarPrimitive.Root size={size}>
52
+ <AvatarPrimitive.Root size={size} maxFontSizeMultiplier={maxFontSizeMultiplier} style={style}>
45
53
  <AvatarPrimitive.Mask>
46
54
  <LinearGradient {...gradientProps} style={styles.gradientFill}>
47
55
  <View style={styles.contentContainer}>
@@ -26,6 +26,7 @@ export function useGroupChatConversationPayload({ groupId, enabled = true }: Pro
26
26
  },
27
27
  }),
28
28
  staleTime: STALE_TIME_MS,
29
+ refetchOnMount: 'always',
29
30
  enabled,
30
31
  })
31
32
 
@@ -16,6 +16,7 @@ export * from './use_message_reaction_toggle'
16
16
  export * from './use_new_conversation_entry'
17
17
  export * from './use_organization'
18
18
  export * from './use_people_person'
19
+ export * from './use_preview_avatar_diameter'
19
20
  export * from './use_product_analytics'
20
21
  export * from './use_qualified_by_age'
21
22
  export * from './use_scalable_number_of_lines'
@@ -0,0 +1,12 @@
1
+ import { useWindowDimensions } from 'react-native'
2
+ import { MAX_FONT_SIZE_MULTIPLIER_LANDMARK } from '../utils'
3
+ import { useFontScale } from './use_font_scale'
4
+
5
+ const VIEWPORT_FRACTION = 0.22
6
+ const BASE_DIAMETER = 80
7
+
8
+ export function usePreviewAvatarDiameter() {
9
+ const { width } = useWindowDimensions()
10
+ const fontScale = useFontScale({ maxFontSizeMultiplier: MAX_FONT_SIZE_MULTIPLIER_LANDMARK })
11
+ return Math.min(BASE_DIAMETER * fontScale, width * VIEWPORT_FRACTION)
12
+ }
package/src/jest.ts CHANGED
@@ -16,6 +16,6 @@
16
16
  */
17
17
  export const jestTransformPackages = [
18
18
  '@planningcenter/chat-react-native',
19
+ '@planningcenter/emoji-keyboard',
19
20
  '@fortawesome',
20
- 'rn-emoji-keyboard',
21
21
  ]
@@ -31,7 +31,7 @@ export function AgeCheckUnderageScreen({ contactEmail }: AgeCheckUnderageScreenP
31
31
 
32
32
  <View style={styles.content}>
33
33
  <Heading variant="h3" style={styles.baseText}>
34
- Your age does not meet the minimum safety requirements to use chat.
34
+ Chat is only available for users 13 and older.
35
35
  </Heading>
36
36
  <Text variant="tertiary" style={styles.baseText}>
37
37
  If you submitted the wrong birthdate by accident,{` `}
@@ -13,6 +13,8 @@ import {
13
13
  type AvatarUpdatePayload,
14
14
  } from '../../hooks/use_conversation_avatar_update'
15
15
  import { useFontScale } from '../../hooks/use_font_scale'
16
+ import { usePreviewAvatarDiameter } from '../../hooks/use_preview_avatar_diameter'
17
+ import { MAX_FONT_SIZE_MULTIPLIER_LANDMARK } from '../../utils'
16
18
  import type { ImagePickerAsset } from '../../utils/native_adapters/image_picker'
17
19
  import {
18
20
  avatarPickerReducer,
@@ -71,6 +73,7 @@ function EditModeContent({ conversationId }: { conversationId: number }) {
71
73
  const navigation = useNavigation()
72
74
  const { data: conversation } = useConversation({ conversation_id: conversationId })
73
75
  const mutation = useConversationAvatarUpdate({ conversationId })
76
+ const previewDiameter = usePreviewAvatarDiameter()
74
77
 
75
78
  const [state, dispatch] = useReducer(avatarPickerReducer, conversation, initAvatarPickerState)
76
79
 
@@ -110,7 +113,14 @@ function EditModeContent({ conversationId }: { conversationId: number }) {
110
113
  </FormSheet.HeaderTextButton>
111
114
  </>
112
115
  }
113
- fallbackPreview={<ConversationAvatar conversation={conversation} size="2xl" />}
116
+ fallbackPreview={
117
+ <ConversationAvatar
118
+ conversation={conversation}
119
+ size="2xl"
120
+ maxFontSizeMultiplier={MAX_FONT_SIZE_MULTIPLIER_LANDMARK}
121
+ style={{ width: previewDiameter, height: previewDiameter }}
122
+ />
123
+ }
114
124
  />
115
125
  )
116
126
  }
@@ -187,7 +197,10 @@ function AvatarPickerFormSheet({
187
197
  activeTab={state.activeTab}
188
198
  onTabPress={tab => dispatch({ type: 'SELECT_TAB', payload: tab })}
189
199
  renderItem={({ item }) => (
190
- <Text style={[styles.tabLabel, item === state.activeTab && styles.tabLabelActive]}>
200
+ <Text
201
+ style={[styles.tabLabel, item === state.activeTab && styles.tabLabelActive]}
202
+ maxFontSizeMultiplier={MAX_FONT_SIZE_MULTIPLIER_LANDMARK}
203
+ >
191
204
  {TAB_LABELS[item]}
192
205
  </Text>
193
206
  )}
@@ -226,7 +239,15 @@ function AvatarPickerFormSheet({
226
239
 
227
240
  function EmptyAvatarPlaceholder() {
228
241
  const styles = useStyles()
229
- return <View style={styles.emptyAvatarPlaceholder} />
242
+ const diameter = usePreviewAvatarDiameter()
243
+ return (
244
+ <View
245
+ style={[
246
+ styles.emptyAvatarPlaceholder,
247
+ { width: diameter, height: diameter, borderRadius: diameter / 2 },
248
+ ]}
249
+ />
250
+ )
230
251
  }
231
252
 
232
253
  type ValidAvatarPickerState = AvatarPickerState &
@@ -264,9 +285,7 @@ function buildPayload(state: AvatarPickerState): AvatarUpdatePayload | null {
264
285
 
265
286
  const useStyles = () => {
266
287
  const { colors } = useTheme()
267
- const fontScale = useFontScale({ maxFontSizeMultiplier: 1.3 })
268
- const uncappedFontScale = useFontScale()
269
- const emptyAvatarDiameter = 80 * uncappedFontScale
288
+ const fontScale = useFontScale({ maxFontSizeMultiplier: MAX_FONT_SIZE_MULTIPLIER_LANDMARK })
270
289
 
271
290
  return StyleSheet.create({
272
291
  formSheetRoot: {
@@ -301,9 +320,6 @@ const useStyles = () => {
301
320
  borderTopColor: colors.borderColorDefaultBase,
302
321
  },
303
322
  emptyAvatarPlaceholder: {
304
- width: emptyAvatarDiameter,
305
- height: emptyAvatarDiameter,
306
- borderRadius: emptyAvatarDiameter / 2,
307
323
  borderWidth: 2,
308
324
  borderStyle: 'dashed',
309
325
  borderColor: colors.borderColorDefaultBase,
@@ -3,6 +3,8 @@ import { StyleSheet, View } from 'react-native'
3
3
  import { EmojiAvatar } from '../../components/display/emoji_avatar'
4
4
  import { IconAvatar } from '../../components/display/icon_avatar'
5
5
  import AvatarPrimitive from '../../components/primitive/avatar_primitive'
6
+ import { usePreviewAvatarDiameter } from '../../hooks/use_preview_avatar_diameter'
7
+ import { MAX_FONT_SIZE_MULTIPLIER_LANDMARK } from '../../utils'
6
8
  import type { AvatarPickerState } from './avatar_picker_state'
7
9
 
8
10
  interface AvatarPreviewProps {
@@ -10,18 +12,24 @@ interface AvatarPreviewProps {
10
12
  fallback: React.ReactNode
11
13
  }
12
14
 
13
- function getPreviewNode(state: AvatarPickerState): React.ReactNode {
15
+ function getPreviewNode(state: AvatarPickerState, diameter: number): React.ReactNode {
16
+ const sizeProps = {
17
+ size: '2xl',
18
+ maxFontSizeMultiplier: MAX_FONT_SIZE_MULTIPLIER_LANDMARK,
19
+ style: { width: diameter, height: diameter },
20
+ } as const
21
+
14
22
  switch (state.selectedType) {
15
23
  case 'icon':
16
24
  if (!state.selectedKey) return null
17
- return <IconAvatar iconKey={state.selectedKey} color={state.selectedColor} size="2xl" />
25
+ return <IconAvatar iconKey={state.selectedKey} color={state.selectedColor} {...sizeProps} />
18
26
  case 'emoji':
19
27
  if (!state.selectedKey) return null
20
- return <EmojiAvatar emoji={state.selectedKey} color={state.selectedColor} size="2xl" />
28
+ return <EmojiAvatar emoji={state.selectedKey} color={state.selectedColor} {...sizeProps} />
21
29
  case 'image':
22
30
  if (!state.imagePreviewUri) return null
23
31
  return (
24
- <AvatarPrimitive.Root size="2xl">
32
+ <AvatarPrimitive.Root {...sizeProps}>
25
33
  <AvatarPrimitive.Mask>
26
34
  <AvatarPrimitive.Image sourceUri={state.imagePreviewUri} />
27
35
  </AvatarPrimitive.Mask>
@@ -33,7 +41,8 @@ function getPreviewNode(state: AvatarPickerState): React.ReactNode {
33
41
  }
34
42
 
35
43
  export function AvatarPreview({ state, fallback }: AvatarPreviewProps) {
36
- const preview = getPreviewNode(state)
44
+ const diameter = usePreviewAvatarDiameter()
45
+ const preview = getPreviewNode(state, diameter)
37
46
  return <View style={styles.container}>{preview ?? fallback}</View>
38
47
  }
39
48
 
@@ -1,15 +1,11 @@
1
+ import { EmojiKeyboard, emojisByCategory, type EmojiType } from '@planningcenter/emoji-keyboard'
1
2
  import React, { useCallback } from 'react'
2
3
  import { StyleSheet, View } from 'react-native'
3
- import { EmojiKeyboard, type EmojiType, type EmojisByCategory } from 'rn-emoji-keyboard'
4
- // rn-emoji-keyboard exposes no public exclusion API, so we reach into its
5
- // internal src/ tree for the emoji JSON. Version is pinned in package.json
6
- // — verify this path still resolves before bumping rn-emoji-keyboard.
7
- import emojiData from 'rn-emoji-keyboard/src/assets/emojis.json'
8
4
  import { useTheme } from '../../hooks'
9
5
 
10
6
  const BLOCKED_EMOJIS = new Set(['🖕'])
11
7
 
12
- const filteredEmojis = (emojiData as EmojisByCategory[]).map(category => ({
8
+ const filteredEmojis = emojisByCategory.map(category => ({
13
9
  ...category,
14
10
  data: category.data.filter(e => !BLOCKED_EMOJIS.has(e.emoji)),
15
11
  }))
@@ -57,6 +53,7 @@ export function EmojiTab({ onEmojiSelect }: EmojiTabProps) {
57
53
  categoryPosition="top"
58
54
  emojisByCategory={filteredEmojis}
59
55
  onEmojiSelected={handleEmojiSelected}
56
+ hideHeader
60
57
  enableSearchBar
61
58
  enableRecentlyUsed
62
59
  theme={emojiTheme}
@@ -2,6 +2,7 @@ import React, { useCallback } from 'react'
2
2
  import { Alert, StyleSheet, View } from 'react-native'
3
3
  import { Button } from '../../components'
4
4
  import { useTheme } from '../../hooks'
5
+ import { MAX_FONT_SIZE_MULTIPLIER_LANDMARK } from '../../utils'
5
6
  import { ImagePicker } from '../../utils/native_adapters'
6
7
  import type { ImagePickerAsset } from '../../utils/native_adapters/image_picker'
7
8
 
@@ -43,6 +44,7 @@ export function UploadTab({ imagePreviewUri, onImageSelect }: UploadTabProps) {
43
44
  variant="outline"
44
45
  appearance="interaction"
45
46
  size="md"
47
+ maxFontSizeMultiplier={MAX_FONT_SIZE_MULTIPLIER_LANDMARK}
46
48
  />
47
49
  </View>
48
50
  )
@@ -49,8 +49,10 @@ import {
49
49
  useConversationUpdate,
50
50
  } from '../hooks/use_conversation'
51
51
  import { availableFeatures, useFeatures } from '../hooks/use_features'
52
+ import { usePreviewAvatarDiameter } from '../hooks/use_preview_avatar_diameter'
52
53
  import { type ConversationResource, MemberResource, isDefined } from '../types'
53
54
  import { GroupResource } from '../types/resources/group_resource'
55
+ import { MAX_FONT_SIZE_MULTIPLIER_LANDMARK } from '../utils'
54
56
  import { genderDisplayLabel } from '../utils/gender_display_label'
55
57
  import { tokens } from '../vendor/tapestry/tokens'
56
58
  // =========================================
@@ -634,10 +636,16 @@ function AvatarCard({
634
636
  onPress: () => void
635
637
  }) {
636
638
  const styles = useStyles()
639
+ const diameter = usePreviewAvatarDiameter()
637
640
 
638
641
  return (
639
642
  <View style={styles.avatarCard}>
640
- <ConversationAvatar conversation={conversation} size="2xl" />
643
+ <ConversationAvatar
644
+ conversation={conversation}
645
+ size="2xl"
646
+ maxFontSizeMultiplier={MAX_FONT_SIZE_MULTIPLIER_LANDMARK}
647
+ style={{ width: diameter, height: diameter }}
648
+ />
641
649
  <Button
642
650
  title="Update avatar"
643
651
  iconNameLeft="general.pencil"
@@ -645,6 +653,7 @@ function AvatarCard({
645
653
  variant="outline"
646
654
  appearance="interaction"
647
655
  size="sm"
656
+ maxFontSizeMultiplier={MAX_FONT_SIZE_MULTIPLIER_LANDMARK}
648
657
  />
649
658
  </View>
650
659
  )
@@ -24,6 +24,7 @@ export const HeaderRow = ({ data, nativeID, style, setTeamFilters }: HeaderRowPr
24
24
  const route = useRoute<RouteProp<ConversationFilterRecipientsScreenProps['route']>>()
25
25
 
26
26
  const { serviceTypeName, teamIdsForServiceType } = data
27
+ const displayName = serviceTypeName ?? 'No service type'
27
28
  const { team_ids: currentTeamIds = [] } = route.params
28
29
 
29
30
  const newTeamIdsAdded = [...new Set([...currentTeamIds, ...teamIdsForServiceType])]
@@ -34,7 +35,7 @@ export const HeaderRow = ({ data, nativeID, style, setTeamFilters }: HeaderRowPr
34
35
  const selectLabel = allTeamsSelected ? 'Deselect' : 'Select'
35
36
 
36
37
  const headingAccessibilityHint = `${pluralize(teamIdsForServiceType.length, 'team')} available to select`
37
- const selectAllAccessibilityLabel = `${selectLabel} ${pluralize(teamIdsForServiceType.length, 'team')} for ${serviceTypeName}`
38
+ const selectAllAccessibilityLabel = `${selectLabel} ${pluralize(teamIdsForServiceType.length, 'team')} for ${displayName}`
38
39
 
39
40
  const handleSelectAll = () => {
40
41
  setTeamFilters({
@@ -53,7 +54,7 @@ export const HeaderRow = ({ data, nativeID, style, setTeamFilters }: HeaderRowPr
53
54
  nativeID={nativeID}
54
55
  accessibilityHint={headingAccessibilityHint}
55
56
  >
56
- {serviceTypeName}
57
+ {displayName}
57
58
  </Heading>
58
59
  </View>
59
60
 
@@ -0,0 +1,108 @@
1
+ import type { TeamResponseItem } from '../../../../types'
2
+ import { decorateTeamResponseItems } from '../use_service_types_with_teams'
3
+
4
+ const makeTeam = (
5
+ overrides: Partial<TeamResponseItem> & { teamId: number; teamName: string }
6
+ ): TeamResponseItem => ({
7
+ name: overrides.teamName,
8
+ value: {
9
+ teamId: overrides.teamId,
10
+ serviceTypeId: overrides.value?.serviceTypeId ?? 0,
11
+ serviceTypeIds: overrides.value?.serviceTypeIds ?? [],
12
+ },
13
+ serviceTypeName: overrides.serviceTypeName ?? '',
14
+ serviceTypeNames: overrides.serviceTypeNames ?? [],
15
+ serviceTypeAcronyms: overrides.serviceTypeAcronyms ?? [],
16
+ teamName: overrides.teamName,
17
+ order: overrides.order ?? [0, '', overrides.teamName],
18
+ })
19
+
20
+ describe('decorateTeamResponseItems', () => {
21
+ it('groups teams under their service types', () => {
22
+ const items = [
23
+ makeTeam({
24
+ teamId: 1,
25
+ teamName: 'Worship',
26
+ value: { teamId: 1, serviceTypeId: 10, serviceTypeIds: [10] },
27
+ serviceTypeNames: ['Sunday Morning'],
28
+ }),
29
+ ]
30
+
31
+ const result = decorateTeamResponseItems(items)
32
+
33
+ expect(result).toHaveLength(1)
34
+ expect(result[0]).toMatchObject({ id: 10, name: 'Sunday Morning', teams: [{ id: 1 }] })
35
+ })
36
+
37
+ it('gives each team without a service type its own bucket', () => {
38
+ const items = [
39
+ makeTeam({
40
+ teamId: 58,
41
+ teamName: 'Services Team 58',
42
+ value: { teamId: 58, serviceTypeId: 0, serviceTypeIds: [] },
43
+ serviceTypeNames: [],
44
+ }),
45
+ makeTeam({
46
+ teamId: 99,
47
+ teamName: 'Another Typeless Team',
48
+ value: { teamId: 99, serviceTypeId: 0, serviceTypeIds: [] },
49
+ serviceTypeNames: [],
50
+ }),
51
+ ]
52
+
53
+ const result = decorateTeamResponseItems(items)
54
+
55
+ expect(result).toHaveLength(2)
56
+ expect(result[0]).toMatchObject({ id: -58, name: 'Services Team 58', teams: [{ id: 58 }] })
57
+ expect(result[1]).toMatchObject({
58
+ id: -99,
59
+ name: 'Another Typeless Team',
60
+ teams: [{ id: 99 }],
61
+ })
62
+ })
63
+
64
+ it('places teams with and without service types in the right buckets', () => {
65
+ const items = [
66
+ makeTeam({
67
+ teamId: 1,
68
+ teamName: 'Worship',
69
+ value: { teamId: 1, serviceTypeId: 10, serviceTypeIds: [10] },
70
+ serviceTypeNames: ['Sunday Morning'],
71
+ }),
72
+ makeTeam({
73
+ teamId: 58,
74
+ teamName: 'Services Team 58',
75
+ value: { teamId: 58, serviceTypeId: 0, serviceTypeIds: [] },
76
+ serviceTypeNames: [],
77
+ }),
78
+ ]
79
+
80
+ const result = decorateTeamResponseItems(items)
81
+
82
+ expect(result).toHaveLength(2)
83
+ expect(result[0]).toMatchObject({ id: -58, name: 'Services Team 58', teams: [{ id: 58 }] })
84
+ expect(result[1]).toMatchObject({ id: 10, name: 'Sunday Morning' })
85
+ })
86
+
87
+ it('filters by search query, matching teams without service types by name', () => {
88
+ const items = [
89
+ makeTeam({
90
+ teamId: 58,
91
+ teamName: 'Services Team 58',
92
+ value: { teamId: 58, serviceTypeId: 0, serviceTypeIds: [] },
93
+ serviceTypeNames: [],
94
+ }),
95
+ makeTeam({
96
+ teamId: 99,
97
+ teamName: 'Unrelated Team',
98
+ value: { teamId: 99, serviceTypeId: 0, serviceTypeIds: [] },
99
+ serviceTypeNames: [],
100
+ }),
101
+ ]
102
+
103
+ const result = decorateTeamResponseItems(items, 'Services Team')
104
+
105
+ expect(result).toHaveLength(1)
106
+ expect(result[0]).toMatchObject({ id: -58, name: 'Services Team 58', teams: [{ id: 58 }] })
107
+ })
108
+ })
@@ -13,9 +13,10 @@ export function useFlattenedArrayOfServiceTypesWithTeams({
13
13
  firstRowStyle,
14
14
  lastRowStyle,
15
15
  }: Props) {
16
- const flattenedData: SectionListData = useMemo(
17
- () =>
18
- data.flatMap(serviceType => {
16
+ const flattenedData: SectionListData = useMemo(() => {
17
+ const serviceTypeRows = data
18
+ .filter(serviceType => serviceType.id > 0)
19
+ .flatMap(serviceType => {
19
20
  const teamIdsForServiceType = serviceType.teams.map(team => team.id)
20
21
 
21
22
  return [
@@ -28,23 +29,49 @@ export function useFlattenedArrayOfServiceTypesWithTeams({
28
29
  },
29
30
  sectionStyle: firstRowStyle,
30
31
  },
31
- ...serviceType.teams.map((team, teamIdx) => {
32
- const isLastTeamInServiceType = teamIdx === serviceType.teams.length - 1
33
-
34
- return {
35
- type: SectionTypes.team as const,
36
- data: {
37
- teamName: team.name,
38
- teamId: team.id,
39
- serviceTypeId: serviceType.id,
40
- },
41
- sectionStyle: isLastTeamInServiceType ? lastRowStyle : undefined,
42
- }
43
- }),
32
+ ...serviceType.teams.map((team, teamIdx) => ({
33
+ type: SectionTypes.team as const,
34
+ data: {
35
+ teamName: team.name,
36
+ teamId: team.id,
37
+ serviceTypeId: serviceType.id,
38
+ },
39
+ sectionStyle: teamIdx === serviceType.teams.length - 1 ? lastRowStyle : undefined,
40
+ })),
44
41
  ]
45
- }),
46
- [data, firstRowStyle, lastRowStyle]
47
- )
42
+ })
43
+
44
+ // Teams without a service type (id < 0) are merged under a single "No service type" section.
45
+ // Service type ID 0 is a safe sentinel — real IDs are always positive.
46
+ const serviceTypelessTeams = data
47
+ .filter(serviceType => serviceType.id < 0)
48
+ .flatMap(serviceType => serviceType.teams)
49
+
50
+ if (serviceTypelessTeams.length === 0) return serviceTypeRows
51
+
52
+ const serviceTypelessRows: SectionListData = [
53
+ {
54
+ type: SectionTypes.header as const,
55
+ data: {
56
+ serviceTypeName: null,
57
+ serviceTypeId: 0,
58
+ teamIdsForServiceType: serviceTypelessTeams.map(t => t.id),
59
+ },
60
+ sectionStyle: firstRowStyle,
61
+ },
62
+ ...serviceTypelessTeams.map((team, teamIdx) => ({
63
+ type: SectionTypes.team as const,
64
+ data: {
65
+ teamName: team.name,
66
+ teamId: team.id,
67
+ serviceTypeId: 0,
68
+ },
69
+ sectionStyle: teamIdx === serviceTypelessTeams.length - 1 ? lastRowStyle : undefined,
70
+ })),
71
+ ]
72
+
73
+ return [...serviceTypelessRows, ...serviceTypeRows]
74
+ }, [data, firstRowStyle, lastRowStyle])
48
75
 
49
76
  return flattenedData
50
77
  }
@@ -59,45 +59,47 @@ const useTeams = ({ filterType }: { filterType: TeamFilterTypes }) => {
59
59
  return { data: result || [], ...rest }
60
60
  }
61
61
 
62
- function decorateTeamResponseItems(teamResponseItems: TeamResponseItem[], searchQuery?: string) {
63
- return teamResponseItems
64
- .filter(item => {
65
- if (!searchQuery) return true
62
+ export function decorateTeamResponseItems(
63
+ teamResponseItems: TeamResponseItem[],
64
+ searchQuery?: string
65
+ ) {
66
+ const filtered = teamResponseItems.filter(item => {
67
+ if (!searchQuery) return true
68
+ const evalMatch = (str: string) => str.toLowerCase().includes(searchQuery.toLowerCase())
69
+ return evalMatch(item.name) || evalMatch(item.serviceTypeNames?.join(',') || '')
70
+ })
66
71
 
67
- const evalMatch = (str: string) => str.toLowerCase().includes(searchQuery.toLowerCase())
68
- const teamNameMatch = evalMatch(item.name)
69
- const serviceTypeNamesMatch = evalMatch(item.serviceTypeNames?.join(',') || '')
72
+ const withServiceTypes = filtered.filter(item => item.value.serviceTypeIds.length > 0)
73
+ const withoutServiceTypes = filtered.filter(item => item.value.serviceTypeIds.length === 0)
70
74
 
71
- return teamNameMatch || serviceTypeNamesMatch
72
- })
73
- .map(({ value, serviceTypeNames, teamName }) => {
74
- return {
75
- service_types: value.serviceTypeIds.map((serviceTypeId, i) => ({
76
- id: serviceTypeId,
77
- name: serviceTypeNames[i],
78
- })),
79
- team: {
80
- id: value.teamId,
81
- name: teamName,
82
- },
83
- }
75
+ // Negative team ID is used as a unique sentinel — real service type IDs are always positive.
76
+ const typelessEntries: ServiceTypeWithTeams[] = withoutServiceTypes.map(
77
+ ({ value, teamName }) => ({
78
+ id: -value.teamId,
79
+ name: teamName,
80
+ teams: [{ id: value.teamId, name: teamName }],
84
81
  })
82
+ )
83
+
84
+ const typedEntries = withServiceTypes
85
+ .map(({ value, serviceTypeNames, teamName }) => ({
86
+ service_types: value.serviceTypeIds.map((serviceTypeId, i) => ({
87
+ id: serviceTypeId,
88
+ name: serviceTypeNames[i],
89
+ })),
90
+ team: { id: value.teamId, name: teamName },
91
+ }))
85
92
  .reduce((acc: ServiceTypeWithTeams[], { service_types, team }) => {
86
93
  service_types.forEach(serviceType => {
87
94
  let serviceTypeEntry = acc.find(entry => entry.id === serviceType.id)
88
-
89
95
  if (!serviceTypeEntry) {
90
- serviceTypeEntry = {
91
- id: serviceType.id,
92
- name: serviceType.name,
93
- teams: [],
94
- }
96
+ serviceTypeEntry = { id: serviceType.id, name: serviceType.name, teams: [] }
95
97
  acc.push(serviceTypeEntry)
96
98
  }
97
-
98
- const initialTeams = serviceTypeEntry.teams
99
- serviceTypeEntry.teams = uniqBy([...initialTeams, team], 'id')
99
+ serviceTypeEntry.teams = uniqBy([...serviceTypeEntry.teams, team], 'id')
100
100
  })
101
101
  return acc
102
102
  }, [])
103
+
104
+ return [...typelessEntries, ...typedEntries]
103
105
  }
@@ -17,7 +17,7 @@ export enum SectionTypes {
17
17
  }
18
18
 
19
19
  export interface ServiceTypeProps {
20
- serviceTypeName: string
20
+ serviceTypeName: string | null
21
21
  serviceTypeId: number
22
22
  teamIdsForServiceType: number[]
23
23
  }
@@ -9,7 +9,8 @@ import {
9
9
  useRoute,
10
10
  } from '@react-navigation/native'
11
11
  import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
12
- import { ActivityIndicator, FlatList, Platform, StyleSheet, View } from 'react-native'
12
+ import { ActivityIndicator, Platform, StyleSheet, View } from 'react-native'
13
+ import type { FlatList } from 'react-native-gesture-handler'
13
14
  import Animated, { FadeIn, FadeOut } from 'react-native-reanimated'
14
15
  import { useSafeAreaInsets } from 'react-native-safe-area-context'
15
16
  import { Badge, Icon, Text } from '../components'
@@ -17,6 +18,7 @@ import { EmptyConversationBlankState } from '../components/conversation/empty_co
17
18
  import { JumpToBottomButton } from '../components/conversation/jump_to_bottom_button'
18
19
  import { Message } from '../components/conversation/message'
19
20
  import { MessageForm } from '../components/conversation/message_form'
21
+ import { MessageList } from '../components/conversation/message_list'
20
22
  import {
21
23
  ConversationDisabledBanner,
22
24
  LeaderMessagesDisabledBanner,
@@ -85,9 +87,6 @@ export type ConversationRouteProps = {
85
87
 
86
88
  export type ConversationScreenProps = StaticScreenProps<ConversationRouteProps>
87
89
 
88
- const extractItemKey = (item: EnrichedMessage) => String(item.id)
89
- const maintainVisibleContentPosition = { minIndexForVisible: 0 }
90
-
91
90
  export function ConversationScreen({ route }: ConversationScreenProps) {
92
91
  const { conversation_id, message_id, reply_root_id } = route.params
93
92
 
@@ -300,16 +299,11 @@ function ConversationScreenContent({ route }: ConversationScreenProps) {
300
299
  {noMessages ? (
301
300
  <EmptyConversationBlankState />
302
301
  ) : (
303
- <FlatList
304
- inverted
305
- ref={listRef}
306
- contentContainerStyle={styles.listContainer}
307
- maintainVisibleContentPosition={maintainVisibleContentPosition}
302
+ <MessageList
303
+ listRef={listRef}
308
304
  data={items}
309
- keyExtractor={extractItemKey}
310
305
  onScroll={onScroll}
311
306
  onScrollBeginDrag={onScrollBeginDrag}
312
- scrollEventThrottle={64}
313
307
  viewabilityConfigCallbackPairs={viewabilityConfigCallbackPairs}
314
308
  onContentSizeChange={onContentSizeChange}
315
309
  onScrollToIndexFailed={onScrollToIndexFailed}
@@ -499,9 +493,6 @@ const useStyles = () => {
499
493
  backgroundColor: navigationTheme.colors.card,
500
494
  paddingBottom: bottom,
501
495
  },
502
- listContainer: {
503
- paddingVertical: 12,
504
- },
505
496
  listHeader: {
506
497
  // Just whitespace to provide space where the typing indicator can be
507
498
  height: 16,