@planningcenter/chat-react-native 3.18.0-rc.1 → 3.18.0-rc.11

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 (137) hide show
  1. package/build/components/conversation/message.d.ts +2 -1
  2. package/build/components/conversation/message.d.ts.map +1 -1
  3. package/build/components/conversation/message.js +53 -26
  4. package/build/components/conversation/message.js.map +1 -1
  5. package/build/components/conversation/message_form.d.ts.map +1 -1
  6. package/build/components/conversation/message_form.js +7 -6
  7. package/build/components/conversation/message_form.js.map +1 -1
  8. package/build/components/conversation/messages_disabled_banners.d.ts +3 -0
  9. package/build/components/conversation/messages_disabled_banners.d.ts.map +1 -0
  10. package/build/components/conversation/messages_disabled_banners.js +53 -0
  11. package/build/components/conversation/messages_disabled_banners.js.map +1 -0
  12. package/build/components/conversation/reply_connectors.d.ts.map +1 -1
  13. package/build/components/conversation/reply_connectors.js +0 -5
  14. package/build/components/conversation/reply_connectors.js.map +1 -1
  15. package/build/components/conversation/reply_shadow_message.d.ts.map +1 -1
  16. package/build/components/conversation/reply_shadow_message.js +8 -3
  17. package/build/components/conversation/reply_shadow_message.js.map +1 -1
  18. package/build/components/conversation/typing_indicator.d.ts +1 -5
  19. package/build/components/conversation/typing_indicator.d.ts.map +1 -1
  20. package/build/components/conversation/typing_indicator.js +2 -2
  21. package/build/components/conversation/typing_indicator.js.map +1 -1
  22. package/build/components/conversations/conversation_actions.d.ts.map +1 -1
  23. package/build/components/conversations/conversation_actions.js +1 -2
  24. package/build/components/conversations/conversation_actions.js.map +1 -1
  25. package/build/components/conversations/conversations.d.ts.map +1 -1
  26. package/build/components/conversations/conversations.js +2 -3
  27. package/build/components/conversations/conversations.js.map +1 -1
  28. package/build/contexts/conversation_context.d.ts +13 -0
  29. package/build/contexts/conversation_context.d.ts.map +1 -0
  30. package/build/contexts/conversation_context.js +14 -0
  31. package/build/contexts/conversation_context.js.map +1 -0
  32. package/build/hooks/use_broadcast_typing_status.d.ts +1 -1
  33. package/build/hooks/use_broadcast_typing_status.d.ts.map +1 -1
  34. package/build/hooks/use_broadcast_typing_status.js +7 -3
  35. package/build/hooks/use_broadcast_typing_status.js.map +1 -1
  36. package/build/hooks/use_conversation_messages.d.ts.map +1 -1
  37. package/build/hooks/use_conversation_messages.js +2 -1
  38. package/build/hooks/use_conversation_messages.js.map +1 -1
  39. package/build/hooks/use_conversation_messages_jolt_events.d.ts.map +1 -1
  40. package/build/hooks/use_conversation_messages_jolt_events.js +23 -70
  41. package/build/hooks/use_conversation_messages_jolt_events.js.map +1 -1
  42. package/build/hooks/use_features.d.ts +9 -0
  43. package/build/hooks/use_features.d.ts.map +1 -0
  44. package/build/hooks/use_features.js +35 -0
  45. package/build/hooks/use_features.js.map +1 -0
  46. package/build/hooks/use_message_create_or_update.d.ts +0 -2
  47. package/build/hooks/use_message_create_or_update.d.ts.map +1 -1
  48. package/build/hooks/use_message_create_or_update.js +10 -8
  49. package/build/hooks/use_message_create_or_update.js.map +1 -1
  50. package/build/hooks/use_typing_indicators.d.ts +1 -1
  51. package/build/hooks/use_typing_indicators.d.ts.map +1 -1
  52. package/build/hooks/use_typing_indicators.js +16 -3
  53. package/build/hooks/use_typing_indicators.js.map +1 -1
  54. package/build/screens/conversation_details_screen.d.ts.map +1 -1
  55. package/build/screens/conversation_details_screen.js +9 -6
  56. package/build/screens/conversation_details_screen.js.map +1 -1
  57. package/build/screens/conversation_new/components/form_list.d.ts +2 -2
  58. package/build/screens/conversation_new/components/form_list.d.ts.map +1 -1
  59. package/build/screens/conversation_new/components/form_list.js +2 -3
  60. package/build/screens/conversation_new/components/form_list.js.map +1 -1
  61. package/build/screens/conversation_screen.d.ts +2 -1
  62. package/build/screens/conversation_screen.d.ts.map +1 -1
  63. package/build/screens/conversation_screen.js +41 -18
  64. package/build/screens/conversation_screen.js.map +1 -1
  65. package/build/screens/conversation_select_recipients/conversation_select_group_recipients_screen.d.ts.map +1 -1
  66. package/build/screens/conversation_select_recipients/conversation_select_group_recipients_screen.js +2 -3
  67. package/build/screens/conversation_select_recipients/conversation_select_group_recipients_screen.js.map +1 -1
  68. package/build/screens/conversation_select_recipients/conversation_select_teams_i_lead_recipients_screen.d.ts.map +1 -1
  69. package/build/screens/conversation_select_recipients/conversation_select_teams_i_lead_recipients_screen.js +2 -3
  70. package/build/screens/conversation_select_recipients/conversation_select_teams_i_lead_recipients_screen.js.map +1 -1
  71. package/build/screens/message_actions_screen.js +4 -2
  72. package/build/screens/message_actions_screen.js.map +1 -1
  73. package/build/types/jolt_events/reaction_events.d.ts +1 -0
  74. package/build/types/jolt_events/reaction_events.d.ts.map +1 -1
  75. package/build/types/jolt_events/reaction_events.js.map +1 -1
  76. package/build/types/jolt_events/typing_events.d.ts +1 -0
  77. package/build/types/jolt_events/typing_events.d.ts.map +1 -1
  78. package/build/types/jolt_events/typing_events.js.map +1 -1
  79. package/build/types/resources/feature_resource.d.ts +7 -0
  80. package/build/types/resources/feature_resource.d.ts.map +1 -0
  81. package/build/types/resources/feature_resource.js +2 -0
  82. package/build/types/resources/feature_resource.js.map +1 -0
  83. package/build/utils/cache/messages_cache.d.ts +9 -0
  84. package/build/utils/cache/messages_cache.d.ts.map +1 -0
  85. package/build/utils/cache/messages_cache.js +89 -0
  86. package/build/utils/cache/messages_cache.js.map +1 -0
  87. package/build/utils/cache/optimistically_create_message.d.ts +2 -1
  88. package/build/utils/cache/optimistically_create_message.d.ts.map +1 -1
  89. package/build/utils/cache/optimistically_create_message.js +6 -3
  90. package/build/utils/cache/optimistically_create_message.js.map +1 -1
  91. package/build/utils/index.d.ts +0 -1
  92. package/build/utils/index.d.ts.map +1 -1
  93. package/build/utils/index.js +0 -1
  94. package/build/utils/index.js.map +1 -1
  95. package/build/utils/request/get_features.d.ts +11 -0
  96. package/build/utils/request/get_features.d.ts.map +1 -0
  97. package/build/utils/request/get_features.js +18 -0
  98. package/build/utils/request/get_features.js.map +1 -0
  99. package/package.json +2 -3
  100. package/src/components/conversation/message.tsx +80 -29
  101. package/src/components/conversation/message_form.tsx +6 -11
  102. package/src/components/conversation/messages_disabled_banners.tsx +69 -0
  103. package/src/components/conversation/reply_connectors.tsx +0 -3
  104. package/src/components/conversation/reply_shadow_message.tsx +9 -2
  105. package/src/components/conversation/typing_indicator.tsx +2 -6
  106. package/src/components/conversations/conversation_actions.tsx +1 -1
  107. package/src/components/conversations/conversations.tsx +7 -9
  108. package/src/contexts/conversation_context.tsx +34 -0
  109. package/src/hooks/use_broadcast_typing_status.ts +7 -3
  110. package/src/hooks/use_conversation_messages.ts +3 -1
  111. package/src/hooks/use_conversation_messages_jolt_events.ts +39 -81
  112. package/src/hooks/use_features.ts +47 -0
  113. package/src/hooks/use_message_create_or_update.ts +10 -9
  114. package/src/hooks/use_typing_indicators.ts +15 -3
  115. package/src/screens/conversation_details_screen.tsx +9 -6
  116. package/src/screens/conversation_new/components/form_list.tsx +3 -5
  117. package/src/screens/conversation_screen.tsx +58 -20
  118. package/src/screens/conversation_select_recipients/conversation_select_group_recipients_screen.tsx +2 -4
  119. package/src/screens/conversation_select_recipients/conversation_select_teams_i_lead_recipients_screen.tsx +2 -4
  120. package/src/screens/message_actions_screen.tsx +4 -2
  121. package/src/types/jolt_events/reaction_events.ts +1 -0
  122. package/src/types/jolt_events/typing_events.ts +1 -0
  123. package/src/types/resources/feature_resource.ts +6 -0
  124. package/src/utils/cache/messages_cache.ts +113 -0
  125. package/src/utils/cache/optimistically_create_message.ts +7 -2
  126. package/src/utils/index.ts +0 -1
  127. package/src/utils/request/get_features.ts +20 -0
  128. package/build/components/conversation/disabled_replies_banners.d.ts +0 -3
  129. package/build/components/conversation/disabled_replies_banners.d.ts.map +0 -1
  130. package/build/components/conversation/disabled_replies_banners.js +0 -41
  131. package/build/components/conversation/disabled_replies_banners.js.map +0 -1
  132. package/build/utils/replies_local_feature_flag.d.ts +0 -2
  133. package/build/utils/replies_local_feature_flag.d.ts.map +0 -1
  134. package/build/utils/replies_local_feature_flag.js +0 -3
  135. package/build/utils/replies_local_feature_flag.js.map +0 -1
  136. package/src/components/conversation/disabled_replies_banners.tsx +0 -58
  137. package/src/utils/replies_local_feature_flag.ts +0 -2
