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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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,
]}
/>

View file

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

View file

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

View file

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

View file

@ -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',
],
})

View file

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

View file

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

View file

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

View file

@ -1,3 +1,4 @@
export const isSafari = false
export const isFirefox = false
export const isTouchDevice = true
export const isAndroidWeb = false

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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': {}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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)
}
}
return map
for (const pack of packsChunks.flat()) {
if (AppBskyGraphStarterpack.isRecord(pack.record)) {
packsMap.set(pack.uri, pack)
}
}
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
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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