@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.
- package/build/components/conversation/attachments/attachment_card.js +1 -0
- package/build/components/conversation/attachments/attachment_card.js.map +1 -1
- package/build/components/conversation/attachments/attachment_deleting_overlay.d.ts +3 -0
- package/build/components/conversation/attachments/attachment_deleting_overlay.d.ts.map +1 -0
- package/build/components/conversation/attachments/attachment_deleting_overlay.js +29 -0
- package/build/components/conversation/attachments/attachment_deleting_overlay.js.map +1 -0
- package/build/components/conversation/attachments/audio_attachment.d.ts +2 -1
- package/build/components/conversation/attachments/audio_attachment.d.ts.map +1 -1
- package/build/components/conversation/attachments/audio_attachment.js +5 -3
- package/build/components/conversation/attachments/audio_attachment.js.map +1 -1
- package/build/components/conversation/attachments/generic_file_attachment.d.ts +2 -1
- package/build/components/conversation/attachments/generic_file_attachment.d.ts.map +1 -1
- package/build/components/conversation/attachments/generic_file_attachment.js +4 -2
- package/build/components/conversation/attachments/generic_file_attachment.js.map +1 -1
- package/build/components/conversation/attachments/image_attachment.d.ts +2 -1
- package/build/components/conversation/attachments/image_attachment.d.ts.map +1 -1
- package/build/components/conversation/attachments/image_attachment.js +9 -4
- package/build/components/conversation/attachments/image_attachment.js.map +1 -1
- package/build/components/conversation/attachments/video_attachment.d.ts +2 -1
- package/build/components/conversation/attachments/video_attachment.d.ts.map +1 -1
- package/build/components/conversation/attachments/video_attachment.js +5 -3
- package/build/components/conversation/attachments/video_attachment.js.map +1 -1
- package/build/components/conversation/message_attachments.d.ts.map +1 -1
- package/build/components/conversation/message_attachments.js +8 -6
- package/build/components/conversation/message_attachments.js.map +1 -1
- package/build/contexts/session_context.d.ts +40 -0
- package/build/contexts/session_context.d.ts.map +1 -0
- package/build/contexts/session_context.js +131 -0
- package/build/contexts/session_context.js.map +1 -0
- 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_deleting_ids.d.ts +4 -0
- package/build/hooks/use_deleting_ids.d.ts.map +1 -0
- package/build/hooks/use_deleting_ids.js +19 -0
- package/build/hooks/use_deleting_ids.js.map +1 -0
- package/build/screens/attachment_actions/attachment_actions_screen.d.ts.map +1 -1
- package/build/screens/attachment_actions/attachment_actions_screen.js +9 -2
- package/build/screens/attachment_actions/attachment_actions_screen.js.map +1 -1
- package/build/screens/attachment_actions/hooks/useDeleteAttachment.d.ts.map +1 -1
- package/build/screens/attachment_actions/hooks/useDeleteAttachment.js +1 -3
- package/build/screens/attachment_actions/hooks/useDeleteAttachment.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/contexts/session_context.tsx +420 -0
- package/src/components/conversation/attachments/attachment_card.tsx +1 -0
- package/src/components/conversation/attachments/attachment_deleting_overlay.tsx +34 -0
- package/src/components/conversation/attachments/audio_attachment.tsx +7 -2
- package/src/components/conversation/attachments/generic_file_attachment.tsx +6 -1
- package/src/components/conversation/attachments/image_attachment.tsx +11 -3
- package/src/components/conversation/attachments/video_attachment.tsx +7 -2
- package/src/components/conversation/message_attachments.tsx +9 -0
- package/src/contexts/session_context.tsx +234 -0
- package/src/hooks/index.ts +1 -0
- package/src/hooks/use_deleting_ids.ts +22 -0
- package/src/screens/attachment_actions/attachment_actions_screen.tsx +9 -2
- 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
|
+
}
|
package/src/hooks/index.ts
CHANGED
|
@@ -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
|
-
{
|
|
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(
|