@@ -1,22 +1,21 @@
1
1
  import { ApiCollection, MessageResource } from '../types'
2
2
  import { useJoltChannel, useJoltEvent } from './use_jolt'
3
- import {
4
- deleteRecordInPagesData,
5
- updateOrCreateRecordInPagesData,
6
- updateRecordInPagesData,
7
- } from '../utils'
3
+ import { deleteRecordInPagesData } from '../utils'
8
4
  import { MessageCreatedEvent, MessageDeletedEvent } from '../types/jolt_events/message_events'
9
5
  import { InfiniteData, useQueryClient } from '@tanstack/react-query'
10
6
  import { useCurrentPerson } from './use_current_person'
11
7
  import { transformMessageEventDataToMessageResource } from '../utils/jolt/transform_message_event_data_to_message_resource'
12
8
  import { getRequestQueryKey } from './use_suspense_api'
13
9
  import { JoltReactionEvent, JoltTypingEvent } from '../types/jolt_events'
14
- import { transformReactionEventDataToReactionCountResource } from '../utils/jolt/transform_reaction_event_data_to_reaction_count_resource'
15
10
  import { getMessagesRequestArgs } from '../utils/request/get_messages'
16
11
  import { TYPING_TIMEOUT_INTERVAL, useTypingStatusCache } from './use_typing_status_cache'
