@planningcenter/chat-react-native 3.21.2-rc.4 → 3.22.0-rc.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 (57) hide show
  1. package/build/components/conversation/attachments/attachment_card.js +1 -0
  2. package/build/components/conversation/attachments/attachment_card.js.map +1 -1
  3. package/build/components/conversation/attachments/attachment_deleting_overlay.d.ts +3 -0
  4. package/build/components/conversation/attachments/attachment_deleting_overlay.d.ts.map +1 -0
  5. package/build/components/conversation/attachments/attachment_deleting_overlay.js +29 -0
  6. package/build/components/conversation/attachments/attachment_deleting_overlay.js.map +1 -0
  7. package/build/components/conversation/attachments/audio_attachment.d.ts +2 -1
  8. package/build/components/conversation/attachments/audio_attachment.d.ts.map +1 -1
  9. package/build/components/conversation/attachments/audio_attachment.js +5 -3
  10. package/build/components/conversation/attachments/audio_attachment.js.map +1 -1
  11. package/build/components/conversation/attachments/generic_file_attachment.d.ts +2 -1
  12. package/build/components/conversation/attachments/generic_file_attachment.d.ts.map +1 -1
  13. package/build/components/conversation/attachments/generic_file_attachment.js +4 -2
  14. package/build/components/conversation/attachments/generic_file_attachment.js.map +1 -1
  15. package/build/components/conversation/attachments/image_attachment.d.ts +2 -1
  16. package/build/components/conversation/attachments/image_attachment.d.ts.map +1 -1
  17. package/build/components/conversation/attachments/image_attachment.js +9 -4
  18. package/build/components/conversation/attachments/image_attachment.js.map +1 -1
  19. package/build/components/conversation/attachments/video_attachment.d.ts +2 -1
  20. package/build/components/conversation/attachments/video_attachment.d.ts.map +1 -1
  21. package/build/components/conversation/attachments/video_attachment.js +5 -3
  22. package/build/components/conversation/attachments/video_attachment.js.map +1 -1
  23. package/build/components/conversation/message_attachments.d.ts.map +1 -1
  24. package/build/components/conversation/message_attachments.js +8 -6
  25. package/build/components/conversation/message_attachments.js.map +1 -1
  26. package/build/contexts/session_context.d.ts +40 -0
  27. package/build/contexts/session_context.d.ts.map +1 -0
  28. package/build/contexts/session_context.js +131 -0
  29. package/build/contexts/session_context.js.map +1 -0
  30. package/build/hooks/index.d.ts +1 -0
  31. package/build/hooks/index.d.ts.map +1 -1
  32. package/build/hooks/index.js +1 -0
  33. package/build/hooks/index.js.map +1 -1
  34. package/build/hooks/use_deleting_ids.d.ts +4 -0
  35. package/build/hooks/use_deleting_ids.d.ts.map +1 -0
  36. package/build/hooks/use_deleting_ids.js +19 -0
  37. package/build/hooks/use_deleting_ids.js.map +1 -0
  38. package/build/screens/attachment_actions/attachment_actions_screen.d.ts.map +1 -1
  39. package/build/screens/attachment_actions/attachment_actions_screen.js +9 -2
  40. package/build/screens/attachment_actions/attachment_actions_screen.js.map +1 -1
  41. package/build/screens/attachment_actions/hooks/useDeleteAttachment.d.ts.map +1 -1
  42. package/build/screens/attachment_actions/hooks/useDeleteAttachment.js +1 -3
  43. package/build/screens/attachment_actions/hooks/useDeleteAttachment.js.map +1 -1
  44. package/package.json +2 -2
  45. package/src/__tests__/contexts/session_context.tsx +420 -0
  46. package/src/components/conversation/attachments/attachment_card.tsx +1 -0
  47. package/src/components/conversation/attachments/attachment_deleting_overlay.tsx +34 -0
  48. package/src/components/conversation/attachments/audio_attachment.tsx +7 -2
  49. package/src/components/conversation/attachments/generic_file_attachment.tsx +6 -1
  50. package/src/components/conversation/attachments/image_attachment.tsx +11 -3
  51. package/src/components/conversation/attachments/video_attachment.tsx +7 -2
  52. package/src/components/conversation/message_attachments.tsx +9 -0
  53. package/src/contexts/session_context.tsx +234 -0
  54. package/src/hooks/index.ts +1 -0
  55. package/src/hooks/use_deleting_ids.ts +22 -0
  56. package/src/screens/attachment_actions/attachment_actions_screen.tsx +9 -2
  57. package/src/screens/attachment_actions/hooks/useDeleteAttachment.tsx +1 -3
