@planningcenter/chat-react-native 3.16.0-rc.2 → 3.16.0-rc.4

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 (104) hide show
  1. package/build/components/conversation/attachments/giphy_attachment.d.ts.map +1 -1
  2. package/build/components/conversation/attachments/giphy_attachment.js +1 -11
  3. package/build/components/conversation/attachments/giphy_attachment.js.map +1 -1
  4. package/build/components/conversation/message.d.ts +2 -1
  5. package/build/components/conversation/message.d.ts.map +1 -1
  6. package/build/components/conversation/message.js +23 -23
  7. package/build/components/conversation/message.js.map +1 -1
  8. package/build/components/conversation/message_form.d.ts +2 -1
  9. package/build/components/conversation/message_form.d.ts.map +1 -1
  10. package/build/components/conversation/message_form.js +38 -16
  11. package/build/components/conversation/message_form.js.map +1 -1
  12. package/build/components/conversation/reply_connectors.js +1 -1
  13. package/build/components/conversation/reply_connectors.js.map +1 -1
  14. package/build/components/conversation/shadow_message.d.ts +8 -0
  15. package/build/components/conversation/shadow_message.d.ts.map +1 -0
  16. package/build/components/conversation/shadow_message.js +161 -0
  17. package/build/components/conversation/shadow_message.js.map +1 -0
  18. package/build/components/display/icon.d.ts +3 -1
  19. package/build/components/display/icon.d.ts.map +1 -1
  20. package/build/components/display/icon.js +2 -0
  21. package/build/components/display/icon.js.map +1 -1
  22. package/build/components/primitive/avatar_primitive.d.ts +1 -0
  23. package/build/components/primitive/avatar_primitive.d.ts.map +1 -1
  24. package/build/components/primitive/avatar_primitive.js +4 -0
  25. package/build/components/primitive/avatar_primitive.js.map +1 -1
  26. package/build/hooks/index.d.ts +1 -0
  27. package/build/hooks/index.d.ts.map +1 -1
  28. package/build/hooks/index.js +1 -0
  29. package/build/hooks/index.js.map +1 -1
  30. package/build/hooks/use_animated_message_background_color.d.ts +8 -0
  31. package/build/hooks/use_animated_message_background_color.d.ts.map +1 -0
  32. package/build/hooks/use_animated_message_background_color.js +29 -0
  33. package/build/hooks/use_animated_message_background_color.js.map +1 -0
  34. package/build/hooks/use_conversation_messages.d.ts +6 -3
  35. package/build/hooks/use_conversation_messages.d.ts.map +1 -1
  36. package/build/hooks/use_conversation_messages.js +32 -25
  37. package/build/hooks/use_conversation_messages.js.map +1 -1
  38. package/build/hooks/use_message_create_or_update.d.ts +2 -1
  39. package/build/hooks/use_message_create_or_update.d.ts.map +1 -1
  40. package/build/hooks/use_message_create_or_update.js +6 -2
  41. package/build/hooks/use_message_create_or_update.js.map +1 -1
  42. package/build/hooks/use_suspense_api.d.ts +3 -1
  43. package/build/hooks/use_suspense_api.d.ts.map +1 -1
  44. package/build/hooks/use_suspense_api.js +1 -0
  45. package/build/hooks/use_suspense_api.js.map +1 -1
  46. package/build/navigation/index.d.ts +6 -0
  47. package/build/navigation/index.d.ts.map +1 -1
  48. package/build/navigation/index.js +6 -0
  49. package/build/navigation/index.js.map +1 -1
  50. package/build/screens/conversation_screen.d.ts +2 -1
  51. package/build/screens/conversation_screen.d.ts.map +1 -1
  52. package/build/screens/conversation_screen.js +7 -5
  53. package/build/screens/conversation_screen.js.map +1 -1
  54. package/build/screens/message_actions_screen.d.ts +1 -0
  55. package/build/screens/message_actions_screen.d.ts.map +1 -1
  56. package/build/screens/message_actions_screen.js +16 -3
  57. package/build/screens/message_actions_screen.js.map +1 -1
  58. package/build/types/jolt_events/message_events.d.ts +2 -0
  59. package/build/types/jolt_events/message_events.d.ts.map +1 -1
  60. package/build/types/jolt_events/message_events.js.map +1 -1
  61. package/build/types/resources/message.d.ts +2 -0
  62. package/build/types/resources/message.d.ts.map +1 -1
  63. package/build/types/resources/message.js.map +1 -1
  64. package/build/utils/assert_keys_are_numbers.d.ts +2 -0
  65. package/build/utils/assert_keys_are_numbers.d.ts.map +1 -0
  66. package/build/utils/assert_keys_are_numbers.js +12 -0
  67. package/build/utils/assert_keys_are_numbers.js.map +1 -0
  68. package/build/utils/cache/optimistically_create_message.d.ts.map +1 -1
  69. package/build/utils/cache/optimistically_create_message.js +2 -0
  70. package/build/utils/cache/optimistically_create_message.js.map +1 -1
  71. package/build/utils/index.d.ts +2 -0
  72. package/build/utils/index.d.ts.map +1 -1
  73. package/build/utils/index.js +2 -0
  74. package/build/utils/index.js.map +1 -1
  75. package/build/utils/jolt/transform_message_event_data_to_message_resource.d.ts.map +1 -1
  76. package/build/utils/jolt/transform_message_event_data_to_message_resource.js +2 -0
  77. package/build/utils/jolt/transform_message_event_data_to_message_resource.js.map +1 -1
  78. package/build/utils/replies_local_feature_flag.d.ts +2 -0
  79. package/build/utils/replies_local_feature_flag.d.ts.map +1 -0
  80. package/build/utils/replies_local_feature_flag.js +3 -0
  81. package/build/utils/replies_local_feature_flag.js.map +1 -0
  82. package/package.json +2 -2
  83. package/src/components/conversation/attachments/giphy_attachment.tsx +1 -14
  84. package/src/components/conversation/message.tsx +40 -36
  85. package/src/components/conversation/message_form.tsx +85 -15
  86. package/src/components/conversation/reply_connectors.tsx +1 -1
  87. package/src/components/conversation/shadow_message.tsx +274 -0
  88. package/src/components/display/icon.tsx +3 -0
  89. package/src/components/primitive/avatar_primitive.tsx +4 -0
  90. package/src/hooks/index.ts +1 -0
  91. package/src/hooks/use_animated_message_background_color.ts +44 -0
  92. package/src/hooks/use_conversation_messages.ts +45 -25
  93. package/src/hooks/use_message_create_or_update.ts +7 -2
  94. package/src/hooks/use_suspense_api.ts +3 -0
  95. package/src/navigation/index.tsx +6 -0
  96. package/src/screens/conversation_screen.tsx +9 -4
  97. package/src/screens/message_actions_screen.tsx +27 -1
  98. package/src/types/jolt_events/message_events.ts +2 -0
  99. package/src/types/resources/message.ts +2 -0
  100. package/src/utils/assert_keys_are_numbers.ts +13 -0
  101. package/src/utils/cache/optimistically_create_message.ts +2 -0
  102. package/src/utils/index.ts +2 -0
  103. package/src/utils/jolt/transform_message_event_data_to_message_resource.ts +2 -0
  104. package/src/utils/replies_local_feature_flag.ts +2 -0