17
- import { isTemporaryMessageId } from './use_message_create_or_update'
18
12
  import { completeMessageCreationTracking } from '../utils/performance_tracking'
19
13
  import { useApiClient } from './use_api_client'
14
+ import {
15
+ updateCacheWithMessage,
16
+ updateCacheWithReaction,
17
+ getThreadedMessagesQueryKey,
18
+ } from '../utils/cache/messages_cache'
20
19
 
21
20
  interface Props {
22
21
  conversationId: number
@@ -47,42 +46,17 @@ export function useConversationMessagesJoltEvents({ conversationId }: Props) {
47
46
  }
48
47
  }
49
48
 
50
- queryClient.setQueryData<QueryData>(messagesQueryKey, prev => {
51
- if (e.event === 'message.created') {
52
- // Before adding the new message, remove any pending temporary messages
53
- // with matching text to prevent duplicates from race conditions
54
- let dataAfterTempRemoval = prev
55
- if (prev && message.text && message.mine) {
56
- dataAfterTempRemoval = deleteRecordInPagesData({
57
- data: prev,
58
- record: message,
59
- matchFn: (existingMessage, _record) => {
60
- return (
61
- isTemporaryMessageId(existingMessage.id) &&
62
- existingMessage.text === message.text &&
63
- existingMessage.mine
64
- )
65
- },
66
- })
67
- }
68
-
69
- return updateOrCreateRecordInPagesData({
70
- data: dataAfterTempRemoval,
71
- record: message,
72
- processRecord: (record, current) => {
73
- return { ...current, ...record }
74
- },
75
- })
76
- } else {
77
- return updateRecordInPagesData({
78
- data: prev,
79
- record: message,
80
- processRecord: (record, current) => {
81
- return { ...current, ...record }
82
- },
83
- })
84
- }
85
- })
49
+ // Update the main conversation cache
50
+ updateCacheWithMessage(queryClient, messagesQueryKey, message, e.event)
51
+
52
+ // If message has a reply_root_id, also update the threaded cache
53
+ if (data.reply_root_id) {
54
+ const threadedMessagesQueryKey = getThreadedMessagesQueryKey(
55
+ conversationId,
56
+ data.reply_root_id
57
+ )
58
+ updateCacheWithMessage(queryClient, threadedMessagesQueryKey, message, e.event)
59
+ }
86
60
  }
87
61
 
88
62
  const handleMessageDeleted = async (e: MessageDeletedEvent) => {
@@ -92,50 +66,34 @@ export function useConversationMessagesJoltEvents({ conversationId }: Props) {
92
66
  currentPersonId: currentPerson.id,
93
67
  })
94
68
 
69
+ // Update the main conversation cache
95
70
  queryClient.setQueryData<QueryData>(messagesQueryKey, prev =>
96
71
  deleteRecordInPagesData({ data: prev, record: message })
97
72
  )
73
+
74
+ // If message has a reply_root_id, also update the threaded cache
75
+ if (data.reply_root_id) {
76
+ const threadedMessagesQueryKey = getThreadedMessagesQueryKey(
77
+ conversationId,
78
+ data.reply_root_id
79
+ )
80
+ queryClient.setQueryData<QueryData>(threadedMessagesQueryKey, prev =>
81
+ deleteRecordInPagesData({ data: prev, record: message })
82
+ )
83
+ }
98
84
  }
99
85
 
