@planningcenter/chat-react-native 3.14.0-rc.0 → 3.14.0-rc.2

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 (66) hide show
  1. package/build/components/conversation/message.d.ts.map +1 -1
  2. package/build/components/conversation/message.js +4 -0
  3. package/build/components/conversation/message.js.map +1 -1
  4. package/build/components/conversation/message_form.d.ts.map +1 -1
  5. package/build/components/conversation/message_form.js +23 -3
  6. package/build/components/conversation/message_form.js.map +1 -1
  7. package/build/components/conversations/conversations.d.ts.map +1 -1
  8. package/build/components/conversations/conversations.js +2 -0
  9. package/build/components/conversations/conversations.js.map +1 -1
  10. package/build/components/display/toggle_button.d.ts +5 -1
  11. package/build/components/display/toggle_button.d.ts.map +1 -1
  12. package/build/components/display/toggle_button.js +8 -2
  13. package/build/components/display/toggle_button.js.map +1 -1
  14. package/build/hooks/use_attachment_uploader.d.ts +2 -1
  15. package/build/hooks/use_attachment_uploader.d.ts.map +1 -1
  16. package/build/hooks/use_attachment_uploader.js +6 -2
  17. package/build/hooks/use_attachment_uploader.js.map +1 -1
  18. package/build/hooks/use_conversations_jolt_events.d.ts.map +1 -1
  19. package/build/hooks/use_conversations_jolt_events.js +10 -5
  20. package/build/hooks/use_conversations_jolt_events.js.map +1 -1
  21. package/build/hooks/use_jolt.d.ts +1 -1
  22. package/build/hooks/use_jolt.d.ts.map +1 -1
  23. package/build/hooks/use_jolt.js +3 -2
  24. package/build/hooks/use_jolt.js.map +1 -1
  25. package/build/hooks/use_message_draft.d.ts +13 -0
  26. package/build/hooks/use_message_draft.d.ts.map +1 -0
  27. package/build/hooks/use_message_draft.js +83 -0
  28. package/build/hooks/use_message_draft.js.map +1 -0
  29. package/build/screens/conversation_new/components/services_form.d.ts.map +1 -1
  30. package/build/screens/conversation_new/components/services_form.js +12 -8
  31. package/build/screens/conversation_new/components/services_form.js.map +1 -1
  32. package/build/screens/conversation_screen.d.ts.map +1 -1
  33. package/build/screens/conversation_screen.js +6 -1
  34. package/build/screens/conversation_screen.js.map +1 -1
  35. package/build/screens/conversation_select_recipients/conversation_select_recipients_screen.d.ts.map +1 -1
  36. package/build/screens/conversation_select_recipients/conversation_select_recipients_screen.js +10 -6
  37. package/build/screens/conversation_select_recipients/conversation_select_recipients_screen.js.map +1 -1
  38. package/build/screens/conversations/conversations_screen.d.ts.map +1 -1
  39. package/build/screens/conversations/conversations_screen.js +0 -2
  40. package/build/screens/conversations/conversations_screen.js.map +1 -1
  41. package/build/screens/message_actions_screen.d.ts.map +1 -1
  42. package/build/screens/message_actions_screen.js +2 -1
  43. package/build/screens/message_actions_screen.js.map +1 -1
  44. package/build/types/resources/denormalized_attachment_resource_for_create.d.ts +1 -0
  45. package/build/types/resources/denormalized_attachment_resource_for_create.d.ts.map +1 -1
  46. package/build/types/resources/denormalized_attachment_resource_for_create.js.map +1 -1
  47. package/package.json +2 -2
  48. package/src/components/conversation/message.tsx +4 -0
  49. package/src/components/conversation/message_form.tsx +25 -3
  50. package/src/components/conversations/conversations.tsx +3 -1
  51. package/src/components/display/toggle_button.tsx +15 -1
  52. package/src/hooks/use_attachment_uploader.ts +12 -2
  53. package/src/hooks/use_conversations_jolt_events.ts +13 -6
  54. package/src/hooks/use_jolt.ts +6 -2
  55. package/src/hooks/use_message_draft.ts +108 -0
  56. package/src/screens/conversation_new/components/services_form.tsx +4 -2
  57. package/src/screens/conversation_screen.tsx +7 -0
  58. package/src/screens/conversation_select_recipients/conversation_select_recipients_screen.tsx +4 -2
  59. package/src/screens/conversations/conversations_screen.tsx +0 -2
  60. package/src/screens/message_actions_screen.tsx +2 -1
  61. package/src/types/resources/denormalized_attachment_resource_for_create.ts +1 -0
  62. package/build/screens/conversations/components/conversations_jolt_events.d.ts +0 -7
  63. package/build/screens/conversations/components/conversations_jolt_events.d.ts.map +0 -1
  64. package/build/screens/conversations/components/conversations_jolt_events.js +0 -14
  65. package/build/screens/conversations/components/conversations_jolt_events.js.map +0 -1
  66. package/src/screens/conversations/components/conversations_jolt_events.tsx +0 -23
