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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/build/components/conversation/attachments/expanded_link.js +1 -0
  2. package/build/components/conversation/attachments/expanded_link.js.map +1 -1
  3. package/build/components/conversation/attachments/image_attachment.js +1 -0
  4. package/build/components/conversation/attachments/image_attachment.js.map +1 -1
  5. package/build/components/conversation/message.d.ts +1 -0
  6. package/build/components/conversation/message.d.ts.map +1 -1
  7. package/build/components/conversation/message.js +18 -3
  8. package/build/components/conversation/message.js.map +1 -1
  9. package/build/components/conversation/message_reaction.d.ts +2 -2
  10. package/build/components/conversation/message_reaction.d.ts.map +1 -1
  11. package/build/components/conversation/message_reaction.js +2 -2
  12. package/build/components/conversation/message_reaction.js.map +1 -1
  13. package/build/components/conversation/message_read_receipts.d.ts +9 -0
  14. package/build/components/conversation/message_read_receipts.d.ts.map +1 -0
  15. package/build/components/conversation/message_read_receipts.js +33 -0
  16. package/build/components/conversation/message_read_receipts.js.map +1 -0
  17. package/build/hooks/use_conversation.d.ts +1 -0
  18. package/build/hooks/use_conversation.d.ts.map +1 -1
  19. package/build/hooks/use_conversation.js +4 -1
  20. package/build/hooks/use_conversation.js.map +1 -1
  21. package/build/hooks/use_conversation_jolt_events.d.ts +6 -0
  22. package/build/hooks/use_conversation_jolt_events.d.ts.map +1 -0
  23. package/build/hooks/use_conversation_jolt_events.js +42 -0
  24. package/build/hooks/use_conversation_jolt_events.js.map +1 -0
  25. package/build/hooks/use_live_relative_time.d.ts +3 -0
  26. package/build/hooks/use_live_relative_time.d.ts.map +1 -0
  27. package/build/hooks/use_live_relative_time.js +31 -0
  28. package/build/hooks/use_live_relative_time.js.map +1 -0
  29. package/build/hooks/use_mark_latest_message_read.d.ts +8 -0
  30. package/build/hooks/use_mark_latest_message_read.d.ts.map +1 -0
  31. package/build/hooks/use_mark_latest_message_read.js +29 -0
  32. package/build/hooks/use_mark_latest_message_read.js.map +1 -0
  33. package/build/navigation/index.d.ts +1 -0
  34. package/build/navigation/index.d.ts.map +1 -1
  35. package/build/navigation/index.js +1 -0
  36. package/build/navigation/index.js.map +1 -1
  37. package/build/screens/conversation_screen.d.ts.map +1 -1
  38. package/build/screens/conversation_screen.js +22 -4
  39. package/build/screens/conversation_screen.js.map +1 -1
  40. package/build/types/resources/conversation.d.ts +4 -0
  41. package/build/types/resources/conversation.d.ts.map +1 -1
  42. package/build/types/resources/conversation.js.map +1 -1
  43. package/build/utils/date.d.ts +4 -3
  44. package/build/utils/date.d.ts.map +1 -1
  45. package/build/utils/date.js +27 -2
  46. package/build/utils/date.js.map +1 -1
  47. package/package.json +2 -2
  48. package/src/components/conversation/attachments/expanded_link.tsx +1 -0
  49. package/src/components/conversation/attachments/image_attachment.tsx +1 -0
  50. package/src/components/conversation/message.tsx +26 -2
  51. package/src/components/conversation/message_reaction.tsx +3 -3
  52. package/src/components/conversation/message_read_receipts.tsx +45 -0
  53. package/src/hooks/use_conversation.ts +4 -1
  54. package/src/hooks/use_conversation_jolt_events.ts +52 -0
  55. package/src/hooks/use_live_relative_time.ts +38 -0
  56. package/src/hooks/use_mark_latest_message_read.ts +41 -0
  57. package/src/navigation/index.tsx +7 -0
  58. package/src/screens/conversation_screen.tsx +24 -4
  59. package/src/types/datetime-fmt.d.ts +20 -0
  60. package/src/types/resources/conversation.ts +4 -0
  61. package/src/utils/date.ts +30 -3