100
86
  const handleReactionJoltEvent = async (e: JoltReactionEvent) => {
101
- const { data } = e.data
102
- const message = { id: data.message_sort_key } as MessageResource
103
- queryClient.setQueryData<QueryData>(messagesQueryKey, prev =>
104
- updateRecordInPagesData({
105
- data: prev,
106
- record: message,
107
- processRecord: (record, oldMessage) => {
108
- const reactionCounts = oldMessage.reactionCounts || []
109
- let foundMatch = false
110
- let newReactionCounts = reactionCounts.map(reactionCount => {
111
- if (reactionCount.value === data.value) {
112
- foundMatch = true
113
- return transformReactionEventDataToReactionCountResource({
114
- data,
115
- oldData: reactionCount,
116
- event: e.event,
117
- currentPersonId: currentPerson.id,
118
- })
119
- }
120
- return reactionCount
121
- })
122
-
123
- if (!foundMatch) {
124
- const newReactionCount = transformReactionEventDataToReactionCountResource({
125
- data,
126
- event: e.event,
127
- currentPersonId: currentPerson.id,
128
- })
129
-
130
- if (newReactionCount?.count) {
131
- newReactionCounts = [...newReactionCounts, newReactionCount]
132
- }
133
- }
134
-
135
- return { ...oldMessage, reactionCounts: newReactionCounts }
136
- },
137
- })
138
- )
87
+ // Update the main conversation cache and capture the reply_root_id if present
88
+ updateCacheWithReaction(queryClient, messagesQueryKey, e, currentPerson.id)
89
+
90
+ const replyRootId = e.data.data.reply_root_id
91
+
92
+ // If the message has a reply_root_id, also update the threaded cache
93
+ if (replyRootId) {
94
+ const threadedMessagesQueryKey = getThreadedMessagesQueryKey(conversationId, replyRootId)
95
+ updateCacheWithReaction(queryClient, threadedMessagesQueryKey, e, currentPerson.id)
96
+ }
139
97
  }
140
98
 
