@planningcenter/chat-react-native 1.4.0-rc.1 → 1.4.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/package.json +5 -4
- package/src/__tests__/index.tsx +5 -0
- package/src/__tests__/session.tsx +46 -0
- package/src/components/conversations.tsx +94 -0
- package/src/components/display/image.tsx +88 -0
- package/src/components/display/index.ts +2 -0
- package/src/components/display/spinner.tsx +97 -0
- package/src/components/error_boundary.tsx +27 -0
- package/src/contexts/api_provider.tsx +60 -0
- package/src/contexts/chat_context.tsx +29 -0
- package/src/hooks/index.ts +2 -0
- package/src/hooks/use_async_storage.ts +41 -0
- package/src/hooks/use_create_chat_theme.tsx +31 -0
- package/src/hooks/use_theme.tsx +9 -0
- package/src/index.tsx +17 -0
- package/src/types.d.ts +35 -0
- package/src/utils/session.ts +47 -0
- package/src/utils/theme.ts +73 -0
- package/src/vendor/tapestry/tapestry_alias_tokens_color_map.ts +82 -0
- package/src/vendor/tapestry/tokens.ts +97 -0
package/package.json
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@planningcenter/chat-react-native",
|
|
3
|
-
"version": "1.4.0
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"description": "",
|
|
5
5
|
"main": "build/index.js",
|
|
6
6
|
"types": "build/index.d.ts",
|
|
7
7
|
"files": [
|
|
8
|
-
"build"
|
|
8
|
+
"build",
|
|
9
|
+
"src"
|
|
9
10
|
],
|
|
10
11
|
"sideEffects": false,
|
|
11
12
|
"scripts": {
|
|
@@ -16,7 +17,7 @@
|
|
|
16
17
|
"prepublishOnly": "expo-module prepublishOnly"
|
|
17
18
|
},
|
|
18
19
|
"dependencies": {
|
|
19
|
-
"@planningcenter/chat-core": "^1.4.0
|
|
20
|
+
"@planningcenter/chat-core": "^1.4.0"
|
|
20
21
|
},
|
|
21
22
|
"peerDependencies": {
|
|
22
23
|
"@react-navigation/elements": "*",
|
|
@@ -37,5 +38,5 @@
|
|
|
37
38
|
"prettier": "^3.4.2",
|
|
38
39
|
"react-native": "0.74.5"
|
|
39
40
|
},
|
|
40
|
-
"gitHead": "
|
|
41
|
+
"gitHead": "6bcacc755d471a8f03b890abd733d5805f643aa4"
|
|
41
42
|
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { OAuthToken } from '../types'
|
|
2
|
+
import { Session } from '../utils/session'
|
|
3
|
+
|
|
4
|
+
describe('Session', () => {
|
|
5
|
+
describe('constructor', () => {
|
|
6
|
+
it('should track the environment', () => {
|
|
7
|
+
const session = new Session()
|
|
8
|
+
expect(session.env).toBe('development')
|
|
9
|
+
expect(session.isAuthenticated).toBe(false)
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
it('should track the token', () => {
|
|
13
|
+
const token: OAuthToken = {
|
|
14
|
+
access_token: 'access_token',
|
|
15
|
+
refresh_token: 'refresh_token',
|
|
16
|
+
token_type: undefined,
|
|
17
|
+
created_at: 0,
|
|
18
|
+
expires_in: undefined,
|
|
19
|
+
scope: '',
|
|
20
|
+
}
|
|
21
|
+
const session = new Session({ token })
|
|
22
|
+
expect(session.token).toEqual(token)
|
|
23
|
+
expect(session.isAuthenticated).toEqual(true)
|
|
24
|
+
})
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
describe('urls', () => {
|
|
28
|
+
it('should return the correct base url', () => {
|
|
29
|
+
const session = new Session({ env: 'production' })
|
|
30
|
+
expect(session.baseUrl).toBe('https://api.planningcenteronline.com')
|
|
31
|
+
session.env = 'development'
|
|
32
|
+
expect(session.baseUrl).toBe('http://api.pco.test')
|
|
33
|
+
session.env = 'staging'
|
|
34
|
+
expect(session.baseUrl).toBe('https://api-staging.planningcenteronline.com')
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('should return the correct upload url', () => {
|
|
38
|
+
const session = new Session({ env: 'production' })
|
|
39
|
+
expect(session.uploadUrl).toBe('https://upload.planningcenteronline.com/v2/files')
|
|
40
|
+
session.env = 'development'
|
|
41
|
+
expect(session.uploadUrl).toBe('https://upload.planningcenteronline.com/v2/files')
|
|
42
|
+
session.env = 'staging'
|
|
43
|
+
expect(session.uploadUrl).toBe('https://upload-staging.planningcenteronline.com/v2/files')
|
|
44
|
+
})
|
|
45
|
+
})
|
|
46
|
+
})
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { ConversationResource, JSONAPICollection } from '@planningcenter/chat-core'
|
|
2
|
+
import { useNavigation } from '@react-navigation/native'
|
|
3
|
+
import { QueryErrorResetBoundary, useSuspenseQuery } from '@tanstack/react-query'
|
|
4
|
+
import React, { Suspense, useContext } from 'react'
|
|
5
|
+
import { FlatList, Pressable, StyleSheet, Text, View } from 'react-native'
|
|
6
|
+
import { ChatContext } from '../contexts/chat_context'
|
|
7
|
+
import { useTheme } from '../hooks'
|
|
8
|
+
import { Image, Spinner } from './display'
|
|
9
|
+
import ErrorBoundary from './error_boundary'
|
|
10
|
+
|
|
11
|
+
type ConversationsResponse = JSONAPICollection<ConversationResource>
|
|
12
|
+
|
|
13
|
+
export function Conversations() {
|
|
14
|
+
const { token } = useContext(ChatContext)
|
|
15
|
+
|
|
16
|
+
if (!token) return null
|
|
17
|
+
|
|
18
|
+
return (
|
|
19
|
+
<QueryErrorResetBoundary>
|
|
20
|
+
{({ reset }) => (
|
|
21
|
+
<ErrorBoundary onReset={reset}>
|
|
22
|
+
<Suspense fallback={<Text>loading...</Text>}>
|
|
23
|
+
<Loaded />
|
|
24
|
+
</Suspense>
|
|
25
|
+
</ErrorBoundary>
|
|
26
|
+
)}
|
|
27
|
+
</QueryErrorResetBoundary>
|
|
28
|
+
)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const Loaded = () => {
|
|
32
|
+
const styles = useStyles()
|
|
33
|
+
const { data: conversations } = useSuspenseQuery<ConversationsResponse>({
|
|
34
|
+
queryKey: ['/chat/v2/me/conversations'],
|
|
35
|
+
})
|
|
36
|
+
const navigation = useNavigation()
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<FlatList
|
|
40
|
+
data={conversations?.data}
|
|
41
|
+
contentContainerStyle={styles.container}
|
|
42
|
+
style={styles.scrollView}
|
|
43
|
+
ListEmptyComponent={<Text>No conversations found</Text>}
|
|
44
|
+
ListHeaderComponent={
|
|
45
|
+
<View style={styles.column}>
|
|
46
|
+
<Text style={styles.foo}>Display Components</Text>
|
|
47
|
+
<View style={[styles.row, styles.spinnerContainer]}>
|
|
48
|
+
<Spinner size={24} />
|
|
49
|
+
</View>
|
|
50
|
+
<View style={styles.row}>
|
|
51
|
+
<Image source={{ uri: 'https://broken.url' }} style={styles.image} />
|
|
52
|
+
<Image
|
|
53
|
+
source={{
|
|
54
|
+
uri: 'https://picsum.photos/seed/picsum/200',
|
|
55
|
+
}}
|
|
56
|
+
style={styles.image}
|
|
57
|
+
/>
|
|
58
|
+
</View>
|
|
59
|
+
<Text style={styles.foo}>Conversations</Text>
|
|
60
|
+
</View>
|
|
61
|
+
}
|
|
62
|
+
renderItem={({ item }) => (
|
|
63
|
+
<Pressable onPress={() => navigation.navigate('Settings')}>
|
|
64
|
+
<Text style={styles.listItem}>{item.attributes.title}</Text>
|
|
65
|
+
</Pressable>
|
|
66
|
+
)}
|
|
67
|
+
/>
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const useStyles = () => {
|
|
72
|
+
const { colors } = useTheme()
|
|
73
|
+
|
|
74
|
+
return StyleSheet.create({
|
|
75
|
+
scrollView: { flex: 1, backgroundColor: colors.fillColorNeutral090 },
|
|
76
|
+
container: { gap: 8, padding: 16 },
|
|
77
|
+
foo: { fontSize: 24, color: colors.testColor },
|
|
78
|
+
listItem: { color: colors.fillColorNeutral020 },
|
|
79
|
+
row: {
|
|
80
|
+
gap: 16,
|
|
81
|
+
flexDirection: 'row',
|
|
82
|
+
alignItems: 'center',
|
|
83
|
+
justifyContent: 'center',
|
|
84
|
+
},
|
|
85
|
+
column: { gap: 16 },
|
|
86
|
+
spinnerContainer: {
|
|
87
|
+
height: 20,
|
|
88
|
+
},
|
|
89
|
+
image: {
|
|
90
|
+
width: 100,
|
|
91
|
+
height: 100,
|
|
92
|
+
},
|
|
93
|
+
})
|
|
94
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { noop } from 'lodash'
|
|
2
|
+
import React, { useState } from 'react'
|
|
3
|
+
import {
|
|
4
|
+
AnimatableNumericValue,
|
|
5
|
+
DimensionValue,
|
|
6
|
+
Image as ReactNativeImage,
|
|
7
|
+
ImageProps as RNImageProps,
|
|
8
|
+
StyleSheet,
|
|
9
|
+
View,
|
|
10
|
+
ViewStyle,
|
|
11
|
+
} from 'react-native'
|
|
12
|
+
import { useTheme } from '../../hooks'
|
|
13
|
+
import { Spinner } from './spinner'
|
|
14
|
+
|
|
15
|
+
interface ImageProps extends RNImageProps {
|
|
16
|
+
/**
|
|
17
|
+
* Should the image show the loading indicator by default.
|
|
18
|
+
*/
|
|
19
|
+
defaultLoading?: boolean
|
|
20
|
+
/**
|
|
21
|
+
* Size of the loading spinner.
|
|
22
|
+
*/
|
|
23
|
+
loaderSize?: number
|
|
24
|
+
/**
|
|
25
|
+
* Style object for the preload background.
|
|
26
|
+
*/
|
|
27
|
+
loadingBackgroundStyles?: ViewStyle
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function Image({
|
|
31
|
+
source,
|
|
32
|
+
onLoad = noop,
|
|
33
|
+
defaultLoading = true,
|
|
34
|
+
loaderSize = 24,
|
|
35
|
+
loadingBackgroundStyles,
|
|
36
|
+
style,
|
|
37
|
+
...restProps
|
|
38
|
+
}: ImageProps) {
|
|
39
|
+
const [loading, setLoading] = useState(defaultLoading)
|
|
40
|
+
|
|
41
|
+
const imageStyles = StyleSheet.flatten(style)
|
|
42
|
+
const { width = '100%', height = '100%', borderRadius = 0 } = imageStyles || {}
|
|
43
|
+
const styles = useStyles(width, height, borderRadius)
|
|
44
|
+
|
|
45
|
+
const handleOnLoad = (event: any) => {
|
|
46
|
+
setLoading(false)
|
|
47
|
+
onLoad?.(event)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<View>
|
|
52
|
+
<ReactNativeImage
|
|
53
|
+
style={[styles.image, imageStyles]}
|
|
54
|
+
onLoad={handleOnLoad}
|
|
55
|
+
source={source}
|
|
56
|
+
{...restProps}
|
|
57
|
+
/>
|
|
58
|
+
{loading && (
|
|
59
|
+
<View style={[styles.loadingBackground, loadingBackgroundStyles]}>
|
|
60
|
+
<Spinner size={loaderSize} />
|
|
61
|
+
</View>
|
|
62
|
+
)}
|
|
63
|
+
</View>
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const useStyles = (
|
|
68
|
+
width: DimensionValue,
|
|
69
|
+
height: DimensionValue,
|
|
70
|
+
borderRadius: AnimatableNumericValue
|
|
71
|
+
) => {
|
|
72
|
+
const { colors } = useTheme()
|
|
73
|
+
|
|
74
|
+
return StyleSheet.create({
|
|
75
|
+
loadingBackground: {
|
|
76
|
+
position: 'absolute',
|
|
77
|
+
top: 0,
|
|
78
|
+
left: 0,
|
|
79
|
+
backgroundColor: colors.fillColorNeutral070,
|
|
80
|
+
borderRadius,
|
|
81
|
+
width,
|
|
82
|
+
height,
|
|
83
|
+
},
|
|
84
|
+
image: {
|
|
85
|
+
backgroundColor: 'transparent',
|
|
86
|
+
},
|
|
87
|
+
})
|
|
88
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import React, { useEffect, useRef } from 'react'
|
|
2
|
+
import { Animated, Easing, StyleSheet, View } from 'react-native'
|
|
3
|
+
import { useTheme } from '../../hooks'
|
|
4
|
+
|
|
5
|
+
interface SpinnerProps {
|
|
6
|
+
/**
|
|
7
|
+
* Size of the spinner in px
|
|
8
|
+
* */
|
|
9
|
+
size?: number
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function Spinner({ size = 20 }: SpinnerProps) {
|
|
13
|
+
const rotation = useRef(new Animated.Value(0)).current
|
|
14
|
+
|
|
15
|
+
const animation = Animated.loop(
|
|
16
|
+
Animated.timing(rotation, {
|
|
17
|
+
toValue: 1,
|
|
18
|
+
duration: 1000,
|
|
19
|
+
easing: Easing.linear,
|
|
20
|
+
useNativeDriver: true,
|
|
21
|
+
})
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
const rotateValue = rotation.interpolate({
|
|
25
|
+
inputRange: [0, 1],
|
|
26
|
+
outputRange: ['0deg', '360deg'],
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
const styles = useStyles(rotateValue, size)
|
|
30
|
+
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
animation.start()
|
|
33
|
+
return () => animation.stop()
|
|
34
|
+
}, [animation])
|
|
35
|
+
|
|
36
|
+
useEffect(() => () => rotation.setValue(0), [rotation])
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<View style={styles.container}>
|
|
40
|
+
<Animated.View style={styles.animatedContainer}>
|
|
41
|
+
<View style={styles.clipping}>
|
|
42
|
+
<View style={[styles.circle, styles.spinner]} />
|
|
43
|
+
</View>
|
|
44
|
+
<View style={[styles.circle, styles.track]} />
|
|
45
|
+
</Animated.View>
|
|
46
|
+
</View>
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const useStyles = (rotate: Animated.AnimatedInterpolation<string | number>, size: number) => {
|
|
51
|
+
const { colors } = useTheme()
|
|
52
|
+
|
|
53
|
+
return StyleSheet.create({
|
|
54
|
+
container: {
|
|
55
|
+
width: '100%',
|
|
56
|
+
height: '100%',
|
|
57
|
+
position: 'absolute',
|
|
58
|
+
top: 0,
|
|
59
|
+
left: 0,
|
|
60
|
+
zIndex: 200,
|
|
61
|
+
justifyContent: 'center',
|
|
62
|
+
alignItems: 'center',
|
|
63
|
+
opacity: 0.7,
|
|
64
|
+
},
|
|
65
|
+
animatedContainer: {
|
|
66
|
+
width: size,
|
|
67
|
+
height: size,
|
|
68
|
+
borderRadius: size / 2,
|
|
69
|
+
transform: [{ rotate }],
|
|
70
|
+
},
|
|
71
|
+
circle: {
|
|
72
|
+
width: size,
|
|
73
|
+
height: size,
|
|
74
|
+
borderRadius: size / 2,
|
|
75
|
+
borderStyle: 'solid',
|
|
76
|
+
borderWidth: 3,
|
|
77
|
+
},
|
|
78
|
+
spinner: {
|
|
79
|
+
position: 'absolute',
|
|
80
|
+
top: 0,
|
|
81
|
+
left: 0,
|
|
82
|
+
borderColor: colors.fillColorNeutral020,
|
|
83
|
+
},
|
|
84
|
+
track: {
|
|
85
|
+
borderColor: colors.fillColorNeutral050Base,
|
|
86
|
+
},
|
|
87
|
+
clipping: {
|
|
88
|
+
position: 'absolute',
|
|
89
|
+
top: 0,
|
|
90
|
+
left: 0,
|
|
91
|
+
width: size / 2,
|
|
92
|
+
height: size / 2,
|
|
93
|
+
overflow: 'hidden',
|
|
94
|
+
zIndex: 200,
|
|
95
|
+
},
|
|
96
|
+
})
|
|
97
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import React, { PropsWithChildren } from 'react'
|
|
2
|
+
import { Text } from 'react-native'
|
|
3
|
+
|
|
4
|
+
class ErrorBoundary extends React.Component<PropsWithChildren<{ onReset?: () => void }>> {
|
|
5
|
+
state = {
|
|
6
|
+
error: null,
|
|
7
|
+
unsubscriber: () => {},
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
componentDidCatch(error: any) {
|
|
11
|
+
this.handleError(error)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
handleError(error: any) {
|
|
15
|
+
this.setState({ error })
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
render() {
|
|
19
|
+
if (this.state.error) {
|
|
20
|
+
return <Text>{JSON.stringify(this.state.error, null, 2)}</Text>
|
|
21
|
+
} else {
|
|
22
|
+
return this.props.children
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export default ErrorBoundary
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { QueryClient, QueryClientProvider, QueryKey } from '@tanstack/react-query'
|
|
2
|
+
import React from 'react'
|
|
3
|
+
import { ViewProps } from 'react-native'
|
|
4
|
+
import { OAuthToken } from '../types'
|
|
5
|
+
import { ENV, session } from '../utils/session'
|
|
6
|
+
|
|
7
|
+
let handleTokenExpired: () => void
|
|
8
|
+
|
|
9
|
+
const defaultQueryFn = ({ queryKey }: { queryKey: QueryKey }) => {
|
|
10
|
+
if (!session.token) {
|
|
11
|
+
throw new Error('No token present')
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const url = `${session.baseUrl}${queryKey[0]}`
|
|
15
|
+
|
|
16
|
+
return fetch(url, {
|
|
17
|
+
headers: {
|
|
18
|
+
Authorization: `Bearer ${session.token?.access_token}`,
|
|
19
|
+
},
|
|
20
|
+
})
|
|
21
|
+
.then(validateResponse)
|
|
22
|
+
.then(response => response.json())
|
|
23
|
+
.catch(error => {
|
|
24
|
+
if (error.message === 'Token expired') {
|
|
25
|
+
handleTokenExpired()
|
|
26
|
+
}
|
|
27
|
+
return null
|
|
28
|
+
})
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const queryClient = new QueryClient({
|
|
32
|
+
defaultOptions: {
|
|
33
|
+
queries: {
|
|
34
|
+
queryFn: defaultQueryFn,
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
export function ApiProvider({
|
|
40
|
+
children,
|
|
41
|
+
env = 'production',
|
|
42
|
+
token,
|
|
43
|
+
onTokenExpired,
|
|
44
|
+
}: ViewProps & { env?: ENV; token?: OAuthToken; onTokenExpired: () => void }) {
|
|
45
|
+
session.env = env
|
|
46
|
+
session.token = token
|
|
47
|
+
handleTokenExpired = onTokenExpired
|
|
48
|
+
|
|
49
|
+
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const validateResponse = (response: Response) => {
|
|
53
|
+
const isExpired = response.status === 401
|
|
54
|
+
|
|
55
|
+
if (isExpired) {
|
|
56
|
+
throw new Error('Token expired')
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return response
|
|
60
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import React, { createContext } from 'react'
|
|
2
|
+
import { OAuthToken } from '../types'
|
|
3
|
+
import { ApiProvider } from './api_provider'
|
|
4
|
+
|
|
5
|
+
type ContextValue = {
|
|
6
|
+
token?: OAuthToken
|
|
7
|
+
onTokenExpired: () => void
|
|
8
|
+
theme: any
|
|
9
|
+
env?: 'production' | 'staging' | 'development'
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const ChatContext = createContext<ContextValue>({
|
|
13
|
+
theme: undefined,
|
|
14
|
+
token: undefined,
|
|
15
|
+
env: undefined,
|
|
16
|
+
onTokenExpired: () => {},
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
export function ChatProvider({ children, value }: { children: any; value: ContextValue }) {
|
|
20
|
+
if (!Object.keys(value.token || {}).length) return null
|
|
21
|
+
|
|
22
|
+
return (
|
|
23
|
+
<ChatContext.Provider value={value}>
|
|
24
|
+
<ApiProvider env={value.env} token={value.token} onTokenExpired={value.onTokenExpired}>
|
|
25
|
+
{children}
|
|
26
|
+
</ApiProvider>
|
|
27
|
+
</ChatContext.Provider>
|
|
28
|
+
)
|
|
29
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import AsyncStorage from '@react-native-async-storage/async-storage'
|
|
2
|
+
import { useSuspenseQuery } from '@tanstack/react-query'
|
|
3
|
+
import { useCallback } from 'react'
|
|
4
|
+
|
|
5
|
+
const cacheKeyGenerator = (key: string) => [`AsyncStorageResource:${key}`]
|
|
6
|
+
|
|
7
|
+
type SetValue<TCacheData> = (_itemValue?: TCacheData | null) => void
|
|
8
|
+
|
|
9
|
+
export function useAsyncStorage<TCacheData>(
|
|
10
|
+
key: string,
|
|
11
|
+
initialValue: TCacheData
|
|
12
|
+
): [TCacheData, SetValue<TCacheData>] {
|
|
13
|
+
const cacheKey = cacheKeyGenerator(key)
|
|
14
|
+
const { data: value, refetch } = useSuspenseQuery<TCacheData>({
|
|
15
|
+
queryKey: cacheKey,
|
|
16
|
+
queryFn: () =>
|
|
17
|
+
AsyncStorage.getItem(key).then(storedValue => {
|
|
18
|
+
if (!storedValue) return initialValue
|
|
19
|
+
|
|
20
|
+
try {
|
|
21
|
+
return JSON.parse(storedValue)
|
|
22
|
+
} catch {
|
|
23
|
+
return storedValue
|
|
24
|
+
}
|
|
25
|
+
}),
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
const setValue: SetValue<TCacheData> = useCallback(
|
|
29
|
+
itemValue => {
|
|
30
|
+
if (itemValue === null || itemValue === undefined) {
|
|
31
|
+
AsyncStorage.removeItem(key)
|
|
32
|
+
} else {
|
|
33
|
+
AsyncStorage.setItem(key, JSON.stringify(itemValue))
|
|
34
|
+
}
|
|
35
|
+
refetch()
|
|
36
|
+
},
|
|
37
|
+
[key, refetch]
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
return [value || initialValue, setValue]
|
|
41
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { useMemo } from 'react'
|
|
2
|
+
import { useColorScheme, ColorSchemeName } from 'react-native'
|
|
3
|
+
import { defaultTheme, DefaultTheme } from '../utils/theme'
|
|
4
|
+
import { DeepPartial } from '../types'
|
|
5
|
+
import { tapestryAliasTokensColorMap } from '../vendor/tapestry/tapestry_alias_tokens_color_map'
|
|
6
|
+
import { merge } from 'lodash'
|
|
7
|
+
|
|
8
|
+
interface CreateChatThemeProps {
|
|
9
|
+
theme?: DeepPartial<DefaultTheme>
|
|
10
|
+
colorScheme?: ColorSchemeName
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const useCreateChatTheme = ({
|
|
14
|
+
theme: customTheme = {},
|
|
15
|
+
colorScheme: appColorScheme,
|
|
16
|
+
}: CreateChatThemeProps) => {
|
|
17
|
+
const internalColorScheme = useColorScheme() || 'light'
|
|
18
|
+
const colorScheme = appColorScheme || internalColorScheme
|
|
19
|
+
|
|
20
|
+
const memoizedTheme = useMemo(() => {
|
|
21
|
+
return {
|
|
22
|
+
...merge({}, defaultTheme(colorScheme), customTheme),
|
|
23
|
+
colors: {
|
|
24
|
+
...merge({}, defaultTheme(colorScheme).colors, customTheme?.colors),
|
|
25
|
+
...tapestryAliasTokensColorMap[colorScheme],
|
|
26
|
+
},
|
|
27
|
+
}
|
|
28
|
+
}, [colorScheme, customTheme])
|
|
29
|
+
|
|
30
|
+
return memoizedTheme
|
|
31
|
+
}
|
package/src/index.tsx
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { Conversations } from './components/conversations'
|
|
2
|
+
import { ChatContext, ChatProvider } from './contexts/chat_context'
|
|
3
|
+
import { useCreateChatTheme } from './hooks'
|
|
4
|
+
import { OAuthToken } from './types'
|
|
5
|
+
import { baseUrlMap, uploadUrlMap } from './utils/session'
|
|
6
|
+
import { TemporaryDefaultColorsType } from './utils/theme'
|
|
7
|
+
|
|
8
|
+
export {
|
|
9
|
+
baseUrlMap,
|
|
10
|
+
ChatContext,
|
|
11
|
+
ChatProvider,
|
|
12
|
+
Conversations,
|
|
13
|
+
OAuthToken,
|
|
14
|
+
TemporaryDefaultColorsType,
|
|
15
|
+
uploadUrlMap,
|
|
16
|
+
useCreateChatTheme,
|
|
17
|
+
}
|
package/src/types.d.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export type ConversationRecord = {
|
|
2
|
+
id: string
|
|
3
|
+
type: 'Conversation'
|
|
4
|
+
attributes: {
|
|
5
|
+
title: string
|
|
6
|
+
created_at: string
|
|
7
|
+
updated_at: string
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type OAuthToken = {
|
|
12
|
+
token_type: any
|
|
13
|
+
access_token: any
|
|
14
|
+
created_at: number
|
|
15
|
+
expires_in: any
|
|
16
|
+
scope: string
|
|
17
|
+
refresh_token: any
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Sets all the properties of of a deeply nested object to optional.
|
|
21
|
+
// Example: `DeepPartial<ChatTheme>`
|
|
22
|
+
export type DeepPartial<T> = {
|
|
23
|
+
[P in keyof T]?: DeepPartial<T[P]>
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type RootStackParamList = {
|
|
27
|
+
Settings: undefined
|
|
28
|
+
Conversations: undefined
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
declare global {
|
|
32
|
+
namespace ReactNavigation {
|
|
33
|
+
interface RootParamList extends RootStackParamList {}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { OAuthToken } from '../types'
|
|
2
|
+
|
|
3
|
+
export type ENV = 'production' | 'staging' | 'development'
|
|
4
|
+
|
|
5
|
+
export const baseUrlMap = {
|
|
6
|
+
production: 'https://api.planningcenteronline.com',
|
|
7
|
+
staging: 'https://api-staging.planningcenteronline.com',
|
|
8
|
+
development: 'http://api.pco.test',
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const uploadUrlMap = {
|
|
12
|
+
production: 'https://upload.planningcenteronline.com/v2/files',
|
|
13
|
+
staging: 'https://upload-staging.planningcenteronline.com/v2/files',
|
|
14
|
+
development: 'https://upload.planningcenteronline.com/v2/files',
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
type SessionProps = { env?: ENV; token?: OAuthToken } | undefined
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Session class to track the environment and token
|
|
21
|
+
* Not intended to make network requests or handle authentication
|
|
22
|
+
* - returns urls for convenience only
|
|
23
|
+
*/
|
|
24
|
+
export class Session {
|
|
25
|
+
env: ENV
|
|
26
|
+
token: OAuthToken | undefined
|
|
27
|
+
|
|
28
|
+
constructor(props?: SessionProps) {
|
|
29
|
+
const { env = 'development', token } = props || {}
|
|
30
|
+
this.env = env
|
|
31
|
+
this.token = token
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
get isAuthenticated() {
|
|
35
|
+
return Boolean(this.token)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
get baseUrl() {
|
|
39
|
+
return baseUrlMap[this.env]
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
get uploadUrl() {
|
|
43
|
+
return uploadUrlMap[this.env]
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const session = new Session({ env: 'development' })
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { TextStyle, ViewStyle, ColorSchemeName } from 'react-native'
|
|
2
|
+
import { tokens } from '../vendor/tapestry/tokens'
|
|
3
|
+
import { tapestryAliasTokensColorMap } from '../vendor/tapestry/tapestry_alias_tokens_color_map'
|
|
4
|
+
|
|
5
|
+
export interface ChatTheme extends DefaultTheme {
|
|
6
|
+
colors: DefaultTheme['colors'] &
|
|
7
|
+
(typeof tapestryAliasTokensColorMap.light | typeof tapestryAliasTokensColorMap.dark)
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** =============================================
|
|
11
|
+
NOTE: The specific values for `colors` and the `productBadge` are temporary examples that can be replaced once we start building out UI. This line's comment can be removed at that time too.
|
|
12
|
+
|
|
13
|
+
The default theme is intended to support two types of customizations:
|
|
14
|
+
1. Specific color properties for a chat component (eg. `primaryButtonBackgroundColor`)
|
|
15
|
+
2. Any styles for a specific component. (Use only one level of nesting.)
|
|
16
|
+
```
|
|
17
|
+
primaryButton: {
|
|
18
|
+
container: ViewStyle
|
|
19
|
+
text: TextStyle
|
|
20
|
+
}
|
|
21
|
+
```
|
|
22
|
+
============================================= */
|
|
23
|
+
|
|
24
|
+
export interface DefaultTheme {
|
|
25
|
+
colors: ChatColors
|
|
26
|
+
temporaryProductBadge: {
|
|
27
|
+
container: ViewStyle
|
|
28
|
+
text: TextStyle
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const defaultTheme = (colorScheme: ColorSchemeName): DefaultTheme => {
|
|
33
|
+
const scheme = colorScheme || 'light'
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
colors: chatThemeColorMap[scheme],
|
|
37
|
+
temporaryProductBadge: {
|
|
38
|
+
container: {
|
|
39
|
+
paddingHorizontal: tokens.spacing1,
|
|
40
|
+
backgroundColor: tapestryAliasTokensColorMap[scheme].fillColorNeutral070,
|
|
41
|
+
borderWidth: tokens.borderSizeDefault,
|
|
42
|
+
},
|
|
43
|
+
text: {
|
|
44
|
+
textAlign: 'center',
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type TemporaryDefaultColorsType = Partial<ChatColors>
|
|
51
|
+
|
|
52
|
+
interface ChatColors {
|
|
53
|
+
name: string
|
|
54
|
+
temporaryButtonBackgroundColor: string
|
|
55
|
+
testColor: string
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const colorsChatLight: ChatColors = {
|
|
59
|
+
name: 'light',
|
|
60
|
+
temporaryButtonBackgroundColor: tokens.colorNeutral95,
|
|
61
|
+
testColor: 'red',
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const colorsChatDark: ChatColors = {
|
|
65
|
+
name: 'dark',
|
|
66
|
+
temporaryButtonBackgroundColor: tokens.colorNeutral17,
|
|
67
|
+
testColor: 'blue',
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const chatThemeColorMap = {
|
|
71
|
+
light: colorsChatLight,
|
|
72
|
+
dark: colorsChatDark,
|
|
73
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { tokens } from './tokens'
|
|
2
|
+
|
|
3
|
+
// Copied from `@planningcenter/tapestry` package.
|
|
4
|
+
// Defining these tokens locally is a temporary solution until the package supports mobile.
|
|
5
|
+
// Tokens Reference: https://planningcenter.github.io/tapestry/?path=/docs/foundations-design-tokens--docs
|
|
6
|
+
|
|
7
|
+
interface NeutralColors {
|
|
8
|
+
name: string
|
|
9
|
+
fillColorNeutral000: string
|
|
10
|
+
fillColorNeutral010: string
|
|
11
|
+
fillColorNeutral020: string
|
|
12
|
+
fillColorNeutral030: string
|
|
13
|
+
fillColorNeutral040: string
|
|
14
|
+
fillColorNeutral050Base: string
|
|
15
|
+
fillColorNeutral060: string
|
|
16
|
+
fillColorNeutral070: string
|
|
17
|
+
fillColorNeutral080: string
|
|
18
|
+
fillColorNeutral090: string
|
|
19
|
+
fillColorNeutral100Inverted: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const neutralsLight: NeutralColors = {
|
|
23
|
+
name: 'light',
|
|
24
|
+
fillColorNeutral000: tokens.colorNeutral12,
|
|
25
|
+
fillColorNeutral010: tokens.colorNeutral24,
|
|
26
|
+
fillColorNeutral020: tokens.colorNeutral45,
|
|
27
|
+
fillColorNeutral030: tokens.colorNeutral58,
|
|
28
|
+
fillColorNeutral040: tokens.colorNeutral81,
|
|
29
|
+
fillColorNeutral050Base: tokens.colorNeutral88,
|
|
30
|
+
fillColorNeutral060: tokens.colorNeutral93,
|
|
31
|
+
fillColorNeutral070: tokens.colorNeutral95,
|
|
32
|
+
fillColorNeutral080: tokens.colorNeutral97,
|
|
33
|
+
fillColorNeutral090: tokens.colorNeutral98,
|
|
34
|
+
fillColorNeutral100Inverted: tokens.colorNeutral100White,
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const neutralsDark: NeutralColors = {
|
|
38
|
+
name: 'dark',
|
|
39
|
+
fillColorNeutral000: tokens.colorNeutral98,
|
|
40
|
+
fillColorNeutral010: tokens.colorNeutral88,
|
|
41
|
+
fillColorNeutral020: tokens.colorNeutral68,
|
|
42
|
+
fillColorNeutral030: tokens.colorNeutral50,
|
|
43
|
+
fillColorNeutral040: tokens.colorNeutral32,
|
|
44
|
+
fillColorNeutral050Base: tokens.colorNeutral24,
|
|
45
|
+
fillColorNeutral060: tokens.colorNeutral19,
|
|
46
|
+
fillColorNeutral070: tokens.colorNeutral17,
|
|
47
|
+
fillColorNeutral080: tokens.colorNeutral15,
|
|
48
|
+
fillColorNeutral090: tokens.colorNeutral12,
|
|
49
|
+
fillColorNeutral100Inverted: tokens.colorNeutral7,
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface SemanticAliasesColors {
|
|
53
|
+
name: string
|
|
54
|
+
iconColorDefaultPrimary: string
|
|
55
|
+
iconColorDefaultSecondary: string
|
|
56
|
+
iconColorDefaultDim: string
|
|
57
|
+
iconColorDefaultDisabled: string
|
|
58
|
+
iconColorDefaultInverted: string
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const semanticAliasesLight: SemanticAliasesColors = {
|
|
62
|
+
name: 'light',
|
|
63
|
+
iconColorDefaultPrimary: neutralsLight.fillColorNeutral010,
|
|
64
|
+
iconColorDefaultSecondary: neutralsLight.fillColorNeutral020,
|
|
65
|
+
iconColorDefaultDim: neutralsLight.fillColorNeutral030,
|
|
66
|
+
iconColorDefaultDisabled: neutralsLight.fillColorNeutral040,
|
|
67
|
+
iconColorDefaultInverted: neutralsLight.fillColorNeutral100Inverted,
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const semanticAliasesDark: SemanticAliasesColors = {
|
|
71
|
+
name: 'dark',
|
|
72
|
+
iconColorDefaultPrimary: neutralsDark.fillColorNeutral010,
|
|
73
|
+
iconColorDefaultSecondary: neutralsDark.fillColorNeutral020,
|
|
74
|
+
iconColorDefaultDim: neutralsDark.fillColorNeutral030,
|
|
75
|
+
iconColorDefaultDisabled: neutralsDark.fillColorNeutral040,
|
|
76
|
+
iconColorDefaultInverted: neutralsDark.fillColorNeutral100Inverted,
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export const tapestryAliasTokensColorMap = {
|
|
80
|
+
light: { ...neutralsLight, ...semanticAliasesLight },
|
|
81
|
+
dark: { ...neutralsDark, ...semanticAliasesDark },
|
|
82
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// Copied from `@planningcenter/tapestry` package.
|
|
2
|
+
// Defining these tokens locally is a temporary solution until the package supports mobile.
|
|
3
|
+
// Tokens Reference: https://planningcenter.github.io/tapestry/?path=/docs/foundations-design-tokens--docs
|
|
4
|
+
|
|
5
|
+
interface ColorPrimitves {
|
|
6
|
+
colorNeutral7: string
|
|
7
|
+
colorNeutral12: string
|
|
8
|
+
colorNeutral15: string
|
|
9
|
+
colorNeutral17: string
|
|
10
|
+
colorNeutral19: string
|
|
11
|
+
colorNeutral24: string
|
|
12
|
+
colorNeutral32: string
|
|
13
|
+
colorNeutral45: string
|
|
14
|
+
colorNeutral50: string
|
|
15
|
+
colorNeutral58: string
|
|
16
|
+
colorNeutral68: string
|
|
17
|
+
colorNeutral81: string
|
|
18
|
+
colorNeutral88: string
|
|
19
|
+
colorNeutral93: string
|
|
20
|
+
colorNeutral95: string
|
|
21
|
+
colorNeutral97: string
|
|
22
|
+
colorNeutral98: string
|
|
23
|
+
colorNeutral100White: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const colorPrimitives: ColorPrimitves = {
|
|
27
|
+
colorNeutral7: 'hsl(0, 0%, 7%)',
|
|
28
|
+
colorNeutral12: 'hsl(0, 0%, 12%)',
|
|
29
|
+
colorNeutral15: 'hsl(0, 0%, 15%)',
|
|
30
|
+
colorNeutral17: 'hsl(0, 0%, 17%)',
|
|
31
|
+
colorNeutral19: 'hsl(0, 0%, 19%)',
|
|
32
|
+
colorNeutral24: 'hsl(0, 0%, 24%)',
|
|
33
|
+
colorNeutral32: 'hsl(0, 0%, 32%)',
|
|
34
|
+
colorNeutral45: 'hsl(0, 0%, 45%)',
|
|
35
|
+
colorNeutral50: 'hsl(0, 0%, 50%)',
|
|
36
|
+
colorNeutral58: 'hsl(0, 0%, 58%)',
|
|
37
|
+
colorNeutral68: 'hsl(0, 0%, 68%)',
|
|
38
|
+
colorNeutral81: 'hsl(0, 0%, 81%)',
|
|
39
|
+
colorNeutral88: 'hsl(0, 0%, 88%)',
|
|
40
|
+
colorNeutral93: 'hsl(0, 0%, 93%)',
|
|
41
|
+
colorNeutral95: 'hsl(0, 0%, 95%)',
|
|
42
|
+
colorNeutral97: 'hsl(0, 0%, 97%)',
|
|
43
|
+
colorNeutral98: 'hsl(0, 0%, 98%)',
|
|
44
|
+
colorNeutral100White: 'hsl(0, 0%, 100%)',
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface NumericPrimitives {
|
|
48
|
+
spacingFourth: number
|
|
49
|
+
spacingHalf: number
|
|
50
|
+
spacing1: number
|
|
51
|
+
spacing2: number
|
|
52
|
+
spacing3: number
|
|
53
|
+
spacing4: number
|
|
54
|
+
spacing5: number
|
|
55
|
+
spacing6: number
|
|
56
|
+
spacing7: number
|
|
57
|
+
borderRadiusSm: number
|
|
58
|
+
borderRadiusMd: number
|
|
59
|
+
borderRadiusLg: number
|
|
60
|
+
borderRadiusXl: number
|
|
61
|
+
borderRadiusRound: number
|
|
62
|
+
borderSizeDefault: number
|
|
63
|
+
borderSizeThick: number
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const numericPrimtives: NumericPrimitives = {
|
|
67
|
+
spacingFourth: 2,
|
|
68
|
+
spacingHalf: 4,
|
|
69
|
+
spacing1: 8,
|
|
70
|
+
spacing2: 16,
|
|
71
|
+
spacing3: 24,
|
|
72
|
+
spacing4: 32,
|
|
73
|
+
spacing5: 40,
|
|
74
|
+
spacing6: 48,
|
|
75
|
+
spacing7: 56,
|
|
76
|
+
borderRadiusSm: 2,
|
|
77
|
+
borderRadiusMd: 4,
|
|
78
|
+
borderRadiusLg: 8,
|
|
79
|
+
borderRadiusXl: 16,
|
|
80
|
+
borderRadiusRound: 56,
|
|
81
|
+
borderSizeDefault: 1,
|
|
82
|
+
borderSizeThick: 2,
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
interface NumericAliases {
|
|
86
|
+
borderRadiusDefault: number
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const numericAliases: NumericAliases = {
|
|
90
|
+
borderRadiusDefault: numericPrimtives.borderRadiusMd,
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export const tokens = {
|
|
94
|
+
...colorPrimitives,
|
|
95
|
+
...numericPrimtives,
|
|
96
|
+
...numericAliases,
|
|
97
|
+
}
|