@planningcenter/chat-react-native 2.0.1-rc.0 → 2.1.0-rc.1

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 (138) hide show
  1. package/build/components/conversation/message.d.ts.map +1 -1
  2. package/build/components/conversation/message.js +7 -2
  3. package/build/components/conversation/message.js.map +1 -1
  4. package/build/components/conversation/message_reaction.d.ts +1 -1
  5. package/build/components/conversation/message_reaction.d.ts.map +1 -1
  6. package/build/components/conversation/message_reaction.js +1 -1
  7. package/build/components/conversation/message_reaction.js.map +1 -1
  8. package/build/components/conversations.d.ts.map +1 -1
  9. package/build/components/conversations.js +76 -30
  10. package/build/components/conversations.js.map +1 -1
  11. package/build/components/display/badge.d.ts +2 -6
  12. package/build/components/display/badge.d.ts.map +1 -1
  13. package/build/components/display/badge.js +1 -5
  14. package/build/components/display/badge.js.map +1 -1
  15. package/build/components/display/tabs.d.ts +17 -0
  16. package/build/components/display/tabs.d.ts.map +1 -0
  17. package/build/components/display/tabs.js +97 -0
  18. package/build/components/display/tabs.js.map +1 -0
  19. package/build/components/index.d.ts +1 -1
  20. package/build/components/index.d.ts.map +1 -1
  21. package/build/components/index.js +1 -1
  22. package/build/components/index.js.map +1 -1
  23. package/build/components/{error_boundary.d.ts → page/error_boundary.d.ts} +6 -4
  24. package/build/components/page/error_boundary.d.ts.map +1 -0
  25. package/build/components/page/error_boundary.js +115 -0
  26. package/build/components/page/error_boundary.js.map +1 -0
  27. package/build/components/page/loading.d.ts +3 -0
  28. package/build/components/page/loading.d.ts.map +1 -0
  29. package/build/components/page/loading.js +24 -0
  30. package/build/components/page/loading.js.map +1 -0
  31. package/build/contexts/api_provider.js +2 -2
  32. package/build/contexts/api_provider.js.map +1 -1
  33. package/build/hooks/use_conversation_jolt_events.d.ts +2 -0
  34. package/build/hooks/use_conversation_jolt_events.d.ts.map +1 -0
  35. package/build/hooks/use_conversation_jolt_events.js +47 -0
  36. package/build/hooks/use_conversation_jolt_events.js.map +1 -0
  37. package/build/hooks/use_conversation_messages.d.ts +2 -18
  38. package/build/hooks/use_conversation_messages.d.ts.map +1 -1
  39. package/build/hooks/use_conversation_messages.js +2 -2
  40. package/build/hooks/use_conversation_messages.js.map +1 -1
  41. package/build/hooks/use_conversations.d.ts +37 -0
  42. package/build/hooks/use_conversations.d.ts.map +1 -0
  43. package/build/hooks/use_conversations.js +48 -0
  44. package/build/hooks/use_conversations.js.map +1 -0
  45. package/build/hooks/use_jolt.d.ts +9 -0
  46. package/build/hooks/use_jolt.d.ts.map +1 -0
  47. package/build/hooks/use_jolt.js +71 -0
  48. package/build/hooks/use_jolt.js.map +1 -0
  49. package/build/hooks/use_suspense_api.d.ts +7 -2
  50. package/build/hooks/use_suspense_api.d.ts.map +1 -1
  51. package/build/hooks/use_suspense_api.js +7 -2
  52. package/build/hooks/use_suspense_api.js.map +1 -1
  53. package/build/navigation/index.d.ts +11 -2
  54. package/build/navigation/index.d.ts.map +1 -1
  55. package/build/navigation/index.js +14 -6
  56. package/build/navigation/index.js.map +1 -1
  57. package/build/navigation/screenLayout.d.ts.map +1 -1
  58. package/build/navigation/screenLayout.js +5 -8
  59. package/build/navigation/screenLayout.js.map +1 -1
  60. package/build/screens/message_actions_screen.d.ts +1 -1
  61. package/build/screens/message_actions_screen.d.ts.map +1 -1
  62. package/build/screens/message_actions_screen.js +1 -1
  63. package/build/screens/message_actions_screen.js.map +1 -1
  64. package/build/screens/reactions_screen.d.ts +11 -0
  65. package/build/screens/reactions_screen.d.ts.map +1 -0
  66. package/build/screens/reactions_screen.js +83 -0
  67. package/build/screens/reactions_screen.js.map +1 -0
  68. package/build/types/resources/app_name.d.ts +2 -0
  69. package/build/types/resources/app_name.d.ts.map +1 -0
  70. package/build/types/resources/app_name.js +2 -0
  71. package/build/types/resources/app_name.js.map +1 -0
  72. package/build/types/resources/conversation.d.ts +18 -10
  73. package/build/types/resources/conversation.d.ts.map +1 -1
  74. package/build/types/resources/conversation.js.map +1 -1
  75. package/build/types/resources/conversation_badge.d.ts +12 -0
  76. package/build/types/resources/conversation_badge.d.ts.map +1 -0
  77. package/build/types/resources/conversation_badge.js +2 -0
  78. package/build/types/resources/conversation_badge.js.map +1 -0
  79. package/build/types/resources/group_resource.d.ts +12 -0
  80. package/build/types/resources/group_resource.d.ts.map +1 -0
  81. package/build/types/resources/group_resource.js +2 -0
  82. package/build/types/resources/group_resource.js.map +1 -0
  83. package/build/types/resources/index.d.ts +2 -1
  84. package/build/types/resources/index.d.ts.map +1 -1
  85. package/build/types/resources/index.js +2 -1
  86. package/build/types/resources/index.js.map +1 -1
  87. package/build/types/resources/member.d.ts +23 -0
  88. package/build/types/resources/member.d.ts.map +1 -0
  89. package/build/types/resources/member.js +2 -0
  90. package/build/types/resources/member.js.map +1 -0
  91. package/build/types/resources/member_ability.d.ts +6 -0
  92. package/build/types/resources/member_ability.d.ts.map +1 -0
  93. package/build/types/resources/member_ability.js +2 -0
  94. package/build/types/resources/member_ability.js.map +1 -0
  95. package/build/types/resources/reaction.d.ts +1 -1
  96. package/build/types/resources/reaction.js.map +1 -1
  97. package/build/utils/cache/page_mutations.d.ts +19 -2
  98. package/build/utils/cache/page_mutations.d.ts.map +1 -1
  99. package/build/utils/cache/page_mutations.js +21 -7
  100. package/build/utils/cache/page_mutations.js.map +1 -1
  101. package/build/utils/date.d.ts +4 -0
  102. package/build/utils/date.d.ts.map +1 -0
  103. package/build/utils/date.js +23 -0
  104. package/build/utils/date.js.map +1 -0
  105. package/package.json +7 -3
  106. package/src/__tests__/utils/cache/page_mutations.ts +7 -46
  107. package/src/components/conversation/message.tsx +8 -3
  108. package/src/components/conversation/message_reaction.tsx +6 -2
  109. package/src/components/conversations.tsx +95 -32
  110. package/src/components/display/badge.tsx +3 -8
  111. package/src/components/display/tabs.tsx +142 -0
  112. package/src/components/index.tsx +1 -1
  113. package/src/components/page/error_boundary.tsx +135 -0
  114. package/src/components/page/loading.tsx +28 -0
  115. package/src/contexts/api_provider.tsx +3 -3
  116. package/src/hooks/use_conversation_jolt_events.ts +67 -0
  117. package/src/hooks/use_conversation_messages.ts +6 -2
  118. package/src/hooks/use_conversations.ts +53 -0
  119. package/src/hooks/use_jolt.ts +101 -0
  120. package/src/hooks/use_suspense_api.ts +10 -3
  121. package/src/navigation/index.tsx +23 -7
  122. package/src/navigation/screenLayout.tsx +5 -10
  123. package/src/screens/message_actions_screen.tsx +1 -1
  124. package/src/screens/reactions_screen.tsx +131 -0
  125. package/src/types/resources/app_name.ts +1 -0
  126. package/src/types/resources/conversation.ts +18 -10
  127. package/src/types/resources/conversation_badge.ts +10 -0
  128. package/src/types/resources/group_resource.ts +10 -0
  129. package/src/types/resources/index.ts +2 -1
  130. package/src/types/resources/member.ts +24 -0
  131. package/src/types/resources/member_ability.ts +5 -0
  132. package/src/types/resources/reaction.ts +1 -1
  133. package/src/utils/cache/page_mutations.ts +32 -9
  134. package/src/utils/date.ts +25 -0
  135. package/build/components/error_boundary.d.ts.map +0 -1
  136. package/build/components/error_boundary.js +0 -24
  137. package/build/components/error_boundary.js.map +0 -1
  138. package/src/components/error_boundary.tsx +0 -27
