@planningcenter/chat-react-native 3.36.1-rc.0 → 3.36.2-qa-726.0

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 (171) hide show
  1. package/build/components/conversation/message.d.ts.map +1 -1
  2. package/build/components/conversation/message.js +3 -3
  3. package/build/components/conversation/message.js.map +1 -1
  4. package/build/components/display/action_button.d.ts +3 -1
  5. package/build/components/display/action_button.d.ts.map +1 -1
  6. package/build/components/display/action_button.js +8 -1
  7. package/build/components/display/action_button.js.map +1 -1
  8. package/build/components/display/index.d.ts +1 -0
  9. package/build/components/display/index.d.ts.map +1 -1
  10. package/build/components/display/index.js +1 -0
  11. package/build/components/display/index.js.map +1 -1
  12. package/build/components/index.d.ts +2 -0
  13. package/build/components/index.d.ts.map +1 -1
  14. package/build/components/index.js +2 -0
  15. package/build/components/index.js.map +1 -1
  16. package/build/components/page/component_error_boundary.d.ts +4 -0
  17. package/build/components/page/component_error_boundary.d.ts.map +1 -0
  18. package/build/components/page/component_error_boundary.js +8 -0
  19. package/build/components/page/component_error_boundary.js.map +1 -0
  20. package/build/components/page/error_boundary.d.ts +13 -10
  21. package/build/components/page/error_boundary.d.ts.map +1 -1
  22. package/build/components/page/error_boundary.js +20 -90
  23. package/build/components/page/error_boundary.js.map +1 -1
  24. package/build/components/page/page_error_boundary.d.ts +4 -0
  25. package/build/components/page/page_error_boundary.d.ts.map +1 -0
  26. package/build/components/page/page_error_boundary.js +80 -0
  27. package/build/components/page/page_error_boundary.js.map +1 -0
  28. package/build/hooks/groups/use_group_chat_conversation_payload.d.ts +168 -0
  29. package/build/hooks/groups/use_group_chat_conversation_payload.d.ts.map +1 -0
  30. package/build/hooks/groups/use_group_chat_conversation_payload.js +23 -0
  31. package/build/hooks/groups/use_group_chat_conversation_payload.js.map +1 -0
  32. package/build/hooks/groups/use_group_members_for_new_conversation.d.ts +0 -4
  33. package/build/hooks/groups/use_group_members_for_new_conversation.d.ts.map +1 -1
  34. package/build/hooks/groups/use_group_members_for_new_conversation.js +6 -18
  35. package/build/hooks/groups/use_group_members_for_new_conversation.js.map +1 -1
  36. package/build/hooks/groups/use_groups_conversation_create.js +1 -1
  37. package/build/hooks/groups/use_groups_conversation_create.js.map +1 -1
  38. package/build/hooks/index.d.ts +2 -1
  39. package/build/hooks/index.d.ts.map +1 -1
  40. package/build/hooks/index.js +2 -1
  41. package/build/hooks/index.js.map +1 -1
  42. package/build/hooks/services/use_find_or_create_services_conversation.d.ts +11 -3
  43. package/build/hooks/services/use_find_or_create_services_conversation.d.ts.map +1 -1
  44. package/build/hooks/services/use_find_or_create_services_conversation.js +10 -14
  45. package/build/hooks/services/use_find_or_create_services_conversation.js.map +1 -1
  46. package/build/hooks/services/use_services_chat_conversation_payload.d.ts +164 -0
  47. package/build/hooks/services/use_services_chat_conversation_payload.d.ts.map +1 -0
  48. package/build/hooks/services/use_services_chat_conversation_payload.js +16 -0
  49. package/build/hooks/services/use_services_chat_conversation_payload.js.map +1 -0
  50. package/build/hooks/services/use_team_members_for_new_conversation.d.ts.map +1 -1
  51. package/build/hooks/services/use_team_members_for_new_conversation.js +11 -4
  52. package/build/hooks/services/use_team_members_for_new_conversation.js.map +1 -1
  53. package/build/hooks/use_conversation_validate.d.ts +12 -0
  54. package/build/hooks/use_conversation_validate.d.ts.map +1 -0
  55. package/build/hooks/use_conversation_validate.js +28 -0
  56. package/build/hooks/use_conversation_validate.js.map +1 -0
  57. package/build/hooks/use_enrich_people.d.ts +13 -0
  58. package/build/hooks/use_enrich_people.d.ts.map +1 -0
  59. package/build/hooks/use_enrich_people.js +25 -0
  60. package/build/hooks/use_enrich_people.js.map +1 -0
  61. package/build/hooks/use_features.d.ts +9 -6
  62. package/build/hooks/use_features.d.ts.map +1 -1
  63. package/build/hooks/use_features.js +1 -0
  64. package/build/hooks/use_features.js.map +1 -1
  65. package/build/hooks/use_jolt.d.ts +2 -1
  66. package/build/hooks/use_jolt.d.ts.map +1 -1
  67. package/build/hooks/use_jolt.js.map +1 -1
  68. package/build/hooks/use_product_analytics.d.ts +7 -1
  69. package/build/hooks/use_product_analytics.d.ts.map +1 -1
  70. package/build/hooks/use_product_analytics.js +4 -0
  71. package/build/hooks/use_product_analytics.js.map +1 -1
  72. package/build/index.d.ts +3 -2
  73. package/build/index.d.ts.map +1 -1
  74. package/build/index.js +2 -1
  75. package/build/index.js.map +1 -1
  76. package/build/navigation/screenLayout.d.ts.map +1 -1
  77. package/build/navigation/screenLayout.js +5 -3
  78. package/build/navigation/screenLayout.js.map +1 -1
  79. package/build/screens/conversation_details_screen.js +1 -1
  80. package/build/screens/conversation_details_screen.js.map +1 -1
  81. package/build/screens/conversation_new/components/groups_form.d.ts.map +1 -1
  82. package/build/screens/conversation_new/components/groups_form.js +14 -1
  83. package/build/screens/conversation_new/components/groups_form.js.map +1 -1
  84. package/build/screens/conversation_new/components/services_form.d.ts.map +1 -1
  85. package/build/screens/conversation_new/components/services_form.js +20 -2
  86. package/build/screens/conversation_new/components/services_form.js.map +1 -1
  87. package/build/screens/conversation_screen.d.ts.map +1 -1
  88. package/build/screens/conversation_screen.js +2 -2
  89. package/build/screens/conversation_screen.js.map +1 -1
  90. package/build/screens/conversations/conversations_screen.js +2 -2
  91. package/build/screens/conversations/conversations_screen.js.map +1 -1
  92. package/build/screens/team_conversation_screen.d.ts.map +1 -1
  93. package/build/screens/team_conversation_screen.js +6 -3
  94. package/build/screens/team_conversation_screen.js.map +1 -1
  95. package/build/types/jolt_events/index.d.ts +2 -0
  96. package/build/types/jolt_events/index.d.ts.map +1 -1
  97. package/build/types/jolt_events/index.js.map +1 -1
  98. package/build/types/resources/conversation_validate.d.ts +10 -0
  99. package/build/types/resources/conversation_validate.d.ts.map +1 -0
  100. package/build/types/resources/conversation_validate.js +2 -0
  101. package/build/types/resources/conversation_validate.js.map +1 -0
  102. package/build/types/resources/index.d.ts +1 -0
  103. package/build/types/resources/index.d.ts.map +1 -1
  104. package/build/types/resources/index.js +1 -0
  105. package/build/types/resources/index.js.map +1 -1
  106. package/build/utils/cache/messages_cache.d.ts +1 -1
  107. package/build/utils/cache/messages_cache.d.ts.map +1 -1
  108. package/build/utils/client/instrumented_fetch.d.ts +2 -0
  109. package/build/utils/client/instrumented_fetch.d.ts.map +1 -0
  110. package/build/utils/client/instrumented_fetch.js +64 -0
  111. package/build/utils/client/instrumented_fetch.js.map +1 -0
  112. package/build/utils/client/request_helpers.d.ts.map +1 -1
  113. package/build/utils/client/request_helpers.js +2 -1
  114. package/build/utils/client/request_helpers.js.map +1 -1
  115. package/build/utils/native_adapters/log.d.ts +11 -0
  116. package/build/utils/native_adapters/log.d.ts.map +1 -1
  117. package/build/utils/native_adapters/log.js +9 -0
  118. package/build/utils/native_adapters/log.js.map +1 -1
  119. package/build/utils/performance_tracking.d.ts +1 -1
  120. package/build/utils/performance_tracking.d.ts.map +1 -1
  121. package/build/utils/performance_tracking.js.map +1 -1
  122. package/build/utils/request/get_chat_configuration.d.ts +1 -1
  123. package/build/utils/request/get_chat_configuration.d.ts.map +1 -1
  124. package/build/utils/request/get_features.d.ts +1 -1
  125. package/build/utils/request/get_features.d.ts.map +1 -1
  126. package/build/utils/request/get_message.d.ts +1 -1
  127. package/build/utils/request/get_message.d.ts.map +1 -1
  128. package/build/utils/request/get_messages.d.ts +1 -1
  129. package/build/utils/request/get_messages.d.ts.map +1 -1
  130. package/package.json +3 -2
  131. package/src/__tests__/hooks/use_conversation_validate.test.tsx +117 -0
  132. package/src/__tests__/hooks/use_enrich_people.test.tsx +95 -0
  133. package/src/components/conversation/message.tsx +6 -4
  134. package/src/components/display/action_button.tsx +18 -0
  135. package/src/components/display/index.ts +1 -0
  136. package/src/components/index.tsx +2 -0
  137. package/src/components/page/__tests__/component_error_boundary.test.tsx +46 -0
  138. package/src/components/page/__tests__/error_boundary.test.tsx +93 -0
  139. package/src/components/page/__tests__/page_error_boundary.test.tsx +77 -0
  140. package/src/components/page/component_error_boundary.tsx +13 -0
  141. package/src/components/page/error_boundary.tsx +34 -118
  142. package/src/components/page/page_error_boundary.tsx +112 -0
  143. package/src/hooks/groups/use_group_chat_conversation_payload.ts +38 -0
  144. package/src/hooks/groups/use_group_members_for_new_conversation.ts +9 -23
  145. package/src/hooks/groups/use_groups_conversation_create.ts +1 -1
  146. package/src/hooks/index.ts +2 -1
  147. package/src/hooks/services/use_find_or_create_services_conversation.ts +27 -24
  148. package/src/hooks/services/use_services_chat_conversation_payload.ts +26 -0
  149. package/src/hooks/services/use_team_members_for_new_conversation.ts +18 -7
  150. package/src/hooks/use_conversation_validate.ts +45 -0
  151. package/src/hooks/use_enrich_people.ts +35 -0
  152. package/src/hooks/use_features.ts +5 -2
  153. package/src/hooks/use_jolt.ts +2 -1
  154. package/src/hooks/use_product_analytics.ts +13 -3
  155. package/src/index.tsx +3 -2
  156. package/src/navigation/screenLayout.tsx +6 -3
  157. package/src/screens/conversation_details_screen.tsx +1 -1
  158. package/src/screens/conversation_new/components/groups_form.tsx +17 -1
  159. package/src/screens/conversation_new/components/services_form.tsx +26 -2
  160. package/src/screens/conversation_screen.tsx +2 -1
  161. package/src/screens/conversations/conversations_screen.tsx +2 -2
  162. package/src/screens/team_conversation_screen.tsx +6 -6
  163. package/src/types/jolt_events/index.ts +3 -0
  164. package/src/types/resources/conversation_validate.ts +11 -0
  165. package/src/types/resources/index.ts +1 -0
  166. package/src/utils/client/__tests__/instrumented_fetch.test.ts +84 -0
  167. package/src/utils/client/instrumented_fetch.ts +69 -0
  168. package/src/utils/client/request_helpers.ts +2 -1
  169. package/src/utils/native_adapters/__tests__/log.test.ts +62 -0
  170. package/src/utils/native_adapters/log.ts +22 -0
  171. package/src/utils/performance_tracking.ts +1 -1
