about summary refs log tree commit diff
path: root/src/components/ProgressGuide
diff options
context:
space:
mode:
Diffstat (limited to 'src/components/ProgressGuide')
-rw-r--r--src/components/ProgressGuide/List.tsx61
-rw-r--r--src/components/ProgressGuide/Task.tsx50
-rw-r--r--src/components/ProgressGuide/Toast.tsx169
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>
+    )
+  )
+})