@@ -0,0 +1,142 @@
1
+ import { PlatformPressable } from '@react-navigation/elements'
2
+ import React, { useEffect, useRef, useState } from 'react'
3
+ import { Animated, Easing, StyleSheet, View, ViewStyle } from 'react-native'
4
+ import { useTheme } from '../../hooks'
5
+
6
+ // =================================
7
+ // ====== Component ================
8
+ // =================================
9
+
10
+ interface TabsProps<ItemT> {
11
+ data: ArrayLike<ItemT> | null | undefined
12
+ activeTab?: ItemT
13
+ keyExtractor?: (_item: ItemT, _index?: number) => string
14
+ onTabPress?: (_item: ItemT) => void
15
+ renderItem: (_: { item: ItemT; index: number }) => React.ReactNode
16
+ style?: ViewStyle
17
+ contentContainerStyle?: ViewStyle
18
+ }
19
+
20
+ const defaultKeyExtractor = (item: any, index?: number) => {
21
+ if (typeof item === 'string') return item
22
+
23
+ return item.id || index?.toString() || item.toString()
24
+ }
25
+
26
+ export function Tabs<ItemT>({
27
+ activeTab = { id: '' } as ItemT,
28
+ contentContainerStyle,
29
+ data,
30
+ keyExtractor = defaultKeyExtractor,
31
+ onTabPress,
32
+ renderItem,
33
+ style,
34
+ }: TabsProps<ItemT>) {
35
+ const [tabDimSet, setTabDimensions] = useState<Set<{ index: number; width: number }>>(new Set())
36
+ const tabDimensions = Array.from(tabDimSet)
37
+ const [tabHeight, setTabHeight] = useState(0)
38
+ const styles = useStyles()
39
+ const opacity = useRef(new Animated.Value(0)).current
40
+ const tabCursorPosition = useRef(new Animated.Value(0)).current
41
+ const dataArray = Array.from(data || [])
42
+ const activeTabIndex = dataArray.findIndex(
43
+ (item, index) => keyExtractor(item, index) === keyExtractor(activeTab)
44
+ )
45
+ const gap = 8
46
+ const tabCursorSpacing = tabDimensions
47
+ .slice(0, activeTabIndex)
48
+ .reduce((acc, { width }) => acc + width + gap, 0)
49
+
50
+ Animated.timing(tabCursorPosition, {
51
+ toValue: tabCursorSpacing,
52
+ easing: Easing.inOut(Easing.ease),
53
+ duration: 100,
54
+ useNativeDriver: true,
55
+ }).start()
56
+
57
+ useEffect(() => {
58
+ if (activeTabIndex === -1) return
59
+
60
+ Animated.timing(opacity, {
61
+ toValue: 1,
62
+ easing: Easing.inOut(Easing.ease),
63
+ duration: 500,
64
+ useNativeDriver: true,
65
+ }).start()
66
+ }, [opacity, activeTabIndex])
67
+
68
+ return (
69
+ <View style={[styles.container, style]}>
70
+ <Animated.View style={[styles.contentContainer, contentContainerStyle]}>
71
+ <View
72
+ style={styles.tabsContainer}
73
+ onLayout={event => {
74
+ const { height } = event.nativeEvent.layout
75
+ setTabHeight(height)
76
+ }}
77
+ >
78
+ {dataArray.map((item, index) => {
79
+ return (
80
+ <PlatformPressable
81
+ key={index}
82
+ style={styles.tab}
83
+ onPress={() => {
84
+ onTabPress?.(item)
85
+ }}
86
+ >
87
+ <View
88
+ onLayout={event => {
89
+ const { width } = event.nativeEvent.layout
90
+ setTabDimensions(dimensions => dimensions.add({ index, width }))
91
+ }}
92
+ >
93
+ {renderItem({ item, index })}
94
+ </View>
95
+ </PlatformPressable>
96
+ )
97
+ })}
98
+ </View>
99
+ <Animated.View
100
+ style={[
101
+ styles.cursor,
102
+ {
103
+ opacity,
104
+ top: tabHeight - 5,
105
+ width: tabDimensions[activeTabIndex]?.width || 0,
106
+ transform: [{ translateX: tabCursorPosition }],
107
+ },
108
+ ]}
109
+ />
110
+ </Animated.View>
111
+ </View>
112
+ )
113
+ }
114
+
115
+ // =================================
116
+ // ====== Styles ===================
117
+ // =================================
118
+
119
+ const useStyles = () => {
120
+ const theme = useTheme()
121
+ return StyleSheet.create({
122
+ container: {
123
+ flexDirection: 'row',
124
+ justifyContent: 'center',
125
+ },
126
+ contentContainer: {},
127
+ cursor: {
128
+ borderBottomWidth: 3,
129
+ borderBottomColor: theme.colors.interaction,
130
+ height: 5,
131
+ flex: 1,
132
+ position: 'absolute',
133
+ zIndex: 5,
134
+ },
135
+ tab: {},
136
+ tabsContainer: {
137
+ flex: 1,
138
+ flexDirection: 'row',
139
+ gap: 8,
140
+ },
141
+ })
142
+ }
@@ -1,3 +1,3 @@
1
1
  export * from './conversations'
