@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.
- package/build/components/conversation/message.d.ts.map +1 -1
- package/build/components/conversation/message.js +7 -2
- package/build/components/conversation/message.js.map +1 -1
- package/build/components/conversation/message_reaction.d.ts +1 -1
- package/build/components/conversation/message_reaction.d.ts.map +1 -1
- package/build/components/conversation/message_reaction.js +1 -1
- package/build/components/conversation/message_reaction.js.map +1 -1
- package/build/components/conversations.d.ts.map +1 -1
- package/build/components/conversations.js +76 -30
- package/build/components/conversations.js.map +1 -1
- package/build/components/display/badge.d.ts +2 -6
- package/build/components/display/badge.d.ts.map +1 -1
- package/build/components/display/badge.js +1 -5
- package/build/components/display/badge.js.map +1 -1
- package/build/components/display/tabs.d.ts +17 -0
- package/build/components/display/tabs.d.ts.map +1 -0
- package/build/components/display/tabs.js +97 -0
- package/build/components/display/tabs.js.map +1 -0
- package/build/components/index.d.ts +1 -1
- package/build/components/index.d.ts.map +1 -1
- package/build/components/index.js +1 -1
- package/build/components/index.js.map +1 -1
- package/build/components/{error_boundary.d.ts → page/error_boundary.d.ts} +6 -4
- package/build/components/page/error_boundary.d.ts.map +1 -0
- package/build/components/page/error_boundary.js +115 -0
- package/build/components/page/error_boundary.js.map +1 -0
- package/build/components/page/loading.d.ts +3 -0
- package/build/components/page/loading.d.ts.map +1 -0
- package/build/components/page/loading.js +24 -0
- package/build/components/page/loading.js.map +1 -0
- package/build/contexts/api_provider.js +2 -2
- package/build/contexts/api_provider.js.map +1 -1
- package/build/hooks/use_conversation_jolt_events.d.ts +2 -0
- package/build/hooks/use_conversation_jolt_events.d.ts.map +1 -0
- package/build/hooks/use_conversation_jolt_events.js +47 -0
- package/build/hooks/use_conversation_jolt_events.js.map +1 -0
- package/build/hooks/use_conversation_messages.d.ts +2 -18
- package/build/hooks/use_conversation_messages.d.ts.map +1 -1
- package/build/hooks/use_conversation_messages.js +2 -2
- package/build/hooks/use_conversation_messages.js.map +1 -1
- package/build/hooks/use_conversations.d.ts +37 -0
- package/build/hooks/use_conversations.d.ts.map +1 -0
- package/build/hooks/use_conversations.js +48 -0
- package/build/hooks/use_conversations.js.map +1 -0
- package/build/hooks/use_jolt.d.ts +9 -0
- package/build/hooks/use_jolt.d.ts.map +1 -0
- package/build/hooks/use_jolt.js +71 -0
- package/build/hooks/use_jolt.js.map +1 -0
- package/build/hooks/use_suspense_api.d.ts +7 -2
- package/build/hooks/use_suspense_api.d.ts.map +1 -1
- package/build/hooks/use_suspense_api.js +7 -2
- package/build/hooks/use_suspense_api.js.map +1 -1
- package/build/navigation/index.d.ts +11 -2
- package/build/navigation/index.d.ts.map +1 -1
- package/build/navigation/index.js +14 -6
- package/build/navigation/index.js.map +1 -1
- package/build/navigation/screenLayout.d.ts.map +1 -1
- package/build/navigation/screenLayout.js +5 -8
- package/build/navigation/screenLayout.js.map +1 -1
- package/build/screens/message_actions_screen.d.ts +1 -1
- package/build/screens/message_actions_screen.d.ts.map +1 -1
- package/build/screens/message_actions_screen.js +1 -1
- package/build/screens/message_actions_screen.js.map +1 -1
- package/build/screens/reactions_screen.d.ts +11 -0
- package/build/screens/reactions_screen.d.ts.map +1 -0
- package/build/screens/reactions_screen.js +83 -0
- package/build/screens/reactions_screen.js.map +1 -0
- package/build/types/resources/app_name.d.ts +2 -0
- package/build/types/resources/app_name.d.ts.map +1 -0
- package/build/types/resources/app_name.js +2 -0
- package/build/types/resources/app_name.js.map +1 -0
- package/build/types/resources/conversation.d.ts +18 -10
- package/build/types/resources/conversation.d.ts.map +1 -1
- package/build/types/resources/conversation.js.map +1 -1
- package/build/types/resources/conversation_badge.d.ts +12 -0
- package/build/types/resources/conversation_badge.d.ts.map +1 -0
- package/build/types/resources/conversation_badge.js +2 -0
- package/build/types/resources/conversation_badge.js.map +1 -0
- package/build/types/resources/group_resource.d.ts +12 -0
- package/build/types/resources/group_resource.d.ts.map +1 -0
- package/build/types/resources/group_resource.js +2 -0
- package/build/types/resources/group_resource.js.map +1 -0
- package/build/types/resources/index.d.ts +2 -1
- package/build/types/resources/index.d.ts.map +1 -1
- package/build/types/resources/index.js +2 -1
- package/build/types/resources/index.js.map +1 -1
- package/build/types/resources/member.d.ts +23 -0
- package/build/types/resources/member.d.ts.map +1 -0
- package/build/types/resources/member.js +2 -0
- package/build/types/resources/member.js.map +1 -0
- package/build/types/resources/member_ability.d.ts +6 -0
- package/build/types/resources/member_ability.d.ts.map +1 -0
- package/build/types/resources/member_ability.js +2 -0
- package/build/types/resources/member_ability.js.map +1 -0
- package/build/types/resources/reaction.d.ts +1 -1
- package/build/types/resources/reaction.js.map +1 -1
- package/build/utils/cache/page_mutations.d.ts +19 -2
- package/build/utils/cache/page_mutations.d.ts.map +1 -1
- package/build/utils/cache/page_mutations.js +21 -7
- package/build/utils/cache/page_mutations.js.map +1 -1
- package/build/utils/date.d.ts +4 -0
- package/build/utils/date.d.ts.map +1 -0
- package/build/utils/date.js +23 -0
- package/build/utils/date.js.map +1 -0
- package/package.json +7 -3
- package/src/__tests__/utils/cache/page_mutations.ts +7 -46
- package/src/components/conversation/message.tsx +8 -3
- package/src/components/conversation/message_reaction.tsx +6 -2
- package/src/components/conversations.tsx +95 -32
- package/src/components/display/badge.tsx +3 -8
- package/src/components/display/tabs.tsx +142 -0
- package/src/components/index.tsx +1 -1
- package/src/components/page/error_boundary.tsx +135 -0
- package/src/components/page/loading.tsx +28 -0
- package/src/contexts/api_provider.tsx +3 -3
- package/src/hooks/use_conversation_jolt_events.ts +67 -0
- package/src/hooks/use_conversation_messages.ts +6 -2
- package/src/hooks/use_conversations.ts +53 -0
- package/src/hooks/use_jolt.ts +101 -0
- package/src/hooks/use_suspense_api.ts +10 -3
- package/src/navigation/index.tsx +23 -7
- package/src/navigation/screenLayout.tsx +5 -10
- package/src/screens/message_actions_screen.tsx +1 -1
- package/src/screens/reactions_screen.tsx +131 -0
- package/src/types/resources/app_name.ts +1 -0
- package/src/types/resources/conversation.ts +18 -10
- package/src/types/resources/conversation_badge.ts +10 -0
- package/src/types/resources/group_resource.ts +10 -0
- package/src/types/resources/index.ts +2 -1
- package/src/types/resources/member.ts +24 -0
- package/src/types/resources/member_ability.ts +5 -0
- package/src/types/resources/reaction.ts +1 -1
- package/src/utils/cache/page_mutations.ts +32 -9
- package/src/utils/date.ts +25 -0
- package/build/components/error_boundary.d.ts.map +0 -1
- package/build/components/error_boundary.js +0 -24
- package/build/components/error_boundary.js.map +0 -1
- 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
|
+
}
|
package/src/components/index.tsx
CHANGED
|
@@ -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
|
|
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 {
|
|
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
|
|
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 =
|
|
13
|
+
type Resource = ApiResource<T>
|
|
14
14
|
|
|
15
15
|
const { data, ...query } = useSuspenseQuery<Resource, Response>({
|
|
16
|
-
queryKey:
|
|
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:
|
|
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
|
+
]
|
package/src/navigation/index.tsx
CHANGED
|
@@ -1,30 +1,42 @@
|
|
|
1
1
|
import React from 'react'
|
|
2
2
|
import { StaticParamList } from '@react-navigation/native'
|
|
3
|
-
import {
|
|
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 {
|
|
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
|
-
|
|
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:
|
|
50
|
+
options: MessageActionsScreenOptions,
|
|
51
|
+
},
|
|
52
|
+
Reactions: {
|
|
53
|
+
screen: ReactionsScreen,
|
|
54
|
+
options: ReactionsScreenOptions,
|
|
39
55
|
},
|
|
40
56
|
NotFound: {
|
|
41
57
|
screen: NotFound,
|