diff options
author | Paul Frazee <pfrazee@gmail.com> | 2024-07-03 19:05:19 -0700 |
---|---|---|
committer | GitHub <noreply@github.com> | 2024-07-04 03:05:19 +0100 |
commit | 0ed99b840d8de13465f010a6434dea50c72b3f62 (patch) | |
tree | 75ebec28653a081793ca0cbca0c428a816980c6a /src/components/ProgressGuide | |
parent | aa7117edb60711a67464f7559118334185f01680 (diff) | |
download | voidsky-0ed99b840d8de13465f010a6434dea50c72b3f62.tar.zst |
New user progress guides (#4716)
* Add the animated checkmark svg * Add progress guide list and task components * Add ProgressGuide Toast component * Implement progress-guide controller * Add 7 follows to the progress guide * Wire up action captures * Wire up progress-guide persistence * Trigger progress guide on account creation * Clear the progress guide from storage on complete * Add progress guide interstitial, put behind gate * Fix: read progress guide state from prefs * Some defensive type checks * Create separate toast for completion * List tweaks * Only show on Discover * Spacing and progress tweaks * Completely hide when complete * Capture the progress guide in local state, and only render toasts while guide is active * Fix: ensure persisted hydrates into local state * Gate --------- Co-authored-by: Eric Bailey <git@esb.lol> Co-authored-by: Dan Abramov <dan.abramov@gmail.com>
Diffstat (limited to 'src/components/ProgressGuide')
-rw-r--r-- | src/components/ProgressGuide/List.tsx | 61 | ||||
-rw-r--r-- | src/components/ProgressGuide/Task.tsx | 50 | ||||
-rw-r--r-- | src/components/ProgressGuide/Toast.tsx | 169 |
3 files changed, 280 insertions, 0 deletions
diff --git a/src/components/ProgressGuide/List.tsx b/src/components/ProgressGuide/List.tsx new file mode 100644 index 000000000..f68445d2b --- /dev/null +++ b/src/components/ProgressGuide/List.tsx @@ -0,0 +1,61 @@ +import React from 'react' +import {StyleProp, View, ViewStyle} from 'react-native' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import { + useProgressGuide, + useProgressGuideControls, +} from '#/state/shell/progress-guide' +import {atoms as a, useTheme} from '#/alf' +import {Button, ButtonIcon} from '#/components/Button' +import {TimesLarge_Stroke2_Corner0_Rounded as Times} from '#/components/icons/Times' +import {Text} from '#/components/Typography' +import {ProgressGuideTask} from './Task' + +export function ProgressGuideList({style}: {style?: StyleProp<ViewStyle>}) { + const t = useTheme() + const {_} = useLingui() + const guide = useProgressGuide('like-10-and-follow-7') + const {endProgressGuide} = useProgressGuideControls() + + if (guide) { + return ( + <View style={[a.flex_col, a.gap_md, style]}> + <View style={[a.flex_row, a.align_center, a.justify_between]}> + <Text + style={[ + t.atoms.text_contrast_medium, + a.font_semibold, + a.text_sm, + {textTransform: 'uppercase'}, + ]}> + <Trans>Getting started</Trans> + </Text> + <Button + variant="ghost" + size="tiny" + color="secondary" + shape="round" + label={_(msg`Dismiss getting started guide`)} + onPress={endProgressGuide}> + <ButtonIcon icon={Times} size="sm" /> + </Button> + </View> + <ProgressGuideTask + current={guide.numLikes + 1} + total={10 + 1} + title={_(msg`Like 10 posts`)} + subtitle={_(msg`Teach our algorithm what you like`)} + /> + <ProgressGuideTask + current={guide.numFollows + 1} + total={7 + 1} + title={_(msg`Follow 7 accounts`)} + subtitle={_(msg`Bluesky is better with friends!`)} + /> + </View> + ) + } + return null +} diff --git a/src/components/ProgressGuide/Task.tsx b/src/components/ProgressGuide/Task.tsx new file mode 100644 index 000000000..d286b8842 --- /dev/null +++ b/src/components/ProgressGuide/Task.tsx @@ -0,0 +1,50 @@ +import React from 'react' +import {View} from 'react-native' +import * as Progress from 'react-native-progress' + +import {atoms as a, useTheme} from '#/alf' +import {AnimatedCheck} from '../anim/AnimatedCheck' +import {Text} from '../Typography' + +export function ProgressGuideTask({ + current, + total, + title, + subtitle, +}: { + current: number + total: number + title: string + subtitle?: string +}) { + const t = useTheme() + + return ( + <View style={[a.flex_row, a.gap_sm, !subtitle && a.align_center]}> + {current === total ? ( + <AnimatedCheck playOnMount fill={t.palette.primary_500} width={24} /> + ) : ( + <Progress.Circle + progress={current / total} + color={t.palette.primary_400} + size={20} + thickness={3} + borderWidth={0} + unfilledColor={t.palette.contrast_50} + /> + )} + + <View style={[a.flex_col, a.gap_2xs, {marginTop: -2}]}> + <Text style={[a.text_sm, a.font_semibold, a.leading_tight]}> + {title} + </Text> + {subtitle && ( + <Text + style={[a.text_sm, t.atoms.text_contrast_medium, a.leading_tight]}> + {subtitle} + </Text> + )} + </View> + </View> + ) +} diff --git a/src/components/ProgressGuide/Toast.tsx b/src/components/ProgressGuide/Toast.tsx new file mode 100644 index 000000000..346312af5 --- /dev/null +++ b/src/components/ProgressGuide/Toast.tsx @@ -0,0 +1,169 @@ +import React, {useImperativeHandle} from 'react' +import {Pressable, useWindowDimensions, View} from 'react-native' +import Animated, { + Easing, + runOnJS, + useAnimatedStyle, + useSharedValue, + withTiming, +} from 'react-native-reanimated' +import {useSafeAreaInsets} from 'react-native-safe-area-context' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {isWeb} from '#/platform/detection' +import {atoms as a, useTheme} from '#/alf' +import {Portal} from '#/components/Portal' +import {AnimatedCheck, AnimatedCheckRef} from '../anim/AnimatedCheck' +import {Text} from '../Typography' + +export interface ProgressGuideToastRef { + open(): void + close(): void +} + +export interface ProgressGuideToastProps { + title: string + subtitle?: string + visibleDuration?: number // default 5s +} + +export const ProgressGuideToast = React.forwardRef< + ProgressGuideToastRef, + ProgressGuideToastProps +>(function ProgressGuideToast({title, subtitle, visibleDuration}, ref) { + const t = useTheme() + const {_} = useLingui() + const insets = useSafeAreaInsets() + const [isOpen, setIsOpen] = React.useState(false) + const translateY = useSharedValue(0) + const opacity = useSharedValue(0) + const animatedCheckRef = React.useRef<AnimatedCheckRef | null>(null) + const timeoutRef = React.useRef<NodeJS.Timeout | undefined>() + const winDim = useWindowDimensions() + + /** + * Methods + */ + + const close = React.useCallback(() => { + // clear the timeout, in case this was called imperatively + if (timeoutRef.current) { + clearTimeout(timeoutRef.current) + timeoutRef.current = undefined + } + + // animate the opacity then set isOpen to false when done + const setIsntOpen = () => setIsOpen(false) + opacity.value = withTiming( + 0, + { + duration: 400, + easing: Easing.out(Easing.cubic), + }, + () => runOnJS(setIsntOpen)(), + ) + }, [setIsOpen, opacity]) + + const open = React.useCallback(() => { + // set isOpen=true to render + setIsOpen(true) + + // animate the vertical translation, the opacity, and the checkmark + const playCheckmark = () => animatedCheckRef.current?.play() + opacity.value = 0 + opacity.value = withTiming( + 1, + { + duration: 100, + easing: Easing.out(Easing.cubic), + }, + () => runOnJS(playCheckmark)(), + ) + translateY.value = 0 + translateY.value = withTiming(insets.top + 10, { + duration: 500, + easing: Easing.out(Easing.cubic), + }) + + // start the countdown timer to autoclose + timeoutRef.current = setTimeout(close, visibleDuration || 5e3) + }, [setIsOpen, translateY, opacity, insets, close, visibleDuration]) + + useImperativeHandle( + ref, + () => ({ + open, + close, + }), + [open, close], + ) + + const containerStyle = React.useMemo(() => { + let left = 10 + let right = 10 + if (isWeb && winDim.width > 400) { + left = right = (winDim.width - 380) / 2 + } + return { + position: isWeb ? 'fixed' : 'absolute', + top: 0, + left, + right, + } + }, [winDim.width]) + + const animatedStyle = useAnimatedStyle(() => ({ + transform: [{translateY: translateY.value}], + opacity: opacity.value, + })) + + return ( + isOpen && ( + <Portal> + <Animated.View + style={[ + // @ts-ignore position: fixed is web only + containerStyle, + animatedStyle, + ]}> + <Pressable + style={[ + t.atoms.bg, + a.flex_row, + a.align_center, + a.gap_md, + a.border, + t.atoms.border_contrast_high, + a.rounded_md, + a.px_lg, + a.py_md, + a.shadow_sm, + { + shadowRadius: 8, + shadowOpacity: 0.1, + shadowOffset: {width: 0, height: 2}, + elevation: 8, + }, + ]} + onPress={close} + accessibilityLabel={_(msg`Tap to dismiss`)} + accessibilityHint=""> + <AnimatedCheck + fill={t.palette.primary_500} + ref={animatedCheckRef} + /> + <View> + <Text style={[a.text_md, a.font_semibold]}>{title}</Text> + {subtitle && ( + <Text style={[a.text_sm, t.atoms.text_contrast_medium]}> + {subtitle} + </Text> + )} + </View> + </Pressable> + </Animated.View> + </Portal> + ) + ) +}) |