Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Refactor, integrate nux, snoozing

+179 -128
+99 -81
src/components/dialogs/nuxs/TenMillion/index.tsx
··· 1 1 import React from 'react' 2 2 import {View} from 'react-native' 3 + import Animated, {FadeIn} from 'react-native-reanimated' 3 4 import ViewShot from 'react-native-view-shot' 4 5 import {Image} from 'expo-image' 5 6 import {moderateProfile} from '@atproto/api' ··· 17 18 import {useAgent, useSession} from '#/state/session' 18 19 import {useComposerControls} from 'state/shell' 19 20 import {formatCount} from '#/view/com/util/numeric/format' 20 - // import {UserAvatar} from '#/view/com/util/UserAvatar' 21 21 import {Logomark} from '#/view/icons/Logomark' 22 22 import { 23 23 atoms as a, ··· 28 28 } from '#/alf' 29 29 import {Button, ButtonIcon, ButtonText} from '#/components/Button' 30 30 import * as Dialog from '#/components/Dialog' 31 - import {useContext} from '#/components/dialogs/nuxs' 31 + import {useNuxDialogContext} from '#/components/dialogs/nuxs' 32 32 import {OnePercent} from '#/components/dialogs/nuxs/TenMillion/icons/OnePercent' 33 33 import {PointOnePercent} from '#/components/dialogs/nuxs/TenMillion/icons/PointOnePercent' 34 34 import {TenPercent} from '#/components/dialogs/nuxs/TenMillion/icons/TenPercent' ··· 39 39 import {Image_Stroke2_Corner0_Rounded as ImageIcon} from '#/components/icons/Image' 40 40 import {Loader} from '#/components/Loader' 41 41 import {Text} from '#/components/Typography' 42 - // import {TwentyFivePercent} from '#/components/dialogs/nuxs/TenMillion/icons/TwentyFivePercent' 43 42 44 43 const DEBUG = false 45 44 const RATIO = 8 / 10 ··· 65 64 } else if (percent <= 0.1) { 66 65 return TenPercent 67 66 } 68 - // else if (percent <= 0.25) { 69 - // return TwentyFivePercent 70 - // } 71 67 return null 72 68 } 73 69 ··· 88 84 } 89 85 90 86 export function TenMillion() { 91 - const {hasSession} = useSession() 92 - return hasSession ? <TenMillionInner /> : null 93 - } 94 - 95 - export function TenMillionInner() { 96 - const t = useTheme() 97 - const lightTheme = useTheme('light') 98 - const {_, i18n} = useLingui() 99 - const {controls} = useContext() 100 - const {gtMobile} = useBreakpoints() 101 - const {openComposer} = useComposerControls() 102 - const {currentAccount} = useSession() 103 - const {isLoading: isProfileLoading, data: profile} = useProfileQuery({ 104 - did: currentAccount!.did, 105 - }) 106 - const moderationOpts = useModerationOpts() 107 - const moderation = React.useMemo(() => { 108 - return profile && moderationOpts 109 - ? moderateProfile(profile, moderationOpts) 110 - : undefined 111 - }, [profile, moderationOpts]) 112 - const [uri, setUri] = React.useState<string | null>(null) 87 + const agent = useAgent() 88 + const nuxDialogs = useNuxDialogContext() 113 89 const [userNumber, setUserNumber] = React.useState<number>(0) 114 - const [error, setError] = React.useState('') 115 90 116 - const isLoadingData = 117 - isProfileLoading || !moderation || !profile || !userNumber 118 - const isLoadingImage = !uri 119 - 120 - const percent = userNumber / 10_000_000 121 - const Badge = getPercentBadge(percent) 122 - 123 - const agent = useAgent() 124 91 React.useEffect(() => { 125 92 async function fetchUserNumber() { 93 + // TODO check for 3p PDS 126 94 if (agent.session?.accessJwt) { 127 95 const res = await fetch( 128 96 `https://bsky.social/xrpc/com.atproto.temp.getSignupNumber`, ··· 146 114 } 147 115 148 116 networkRetry(3, fetchUserNumber).catch(() => { 149 - setError( 150 - _( 151 - msg`Oh no! We couldn't fetch your user number. Rest assured, we're glad you're here ❤️`, 152 - ), 153 - ) 117 + nuxDialogs.dismissActiveNux() 154 118 }) 155 119 }, [ 156 - _, 157 120 agent.session?.accessJwt, 158 121 setUserNumber, 159 - controls.tenMillion, 160 - setError, 122 + nuxDialogs.dismissActiveNux, 123 + nuxDialogs, 161 124 ]) 162 125 163 - const sharePost = () => { 126 + return userNumber ? <TenMillionInner userNumber={userNumber} /> : null 127 + } 128 + 129 + export function TenMillionInner({userNumber}: {userNumber: number}) { 130 + const t = useTheme() 131 + const lightTheme = useTheme('light') 132 + const {_, i18n} = useLingui() 133 + const control = Dialog.useDialogControl() 134 + const {gtMobile} = useBreakpoints() 135 + const {openComposer} = useComposerControls() 136 + const {currentAccount} = useSession() 137 + const { 138 + isLoading: isProfileLoading, 139 + data: profile, 140 + error: profileError, 141 + } = useProfileQuery({ 142 + did: currentAccount!.did, 143 + }) 144 + const moderationOpts = useModerationOpts() 145 + const nuxDialogs = useNuxDialogContext() 146 + const moderation = React.useMemo(() => { 147 + return profile && moderationOpts 148 + ? moderateProfile(profile, moderationOpts) 149 + : undefined 150 + }, [profile, moderationOpts]) 151 + const [uri, setUri] = React.useState<string | null>(null) 152 + const percent = userNumber / 10_000_000 153 + const Badge = getPercentBadge(percent) 154 + const isLoadingData = isProfileLoading || !moderation || !profile 155 + const isLoadingImage = !uri 156 + 157 + const error: string = React.useMemo(() => { 158 + if (profileError) { 159 + return _( 160 + msg`Oh no! We weren't able to generate an image for you to share. Rest assured, we're glad you're here 🦋`, 161 + ) 162 + } 163 + return '' 164 + }, [_, profileError]) 165 + 166 + /* 167 + * Opening and closing 168 + */ 169 + React.useEffect(() => { 170 + const timeout = setTimeout(() => { 171 + control.open() 172 + }, 3e3) 173 + return () => { 174 + clearTimeout(timeout) 175 + } 176 + }, [control]) 177 + const onClose = React.useCallback(() => { 178 + nuxDialogs.dismissActiveNux() 179 + }, [nuxDialogs]) 180 + 181 + /* 182 + * Actions 183 + */ 184 + const sharePost = React.useCallback(() => { 164 185 if (uri) { 165 - controls.tenMillion.close(() => { 186 + control.close(() => { 166 187 setTimeout(() => { 167 188 openComposer({ 168 - text: '10 milly, babyyy', 189 + text: _( 190 + msg`I'm user #${i18n.number( 191 + userNumber, 192 + )} out of 10M. What a ride 😎`, 193 + ), // TODO 169 194 imageUris: [ 170 195 { 171 196 uri, ··· 177 202 }, 1e3) 178 203 }) 179 204 } 180 - } 181 - 182 - const onNativeShare = () => { 205 + }, [_, i18n, control, openComposer, uri, userNumber]) 206 + const onNativeShare = React.useCallback(() => { 183 207 if (uri) { 184 - controls.tenMillion.close(() => { 208 + control.close(() => { 185 209 shareUrl(uri) 186 210 }) 187 211 } 188 - } 189 - 190 - const download = async () => { 212 + }, [uri, control]) 213 + const download = React.useCallback(async () => { 191 214 if (uri) { 192 215 const canvas = await getCanvas(uri) 193 216 const imgHref = canvas ··· 198 221 link.setAttribute('href', imgHref) 199 222 link.click() 200 223 } 201 - } 224 + }, [uri]) 202 225 226 + /* 227 + * Canvas stuff 228 + */ 203 229 const imageRef = React.useRef<ViewShot>(null) 204 - // const captureInProgress = React.useRef(false) 205 - // const [cavasRelayout, setCanvasRelayout] = React.useState('key') 206 - // const onCanvasReady = async () => { 207 - // if ( 208 - // imageRef.current && 209 - // imageRef.current.capture && 210 - // !captureInProgress.current 211 - // ) { 212 - // captureInProgress.current = true 213 - // setCanvasRelayout('updated') 214 - // } 215 - // } 216 - const onCanvasLayout = async () => { 230 + const captureInProgress = React.useRef(false) 231 + const onCanvasReady = React.useCallback(async () => { 217 232 if ( 218 233 imageRef.current && 219 - imageRef.current.capture // && 220 - // cavasRelayout === 'updated' 234 + imageRef.current.capture && 235 + !captureInProgress.current 221 236 ) { 237 + captureInProgress.current = true 222 238 const uri = await imageRef.current.capture() 223 239 setUri(uri) 224 240 } 225 - } 226 - 241 + }, [setUri]) 227 242 const canvas = isLoadingData ? null : ( 228 243 <View 229 244 style={[ ··· 247 262 options={{width: WIDTH, height: HEIGHT}} 248 263 style={[a.absolute, a.inset_0]}> 249 264 <View 250 - // key={cavasRelayout} 251 - onLayout={onCanvasLayout} 265 + onLayout={onCanvasReady} 252 266 style={[ 253 267 a.absolute, 254 268 a.inset_0, ··· 442 456 ) 443 457 444 458 return ( 445 - <Dialog.Outer control={controls.tenMillion}> 459 + <Dialog.Outer control={control} onClose={onClose}> 446 460 <Dialog.ScrollableInner 447 461 label={_(msg`Ten Million`)} 448 462 style={[ ··· 494 508 ) : isLoadingData || isLoadingImage ? ( 495 509 <Loader size="xl" fill="white" /> 496 510 ) : ( 497 - <Image 498 - accessibilityIgnoresInvertColors 499 - source={{uri}} 500 - style={[a.w_full, a.h_full]} 501 - /> 511 + <Animated.View 512 + entering={FadeIn.duration(150)} 513 + style={[a.w_full, a.h_full]}> 514 + <Image 515 + accessibilityIgnoresInvertColors 516 + source={{uri}} 517 + style={[a.w_full, a.h_full]} 518 + /> 519 + </Animated.View> 502 520 )} 503 521 </View> 504 522 </Frame>
+52 -28
src/components/dialogs/nuxs/index.tsx
··· 1 1 import React from 'react' 2 2 3 + import {Nux, useNuxs, useUpsertNuxMutation} from '#/state/queries/nuxs' 3 4 import {useSession} from '#/state/session' 4 - import * as Dialog from '#/components/Dialog' 5 + import {isSnoozed, snooze} from '#/components/dialogs/nuxs/snoozing' 5 6 import {TenMillion} from '#/components/dialogs/nuxs/TenMillion' 6 7 7 8 type Context = { 8 - controls: { 9 - tenMillion: Dialog.DialogOuterProps['control'] 10 - } 9 + activeNux: Nux | undefined 10 + dismissActiveNux: () => void 11 11 } 12 + 13 + const queuedNuxs = [Nux.TenMillionDialog] 12 14 13 15 const Context = React.createContext<Context>({ 14 - // @ts-ignore 15 - controls: {}, 16 + activeNux: undefined, 17 + dismissActiveNux: () => {}, 16 18 }) 17 19 18 - export function useContext() { 20 + export function useNuxDialogContext() { 19 21 return React.useContext(Context) 20 22 } 21 - 22 - let SHOWN = false 23 23 24 24 export function NuxDialogs() { 25 25 const {hasSession} = useSession() 26 - const tenMillion = Dialog.useDialogControl() 26 + return hasSession ? <Inner /> : null 27 + } 27 28 28 - const ctx = React.useMemo(() => { 29 - return { 30 - controls: { 31 - tenMillion, 32 - }, 33 - } 34 - }, [tenMillion]) 29 + function Inner() { 30 + const {nuxs} = useNuxs() 31 + const [snoozed, setSnoozed] = React.useState(() => { 32 + return isSnoozed() 33 + }) 34 + const [activeNux, setActiveNux] = React.useState<Nux | undefined>() 35 + const {mutate: upsertNux} = useUpsertNuxMutation() 36 + 37 + const snoozeNuxDialog = React.useCallback(() => { 38 + snooze() 39 + setSnoozed(true) 40 + }, [setSnoozed]) 41 + 42 + const dismissActiveNux = React.useCallback(() => { 43 + setActiveNux(undefined) 44 + upsertNux({ 45 + id: activeNux!, 46 + completed: true, 47 + data: undefined, 48 + }) 49 + }, [activeNux, setActiveNux, upsertNux]) 35 50 36 51 React.useEffect(() => { 37 - if (!hasSession) return 52 + if (snoozed) return 53 + if (!nuxs) return 38 54 39 - const t = setTimeout(() => { 40 - if (!SHOWN) { 41 - SHOWN = true 42 - ctx.controls.tenMillion.open() 43 - } 44 - }, 2e3) 55 + for (const id of queuedNuxs) { 56 + const nux = nuxs.find(nux => nux.id === id) 45 57 46 - return () => { 47 - clearTimeout(t) 58 + if (nux && nux.completed) continue 59 + 60 + setActiveNux(id) 61 + // snooze immediately upon enabling 62 + snoozeNuxDialog() 63 + 64 + break 48 65 } 49 - }, [ctx, hasSession]) 66 + }, [nuxs, snoozed, snoozeNuxDialog]) 67 + 68 + const ctx = React.useMemo(() => { 69 + return { 70 + activeNux, 71 + dismissActiveNux, 72 + } 73 + }, [activeNux, dismissActiveNux]) 50 74 51 75 return ( 52 76 <Context.Provider value={ctx}> 53 - <TenMillion /> 77 + {activeNux === Nux.TenMillionDialog && <TenMillion />} 54 78 </Context.Provider> 55 79 ) 56 80 }
+18
src/components/dialogs/nuxs/snoozing.ts
··· 1 + import {simpleAreDatesEqual} from '#/lib/strings/time' 2 + import {device} from '#/storage' 3 + 4 + export function snooze() { 5 + device.set(['lastNuxDialog'], new Date().toISOString()) 6 + } 7 + 8 + export function isSnoozed() { 9 + const lastNuxDialog = device.get(['lastNuxDialog']) 10 + if (!lastNuxDialog) return false 11 + const last = new Date(lastNuxDialog) 12 + const now = new Date() 13 + // already snoozed today 14 + if (simpleAreDatesEqual(last, now)) { 15 + return true 16 + } 17 + return false 18 + }
+7 -18
src/state/queries/nuxs/definitions.ts
··· 3 3 import {BaseNux} from '#/state/queries/nuxs/types' 4 4 5 5 export enum Nux { 6 - One = 'one', 7 - Two = 'two', 6 + TenMillionDialog = 'TenMillionDialog', 8 7 } 9 8 10 9 export const nuxNames = new Set(Object.values(Nux)) 11 10 12 - export type AppNux = 13 - | BaseNux<{ 14 - id: Nux.One 15 - data: { 16 - likes: number 17 - } 18 - }> 19 - | BaseNux<{ 20 - id: Nux.Two 21 - data: undefined 22 - }> 11 + export type AppNux = BaseNux<{ 12 + id: Nux.TenMillionDialog 13 + data: undefined 14 + }> 23 15 24 - export const NuxSchemas = { 25 - [Nux.One]: zod.object({ 26 - likes: zod.number(), 27 - }), 28 - [Nux.Two]: undefined, 16 + export const NuxSchemas: Record<Nux, zod.ZodObject<any> | undefined> = { 17 + [Nux.TenMillionDialog]: undefined, 29 18 }
+3 -1
src/storage/schema.ts
··· 1 1 /** 2 2 * Device data that's specific to the device and does not vary based account 3 3 */ 4 - export type Device = {} 4 + export type Device = { 5 + lastNuxDialog: string 6 + }