@planningcenter/chat-react-native 3.38.0-rc.1 → 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 (106) 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/display/conversation_avatar.d.ts +2 -1
  6. package/build/components/display/conversation_avatar.d.ts.map +1 -1
  7. package/build/components/display/conversation_avatar.js +6 -5
  8. package/build/components/display/conversation_avatar.js.map +1 -1
  9. package/build/components/display/emoji_avatar.d.ts +3 -1
  10. package/build/components/display/emoji_avatar.d.ts.map +1 -1
  11. package/build/components/display/emoji_avatar.js +2 -2
  12. package/build/components/display/emoji_avatar.js.map +1 -1
  13. package/build/components/display/icon_avatar.d.ts +3 -1
  14. package/build/components/display/icon_avatar.d.ts.map +1 -1
  15. package/build/components/display/icon_avatar.js +2 -2
  16. package/build/components/display/icon_avatar.js.map +1 -1
  17. package/build/hooks/groups/use_group_chat_conversation_payload.d.ts.map +1 -1
  18. package/build/hooks/groups/use_group_chat_conversation_payload.js +1 -0
  19. package/build/hooks/groups/use_group_chat_conversation_payload.js.map +1 -1
  20. package/build/hooks/index.d.ts +1 -0
  21. package/build/hooks/index.d.ts.map +1 -1
  22. package/build/hooks/index.js +1 -0
  23. package/build/hooks/index.js.map +1 -1
  24. package/build/hooks/use_preview_avatar_diameter.d.ts +2 -0
  25. package/build/hooks/use_preview_avatar_diameter.d.ts.map +1 -0
  26. package/build/hooks/use_preview_avatar_diameter.js +11 -0
  27. package/build/hooks/use_preview_avatar_diameter.js.map +1 -0
  28. package/build/jest.js +1 -1
  29. package/build/jest.js.map +1 -1
  30. package/build/screens/avatar_picker/avatar_picker_screen.d.ts.map +1 -1
  31. package/build/screens/avatar_picker/avatar_picker_screen.js +11 -9
  32. package/build/screens/avatar_picker/avatar_picker_screen.js.map +1 -1
  33. package/build/screens/avatar_picker/avatar_preview.d.ts.map +1 -1
  34. package/build/screens/avatar_picker/avatar_preview.js +13 -5
  35. package/build/screens/avatar_picker/avatar_preview.js.map +1 -1
  36. package/build/screens/avatar_picker/emoji_tab.d.ts.map +1 -1
  37. package/build/screens/avatar_picker/emoji_tab.js +3 -7
  38. package/build/screens/avatar_picker/emoji_tab.js.map +1 -1
  39. package/build/screens/avatar_picker/upload_tab.d.ts.map +1 -1
  40. package/build/screens/avatar_picker/upload_tab.js +2 -1
  41. package/build/screens/avatar_picker/upload_tab.js.map +1 -1
  42. package/build/screens/conversation_details_screen.d.ts.map +1 -1
  43. package/build/screens/conversation_details_screen.js +5 -2
  44. package/build/screens/conversation_details_screen.js.map +1 -1
  45. package/build/screens/conversation_filter_recipients/components/header_row.d.ts.map +1 -1
  46. package/build/screens/conversation_filter_recipients/components/header_row.js +3 -2
  47. package/build/screens/conversation_filter_recipients/components/header_row.js.map +1 -1
  48. package/build/screens/conversation_filter_recipients/hooks/use_flattened_array_of_service_types_with_teams.d.ts.map +1 -1
  49. package/build/screens/conversation_filter_recipients/hooks/use_flattened_array_of_service_types_with_teams.js +47 -18
  50. package/build/screens/conversation_filter_recipients/hooks/use_flattened_array_of_service_types_with_teams.js.map +1 -1
  51. package/build/screens/conversation_filter_recipients/hooks/use_service_types_with_teams.d.ts +2 -1
  52. package/build/screens/conversation_filter_recipients/hooks/use_service_types_with_teams.d.ts.map +1 -1
  53. package/build/screens/conversation_filter_recipients/hooks/use_service_types_with_teams.js +23 -26
  54. package/build/screens/conversation_filter_recipients/hooks/use_service_types_with_teams.js.map +1 -1
  55. package/build/screens/conversation_filter_recipients/types.d.ts +1 -1
  56. package/build/screens/conversation_filter_recipients/types.d.ts.map +1 -1
  57. package/build/screens/conversation_filter_recipients/types.js.map +1 -1
  58. package/build/screens/conversation_screen.d.ts.map +1 -1
  59. package/build/screens/conversation_screen.js +3 -7
  60. package/build/screens/conversation_screen.js.map +1 -1
  61. package/build/screens/conversation_select_recipients/components/recipient_link_row.d.ts +1 -1
  62. package/build/screens/conversation_select_recipients/components/recipient_link_row.d.ts.map +1 -1
  63. package/build/screens/conversation_select_recipients/components/recipient_link_row.js +3 -3
  64. package/build/screens/conversation_select_recipients/components/recipient_link_row.js.map +1 -1
  65. package/build/screens/conversation_select_recipients/components/team_recipient_row.d.ts.map +1 -1
  66. package/build/screens/conversation_select_recipients/components/team_recipient_row.js +1 -1
  67. package/build/screens/conversation_select_recipients/components/team_recipient_row.js.map +1 -1
  68. package/build/screens/team_conversation_screen.d.ts.map +1 -1
  69. package/build/screens/team_conversation_screen.js +24 -1
  70. package/build/screens/team_conversation_screen.js.map +1 -1
  71. package/build/utils/client/client.d.ts +1 -1
  72. package/build/utils/client/client.d.ts.map +1 -1
  73. package/build/utils/client/client.js +7 -6
  74. package/build/utils/client/client.js.map +1 -1
  75. package/build/utils/client/instrumented_fetch.js +3 -5
  76. package/build/utils/client/instrumented_fetch.js.map +1 -1
  77. package/package.json +4 -4
  78. package/src/__tests__/hooks/use_group_chat_conversation_payload.test.tsx +50 -0
  79. package/src/__tests__/jest.ts +1 -1
  80. package/src/__tests__/utils/client.ts +32 -0
  81. package/src/components/conversation/__tests__/message_list.test.tsx +14 -0
  82. package/src/components/conversation/message_list.tsx +42 -0
  83. package/src/components/display/conversation_avatar.tsx +7 -5
  84. package/src/components/display/emoji_avatar.tsx +10 -2
  85. package/src/components/display/icon_avatar.tsx +10 -2
  86. package/src/hooks/groups/use_group_chat_conversation_payload.ts +1 -0
  87. package/src/hooks/index.ts +1 -0
  88. package/src/hooks/use_preview_avatar_diameter.ts +12 -0
  89. package/src/jest.ts +1 -1
  90. package/src/screens/avatar_picker/avatar_picker_screen.tsx +25 -9
  91. package/src/screens/avatar_picker/avatar_preview.tsx +14 -5
  92. package/src/screens/avatar_picker/emoji_tab.tsx +3 -6
  93. package/src/screens/avatar_picker/upload_tab.tsx +2 -0
  94. package/src/screens/conversation_details_screen.tsx +10 -1
  95. package/src/screens/conversation_filter_recipients/components/header_row.tsx +3 -2
  96. package/src/screens/conversation_filter_recipients/hooks/__tests__/use_service_types_with_teams.test.ts +108 -0
  97. package/src/screens/conversation_filter_recipients/hooks/use_flattened_array_of_service_types_with_teams.tsx +46 -19
  98. package/src/screens/conversation_filter_recipients/hooks/use_service_types_with_teams.ts +31 -29
  99. package/src/screens/conversation_filter_recipients/types.tsx +1 -1
  100. package/src/screens/conversation_screen.tsx +5 -14
  101. package/src/screens/conversation_select_recipients/components/recipient_link_row.tsx +6 -4
  102. package/src/screens/conversation_select_recipients/components/team_recipient_row.tsx +2 -1
  103. package/src/screens/team_conversation_screen.tsx +33 -1
  104. package/src/utils/client/__tests__/instrumented_fetch.test.ts +9 -5
  105. package/src/utils/client/client.ts +9 -7
  106. package/src/utils/client/instrumented_fetch.ts +3 -6
