Merge branch 'ansh/app-812-add-custom-feed-discovery-to-onboarding' into main
This commit is contained in:
commit
f9cab178b9
29 changed files with 1033 additions and 217 deletions
34
src/view/com/auth/Onboarding.tsx
Normal file
34
src/view/com/auth/Onboarding.tsx
Normal file
|
@ -0,0 +1,34 @@
|
|||
import React from 'react'
|
||||
import {SafeAreaView} from 'react-native'
|
||||
import {observer} from 'mobx-react-lite'
|
||||
import {ErrorBoundary} from 'view/com/util/ErrorBoundary'
|
||||
import {s} from 'lib/styles'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {useStores} from 'state/index'
|
||||
import {Welcome} from './onboarding/Welcome'
|
||||
import {RecommendedFeeds} from './onboarding/RecommendedFeeds'
|
||||
|
||||
export const Onboarding = observer(() => {
|
||||
const pal = usePalette('default')
|
||||
const store = useStores()
|
||||
|
||||
React.useEffect(() => {
|
||||
store.shell.setMinimalShellMode(true)
|
||||
}, [store])
|
||||
|
||||
const next = () => store.onboarding.next()
|
||||
const skip = () => store.onboarding.skip()
|
||||
|
||||
return (
|
||||
<SafeAreaView testID="onboardingView" style={[s.hContentRegion, pal.view]}>
|
||||
<ErrorBoundary>
|
||||
{store.onboarding.step === 'Welcome' && (
|
||||
<Welcome skip={skip} next={next} />
|
||||
)}
|
||||
{store.onboarding.step === 'RecommendedFeeds' && (
|
||||
<RecommendedFeeds next={next} />
|
||||
)}
|
||||
</ErrorBoundary>
|
||||
</SafeAreaView>
|
||||
)
|
||||
})
|
|
@ -1,66 +0,0 @@
|
|||
import React from 'react'
|
||||
import {StyleSheet, View} from 'react-native'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {Welcome} from './Welcome'
|
||||
import {useStores} from 'state/index'
|
||||
import {track} from 'lib/analytics/analytics'
|
||||
|
||||
enum OnboardingStep {
|
||||
WELCOME = 'WELCOME',
|
||||
// SELECT_INTERESTS = 'SELECT_INTERESTS',
|
||||
COMPLETE = 'COMPLETE',
|
||||
}
|
||||
type OnboardingState = {
|
||||
currentStep: OnboardingStep
|
||||
}
|
||||
type Action = {type: 'NEXT_STEP'}
|
||||
const initialState: OnboardingState = {
|
||||
currentStep: OnboardingStep.WELCOME,
|
||||
}
|
||||
const reducer = (state: OnboardingState, action: Action): OnboardingState => {
|
||||
switch (action.type) {
|
||||
case 'NEXT_STEP':
|
||||
switch (state.currentStep) {
|
||||
case OnboardingStep.WELCOME:
|
||||
track('Onboarding:Begin')
|
||||
return {...state, currentStep: OnboardingStep.COMPLETE}
|
||||
case OnboardingStep.COMPLETE:
|
||||
track('Onboarding:Complete')
|
||||
return state
|
||||
default:
|
||||
return state
|
||||
}
|
||||
default:
|
||||
return state
|
||||
}
|
||||
}
|
||||
|
||||
export const Onboarding = () => {
|
||||
const pal = usePalette('default')
|
||||
const rootStore = useStores()
|
||||
const [state, dispatch] = React.useReducer(reducer, initialState)
|
||||
const next = React.useCallback(
|
||||
() => dispatch({type: 'NEXT_STEP'}),
|
||||
[dispatch],
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (state.currentStep === OnboardingStep.COMPLETE) {
|
||||
// navigate to home
|
||||
rootStore.shell.closeModal()
|
||||
}
|
||||
}, [state.currentStep, rootStore.shell])
|
||||
|
||||
return (
|
||||
<View style={[pal.view, styles.container]}>
|
||||
{state.currentStep === OnboardingStep.WELCOME && <Welcome next={next} />}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
paddingHorizontal: 20,
|
||||
},
|
||||
})
|
176
src/view/com/auth/onboarding/RecommendedFeeds.tsx
Normal file
176
src/view/com/auth/onboarding/RecommendedFeeds.tsx
Normal file
|
@ -0,0 +1,176 @@
|
|||
import React from 'react'
|
||||
import {FlatList, StyleSheet, View} from 'react-native'
|
||||
import {observer} from 'mobx-react-lite'
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||
import {TabletOrDesktop, Mobile} from 'view/com/util/layouts/Breakpoints'
|
||||
import {Text} from 'view/com/util/text/Text'
|
||||
import {ViewHeader} from 'view/com/util/ViewHeader'
|
||||
import {TitleColumnLayout} from 'view/com/util/layouts/TitleColumnLayout'
|
||||
import {Button} from 'view/com/util/forms/Button'
|
||||
import {RecommendedFeedsItem} from './RecommendedFeedsItem'
|
||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {RECOMMENDED_FEEDS} from 'lib/constants'
|
||||
|
||||
type Props = {
|
||||
next: () => void
|
||||
}
|
||||
export const RecommendedFeeds = observer(({next}: Props) => {
|
||||
const pal = usePalette('default')
|
||||
const {isTabletOrMobile} = useWebMediaQueries()
|
||||
|
||||
const title = (
|
||||
<>
|
||||
<Text
|
||||
style={[
|
||||
pal.textLight,
|
||||
tdStyles.title1,
|
||||
isTabletOrMobile && tdStyles.title1Small,
|
||||
]}>
|
||||
Choose your
|
||||
</Text>
|
||||
<Text
|
||||
style={[
|
||||
pal.link,
|
||||
tdStyles.title2,
|
||||
isTabletOrMobile && tdStyles.title2Small,
|
||||
]}>
|
||||
Recomended
|
||||
</Text>
|
||||
<Text
|
||||
style={[
|
||||
pal.link,
|
||||
tdStyles.title2,
|
||||
isTabletOrMobile && tdStyles.title2Small,
|
||||
]}>
|
||||
Feeds
|
||||
</Text>
|
||||
<Text type="2xl-medium" style={[pal.textLight, tdStyles.description]}>
|
||||
Feeds are created by users to curate content. Choose some feeds that you
|
||||
find interesting.
|
||||
</Text>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-end',
|
||||
marginTop: 20,
|
||||
}}>
|
||||
<Button onPress={next} testID="continueBtn">
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingLeft: 2,
|
||||
gap: 6,
|
||||
}}>
|
||||
<Text
|
||||
type="2xl-medium"
|
||||
style={{color: '#fff', position: 'relative', top: -1}}>
|
||||
Done
|
||||
</Text>
|
||||
<FontAwesomeIcon icon="angle-right" color="#fff" size={14} />
|
||||
</View>
|
||||
</Button>
|
||||
</View>
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<TabletOrDesktop>
|
||||
<TitleColumnLayout
|
||||
testID="recommendedFeedsScreen"
|
||||
title={title}
|
||||
horizontal
|
||||
titleStyle={isTabletOrMobile ? undefined : {minWidth: 470}}
|
||||
contentStyle={{paddingHorizontal: 0}}>
|
||||
<FlatList
|
||||
data={RECOMMENDED_FEEDS}
|
||||
renderItem={({item}) => <RecommendedFeedsItem {...item} />}
|
||||
keyExtractor={item => item.did + item.rkey}
|
||||
style={{flex: 1}}
|
||||
/>
|
||||
</TitleColumnLayout>
|
||||
</TabletOrDesktop>
|
||||
<Mobile>
|
||||
<View style={[mStyles.container]} testID="recommendedFeedsScreen">
|
||||
<ViewHeader
|
||||
title="Recommended Feeds"
|
||||
showBackButton={false}
|
||||
showOnDesktop
|
||||
/>
|
||||
<Text type="lg-medium" style={[pal.text, mStyles.header]}>
|
||||
Check out some recommended feeds. Tap + to add them to your list of
|
||||
pinned feeds.
|
||||
</Text>
|
||||
|
||||
<FlatList
|
||||
data={RECOMMENDED_FEEDS}
|
||||
renderItem={({item}) => <RecommendedFeedsItem {...item} />}
|
||||
keyExtractor={item => item.did + item.rkey}
|
||||
style={{flex: 1}}
|
||||
/>
|
||||
|
||||
<Button
|
||||
onPress={next}
|
||||
label="Continue"
|
||||
testID="continueBtn"
|
||||
style={mStyles.button}
|
||||
labelStyle={mStyles.buttonText}
|
||||
/>
|
||||
</View>
|
||||
</Mobile>
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
const tdStyles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
marginHorizontal: 16,
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
title1: {
|
||||
fontSize: 36,
|
||||
fontWeight: '800',
|
||||
textAlign: 'right',
|
||||
},
|
||||
title1Small: {
|
||||
fontSize: 24,
|
||||
},
|
||||
title2: {
|
||||
fontSize: 58,
|
||||
fontWeight: '800',
|
||||
textAlign: 'right',
|
||||
},
|
||||
title2Small: {
|
||||
fontSize: 36,
|
||||
},
|
||||
description: {
|
||||
maxWidth: 400,
|
||||
marginTop: 10,
|
||||
marginLeft: 'auto',
|
||||
textAlign: 'right',
|
||||
},
|
||||
})
|
||||
|
||||
const mStyles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
header: {
|
||||
marginBottom: 16,
|
||||
marginHorizontal: 16,
|
||||
},
|
||||
button: {
|
||||
marginBottom: 16,
|
||||
marginHorizontal: 16,
|
||||
marginTop: 16,
|
||||
},
|
||||
buttonText: {
|
||||
textAlign: 'center',
|
||||
fontSize: 18,
|
||||
paddingVertical: 4,
|
||||
},
|
||||
})
|
142
src/view/com/auth/onboarding/RecommendedFeedsItem.tsx
Normal file
142
src/view/com/auth/onboarding/RecommendedFeedsItem.tsx
Normal file
|
@ -0,0 +1,142 @@
|
|||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {observer} from 'mobx-react-lite'
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||
import {Text} from 'view/com/util/text/Text'
|
||||
import {Button} from 'view/com/util/forms/Button'
|
||||
import {UserAvatar} from 'view/com/util/UserAvatar'
|
||||
import * as Toast from 'view/com/util/Toast'
|
||||
import {HeartIcon} from 'lib/icons'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {useCustomFeed} from 'lib/hooks/useCustomFeed'
|
||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||
import {makeRecordUri} from 'lib/strings/url-helpers'
|
||||
import {sanitizeHandle} from 'lib/strings/handles'
|
||||
|
||||
export const RecommendedFeedsItem = observer(
|
||||
({did, rkey}: {did: string; rkey: string}) => {
|
||||
const {isMobile} = useWebMediaQueries()
|
||||
const pal = usePalette('default')
|
||||
const uri = makeRecordUri(did, 'app.bsky.feed.generator', rkey)
|
||||
const item = useCustomFeed(uri)
|
||||
if (!item) return null
|
||||
const onToggle = async () => {
|
||||
if (item.isSaved) {
|
||||
try {
|
||||
await item.unsave()
|
||||
} catch (e) {
|
||||
Toast.show('There was an issue contacting your server')
|
||||
console.error('Failed to unsave feed', {e})
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
await item.save()
|
||||
await item.pin()
|
||||
} catch (e) {
|
||||
Toast.show('There was an issue contacting your server')
|
||||
console.error('Failed to pin feed', {e})
|
||||
}
|
||||
}
|
||||
}
|
||||
return (
|
||||
<View testID={`feed-${item.displayName}`}>
|
||||
<View
|
||||
style={[
|
||||
pal.border,
|
||||
{
|
||||
flex: isMobile ? 1 : undefined,
|
||||
flexDirection: 'row',
|
||||
gap: 18,
|
||||
maxWidth: isMobile ? undefined : 670,
|
||||
borderRightWidth: isMobile ? undefined : 1,
|
||||
paddingHorizontal: 24,
|
||||
paddingVertical: isMobile ? 12 : 24,
|
||||
borderTopWidth: 1,
|
||||
},
|
||||
]}>
|
||||
<View style={{marginTop: 2}}>
|
||||
<UserAvatar type="algo" size={42} avatar={item.data.avatar} />
|
||||
</View>
|
||||
<View style={{flex: isMobile ? 1 : undefined}}>
|
||||
<Text
|
||||
type="2xl-bold"
|
||||
numberOfLines={1}
|
||||
style={[pal.text, {fontSize: 19}]}>
|
||||
{item.displayName}
|
||||
</Text>
|
||||
|
||||
<Text style={[pal.textLight, {marginBottom: 8}]} numberOfLines={1}>
|
||||
by {sanitizeHandle(item.data.creator.handle, '@')}
|
||||
</Text>
|
||||
|
||||
{item.data.description ? (
|
||||
<Text
|
||||
type="xl"
|
||||
style={[
|
||||
pal.text,
|
||||
{
|
||||
flex: isMobile ? 1 : undefined,
|
||||
maxWidth: 550,
|
||||
marginBottom: 18,
|
||||
},
|
||||
]}
|
||||
numberOfLines={6}>
|
||||
{item.data.description}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
<View style={{flexDirection: 'row', alignItems: 'center', gap: 12}}>
|
||||
<Button
|
||||
type="inverted"
|
||||
style={{paddingVertical: 6}}
|
||||
onPress={onToggle}>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingRight: 2,
|
||||
gap: 6,
|
||||
}}>
|
||||
{item.isSaved ? (
|
||||
<>
|
||||
<FontAwesomeIcon
|
||||
icon="check"
|
||||
size={16}
|
||||
color={pal.colors.textInverted}
|
||||
/>
|
||||
<Text type="lg-medium" style={pal.textInverted}>
|
||||
Added
|
||||
</Text>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FontAwesomeIcon
|
||||
icon="plus"
|
||||
size={16}
|
||||
color={pal.colors.textInverted}
|
||||
/>
|
||||
<Text type="lg-medium" style={pal.textInverted}>
|
||||
Add
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</Button>
|
||||
|
||||
<View style={{flexDirection: 'row', gap: 4}}>
|
||||
<HeartIcon
|
||||
size={16}
|
||||
strokeWidth={2.5}
|
||||
style={[pal.textLight, {position: 'relative', top: 2}]}
|
||||
/>
|
||||
<Text type="lg-medium" style={[pal.text, pal.textLight]}>
|
||||
{item.data.likeCount || 0}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
},
|
||||
)
|
|
@ -1,92 +1,10 @@
|
|||
import React from 'react'
|
||||
import {StyleSheet, View} from 'react-native'
|
||||
import {Text} from 'view/com/util/text/Text'
|
||||
import {s} from 'lib/styles'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||
import {Button} from 'view/com/util/forms/Button'
|
||||
import 'react'
|
||||
import {withBreakpoints} from 'view/com/util/layouts/withBreakpoints'
|
||||
import {WelcomeDesktop} from './WelcomeDesktop'
|
||||
import {WelcomeMobile} from './WelcomeMobile'
|
||||
|
||||
export const Welcome = ({next}: {next: () => void}) => {
|
||||
const pal = usePalette('default')
|
||||
return (
|
||||
<View style={[styles.container]}>
|
||||
<View testID="welcomeScreen">
|
||||
<Text style={[pal.text, styles.title]}>Welcome to </Text>
|
||||
<Text style={[pal.text, pal.link, styles.title]}>Bluesky</Text>
|
||||
|
||||
<View style={styles.spacer} />
|
||||
|
||||
<View style={[styles.row]}>
|
||||
<FontAwesomeIcon icon={'globe'} size={36} color={pal.colors.link} />
|
||||
<View style={[styles.rowText]}>
|
||||
<Text type="lg-bold" style={[pal.text]}>
|
||||
Bluesky is public.
|
||||
</Text>
|
||||
<Text type="lg-thin" style={[pal.text, s.pt2]}>
|
||||
Your posts, likes, and blocks are public. Mutes are private.
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={[styles.row]}>
|
||||
<FontAwesomeIcon icon={'at'} size={36} color={pal.colors.link} />
|
||||
<View style={[styles.rowText]}>
|
||||
<Text type="lg-bold" style={[pal.text]}>
|
||||
Bluesky is open.
|
||||
</Text>
|
||||
<Text type="lg-thin" style={[pal.text, s.pt2]}>
|
||||
Never lose access to your followers and data.
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={[styles.row]}>
|
||||
<FontAwesomeIcon icon={'gear'} size={36} color={pal.colors.link} />
|
||||
<View style={[styles.rowText]}>
|
||||
<Text type="lg-bold" style={[pal.text]}>
|
||||
Bluesky is flexible.
|
||||
</Text>
|
||||
<Text type="lg-thin" style={[pal.text, s.pt2]}>
|
||||
Choose the algorithms that power your experience with custom
|
||||
feeds.
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Button
|
||||
onPress={next}
|
||||
label="Continue"
|
||||
testID="continueBtn"
|
||||
labelStyle={styles.buttonText}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
marginVertical: 60,
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
title: {
|
||||
fontSize: 48,
|
||||
fontWeight: '800',
|
||||
},
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
columnGap: 20,
|
||||
alignItems: 'center',
|
||||
marginVertical: 20,
|
||||
},
|
||||
rowText: {
|
||||
flex: 1,
|
||||
},
|
||||
spacer: {
|
||||
height: 20,
|
||||
},
|
||||
buttonText: {
|
||||
textAlign: 'center',
|
||||
fontSize: 18,
|
||||
marginVertical: 4,
|
||||
},
|
||||
})
|
||||
export const Welcome = withBreakpoints(
|
||||
WelcomeMobile,
|
||||
WelcomeDesktop,
|
||||
WelcomeDesktop,
|
||||
)
|
||||
|
|
123
src/view/com/auth/onboarding/WelcomeDesktop.tsx
Normal file
123
src/view/com/auth/onboarding/WelcomeDesktop.tsx
Normal file
|
@ -0,0 +1,123 @@
|
|||
import React from 'react'
|
||||
import {StyleSheet, View} from 'react-native'
|
||||
import {useMediaQuery} from 'react-responsive'
|
||||
import {Text} from 'view/com/util/text/Text'
|
||||
import {s} from 'lib/styles'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||
import {TitleColumnLayout} from 'view/com/util/layouts/TitleColumnLayout'
|
||||
import {Button} from 'view/com/util/forms/Button'
|
||||
import {observer} from 'mobx-react-lite'
|
||||
|
||||
type Props = {
|
||||
next: () => void
|
||||
skip: () => void
|
||||
}
|
||||
|
||||
export const WelcomeDesktop = observer(({next}: Props) => {
|
||||
const pal = usePalette('default')
|
||||
const horizontal = useMediaQuery({
|
||||
query: '(min-width: 1230px)',
|
||||
})
|
||||
const title = (
|
||||
<>
|
||||
<Text
|
||||
style={[
|
||||
pal.textLight,
|
||||
{
|
||||
fontSize: 36,
|
||||
fontWeight: '800',
|
||||
textAlign: horizontal ? 'right' : 'left',
|
||||
},
|
||||
]}>
|
||||
Welcome to
|
||||
</Text>
|
||||
<Text
|
||||
style={[
|
||||
pal.link,
|
||||
{
|
||||
fontSize: 72,
|
||||
fontWeight: '800',
|
||||
textAlign: horizontal ? 'right' : 'left',
|
||||
},
|
||||
]}>
|
||||
Bluesky
|
||||
</Text>
|
||||
</>
|
||||
)
|
||||
return (
|
||||
<TitleColumnLayout
|
||||
testID="welcomeOnboarding"
|
||||
title={title}
|
||||
horizontal={horizontal}
|
||||
titleStyle={horizontal ? {paddingBottom: 160} : undefined}>
|
||||
<View style={[styles.row]}>
|
||||
<FontAwesomeIcon icon={'globe'} size={36} color={pal.colors.link} />
|
||||
<View style={[styles.rowText]}>
|
||||
<Text type="xl-bold" style={[pal.text]}>
|
||||
Bluesky is public.
|
||||
</Text>
|
||||
<Text type="xl" style={[pal.text, s.pt2]}>
|
||||
Your posts, likes, and blocks are public. Mutes are private.
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={[styles.row]}>
|
||||
<FontAwesomeIcon icon={'at'} size={36} color={pal.colors.link} />
|
||||
<View style={[styles.rowText]}>
|
||||
<Text type="xl-bold" style={[pal.text]}>
|
||||
Bluesky is open.
|
||||
</Text>
|
||||
<Text type="xl" style={[pal.text, s.pt2]}>
|
||||
Never lose access to your followers and data.
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={[styles.row]}>
|
||||
<FontAwesomeIcon icon={'gear'} size={36} color={pal.colors.link} />
|
||||
<View style={[styles.rowText]}>
|
||||
<Text type="xl-bold" style={[pal.text]}>
|
||||
Bluesky is flexible.
|
||||
</Text>
|
||||
<Text type="xl" style={[pal.text, s.pt2]}>
|
||||
Choose the algorithms that power your experience with custom feeds.
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.spacer} />
|
||||
<View style={{flexDirection: 'row'}}>
|
||||
<Button onPress={next} testID="continueBtn">
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingLeft: 2,
|
||||
gap: 6,
|
||||
}}>
|
||||
<Text
|
||||
type="2xl-medium"
|
||||
style={{color: '#fff', position: 'relative', top: -1}}>
|
||||
Next
|
||||
</Text>
|
||||
<FontAwesomeIcon icon="angle-right" color="#fff" size={14} />
|
||||
</View>
|
||||
</Button>
|
||||
</View>
|
||||
</TitleColumnLayout>
|
||||
)
|
||||
})
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
columnGap: 20,
|
||||
alignItems: 'center',
|
||||
marginVertical: 20,
|
||||
},
|
||||
rowText: {
|
||||
flex: 1,
|
||||
},
|
||||
spacer: {
|
||||
height: 20,
|
||||
},
|
||||
})
|
123
src/view/com/auth/onboarding/WelcomeMobile.tsx
Normal file
123
src/view/com/auth/onboarding/WelcomeMobile.tsx
Normal file
|
@ -0,0 +1,123 @@
|
|||
import React from 'react'
|
||||
import {Pressable, StyleSheet, View} from 'react-native'
|
||||
import {Text} from 'view/com/util/text/Text'
|
||||
import {s} from 'lib/styles'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||
import {Button} from 'view/com/util/forms/Button'
|
||||
import {observer} from 'mobx-react-lite'
|
||||
import {ViewHeader} from 'view/com/util/ViewHeader'
|
||||
import {isDesktopWeb} from 'platform/detection'
|
||||
|
||||
type Props = {
|
||||
next: () => void
|
||||
skip: () => void
|
||||
}
|
||||
|
||||
export const WelcomeMobile = observer(({next, skip}: Props) => {
|
||||
const pal = usePalette('default')
|
||||
|
||||
return (
|
||||
<View style={[styles.container]} testID="welcomeOnboarding">
|
||||
<ViewHeader
|
||||
showOnDesktop
|
||||
showBorder={false}
|
||||
showBackButton={false}
|
||||
title=""
|
||||
renderButton={() => {
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
style={[s.flexRow, s.alignCenter]}
|
||||
onPress={skip}>
|
||||
<Text style={[pal.link]}>Skip</Text>
|
||||
<FontAwesomeIcon
|
||||
icon={'chevron-right'}
|
||||
size={14}
|
||||
color={pal.colors.link}
|
||||
/>
|
||||
</Pressable>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<View>
|
||||
<Text style={[pal.text, styles.title]}>
|
||||
Welcome to{' '}
|
||||
<Text style={[pal.text, pal.link, styles.title]}>Bluesky</Text>
|
||||
</Text>
|
||||
<View style={styles.spacer} />
|
||||
<View style={[styles.row]}>
|
||||
<FontAwesomeIcon icon={'globe'} size={36} color={pal.colors.link} />
|
||||
<View style={[styles.rowText]}>
|
||||
<Text type="lg-bold" style={[pal.text]}>
|
||||
Bluesky is public.
|
||||
</Text>
|
||||
<Text type="lg-thin" style={[pal.text, s.pt2]}>
|
||||
Your posts, likes, and blocks are public. Mutes are private.
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={[styles.row]}>
|
||||
<FontAwesomeIcon icon={'at'} size={36} color={pal.colors.link} />
|
||||
<View style={[styles.rowText]}>
|
||||
<Text type="lg-bold" style={[pal.text]}>
|
||||
Bluesky is open.
|
||||
</Text>
|
||||
<Text type="lg-thin" style={[pal.text, s.pt2]}>
|
||||
Never lose access to your followers and data.
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={[styles.row]}>
|
||||
<FontAwesomeIcon icon={'gear'} size={36} color={pal.colors.link} />
|
||||
<View style={[styles.rowText]}>
|
||||
<Text type="lg-bold" style={[pal.text]}>
|
||||
Bluesky is flexible.
|
||||
</Text>
|
||||
<Text type="lg-thin" style={[pal.text, s.pt2]}>
|
||||
Choose the algorithms that power your experience with custom
|
||||
feeds.
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Button
|
||||
onPress={next}
|
||||
label="Continue"
|
||||
testID="continueBtn"
|
||||
labelStyle={styles.buttonText}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
})
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
marginBottom: isDesktopWeb ? 30 : 60,
|
||||
marginHorizontal: 16,
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
title: {
|
||||
fontSize: 42,
|
||||
fontWeight: '800',
|
||||
},
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
columnGap: 20,
|
||||
alignItems: 'center',
|
||||
marginVertical: 20,
|
||||
},
|
||||
rowText: {
|
||||
flex: 1,
|
||||
},
|
||||
spacer: {
|
||||
height: 20,
|
||||
},
|
||||
buttonText: {
|
||||
textAlign: 'center',
|
||||
fontSize: 18,
|
||||
marginVertical: 4,
|
||||
},
|
||||
})
|
|
@ -9,6 +9,7 @@ import {observer} from 'mobx-react-lite'
|
|||
import {useStores} from 'state/index'
|
||||
import {CenteredView} from '../util/Views'
|
||||
import {LoggedOut} from './LoggedOut'
|
||||
import {Onboarding} from './Onboarding'
|
||||
import {Text} from '../util/text/Text'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {STATUS_PAGE_URL} from 'lib/constants'
|
||||
|
@ -24,6 +25,9 @@ export const withAuthRequired = <P extends object>(
|
|||
if (!store.session.hasSession) {
|
||||
return <LoggedOut />
|
||||
}
|
||||
if (store.onboarding.isActive) {
|
||||
return <Onboarding />
|
||||
}
|
||||
return <Component {...props} />
|
||||
})
|
||||
|
||||
|
|
|
@ -28,7 +28,6 @@ import * as AddAppPassword from './AddAppPasswords'
|
|||
import * as ContentFilteringSettingsModal from './ContentFilteringSettings'
|
||||
import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings'
|
||||
import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings'
|
||||
import * as OnboardingModal from './OnboardingModal'
|
||||
import * as ModerationDetailsModal from './ModerationDetails'
|
||||
|
||||
const DEFAULT_SNAPPOINTS = ['90%']
|
||||
|
@ -130,9 +129,6 @@ export const ModalsContainer = observer(function ModalsContainer() {
|
|||
} else if (activeModal?.name === 'post-languages-settings') {
|
||||
snapPoints = PostLanguagesSettingsModal.snapPoints
|
||||
element = <PostLanguagesSettingsModal.Component />
|
||||
} else if (activeModal?.name === 'onboarding') {
|
||||
snapPoints = OnboardingModal.snapPoints
|
||||
element = <OnboardingModal.Component />
|
||||
} else if (activeModal?.name === 'moderation-details') {
|
||||
snapPoints = ModerationDetailsModal.snapPoints
|
||||
element = <ModerationDetailsModal.Component {...activeModal} />
|
||||
|
|
|
@ -26,7 +26,6 @@ import * as AddAppPassword from './AddAppPasswords'
|
|||
import * as ContentFilteringSettingsModal from './ContentFilteringSettings'
|
||||
import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings'
|
||||
import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings'
|
||||
import * as OnboardingModal from './OnboardingModal'
|
||||
import * as ModerationDetailsModal from './ModerationDetails'
|
||||
|
||||
export const ModalsContainer = observer(function ModalsContainer() {
|
||||
|
@ -105,8 +104,6 @@ function Modal({modal}: {modal: ModalIface}) {
|
|||
element = <AltTextImageModal.Component {...modal} />
|
||||
} else if (modal.name === 'edit-image') {
|
||||
element = <EditImageModal.Component {...modal} />
|
||||
} else if (modal.name === 'onboarding') {
|
||||
element = <OnboardingModal.Component />
|
||||
} else if (modal.name === 'moderation-details') {
|
||||
element = <ModerationDetailsModal.Component {...modal} />
|
||||
} else {
|
||||
|
|
|
@ -1,8 +0,0 @@
|
|||
import React from 'react'
|
||||
import {Onboarding} from '../auth/onboarding/Onboarding'
|
||||
|
||||
export const snapPoints = ['90%']
|
||||
|
||||
export function Component() {
|
||||
return <Onboarding />
|
||||
}
|
|
@ -17,6 +17,7 @@ const BACK_HITSLOP = {left: 20, top: 20, right: 50, bottom: 20}
|
|||
export const ViewHeader = observer(function ({
|
||||
title,
|
||||
canGoBack,
|
||||
showBackButton = true,
|
||||
hideOnScroll,
|
||||
showOnDesktop,
|
||||
showBorder,
|
||||
|
@ -24,6 +25,7 @@ export const ViewHeader = observer(function ({
|
|||
}: {
|
||||
title: string
|
||||
canGoBack?: boolean
|
||||
showBackButton?: boolean
|
||||
hideOnScroll?: boolean
|
||||
showOnDesktop?: boolean
|
||||
showBorder?: boolean
|
||||
|
@ -49,7 +51,13 @@ export const ViewHeader = observer(function ({
|
|||
|
||||
if (isDesktopWeb) {
|
||||
if (showOnDesktop) {
|
||||
return <DesktopWebHeader title={title} renderButton={renderButton} />
|
||||
return (
|
||||
<DesktopWebHeader
|
||||
title={title}
|
||||
renderButton={renderButton}
|
||||
showBorder={showBorder}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return null
|
||||
} else {
|
||||
|
@ -59,30 +67,32 @@ export const ViewHeader = observer(function ({
|
|||
|
||||
return (
|
||||
<Container hideOnScroll={hideOnScroll || false} showBorder={showBorder}>
|
||||
<TouchableOpacity
|
||||
testID="viewHeaderDrawerBtn"
|
||||
onPress={canGoBack ? onPressBack : onPressMenu}
|
||||
hitSlop={BACK_HITSLOP}
|
||||
style={canGoBack ? styles.backBtn : styles.backBtnWide}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={canGoBack ? 'Back' : 'Menu'}
|
||||
accessibilityHint={
|
||||
canGoBack ? '' : 'Access navigation links and settings'
|
||||
}>
|
||||
{canGoBack ? (
|
||||
<FontAwesomeIcon
|
||||
size={18}
|
||||
icon="angle-left"
|
||||
style={[styles.backIcon, pal.text]}
|
||||
/>
|
||||
) : (
|
||||
<FontAwesomeIcon
|
||||
size={18}
|
||||
icon="bars"
|
||||
style={[styles.backIcon, pal.textLight]}
|
||||
/>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
{showBackButton ? (
|
||||
<TouchableOpacity
|
||||
testID="viewHeaderDrawerBtn"
|
||||
onPress={canGoBack ? onPressBack : onPressMenu}
|
||||
hitSlop={BACK_HITSLOP}
|
||||
style={canGoBack ? styles.backBtn : styles.backBtnWide}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={canGoBack ? 'Back' : 'Menu'}
|
||||
accessibilityHint={
|
||||
canGoBack ? '' : 'Access navigation links and settings'
|
||||
}>
|
||||
{canGoBack ? (
|
||||
<FontAwesomeIcon
|
||||
size={18}
|
||||
icon="angle-left"
|
||||
style={[styles.backIcon, pal.text]}
|
||||
/>
|
||||
) : (
|
||||
<FontAwesomeIcon
|
||||
size={18}
|
||||
icon="bars"
|
||||
style={[styles.backIcon, pal.textLight]}
|
||||
/>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
) : null}
|
||||
<View style={styles.titleContainer} pointerEvents="none">
|
||||
<Text type="title" style={[pal.text, styles.title]}>
|
||||
{title}
|
||||
|
@ -90,9 +100,9 @@ export const ViewHeader = observer(function ({
|
|||
</View>
|
||||
{renderButton ? (
|
||||
renderButton()
|
||||
) : (
|
||||
) : showBackButton ? (
|
||||
<View style={canGoBack ? styles.backBtn : styles.backBtnWide} />
|
||||
)}
|
||||
) : null}
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
@ -101,13 +111,23 @@ export const ViewHeader = observer(function ({
|
|||
function DesktopWebHeader({
|
||||
title,
|
||||
renderButton,
|
||||
showBorder = true,
|
||||
}: {
|
||||
title: string
|
||||
renderButton?: () => JSX.Element
|
||||
showBorder?: boolean
|
||||
}) {
|
||||
const pal = usePalette('default')
|
||||
return (
|
||||
<CenteredView style={[styles.header, styles.desktopHeader, pal.border]}>
|
||||
<CenteredView
|
||||
style={[
|
||||
styles.header,
|
||||
styles.desktopHeader,
|
||||
pal.border,
|
||||
{
|
||||
borderBottomWidth: showBorder ? 1 : 0,
|
||||
},
|
||||
]}>
|
||||
<View style={styles.titleContainer} pointerEvents="none">
|
||||
<Text type="title-lg" style={[pal.text, styles.title]}>
|
||||
{title}
|
||||
|
@ -195,13 +215,11 @@ const styles = StyleSheet.create({
|
|||
width: '100%',
|
||||
},
|
||||
desktopHeader: {
|
||||
borderBottomWidth: 1,
|
||||
paddingVertical: 12,
|
||||
},
|
||||
border: {
|
||||
borderBottomWidth: 1,
|
||||
},
|
||||
|
||||
titleContainer: {
|
||||
marginLeft: 'auto',
|
||||
marginRight: 'auto',
|
||||
|
|
8
src/view/com/util/layouts/Breakpoints.tsx
Normal file
8
src/view/com/util/layouts/Breakpoints.tsx
Normal file
|
@ -0,0 +1,8 @@
|
|||
import React from 'react'
|
||||
|
||||
export const Desktop = ({}: React.PropsWithChildren<{}>) => null
|
||||
export const TabletOrDesktop = ({}: React.PropsWithChildren<{}>) => null
|
||||
export const Tablet = ({}: React.PropsWithChildren<{}>) => null
|
||||
export const TabletOrMobile = ({children}: React.PropsWithChildren<{}>) =>
|
||||
children
|
||||
export const Mobile = ({children}: React.PropsWithChildren<{}>) => children
|
20
src/view/com/util/layouts/Breakpoints.web.tsx
Normal file
20
src/view/com/util/layouts/Breakpoints.web.tsx
Normal file
|
@ -0,0 +1,20 @@
|
|||
import React from 'react'
|
||||
import MediaQuery from 'react-responsive'
|
||||
|
||||
export const Desktop = ({children}: React.PropsWithChildren<{}>) => (
|
||||
<MediaQuery minWidth={1224}>{children}</MediaQuery>
|
||||
)
|
||||
export const TabletOrDesktop = ({children}: React.PropsWithChildren<{}>) => (
|
||||
<MediaQuery minWidth={800}>{children}</MediaQuery>
|
||||
)
|
||||
export const Tablet = ({children}: React.PropsWithChildren<{}>) => (
|
||||
<MediaQuery minWidth={800} maxWidth={1224}>
|
||||
{children}
|
||||
</MediaQuery>
|
||||
)
|
||||
export const TabletOrMobile = ({children}: React.PropsWithChildren<{}>) => (
|
||||
<MediaQuery maxWidth={1224}>{children}</MediaQuery>
|
||||
)
|
||||
export const Mobile = ({children}: React.PropsWithChildren<{}>) => (
|
||||
<MediaQuery maxWidth={800}>{children}</MediaQuery>
|
||||
)
|
69
src/view/com/util/layouts/TitleColumnLayout.tsx
Normal file
69
src/view/com/util/layouts/TitleColumnLayout.tsx
Normal file
|
@ -0,0 +1,69 @@
|
|||
import React from 'react'
|
||||
import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle'
|
||||
|
||||
interface Props {
|
||||
testID?: string
|
||||
title: JSX.Element
|
||||
horizontal: boolean
|
||||
titleStyle?: StyleProp<ViewStyle>
|
||||
contentStyle?: StyleProp<ViewStyle>
|
||||
}
|
||||
|
||||
export function TitleColumnLayout({
|
||||
testID,
|
||||
title,
|
||||
horizontal,
|
||||
children,
|
||||
titleStyle,
|
||||
contentStyle,
|
||||
}: React.PropsWithChildren<Props>) {
|
||||
const pal = usePalette('default')
|
||||
const titleBg = useColorSchemeStyle(pal.viewLight, pal.view)
|
||||
const contentBg = useColorSchemeStyle(pal.view, {
|
||||
backgroundColor: pal.colors.background,
|
||||
borderColor: pal.colors.border,
|
||||
borderLeftWidth: 1,
|
||||
})
|
||||
|
||||
const layoutStyles = horizontal ? styles2Column : styles1Column
|
||||
return (
|
||||
<View testID={testID} style={layoutStyles.container}>
|
||||
<View style={[layoutStyles.title, titleBg, titleStyle]}>{title}</View>
|
||||
<View style={[layoutStyles.content, contentBg, contentStyle]}>
|
||||
{children}
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles2Column = StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
height: '100%',
|
||||
},
|
||||
title: {
|
||||
flex: 1,
|
||||
paddingHorizontal: 40,
|
||||
paddingBottom: 80,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
content: {
|
||||
flex: 2,
|
||||
paddingHorizontal: 40,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
})
|
||||
|
||||
const styles1Column = StyleSheet.create({
|
||||
container: {},
|
||||
title: {
|
||||
paddingHorizontal: 40,
|
||||
paddingVertical: 40,
|
||||
},
|
||||
content: {
|
||||
paddingHorizontal: 40,
|
||||
paddingVertical: 40,
|
||||
},
|
||||
})
|
21
src/view/com/util/layouts/withBreakpoints.tsx
Normal file
21
src/view/com/util/layouts/withBreakpoints.tsx
Normal file
|
@ -0,0 +1,21 @@
|
|||
import React from 'react'
|
||||
import {isNative} from 'platform/detection'
|
||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||
|
||||
export const withBreakpoints =
|
||||
<P extends object>(
|
||||
Mobile: React.ComponentType<P>,
|
||||
Tablet: React.ComponentType<P>,
|
||||
Desktop: React.ComponentType<P>,
|
||||
): React.FC<P> =>
|
||||
(props: P) => {
|
||||
const {isMobile, isTabletOrMobile} = useWebMediaQueries()
|
||||
|
||||
if (isMobile || isNative) {
|
||||
return <Mobile {...props} />
|
||||
}
|
||||
if (isTabletOrMobile) {
|
||||
return <Tablet {...props} />
|
||||
}
|
||||
return <Desktop {...props} />
|
||||
}
|
|
@ -92,6 +92,7 @@ import {faPlay} from '@fortawesome/free-solid-svg-icons/faPlay'
|
|||
import {faPause} from '@fortawesome/free-solid-svg-icons/faPause'
|
||||
import {faThumbtack} from '@fortawesome/free-solid-svg-icons/faThumbtack'
|
||||
import {faList} from '@fortawesome/free-solid-svg-icons/faList'
|
||||
import {faChevronRight} from '@fortawesome/free-solid-svg-icons/faChevronRight'
|
||||
|
||||
export function setup() {
|
||||
library.add(
|
||||
|
@ -187,5 +188,6 @@ export function setup() {
|
|||
faPlay,
|
||||
faPause,
|
||||
faList,
|
||||
faChevronRight,
|
||||
)
|
||||
}
|
||||
|
|
|
@ -31,7 +31,7 @@ const POLL_FREQ = 30e3 // 30sec
|
|||
|
||||
type Props = NativeStackScreenProps<HomeTabNavigatorParams, 'Home'>
|
||||
export const HomeScreen = withAuthRequired(
|
||||
observer((_opts: Props) => {
|
||||
observer(({}: Props) => {
|
||||
const store = useStores()
|
||||
const pagerRef = React.useRef<PagerRef>(null)
|
||||
const [selectedPage, setSelectedPage] = React.useState(0)
|
||||
|
|
|
@ -162,6 +162,11 @@ export const SettingsScreen = withAuthRequired(
|
|||
Toast.show('Preferences reset')
|
||||
}, [store])
|
||||
|
||||
const onPressResetOnboarding = React.useCallback(async () => {
|
||||
store.onboarding.reset()
|
||||
Toast.show('Onboarding reset')
|
||||
}, [store])
|
||||
|
||||
const onPressBuildInfo = React.useCallback(() => {
|
||||
Clipboard.setString(
|
||||
`Build version: ${AppInfo.appVersion}; Platform: ${Platform.OS}`,
|
||||
|
@ -533,6 +538,16 @@ export const SettingsScreen = withAuthRequired(
|
|||
Reset preferences state
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[pal.view, styles.linkCardNoIcon]}
|
||||
onPress={onPressResetOnboarding}
|
||||
accessibilityRole="button"
|
||||
accessibilityHint="Reset onboarding"
|
||||
accessibilityLabel="Resets the onboarding state">
|
||||
<Text type="lg" style={pal.text}>
|
||||
Reset onboarding state
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</>
|
||||
) : null}
|
||||
<View style={[styles.footer]}>
|
||||
|
|
|
@ -20,7 +20,6 @@ import {NavigationProp} from 'lib/routes/types'
|
|||
const ShellInner = observer(() => {
|
||||
const store = useStores()
|
||||
const {isDesktop} = useWebMediaQueries()
|
||||
|
||||
const navigator = useNavigation<NavigationProp>()
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -29,6 +28,9 @@ const ShellInner = observer(() => {
|
|||
})
|
||||
}, [navigator, store.shell])
|
||||
|
||||
const showBottomBar = !isDesktop && !store.onboarding.isActive
|
||||
const showSideNavs =
|
||||
isDesktop && store.session.hasSession && !store.onboarding.isActive
|
||||
return (
|
||||
<>
|
||||
<View style={s.hContentRegion}>
|
||||
|
@ -36,7 +38,7 @@ const ShellInner = observer(() => {
|
|||
<FlatNavigator />
|
||||
</ErrorBoundary>
|
||||
</View>
|
||||
{isDesktop && store.session.hasSession && (
|
||||
{showSideNavs && (
|
||||
<>
|
||||
<DesktopLeftNav />
|
||||
<DesktopRightNav />
|
||||
|
@ -51,7 +53,7 @@ const ShellInner = observer(() => {
|
|||
onPost={store.shell.composerOpts?.onPost}
|
||||
mention={store.shell.composerOpts?.mention}
|
||||
/>
|
||||
{!isDesktop && <BottomBarWeb />}
|
||||
{showBottomBar && <BottomBarWeb />}
|
||||
<ModalsContainer />
|
||||
<Lightbox />
|
||||
{!isDesktop && store.shell.isDrawerOpen && (
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue