diff options
Diffstat (limited to 'src/screens')
-rw-r--r-- | src/screens/Login/LoginForm.tsx | 3 | ||||
-rw-r--r-- | src/screens/Login/ScreenTransition.tsx | 11 | ||||
-rw-r--r-- | src/screens/Onboarding/StepFinished.tsx | 117 | ||||
-rw-r--r-- | src/screens/Profile/Header/DisplayName.tsx | 6 | ||||
-rw-r--r-- | src/screens/Signup/index.tsx | 40 | ||||
-rw-r--r-- | src/screens/StarterPack/StarterPackLandingScreen.tsx | 378 | ||||
-rw-r--r-- | src/screens/StarterPack/StarterPackScreen.tsx | 627 | ||||
-rw-r--r-- | src/screens/StarterPack/Wizard/State.tsx | 163 | ||||
-rw-r--r-- | src/screens/StarterPack/Wizard/StepDetails.tsx | 84 | ||||
-rw-r--r-- | src/screens/StarterPack/Wizard/StepFeeds.tsx | 113 | ||||
-rw-r--r-- | src/screens/StarterPack/Wizard/StepFinished.tsx | 0 | ||||
-rw-r--r-- | src/screens/StarterPack/Wizard/StepProfiles.tsx | 101 | ||||
-rw-r--r-- | src/screens/StarterPack/Wizard/index.tsx | 575 |
13 files changed, 2206 insertions, 12 deletions
diff --git a/src/screens/Login/LoginForm.tsx b/src/screens/Login/LoginForm.tsx index dfa10668b..7cfd38e34 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/Login/ScreenTransition.tsx b/src/screens/Login/ScreenTransition.tsx index ab0a22367..6fad26680 100644 --- a/src/screens/Login/ScreenTransition.tsx +++ b/src/screens/Login/ScreenTransition.tsx @@ -1,9 +1,16 @@ import React from 'react' +import {StyleProp, ViewStyle} from 'react-native' import Animated, {FadeInRight, FadeOutLeft} from 'react-native-reanimated' -export function ScreenTransition({children}: {children: React.ReactNode}) { +export function ScreenTransition({ + style, + children, +}: { + style?: StyleProp<ViewStyle> + children: React.ReactNode +}) { return ( - <Animated.View entering={FadeInRight} exiting={FadeOutLeft}> + <Animated.View style={style} entering={FadeInRight} exiting={FadeOutLeft}> {children} </Animated.View> ) diff --git a/src/screens/Onboarding/StepFinished.tsx b/src/screens/Onboarding/StepFinished.tsx index c75dd4fa7..c7a459659 100644 --- a/src/screens/Onboarding/StepFinished.tsx +++ b/src/screens/Onboarding/StepFinished.tsx @@ -1,11 +1,18 @@ import React from 'react' import {View} from 'react-native' +import {AppBskyGraphDefs, AppBskyGraphStarterpack} from '@atproto/api' +import {SavedFeed} from '@atproto/api/dist/client/types/app/bsky/actor/defs' +import {TID} from '@atproto/common-web' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useQueryClient} from '@tanstack/react-query' import {useAnalytics} from '#/lib/analytics/analytics' -import {BSKY_APP_ACCOUNT_DID} from '#/lib/constants' +import { + BSKY_APP_ACCOUNT_DID, + DISCOVER_SAVED_FEED, + TIMELINE_SAVED_FEED, +} from '#/lib/constants' import {logEvent} from '#/lib/statsig/statsig' import {logger} from '#/logger' import {preferencesQueryKey} from '#/state/queries/preferences' @@ -14,6 +21,11 @@ import {useAgent} from '#/state/session' import {useOnboardingDispatch} from '#/state/shell' import {uploadBlob} from 'lib/api' import {useRequestNotificationsPermission} from 'lib/notifications/notifications' +import {useSetHasCheckedForStarterPack} from 'state/preferences/used-starter-packs' +import { + useActiveStarterPack, + useSetActiveStarterPack, +} from 'state/shell/starter-pack' import { DescriptionText, OnboardingControls, @@ -41,17 +53,74 @@ export function StepFinished() { const queryClient = useQueryClient() const agent = useAgent() const requestNotificationsPermission = useRequestNotificationsPermission() + const activeStarterPack = useActiveStarterPack() + const setActiveStarterPack = useSetActiveStarterPack() + const setHasCheckedForStarterPack = useSetHasCheckedForStarterPack() const finishOnboarding = React.useCallback(async () => { setSaving(true) - const {interestsStepResults, profileStepResults} = state - const {selectedInterests} = interestsStepResults + let starterPack: AppBskyGraphDefs.StarterPackView | undefined + let listItems: AppBskyGraphDefs.ListItemView[] | undefined + + if (activeStarterPack?.uri) { + try { + const spRes = await agent.app.bsky.graph.getStarterPack({ + starterPack: activeStarterPack.uri, + }) + starterPack = spRes.data.starterPack + + if (starterPack.list) { + const listRes = await agent.app.bsky.graph.getList({ + list: starterPack.list.uri, + limit: 50, + }) + listItems = listRes.data.items + } + } catch (e) { + logger.error('Failed to fetch starter pack', {safeMessage: e}) + // don't tell the user, just get them through onboarding. + } + } + try { + const {interestsStepResults, profileStepResults} = state + const {selectedInterests} = interestsStepResults + await Promise.all([ - bulkWriteFollows(agent, [BSKY_APP_ACCOUNT_DID]), + bulkWriteFollows(agent, [ + BSKY_APP_ACCOUNT_DID, + ...(listItems?.map(i => i.subject.did) ?? []), + ]), (async () => { + // Interests need to get saved first, then we can write the feeds to prefs await agent.setInterestsPref({tags: selectedInterests}) + + // Default feeds that every user should have pinned when landing in the app + const feedsToSave: SavedFeed[] = [ + { + ...DISCOVER_SAVED_FEED, + id: TID.nextStr(), + }, + { + ...TIMELINE_SAVED_FEED, + id: TID.nextStr(), + }, + ] + + // Any starter pack feeds will be pinned _after_ the defaults + if (starterPack && starterPack.feeds?.length) { + feedsToSave.concat( + starterPack.feeds.map(f => ({ + type: 'feed', + value: f.uri, + pinned: true, + id: TID.nextStr(), + })), + ) + } + + await agent.overwriteSavedFeeds(feedsToSave) })(), (async () => { const {imageUri, imageMime} = profileStepResults @@ -63,9 +132,24 @@ export function StepFinished() { if (res.data.blob) { existing.avatar = res.data.blob } + + if (starterPack) { + existing.joinedViaStarterPack = { + uri: starterPack.uri, + cid: starterPack.cid, + } + } + + existing.displayName = '' + // HACKFIX + // creating a bunch of identical profile objects is breaking the relay + // tossing this unspecced field onto it to reduce the size of the problem + // -prf + existing.createdAt = new Date().toISOString() return existing }) } + logEvent('onboarding:finished:avatarResult', { avatarResult: profileStepResults.isCreatedAvatar ? 'created' @@ -96,19 +180,40 @@ export function StepFinished() { }) setSaving(false) + setActiveStarterPack(undefined) + setHasCheckedForStarterPack(true) dispatch({type: 'finish'}) onboardDispatch({type: 'finish'}) track('OnboardingV2:StepFinished:End') track('OnboardingV2:Complete') - logEvent('onboarding:finished:nextPressed', {}) + logEvent('onboarding:finished:nextPressed', { + usedStarterPack: Boolean(starterPack), + starterPackName: AppBskyGraphStarterpack.isRecord(starterPack?.record) + ? starterPack.record.name + : undefined, + starterPackCreator: starterPack?.creator.did, + starterPackUri: starterPack?.uri, + profilesFollowed: listItems?.length ?? 0, + feedsPinned: starterPack?.feeds?.length ?? 0, + }) + if (starterPack && listItems?.length) { + logEvent('starterPack:followAll', { + logContext: 'Onboarding', + starterPack: starterPack.uri, + count: listItems?.length, + }) + } }, [ - state, queryClient, agent, dispatch, onboardDispatch, track, + activeStarterPack, + state, requestNotificationsPermission, + setActiveStarterPack, + setHasCheckedForStarterPack, ]) React.useEffect(() => { diff --git a/src/screens/Profile/Header/DisplayName.tsx b/src/screens/Profile/Header/DisplayName.tsx index b6d88db71..c63658a44 100644 --- a/src/screens/Profile/Header/DisplayName.tsx +++ b/src/screens/Profile/Header/DisplayName.tsx @@ -1,10 +1,10 @@ import React from 'react' import {View} from 'react-native' import {AppBskyActorDefs, ModerationDecision} from '@atproto/api' -import {sanitizeHandle} from 'lib/strings/handles' -import {sanitizeDisplayName} from 'lib/strings/display-names' -import {Shadow} from '#/state/cache/types' +import {Shadow} from '#/state/cache/types' +import {sanitizeDisplayName} from 'lib/strings/display-names' +import {sanitizeHandle} from 'lib/strings/handles' import {atoms as a, useTheme} from '#/alf' import {Text} from '#/components/Typography' diff --git a/src/screens/Signup/index.tsx b/src/screens/Signup/index.tsx index 2cc1bcab0..3203d443c 100644 --- a/src/screens/Signup/index.tsx +++ b/src/screens/Signup/index.tsx @@ -1,6 +1,11 @@ import React from 'react' import {View} from 'react-native' -import {LayoutAnimationConfig} from 'react-native-reanimated' +import Animated, { + FadeIn, + FadeOut, + LayoutAnimationConfig, +} from 'react-native-reanimated' +import {AppBskyGraphStarterpack} from '@atproto/api' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' @@ -11,6 +16,8 @@ import {createFullHandle} from '#/lib/strings/handles' import {logger} from '#/logger' import {useServiceQuery} from '#/state/queries/service' import {useAgent} from '#/state/session' +import {useStarterPackQuery} from 'state/queries/starter-packs' +import {useActiveStarterPack} from 'state/shell/starter-pack' import {LoggedOutLayout} from '#/view/com/util/layouts/LoggedOutLayout' import { initialState, @@ -26,6 +33,7 @@ import {atoms as a, useBreakpoints, useTheme} from '#/alf' import {AppLanguageDropdown} from '#/components/AppLanguageDropdown' import {Button, ButtonText} from '#/components/Button' import {Divider} from '#/components/Divider' +import {LinearGradientBackground} from '#/components/LinearGradientBackground' import {InlineLinkText} from '#/components/Link' import {Text} from '#/components/Typography' @@ -38,6 +46,11 @@ export function Signup({onPressBack}: {onPressBack: () => void}) { const {gtMobile} = useBreakpoints() const agent = useAgent() + const activeStarterPack = useActiveStarterPack() + const {data: starterPack} = useStarterPackQuery({ + uri: activeStarterPack?.uri, + }) + const { data: serviceInfo, isFetching, @@ -142,6 +155,31 @@ export function Signup({onPressBack}: {onPressBack: () => void}) { description={_(msg`We're so excited to have you join us!`)} scrollable> <View testID="createAccount" style={a.flex_1}> + {state.activeStep === SignupStep.INFO && + starterPack && + AppBskyGraphStarterpack.isRecord(starterPack.record) ? ( + <Animated.View entering={FadeIn} exiting={FadeOut}> + <LinearGradientBackground + style={[a.mx_lg, a.p_lg, a.gap_sm, a.rounded_sm]}> + <Text style={[a.font_bold, a.text_xl, {color: 'white'}]}> + {starterPack.record.name} + </Text> + <Text style={[{color: 'white'}]}> + {starterPack.feeds?.length ? ( + <Trans> + You'll follow the suggested users and feeds once you + finish creating your account! + </Trans> + ) : ( + <Trans> + You'll follow the suggested users once you finish creating + your account! + </Trans> + )} + </Text> + </LinearGradientBackground> + </Animated.View> + ) : null} <View style={[ a.flex_1, diff --git a/src/screens/StarterPack/StarterPackLandingScreen.tsx b/src/screens/StarterPack/StarterPackLandingScreen.tsx new file mode 100644 index 000000000..1c9587a79 --- /dev/null +++ b/src/screens/StarterPack/StarterPackLandingScreen.tsx @@ -0,0 +1,378 @@ +import React from 'react' +import {Pressable, ScrollView, View} from 'react-native' +import Animated, {FadeIn, FadeOut} from 'react-native-reanimated' +import { + AppBskyGraphDefs, + AppBskyGraphStarterpack, + AtUri, + ModerationOpts, +} from '@atproto/api' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {isAndroidWeb} from 'lib/browser' +import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' +import {createStarterPackGooglePlayUri} from 'lib/strings/starter-pack' +import {isWeb} from 'platform/detection' +import {useModerationOpts} from 'state/preferences/moderation-opts' +import {useStarterPackQuery} from 'state/queries/starter-packs' +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' +import {atoms as a, useTheme} from '#/alf' +import {Button, ButtonText} from '#/components/Button' +import {useDialogControl} from '#/components/Dialog' +import * as FeedCard from '#/components/FeedCard' +import {LinearGradientBackground} from '#/components/LinearGradientBackground' +import {ListMaybePlaceholder} from '#/components/Lists' +import {Default as ProfileCard} from '#/components/ProfileCard' +import * as Prompt from '#/components/Prompt' +import {Text} from '#/components/Typography' + +const AnimatedPressable = Animated.createAnimatedComponent(Pressable) + +interface AppClipMessage { + action: 'present' | 'store' + keyToStoreAs?: string + jsonToStore?: string +} + +function postAppClipMessage(message: AppClipMessage) { + // @ts-expect-error safari webview only + window.webkit.messageHandlers.onMessage.postMessage(JSON.stringify(message)) +} + +export function LandingScreen({ + setScreenState, +}: { + setScreenState: (state: LoggedOutScreenState) => void +}) { + const moderationOpts = useModerationOpts() + const activeStarterPack = useActiveStarterPack() + + const {data: starterPack, isError: isErrorStarterPack} = useStarterPackQuery({ + uri: activeStarterPack?.uri, + }) + + const isValid = + starterPack && + starterPack.list && + AppBskyGraphDefs.validateStarterPackView(starterPack) && + AppBskyGraphStarterpack.validateRecord(starterPack.record) + + React.useEffect(() => { + if (isErrorStarterPack || (starterPack && !isValid)) { + setScreenState(LoggedOutScreenState.S_LoginOrCreateAccount) + } + }, [isErrorStarterPack, setScreenState, isValid, starterPack]) + + if (!starterPack || !isValid || !moderationOpts) { + return <ListMaybePlaceholder isLoading={true} /> + } + + return ( + <LandingScreenLoaded + starterPack={starterPack} + setScreenState={setScreenState} + moderationOpts={moderationOpts} + /> + ) +} + +function LandingScreenLoaded({ + starterPack, + setScreenState, + // TODO apply this to profile card + + moderationOpts, +}: { + starterPack: AppBskyGraphDefs.StarterPackView + setScreenState: (state: LoggedOutScreenState) => void + moderationOpts: ModerationOpts +}) { + const {record, creator, listItemsSample, feeds, joinedWeekCount} = starterPack + const {_} = useLingui() + const t = useTheme() + const activeStarterPack = useActiveStarterPack() + const setActiveStarterPack = useSetActiveStarterPack() + const {isTabletOrDesktop} = useWebMediaQueries() + const androidDialogControl = useDialogControl() + + const [appClipOverlayVisible, setAppClipOverlayVisible] = + React.useState(false) + + const listItemsCount = starterPack.list?.listItemCount ?? 0 + + const onContinue = () => { + setActiveStarterPack({ + uri: starterPack.uri, + }) + setScreenState(LoggedOutScreenState.S_CreateAccount) + } + + const onJoinPress = () => { + if (activeStarterPack?.isClip) { + setAppClipOverlayVisible(true) + postAppClipMessage({ + action: 'present', + }) + } else if (isAndroidWeb) { + androidDialogControl.open() + } else { + onContinue() + } + } + + const onJoinWithoutPress = () => { + if (activeStarterPack?.isClip) { + setAppClipOverlayVisible(true) + postAppClipMessage({ + action: 'present', + }) + } else { + setActiveStarterPack(undefined) + setScreenState(LoggedOutScreenState.S_CreateAccount) + } + } + + if (!AppBskyGraphStarterpack.isRecord(record)) { + return null + } + + return ( + <CenteredView style={a.flex_1}> + <ScrollView + style={[a.flex_1, t.atoms.bg]} + contentContainerStyle={{paddingBottom: 100}}> + <LinearGradientBackground + style={[ + a.align_center, + a.gap_sm, + a.px_lg, + a.py_2xl, + isTabletOrDesktop && [a.mt_2xl, a.rounded_md], + activeStarterPack?.isClip && { + paddingTop: 100, + }, + ]}> + <View style={[a.flex_row, a.gap_md, a.pb_sm]}> + <Logo width={76} fill="white" /> + </View> + <Text + style={[ + a.font_bold, + a.text_4xl, + a.text_center, + a.leading_tight, + {color: 'white'}, + ]}> + {record.name} + </Text> + <Text + style={[ + a.text_center, + a.font_semibold, + a.text_md, + {color: 'white'}, + ]}> + Starter pack by {`@${creator.handle}`} + </Text> + </LinearGradientBackground> + <View style={[a.gap_2xl, a.mx_lg, a.my_2xl]}> + {record.description ? ( + <Text style={[a.text_md, t.atoms.text_contrast_medium]}> + {record.description} + </Text> + ) : null} + <View style={[a.gap_sm]}> + <Button + label={_(msg`Join Bluesky`)} + onPress={onJoinPress} + variant="solid" + color="primary" + size="large"> + <ButtonText style={[a.text_lg]}> + <Trans>Join Bluesky</Trans> + </ButtonText> + </Button> + {joinedWeekCount && joinedWeekCount >= 25 ? ( + <View style={[a.flex_row, a.align_center, a.gap_sm]}> + <FontAwesomeIcon + icon="arrow-trend-up" + size={12} + color={t.atoms.text_contrast_medium.color} + /> + <Text + style={[ + a.font_semibold, + a.text_sm, + t.atoms.text_contrast_medium, + ]} + numberOfLines={1}> + 123,659 joined this week + </Text> + </View> + ) : null} + </View> + <View style={[a.gap_3xl]}> + {Boolean(listItemsSample?.length) && ( + <View style={[a.gap_md]}> + <Text style={[a.font_heavy, a.text_lg]}> + {listItemsCount <= 8 ? ( + <Trans>You'll follow these people right away</Trans> + ) : ( + <Trans> + You'll follow these people and {listItemsCount - 8} others + </Trans> + )} + </Text> + <View> + {starterPack.listItemsSample?.slice(0, 8).map(item => ( + <View + key={item.subject.did} + style={[ + a.py_lg, + a.px_md, + a.border_t, + t.atoms.border_contrast_low, + ]}> + <ProfileCard + profile={item.subject} + moderationOpts={moderationOpts} + /> + </View> + ))} + </View> + </View> + )} + {feeds?.length ? ( + <View style={[a.gap_md]}> + <Text style={[a.font_heavy, a.text_lg]}> + <Trans>You'll stay updated with these feeds</Trans> + </Text> + + <View style={[{pointerEvents: 'none'}]}> + {feeds?.map(feed => ( + <View + style={[ + a.py_lg, + a.px_md, + a.border_t, + t.atoms.border_contrast_low, + ]} + key={feed.uri}> + <FeedCard.Default type="feed" view={feed} /> + </View> + ))} + </View> + </View> + ) : null} + </View> + <Button + label={_(msg`Signup without a starter pack`)} + variant="solid" + color="secondary" + size="medium" + style={[a.py_lg]} + onPress={onJoinWithoutPress}> + <ButtonText> + <Trans>Signup without a starter pack</Trans> + </ButtonText> + </Button> + </View> + </ScrollView> + <AppClipOverlay + visible={appClipOverlayVisible} + setIsVisible={setAppClipOverlayVisible} + /> + <Prompt.Outer control={androidDialogControl}> + <Prompt.TitleText> + <Trans>Download Bluesky</Trans> + </Prompt.TitleText> + <Prompt.DescriptionText> + <Trans> + The experience is better in the app. Download Bluesky now and we'll + pick back up where you left off. + </Trans> + </Prompt.DescriptionText> + <Prompt.Actions> + <Prompt.Action + cta="Download on Google Play" + color="primary" + onPress={() => { + const rkey = new AtUri(starterPack.uri).rkey + if (!rkey) return + + const googlePlayUri = createStarterPackGooglePlayUri( + creator.handle, + rkey, + ) + if (!googlePlayUri) return + + window.location.href = googlePlayUri + }} + /> + <Prompt.Action + cta="Continue on web" + color="secondary" + onPress={onContinue} + /> + </Prompt.Actions> + </Prompt.Outer> + {isWeb && ( + <meta + name="apple-itunes-app" + content="app-id=xyz.blueskyweb.app, app-clip-bundle-id=xyz.blueskyweb.app.AppClip, app-clip-display=card" + /> + )} + </CenteredView> + ) +} + +function AppClipOverlay({ + visible, + setIsVisible, +}: { + visible: boolean + setIsVisible: (visible: boolean) => void +}) { + if (!visible) return + + return ( + <AnimatedPressable + accessibilityRole="button" + style={[ + a.absolute, + { + top: 0, + left: 0, + right: 0, + bottom: 0, + backgroundColor: 'rgba(0, 0, 0, 0.95)', + zIndex: 1, + }, + ]} + entering={FadeIn} + exiting={FadeOut} + onPress={() => setIsVisible(false)}> + <View style={[a.flex_1, a.px_lg, {marginTop: 250}]}> + {/* Webkit needs this to have a zindex of 2? */} + <View style={[a.gap_md, {zIndex: 2}]}> + <Text + style={[a.font_bold, a.text_4xl, {lineHeight: 40, color: 'white'}]}> + Download Bluesky to get started! + </Text> + <Text style={[a.text_lg, {color: 'white'}]}> + We'll remember the starter pack you chose and use it when you create + an account in the app. + </Text> + </View> + </View> + </AnimatedPressable> + ) +} diff --git a/src/screens/StarterPack/StarterPackScreen.tsx b/src/screens/StarterPack/StarterPackScreen.tsx new file mode 100644 index 000000000..46ce25236 --- /dev/null +++ b/src/screens/StarterPack/StarterPackScreen.tsx @@ -0,0 +1,627 @@ +import React from 'react' +import {View} from 'react-native' +import {Image} from 'expo-image' +import { + AppBskyGraphDefs, + AppBskyGraphGetList, + AppBskyGraphStarterpack, + AtUri, + ModerationOpts, +} from '@atproto/api' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useNavigation} from '@react-navigation/native' +import {NativeStackScreenProps} from '@react-navigation/native-stack' +import { + InfiniteData, + UseInfiniteQueryResult, + useQueryClient, +} from '@tanstack/react-query' + +import {cleanError} from '#/lib/strings/errors' +import {logger} from '#/logger' +import {useDeleteStarterPackMutation} from '#/state/queries/starter-packs' +import {HITSLOP_20} from 'lib/constants' +import {makeProfileLink, makeStarterPackLink} from 'lib/routes/links' +import {CommonNavigatorParams, NavigationProp} from 'lib/routes/types' +import {logEvent} from 'lib/statsig/statsig' +import {getStarterPackOgCard} from 'lib/strings/starter-pack' +import {isWeb} from 'platform/detection' +import {useModerationOpts} from 'state/preferences/moderation-opts' +import {RQKEY, useListMembersQuery} from 'state/queries/list-members' +import {useResolveDidQuery} from 'state/queries/resolve-uri' +import {useShortenLink} from 'state/queries/shorten-link' +import {useStarterPackQuery} from 'state/queries/starter-packs' +import {useAgent, useSession} from 'state/session' +import * as Toast from '#/view/com/util/Toast' +import {PagerWithHeader} from 'view/com/pager/PagerWithHeader' +import {ProfileSubpageHeader} from 'view/com/profile/ProfileSubpageHeader' +import {CenteredView} from 'view/com/util/Views' +import {bulkWriteFollows} from '#/screens/Onboarding/util' +import {atoms as a, useBreakpoints, useTheme} from '#/alf' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import {useDialogControl} from '#/components/Dialog' +import {ArrowOutOfBox_Stroke2_Corner0_Rounded as ArrowOutOfBox} from '#/components/icons/ArrowOutOfBox' +import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' +import {DotGrid_Stroke2_Corner0_Rounded as Ellipsis} from '#/components/icons/DotGrid' +import {Pencil_Stroke2_Corner0_Rounded as Pencil} from '#/components/icons/Pencil' +import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash' +import {ListMaybePlaceholder} from '#/components/Lists' +import {Loader} from '#/components/Loader' +import * as Menu from '#/components/Menu' +import * as Prompt from '#/components/Prompt' +import {ReportDialog, useReportDialogControl} from '#/components/ReportDialog' +import {FeedsList} from '#/components/StarterPack/Main/FeedsList' +import {ProfilesList} from '#/components/StarterPack/Main/ProfilesList' +import {QrCodeDialog} from '#/components/StarterPack/QrCodeDialog' +import {ShareDialog} from '#/components/StarterPack/ShareDialog' +import {Text} from '#/components/Typography' + +type StarterPackScreeProps = NativeStackScreenProps< + CommonNavigatorParams, + 'StarterPack' +> + +export function StarterPackScreen({route}: StarterPackScreeProps) { + const {_} = useLingui() + const {currentAccount} = useSession() + + const {name, rkey} = route.params + const moderationOpts = useModerationOpts() + const { + data: did, + isLoading: isLoadingDid, + isError: isErrorDid, + } = useResolveDidQuery(name) + const { + data: starterPack, + isLoading: isLoadingStarterPack, + isError: isErrorStarterPack, + } = useStarterPackQuery({did, rkey}) + const listMembersQuery = useListMembersQuery(starterPack?.list?.uri, 50) + + const isValid = + starterPack && + (starterPack.list || starterPack?.creator?.did === currentAccount?.did) && + AppBskyGraphDefs.validateStarterPackView(starterPack) && + AppBskyGraphStarterpack.validateRecord(starterPack.record) + + if (!did || !starterPack || !isValid || !moderationOpts) { + return ( + <ListMaybePlaceholder + isLoading={ + isLoadingDid || + isLoadingStarterPack || + listMembersQuery.isLoading || + !moderationOpts + } + isError={isErrorDid || isErrorStarterPack || !isValid} + errorMessage={_(msg`That starter pack could not be found.`)} + emptyMessage={_(msg`That starter pack could not be found.`)} + /> + ) + } + + if (!starterPack.list && starterPack.creator.did === currentAccount?.did) { + return <InvalidStarterPack rkey={rkey} /> + } + + return ( + <StarterPackScreenInner + starterPack={starterPack} + routeParams={route.params} + listMembersQuery={listMembersQuery} + moderationOpts={moderationOpts} + /> + ) +} + +function StarterPackScreenInner({ + starterPack, + routeParams, + listMembersQuery, + moderationOpts, +}: { + starterPack: AppBskyGraphDefs.StarterPackView + routeParams: StarterPackScreeProps['route']['params'] + listMembersQuery: UseInfiniteQueryResult< + InfiniteData<AppBskyGraphGetList.OutputSchema> + > + moderationOpts: ModerationOpts +}) { + const tabs = [ + ...(starterPack.list ? ['People'] : []), + ...(starterPack.feeds?.length ? ['Feeds'] : []), + ] + + const qrCodeDialogControl = useDialogControl() + const shareDialogControl = useDialogControl() + + const shortenLink = useShortenLink() + const [link, setLink] = React.useState<string>() + const [imageLoaded, setImageLoaded] = React.useState(false) + + const onOpenShareDialog = React.useCallback(() => { + const rkey = new AtUri(starterPack.uri).rkey + shortenLink(makeStarterPackLink(starterPack.creator.did, rkey)).then( + res => { + setLink(res.url) + }, + ) + Image.prefetch(getStarterPackOgCard(starterPack)) + .then(() => { + setImageLoaded(true) + }) + .catch(() => { + setImageLoaded(true) + }) + shareDialogControl.open() + }, [shareDialogControl, shortenLink, starterPack]) + + React.useEffect(() => { + if (routeParams.new) { + onOpenShareDialog() + } + }, [onOpenShareDialog, routeParams.new, shareDialogControl]) + + return ( + <CenteredView style={[a.h_full_vh]}> + <View style={isWeb ? {minHeight: '100%'} : {height: '100%'}}> + <PagerWithHeader + items={tabs} + isHeaderReady={true} + renderHeader={() => ( + <Header + starterPack={starterPack} + routeParams={routeParams} + onOpenShareDialog={onOpenShareDialog} + /> + )}> + {starterPack.list != null + ? ({headerHeight, scrollElRef}) => ( + <ProfilesList + key={0} + // Validated above + listUri={starterPack!.list!.uri} + headerHeight={headerHeight} + // @ts-expect-error + scrollElRef={scrollElRef} + listMembersQuery={listMembersQuery} + moderationOpts={moderationOpts} + /> + ) + : null} + {starterPack.feeds != null + ? ({headerHeight, scrollElRef}) => ( + <FeedsList + key={1} + // @ts-expect-error ? + feeds={starterPack?.feeds} + headerHeight={headerHeight} + // @ts-expect-error + scrollElRef={scrollElRef} + /> + ) + : null} + </PagerWithHeader> + </View> + + <QrCodeDialog + control={qrCodeDialogControl} + starterPack={starterPack} + link={link} + /> + <ShareDialog + control={shareDialogControl} + qrDialogControl={qrCodeDialogControl} + starterPack={starterPack} + link={link} + imageLoaded={imageLoaded} + /> + </CenteredView> + ) +} + +function Header({ + starterPack, + routeParams, + onOpenShareDialog, +}: { + starterPack: AppBskyGraphDefs.StarterPackView + routeParams: StarterPackScreeProps['route']['params'] + onOpenShareDialog: () => void +}) { + const {_} = useLingui() + const t = useTheme() + const {currentAccount} = useSession() + const agent = useAgent() + const queryClient = useQueryClient() + + const [isProcessing, setIsProcessing] = React.useState(false) + + const {record, creator} = starterPack + const isOwn = creator?.did === currentAccount?.did + const joinedAllTimeCount = starterPack.joinedAllTimeCount ?? 0 + + const onFollowAll = async () => { + if (!starterPack.list) return + + setIsProcessing(true) + + try { + const list = await agent.app.bsky.graph.getList({ + list: starterPack.list.uri, + }) + const dids = list.data.items + .filter(li => !li.subject.viewer?.following) + .map(li => li.subject.did) + + await bulkWriteFollows(agent, dids) + + await queryClient.refetchQueries({ + queryKey: RQKEY(starterPack.list.uri), + }) + + logEvent('starterPack:followAll', { + logContext: 'StarterPackProfilesList', + starterPack: starterPack.uri, + count: dids.length, + }) + Toast.show(_(msg`All accounts have been followed!`)) + } catch (e) { + Toast.show(_(msg`An error occurred while trying to follow all`)) + } finally { + setIsProcessing(false) + } + } + + if (!AppBskyGraphStarterpack.isRecord(record)) { + return null + } + + return ( + <> + <ProfileSubpageHeader + isLoading={false} + href={makeProfileLink(creator)} + title={record.name} + isOwner={isOwn} + avatar={undefined} + creator={creator} + avatarType="starter-pack"> + <View style={[a.flex_row, a.gap_sm, a.align_center]}> + {isOwn ? ( + <Button + label={_(msg`Share this starter pack`)} + hitSlop={HITSLOP_20} + variant="solid" + color="primary" + size="small" + onPress={onOpenShareDialog}> + <ButtonText> + <Trans>Share</Trans> + </ButtonText> + </Button> + ) : ( + <Button + label={_(msg`Follow all`)} + variant="solid" + color="primary" + size="small" + disabled={isProcessing} + onPress={onFollowAll}> + <ButtonText> + <Trans>Follow all</Trans> + {isProcessing && <Loader size="xs" />} + </ButtonText> + </Button> + )} + <OverflowMenu + routeParams={routeParams} + starterPack={starterPack} + onOpenShareDialog={onOpenShareDialog} + /> + </View> + </ProfileSubpageHeader> + {record.description || joinedAllTimeCount >= 25 ? ( + <View style={[a.px_lg, a.pt_md, a.pb_sm, a.gap_md]}> + {record.description ? ( + <Text style={[a.text_md, a.leading_snug]}> + {record.description} + </Text> + ) : null} + {joinedAllTimeCount >= 25 ? ( + <View style={[a.flex_row, a.align_center, a.gap_sm]}> + <FontAwesomeIcon + icon="arrow-trend-up" + size={12} + color={t.atoms.text_contrast_medium.color} + /> + <Text + style={[a.font_bold, a.text_sm, t.atoms.text_contrast_medium]}> + <Trans> + {starterPack.joinedAllTimeCount || 0} people have used this + starter pack! + </Trans> + </Text> + </View> + ) : null} + </View> + ) : null} + </> + ) +} + +function OverflowMenu({ + starterPack, + routeParams, + onOpenShareDialog, +}: { + starterPack: AppBskyGraphDefs.StarterPackView + routeParams: StarterPackScreeProps['route']['params'] + onOpenShareDialog: () => void +}) { + const t = useTheme() + const {_} = useLingui() + const {gtMobile} = useBreakpoints() + const {currentAccount} = useSession() + const reportDialogControl = useReportDialogControl() + const deleteDialogControl = useDialogControl() + const navigation = useNavigation<NavigationProp>() + + const { + mutate: deleteStarterPack, + isPending: isDeletePending, + error: deleteError, + } = useDeleteStarterPackMutation({ + onSuccess: () => { + logEvent('starterPack:delete', {}) + deleteDialogControl.close(() => { + if (navigation.canGoBack()) { + navigation.popToTop() + } else { + navigation.navigate('Home') + } + }) + }, + onError: e => { + logger.error('Failed to delete starter pack', {safeMessage: e}) + }, + }) + + const isOwn = starterPack.creator.did === currentAccount?.did + + const onDeleteStarterPack = async () => { + if (!starterPack.list) { + logger.error(`Unable to delete starterpack because list is missing`) + return + } + + deleteStarterPack({ + rkey: routeParams.rkey, + listUri: starterPack.list.uri, + }) + logEvent('starterPack:delete', {}) + } + + return ( + <> + <Menu.Root> + <Menu.Trigger label={_(msg`Repost or quote post`)}> + {({props}) => ( + <Button + {...props} + testID="headerDropdownBtn" + label={_(msg`Open starter pack menu`)} + hitSlop={HITSLOP_20} + variant="solid" + color="secondary" + size="small" + shape="round"> + <ButtonIcon icon={Ellipsis} /> + </Button> + )} + </Menu.Trigger> + <Menu.Outer style={{minWidth: 170}}> + {isOwn ? ( + <> + <Menu.Item + label={_(msg`Edit starter pack`)} + testID="editStarterPackLinkBtn" + onPress={() => { + navigation.navigate('StarterPackEdit', { + rkey: routeParams.rkey, + }) + }}> + <Menu.ItemText> + <Trans>Edit</Trans> + </Menu.ItemText> + <Menu.ItemIcon icon={Pencil} position="right" /> + </Menu.Item> + <Menu.Item + label={_(msg`Delete starter pack`)} + testID="deleteStarterPackBtn" + onPress={() => { + deleteDialogControl.open() + }}> + <Menu.ItemText> + <Trans>Delete</Trans> + </Menu.ItemText> + <Menu.ItemIcon icon={Trash} position="right" /> + </Menu.Item> + </> + ) : ( + <> + <Menu.Group> + <Menu.Item + label={_(msg`Share`)} + testID="shareStarterPackLinkBtn" + onPress={onOpenShareDialog}> + <Menu.ItemText> + <Trans>Share link</Trans> + </Menu.ItemText> + <Menu.ItemIcon icon={ArrowOutOfBox} position="right" /> + </Menu.Item> + </Menu.Group> + + <Menu.Item + label={_(msg`Report starter pack`)} + onPress={reportDialogControl.open}> + <Menu.ItemText> + <Trans>Report starter pack</Trans> + </Menu.ItemText> + <Menu.ItemIcon icon={CircleInfo} position="right" /> + </Menu.Item> + </> + )} + </Menu.Outer> + </Menu.Root> + + {starterPack.list && ( + <ReportDialog + control={reportDialogControl} + params={{ + type: 'starterpack', + uri: starterPack.uri, + cid: starterPack.cid, + }} + /> + )} + + <Prompt.Outer control={deleteDialogControl}> + <Prompt.TitleText> + <Trans>Delete starter pack?</Trans> + </Prompt.TitleText> + <Prompt.DescriptionText> + <Trans>Are you sure you want delete this starter pack?</Trans> + </Prompt.DescriptionText> + {deleteError && ( + <View + style={[ + a.flex_row, + a.gap_sm, + a.rounded_sm, + a.p_md, + a.mb_lg, + a.border, + t.atoms.border_contrast_medium, + t.atoms.bg_contrast_25, + ]}> + <View style={[a.flex_1, a.gap_2xs]}> + <Text style={[a.font_bold]}> + <Trans>Unable to delete</Trans> + </Text> + <Text style={[a.leading_snug]}>{cleanError(deleteError)}</Text> + </View> + <CircleInfo size="sm" fill={t.palette.negative_400} /> + </View> + )} + <Prompt.Actions> + <Button + variant="solid" + color="negative" + size={gtMobile ? 'small' : 'medium'} + label={_(msg`Yes, delete this starter pack`)} + onPress={onDeleteStarterPack}> + <ButtonText> + <Trans>Delete</Trans> + </ButtonText> + {isDeletePending && <ButtonIcon icon={Loader} />} + </Button> + <Prompt.Cancel /> + </Prompt.Actions> + </Prompt.Outer> + </> + ) +} + +function InvalidStarterPack({rkey}: {rkey: string}) { + const {_} = useLingui() + const t = useTheme() + const navigation = useNavigation<NavigationProp>() + const {gtMobile} = useBreakpoints() + const [isProcessing, setIsProcessing] = React.useState(false) + + const goBack = () => { + if (navigation.canGoBack()) { + navigation.goBack() + } else { + navigation.replace('Home') + } + } + + const {mutate: deleteStarterPack} = useDeleteStarterPackMutation({ + onSuccess: () => { + setIsProcessing(false) + goBack() + }, + onError: e => { + setIsProcessing(false) + logger.error('Failed to delete invalid starter pack', {safeMessage: e}) + Toast.show(_(msg`Failed to delete starter pack`)) + }, + }) + + return ( + <CenteredView + style={[ + a.flex_1, + a.align_center, + a.gap_5xl, + !gtMobile && a.justify_between, + t.atoms.border_contrast_low, + {paddingTop: 175, paddingBottom: 110}, + ]} + sideBorders={true}> + <View style={[a.w_full, a.align_center, a.gap_lg]}> + <Text style={[a.font_bold, a.text_3xl]}> + <Trans>Starter pack is invalid</Trans> + </Text> + <Text + style={[ + a.text_md, + a.text_center, + t.atoms.text_contrast_high, + {lineHeight: 1.4}, + gtMobile ? {width: 450} : [a.w_full, a.px_lg], + ]}> + <Trans> + The starter pack that you are trying to view is invalid. You may + delete this starter pack instead. + </Trans> + </Text> + </View> + <View style={[a.gap_md, gtMobile ? {width: 350} : [a.w_full, a.px_lg]]}> + <Button + variant="solid" + color="primary" + label={_(msg`Delete starter pack`)} + size="large" + style={[a.rounded_sm, a.overflow_hidden, {paddingVertical: 10}]} + disabled={isProcessing} + onPress={() => { + setIsProcessing(true) + deleteStarterPack({rkey}) + }}> + <ButtonText> + <Trans>Delete</Trans> + </ButtonText> + {isProcessing && <Loader size="xs" color="white" />} + </Button> + <Button + variant="solid" + color="secondary" + label={_(msg`Return to previous page`)} + size="large" + style={[a.rounded_sm, a.overflow_hidden, {paddingVertical: 10}]} + disabled={isProcessing} + onPress={goBack}> + <ButtonText> + <Trans>Go Back</Trans> + </ButtonText> + </Button> + </View> + </CenteredView> + ) +} diff --git a/src/screens/StarterPack/Wizard/State.tsx b/src/screens/StarterPack/Wizard/State.tsx new file mode 100644 index 000000000..ea9bbf9d3 --- /dev/null +++ b/src/screens/StarterPack/Wizard/State.tsx @@ -0,0 +1,163 @@ +import React from 'react' +import { + AppBskyActorDefs, + AppBskyGraphDefs, + AppBskyGraphStarterpack, +} from '@atproto/api' +import {GeneratorView} from '@atproto/api/dist/client/types/app/bsky/feed/defs' +import {msg} from '@lingui/macro' + +import {useSession} from 'state/session' +import * as Toast from '#/view/com/util/Toast' + +const steps = ['Details', 'Profiles', 'Feeds'] as const +type Step = (typeof steps)[number] + +type Action = + | {type: 'Next'} + | {type: 'Back'} + | {type: 'SetCanNext'; canNext: boolean} + | {type: 'SetName'; name: string} + | {type: 'SetDescription'; description: string} + | {type: 'AddProfile'; profile: AppBskyActorDefs.ProfileViewBasic} + | {type: 'RemoveProfile'; profileDid: string} + | {type: 'AddFeed'; feed: GeneratorView} + | {type: 'RemoveFeed'; feedUri: string} + | {type: 'SetProcessing'; processing: boolean} + | {type: 'SetError'; error: string} + +interface State { + canNext: boolean + currentStep: Step + name?: string + description?: string + profiles: AppBskyActorDefs.ProfileViewBasic[] + feeds: GeneratorView[] + processing: boolean + error?: string + transitionDirection: 'Backward' | 'Forward' +} + +type TStateContext = [State, (action: Action) => void] + +const StateContext = React.createContext<TStateContext>([ + {} as State, + (_: Action) => {}, +]) +export const useWizardState = () => React.useContext(StateContext) + +function reducer(state: State, action: Action): State { + let updatedState = state + + // -- Navigation + const currentIndex = steps.indexOf(state.currentStep) + if (action.type === 'Next' && state.currentStep !== 'Feeds') { + updatedState = { + ...state, + currentStep: steps[currentIndex + 1], + transitionDirection: 'Forward', + } + } else if (action.type === 'Back' && state.currentStep !== 'Details') { + updatedState = { + ...state, + currentStep: steps[currentIndex - 1], + transitionDirection: 'Backward', + } + } + + switch (action.type) { + case 'SetName': + updatedState = {...state, name: action.name.slice(0, 50)} + break + case 'SetDescription': + updatedState = {...state, description: action.description} + break + case 'AddProfile': + if (state.profiles.length >= 51) { + Toast.show(msg`You may only add up to 50 profiles`.message ?? '') + } else { + updatedState = {...state, profiles: [...state.profiles, action.profile]} + } + break + case 'RemoveProfile': + updatedState = { + ...state, + profiles: state.profiles.filter( + profile => profile.did !== action.profileDid, + ), + } + break + case 'AddFeed': + if (state.feeds.length >= 50) { + Toast.show(msg`You may only add up to 50 feeds`.message ?? '') + } else { + updatedState = {...state, feeds: [...state.feeds, action.feed]} + } + break + case 'RemoveFeed': + updatedState = { + ...state, + feeds: state.feeds.filter(f => f.uri !== action.feedUri), + } + break + case 'SetProcessing': + updatedState = {...state, processing: action.processing} + break + } + + return updatedState +} + +// TODO supply the initial state to this component +export function Provider({ + starterPack, + listItems, + children, +}: { + starterPack?: AppBskyGraphDefs.StarterPackView + listItems?: AppBskyGraphDefs.ListItemView[] + children: React.ReactNode +}) { + const {currentAccount} = useSession() + + const createInitialState = (): State => { + if (starterPack && AppBskyGraphStarterpack.isRecord(starterPack.record)) { + return { + canNext: true, + currentStep: 'Details', + name: starterPack.record.name, + description: starterPack.record.description, + profiles: + listItems + ?.map(i => i.subject) + .filter(p => p.did !== currentAccount?.did) ?? [], + feeds: starterPack.feeds ?? [], + processing: false, + transitionDirection: 'Forward', + } + } + + return { + canNext: true, + currentStep: 'Details', + profiles: [], + feeds: [], + processing: false, + transitionDirection: 'Forward', + } + } + + const [state, dispatch] = React.useReducer(reducer, null, createInitialState) + + return ( + <StateContext.Provider value={[state, dispatch]}> + {children} + </StateContext.Provider> + ) +} + +export { + type Action as WizardAction, + type State as WizardState, + type Step as WizardStep, +} diff --git a/src/screens/StarterPack/Wizard/StepDetails.tsx b/src/screens/StarterPack/Wizard/StepDetails.tsx new file mode 100644 index 000000000..24c992c60 --- /dev/null +++ b/src/screens/StarterPack/Wizard/StepDetails.tsx @@ -0,0 +1,84 @@ +import React from 'react' +import {View} from 'react-native' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {useProfileQuery} from 'state/queries/profile' +import {useSession} from 'state/session' +import {useWizardState} from '#/screens/StarterPack/Wizard/State' +import {atoms as a, useTheme} from '#/alf' +import * as TextField from '#/components/forms/TextField' +import {StarterPack} from '#/components/icons/StarterPack' +import {ScreenTransition} from '#/components/StarterPack/Wizard/ScreenTransition' +import {Text} from '#/components/Typography' + +export function StepDetails() { + const {_} = useLingui() + const t = useTheme() + const [state, dispatch] = useWizardState() + + const {currentAccount} = useSession() + const {data: currentProfile} = useProfileQuery({ + did: currentAccount?.did, + staleTime: 300, + }) + + return ( + <ScreenTransition direction={state.transitionDirection}> + <View style={[a.px_xl, a.gap_xl, a.mt_4xl]}> + <View style={[a.gap_md, a.align_center, a.px_md, a.mb_md]}> + <StarterPack width={90} gradient="sky" /> + <Text style={[a.font_bold, a.text_3xl]}> + <Trans>Invites, but personal</Trans> + </Text> + <Text style={[a.text_center, a.text_md, a.px_md]}> + <Trans> + Invite your friends to follow your favorite feeds and people + </Trans> + </Text> + </View> + <View> + <TextField.LabelText> + <Trans>What do you want to call your starter pack?</Trans> + </TextField.LabelText> + <TextField.Root> + <TextField.Input + label={_( + msg`${ + currentProfile?.displayName || currentProfile?.handle + }'s starter pack`, + )} + value={state.name} + onChangeText={text => dispatch({type: 'SetName', name: text})} + /> + <TextField.SuffixText label={_(`${state.name?.length} out of 50`)}> + <Text style={[t.atoms.text_contrast_medium]}> + {state.name?.length ?? 0}/50 + </Text> + </TextField.SuffixText> + </TextField.Root> + </View> + <View> + <TextField.LabelText> + <Trans>Tell us a little more</Trans> + </TextField.LabelText> + <TextField.Root> + <TextField.Input + label={_( + msg`${ + currentProfile?.displayName || currentProfile?.handle + }'s favorite feeds and people - join me!`, + )} + value={state.description} + onChangeText={text => + dispatch({type: 'SetDescription', description: text}) + } + multiline + style={{minHeight: 150}} + /> + </TextField.Root> + </View> + </View> + </ScreenTransition> + ) +} diff --git a/src/screens/StarterPack/Wizard/StepFeeds.tsx b/src/screens/StarterPack/Wizard/StepFeeds.tsx new file mode 100644 index 000000000..6752a95db --- /dev/null +++ b/src/screens/StarterPack/Wizard/StepFeeds.tsx @@ -0,0 +1,113 @@ +import React, {useState} from 'react' +import {ListRenderItemInfo, View} from 'react-native' +import {KeyboardAwareScrollView} from 'react-native-keyboard-controller' +import {AppBskyFeedDefs, ModerationOpts} from '@atproto/api' +import {Trans} from '@lingui/macro' + +import {useA11y} from '#/state/a11y' +import {DISCOVER_FEED_URI} from 'lib/constants' +import { + useGetPopularFeedsQuery, + useSavedFeeds, + useSearchPopularFeedsQuery, +} from 'state/queries/feed' +import {SearchInput} from 'view/com/util/forms/SearchInput' +import {List} from 'view/com/util/List' +import {useWizardState} from '#/screens/StarterPack/Wizard/State' +import {atoms as a, useTheme} from '#/alf' +import {useThrottledValue} from '#/components/hooks/useThrottledValue' +import {Loader} from '#/components/Loader' +import {ScreenTransition} from '#/components/StarterPack/Wizard/ScreenTransition' +import {WizardFeedCard} from '#/components/StarterPack/Wizard/WizardListCard' +import {Text} from '#/components/Typography' + +function keyExtractor(item: AppBskyFeedDefs.GeneratorView) { + return item.uri +} + +export function StepFeeds({moderationOpts}: {moderationOpts: ModerationOpts}) { + const t = useTheme() + const [state, dispatch] = useWizardState() + const [query, setQuery] = useState('') + const throttledQuery = useThrottledValue(query, 500) + const {screenReaderEnabled} = useA11y() + + const {data: savedFeedsAndLists} = useSavedFeeds() + const savedFeeds = savedFeedsAndLists?.feeds + .filter(f => f.type === 'feed' && f.view.uri !== DISCOVER_FEED_URI) + .map(f => f.view) as AppBskyFeedDefs.GeneratorView[] + + const {data: popularFeedsPages, fetchNextPage} = useGetPopularFeedsQuery({ + limit: 30, + }) + const popularFeeds = + popularFeedsPages?.pages + .flatMap(page => page.feeds) + .filter(f => !savedFeeds?.some(sf => sf?.uri === f.uri)) ?? [] + + const suggestedFeeds = savedFeeds?.concat(popularFeeds) + + const {data: searchedFeeds, isLoading: isLoadingSearch} = + useSearchPopularFeedsQuery({q: throttledQuery}) + + const renderItem = ({ + item, + }: ListRenderItemInfo<AppBskyFeedDefs.GeneratorView>) => { + return ( + <WizardFeedCard + generator={item} + state={state} + dispatch={dispatch} + moderationOpts={moderationOpts} + /> + ) + } + + return ( + <ScreenTransition style={[a.flex_1]} direction={state.transitionDirection}> + <View style={[a.border_b, t.atoms.border_contrast_medium]}> + <View style={[a.my_sm, a.px_md, {height: 40}]}> + <SearchInput + query={query} + onChangeQuery={t => setQuery(t)} + onPressCancelSearch={() => setQuery('')} + onSubmitQuery={() => {}} + /> + </View> + </View> + <List + data={query ? searchedFeeds : suggestedFeeds} + renderItem={renderItem} + keyExtractor={keyExtractor} + contentContainerStyle={{paddingTop: 6}} + onEndReached={ + !query && !screenReaderEnabled ? () => fetchNextPage() : undefined + } + onEndReachedThreshold={2} + renderScrollComponent={props => <KeyboardAwareScrollView {...props} />} + keyboardShouldPersistTaps="handled" + containWeb={true} + sideBorders={false} + style={{flex: 1}} + ListEmptyComponent={ + <View style={[a.flex_1, a.align_center, a.mt_lg, a.px_lg]}> + {isLoadingSearch ? ( + <Loader size="lg" /> + ) : ( + <Text + style={[ + a.font_bold, + a.text_lg, + a.text_center, + a.mt_lg, + a.leading_snug, + ]}> + <Trans>No feeds found. Try searching for something else.</Trans> + </Text> + )} + </View> + } + /> + </ScreenTransition> + ) +} diff --git a/src/screens/StarterPack/Wizard/StepFinished.tsx b/src/screens/StarterPack/Wizard/StepFinished.tsx new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/src/screens/StarterPack/Wizard/StepFinished.tsx diff --git a/src/screens/StarterPack/Wizard/StepProfiles.tsx b/src/screens/StarterPack/Wizard/StepProfiles.tsx new file mode 100644 index 000000000..8fe7f52fe --- /dev/null +++ b/src/screens/StarterPack/Wizard/StepProfiles.tsx @@ -0,0 +1,101 @@ +import React, {useState} from 'react' +import {ListRenderItemInfo, View} from 'react-native' +import {KeyboardAwareScrollView} from 'react-native-keyboard-controller' +import {AppBskyActorDefs, ModerationOpts} from '@atproto/api' +import {Trans} from '@lingui/macro' + +import {useA11y} from '#/state/a11y' +import {isNative} from 'platform/detection' +import {useActorAutocompleteQuery} from 'state/queries/actor-autocomplete' +import {useActorSearchPaginated} from 'state/queries/actor-search' +import {SearchInput} from 'view/com/util/forms/SearchInput' +import {List} from 'view/com/util/List' +import {useWizardState} from '#/screens/StarterPack/Wizard/State' +import {atoms as a, useTheme} from '#/alf' +import {Loader} from '#/components/Loader' +import {ScreenTransition} from '#/components/StarterPack/Wizard/ScreenTransition' +import {WizardProfileCard} from '#/components/StarterPack/Wizard/WizardListCard' +import {Text} from '#/components/Typography' + +function keyExtractor(item: AppBskyActorDefs.ProfileViewBasic) { + return item?.did ?? '' +} + +export function StepProfiles({ + moderationOpts, +}: { + moderationOpts: ModerationOpts +}) { + const t = useTheme() + const [state, dispatch] = useWizardState() + const [query, setQuery] = useState('') + const {screenReaderEnabled} = useA11y() + + const {data: topPages, fetchNextPage} = useActorSearchPaginated({ + query: encodeURIComponent('*'), + }) + const topFollowers = topPages?.pages.flatMap(p => p.actors) + + const {data: results, isLoading: isLoadingResults} = + useActorAutocompleteQuery(query, true, 12) + + const renderItem = ({ + item, + }: ListRenderItemInfo<AppBskyActorDefs.ProfileViewBasic>) => { + return ( + <WizardProfileCard + profile={item} + state={state} + dispatch={dispatch} + moderationOpts={moderationOpts} + /> + ) + } + + return ( + <ScreenTransition style={[a.flex_1]} direction={state.transitionDirection}> + <View style={[a.border_b, t.atoms.border_contrast_medium]}> + <View style={[a.my_sm, a.px_md, {height: 40}]}> + <SearchInput + query={query} + onChangeQuery={setQuery} + onPressCancelSearch={() => setQuery('')} + onSubmitQuery={() => {}} + /> + </View> + </View> + <List + data={query ? results : topFollowers} + renderItem={renderItem} + keyExtractor={keyExtractor} + renderScrollComponent={props => <KeyboardAwareScrollView {...props} />} + keyboardShouldPersistTaps="handled" + containWeb={true} + sideBorders={false} + style={[a.flex_1]} + onEndReached={ + !query && !screenReaderEnabled ? () => fetchNextPage() : undefined + } + onEndReachedThreshold={isNative ? 2 : 0.25} + ListEmptyComponent={ + <View style={[a.flex_1, a.align_center, a.mt_lg, a.px_lg]}> + {isLoadingResults ? ( + <Loader size="lg" /> + ) : ( + <Text + style={[ + a.font_bold, + a.text_lg, + a.text_center, + a.mt_lg, + a.leading_snug, + ]}> + <Trans>Nobody was found. Try searching for someone else.</Trans> + </Text> + )} + </View> + } + /> + </ScreenTransition> + ) +} diff --git a/src/screens/StarterPack/Wizard/index.tsx b/src/screens/StarterPack/Wizard/index.tsx new file mode 100644 index 000000000..76691dc98 --- /dev/null +++ b/src/screens/StarterPack/Wizard/index.tsx @@ -0,0 +1,575 @@ +import React from 'react' +import {Keyboard, TouchableOpacity, View} from 'react-native' +import { + KeyboardAwareScrollView, + useKeyboardController, +} from 'react-native-keyboard-controller' +import {useSafeAreaInsets} from 'react-native-safe-area-context' +import {Image} from 'expo-image' +import { + AppBskyActorDefs, + AppBskyGraphDefs, + AtUri, + ModerationOpts, +} from '@atproto/api' +import {GeneratorView} from '@atproto/api/dist/client/types/app/bsky/feed/defs' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import {msg, Plural, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useFocusEffect, useNavigation} from '@react-navigation/native' +import {NativeStackScreenProps} from '@react-navigation/native-stack' + +import {logger} from '#/logger' +import {HITSLOP_10} from 'lib/constants' +import {CommonNavigatorParams, NavigationProp} from 'lib/routes/types' +import {logEvent} from 'lib/statsig/statsig' +import {sanitizeDisplayName} from 'lib/strings/display-names' +import {sanitizeHandle} from 'lib/strings/handles' +import {enforceLen} from 'lib/strings/helpers' +import { + getStarterPackOgCard, + parseStarterPackUri, +} from 'lib/strings/starter-pack' +import {isAndroid, isNative, isWeb} from 'platform/detection' +import {useModerationOpts} from 'state/preferences/moderation-opts' +import {useListMembersQuery} from 'state/queries/list-members' +import {useProfileQuery} from 'state/queries/profile' +import { + useCreateStarterPackMutation, + useEditStarterPackMutation, + useStarterPackQuery, +} from 'state/queries/starter-packs' +import {useSession} from 'state/session' +import {useSetMinimalShellMode} from 'state/shell' +import * as Toast from '#/view/com/util/Toast' +import {UserAvatar} from 'view/com/util/UserAvatar' +import {CenteredView} from 'view/com/util/Views' +import {useWizardState, WizardStep} from '#/screens/StarterPack/Wizard/State' +import {StepDetails} from '#/screens/StarterPack/Wizard/StepDetails' +import {StepFeeds} from '#/screens/StarterPack/Wizard/StepFeeds' +import {StepProfiles} from '#/screens/StarterPack/Wizard/StepProfiles' +import {atoms as a, useTheme} from '#/alf' +import {Button, ButtonText} from '#/components/Button' +import {useDialogControl} from '#/components/Dialog' +import {ListMaybePlaceholder} from '#/components/Lists' +import {Loader} from '#/components/Loader' +import {WizardEditListDialog} from '#/components/StarterPack/Wizard/WizardEditListDialog' +import {Text} from '#/components/Typography' +import {Provider} from './State' + +export function Wizard({ + route, +}: NativeStackScreenProps< + CommonNavigatorParams, + 'StarterPackEdit' | 'StarterPackWizard' +>) { + const {rkey} = route.params ?? {} + const {currentAccount} = useSession() + const moderationOpts = useModerationOpts() + + const {_} = useLingui() + + const { + data: starterPack, + isLoading: isLoadingStarterPack, + isError: isErrorStarterPack, + } = useStarterPackQuery({did: currentAccount!.did, rkey}) + const listUri = starterPack?.list?.uri + + const { + data: profilesData, + isLoading: isLoadingProfiles, + isError: isErrorProfiles, + } = useListMembersQuery(listUri, 50) + const listItems = profilesData?.pages.flatMap(p => p.items) + + const { + data: profile, + isLoading: isLoadingProfile, + isError: isErrorProfile, + } = useProfileQuery({did: currentAccount?.did}) + + const isEdit = Boolean(rkey) + const isReady = + (!isEdit || (isEdit && starterPack && listItems)) && + profile && + moderationOpts + + if (!isReady) { + return ( + <ListMaybePlaceholder + isLoading={ + isLoadingStarterPack || isLoadingProfiles || isLoadingProfile + } + isError={isErrorStarterPack || isErrorProfiles || isErrorProfile} + errorMessage={_(msg`That starter pack could not be found.`)} + /> + ) + } else if (isEdit && starterPack?.creator.did !== currentAccount?.did) { + return ( + <ListMaybePlaceholder + isLoading={false} + isError={true} + errorMessage={_(msg`That starter pack could not be found.`)} + /> + ) + } + + return ( + <Provider starterPack={starterPack} listItems={listItems}> + <WizardInner + currentStarterPack={starterPack} + currentListItems={listItems} + profile={profile} + moderationOpts={moderationOpts} + /> + </Provider> + ) +} + +function WizardInner({ + currentStarterPack, + currentListItems, + profile, + moderationOpts, +}: { + currentStarterPack?: AppBskyGraphDefs.StarterPackView + currentListItems?: AppBskyGraphDefs.ListItemView[] + profile: AppBskyActorDefs.ProfileViewBasic + moderationOpts: ModerationOpts +}) { + const navigation = useNavigation<NavigationProp>() + const {_} = useLingui() + const t = useTheme() + const setMinimalShellMode = useSetMinimalShellMode() + const {setEnabled} = useKeyboardController() + const [state, dispatch] = useWizardState() + const {currentAccount} = useSession() + const {data: currentProfile} = useProfileQuery({ + did: currentAccount?.did, + staleTime: 0, + }) + const parsed = parseStarterPackUri(currentStarterPack?.uri) + + React.useEffect(() => { + navigation.setOptions({ + gestureEnabled: false, + }) + }, [navigation]) + + useFocusEffect( + React.useCallback(() => { + setEnabled(true) + setMinimalShellMode(true) + + return () => { + setMinimalShellMode(false) + setEnabled(false) + } + }, [setMinimalShellMode, setEnabled]), + ) + + const getDefaultName = () => { + let displayName + if ( + currentProfile?.displayName != null && + currentProfile?.displayName !== '' + ) { + displayName = sanitizeDisplayName(currentProfile.displayName) + } else { + displayName = sanitizeHandle(currentProfile!.handle) + } + return _(msg`${displayName}'s Starter Pack`).slice(0, 50) + } + + const wizardUiStrings: Record< + WizardStep, + {header: string; nextBtn: string; subtitle?: string} + > = { + Details: { + header: _(msg`Starter Pack`), + nextBtn: _(msg`Next`), + }, + Profiles: { + header: _(msg`People`), + nextBtn: _(msg`Next`), + subtitle: _( + msg`Add people to your starter pack that you think others will enjoy following`, + ), + }, + Feeds: { + header: _(msg`Feeds`), + nextBtn: state.feeds.length === 0 ? _(msg`Skip`) : _(msg`Finish`), + subtitle: _(msg`Some subtitle`), + }, + } + const currUiStrings = wizardUiStrings[state.currentStep] + + const onSuccessCreate = (data: {uri: string; cid: string}) => { + const rkey = new AtUri(data.uri).rkey + logEvent('starterPack:create', { + setName: state.name != null, + setDescription: state.description != null, + profilesCount: state.profiles.length, + feedsCount: state.feeds.length, + }) + Image.prefetch([getStarterPackOgCard(currentProfile!.did, rkey)]) + dispatch({type: 'SetProcessing', processing: false}) + navigation.replace('StarterPack', { + name: currentAccount!.handle, + rkey, + new: true, + }) + } + + const onSuccessEdit = () => { + if (navigation.canGoBack()) { + navigation.goBack() + } else { + navigation.replace('StarterPack', { + name: currentAccount!.handle, + rkey: parsed!.rkey, + }) + } + } + + const {mutate: createStarterPack} = useCreateStarterPackMutation({ + onSuccess: onSuccessCreate, + onError: e => { + logger.error('Failed to create starter pack', {safeMessage: e}) + dispatch({type: 'SetProcessing', processing: false}) + Toast.show(_(msg`Failed to create starter pack`)) + }, + }) + const {mutate: editStarterPack} = useEditStarterPackMutation({ + onSuccess: onSuccessEdit, + onError: e => { + logger.error('Failed to edit starter pack', {safeMessage: e}) + dispatch({type: 'SetProcessing', processing: false}) + Toast.show(_(msg`Failed to create starter pack`)) + }, + }) + + const submit = async () => { + dispatch({type: 'SetProcessing', processing: true}) + if (currentStarterPack && currentListItems) { + editStarterPack({ + name: state.name ?? getDefaultName(), + description: state.description, + descriptionFacets: [], + profiles: state.profiles, + feeds: state.feeds, + currentStarterPack: currentStarterPack, + currentListItems: currentListItems, + }) + } else { + createStarterPack({ + name: state.name ?? getDefaultName(), + description: state.description, + descriptionFacets: [], + profiles: state.profiles, + feeds: state.feeds, + }) + } + } + + const onNext = () => { + if (state.currentStep === 'Feeds') { + submit() + return + } + + const keyboardVisible = Keyboard.isVisible() + Keyboard.dismiss() + setTimeout( + () => { + dispatch({type: 'Next'}) + }, + keyboardVisible ? 16 : 0, + ) + } + + return ( + <CenteredView style={[a.flex_1]} sideBorders> + <View + style={[ + a.flex_row, + a.pb_sm, + a.px_md, + a.border_b, + t.atoms.border_contrast_medium, + a.gap_sm, + a.justify_between, + a.align_center, + isAndroid && a.pt_sm, + isWeb && [a.py_md], + ]}> + <View style={[{width: 65}]}> + <TouchableOpacity + testID="viewHeaderDrawerBtn" + hitSlop={HITSLOP_10} + accessibilityRole="button" + accessibilityLabel={_(msg`Back`)} + accessibilityHint={_(msg`Go back to the previous step`)} + onPress={() => { + if (state.currentStep === 'Details') { + navigation.pop() + } else { + dispatch({type: 'Back'}) + } + }}> + <FontAwesomeIcon + size={18} + icon="angle-left" + color={t.atoms.text.color} + /> + </TouchableOpacity> + </View> + <Text style={[a.flex_1, a.font_bold, a.text_lg, a.text_center]}> + {currUiStrings.header} + </Text> + <View style={[{width: 65}]} /> + </View> + + <Container> + {state.currentStep === 'Details' ? ( + <StepDetails /> + ) : state.currentStep === 'Profiles' ? ( + <StepProfiles moderationOpts={moderationOpts} /> + ) : state.currentStep === 'Feeds' ? ( + <StepFeeds moderationOpts={moderationOpts} /> + ) : null} + </Container> + + {state.currentStep !== 'Details' && ( + <Footer + onNext={onNext} + nextBtnText={currUiStrings.nextBtn} + moderationOpts={moderationOpts} + profile={profile} + /> + )} + </CenteredView> + ) +} + +function Container({children}: {children: React.ReactNode}) { + const {_} = useLingui() + const [state, dispatch] = useWizardState() + + if (state.currentStep === 'Profiles' || state.currentStep === 'Feeds') { + return <View style={[a.flex_1]}>{children}</View> + } + + return ( + <KeyboardAwareScrollView + style={[a.flex_1]} + keyboardShouldPersistTaps="handled"> + {children} + {state.currentStep === 'Details' && ( + <> + <Button + label={_(msg`Next`)} + variant="solid" + color="primary" + size="medium" + style={[a.mx_xl, a.mb_lg, {marginTop: 35}]} + onPress={() => dispatch({type: 'Next'})}> + <ButtonText> + <Trans>Next</Trans> + </ButtonText> + </Button> + </> + )} + </KeyboardAwareScrollView> + ) +} + +function Footer({ + onNext, + nextBtnText, + moderationOpts, + profile, +}: { + onNext: () => void + nextBtnText: string + moderationOpts: ModerationOpts + profile: AppBskyActorDefs.ProfileViewBasic +}) { + const {_} = useLingui() + const t = useTheme() + const [state, dispatch] = useWizardState() + const editDialogControl = useDialogControl() + const {bottom: bottomInset} = useSafeAreaInsets() + + const items = + state.currentStep === 'Profiles' + ? [profile, ...state.profiles] + : state.feeds + const initialNamesIndex = state.currentStep === 'Profiles' ? 1 : 0 + + const isEditEnabled = + (state.currentStep === 'Profiles' && items.length > 1) || + (state.currentStep === 'Feeds' && items.length > 0) + + const minimumItems = state.currentStep === 'Profiles' ? 8 : 0 + + const textStyles = [a.text_md] + + return ( + <View + style={[ + a.border_t, + a.align_center, + a.px_lg, + a.pt_xl, + a.gap_md, + t.atoms.bg, + t.atoms.border_contrast_medium, + { + paddingBottom: a.pb_lg.paddingBottom + bottomInset, + }, + isNative && [ + a.border_l, + a.border_r, + t.atoms.shadow_md, + { + borderTopLeftRadius: 14, + borderTopRightRadius: 14, + }, + ], + ]}> + {items.length > minimumItems && ( + <View style={[a.absolute, {right: 14, top: 31}]}> + <Text style={[a.font_bold]}> + {items.length}/{state.currentStep === 'Profiles' ? 50 : 3} + </Text> + </View> + )} + + <View style={[a.flex_row, a.gap_xs]}> + {items.slice(0, 6).map((p, index) => ( + <UserAvatar + key={index} + avatar={p.avatar} + size={32} + type={state.currentStep === 'Profiles' ? 'user' : 'algo'} + /> + ))} + </View> + + {items.length === 0 ? ( + <View style={[a.gap_sm]}> + <Text style={[a.font_bold, a.text_center, textStyles]}> + <Trans>Add some feeds to your starter pack!</Trans> + </Text> + <Text style={[a.text_center, textStyles]}> + <Trans>Search for feeds that you want to suggest to others.</Trans> + </Text> + </View> + ) : ( + <Text style={[a.text_center, textStyles]}> + {state.currentStep === 'Profiles' && items.length === 1 ? ( + <Trans> + It's just you right now! Add more people to your starter pack by + searching above. + </Trans> + ) : items.length === 1 ? ( + <Trans> + <Text style={[a.font_bold, textStyles]}> + {getName(items[initialNamesIndex])} + </Text>{' '} + is included in your starter pack + </Trans> + ) : items.length === 2 ? ( + <Trans> + <Text style={[a.font_bold, textStyles]}> + {getName(items[initialNamesIndex])}{' '} + </Text> + and + <Text> </Text> + <Text style={[a.font_bold, textStyles]}> + {getName(items[state.currentStep === 'Profiles' ? 0 : 1])}{' '} + </Text> + are included in your starter pack + </Trans> + ) : ( + <Trans> + <Text style={[a.font_bold, textStyles]}> + {getName(items[initialNamesIndex])},{' '} + </Text> + <Text style={[a.font_bold, textStyles]}> + {getName(items[initialNamesIndex + 1])},{' '} + </Text> + and {items.length - 2}{' '} + <Plural value={items.length - 2} one="other" other="others" /> are + included in your starter pack + </Trans> + )} + </Text> + )} + + <View + style={[ + a.flex_row, + a.w_full, + a.justify_between, + a.align_center, + isNative ? a.mt_sm : a.mt_md, + ]}> + {isEditEnabled ? ( + <Button + label={_(msg`Edit`)} + variant="solid" + color="secondary" + size="small" + style={{width: 70}} + onPress={editDialogControl.open}> + <ButtonText> + <Trans>Edit</Trans> + </ButtonText> + </Button> + ) : ( + <View style={{width: 70, height: 35}} /> + )} + {state.currentStep === 'Profiles' && items.length < 8 ? ( + <> + <Text + style={[a.font_bold, textStyles, t.atoms.text_contrast_medium]}> + <Trans>Add {8 - items.length} more to continue</Trans> + </Text> + <View style={{width: 70}} /> + </> + ) : ( + <Button + label={nextBtnText} + variant="solid" + color="primary" + size="small" + onPress={onNext} + disabled={!state.canNext || state.processing}> + <ButtonText>{nextBtnText}</ButtonText> + {state.processing && <Loader size="xs" style={{color: 'white'}} />} + </Button> + )} + </View> + + <WizardEditListDialog + control={editDialogControl} + state={state} + dispatch={dispatch} + moderationOpts={moderationOpts} + profile={profile} + /> + </View> + ) +} + +function getName(item: AppBskyActorDefs.ProfileViewBasic | GeneratorView) { + if (typeof item.displayName === 'string') { + return enforceLen(sanitizeDisplayName(item.displayName), 16, true) + } else if (typeof item.handle === 'string') { + return enforceLen(sanitizeHandle(item.handle), 16, true) + } + return '' +} |