@planningcenter/chat-react-native 3.37.0-rc.0 → 3.37.0-rc.1

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 (57) hide show
  1. package/build/components/display/action_button.d.ts +3 -1
  2. package/build/components/display/action_button.d.ts.map +1 -1
  3. package/build/components/display/action_button.js +8 -1
  4. package/build/components/display/action_button.js.map +1 -1
  5. package/build/hooks/groups/use_group_chat_conversation_payload.d.ts +168 -0
  6. package/build/hooks/groups/use_group_chat_conversation_payload.d.ts.map +1 -0
  7. package/build/hooks/groups/use_group_chat_conversation_payload.js +23 -0
  8. package/build/hooks/groups/use_group_chat_conversation_payload.js.map +1 -0
  9. package/build/hooks/groups/use_groups_conversation_create.js +1 -1
  10. package/build/hooks/groups/use_groups_conversation_create.js.map +1 -1
  11. package/build/hooks/services/use_find_or_create_services_conversation.d.ts +11 -3
  12. package/build/hooks/services/use_find_or_create_services_conversation.d.ts.map +1 -1
  13. package/build/hooks/services/use_find_or_create_services_conversation.js +10 -14
  14. package/build/hooks/services/use_find_or_create_services_conversation.js.map +1 -1
  15. package/build/hooks/services/use_services_chat_conversation_payload.d.ts +164 -0
  16. package/build/hooks/services/use_services_chat_conversation_payload.d.ts.map +1 -0
  17. package/build/hooks/services/use_services_chat_conversation_payload.js +16 -0
  18. package/build/hooks/services/use_services_chat_conversation_payload.js.map +1 -0
  19. package/build/hooks/use_conversation_validate.d.ts +12 -0
  20. package/build/hooks/use_conversation_validate.d.ts.map +1 -0
  21. package/build/hooks/use_conversation_validate.js +28 -0
  22. package/build/hooks/use_conversation_validate.js.map +1 -0
  23. package/build/hooks/use_features.d.ts +1 -0
  24. package/build/hooks/use_features.d.ts.map +1 -1
  25. package/build/hooks/use_features.js +1 -0
  26. package/build/hooks/use_features.js.map +1 -1
  27. package/build/screens/conversation_new/components/groups_form.d.ts.map +1 -1
  28. package/build/screens/conversation_new/components/groups_form.js +14 -1
  29. package/build/screens/conversation_new/components/groups_form.js.map +1 -1
  30. package/build/screens/conversation_new/components/services_form.d.ts.map +1 -1
  31. package/build/screens/conversation_new/components/services_form.js +20 -2
  32. package/build/screens/conversation_new/components/services_form.js.map +1 -1
  33. package/build/screens/team_conversation_screen.d.ts.map +1 -1
  34. package/build/screens/team_conversation_screen.js +6 -3
  35. package/build/screens/team_conversation_screen.js.map +1 -1
  36. package/build/types/resources/conversation_validate.d.ts +10 -0
  37. package/build/types/resources/conversation_validate.d.ts.map +1 -0
  38. package/build/types/resources/conversation_validate.js +2 -0
  39. package/build/types/resources/conversation_validate.js.map +1 -0
  40. package/build/types/resources/index.d.ts +1 -0
  41. package/build/types/resources/index.d.ts.map +1 -1
  42. package/build/types/resources/index.js +1 -0
  43. package/build/types/resources/index.js.map +1 -1
  44. package/package.json +2 -2
  45. package/src/__tests__/hooks/use_conversation_validate.test.tsx +117 -0
  46. package/src/components/display/action_button.tsx +18 -0
  47. package/src/hooks/groups/use_group_chat_conversation_payload.ts +38 -0
  48. package/src/hooks/groups/use_groups_conversation_create.ts +1 -1
  49. package/src/hooks/services/use_find_or_create_services_conversation.ts +27 -24
  50. package/src/hooks/services/use_services_chat_conversation_payload.ts +26 -0
  51. package/src/hooks/use_conversation_validate.ts +45 -0
  52. package/src/hooks/use_features.ts +1 -0
  53. package/src/screens/conversation_new/components/groups_form.tsx +17 -1
  54. package/src/screens/conversation_new/components/services_form.tsx +26 -2
  55. package/src/screens/team_conversation_screen.tsx +6 -6
  56. package/src/types/resources/conversation_validate.ts +11 -0
  57. package/src/types/resources/index.ts +1 -0