2
- export * from './error_boundary'
2
+ export * from './page/error_boundary'
3
3
  export * from './display'
@@ -0,0 +1,135 @@
1
+ import { useNavigation } from '@react-navigation/native'
2
+ import { useQueryErrorResetBoundary } from '@tanstack/react-query'
3
+ import React, { PropsWithChildren, useEffect, useMemo } from 'react'
4
+ import { StyleSheet, View } from 'react-native'
5
+ import { useSafeAreaInsets } from 'react-native-safe-area-context'
6
+ import { Button, Heading, Icon, Text } from '../display'
7
+ import { useTheme } from '../../hooks'
8
+
9
+ type ErrorBoundaryState = {
10
+ error: Response | Error | null
11
+ unsubscriber: () => void
12
+ }
13
+
14
+ class ErrorBoundary extends React.Component<PropsWithChildren<{ onReset?: () => void }>> {
15
+ state: ErrorBoundaryState = {
16
+ error: null,
17
+ unsubscriber: () => {},
18
+ }
19
+
20
+ componentDidCatch(error: any) {
21
+ this.handleError(error)
22
+ }
23
+
24
+ handleError(error: any) {
25
+ this.setState({ error })
26
+ }
27
+
28
+ handleReset = () => {
29
+ this.props.onReset?.()
30
+ this.setState({ error: null })
31
+ }
32
+
33
+ render() {
34
+ if (this.state.error) {
35
+ return <ErrorView error={this.state.error} onReset={this.handleReset} />
36
+ } else {
37
+ return this.props.children
38
+ }
39
+ }
40
+ }
41
+
42
+ function ErrorView({ error, onReset }: { error: Error | Response; onReset: () => void }) {
43
+ const { reset } = useQueryErrorResetBoundary()
44
+ useEffect(() => {
45
+ if (!reset) return
46
+
47
+ return () => {
48
+ onReset()
49
+ reset()
50
+ }
51
+ }, [reset, onReset])
52
+
53
+ if (error instanceof Response) {
54
+ return <ResponseErrorView error={error} onReset={onReset} />
55
+ }
56
+
57
+ return <ErrorContent heading={'Oops!'} body={'Something unexpected happened.'} />
58
+ }
59
+
60
+ function ResponseErrorView({ error: response }: { error: Response; onReset: () => void }) {
61
+ const { status } = response
62
+ const heading = useMemo(() => {
63
+ switch (status) {
64
+ case 403:
65
+ return 'Permission required'
66
+ case 404:
67
+ return 'Content not found'
68
+ default:
69
+ return 'Oops!'
70
+ }
71
+ }, [status])
72
+
73
+ const body = useMemo(() => {
74
+ switch (status) {
75
+ case 403:
76
+ return 'Contact your administrator for access.'
77
+ case 404:
78
+ return 'If you believe something should be here, please reach out to your administrator.'
79
+ default:
80
+ return 'Something unexpected happened.'
81
+ }
82
+ }, [status])
83
+
84
+ return <ErrorContent heading={heading} body={body} />
85
+ }
86
+
87
+ function ErrorContent({ heading, body }: { heading: string; body: string }) {
88
+ const styles = useStyles()
89
+ const navigation = useNavigation()
90
+
91
+ return (
92
+ <View style={styles.container}>
93
+ <Icon name="general.outlinedTextMessage" size={32} color={styles.icon.color} />
94
+ <View style={styles.information}>
95
+ <Heading variant="h3" style={styles.heading}>
96
+ {heading}
97
+ </Heading>
98
+ <Text style={styles.body}>{body}</Text>
99
+ </View>
100
+ <Button variant="outline" onPress={navigation.goBack} title="Go back" size="md" />
101
+ </View>
102
+ )
103
+ }
104
+
105
+ const useStyles = () => {
106
+ const theme = useTheme()
107
+ const { bottom } = useSafeAreaInsets()
108
+ return StyleSheet.create({
109
+ container: {
110
+ flex: 1,
111
+ justifyContent: 'center',
112
+ alignItems: 'center',
113
+ gap: 24,
114
+ paddingHorizontal: 16,
115
+ paddingBottom: bottom,
116
+ },
117
+ information: {
118
+ alignItems: 'center',
119
+ gap: 8,
120
+ },
121
+ heading: {
122
+ textAlign: 'center',
123
+ lineHeight: 20,
124
+ },
125
+ body: {
126
+ textAlign: 'center',
127
+ lineHeight: 20,
128
+ },
129
+ icon: {
130
+ color: theme.colors.iconColorDefaultDisabled,
131
+ },
132
+ })
133
+ }
134
+
135
+ export default ErrorBoundary
@@ -0,0 +1,28 @@
1
+ import { useTheme } from '@react-navigation/native'
2
+ import React from 'react'
3
+ import { StyleSheet, View } from 'react-native'
4
+ import { Spinner } from '../display'
5
+
6
+ export function DefaultLoading() {
7
+ const styles = useStyles()
8
+
9
+ return (
10
+ <View style={styles.container}>
11
+ <Spinner size={48} />
12
+ </View>
13
+ )
14
+ }
15
+
16
+ const useStyles = () => {
17
+ const theme = useTheme()
18
+ return StyleSheet.create({
19
+ container: {
20
+ flex: 1,
21
+ justifyContent: 'center',
22
+ alignItems: 'center',
23
+ },
24
+ loading: {
25
+ color: theme.colors.text,
26
+ },
27
+ })
28
+ }
@@ -2,8 +2,8 @@ import { QueryClient, QueryClientProvider, QueryKey } from '@tanstack/react-quer
2
2
  import React, { useContext, useEffect, useRef } from 'react'
