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/Toast.tsx | |
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/Toast.tsx')
-rw-r--r-- | src/components/ProgressGuide/Toast.tsx | 169 |
1 files changed, 169 insertions, 0 deletions
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> + ) + ) +}) |