@@ -0,0 +1,274 @@
1
+ import React from 'react'
2
+ import { Pressable, StyleSheet, useWindowDimensions, View } from 'react-native'
3
+ import { Avatar, Icon, IconProps, Image, Text } from '../display'
4
+ import {
5
+ useAnimatedMessageBackgroundColor,
6
+ useFontScale,
7
+ useScalableNumberOfLines,
8
+ useTheme,
9
+ } from '../../hooks'
10
+ import { MessageResource } from '../../types'
11
+ import {
12
+ DenormalizedAttachmentResource,
13
+ DenormalizedGiphyAttachmentResource,
14
+ DenormalizedExpandedLinkAttachmentResource,
15
+ DenormalizedMessageAttachmentResource,
16
+ } from '../../types/resources/denormalized_attachment_resource'
17
+ import {
18
+ CONVERSATION_MESSAGE_LIST_PADDING_HORIZONTAL,
19
+ MAX_FONT_SIZE_MULTIPLIER,
20
+ MESSAGE_AUTHOR_AVATAR_COLUMN_WIDTH,
21
+ platformFontWeightMedium,
22
+ } from '../../utils/styles'
23
+ import Animated from 'react-native-reanimated'
24
+ import { TheirReplyConnector, MyReplyConnector } from './reply_connectors'
25
+ import { assertKeysAreNumbers } from '../../utils'
26
+ import { useNavigation } from '@react-navigation/native'
27
+
28
+ interface ShadowMessageProps extends MessageResource {
29
+ conversation_id: number
30
+ }
31
+
32
+ export function ShadowMessage({ conversation_id, ...message }: ShadowMessageProps) {
33
+ const { text } = message
34
+ const styles = useStyles(message)
35
+ const { colors } = useTheme()
36
+ const navigation = useNavigation()
37
+
38
+ const [messageBubbleHeight, setMessageBubbleHeight] = React.useState(0)
39
+ const { animatedBackgroundColor, handleMessagePressIn, handleMessagePressOut } =
40
+ useAnimatedMessageBackgroundColor()
41
+ const scalableNumberOfLines = useScalableNumberOfLines(2)
42
+
43
+ const handleNavigateToReplies = () => {
44
+ navigation.navigate('ConversationReply', {
45
+ conversation_id,
46
+ reply_root_id: message.id,
47
+ // TODO: Add a way to pass the reply root author's name
48
+ })
49
+ }
50
+
51
+ return (
52
+ <Pressable
53
+ android_ripple={{ color: colors.androidRippleNeutral }}
54
+ onPress={handleNavigateToReplies}
55
+ onPressIn={handleMessagePressIn}
56
+ onPressOut={handleMessagePressOut}
57
+ accessibilityHint="Navigate to reply screen for this message"
58
+ accessibilityRole="link"
59
+ >
60
+ <Animated.View style={[styles.message, animatedBackgroundColor]}>
61
+ {!message.mine && (
62
+ <View>
63
+ <View style={styles.avatarWrapper}>
64
+ <Avatar
65
+ size="xs"
66
+ sourceUri={message.author.avatar}
67
+ style={styles.avatar}
68
+ maxFontSizeMultiplier={1}
69
+ />
70
+ </View>
71
+ <TheirReplyConnector message={message} messageBubbleHeight={messageBubbleHeight} />
72
+ </View>
73
+ )}
74
+ <View style={styles.messageContent}>
75
+ <View
76
+ style={styles.messageBubble}
77
+ onLayout={e => setMessageBubbleHeight(e.nativeEvent.layout.height)}
78
+ >
79
+ <MessageAttachmentImagery attachments={message.attachments} />
80
+ {text && (
81
+ <Text
82
+ variant="footnote"
83
+ style={styles.messageText}
84
+ numberOfLines={scalableNumberOfLines}
85
+ >
86
+ {text}
87
+ </Text>
88
+ )}
89
+ </View>
90
+ <View style={styles.messageMeta}>
91
+ <Text variant="footnote" style={styles.replyCountText}>
92
+ {message.replyCount} replies
93
+ </Text>
94
+ </View>
95
+ </View>
96
+ {message.mine && (
97
+ <MyReplyConnector message={message} messageBubbleHeight={messageBubbleHeight} />
98
+ )}
99
+ </Animated.View>
100
+ </Pressable>
101
+ )
102
+ }
103
+
104
+ function MessageAttachmentImagery({
105
+ attachments,
106
+ }: {
107
+ attachments: DenormalizedAttachmentResource[]
108
+ }) {
109
+ if (!attachments || attachments.length === 0) return null
110
+
111
+ const attachment = attachments[0]
112
+
113
+ if (attachment.type === 'giphy') {
114
+ return <GiphyImage attachment={attachment} />
115
+ }
116
+ if (attachment.type === 'ExpandedLink') {
117
+ return <ExpandedLinkImage attachment={attachment} />
118
+ }
119
+ if (attachment.type === 'MessageAttachment') {
120
+ const contentType = attachment.attributes?.contentType
121
+ const basicType = contentType?.split('/')[0]
122
+
123
+ switch (basicType) {
124
+ case 'image':
125
+ return <MessageAttachmentImage attachment={attachment} />
126
+ case 'video':
127
+ return <MessageAttachmentIcon iconName="general.outlinedVideoFile" />
128
+ case 'audio':
129
+ return <MessageAttachmentIcon iconName="general.outlinedMusicFile" />
130
+ case 'application':
131
+ return <MessageAttachmentIcon iconName="general.outlinedGenericFile" />
132
+ default:
133
+ return null
134
+ }
135
+ }
136
+
137
+ return null
138
+ }
139
+
140
+ function GiphyImage({ attachment }: { attachment: DenormalizedGiphyAttachmentResource }) {
141
+ const { title, giphy } = attachment
142
+ const { url } = giphy.fixedWidth
143
+ const { width, height } = assertKeysAreNumbers(giphy.fixedWidth)
144
+ const styles = useStyles({ imageWidth: width, imageHeight: height })
145
+
146
+ return (
147
+ <Image
148
+ source={{ uri: url }}
149
+ wrapperStyle={styles.imageWrapper}
150
+ style={styles.image}
151
+ alt={title}
152
+ loaderSize={16}
153
+ />
154
+ )
155
+ }
156
+
157
+ function ExpandedLinkImage({
158
+ attachment,
159
+ }: {
160
+ attachment: DenormalizedExpandedLinkAttachmentResource
161
+ }) {
162
+ const { title = '', imageUrl, imageHeight, imageWidth } = attachment.attributes
163
+ const styles = useStyles({ imageWidth, imageHeight })
164
+
165
+ return (
166
+ <Image
167
+ source={{ uri: imageUrl }}
168
+ wrapperStyle={styles.imageWrapper}
169
+ style={styles.image}
170
+ alt={title}
171
+ loaderSize={16}
172
+ />
173
+ )
174
+ }
175
+
176
+ function MessageAttachmentImage({
177
+ attachment,
178
+ }: {
179
+ attachment: DenormalizedMessageAttachmentResource
180
+ }) {
181
+ const { url, urlMedium, filename, metadata = {} } = attachment.attributes
182
+ const styles = useStyles({ imageWidth: metadata.width, imageHeight: metadata.height })
183
+
184
+ return (
185
+ <Image
186
+ source={{ uri: urlMedium || url }}
187
+ style={styles.image}
188
+ wrapperStyle={styles.imageWrapper}
189
+ alt={filename}
190
+ loaderSize={16}
191
+ />
192
+ )
193
+ }
194
+
195
+ function MessageAttachmentIcon({ iconName }: { iconName: IconProps['name'] }) {
196
+ const styles = useStyles()
197
+ return (
198
+ <Icon
199
+ name={iconName}
200
+ style={styles.attachmentIcon}
201
+ maxFontSizeMultiplier={MAX_FONT_SIZE_MULTIPLIER}
202
+ />
203
+ )
204
+ }
205
+
206
+ interface StylesProps {
207
+ imageWidth?: number
208
+ imageHeight?: number
209
+ mine?: boolean
210
+ }
211
+
212
+ const useStyles = ({ mine, imageWidth = 32, imageHeight = 32 }: StylesProps = {}) => {
213
+ const { colors } = useTheme()
214
+ const fontScale = useFontScale({ maxFontSizeMultiplier: MAX_FONT_SIZE_MULTIPLIER })
215
+ const { width } = useWindowDimensions()
216
+ const tabletWidth = width >= 744 // Smallest iPad Mini's width
217
+
218
+ return StyleSheet.create({
219
+ message: {
220
+ gap: 8,
221
+ flexDirection: mine ? 'row-reverse' : 'row',
222
+ paddingHorizontal: CONVERSATION_MESSAGE_LIST_PADDING_HORIZONTAL,
223
+ },
224
+ messageContent: {
225
+ flex: 1,
226
+ gap: 4,
227
+ marginBottom: 12,
228
+ },
229
+ avatarWrapper: {
230
+ width: MESSAGE_AUTHOR_AVATAR_COLUMN_WIDTH,
231
+ alignItems: 'center',
232
+ },
233
+ avatar: {
234
+ marginBottom: 8,
235
+ opacity: 0.5,
236
+ },
237
+ messageBubble: {
238
+ flexDirection: 'row',
239
+ alignSelf: mine ? 'flex-end' : 'flex-start',
240
+ alignItems: 'center',
241
+ gap: 8,
242
+ borderColor: colors.borderColorDefaultBase,
243
+ borderWidth: 1,
244
+ borderRadius: 8,
245
+ maxWidth: tabletWidth ? 360 : '80%',
246
+ paddingVertical: 6,
247
+ paddingHorizontal: 8,
248
+ },
249
+ messageText: {
250
+ color: colors.textColorDefaultPlaceholder,
251
+ flexShrink: 1,
252
+ },
253
+ messageMeta: {
254
+ flexDirection: 'row',
255
+ justifyContent: mine ? 'flex-end' : 'flex-start',
256
+ },
257
+ replyCountText: {
258
+ color: colors.interaction,
259
+ fontWeight: platformFontWeightMedium,
260
+ },
261
+ imageWrapper: {
262
+ width: 32 * fontScale,
263
+ aspectRatio: imageWidth / imageHeight,
264
+ opacity: 0.5,
265
+ },
266
+ image: {
267
+ borderRadius: 4,
268
+ },
269
+ attachmentIcon: {
270
+ color: colors.iconColorDefaultDim,
271
+ fontSize: 16,
272
+ },
273
+ })
274
+ }
@@ -17,6 +17,7 @@ import * as logomark from '@planningcenter/icons/paths/logomark'
17
17
  import * as people from '@planningcenter/icons/paths/people'
