diff --git a/__tests__/lib/string.test.ts b/__tests__/lib/string.test.ts index 2aef90fea1..c5ae06591e 100644 --- a/__tests__/lib/string.test.ts +++ b/__tests__/lib/string.test.ts @@ -4,7 +4,7 @@ import {parseEmbedPlayerFromUrl} from 'lib/strings/embed-player' import { createStarterPackGooglePlayUri, createStarterPackLinkFromAndroidReferrer, - parseStarterPackHttpUri, + parseStarterPackUri, } from 'lib/strings/starter-pack' import {cleanError} from '../../src/lib/strings/errors' import {createFullHandle, makeValidHandle} from '../../src/lib/strings/handles' @@ -803,62 +803,129 @@ describe('parseEmbedPlayerFromUrl', () => { }) describe('createStarterPackLinkFromAndroidReferrer', () => { - const inputs = [ - 'utm_source=bluesky&utm_medium=starterpack&utm_content=starterpack-haileyok.com-rkey', - 'utm_source=bluesky&utm_content=starterpack-haileyok.com-rkey&utm_medium=starterpack', - 'utm_source=bluesky&utm_content=starterpack-haileyok.com-rkey', - 'utm_content=starterpack-haileyok.com-rkey', - 'utm_source=redsea&utm_content=starterpack-haileyok.com-rkey', - 'utm_source=bluesky&utm_content=starterpack-haileyok.com', - 'utm_source=bluesky&utm_content=starterpack', - 'utm_source=bluesky&utm_content=nope', - 'utm_source=bluesky', - 'utm_content=starterpack-haileyok.com-rkey', - ] - const outputs = [ - 'https://bsky.app/start/haileyok.com/rkey', - 'https://bsky.app/start/haileyok.com/rkey', - 'https://bsky.app/start/haileyok.com/rkey', - null, - null, - null, - null, - null, - null, - null, - ] + const validOutput = 'https://bsky.app/start/haileyok.com/rkey' - it('returns a starter pack link when input is valid', () => { - for (let i = 0; i < inputs.length; i++) { - const result = createStarterPackLinkFromAndroidReferrer(inputs[i]) - expect(result).toEqual(outputs[i]) - } + it('returns a link when input contains utm_source and utm_content', () => { + expect( + createStarterPackLinkFromAndroidReferrer( + 'utm_source=bluesky&utm_content=starterpack-haileyok.com-rkey', + ), + ).toEqual(validOutput) + }) + + it('returns a link when input contains utm_source and utm_content in different order', () => { + expect( + createStarterPackLinkFromAndroidReferrer( + 'utm_content=starterpack-haileyok.com-rkey&utm_source=bluesky', + ), + ).toEqual(validOutput) + }) + + it('returns a link when input contains other parameters as well', () => { + expect( + createStarterPackLinkFromAndroidReferrer( + 'utm_source=bluesky&utm_medium=starterpack&utm_content=starterpack-haileyok.com-rkey', + ), + ).toEqual(validOutput) + }) + + it('returns null when utm_source is not present', () => { + expect( + createStarterPackLinkFromAndroidReferrer( + 'utm_content=starterpack-haileyok.com-rkey', + ), + ).toEqual(null) + }) + + it('returns null when utm_content is not present', () => { + expect( + createStarterPackLinkFromAndroidReferrer('utm_source=bluesky'), + ).toEqual(null) + }) + + it('returns null when utm_content is malformed', () => { + expect( + createStarterPackLinkFromAndroidReferrer( + 'utm_content=starterpack-haileyok.com', + ), + ).toEqual(null) + + expect( + createStarterPackLinkFromAndroidReferrer('utm_content=starterpack'), + ).toEqual(null) + + expect( + createStarterPackLinkFromAndroidReferrer( + 'utm_content=starterpack-haileyok.com-rkey-more', + ), + ).toEqual(null) + + expect( + createStarterPackLinkFromAndroidReferrer( + 'utm_content=notastarterpack-haileyok.com-rkey', + ), + ).toEqual(null) }) }) describe('parseStarterPackHttpUri', () => { - const inputs = [ - 'https://bsky.app/start/haileyok.com/rkey', - 'https://bsky.app/start/haileyok.com/ilovetesting', - 'https://bsky.app/start/testlover9000.com/rkey', - 'https://bsky.app/start/testlover9000.com', - 'https://bsky.app/start/testlover9000.com/rkey/other', - 'https://bsky.app/start', - ] - const outputs = [ - {name: 'haileyok.com', rkey: 'rkey'}, - {name: 'haileyok.com', rkey: 'ilovetesting'}, - {name: 'testlover9000.com', rkey: 'rkey'}, - null, - null, - null, - ] + const baseUri = 'https://bsky.app/start' - it('returns the correct name and rkey when input is valid', () => { - for (let i = 0; i < inputs.length; i++) { - const result = parseStarterPackHttpUri(inputs[i]) - expect(result).toEqual(outputs[i]) - } + it('returns a valid at uri when http uri is valid', () => { + const validHttpUri = `${baseUri}/haileyok.com/rkey` + expect(parseStarterPackUri(validHttpUri)).toEqual({ + name: 'haileyok.com', + rkey: 'rkey', + }) + + const validHttpUri2 = `${baseUri}/haileyok.com/ilovetesting` + expect(parseStarterPackUri(validHttpUri2)).toEqual({ + name: 'haileyok.com', + rkey: 'ilovetesting', + }) + + const validHttpUri3 = `${baseUri}/testlover9000.com/rkey` + expect(parseStarterPackUri(validHttpUri3)).toEqual({ + name: 'testlover9000.com', + rkey: 'rkey', + }) + }) + + it('returns null when there is no rkey', () => { + const validHttpUri = `${baseUri}/haileyok.com` + expect(parseStarterPackUri(validHttpUri)).toEqual(null) + }) + + it('returns null when there is an extra path', () => { + const validHttpUri = `${baseUri}/haileyok.com/rkey/other` + expect(parseStarterPackUri(validHttpUri)).toEqual(null) + }) + + it('returns null when there is no handle or rkey', () => { + const validHttpUri = `${baseUri}` + expect(parseStarterPackUri(validHttpUri)).toEqual(null) + }) + + it('returns the at uri when the input is a valid starterpack at uri', () => { + const validAtUri = 'at://did:123/app.bsky.graph.starterpack/rkey' + expect(parseStarterPackUri(validAtUri)).toEqual({ + name: 'did:123', + rkey: 'rkey', + }) + }) + + it('returns null when the at uri has no rkey', () => { + const validAtUri = 'at://did:123/app.bsky.graph.starterpack' + expect(parseStarterPackUri(validAtUri)).toEqual(null) + }) + + it('returns null when the collection is not app.bsky.graph.starterpack', () => { + const validAtUri = 'at://did:123/app.bsky.graph.list/rkey' + expect(parseStarterPackUri(validAtUri)).toEqual(null) + }) + + it('returns null when the input is undefined', () => { + expect(parseStarterPackUri(undefined)).toEqual(null) }) }) @@ -866,13 +933,24 @@ describe('createStarterPackGooglePlayUri', () => { const base = 'https://play.google.com/store/apps/details?id=xyz.blueskyweb.app&referrer=utm_source%3Dbluesky%26utm_medium%3Dstarterpack%26utm_content%3Dstarterpack-' - const inputs = [['name', 'rkey'], ['name'], []] - const outputs = [base + 'name-rkey', null, null] - it('returns valid google play uri when input is valid', () => { - for (let i = 0; i < inputs.length; i++) { - const result = createStarterPackGooglePlayUri(inputs[i][0], inputs[i][1]) - expect(result).toEqual(outputs[i]) - } + expect(createStarterPackGooglePlayUri('name', 'rkey')).toEqual( + `${base}name-rkey`, + ) + }) + + it('returns null when no rkey is supplied', () => { + // @ts-expect-error test + expect(createStarterPackGooglePlayUri('name', undefined)).toEqual(null) + }) + + it('returns null when no name or rkey are supplied', () => { + // @ts-expect-error test + expect(createStarterPackGooglePlayUri(undefined, undefined)).toEqual(null) + }) + + it('returns null when rkey is supplied but no name', () => { + // @ts-expect-error test + expect(createStarterPackGooglePlayUri(undefined, 'rkey')).toEqual(null) }) }) diff --git a/src/components/hooks/useStarterPackEntry.native.ts b/src/components/hooks/useStarterPackEntry.native.ts index 44299dafd2..a7304d64e4 100644 --- a/src/components/hooks/useStarterPackEntry.native.ts +++ b/src/components/hooks/useStarterPackEntry.native.ts @@ -2,21 +2,31 @@ import React from 'react' import {createStarterPackLinkFromAndroidReferrer} from 'lib/strings/starter-pack' import {isAndroid} from 'platform/detection' -import {useSetCurrentStarterPack} from 'state/preferences/starter-pack' -import {useUsedStarterPacks} from 'state/preferences/used-starter-packs' +import {useHasCheckedForStarterPack} from 'state/preferences/used-starter-packs' +import {useSetActiveStarterPack} from 'state/shell/starter-pack' import SwissArmyKnife from '../../../modules/expo-bluesky-swiss-army' import GooglePlayReferrer from '../../../modules/expo-google-play-referrer' export function useStarterPackEntry() { const [ready, setReady] = React.useState(false) - const setCurrentStarterPack = useSetCurrentStarterPack() - const usedStarterPacks = useUsedStarterPacks() - const hasRan = React.useRef(false) + const setActiveStarterPack = useSetActiveStarterPack() + const hasCheckedForStarterPack = useHasCheckedForStarterPack() React.useEffect(() => { - if (ready || hasRan.current) return + if (ready) return + + // On Android, we cannot clear the referral link. It gets stored for 90 days and all we can do is query for it. So, + // let's just ensure we never check again after the first time. + if (hasCheckedForStarterPack) { + setReady(true) + return + } + + // Safety for Android. Very unlike this could happen, but just in case. The response should be nearly immediate + const timeout = setTimeout(() => { + setReady(true) + }, 500) - hasRan.current = true ;(async () => { let uri: string | null | undefined @@ -28,17 +38,22 @@ export function useStarterPackEntry() { } } else { uri = await SwissArmyKnife.getStringValueAsync('starterPackUri', true) + SwissArmyKnife.setStringValueAsync('starterPackUri', null, true) } - if (uri && !usedStarterPacks?.includes(uri)) { - setCurrentStarterPack({ + if (uri) { + setActiveStarterPack({ uri, }) } setReady(true) })() - }, [ready, setCurrentStarterPack, usedStarterPacks]) + + return () => { + clearTimeout(timeout) + } + }, [ready, setActiveStarterPack, hasCheckedForStarterPack]) return ready } diff --git a/src/components/hooks/useStarterPackEntry.ts b/src/components/hooks/useStarterPackEntry.ts index dbe2c74862..3518fbced2 100644 --- a/src/components/hooks/useStarterPackEntry.ts +++ b/src/components/hooks/useStarterPackEntry.ts @@ -1,25 +1,25 @@ import React from 'react' -import {parseStarterPackHttpUri} from 'lib/strings/starter-pack' -import {useSetCurrentStarterPack} from 'state/preferences/starter-pack' +import {httpStarterPackUriToAtUri} from 'lib/strings/starter-pack' +import {useSetActiveStarterPack} from 'state/shell/starter-pack' export function useStarterPackEntry() { - const setCurrentStarterPack = useSetCurrentStarterPack() + const setActiveStarterPack = useSetActiveStarterPack() React.useEffect(() => { const href = window.location.href - const parsed = parseStarterPackHttpUri(href) + const atUri = httpStarterPackUriToAtUri(href) - if (parsed) { + if (atUri) { const url = new URL(href) + // Determines if an App Clip is loading this landing page const isClip = url.searchParams.get('clip') === 'true' - - setCurrentStarterPack({ - uri: href, + setActiveStarterPack({ + uri: atUri, isClip, }) } - }, [setCurrentStarterPack]) + }, [setActiveStarterPack]) return true } diff --git a/src/lib/strings/starter-pack.ts b/src/lib/strings/starter-pack.ts index 36f6c7802d..1836c435fd 100644 --- a/src/lib/strings/starter-pack.ts +++ b/src/lib/strings/starter-pack.ts @@ -1,3 +1,5 @@ +import {AtUri} from '@atproto/api' + import {makeStarterPackLink} from 'lib/routes/links' export function createStarterPackLinkFromAndroidReferrer( @@ -24,21 +26,35 @@ export function createStarterPackLinkFromAndroidReferrer( } } -export function parseStarterPackHttpUri(uri: string): { - name?: string - rkey?: string +export function parseStarterPackUri(uri?: string): { + name: string + rkey: string } | null { - try { - const url = new URL(uri) - const parts = url.pathname.split('/') - const name = parts[2] - const rkey = parts[3] + if (!uri) return null - if (parts.length !== 4) return null - if (!name || !rkey) return null - return { - name, - rkey, + try { + if (uri.startsWith('at://')) { + const atUri = new AtUri(uri) + if (atUri.collection !== 'app.bsky.graph.starterpack') return null + if (atUri.rkey) { + return { + name: atUri.hostname, + rkey: atUri.rkey, + } + } + return null + } else { + const url = new URL(uri) + const parts = url.pathname.split('/') + const name = parts[2] + const rkey = parts[3] + + if (parts.length !== 4) return null + if (!name || !rkey) return null + return { + name, + rkey, + } } } catch (e) { return null @@ -52,3 +68,14 @@ export function createStarterPackGooglePlayUri( if (!name || !rkey) return null return `https://play.google.com/store/apps/details?id=xyz.blueskyweb.app&referrer=utm_source%3Dbluesky%26utm_medium%3Dstarterpack%26utm_content%3Dstarterpack-${name}-${rkey}` } + +export function httpStarterPackUriToAtUri(httpUri?: string): string | null { + if (!httpUri) return null + + const parsed = parseStarterPackUri(httpUri) + if (!parsed) return null + + if (httpUri.startsWith('at://')) return httpUri + + return `at://${parsed.name}/app.bsky.graph.starterpack/${parsed.rkey}` +} diff --git a/src/screens/Login/LoginForm.tsx b/src/screens/Login/LoginForm.tsx index dfa10668b6..7cfd38e34f 100644 --- a/src/screens/Login/LoginForm.tsx +++ b/src/screens/Login/LoginForm.tsx @@ -21,6 +21,7 @@ import {logger} from '#/logger' import {useSessionApi} from '#/state/session' import {useLoggedOutViewControls} from '#/state/shell/logged-out' import {useRequestNotificationsPermission} from 'lib/notifications/notifications' +import {useSetHasCheckedForStarterPack} from 'state/preferences/used-starter-packs' import {atoms as a, useTheme} from '#/alf' import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {FormError} from '#/components/forms/FormError' @@ -69,6 +70,7 @@ export const LoginForm = ({ const {login} = useSessionApi() const requestNotificationsPermission = useRequestNotificationsPermission() const {setShowLoggedOut} = useLoggedOutViewControls() + const setHasCheckedForStarterPack = useSetHasCheckedForStarterPack() const onPressSelectService = React.useCallback(() => { Keyboard.dismiss() @@ -116,6 +118,7 @@ export const LoginForm = ({ 'LoginForm', ) setShowLoggedOut(false) + setHasCheckedForStarterPack(true) requestNotificationsPermission('Login') } catch (e: any) { const errMsg = e.toString() diff --git a/src/screens/Onboarding/StepFinished.tsx b/src/screens/Onboarding/StepFinished.tsx index 8df50d6ba9..b2fbd72efe 100644 --- a/src/screens/Onboarding/StepFinished.tsx +++ b/src/screens/Onboarding/StepFinished.tsx @@ -15,12 +15,12 @@ import {useAgent} from '#/state/session' import {useOnboardingDispatch} from '#/state/shell' import {uploadBlob} from 'lib/api' import {useRequestNotificationsPermission} from 'lib/notifications/notifications' -import {makeStarterPackLink} from 'lib/routes/links' +import {useSetHasCheckedForStarterPack} from 'state/preferences/used-starter-packs' +import {useSetSelectedFeed} from 'state/shell/selected-feed' import { - useCurrentStarterPack, - useSetCurrentStarterPack, -} from 'state/preferences/starter-pack' -import {useAddUsedStarterPack} from 'state/preferences/used-starter-packs' + useActiveStarterPack, + useSetActiveStarterPack, +} from 'state/shell/starter-pack' import { DescriptionText, OnboardingControls, @@ -48,18 +48,20 @@ export function StepFinished() { const queryClient = useQueryClient() const agent = useAgent() const requestNotificationsPermission = useRequestNotificationsPermission() - const currentStarterPack = useCurrentStarterPack() - const setCurrentStarterPack = useSetCurrentStarterPack() - const addUsedStarterPack = useAddUsedStarterPack() + const activeStarterPack = useActiveStarterPack() + const setActiveStarterPack = useSetActiveStarterPack() + const setHasCheckedForStarterPack = useSetHasCheckedForStarterPack() + const setSelectedFeed = useSetSelectedFeed() const finishOnboarding = React.useCallback(async () => { setSaving(true) try { let starterPack: AppBskyGraphDefs.StarterPackView | undefined let listItems: AppBskyGraphDefs.ListItemView[] | undefined - if (currentStarterPack) { + + if (activeStarterPack?.uri) { const spRes = await agent.app.bsky.graph.getStarterPack({ - starterPack: currentStarterPack.uri, + starterPack: activeStarterPack.uri, }) starterPack = spRes.data.starterPack @@ -92,17 +94,11 @@ export function StepFinished() { pinned: true, })), ) - setCurrentStarterPack({ - uri: '', - initialFeed: starterPack.feeds?.[0].uri, - }) + setSelectedFeed(`feedgen|${starterPack.feeds[0].uri}`) } else { - setCurrentStarterPack({ - uri: '', - initialFeed: 'following', - }) + setSelectedFeed('following') } - addUsedStarterPack(makeStarterPackLink(starterPack)) + setActiveStarterPack(undefined) } })(), (async () => { @@ -150,7 +146,7 @@ export function StepFinished() { logger.error(e) // If there was an error encountered, we need to just clear the starter pack so we don't break things for subsequent // app restarts - setCurrentStarterPack(undefined) + setActiveStarterPack(undefined) // don't alert the user, just let them into their account } @@ -168,6 +164,7 @@ export function StepFinished() { }) setSaving(false) + setHasCheckedForStarterPack(true) dispatch({type: 'finish'}) onboardDispatch({type: 'finish'}) track('OnboardingV2:StepFinished:End') @@ -179,11 +176,12 @@ export function StepFinished() { dispatch, onboardDispatch, track, - currentStarterPack, + activeStarterPack, state, requestNotificationsPermission, - addUsedStarterPack, - setCurrentStarterPack, + setActiveStarterPack, + setHasCheckedForStarterPack, + setSelectedFeed, ]) React.useEffect(() => { diff --git a/src/screens/StarterPack/StarterPackLandingScreen.tsx b/src/screens/StarterPack/StarterPackLandingScreen.tsx index d9d26a2b53..4f3da8bf93 100644 --- a/src/screens/StarterPack/StarterPackLandingScreen.tsx +++ b/src/screens/StarterPack/StarterPackLandingScreen.tsx @@ -12,18 +12,14 @@ import {useLingui} from '@lingui/react' import {isAndroidWeb} from 'lib/browser' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' -import { - createStarterPackGooglePlayUri, - parseStarterPackHttpUri, -} from 'lib/strings/starter-pack' +import {createStarterPackGooglePlayUri} from 'lib/strings/starter-pack' import {isWeb} from 'platform/detection' import {useModerationOpts} from 'state/preferences/moderation-opts' -import { - useCurrentStarterPack, - useSetCurrentStarterPack, -} from 'state/preferences/starter-pack' -import {useResolveDidQuery} from 'state/queries/resolve-uri' import {useStarterPackQuery} from 'state/queries/useStarterPackQuery' +import { + useActiveStarterPack, + useSetActiveStarterPack, +} from 'state/shell/starter-pack' import {LoggedOutScreenState} from 'view/com/auth/LoggedOut' import {CenteredView} from 'view/com/util/Views' import {Logo} from 'view/icons/Logo' @@ -54,43 +50,15 @@ export function LandingScreen({ setScreenState, }: { setScreenState: (state: LoggedOutScreenState) => void -}) { - const currentStarterPack = useCurrentStarterPack() - const parsed = parseStarterPackHttpUri(currentStarterPack?.uri || '') - - React.useEffect(() => { - if (!parsed) { - setScreenState(LoggedOutScreenState.S_LoginOrCreateAccount) - } - }, [parsed, setScreenState]) - - if (!parsed) { - return null - } - - return -} - -export function LandingScreenInner({ - setScreenState, -}: { - setScreenState: (state: LoggedOutScreenState) => void }) { const moderationOpts = useModerationOpts() - const currentStarterPack = useCurrentStarterPack() - const {name, rkey} = - parseStarterPackHttpUri(currentStarterPack?.uri || '') ?? {} + const activeStarterPack = useActiveStarterPack() - const { - data: did, - isLoading: isLoadingDid, - isError: isErrorDid, - } = useResolveDidQuery(name) const { data: starterPack, isLoading: isLoadingStarterPack, isError: isErrorStarterPack, - } = useStarterPackQuery({did, rkey}) + } = useStarterPackQuery({uri: activeStarterPack?.uri}) const isValid = starterPack && @@ -98,17 +66,15 @@ export function LandingScreenInner({ AppBskyGraphStarterpack.validateRecord(starterPack.record) React.useEffect(() => { - if (isErrorDid || isErrorStarterPack || (starterPack && !isValid)) { + if (isErrorStarterPack || (starterPack && !isValid)) { setScreenState(LoggedOutScreenState.S_LoginOrCreateAccount) } - }, [isErrorDid, isErrorStarterPack, setScreenState, isValid, starterPack]) + }, [isErrorStarterPack, setScreenState, isValid, starterPack]) - if (!did || !starterPack || !isValid || !moderationOpts) { + if (!starterPack || !isValid || !moderationOpts) { return ( ) } @@ -136,8 +102,8 @@ function LandingScreenLoaded({ const {record, creator, listItemsSample, feeds, joinedWeekCount} = starterPack const {_} = useLingui() const t = useTheme() - const currentStarterPack = useCurrentStarterPack() - const setCurrentStarterPack = useSetCurrentStarterPack() + const activeStarterPack = useActiveStarterPack() + const setActiveStarterPack = useSetActiveStarterPack() const {isTabletOrDesktop} = useWebMediaQueries() const androidDialogControl = useDialogControl() @@ -147,14 +113,14 @@ function LandingScreenLoaded({ const listItemsCount = starterPack.list?.listItemCount ?? 0 const onContinue = () => { - setCurrentStarterPack({ + setActiveStarterPack({ uri: starterPack.uri, }) setScreenState(LoggedOutScreenState.S_CreateAccount) } const onJoinPress = () => { - if (currentStarterPack?.isClip) { + if (activeStarterPack?.isClip) { setAppClipOverlayVisible(true) postAppClipMessage({ action: 'present', @@ -185,7 +151,7 @@ function LandingScreenLoaded({ borderBottomLeftRadius: 10, borderBottomRightRadius: 10, }, - currentStarterPack?.isClip && { + activeStarterPack?.isClip && { paddingTop: 100, }, ]}> @@ -312,7 +278,7 @@ function LandingScreenLoaded({ size="medium" style={[a.mt_2xl]} onPress={() => { - setCurrentStarterPack(undefined) + setActiveStarterPack(undefined) setScreenState(LoggedOutScreenState.S_CreateAccount) }}> diff --git a/src/screens/StarterPack/Wizard/State.tsx b/src/screens/StarterPack/Wizard/State.tsx index 9a4867548e..6b298a78cc 100644 --- a/src/screens/StarterPack/Wizard/State.tsx +++ b/src/screens/StarterPack/Wizard/State.tsx @@ -113,12 +113,10 @@ function reducer(state: State, action: Action): State { export function Provider({ starterPack, listItems, - profile, children, }: { starterPack?: AppBskyGraphDefs.StarterPackView listItems?: AppBskyGraphDefs.ListItemView[] - profile: AppBskyActorDefs.ProfileView children: React.ReactNode }) { const createInitialState = (): State => { @@ -138,7 +136,7 @@ export function Provider({ return { canNext: true, currentStep: 'Details', - profiles: [profile], + profiles: [], feeds: [], processing: false, transitionDirection: 'Forward', diff --git a/src/screens/StarterPack/Wizard/index.tsx b/src/screens/StarterPack/Wizard/index.tsx index 84280f2b24..b4a65cb698 100644 --- a/src/screens/StarterPack/Wizard/index.tsx +++ b/src/screens/StarterPack/Wizard/index.tsx @@ -127,7 +127,7 @@ export function Wizard({ } return ( - + @@ -150,6 +151,7 @@ function WizardInner({ createdAt: initialCreatedAt, listUri: initialListUri, listItems: initialListItems, + profile, moderationOpts, }: { did?: string @@ -157,6 +159,7 @@ function WizardInner({ createdAt?: string listUri?: string listItems?: AppBskyGraphDefs.ListItemView[] + profile: AppBskyActorDefs.ProfileViewBasic moderationOpts: ModerationOpts }) { const navigation = useNavigation() @@ -470,6 +473,7 @@ function WizardInner({ onNext={onNext} nextBtnText={currUiStrings.nextBtn} moderationOpts={moderationOpts} + profile={profile} /> )} @@ -552,10 +556,12 @@ function Footer({ onNext, nextBtnText, moderationOpts, + profile, }: { onNext: () => void nextBtnText: string moderationOpts: ModerationOpts + profile: AppBskyActorDefs.ProfileViewBasic }) { const {_} = useLingui() const t = useTheme() @@ -563,7 +569,10 @@ function Footer({ const editDialogControl = useDialogControl() const {bottom: bottomInset} = useSafeAreaInsets() - const items = state.currentStep === 'Profiles' ? state.profiles : state.feeds + const items = + state.currentStep === 'Profiles' + ? [profile, ...state.profiles] + : state.feeds const initialNamesIndex = state.currentStep === 'Profiles' ? 1 : 0 const isEditEnabled = diff --git a/src/state/persisted/schema.ts b/src/state/persisted/schema.ts index be050bae3b..0813ad3f15 100644 --- a/src/state/persisted/schema.ts +++ b/src/state/persisted/schema.ts @@ -87,14 +87,7 @@ export const schema = z.object({ disableHaptics: z.boolean().optional(), disableAutoplay: z.boolean().optional(), kawaii: z.boolean().optional(), - currentStarterPack: z - .object({ - uri: z.string(), - initialFeed: z.string().optional(), - isClip: z.boolean().optional(), - }) - .optional(), - usedStarterPacks: z.array(z.string()).optional(), + hasCheckedForStarterPack: z.boolean().optional(), /** @deprecated */ mutedThreads: z.array(z.string()), }) @@ -135,6 +128,5 @@ export const defaults: Schema = { disableHaptics: false, disableAutoplay: prefersReducedMotion, kawaii: false, - currentStarterPack: undefined, - usedStarterPacks: [], + hasCheckedForStarterPack: false, } diff --git a/src/state/preferences/index.tsx b/src/state/preferences/index.tsx index 405c9969db..72ed0067bc 100644 --- a/src/state/preferences/index.tsx +++ b/src/state/preferences/index.tsx @@ -1,5 +1,6 @@ import React from 'react' +import {Provider as StarterPackProvider} from '../shell/starter-pack' import {Provider as AltTextRequiredProvider} from './alt-text-required' import {Provider as AutoplayProvider} from './autoplay' import {Provider as DisableHapticsProvider} from './disable-haptics' @@ -8,7 +9,6 @@ import {Provider as HiddenPostsProvider} from './hidden-posts' import {Provider as InAppBrowserProvider} from './in-app-browser' import {Provider as KawaiiProvider} from './kawaii' import {Provider as LanguagesProvider} from './languages' -import {Provider as StarterPackProvider} from './starter-pack' import {Provider as UsedStarterPacksProvider} from './used-starter-packs' export { diff --git a/src/state/preferences/starter-pack.tsx b/src/state/preferences/starter-pack.tsx deleted file mode 100644 index 583c2b65f8..0000000000 --- a/src/state/preferences/starter-pack.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import React from 'react' - -import * as persisted from '#/state/persisted' - -type StateContext = - | { - uri: string - initialFeed?: string - isClip?: boolean - } - | undefined -type SetContext = (v: StateContext) => void - -const stateContext = React.createContext(undefined) -const setContext = React.createContext((_: StateContext) => {}) - -export function Provider({children}: {children: React.ReactNode}) { - const [state, setState] = React.useState(() => - persisted.get('currentStarterPack'), - ) - - const setStateWrapped = (v: StateContext) => { - setState(v) - persisted.write('currentStarterPack', v) - } - - React.useEffect(() => { - return persisted.onUpdate(() => { - setState(persisted.get('currentStarterPack')) - }) - }, []) - - return ( - - - {children} - - - ) -} - -export const useCurrentStarterPack = () => React.useContext(stateContext) -export const useSetCurrentStarterPack = () => React.useContext(setContext) diff --git a/src/state/preferences/used-starter-packs.tsx b/src/state/preferences/used-starter-packs.tsx index b04698caca..8d5d9e8283 100644 --- a/src/state/preferences/used-starter-packs.tsx +++ b/src/state/preferences/used-starter-packs.tsx @@ -2,25 +2,25 @@ import React from 'react' import * as persisted from '#/state/persisted' -type StateContext = string[] | undefined -type SetContext = (v: string) => void +type StateContext = boolean | undefined +type SetContext = (v: boolean) => void -const stateContext = React.createContext([]) -const setContext = React.createContext((_: string) => {}) +const stateContext = React.createContext(false) +const setContext = React.createContext((_: boolean) => {}) export function Provider({children}: {children: React.ReactNode}) { const [state, setState] = React.useState(() => - persisted.get('usedStarterPacks'), + persisted.get('hasCheckedForStarterPack'), ) - const setStateWrapped = (v: string) => { - persisted.write('usedStarterPacks', [...(state ? state : []), v]) - setState(prev => [...(prev ? prev : []), v]) + const setStateWrapped = (v: boolean) => { + setState(v) + persisted.write('hasCheckedForStarterPack', v) } React.useEffect(() => { return persisted.onUpdate(() => { - setState(persisted.get('usedStarterPacks')) + setState(persisted.get('hasCheckedForStarterPack')) }) }, []) @@ -33,5 +33,5 @@ export function Provider({children}: {children: React.ReactNode}) { ) } -export const useUsedStarterPacks = () => React.useContext(stateContext) -export const useAddUsedStarterPack = () => React.useContext(setContext) +export const useHasCheckedForStarterPack = () => React.useContext(stateContext) +export const useSetHasCheckedForStarterPack = () => React.useContext(setContext) diff --git a/src/state/queries/useStarterPackQuery.ts b/src/state/queries/useStarterPackQuery.ts index ca659f43b4..0348c2e0ea 100644 --- a/src/state/queries/useStarterPackQuery.ts +++ b/src/state/queries/useStarterPackQuery.ts @@ -1,30 +1,38 @@ import {StarterPackView} from '@atproto/api/dist/client/types/app/bsky/graph/defs' import {QueryClient, useQuery} from '@tanstack/react-query' +import {httpStarterPackUriToAtUri} from 'lib/strings/starter-pack' import {useAgent} from 'state/session' const RQKEY_ROOT = 'starter-pack' const RQKEY = (did?: string, rkey?: string) => [RQKEY_ROOT, did, rkey] export function useStarterPackQuery({ + uri, did, rkey, }: { + uri?: string did?: string rkey?: string }) { const agent = useAgent() - const uri = `at://${did}/app.bsky.graph.starterpack/${rkey}` return useQuery({ queryKey: RQKEY(did, rkey), queryFn: async () => { + if (!uri) { + uri = `at://${did}/app.bsky.graph.starterpack/${rkey}` + } else if (uri && !uri.startsWith('at://')) { + // TODO remove this assertion + uri = httpStarterPackUriToAtUri(uri) as string + } const res = await agent.app.bsky.graph.getStarterPack({ starterPack: uri, }) return res.data.starterPack }, - enabled: Boolean(did) && Boolean(rkey), + enabled: Boolean(uri) || Boolean(did && rkey), }) } diff --git a/src/state/shell/starter-pack.tsx b/src/state/shell/starter-pack.tsx new file mode 100644 index 0000000000..f564712f0e --- /dev/null +++ b/src/state/shell/starter-pack.tsx @@ -0,0 +1,25 @@ +import React from 'react' + +type StateContext = + | { + uri: string + isClip?: boolean + } + | undefined +type SetContext = (v: StateContext) => void + +const stateContext = React.createContext(undefined) +const setContext = React.createContext((_: StateContext) => {}) + +export function Provider({children}: {children: React.ReactNode}) { + const [state, setState] = React.useState() + + return ( + + {children} + + ) +} + +export const useActiveStarterPack = () => React.useContext(stateContext) +export const useSetActiveStarterPack = () => React.useContext(setContext) diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx index 7bfef1511d..11f6ded67e 100644 --- a/src/view/screens/Home.tsx +++ b/src/view/screens/Home.tsx @@ -22,12 +22,13 @@ import {useSelectedFeed, useSetSelectedFeed} from '#/state/shell/selected-feed' import {useOTAUpdates} from 'lib/hooks/useOTAUpdates' import {useRequestNotificationsPermission} from 'lib/notifications/notifications' import {HomeTabNavigatorParams, NativeStackScreenProps} from 'lib/routes/types' -import { - useCurrentStarterPack, - useSetCurrentStarterPack, -} from 'state/preferences/starter-pack' -import {useUsedStarterPacks} from 'state/preferences/used-starter-packs' +import {parseStarterPackUri} from 'lib/strings/starter-pack' +import {isWeb} from 'platform/detection' import {useLoggedOutViewControls} from 'state/shell/logged-out' +import { + useActiveStarterPack, + useSetActiveStarterPack, +} from 'state/shell/starter-pack' import {FeedPage} from 'view/com/feeds/FeedPage' import {Pager, PagerRef, RenderTabBarFnProps} from 'view/com/pager/Pager' import {CustomFeedEmptyState} from 'view/com/posts/CustomFeedEmptyState' @@ -38,33 +39,37 @@ import {HomeHeader} from '../com/home/HomeHeader' type Props = NativeStackScreenProps export function HomeScreen(props: Props) { + const {navigation} = props + const {hasSession} = useSession() const {data: preferences} = usePreferencesQuery() const {data: pinnedFeedInfos, isLoading: isPinnedFeedsLoading} = usePinnedFeedsInfos() - const currentStarterPack = useCurrentStarterPack() - const usedStarterPacks = useUsedStarterPacks() + const activeStarterPack = useActiveStarterPack() + const setActiveStarterPack = useSetActiveStarterPack() const {setShowLoggedOut, requestSwitchToAccount} = useLoggedOutViewControls() React.useEffect(() => { - if (currentStarterPack && !currentStarterPack?.initialFeed) { - // In test environments, the URL won't start with `https://bsky.app`, so we want to find it by the route - try { - const route = new URL(currentStarterPack.uri).pathname - const foundIndex = usedStarterPacks?.findIndex(p => p.includes(route)) - if (foundIndex === -1) { - setShowLoggedOut(true) - requestSwitchToAccount({requestedAccount: 'starterpack'}) - } - } catch { - // Don't need to handle anything here, just put the user on the home screen + // This will be true if the app was launched with a starter pack referral. + if (activeStarterPack?.uri) { + if (hasSession) { + const parsed = parseStarterPackUri(activeStarterPack.uri) + if (!parsed) return + setActiveStarterPack(undefined) + navigation.navigate('StarterPack', parsed) + } else { + setShowLoggedOut(true) + requestSwitchToAccount({ + requestedAccount: isWeb ? 'starterpack' : 'new', + }) } } }, [ + hasSession, setShowLoggedOut, requestSwitchToAccount, - currentStarterPack?.initialFeed, - currentStarterPack, - usedStarterPacks, + activeStarterPack, + setActiveStarterPack, + navigation, ]) if (preferences && pinnedFeedInfos && !isPinnedFeedsLoading) { @@ -96,23 +101,9 @@ function HomeScreenReady({ [pinnedFeedInfos], ) - const currentStarterPack = useCurrentStarterPack() - const setCurrentStarterPack = useSetCurrentStarterPack() - - const starterPackInitialFeed = currentStarterPack?.initialFeed - ? allFeeds.find(f => { - if (currentStarterPack.initialFeed === 'following') { - return f === 'following' - } else { - return f === `feedgen|${currentStarterPack.initialFeed}` - } - }) - : undefined const rawSelectedFeed = useSelectedFeed() ?? allFeeds[0] const setSelectedFeed = useSetSelectedFeed() - const maybeFoundIndex = allFeeds.indexOf( - starterPackInitialFeed ?? rawSelectedFeed, - ) + const maybeFoundIndex = allFeeds.indexOf(rawSelectedFeed) const selectedIndex = Math.max(0, maybeFoundIndex) const selectedFeed = allFeeds[selectedIndex] const requestNotificationsPermission = useRequestNotificationsPermission() @@ -138,12 +129,7 @@ function HomeScreenReady({ lastPagerReportedIndexRef.current = selectedIndex pagerRef.current?.setPage(selectedIndex, 'desktop-sidebar-click') } - }, [ - selectedIndex, - allFeeds, - setCurrentStarterPack, - currentStarterPack?.initialFeed, - ]) + }, [selectedIndex, allFeeds]) const {hasSession} = useSession() const setMinimalShellMode = useSetMinimalShellMode()