141
99
  const handleTypingEvent = async (e: JoltTypingEvent) => {
@@ -0,0 +1,47 @@
1
+ import { useSuspenseQuery } from '@tanstack/react-query'
2
+ import { useCallback } from 'react'
3
+ import { getFeaturesRequestArgs, getFeaturesQueryKey } from '../utils/request/get_features'
4
+ import { useApiClient } from './use_api_client'
5
+ import type { FeatureResource } from '../types/resources/feature_resource'
6
+ import { ApiCollection } from '../types'
7
+
8
+ export function useFeatures() {
9
+ const apiClient = useApiClient()
10
+ const requestArgs = getFeaturesRequestArgs()
11
+
12
+ const { data } = useSuspenseQuery({
13
+ queryKey: getFeaturesQueryKey(),
14
+ queryFn: () => {
15
+ return apiClient.chat
16
+ .get<ApiCollection<FeatureResource>>(requestArgs)
17
+ .catch(() => stableEmptyFeatures)
18
+ },
19
+ staleTime: 1000 * 60 * 5, // 5 minutes
20
+ })
21
+
22
+ const features = data.data
23
+
24
+ const featureEnabled = useCallback(
25
+ (featureName: string) =>
26
+ features.some(feature => feature.name === featureName && feature.enabled),
27
+ [features]
28
+ )
29
+
30
+ return {
31
+ features,
32
+ featureEnabled,
33
+ }
34
+ }
35
+
36
+ export const availableFeatures = {
37
+ threaded_replies: 'QA_MOBILE_threaded_replies',
38
+ }
39
+
40
+ const stableEmptyFeatures: ApiCollection<FeatureResource> = {
41
+ data: [],
42
+ links: {},
43
+ meta: {
44
+ count: 0,
45
+ totalCount: 0,
46
+ },
47
+ }
@@ -14,6 +14,7 @@ import { useCurrentPerson } from './use_current_person'
14
14
  import { optimisticallyUpdateMessage } from '../utils/cache/optimistically_update_message'
15
15
  import { optimisticallyCreateMessage } from '../utils/cache/optimistically_create_message'
16
16
  import { startMessageCreationTracking } from '../utils/performance_tracking'
17
+ import { isNewMessage } from '../utils/cache/messages_cache'
17
18
 
18
19
  interface Props {
19
20
  conversationId: number
@@ -93,6 +94,7 @@ export function useMessageCreateOrUpdate({ conversationId, message, replyRootId
93
94
  text,
94
95
  attachments,
95
96
  currentPerson,
97
+ replyRootId,
96
98
  })
97
99
 
98
100
  return { message: optimisticMessage }
@@ -103,7 +105,10 @@ export function useMessageCreateOrUpdate({ conversationId, message, replyRootId
103
105
 
104
106
  // Add error to the optimistic message from the cache on error
105
107
  if (optimisticMessage) {
106
- const queryKey = getMessagesQueryKey({ conversation_id: conversationId })
108
+ const queryKey = getMessagesQueryKey({
109
+ conversation_id: conversationId,
110
+ reply_root_id: replyRootId,
111
+ })
107
112
  chatQueryClient.setQueryData(
108
113
  queryKey,
109
114
  (data: InfiniteData<ApiCollection<MessageResource>> | undefined) =>
@@ -123,7 +128,10 @@ export function useMessageCreateOrUpdate({ conversationId, message, replyRootId
123
128
  const { message: optimisticMessage } = context || {}
124
129
  const updatedMessage = result.data
125
130
  type QueryData = InfiniteData<ApiCollection<MessageResource>>
126
- const queryKey = getMessagesQueryKey({ conversation_id: conversationId })
131
+ const queryKey = getMessagesQueryKey({
132
+ conversation_id: conversationId,
133
+ reply_root_id: replyRootId,
134
+ })
127
135
 
128
136
  // First remove the optimistic message if it exists
129
137
  if (optimisticMessage) {
@@ -148,13 +156,6 @@ export function useMessageCreateOrUpdate({ conversationId, message, replyRootId
148
156
  return mutation
149
157
  }
150
158
 
151
- export function isTemporaryMessageId(messageId?: string | null): boolean {
152
- return !!messageId && messageId.endsWith('-temp')
153
- }
154
- export function isNewMessage(message?: MessageResource): boolean {
155
- return !message?.id || isTemporaryMessageId(message.id)
156
- }
157
-
158
159
  /**
159
160
  * Generate a random UUID (v4) for idempotent keys.
160
161
  * Uses Math.random, which is not cryptographically secure.
@@ -2,6 +2,7 @@ import { useQuery } from '@tanstack/react-query'
2
2
  import { useMemo } from 'react'
3
3
  import { PersonTyping } from './use_typing_status_cache'
4
4
  import { useCurrentPerson } from './use_current_person'
5
+ import { useConversationContext } from '../contexts/conversation_context'
5
6
 
6
7
  /**
7
8
  * Hook for getting people currently typing in a conversation
@@ -9,9 +10,12 @@ import { useCurrentPerson } from './use_current_person'
9
10
  * The query function itself doesn't do anything, but we add to the cache
10
11
  * from useTypingStatusCache when we receive a typing event.
11
12
  */
12
- export const useTypingIndicators = (conversationId: number) => {
13
+ export const useTypingIndicators = () => {
14
+ const { conversationId, currentPageReplyRootId } = useConversationContext()
13
15
  const cacheKey = useMemo(() => ['conversationTyping', String(conversationId)], [conversationId])
14
16
  const currentPerson = useCurrentPerson()
17
+ const isCurrentPerson = (authorId: number) => authorId === currentPerson?.id
18
+ const inMainConversationView = !currentPageReplyRootId
15
19
  const now = Date.now()
16
20
  const { data } = useQuery({
17
21
  queryKey: cacheKey,
@@ -19,8 +23,16 @@ export const useTypingIndicators = (conversationId: number) => {
19
23
  initialData: stableArray,
20
24
  })
21
25
 
22
- // Filter out expired entries and the current user
23
- return data.filter(person => person.expires > now && person.author_id !== currentPerson?.id)
26
+ return data.filter(({ author_id, expires, reply_root_id }) => {
27
+ if (isCurrentPerson(author_id)) return false
28
+ if (now > expires) return false
29
+
30
+ // If you are in the main conversation view, you will see any message sent
31
+ if (inMainConversationView) return true
32
+
33
+ // If you are in a reply view, you will only see messages sent in this reply thread
34
+ return reply_root_id === currentPageReplyRootId
35
+ })
24
36
  }
25
37
 
26
38
  /**
@@ -9,6 +9,7 @@ import React, {
9
9
  useRef,
10
10
  } from 'react'
11
11
  import {
12
+ FlatList,
12
13
  StyleSheet,
13
14
  TextInput,
14
15
  View,
@@ -40,10 +41,11 @@ import {
40
41
  } from '../hooks/use_conversation'
41
42
  import { MemberResource, isDefined } from '../types'
42
43
  import { HeaderTextButton } from '../components/display/platform_modal_header_buttons'
43
- import { FlashList } from '@shopify/flash-list'
44
44
  import { tokens } from '../vendor/tapestry/tokens'
45
45
  import { ButtonAppearanceUnion } from '../components/display/utils/button_colors'
46
46
  import { GroupResource } from '../types/resources/group_resource'
47
+ import { useFeatures } from '../hooks/use_features'
48
+ import { availableFeatures } from '../hooks/use_features'
47
49
 
48
50
  // =========================================
49
51
  // ====== Factory Constants & Types ========
@@ -93,6 +95,8 @@ export function ConversationDetailsScreen({ route }: ConversationDetailsScreenPr
93
95
  const { repliesDisabled, setRepliesDisabled } = useConversationDisableReplies(route.params)
94
96
  const { mutate: saveTitle } = useConversationUpdate(route.params)
95
97
  const { mutate: deleteConversation } = useConversationDelete(route.params)
98
+ const { featureEnabled } = useFeatures()
99
+ const repliesEnabled = featureEnabled(availableFeatures.threaded_replies)
96
100
 
97
101
  const trimmedTitle = title.trim()
98
102
  const emptyTitle = trimmedTitle === '' || title === null
@@ -250,8 +254,8 @@ export function ConversationDetailsScreen({ route }: ConversationDetailsScreenPr
250
254
  {
251
255
  type: canUpdate ? SectionTypes.setting : SectionTypes.hidden,
252
256
  data: {
253
- title: 'Freeze conversation',
254
- subtitle: 'Disables replies for everyone except leaders.',
257
+ title: repliesEnabled ? 'Leader messages only' : 'Freeze conversation',
258
+ subtitle: repliesEnabled ? undefined : 'Disables replies for everyone except leaders.',
255
259
  rightItem: (
256
260
  <Switch value={repliesDisabled} onChange={() => setRepliesDisabled(!repliesDisabled)} />
257
261
  ),
@@ -300,9 +304,8 @@ export function ConversationDetailsScreen({ route }: ConversationDetailsScreenPr
300
304
 
301
305
  return (
302
306
  <View style={styles.listContainer}>
303
- <FlashList
307
+ <FlatList
304
308
  data={listData as SectionListData}
305
- estimatedItemSize={52}
306
309
  contentContainerStyle={styles.contentContainer}
307
310
  renderItem={({ item, index }) => {
308
311
  const [isStart, isEnd] = [
@@ -375,7 +378,7 @@ export function ConversationDetailsScreen({ route }: ConversationDetailsScreenPr
375
378
  return null
376
379
  }
377
380
  }}
378
- onEndReached={fetchNextPageOfMembers}
381
+ onEndReached={() => fetchNextPageOfMembers()}
379
382
  />
380
383
  </View>
381
384
  )
@@ -1,6 +1,5 @@
1
1
  import React from 'react'
2
- import { StyleSheet, View } from 'react-native'
3
- import { FlashList, type FlashListProps } from '@shopify/flash-list'
2
+ import { FlatList, type FlatListProps, StyleSheet, View } from 'react-native'
4
3
  import { MemberResource } from '../../../types'
5
4
  import { Person, Text } from '../../../components/display'
6
5
  import { useTheme } from '../../../hooks'
@@ -8,7 +7,7 @@ import { useTheme } from '../../../hooks'
8
7
  interface FormListProps {
9
8
  memberData: MemberResource[]
10
9
  loadingMore?: boolean
11
- FormContent?: FlashListProps<MemberResource>['ListHeaderComponent']
10
+ FormContent?: FlatListProps<MemberResource>['ListHeaderComponent']
12
11
  listEmptyText?: string
13
12
  onEndReached?: () => void
14
13
  }
@@ -23,7 +22,7 @@ export const FormList = ({
23
22
  const styles = useStyles()
24
23
 
25
24
  return (
26
- <FlashList
25
+ <FlatList
27
26
  data={memberData}
28
27
  ListHeaderComponent={FormContent}
29
28
  renderItem={({ item }) => <Person person={item} style={styles.person} />}
@@ -31,7 +30,6 @@ export const FormList = ({
31
30
  loadingMore ? <Text style={styles.loadingMore}>Loading more...</Text> : null
32
31
  }
33
32
  keyExtractor={item => item.id.toString()}
34
- estimatedItemSize={45}
35
33
  ListEmptyComponent={<ListEmptyText text={listEmptyText || 'No members found'} />}
36
34
  onEndReached={onEndReached}
37
35
  />
@@ -14,9 +14,9 @@ import { FlatList, Platform, StyleSheet, View } from 'react-native'
14
14
  import { useSafeAreaInsets } from 'react-native-safe-area-context'
15
15
  import { Badge, Icon, Text } from '../components'
16
16
  import {
17
- LeaderDisabledRepliesBanner,
18
- MemberDisabledRepliesBanner,
19
- } from '../components/conversation/disabled_replies_banners'
17
+ LeaderMessagesDisabledBanner,
18
+ MemberMessagesDisabledBanner,
19
+ } from '../components/conversation/messages_disabled_banners'
20
20
  import { EmptyConversationBlankState } from '../components/conversation/empty_conversation_blank_state'
21
21
  import BlankState from '../components/primitive/blank_state_primitive'
22
22
  import { Message } from '../components/conversation/message'
@@ -35,7 +35,8 @@ 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
  import { ReplyShadowMessage } from '../components/conversation/reply_shadow_message'
38
- import { REPLIES_FEATURE_ENABLED } from '../utils'
38
+ import { availableFeatures, useFeatures } from '../hooks/use_features'
39
+ import { ConversationContextProvider } from '../contexts/conversation_context'
39
40
 
40
41
  export type ConversationRouteProps = {
41
42
  conversation_id: number
@@ -53,6 +54,19 @@ export type ConversationRouteProps = {
53
54
  export type ConversationScreenProps = StaticScreenProps<ConversationRouteProps>
54
55
 
55
56
  export function ConversationScreen({ route }: ConversationScreenProps) {
57
+ const { conversation_id, reply_root_id } = route.params
58
+
59
+ return (
60
+ <ConversationContextProvider
61
+ conversationId={conversation_id}
62
+ currentPageReplyRootId={reply_root_id ?? null}
63
+ >
64
+ <ConversationScreenContent route={route} />
65
+ </ConversationContextProvider>
66
+ )
67
+ }
68
+
69
+ function ConversationScreenContent({ route }: ConversationScreenProps) {
56
70
  const styles = useStyles()
57
71
  const navigation = useNavigation()
58
72
  const { conversation_id, editing_message_id, reply_root_id, reply_root_author_name } =
@@ -66,7 +80,13 @@ export function ConversationScreen({ route }: ConversationScreenProps) {
66
80
  useConversationMessagesJoltEvents({ conversationId: conversation_id })
67
81
  useEnsureConversationsRouteExists()
68
82
  useMarkLatestMessageRead({ conversation, messages })
69
- const messagesWithSeparators = groupMessages({ ms: messages, inReplyScreen: !!reply_root_id })
83
+ const { featureEnabled } = useFeatures()
84
+ const repliesEnabled = featureEnabled(availableFeatures.threaded_replies)
85
+ const messagesWithSeparators = groupMessages({
86
+ ms: messages,
87
+ inReplyScreen: !!reply_root_id,
88
+ repliesEnabled,
89
+ })
70
90
  const noMessages = messagesWithSeparators.length === 0
71
91
 
72
92
  const { repliesDisabled, memberAbility, badges, title } = conversation
@@ -164,6 +184,7 @@ export function ConversationScreen({ route }: ConversationScreenProps) {
164
184
  conversation_id={conversation_id}
165
185
  latestReadMessageSortKey={conversation?.latestReadMessageSortKey}
166
186
  inReplyScreen={!!reply_root_id}
187
+ repliesEnabled={repliesEnabled}
167
188
  />
168
189
  )
169
190
  }}
@@ -172,8 +193,8 @@ export function ConversationScreen({ route }: ConversationScreenProps) {
172
193
  />
173
194
  )}
174
195
  <JumpToBottomButton onPress={handleReturnToBottom} visible={showJumpToBottomButton} />
175
- {!noMessages && <TypingIndicator conversationId={conversation_id} />}
176
- {showLeaderDisabledReplyBanner && <LeaderDisabledRepliesBanner />}
196
+ {!noMessages && <TypingIndicator />}
197
+ {showLeaderDisabledReplyBanner && <LeaderMessagesDisabledBanner />}
177
198
  {canReply ? (
178
199
  <MessageForm.Root
179
200
  replyRootAuthorFirstName={replyRootAuthorFirstName}
@@ -194,7 +215,7 @@ export function ConversationScreen({ route }: ConversationScreenProps) {
194
215
  <MessageForm.SubmitButton />
195
216
  </MessageForm.Root>
196
217
  ) : (
197
- <MemberDisabledRepliesBanner />
218
+ <MemberMessagesDisabledBanner />
198
219
  )}
199
220
  </KeyboardView>
200
221
  </View>
@@ -252,9 +273,14 @@ type ReplyShadowMessage = {
252
273
  interface GroupMessagesProps {
253
274
  ms: MessageResource[]
254
275
  inReplyScreen?: boolean
276
+ repliesEnabled?: boolean
255
277
  }
256
278
 
257
- export const groupMessages = ({ ms, inReplyScreen }: GroupMessagesProps) => {
279
+ export const groupMessages = ({
280
+ ms,
281
+ inReplyScreen,
282
+ repliesEnabled = false,
283
+ }: GroupMessagesProps) => {
258
284
  let enrichedMessages: (MessageResource | DateSeparator | ReplyShadowMessage)[] = []
259
285
  let encounteredOneOfMyMessages = false
260
286
 
@@ -284,6 +310,24 @@ export const groupMessages = ({ ms, inReplyScreen }: GroupMessagesProps) => {
284
310
  message.replyRootId &&
285
311
  !threadRoot &&
286
312
  (prevMessageDifferentThread || prevMessageIsDateSeparator)
313
+ const lastInGroup =
314
+ !nextMessage ||
315
+ nextMessageDifferentAuthor ||
316
+ nextMessageMoreThan5Minutes ||
317
+ nextMessageDifferentThread ||
318
+ nextMessageIsDateSeparator
319
+ const renderAuthor =
320
+ !message.mine &&
321
+ (!prevMessage ||
322
+ prevMessageDifferentAuthor ||
323
+ prevMessageMoreThan5Minutes ||
324
+ prevMessageDifferentThread ||
325
+ prevMessageIsDateSeparator)
326
+ const nextIsReplyShadowMessage =
327
+ repliesEnabled &&
328
+ nextMessageInThread &&
329
+ !nextMessageThreadRoot &&
330
+ (nextMessageDifferentThread || nextMessageIsDateSeparator)
287
331
 
288
332
  if (message.mine && !encounteredOneOfMyMessages) {
289
333
  encounteredOneOfMyMessages = true
@@ -291,17 +335,12 @@ export const groupMessages = ({ ms, inReplyScreen }: GroupMessagesProps) => {
291
335
  } else {
292
336
  message.myLatestInConversation = false
293
337
  }
294
- message.lastInGroup = !nextMessage || nextMessageDifferentAuthor || nextMessageMoreThan5Minutes
295
- message.renderAuthor =
296
- !message.mine && (!prevMessage || prevMessageDifferentAuthor || prevMessageMoreThan5Minutes)
338
+ message.lastInGroup = lastInGroup
339
+ message.renderAuthor = renderAuthor
297
340
  message.threadPosition = null
298
341
  message.nextRendersAuthor = nextMessage?.renderAuthor
299
342
  message.isReplyShadowMessage = false
300
- message.nextIsReplyShadowMessage =
301
- REPLIES_FEATURE_ENABLED &&
302
- nextMessageInThread &&
303
- !nextMessageThreadRoot &&
304
- (nextMessageDifferentThread || nextMessageIsDateSeparator)
343
+ message.nextIsReplyShadowMessage = nextIsReplyShadowMessage
305
344
 
306
345
  if (!inReplyScreen && inThread) {
307
346
  message.prevIsMyReply = prevMessage?.mine
@@ -314,13 +353,12 @@ export const groupMessages = ({ ms, inReplyScreen }: GroupMessagesProps) => {
314
353
  message.threadPosition = null // ensures we don't render a connector for root replies that aren't immediately followed up a reply
315
354
  else if (firstInThread) message.threadPosition = 'first'
316
355
  else if (lastInThread) message.threadPosition = 'last'
317
- else if (!prevMessageDifferentThread && !nextMessageDifferentThread)
318
- message.threadPosition = 'center'
356
+ else message.threadPosition = 'center'
319
357
  }
320
358
 
321
359
  enrichedMessages.push(message)
322
360
 
323
- if (insertReplyShadowMessage && REPLIES_FEATURE_ENABLED) {
361
+ if (insertReplyShadowMessage && repliesEnabled) {
324
362
  enrichedMessages.push({
325
363
  type: 'ReplyShadowMessage',
326
364
  id: `${message.id}-${message.replyRootId}`,
@@ -1,7 +1,6 @@
1
1
  import { useNavigation } from '@react-navigation/native'
2
2
  import React from 'react'
3
- import { StyleSheet, View } from 'react-native'
4
- import { FlashList } from '@shopify/flash-list'
3
+ import { FlatList, StyleSheet, View } from 'react-native'
5
4
  import { Heading } from '../../components'
6
5
  import { GroupsGroupResource } from '../../types'
7
6
  import { useGroupsGroups } from '../../hooks/use_groups_groups'
@@ -31,10 +30,9 @@ export const ConversationSelectGroupRecipientsScreen = ({
31
30
  }
32
31
 
33
32
  return (
34
- <FlashList
33
+ <FlatList
35
34
  data={groupsWithCreatePermission}
36
35
  keyExtractor={item => item.id.toString()}
37
- estimatedItemSize={65}
38
36
  contentContainerStyle={styles.contentContainer}
39
37
  ListHeaderComponent={
40
38
  <View style={styles.sectionHeader}>
@@ -1,7 +1,6 @@
1
1
  import { useNavigation } from '@react-navigation/native'
2
2
  import React from 'react'
3
- import { StyleSheet, View } from 'react-native'
4
- import { FlashList } from '@shopify/flash-list'
3
+ import { FlatList, StyleSheet, View } from 'react-native'
5
4
  import { Heading } from '../../components'
6
5
  import { useSafeAreaInsets } from 'react-native-safe-area-context'
7
6
  import { ConversationSelectRecipientsScreenProps } from './types/screen_props'
@@ -30,10 +29,9 @@ export const ConversationSelectTeamsILeadRecipientsScreen = ({
30
29
  }
31
30
 
32
31
  return (
33
- <FlashList
32
+ <FlatList
34
33
  data={serviceTypes}
35
34
  keyExtractor={item => item.id.toString()}
36
- estimatedItemSize={65}
37
35
  contentContainerStyle={styles.contentContainer}
38
36
  ListHeaderComponent={
39
37
  <View style={styles.sectionHeader}>
@@ -14,7 +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
+ import { availableFeatures, useFeatures } from '../hooks/use_features'
18
18
 
19
19
  export const MessageActionsScreenOptions = getFormSheetScreenOptions({
20
20
  sheetAllowedDetents: [0.5],
@@ -76,6 +76,8 @@ function MessageActionsScreenContent({
76
76
  const navigation = useNavigation()
77
77
  const apiClient = useApiClient()
78
78
  const styles = useStyles()
79
+ const { featureEnabled } = useFeatures()
80
+ const repliesEnabled = featureEnabled(availableFeatures.threaded_replies)
79
81
 
80
82
  const myReactions = message?.reactionCounts
81
83
  .filter(reaction => reaction.mine)
@@ -190,7 +192,7 @@ function MessageActionsScreenContent({
190
192
  ))}
191
193
  </View>
192
194
  <View style={styles.actions}>
193
- {REPLIES_FEATURE_ENABLED && !inReplyScreen && (
195
+ {repliesEnabled && !inReplyScreen && (
194
196
  <FormSheet.Action
195
197
  onPress={handleReplyPress}
196
198
  title="Reply to message"
@@ -6,6 +6,7 @@ interface BaseReactionEventData extends Record<string, unknown> {
6
6
  author_id: number
7
7
  conversation_id: number
8
8
  message_sort_key: string
9
+ reply_root_id?: string | null
9
10
  created_at: string
10
11
  organization_id: number
11
12
  value: ReactionCountResource['value']
@@ -13,4 +13,5 @@ export interface TypingBroadcastDataAttributes {
13
13
  author_id: number
14
14
  author_name: string
15
15
  id: string
16
+ reply_root_id: string | null
16
17
  }
@@ -0,0 +1,6 @@
1
+ export interface FeatureResource {
2
+ type: 'Feature'
3
+ id: string
4
+ name: string
5
+ enabled: boolean
6
+ }