@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.
- package/build/components/conversation/attachments/expanded_link.js +1 -0
- package/build/components/conversation/attachments/expanded_link.js.map +1 -1
- package/build/components/conversation/attachments/image_attachment.js +1 -0
- package/build/components/conversation/attachments/image_attachment.js.map +1 -1
- package/build/components/conversation/message.d.ts +1 -0
- package/build/components/conversation/message.d.ts.map +1 -1
- package/build/components/conversation/message.js +18 -3
- package/build/components/conversation/message.js.map +1 -1
- package/build/components/conversation/message_reaction.d.ts +2 -2
- package/build/components/conversation/message_reaction.d.ts.map +1 -1
- package/build/components/conversation/message_reaction.js +2 -2
- package/build/components/conversation/message_reaction.js.map +1 -1
- package/build/components/conversation/message_read_receipts.d.ts +9 -0
- package/build/components/conversation/message_read_receipts.d.ts.map +1 -0
- package/build/components/conversation/message_read_receipts.js +33 -0
- package/build/components/conversation/message_read_receipts.js.map +1 -0
- package/build/hooks/use_conversation.d.ts +1 -0
- package/build/hooks/use_conversation.d.ts.map +1 -1
- package/build/hooks/use_conversation.js +4 -1
- package/build/hooks/use_conversation.js.map +1 -1
- package/build/hooks/use_conversation_jolt_events.d.ts +6 -0
- package/build/hooks/use_conversation_jolt_events.d.ts.map +1 -0
- package/build/hooks/use_conversation_jolt_events.js +42 -0
- package/build/hooks/use_conversation_jolt_events.js.map +1 -0
- package/build/hooks/use_live_relative_time.d.ts +3 -0
- package/build/hooks/use_live_relative_time.d.ts.map +1 -0
- package/build/hooks/use_live_relative_time.js +31 -0
- package/build/hooks/use_live_relative_time.js.map +1 -0
- package/build/hooks/use_mark_latest_message_read.d.ts +8 -0
- package/build/hooks/use_mark_latest_message_read.d.ts.map +1 -0
- package/build/hooks/use_mark_latest_message_read.js +29 -0
- package/build/hooks/use_mark_latest_message_read.js.map +1 -0
- package/build/navigation/index.d.ts +1 -0
- package/build/navigation/index.d.ts.map +1 -1
- package/build/navigation/index.js +1 -0
- package/build/navigation/index.js.map +1 -1
- package/build/screens/conversation_screen.d.ts.map +1 -1
- package/build/screens/conversation_screen.js +22 -4
- package/build/screens/conversation_screen.js.map +1 -1
- package/build/types/resources/conversation.d.ts +4 -0
- package/build/types/resources/conversation.d.ts.map +1 -1
- package/build/types/resources/conversation.js.map +1 -1
- package/build/utils/date.d.ts +4 -3
- package/build/utils/date.d.ts.map +1 -1
- package/build/utils/date.js +27 -2
- package/build/utils/date.js.map +1 -1
- package/package.json +2 -2
- package/src/components/conversation/attachments/expanded_link.tsx +1 -0
- package/src/components/conversation/attachments/image_attachment.tsx +1 -0
- package/src/components/conversation/message.tsx +26 -2
- package/src/components/conversation/message_reaction.tsx +3 -3
- package/src/components/conversation/message_read_receipts.tsx +45 -0
- package/src/hooks/use_conversation.ts +4 -1
- package/src/hooks/use_conversation_jolt_events.ts +52 -0
- package/src/hooks/use_live_relative_time.ts +38 -0
- package/src/hooks/use_mark_latest_message_read.ts +41 -0
- package/src/navigation/index.tsx +7 -0
- package/src/screens/conversation_screen.tsx +24 -4
- package/src/types/datetime-fmt.d.ts +20 -0
- package/src/types/resources/conversation.ts +4 -0
- 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
|
+
}
|
package/src/navigation/index.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|