diff options
Diffstat (limited to 'src')
78 files changed, 5361 insertions, 205 deletions
diff --git a/src/App.native.tsx b/src/App.native.tsx index 4c73d8752..639276a12 100644 --- a/src/App.native.tsx +++ b/src/App.native.tsx @@ -46,11 +46,13 @@ import {readLastActiveAccount} from '#/state/session/util' import {Provider as ShellStateProvider} from '#/state/shell' import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out' import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed' +import {Provider as StarterPackProvider} from '#/state/shell/starter-pack' import {TestCtrls} from '#/view/com/testing/TestCtrls' import * as Toast from '#/view/com/util/Toast' import {Shell} from '#/view/shell' import {ThemeProvider as Alf} from '#/alf' import {useColorModeTheme} from '#/alf/util/useColorModeTheme' +import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry' import {Provider as PortalProvider} from '#/components/Portal' import {Splash} from '#/Splash' import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider' @@ -67,6 +69,7 @@ function InnerApp() { const {_} = useLingui() useIntentHandler() + const hasCheckedReferrer = useStarterPackEntry() // init useEffect(() => { @@ -98,7 +101,7 @@ function InnerApp() { <SafeAreaProvider initialMetrics={initialWindowMetrics}> <Alf theme={theme}> <ThemeProvider theme={theme}> - <Splash isReady={isReady}> + <Splash isReady={isReady && hasCheckedReferrer}> <RootSiblingParent> <React.Fragment // Resets the entire tree below when it changes: @@ -164,7 +167,9 @@ function App() { <LightboxStateProvider> <I18nProvider> <PortalProvider> - <InnerApp /> + <StarterPackProvider> + <InnerApp /> + </StarterPackProvider> </PortalProvider> </I18nProvider> </LightboxStateProvider> diff --git a/src/App.web.tsx b/src/App.web.tsx index 00939c9eb..31a59d97d 100644 --- a/src/App.web.tsx +++ b/src/App.web.tsx @@ -35,11 +35,13 @@ import {readLastActiveAccount} from '#/state/session/util' import {Provider as ShellStateProvider} from '#/state/shell' import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out' import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed' +import {Provider as StarterPackProvider} from '#/state/shell/starter-pack' import * as Toast from '#/view/com/util/Toast' import {ToastContainer} from '#/view/com/util/Toast.web' import {Shell} from '#/view/shell/index' import {ThemeProvider as Alf} from '#/alf' import {useColorModeTheme} from '#/alf/util/useColorModeTheme' +import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry' import {Provider as PortalProvider} from '#/components/Portal' import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider' import I18nProvider from './locale/i18nProvider' @@ -52,6 +54,7 @@ function InnerApp() { const theme = useColorModeTheme() const {_} = useLingui() useIntentHandler() + const hasCheckedReferrer = useStarterPackEntry() // init useEffect(() => { @@ -77,7 +80,7 @@ function InnerApp() { }, [_]) // wait for session to resume - if (!isReady) return null + if (!isReady || !hasCheckedReferrer) return null return ( <KeyboardProvider enabled={false}> @@ -146,7 +149,9 @@ function App() { <LightboxStateProvider> <I18nProvider> <PortalProvider> - <InnerApp /> + <StarterPackProvider> + <InnerApp /> + </StarterPackProvider> </PortalProvider> </I18nProvider> </LightboxStateProvider> diff --git a/src/Navigation.tsx b/src/Navigation.tsx index f2b7cd911..5cb4f4105 100644 --- a/src/Navigation.tsx +++ b/src/Navigation.tsx @@ -43,6 +43,8 @@ import HashtagScreen from '#/screens/Hashtag' import {ModerationScreen} from '#/screens/Moderation' import {ProfileKnownFollowersScreen} from '#/screens/Profile/KnownFollowers' import {ProfileLabelerLikedByScreen} from '#/screens/Profile/ProfileLabelerLikedBy' +import {StarterPackScreen} from '#/screens/StarterPack/StarterPackScreen' +import {Wizard} from '#/screens/StarterPack/Wizard' import {init as initAnalytics} from './lib/analytics/analytics' import {useWebScrollRestoration} from './lib/hooks/useWebScrollRestoration' import {attachRouteToLogEvents, logEvent} from './lib/statsig/statsig' @@ -317,6 +319,21 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) { getComponent={() => FeedsScreen} options={{title: title(msg`Feeds`)}} /> + <Stack.Screen + name="StarterPack" + getComponent={() => StarterPackScreen} + options={{title: title(msg`Starter Pack`), requireAuth: true}} + /> + <Stack.Screen + name="StarterPackWizard" + getComponent={() => Wizard} + options={{title: title(msg`Create a starter pack`), requireAuth: true}} + /> + <Stack.Screen + name="StarterPackEdit" + getComponent={() => Wizard} + options={{title: title(msg`Edit your starter pack`), requireAuth: true}} + /> </> ) } @@ -371,6 +388,7 @@ function HomeTabNavigator() { contentStyle: pal.view, }}> <HomeTab.Screen name="Home" getComponent={() => HomeScreen} /> + <HomeTab.Screen name="Start" getComponent={() => HomeScreen} /> {commonScreens(HomeTab)} </HomeTab.Navigator> ) @@ -507,6 +525,11 @@ const FlatNavigator = () => { getComponent={() => MessagesScreen} options={{title: title(msg`Messages`), requireAuth: true}} /> + <Flat.Screen + name="Start" + getComponent={() => HomeScreen} + options={{title: title(msg`Home`)}} + /> {commonScreens(Flat as typeof HomeTab, numUnread)} </Flat.Navigator> ) diff --git a/src/components/LinearGradientBackground.tsx b/src/components/LinearGradientBackground.tsx new file mode 100644 index 000000000..f516b19f5 --- /dev/null +++ b/src/components/LinearGradientBackground.tsx @@ -0,0 +1,23 @@ +import React from 'react' +import {StyleProp, ViewStyle} from 'react-native' +import {LinearGradient} from 'expo-linear-gradient' + +import {gradients} from '#/alf/tokens' + +export function LinearGradientBackground({ + style, + children, +}: { + style: StyleProp<ViewStyle> + children: React.ReactNode +}) { + const gradient = gradients.sky.values.map(([_, color]) => { + return color + }) + + return ( + <LinearGradient colors={gradient} style={style}> + {children} + </LinearGradient> + ) +} diff --git a/src/components/NewskieDialog.tsx b/src/components/NewskieDialog.tsx index 0354bfc43..6743a592b 100644 --- a/src/components/NewskieDialog.tsx +++ b/src/components/NewskieDialog.tsx @@ -9,11 +9,13 @@ import {useGetTimeAgo} from '#/lib/hooks/useTimeAgo' import {useModerationOpts} from '#/state/preferences/moderation-opts' import {HITSLOP_10} from 'lib/constants' import {sanitizeDisplayName} from 'lib/strings/display-names' -import {atoms as a} from '#/alf' -import {Button} from '#/components/Button' +import {isWeb} from 'platform/detection' +import {atoms as a, useTheme} from '#/alf' +import {Button, ButtonText} from '#/components/Button' import * as Dialog from '#/components/Dialog' import {useDialogControl} from '#/components/Dialog' import {Newskie} from '#/components/icons/Newskie' +import * as StarterPackCard from '#/components/StarterPack/StarterPackCard' import {Text} from '#/components/Typography' export function NewskieDialog({ @@ -24,6 +26,7 @@ export function NewskieDialog({ disabled?: boolean }) { const {_} = useLingui() + const t = useTheme() const moderationOpts = useModerationOpts() const control = useDialogControl() const profileName = React.useMemo(() => { @@ -68,15 +71,62 @@ export function NewskieDialog({ label={_(msg`New user info dialog`)} style={[{width: 'auto', maxWidth: 400, minWidth: 200}]}> <View style={[a.gap_sm]}> - <Text style={[a.font_bold, a.text_xl]}> - <Trans>Say hello!</Trans> - </Text> - <Text style={[a.text_md]}> - <Trans> - {profileName} joined Bluesky{' '} - {timeAgo(createdAt, now, {format: 'long'})} ago - </Trans> + <View style={[a.align_center]}> + <Newskie + width={64} + height={64} + fill="#FFC404" + style={{marginTop: -10}} + /> + <Text style={[a.font_bold, a.text_xl, {marginTop: -10}]}> + <Trans>Say hello!</Trans> + </Text> + </View> + <Text style={[a.text_md, a.text_center, a.leading_tight]}> + {profile.joinedViaStarterPack ? ( + <Trans> + {profileName} joined Bluesky using a starter pack{' '} + {timeAgo(createdAt, now, {format: 'long'})} ago + </Trans> + ) : ( + <Trans> + {profileName} joined Bluesky{' '} + {timeAgo(createdAt, now, {format: 'long'})} ago + </Trans> + )} </Text> + {profile.joinedViaStarterPack ? ( + <StarterPackCard.Link + starterPack={profile.joinedViaStarterPack} + onPress={() => { + control.close() + }}> + <View + style={[ + a.flex_1, + a.mt_sm, + a.p_lg, + a.border, + a.rounded_sm, + t.atoms.border_contrast_low, + ]}> + <StarterPackCard.Card + starterPack={profile.joinedViaStarterPack} + /> + </View> + </StarterPackCard.Link> + ) : null} + <Button + label={_(msg`Close`)} + variant="solid" + color="secondary" + size="small" + style={[a.mt_sm, isWeb && [a.self_center, {marginLeft: 'auto'}]]} + onPress={() => control.close()}> + <ButtonText> + <Trans>Close</Trans> + </ButtonText> + </Button> </View> </Dialog.ScrollableInner> </Dialog.Outer> diff --git a/src/components/ProfileCard.tsx b/src/components/ProfileCard.tsx new file mode 100644 index 000000000..a0d222854 --- /dev/null +++ b/src/components/ProfileCard.tsx @@ -0,0 +1,91 @@ +import React from 'react' +import {View} from 'react-native' +import {AppBskyActorDefs, moderateProfile, ModerationOpts} from '@atproto/api' + +import {createSanitizedDisplayName} from 'lib/moderation/create-sanitized-display-name' +import {sanitizeHandle} from 'lib/strings/handles' +import {useProfileShadow} from 'state/cache/profile-shadow' +import {useSession} from 'state/session' +import {FollowButton} from 'view/com/profile/FollowButton' +import {ProfileCardPills} from 'view/com/profile/ProfileCard' +import {UserAvatar} from 'view/com/util/UserAvatar' +import {atoms as a, useTheme} from '#/alf' +import {Link} from '#/components/Link' +import {Text} from '#/components/Typography' + +export function Default({ + profile: profileUnshadowed, + moderationOpts, + logContext = 'ProfileCard', +}: { + profile: AppBskyActorDefs.ProfileViewDetailed + moderationOpts: ModerationOpts + logContext?: 'ProfileCard' | 'StarterPackProfilesList' +}) { + const t = useTheme() + const {currentAccount, hasSession} = useSession() + + const profile = useProfileShadow(profileUnshadowed) + const name = createSanitizedDisplayName(profile) + const handle = `@${sanitizeHandle(profile.handle)}` + const moderation = moderateProfile(profile, moderationOpts) + + return ( + <Wrapper did={profile.did}> + <View style={[a.flex_row, a.gap_sm]}> + <UserAvatar + size={42} + avatar={profile.avatar} + type={ + profile.associated?.labeler + ? 'labeler' + : profile.associated?.feedgens + ? 'algo' + : 'user' + } + moderation={moderation.ui('avatar')} + /> + <View style={[a.flex_1]}> + <Text + style={[a.text_md, a.font_bold, a.leading_snug]} + numberOfLines={1}> + {name} + </Text> + <Text + style={[a.leading_snug, t.atoms.text_contrast_medium]} + numberOfLines={1}> + {handle} + </Text> + </View> + {hasSession && profile.did !== currentAccount?.did && ( + <View style={[a.justify_center, {marginLeft: 'auto'}]}> + <FollowButton profile={profile} logContext={logContext} /> + </View> + )} + </View> + <View style={[a.mb_xs]}> + <ProfileCardPills + followedBy={Boolean(profile.viewer?.followedBy)} + moderation={moderation} + /> + </View> + {profile.description && ( + <Text numberOfLines={3} style={[a.leading_snug]}> + {profile.description} + </Text> + )} + </Wrapper> + ) +} + +function Wrapper({did, children}: {did: string; children: React.ReactNode}) { + return ( + <Link + to={{ + screen: 'Profile', + params: {name: did}, + }}> + <View style={[a.flex_1, a.gap_xs]}>{children}</View> + </Link> + ) +} diff --git a/src/components/ReportDialog/SelectReportOptionView.tsx b/src/components/ReportDialog/SelectReportOptionView.tsx index 4413cbe89..169c07d73 100644 --- a/src/components/ReportDialog/SelectReportOptionView.tsx +++ b/src/components/ReportDialog/SelectReportOptionView.tsx @@ -55,6 +55,9 @@ export function SelectReportOptionView({ } else if (props.params.type === 'feedgen') { title = _(msg`Report this feed`) description = _(msg`Why should this feed be reviewed?`) + } else if (props.params.type === 'starterpack') { + title = _(msg`Report this starter pack`) + description = _(msg`Why should this starter pack be reviewed?`) } else if (props.params.type === 'convoMessage') { title = _(msg`Report this message`) description = _(msg`Why should this message be reviewed?`) diff --git a/src/components/ReportDialog/types.ts b/src/components/ReportDialog/types.ts index ceabe0b90..3f43db4a1 100644 --- a/src/components/ReportDialog/types.ts +++ b/src/components/ReportDialog/types.ts @@ -4,7 +4,7 @@ export type ReportDialogProps = { control: Dialog.DialogOuterProps['control'] params: | { - type: 'post' | 'list' | 'feedgen' | 'other' + type: 'post' | 'list' | 'feedgen' | 'starterpack' | 'other' uri: string cid: string } diff --git a/src/components/StarterPack/Main/FeedsList.tsx b/src/components/StarterPack/Main/FeedsList.tsx new file mode 100644 index 000000000..e350a422c --- /dev/null +++ b/src/components/StarterPack/Main/FeedsList.tsx @@ -0,0 +1,68 @@ +import React, {useCallback} from 'react' +import {ListRenderItemInfo, View} from 'react-native' +import {AppBskyFeedDefs} from '@atproto/api' +import {GeneratorView} from '@atproto/api/dist/client/types/app/bsky/feed/defs' + +import {useBottomBarOffset} from 'lib/hooks/useBottomBarOffset' +import {isNative, isWeb} from 'platform/detection' +import {List, ListRef} from 'view/com/util/List' +import {SectionRef} from '#/screens/Profile/Sections/types' +import {atoms as a, useTheme} from '#/alf' +import * as FeedCard from '#/components/FeedCard' + +function keyExtractor(item: AppBskyFeedDefs.GeneratorView) { + return item.uri +} + +interface ProfilesListProps { + feeds: AppBskyFeedDefs.GeneratorView[] + headerHeight: number + scrollElRef: ListRef +} + +export const FeedsList = React.forwardRef<SectionRef, ProfilesListProps>( + function FeedsListImpl({feeds, headerHeight, scrollElRef}, ref) { + const [initialHeaderHeight] = React.useState(headerHeight) + const bottomBarOffset = useBottomBarOffset(20) + const t = useTheme() + + const onScrollToTop = useCallback(() => { + scrollElRef.current?.scrollToOffset({ + animated: isNative, + offset: -headerHeight, + }) + }, [scrollElRef, headerHeight]) + + React.useImperativeHandle(ref, () => ({ + scrollToTop: onScrollToTop, + })) + + const renderItem = ({item, index}: ListRenderItemInfo<GeneratorView>) => { + return ( + <View + style={[ + a.p_lg, + (isWeb || index !== 0) && a.border_t, + t.atoms.border_contrast_low, + ]}> + <FeedCard.Default type="feed" view={item} /> + </View> + ) + } + + return ( + <List + data={feeds} + renderItem={renderItem} + keyExtractor={keyExtractor} + ref={scrollElRef} + headerOffset={headerHeight} + ListFooterComponent={ + <View style={[{height: initialHeaderHeight + bottomBarOffset}]} /> + } + showsVerticalScrollIndicator={false} + desktopFixedHeight={true} + /> + ) + }, +) diff --git a/src/components/StarterPack/Main/ProfilesList.tsx b/src/components/StarterPack/Main/ProfilesList.tsx new file mode 100644 index 000000000..72d35fe2b --- /dev/null +++ b/src/components/StarterPack/Main/ProfilesList.tsx @@ -0,0 +1,119 @@ +import React, {useCallback} from 'react' +import {ListRenderItemInfo, View} from 'react-native' +import { + AppBskyActorDefs, + AppBskyGraphGetList, + AtUri, + ModerationOpts, +} from '@atproto/api' +import {InfiniteData, UseInfiniteQueryResult} from '@tanstack/react-query' + +import {useBottomBarOffset} from 'lib/hooks/useBottomBarOffset' +import {isNative, isWeb} from 'platform/detection' +import {useSession} from 'state/session' +import {List, ListRef} from 'view/com/util/List' +import {SectionRef} from '#/screens/Profile/Sections/types' +import {atoms as a, useTheme} from '#/alf' +import {Default as ProfileCard} from '#/components/ProfileCard' + +function keyExtractor(item: AppBskyActorDefs.ProfileViewBasic, index: number) { + return `${item.did}-${index}` +} + +interface ProfilesListProps { + listUri: string + listMembersQuery: UseInfiniteQueryResult< + InfiniteData<AppBskyGraphGetList.OutputSchema> + > + moderationOpts: ModerationOpts + headerHeight: number + scrollElRef: ListRef +} + +export const ProfilesList = React.forwardRef<SectionRef, ProfilesListProps>( + function ProfilesListImpl( + {listUri, listMembersQuery, moderationOpts, headerHeight, scrollElRef}, + ref, + ) { + const t = useTheme() + const [initialHeaderHeight] = React.useState(headerHeight) + const bottomBarOffset = useBottomBarOffset(20) + const {currentAccount} = useSession() + + const [isPTRing, setIsPTRing] = React.useState(false) + + const {data, refetch} = listMembersQuery + + // The server returns these sorted by descending creation date, so we want to invert + const profiles = data?.pages + .flatMap(p => p.items.map(i => i.subject)) + .reverse() + const isOwn = new AtUri(listUri).host === currentAccount?.did + + const getSortedProfiles = () => { + if (!profiles) return + if (!isOwn) return profiles + + const myIndex = profiles.findIndex(p => p.did === currentAccount?.did) + return myIndex !== -1 + ? [ + profiles[myIndex], + ...profiles.slice(0, myIndex), + ...profiles.slice(myIndex + 1), + ] + : profiles + } + const onScrollToTop = useCallback(() => { + scrollElRef.current?.scrollToOffset({ + animated: isNative, + offset: -headerHeight, + }) + }, [scrollElRef, headerHeight]) + + React.useImperativeHandle(ref, () => ({ + scrollToTop: onScrollToTop, + })) + + const renderItem = ({ + item, + index, + }: ListRenderItemInfo<AppBskyActorDefs.ProfileViewBasic>) => { + return ( + <View + style={[ + a.p_lg, + t.atoms.border_contrast_low, + (isWeb || index !== 0) && a.border_t, + ]}> + <ProfileCard + profile={item} + moderationOpts={moderationOpts} + logContext="StarterPackProfilesList" + /> + </View> + ) + } + + if (listMembersQuery) + return ( + <List + data={getSortedProfiles()} + renderItem={renderItem} + keyExtractor={keyExtractor} + ref={scrollElRef} + headerOffset={headerHeight} + ListFooterComponent={ + <View style={[{height: initialHeaderHeight + bottomBarOffset}]} /> + } + showsVerticalScrollIndicator={false} + desktopFixedHeight + refreshing={isPTRing} + onRefresh={async () => { + setIsPTRing(true) + await refetch() + setIsPTRing(false) + }} + /> + ) + }, +) diff --git a/src/components/StarterPack/ProfileStarterPacks.tsx b/src/components/StarterPack/ProfileStarterPacks.tsx new file mode 100644 index 000000000..096f04f2d --- /dev/null +++ b/src/components/StarterPack/ProfileStarterPacks.tsx @@ -0,0 +1,320 @@ +import React from 'react' +import { + findNodeHandle, + ListRenderItemInfo, + StyleProp, + View, + ViewStyle, +} from 'react-native' +import {AppBskyGraphDefs, AppBskyGraphGetActorStarterPacks} from '@atproto/api' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useNavigation} from '@react-navigation/native' +import {InfiniteData, UseInfiniteQueryResult} from '@tanstack/react-query' + +import {logger} from '#/logger' +import {useGenerateStarterPackMutation} from 'lib/generate-starterpack' +import {useBottomBarOffset} from 'lib/hooks/useBottomBarOffset' +import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' +import {NavigationProp} from 'lib/routes/types' +import {parseStarterPackUri} from 'lib/strings/starter-pack' +import {List, ListRef} from 'view/com/util/List' +import {Text} from 'view/com/util/text/Text' +import {atoms as a, useTheme} from '#/alf' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import {useDialogControl} from '#/components/Dialog' +import {LinearGradientBackground} from '#/components/LinearGradientBackground' +import {Loader} from '#/components/Loader' +import * as Prompt from '#/components/Prompt' +import {Default as StarterPackCard} from '#/components/StarterPack/StarterPackCard' +import {PlusSmall_Stroke2_Corner0_Rounded as Plus} from '../icons/Plus' + +interface SectionRef { + scrollToTop: () => void +} + +interface ProfileFeedgensProps { + starterPacksQuery: UseInfiniteQueryResult< + InfiniteData<AppBskyGraphGetActorStarterPacks.OutputSchema, unknown>, + Error + > + scrollElRef: ListRef + headerOffset: number + enabled?: boolean + style?: StyleProp<ViewStyle> + testID?: string + setScrollViewTag: (tag: number | null) => void + isMe: boolean +} + +function keyExtractor(item: AppBskyGraphDefs.StarterPackView) { + return item.uri +} + +export const ProfileStarterPacks = React.forwardRef< + SectionRef, + ProfileFeedgensProps +>(function ProfileFeedgensImpl( + { + starterPacksQuery: query, + scrollElRef, + headerOffset, + enabled, + style, + testID, + setScrollViewTag, + isMe, + }, + ref, +) { + const t = useTheme() + const bottomBarOffset = useBottomBarOffset(100) + const [isPTRing, setIsPTRing] = React.useState(false) + const {data, refetch, isFetching, hasNextPage, fetchNextPage} = query + const {isTabletOrDesktop} = useWebMediaQueries() + + const items = data?.pages.flatMap(page => page.starterPacks) + + React.useImperativeHandle(ref, () => ({ + scrollToTop: () => {}, + })) + + const onRefresh = React.useCallback(async () => { + setIsPTRing(true) + try { + await refetch() + } catch (err) { + logger.error('Failed to refresh starter packs', {message: err}) + } + setIsPTRing(false) + }, [refetch, setIsPTRing]) + + const onEndReached = React.useCallback(async () => { + if (isFetching || !hasNextPage) return + + try { + await fetchNextPage() + } catch (err) { + logger.error('Failed to load more starter packs', {message: err}) + } + }, [isFetching, hasNextPage, fetchNextPage]) + + React.useEffect(() => { + if (enabled && scrollElRef.current) { + const nativeTag = findNodeHandle(scrollElRef.current) + setScrollViewTag(nativeTag) + } + }, [enabled, scrollElRef, setScrollViewTag]) + + const renderItem = ({ + item, + index, + }: ListRenderItemInfo<AppBskyGraphDefs.StarterPackView>) => { + return ( + <View + style={[ + a.p_lg, + (isTabletOrDesktop || index !== 0) && a.border_t, + t.atoms.border_contrast_low, + ]}> + <StarterPackCard starterPack={item} /> + </View> + ) + } + + return ( + <View testID={testID} style={style}> + <List + testID={testID ? `${testID}-flatlist` : undefined} + ref={scrollElRef} + data={items} + renderItem={renderItem} + keyExtractor={keyExtractor} + refreshing={isPTRing} + headerOffset={headerOffset} + contentContainerStyle={{paddingBottom: headerOffset + bottomBarOffset}} + indicatorStyle={t.name === 'light' ? 'black' : 'white'} + removeClippedSubviews={true} + desktopFixedHeight + onEndReached={onEndReached} + onRefresh={onRefresh} + ListEmptyComponent={Empty} + ListFooterComponent={ + items?.length !== 0 && isMe ? CreateAnother : undefined + } + /> + </View> + ) +}) + +function CreateAnother() { + const {_} = useLingui() + const t = useTheme() + const navigation = useNavigation<NavigationProp>() + + return ( + <View + style={[ + a.pr_md, + a.pt_lg, + a.gap_lg, + a.border_t, + t.atoms.border_contrast_low, + ]}> + <Button + label={_(msg`Create a starter pack`)} + variant="solid" + color="secondary" + size="small" + style={[a.self_center]} + onPress={() => navigation.navigate('StarterPackWizard')}> + <ButtonText> + <Trans>Create another</Trans> + </ButtonText> + <ButtonIcon icon={Plus} position="right" /> + </Button> + </View> + ) +} + +function Empty() { + const {_} = useLingui() + const t = useTheme() + const navigation = useNavigation<NavigationProp>() + const confirmDialogControl = useDialogControl() + const followersDialogControl = useDialogControl() + const errorDialogControl = useDialogControl() + + const [isGenerating, setIsGenerating] = React.useState(false) + + const {mutate: generateStarterPack} = useGenerateStarterPackMutation({ + onSuccess: ({uri}) => { + const parsed = parseStarterPackUri(uri) + if (parsed) { + navigation.push('StarterPack', { + name: parsed.name, + rkey: parsed.rkey, + }) + } + setIsGenerating(false) + }, + onError: e => { + logger.error('Failed to generate starter pack', {safeMessage: e}) + setIsGenerating(false) + if (e.name === 'NOT_ENOUGH_FOLLOWERS') { + followersDialogControl.open() + } else { + errorDialogControl.open() + } + }, + }) + + const generate = () => { + setIsGenerating(true) + generateStarterPack() + } + + return ( + <LinearGradientBackground + style={[ + a.px_lg, + a.py_lg, + a.justify_between, + a.gap_lg, + a.shadow_lg, + {marginTop: 2}, + ]}> + <View style={[a.gap_xs]}> + <Text + style={[ + a.font_bold, + a.text_lg, + t.atoms.text_contrast_medium, + {color: 'white'}, + ]}> + You haven't created a starter pack yet! + </Text> + <Text style={[a.text_md, {color: 'white'}]}> + Starter packs let you easily share your favorite feeds and people with + your friends. + </Text> + </View> + <View style={[a.flex_row, a.gap_md, {marginLeft: 'auto'}]}> + <Button + label={_(msg`Create a starter pack for me`)} + variant="ghost" + color="primary" + size="small" + disabled={isGenerating} + onPress={confirmDialogControl.open} + style={{backgroundColor: 'transparent'}}> + <ButtonText style={{color: 'white'}}> + <Trans>Make one for me</Trans> + </ButtonText> + {isGenerating && <Loader size="md" />} + </Button> + <Button + label={_(msg`Create a starter pack`)} + variant="ghost" + color="primary" + size="small" + disabled={isGenerating} + onPress={() => navigation.navigate('StarterPackWizard')} + style={{ + backgroundColor: 'white', + borderColor: 'white', + width: 100, + }} + hoverStyle={[{backgroundColor: '#dfdfdf'}]}> + <ButtonText> + <Trans>Create</Trans> + </ButtonText> + </Button> + </View> + + <Prompt.Outer control={confirmDialogControl}> + <Prompt.TitleText> + <Trans>Generate a starter pack</Trans> + </Prompt.TitleText> + <Prompt.DescriptionText> + <Trans> + Bluesky will choose a set of recommended accounts from people in + your network. + </Trans> + </Prompt.DescriptionText> + <Prompt.Actions> + <Prompt.Action + color="primary" + cta={_(msg`Choose for me`)} + onPress={generate} + /> + <Prompt.Action + color="secondary" + cta={_(msg`Let me choose`)} + onPress={() => { + navigation.navigate('StarterPackWizard') + }} + /> + </Prompt.Actions> + </Prompt.Outer> + <Prompt.Basic + control={followersDialogControl} + title={_(msg`Oops!`)} + description={_( + msg`You must be following at least seven other people to generate a starter pack.`, + )} + onConfirm={() => {}} + showCancel={false} + /> + <Prompt.Basic + control={errorDialogControl} + title={_(msg`Oops!`)} + description={_( + msg`An error occurred while generating your starter pack. Want to try again?`, + )} + onConfirm={generate} + confirmButtonCta={_(msg`Retry`)} + /> + </LinearGradientBackground> + ) +} diff --git a/src/components/StarterPack/QrCode.tsx b/src/components/StarterPack/QrCode.tsx new file mode 100644 index 000000000..08ee03d62 --- /dev/null +++ b/src/components/StarterPack/QrCode.tsx @@ -0,0 +1,119 @@ +import React from 'react' +import {View} from 'react-native' +import QRCode from 'react-native-qrcode-styled' +import ViewShot from 'react-native-view-shot' +import {AppBskyGraphDefs, AppBskyGraphStarterpack} from '@atproto/api' +import {Trans} from '@lingui/macro' + +import {isWeb} from 'platform/detection' +import {Logo} from 'view/icons/Logo' +import {Logotype} from 'view/icons/Logotype' +import {useTheme} from '#/alf' +import {atoms as a} from '#/alf' +import {LinearGradientBackground} from '#/components/LinearGradientBackground' +import {Text} from '#/components/Typography' + +interface Props { + starterPack: AppBskyGraphDefs.StarterPackView + link: string +} + +export const QrCode = React.forwardRef<ViewShot, Props>(function QrCode( + {starterPack, link}, + ref, +) { + const {record} = starterPack + + if (!AppBskyGraphStarterpack.isRecord(record)) { + return null + } + + return ( + <ViewShot ref={ref}> + <LinearGradientBackground + style={[ + {width: 300, minHeight: 390}, + a.align_center, + a.px_sm, + a.py_xl, + a.rounded_sm, + a.justify_between, + a.gap_md, + ]}> + <View style={[a.gap_sm]}> + <Text + style={[a.font_bold, a.text_3xl, a.text_center, {color: 'white'}]}> + {record.name} + </Text> + </View> + <View style={[a.gap_xl, a.align_center]}> + <Text + style={[ + a.font_bold, + a.text_center, + {color: 'white', fontSize: 18}, + ]}> + <Trans>Join the conversation</Trans> + </Text> + <View style={[a.rounded_sm, a.overflow_hidden]}> + <QrCodeInner link={link} /> + </View> + + <View style={[a.flex_row, a.align_center, {gap: 5}]}> + <Text + style={[ + a.font_bold, + a.text_center, + {color: 'white', fontSize: 18}, + ]}> + <Trans>on</Trans> + </Text> + <Logo width={26} fill="white" /> + <View style={[{marginTop: 5, marginLeft: 2.5}]}> + <Logotype width={68} fill="white" /> + </View> + </View> + </View> + </LinearGradientBackground> + </ViewShot> + ) +}) + +export function QrCodeInner({link}: {link: string}) { + const t = useTheme() + + return ( + <QRCode + data={link} + style={[ + a.rounded_sm, + {height: 225, width: 225, backgroundColor: '#f3f3f3'}, + ]} + pieceSize={isWeb ? 8 : 6} + padding={20} + // pieceLiquidRadius={2} + pieceBorderRadius={isWeb ? 4.5 : 3.5} + outerEyesOptions={{ + topLeft: { + borderRadius: [12, 12, 0, 12], + color: t.palette.primary_500, + }, + topRight: { + borderRadius: [12, 12, 12, 0], + color: t.palette.primary_500, + }, + bottomLeft: { + borderRadius: [12, 0, 12, 12], + color: t.palette.primary_500, + }, + }} + innerEyesOptions={{borderRadius: 3}} + logo={{ + href: require('../../../assets/logo.png'), + scale: 1.2, + padding: 2, + hidePieces: true, + }} + /> + ) +} diff --git a/src/components/StarterPack/QrCodeDialog.tsx b/src/components/StarterPack/QrCodeDialog.tsx new file mode 100644 index 000000000..580c6cc7c --- /dev/null +++ b/src/components/StarterPack/QrCodeDialog.tsx @@ -0,0 +1,201 @@ +import React from 'react' +import {View} from 'react-native' +import ViewShot from 'react-native-view-shot' +import * as FS from 'expo-file-system' +import {requestMediaLibraryPermissionsAsync} from 'expo-image-picker' +import * as Sharing from 'expo-sharing' +import {AppBskyGraphDefs, AppBskyGraphStarterpack} from '@atproto/api' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {nanoid} from 'nanoid/non-secure' + +import {logger} from '#/logger' +import {saveImageToMediaLibrary} from 'lib/media/manip' +import {logEvent} from 'lib/statsig/statsig' +import {isNative, isWeb} from 'platform/detection' +import * as Toast from '#/view/com/util/Toast' +import {atoms as a} from '#/alf' +import {Button, ButtonText} from '#/components/Button' +import * as Dialog from '#/components/Dialog' +import {DialogControlProps} from '#/components/Dialog' +import {Loader} from '#/components/Loader' +import {QrCode} from '#/components/StarterPack/QrCode' + +export function QrCodeDialog({ + starterPack, + link, + control, +}: { + starterPack: AppBskyGraphDefs.StarterPackView + link?: string + control: DialogControlProps +}) { + const {_} = useLingui() + const [isProcessing, setIsProcessing] = React.useState(false) + + const ref = React.useRef<ViewShot>(null) + + const getCanvas = (base64: string): Promise<HTMLCanvasElement> => { + return new Promise(resolve => { + const image = new Image() + image.onload = () => { + const canvas = document.createElement('canvas') + canvas.width = image.width + canvas.height = image.height + + const ctx = canvas.getContext('2d') + ctx?.drawImage(image, 0, 0) + resolve(canvas) + } + image.src = base64 + }) + } + + const onSavePress = async () => { + ref.current?.capture?.().then(async (uri: string) => { + if (isNative) { + const res = await requestMediaLibraryPermissionsAsync() + + if (!res) { + Toast.show( + _( + msg`You must grant access to your photo library to save a QR code`, + ), + ) + return + } + + const filename = `${FS.documentDirectory}/${nanoid(12)}.png` + + // Incase of a FS failure, don't crash the app + try { + await FS.copyAsync({from: uri, to: filename}) + await saveImageToMediaLibrary({uri: filename}) + await FS.deleteAsync(filename) + } catch (e: unknown) { + Toast.show(_(msg`An error occurred while saving the QR code!`)) + logger.error('Failed to save QR code', {error: e}) + return + } + } else { + setIsProcessing(true) + + if (!AppBskyGraphStarterpack.isRecord(starterPack.record)) { + return + } + + const canvas = await getCanvas(uri) + const imgHref = canvas + .toDataURL('image/png') + .replace('image/png', 'image/octet-stream') + + const link = document.createElement('a') + link.setAttribute( + 'download', + `${starterPack.record.name.replaceAll(' ', '_')}_Share_Card.png`, + ) + link.setAttribute('href', imgHref) + link.click() + } + + logEvent('starterPack:share', { + starterPack: starterPack.uri, + shareType: 'qrcode', + qrShareType: 'save', + }) + setIsProcessing(false) + Toast.show( + isWeb + ? _(msg`QR code has been downloaded!`) + : _(msg`QR code saved to your camera roll!`), + ) + control.close() + }) + } + + const onCopyPress = async () => { + setIsProcessing(true) + ref.current?.capture?.().then(async (uri: string) => { + const canvas = await getCanvas(uri) + // @ts-expect-error web only + canvas.toBlob((blob: Blob) => { + const item = new ClipboardItem({'image/png': blob}) + navigator.clipboard.write([item]) + }) + + logEvent('starterPack:share', { + starterPack: starterPack.uri, + shareType: 'qrcode', + qrShareType: 'copy', + }) + Toast.show(_(msg`QR code copied to your clipboard!`)) + setIsProcessing(false) + control.close() + }) + } + + const onSharePress = async () => { + ref.current?.capture?.().then(async (uri: string) => { + control.close(() => { + Sharing.shareAsync(uri, {mimeType: 'image/png', UTI: 'image/png'}).then( + () => { + logEvent('starterPack:share', { + starterPack: starterPack.uri, + shareType: 'qrcode', + qrShareType: 'share', + }) + }, + ) + }) + }) + } + + return ( + <Dialog.Outer control={control}> + <Dialog.Handle /> + <Dialog.ScrollableInner + label={_(msg`Create a QR code for a starter pack`)}> + <View style={[a.flex_1, a.align_center, a.gap_5xl]}> + {!link ? ( + <View style={[a.align_center, a.p_xl]}> + <Loader size="xl" /> + </View> + ) : ( + <> + <QrCode starterPack={starterPack} link={link} ref={ref} /> + {isProcessing ? ( + <View> + <Loader size="xl" /> + </View> + ) : ( + <View + style={[a.w_full, a.gap_md, isWeb && [a.flex_row_reverse]]}> + <Button + label={_(msg`Copy QR code`)} + variant="solid" + color="secondary" + size="small" + onPress={isWeb ? onCopyPress : onSharePress}> + <ButtonText> + {isWeb ? <Trans>Copy</Trans> : <Trans>Share</Trans>} + </ButtonText> + </Button> + <Button + label={_(msg`Save QR code`)} + variant="solid" + color="secondary" + size="small" + onPress={onSavePress}> + <ButtonText> + <Trans>Save</Trans> + </ButtonText> + </Button> + </View> + )} + </> + )} + </View> + </Dialog.ScrollableInner> + </Dialog.Outer> + ) +} diff --git a/src/components/StarterPack/ShareDialog.tsx b/src/components/StarterPack/ShareDialog.tsx new file mode 100644 index 000000000..23fa10fb3 --- /dev/null +++ b/src/components/StarterPack/ShareDialog.tsx @@ -0,0 +1,180 @@ +import React from 'react' +import {View} from 'react-native' +import * as FS from 'expo-file-system' +import {Image} from 'expo-image' +import {requestMediaLibraryPermissionsAsync} from 'expo-image-picker' +import {AppBskyGraphDefs} from '@atproto/api' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {nanoid} from 'nanoid/non-secure' + +import {logger} from '#/logger' +import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' +import {saveImageToMediaLibrary} from 'lib/media/manip' +import {shareUrl} from 'lib/sharing' +import {logEvent} from 'lib/statsig/statsig' +import {getStarterPackOgCard} from 'lib/strings/starter-pack' +import {isNative, isWeb} from 'platform/detection' +import * as Toast from 'view/com/util/Toast' +import {atoms as a, useTheme} from '#/alf' +import {Button, ButtonText} from '#/components/Button' +import {DialogControlProps} from '#/components/Dialog' +import * as Dialog from '#/components/Dialog' +import {Loader} from '#/components/Loader' +import {Text} from '#/components/Typography' + +interface Props { + starterPack: AppBskyGraphDefs.StarterPackView + link?: string + imageLoaded?: boolean + qrDialogControl: DialogControlProps + control: DialogControlProps +} + +export function ShareDialog(props: Props) { + return ( + <Dialog.Outer control={props.control}> + <ShareDialogInner {...props} /> + </Dialog.Outer> + ) +} + +function ShareDialogInner({ + starterPack, + link, + imageLoaded, + qrDialogControl, + control, +}: Props) { + const {_} = useLingui() + const t = useTheme() + const {isTabletOrDesktop} = useWebMediaQueries() + + const imageUrl = getStarterPackOgCard(starterPack) + + const onShareLink = async () => { + if (!link) return + shareUrl(link) + logEvent('starterPack:share', { + starterPack: starterPack.uri, + shareType: 'link', + }) + control.close() + } + + const onSave = async () => { + const res = await requestMediaLibraryPermissionsAsync() + + if (!res) { + Toast.show( + _(msg`You must grant access to your photo library to save the image.`), + ) + return + } + + const cachePath = await Image.getCachePathAsync(imageUrl) + const filename = `${FS.documentDirectory}/${nanoid(12)}.png` + + if (!cachePath) { + Toast.show(_(msg`An error occurred while saving the image.`)) + return + } + + try { + await FS.copyAsync({from: cachePath, to: filename}) + await saveImageToMediaLibrary({uri: filename}) + await FS.deleteAsync(filename) + + Toast.show(_(msg`Image saved to your camera roll!`)) + control.close() + } catch (e: unknown) { + Toast.show(_(msg`An error occurred while saving the QR code!`)) + logger.error('Failed to save QR code', {error: e}) + return + } + } + + return ( + <> + <Dialog.Handle /> + <Dialog.ScrollableInner label={_(msg`Share link dialog`)}> + {!imageLoaded || !link ? ( + <View style={[a.p_xl, a.align_center]}> + <Loader size="xl" /> + </View> + ) : ( + <View style={[!isTabletOrDesktop && a.gap_lg]}> + <View style={[a.gap_sm, isTabletOrDesktop && a.pb_lg]}> + <Text style={[a.font_bold, a.text_2xl]}> + <Trans>Invite people to this starter pack!</Trans> + </Text> + <Text style={[a.text_md, t.atoms.text_contrast_medium]}> + <Trans> + Share this starter pack and help people join your community on + Bluesky. + </Trans> + </Text> + </View> + <Image + source={{uri: imageUrl}} + style={[ + a.rounded_sm, + { + aspectRatio: 1200 / 630, + transform: [{scale: isTabletOrDesktop ? 0.85 : 1}], + marginTop: isTabletOrDesktop ? -20 : 0, + }, + ]} + accessibilityIgnoresInvertColors={true} + /> + <View + style={[ + a.gap_md, + isWeb && [a.gap_sm, a.flex_row_reverse, {marginLeft: 'auto'}], + ]}> + <Button + label="Share link" + variant="solid" + color="secondary" + size="small" + style={[isWeb && a.self_center]} + onPress={onShareLink}> + <ButtonText> + {isWeb ? <Trans>Copy Link</Trans> : <Trans>Share Link</Trans>} + </ButtonText> + </Button> + <Button + label="Create QR code" + variant="solid" + color="secondary" + size="small" + style={[isWeb && a.self_center]} + onPress={() => { + control.close(() => { + qrDialogControl.open() + }) + }}> + <ButtonText> + <Trans>Create QR code</Trans> + </ButtonText> + </Button> + {isNative && ( + <Button + label={_(msg`Save image`)} + variant="ghost" + color="secondary" + size="small" + style={[isWeb && a.self_center]} + onPress={onSave}> + <ButtonText> + <Trans>Save image</Trans> + </ButtonText> + </Button> + )} + </View> + </View> + )} + </Dialog.ScrollableInner> + </> + ) +} diff --git a/src/components/StarterPack/StarterPackCard.tsx b/src/components/StarterPack/StarterPackCard.tsx new file mode 100644 index 000000000..ab904d7ff --- /dev/null +++ b/src/components/StarterPack/StarterPackCard.tsx @@ -0,0 +1,117 @@ +import React from 'react' +import {View} from 'react-native' +import {AppBskyGraphStarterpack, AtUri} from '@atproto/api' +import {StarterPackViewBasic} from '@atproto/api/dist/client/types/app/bsky/graph/defs' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {sanitizeHandle} from 'lib/strings/handles' +import {useSession} from 'state/session' +import {atoms as a, useTheme} from '#/alf' +import {StarterPack} from '#/components/icons/StarterPack' +import {Link as InternalLink, LinkProps} from '#/components/Link' +import {Text} from '#/components/Typography' + +export function Default({starterPack}: {starterPack?: StarterPackViewBasic}) { + if (!starterPack) return null + return ( + <Link starterPack={starterPack}> + <Card starterPack={starterPack} /> + </Link> + ) +} + +export function Notification({ + starterPack, +}: { + starterPack?: StarterPackViewBasic +}) { + if (!starterPack) return null + return ( + <Link starterPack={starterPack}> + <Card starterPack={starterPack} noIcon={true} noDescription={true} /> + </Link> + ) +} + +export function Card({ + starterPack, + noIcon, + noDescription, +}: { + starterPack: StarterPackViewBasic + noIcon?: boolean + noDescription?: boolean +}) { + const {record, creator, joinedAllTimeCount} = starterPack + + const {_} = useLingui() + const t = useTheme() + const {currentAccount} = useSession() + + if (!AppBskyGraphStarterpack.isRecord(record)) { + return null + } + + return ( + <View style={[a.flex_1, a.gap_md]}> + <View style={[a.flex_row, a.gap_sm]}> + {!noIcon ? <StarterPack width={40} gradient="sky" /> : null} + <View> + <Text style={[a.text_md, a.font_bold, a.leading_snug]}> + {record.name} + </Text> + <Text style={[a.leading_snug, t.atoms.text_contrast_medium]}> + <Trans> + Starter pack by{' '} + {creator?.did === currentAccount?.did + ? _(msg`you`) + : `@${sanitizeHandle(creator.handle)}`} + </Trans> + </Text> + </View> + </View> + {!noDescription && record.description ? ( + <Text numberOfLines={3} style={[a.leading_snug]}> + {record.description} + </Text> + ) : null} + {!!joinedAllTimeCount && joinedAllTimeCount >= 50 && ( + <Text style={[a.font_bold, t.atoms.text_contrast_medium]}> + {joinedAllTimeCount} users have joined! + </Text> + )} + </View> + ) +} + +export function Link({ + starterPack, + children, + ...rest +}: { + starterPack: StarterPackViewBasic +} & Omit<LinkProps, 'to'>) { + const {record} = starterPack + const {rkey, handleOrDid} = React.useMemo(() => { + const rkey = new AtUri(starterPack.uri).rkey + const {creator} = starterPack + return {rkey, handleOrDid: creator.handle || creator.did} + }, [starterPack]) + + if (!AppBskyGraphStarterpack.isRecord(record)) { + return null + } + + return ( + <InternalLink + label={record.name} + {...rest} + to={{ + screen: 'StarterPack', + params: {name: handleOrDid, rkey}, + }}> + {children} + </InternalLink> + ) +} diff --git a/src/components/StarterPack/Wizard/ScreenTransition.tsx b/src/components/StarterPack/Wizard/ScreenTransition.tsx new file mode 100644 index 000000000..b7cd4e4c1 --- /dev/null +++ b/src/components/StarterPack/Wizard/ScreenTransition.tsx @@ -0,0 +1,31 @@ +import React from 'react' +import {StyleProp, ViewStyle} from 'react-native' +import Animated, { + FadeIn, + FadeOut, + SlideInLeft, + SlideInRight, +} from 'react-native-reanimated' + +import {isWeb} from 'platform/detection' + +export function ScreenTransition({ + direction, + style, + children, +}: { + direction: 'Backward' | 'Forward' + style?: StyleProp<ViewStyle> + children: React.ReactNode +}) { + const entering = direction === 'Forward' ? SlideInRight : SlideInLeft + + return ( + <Animated.View + entering={isWeb ? FadeIn.duration(90) : entering} + exiting={FadeOut.duration(90)} // Totally vibes based + style={style}> + {children} + </Animated.View> + ) +} diff --git a/src/components/StarterPack/Wizard/WizardEditListDialog.tsx b/src/components/StarterPack/Wizard/WizardEditListDialog.tsx new file mode 100644 index 000000000..bf250ac35 --- /dev/null +++ b/src/components/StarterPack/Wizard/WizardEditListDialog.tsx @@ -0,0 +1,152 @@ +import React, {useRef} from 'react' +import type {ListRenderItemInfo} from 'react-native' +import {View} from 'react-native' +import {AppBskyActorDefs, ModerationOpts} from '@atproto/api' +import {GeneratorView} from '@atproto/api/dist/client/types/app/bsky/feed/defs' +import {BottomSheetFlatListMethods} from '@discord/bottom-sheet' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {isWeb} from 'platform/detection' +import {useSession} from 'state/session' +import {WizardAction, WizardState} from '#/screens/StarterPack/Wizard/State' +import {atoms as a, native, useTheme, web} from '#/alf' +import {Button, ButtonText} from '#/components/Button' +import * as Dialog from '#/components/Dialog' +import { + WizardFeedCard, + WizardProfileCard, +} from '#/components/StarterPack/Wizard/WizardListCard' +import {Text} from '#/components/Typography' + +function keyExtractor( + item: AppBskyActorDefs.ProfileViewBasic | GeneratorView, + index: number, +) { + return `${item.did}-${index}` +} + +export function WizardEditListDialog({ + control, + state, + dispatch, + moderationOpts, + profile, +}: { + control: Dialog.DialogControlProps + state: WizardState + dispatch: (action: WizardAction) => void + moderationOpts: ModerationOpts + profile: AppBskyActorDefs.ProfileViewBasic +}) { + const {_} = useLingui() + const t = useTheme() + const {currentAccount} = useSession() + + const listRef = useRef<BottomSheetFlatListMethods>(null) + + const getData = () => { + if (state.currentStep === 'Feeds') return state.feeds + + return [ + profile, + ...state.profiles.filter(p => p.did !== currentAccount?.did), + ] + } + + const renderItem = ({item}: ListRenderItemInfo<any>) => + state.currentStep === 'Profiles' ? ( + <WizardProfileCard + profile={item} + state={state} + dispatch={dispatch} + moderationOpts={moderationOpts} + /> + ) : ( + <WizardFeedCard + generator={item} + state={state} + dispatch={dispatch} + moderationOpts={moderationOpts} + /> + ) + + return ( + <Dialog.Outer + control={control} + testID="newChatDialog" + nativeOptions={{sheet: {snapPoints: ['95%']}}}> + <Dialog.Handle /> + <Dialog.InnerFlatList + ref={listRef} + data={getData()} + renderItem={renderItem} + keyExtractor={keyExtractor} + ListHeaderComponent={ + <View + style={[ + a.flex_row, + a.justify_between, + a.border_b, + a.px_sm, + a.mb_sm, + t.atoms.bg, + t.atoms.border_contrast_medium, + isWeb + ? [ + a.align_center, + { + height: 48, + }, + ] + : [ + a.pb_sm, + a.align_end, + { + height: 68, + }, + ], + ]}> + <View style={{width: 60}} /> + <Text style={[a.font_bold, a.text_xl]}> + {state.currentStep === 'Profiles' ? ( + <Trans>Edit People</Trans> + ) : ( + <Trans>Edit Feeds</Trans> + )} + </Text> + <View style={{width: 60}}> + {isWeb && ( + <Button + label={_(msg`Close`)} + variant="ghost" + color="primary" + size="xsmall" + onPress={() => control.close()}> + <ButtonText> + <Trans>Close</Trans> + </ButtonText> + </Button> + )} + </View> + </View> + } + stickyHeaderIndices={[0]} + style={[ + web([a.py_0, {height: '100vh', maxHeight: 600}, a.px_0]), + native({ + height: '100%', + paddingHorizontal: 0, + marginTop: 0, + paddingTop: 0, + borderTopLeftRadius: 40, + borderTopRightRadius: 40, + }), + ]} + webInnerStyle={[a.py_0, {maxWidth: 500, minWidth: 200}]} + keyboardDismissMode="on-drag" + removeClippedSubviews={true} + /> + </Dialog.Outer> + ) +} diff --git a/src/components/StarterPack/Wizard/WizardListCard.tsx b/src/components/StarterPack/Wizard/WizardListCard.tsx new file mode 100644 index 000000000..f1332011d --- /dev/null +++ b/src/components/StarterPack/Wizard/WizardListCard.tsx @@ -0,0 +1,182 @@ +import React from 'react' +import {Keyboard, View} from 'react-native' +import { + AppBskyActorDefs, + AppBskyFeedDefs, + moderateFeedGenerator, + moderateProfile, + ModerationOpts, + ModerationUI, +} from '@atproto/api' +import {GeneratorView} from '@atproto/api/dist/client/types/app/bsky/feed/defs' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {DISCOVER_FEED_URI} from 'lib/constants' +import {sanitizeDisplayName} from 'lib/strings/display-names' +import {sanitizeHandle} from 'lib/strings/handles' +import {useSession} from 'state/session' +import {UserAvatar} from 'view/com/util/UserAvatar' +import {WizardAction, WizardState} from '#/screens/StarterPack/Wizard/State' +import {atoms as a, useTheme} from '#/alf' +import * as Toggle from '#/components/forms/Toggle' +import {Checkbox} from '#/components/forms/Toggle' +import {Text} from '#/components/Typography' + +function WizardListCard({ + type, + displayName, + subtitle, + onPress, + avatar, + included, + disabled, + moderationUi, +}: { + type: 'user' | 'algo' + profile?: AppBskyActorDefs.ProfileViewBasic + feed?: AppBskyFeedDefs.GeneratorView + displayName: string + subtitle: string + onPress: () => void + avatar?: string + included?: boolean + disabled?: boolean + moderationUi: ModerationUI +}) { + const t = useTheme() + const {_} = useLingui() + + return ( + <Toggle.Item + name={type === 'user' ? _(msg`Person toggle`) : _(msg`Feed toggle`)} + label={ + included + ? _(msg`Remove ${displayName} from starter pack`) + : _(msg`Add ${displayName} to starter pack`) + } + value={included} + disabled={disabled} + onChange={onPress} + style={[ + a.flex_row, + a.align_center, + a.px_lg, + a.py_md, + a.gap_md, + a.border_b, + t.atoms.border_contrast_low, + ]}> + <UserAvatar + size={45} + avatar={avatar} + moderation={moderationUi} + type={type} + /> + <View style={[a.flex_1, a.gap_2xs]}> + <Text + style={[a.flex_1, a.font_bold, a.text_md, a.leading_tight]} + numberOfLines={1}> + {displayName} + </Text> + <Text + style={[a.flex_1, a.leading_tight, t.atoms.text_contrast_medium]} + numberOfLines={1}> + {subtitle} + </Text> + </View> + <Checkbox /> + </Toggle.Item> + ) +} + +export function WizardProfileCard({ + state, + dispatch, + profile, + moderationOpts, +}: { + state: WizardState + dispatch: (action: WizardAction) => void + profile: AppBskyActorDefs.ProfileViewBasic + moderationOpts: ModerationOpts +}) { + const {currentAccount} = useSession() + + const isMe = profile.did === currentAccount?.did + const included = isMe || state.profiles.some(p => p.did === profile.did) + const disabled = isMe || (!included && state.profiles.length >= 49) + const moderationUi = moderateProfile(profile, moderationOpts).ui('avatar') + const displayName = profile.displayName + ? sanitizeDisplayName(profile.displayName) + : `@${sanitizeHandle(profile.handle)}` + + const onPress = () => { + if (disabled) return + + Keyboard.dismiss() + if (profile.did === currentAccount?.did) return + + if (!included) { + dispatch({type: 'AddProfile', profile}) + } else { + dispatch({type: 'RemoveProfile', profileDid: profile.did}) + } + } + + return ( + <WizardListCard + type="user" + displayName={displayName} + subtitle={`@${sanitizeHandle(profile.handle)}`} + onPress={onPress} + avatar={profile.avatar} + included={included} + disabled={disabled} + moderationUi={moderationUi} + /> + ) +} + +export function WizardFeedCard({ + generator, + state, + dispatch, + moderationOpts, +}: { + generator: GeneratorView + state: WizardState + dispatch: (action: WizardAction) => void + moderationOpts: ModerationOpts +}) { + const isDiscover = generator.uri === DISCOVER_FEED_URI + const included = isDiscover || state.feeds.some(f => f.uri === generator.uri) + const disabled = isDiscover || (!included && state.feeds.length >= 3) + const moderationUi = moderateFeedGenerator(generator, moderationOpts).ui( + 'avatar', + ) + + const onPress = () => { + if (disabled) return + + Keyboard.dismiss() + if (included) { + dispatch({type: 'RemoveFeed', feedUri: generator.uri}) + } else { + dispatch({type: 'AddFeed', feed: generator}) + } + } + + return ( + <WizardListCard + type="algo" + displayName={sanitizeDisplayName(generator.displayName)} + subtitle={`Feed by @${sanitizeHandle(generator.creator.handle)}`} + onPress={onPress} + avatar={generator.avatar} + included={included} + disabled={disabled} + moderationUi={moderationUi} + /> + ) +} diff --git a/src/components/forms/TextField.tsx b/src/components/forms/TextField.tsx index f7a827b49..d513a6db9 100644 --- a/src/components/forms/TextField.tsx +++ b/src/components/forms/TextField.tsx @@ -140,6 +140,7 @@ export function createInput(Component: typeof TextInput) { onChangeText, isInvalid, inputRef, + style, ...rest }: InputProps) { const t = useTheme() @@ -206,6 +207,7 @@ export function createInput(Component: typeof TextInput) { android({ paddingBottom: 16, }), + style, ]} /> diff --git a/src/components/hooks/useStarterPackEntry.native.ts b/src/components/hooks/useStarterPackEntry.native.ts new file mode 100644 index 000000000..b6e4ab05b --- /dev/null +++ b/src/components/hooks/useStarterPackEntry.native.ts @@ -0,0 +1,68 @@ +import React from 'react' + +import { + createStarterPackLinkFromAndroidReferrer, + httpStarterPackUriToAtUri, +} from 'lib/strings/starter-pack' +import {isAndroid} from 'platform/detection' +import {useHasCheckedForStarterPack} from 'state/preferences/used-starter-packs' +import {useSetActiveStarterPack} from 'state/shell/starter-pack' +import {DevicePrefs, Referrer} from '../../../modules/expo-bluesky-swiss-army' + +export function useStarterPackEntry() { + const [ready, setReady] = React.useState(false) + const setActiveStarterPack = useSetActiveStarterPack() + const hasCheckedForStarterPack = useHasCheckedForStarterPack() + + React.useEffect(() => { + 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) + + ;(async () => { + let uri: string | null | undefined + + if (isAndroid) { + const res = await Referrer.getGooglePlayReferrerInfoAsync() + + if (res && res.installReferrer) { + uri = createStarterPackLinkFromAndroidReferrer(res.installReferrer) + } + } else { + const res = await DevicePrefs.getStringValueAsync( + 'starterPackUri', + true, + ) + + if (res) { + uri = httpStarterPackUriToAtUri(res) + DevicePrefs.setStringValueAsync('starterPackUri', null, true) + } + } + + if (uri) { + setActiveStarterPack({ + uri, + }) + } + + setReady(true) + })() + + return () => { + clearTimeout(timeout) + } + }, [ready, setActiveStarterPack, hasCheckedForStarterPack]) + + return ready +} diff --git a/src/components/hooks/useStarterPackEntry.ts b/src/components/hooks/useStarterPackEntry.ts new file mode 100644 index 000000000..dba801e09 --- /dev/null +++ b/src/components/hooks/useStarterPackEntry.ts @@ -0,0 +1,29 @@ +import React from 'react' + +import {httpStarterPackUriToAtUri} from 'lib/strings/starter-pack' +import {useSetActiveStarterPack} from 'state/shell/starter-pack' + +export function useStarterPackEntry() { + const [ready, setReady] = React.useState(false) + + const setActiveStarterPack = useSetActiveStarterPack() + + React.useEffect(() => { + const href = window.location.href + const atUri = httpStarterPackUriToAtUri(href) + + if (atUri) { + const url = new URL(href) + // Determines if an App Clip is loading this landing page + const isClip = url.searchParams.get('clip') === 'true' + setActiveStarterPack({ + uri: atUri, + isClip, + }) + } + + setReady(true) + }, [setActiveStarterPack]) + + return ready +} diff --git a/src/components/icons/QrCode.tsx b/src/components/icons/QrCode.tsx new file mode 100644 index 000000000..e841071f7 --- /dev/null +++ b/src/components/icons/QrCode.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const QrCode_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M3 5a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5Zm6 0H5v4h4V5ZM3 15a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4Zm6 0H5v4h4v-4ZM13 5a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2h-4a2 2 0 0 1-2-2V5Zm6 0h-4v4h4V5ZM14 13a1 1 0 0 1 1 1v1h1a1 1 0 1 1 0 2h-2a1 1 0 0 1-1-1v-2a1 1 0 0 1 1-1Zm3 1a1 1 0 0 1 1-1h2a1 1 0 1 1 0 2h-2a1 1 0 0 1-1-1Zm0 4a1 1 0 0 1 1-1h2a1 1 0 1 1 0 2h-1v1a1 1 0 1 1-2 0v-2Z', +}) diff --git a/src/components/icons/StarterPack.tsx b/src/components/icons/StarterPack.tsx new file mode 100644 index 000000000..8c678bca4 --- /dev/null +++ b/src/components/icons/StarterPack.tsx @@ -0,0 +1,8 @@ +import {createMultiPathSVG} from './TEMPLATE' + +export const StarterPack = createMultiPathSVG({ + paths: [ + 'M11.26 5.227 5.02 6.899c-.734.197-1.17.95-.973 1.685l1.672 6.24c.197.734.951 1.17 1.685.973l6.24-1.672c.734-.197 1.17-.951.973-1.685L12.945 6.2a1.375 1.375 0 0 0-1.685-.973Zm-6.566.459a2.632 2.632 0 0 0-1.86 3.223l1.672 6.24a2.632 2.632 0 0 0 3.223 1.861l6.24-1.672a2.631 2.631 0 0 0 1.861-3.223l-1.672-6.24a2.632 2.632 0 0 0-3.223-1.861l-6.24 1.672Z', + 'M15.138 18.411a4.606 4.606 0 1 0 0-9.211 4.606 4.606 0 0 0 0 9.211Zm0 1.257a5.862 5.862 0 1 0 0-11.724 5.862 5.862 0 0 0 0 11.724Z', + ], +}) diff --git a/src/components/icons/TEMPLATE.tsx b/src/components/icons/TEMPLATE.tsx index f49c4280b..47a5c36b2 100644 --- a/src/components/icons/TEMPLATE.tsx +++ b/src/components/icons/TEMPLATE.tsx @@ -30,7 +30,7 @@ export const IconTemplate_Stroke2_Corner0_Rounded = React.forwardRef( export function createSinglePathSVG({path}: {path: string}) { return React.forwardRef<Svg, Props>(function LogoImpl(props, ref) { - const {fill, size, style, ...rest} = useCommonSVGProps(props) + const {fill, size, style, gradient, ...rest} = useCommonSVGProps(props) return ( <Svg @@ -41,8 +41,37 @@ export function createSinglePathSVG({path}: {path: string}) { width={size} height={size} style={[style]}> + {gradient} <Path fill={fill} fillRule="evenodd" clipRule="evenodd" d={path} /> </Svg> ) }) } + +export function createMultiPathSVG({paths}: {paths: string[]}) { + return React.forwardRef<Svg, Props>(function LogoImpl(props, ref) { + const {fill, size, style, gradient, ...rest} = useCommonSVGProps(props) + + return ( + <Svg + fill="none" + {...rest} + ref={ref} + viewBox="0 0 24 24" + width={size} + height={size} + style={[style]}> + {gradient} + {paths.map((path, i) => ( + <Path + key={i} + fill={fill} + fillRule="evenodd" + clipRule="evenodd" + d={path} + /> + ))} + </Svg> + ) + }) +} diff --git a/src/components/icons/common.ts b/src/components/icons/common.ts deleted file mode 100644 index 669c157f5..000000000 --- a/src/components/icons/common.ts +++ /dev/null @@ -1,32 +0,0 @@ -import {StyleSheet, TextProps} from 'react-native' -import type {PathProps, SvgProps} from 'react-native-svg' - -import {tokens} from '#/alf' - -export type Props = { - fill?: PathProps['fill'] - style?: TextProps['style'] - size?: keyof typeof sizes -} & Omit<SvgProps, 'style' | 'size'> - -export const sizes = { - xs: 12, - sm: 16, - md: 20, - lg: 24, - xl: 28, -} - -export function useCommonSVGProps(props: Props) { - const {fill, size, ...rest} = props - const style = StyleSheet.flatten(rest.style) - const _fill = fill || style?.color || tokens.color.blue_500 - const _size = Number(size ? sizes[size] : rest.width || sizes.md) - - return { - fill: _fill, - size: _size, - style, - ...rest, - } -} diff --git a/src/components/icons/common.tsx b/src/components/icons/common.tsx new file mode 100644 index 000000000..662718338 --- /dev/null +++ b/src/components/icons/common.tsx @@ -0,0 +1,59 @@ +import React from 'react' +import {StyleSheet, TextProps} from 'react-native' +import type {PathProps, SvgProps} from 'react-native-svg' +import {Defs, LinearGradient, Stop} from 'react-native-svg' +import {nanoid} from 'nanoid/non-secure' + +import {tokens} from '#/alf' + +export type Props = { + fill?: PathProps['fill'] + style?: TextProps['style'] + size?: keyof typeof sizes + gradient?: keyof typeof tokens.gradients +} & Omit<SvgProps, 'style' | 'size'> + +export const sizes = { + xs: 12, + sm: 16, + md: 20, + lg: 24, + xl: 28, +} + +export function useCommonSVGProps(props: Props) { + const {fill, size, gradient, ...rest} = props + const style = StyleSheet.flatten(rest.style) + const _size = Number(size ? sizes[size] : rest.width || sizes.md) + let _fill = fill || style?.color || tokens.color.blue_500 + let gradientDef = null + + if (gradient && tokens.gradients[gradient]) { + const id = gradient + '_' + nanoid() + const config = tokens.gradients[gradient] + _fill = `url(#${id})` + gradientDef = ( + <Defs> + <LinearGradient + id={id} + x1="0" + y1="0" + x2="100%" + y2="0" + gradientTransform="rotate(45)"> + {config.values.map(([stop, fill]) => ( + <Stop key={stop} offset={stop} stopColor={fill} /> + ))} + </LinearGradient> + </Defs> + ) + } + + return { + fill: _fill, + size: _size, + style, + gradient: gradientDef, + ...rest, + } +} diff --git a/src/lib/browser.native.ts b/src/lib/browser.native.ts index fb9be56f1..8e045138c 100644 --- a/src/lib/browser.native.ts +++ b/src/lib/browser.native.ts @@ -1,3 +1,4 @@ export const isSafari = false export const isFirefox = false export const isTouchDevice = true +export const isAndroidWeb = false diff --git a/src/lib/browser.ts b/src/lib/browser.ts index d178a9a64..08c43fbfd 100644 --- a/src/lib/browser.ts +++ b/src/lib/browser.ts @@ -5,3 +5,5 @@ export const isSafari = /^((?!chrome|android).)*safari/i.test( export const isFirefox = /firefox|fxios/i.test(navigator.userAgent) export const isTouchDevice = 'ontouchstart' in window || navigator.maxTouchPoints > 1 +export const isAndroidWeb = + /android/i.test(navigator.userAgent) && isTouchDevice diff --git a/src/lib/generate-starterpack.ts b/src/lib/generate-starterpack.ts new file mode 100644 index 000000000..64d30a954 --- /dev/null +++ b/src/lib/generate-starterpack.ts @@ -0,0 +1,164 @@ +import { + AppBskyActorDefs, + AppBskyGraphGetStarterPack, + BskyAgent, + Facet, +} from '@atproto/api' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useMutation} from '@tanstack/react-query' + +import {until} from 'lib/async/until' +import {sanitizeDisplayName} from 'lib/strings/display-names' +import {sanitizeHandle} from 'lib/strings/handles' +import {enforceLen} from 'lib/strings/helpers' +import {useAgent} from 'state/session' + +export const createStarterPackList = async ({ + name, + description, + descriptionFacets, + profiles, + agent, +}: { + name: string + description?: string + descriptionFacets?: Facet[] + profiles: AppBskyActorDefs.ProfileViewBasic[] + agent: BskyAgent +}): Promise<{uri: string; cid: string}> => { + if (profiles.length === 0) throw new Error('No profiles given') + + const list = await agent.app.bsky.graph.list.create( + {repo: agent.session!.did}, + { + name, + description, + descriptionFacets, + avatar: undefined, + createdAt: new Date().toISOString(), + purpose: 'app.bsky.graph.defs#referencelist', + }, + ) + if (!list) throw new Error('List creation failed') + await agent.com.atproto.repo.applyWrites({ + repo: agent.session!.did, + writes: [ + createListItem({did: agent.session!.did, listUri: list.uri}), + ].concat( + profiles + // Ensure we don't have ourselves in this list twice + .filter(p => p.did !== agent.session!.did) + .map(p => createListItem({did: p.did, listUri: list.uri})), + ), + }) + + return list +} + +export function useGenerateStarterPackMutation({ + onSuccess, + onError, +}: { + onSuccess: ({uri, cid}: {uri: string; cid: string}) => void + onError: (e: Error) => void +}) { + const {_} = useLingui() + const agent = useAgent() + const starterPackString = _(msg`Starter Pack`) + + return useMutation<{uri: string; cid: string}, Error, void>({ + mutationFn: async () => { + let profile: AppBskyActorDefs.ProfileViewBasic | undefined + let profiles: AppBskyActorDefs.ProfileViewBasic[] | undefined + + await Promise.all([ + (async () => { + profile = ( + await agent.app.bsky.actor.getProfile({ + actor: agent.session!.did, + }) + ).data + })(), + (async () => { + profiles = ( + await agent.app.bsky.actor.searchActors({ + q: encodeURIComponent('*'), + limit: 49, + }) + ).data.actors.filter(p => p.viewer?.following) + })(), + ]) + + if (!profile || !profiles) { + throw new Error('ERROR_DATA') + } + + // We include ourselves when we make the list + if (profiles.length < 7) { + throw new Error('NOT_ENOUGH_FOLLOWERS') + } + + const displayName = enforceLen( + profile.displayName + ? sanitizeDisplayName(profile.displayName) + : `@${sanitizeHandle(profile.handle)}`, + 25, + true, + ) + const starterPackName = `${displayName}'s ${starterPackString}` + + const list = await createStarterPackList({ + name: starterPackName, + profiles, + agent, + }) + + return await agent.app.bsky.graph.starterpack.create( + { + repo: agent.session!.did, + }, + { + name: starterPackName, + list: list.uri, + createdAt: new Date().toISOString(), + }, + ) + }, + onSuccess: async data => { + await whenAppViewReady(agent, data.uri, v => { + return typeof v?.data.starterPack.uri === 'string' + }) + onSuccess(data) + }, + onError: error => { + onError(error) + }, + }) +} + +function createListItem({did, listUri}: {did: string; listUri: string}) { + return { + $type: 'com.atproto.repo.applyWrites#create', + collection: 'app.bsky.graph.listitem', + value: { + $type: 'app.bsky.graph.listitem', + subject: did, + list: listUri, + createdAt: new Date().toISOString(), + }, + } +} + +async function whenAppViewReady( + agent: BskyAgent, + uri: string, + fn: (res?: AppBskyGraphGetStarterPack.Response) => boolean, +) { + await until( + 5, // 5 tries + 1e3, // 1s delay between tries + fn, + () => agent.app.bsky.graph.getStarterPack({starterPack: uri}), + ) +} diff --git a/src/lib/hooks/useBottomBarOffset.ts b/src/lib/hooks/useBottomBarOffset.ts new file mode 100644 index 000000000..945c98062 --- /dev/null +++ b/src/lib/hooks/useBottomBarOffset.ts @@ -0,0 +1,14 @@ +import {useSafeAreaInsets} from 'react-native-safe-area-context' + +import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' +import {clamp} from 'lib/numbers' +import {isWeb} from 'platform/detection' + +export function useBottomBarOffset(modifier: number = 0) { + const {isTabletOrDesktop} = useWebMediaQueries() + const {bottom: bottomInset} = useSafeAreaInsets() + return ( + (isWeb && isTabletOrDesktop ? 0 : clamp(60 + bottomInset, 60, 75)) + + modifier + ) +} diff --git a/src/lib/hooks/useNotificationHandler.ts b/src/lib/hooks/useNotificationHandler.ts index 347062beb..e4e7e1474 100644 --- a/src/lib/hooks/useNotificationHandler.ts +++ b/src/lib/hooks/useNotificationHandler.ts @@ -26,6 +26,7 @@ type NotificationReason = | 'reply' | 'quote' | 'chat-message' + | 'starterpack-joined' type NotificationPayload = | { @@ -142,6 +143,7 @@ export function useNotificationsHandler() { case 'mention': case 'quote': case 'reply': + case 'starterpack-joined': resetToTab('NotificationsTab') break // TODO implement these after we have an idea of how to handle each individual case diff --git a/src/lib/moderation/create-sanitized-display-name.ts b/src/lib/moderation/create-sanitized-display-name.ts new file mode 100644 index 000000000..16135b274 --- /dev/null +++ b/src/lib/moderation/create-sanitized-display-name.ts @@ -0,0 +1,21 @@ +import {AppBskyActorDefs} from '@atproto/api' + +import {sanitizeDisplayName} from 'lib/strings/display-names' +import {sanitizeHandle} from 'lib/strings/handles' + +export function createSanitizedDisplayName( + profile: + | AppBskyActorDefs.ProfileViewBasic + | AppBskyActorDefs.ProfileViewDetailed, + noAt = false, +) { + if (profile.displayName != null && profile.displayName !== '') { + return sanitizeDisplayName(profile.displayName) + } else { + let sanitizedHandle = sanitizeHandle(profile.handle) + if (!noAt) { + sanitizedHandle = `@${sanitizedHandle}` + } + return sanitizedHandle + } +} diff --git a/src/lib/moderation/useReportOptions.ts b/src/lib/moderation/useReportOptions.ts index 54b727b76..91656857e 100644 --- a/src/lib/moderation/useReportOptions.ts +++ b/src/lib/moderation/useReportOptions.ts @@ -13,6 +13,7 @@ interface ReportOptions { account: ReportOption[] post: ReportOption[] list: ReportOption[] + starterpack: ReportOption[] feedgen: ReportOption[] other: ReportOption[] convoMessage: ReportOption[] @@ -94,6 +95,14 @@ export function useReportOptions(): ReportOptions { }, ...common, ], + starterpack: [ + { + reason: ComAtprotoModerationDefs.REASONVIOLATION, + title: _(msg`Name or Description Violates Community Standards`), + description: _(msg`Terms used violate community standards`), + }, + ...common, + ], feedgen: [ { reason: ComAtprotoModerationDefs.REASONVIOLATION, diff --git a/src/lib/routes/links.ts b/src/lib/routes/links.ts index 9dfdab909..56b716677 100644 --- a/src/lib/routes/links.ts +++ b/src/lib/routes/links.ts @@ -1,3 +1,5 @@ +import {AppBskyGraphDefs, AtUri} from '@atproto/api' + import {isInvalidHandle} from 'lib/strings/handles' export function makeProfileLink( @@ -35,3 +37,18 @@ export function makeSearchLink(props: {query: string; from?: 'me' | string}) { props.query + (props.from ? ` from:${props.from}` : ''), )}` } + +export function makeStarterPackLink( + starterPackOrName: + | AppBskyGraphDefs.StarterPackViewBasic + | AppBskyGraphDefs.StarterPackView + | string, + rkey?: string, +) { + if (typeof starterPackOrName === 'string') { + return `https://bsky.app/start/${starterPackOrName}/${rkey}` + } else { + const uriRkey = new AtUri(starterPackOrName.uri).rkey + return `https://bsky.app/start/${starterPackOrName.creator.handle}/${uriRkey}` + } +} diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts index 403c2bb67..8a173b675 100644 --- a/src/lib/routes/types.ts +++ b/src/lib/routes/types.ts @@ -42,6 +42,12 @@ export type CommonNavigatorParams = { MessagesConversation: {conversation: string; embed?: string} MessagesSettings: undefined Feeds: undefined + Start: {name: string; rkey: string} + StarterPack: {name: string; rkey: string; new?: boolean} + StarterPackWizard: undefined + StarterPackEdit: { + rkey?: string + } } export type BottomTabNavigatorParams = CommonNavigatorParams & { @@ -93,6 +99,12 @@ export type AllNavigatorParams = CommonNavigatorParams & { Hashtag: {tag: string; author?: string} MessagesTab: undefined Messages: {animation?: 'push' | 'pop'} + Start: {name: string; rkey: string} + StarterPack: {name: string; rkey: string; new?: boolean} + StarterPackWizard: undefined + StarterPackEdit: { + rkey?: string + } } // NOTE diff --git a/src/lib/statsig/events.ts b/src/lib/statsig/events.ts index 2e8cedb54..07ed8c0ca 100644 --- a/src/lib/statsig/events.ts +++ b/src/lib/statsig/events.ts @@ -53,7 +53,14 @@ export type LogEvents = { } 'onboarding:moderation:nextPressed': {} 'onboarding:profile:nextPressed': {} - 'onboarding:finished:nextPressed': {} + 'onboarding:finished:nextPressed': { + usedStarterPack: boolean + starterPackName?: string + starterPackCreator?: string + starterPackUri?: string + profilesFollowed: number + feedsPinned: number + } 'onboarding:finished:avatarResult': { avatarResult: 'default' | 'created' | 'uploaded' } @@ -61,7 +68,12 @@ export type LogEvents = { feedUrl: string feedType: string index: number - reason: 'focus' | 'tabbar-click' | 'pager-swipe' | 'desktop-sidebar-click' + reason: + | 'focus' + | 'tabbar-click' + | 'pager-swipe' + | 'desktop-sidebar-click' + | 'starter-pack-initial-feed' } 'feed:endReached:sampled': { feedUrl: string @@ -134,6 +146,7 @@ export type LogEvents = { | 'ProfileMenu' | 'ProfileHoverCard' | 'AvatarButton' + | 'StarterPackProfilesList' } 'profile:unfollow': { logContext: @@ -146,6 +159,7 @@ export type LogEvents = { | 'ProfileHoverCard' | 'Chat' | 'AvatarButton' + | 'StarterPackProfilesList' } 'chat:create': { logContext: 'ProfileHeader' | 'NewChatDialog' | 'SendViaChatDialog' @@ -157,6 +171,23 @@ export type LogEvents = { | 'ChatsList' | 'SendViaChatDialog' } + 'starterPack:share': { + starterPack: string + shareType: 'link' | 'qrcode' + qrShareType?: 'save' | 'copy' | 'share' + } + 'starterPack:followAll': { + logContext: 'StarterPackProfilesList' | 'Onboarding' + starterPack: string + count: number + } + 'starterPack:delete': {} + 'starterPack:create': { + setName: boolean + setDescription: boolean + profilesCount: number + feedsCount: number + } 'test:all:always': {} 'test:all:sometimes': {} diff --git a/src/lib/statsig/gates.ts b/src/lib/statsig/gates.ts index 46ef934ef..bf2484ccb 100644 --- a/src/lib/statsig/gates.ts +++ b/src/lib/statsig/gates.ts @@ -5,3 +5,4 @@ export type Gate = | 'request_notifications_permission_after_onboarding_v2' | 'show_avi_follow_button' | 'show_follow_back_label_v2' + | 'starter_packs_enabled' diff --git a/src/lib/strings/starter-pack.ts b/src/lib/strings/starter-pack.ts new file mode 100644 index 000000000..489d0b923 --- /dev/null +++ b/src/lib/strings/starter-pack.ts @@ -0,0 +1,101 @@ +import {AppBskyGraphDefs, AtUri} from '@atproto/api' + +export function createStarterPackLinkFromAndroidReferrer( + referrerQueryString: string, +): string | null { + try { + // The referrer string is just some URL parameters, so lets add them to a fake URL + const url = new URL('http://throwaway.com/?' + referrerQueryString) + const utmContent = url.searchParams.get('utm_content') + const utmSource = url.searchParams.get('utm_source') + + if (!utmContent) return null + if (utmSource !== 'bluesky') return null + + // This should be a string like `starterpack_haileyok.com_rkey` + const contentParts = utmContent.split('_') + + if (contentParts[0] !== 'starterpack') return null + if (contentParts.length !== 3) return null + + return `at://${contentParts[1]}/app.bsky.graph.starterpack/${contentParts[2]}` + } catch (e) { + return null + } +} + +export function parseStarterPackUri(uri?: string): { + name: string + rkey: string +} | null { + if (!uri) return null + + 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 [_, path, name, rkey] = parts + + if (parts.length !== 4) return null + if (path !== 'starter-pack' && path !== 'start') return null + if (!name || !rkey) return null + return { + name, + rkey, + } + } + } catch (e) { + return null + } +} + +export function createStarterPackGooglePlayUri( + name: string, + rkey: string, +): string | null { + 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}` +} + +export function getStarterPackOgCard( + didOrStarterPack: AppBskyGraphDefs.StarterPackView | string, + rkey?: string, +) { + if (typeof didOrStarterPack === 'string') { + return `https://ogcard.cdn.bsky.app/start/${didOrStarterPack}/${rkey}` + } else { + const rkey = new AtUri(didOrStarterPack.uri).rkey + return `https://ogcard.cdn.bsky.app/start/${didOrStarterPack.creator.did}/${rkey}` + } +} + +export function createStarterPackUri({ + did, + rkey, +}: { + did: string + rkey: string +}): string | null { + return new AtUri(`at://${did}/app.bsky.graph.starterpack/${rkey}`).toString() +} diff --git a/src/routes.ts b/src/routes.ts index de711f5dc..f241d37a0 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -41,4 +41,8 @@ export const router = new Router({ Messages: '/messages', MessagesSettings: '/messages/settings', MessagesConversation: '/messages/:conversation', + Start: '/start/:name/:rkey', + StarterPackEdit: '/starter-pack/edit/:rkey', + StarterPack: '/starter-pack/:name/:rkey', + StarterPackWizard: '/starter-pack/create', }) 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 '' +} diff --git a/src/state/persisted/schema.ts b/src/state/persisted/schema.ts index c942828f2..88fc370a6 100644 --- a/src/state/persisted/schema.ts +++ b/src/state/persisted/schema.ts @@ -88,6 +88,7 @@ export const schema = z.object({ disableHaptics: z.boolean().optional(), disableAutoplay: z.boolean().optional(), kawaii: z.boolean().optional(), + hasCheckedForStarterPack: z.boolean().optional(), /** @deprecated */ mutedThreads: z.array(z.string()), }) @@ -129,4 +130,5 @@ export const defaults: Schema = { disableHaptics: false, disableAutoplay: prefersReducedMotion, kawaii: false, + hasCheckedForStarterPack: false, } diff --git a/src/state/preferences/index.tsx b/src/state/preferences/index.tsx index e1a35f193..e6b53d5be 100644 --- a/src/state/preferences/index.tsx +++ b/src/state/preferences/index.tsx @@ -9,6 +9,7 @@ import {Provider as InAppBrowserProvider} from './in-app-browser' import {Provider as KawaiiProvider} from './kawaii' import {Provider as LanguagesProvider} from './languages' import {Provider as LargeAltBadgeProvider} from './large-alt-badge' +import {Provider as UsedStarterPacksProvider} from './used-starter-packs' export { useRequireAltTextEnabled, @@ -34,7 +35,9 @@ export function Provider({children}: React.PropsWithChildren<{}>) { <InAppBrowserProvider> <DisableHapticsProvider> <AutoplayProvider> - <KawaiiProvider>{children}</KawaiiProvider> + <UsedStarterPacksProvider> + <KawaiiProvider>{children}</KawaiiProvider> + </UsedStarterPacksProvider> </AutoplayProvider> </DisableHapticsProvider> </InAppBrowserProvider> diff --git a/src/state/preferences/used-starter-packs.tsx b/src/state/preferences/used-starter-packs.tsx new file mode 100644 index 000000000..8d5d9e828 --- /dev/null +++ b/src/state/preferences/used-starter-packs.tsx @@ -0,0 +1,37 @@ +import React from 'react' + +import * as persisted from '#/state/persisted' + +type StateContext = boolean | undefined +type SetContext = (v: boolean) => void + +const stateContext = React.createContext<StateContext>(false) +const setContext = React.createContext<SetContext>((_: boolean) => {}) + +export function Provider({children}: {children: React.ReactNode}) { + const [state, setState] = React.useState<StateContext>(() => + persisted.get('hasCheckedForStarterPack'), + ) + + const setStateWrapped = (v: boolean) => { + setState(v) + persisted.write('hasCheckedForStarterPack', v) + } + + React.useEffect(() => { + return persisted.onUpdate(() => { + setState(persisted.get('hasCheckedForStarterPack')) + }) + }, []) + + return ( + <stateContext.Provider value={state}> + <setContext.Provider value={setStateWrapped}> + {children} + </setContext.Provider> + </stateContext.Provider> + ) +} + +export const useHasCheckedForStarterPack = () => React.useContext(stateContext) +export const useSetHasCheckedForStarterPack = () => React.useContext(setContext) diff --git a/src/state/queries/actor-search.ts b/src/state/queries/actor-search.ts index 1e301a1ba..479fc1a9f 100644 --- a/src/state/queries/actor-search.ts +++ b/src/state/queries/actor-search.ts @@ -1,5 +1,11 @@ -import {AppBskyActorDefs} from '@atproto/api' -import {QueryClient, useQuery} from '@tanstack/react-query' +import {AppBskyActorDefs, AppBskyActorSearchActors} from '@atproto/api' +import { + InfiniteData, + QueryClient, + QueryKey, + useInfiniteQuery, + useQuery, +} from '@tanstack/react-query' import {STALE} from '#/state/queries' import {useAgent} from '#/state/session' @@ -7,6 +13,11 @@ import {useAgent} from '#/state/session' const RQKEY_ROOT = 'actor-search' export const RQKEY = (query: string) => [RQKEY_ROOT, query] +export const RQKEY_PAGINATED = (query: string) => [ + `${RQKEY_ROOT}_paginated`, + query, +] + export function useActorSearch({ query, enabled, @@ -28,6 +39,37 @@ export function useActorSearch({ }) } +export function useActorSearchPaginated({ + query, + enabled, +}: { + query: string + enabled?: boolean +}) { + const agent = useAgent() + return useInfiniteQuery< + AppBskyActorSearchActors.OutputSchema, + Error, + InfiniteData<AppBskyActorSearchActors.OutputSchema>, + QueryKey, + string | undefined + >({ + staleTime: STALE.MINUTES.FIVE, + queryKey: RQKEY_PAGINATED(query), + queryFn: async ({pageParam}) => { + const res = await agent.searchActors({ + q: query, + limit: 25, + cursor: pageParam, + }) + return res.data + }, + enabled: enabled && !!query, + initialPageParam: undefined, + getNextPageParam: lastPage => lastPage.cursor, + }) +} + export function* findAllProfilesInQueryData( queryClient: QueryClient, did: string, diff --git a/src/state/queries/actor-starter-packs.ts b/src/state/queries/actor-starter-packs.ts new file mode 100644 index 000000000..9de80b07d --- /dev/null +++ b/src/state/queries/actor-starter-packs.ts @@ -0,0 +1,47 @@ +import {AppBskyGraphGetActorStarterPacks} from '@atproto/api' +import { + InfiniteData, + QueryClient, + QueryKey, + useInfiniteQuery, +} from '@tanstack/react-query' + +import {useAgent} from 'state/session' + +const RQKEY_ROOT = 'actor-starter-packs' +export const RQKEY = (did?: string) => [RQKEY_ROOT, did] + +export function useActorStarterPacksQuery({did}: {did?: string}) { + const agent = useAgent() + + return useInfiniteQuery< + AppBskyGraphGetActorStarterPacks.OutputSchema, + Error, + InfiniteData<AppBskyGraphGetActorStarterPacks.OutputSchema>, + QueryKey, + string | undefined + >({ + queryKey: RQKEY(did), + queryFn: async ({pageParam}: {pageParam?: string}) => { + const res = await agent.app.bsky.graph.getActorStarterPacks({ + actor: did!, + limit: 10, + cursor: pageParam, + }) + return res.data + }, + enabled: Boolean(did), + initialPageParam: undefined, + getNextPageParam: lastPage => lastPage.cursor, + }) +} + +export async function invalidateActorStarterPacksQuery({ + queryClient, + did, +}: { + queryClient: QueryClient + did: string +}) { + await queryClient.invalidateQueries({queryKey: RQKEY(did)}) +} diff --git a/src/state/queries/feed.ts b/src/state/queries/feed.ts index e5d615177..dea6f5d77 100644 --- a/src/state/queries/feed.ts +++ b/src/state/queries/feed.ts @@ -9,6 +9,7 @@ import { } from '@atproto/api' import { InfiniteData, + keepPreviousData, QueryClient, QueryKey, useInfiniteQuery, @@ -315,6 +316,22 @@ export function useSearchPopularFeedsMutation() { }) } +export function useSearchPopularFeedsQuery({q}: {q: string}) { + const agent = useAgent() + return useQuery({ + queryKey: ['searchPopularFeeds', q], + queryFn: async () => { + const res = await agent.app.bsky.unspecced.getPopularFeedGenerators({ + limit: 15, + query: q, + }) + + return res.data.feeds + }, + placeholderData: keepPreviousData, + }) +} + const popularFeedsSearchQueryKeyRoot = 'popularFeedsSearch' export const createPopularFeedsSearchQueryKey = (query: string) => [ popularFeedsSearchQueryKeyRoot, diff --git a/src/state/queries/list-members.ts b/src/state/queries/list-members.ts index de9a36ab7..3131a2ec3 100644 --- a/src/state/queries/list-members.ts +++ b/src/state/queries/list-members.ts @@ -15,7 +15,7 @@ type RQPageParam = string | undefined const RQKEY_ROOT = 'list-members' export const RQKEY = (uri: string) => [RQKEY_ROOT, uri] -export function useListMembersQuery(uri: string) { +export function useListMembersQuery(uri?: string, limit: number = PAGE_SIZE) { const agent = useAgent() return useInfiniteQuery< AppBskyGraphGetList.OutputSchema, @@ -25,20 +25,31 @@ export function useListMembersQuery(uri: string) { RQPageParam >({ staleTime: STALE.MINUTES.ONE, - queryKey: RQKEY(uri), + queryKey: RQKEY(uri ?? ''), async queryFn({pageParam}: {pageParam: RQPageParam}) { const res = await agent.app.bsky.graph.getList({ - list: uri, - limit: PAGE_SIZE, + list: uri!, // the enabled flag will prevent this from running until uri is set + limit, cursor: pageParam, }) return res.data }, initialPageParam: undefined, getNextPageParam: lastPage => lastPage.cursor, + enabled: Boolean(uri), }) } +export async function invalidateListMembersQuery({ + queryClient, + uri, +}: { + queryClient: QueryClient + uri: string +}) { + await queryClient.invalidateQueries({queryKey: RQKEY(uri)}) +} + export function* findAllProfilesInQueryData( queryClient: QueryClient, did: string, diff --git a/src/state/queries/notifications/feed.ts b/src/state/queries/notifications/feed.ts index 0607f07a1..13ca3ffde 100644 --- a/src/state/queries/notifications/feed.ts +++ b/src/state/queries/notifications/feed.ts @@ -155,8 +155,10 @@ export function* findAllPostsInQueryData( for (const page of queryData?.pages) { for (const item of page.items) { - if (item.subject && didOrHandleUriMatches(atUri, item.subject)) { - yield item.subject + if (item.type !== 'starterpack-joined') { + if (item.subject && didOrHandleUriMatches(atUri, item.subject)) { + yield item.subject + } } const quotedPost = getEmbeddedPost(item.subject?.embed) @@ -181,7 +183,10 @@ export function* findAllProfilesInQueryData( } for (const page of queryData?.pages) { for (const item of page.items) { - if (item.subject?.author.did === did) { + if ( + item.type !== 'starterpack-joined' && + item.subject?.author.did === did + ) { yield item.subject.author } const quotedPost = getEmbeddedPost(item.subject?.embed) diff --git a/src/state/queries/notifications/types.ts b/src/state/queries/notifications/types.ts index 812236cf0..d40a07b12 100644 --- a/src/state/queries/notifications/types.ts +++ b/src/state/queries/notifications/types.ts @@ -1,26 +1,22 @@ import { - AppBskyNotificationListNotifications, AppBskyFeedDefs, + AppBskyGraphDefs, + AppBskyNotificationListNotifications, } from '@atproto/api' export type NotificationType = - | 'post-like' - | 'feedgen-like' - | 'repost' - | 'mention' - | 'reply' - | 'quote' - | 'follow' - | 'unknown' + | StarterPackNotificationType + | OtherNotificationType -export interface FeedNotification { - _reactKey: string - type: NotificationType - notification: AppBskyNotificationListNotifications.Notification - additional?: AppBskyNotificationListNotifications.Notification[] - subjectUri?: string - subject?: AppBskyFeedDefs.PostView -} +export type FeedNotification = + | (FeedNotificationBase & { + type: StarterPackNotificationType + subject?: AppBskyGraphDefs.StarterPackViewBasic + }) + | (FeedNotificationBase & { + type: OtherNotificationType + subject?: AppBskyFeedDefs.PostView + }) export interface FeedPage { cursor: string | undefined @@ -37,3 +33,22 @@ export interface CachedFeedPage { data: FeedPage | undefined unreadCount: number } + +type StarterPackNotificationType = 'starterpack-joined' +type OtherNotificationType = + | 'post-like' + | 'repost' + | 'mention' + | 'reply' + | 'quote' + | 'follow' + | 'feedgen-like' + | 'unknown' + +type FeedNotificationBase = { + _reactKey: string + notification: AppBskyNotificationListNotifications.Notification + additional?: AppBskyNotificationListNotifications.Notification[] + subjectUri?: string + subject?: AppBskyFeedDefs.PostView | AppBskyGraphDefs.StarterPackViewBasic +} diff --git a/src/state/queries/notifications/util.ts b/src/state/queries/notifications/util.ts index 8ed1c0390..ade98b317 100644 --- a/src/state/queries/notifications/util.ts +++ b/src/state/queries/notifications/util.ts @@ -3,6 +3,8 @@ import { AppBskyFeedLike, AppBskyFeedPost, AppBskyFeedRepost, + AppBskyGraphDefs, + AppBskyGraphStarterpack, AppBskyNotificationListNotifications, BskyAgent, moderateNotification, @@ -40,6 +42,7 @@ export async function fetchPage({ limit, cursor, }) + const indexedAt = res.data.notifications[0]?.indexedAt // filter out notifs by mod rules @@ -56,9 +59,18 @@ export async function fetchPage({ const subjects = await fetchSubjects(agent, notifsGrouped) for (const notif of notifsGrouped) { if (notif.subjectUri) { - notif.subject = subjects.get(notif.subjectUri) - if (notif.subject) { - precacheProfile(queryClient, notif.subject.author) + if ( + notif.type === 'starterpack-joined' && + notif.notification.reasonSubject + ) { + notif.subject = subjects.starterPacks.get( + notif.notification.reasonSubject, + ) + } else { + notif.subject = subjects.posts.get(notif.subjectUri) + if (notif.subject) { + precacheProfile(queryClient, notif.subject.author) + } } } } @@ -120,12 +132,21 @@ export function groupNotifications( } if (!grouped) { const type = toKnownType(notif) - groupedNotifs.push({ - _reactKey: `notif-${notif.uri}`, - type, - notification: notif, - subjectUri: getSubjectUri(type, notif), - }) + if (type !== 'starterpack-joined') { + groupedNotifs.push({ + _reactKey: `notif-${notif.uri}`, + type, + notification: notif, + subjectUri: getSubjectUri(type, notif), + }) + } else { + groupedNotifs.push({ + _reactKey: `notif-${notif.uri}`, + type: 'starterpack-joined', + notification: notif, + subjectUri: notif.uri, + }) + } } } return groupedNotifs @@ -134,29 +155,54 @@ export function groupNotifications( async function fetchSubjects( agent: BskyAgent, groupedNotifs: FeedNotification[], -): Promise<Map<string, AppBskyFeedDefs.PostView>> { - const uris = new Set<string>() +): Promise<{ + posts: Map<string, AppBskyFeedDefs.PostView> + starterPacks: Map<string, AppBskyGraphDefs.StarterPackViewBasic> +}> { + const postUris = new Set<string>() + const packUris = new Set<string>() for (const notif of groupedNotifs) { if (notif.subjectUri?.includes('app.bsky.feed.post')) { - uris.add(notif.subjectUri) + postUris.add(notif.subjectUri) + } else if ( + notif.notification.reasonSubject?.includes('app.bsky.graph.starterpack') + ) { + packUris.add(notif.notification.reasonSubject) } } - const uriChunks = chunk(Array.from(uris), 25) + const postUriChunks = chunk(Array.from(postUris), 25) + const packUriChunks = chunk(Array.from(packUris), 25) const postsChunks = await Promise.all( - uriChunks.map(uris => + postUriChunks.map(uris => agent.app.bsky.feed.getPosts({uris}).then(res => res.data.posts), ), ) - const map = new Map<string, AppBskyFeedDefs.PostView>() + const packsChunks = await Promise.all( + packUriChunks.map(uris => + agent.app.bsky.graph + .getStarterPacks({uris}) + .then(res => res.data.starterPacks), + ), + ) + const postsMap = new Map<string, AppBskyFeedDefs.PostView>() + const packsMap = new Map<string, AppBskyGraphDefs.StarterPackView>() for (const post of postsChunks.flat()) { if ( AppBskyFeedPost.isRecord(post.record) && AppBskyFeedPost.validateRecord(post.record).success ) { - map.set(post.uri, post) + postsMap.set(post.uri, post) + } + } + for (const pack of packsChunks.flat()) { + if (AppBskyGraphStarterpack.isRecord(pack.record)) { + packsMap.set(pack.uri, pack) } } - return map + return { + posts: postsMap, + starterPacks: packsMap, + } } function toKnownType( @@ -173,7 +219,8 @@ function toKnownType( notif.reason === 'mention' || notif.reason === 'reply' || notif.reason === 'quote' || - notif.reason === 'follow' + notif.reason === 'follow' || + notif.reason === 'starterpack-joined' ) { return notif.reason as NotificationType } diff --git a/src/state/queries/profile-lists.ts b/src/state/queries/profile-lists.ts index 2bb5f4d28..112a62c83 100644 --- a/src/state/queries/profile-lists.ts +++ b/src/state/queries/profile-lists.ts @@ -26,7 +26,15 @@ export function useProfileListsQuery(did: string, opts?: {enabled?: boolean}) { limit: PAGE_SIZE, cursor: pageParam, }) - return res.data + + // Starter packs use a reference list, which we do not want to show on profiles. At some point we could probably + // just filter this out on the backend instead of in the client. + return { + ...res.data, + lists: res.data.lists.filter( + l => l.purpose !== 'app.bsky.graph.defs#referencelist', + ), + } }, initialPageParam: undefined, getNextPageParam: lastPage => lastPage.cursor, diff --git a/src/state/queries/shorten-link.ts b/src/state/queries/shorten-link.ts new file mode 100644 index 000000000..76c63c356 --- /dev/null +++ b/src/state/queries/shorten-link.ts @@ -0,0 +1,23 @@ +import {logger} from '#/logger' + +export function useShortenLink() { + return async (inputUrl: string): Promise<{url: string}> => { + const url = new URL(inputUrl) + const res = await fetch('https://go.bsky.app/link', { + method: 'POST', + body: JSON.stringify({ + path: url.pathname, + }), + headers: { + 'Content-Type': 'application/json', + }, + }) + + if (!res.ok) { + logger.error('Failed to shorten link', {safeMessage: res.status}) + return {url: inputUrl} + } + + return res.json() + } +} diff --git a/src/state/queries/starter-packs.ts b/src/state/queries/starter-packs.ts new file mode 100644 index 000000000..241bc6419 --- /dev/null +++ b/src/state/queries/starter-packs.ts @@ -0,0 +1,317 @@ +import { + AppBskyActorDefs, + AppBskyFeedDefs, + AppBskyGraphDefs, + AppBskyGraphGetStarterPack, + AppBskyGraphStarterpack, + AtUri, + BskyAgent, +} from '@atproto/api' +import {StarterPackView} from '@atproto/api/dist/client/types/app/bsky/graph/defs' +import { + QueryClient, + useMutation, + useQuery, + useQueryClient, +} from '@tanstack/react-query' + +import {until} from 'lib/async/until' +import {createStarterPackList} from 'lib/generate-starterpack' +import { + createStarterPackUri, + httpStarterPackUriToAtUri, + parseStarterPackUri, +} from 'lib/strings/starter-pack' +import {invalidateActorStarterPacksQuery} from 'state/queries/actor-starter-packs' +import {invalidateListMembersQuery} from 'state/queries/list-members' +import {useAgent} from 'state/session' + +const RQKEY_ROOT = 'starter-pack' +const RQKEY = (did?: string, rkey?: string) => { + if (did?.startsWith('https://') || did?.startsWith('at://')) { + const parsed = parseStarterPackUri(did) + return [RQKEY_ROOT, parsed?.name, parsed?.rkey] + } else { + return [RQKEY_ROOT, did, rkey] + } +} + +export function useStarterPackQuery({ + uri, + did, + rkey, +}: { + uri?: string + did?: string + rkey?: string +}) { + const agent = useAgent() + + return useQuery<StarterPackView>({ + queryKey: RQKEY(did, rkey), + queryFn: async () => { + if (!uri) { + uri = `at://${did}/app.bsky.graph.starterpack/${rkey}` + } else if (uri && !uri.startsWith('at://')) { + uri = httpStarterPackUriToAtUri(uri) as string + } + + const res = await agent.app.bsky.graph.getStarterPack({ + starterPack: uri, + }) + return res.data.starterPack + }, + enabled: Boolean(uri) || Boolean(did && rkey), + }) +} + +export async function invalidateStarterPack({ + queryClient, + did, + rkey, +}: { + queryClient: QueryClient + did: string + rkey: string +}) { + await queryClient.invalidateQueries({queryKey: RQKEY(did, rkey)}) +} + +interface UseCreateStarterPackMutationParams { + name: string + description?: string + descriptionFacets: [] + profiles: AppBskyActorDefs.ProfileViewBasic[] + feeds?: AppBskyFeedDefs.GeneratorView[] +} + +export function useCreateStarterPackMutation({ + onSuccess, + onError, +}: { + onSuccess: (data: {uri: string; cid: string}) => void + onError: (e: Error) => void +}) { + const queryClient = useQueryClient() + const agent = useAgent() + + return useMutation< + {uri: string; cid: string}, + Error, + UseCreateStarterPackMutationParams + >({ + mutationFn: async params => { + let listRes + listRes = await createStarterPackList({...params, agent}) + return await agent.app.bsky.graph.starterpack.create( + { + repo: agent.session?.did, + }, + { + ...params, + list: listRes?.uri, + createdAt: new Date().toISOString(), + }, + ) + }, + onSuccess: async data => { + await whenAppViewReady(agent, data.uri, v => { + return typeof v?.data.starterPack.uri === 'string' + }) + await invalidateActorStarterPacksQuery({ + queryClient, + did: agent.session!.did, + }) + onSuccess(data) + }, + onError: async error => { + onError(error) + }, + }) +} + +export function useEditStarterPackMutation({ + onSuccess, + onError, +}: { + onSuccess: () => void + onError: (error: Error) => void +}) { + const queryClient = useQueryClient() + const agent = useAgent() + + return useMutation< + void, + Error, + UseCreateStarterPackMutationParams & { + currentStarterPack: AppBskyGraphDefs.StarterPackView + currentListItems: AppBskyGraphDefs.ListItemView[] + } + >({ + mutationFn: async params => { + const { + name, + description, + descriptionFacets, + feeds, + profiles, + currentStarterPack, + currentListItems, + } = params + + if (!AppBskyGraphStarterpack.isRecord(currentStarterPack.record)) { + throw new Error('Invalid starter pack') + } + + const removedItems = currentListItems.filter( + i => + i.subject.did !== agent.session?.did && + !profiles.find(p => p.did === i.subject.did && p.did), + ) + + if (removedItems.length !== 0) { + await agent.com.atproto.repo.applyWrites({ + repo: agent.session!.did, + writes: removedItems.map(i => ({ + $type: 'com.atproto.repo.applyWrites#delete', + collection: 'app.bsky.graph.listitem', + rkey: new AtUri(i.uri).rkey, + })), + }) + } + + const addedProfiles = profiles.filter( + p => !currentListItems.find(i => i.subject.did === p.did), + ) + + if (addedProfiles.length > 0) { + await agent.com.atproto.repo.applyWrites({ + repo: agent.session!.did, + writes: addedProfiles.map(p => ({ + $type: 'com.atproto.repo.applyWrites#create', + collection: 'app.bsky.graph.listitem', + value: { + $type: 'app.bsky.graph.listitem', + subject: p.did, + list: currentStarterPack.list?.uri, + createdAt: new Date().toISOString(), + }, + })), + }) + } + + const rkey = parseStarterPackUri(currentStarterPack.uri)!.rkey + await agent.com.atproto.repo.putRecord({ + repo: agent.session!.did, + collection: 'app.bsky.graph.starterpack', + rkey, + record: { + name, + description, + descriptionFacets, + list: currentStarterPack.list?.uri, + feeds, + createdAt: currentStarterPack.record.createdAt, + updatedAt: new Date().toISOString(), + }, + }) + }, + onSuccess: async (_, {currentStarterPack}) => { + const parsed = parseStarterPackUri(currentStarterPack.uri) + await whenAppViewReady(agent, currentStarterPack.uri, v => { + return currentStarterPack.cid !== v?.data.starterPack.cid + }) + await invalidateActorStarterPacksQuery({ + queryClient, + did: agent.session!.did, + }) + if (currentStarterPack.list) { + await invalidateListMembersQuery({ + queryClient, + uri: currentStarterPack.list.uri, + }) + } + await invalidateStarterPack({ + queryClient, + did: agent.session!.did, + rkey: parsed!.rkey, + }) + onSuccess() + }, + onError: error => { + onError(error) + }, + }) +} + +export function useDeleteStarterPackMutation({ + onSuccess, + onError, +}: { + onSuccess: () => void + onError: (error: Error) => void +}) { + const agent = useAgent() + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({listUri, rkey}: {listUri?: string; rkey: string}) => { + if (!agent.session) { + throw new Error(`Requires logged in user`) + } + + if (listUri) { + await agent.app.bsky.graph.list.delete({ + repo: agent.session.did, + rkey: new AtUri(listUri).rkey, + }) + } + await agent.app.bsky.graph.starterpack.delete({ + repo: agent.session.did, + rkey, + }) + }, + onSuccess: async (_, {listUri, rkey}) => { + const uri = createStarterPackUri({ + did: agent.session!.did, + rkey, + }) + + if (uri) { + await whenAppViewReady(agent, uri, v => { + return Boolean(v?.data?.starterPack) === false + }) + } + + if (listUri) { + await invalidateListMembersQuery({queryClient, uri: listUri}) + } + await invalidateActorStarterPacksQuery({ + queryClient, + did: agent.session!.did, + }) + await invalidateStarterPack({ + queryClient, + did: agent.session!.did, + rkey, + }) + onSuccess() + }, + onError: error => { + onError(error) + }, + }) +} + +async function whenAppViewReady( + agent: BskyAgent, + uri: string, + fn: (res?: AppBskyGraphGetStarterPack.Response) => boolean, +) { + await until( + 5, // 5 tries + 1e3, // 1s delay between tries + fn, + () => agent.app.bsky.graph.getStarterPack({starterPack: uri}), + ) +} diff --git a/src/state/session/agent.ts b/src/state/session/agent.ts index 5a58937fa..4bcb4c11c 100644 --- a/src/state/session/agent.ts +++ b/src/state/session/agent.ts @@ -127,18 +127,6 @@ export async function createAgentAndCreateAccount( const account = agentToSessionAccountOrThrow(agent) const gates = tryFetchGates(account.did, 'prefer-fresh-gates') const moderation = configureModerationForAccount(agent, account) - if (!account.signupQueued) { - /*dont await*/ agent.upsertProfile(_existing => { - return { - 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 - createdAt: new Date().toISOString(), - } - }) - } // Not awaited so that we can still get into onboarding. // This is OK because we won't let you toggle adult stuff until you set the date. diff --git a/src/state/shell/logged-out.tsx b/src/state/shell/logged-out.tsx index 8fe2a9c01..dc78d03d5 100644 --- a/src/state/shell/logged-out.tsx +++ b/src/state/shell/logged-out.tsx @@ -1,5 +1,9 @@ import React from 'react' +import {isWeb} from 'platform/detection' +import {useSession} from 'state/session' +import {useActiveStarterPack} from 'state/shell/starter-pack' + type State = { showLoggedOut: boolean /** @@ -22,7 +26,7 @@ type Controls = { /** * The did of the account to populate the login form with. */ - requestedAccount?: string | 'none' | 'new' + requestedAccount?: string | 'none' | 'new' | 'starterpack' }) => void /** * Clears the requested account so that next time the logged out view is @@ -43,9 +47,16 @@ const ControlsContext = React.createContext<Controls>({ }) export function Provider({children}: React.PropsWithChildren<{}>) { + const activeStarterPack = useActiveStarterPack() + const {hasSession} = useSession() + const shouldShowStarterPack = Boolean(activeStarterPack?.uri) && !hasSession const [state, setState] = React.useState<State>({ - showLoggedOut: false, - requestedAccountSwitchTo: undefined, + showLoggedOut: shouldShowStarterPack, + requestedAccountSwitchTo: shouldShowStarterPack + ? isWeb + ? 'starterpack' + : 'new' + : undefined, }) const controls = React.useMemo<Controls>( diff --git a/src/state/shell/starter-pack.tsx b/src/state/shell/starter-pack.tsx new file mode 100644 index 000000000..f564712f0 --- /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<StateContext>(undefined) +const setContext = React.createContext<SetContext>((_: StateContext) => {}) + +export function Provider({children}: {children: React.ReactNode}) { + const [state, setState] = React.useState<StateContext>() + + return ( + <stateContext.Provider value={state}> + <setContext.Provider value={setState}>{children}</setContext.Provider> + </stateContext.Provider> + ) +} + +export const useActiveStarterPack = () => React.useContext(stateContext) +export const useSetActiveStarterPack = () => React.useContext(setContext) diff --git a/src/view/com/auth/LoggedOut.tsx b/src/view/com/auth/LoggedOut.tsx index c8c81dd77..29127ec45 100644 --- a/src/view/com/auth/LoggedOut.tsx +++ b/src/view/com/auth/LoggedOut.tsx @@ -7,7 +7,6 @@ import {useNavigation} from '@react-navigation/native' import {useAnalytics} from '#/lib/analytics/analytics' import {usePalette} from '#/lib/hooks/usePalette' -import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' import {logEvent} from '#/lib/statsig/statsig' import {s} from '#/lib/styles' import {isIOS, isNative} from '#/platform/detection' @@ -22,13 +21,16 @@ import {ErrorBoundary} from '#/view/com/util/ErrorBoundary' import {Text} from '#/view/com/util/text/Text' import {Login} from '#/screens/Login' import {Signup} from '#/screens/Signup' +import {LandingScreen} from '#/screens/StarterPack/StarterPackLandingScreen' import {SplashScreen} from './SplashScreen' enum ScreenState { S_LoginOrCreateAccount, S_Login, S_CreateAccount, + S_StarterPack, } +export {ScreenState as LoggedOutScreenState} export function LoggedOut({onDismiss}: {onDismiss?: () => void}) { const {hasSession} = useSession() @@ -37,18 +39,21 @@ export function LoggedOut({onDismiss}: {onDismiss?: () => void}) { const setMinimalShellMode = useSetMinimalShellMode() const {screen} = useAnalytics() const {requestedAccountSwitchTo} = useLoggedOutView() - const [screenState, setScreenState] = React.useState<ScreenState>( - requestedAccountSwitchTo - ? requestedAccountSwitchTo === 'new' - ? ScreenState.S_CreateAccount - : ScreenState.S_Login - : ScreenState.S_LoginOrCreateAccount, - ) - const {isMobile} = useWebMediaQueries() + const [screenState, setScreenState] = React.useState<ScreenState>(() => { + if (requestedAccountSwitchTo === 'new') { + return ScreenState.S_CreateAccount + } else if (requestedAccountSwitchTo === 'starterpack') { + return ScreenState.S_StarterPack + } else if (requestedAccountSwitchTo != null) { + return ScreenState.S_Login + } else { + return ScreenState.S_LoginOrCreateAccount + } + }) const {clearRequestedAccount} = useLoggedOutViewControls() const navigation = useNavigation<NavigationProp>() - const isFirstScreen = screenState === ScreenState.S_LoginOrCreateAccount + const isFirstScreen = screenState === ScreenState.S_LoginOrCreateAccount React.useEffect(() => { screen('Login') setMinimalShellMode(true) @@ -66,18 +71,9 @@ export function LoggedOut({onDismiss}: {onDismiss?: () => void}) { }, [navigation]) return ( - <View - testID="noSessionView" - style={[ - s.hContentRegion, - pal.view, - { - // only needed if dismiss button is present - paddingTop: onDismiss && isMobile ? 40 : 0, - }, - ]}> + <View testID="noSessionView" style={[s.hContentRegion, pal.view]}> <ErrorBoundary> - {onDismiss ? ( + {onDismiss && screenState === ScreenState.S_LoginOrCreateAccount ? ( <Pressable accessibilityHint={_(msg`Go back`)} accessibilityLabel={_(msg`Go back`)} @@ -132,7 +128,9 @@ export function LoggedOut({onDismiss}: {onDismiss?: () => void}) { </Pressable> ) : null} - {screenState === ScreenState.S_LoginOrCreateAccount ? ( + {screenState === ScreenState.S_StarterPack ? ( + <LandingScreen setScreenState={setScreenState} /> + ) : screenState === ScreenState.S_LoginOrCreateAccount ? ( <SplashScreen onPressSignin={() => { setScreenState(ScreenState.S_Login) diff --git a/src/view/com/feeds/FeedSourceCard.tsx b/src/view/com/feeds/FeedSourceCard.tsx index a61789434..d216849c5 100644 --- a/src/view/com/feeds/FeedSourceCard.tsx +++ b/src/view/com/feeds/FeedSourceCard.tsx @@ -329,6 +329,9 @@ const styles = StyleSheet.create({ flex: 1, gap: 14, }, + border: { + borderTopWidth: hairlineWidth, + }, headerContainer: { flexDirection: 'row', }, diff --git a/src/view/com/notifications/FeedItem.tsx b/src/view/com/notifications/FeedItem.tsx index 9cd7a2917..2f8d65a1d 100644 --- a/src/view/com/notifications/FeedItem.tsx +++ b/src/view/com/notifications/FeedItem.tsx @@ -52,7 +52,16 @@ import {TimeElapsed} from '../util/TimeElapsed' import {PreviewableUserAvatar, UserAvatar} from '../util/UserAvatar' import hairlineWidth = StyleSheet.hairlineWidth +import {useNavigation} from '@react-navigation/native' + import {parseTenorGif} from '#/lib/strings/embed-player' +import {logger} from '#/logger' +import {NavigationProp} from 'lib/routes/types' +import {DM_SERVICE_HEADERS} from 'state/queries/messages/const' +import {useAgent} from 'state/session' +import {Button, ButtonText} from '#/components/Button' +import {StarterPack} from '#/components/icons/StarterPack' +import {Notification as StarterPackCard} from '#/components/StarterPack/StarterPackCard' const MAX_AUTHORS = 5 @@ -89,7 +98,10 @@ let FeedItem = ({ } else if (item.type === 'reply') { const urip = new AtUri(item.notification.uri) return `/profile/${urip.host}/post/${urip.rkey}` - } else if (item.type === 'feedgen-like') { + } else if ( + item.type === 'feedgen-like' || + item.type === 'starterpack-joined' + ) { if (item.subjectUri) { const urip = new AtUri(item.subjectUri) return `/profile/${urip.host}/feed/${urip.rkey}` @@ -176,6 +188,13 @@ let FeedItem = ({ icon = <PersonPlusIcon size="xl" style={{color: t.palette.primary_500}} /> } else if (item.type === 'feedgen-like') { action = _(msg`liked your custom feed`) + } else if (item.type === 'starterpack-joined') { + icon = ( + <View style={{height: 30, width: 30}}> + <StarterPack width={30} gradient="sky" /> + </View> + ) + action = _(msg`signed up with your starter pack`) } else { return null } @@ -289,6 +308,20 @@ let FeedItem = ({ showLikes /> ) : null} + {item.type === 'starterpack-joined' ? ( + <View> + <View + style={[ + a.border, + a.p_sm, + a.rounded_sm, + a.mt_sm, + t.atoms.border_contrast_low, + ]}> + <StarterPackCard starterPack={item.subject} /> + </View> + </View> + ) : null} </View> </Link> ) @@ -319,14 +352,63 @@ function ExpandListPressable({ } } +function SayHelloBtn({profile}: {profile: AppBskyActorDefs.ProfileViewBasic}) { + const {_} = useLingui() + const agent = useAgent() + const navigation = useNavigation<NavigationProp>() + const [isLoading, setIsLoading] = React.useState(false) + + if ( + profile.associated?.chat?.allowIncoming === 'none' || + (profile.associated?.chat?.allowIncoming === 'following' && + !profile.viewer?.followedBy) + ) { + return null + } + + return ( + <Button + label={_(msg`Say hello!`)} + variant="ghost" + color="primary" + size="xsmall" + style={[a.self_center, {marginLeft: 'auto'}]} + disabled={isLoading} + onPress={async () => { + try { + setIsLoading(true) + const res = await agent.api.chat.bsky.convo.getConvoForMembers( + { + members: [profile.did, agent.session!.did!], + }, + {headers: DM_SERVICE_HEADERS}, + ) + navigation.navigate('MessagesConversation', { + conversation: res.data.convo.id, + }) + } catch (e) { + logger.error('Failed to get conversation', {safeMessage: e}) + } finally { + setIsLoading(false) + } + }}> + <ButtonText> + <Trans>Say hello!</Trans> + </ButtonText> + </Button> + ) +} + function CondensedAuthorsList({ visible, authors, onToggleAuthorsExpanded, + showDmButton = true, }: { visible: boolean authors: Author[] onToggleAuthorsExpanded: () => void + showDmButton?: boolean }) { const pal = usePalette('default') const {_} = useLingui() @@ -355,7 +437,7 @@ function CondensedAuthorsList({ } if (authors.length === 1) { return ( - <View style={styles.avis}> + <View style={[styles.avis]}> <PreviewableUserAvatar size={35} profile={authors[0].profile} @@ -363,6 +445,7 @@ function CondensedAuthorsList({ type={authors[0].profile.associated?.labeler ? 'labeler' : 'user'} accessible={false} /> + {showDmButton ? <SayHelloBtn profile={authors[0].profile} /> : null} </View> ) } diff --git a/src/view/com/profile/FollowButton.tsx b/src/view/com/profile/FollowButton.tsx index 7b090ffeb..8e63da85b 100644 --- a/src/view/com/profile/FollowButton.tsx +++ b/src/view/com/profile/FollowButton.tsx @@ -1,12 +1,13 @@ import React from 'react' import {StyleProp, TextStyle, View} from 'react-native' import {AppBskyActorDefs} from '@atproto/api' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {Shadow} from '#/state/cache/types' +import {useProfileFollowMutationQueue} from '#/state/queries/profile' import {Button, ButtonType} from '../util/forms/Button' import * as Toast from '../util/Toast' -import {useProfileFollowMutationQueue} from '#/state/queries/profile' -import {Shadow} from '#/state/cache/types' -import {useLingui} from '@lingui/react' -import {msg} from '@lingui/macro' export function FollowButton({ unfollowedType = 'inverted', @@ -19,7 +20,7 @@ export function FollowButton({ followedType?: ButtonType profile: Shadow<AppBskyActorDefs.ProfileViewBasic> labelStyle?: StyleProp<TextStyle> - logContext: 'ProfileCard' + logContext: 'ProfileCard' | 'StarterPackProfilesList' }) { const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue( profile, diff --git a/src/view/com/profile/ProfileCard.tsx b/src/view/com/profile/ProfileCard.tsx index a3cd5ca1b..d7ed0dd6a 100644 --- a/src/view/com/profile/ProfileCard.tsx +++ b/src/view/com/profile/ProfileCard.tsx @@ -251,12 +251,14 @@ export function ProfileCardWithFollowBtn({ noBorder, followers, onPress, + logContext = 'ProfileCard', }: { profile: AppBskyActorDefs.ProfileViewBasic noBg?: boolean noBorder?: boolean followers?: AppBskyActorDefs.ProfileView[] | undefined onPress?: () => void + logContext?: 'ProfileCard' | 'StarterPackProfilesList' }) { const {currentAccount} = useSession() const isMe = profile.did === currentAccount?.did @@ -271,7 +273,7 @@ export function ProfileCardWithFollowBtn({ isMe ? undefined : profileShadow => ( - <FollowButton profile={profileShadow} logContext="ProfileCard" /> + <FollowButton profile={profileShadow} logContext={logContext} /> ) } onPress={onPress} @@ -314,6 +316,7 @@ const styles = StyleSheet.create({ paddingRight: 10, }, details: { + justifyContent: 'center', paddingLeft: 54, paddingRight: 10, paddingBottom: 10, @@ -339,7 +342,6 @@ const styles = StyleSheet.create({ followedBy: { flexDirection: 'row', - alignItems: 'center', paddingLeft: 54, paddingRight: 20, marginBottom: 10, diff --git a/src/view/com/profile/ProfileSubpageHeader.tsx b/src/view/com/profile/ProfileSubpageHeader.tsx index edc6b75f9..ac5febcda 100644 --- a/src/view/com/profile/ProfileSubpageHeader.tsx +++ b/src/view/com/profile/ProfileSubpageHeader.tsx @@ -21,7 +21,9 @@ import {Text} from '../util/text/Text' import {UserAvatar, UserAvatarType} from '../util/UserAvatar' import {CenteredView} from '../util/Views' import hairlineWidth = StyleSheet.hairlineWidth + import {Menu_Stroke2_Corner0_Rounded as Menu} from '#/components/icons/Menu' +import {StarterPack} from '#/components/icons/StarterPack' export function ProfileSubpageHeader({ isLoading, @@ -44,7 +46,7 @@ export function ProfileSubpageHeader({ handle: string } | undefined - avatarType: UserAvatarType + avatarType: UserAvatarType | 'starter-pack' }>) { const setDrawerOpen = useSetDrawerOpen() const navigation = useNavigation<NavigationProp>() @@ -127,7 +129,11 @@ export function ProfileSubpageHeader({ accessibilityLabel={_(msg`View the avatar`)} accessibilityHint="" style={{width: 58}}> - <UserAvatar type={avatarType} size={58} avatar={avatar} /> + {avatarType === 'starter-pack' ? ( + <StarterPack width={58} gradient="sky" /> + ) : ( + <UserAvatar type={avatarType} size={58} avatar={avatar} /> + )} </Pressable> <View style={{flex: 1}}> {isLoading ? ( diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx index e49f2fbb2..dfadf9bbe 100644 --- a/src/view/screens/Home.tsx +++ b/src/view/screens/Home.tsx @@ -30,7 +30,7 @@ import {FollowingEndOfFeed} from 'view/com/posts/FollowingEndOfFeed' import {NoFeedsPinned} from '#/screens/Home/NoFeedsPinned' import {HomeHeader} from '../com/home/HomeHeader' -type Props = NativeStackScreenProps<HomeTabNavigatorParams, 'Home'> +type Props = NativeStackScreenProps<HomeTabNavigatorParams, 'Home' | 'Start'> export function HomeScreen(props: Props) { const {data: preferences} = usePreferencesQuery() const {data: pinnedFeedInfos, isLoading: isPinnedFeedsLoading} = diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx index 734230c6c..946f6ac54 100644 --- a/src/view/screens/Profile.tsx +++ b/src/view/screens/Profile.tsx @@ -1,7 +1,8 @@ -import React, {useMemo} from 'react' +import React, {useCallback, useMemo} from 'react' import {StyleSheet} from 'react-native' import { AppBskyActorDefs, + AppBskyGraphGetActorStarterPacks, moderateProfile, ModerationOpts, RichText as RichTextAPI, @@ -9,7 +10,11 @@ import { import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useFocusEffect} from '@react-navigation/native' -import {useQueryClient} from '@tanstack/react-query' +import { + InfiniteData, + UseInfiniteQueryResult, + useQueryClient, +} from '@tanstack/react-query' import {cleanError} from '#/lib/strings/errors' import {useProfileShadow} from '#/state/cache/profile-shadow' @@ -22,18 +27,23 @@ import {useAgent, useSession} from '#/state/session' import {useSetDrawerSwipeDisabled, useSetMinimalShellMode} from '#/state/shell' import {useComposerControls} from '#/state/shell/composer' import {useAnalytics} from 'lib/analytics/analytics' +import {IS_DEV, IS_TESTFLIGHT} from 'lib/app-info' import {useSetTitle} from 'lib/hooks/useSetTitle' import {ComposeIcon2} from 'lib/icons' import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types' +import {useGate} from 'lib/statsig/statsig' import {combinedDisplayName} from 'lib/strings/display-names' import {isInvalidHandle} from 'lib/strings/handles' import {colors, s} from 'lib/styles' +import {isWeb} from 'platform/detection' import {listenSoftReset} from 'state/events' +import {useActorStarterPacksQuery} from 'state/queries/actor-starter-packs' import {PagerWithHeader} from 'view/com/pager/PagerWithHeader' import {ProfileHeader, ProfileHeaderLoading} from '#/screens/Profile/Header' import {ProfileFeedSection} from '#/screens/Profile/Sections/Feed' import {ProfileLabelsSection} from '#/screens/Profile/Sections/Labels' import {ScreenHider} from '#/components/moderation/ScreenHider' +import {ProfileStarterPacks} from '#/components/StarterPack/ProfileStarterPacks' import {ExpoScrollForwarderView} from '../../../modules/expo-scroll-forwarder' import {ProfileFeedgens} from '../com/feeds/ProfileFeedgens' import {ProfileLists} from '../com/lists/ProfileLists' @@ -69,6 +79,7 @@ export function ProfileScreen({route}: Props) { } = useProfileQuery({ did: resolvedDid, }) + const starterPacksQuery = useActorStarterPacksQuery({did: resolvedDid}) const onPressTryAgain = React.useCallback(() => { if (resolveError) { @@ -86,7 +97,7 @@ export function ProfileScreen({route}: Props) { }, [queryClient, profile?.viewer?.blockedBy, resolvedDid]) // Most pushes will happen here, since we will have only placeholder data - if (isLoadingDid || isLoadingProfile) { + if (isLoadingDid || isLoadingProfile || starterPacksQuery.isLoading) { return ( <CenteredView> <ProfileHeaderLoading /> @@ -108,6 +119,7 @@ export function ProfileScreen({route}: Props) { return ( <ProfileScreenLoaded profile={profile} + starterPacksQuery={starterPacksQuery} moderationOpts={moderationOpts} isPlaceholderProfile={isPlaceholderProfile} hideBackButton={!!route.params.hideBackButton} @@ -131,11 +143,16 @@ function ProfileScreenLoaded({ isPlaceholderProfile, moderationOpts, hideBackButton, + starterPacksQuery, }: { profile: AppBskyActorDefs.ProfileViewDetailed moderationOpts: ModerationOpts hideBackButton: boolean isPlaceholderProfile: boolean + starterPacksQuery: UseInfiniteQueryResult< + InfiniteData<AppBskyGraphGetActorStarterPacks.OutputSchema, unknown>, + Error + > }) { const profile = useProfileShadow(profileUnshadowed) const {hasSession, currentAccount} = useSession() @@ -153,6 +170,9 @@ function ProfileScreenLoaded({ const [currentPage, setCurrentPage] = React.useState(0) const {_} = useLingui() const setDrawerSwipeDisabled = useSetDrawerSwipeDisabled() + const gate = useGate() + const starterPacksEnabled = + IS_DEV || IS_TESTFLIGHT || (!isWeb && gate('starter_packs_enabled')) const [scrollViewTag, setScrollViewTag] = React.useState<number | null>(null) @@ -162,6 +182,7 @@ function ProfileScreenLoaded({ const likesSectionRef = React.useRef<SectionRef>(null) const feedsSectionRef = React.useRef<SectionRef>(null) const listsSectionRef = React.useRef<SectionRef>(null) + const starterPacksSectionRef = React.useRef<SectionRef>(null) const labelsSectionRef = React.useRef<SectionRef>(null) useSetTitle(combinedDisplayName(profile)) @@ -183,31 +204,23 @@ function ProfileScreenLoaded({ const showMediaTab = !hasLabeler const showLikesTab = isMe const showFeedsTab = isMe || (profile.associated?.feedgens || 0) > 0 + const showStarterPacksTab = + starterPacksEnabled && + (isMe || !!starterPacksQuery.data?.pages?.[0].starterPacks.length) const showListsTab = hasSession && (isMe || (profile.associated?.lists || 0) > 0) - const sectionTitles = useMemo<string[]>(() => { - return [ - showFiltersTab ? _(msg`Labels`) : undefined, - showListsTab && hasLabeler ? _(msg`Lists`) : undefined, - showPostsTab ? _(msg`Posts`) : undefined, - showRepliesTab ? _(msg`Replies`) : undefined, - showMediaTab ? _(msg`Media`) : undefined, - showLikesTab ? _(msg`Likes`) : undefined, - showFeedsTab ? _(msg`Feeds`) : undefined, - showListsTab && !hasLabeler ? _(msg`Lists`) : undefined, - ].filter(Boolean) as string[] - }, [ - showPostsTab, - showRepliesTab, - showMediaTab, - showLikesTab, - showFeedsTab, - showListsTab, - showFiltersTab, - hasLabeler, - _, - ]) + const sectionTitles = [ + showFiltersTab ? _(msg`Labels`) : undefined, + showListsTab && hasLabeler ? _(msg`Lists`) : undefined, + showPostsTab ? _(msg`Posts`) : undefined, + showRepliesTab ? _(msg`Replies`) : undefined, + showMediaTab ? _(msg`Media`) : undefined, + showLikesTab ? _(msg`Likes`) : undefined, + showFeedsTab ? _(msg`Feeds`) : undefined, + showStarterPacksTab ? _(msg`Starter Packs`) : undefined, + showListsTab && !hasLabeler ? _(msg`Lists`) : undefined, + ].filter(Boolean) as string[] let nextIndex = 0 let filtersIndex: number | null = null @@ -216,6 +229,7 @@ function ProfileScreenLoaded({ let mediaIndex: number | null = null let likesIndex: number | null = null let feedsIndex: number | null = null + let starterPacksIndex: number | null = null let listsIndex: number | null = null if (showFiltersTab) { filtersIndex = nextIndex++ @@ -235,11 +249,14 @@ function ProfileScreenLoaded({ if (showFeedsTab) { feedsIndex = nextIndex++ } + if (showStarterPacksTab) { + starterPacksIndex = nextIndex++ + } if (showListsTab) { listsIndex = nextIndex++ } - const scrollSectionToTop = React.useCallback( + const scrollSectionToTop = useCallback( (index: number) => { if (index === filtersIndex) { labelsSectionRef.current?.scrollToTop() @@ -253,6 +270,8 @@ function ProfileScreenLoaded({ likesSectionRef.current?.scrollToTop() } else if (index === feedsIndex) { feedsSectionRef.current?.scrollToTop() + } else if (index === starterPacksIndex) { + starterPacksSectionRef.current?.scrollToTop() } else if (index === listsIndex) { listsSectionRef.current?.scrollToTop() } @@ -265,6 +284,7 @@ function ProfileScreenLoaded({ likesIndex, feedsIndex, listsIndex, + starterPacksIndex, ], ) @@ -290,7 +310,7 @@ function ProfileScreenLoaded({ // events // = - const onPressCompose = React.useCallback(() => { + const onPressCompose = () => { track('ProfileScreen:PressCompose') const mention = profile.handle === currentAccount?.handle || @@ -298,23 +318,20 @@ function ProfileScreenLoaded({ ? undefined : profile.handle openComposer({mention}) - }, [openComposer, currentAccount, track, profile]) + } - const onPageSelected = React.useCallback((i: number) => { + const onPageSelected = (i: number) => { setCurrentPage(i) - }, []) + } - const onCurrentPageSelected = React.useCallback( - (index: number) => { - scrollSectionToTop(index) - }, - [scrollSectionToTop], - ) + const onCurrentPageSelected = (index: number) => { + scrollSectionToTop(index) + } // rendering // = - const renderHeader = React.useCallback(() => { + const renderHeader = () => { return ( <ExpoScrollForwarderView scrollViewTag={scrollViewTag}> <ProfileHeader @@ -327,16 +344,7 @@ function ProfileScreenLoaded({ /> </ExpoScrollForwarderView> ) - }, [ - scrollViewTag, - profile, - labelerInfo, - hasDescription, - descriptionRT, - moderationOpts, - hideBackButton, - showPlaceholder, - ]) + } return ( <ScreenHider @@ -442,6 +450,19 @@ function ProfileScreenLoaded({ /> ) : null} + {showStarterPacksTab + ? ({headerHeight, isFocused, scrollElRef}) => ( + <ProfileStarterPacks + ref={starterPacksSectionRef} + isMe={isMe} + starterPacksQuery={starterPacksQuery} + scrollElRef={scrollElRef as ListRef} + headerOffset={headerHeight} + enabled={isFocused} + setScrollViewTag={setScrollViewTag} + /> + ) + : null} {showListsTab && !profile.associated?.labeler ? ({headerHeight, isFocused, scrollElRef}) => ( <ProfileLists diff --git a/src/view/screens/Storybook/Icons.tsx b/src/view/screens/Storybook/Icons.tsx index bff1fdc9b..9de126d6b 100644 --- a/src/view/screens/Storybook/Icons.tsx +++ b/src/view/screens/Storybook/Icons.tsx @@ -45,6 +45,14 @@ export function Icons() { <Loader size="lg" fill={t.atoms.text.color} /> <Loader size="xl" fill={t.atoms.text.color} /> </View> + + <View style={[a.flex_row, a.gap_xl]}> + <Globe size="xs" gradient="sky" /> + <Globe size="sm" gradient="sky" /> + <Globe size="md" gradient="sky" /> + <Globe size="lg" gradient="sky" /> + <Globe size="xl" gradient="sky" /> + </View> </View> ) } diff --git a/src/view/shell/desktop/LeftNav.tsx b/src/view/shell/desktop/LeftNav.tsx index 9b2b4922a..ca8073f57 100644 --- a/src/view/shell/desktop/LeftNav.tsx +++ b/src/view/shell/desktop/LeftNav.tsx @@ -100,12 +100,18 @@ function ProfileCard() { ) } +const HIDDEN_BACK_BNT_ROUTES = ['StarterPackWizard', 'StarterPackEdit'] + function BackBtn() { const {isTablet} = useWebMediaQueries() const pal = usePalette('default') const navigation = useNavigation<NavigationProp>() const {_} = useLingui() - const shouldShow = useNavigationState(state => !isStateAtTabRoot(state)) + const shouldShow = useNavigationState( + state => + !isStateAtTabRoot(state) && + !HIDDEN_BACK_BNT_ROUTES.includes(getCurrentRoute(state).name), + ) const onPressBack = React.useCallback(() => { if (navigation.canGoBack()) { |