@@ -0,0 +1,117 @@
1
+ import { QueryClientProvider } from '@tanstack/react-query'
2
+ import { renderHook, waitFor } from '@testing-library/react-native'
3
+ import React from 'react'
4
+ import { buildTestQueryClient } from '../../__utils__/query_client'
5
+ import * as useApiClientModule from '../../hooks/use_api_client'
6
+ import { useConversationValidate } from '../../hooks/use_conversation_validate'
7
+
8
+ const createWrapper = () => {
9
+ const queryClient = buildTestQueryClient()
10
+ return ({ children }: { children: React.ReactNode }) => (
11
+ <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
12
+ )
13
+ }
14
+
15
+ const mockChatPost = (impl: jest.Mock) => {
16
+ jest.spyOn(useApiClientModule, 'useApiClient').mockReturnValue({
17
+ chat: { post: impl },
18
+ } as unknown as ReturnType<typeof useApiClientModule.useApiClient>)
19
+ return impl
20
+ }
21
+
22
+ const resolveWith = (warnings: { attribute: string; fullMessage: string }[]) =>
23
+ jest.fn().mockResolvedValue({
24
+ data: { type: 'ConversationValidate', id: '1', warnings },
25
+ links: {},
26
+ meta: {},
27
+ })
28
+
29
+ describe('useConversationValidate', () => {
30
+ afterEach(() => {
31
+ jest.restoreAllMocks()
32
+ })
33
+
34
+ it('does not fire the request when disabled', () => {
35
+ const post = mockChatPost(resolveWith([]))
36
+
37
+ const { result } = renderHook(
38
+ () => useConversationValidate({ payload: 'abc', enabled: false }),
39
+ { wrapper: createWrapper() }
40
+ )
41
+
42
+ expect(post).not.toHaveBeenCalled()
43
+ expect(result.current.warnings).toEqual([])
44
+ expect(result.current.validationPending).toBe(false)
45
+ })
46
+
47
+ it('does not fire the request when payload is undefined', () => {
48
+ const post = mockChatPost(resolveWith([]))
49
+
50
+ const { result } = renderHook(
51
+ () => useConversationValidate({ payload: undefined, isLoadingPayload: true }),
52
+ { wrapper: createWrapper() }
53
+ )
54
+
55
+ expect(post).not.toHaveBeenCalled()
56
+ // validationPending stays true while the upstream payload query is still loading
57
+ expect(result.current.validationPending).toBe(true)
58
+ })
59
+
60
+ it('returns warnings from the response and clears validationPending', async () => {
61
+ const warnings = [{ attribute: 'safety_policy', fullMessage: 'Needs a second adult.' }]
62
+ mockChatPost(resolveWith(warnings))
63
+
64
+ const { result } = renderHook(() => useConversationValidate({ payload: 'abc' }), {
65
+ wrapper: createWrapper(),
66
+ })
67
+
68
+ await waitFor(() => expect(result.current.warnings).toEqual(warnings))
69
+ expect(result.current.validationPending).toBe(false)
70
+ })
71
+
72
+ it('sends gender_id in attributes when provided', async () => {
73
+ const post = mockChatPost(resolveWith([]))
74
+
75
+ renderHook(() => useConversationValidate({ payload: 'abc', genderId: '42' }), {
76
+ wrapper: createWrapper(),
77
+ })
78
+
79
+ await waitFor(() => expect(post).toHaveBeenCalledTimes(1))
80
+ expect(post).toHaveBeenCalledWith(
81
+ expect.objectContaining({
82
+ url: '/me/conversation_validate',
83
+ data: {
84
+ data: {
85
+ type: 'ConversationValidate',
86
+ attributes: { payload: 'abc', gender_id: '42' },
87
+ },
88
+ },
89
+ })
90
+ )
91
+ })
92
+
93
+ it('omits gender_id when not provided', async () => {
94
+ const post = mockChatPost(resolveWith([]))
95
+
96
+ renderHook(() => useConversationValidate({ payload: 'abc' }), {
97
+ wrapper: createWrapper(),
98
+ })
99
+
100
+ await waitFor(() => expect(post).toHaveBeenCalledTimes(1))
101
+ const [call] = post.mock.calls
102
+ expect(call[0].data.data.attributes).toEqual({ payload: 'abc' })
103
+ })
104
+
105
+ it('falls back to empty warnings and unblocks on request error', async () => {
106
+ // Documents the current fail-open behavior: if validate errors, the user is allowed to proceed.
107
+ const post = mockChatPost(jest.fn().mockRejectedValue(new Error('boom')))
108
+
109
+ const { result } = renderHook(() => useConversationValidate({ payload: 'abc' }), {
110
+ wrapper: createWrapper(),
111
+ })
112
+
113
+ await waitFor(() => expect(post).toHaveBeenCalledTimes(1))
114
+ await waitFor(() => expect(result.current.validationPending).toBe(false))
115
+ expect(result.current.warnings).toEqual([])
116
+ })
117
+ })
@@ -3,7 +3,9 @@ import { useEffect } from 'react'
3
3
  import { Animated, LayoutAnimation, StyleSheet, useWindowDimensions, View } from 'react-native'