@@ -10,6 +10,7 @@ import { GiphyAttachment } from './attachments/giphy_attachment'
10
10
  import { GenericFileAttachment } from './attachments/generic_file_attachment'
11
11
  import { ExpandedLink } from './attachments/expanded_link'
12
12
  import { ImageAttachment, type MetaProps } from './attachments/image_attachment'
13
+ import { useDeletingIds } from '../../hooks'
13
14
 
14
15
  export function MessageAttachments(props: {
15
16
  attachments: DenormalizedAttachmentResource[]
@@ -19,6 +20,7 @@ export function MessageAttachments(props: {
19
20
  }) {
20
21
  const styles = useStyles()
21
22
  const { attachments, metaProps, onMessageAttachmentLongPress, onMessageLongPress } = props
23
+ const deletingAttachmentIds = useDeletingIds('deleteAttachment')
22
24
  if (!attachments || attachments.length === 0) return null
23
25
 
24
26
  const imageAttachments = attachments.filter(
@@ -40,6 +42,7 @@ export function MessageAttachments(props: {
40
42
  currentImageIndex={index}
41
43
  metaProps={metaProps}
42
44
  onMessageAttachmentLongPress={onMessageAttachmentLongPress}
45
+ isDeleting={deletingAttachmentIds.has(image.id)}
43
46
  />
44
47
  ))}
45
48
 
@@ -51,6 +54,7 @@ export function MessageAttachments(props: {
51
54
  key={`${attachment.id}-${index}`}
52
55
  attachment={attachment}
53
56
  onMessageAttachmentLongPress={onMessageAttachmentLongPress}
57
+ isDeleting={deletingAttachmentIds.has(attachment.id)}
54
58
  />
55
59
  )
56
60
  case 'giphy':
@@ -80,9 +84,11 @@ export function MessageAttachments(props: {
80
84
  function MessageAttachment({
81
85
  attachment,
82
86
  onMessageAttachmentLongPress,
87
+ isDeleting,
83
88
  }: {
84
89
  attachment: DenormalizedMessageAttachmentResource
85
90
  onMessageAttachmentLongPress: (attachment: DenormalizedMessageAttachmentResource) => void
91
+ isDeleting: boolean
86
92
  }) {
87
93
  const { attributes } = attachment
88
94
  const contentType = attributes?.contentType
@@ -94,6 +100,7 @@ function MessageAttachment({
94
100
  <VideoAttachment
95
101
  attachment={attachment}
96
102
  onMessageAttachmentLongPress={onMessageAttachmentLongPress}
103
+ isDeleting={isDeleting}
97
104
  />
98
105
  )
99
106
  case 'audio':
@@ -101,6 +108,7 @@ function MessageAttachment({
101
108
  <AudioAttachment
102
109
  attachment={attachment}
103
110
  onMessageAttachmentLongPress={onMessageAttachmentLongPress}
111
+ isDeleting={isDeleting}
104
112
  />
105
113
  )
106
114
  case 'application':
@@ -108,6 +116,7 @@ function MessageAttachment({
108
116
  <GenericFileAttachment
109
117
  attachment={attachment}
110
118
  onMessageAttachmentLongPress={onMessageAttachmentLongPress}
119
+ isDeleting={isDeleting}
111
120
  />
112
121
  )
113
122
  default:
@@ -0,0 +1,234 @@
1
+ import { Session, ENV } from '../utils'
2
+ import { FailedResponse, OAuthToken } from '../types'
3
+ import { chatQueryClient } from './api_provider'
4
+ import { QueryClient, useMutation } from '@tanstack/react-query'
5
+ import React, {
6
+ createContext,
7
+ PropsWithChildren,
8
+ useCallback,
9
+ useEffect,
10
+ useMemo,
11
+ useState,
12
+ } from 'react'
13
+ import { Alert } from 'react-native'
14
+ import { useStorage } from '../hooks/use_storage'
15
+ import { StorageAdapter } from '../utils/native_adapters/storage_adapter'
16
+
17
+ export type SessionContextValue = {
18
+ env: ENV
19
+ setEnv: (_env: ENV) => void
20
+ session: Session
21
+ token: OAuthToken | undefined
22
+ handleUnauthorizedResponse: (_response: FailedResponse) => void
23
+ logout: () => void
24
+ setToken: (_token: OAuthToken) => void
25
+ }
26
+
27
+ export const SessionContext = createContext<SessionContextValue>({
28
+ env: 'production',
29
+ setEnv: () => null,
30
+ session: new Session(),
31
+ token: undefined,
32
+ handleUnauthorizedResponse: () => null,
33
+ logout: () => null,
34
+ setToken: () => null,
35
+ })
36
+
37
+ type Sessions = Record<ENV, string>
38
+ const environments: ENV[] = ['production', 'staging', 'development']
39
+
40
+ const initialSessions = environments.reduce((acc, env) => {
41
+ const sessionProps = { env, token: undefined }
42
+
43
+ acc[env] = new Session(sessionProps).toString()
44
+
45
+ return acc
46
+ }, {} as Sessions)
47
+
48
+ export type SessionContextConfig = {
49
+ // Storage adapters
50
+ storage: StorageAdapter // For non-sensitive data (env preference)
51
+ secureStorage: StorageAdapter // For sensitive data (tokens/sessions)
52
+
53
+ // Functional callbacks
54
+ refreshTokenFn: (params: { refresh_token: string; env: ENV }) => Promise<OAuthToken>
55
+ onLogout: () => void | Promise<void>
56
+
57
+ // Optional configuration
58
+ storageKeys?: {
59
+ env?: string
60
+ sessions?: string
61
+ }
62
+ alertConfig?: {
63
+ title?: string
64
+ message?: string
65
+ retryText?: string
66
+ logoutText?: string
67
+ }
68
+ defaultEnv?: ENV
69
+ }
70
+
71
+ export function SessionContextProvider({
72
+ children,
73
+ queryClient,
74
+ config,
75
+ }: PropsWithChildren<{ queryClient: QueryClient; config: SessionContextConfig }>) {
76
+ const {
77
+ storage,
78
+ secureStorage,
79
+ refreshTokenFn,
80
+ onLogout,
81
+ storageKeys = {},
82
+ alertConfig = {},
83
+ defaultEnv = 'production',
84
+ } = config
85
+
86
+ const envKey = storageKeys.env || 'env'
87
+ const sessionsKey = storageKeys.sessions || 'sessions-storage'
88
+
89
+ const [env, setEnv] = useStorage<ENV>(storage, envKey, defaultEnv)
90
+ const [sessions, setSessions] = useStorage<Sessions>(secureStorage, sessionsKey, initialSessions)
91
+ const [alertShown, setAlertShown] = useState(false)
92
+ const session = useMemo(() => Session.hydrate<OAuthToken>(sessions[env]), [sessions, env])
93
+ const { token } = session
94
+
95
+ const {
96
+ mutate: refreshToken,
97
+ isPending: isRefreshingToken,
98
+ isError: isRefreshError,
99
+ } = useMutation({
100
+ mutationKey: ['refresh-token', token?.refresh_token],
101
+ mutationFn: () => {
102
+ if (!token?.refresh_token) {
103
+ return Promise.reject(new Error('Refresh token is required'))
104
+ }
105
+ return refreshTokenFn({ refresh_token: token.refresh_token, env: session.env }).then(
106
+ handleTokenUpdate
107
+ )
108
+ },
109
+ onError: (t: Partial<FailedResponse>) => {
110
+ handleRefreshFailed(t)
111
+ },
112
+ })
113
+
114
+ const handleClearQueryClient = useCallback(() => {
115
+ chatQueryClient.clear()
116
+ queryClient.clear()
117
+ }, [queryClient])
118
+
119
+ const handleSetEnv = useCallback(
120
+ (_env: ENV) => {
121
+ setEnv(_env)
122
+ handleClearQueryClient()
123
+ },
124
+ [handleClearQueryClient, setEnv]
125
+ )
126
+
127
+ const handleLogout = useCallback(async () => {
128
+ handleClearQueryClient()
129
+
130
+ return setSessions({
131
+ ...sessions,
132
+ [env]: new Session({ env }).toString(),
133
+ }).then(() => onLogout())
134
+ }, [env, sessions, setSessions, handleClearQueryClient, onLogout])
135
+
136
+ const handleTokenUpdate = useCallback(
137
+ (t: OAuthToken) => {
138
+ if (!t || !session) return t
139
+
140
+ session.token = t
141
+
142
+ return setSessions({
143
+ ...sessions,
144
+ [env]: session?.toString(),
145
+ }).then(() => t)
146
+ },
147
+ [env, session, sessions, setSessions]
148
+ )
149
+
150
+ const handleRefreshFailed = useCallback(
151
+ (t: Partial<FailedResponse>) => {
152
+ if (t.status !== 401) return
153
+ // If we didn't fail because of an unauthorized response ( 401 ),
154
+ // it could be because we were unable to store the token.
155
+ // Do not log out.
156
+
157
+ const { errors = [] } = t
158
+ // The code for a forced logout is "capuchin"
159
+ const isForcedLogout = errors.some((e: { detail?: string }) =>
160
+ /capuchin/i.test(e.detail || '')
161
+ )
162
+
163
+ if (isForcedLogout) {
164
+ handleLogout()
165
+ }
166
+ },
167
+ [handleLogout]
168
+ )
169
+
170
+ const handleUnauthorizedResponse = useCallback(
171
+ async (response: FailedResponse) => {
172
+ const { errors = [] } = response
173
+
174
+ const isUnauthorized = errors.some((e: { detail?: string }) =>
175
+ /TRASH_PANDA/i.test(e.detail || '')
176
+ )
177
+
178
+ if (isUnauthorized) return
179
+
180
+ const isForcedLogout = errors.some((e: { detail?: string }) =>
181
+ /capuchin/i.test(e.detail || '')
182
+ )
183
+ const isExpiredToken = errors.some((e: { detail?: string }) => /baboon/i.test(e.detail || ''))
184
+
185
+ if (!isRefreshingToken || isExpiredToken || isForcedLogout) {
186
+ refreshToken()
187
+ }
188
+ },
189
+ [refreshToken, isRefreshingToken]
190
+ )
191
+
192
+ const sessionContextValue: SessionContextValue = {
193
+ env: session.env,
194
+ token: session.token,
195
+ session,
196
+ logout: handleLogout,
197
+ setToken: handleTokenUpdate,
198
+ handleUnauthorizedResponse,
199
+ setEnv: handleSetEnv,
200
+ }
201
+
202
+ const alertTitle = alertConfig.title || 'Oops'
203
+ const alertMessage = alertConfig.message || 'Something went wrong with your login!'
204
+ const retryText = alertConfig.retryText || 'Keep trying'
205
+ const logoutText = alertConfig.logoutText || 'Logout'
206
+
207
+ useEffect(() => {
208
+ if (isRefreshError && !alertShown && token?.refresh_token) {
209
+ setAlertShown(true)
210
+ Alert.alert(alertTitle, alertMessage, [
211
+ {
212
+ text: retryText,
213
+ onPress: () => {
214
+ refreshToken()
215
+ setAlertShown(false)
216
+ },
217
+ },
218
+ { text: logoutText, onPress: () => handleLogout() },
219
+ ])
220
+ }
221
+ }, [
222
+ isRefreshError,
223
+ handleLogout,
224
+ alertShown,
225
+ refreshToken,
226
+ token?.refresh_token,
227
+ alertTitle,
228
+ alertMessage,
229
+ retryText,
230
+ logoutText,
231
+ ])
232
+
233
+ return <SessionContext.Provider value={sessionContextValue}>{children}</SessionContext.Provider>
234
+ }
@@ -3,6 +3,7 @@ export * from './use_animated_message_background_color'
3
3
  export * from './use_theme'
4
4
  export * from './use_suspense_api'
5
5
  export * from './use_current_person'
6
+ export * from './use_deleting_ids'
6
7
  export * from './use_font_scale'
7
8
  export * from './use_create_android_ripple_color'
8
9
  export * from './use_chat_permissions'
@@ -0,0 +1,22 @@
1
+ import { useMutationState } from '@tanstack/react-query'
2
+ import { useMemo } from 'react'
3
+
4
+ type DeletableResourceEvent = 'deleteAttachment'
5
+
6
+ export function useDeletingIds(event: DeletableResourceEvent): Set<string> {
7
+ const ids = useMutationState({
8
+ filters: {
9
+ mutationKey: [event],
10
+ status: 'pending',
11
+ },
12
+ select: mutation => {
13
+ // Extract the item ID from the mutation key: [ event, id ]
14
+ const key = mutation.options.mutationKey
15
+ return key && Array.isArray(key) && typeof key[1] === 'string' ? key[1] : null
16
+ },
17
+ })
18
+
19
+ return useMemo(() => {
20
+ return new Set(ids.filter(id => id !== null))
21
+ }, [ids])
22
+ }
@@ -44,10 +44,17 @@ export function AttachmentActionsScreen({ route }: AttachmentActionsScreenProps)
44
44
  'Are you sure you want to permanently delete this attachment?',
45
45
  [
46
46
  { text: 'Cancel', style: 'cancel' },
47
- { text: 'Delete', style: 'destructive', onPress: () => handleDeleteAttachment() },
47
+ {
48
+ text: 'Delete',
49
+ style: 'destructive',
50
+ onPress: () => {
51
+ handleDeleteAttachment()
52
+ navigation.goBack()
53
+ },
54
+ },
48
55
  ]
49
56
  )
50
- }, [attachmentName, handleDeleteAttachment])
57
+ }, [attachmentName, handleDeleteAttachment, navigation])
51
58
 
52
59
  const handleOpenInBrowser = () => {
53
60
  Linking.openURL(attachmentUrl)
@@ -1,5 +1,4 @@
1
1
  import { Alert } from 'react-native'
2
- import { useNavigation } from '@react-navigation/native'
3
2
  import { useMutation } from '@tanstack/react-query'
4
3
  import { useApiClient } from '../../../hooks/use_api_client'
5
4
  import { useCallback } from 'react'
@@ -18,7 +17,6 @@ export function useDeleteAttachment({
18
17
  attachmentName,
19
18
  }: UseDeleteAttachmentProps) {
20
19
  const apiClient = useApiClient()
21
- const navigation = useNavigation()
22
20
  const { refetch } = useConversationMessages({ conversation_id }, { refetchOnMount: false })
23
21
 
24
22
  const deleteAttachment = useCallback(() => {
@@ -29,10 +27,10 @@ export function useDeleteAttachment({
29
27
 
30
28
  const { mutate: handleDeleteAttachment } = useMutation({
31
29
  mutationFn: deleteAttachment,
30
+ mutationKey: ['deleteAttachment', attachmentId],
32
31
  onSuccess: () => {
33
32
  refetch()
34
33
  Haptic.notificationSuccess()
35
- navigation.goBack()
36
34
  },
37
35
  onError: () => {
38
36
  Alert.alert(