Starter Packs (#4332)

Co-authored-by: Dan Abramov <dan.abramov@gmail.com>
Co-authored-by: Paul Frazee <pfrazee@gmail.com>
Co-authored-by: Eric Bailey <git@esb.lol>
Co-authored-by: Samuel Newman <mozzius@protonmail.com>
This commit is contained in:
Hailey 2024-06-21 21:38:04 -07:00 committed by GitHub
parent 35f64535cb
commit f089f45781
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
115 changed files with 6336 additions and 237 deletions

View file

@ -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)

View file

@ -329,6 +329,9 @@ const styles = StyleSheet.create({
flex: 1,
gap: 14,
},
border: {
borderTopWidth: hairlineWidth,
},
headerContainer: {
flexDirection: 'row',
},

View file

@ -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>
)
}

View file

@ -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,

View file

@ -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,

View file

@ -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 ? (

View file

@ -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} =

View file

@ -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

View file

@ -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>
)
}

View file

@ -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()) {