4
4
  import { useSafeAreaInsets } from 'react-native-safe-area-context'
5
5
  import { useTheme } from '../../hooks'
6
+ import { ConversationWarning } from '../../types'
6
7
  import { MAX_FONT_SIZE_MULTIPLIER_LANDMARK } from '../../utils'
8
+ import { Banner } from './banner'
7
9
  import { Button } from './button'
8
10
  import { IconString } from './icon'
9
11
  import { Text } from './text'
@@ -17,6 +19,7 @@ export const ActionButton = ({
17
19
  buttonIconNameLeft,
18
20
  secondaryButton,
19
21
  loading = false,
22
+ warnings = [],
20
23
  }: {
21
24
  visible?: boolean
22
25
  disabled?: boolean
@@ -26,6 +29,7 @@ export const ActionButton = ({
26
29
  buttonIconNameLeft?: IconString
27
30
  secondaryButton?: React.ReactNode
28
31
  loading?: boolean
32
+ warnings?: ConversationWarning[]
29
33
  }) => {
30
34
  const styles = useStyles()
31
35
  const [show, setShow] = useState(visible)
@@ -41,6 +45,17 @@ export const ActionButton = ({
41
45
 
42
46
  return (
43
47
  <Animated.View style={styles.container}>
48
+ {warnings.length > 0 && (
49
+ <View style={styles.warnings}>
50
+ {warnings.map((warning, index) => (
51
+ <Banner
52
+ key={`${warning.attribute}-${index}`}
53
+ appearance="error"
54
+ description={warning.fullMessage}
55
+ />
56
+ ))}
57
+ </View>
58
+ )}
44
59
  {Boolean(infoText) && (
45
60
  <Text style={styles.infoText} variant="footnote">
46
61
  {infoText}
@@ -95,5 +110,8 @@ const useStyles = () => {
95
110
  infoText: {
96
111
  textAlign: 'center',
97
112
  },
113
+ warnings: {
114
+ gap: 8,
115
+ },
98
116
  })
99
117
  }
@@ -0,0 +1,38 @@
1
+ import { useQuery } from '@tanstack/react-query'
2
+ import { ApiResource, ResourceObject } from '../../types'
3
+ import { useApiClient } from '../use_api_client'
4
+
5
+ const STALE_TIME_MS = 25 * 60 * 1000 // under Groups' 30-min server-side expiry
6
+
7
+ interface Props {
8
+ groupId: number
9
+ enabled?: boolean
10
+ }
11
+
12
+ export function useGroupChatConversationPayload({ groupId, enabled = true }: Props) {
13
+ const apiClient = useApiClient()
14
+ const url = `/me/groups/${groupId}/chat_conversation_payload`
15
+
16
+ const { data, ...rest } = useQuery({
17
+ queryKey: ['groups', url],
18
+ queryFn: () =>
19
+ apiClient.groups.post<ApiResource<GroupChatConversationPayload>>({
20
+ url,
21
+ data: {
22
+ data: {
23
+ type: 'GroupChatConversationPayload',
24
+ attributes: { title: '' },
25
+ },
26
+ },
27
+ }),
28
+ staleTime: STALE_TIME_MS,
29
+ enabled,
30
+ })
31
+
32
+ return { payload: data?.data.value, ...rest }
33
+ }
34
+
35
+ interface GroupChatConversationPayload extends ResourceObject {
36
+ type: 'GroupChatConversationPayload'
37
+ value: string
38
+ }
@@ -24,7 +24,7 @@ export function useGroupsConversationCreate({ groupId, title, genderId, onSucces
24
24
  url: `/me/groups/${groupId}/chat_conversation_payload`,
25
25
  data: {
26
26
  data: {
27
- type: '',
27
+ type: 'GroupChatConversationPayload',
28
28
  attributes: {
29
29
  title,
30
30
  },
@@ -26,14 +26,7 @@ export const useFindOrCreateServicesConversation = ({ teamIds, planId, onSuccess
26
26
  const apiClient = useApiClient()
27
27
 
28
28
  const teamAndPlanParams: TeamAndPlanParams = useMemo(
29
- () =>
30
- omitBy(
31
- {
32
- team_id: teamIds?.join(',') || null,
33
- plan_id: planId,
34
- },
35
- isNil
36
- ),
29
+ () => buildTeamAndPlanParams(teamIds, planId),
37
30
  [teamIds, planId]
38
31
  )
39
32
 
@@ -49,16 +42,23 @@ export const useFindOrCreateServicesConversation = ({ teamIds, planId, onSuccess
49
42
  onSuccess: ({ conversation, created }) => {
50
43
  onSuccess?.(conversation, { created })
51
44
  },
52
- mutationFn: async () => findOrCreateServicesConversation(apiClient, teamAndPlanParams),
45
+ mutationFn: async () =>
46
+ findOrCreateServicesConversation({ apiClient, teamIds: teamIds ?? [], planId }),
53
47
  })
54
48
 
55
49
  return { ...mutation, selectionHasConversation, isLoadingConversationCheck }
56
50
  }
57
51
 
58
- export const findOrCreateServicesConversation = async (
59
- apiClient: ApiClient,
60
- teamAndPlanParams: TeamAndPlanParams
61
- ): Promise<{ conversation: ConversationResource; created: boolean }> => {
52
+ export const findOrCreateServicesConversation = async ({
53
+ apiClient,
54
+ teamIds,
55
+ planId,
56
+ }: {
57
+ apiClient: ApiClient
58
+ teamIds: number[]
59
+ planId?: number
60
+ }): Promise<{ conversation: ConversationResource; created: boolean }> => {
61
+ const teamAndPlanParams = buildTeamAndPlanParams(teamIds, planId)
62
62
  const foundConversations = await getGroupIdsFromServices(apiClient, teamAndPlanParams)
63
63
  .then(res => res.data.groupIdentifiers)
64
64
  .then(groupIdentifiers => findConversationWithExactTeams(apiClient, groupIdentifiers))
@@ -69,24 +69,27 @@ export const findOrCreateServicesConversation = async (
69
69
  return { conversation: foundConversation, created: false }
70
70
  }
71
71
 
72
- return fetchServicesPayload(apiClient, teamAndPlanParams)
72
+ return getServicesChatConversationPayload({ apiClient, teamIds, planId })
73
73
  .then(res => res.data.payload)
74
74
  .then(payload => createConversation(apiClient, payload))
75
75
  .then(res => ({ conversation: res.data, created: true }))
76
76
  .catch(throwResponseError)
77
77
  }
78
78
 
79
- export const fetchServicesPayload = (
80
- apiClient: ApiClient,
81
- teamAndPlanParams: TeamAndPlanParams
82
- ) => {
79
+ export const getServicesChatConversationPayload = ({
80
+ apiClient,
81
+ teamIds,
82
+ planId,
83
+ }: {
84
+ apiClient: ApiClient
85
+ teamIds: number[]
86
+ planId?: number
87
+ }) => {
83
88
  return apiClient.services.get({
84
- url: `/chat`,
89
+ url: '/chat',
85
90
  data: {
86
- ...teamAndPlanParams,
87
- fields: {
88
- Chat: ['payload'],
89
- },
91
+ ...buildTeamAndPlanParams(teamIds, planId),
92
+ fields: { Chat: ['payload'] },
90
93
  },
91
94
  }) as Promise<ApiResource<ServicesChatPayloadResource>>
92
95
  }
@@ -157,7 +160,7 @@ export const checkIfConversationWithGroupExists = (
157
160
  export const buildTeamAndPlanParams = (teamIds: number[] = [], planId?: number) => {
158
161
  return omitBy(
159
162
  {
160
- team_id: teamIds.join(','),
163
+ team_id: teamIds.length ? teamIds.join(',') : null,
161
164
  plan_id: planId,
162
165
  },
163
166
  isNil
@@ -0,0 +1,26 @@
1
+ import { useQuery } from '@tanstack/react-query'
2
+ import { useApiClient } from '../use_api_client'
3
+ import { getServicesChatConversationPayload } from './use_find_or_create_services_conversation'
4
+
5
+ const STALE_TIME_MS = 55 * 60 * 1000 // under Services' 1-hour server-side expiry
6
+
7
+ interface Props {
8
+ teamIds: number[]
9
+ planId?: number
10
+ enabled?: boolean
11
+ }
12
+
13
+ export function useServicesChatConversationPayload({ teamIds, planId, enabled = true }: Props) {
14
+ const apiClient = useApiClient()
15
+ const sortedTeamIds = [...teamIds].sort((a, b) => a - b)
16
+
17
+ const { data, ...rest } = useQuery({
18
+ queryKey: ['services', '/chat', { teamIds: sortedTeamIds, planId }],
19
+ queryFn: () =>
20
+ getServicesChatConversationPayload({ apiClient, teamIds: sortedTeamIds, planId }),
21
+ staleTime: STALE_TIME_MS,
22
+ enabled: enabled && sortedTeamIds.length > 0,
23
+ })
24
+
25
+ return { payload: data?.data.payload, ...rest }
26
+ }
@@ -0,0 +1,45 @@
1
+ import { useQuery } from '@tanstack/react-query'
2
+ import { ApiResource, ConversationValidateResource } from '../types'
3
+ import { useApiClient } from './use_api_client'
4
+
5
+ interface Props {
6
+ payload: string | undefined
7
+ isLoadingPayload?: boolean
8
+ genderId?: string | null
9
+ enabled?: boolean
10
+ }
11
+
12
+ export function useConversationValidate({
13
+ payload,
14
+ isLoadingPayload = false,
15
+ genderId,
16
+ enabled = true,
17
+ }: Props) {
18
+ const apiClient = useApiClient()
19
+
20
+ const { data, isLoading } = useQuery({
21
+ queryKey: ['chat', '/me/conversation_validate', { payload, genderId }],
22
+ queryFn: () =>
23
+ apiClient.chat.post<ApiResource<ConversationValidateResource>>({
24
+ url: '/me/conversation_validate',
25
+ data: {
26
+ data: {
27
+ type: 'ConversationValidate',
28
+ attributes: {
29
+ payload: payload!,
30
+ ...(genderId ? { gender_id: genderId } : {}),
31
+ },
32
+ },
33
+ },
34
+ }),
35
+ enabled: enabled && payload != null,
36
+ retry: false,
37
+ })
38
+
39
+ const warnings = data?.data.warnings ?? []
40
+
41
+ return {
42
+ warnings,
43
+ validationPending: enabled && (isLoadingPayload || isLoading),
44
+ }
45
+ }
@@ -41,6 +41,7 @@ export const availableFeatures = {
41
41
  granular_notifications_ui: 'ROLLOUT_granular_notification_preferences_ui',
42
42
  custom_conversation_avatars: 'ROLLOUT_custom_conversation_avatars',
43
43
  jump_to_unread: 'ROLLOUT_jump_to_unread',
44
+ conversation_safety_lock: 'ROLLOUT_conversation_safety_lock',
44
45
  } as const satisfies Record<string, `ROLLOUT_${string}`>
45
46
 
46
47
  const stableEmptyFeatures: ApiCollection<FeatureResource> = {
@@ -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
  )
@@ -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(() => {
@@ -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'