@planningcenter/chat-react-native 3.10.0 → 3.10.1-qa-291.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 (35) hide show
  1. package/build/components/conversation/attachments/image_attachment.d.ts.map +1 -1
  2. package/build/components/conversation/attachments/image_attachment.js +294 -34
  3. package/build/components/conversation/attachments/image_attachment.js.map +1 -1
  4. package/build/components/conversation/attachments/image_attachment_legacy.d.ts +12 -0
  5. package/build/components/conversation/attachments/image_attachment_legacy.d.ts.map +1 -0
  6. package/build/components/conversation/attachments/image_attachment_legacy.js +142 -0
  7. package/build/components/conversation/attachments/image_attachment_legacy.js.map +1 -0
  8. package/build/components/conversation/message_attachments.d.ts +1 -1
  9. package/build/components/conversation/message_attachments.d.ts.map +1 -1
  10. package/build/components/conversation/message_attachments.js +17 -5
  11. package/build/components/conversation/message_attachments.js.map +1 -1
  12. package/build/hooks/services/use_find_or_create_services_conversation.d.ts +2 -0
  13. package/build/hooks/services/use_find_or_create_services_conversation.d.ts.map +1 -1
  14. package/build/hooks/services/use_find_or_create_services_conversation.js +20 -19
  15. package/build/hooks/services/use_find_or_create_services_conversation.js.map +1 -1
  16. package/build/navigation/index.d.ts +9 -0
  17. package/build/navigation/index.d.ts.map +1 -1
  18. package/build/navigation/index.js +10 -0
  19. package/build/navigation/index.js.map +1 -1
  20. package/build/screens/send_giphy_screen.d.ts +1 -1
  21. package/build/screens/send_giphy_screen.d.ts.map +1 -1
  22. package/build/screens/send_giphy_screen.js +14 -2
  23. package/build/screens/send_giphy_screen.js.map +1 -1
  24. package/build/screens/team_conversation_screen.d.ts +8 -0
  25. package/build/screens/team_conversation_screen.d.ts.map +1 -0
  26. package/build/screens/team_conversation_screen.js +28 -0
  27. package/build/screens/team_conversation_screen.js.map +1 -0
  28. package/package.json +2 -2
  29. package/src/components/conversation/attachments/image_attachment.tsx +375 -65
  30. package/src/components/conversation/attachments/image_attachment_legacy.tsx +258 -0
  31. package/src/components/conversation/message_attachments.tsx +35 -6
  32. package/src/hooks/services/use_find_or_create_services_conversation.ts +27 -21
  33. package/src/navigation/index.tsx +10 -0
  34. package/src/screens/send_giphy_screen.tsx +23 -2
  35. package/src/screens/team_conversation_screen.tsx +46 -0