3
3
  import { ViewProps } from 'react-native'
4
4
  import { Client } from '../utils'
5
- import { GetRequest } from '../utils/client/types'
6
5
  import { ChatContext, ChatContextValue } from './chat_context'
6
+ import { RequestQueryKey } from '../hooks'
7
7
 
8
8
  let apiClient: Client | undefined
9
9
 
@@ -12,9 +12,9 @@ const defaultQueryFn = ({ queryKey }: { queryKey: QueryKey }) => {
12
12
  throw new Error('No token present')
13
13
  }
14
14
 
15
- const data = queryKey[0] as GetRequest
15
+ const [url, data, headers] = queryKey as RequestQueryKey
16
16
 
17
- return apiClient.get(data)
17
+ return apiClient.get({ url, data, headers })
18
18
  }
19
19
 
20
20
  export const queryClient = new QueryClient({
@@ -0,0 +1,67 @@
1
+ import { CustomMessage } from '@planningcenter/jolt-client/dist/types/JoltConnection'
2
+ import { InfiniteData, useQueryClient } from '@tanstack/react-query'
3
+ import { useContext } from 'react'
4
+ import { ChatContext } from '../contexts'
5
+ import { ApiCollection, ApiResource, ConversationResource } from '../types'
6
+ import { deleteRecordInPagesData, updateRecordInPagesData } from '../utils'
7
+ import { getConversationsRequestArgs } from './use_conversations'
8
+ import { useCurrentPerson } from './use_current_person'
9
+ import { useJoltChannel, useJoltEvent } from './use_jolt'
10
+ import { getRequestQueryKey } from './use_suspense_api'
11
+
12
+ type QueryData = InfiniteData<ApiCollection<ConversationResource>>
13
+ interface JoltConversationsEvent extends CustomMessage {
14
+ data: {
15
+ data: ConversationResource
16
+ }
17
+ }
18
+
19
+ export function useConversationsJoltEvents() {
20
+ const { client } = useContext(ChatContext)
21
+ const queryClient = useQueryClient()
22
+ const currentPerson = useCurrentPerson()
23
+ const joltChannel = useJoltChannel(`chat.people.${currentPerson.id}`)
24
+
25
+ const conversationsRequestArgs = getConversationsRequestArgs()
26
+ const conversationQueryKey = getRequestQueryKey(conversationsRequestArgs)
27
+
28
+ const fetchConversation = async ({ id }: ConversationResource) => {
29
+ const { data: argsData } = conversationsRequestArgs
30
+ const { data } = await client.get<ApiResource<ConversationResource>>({
31
+ url: `/me/conversations/${id}`,
32
+ data: {
33
+ fields: argsData.fields,
34
+ include: argsData.include,
35
+ },
36
+ })
37
+
38
+ return data
39
+ }
40
+
41
+ const handleConversationUpdateOrCreate = async (e: JoltConversationsEvent) => {
42
+ const conversation = await fetchConversation(e.data.data).catch(c => c)
43
+
44
+ queryClient.setQueryData<QueryData>(conversationQueryKey, prev =>
45
+ updateRecordInPagesData({
46
+ data: prev,
47
+ record: conversation,
48
+ processRecord: (record, current) => {
49
+ return { ...current, ...record }
50
+ },
51
+ })
52
+ )
53
+ }
54
+
55
+ const handleConversationDestroy = (e: JoltConversationsEvent) => {
56
+ queryClient.setQueryData<QueryData>(conversationQueryKey, prev =>
57
+ deleteRecordInPagesData({
58
+ data: prev,
59
+ record: e.data.data,
60
+ })
61
+ )
62
+ }
63
+
64
+ useJoltEvent(joltChannel, 'conversation.updated', handleConversationUpdateOrCreate)
65
+ useJoltEvent(joltChannel, 'conversation.created', handleConversationUpdateOrCreate)
66
+ useJoltEvent(joltChannel, 'conversation.destroyed', handleConversationDestroy)
67
+ }
@@ -1,5 +1,9 @@
1
1
  import { MessageResource } from '../types'
2
- import { SuspensePaginatorOptions, useSuspensePaginator } from './use_suspense_api'
2
+ import {
3
+ getRequestQueryKey,
4
+ SuspensePaginatorOptions,
5
+ useSuspensePaginator,
6
+ } from './use_suspense_api'
3
7
 
4
8
  export const useConversationMessages = (
5
9
  { conversation_id }: { conversation_id: string },
@@ -39,5 +43,5 @@ export const getMessagesRequestArgs = ({ conversation_id }: { conversation_id: s
39
43
 
40
44
  export const getMessagesQueryKey = ({ conversation_id }: { conversation_id: string }) => {
41
45
  const requestArgs = getMessagesRequestArgs({ conversation_id })
42
- return [requestArgs.url, requestArgs.data]
46
+ return getRequestQueryKey(requestArgs)
43
47
  }
@@ -0,0 +1,53 @@
1
+ import { useMemo } from 'react'
2
+ import { ConversationResource } from '../types'
3
+ import { GetRequest } from '../utils/client/types'
4
+ import { useSuspensePaginator } from './use_suspense_api'
5
+
6
+ export const getConversationsRequestArgs = (): GetRequest => ({
7
+ url: '/me/conversations',
8
+ data: {
9
+ perPage: 20,
10
+ order: '-last_message',
11
+ fields: {
12
+ Conversation: [
13
+ 'created_at',
14
+ 'badges',
15
+ 'groups',
16
+ 'last_message_author_id',
17
+ 'last_message_author_name',
18
+ 'last_message_created_at',
19
+ 'last_message_text_preview',
20
+ 'preview_avatar_urls',
21
+ 'member_ability',
22
+ 'muted',
23
+ 'replies_disabled',
24
+ 'title',
25
+ 'unread_count',
26
+ 'updated_at',
27
+ ],
28
+ ConversationBadge: ['app_name', 'pco_resource_type', 'text'],
29
+ },
30
+ include: ['badges'],
31
+ },
32
+ })
33
+
34
+ export function useConversations() {
35
+ const requestArgs = getConversationsRequestArgs()
36
+ const { data, ...rest } = useSuspensePaginator<ConversationResource>(requestArgs)
37
+
38
+ const conversations = useMemo(
39
+ () =>
40
+ data.sort((a, b) => {
41
+ const dateA = a.lastMessageCreatedAt || a.createdAt
42
+ const dateB = b.lastMessageCreatedAt || b.createdAt
43
+ if (a.lastMessageCreatedAt && !b.lastMessageCreatedAt) return 1
44
+ if (!a.lastMessageCreatedAt && b.lastMessageCreatedAt) return -1
45
+ if (dateB > dateA) return 1
46
+ if (dateB < dateA) return -1
47
+ return 0
48
+ }),
49
+ [data]
50
+ )
51
+
52
+ return { conversations, ...rest }
53
+ }
@@ -0,0 +1,101 @@
1
+ import JoltClient from '@planningcenter/jolt-client'
2
+ import { useQuery, useSuspenseQuery } from '@tanstack/react-query'
3
+ import { useContext, useEffect, useState } from 'react'
4
+ import { ChatContext } from '../contexts'
5
+ import { ApiResource } from '../types'
6
+ import {
7
+ FetchSubscribeToken,
8
+ JoltSubscription,
9
+ } from '@planningcenter/jolt-client/dist/types/JoltSubscription'
10
+ import { CustomMessage } from '@planningcenter/jolt-client/dist/types/JoltConnection'
11
+
12
+ interface JoltResponse {
13
+ type: 'JoltToken'
14
+ id: string
15
+ wssUrl: string
16
+ }
17
+
18
+ export const useJoltClient = (): JoltClient | undefined => {
19
+ const { client } = useContext(ChatContext)
20
+ const { data: joltToken } = useSuspenseQuery<ApiResource<JoltResponse>>({
21
+ refetchOnMount: false,
22
+ queryKey: ['jolt-token'],
23
+ queryFn: () => {
24
+ return client.post({
25
+ url: '/me/jolt_authorize',
26
+ data: {
27
+ data: {
28
+ type: 'JoltToken',
29
+ attributes: {},
30
+ },
31
+ },
32
+ })
33
+ },
34
+ })
35
+
36
+ const fetchAuthTokenFn = async () => {
37
+ return joltToken.data.id || ''
38
+ }
39
+
40
+ const fetchSubscribeTokenFn: FetchSubscribeToken = (channel: string, connectionId: string) => {
41
+ return client
42
+ .post({
43
+ url: '/me/jolt_subscribe',
44
+ data: {
45
+ data: {
46
+ type: 'JoltSubscribeToken',
47
+ attributes: { channel, cid: connectionId },
48
+ },
49
+ },
50
+ })
51
+ .then((res: ApiResource<JoltResponse>) => res.data.id)
52
+ }
53
+
54
+ const { data: joltClient } = useQuery({
55
+ refetchOnMount: false,
56
+ refetchOnWindowFocus: false,
57
+ refetchOnReconnect: false,
58
+ enabled: Boolean(joltToken),
59
+ queryKey: ['jolt-client'],
60
+ queryFn: async () => {
61
+ if (!joltToken) return undefined
62
+
63
+ return new JoltClient(
64
+ joltToken?.data.wssUrl,
65
+ {
66
+ fetchAuthTokenFn,
67
+ fetchSubscribeTokenFn,
68
+ },
69
+ { logToConsole: true }
70
+ )
71
+ },
72
+ })
73
+
74
+ return joltClient
75
+ }
76
+
77
+ export function useJoltChannel(channelName: string) {
78
+ const [joltChannel, setJoltChannel] = useState<JoltSubscription>()
79
+ const jolt = useJoltClient()
80
+
81
+ useEffect(() => {
82
+ setJoltChannel(jolt?.subscribe(channelName))
83
+ return () => jolt?.unsubscribe(channelName)
84
+ }, [channelName, jolt])
85
+
86
+ return joltChannel
87
+ }
88
+
89
+ type UserCallbackFn<T> = (_event: T) => void
90
+
91
+ export function useJoltEvent<T extends CustomMessage>(
92
+ channel: JoltSubscription | undefined,
93
+ eventName: string,
94
+ callback: UserCallbackFn<T>
95
+ ) {
96
+ useEffect(() => {
97
+ if (!channel) return () => {}
98
+
99
+ return channel.bind(eventName, e => callback(e as T))
100
+ }, [channel, eventName, callback])
101
+ }
@@ -10,10 +10,10 @@ import { ApiCollection, ApiResource, ResourceObject } from '../types'
10
10
  import { GetRequest, RequestData } from '../utils/client/types'
11
11
 
12
12
  export const useSuspenseGet = <T extends ResourceObject | ResourceObject[]>(args: GetRequest) => {
13
- type Resource = T extends ResourceObject ? ApiResource<T> : ApiCollection<T>
13
+ type Resource = ApiResource<T>
14
14
 
15
15
  const { data, ...query } = useSuspenseQuery<Resource, Response>({
16
- queryKey: [args],
16
+ queryKey: getRequestQueryKey(args),
17
17
  })
18
18
 
19
19
  return { ...data, ...query }
@@ -41,7 +41,7 @@ export const useSuspensePaginator = <T extends ResourceObject>(
41
41
  any,
42
42
  Partial<RequestData> | undefined
43
43
  >({
44
- queryKey: [args.url, args.data],
44
+ queryKey: getRequestQueryKey(args),
45
45
  queryFn: ({ pageParam }) => {
46
46
  const pageParmWhere = pageParam?.where || {}
47
47
  const argsWhere = args.data.where || {}
@@ -72,3 +72,10 @@ export const useSuspensePaginator = <T extends ResourceObject>(
72
72
 
73
73
  return { ...query, data }
74
74
  }
75
+
76
+ export type RequestQueryKey = [GetRequest['url'], GetRequest['data'], GetRequest['headers']]
77
+ export const getRequestQueryKey = (args: GetRequest): RequestQueryKey => [
78
+ args.url,
79
+ args.data,
80
+ args.headers,
81
+ ]
@@ -1,30 +1,42 @@
1
1
  import React from 'react'
2
2
  import { StaticParamList } from '@react-navigation/native'
3
- import { createNativeStackNavigator } from '@react-navigation/native-stack'
3
+ import {
4
+ createNativeStackNavigator,
5
+ NativeStackHeaderLeftProps,
6
+ NativeStackHeaderRightProps,
7
+ } from '@react-navigation/native-stack'
4
8
  import { NotFound } from '../screens/not_found'
5
9
  import { ScreenLayout } from './screenLayout'
6
10
  import { ConversationsScreen } from '../screens/conversations_screen'
7
11
  import { ConversationScreen } from '../screens/conversation_screen'
8
12
  import { HeaderBackButton, HeaderButton } from '@react-navigation/elements'
9
13
  import { Icon } from '../components'
10
- import { MessageActionsScreen, ReactScreenOptions } from '../screens/message_actions_screen'
14
+ import {
15
+ MessageActionsScreen,
16
+ MessageActionsScreenOptions,
17
+ } from '../screens/message_actions_screen'
18
+ import { ReactionsScreen, ReactionsScreenOptions } from '../screens/reactions_screen'
11
19
 
12
20
  export const ChatStack = createNativeStackNavigator({
21
+ screenOptions: {
22
+ headerBackButtonDisplayMode: 'minimal',
23
+ },
13
24
  screenLayout: ScreenLayout,
14
25
  screens: {
15
26
  Conversations: {
16
27
  screen: ConversationsScreen,
17
28
  options: ({ route, navigation }) => ({
18
29
  headerTitle: (route.params as { title?: string })?.title ?? 'Chat',
19
- headerLeft: () => (
30
+ headerLeft: ({ tintColor }: NativeStackHeaderLeftProps) => (
20
31
  <HeaderButton>
21
- <Icon name="general.threeReducingHorizontalBars" size={18} />
32
+ <Icon name="general.threeReducingHorizontalBars" size={18} color={tintColor} />
22
33
  </HeaderButton>
23
34
  ),
24
- headerRight: () => (
35
+ headerRight: (props: NativeStackHeaderRightProps) => (
25
36
  <HeaderBackButton
37
+ backImage={() => <Icon name="general.x" size={18} color={props.tintColor} />}
26
38
  onPress={navigation.goBack}
27
- backImage={() => <Icon name="general.x" size={18} />}
39
+ {...props}
28
40
  />
29
41
  ),
30
42
  }),
@@ -35,7 +47,11 @@ export const ChatStack = createNativeStackNavigator({
35
47
  MessageActions: {
36
48
  screen: MessageActionsScreen,
37
49
  // Something about sheetAllowedDetents declared inline breaks TS
38
- options: ReactScreenOptions,
50
+ options: MessageActionsScreenOptions,
51
+ },
52
+ Reactions: {
53
+ screen: ReactionsScreen,
54
+ options: ReactionsScreenOptions,
39
55
  },
40
56
  NotFound: {
41
57
  screen: NotFound,