Refactor, integrate nux, snoozing

zio/dev^2
Eric Bailey 2024-09-11 21:20:39 -05:00
parent 63444052e8
commit 9bb385a4dd
5 changed files with 182 additions and 131 deletions

View File

@ -1,5 +1,6 @@
import React from 'react' import React from 'react'
import {View} from 'react-native' import {View} from 'react-native'
import Animated, {FadeIn} from 'react-native-reanimated'
import ViewShot from 'react-native-view-shot' import ViewShot from 'react-native-view-shot'
import {Image} from 'expo-image' import {Image} from 'expo-image'
import {moderateProfile} from '@atproto/api' import {moderateProfile} from '@atproto/api'
@ -17,7 +18,6 @@ import {useProfileQuery} from '#/state/queries/profile'
import {useAgent, useSession} from '#/state/session' import {useAgent, useSession} from '#/state/session'
import {useComposerControls} from 'state/shell' import {useComposerControls} from 'state/shell'
import {formatCount} from '#/view/com/util/numeric/format' import {formatCount} from '#/view/com/util/numeric/format'
// import {UserAvatar} from '#/view/com/util/UserAvatar'
import {Logomark} from '#/view/icons/Logomark' import {Logomark} from '#/view/icons/Logomark'
import { import {
atoms as a, atoms as a,
@ -28,7 +28,7 @@ import {
} from '#/alf' } from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog' import * as Dialog from '#/components/Dialog'
import {useContext} from '#/components/dialogs/nuxs' import {useNuxDialogContext} from '#/components/dialogs/nuxs'
import {OnePercent} from '#/components/dialogs/nuxs/TenMillion/icons/OnePercent' import {OnePercent} from '#/components/dialogs/nuxs/TenMillion/icons/OnePercent'
import {PointOnePercent} from '#/components/dialogs/nuxs/TenMillion/icons/PointOnePercent' import {PointOnePercent} from '#/components/dialogs/nuxs/TenMillion/icons/PointOnePercent'
import {TenPercent} from '#/components/dialogs/nuxs/TenMillion/icons/TenPercent' import {TenPercent} from '#/components/dialogs/nuxs/TenMillion/icons/TenPercent'
@ -39,7 +39,6 @@ import {Download_Stroke2_Corner0_Rounded as Download} from '#/components/icons/D
import {Image_Stroke2_Corner0_Rounded as ImageIcon} from '#/components/icons/Image' import {Image_Stroke2_Corner0_Rounded as ImageIcon} from '#/components/icons/Image'
import {Loader} from '#/components/Loader' import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
// import {TwentyFivePercent} from '#/components/dialogs/nuxs/TenMillion/icons/TwentyFivePercent'
const DEBUG = false const DEBUG = false
const RATIO = 8 / 10 const RATIO = 8 / 10
@ -65,9 +64,6 @@ function getPercentBadge(percent: number) {
} else if (percent <= 0.1) { } else if (percent <= 0.1) {
return TenPercent return TenPercent
} }
// else if (percent <= 0.25) {
// return TwentyFivePercent
// }
return null return null
} }
@ -88,41 +84,13 @@ function Frame({children}: {children: React.ReactNode}) {
} }
export function TenMillion() { export function TenMillion() {
const {hasSession} = useSession()
return hasSession ? <TenMillionInner /> : null
}
export function TenMillionInner() {
const t = useTheme()
const lightTheme = useTheme('light')
const {_, i18n} = useLingui()
const {controls} = useContext()
const {gtMobile} = useBreakpoints()
const {openComposer} = useComposerControls()
const {currentAccount} = useSession()
const {isLoading: isProfileLoading, data: profile} = useProfileQuery({
did: currentAccount!.did,
})
const moderationOpts = useModerationOpts()
const moderation = React.useMemo(() => {
return profile && moderationOpts
? moderateProfile(profile, moderationOpts)
: undefined
}, [profile, moderationOpts])
const [uri, setUri] = React.useState<string | null>(null)
const [userNumber, setUserNumber] = React.useState<number>(0)
const [error, setError] = React.useState('')
const isLoadingData =
isProfileLoading || !moderation || !profile || !userNumber
const isLoadingImage = !uri
const percent = userNumber / 10_000_000
const Badge = getPercentBadge(percent)
const agent = useAgent() const agent = useAgent()
const nuxDialogs = useNuxDialogContext()
const [userNumber, setUserNumber] = React.useState<number>(0)
React.useEffect(() => { React.useEffect(() => {
async function fetchUserNumber() { async function fetchUserNumber() {
// TODO check for 3p PDS
if (agent.session?.accessJwt) { if (agent.session?.accessJwt) {
const res = await fetch( const res = await fetch(
`https://bsky.social/xrpc/com.atproto.temp.getSignupNumber`, `https://bsky.social/xrpc/com.atproto.temp.getSignupNumber`,
@ -146,26 +114,83 @@ export function TenMillionInner() {
} }
networkRetry(3, fetchUserNumber).catch(() => { networkRetry(3, fetchUserNumber).catch(() => {
setError( nuxDialogs.dismissActiveNux()
_(
msg`Oh no! We couldn't fetch your user number. Rest assured, we're glad you're here ❤️`,
),
)
}) })
}, [ }, [
_,
agent.session?.accessJwt, agent.session?.accessJwt,
setUserNumber, setUserNumber,
controls.tenMillion, nuxDialogs.dismissActiveNux,
setError, nuxDialogs,
]) ])
const sharePost = () => { return userNumber ? <TenMillionInner userNumber={userNumber} /> : null
}
export function TenMillionInner({userNumber}: {userNumber: number}) {
const t = useTheme()
const lightTheme = useTheme('light')
const {_, i18n} = useLingui()
const control = Dialog.useDialogControl()
const {gtMobile} = useBreakpoints()
const {openComposer} = useComposerControls()
const {currentAccount} = useSession()
const {
isLoading: isProfileLoading,
data: profile,
error: profileError,
} = useProfileQuery({
did: currentAccount!.did,
})
const moderationOpts = useModerationOpts()
const nuxDialogs = useNuxDialogContext()
const moderation = React.useMemo(() => {
return profile && moderationOpts
? moderateProfile(profile, moderationOpts)
: undefined
}, [profile, moderationOpts])
const [uri, setUri] = React.useState<string | null>(null)
const percent = userNumber / 10_000_000
const Badge = getPercentBadge(percent)
const isLoadingData = isProfileLoading || !moderation || !profile
const isLoadingImage = !uri
const error: string = React.useMemo(() => {
if (profileError) {
return _(
msg`Oh no! We weren't able to generate an image for you to share. Rest assured, we're glad you're here 🦋`,
)
}
return ''
}, [_, profileError])
/*
* Opening and closing
*/
React.useEffect(() => {
const timeout = setTimeout(() => {
control.open()
}, 3e3)
return () => {
clearTimeout(timeout)
}
}, [control])
const onClose = React.useCallback(() => {
nuxDialogs.dismissActiveNux()
}, [nuxDialogs])
/*
* Actions
*/
const sharePost = React.useCallback(() => {
if (uri) { if (uri) {
controls.tenMillion.close(() => { control.close(() => {
setTimeout(() => { setTimeout(() => {
openComposer({ openComposer({
text: '10 milly, babyyy', text: _(
msg`I'm user #${i18n.number(
userNumber,
)} out of 10M. What a ride 😎`,
), // TODO
imageUris: [ imageUris: [
{ {
uri, uri,
@ -177,17 +202,15 @@ export function TenMillionInner() {
}, 1e3) }, 1e3)
}) })
} }
} }, [_, i18n, control, openComposer, uri, userNumber])
const onNativeShare = React.useCallback(() => {
const onNativeShare = () => {
if (uri) { if (uri) {
controls.tenMillion.close(() => { control.close(() => {
shareUrl(uri) shareUrl(uri)
}) })
} }
} }, [uri, control])
const download = React.useCallback(async () => {
const download = async () => {
if (uri) { if (uri) {
const canvas = await getCanvas(uri) const canvas = await getCanvas(uri)
const imgHref = canvas const imgHref = canvas
@ -198,32 +221,24 @@ export function TenMillionInner() {
link.setAttribute('href', imgHref) link.setAttribute('href', imgHref)
link.click() link.click()
} }
} }, [uri])
/*
* Canvas stuff
*/
const imageRef = React.useRef<ViewShot>(null) const imageRef = React.useRef<ViewShot>(null)
// const captureInProgress = React.useRef(false) const captureInProgress = React.useRef(false)
// const [cavasRelayout, setCanvasRelayout] = React.useState('key') const onCanvasReady = React.useCallback(async () => {
// const onCanvasReady = async () => {
// if (
// imageRef.current &&
// imageRef.current.capture &&
// !captureInProgress.current
// ) {
// captureInProgress.current = true
// setCanvasRelayout('updated')
// }
// }
const onCanvasLayout = async () => {
if ( if (
imageRef.current && imageRef.current &&
imageRef.current.capture // && imageRef.current.capture &&
// cavasRelayout === 'updated' !captureInProgress.current
) { ) {
captureInProgress.current = true
const uri = await imageRef.current.capture() const uri = await imageRef.current.capture()
setUri(uri) setUri(uri)
} }
} }, [setUri])
const canvas = isLoadingData ? null : ( const canvas = isLoadingData ? null : (
<View <View
style={[ style={[
@ -247,8 +262,7 @@ export function TenMillionInner() {
options={{width: WIDTH, height: HEIGHT}} options={{width: WIDTH, height: HEIGHT}}
style={[a.absolute, a.inset_0]}> style={[a.absolute, a.inset_0]}>
<View <View
// key={cavasRelayout} onLayout={onCanvasReady}
onLayout={onCanvasLayout}
style={[ style={[
a.absolute, a.absolute,
a.inset_0, a.inset_0,
@ -442,7 +456,7 @@ export function TenMillionInner() {
) )
return ( return (
<Dialog.Outer control={controls.tenMillion}> <Dialog.Outer control={control} onClose={onClose}>
<Dialog.ScrollableInner <Dialog.ScrollableInner
label={_(msg`Ten Million`)} label={_(msg`Ten Million`)}
style={[ style={[
@ -494,11 +508,15 @@ export function TenMillionInner() {
) : isLoadingData || isLoadingImage ? ( ) : isLoadingData || isLoadingImage ? (
<Loader size="xl" fill="white" /> <Loader size="xl" fill="white" />
) : ( ) : (
<Animated.View
entering={FadeIn.duration(150)}
style={[a.w_full, a.h_full]}>
<Image <Image
accessibilityIgnoresInvertColors accessibilityIgnoresInvertColors
source={{uri}} source={{uri}}
style={[a.w_full, a.h_full]} style={[a.w_full, a.h_full]}
/> />
</Animated.View>
)} )}
</View> </View>
</Frame> </Frame>

View File

@ -1,56 +1,80 @@
import React from 'react' import React from 'react'
import {Nux, useNuxs, useUpsertNuxMutation} from '#/state/queries/nuxs'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
import * as Dialog from '#/components/Dialog' import {isSnoozed, snooze} from '#/components/dialogs/nuxs/snoozing'
import {TenMillion} from '#/components/dialogs/nuxs/TenMillion' import {TenMillion} from '#/components/dialogs/nuxs/TenMillion'
type Context = { type Context = {
controls: { activeNux: Nux | undefined
tenMillion: Dialog.DialogOuterProps['control'] dismissActiveNux: () => void
}
} }
const queuedNuxs = [Nux.TenMillionDialog]
const Context = React.createContext<Context>({ const Context = React.createContext<Context>({
// @ts-ignore activeNux: undefined,
controls: {}, dismissActiveNux: () => {},
}) })
export function useContext() { export function useNuxDialogContext() {
return React.useContext(Context) return React.useContext(Context)
} }
let SHOWN = false
export function NuxDialogs() { export function NuxDialogs() {
const {hasSession} = useSession() const {hasSession} = useSession()
const tenMillion = Dialog.useDialogControl() return hasSession ? <Inner /> : null
}
function Inner() {
const {nuxs} = useNuxs()
const [snoozed, setSnoozed] = React.useState(() => {
return isSnoozed()
})
const [activeNux, setActiveNux] = React.useState<Nux | undefined>()
const {mutate: upsertNux} = useUpsertNuxMutation()
const snoozeNuxDialog = React.useCallback(() => {
snooze()
setSnoozed(true)
}, [setSnoozed])
const dismissActiveNux = React.useCallback(() => {
setActiveNux(undefined)
upsertNux({
id: activeNux!,
completed: true,
data: undefined,
})
}, [activeNux, setActiveNux, upsertNux])
React.useEffect(() => {
if (snoozed) return
if (!nuxs) return
for (const id of queuedNuxs) {
const nux = nuxs.find(nux => nux.id === id)
if (nux && nux.completed) continue
setActiveNux(id)
// snooze immediately upon enabling
snoozeNuxDialog()
break
}
}, [nuxs, snoozed, snoozeNuxDialog])
const ctx = React.useMemo(() => { const ctx = React.useMemo(() => {
return { return {
controls: { activeNux,
tenMillion, dismissActiveNux,
},
} }
}, [tenMillion]) }, [activeNux, dismissActiveNux])
React.useEffect(() => {
if (!hasSession) return
const t = setTimeout(() => {
if (!SHOWN) {
SHOWN = true
ctx.controls.tenMillion.open()
}
}, 2e3)
return () => {
clearTimeout(t)
}
}, [ctx, hasSession])
return ( return (
<Context.Provider value={ctx}> <Context.Provider value={ctx}>
<TenMillion /> {activeNux === Nux.TenMillionDialog && <TenMillion />}
</Context.Provider> </Context.Provider>
) )
} }

View File

@ -0,0 +1,18 @@
import {simpleAreDatesEqual} from '#/lib/strings/time'
import {device} from '#/storage'
export function snooze() {
device.set(['lastNuxDialog'], new Date().toISOString())
}
export function isSnoozed() {
const lastNuxDialog = device.get(['lastNuxDialog'])
if (!lastNuxDialog) return false
const last = new Date(lastNuxDialog)
const now = new Date()
// already snoozed today
if (simpleAreDatesEqual(last, now)) {
return true
}
return false
}

View File

@ -3,27 +3,16 @@ import zod from 'zod'
import {BaseNux} from '#/state/queries/nuxs/types' import {BaseNux} from '#/state/queries/nuxs/types'
export enum Nux { export enum Nux {
One = 'one', TenMillionDialog = 'TenMillionDialog',
Two = 'two',
} }
export const nuxNames = new Set(Object.values(Nux)) export const nuxNames = new Set(Object.values(Nux))
export type AppNux = export type AppNux = BaseNux<{
| BaseNux<{ id: Nux.TenMillionDialog
id: Nux.One
data: {
likes: number
}
}>
| BaseNux<{
id: Nux.Two
data: undefined data: undefined
}> }>
export const NuxSchemas = { export const NuxSchemas: Record<Nux, zod.ZodObject<any> | undefined> = {
[Nux.One]: zod.object({ [Nux.TenMillionDialog]: undefined,
likes: zod.number(),
}),
[Nux.Two]: undefined,
} }

View File

@ -1,4 +1,6 @@
/** /**
* Device data that's specific to the device and does not vary based account * Device data that's specific to the device and does not vary based account
*/ */
export type Device = {} export type Device = {
lastNuxDialog: string
}