@@ -0,0 +1,258 @@
1
+ import React, { useCallback, useMemo, useState } from 'react'
2
+ import {
3
+ StyleSheet,
4
+ Modal,
5
+ useWindowDimensions,
6
+ SafeAreaView,
7
+ View,
8
+ Linking,
9
+ ImageStyle,
10
+ } from 'react-native'
11
+ import {
12
+ Gesture,
13
+ GestureDetector,
14
+ GestureHandlerRootView,
15
+ type PanGesture,
16
+ } from 'react-native-gesture-handler'
17
+ import {
18
+ runOnJS,
19
+ useAnimatedStyle,
20
+ useSharedValue,
21
+ withTiming,
22
+ type AnimatedStyle,
23
+ } from 'react-native-reanimated'
24
+ import { tokens } from '../../../vendor/tapestry/tokens'
25
+ import { IconButton, Image, Heading, Text } from '../../display'
26
+ import colorFunction from 'color'
27
+ import { formatDatePreview } from '../../../utils/date'
28
+ import { DenormalizedMessageAttachmentResource } from '../../../types/resources/denormalized_attachment_resource'
29
+ import { PlatformPressable } from '@react-navigation/elements'
30
+ import { useTheme } from '../../../hooks'
31
+
32
+ const PAN_THRESHOLD_PX = 300
33
+
34
+ export type MetaProps = {
35
+ authorName: string
36
+ createdAt: string
37
+ }
38
+
39
+ export function ImageAttachmentLegacy({
40
+ attachment,
41
+ metaProps,
42
+ onMessageAttachmentLongPress,
43
+ }: {
44
+ attachment: DenormalizedMessageAttachmentResource
45
+ metaProps: MetaProps
46
+ onMessageAttachmentLongPress: (attachment: DenormalizedMessageAttachmentResource) => void
47
+ }) {
48
+ const { attributes } = attachment
49
+ const { url, urlMedium, filename, metadata = {} } = attributes
50
+ const { colors } = useTheme()
51
+
52
+ const styles = useStyles({ imageWidth: metadata.width, imageHeight: metadata.height })
53
+ const [visible, setVisible] = useState(false)
54
+
55
+ // shared values run on the native UI thread and prevents clogging up the JS thread
56
+ const dismissY = useSharedValue(0)
57
+ const opacity = useSharedValue(1)
58
+
59
+ const resetAnimations = useCallback(() => {
60
+ dismissY.value = withTiming(0)
61
+ opacity.value = withTiming(1)
62
+ }, [dismissY, opacity])
63
+
64
+ const handleCloseModal = useCallback(() => {
65
+ setVisible(false)
66
+ resetAnimations()
67
+ }, [setVisible, resetAnimations])
68
+
69
+ const panGesture = Gesture.Pan()
70
+ .onUpdate(e => {
71
+ dismissY.value = e.translationY
72
+ opacity.value = 1 - Math.abs(e.translationY) / PAN_THRESHOLD_PX
73
+ })
74
+ .onEnd(() => {
75
+ runOnJS(handleCloseModal)() // Ensures we can call a JS function
76
+ })
77
+
78
+ const animatedImageStyle = useAnimatedStyle(() => ({
79
+ transform: [{ translateY: dismissY.value }],
80
+ opacity: opacity.value,
81
+ }))
82
+
83
+ return (
84
+ <>
85
+ <PlatformPressable
86
+ style={styles.container}
87
+ onPress={() => setVisible(true)}
88
+ onLongPress={() => onMessageAttachmentLongPress(attachment)}
89
+ android_ripple={{ color: colors.androidRippleNeutral, foreground: true }}
90
+ accessibilityHint="Long press for more options"
91
+ >
92
+ <Image
93
+ source={{ uri: urlMedium || url }}
94
+ style={styles.image}
95
+ wrapperStyle={styles.imageWrapper}
96
+ alt={filename}
97
+ />
98
+ </PlatformPressable>
99
+ <LightboxModal
100
+ visible={visible}
101
+ handleCloseModal={handleCloseModal}
102
+ uri={urlMedium || url}
103
+ metaProps={metaProps}
104
+ panGesture={panGesture}
105
+ animatedImageStyle={animatedImageStyle}
106
+ />
107
+ </>
108
+ )
109
+ }
110
+
111
+ interface LightboxModalProps {
112
+ visible: boolean
113
+ handleCloseModal: () => void
114
+ uri: string
115
+ metaProps: MetaProps
116
+ panGesture: PanGesture
117
+ animatedImageStyle: AnimatedStyle<ImageStyle>
118
+ }
119
+
120
+ const LightboxModal = ({
121
+ uri,
122
+ visible,
123
+ handleCloseModal,
124
+ metaProps,
125
+ panGesture,
126
+ animatedImageStyle,
127
+ }: LightboxModalProps) => {
128
+ const styles = useStyles()
129
+
130
+ const { authorName, createdAt } = metaProps
131
+
132
+ const handleOpenInBrowser = () => {
133
+ Linking.openURL(uri)
134
+ }
135
+
136
+ return (
137
+ <Modal visible={visible} transparent animationType="fade" onRequestClose={handleCloseModal}>
138
+ <SafeAreaView style={styles.modal}>
139
+ <GestureHandlerRootView>
140
+ <GestureDetector gesture={panGesture}>
141
+ <Image
142
+ source={{ uri }}
143
+ loadingBackgroundStyles={styles.lightboxImageLoading}
144
+ style={styles.lightboxImage}
145
+ animatedImageStyle={animatedImageStyle}
146
+ resizeMode="contain"
147
+ animated={true}
148
+ alt=""
149
+ />
150
+ </GestureDetector>
151
+ <View style={styles.actionToolbar} accessibilityRole="toolbar">
152
+ <View style={styles.actionToolbarTextMeta}>
153
+ <Heading variant="h3" style={styles.actionToolbarTitle} numberOfLines={1}>
154
+ {authorName}
155
+ </Heading>
156
+ <Text variant="tertiary" style={styles.actionToolbarSubtitle}>
157
+ {formatDatePreview(createdAt)}
158
+ </Text>
159
+ </View>
160
+ <IconButton
161
+ onPress={handleOpenInBrowser}
162
+ name="general.newWindow"
163
+ accessibilityRole="link"
164
+ accessibilityLabel="Open image in browser"
165
+ accessibilityHint="Image can be downloaded and shared through the browser."
166
+ style={styles.actionButton}
167
+ iconStyle={styles.actionButtonIcon}
168
+ size="lg"
169
+ />
170
+ <IconButton
171
+ onPress={handleCloseModal}
172
+ name="general.x"
173
+ accessibilityLabel="Close image"
174
+ style={styles.actionButton}
175
+ iconStyle={styles.actionButtonIcon}
176
+ />
177
+ </View>
178
+ </GestureHandlerRootView>
179
+ </SafeAreaView>
180
+ </Modal>
181
+ )
182
+ }
183
+
184
+ interface UseStylesProps {
185
+ imageWidth?: number
186
+ imageHeight?: number
187
+ }
188
+
189
+ const useStyles = ({ imageWidth = 100, imageHeight = 100 }: UseStylesProps = {}) => {
190
+ const { width: windowWidth } = useWindowDimensions()
191
+ const backgroundColor = tokens.colorNeutral7
192
+ const transparentBackgroundColor = useMemo(
193
+ () => colorFunction(backgroundColor).alpha(0.8).toString(),
194
+ [backgroundColor]
195
+ )
196
+
197
+ return StyleSheet.create({
198
+ container: {
199
+ maxWidth: '100%',
200
+ },
201
+ imageWrapper: {
202
+ width: '100%',
203
+ minWidth: 200,
204
+ aspectRatio: imageWidth / imageHeight,
205
+ },
206
+ image: {
207
+ borderRadius: 8,
208
+ },
209
+ modal: {
210
+ flex: 1,
211
+ backgroundColor,
212
+ justifyContent: 'center',
213
+ alignItems: 'center',
214
+ },
215
+ lightboxImage: {
216
+ height: '100%',
217
+ width: windowWidth,
218
+ backgroundColor,
219
+ },
220
+ lightboxImageLoading: {
221
+ backgroundColor,
222
+ },
223
+ actionToolbar: {
224
+ width: '100%',
225
+ position: 'absolute',
226
+ top: 0,
227
+ flexDirection: 'row',
228
+ alignItems: 'center',
229
+ gap: 20,
230
+ paddingHorizontal: 16,
231
+ paddingTop: 16,
232
+ paddingBottom: 8,
233
+ backgroundColor: transparentBackgroundColor,
234
+ },
235
+ actionToolbarTextMeta: {
236
+ flex: 1,
237
+ },
238
+ actionToolbarTitle: {
239
+ marginRight: 'auto',
240
+ flexShrink: 1,
241
+ color: tokens.colorNeutral88,
242
+ },
243
+ actionToolbarSubtitle: {
244
+ color: tokens.colorNeutral68,
245
+ },
246
+ actionButton: {
247
+ backgroundColor,
248
+ height: 40,
249
+ width: 40,
250
+ borderRadius: 50,
251
+ borderWidth: 1,
252
+ borderColor: tokens.colorNeutral24,
253
+ },
254
+ actionButtonIcon: {
255
+ color: tokens.colorNeutral88,
256
+ },
257
+ })
258
+ }
@@ -9,7 +9,11 @@ import { VideoAttachment } from './attachments/video_attachment'
9
9
  import { GiphyAttachment } from './attachments/giphy_attachment'
