about summary refs log tree commit diff
path: root/src/components/ProgressGuide/Toast.tsx
diff options
context:
space:
mode:
authorPaul Frazee <pfrazee@gmail.com>2024-07-03 19:05:19 -0700
committerGitHub <noreply@github.com>2024-07-04 03:05:19 +0100
commit0ed99b840d8de13465f010a6434dea50c72b3f62 (patch)
tree75ebec28653a081793ca0cbca0c428a816980c6a /src/components/ProgressGuide/Toast.tsx
parentaa7117edb60711a67464f7559118334185f01680 (diff)
downloadvoidsky-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.tsx169
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>
+    )
+  )
+})