@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.
- package/build/components/conversation/message_list.d.ts +10 -0
- package/build/components/conversation/message_list.d.ts.map +1 -0
- package/build/components/conversation/message_list.js +13 -0
- package/build/components/conversation/message_list.js.map +1 -0
- package/build/components/display/conversation_avatar.d.ts +2 -1
- package/build/components/display/conversation_avatar.d.ts.map +1 -1
- package/build/components/display/conversation_avatar.js +6 -5
- package/build/components/display/conversation_avatar.js.map +1 -1
- package/build/components/display/emoji_avatar.d.ts +3 -1
- package/build/components/display/emoji_avatar.d.ts.map +1 -1
- package/build/components/display/emoji_avatar.js +2 -2
- package/build/components/display/emoji_avatar.js.map +1 -1
- package/build/components/display/icon_avatar.d.ts +3 -1
- package/build/components/display/icon_avatar.d.ts.map +1 -1
- package/build/components/display/icon_avatar.js +2 -2
- package/build/components/display/icon_avatar.js.map +1 -1
- package/build/hooks/groups/use_group_chat_conversation_payload.d.ts.map +1 -1
- package/build/hooks/groups/use_group_chat_conversation_payload.js +1 -0
- package/build/hooks/groups/use_group_chat_conversation_payload.js.map +1 -1
- package/build/hooks/index.d.ts +1 -0
- package/build/hooks/index.d.ts.map +1 -1
- package/build/hooks/index.js +1 -0
- package/build/hooks/index.js.map +1 -1
- package/build/hooks/use_preview_avatar_diameter.d.ts +2 -0
- package/build/hooks/use_preview_avatar_diameter.d.ts.map +1 -0
- package/build/hooks/use_preview_avatar_diameter.js +11 -0
- package/build/hooks/use_preview_avatar_diameter.js.map +1 -0
- package/build/jest.js +1 -1
- package/build/jest.js.map +1 -1
- package/build/screens/avatar_picker/avatar_picker_screen.d.ts.map +1 -1
- package/build/screens/avatar_picker/avatar_picker_screen.js +11 -9
- package/build/screens/avatar_picker/avatar_picker_screen.js.map +1 -1
- package/build/screens/avatar_picker/avatar_preview.d.ts.map +1 -1
- package/build/screens/avatar_picker/avatar_preview.js +13 -5
- package/build/screens/avatar_picker/avatar_preview.js.map +1 -1
- package/build/screens/avatar_picker/emoji_tab.d.ts.map +1 -1
- package/build/screens/avatar_picker/emoji_tab.js +3 -7
- package/build/screens/avatar_picker/emoji_tab.js.map +1 -1
- package/build/screens/avatar_picker/upload_tab.d.ts.map +1 -1
- package/build/screens/avatar_picker/upload_tab.js +2 -1
- package/build/screens/avatar_picker/upload_tab.js.map +1 -1
- package/build/screens/conversation_details_screen.d.ts.map +1 -1
- package/build/screens/conversation_details_screen.js +5 -2
- package/build/screens/conversation_details_screen.js.map +1 -1
- package/build/screens/conversation_filter_recipients/components/header_row.d.ts.map +1 -1
- package/build/screens/conversation_filter_recipients/components/header_row.js +3 -2
- package/build/screens/conversation_filter_recipients/components/header_row.js.map +1 -1
- package/build/screens/conversation_filter_recipients/hooks/use_flattened_array_of_service_types_with_teams.d.ts.map +1 -1
- package/build/screens/conversation_filter_recipients/hooks/use_flattened_array_of_service_types_with_teams.js +47 -18
- package/build/screens/conversation_filter_recipients/hooks/use_flattened_array_of_service_types_with_teams.js.map +1 -1
- package/build/screens/conversation_filter_recipients/hooks/use_service_types_with_teams.d.ts +2 -1
- package/build/screens/conversation_filter_recipients/hooks/use_service_types_with_teams.d.ts.map +1 -1
- package/build/screens/conversation_filter_recipients/hooks/use_service_types_with_teams.js +23 -26
- package/build/screens/conversation_filter_recipients/hooks/use_service_types_with_teams.js.map +1 -1
- package/build/screens/conversation_filter_recipients/types.d.ts +1 -1
- package/build/screens/conversation_filter_recipients/types.d.ts.map +1 -1
- package/build/screens/conversation_filter_recipients/types.js.map +1 -1
- package/build/screens/conversation_screen.d.ts.map +1 -1
- package/build/screens/conversation_screen.js +3 -7
- package/build/screens/conversation_screen.js.map +1 -1
- package/build/screens/conversation_select_recipients/components/recipient_link_row.d.ts +1 -1
- package/build/screens/conversation_select_recipients/components/recipient_link_row.d.ts.map +1 -1
- package/build/screens/conversation_select_recipients/components/recipient_link_row.js +3 -3
- package/build/screens/conversation_select_recipients/components/recipient_link_row.js.map +1 -1
- package/build/screens/conversation_select_recipients/components/team_recipient_row.d.ts.map +1 -1
- package/build/screens/conversation_select_recipients/components/team_recipient_row.js +1 -1
- package/build/screens/conversation_select_recipients/components/team_recipient_row.js.map +1 -1
- package/build/screens/team_conversation_screen.d.ts.map +1 -1
- package/build/screens/team_conversation_screen.js +24 -1
- package/build/screens/team_conversation_screen.js.map +1 -1
- package/build/utils/client/client.d.ts +1 -1
- package/build/utils/client/client.d.ts.map +1 -1
- package/build/utils/client/client.js +7 -6
- package/build/utils/client/client.js.map +1 -1
- package/build/utils/client/instrumented_fetch.js +3 -5
- package/build/utils/client/instrumented_fetch.js.map +1 -1
- package/package.json +4 -4
- package/src/__tests__/hooks/use_group_chat_conversation_payload.test.tsx +50 -0
- package/src/__tests__/jest.ts +1 -1
- package/src/__tests__/utils/client.ts +32 -0
- package/src/components/conversation/__tests__/message_list.test.tsx +14 -0
- package/src/components/conversation/message_list.tsx +42 -0
- package/src/components/display/conversation_avatar.tsx +7 -5
- package/src/components/display/emoji_avatar.tsx +10 -2
- package/src/components/display/icon_avatar.tsx +10 -2
- package/src/hooks/groups/use_group_chat_conversation_payload.ts +1 -0
- package/src/hooks/index.ts +1 -0
- package/src/hooks/use_preview_avatar_diameter.ts +12 -0
- package/src/jest.ts +1 -1
- package/src/screens/avatar_picker/avatar_picker_screen.tsx +25 -9
- package/src/screens/avatar_picker/avatar_preview.tsx +14 -5
- package/src/screens/avatar_picker/emoji_tab.tsx +3 -6
- package/src/screens/avatar_picker/upload_tab.tsx +2 -0
- package/src/screens/conversation_details_screen.tsx +10 -1
- package/src/screens/conversation_filter_recipients/components/header_row.tsx +3 -2
- package/src/screens/conversation_filter_recipients/hooks/__tests__/use_service_types_with_teams.test.ts +108 -0
- package/src/screens/conversation_filter_recipients/hooks/use_flattened_array_of_service_types_with_teams.tsx +46 -19
- package/src/screens/conversation_filter_recipients/hooks/use_service_types_with_teams.ts +31 -29
- package/src/screens/conversation_filter_recipients/types.tsx +1 -1
- package/src/screens/conversation_screen.tsx +5 -14
- package/src/screens/conversation_select_recipients/components/recipient_link_row.tsx +6 -4
- package/src/screens/conversation_select_recipients/components/team_recipient_row.tsx +2 -1
- package/src/screens/team_conversation_screen.tsx +33 -1
- package/src/utils/client/__tests__/instrumented_fetch.test.ts +9 -5
- package/src/utils/client/client.ts +9 -7
- 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 ${
|
|
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
|
-
{
|
|
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
|
-
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
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(
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
68
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
}
|
|
@@ -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,
|
|
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
|
-
<
|
|
304
|
-
|
|
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
|
|
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
|
-
|
|
44
|
-
{
|
|
45
|
-
|
|
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 =
|
|
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 {
|
|
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('
|
|
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).
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
expect(
|
|
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
|
-
|
|
111
|
-
this.onUnauthorizedResponse?.({
|
|
112
|
-
...response,
|
|
113
|
-
errors: errorData.errors || [],
|
|
114
|
-
} as FailedResponse)
|
|
116
|
+
this.onUnauthorizedResponse?.(notOkResponse)
|
|
115
117
|
}
|
|
116
118
|
|
|
117
|
-
return Promise.reject(
|
|
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
|
-
|
|
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
|
|
41
|
+
function warnNetworkError(networkError: Error, method: string, url: string) {
|
|
42
42
|
const path = templatePath(url)
|
|
43
|
-
|
|
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,
|