Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dc21472525 | |||
| db5cf919dc | |||
| 9c5420c0b2 | |||
| 813e9e73c0 | |||
| ef2a11795f | |||
| 8a0400d9bb | |||
| 7d3368cc14 | |||
| 9513b574df | |||
| 55a6a1c7af | |||
| 2123840756 | |||
| d12efd5745 |
+1
-1
@@ -49,7 +49,7 @@
|
||||
"open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web"
|
||||
},
|
||||
"dependencies": {
|
||||
"@atproto/api": "^0.12.22",
|
||||
"@atproto/api": "^0.12.23",
|
||||
"@bam.tech/react-native-image-resizer": "^3.0.4",
|
||||
"@braintree/sanitize-url": "^6.0.2",
|
||||
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
|
||||
|
||||
+8
-4
@@ -45,6 +45,7 @@ import {
|
||||
import {readLastActiveAccount} from '#/state/session/util'
|
||||
import {Provider as ShellStateProvider} from '#/state/shell'
|
||||
import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out'
|
||||
import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide'
|
||||
import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
|
||||
import {Provider as StarterPackProvider} from '#/state/shell/starter-pack'
|
||||
import {TestCtrls} from '#/view/com/testing/TestCtrls'
|
||||
@@ -119,10 +120,13 @@ function InnerApp() {
|
||||
<BackgroundNotificationPreferencesProvider>
|
||||
<MutedThreadsProvider>
|
||||
<TourProvider>
|
||||
<GestureHandlerRootView style={s.h100pct}>
|
||||
<TestCtrls />
|
||||
<Shell />
|
||||
</GestureHandlerRootView>
|
||||
<ProgressGuideProvider>
|
||||
<GestureHandlerRootView
|
||||
style={s.h100pct}>
|
||||
<TestCtrls />
|
||||
<Shell />
|
||||
</GestureHandlerRootView>
|
||||
</ProgressGuideProvider>
|
||||
</TourProvider>
|
||||
</MutedThreadsProvider>
|
||||
</BackgroundNotificationPreferencesProvider>
|
||||
|
||||
+4
-1
@@ -34,6 +34,7 @@ import {
|
||||
import {readLastActiveAccount} from '#/state/session/util'
|
||||
import {Provider as ShellStateProvider} from '#/state/shell'
|
||||
import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out'
|
||||
import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide'
|
||||
import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
|
||||
import {Provider as StarterPackProvider} from '#/state/shell/starter-pack'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
@@ -104,7 +105,9 @@ function InnerApp() {
|
||||
<MutedThreadsProvider>
|
||||
<SafeAreaProvider>
|
||||
<TourProvider>
|
||||
<Shell />
|
||||
<ProgressGuideProvider>
|
||||
<Shell />
|
||||
</ProgressGuideProvider>
|
||||
</TourProvider>
|
||||
</SafeAreaProvider>
|
||||
</MutedThreadsProvider>
|
||||
|
||||
@@ -6,6 +6,7 @@ import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
|
||||
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
|
||||
import {NavigationProp} from '#/lib/routes/types'
|
||||
import {logEvent} from '#/lib/statsig/statsig'
|
||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||
@@ -20,6 +21,7 @@ import {PersonPlus_Stroke2_Corner0_Rounded as Person} from '#/components/icons/P
|
||||
import {InlineLinkText} from '#/components/Link'
|
||||
import * as ProfileCard from '#/components/ProfileCard'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {ProgressGuideList} from './ProgressGuide/List'
|
||||
|
||||
function CardOuter({
|
||||
children,
|
||||
@@ -352,3 +354,25 @@ export function SuggestedFeeds() {
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export function ProgressGuide() {
|
||||
const t = useTheme()
|
||||
const {isDesktop} = useWebMediaQueries()
|
||||
|
||||
if (isDesktop) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.border_t,
|
||||
t.atoms.border_contrast_low,
|
||||
a.px_lg,
|
||||
a.py_lg,
|
||||
a.pb_lg,
|
||||
]}>
|
||||
<ProgressGuideList />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
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]}>
|
||||
<Text
|
||||
style={[
|
||||
t.atoms.text_contrast_medium,
|
||||
a.font_semibold,
|
||||
a.text_sm,
|
||||
{textTransform: 'uppercase'},
|
||||
]}>
|
||||
<Trans>Get started</Trans>
|
||||
</Text>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="tiny"
|
||||
color="secondary"
|
||||
label={_(msg`Dismiss getting started guide`)}
|
||||
onPress={endProgressGuide}>
|
||||
<ButtonIcon icon={Times} />
|
||||
</Button>
|
||||
</View>
|
||||
<ProgressGuideTask
|
||||
current={guide.numLikes}
|
||||
total={10}
|
||||
title={_(msg`Like 10 posts`)}
|
||||
subtitle={_(msg`Teach Discover what you like.`)}
|
||||
/>
|
||||
<ProgressGuideTask
|
||||
current={guide.numFollows}
|
||||
total={7}
|
||||
title={_(msg`Follow 7 accounts`)}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
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_md, !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={24}
|
||||
thickness={2}
|
||||
borderWidth={0}
|
||||
unfilledColor={t.palette.contrast_50}
|
||||
/>
|
||||
)}
|
||||
|
||||
<View style={[a.flex_col, a.gap_xs]}>
|
||||
<Text style={[a.text_md, a.font_semibold]}>{title}</Text>
|
||||
{subtitle && (
|
||||
<Text style={[a.text_sm, t.atoms.text_contrast_medium]}>
|
||||
{subtitle}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,92 @@
|
||||
import React from 'react'
|
||||
import Animated, {
|
||||
Easing,
|
||||
useAnimatedProps,
|
||||
useSharedValue,
|
||||
withDelay,
|
||||
withTiming,
|
||||
} from 'react-native-reanimated'
|
||||
import Svg, {Circle, Path} from 'react-native-svg'
|
||||
|
||||
import {Props, useCommonSVGProps} from '#/components/icons/common'
|
||||
|
||||
const AnimatedPath = Animated.createAnimatedComponent(Path)
|
||||
const AnimatedCircle = Animated.createAnimatedComponent(Circle)
|
||||
|
||||
const PATH = 'M14.1 27.2l7.1 7.2 16.7-16.8'
|
||||
|
||||
export interface AnimatedCheckRef {
|
||||
play(cb?: () => void): void
|
||||
}
|
||||
|
||||
export interface AnimatedCheckProps extends Props {
|
||||
playOnMount?: boolean
|
||||
}
|
||||
|
||||
export const AnimatedCheck = React.forwardRef<
|
||||
AnimatedCheckRef,
|
||||
AnimatedCheckProps
|
||||
>(function AnimatedCheck({playOnMount, ...props}, ref) {
|
||||
const {fill, size, style, ...rest} = useCommonSVGProps(props)
|
||||
const circleAnim = useSharedValue(0)
|
||||
const checkAnim = useSharedValue(0)
|
||||
|
||||
const circleAnimatedProps = useAnimatedProps(() => ({
|
||||
strokeDashoffset: 166 - circleAnim.value * 166,
|
||||
}))
|
||||
const checkAnimatedProps = useAnimatedProps(() => ({
|
||||
strokeDashoffset: 48 - 48 * checkAnim.value,
|
||||
}))
|
||||
|
||||
const play = React.useCallback(
|
||||
(cb?: () => void) => {
|
||||
circleAnim.value = 0
|
||||
checkAnim.value = 0
|
||||
|
||||
circleAnim.value = withTiming(1, {duration: 500, easing: Easing.linear})
|
||||
checkAnim.value = withDelay(
|
||||
500,
|
||||
withTiming(1, {duration: 300, easing: Easing.linear}, cb),
|
||||
)
|
||||
},
|
||||
[circleAnim, checkAnim],
|
||||
)
|
||||
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
play,
|
||||
}))
|
||||
|
||||
React.useEffect(() => {
|
||||
if (playOnMount) {
|
||||
play()
|
||||
}
|
||||
}, [play, playOnMount])
|
||||
|
||||
return (
|
||||
<Svg
|
||||
fill="none"
|
||||
{...rest}
|
||||
viewBox="0 0 52 52"
|
||||
width={size}
|
||||
height={size}
|
||||
style={style}>
|
||||
<AnimatedCircle
|
||||
animatedProps={circleAnimatedProps}
|
||||
cx="26"
|
||||
cy="26"
|
||||
r="24"
|
||||
fill="none"
|
||||
stroke={fill}
|
||||
strokeWidth={4}
|
||||
strokeDasharray={166}
|
||||
/>
|
||||
<AnimatedPath
|
||||
animatedProps={checkAnimatedProps}
|
||||
stroke={fill}
|
||||
d={PATH}
|
||||
strokeWidth={4}
|
||||
strokeDasharray={48}
|
||||
/>
|
||||
</Svg>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,88 @@
|
||||
import React from 'react'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {
|
||||
ProgressGuideToast,
|
||||
ProgressGuideToastRef,
|
||||
} from '#/components/ProgressGuide/Toast'
|
||||
import {useNudges, NudgeType} from '#/components/nudges'
|
||||
|
||||
export type Action = {
|
||||
type: 'activate'
|
||||
} | {
|
||||
type: 'like'
|
||||
}
|
||||
|
||||
export function useProgressGuide() {
|
||||
const {nudges, activateNudges, updateNudges, dismissNudges} = useNudges()
|
||||
const firstLikeToastRef = React.useRef<ProgressGuideToastRef | null>(null)
|
||||
const fifthLikeToastRef = React.useRef<ProgressGuideToastRef | null>(null)
|
||||
const tenthLikeToastRef = React.useRef<ProgressGuideToastRef | null>(null)
|
||||
const isActive = Boolean(nudges.find(n => n.type === NudgeType.progressGuide_like10Follow7))
|
||||
|
||||
const dispatch = (action: Action) => {
|
||||
const prevState = nudges.find(n => n.type === NudgeType.progressGuide_like10Follow7)
|
||||
|
||||
if (!prevState || prevState.type !== NudgeType.progressGuide_like10Follow7) return
|
||||
|
||||
let next = prevState
|
||||
|
||||
switch (action.type) {
|
||||
case 'activate': {
|
||||
activateNudges([
|
||||
{
|
||||
type: NudgeType.progressGuide_like10Follow7,
|
||||
numLikes: 0,
|
||||
numFollows: 0,
|
||||
complete: false,
|
||||
},
|
||||
])
|
||||
break;
|
||||
}
|
||||
case 'like': {
|
||||
next = {
|
||||
...next,
|
||||
numLikes: prevState.numLikes + 1,
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isActive,
|
||||
dispatch,
|
||||
refs: {
|
||||
firstLikeToastRef,
|
||||
fifthLikeToastRef,
|
||||
tenthLikeToastRef,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function Toasts() {
|
||||
const {_} = useLingui()
|
||||
const {isActive, refs} = useProgressGuide()
|
||||
|
||||
return isActive ? (
|
||||
<>
|
||||
<ProgressGuideToast
|
||||
ref={refs.firstLikeToastRef}
|
||||
title={_(msg`Your first like!`)}
|
||||
subtitle={_(msg`Like 10 posts to train the Discover feed`)}
|
||||
/>
|
||||
<ProgressGuideToast
|
||||
ref={refs.fifthLikeToastRef}
|
||||
title={_(msg`Half way there!`)}
|
||||
subtitle={_(msg`Like 10 posts to train the Discover feed`)}
|
||||
/>
|
||||
<ProgressGuideToast
|
||||
ref={refs.tenthLikeToastRef}
|
||||
title={_(msg`Task complete - 10 likes!`)}
|
||||
subtitle={_(msg`The Discover feed now knows what you like`)}
|
||||
/>
|
||||
</>
|
||||
) : null
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import React from 'react'
|
||||
|
||||
import {Nudge} from '#/components/nudges/types'
|
||||
|
||||
export * from '#/components/nudges/types'
|
||||
|
||||
export type Context = {
|
||||
nudges: Nudge[]
|
||||
activateNudges: (nudges: Nudge[]) => Promise<void>
|
||||
updateNudges: (nudges: Nudge[]) => Promise<void>
|
||||
dismissNudges: (nudges: Nudge[]) => Promise<void>
|
||||
}
|
||||
|
||||
const Context = React.createContext<Context>({
|
||||
nudges: [],
|
||||
activateNudges: async () => {},
|
||||
updateNudges: async () => {},
|
||||
dismissNudges: async () => {},
|
||||
})
|
||||
|
||||
export function useNudges() {
|
||||
return React.useContext(Context)
|
||||
}
|
||||
|
||||
export function NudgeProvider({children}: {children: React.ReactNode}) {
|
||||
// TODO hydrate from remote storage
|
||||
const [nudges, setNudges] = React.useState<Nudge[]>([])
|
||||
|
||||
const activateNudges = async (nudges: Nudge[]) => {
|
||||
setNudges(s => Array.from(new Set([...s, ...nudges])))
|
||||
// TODO write to remote storage
|
||||
}
|
||||
|
||||
const updateNudges = async (nudges: Nudge[]) => {
|
||||
setNudges(s => [
|
||||
...s.filter(n => !nudges.some(nudge => nudge.type === n.type)),
|
||||
...nudges,
|
||||
])
|
||||
// TODO write to remote storage
|
||||
}
|
||||
|
||||
const dismissNudges = async (nudges: Nudge[]) => {
|
||||
setNudges(s => s.filter(n => !nudges.some(nudge => nudge.type === n.type)))
|
||||
// TODO write to remote storage
|
||||
}
|
||||
|
||||
const ctx = React.useMemo<Context>(
|
||||
() => ({nudges, activateNudges, updateNudges, dismissNudges}),
|
||||
[nudges],
|
||||
)
|
||||
|
||||
return <Context.Provider value={ctx}>{children}</Context.Provider>
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
export enum NudgeType {
|
||||
/*
|
||||
* Feed interstitials
|
||||
*/
|
||||
interstitialPopularFeeds = 'interstitialPopularFeeds',
|
||||
interstitialSuggestedFollows = 'interstitialSuggestedFollows',
|
||||
|
||||
/*
|
||||
* Progress guides
|
||||
*/
|
||||
progressGuide_like10Follow7 = 'progressGuide_like10Follow7',
|
||||
}
|
||||
|
||||
export type Interstitials = {
|
||||
type: NudgeType.interstitialPopularFeeds
|
||||
hidden: boolean
|
||||
} | {
|
||||
type: NudgeType.interstitialSuggestedFollows
|
||||
hidden: boolean
|
||||
}
|
||||
|
||||
export type ProgressGuides = {
|
||||
type: NudgeType.progressGuide_like10Follow7
|
||||
numLikes: number
|
||||
numFollows: number
|
||||
complete: boolean
|
||||
}
|
||||
|
||||
export type Nudge = Interstitials | ProgressGuides
|
||||
@@ -7,5 +7,6 @@ export type Gate =
|
||||
| 'show_avi_follow_button'
|
||||
| 'show_follow_back_label_v2'
|
||||
| 'new_user_guided_tour'
|
||||
| 'new_user_progress_guide'
|
||||
| 'suggested_feeds_interstitial'
|
||||
| 'suggested_follows_interstitial'
|
||||
|
||||
@@ -19,6 +19,7 @@ import {preferencesQueryKey} from '#/state/queries/preferences'
|
||||
import {RQKEY as profileRQKey} from '#/state/queries/profile'
|
||||
import {useAgent} from '#/state/session'
|
||||
import {useOnboardingDispatch} from '#/state/shell'
|
||||
import {useProgressGuideControls} from '#/state/shell/progress-guide'
|
||||
import {uploadBlob} from 'lib/api'
|
||||
import {useRequestNotificationsPermission} from 'lib/notifications/notifications'
|
||||
import {useSetHasCheckedForStarterPack} from 'state/preferences/used-starter-packs'
|
||||
@@ -58,6 +59,7 @@ export function StepFinished() {
|
||||
const setActiveStarterPack = useSetActiveStarterPack()
|
||||
const setHasCheckedForStarterPack = useSetHasCheckedForStarterPack()
|
||||
const setQueuedTour = useSetQueuedTour()
|
||||
const {startProgressGuide} = useProgressGuideControls()
|
||||
|
||||
const finishOnboarding = React.useCallback(async () => {
|
||||
setSaving(true)
|
||||
@@ -185,6 +187,7 @@ export function StepFinished() {
|
||||
setActiveStarterPack(undefined)
|
||||
setHasCheckedForStarterPack(true)
|
||||
setQueuedTour(TOURS.HOME)
|
||||
startProgressGuide('like-10-and-follow-7')
|
||||
dispatch({type: 'finish'})
|
||||
onboardDispatch({type: 'finish'})
|
||||
track('OnboardingV2:StepFinished:End')
|
||||
@@ -218,6 +221,7 @@ export function StepFinished() {
|
||||
setActiveStarterPack,
|
||||
setHasCheckedForStarterPack,
|
||||
setQueuedTour,
|
||||
startProgressGuide,
|
||||
])
|
||||
|
||||
React.useEffect(() => {
|
||||
|
||||
@@ -23,6 +23,10 @@ import {
|
||||
import {cleanError} from '#/lib/strings/errors'
|
||||
import {logger} from '#/logger'
|
||||
import {useDeleteStarterPackMutation} from '#/state/queries/starter-packs'
|
||||
import {
|
||||
ProgressGuideAction,
|
||||
useProgressGuideControls,
|
||||
} from '#/state/shell/progress-guide'
|
||||
import {batchedUpdates} from 'lib/batchedUpdates'
|
||||
import {HITSLOP_20} from 'lib/constants'
|
||||
import {isBlockedOrBlocking, isMuted} from 'lib/moderation/blocked-and-muted'
|
||||
@@ -305,6 +309,7 @@ function Header({
|
||||
const queryClient = useQueryClient()
|
||||
const setActiveStarterPack = useSetActiveStarterPack()
|
||||
const {requestSwitchToAccount} = useLoggedOutViewControls()
|
||||
const {captureAction} = useProgressGuideControls()
|
||||
|
||||
const [isProcessing, setIsProcessing] = React.useState(false)
|
||||
|
||||
@@ -369,6 +374,7 @@ function Header({
|
||||
starterPack: starterPack.uri,
|
||||
count: dids.length,
|
||||
})
|
||||
captureAction(ProgressGuideAction.Follow, dids.length)
|
||||
Toast.show(_(msg`All accounts have been followed!`))
|
||||
} catch (e) {
|
||||
Toast.show(_(msg`An error occurred while trying to follow all`))
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
// import {useQueueNudgesMutation, useDismissNudgesMutation} from '#/state/queries/preferences'
|
||||
|
||||
export enum NudgeType {
|
||||
/*
|
||||
* Feed interstitials
|
||||
*/
|
||||
interstitialPopularFeeds = 'interstitialPopularFeeds',
|
||||
interstitialSuggestedFollows = 'interstitialSuggestedFollows',
|
||||
|
||||
/*
|
||||
* Progress guides
|
||||
*/
|
||||
progressGuide_like10Follow7 = 'progressGuide_like10Follow7',
|
||||
}
|
||||
|
||||
export type Interstitials = {
|
||||
type: NudgeType.interstitialPopularFeeds
|
||||
} | {
|
||||
type: NudgeType.interstitialSuggestedFollows
|
||||
}
|
||||
|
||||
export type ProgressGuides = {
|
||||
type: NudgeType.progressGuide_like10Follow7
|
||||
numLikes: number
|
||||
numFollows: number
|
||||
}
|
||||
|
||||
export type Nudge = Interstitials | ProgressGuides
|
||||
|
||||
let __temp_storage: Nudge[] = []
|
||||
|
||||
export function useQueueNudges() {
|
||||
const queueNudges = async (nudges: Nudge[]) => {
|
||||
__temp_storage.push(...nudges)
|
||||
}
|
||||
|
||||
return { queueNudges }
|
||||
}
|
||||
|
||||
export function useDismissNudges() {
|
||||
const dismissNudges = async (nudges: Nudge[]) => {
|
||||
__temp_storage = __temp_storage.filter(n => {
|
||||
return !nudges.some(nudge => nudge.type === n.type)
|
||||
})
|
||||
}
|
||||
|
||||
return { dismissNudges }
|
||||
}
|
||||
@@ -34,4 +34,8 @@ export const DEFAULT_LOGGED_OUT_PREFERENCES: UsePreferencesQueryResponse = {
|
||||
userAge: 13, // TODO(pwi)
|
||||
interests: {tags: []},
|
||||
savedFeeds: [],
|
||||
bskyAppState: {
|
||||
queuedNudges: [],
|
||||
activeProgressGuide: undefined,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -342,3 +342,50 @@ export function useRemoveMutedWordMutation() {
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useQueueNudgesMutation() {
|
||||
const queryClient = useQueryClient()
|
||||
const agent = useAgent()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (nudges: string | string[]) => {
|
||||
await agent.bskyAppQueueNudges(nudges)
|
||||
// triggers a refetch
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: preferencesQueryKey,
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useDismissNudgesMutation() {
|
||||
const queryClient = useQueryClient()
|
||||
const agent = useAgent()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (nudges: string | string[]) => {
|
||||
await agent.bskyAppDismissNudges(nudges)
|
||||
// triggers a refetch
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: preferencesQueryKey,
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useSetActiveProgressGuideMutation() {
|
||||
const queryClient = useQueryClient()
|
||||
const agent = useAgent()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (
|
||||
guide: AppBskyActorDefs.BskyAppProgressGuide | undefined,
|
||||
) => {
|
||||
await agent.bskyAppSetActiveProgressGuide(guide)
|
||||
// triggers a refetch
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: preferencesQueryKey,
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -25,6 +25,10 @@ import {STALE} from '#/state/queries'
|
||||
import {resetProfilePostsQueries} from '#/state/queries/post-feed'
|
||||
import {updateProfileShadow} from '../cache/profile-shadow'
|
||||
import {useAgent, useSession} from '../session'
|
||||
import {
|
||||
ProgressGuideAction,
|
||||
useProgressGuideControls,
|
||||
} from '../shell/progress-guide'
|
||||
import {RQKEY as RQKEY_LIST_CONVOS} from './messages/list-converations'
|
||||
import {RQKEY as RQKEY_MY_BLOCKED} from './my-blocked-accounts'
|
||||
import {RQKEY as RQKEY_MY_MUTED} from './my-muted-accounts'
|
||||
@@ -274,12 +278,15 @@ function useProfileFollowMutation(
|
||||
const {currentAccount} = useSession()
|
||||
const agent = useAgent()
|
||||
const queryClient = useQueryClient()
|
||||
const {captureAction} = useProgressGuideControls()
|
||||
|
||||
return useMutation<{uri: string; cid: string}, Error, {did: string}>({
|
||||
mutationFn: async ({did}) => {
|
||||
let ownProfile: AppBskyActorDefs.ProfileViewDetailed | undefined
|
||||
if (currentAccount) {
|
||||
ownProfile = findProfileQueryData(queryClient, currentAccount.did)
|
||||
}
|
||||
captureAction(ProgressGuideAction.Follow)
|
||||
logEvent('profile:follow', {
|
||||
logContext,
|
||||
didBecomeMutual: profile.viewer
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import React from 'react'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useGate} from '#/lib/statsig/statsig'
|
||||
import {
|
||||
ProgressGuideToast,
|
||||
ProgressGuideToastRef,
|
||||
} from '#/components/ProgressGuide/Toast'
|
||||
import {useSetActiveProgressGuideMutation} from '../queries/preferences'
|
||||
|
||||
export enum ProgressGuideAction {
|
||||
Like = 'like',
|
||||
Follow = 'follow',
|
||||
}
|
||||
|
||||
type ProgressGuideName = 'like-10-and-follow-7'
|
||||
|
||||
interface BaseProgressGuide {
|
||||
guide: string
|
||||
isComplete: boolean
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
interface Like10AndFollow7ProgressGuide extends BaseProgressGuide {
|
||||
numLikes: number
|
||||
numFollows: number
|
||||
}
|
||||
|
||||
type ProgressGuide = Like10AndFollow7ProgressGuide | undefined
|
||||
|
||||
const ProgressGuideContext = React.createContext<ProgressGuide>(undefined)
|
||||
|
||||
const ProgressGuideControlContext = React.createContext<{
|
||||
startProgressGuide(guide: ProgressGuideName): void
|
||||
endProgressGuide(): void
|
||||
captureAction(action: ProgressGuideAction, count?: number): void
|
||||
}>({
|
||||
startProgressGuide: (_guide: ProgressGuideName) => {},
|
||||
endProgressGuide: () => {},
|
||||
captureAction: (_action: ProgressGuideAction, _count = 1) => {},
|
||||
})
|
||||
|
||||
export function useProgressGuide(guide: ProgressGuideName) {
|
||||
const ctx = React.useContext(ProgressGuideContext)
|
||||
if (ctx?.guide === guide) {
|
||||
return ctx
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function useProgressGuideControls() {
|
||||
return React.useContext(ProgressGuideControlContext)
|
||||
}
|
||||
|
||||
export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
const {_} = useLingui()
|
||||
const {mutate} = useSetActiveProgressGuideMutation()
|
||||
const gate = useGate()
|
||||
|
||||
const [activeProgressGuide, setActiveProgressGuide] =
|
||||
React.useState<ProgressGuide>(undefined)
|
||||
|
||||
const firstLikeToastRef = React.useRef<ProgressGuideToastRef | null>(null)
|
||||
const fifthLikeToastRef = React.useRef<ProgressGuideToastRef | null>(null)
|
||||
const tenthLikeToastRef = React.useRef<ProgressGuideToastRef | null>(null)
|
||||
|
||||
const controls = React.useMemo(() => {
|
||||
return {
|
||||
startProgressGuide(guide: ProgressGuideName) {
|
||||
if (!gate('new_user_progress_guide')) {
|
||||
return
|
||||
}
|
||||
if (guide === 'like-10-and-follow-7') {
|
||||
const guideObj = {
|
||||
guide: 'like-10-and-follow-7',
|
||||
numLikes: 0,
|
||||
numFollows: 0,
|
||||
isComplete: false,
|
||||
}
|
||||
mutate(guideObj)
|
||||
setActiveProgressGuide(guideObj)
|
||||
}
|
||||
},
|
||||
|
||||
endProgressGuide() {
|
||||
mutate(undefined)
|
||||
setActiveProgressGuide(undefined)
|
||||
},
|
||||
|
||||
captureAction(action: ProgressGuideAction, count = 1) {
|
||||
let guide = activeProgressGuide
|
||||
if (guide?.isComplete) {
|
||||
return
|
||||
}
|
||||
if (guide?.guide === 'like-10-and-follow-7') {
|
||||
if (action === ProgressGuideAction.Like) {
|
||||
guide = {
|
||||
...guide,
|
||||
numLikes: (guide.numLikes || 0) + count,
|
||||
}
|
||||
if (guide.numLikes === 1) {
|
||||
firstLikeToastRef.current?.open()
|
||||
}
|
||||
if (guide.numLikes === 5) {
|
||||
fifthLikeToastRef.current?.open()
|
||||
}
|
||||
if (guide.numLikes === 10) {
|
||||
tenthLikeToastRef.current?.open()
|
||||
}
|
||||
}
|
||||
if (action === ProgressGuideAction.Follow) {
|
||||
guide = {
|
||||
...guide,
|
||||
numFollows: (guide.numFollows || 0) + count,
|
||||
}
|
||||
}
|
||||
if (guide.numLikes >= 10 && guide.numFollows >= 7) {
|
||||
tenthLikeToastRef.current?.open()
|
||||
guide = {
|
||||
...guide,
|
||||
isComplete: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
mutate(guide?.isComplete ? undefined : guide)
|
||||
setActiveProgressGuide(guide)
|
||||
},
|
||||
}
|
||||
}, [activeProgressGuide, setActiveProgressGuide, mutate, gate])
|
||||
|
||||
return (
|
||||
<ProgressGuideContext.Provider value={activeProgressGuide}>
|
||||
<ProgressGuideControlContext.Provider value={controls}>
|
||||
{children}
|
||||
<ProgressGuideToast
|
||||
ref={firstLikeToastRef}
|
||||
title={_(msg`Your first like!`)}
|
||||
subtitle={_(msg`Like 10 posts to train the Discover feed`)}
|
||||
/>
|
||||
<ProgressGuideToast
|
||||
ref={fifthLikeToastRef}
|
||||
title={_(msg`Half way there!`)}
|
||||
subtitle={_(msg`Like 10 posts to train the Discover feed`)}
|
||||
/>
|
||||
<ProgressGuideToast
|
||||
ref={tenthLikeToastRef}
|
||||
title={_(msg`Task complete - 10 likes!`)}
|
||||
subtitle={_(msg`The Discover feed now knows what you like`)}
|
||||
/>
|
||||
</ProgressGuideControlContext.Provider>
|
||||
</ProgressGuideContext.Provider>
|
||||
)
|
||||
}
|
||||
@@ -34,7 +34,11 @@ import {useSession} from '#/state/session'
|
||||
import {useAnalytics} from 'lib/analytics/analytics'
|
||||
import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender'
|
||||
import {useTheme} from 'lib/ThemeContext'
|
||||
import {SuggestedFeeds, SuggestedFollows} from '#/components/FeedInterstitials'
|
||||
import {
|
||||
ProgressGuide,
|
||||
SuggestedFeeds,
|
||||
SuggestedFollows,
|
||||
} from '#/components/FeedInterstitials'
|
||||
import {List, ListRef} from '../util/List'
|
||||
import {PostFeedLoadingPlaceholder} from '../util/LoadingPlaceholder'
|
||||
import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn'
|
||||
@@ -85,14 +89,36 @@ type FeedItem =
|
||||
}
|
||||
slot: number
|
||||
}
|
||||
| {
|
||||
type: 'interstitialProgressGuide'
|
||||
key: string
|
||||
params: {
|
||||
variant: 'default' | string
|
||||
}
|
||||
slot: number
|
||||
}
|
||||
|
||||
const feedInterstitialType = 'interstitialFeeds'
|
||||
const followInterstitialType = 'interstitialFollows'
|
||||
const progressGuideInterstitialType = 'interstitialProgressGuide'
|
||||
const interstials: Record<
|
||||
'following' | 'discover',
|
||||
(FeedItem & {type: 'interstitialFeeds' | 'interstitialFollows'})[]
|
||||
(FeedItem & {
|
||||
type:
|
||||
| 'interstitialFeeds'
|
||||
| 'interstitialFollows'
|
||||
| 'interstitialProgressGuide'
|
||||
})[]
|
||||
> = {
|
||||
following: [
|
||||
{
|
||||
type: progressGuideInterstitialType,
|
||||
params: {
|
||||
variant: 'default',
|
||||
},
|
||||
key: progressGuideInterstitialType,
|
||||
slot: 0,
|
||||
},
|
||||
{
|
||||
type: followInterstitialType,
|
||||
params: {
|
||||
@@ -111,6 +137,14 @@ const interstials: Record<
|
||||
},
|
||||
],
|
||||
discover: [
|
||||
{
|
||||
type: progressGuideInterstitialType,
|
||||
params: {
|
||||
variant: 'default',
|
||||
},
|
||||
key: progressGuideInterstitialType,
|
||||
slot: 0,
|
||||
},
|
||||
{
|
||||
type: feedInterstitialType,
|
||||
params: {
|
||||
@@ -336,14 +370,14 @@ let Feed = ({
|
||||
|
||||
if (feedType) {
|
||||
for (const interstitial of interstials[feedType]) {
|
||||
const feedInterstitialEnabled =
|
||||
interstitial.type === feedInterstitialType &&
|
||||
gate('suggested_feeds_interstitial')
|
||||
const followInterstitialEnabled =
|
||||
interstitial.type === followInterstitialType &&
|
||||
gate('suggested_follows_interstitial')
|
||||
const shouldShow =
|
||||
(interstitial.type === feedInterstitialType &&
|
||||
gate('suggested_feeds_interstitial')) ||
|
||||
(interstitial.type === followInterstitialType &&
|
||||
gate('suggested_follows_interstitial')) ||
|
||||
interstitial.type === progressGuideInterstitialType
|
||||
|
||||
if (feedInterstitialEnabled || followInterstitialEnabled) {
|
||||
if (shouldShow) {
|
||||
const variant = 'default' // replace with experiment variant
|
||||
const int = {
|
||||
...interstitial,
|
||||
@@ -460,6 +494,8 @@ let Feed = ({
|
||||
return <SuggestedFeeds />
|
||||
} else if (item.type === followInterstitialType) {
|
||||
return <SuggestedFollows />
|
||||
} else if (item.type === progressGuideInterstitialType) {
|
||||
return <ProgressGuide />
|
||||
} else if (item.type === 'slice') {
|
||||
if (item.slice.rootUri === FALLBACK_MARKER_POST.post.uri) {
|
||||
// HACK
|
||||
|
||||
@@ -31,6 +31,10 @@ import {
|
||||
} from '#/state/queries/post'
|
||||
import {useRequireAuth, useSession} from '#/state/session'
|
||||
import {useComposerControls} from '#/state/shell/composer'
|
||||
import {
|
||||
ProgressGuideAction,
|
||||
useProgressGuideControls,
|
||||
} from '#/state/shell/progress-guide'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {useDialogControl} from '#/components/Dialog'
|
||||
import {ArrowOutOfBox_Stroke2_Corner0_Rounded as ArrowOutOfBox} from '#/components/icons/ArrowOutOfBox'
|
||||
@@ -77,6 +81,7 @@ let PostCtrls = ({
|
||||
const requireAuth = useRequireAuth()
|
||||
const loggedOutWarningPromptControl = useDialogControl()
|
||||
const {sendInteraction} = useFeedFeedbackContext()
|
||||
const {captureAction} = useProgressGuideControls()
|
||||
const playHaptic = useHaptics()
|
||||
const gate = useGate()
|
||||
|
||||
@@ -103,6 +108,7 @@ let PostCtrls = ({
|
||||
event: 'app.bsky.feed.defs#interactionLike',
|
||||
feedContext,
|
||||
})
|
||||
captureAction(ProgressGuideAction.Like)
|
||||
await queueLike()
|
||||
} else {
|
||||
await queueUnlike()
|
||||
@@ -119,6 +125,7 @@ let PostCtrls = ({
|
||||
queueLike,
|
||||
queueUnlike,
|
||||
sendInteraction,
|
||||
captureAction,
|
||||
feedContext,
|
||||
])
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import {Text} from 'view/com/util/text/Text'
|
||||
import {DesktopFeeds} from './Feeds'
|
||||
import {DesktopSearch} from './Search'
|
||||
import hairlineWidth = StyleSheet.hairlineWidth
|
||||
import {ProgressGuideList} from '#/components/ProgressGuide/List'
|
||||
|
||||
export function DesktopRightNav({routeName}: {routeName: string}) {
|
||||
const pal = usePalette('default')
|
||||
@@ -39,9 +40,12 @@ export function DesktopRightNav({routeName}: {routeName: string}) {
|
||||
<DesktopSearch />
|
||||
|
||||
{hasSession && (
|
||||
<View style={[pal.border, styles.desktopFeedsContainer]}>
|
||||
<DesktopFeeds />
|
||||
</View>
|
||||
<>
|
||||
<ProgressGuideList style={[{marginTop: 22, marginBottom: 8}]} />
|
||||
<View style={[pal.border, styles.desktopFeedsContainer]}>
|
||||
<DesktopFeeds />
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -34,15 +34,16 @@
|
||||
jsonpointer "^5.0.0"
|
||||
leven "^3.1.0"
|
||||
|
||||
"@atproto/api@^0.12.22":
|
||||
version "0.12.22"
|
||||
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.12.22.tgz#1880a93a0caa4485cd8463bd1e10bf2424b9826c"
|
||||
integrity sha512-TIXSnf3qqyX40Ei/FkK4H24w+7s5rOc63TPwrGakRBOqIgSNBKOggei8I600fJ/AXB7HO6Vp9tBmDVOt2+021A==
|
||||
"@atproto/api@^0.12.23":
|
||||
version "0.12.23"
|
||||
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.12.23.tgz#b3409817d0b981a64f30d16e8257f0fe261338af"
|
||||
integrity sha512-fgQ30u+q9smX5g41eep7fISSkSAhRkX0inc81PZ82QwcHbFkC8ePaha/KP0CoTaPWKi7EsC89Z/8BEBCJo0oBA==
|
||||
dependencies:
|
||||
"@atproto/common-web" "^0.3.0"
|
||||
"@atproto/lexicon" "^0.4.0"
|
||||
"@atproto/syntax" "^0.3.0"
|
||||
"@atproto/xrpc" "^0.5.0"
|
||||
await-lock "^2.2.2"
|
||||
multiformats "^9.9.0"
|
||||
tlds "^1.234.0"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user