@@ -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,
@@ -11,7 +11,7 @@ interface RecipientLinkRowProps {
11
11
  accessibilityHint: string
12
12
  imageUri?: string
13
13
  title: string
14
- subtitle: string
14
+ subtitle?: string
15
15
  }
16
16
 
17
17
  export const RecipientLinkRow = ({
@@ -40,9 +40,11 @@ export const RecipientLinkRow = ({
40
40
  <Text style={styles.title} numberOfLines={2}>
41
41
  {title}
42
42
  </Text>
43
- <Text variant="tertiary" numberOfLines={1}>
44
- {subtitle}
45
- </Text>
43
+ {subtitle && (
44
+ <Text variant="tertiary" numberOfLines={1}>
45
+ {subtitle}
46
+ </Text>
47
+ )}
46
48
  </View>
47
49
  {Platform.OS === 'ios' && (
48
50
  <Icon name="general.rightChevron" size={16} style={styles.icon} />
@@ -9,7 +9,8 @@ interface TeamRecipientRowProps {
9
9
 
10
10
  export const TeamRecipientRow = ({ serviceType, onPress }: TeamRecipientRowProps) => {
11
11
  const serviceTypeAccessibilityLabel = `Select ${pluralize(serviceType.teams.length, 'team')} for ${serviceType.name}`
12
- const teamNames = serviceType.teams.map(team => team.name).join(', ')
12
+ const teamNames =
13
+ serviceType.id > 0 ? serviceType.teams.map(team => team.name).join(', ') : undefined
13
14
 
14
15
  return (
15
16
  <RecipientLinkRow
@@ -2,8 +2,10 @@ import { StackActions, StaticScreenProps, useNavigation } from '@react-navigatio
2
2
  import { useQuery, useQueryClient } from '@tanstack/react-query'
3
3
  import { useEffect } from 'react'
4
4
  import { DefaultLoading } from '../components/page/loading'
5
+ import BlankState from '../components/primitive/blank_state_primitive'
5
6
  import { useApiClient } from '../hooks'
6
7
  import { findOrCreateServicesConversation } from '../hooks/services/use_find_or_create_services_conversation'
8
+ import { ResponseError } from '../utils/response_error'
7
9
 
8
10
  export type TeamConversationRouteProps = {
9
11
  plan_id?: number
@@ -16,7 +18,11 @@ export const TeamConversationScreen = ({ route }: TeamConversationScreenProps) =
16
18
  const apiClient = useApiClient()
17
19
  const queryClient = useQueryClient()
18
20
  const navigation = useNavigation()
19
- const { data: conversation } = useQuery({
21
+ const {
22
+ data: conversation,
23
+ isError,
24
+ error,
25
+ } = useQuery({
20
26
  queryKey: ['team-conversation', route.params.team_ids, route.params.plan_id],
21
27
  queryFn: () =>
22
28
  findOrCreateServicesConversation({
@@ -24,6 +30,7 @@ export const TeamConversationScreen = ({ route }: TeamConversationScreenProps) =
24
30
  teamIds: route.params.team_ids ?? [],
25
31
  planId: route.params.plan_id,
26
32
  }).then(r => r.conversation),
33
+ retry: (failureCount, err) => !(err instanceof ResponseError) && failureCount < 3,
27
34
  })
28
35
 
29
36
  useEffect(() => {
@@ -41,5 +48,30 @@ export const TeamConversationScreen = ({ route }: TeamConversationScreenProps) =
41
48
  }
42
49
  }, [conversation?.id, conversation?.title, navigation, queryClient])
43
50
 
51
+ if (isError) return <TeamConversationError error={error} onGoBack={navigation.goBack} />
52
+
44
53
  return <DefaultLoading />
45
54
  }
55
+
56
+ function TeamConversationError({ error, onGoBack }: { error: Error; onGoBack: () => void }) {
57
+ const detail =
58
+ error instanceof ResponseError
59
+ ? error.errors
60
+ .map(e => e.detail)
61
+ .filter(Boolean)
62
+ .join('\n')
63
+ : ''
64
+
65
+ return (
66
+ <BlankState.Root>
67
+ <BlankState.Imagery name="people.noTextMessage" />
68
+ <BlankState.Content>
69
+ <BlankState.Heading>Can't start this conversation</BlankState.Heading>
70
+ <BlankState.Text>
71
+ {detail || 'Something went wrong while starting this conversation.'}
72
+ </BlankState.Text>
73
+ </BlankState.Content>
74
+ <BlankState.Button title="Go back" onPress={onGoBack} size="md" accessibilityRole="link" />
75
+ </BlankState.Root>
76
+ )
77
+ }
@@ -5,15 +5,18 @@ const buildResponse = (status: number) => new Response('', { status })
5
5
 
6
6
  describe('instrumentedFetch', () => {
7
7
  let reportError: jest.SpyInstance
8
+ let warnSpy: jest.SpyInstance
8
9
  let fetchSpy: jest.SpyInstance
9
10
 
10
11
  beforeEach(() => {
11
12
  reportError = jest.spyOn(Log, 'reportError').mockImplementation(() => {})
13
+ warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {})
12
14
  fetchSpy = jest.spyOn(globalThis, 'fetch')
13
15
  })
14
16
 
15
17
  afterEach(() => {
16
18
  reportError.mockRestore()
19
+ warnSpy.mockRestore()
17
20
  fetchSpy.mockRestore()
18
21
  })
19
22
 
@@ -27,6 +30,7 @@ describe('instrumentedFetch', () => {
27
30
 
28
31
  expect(result).toBe(response)
29
32
  expect(reportError).not.toHaveBeenCalled()
33
+ expect(warnSpy).not.toHaveBeenCalled()
30
34
  })
31
35
 
32
36
  it.each([400, 422, 500])(
@@ -63,7 +67,7 @@ describe('instrumentedFetch', () => {
63
67
  expect(reportError).not.toHaveBeenCalled()
64
68
  })
65
69
 
66
- it('reports network failures with http.error=network', async () => {
70
+ it('warns on network failures instead of reporting them', async () => {
67
71
  const networkError = new TypeError('Network request failed')
68
72
  fetchSpy.mockRejectedValueOnce(networkError)
69
73
 
@@ -71,10 +75,10 @@ describe('instrumentedFetch', () => {
71
75
  instrumentedFetch('https://api.example.com/conversations/123', { method: 'GET' })
72
76
  ).rejects.toBe(networkError)
73
77
 
74
- expect(reportError).toHaveBeenCalledTimes(1)
75
- const [error, context] = reportError.mock.calls[0]
76
- expect(error.name).toBe('NetworkError')
77
- expect(error.message).toContain('Network failure GET /conversations/:id')
78
+ expect(reportError).not.toHaveBeenCalled()
79
+ expect(warnSpy).toHaveBeenCalledTimes(1)
80
+ const [message, context] = warnSpy.mock.calls[0]
81
+ expect(message).toContain('Network failure GET /conversations/:id')
78
82
  expect(context.tags).toMatchObject({
79
83
  'http.method': 'GET',
80
84
  'http.path': '/conversations/:id',
@@ -105,16 +105,18 @@ export class Client {
105
105
  return makeRequest(requestArgs).catch(this.handleNotOk)
106
106
  }
107
107
 
108
- handleNotOk = async (response: Response) => {
108
+ handleNotOk = async (response: Response | Error) => {
109
+ if (!(response instanceof Response)) return Promise.reject(response)
110
+
111
+ const errorData = await this.parseErrorResponse(response)
112
+ const notOkResponse = response.clone() as FailedResponse
113
+ notOkResponse.errors = errorData.errors || []
114
+
109
115
  if (response.status === 401) {
110
- const errorData = await this.parseErrorResponse(response)
111
- this.onUnauthorizedResponse?.({
112
- ...response,
113
- errors: errorData.errors || [],
114
- } as FailedResponse)
116
+ this.onUnauthorizedResponse?.(notOkResponse)
115
117
  }
116
118
 
117
- return Promise.reject(response)
119
+ return Promise.reject(notOkResponse)
118
120
  }
119
121
 
120
122
  parseErrorResponse = async (response: Response): Promise<Partial<FailedResponse>> => {
@@ -14,7 +14,7 @@ export async function instrumentedFetch(url: string, init: RequestInit): Promise
14
14
  if (!response.ok) reportHttpError(response, method, url)
15
15
  return response
16
16
  } catch (networkError) {
17
- reportNetworkError(networkError as Error, method, url)
17
+ warnNetworkError(networkError as Error, method, url)
18
18
  throw networkError
19
19
  }
20
20
  }
@@ -38,12 +38,9 @@ function reportHttpError(response: Response, method: string, url: string) {
38
38
  })
39
39
  }
40
40
 
41
- function reportNetworkError(networkError: Error, method: string, url: string) {
41
+ function warnNetworkError(networkError: Error, method: string, url: string) {
42
42
  const path = templatePath(url)
43
- const error = new Error(`Network failure ${method} ${path}: ${networkError.message}`)
44
- error.name = 'NetworkError'
45
-
46
- Log.reportError(error, {
43
+ console.warn(`Network failure ${method} ${path}: ${networkError.message}`, {
47
44
  scope: 'http',
48
45
  tags: {
49
46
  ...SHARED_TAGS,