10
10
  import { GenericFileAttachment } from './attachments/generic_file_attachment'
11
11
  import { ExpandedLink } from './attachments/expanded_link'
12
- import { ImageAttachment, type MetaProps } from './attachments/image_attachment'
12
+ import { ImageAttachmentLegacy, type MetaProps } from './attachments/image_attachment_legacy'
13
+ import { ImageAttachment } from './attachments/image_attachment'
14
+
15
+ // Temporarily controls whether image attachments can be opened in a gallery lightbox. (Will remove after QA approves project.)
16
+ const ENABLE_MESSAGE_ATTACHMENT_IMAGE_GALLERY = false
13
17
 
14
18
  export function MessageAttachments(props: {
15
19
  attachments: DenormalizedAttachmentResource[]
@@ -20,14 +24,34 @@ export function MessageAttachments(props: {
20
24
  const styles = useStyles()
21
25
  const { attachments, metaProps, onMessageAttachmentLongPress, onMessageLongPress } = props
22
26
  if (!attachments || attachments.length === 0) return null
27
+
28
+ const imageAttachments = attachments.filter(
29
+ attachment =>
30
+ attachment.type === 'MessageAttachment' &&
31
+ attachment.attributes?.contentType?.startsWith('image/')
32
+ ) as DenormalizedMessageAttachmentResource[]
33
+
34
+ const showImageAttachmentGroup =
35
+ ENABLE_MESSAGE_ATTACHMENT_IMAGE_GALLERY && imageAttachments.length > 0
36
+
23
37
  return (
24
38
  <View style={styles.attachmentsContainer}>
25
- {attachments.map(attachment => {
39
+ {showImageAttachmentGroup &&
40
+ imageAttachments.map((image, index) => (
41
+ <ImageAttachment
42
+ key={`${image.id}-${index}`}
43
+ attachment={image}
44
+ metaProps={metaProps}
45
+ onMessageAttachmentLongPress={onMessageAttachmentLongPress}
46
+ />
47
+ ))}
48
+
49
+ {attachments.map((attachment, index) => {
26
50
  switch (attachment.type) {
27
51
  case 'MessageAttachment':
28
52
  return (
29
53
  <MessageAttachment
30
- key={attachment.id}
54
+ key={`${attachment.id}-${index}`}
31
55
  attachment={attachment}
32
56
  metaProps={metaProps}
33
57
  onMessageAttachmentLongPress={onMessageAttachmentLongPress}
@@ -36,7 +60,7 @@ export function MessageAttachments(props: {
36
60
  case 'giphy':
37
61
  return (
38
62
  <GiphyAttachment
39
- key={attachment.id || attachment.titleLink}
63
+ key={`${attachment.id || attachment.titleLink}-${index}`}
40
64
  attachment={attachment}
41
65
  onMessageLongPress={onMessageLongPress}
42
66
  />
@@ -44,7 +68,7 @@ export function MessageAttachments(props: {
44
68
  case 'ExpandedLink':
45
69
  return (
46
70
  <ExpandedLink
47
- key={attachment.id}
71
+ key={`${attachment.id}-${index}`}
48
72
  attachment={attachment}
49
73
  onMessageLongPress={onMessageLongPress}
50
74
  />
@@ -69,10 +93,15 @@ function MessageAttachment({
69
93
  const { attributes } = attachment
70
94
  const contentType = attributes?.contentType
71
95
  const basicType = contentType ? contentType.split('/')[0] : ''
96
+
97
+ if (basicType === 'image' && ENABLE_MESSAGE_ATTACHMENT_IMAGE_GALLERY) {
98
+ return null
99
+ }
100
+
72
101
  switch (basicType) {
73
102
  case 'image':
74
103
  return (
75
- <ImageAttachment
104
+ <ImageAttachmentLegacy
76
105
  attachment={attachment}
77
106
  metaProps={metaProps}
78
107
  onMessageAttachmentLongPress={onMessageAttachmentLongPress}
@@ -15,34 +15,40 @@ interface Props {
15
15
  }
16
16
 
17
17
  export function useFindOrCreateServicesConversation({ teamIds, planId, onSuccess }: Props) {
18
- const teamAndPlanParams: TeamAndPlanParams = {
19
- team_id: teamIds.join(','),
20
- ...(planId ? { plan_id: planId } : {}),
21
- }
22
-
23
18
  const apiClient = useApiClient()
24
19
  return useMutation({
25
20
  throwOnError: true,
26
21
  onSuccess: result => {
27
22
  onSuccess && onSuccess(result)
28
23
  },
29
- mutationFn: async () => {
30
- const foundConversations = await getGroupIdsFromServices(apiClient, teamAndPlanParams)
31
- .then(res => res.data.groupIdentifiers)
32
- .then(groupIdentifiers => findConversationWithExactTeams(apiClient, groupIdentifiers))
33
- .catch(() => null)
34
- const foundConversation = foundConversations?.data[0]
24
+ mutationFn: async () => findOrCreateServicesConversation(apiClient, teamIds, planId),
25
+ })
26
+ }
35
27
 
36
- if (foundConversation?.id) {
37
- return foundConversation
38
- }
28
+ export const findOrCreateServicesConversation = async (
29
+ apiClient: ApiClient,
30
+ teamIds: number[],
31
+ planId?: number
32
+ ) => {
33
+ const teamAndPlanParams: TeamAndPlanParams = {
34
+ team_id: teamIds.join(','),
35
+ ...(planId ? { plan_id: planId } : {}),
36
+ }
39
37
 
40
- return fetchServicesPayload(apiClient, teamAndPlanParams)
41
- .then(res => res.data.payload)
42
- .then(payload => createConversation(apiClient, payload))
43
- .then(res => res.data)
44
- },
45
- })
38
+ const foundConversations = await getGroupIdsFromServices(apiClient, teamAndPlanParams)
39
+ .then(res => res.data.groupIdentifiers)
40
+ .then(groupIdentifiers => findConversationWithExactTeams(apiClient, groupIdentifiers))
41
+ .catch(() => null)
42
+ const foundConversation = foundConversations?.data[0]
43
+
44
+ if (foundConversation?.id) {
45
+ return foundConversation
46
+ }
47
+
48
+ return fetchServicesPayload(apiClient, teamAndPlanParams)
49
+ .then(res => res.data.payload)
50
+ .then(payload => createConversation(apiClient, payload))
51
+ .then(res => res.data)
46
52
  }
47
53
 
48
54
  interface TeamAndPlanParams {
@@ -79,7 +85,7 @@ function findConversationWithExactTeams(apiClient: ApiClient, groupIdentifiers:
79
85
  url: '/me/conversations',
80
86
  data: {
81
87
  fields: {
82
- Conversation: ['stream_channel'],
88
+ Conversation: ['stream_channel', 'title'],
83
89
  },
84
90
  filter: 'with_exact_groups',
85
91
  gids: groupIdentifiers.join(','),
@@ -43,6 +43,8 @@ import {
43
43
  } from '../screens/conversation/message_read_receipts_screen'
44
44
  import { Platform } from 'react-native'
45
45
  import { HeaderSubmitButton } from '../components/display/platform_modal_header_buttons'
46
+ import { TeamConversationScreen } from '../screens/team_conversation_screen'
47
+ import { CardStyleInterpolators } from '@react-navigation/stack'
46
48
 
47
49
  const HEADER_BACK_BUTTON_LAYOUT_RESET_STYLES = {
48
50
  marginLeft: Platform.select({ ios: -8, default: -3 }),
@@ -179,6 +181,14 @@ export const ChatStack = createNativeStackNavigator({
179
181
  ),
180
182
  }),
181
183
  },
184
+ TeamConversation: {
185
+ screen: TeamConversationScreen,
186
+ options: {
187
+ title: 'Finding conversation...',
188
+ animation: 'none',
189
+ cardStyleInterpolator: CardStyleInterpolators.forNoAnimation,
190
+ },
191
+ },
182
192
  ConversationDetails: {
183
193
  screen: ConversationDetailsScreen,
184
194
  options: ({ navigation }) => ({
@@ -1,7 +1,7 @@
1
1
  import { StackActions, StaticScreenProps, useNavigation } from '@react-navigation/native'
2
2
  import React from 'react'
3
3
  import { Image as NativeImage, Platform, StyleSheet, useWindowDimensions, View } from 'react-native'
4
- import { Button, IconButton, IconString, TextButton } from '../components'
4
+ import { BlankState, Button, IconButton, IconString, TextButton } from '../components'
5
5
  import { DefaultLoading } from '../components/page/loading'
6
6
  import FormSheet, { getFormSheetScreenOptions } from '../components/primitive/form_sheet'
7
7
  import { useCreateAndroidRippleColor, useTheme } from '../hooks'
@@ -33,7 +33,21 @@ export function SendGiphyScreen({ route }: SendGiphyScreenProps) {
33
33
  const { width } = useWindowDimensions()
34
34
  const size = width - 24
35
35
 
36
- if (!result) return null
36
+ if (!result)
37
+ return (
38
+ <FormSheet.Root contentStyle={styles.blankStateContainer}>
39
+ <BlankState
40
+ style={styles.blankState}
41
+ title="No Giphys found"
42
+ subtitle="Try a different search term."
43
+ iconName="general.bolt"
44
+ buttonProps={{
45
+ onPress: () => navigation.goBack(),
46
+ title: 'Close',
47
+ }}
48
+ />
49
+ </FormSheet.Root>
50
+ )
37
51
 
38
52
  const gif = result.giphy.fixed_width
39
53
  const { url } = gif
@@ -158,6 +172,13 @@ const useStyles = () => {
158
172
  paddingHorizontal: 16,
159
173
  gap: 8,
160
174
  },
175
+ blankStateContainer: {
176
+ paddingVertical: 64,
177
+ },
178
+ blankState: {
179
+ flex: 0,
180
+ flexGrow: 1,
181
+ },
161
182
  poweredByGiphyContainer: {
162
183
  alignSelf: 'flex-start',
163
184
  padding: 4,
@@ -0,0 +1,46 @@
1
+ import { StackActions, StaticScreenProps, useNavigation } from '@react-navigation/native'
2
+ import { useQuery, useQueryClient } from '@tanstack/react-query'
3
+ import { useEffect } from 'react'
4
+ import { useApiClient } from '../hooks'
5
+ import { findOrCreateServicesConversation } from '../hooks/services/use_find_or_create_services_conversation'
6
+ import { DefaultLoading } from '../components/page/loading'
7
+
8
+ export type TeamConversationRouteProps = {
9
+ plan_id?: number
10
+ team_ids?: number[]
11
+ }
12
+
13
+ export type TeamConversationScreenProps = StaticScreenProps<TeamConversationRouteProps>
14
+
15
+ export const TeamConversationScreen = ({ route }: TeamConversationScreenProps) => {
16
+ const apiClient = useApiClient()
17
+ const queryClient = useQueryClient()
18
+ const navigation = useNavigation()
19
+
20
+ const { data: conversation } = useQuery({
21
+ queryKey: ['team-conversation', route.params.team_ids, route.params.plan_id],
22
+ queryFn: () =>
23
+ findOrCreateServicesConversation(
24
+ apiClient,
25
+ route.params.team_ids || [2317674, 5223552],
26
+ route.params.plan_id
27
+ ),
28
+ })
29
+
30
+ useEffect(() => {
31
+ if (!conversation?.id) return
32
+
33
+ navigation.dispatch(
34
+ StackActions.replace('Conversation', {
35
+ conversation_id: conversation.id,
36
+ title: conversation.title,
37
+ })
38
+ )
39
+
40
+ return () => {
41
+ queryClient.removeQueries({ queryKey: ['team-conversation'] })
42
+ }
43
+ }, [conversation?.id, conversation?.title, navigation, queryClient])
44
+
45
+ return <DefaultLoading />
46
+ }