18
18
  import * as services from '@planningcenter/icons/paths/services'
19
19
  import * as publishing from '@planningcenter/icons/paths/publishing'
20
+ import * as registrations from '@planningcenter/icons/paths/registrations'
20
21
 
21
22
  // =================================
22
23
  // ====== Constants ================
@@ -37,6 +38,7 @@ const ICONS = {
37
38
  people,
38
39
  services,
39
40
  publishing,
41
+ registrations,
40
42
  } as const
41
43
 
42
44
  export type IconStyle = ViewStyle & {
@@ -61,6 +63,7 @@ export type IconString =
61
63
  | `people.${IconName<'people'>}`
62
64
  | `services.${IconName<'services'>}`
63
65
  | `publishing.${IconName<'publishing'>}`
66
+ | `registrations.${IconName<'registrations'>}`
64
67
 
65
68
  // =================================
66
69
  // ====== Component ================
@@ -45,6 +45,7 @@ export type {
45
45
  // =================================
46
46
 
47
47
  const AVATAR_SIZES = {
48
+ xs: 'xs',
48
49
  sm: 'sm',
49
50
  md: 'md',
50
51
  lg: 'lg',
@@ -60,18 +61,21 @@ type AvatarSize = (typeof AVATAR_SIZES)[keyof typeof AVATAR_SIZES]
60
61
  type AvatarPresenceType = (typeof AVATAR_PRESENCE_TYPES)[keyof typeof AVATAR_PRESENCE_TYPES]
61
62
 
62
63
  const AVATAR_PX: Record<AvatarSize, number> = {
64
+ [AVATAR_SIZES.xs]: 20,
63
65
  [AVATAR_SIZES.sm]: 24,
64
66
  [AVATAR_SIZES.md]: 32,
65
67
  [AVATAR_SIZES.lg]: 40,
66
68
  }
67
69
 
68
70
  const AVATAR_PRESENCE_PX: Record<AvatarSize, number> = {
71
+ [AVATAR_SIZES.xs]: 8,
69
72
  [AVATAR_SIZES.sm]: 10,
70
73
  [AVATAR_SIZES.md]: 12,
71
74
  [AVATAR_SIZES.lg]: 14,
72
75
  }
73
76
 
74
77
  const AVATAR_FALLBACK_ICON_PX: Record<AvatarSize, number> = {
78
+ [AVATAR_SIZES.xs]: 10,
75
79
  [AVATAR_SIZES.sm]: 12,
76
80
  [AVATAR_SIZES.md]: 16,
77
81
  [AVATAR_SIZES.lg]: 20,
@@ -1,4 +1,5 @@
1
1
  export * from './use_async_storage'
2
+ export * from './use_animated_message_background_color'
2
3
  export * from './use_theme'
3
4
  export * from './use_suspense_api'
4
5
  export * from './use_current_person'
@@ -0,0 +1,44 @@
1
+ import { Platform } from 'react-native'
2
+ import {
3
+ useSharedValue,
4
+ useAnimatedStyle,
5
+ withTiming,
6
+ interpolateColor,
7
+ Easing,
8
+ } from 'react-native-reanimated'
9
+ import { useTheme } from '../hooks'
10
+
11
+ export function useAnimatedMessageBackgroundColor() {
12
+ const { colors } = useTheme()
13
+ const bgFadeProgress = useSharedValue(0)
14
+
15
+ const pressedColor = Platform.select({
16
+ ios: colors.fillColorNeutral050Base,
17
+ default: 'transparent',
18
+ })
19
+
20
+ const animatedBackgroundColor = useAnimatedStyle(() => {
21
+ const backgroundColor = interpolateColor(
22
+ bgFadeProgress.value,
23
+ [0, 1],
24
+ ['transparent', pressedColor]
25
+ )
26
+ return {
27
+ backgroundColor,
28
+ }
29
+ })
30
+
31
+ const handleMessagePressIn = () => {
32
+ bgFadeProgress.value = withTiming(1, { duration: 300, easing: Easing.inOut(Easing.ease) })
33
+ }
34
+
35
+ const handleMessagePressOut = () => {
36
+ bgFadeProgress.value = withTiming(0, { duration: 300, easing: Easing.inOut(Easing.ease) })
37
+ }
38
+
39
+ return {
40
+ animatedBackgroundColor,
41
+ handleMessagePressIn,
42
+ handleMessagePressOut,
43
+ }
44
+ }
@@ -7,14 +7,14 @@ import {
7
7
  } from './use_suspense_api'
8
8
 
9
9
  export const useConversationMessages = (
10
- { conversation_id }: { conversation_id: number },
10
+ { conversation_id, reply_root_id }: { conversation_id: number; reply_root_id?: string },
11
11
  opts?: SuspensePaginatorOptions
12
12
  ) => {
13
13
  const { data, refetch, isRefetching, fetchNextPage } = useSuspensePaginator<MessageResource>(
14
- getMessagesRequestArgs({ conversation_id }),
14
+ getMessagesRequestArgs({ conversation_id, reply_root_id }),
15
15
  opts
16
16
  )
17
- const queryKey = getMessagesQueryKey({ conversation_id })
17
+ const queryKey = getMessagesQueryKey({ conversation_id, reply_root_id })
18
18
  const messages = useMemo(
19
19
  () =>
20
20
  data
@@ -28,29 +28,49 @@ export const useConversationMessages = (
28
28
  return { messages, refetch, isRefetching, fetchNextPage, queryKey }
29
29
  }
30
30
 
31
- export const getMessagesRequestArgs = ({ conversation_id }: { conversation_id: number }) => ({
32
- url: `/me/conversations/${conversation_id}/messages`,
33
- data: {
34
- perPage: 25,
35
- fields: {
36
- Message: [
37
- 'text',
38
- 'text_edited_at',
39
- 'mine',
40
- 'attachments',
41
- 'created_at',
42
- 'deleted_at',
43
- 'author',
44
- 'reaction_counts',
45
- ],
46
- Person: ['name', 'avatar'],
47
- ReactionCount: ['value', 'count', 'mine', 'message_id', 'author_ids'],
31
+ export const getMessagesRequestArgs = ({
32
+ conversation_id,
33
+ reply_root_id,
34
+ }: {
35
+ conversation_id: number
36
+ reply_root_id?: string
37
+ }) => {
38
+ const url = reply_root_id
39
+ ? `/me/conversations/${conversation_id}/messages/${reply_root_id}/replies`
40
+ : `/me/conversations/${conversation_id}/messages`
41
+
42
+ return {
43
+ url,
44
+ data: {
45
+ perPage: 25,
46
+ fields: {
47
+ Message: [
48
+ 'text',
49
+ 'text_edited_at',
50
+ 'mine',
51
+ 'attachments',
52
+ 'created_at',
53
+ 'deleted_at',
54
+ 'author',
55
+ 'reaction_counts',
56
+ 'reply_count',
57
+ 'reply_root',
58
+ ],
59
+ Person: ['name', 'avatar'],
60
+ ReactionCount: ['value', 'count', 'mine', 'message_id', 'author_ids'],
61
+ },
62
+ include: ['author', 'reaction_counts'],
48
63
  },
49
- include: ['author', 'reaction_counts'],
50
- },
51
- })
64
+ }
65
+ }
52
66
 
53
- export const getMessagesQueryKey = ({ conversation_id }: { conversation_id: number }) => {
54
- const requestArgs = getMessagesRequestArgs({ conversation_id })
67
+ export const getMessagesQueryKey = ({
68
+ conversation_id,
69
+ reply_root_id,
70
+ }: {
71
+ conversation_id: number
72
+ reply_root_id?: string
73
+ }) => {
74
+ const requestArgs = getMessagesRequestArgs({ conversation_id, reply_root_id })
55
75
  return getRequestQueryKey(requestArgs)
56
76
  }
@@ -18,9 +18,10 @@ import { startMessageCreationTracking } from '../utils/performance_tracking'
18
18
  interface Props {
19
19
  conversationId: number
20
20
  message?: MessageResource
21
+ replyRootId?: string
21
22
  }
22
23
 
23
- export function useMessageCreateOrUpdate({ conversationId, message }: Props) {
24
+ export function useMessageCreateOrUpdate({ conversationId, message, replyRootId }: Props) {
24
25
  const messageId = message?.id || null
25
26
  const isEditing = !isNewMessage(message)
26
27
  const apiClient = useApiClient()
@@ -38,7 +39,11 @@ export function useMessageCreateOrUpdate({ conversationId, message }: Props) {
38
39
  const fieldsWithValueJoined = Object.fromEntries(
39
40
  Object.entries(requestParams.data.fields).map(([k, v]) => [k, v.join(',')])
40
41
  )
41
- let attributes: any = { text, ...(attachments ? { attachments } : {}) }
42
+ let attributes: any = {
43
+ text,
44
+ ...(attachments ? { attachments } : {}),
45
+ ...(replyRootId ? { reply_root: { id: replyRootId } } : {}),
46
+ }
42
47
  if (!isEditing) {
43
48
  const idempotentKey = insecureUUID()
44
49
  attributes.idempotent_key = idempotentKey
@@ -14,6 +14,7 @@ import { ResponseError } from '../utils/response_error'
14
14
 
15
15
  interface SuspenseGetOptions extends GetRequest {
16
16
  app?: App
17
+ reply_root_id?: string
17
18
  }
18
19
 
19
20
  export type SuspenseGetQueryOptions<T extends ResourceObject | ResourceObject[]> = Omit<
@@ -114,10 +115,12 @@ export type RequestQueryKey = [
114
115
  SuspenseGetOptions['data'],
115
116
  SuspenseGetOptions['headers'],
116
117
  SuspenseGetOptions['app'],
118
+ SuspenseGetOptions['reply_root_id'],
117
119
  ]
118
120
  export const getRequestQueryKey = (args: SuspenseGetOptions): RequestQueryKey => [
119
121
  args.url,
120
122
  args.data,
121
123
  args.headers,
122
124
  args.app || 'chat',
125
+ args.reply_root_id,
123
126
  ]
@@ -184,6 +184,12 @@ export const ChatStack = createNativeStackNavigator({
184
184
  ),
185
185
  }),
186
186
  },
187
+ ConversationReply: {
188
+ screen: ConversationScreen,
189
+ options: {
190
+ title: 'Reply', // TODO: Get root reply author
191
+ },
192
+ },
187
193
  TeamConversation: {
188
194
  screen: TeamConversationScreen,
189
195
  options: {
@@ -35,10 +35,10 @@ import { CONVERSATION_MESSAGE_LIST_PADDING_HORIZONTAL } from '../utils/styles'
35
35
  import { useConversationJoltEvents } from '../hooks/use_conversation_jolt_events'
36
36
  import { JumpToBottomButton } from '../components/conversation/jump_to_bottom_button'
37
37
 
38
- export const REPLIES_FEATURE_ENABLED = false
39
-
40
38
  export type ConversationRouteProps = {
41
39
  conversation_id: number
40
+ reply_root_id?: string
41
+ replyRootAuthor?: string
42
42
  chat_group_graph_id?: string
43
43
  clear_input?: boolean
44
44
  editing_message_id?: number | null
@@ -53,10 +53,11 @@ export type ConversationScreenProps = StaticScreenProps<ConversationRouteProps>
53
53
  export function ConversationScreen({ route }: ConversationScreenProps) {
54
54
  const styles = useStyles()
55
55
  const navigation = useNavigation()
56
- const { conversation_id, editing_message_id } = route.params
56
+ const { conversation_id, editing_message_id, reply_root_id } = route.params
57
57
  const { data: conversation } = useConversation(route.params)
58
58
  const { messages, refetch, isRefetching, fetchNextPage } = useConversationMessages({
59
59
  conversation_id,
60
+ reply_root_id,
60
61
  })
61
62
  useConversationJoltEvents({ conversationId: conversation_id })
62
63
  useConversationMessagesJoltEvents({ conversationId: conversation_id })
@@ -86,12 +87,14 @@ export function ConversationScreen({ route }: ConversationScreenProps) {
86
87
  }, [])
87
88
 
88
89
  useEffect(() => {
90
+ if (reply_root_id) return
91
+
89
92
  navigation.setParams({
90
93
  title: title,
91
94
  badge: badges?.[0],
92
95
  deleted: conversation?.deleted,
93
96
  })
94
- }, [navigation, title, badges, conversation?.deleted])
97
+ }, [navigation, title, badges, conversation?.deleted, reply_root_id])
95
98
 
96
99
  if (!conversation || conversation.deleted) {
97
100
  return (
@@ -139,6 +142,7 @@ export function ConversationScreen({ route }: ConversationScreenProps) {
139
142
  canDeleteNonAuthoredMessages={canDeleteNonAuthoredMessages}
140
143
  conversation_id={conversation_id}
141
144
  latestReadMessageSortKey={conversation?.latestReadMessageSortKey}
145
+ inReplyScreen={!!reply_root_id}
142
146
  />
143
147
  )
144
148
  }}
@@ -152,6 +156,7 @@ export function ConversationScreen({ route }: ConversationScreenProps) {
152
156
  {canReply ? (
153
157
  <MessageForm.Root
154
158
  conversation={conversation}
159
+ replyRootId={reply_root_id}
155
160
  currentlyEditingMessage={currentlyEditingMessage}
156
161
  // We use a separate key so that it remounts component when switching between new
157
162
  // and edit message. This simplifies internal state handling.
@@ -14,6 +14,7 @@ import { useMessageReactionToggle } from '../hooks/use_message_reaction_toggle'
14
14
  import { ReactionCountResource } from '../types/resources/reaction'
15
15
  import { Clipboard, Haptic } from '../utils/native_adapters'
16
16
  import { MessageResource } from '../types'
17
+ import { REPLIES_FEATURE_ENABLED } from '../utils'
17
18
 
18
19
  export const MessageActionsScreenOptions = getFormSheetScreenOptions({
19
20
  sheetAllowedDetents: [0.5],
@@ -24,10 +25,11 @@ export type MessageActionsScreenProps = StaticScreenProps<{
24
25
  message_id: string
25
26
  conversation_id: number
26
27
  canDeleteNonAuthoredMessages?: boolean
28
+ inReplyScreen?: boolean
27
29
  }>
28
30
 
29
31
  export function MessageActionsScreen({ route }: MessageActionsScreenProps) {
30
- const { conversation_id, message_id, canDeleteNonAuthoredMessages } = route.params
32
+ const { conversation_id, message_id, canDeleteNonAuthoredMessages, inReplyScreen } = route.params
31
33
 
32
34
  const { messages, refetch } = useConversationMessages(
33
35
  { conversation_id },
@@ -43,6 +45,7 @@ export function MessageActionsScreen({ route }: MessageActionsScreenProps) {
43
45
  conversation_id={conversation_id}
44
46
  canDeleteNonAuthoredMessages={canDeleteNonAuthoredMessages || false}
45
47
  refetchMessages={refetch}
48
+ inReplyScreen={inReplyScreen}
46
49
  />
47
50
  )
48
51
  }
@@ -52,11 +55,13 @@ function MessageActionsScreenContent({
52
55
  conversation_id,
53
56
  canDeleteNonAuthoredMessages,
54
57
  refetchMessages,
58
+ inReplyScreen,
55
59
  }: {
56
60
  message: MessageResource
57
61
  conversation_id: number
58
62
  canDeleteNonAuthoredMessages: boolean
59
63
  refetchMessages: () => void
64
+ inReplyScreen?: boolean
60
65
  }) {
61
66
  const navigation = useNavigation()
62
67
  const apiClient = useApiClient()
@@ -83,6 +88,18 @@ function MessageActionsScreenContent({
83
88
  return ''
84
89
  }
85
90
 
91
+ const handleReplyPress = useCallback(() => {
92
+ navigation.goBack()
93
+ // Waits for the modal to be dismissed before pushing the reply screen
94
+ requestAnimationFrame(() => {
95
+ navigation.navigate('ConversationReply', {
96
+ conversation_id,
97
+ reply_root_id: message.replyRootId || message.id,
98
+ // TODO: Update title param with reply root author
99
+ })
100
+ })
101
+ }, [navigation, conversation_id, message.id, message.replyRootId])
102
+
86
103
  const handleCopyPress = () => {
87
104
  Clipboard.setStringAsync(message?.text || attachmentForCopy() || '')
88
105
  navigation.goBack()
@@ -163,6 +180,15 @@ function MessageActionsScreenContent({
163
180
  ))}
164
181
  </View>
165
182
  <View style={styles.actions}>
183
+ {REPLIES_FEATURE_ENABLED && !inReplyScreen && (
184
+ <FormSheet.Action
185
+ onPress={handleReplyPress}
186
+ title="Reply to message"
187
+ iconName="registrations.undo"
188
+ accessibilityHint="Navigates to the reply screen"
189
+ accessibilityRole="link"
190
+ />
191
+ )}
166
192
  <FormSheet.Action
167
193
  onPress={handleCopyPress}
168
194
  title="Copy text"
@@ -16,6 +16,8 @@ interface BaseMessageEventData extends Record<string, unknown> {
16
16
  html: string
17
17
  attachments: DenormalizedAttachmentResource[]
18
18
  idempotent_key?: string | null
19
+ reply_count: number
20
+ reply_root_id: string | null
19
21
  }
20
22
  }
21
23
 
@@ -15,6 +15,8 @@ export interface MessageResource {
15
15
  attachments: DenormalizedAttachmentResource[]
16
16
  author: PersonResource
17
17
  reactionCounts: ReactionCountResource[]
18
+ replyCount: number
19
+ replyRootId?: string | null
18
20
 
19
21
  // Custom Local Properties we set for rendering
20
22
  renderAuthor?: boolean