@@ -18,10 +18,16 @@ const MAX_FILE_SIZE_IN_MB = 50
18
18
  const MAX_FILE_SIZE_IN_BYTES = MAX_FILE_SIZE_IN_MB * 1024 * 1024
19
19
  const MAX_NUMBER_OF_ATTACHMENTS = 10
20
20
 
21
- export function useAttachmentUploader({ conversationId }: { conversationId: number }) {
21
+ export function useAttachmentUploader({
22
+ conversationId,
23
+ draftAttachments,
24
+ }: {
25
+ conversationId: number
26
+ draftAttachments?: FileAttachment[]
27
+ }) {
22
28
  const apiClient = useApiClient()
23
29
  const uploadApi = useUploadClient()
24
- const [attachments, setAttachments] = useState<FileAttachment[]>([])
30
+ const [attachments, setAttachments] = useState<FileAttachment[]>(() => draftAttachments || [])
25
31
  const uploadState = useRef<FileUploadState>({})
26
32
  const [lastUploadId, setLastUploadId] = useState<string>()
27
33
  const numberOfAttachments = attachments.length
@@ -67,6 +73,7 @@ export function useAttachmentUploader({ conversationId }: { conversationId: numb
67
73
  const newAttachments: FileAttachment[] = validFiles.map(file => ({
68
74
  file,
69
75
  status: 'uploading',
76
+ uploadedAt: Date.now(),
70
77
  }))
71
78
 
72
79
  if (newAttachments && newAttachments.length > 0) {
@@ -111,6 +118,9 @@ export function useAttachmentUploader({ conversationId }: { conversationId: numb
111
118
  setLastUploadId(undefined)
112
119
  setAttachments(
113
120
  attachments.map(attachment => {
121
+ // Don't risk overwriting ids already set
122
+ if (attachment.id) return attachment
123
+
114
124
  const state = uploadState.current[attachment.file.name]
115
125
  if (state) {
116
126
  return { ...attachment, id: state.id, status: state.status }
@@ -1,22 +1,29 @@
1
- import { JoltConversationEvent } from '../types/jolt_events'
1
+ import { CurrentPersonResource } from '../types'
2
2
  import { ConversationRequestArgs } from '../utils/request/conversation'
3
3
  import { useConversationsCache } from './use_conversations_cache'
4
- import { useCurrentPerson, useCurrentPersonCache } from './use_current_person'
4
+ import { currentPersonRequestArgs, useCurrentPersonCache } from './use_current_person'
5
5
  import { useJoltChannel, useJoltEvent } from './use_jolt'
6
- import { completeMessageCreationConversationTracking } from '../utils/performance_tracking'
6
+ import { useApiGet } from './use_api'
7
7
  import { useApiClient } from './use_api_client'
8
+ import { JoltConversationEvent } from '../types/jolt_events'
9
+ import { completeMessageCreationConversationTracking } from '../utils/performance_tracking'
10
+
11
+ const useCurrentPerson = () => {
12
+ return useApiGet<CurrentPersonResource>(currentPersonRequestArgs)
13
+ }
8
14
 
9
15
  export function useConversationsJoltEvents(args?: Partial<ConversationRequestArgs>) {
10
- const currentPerson = useCurrentPerson()
11
- const joltChannel = useJoltChannel(`chat.people.${currentPerson.id}`)
16
+ const { data: currentPerson } = useCurrentPerson()
12
17
  const cache = useConversationsCache(args)
13
18
  const currentPersonCache = useCurrentPersonCache()
14
19
  const apiClient = useApiClient()
20
+ const enabled = currentPerson?.id ? true : false
21
+ const joltChannel = useJoltChannel(`chat.people.${currentPerson?.id}`, enabled)
15
22
 
16
23
  useJoltEvent(joltChannel, 'conversation.updated', (e: JoltConversationEvent) => {
17
24
  if (
18
25
  e.data.data.last_message_idempotent_key &&
19
- e.data.data.last_message_author_id === currentPerson.id
26
+ e.data.data.last_message_author_id === currentPerson?.id
20
27
  ) {
21
28
  completeMessageCreationConversationTracking({
22
29
  apiClient,
@@ -119,10 +119,14 @@ export const useJoltClient = (): JoltClient | undefined => {
119
119
  return joltClient
120
120
  }
121
121
 
122
- export function useJoltChannel(channelName: string): JoltSubscription | undefined | null {
122
+ export function useJoltChannel(
123
+ channelName: string,
124
+ enabled: boolean = true
125
+ ): JoltSubscription | undefined | null {
123
126
  const jolt = useJoltClient()
124
127
  const appState = useAppState()
125
128
  const queryClient = useQueryClient()
129
+ const ready = Boolean(jolt) && appState !== 'background' && enabled
126
130
 
127
131
  const handleSubscribe = useCallback(async () => {
128
132
  if (!jolt) return null
@@ -137,7 +141,7 @@ export function useJoltChannel(channelName: string): JoltSubscription | undefine
137
141
  const { data: subscription } = useQuery<JoltSubscription | null>({
138
142
  queryKey: ['jolt-subscription', channelName],
139
143
  queryFn: handleSubscribe,
140
- enabled: Boolean(jolt) && appState !== 'background',
144
+ enabled: ready,
141
145
  })
142
146
 
143
147
  const handleUnsubscribe = useCallback(() => {
@@ -0,0 +1,108 @@
1
+ import { useCallback, useEffect, useState } from 'react'
2
+ import { useAsyncStorage } from './use_async_storage'
3
+ import type { FileAttachment } from '../types/resources/denormalized_attachment_resource_for_create'
4
+
5
+ interface MessageDraft {
6
+ text: string
7
+ attachments: FileAttachment[]
8
+ lastUpdated: number
9
+ }
10
+
11
+ const DRAFT_STORAGE_KEY = 'chat_message_drafts'
12
+ const DRAFT_EXPIRY_HOURS = 720 // 30 days
13
+ const DRAFT_EXPIRY_MS = DRAFT_EXPIRY_HOURS * 60 * 60 * 1000
14
+ const ATTACHMENT_EXPIRY_HOURS = 48 // 2 days
15
+ const ATTACHMENT_EXPIRY_MS = ATTACHMENT_EXPIRY_HOURS * 60 * 60 * 1000
16
+
17
+ export function useMessageDraft(conversationId: number) {
18
+ const conversationKey = conversationId.toString()
19
+ const [allDrafts, setAllDrafts] = useAsyncStorage<Record<string, MessageDraft>>(
20
+ DRAFT_STORAGE_KEY,
21
+ {}
22
+ )
23
+
24
+ const [draft, setDraft] = useState<MessageDraft | null>(() => {
25
+ const storedDraft = allDrafts[conversationKey] || null
26
+
27
+ // Filter expired attachments
28
+ if (storedDraft?.attachments) {
29
+ const now = Date.now()
30
+ storedDraft.attachments = storedDraft.attachments.filter(attachment => {
31
+ if (!attachment.uploadedAt) return false
32
+ return now - attachment.uploadedAt < ATTACHMENT_EXPIRY_MS
33
+ })
34
+ }
35
+
36
+ return storedDraft
37
+ })
38
+
39
+ // Clean up expired drafts on mount
40
+ useEffect(() => {
41
+ const now = Date.now()
42
+ const validDrafts: Record<string, MessageDraft> = {}
43
+
44
+ Object.entries(allDrafts).forEach(([id, draftData]) => {
45
+ if (draftData?.lastUpdated && now - draftData.lastUpdated < DRAFT_EXPIRY_MS) {
46
+ validDrafts[id] = draftData
47
+ }
48
+ })
49
+
50
+ // If we filtered out any drafts, update storage
51
+ if (Object.keys(validDrafts).length !== Object.keys(allDrafts).length) {
52
+ setAllDrafts(validDrafts)
53
+ }
54
+ // eslint-disable-next-line react-hooks/exhaustive-deps
55
+ }, []) // I only want this to run on mount
56
+
57
+ const saveDraft = useCallback(
58
+ (text: string, attachments: FileAttachment[]) => {
59
+ const hasContent = text.trim().length > 0 || attachments.length > 0
60
+ const updatedDrafts = { ...allDrafts }
61
+
62
+ if (hasContent) {
63
+ // Convert attachments to serializable format (similar to web implementation)
64
+ const normalizedAttachments = attachments
65
+ .filter(att => att.id && att.status === 'success')
66
+ .map(att => ({
67
+ ...att,
68
+ file: {
69
+ uri: att.file.uri,
70
+ name: att.file.name,
71
+ type: att.file.type,
72
+ size: att.file.size,
73
+ width: att.file.width,
74
+ height: att.file.height,
75
+ },
76
+ }))
77
+
78
+ const newDraft: MessageDraft = {
79
+ text,
80
+ attachments: normalizedAttachments,
81
+ lastUpdated: Date.now(),
82
+ }
83
+
84
+ updatedDrafts[conversationKey] = newDraft
85
+ setDraft(newDraft)
86
+ } else {
87
+ delete updatedDrafts[conversationKey]
88
+ setDraft(null)
89
+ }
90
+
91
+ setAllDrafts(updatedDrafts)
92
+ },
93
+ [conversationKey, allDrafts, setAllDrafts]
94
+ )
95
+
96
+ const clearDraft = useCallback(() => {
97
+ const updatedDrafts = { ...allDrafts }
98
+ delete updatedDrafts[conversationKey]
99
+ setAllDrafts(updatedDrafts)
100
+ setDraft(null)
101
+ }, [conversationKey, allDrafts, setAllDrafts])
102
+
103
+ return {
104
+ draft,
105
+ saveDraft,
106
+ clearDraft,
107
+ }
108
+ }
@@ -13,6 +13,7 @@ import { ConversationResource, MemberResource } from '../../../types'
13
13
  import { tokens } from '../../../vendor/tapestry/tokens'
14
14
  import { TeamFilterTypes } from '../../conversation_filter_recipients/types'
15
15
  import { useServicesTeams } from '../../../hooks/services/use_services_team'
16
+ import { Haptic } from '../../../utils/native_adapters'
16
17
 
17
18
  type ServicesFormProps = {
18
19
  initialTeamIds?: number[]
@@ -153,7 +154,8 @@ function FormContent({
153
154
  <TextButton
154
155
  accessibilityLabel="Select teams"
155
156
  textStyle={styles.teamCountHeader}
156
- onPress={() =>
157
+ onPress={() => {
158
+ Haptic.impactLight()
157
159
  navigation.navigate('New', {
158
160
  screen: 'ConversationFilterRecipients',
159
161
  params: {
@@ -162,7 +164,7 @@ function FormContent({
162
164
  team_filter_type: teamFilterType,
163
165
  },
164
166
  })
165
- }
167
+ }}
166
168
  >
167
169
  {teamCountHeader}
168
170
  </TextButton>
@@ -149,6 +149,13 @@ export function ConversationScreen({ route }: ConversationScreenProps) {
149
149
  <MessageForm.Root
150
150
  conversation={conversation}
151
151
  currentlyEditingMessage={currentlyEditingMessage}
152
+ // We use a separate key so that it remounts component when switching between new
153
+ // and edit message. This simplifies internal state handling.
154
+ key={
155
+ currentlyEditingMessage
156
+ ? `edit-message-form-${currentlyEditingMessage.id}`
157
+ : 'new-message-form'
158
+ }
152
159
  >
153
160
  <MessageForm.AttachmentPicker />
154
161
  <MessageForm.Commands />
@@ -14,6 +14,7 @@ import { TeamRecipientRow } from './components/team_recipient_row'
14
14
  import { GroupsRecipientRow } from './components/groups_recipient_row'
15
15
  import { DefaultLoading } from '../../components/page/loading'
16
16
  import { useAppGrants } from '../../hooks'
17
+ import { Haptic } from '../../utils/native_adapters'
17
18
 
18
19
  const MAX_VISIBLE_RECIPIENTS = 5
19
20
 
@@ -128,14 +129,15 @@ export const ConversationSelectRecipientsScreen = ({
128
129
  {hasServiceTypes && (
129
130
  <Button
130
131
  style={styles.selectTeamsButton}
131
- onPress={() =>
132
+ onPress={() => {
133
+ Haptic.impactLight()
132
134
  navigation.navigate('New', {
133
135
  screen: 'ConversationFilterRecipients',
134
136
  params: {
135
137
  source_app_name: 'Services',
136
138
  },
137
139
  })
138
- }
140
+ }}
139
141
  title="Select teams"
140
142
  variant="outline"
141
143
  iconNameLeft="general.search"
@@ -9,7 +9,6 @@ import { GraphId } from '../../types/resources/group_resource'
9
9
  import { destructureChatGroupGraphId } from '../../utils'
10
10
  import { ListHeaderComponent } from './components/list_header_component'
11
11
  import { AppName } from '../../types/resources/app_name'
12
- import { ConversationsJoltEvents } from './components/conversations_jolt_events'
13
12
 
14
13
  export type ConversationsScreenProps = StaticScreenProps<{
15
14
  title?: string
@@ -62,7 +61,6 @@ export function ConversationsScreen({ route }: ConversationsScreenProps) {
62
61
  return (
63
62
  <View style={styles.container}>
64
63
  <ConversationsContextProvider args={route.params}>
65
- <ConversationsJoltEvents args={route.params} />
66
64
  <Conversations ListHeaderComponent={ListHeaderComponent} />
67
65
  </ConversationsContextProvider>
68
66
  <ActionButton
@@ -12,7 +12,7 @@ import { useApiClient } from '../hooks/use_api_client'
12
12
  import { useConversationMessages } from '../hooks/use_conversation_messages'
13
13
  import { useMessageReactionToggle } from '../hooks/use_message_reaction_toggle'
14
14
  import { ReactionCountResource } from '../types/resources/reaction'
15
- import { Clipboard } from '../utils/native_adapters'
15
+ import { Clipboard, Haptic } from '../utils/native_adapters'
16
16
 
17
17
  export const MessageActionsScreenOptions = getFormSheetScreenOptions({
18
18
  sheetAllowedDetents: [0.5],
@@ -111,6 +111,7 @@ export function MessageActionsScreen({ route }: MessageActionsScreenProps) {
111
111
  }, [navigation, conversation_id, message_id])
112
112
 
113
113
  const handleViewReadReceiptsPress = useCallback(() => {
114
+ Haptic.impactLight()
114
115
  const params = omitBy(
115
116
  {
116
117
  conversation_id,
@@ -50,6 +50,7 @@ export interface FileAttachment {
50
50
  id?: string
51
51
  file: NativeAttachmentFile
52
52
  status: AttachmentStatus
53
+ uploadedAt: number
53
54
  }
54
55
 
55
56
  export interface FileUploadState {
@@ -1,7 +0,0 @@
1
- import { ConversationRequestArgs } from '../../../utils/request/conversation';
2
- interface Props {
3
- args?: Partial<ConversationRequestArgs>;
4
- }
5
- export declare function ConversationsJoltEvents({ args }: Props): import("react").JSX.Element;
6
- export {};
7
- //# sourceMappingURL=conversations_jolt_events.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"conversations_jolt_events.d.ts","sourceRoot":"","sources":["../../../../src/screens/conversations/components/conversations_jolt_events.tsx"],"names":[],"mappings":"AACA,OAAO,EAAE,uBAAuB,EAAE,MAAM,qCAAqC,CAAA;AAG7E,UAAU,KAAK;IACb,IAAI,CAAC,EAAE,OAAO,CAAC,uBAAuB,CAAC,CAAA;CACxC;AAID,wBAAgB,uBAAuB,CAAC,EAAE,IAAI,EAAE,EAAE,KAAK,+BAMtD"}
@@ -1,14 +0,0 @@
1
- import { Suspense } from 'react';
2
- import { useConversationsJoltEvents } from '../../../hooks/use_conversations_jolt_events';
3
- // This hook is extracted into a component so the Suspense boundary can be isolated.
4
- // It doesn't need to block the entire Conversations screen from rendering.
5
- export function ConversationsJoltEvents({ args }) {
6
- return (<Suspense fallback={null}>
7
- <PrivateSuspendedConversationsJoltEvents args={args}/>
8
- </Suspense>);
9
- }
10
- function PrivateSuspendedConversationsJoltEvents({ args }) {
11
- useConversationsJoltEvents(args);
12
- return null;
13
- }
14
- //# sourceMappingURL=conversations_jolt_events.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"conversations_jolt_events.js","sourceRoot":"","sources":["../../../../src/screens/conversations/components/conversations_jolt_events.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAA;AAEhC,OAAO,EAAE,0BAA0B,EAAE,MAAM,8CAA8C,CAAA;AAMzF,oFAAoF;AACpF,2EAA2E;AAC3E,MAAM,UAAU,uBAAuB,CAAC,EAAE,IAAI,EAAS;IACrD,OAAO,CACL,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC,IAAI,CAAC,CACvB;MAAA,CAAC,uCAAuC,CAAC,IAAI,CAAC,CAAC,IAAI,CAAC,EACtD;IAAA,EAAE,QAAQ,CAAC,CACZ,CAAA;AACH,CAAC;AAED,SAAS,uCAAuC,CAAC,EAAE,IAAI,EAAS;IAC9D,0BAA0B,CAAC,IAAI,CAAC,CAAA;IAEhC,OAAO,IAAI,CAAA;AACb,CAAC","sourcesContent":["import { Suspense } from 'react'\nimport { ConversationRequestArgs } from '../../../utils/request/conversation'\nimport { useConversationsJoltEvents } from '../../../hooks/use_conversations_jolt_events'\n\ninterface Props {\n args?: Partial<ConversationRequestArgs>\n}\n\n// This hook is extracted into a component so the Suspense boundary can be isolated.\n// It doesn't need to block the entire Conversations screen from rendering.\nexport function ConversationsJoltEvents({ args }: Props) {\n return (\n <Suspense fallback={null}>\n <PrivateSuspendedConversationsJoltEvents args={args} />\n </Suspense>\n )\n}\n\nfunction PrivateSuspendedConversationsJoltEvents({ args }: Props): null {\n useConversationsJoltEvents(args)\n\n return null\n}\n"]}
@@ -1,23 +0,0 @@
1
- import { Suspense } from 'react'
2
- import { ConversationRequestArgs } from '../../../utils/request/conversation'
3
- import { useConversationsJoltEvents } from '../../../hooks/use_conversations_jolt_events'
4
-
5
- interface Props {
6
- args?: Partial<ConversationRequestArgs>
7
- }
8
-
9
- // This hook is extracted into a component so the Suspense boundary can be isolated.
10
- // It doesn't need to block the entire Conversations screen from rendering.
11
- export function ConversationsJoltEvents({ args }: Props) {
12
- return (
13
- <Suspense fallback={null}>
14
- <PrivateSuspendedConversationsJoltEvents args={args} />
15
- </Suspense>
16
- )
17
- }
18
-
19
- function PrivateSuspendedConversationsJoltEvents({ args }: Props): null {
20
- useConversationsJoltEvents(args)
21
-
22
- return null
23
- }