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

View File

@ -1,56 +1,80 @@
import React from 'react'
import {Nux, useNuxs, useUpsertNuxMutation} from '#/state/queries/nuxs'
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'
type Context = {
controls: {
tenMillion: Dialog.DialogOuterProps['control']
}
activeNux: Nux | undefined
dismissActiveNux: () => void
}
const queuedNuxs = [Nux.TenMillionDialog]
const Context = React.createContext<Context>({
// @ts-ignore
controls: {},
activeNux: undefined,
dismissActiveNux: () => {},
})
export function useContext() {
export function useNuxDialogContext() {
return React.useContext(Context)
}
let SHOWN = false
export function NuxDialogs() {
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(() => {
return {
controls: {
tenMillion,
},
activeNux,
dismissActiveNux,
}
}, [tenMillion])
React.useEffect(() => {
if (!hasSession) return
const t = setTimeout(() => {
if (!SHOWN) {
SHOWN = true
ctx.controls.tenMillion.open()
}
}, 2e3)
return () => {
clearTimeout(t)
}
}, [ctx, hasSession])
}, [activeNux, dismissActiveNux])
return (
<Context.Provider value={ctx}>
<TenMillion />
{activeNux === Nux.TenMillionDialog && <TenMillion />}
</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'
export enum Nux {
One = 'one',
Two = 'two',
TenMillionDialog = 'TenMillionDialog',
}
export const nuxNames = new Set(Object.values(Nux))
export type AppNux =
| BaseNux<{
id: Nux.One
data: {
likes: number
}
}>
| BaseNux<{
id: Nux.Two
data: undefined
}>
export type AppNux = BaseNux<{
id: Nux.TenMillionDialog
data: undefined
}>
export const NuxSchemas = {
[Nux.One]: zod.object({
likes: zod.number(),
}),
[Nux.Two]: undefined,
export const NuxSchemas: Record<Nux, zod.ZodObject<any> | undefined> = {
[Nux.TenMillionDialog]: undefined,
}

View File

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