@@ -13,11 +13,13 @@ export const getConversationRequestArgs = ({ conversation_id }: { conversation_i
13
13
  Conversation: [
14
14
  'created_at',
15
15
  'badges',
16
+ 'conversation_membership',
16
17
  'groups',
17
18
  'last_message_author_id',
18
19
  'last_message_author_name',
19
20
  'last_message_created_at',
20
21
  'last_message_text_preview',
22
+ 'latest_read_message_sort_key',
21
23
  'preview_avatar_urls',
22
24
  'member_ability',
23
25
  'muted',
@@ -33,10 +35,11 @@ export const getConversationRequestArgs = ({ conversation_id }: { conversation_i
33
35
  'can_reply',
34
36
  'can_delete_non_authored_messages',
35
37
  ],
38
+ ConversationMembership: ['last_read_message_sort_key'],
36
39
  ConversationBadge: ['app_name', 'pco_resource_type', 'text'],
37
40
  Group: ['type', 'id', 'links', 'name', 'source_app_name', 'source_type'],
38
41
  },
39
- include: ['badges', 'member_ability', 'groups'],
42
+ include: ['badges', 'conversation_membership', 'member_ability', 'groups'],
40
43
  },
41
44
  })
42
45
 
@@ -0,0 +1,52 @@
1
+ import { JoltConversationEvent } from '../types/jolt_events'
2
+ import { useJoltChannel, useJoltEvent } from './use_jolt'
3
+ import { getConversationRequestArgs, useConversation } from './use_conversation'
4
+ import { useQueryClient } from '@tanstack/react-query'
5
+ import { ApiResource, ConversationResource } from '../types'
6
+ import { getRequestQueryKey } from './use_suspense_api'
7
+
8
+ interface Props {
9
+ conversationId: number
10
+ }
11
+
12
+ export function useConversationJoltEvents({ conversationId }: Props) {
13
+ const joltChannel = useJoltChannel(`chat.conversations.${conversationId}`)
14
+ const { refetch } = useConversation({ conversation_id: conversationId })
15
+ const queryClient = useQueryClient()
16
+ const requestArgs = getConversationRequestArgs({ conversation_id: conversationId })
17
+ const queryKey = getRequestQueryKey(requestArgs)
18
+
19
+ function updateLastRead(e: JoltConversationEvent) {
20
+ e.data.data.latest_read_message_sort_key
21
+
22
+ queryClient.setQueryData<ApiResource<ConversationResource>>(queryKey, prev => {
23
+ if (!prev?.data) return prev
24
+
25
+ return {
26
+ ...prev,
27
+ data: {
28
+ ...prev.data,
29
+ last_read_message_sort_key: e.data.data.latest_read_message_sort_key,
30
+ },
31
+ }
32
+ })
33
+ }
34
+
35
+ function removeFromCache() {
36
+ queryClient.setQueryData<ApiResource<ConversationResource>>(queryKey, prev => {
37
+ if (!prev?.data) return prev
38
+
39
+ return {
40
+ ...prev,
41
+ data: {
42
+ ...prev.data,
43
+ deleted: true,
44
+ },
45
+ }
46
+ })
47
+ }
48
+
49
+ useJoltEvent(joltChannel, 'conversation.updated', () => refetch())
50
+ useJoltEvent(joltChannel, 'conversation.read', updateLastRead)
51
+ useJoltEvent(joltChannel, 'conversation.destroyed', removeFromCache)
52
+ }
@@ -0,0 +1,38 @@
1
+ import { useEffect, useState } from 'react'
2
+ import moment from 'moment'
3
+ import { relativeTime, type DateProps } from '../utils/date'
4
+
5
+ const MINUTE = 60
6
+ const HOUR = MINUTE * 60
7
+ const DAY = HOUR * 24
8
+
9
+ export function useLiveRelativeTime(date: DateProps) {
10
+ const [timeNow, setTimeNow] = useState(Date.now())
11
+
12
+ useEffect(() => {
13
+ const tick = () => {
14
+ const then = moment(date).valueOf()
15
+ const seconds = Math.round(Math.abs(timeNow - then) / 1000)
16
+
17
+ const periodInMinutes = seconds < HOUR ? 1 : seconds < DAY ? 60 : 0
18
+
19
+ if (periodInMinutes) {
20
+ return setTimeout(() => setTimeNow(Date.now()), periodInMinutes * 60000)
21
+ }
22
+
23
+ return 0
24
+ }
25
+ const timeoutId = tick()
26
+ return () => {
27
+ if (timeoutId) {
28
+ clearTimeout(timeoutId)
29
+ }
30
+ }
31
+ }, [date, timeNow])
32
+
33
+ useEffect(() => {
34
+ setTimeNow(Date.now())
35
+ }, [date])
36
+
37
+ return relativeTime(date)
38
+ }
@@ -0,0 +1,41 @@
1
+ import { useEffect, useMemo, useState } from 'react'
2
+ import { ConversationResource, MessageResource } from '../types'
3
+ import { useCurrentPerson } from './use_current_person'
4
+ import { useAppState } from './use_app_state'
5
+ import { useConversationsMarkRead } from './use_conversations_actions'
6
+
7
+ interface Props {
8
+ conversation: ConversationResource
9
+ messages: MessageResource[]
10
+ }
11
+
12
+ export function useMarkLatestMessageRead({ conversation, messages }: Props) {
13
+ const currentPerson = useCurrentPerson()
14
+ const { markRead } = useConversationsMarkRead({ conversation })
15
+
16
+ const latestOtherPersonMessageId = useMemo(
17
+ () => messages.find(message => message.author.id !== currentPerson.id)?.id,
18
+ [currentPerson.id, messages]
19
+ )
20
+ const [lastReadMessageId, setLastReadMessageId] = useState(
21
+ conversation.conversationMembership?.lastReadMessageSortKey
22
+ )
23
+ const appState = useAppState()
24
+ const isActive = appState === 'active'
25
+
26
+ /**
27
+ * Handle marking the conversation as read.
28
+ * * The app needs to be active
29
+ * * The latest message from someone else is newer than the last read message
30
+ */
31
+ useEffect(() => {
32
+ if (!isActive || !latestOtherPersonMessageId) return
33
+ if (!lastReadMessageId || latestOtherPersonMessageId > lastReadMessageId) {
34
+ markRead(true, {
35
+ onSuccess: () => {
36
+ setLastReadMessageId(latestOtherPersonMessageId)
37
+ },
38
+ })
39
+ }
40
+ }, [isActive, latestOtherPersonMessageId, lastReadMessageId, markRead])
41
+ }
@@ -128,6 +128,13 @@ export const ChatStack = createNativeStackNavigator({
128
128
  screen: ConversationScreen,
129
129
  options: ({ route, navigation }) => ({
130
130
  headerTitle: (route.params as { title?: string })?.title ?? 'Chat',
131
+ headerRight: (props: NativeStackHeaderRightProps) => (
132
+ <HeaderBackButton
133
+ backImage={() => <Icon name="general.x" size={18} color={props.tintColor} />}
134
+ onPress={() => navigation.getParent()?.goBack()}
135
+ {...props}
136
+ />
137
+ ),
131
138
  headerLeft: props => (
132
139
  <HeaderBackButton
133
140
  displayMode="minimal"
@@ -1,4 +1,3 @@
1
- // @ts-expect-error
2
1
  import { date as formatDate } from '@planningcenter/datetime-fmt'
3
2
  import { HeaderTitle, HeaderTitleProps, PlatformPressable } from '@react-navigation/elements'
4
3
  import {
@@ -28,7 +27,9 @@ import {
28
27
  MemberDisabledRepliesBanner,
29
28
  } from '../components/conversation/disabled_replies_banners'
30
29
  import { useConversationMessagesJoltEvents } from '../hooks/use_conversation_messages_jolt_events'
30
+ import { useMarkLatestMessageRead } from '../hooks/use_mark_latest_message_read'
31
31
  import { CONVERSATION_MESSAGE_LIST_PADDING_HORIZONTAL } from '../utils/styles'
32
+ import { useConversationJoltEvents } from '../hooks/use_conversation_jolt_events'
32
33
 
33
34
  type ConversationRouteProps = {
34
35
  conversation_id: number
@@ -48,8 +49,10 @@ export function ConversationScreen({ route }: ConversationScreenProps) {
48
49
  const { messages, refetch, isRefetching, fetchNextPage } = useConversationMessages({
49
50
  conversation_id,
50
51
  })
52
+ useConversationJoltEvents({ conversationId: conversation_id })
51
53
  useConversationMessagesJoltEvents({ conversationId: conversation_id })
52
54
  useEnsureConversationsRouteExists()
55
+ useMarkLatestMessageRead({ conversation, messages })
53
56
  const messagesWithSeparators = groupMessages(messages)
54
57
  const noMessages = messagesWithSeparators.length === 0
55
58
 
@@ -70,6 +73,16 @@ export function ConversationScreen({ route }: ConversationScreenProps) {
70
73
  navigation.setOptions({ headerTitle, title: conversation?.title })
71
74
  }, [conversation, conversation_id, navigation, headerTitle, conversation?.title])
72
75
 
76
+ if (!conversation || conversation.deleted) {
77
+ return (
78
+ <View style={styles.container}>
79
+ <Text variant="plain" style={styles.deletedAlert}>
80
+ This conversation has been deleted.
81
+ </Text>
82
+ </View>
83
+ )
84
+ }
85
+
73
86
  return (
74
87
  <View style={styles.container}>
75
88
  <KeyboardView>
@@ -93,6 +106,7 @@ export function ConversationScreen({ route }: ConversationScreenProps) {
93
106
  {...item}
94
107
  canDeleteNonAuthoredMessages={canDeleteNonAuthoredMessages}
95
108
  conversation_id={conversation_id}
109
+ latestReadMessageSortKey={conversation?.latestReadMessageSortKey}
96
110
  />
97
111
  )
98
112
  }}
@@ -209,13 +223,15 @@ const PressableHeaderTitle = ({ style, children }: HeaderTitleProps) => {
209
223
  return (
210
224
  <PlatformPressable
211
225
  style={styles.container}
212
- onPress={() =>
226
+ onPress={() => {
227
+ if (conversation.deleted) return
228
+
213
229
  navigation.navigate('ConversationDetails', { conversation_id: conversation?.id })
214
- }
230
+ }}
215
231
  >
216
232
  <View style={styles.titleWrapper}>
217
233
  <HeaderTitle style={[styles.title, style]}>{children}</HeaderTitle>
218
- <Icon name="general.downChevron" size={12} />
234
+ {!conversation.deleted && <Icon name="general.downChevron" size={12} />}
219
235
  </View>
220
236
  <Badge
221
237
  variant="metaSubtle"
@@ -264,6 +280,10 @@ const useStyles = () => {
264
280
  gap: 12,
265
281
  paddingVertical: 12,
266
282
  },
283
+ deletedAlert: {
284
+ textAlign: 'center',
285
+ padding: 16,
286
+ },
267
287
  })
268
288
  }
269
289
 
@@ -0,0 +1,20 @@
1
+ type DateProps = string | number | Date
2
+
3
+ type options = Partial<{
4
+ dateFirst: boolean
5
+ hour12: boolean
6
+ timeZone: string
7
+ showTimeZone: 'automatic' | boolean // 'automatic'/true/false
8
+ style: string
9
+ year: boolean
10
+ yearSeparator: string
11
+ truncateSameMonth: boolean
12
+ truncateSameYear: boolean
13
+ anchorDate: Date // very optional - only used to anchor relative dates from a date other than `moment()` (current datetime), primarily for tests
14
+ }>
15
+
16
+ declare module '@planningcenter/datetime-fmt' {
17
+ export const datetime: (d: DateProps, opts: options) => string
18
+ export const date: (d: DateProps, opts: options) => string
19
+ export const time: (d: DateProps, opts?: options) => string
20
+ }
@@ -6,6 +6,9 @@ export interface ConversationResource {
6
6
  type: 'Conversation'
7
7
  id: number
8
8
  badges?: ConversationBadgeResource[]
9
+ conversationMembership?: {
10
+ lastReadMessageSortKey: string
11
+ }
9
12
  createdAt: string
10
13
  deleted?: boolean
11
14
  groups?: GroupResource[]
@@ -14,6 +17,7 @@ export interface ConversationResource {
14
17
  lastMessageAuthorName?: string
15
18
  lastMessageCreatedAt?: string
16
19
  lastMessageTextPreview?: string
20
+ latestReadMessageSortKey?: string
17
21
  memberAbility?: MemberAbilityResource
18
22
  muted: boolean
19
23
  repliesDisabled: boolean
package/src/utils/date.ts CHANGED
@@ -1,8 +1,7 @@
1
- // @ts-expect-error
2
- import { date as formatDate } from '@planningcenter/datetime-fmt'
1
+ import { date as formatDate, time as formatTime } from '@planningcenter/datetime-fmt'
3
2
  import moment from 'moment-timezone'
4
3
 
5
- type DateProps = string | number | Date
4
+ export type DateProps = string | number | Date
6
5
 
7
6
  export function formatDatePreview(date?: DateProps) {
8
7
  if (!date) return ''
@@ -34,3 +33,31 @@ export function getRelativeDateStatus(date: DateProps) {
34
33
 
35
34
  return { isToday, isThisWeek, isThisYear }
36
35
  }
36
+
37
+ export function relativeDateTime(dateTimeString: string): string {
38
+ const date = new Date(dateTimeString)
39
+ const now = new Date()
40
+
41
+ const isToday = date.toDateString() === now.toDateString()
42
+ const isThisYear = date.getFullYear() === now.getFullYear()
43
+
44
+ if (isToday) {
45
+ return date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: true })
46
+ } else if (isThisYear) {
47
+ return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
48
+ } else {
49
+ return date.toLocaleDateString('en-US')
50
+ }
51
+ }
52
+
53
+ export function relativeTime(date: DateProps): string {
54
+ const now = moment()
55
+ const then = moment(date)
56
+ const duration = moment.duration(now.diff(then))
57
+
58
+ if (duration.asDays() < 1) {
59
+ return then.fromNow()
60
+ } else {
61
+ return formatTime(date)
62
+ }
63
+ }