@@ -11,6 +11,7 @@ import { useQuery, useQueryClient, useSuspenseQuery } from '@tanstack/react-quer
11
11
  import { useCallback, useEffect, useMemo } from 'react'
12
12
  import { useChatContext } from '../contexts/chat_context'
13
13
  import { ApiResource } from '../types'
14
+ import type { JoltSubscriptionPattern } from '../types/jolt_events'
14
15
  import { Client, Uri } from '../utils'
15
16
 
16
17
  interface JoltResponse {
@@ -165,7 +166,7 @@ type UserCallbackFn<T> = (_event: T) => void
165
166
 
166
167
  export function useJoltEvent<T extends CustomMessage>(
167
168
  channel: JoltSubscription | undefined | null,
168
- eventName: string,
169
+ eventName: JoltSubscriptionPattern,
169
170
  callback: UserCallbackFn<T>
170
171
  ) {
171
172
  useEffect(() => {
@@ -9,6 +9,13 @@ export interface EventMetadata {
9
9
  [key: string]: string | number | boolean | null | undefined
10
10
  }
11
11
 
12
+ export const analyticsEvents = {
13
+ conversation_index_opened: 'chat.mobile.conversations.index.opened',
14
+ conversation_show_opened: 'chat.mobile.conversations.show.opened',
15
+ } as const satisfies Record<string, `chat.mobile.${string}`>
16
+
17
+ type AnalyticsEventName = (typeof analyticsEvents)[keyof typeof analyticsEvents]
18
+
12
19
  interface AnalyticsEvent {
13
20
  name: string
14
21
  meta: EventMetadata
@@ -55,7 +62,7 @@ const useProductAnalytics = () => {
55
62
  )
56
63
 
57
64
  const publishEvent = useCallback(
58
- (name: string, meta: EventMetadata = {}): Promise<Response[]> => {
65
+ (name: AnalyticsEventName, meta: EventMetadata = {}): Promise<Response[]> => {
59
66
  if (!productAnalyticsConfig) {
60
67
  if (__DEV__) {
61
68
  console.warn('Product Analytics not available')
@@ -71,7 +78,7 @@ const useProductAnalytics = () => {
71
78
  )
72
79
 
73
80
  const publishEvents = useCallback(
74
- (events: Array<{ name: string; meta?: EventMetadata }>): Promise<Response[]> => {
81
+ (events: Array<{ name: AnalyticsEventName; meta?: EventMetadata }>): Promise<Response[]> => {
75
82
  if (!productAnalyticsConfig) {
76
83
  if (__DEV__) {
77
84
  console.warn('Product Analytics not available')
@@ -92,7 +99,10 @@ const useProductAnalytics = () => {
92
99
  return { publishEvent, publishEvents }
93
100
  }
94
101
 
95
- export function usePublishProductAnalyticsEvent(eventName: string, meta: EventMetadata = {}) {
102
+ export function usePublishProductAnalyticsEvent(
103
+ eventName: AnalyticsEventName,
104
+ meta: EventMetadata = {}
105
+ ) {
96
106
  const { publishEvent } = useProductAnalytics()
97
107
  const hasPublishedEventRef = useRef(false)
98
108
 
package/src/index.tsx CHANGED
@@ -1,6 +1,7 @@
1
1
  import './icons/font_awesome'
2
2
 
3
- export { GroupConversations } from './components'
3
+ export * from './components'
4
+ export * from './hooks'
4
5
  export { ApiProvider, chatQueryClient, useFocusManager } from './contexts/api_provider'
5
6
  export * from './contexts/chat_context'
6
7
  export * from './contexts/session_context'
@@ -11,7 +12,7 @@ export { default as Event } from './polyfills/events/Event'
11
12
  export * from './screens'
12
13
  export { AgeCheckUnderageScreen } from './screens/age_check/age_check_underage_screen' // TODO: add to barrel
13
14
  export * from './types'
14
- export { platformFontWeightBold, Session, TemporaryDefaultColorsType, Uri } from './utils'
15
+ export { ENV, platformFontWeightBold, Session, TemporaryDefaultColorsType, Uri } from './utils'
15
16
  export * from './utils/client'
16
17
  export * from './utils/host_bridge'
17
18
  export * from './utils/native_adapters'
@@ -1,14 +1,17 @@
1
+ import { useRoute } from '@react-navigation/native'
1
2
  import React from 'react'
2
3
  import { Suspense } from 'react'
3
- import ErrorBoundary from '../components/page/error_boundary'
4
4
  import { DefaultLoading } from '../components/page/loading'
5
+ import { PageErrorBoundary } from '../components/page/page_error_boundary'
5
6
  import { ChatAccessGate } from './chat_access_gate'
6
7
 
7
8
  export function ScreenLayout({ children }: { children: React.ReactElement }) {
9
+ const route = useRoute()
10
+
8
11
  return (
9
- <ErrorBoundary>
12
+ <PageErrorBoundary screenName={route.name}>
10
13
  <Suspense fallback={<DefaultLoading />}>{children}</Suspense>
11
- </ErrorBoundary>
14
+ </PageErrorBoundary>
12
15
  )
13
16
  }
14
17
 
@@ -148,7 +148,7 @@ export function ConversationDetailsScreen({ route }: ConversationDetailsScreenPr
148
148
  const memberSectionStyle = memberSectionStyleMap[memberPosition]
149
149
 
150
150
  return {
151
- type: data.child ? SectionTypes.hidden : SectionTypes.members,
151
+ type: SectionTypes.members,
152
152
  data,
153
153
  sectionInnerStyle: memberSectionStyle,
154
154
  }
@@ -5,6 +5,7 @@ import { Banner, ChildNotice, Heading, Text } from '../../../components'
5
5
  import { ActionButton } from '../../../components/display/action_button'
6
6
  import { KeyboardView } from '../../../components/display/keyboard_view'
7
7
  import { useApiClient, useCurrentPerson, useSuspenseGet, useTheme } from '../../../hooks'
8
+ import { useGroupChatConversationPayload } from '../../../hooks/groups/use_group_chat_conversation_payload'
8
9
  import {
9
10
  GroupMembersForNewConversationResult,
10
11
  useGroupMembersForNewConversation,
@@ -14,6 +15,7 @@ import {
14
15
  type AvatarUpdatePayload,
15
16
  patchConversationAvatar,
16
17
  } from '../../../hooks/use_conversation_avatar_update'
18
+ import { useConversationValidate } from '../../../hooks/use_conversation_validate'
17
19
  import { availableFeatures, useFeatures } from '../../../hooks/use_features'
18
20
  import { useMyGender } from '../../../hooks/use_my_gender'
19
21
  import { useUploadClient } from '../../../hooks/use_upload_client'
@@ -40,6 +42,7 @@ export const GroupsForm = ({ groupId, chat_group_graph_id, avatarSelection }: Gr
40
42
  const [genderFilterEnabled, setGenderFilterEnabled] = useState(false)
41
43
  const { featureEnabled } = useFeatures()
42
44
  const customAvatarsEnabled = featureEnabled(availableFeatures.custom_conversation_avatars)
45
+ const safetyLockEnabled = featureEnabled(availableFeatures.conversation_safety_lock)
43
46
  const {
44
47
  isFeatureEnabled: genderFilterAvailable,
45
48
  genderId,
@@ -59,6 +62,18 @@ export const GroupsForm = ({ groupId, chat_group_graph_id, avatarSelection }: Gr
59
62
  const activeGenderId = genderFilterEnabled ? genderId : null
60
63
  const activeGenderValue = genderFilterEnabled ? genderValue : null
61
64
 
65
+ const { payload, isLoading: isLoadingPayload } = useGroupChatConversationPayload({
66
+ groupId,
67
+ enabled: safetyLockEnabled,
68
+ })
69
+
70
+ const { warnings, validationPending } = useConversationValidate({
71
+ payload,
72
+ isLoadingPayload,
73
+ genderId: activeGenderId,
74
+ enabled: safetyLockEnabled,
75
+ })
76
+
62
77
  const groupMemberships = useGroupMembersForNewConversation({
63
78
  id: groupId,
64
79
  gender: activeGenderValue,
@@ -127,10 +142,11 @@ export const GroupsForm = ({ groupId, chat_group_graph_id, avatarSelection }: Gr
127
142
  }
128
143
  />
129
144
  <ActionButton
130
- disabled={!title || isPending}
145
+ disabled={!title || isPending || validationPending || warnings.length > 0}
131
146
  title="Start Conversation"
132
147
  onPress={() => handleSave()}
133
148
  infoText="Conversation will be automatically updated if any members are added or removed from this group."
149
+ warnings={warnings}
134
150
  />
135
151
  </KeyboardView>
136
152
  )
@@ -6,12 +6,14 @@ import { Badge, Banner, ChildNotice, Heading, Switch, TextButton } from '../../.
6
6
  import { ActionButton } from '../../../components/display/action_button'
7
7
  import { useApiClient } from '../../../hooks'
8
8
  import { useFindOrCreateServicesConversation } from '../../../hooks/services/use_find_or_create_services_conversation'
9
+ import { useServicesChatConversationPayload } from '../../../hooks/services/use_services_chat_conversation_payload'
9
10
  import { useServicesTeams } from '../../../hooks/services/use_services_team'
10
11
  import { useTeamMembersForNewConversation } from '../../../hooks/services/use_team_members_for_new_conversation'
11
12
  import {
12
13
  type AvatarUpdatePayload,
13
14
  patchConversationAvatar,
14
15
  } from '../../../hooks/use_conversation_avatar_update'
16
+ import { useConversationValidate } from '../../../hooks/use_conversation_validate'
15
17
  import { availableFeatures, useFeatures } from '../../../hooks/use_features'
16
18
  import { useUploadClient } from '../../../hooks/use_upload_client'
17
19
  import { MemberResource } from '../../../types'
@@ -41,6 +43,7 @@ export const ServicesForm = ({
41
43
  const uploadClient = useUploadClient()
42
44
  const { featureEnabled } = useFeatures()
43
45
  const customAvatarsEnabled = featureEnabled(availableFeatures.custom_conversation_avatars)
46
+ const safetyLockEnabled = featureEnabled(availableFeatures.conversation_safety_lock)
44
47
  const [selectedPlanId, setSelectedPlanId] = useState<number | undefined>(initialPlanId)
45
48
  const initialState = useMemo(() => uniq(initialTeamIds) || [], [initialTeamIds]) // Uniq here because services can send duplicates in the teams_i_lead response.
46
49
  const [selectedTeamIds, setSelectedTeamIds] = useState<number[]>(initialState)
@@ -60,6 +63,20 @@ export const ServicesForm = ({
60
63
 
61
64
  const [filerByPlan, setFilterByPlan] = useState(false)
62
65
 
66
+ const activePlanId = filerByPlan ? selectedPlanId : undefined
67
+
68
+ const { payload, isLoading: isLoadingPayload } = useServicesChatConversationPayload({
69
+ teamIds: selectedTeamIds,
70
+ planId: activePlanId,
71
+ enabled: safetyLockEnabled,
72
+ })
73
+
74
+ const { warnings, validationPending } = useConversationValidate({
75
+ payload,
76
+ isLoadingPayload,
77
+ enabled: safetyLockEnabled,
78
+ })
79
+
63
80
  const { members, isError: isMemberError } = useTeamMembersForNewConversation({
64
81
  teamIds: selectedTeamIds,
65
82
  planId: selectedPlanId,
@@ -74,7 +91,7 @@ export const ServicesForm = ({
74
91
  isLoadingConversationCheck,
75
92
  } = useFindOrCreateServicesConversation({
76
93
  teamIds: selectedTeamIds,
77
- planId: filerByPlan ? selectedPlanId : undefined,
94
+ planId: activePlanId,
78
95
  onSuccess: (conversation, { created }) => {
79
96
  navigation.getParent()?.goBack()
80
97
  navigation.dispatch(
@@ -113,11 +130,18 @@ export const ServicesForm = ({
113
130
  }
114
131
  />
115
132
  <ActionButton
116
- disabled={!selectedTeamIds.length || isPending || isLoadingConversationCheck}
133
+ disabled={
134
+ !selectedTeamIds.length ||
135
+ isPending ||
136
+ isLoadingConversationCheck ||
137
+ validationPending ||
138
+ warnings.length > 0
139
+ }
117
140
  title={selectionHasConversation ? 'Open conversation' : 'Start conversation'}
118
141
  onPress={createConversation}
119
142
  infoText="Conversation will be automatically updated if any members are added or removed from included teams."
120
143
  loading={isLoadingConversationCheck}
144
+ warnings={warnings}
121
145
  />
122
146
  </View>
123
147
  )
@@ -44,6 +44,7 @@ import { useJumpToUnreadAnchor } from '../hooks/use_jump_to_unread_anchor'
44
44
  import { useJumpToUnreadGates } from '../hooks/use_jump_to_unread_gates'
45
45
  import { useMarkLatestMessageRead } from '../hooks/use_mark_latest_message_read'
46
46
  import {
47
+ analyticsEvents,
47
48
  normalizeAnalyticsMetadata,
48
49
  usePublishProductAnalyticsEvent,
49
50
  } from '../hooks/use_product_analytics'
@@ -93,7 +94,7 @@ export function ConversationScreen({ route }: ConversationScreenProps) {
93
94
  const { data: conversation } = useConversation({ conversation_id })
94
95
  const { featureEnabled } = useFeatures()
95
96
 
96
- usePublishProductAnalyticsEvent('chat.mobile.conversations.show.opened', {
97
+ usePublishProductAnalyticsEvent(analyticsEvents.conversation_show_opened, {
97
98
  reply_root_id,
98
99
  ...normalizeAnalyticsMetadata(conversation),
99
100
  })
@@ -3,7 +3,7 @@ import React from 'react'
3
3
  import { StyleSheet, View } from 'react-native'
4
4
  import { Conversations } from '../../components'
5
5
  import { ConversationsContextProvider } from '../../contexts/conversations_context'
6
- import { usePublishProductAnalyticsEvent } from '../../hooks'
6
+ import { analyticsEvents, usePublishProductAnalyticsEvent } from '../../hooks/use_product_analytics'
7
7
  import { AppName } from '../../types/resources/app_name'
8
8
  import { GraphId } from '../../types/resources/group_resource'
9
9
  import { ListHeaderComponent } from './components/list_header_component'
@@ -17,7 +17,7 @@ export type ConversationsScreenProps = StaticScreenProps<{
17
17
  export function ConversationsScreen({ route }: ConversationsScreenProps) {
18
18
  const styles = useStyles()
19
19
 
20
- usePublishProductAnalyticsEvent('chat.mobile.conversations.index.opened')
20
+ usePublishProductAnalyticsEvent(analyticsEvents.conversation_index_opened)
21
21
 
22
22
  return (
23
23
  <View style={styles.container}>
@@ -3,10 +3,7 @@ import { useQuery, useQueryClient } from '@tanstack/react-query'
3
3
  import { useEffect } from 'react'
4
4
  import { DefaultLoading } from '../components/page/loading'
5
5
  import { useApiClient } from '../hooks'
6
- import {
7
- buildTeamAndPlanParams,
8
- findOrCreateServicesConversation,
9
- } from '../hooks/services/use_find_or_create_services_conversation'
6
+ import { findOrCreateServicesConversation } from '../hooks/services/use_find_or_create_services_conversation'
10
7
 
11
8
  export type TeamConversationRouteProps = {
12
9
  plan_id?: number
@@ -19,11 +16,14 @@ export const TeamConversationScreen = ({ route }: TeamConversationScreenProps) =
19
16
  const apiClient = useApiClient()
20
17
  const queryClient = useQueryClient()
21
18
  const navigation = useNavigation()
22
- const teamAndPlanParams = buildTeamAndPlanParams(route.params.team_ids, route.params.plan_id)
23
19
  const { data: conversation } = useQuery({
24
20
  queryKey: ['team-conversation', route.params.team_ids, route.params.plan_id],
25
21
  queryFn: () =>
26
- findOrCreateServicesConversation(apiClient, teamAndPlanParams).then(r => r.conversation),
22
+ findOrCreateServicesConversation({
23
+ apiClient,
24
+ teamIds: route.params.team_ids ?? [],
25
+ planId: route.params.plan_id,
26
+ }).then(r => r.conversation),
27
27
  })
28
28
 
29
29
  useEffect(() => {
@@ -26,3 +26,6 @@ export type CustomJoltEvent =
26
26
  | JoltMessageEvent
27
27
  | JoltReactionEvent
28
28
  | JoltTypingEvent
29
+
30
+ export type JoltEventName = CustomJoltEvent['event'] | 'STREAM_USER_UPDATED'
31
+ export type JoltSubscriptionPattern = JoltEventName | 'reaction.*'
@@ -0,0 +1,11 @@
1
+ import { ResourceObject } from '../api_primitives'
2
+
3
+ export interface ConversationWarning {
4
+ attribute: string
5
+ fullMessage: string
6
+ }
7
+
8
+ export interface ConversationValidateResource extends ResourceObject {
9
+ type: 'ConversationValidate'
10
+ warnings: ConversationWarning[]
11
+ }
@@ -1,6 +1,7 @@
1
1
  export * from './analytics_metadata'
2
2
  export * from './conversation'
3
3
  export * from './conversation_membership'
4
+ export * from './conversation_validate'
4
5
  export * from './member'
5
6
  export * from './message'
6
7
  export * from './oauth_token'
@@ -0,0 +1,84 @@
1
+ import { Log } from '../../native_adapters/configuration'
2
+ import { instrumentedFetch } from '../instrumented_fetch'
3
+
4
+ const buildResponse = (status: number) => new Response('', { status })
5
+
6
+ describe('instrumentedFetch', () => {
7
+ let reportError: jest.SpyInstance
8
+ let fetchSpy: jest.SpyInstance
9
+
10
+ beforeEach(() => {
11
+ reportError = jest.spyOn(Log, 'reportError').mockImplementation(() => {})
12
+ fetchSpy = jest.spyOn(globalThis, 'fetch')
13
+ })
14
+
15
+ afterEach(() => {
16
+ reportError.mockRestore()
17
+ fetchSpy.mockRestore()
18
+ })
19
+
20
+ it('passes successful responses through without reporting', async () => {
21
+ const response = buildResponse(200)
22
+ fetchSpy.mockResolvedValueOnce(response)
23
+
24
+ const result = await instrumentedFetch('https://api.example.com/conversations', {
25
+ method: 'GET',
26
+ })
27
+
28
+ expect(result).toBe(response)
29
+ expect(reportError).not.toHaveBeenCalled()
30
+ })
31
+
32
+ it.each([400, 422, 500])(
33
+ 'reports %d with method, status, and templated path tags',
34
+ async status => {
35
+ fetchSpy.mockResolvedValueOnce(buildResponse(status))
36
+
37
+ await instrumentedFetch('https://api.example.com/conversations/123/messages', {
38
+ method: 'POST',
39
+ })
40
+
41
+ expect(reportError).toHaveBeenCalledTimes(1)
42
+ const [error, context] = reportError.mock.calls[0]
43
+ expect(error.name).toBe(`HTTPError${status}`)
44
+ expect(error.message).toBe(`HTTP ${status} POST /conversations/:id/messages`)
45
+ expect(context).toMatchObject({
46
+ scope: 'http',
47
+ tags: {
48
+ team: 'chat',
49
+ package: 'chat-react-native',
50
+ 'http.status': String(status),
51
+ 'http.method': 'POST',
52
+ 'http.path': '/conversations/:id/messages',
53
+ },
54
+ })
55
+ }
56
+ )
57
+
58
+ it.each([401, 403, 404])('does not report %d (expected user states)', async status => {
59
+ fetchSpy.mockResolvedValueOnce(buildResponse(status))
60
+
61
+ await instrumentedFetch('https://api.example.com/x', { method: 'GET' })
62
+
63
+ expect(reportError).not.toHaveBeenCalled()
64
+ })
65
+
66
+ it('reports network failures with http.error=network', async () => {
67
+ const networkError = new TypeError('Network request failed')
68
+ fetchSpy.mockRejectedValueOnce(networkError)
69
+
70
+ await expect(
71
+ instrumentedFetch('https://api.example.com/conversations/123', { method: 'GET' })
72
+ ).rejects.toBe(networkError)
73
+
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(context.tags).toMatchObject({
79
+ 'http.method': 'GET',
80
+ 'http.path': '/conversations/:id',
81
+ 'http.error': 'network',
82
+ })
83
+ })
84
+ })
@@ -0,0 +1,69 @@
1
+ import { Log } from '../native_adapters/configuration'
2
+
3
+ const SHARED_TAGS = {
4
+ team: 'chat',
5
+ package: 'chat-react-native',
6
+ } as const
7
+
8
+ const SKIPPED_STATUSES = new Set([401, 403, 404])
9
+
10
+ export async function instrumentedFetch(url: string, init: RequestInit): Promise<Response> {
11
+ const method = init.method ?? 'GET'
12
+ try {
13
+ const response = await fetch(url, init)
14
+ if (!response.ok) reportHttpError(response, method, url)
15
+ return response
16
+ } catch (networkError) {
17
+ reportNetworkError(networkError as Error, method, url)
18
+ throw networkError
19
+ }
20
+ }
21
+
22
+ function reportHttpError(response: Response, method: string, url: string) {
23
+ const { status } = response
24
+ if (SKIPPED_STATUSES.has(status)) return
25
+
26
+ const path = templatePath(url)
27
+ const error = new Error(`HTTP ${status} ${method} ${path}`)
28
+ error.name = `HTTPError${status}`
29
+
30
+ Log.reportError(error, {
31
+ scope: 'http',
32
+ tags: {
33
+ ...SHARED_TAGS,
34
+ 'http.status': String(status),
35
+ 'http.method': method,
36
+ 'http.path': path,
37
+ },
38
+ })
39
+ }
40
+
41
+ function reportNetworkError(networkError: Error, method: string, url: string) {
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, {
47
+ scope: 'http',
48
+ tags: {
49
+ ...SHARED_TAGS,
50
+ 'http.method': method,
51
+ 'http.path': path,
52
+ 'http.error': 'network',
53
+ },
54
+ })
55
+ }
56
+
57
+ function templatePath(url: string): string {
58
+ let path: string
59
+ try {
60
+ path = new URL(url).pathname
61
+ } catch {
62
+ path = url
63
+ }
64
+
65
+ return path
66
+ .split('/')
67
+ .map(segment => (/^\d+$/.test(segment) ? ':id' : segment))
68
+ .join('/')
69
+ }
@@ -1,5 +1,6 @@
1
1
  import _ from 'lodash'
2
2
  import URI from 'urijs'
3
+ import { instrumentedFetch } from './instrumented_fetch'
3
4
  import { transformRequestData } from './transform_request_data'
4
5
  import transformResponse from './transform_response'
5
6
  import { Accumulator, GetRequest, PostRequest, RequestData, WalkRequest } from './types'
@@ -32,7 +33,7 @@ export const makeRequest = ({ action = 'GET', url, data = {}, headers = {} }: Ma
32
33
 
33
34
  const body = ['POST', 'PATCH'].includes(action) ? JSON.stringify(data) : undefined
34
35
 
35
- return fetch(decodeURIComponent(uri.toString()), {
36
+ return instrumentedFetch(decodeURIComponent(uri.toString()), {
36
37
  method: action,
37
38
  headers,
38
39
  body,
@@ -0,0 +1,62 @@
1
+ import { LogAdapter } from '../log'
2
+
3
+ describe('LogAdapter', () => {
4
+ describe('reportError', () => {
5
+ it('forwards the error and context to the host callback', () => {
6
+ const reportError = jest.fn()
7
+ const adapter = new LogAdapter({ reportError })
8
+ const error = new Error('boom')
9
+ const context = { scope: 'screen' as const, screenName: 'ConversationScreen' }
10
+
11
+ adapter.reportError(error, context)
12
+
13
+ expect(reportError).toHaveBeenCalledWith(error, context)
14
+ })
15
+
16
+ it('fires even when the adapter is disabled', () => {
17
+ const reportError = jest.fn()
18
+ const adapter = new LogAdapter({ enabled: false, reportError })
19
+
20
+ adapter.reportError(new Error('boom'))
21
+
22
+ expect(reportError).toHaveBeenCalledTimes(1)
23
+ })
24
+
25
+ it('falls back to console.error when no host callback is configured', () => {
26
+ const spy = jest.spyOn(console, 'error').mockImplementation(() => {})
27
+ const adapter = new LogAdapter()
28
+ const error = new Error('boom')
29
+
30
+ adapter.reportError(error, { scope: 'http' })
31
+
32
+ expect(spy).toHaveBeenCalledWith(error, { scope: 'http' })
33
+ spy.mockRestore()
34
+ })
35
+ })
36
+
37
+ describe('info and error', () => {
38
+ it('drops info and error calls when disabled', () => {
39
+ const info = jest.fn()
40
+ const error = jest.fn()
41
+ const adapter = new LogAdapter({ enabled: false, info, error })
42
+
43
+ adapter.info('hi')
44
+ adapter.error('bye')
45
+
46
+ expect(info).not.toHaveBeenCalled()
47
+ expect(error).not.toHaveBeenCalled()
48
+ })
49
+
50
+ it('forwards info and error calls when enabled', () => {
51
+ const info = jest.fn()
52
+ const error = jest.fn()
53
+ const adapter = new LogAdapter({ enabled: true, info, error })
54
+
55
+ adapter.info('hi', { a: 1 })
56
+ adapter.error('bye', { b: 2 })
57
+
58
+ expect(info).toHaveBeenCalledWith('hi', { a: 1 })
59
+ expect(error).toHaveBeenCalledWith('bye', { b: 2 })
60
+ })
61
+ })
62
+ })
@@ -1,18 +1,31 @@
1
+ export type ReportErrorScope = 'screen' | 'component' | 'http'
2
+
3
+ export type ReportErrorContext = {
4
+ componentStack?: string
5
+ scope?: ReportErrorScope
6
+ screenName?: string
7
+ tags?: Record<string, string>
8
+ extra?: Record<string, unknown>
9
+ }
10
+
1
11
  interface LogAdapterConfig {
2
12
  enabled: boolean
3
13
  error: (message: string, ...args: any[]) => void
4
14
  info: (message: string, ...args: any[]) => void
15
+ reportError: (error: Error, context?: ReportErrorContext) => void
5
16
  }
6
17
 
7
18
  export class LogAdapter {
8
19
  enabled: boolean
9
20
  onError?: LogAdapterConfig['error']
10
21
  onInfo?: LogAdapterConfig['info']
22
+ onReportError?: LogAdapterConfig['reportError']
11
23
 
12
24
  constructor(config: Partial<LogAdapterConfig> = {}) {
13
25
  this.enabled = config.enabled ?? false
14
26
  this.onError = config.error
15
27
  this.onInfo = config.info
28
+ this.onReportError = config.reportError
16
29
  }
17
30
 
18
31
  error(message: string, ...args: any[]) {
@@ -26,4 +39,13 @@ export class LogAdapter {
26
39
 
27
40
  this.onInfo?.(message, ...args)
28
41
  }
42
+
43
+ reportError(error: Error, context?: ReportErrorContext) {
44
+ if (this.onReportError) {
45
+ this.onReportError(error, context)
46
+ return
47
+ }
48
+
49
+ console.error(error, context)
50
+ }
29
51
  }
@@ -7,7 +7,7 @@
7
7
 
8
8
  import { Platform } from 'react-native'
9
9
  import DeviceInfo from 'react-native-device-info'
10
- import { ApiClient } from '../hooks'
10
+ import type { ApiClient } from '../hooks/use_api_client'
11
11
  const appName = DeviceInfo.getApplicationName()
12
12
 
13
13
  interface PerformanceMetric {