Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cdae4454e3 | |||
| 7ebe72324b | |||
| 6ed7f9fb1e | |||
| e717e18dae | |||
| 64ccace6b6 | |||
| 02542f9ad3 | |||
| cf109da792 | |||
| b57a184002 | |||
| a373cd056d | |||
| 8e7ee4c94c | |||
| 55c2ca5b92 | |||
| 30686702a6 | |||
| cb581bffd1 |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#080B12" fill-rule="evenodd" d="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" clip-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 580 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 153 133"><path fill="url(#a)" fill-rule="evenodd" d="m60.196 105.445-18.1 4.85c-11.73 3.143-23.788-3.819-26.931-15.55L1.19 42.597c-3.143-11.731 3.819-23.79 15.55-26.932L68.889 1.69C80.62-1.452 92.68 5.51 95.821 17.241l4.667 17.416a49.7 49.7 0 0 1 3.522-.125c27.053 0 48.984 21.931 48.984 48.984S131.063 132.5 104.01 132.5c-19.17 0-35.769-11.012-43.814-27.055ZM19.457 25.804 71.606 11.83c6.131-1.643 12.434 1.996 14.076 8.127l4.44 16.571c-20.289 5.987-35.096 24.758-35.096 46.988 0 4.157.517 8.193 1.492 12.047l-17.138 4.593c-6.131 1.642-12.434-1.996-14.077-8.128L11.33 39.88c-1.643-6.131 1.996-12.434 8.127-14.077Zm83.812 19.232c.246-.005.493-.007.741-.007 21.256 0 38.487 17.231 38.487 38.487s-17.231 38.488-38.487 38.488c-14.29 0-26.76-7.788-33.4-19.35l23.635-6.333c11.731-3.143 18.693-15.2 15.55-26.932l-6.526-24.353Zm-10.428 1.638 6.815 25.432c1.642 6.131-1.996 12.434-8.128 14.076l-24.867 6.664a38.57 38.57 0 0 1-1.139-9.33c0-17.372 11.51-32.056 27.32-36.842Z" clip-rule="evenodd"/><defs><linearGradient id="a" x1="76.715" x2="76.715" y1=".937" y2="132.5" gradientUnits="userSpaceOnUse"><stop stop-color="#0A7AFF"/><stop offset="1" stop-color="#59B9FF"/></linearGradient></defs></svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -43,6 +43,9 @@ import {SavedFeeds} from 'view/screens/SavedFeeds'
|
||||
import HashtagScreen from '#/screens/Hashtag'
|
||||
import {ModerationScreen} from '#/screens/Moderation'
|
||||
import {ProfileLabelerLikedByScreen} from '#/screens/Profile/ProfileLabelerLikedBy'
|
||||
import {LandingScreen} from '#/screens/StarterPack/Landing'
|
||||
import {StarterPackScreen} from '#/screens/StarterPack/Main'
|
||||
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'
|
||||
@@ -306,6 +309,23 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
|
||||
getComponent={() => MessagesSettingsScreen}
|
||||
options={{title: title(msg`Chat settings`), requireAuth: true}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
// TODO figure out what is up with this
|
||||
// @ts-ignore what the absolute fuck is going on here??
|
||||
name="StarterPack"
|
||||
getComponent={() => StarterPackScreen}
|
||||
options={{title: title(msg`Starter Pack`), requireAuth: true}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="StarterPackLanding"
|
||||
getComponent={() => LandingScreen}
|
||||
options={{title: title(msg`Join Bluesky Today!`)}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="StarterPackWizard"
|
||||
getComponent={() => Wizard}
|
||||
options={{title: title(msg`Create a starter pack`), requireAuth: true}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
import React from 'react'
|
||||
import {
|
||||
findNodeHandle,
|
||||
ListRenderItemInfo,
|
||||
StyleProp,
|
||||
View,
|
||||
ViewStyle,
|
||||
} from 'react-native'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {cleanError} from '#/lib/strings/errors'
|
||||
import {useTheme} from '#/lib/ThemeContext'
|
||||
import {logger} from '#/logger'
|
||||
import {isNative} from '#/platform/detection'
|
||||
import {hydrateFeedGenerator} from '#/state/queries/feed'
|
||||
import {usePreferencesQuery} from '#/state/queries/preferences'
|
||||
import {RQKEY, useProfileFeedgensQuery} from '#/state/queries/profile-feedgens'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
|
||||
import {ErrorMessage} from 'view/com/util/error/ErrorMessage'
|
||||
import {List, ListRef} from 'view/com/util/List'
|
||||
import {LoadMoreRetryBtn} from 'view/com/util/LoadMoreRetryBtn'
|
||||
import {Text} from 'view/com/util/text/Text'
|
||||
import {StarterPackCard} from '#/components/StarterPack/StarterPackCard'
|
||||
|
||||
const LOADING = {_reactKey: '__loading__'}
|
||||
const EMPTY = {_reactKey: '__empty__'}
|
||||
const ERROR_ITEM = {_reactKey: '__error__'}
|
||||
const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'}
|
||||
|
||||
interface SectionRef {
|
||||
scrollToTop: () => void
|
||||
}
|
||||
|
||||
interface ProfileFeedgensProps {
|
||||
did: string
|
||||
scrollElRef: ListRef
|
||||
headerOffset: number
|
||||
enabled?: boolean
|
||||
style?: StyleProp<ViewStyle>
|
||||
testID?: string
|
||||
setScrollViewTag: (tag: number | null) => void
|
||||
}
|
||||
|
||||
export const ProfileStarterPacks = React.forwardRef<
|
||||
SectionRef,
|
||||
ProfileFeedgensProps
|
||||
>(function ProfileFeedgensImpl(
|
||||
{did, scrollElRef, headerOffset, enabled, style, testID, setScrollViewTag},
|
||||
ref,
|
||||
) {
|
||||
const pal = usePalette('default')
|
||||
const {_} = useLingui()
|
||||
const theme = useTheme()
|
||||
const [isPTRing, setIsPTRing] = React.useState(false)
|
||||
const opts = React.useMemo(() => ({enabled}), [enabled])
|
||||
const {
|
||||
data,
|
||||
isFetching,
|
||||
isFetched,
|
||||
hasNextPage,
|
||||
fetchNextPage,
|
||||
isError,
|
||||
error,
|
||||
refetch,
|
||||
} = useProfileFeedgensQuery(did, opts)
|
||||
const isEmpty = !isFetching && !data?.pages[0]?.feeds.length
|
||||
const {data: preferences} = usePreferencesQuery()
|
||||
|
||||
const items = React.useMemo(() => {
|
||||
let items: any[] = []
|
||||
if (isError && isEmpty) {
|
||||
items = items.concat([ERROR_ITEM])
|
||||
}
|
||||
if (!isFetched && isFetching) {
|
||||
items = items.concat([LOADING])
|
||||
} else if (isEmpty) {
|
||||
items = items.concat([EMPTY])
|
||||
} else if (data?.pages) {
|
||||
for (const page of data?.pages) {
|
||||
items = items.concat(page.feeds.map(feed => hydrateFeedGenerator(feed)))
|
||||
}
|
||||
}
|
||||
if (isError && !isEmpty) {
|
||||
items = items.concat([LOAD_MORE_ERROR_ITEM])
|
||||
}
|
||||
return items
|
||||
}, [isError, isEmpty, isFetched, isFetching, data])
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const onScrollToTop = React.useCallback(() => {
|
||||
scrollElRef.current?.scrollToOffset({
|
||||
animated: isNative,
|
||||
offset: -headerOffset,
|
||||
})
|
||||
queryClient.invalidateQueries({queryKey: RQKEY(did)})
|
||||
}, [scrollElRef, queryClient, headerOffset, did])
|
||||
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
scrollToTop: onScrollToTop,
|
||||
}))
|
||||
|
||||
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 || isError) return
|
||||
|
||||
try {
|
||||
await fetchNextPage()
|
||||
} catch (err) {
|
||||
logger.error('Failed to load more starter packs', {message: err})
|
||||
}
|
||||
}, [isFetching, hasNextPage, isError, fetchNextPage])
|
||||
|
||||
const onPressRetryLoadMore = React.useCallback(() => {
|
||||
fetchNextPage()
|
||||
}, [fetchNextPage])
|
||||
|
||||
const renderItem = React.useCallback(
|
||||
({item, index}: ListRenderItemInfo<any>) => {
|
||||
if (item === EMPTY) {
|
||||
return (
|
||||
<View
|
||||
testID="listsEmpty"
|
||||
style={[{padding: 18, borderTopWidth: 1}, pal.border]}>
|
||||
<Text style={pal.textLight}>
|
||||
<Trans>You have no feeds.</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
} else if (item === ERROR_ITEM) {
|
||||
return (
|
||||
<ErrorMessage message={cleanError(error)} onPressTryAgain={refetch} />
|
||||
)
|
||||
} else if (item === LOAD_MORE_ERROR_ITEM) {
|
||||
return (
|
||||
<LoadMoreRetryBtn
|
||||
label={_(
|
||||
msg`There was an issue fetching your lists. Tap here to try again.`,
|
||||
)}
|
||||
onPress={onPressRetryLoadMore}
|
||||
/>
|
||||
)
|
||||
} else if (item === LOADING) {
|
||||
return <FeedLoadingPlaceholder />
|
||||
}
|
||||
if (preferences) {
|
||||
return <StarterPackCard hideTopBorder={index === 0} />
|
||||
}
|
||||
return null
|
||||
},
|
||||
[error, refetch, onPressRetryLoadMore, pal, preferences, _],
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (enabled && scrollElRef.current) {
|
||||
const nativeTag = findNodeHandle(scrollElRef.current)
|
||||
setScrollViewTag(nativeTag)
|
||||
}
|
||||
}, [enabled, scrollElRef, setScrollViewTag])
|
||||
|
||||
return (
|
||||
<View testID={testID} style={style}>
|
||||
<List
|
||||
testID={testID ? `${testID}-flatlist` : undefined}
|
||||
ref={scrollElRef}
|
||||
data={items}
|
||||
keyExtractor={(item: any) => item._reactKey || item.uri}
|
||||
renderItem={renderItem}
|
||||
refreshing={isPTRing}
|
||||
onRefresh={onRefresh}
|
||||
headerOffset={headerOffset}
|
||||
contentContainerStyle={isNative && {paddingBottom: headerOffset + 100}}
|
||||
indicatorStyle={theme.colorScheme === 'dark' ? 'white' : 'black'}
|
||||
removeClippedSubviews={true}
|
||||
// @ts-ignore our .web version only -prf
|
||||
desktopFixedHeight
|
||||
onEndReached={onEndReached}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,39 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {StarterPackIcon} from '#/components/icons/StarterPackIcon'
|
||||
import {Link} from '#/components/Link'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export function StarterPackCard({hideTopBorder}: {hideTopBorder?: boolean}) {
|
||||
const t = useTheme()
|
||||
|
||||
return (
|
||||
<Link to={{screen: 'StarterPack', params: {id: '123'}}}>
|
||||
<View
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.w_full,
|
||||
!hideTopBorder && a.border_t,
|
||||
a.px_xl,
|
||||
a.py_lg,
|
||||
a.gap_md,
|
||||
t.atoms.border_contrast_low,
|
||||
]}>
|
||||
<StarterPackIcon width={36} height={36} />
|
||||
<View style={a.gap_md}>
|
||||
<View>
|
||||
<Text style={[a.font_bold, a.text_md]}>Science</Text>
|
||||
<Text style={[t.atoms.text_contrast_medium]}>
|
||||
Starter pack by @bossett.social
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={[a.font_bold, t.atoms.text_contrast_medium]}>
|
||||
380 users have joined!
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
@@ -140,6 +140,7 @@ export function createInput(Component: typeof TextInput) {
|
||||
onChangeText,
|
||||
isInvalid,
|
||||
inputRef,
|
||||
style,
|
||||
...rest
|
||||
}: InputProps) {
|
||||
const t = useTheme()
|
||||
@@ -199,6 +200,7 @@ export function createInput(Component: typeof TextInput) {
|
||||
android({
|
||||
paddingBottom: 16,
|
||||
}),
|
||||
style,
|
||||
]}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
@@ -0,0 +1,26 @@
|
||||
import * as React from 'react'
|
||||
import Svg, {Defs, LinearGradient, Path, Stop} from 'react-native-svg'
|
||||
export function StarterPackIcon({...props}: React.ComponentProps<typeof Svg>) {
|
||||
return (
|
||||
<Svg fill="none" viewBox="0 0 153 133" {...props}>
|
||||
<Path
|
||||
fill="url(#a)"
|
||||
fillRule="evenodd"
|
||||
d="m60.196 105.445-18.1 4.85c-11.73 3.143-23.788-3.819-26.931-15.55L1.19 42.597c-3.143-11.731 3.819-23.79 15.55-26.932L68.889 1.69C80.62-1.452 92.68 5.51 95.821 17.241l4.667 17.416a49.7 49.7 0 0 1 3.522-.125c27.053 0 48.984 21.931 48.984 48.984S131.063 132.5 104.01 132.5c-19.17 0-35.769-11.012-43.814-27.055ZM19.457 25.804 71.606 11.83c6.131-1.643 12.434 1.996 14.076 8.127l4.44 16.571c-20.289 5.987-35.096 24.758-35.096 46.988 0 4.157.517 8.193 1.492 12.047l-17.138 4.593c-6.131 1.642-12.434-1.996-14.077-8.128L11.33 39.88c-1.643-6.131 1.996-12.434 8.127-14.077Zm83.812 19.232c.246-.005.493-.007.741-.007 21.256 0 38.487 17.231 38.487 38.487s-17.231 38.488-38.487 38.488c-14.29 0-26.76-7.788-33.4-19.35l23.635-6.333c11.731-3.143 18.693-15.2 15.55-26.932l-6.526-24.353Zm-10.428 1.638 6.815 25.432c1.642 6.131-1.996 12.434-8.128 14.076l-24.867 6.664a38.57 38.57 0 0 1-1.139-9.33c0-17.372 11.51-32.056 27.32-36.842Z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
<Defs>
|
||||
<LinearGradient
|
||||
id="a"
|
||||
x1={76.715}
|
||||
x2={76.715}
|
||||
y1={0.937}
|
||||
y2={132.5}
|
||||
gradientUnits="userSpaceOnUse">
|
||||
<Stop stopColor="#0A7AFF" />
|
||||
<Stop offset={1} stopColor="#59B9FF" />
|
||||
</LinearGradient>
|
||||
</Defs>
|
||||
</Svg>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -40,6 +40,13 @@ export type CommonNavigatorParams = {
|
||||
Hashtag: {tag: string; author?: string}
|
||||
MessagesConversation: {conversation: string; embed?: string}
|
||||
MessagesSettings: undefined
|
||||
StarterPack: {id: string}
|
||||
StarterPackLanding: {id: string}
|
||||
StarterPackWizard: {
|
||||
mode: 'Create' | 'Edit'
|
||||
id?: string
|
||||
initialStep?: 'Details' | 'Profiles' | 'Feeds'
|
||||
}
|
||||
}
|
||||
|
||||
export type BottomTabNavigatorParams = CommonNavigatorParams & {
|
||||
@@ -97,6 +104,12 @@ export type AllNavigatorParams = CommonNavigatorParams & {
|
||||
Hashtag: {tag: string; author?: string}
|
||||
MessagesTab: undefined
|
||||
Messages: {animation?: 'push' | 'pop'}
|
||||
StarterPackLanding: {id: string}
|
||||
StarterPackWizard: {
|
||||
mode: 'Create' | 'Edit'
|
||||
id?: string
|
||||
initialStep?: 'Details' | 'Profiles' | 'Feeds'
|
||||
}
|
||||
}
|
||||
|
||||
// NOTE
|
||||
|
||||
@@ -3,3 +3,4 @@ export type Gate =
|
||||
| 'request_notifications_permission_after_onboarding_v2'
|
||||
| 'show_avi_follow_button'
|
||||
| 'show_follow_back_label_v2'
|
||||
| 'starter_packs_enabled'
|
||||
|
||||
@@ -40,4 +40,7 @@ export const router = new Router({
|
||||
Messages: '/messages',
|
||||
MessagesSettings: '/messages/settings',
|
||||
MessagesConversation: '/messages/:conversation',
|
||||
StarterPack: '/starter-pack/:id',
|
||||
StarterPackLanding: '/start/:id',
|
||||
StarterPackWizard: '/starter-pack/create',
|
||||
})
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
import React from 'react'
|
||||
import {ScrollView, View} from 'react-native'
|
||||
import {LinearGradient} from 'expo-linear-gradient'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {NativeStackScreenProps} from '@react-navigation/native-stack'
|
||||
|
||||
import {CommonNavigatorParams} from 'lib/routes/types'
|
||||
import {useGate} from 'lib/statsig/statsig'
|
||||
import {useSetMinimalShellMode} from 'state/shell'
|
||||
import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard'
|
||||
import {UserAvatar} from 'view/com/util/UserAvatar'
|
||||
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 {Divider} from '#/components/Divider'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
/**
|
||||
* TEMPORARY CONTENT, DO NOT TRANSLATE
|
||||
*/
|
||||
/**
|
||||
* TEMPORARY CONTENT, DO NOT TRANSLATE
|
||||
*/
|
||||
/**
|
||||
* TEMPORARY CONTENT, DO NOT TRANSLATE
|
||||
*/
|
||||
|
||||
const PLACEHOLDER_USERS = [
|
||||
{
|
||||
displayName: 'Bossett',
|
||||
avatar:
|
||||
'https://cdn.bsky.app/img/avatar/plain/did:plc:jfhpnnst6flqway4eaeqzj2a/bafkreid42e2su4sju7hl2nm4ouacw3icvvytf7r6gab3pvc2qxhqhc5ji4@jpeg',
|
||||
},
|
||||
{
|
||||
displayName: 'hailey',
|
||||
avatar:
|
||||
'https://cdn.bsky.app/img/avatar/plain/did:plc:oisofpd7lj26yvgiivf3lxsi/bafkreia4lpswywb5yx3gsezqb5io73kt7icw634ks4m4mmbfvp7e62cxku@jpeg',
|
||||
},
|
||||
{
|
||||
displayName: 'samuel',
|
||||
avatar:
|
||||
'https://cdn.bsky.app/img/avatar/plain/did:plc:p2cp5gopk7mgjegy6wadk3ep/bafkreias5vktko4jpg4mwp4audptcbdfkb4o3cfyzt4426mj2h7ewwvg7q@jpeg',
|
||||
},
|
||||
|
||||
{
|
||||
displayName: 'Paul “Frazee” 🦋',
|
||||
avatar:
|
||||
'https://cdn.bsky.app/img/avatar/plain/did:plc:ragtjsm2j2vknwkz3zp4oxrd/bafkreihhpqdyntku66himwor5wlhtdo44hllmngj2ofmbqnm25bdm454wq@jpeg',
|
||||
},
|
||||
{
|
||||
displayName: 'Jay',
|
||||
avatar:
|
||||
'https://cdn.bsky.app/img/avatar/plain/did:plc:oky5czdrnfjpqslsw2a5iclo/bafkreihidru2xruxdxlvvcixc7lbgoudzicjbrdgacdhdhxyfw4yut4nfq@jpeg',
|
||||
},
|
||||
{
|
||||
displayName: 'Emily 🦋',
|
||||
avatar:
|
||||
'https://cdn.bsky.app/img/avatar/plain/did:plc:vjug55kidv6sye7ykr5faxxn/bafkreia63347vwjt4sgfkedgvytodt22ugfck5o36qotwaflsf3qq5xj3y@jpeg',
|
||||
},
|
||||
{
|
||||
displayName: 'Rose 🌹',
|
||||
avatar:
|
||||
'https://cdn.bsky.app/img/avatar/plain/did:plc:qjeavhlw222ppsre4rscd3n2/bafkreiehdi7v7bmz64anqam72ybtkkeodfe22sh7dcsh7m6wei2cf5liye@jpeg',
|
||||
},
|
||||
{
|
||||
displayName: 'dan',
|
||||
avatar:
|
||||
'https://cdn.bsky.app/img/avatar/plain/did:plc:fpruhuo22xkm5o7ttr2ktxdo/bafkreif65cz6sp4cvwue6dnsy7lhy7o7vdelknwd6hermcbzg2npealz5q@jpeg',
|
||||
},
|
||||
]
|
||||
|
||||
export function LandingScreen({
|
||||
navigation,
|
||||
}: NativeStackScreenProps<CommonNavigatorParams, 'StarterPackLanding'>) {
|
||||
const {_} = useLingui()
|
||||
const gate = useGate()
|
||||
const t = useTheme()
|
||||
const setMinimalShellMode = useSetMinimalShellMode()
|
||||
const gradient =
|
||||
t.name === 'light'
|
||||
? [t.palette.primary_500, t.palette.primary_300]
|
||||
: [t.palette.primary_600, t.palette.primary_400]
|
||||
|
||||
const userSets = React.useMemo(() => {
|
||||
return {
|
||||
first: PLACEHOLDER_USERS.slice(0, 4),
|
||||
second: PLACEHOLDER_USERS.slice(4, 8),
|
||||
}
|
||||
}, [])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!gate('starter_packs_enabled')) {
|
||||
// @ts-expect-error idk
|
||||
navigation.replace('Home')
|
||||
return
|
||||
}
|
||||
|
||||
setMinimalShellMode(true)
|
||||
|
||||
return () => {
|
||||
setMinimalShellMode(false)
|
||||
}
|
||||
}, [gate, navigation, setMinimalShellMode])
|
||||
|
||||
return (
|
||||
<CenteredView style={a.flex_1}>
|
||||
<ScrollView
|
||||
style={[a.flex_1]}
|
||||
contentContainerStyle={{paddingBottom: 100}}>
|
||||
<LinearGradient
|
||||
colors={gradient}
|
||||
style={[a.align_center, a.gap_sm, a.py_2xl]}>
|
||||
<View style={[a.flex_row, a.gap_md, a.pb_sm]}>
|
||||
<Logo width={76} fill="white" />
|
||||
</View>
|
||||
<View style={[a.align_center, a.gap_xs]}>
|
||||
<Text style={[a.font_bold, a.text_5xl, {color: 'white'}]}>
|
||||
Science
|
||||
</Text>
|
||||
<Text style={[a.font_bold, a.text_md, {color: 'white'}]}>
|
||||
Starter pack by Bossett
|
||||
</Text>
|
||||
</View>
|
||||
</LinearGradient>
|
||||
<View style={[a.gap_md, a.mt_lg, a.mx_lg]}>
|
||||
<Text
|
||||
style={[a.text_md, a.text_center, t.atoms.text_contrast_medium]}>
|
||||
186 joined this week
|
||||
</Text>
|
||||
<Button
|
||||
label={_(msg`Join Bluesky now`)}
|
||||
onPress={() => {}}
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="large">
|
||||
<ButtonText style={[a.text_lg]}>
|
||||
<Trans>Join Bluesky now</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
<View style={[a.gap_xl, a.mt_md]}>
|
||||
<Text style={[a.text_md, t.atoms.text_contrast_medium]}>
|
||||
(This is the description) A collection of feeds and users to get
|
||||
you started with the science community on Bluesky!
|
||||
</Text>
|
||||
<Divider />
|
||||
<Text style={[a.text_md, t.atoms.text_contrast_medium]}>
|
||||
Join Bluesky now to subscribe to these feeds:
|
||||
</Text>
|
||||
<View
|
||||
style={[
|
||||
t.atoms.bg_contrast_25,
|
||||
a.rounded_sm,
|
||||
{pointerEvents: 'none'},
|
||||
]}>
|
||||
<FeedSourceCard
|
||||
feedUri="at://did:plc:jfhpnnst6flqway4eaeqzj2a/app.bsky.feed.generator/for-science"
|
||||
hideTopBorder={true}
|
||||
/>
|
||||
<FeedSourceCard feedUri="at://did:plc:upmfcx5muayjhkg5sltj625o/app.bsky.feed.generator/aaachrckxlsh2" />
|
||||
</View>
|
||||
<Text style={[a.mt_sm, a.text_md, t.atoms.text_contrast_medium]}>
|
||||
You'll also follow these people and many others!
|
||||
</Text>
|
||||
<View
|
||||
style={[
|
||||
t.atoms.bg_contrast_25,
|
||||
a.rounded_sm,
|
||||
a.px_xs,
|
||||
a.py_md,
|
||||
a.gap_xl,
|
||||
]}>
|
||||
<UserSet users={userSets.first} />
|
||||
<UserSet users={userSets.second} />
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</CenteredView>
|
||||
)
|
||||
}
|
||||
|
||||
function User({displayName, avatar}: {displayName: string; avatar: string}) {
|
||||
return (
|
||||
<View style={[a.flex_1, a.align_center, a.gap_sm]}>
|
||||
<UserAvatar size={64} avatar={avatar} />
|
||||
<Text style={[a.flex_1, a.text_sm, a.font_bold]} numberOfLines={1}>
|
||||
{displayName}
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function UserSet({users}: {users: {displayName: string; avatar: string}[]}) {
|
||||
return (
|
||||
<View style={[a.flex_row, a.gap_xs, a.align_center, a.justify_between]}>
|
||||
<User displayName={users[0].displayName} avatar={users[0].avatar} />
|
||||
<User displayName={users[1].displayName} avatar={users[1].avatar} />
|
||||
<User displayName={users[2].displayName} avatar={users[2].avatar} />
|
||||
<User displayName={users[3].displayName} avatar={users[3].avatar} />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import React, {useCallback} from 'react'
|
||||
import {View} from 'react-native'
|
||||
|
||||
import {useBottomBarOffset} from 'lib/hooks/useBottomBarOffset'
|
||||
import {isNative} from 'platform/detection'
|
||||
import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard'
|
||||
import {List, ListRef} from 'view/com/util/List'
|
||||
import {SectionRef} from '#/screens/Profile/Sections/types'
|
||||
|
||||
function renderItem({item}: {item: string}) {
|
||||
return <FeedSourceCard feedUri={item} />
|
||||
}
|
||||
|
||||
function keyExtractor(item: string) {
|
||||
return item
|
||||
}
|
||||
|
||||
interface ProfilesListProps {
|
||||
feeds: string[]
|
||||
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 onScrollToTop = useCallback(() => {
|
||||
scrollElRef.current?.scrollToOffset({
|
||||
animated: isNative,
|
||||
offset: -headerHeight,
|
||||
})
|
||||
}, [scrollElRef, headerHeight])
|
||||
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
scrollToTop: onScrollToTop,
|
||||
}))
|
||||
|
||||
return (
|
||||
<List
|
||||
data={feeds}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={keyExtractor}
|
||||
ref={scrollElRef}
|
||||
headerOffset={headerHeight}
|
||||
ListFooterComponent={
|
||||
<View style={[{height: initialHeaderHeight + bottomBarOffset}]} />
|
||||
}
|
||||
showsVerticalScrollIndicator={false}
|
||||
/>
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,55 @@
|
||||
import React, {useCallback} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {AppBskyActorDefs} from '@atproto/api'
|
||||
|
||||
import {useBottomBarOffset} from 'lib/hooks/useBottomBarOffset'
|
||||
import {isNative} from 'platform/detection'
|
||||
import {ProfileCardWithFollowBtn} from 'view/com/profile/ProfileCard'
|
||||
import {List, ListRef} from 'view/com/util/List'
|
||||
import {SectionRef} from '#/screens/Profile/Sections/types'
|
||||
|
||||
function renderItem({item}: {item: AppBskyActorDefs.ProfileViewBasic}) {
|
||||
return <ProfileCardWithFollowBtn profile={item} />
|
||||
}
|
||||
|
||||
function keyExtractor(item: AppBskyActorDefs.ProfileViewBasic, index: number) {
|
||||
return `${item.did}-${index}`
|
||||
}
|
||||
|
||||
interface ProfilesListProps {
|
||||
profiles: AppBskyActorDefs.ProfileViewBasic[]
|
||||
headerHeight: number
|
||||
scrollElRef: ListRef
|
||||
}
|
||||
|
||||
export const ProfilesList = React.forwardRef<SectionRef, ProfilesListProps>(
|
||||
function ProfilesListImpl({profiles, headerHeight, scrollElRef}, ref) {
|
||||
const [initialHeaderHeight] = React.useState(headerHeight)
|
||||
const bottomBarOffset = useBottomBarOffset(20)
|
||||
|
||||
const onScrollToTop = useCallback(() => {
|
||||
scrollElRef.current?.scrollToOffset({
|
||||
animated: isNative,
|
||||
offset: -headerHeight,
|
||||
})
|
||||
}, [scrollElRef, headerHeight])
|
||||
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
scrollToTop: onScrollToTop,
|
||||
}))
|
||||
|
||||
return (
|
||||
<List
|
||||
data={profiles}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={keyExtractor}
|
||||
ref={scrollElRef}
|
||||
headerOffset={headerHeight}
|
||||
ListFooterComponent={
|
||||
<View style={[{height: initialHeaderHeight + bottomBarOffset}]} />
|
||||
}
|
||||
showsVerticalScrollIndicator={false}
|
||||
/>
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,219 @@
|
||||
import React from 'react'
|
||||
import {Text, View} from 'react-native'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {NativeStackScreenProps} from '@react-navigation/native-stack'
|
||||
|
||||
import {CommonNavigatorParams} from 'lib/routes/types'
|
||||
import {isWeb} from 'platform/detection'
|
||||
import {PagerWithHeader} from 'view/com/pager/PagerWithHeader'
|
||||
import {ProfileSubpageHeader} from 'view/com/profile/ProfileSubpageHeader'
|
||||
import {EventStopper} from 'view/com/util/EventStopper'
|
||||
import {CenteredView} from 'view/com/util/Views'
|
||||
import {FeedsList} from '#/screens/StarterPack/Main/FeedsList'
|
||||
import {ProfilesList} from '#/screens/StarterPack/Main/ProfilesList'
|
||||
import {atoms as a} from '#/alf'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import {ArrowOutOfBox_Stroke2_Corner0_Rounded as ArrowOutOfBox} from '#/components/icons/ArrowOutOfBox'
|
||||
import {QrCode_Stroke2_Corner0_Rounded as QrCode} from '#/components/icons/QrCode'
|
||||
import * as Menu from '#/components/Menu'
|
||||
|
||||
/**
|
||||
* TEMPORARY CONTENT, DO NOT TRANSLATE
|
||||
*/
|
||||
/**
|
||||
* TEMPORARY CONTENT, DO NOT TRANSLATE
|
||||
*/
|
||||
/**
|
||||
* TEMPORARY CONTENT, DO NOT TRANSLATE
|
||||
*/
|
||||
|
||||
const PLACEHOLDER_USERS = [
|
||||
{
|
||||
did: 'did:plc:qjeavhlw222ppsre4rscd3n2',
|
||||
handle: 'test.handle',
|
||||
displayName: 'Bossett',
|
||||
avatar:
|
||||
'https://cdn.bsky.app/img/avatar/plain/did:plc:jfhpnnst6flqway4eaeqzj2a/bafkreid42e2su4sju7hl2nm4ouacw3icvvytf7r6gab3pvc2qxhqhc5ji4@jpeg',
|
||||
},
|
||||
{
|
||||
did: 'did:plc:qjeavhlw222ppsre4rscd3n2',
|
||||
handle: 'test.handle',
|
||||
displayName: 'hailey',
|
||||
avatar:
|
||||
'https://cdn.bsky.app/img/avatar/plain/did:plc:oisofpd7lj26yvgiivf3lxsi/bafkreia4lpswywb5yx3gsezqb5io73kt7icw634ks4m4mmbfvp7e62cxku@jpeg',
|
||||
},
|
||||
{
|
||||
did: 'did:plc:qjeavhlw222ppsre4rscd3n2',
|
||||
handle: 'test.handle',
|
||||
displayName: 'samuel',
|
||||
avatar:
|
||||
'https://cdn.bsky.app/img/avatar/plain/did:plc:p2cp5gopk7mgjegy6wadk3ep/bafkreias5vktko4jpg4mwp4audptcbdfkb4o3cfyzt4426mj2h7ewwvg7q@jpeg',
|
||||
},
|
||||
|
||||
{
|
||||
did: 'did:plc:qjeavhlw222ppsre4rscd3n2',
|
||||
handle: 'test.handle',
|
||||
displayName: 'Paul “Frazee” 🦋',
|
||||
avatar:
|
||||
'https://cdn.bsky.app/img/avatar/plain/did:plc:ragtjsm2j2vknwkz3zp4oxrd/bafkreihhpqdyntku66himwor5wlhtdo44hllmngj2ofmbqnm25bdm454wq@jpeg',
|
||||
},
|
||||
{
|
||||
did: 'did:plc:qjeavhlw222ppsre4rscd3n2',
|
||||
handle: 'test.handle',
|
||||
displayName: 'Jay',
|
||||
avatar:
|
||||
'https://cdn.bsky.app/img/avatar/plain/did:plc:oky5czdrnfjpqslsw2a5iclo/bafkreihidru2xruxdxlvvcixc7lbgoudzicjbrdgacdhdhxyfw4yut4nfq@jpeg',
|
||||
},
|
||||
{
|
||||
did: 'did:plc:qjeavhlw222ppsre4rscd3n2',
|
||||
handle: 'test.handle',
|
||||
displayName: 'Emily 🦋',
|
||||
avatar:
|
||||
'https://cdn.bsky.app/img/avatar/plain/did:plc:vjug55kidv6sye7ykr5faxxn/bafkreia63347vwjt4sgfkedgvytodt22ugfck5o36qotwaflsf3qq5xj3y@jpeg',
|
||||
},
|
||||
{
|
||||
did: 'did:plc:qjeavhlw222ppsre4rscd3n2',
|
||||
handle: 'test.handle',
|
||||
displayName: 'Rose 🌹',
|
||||
avatar:
|
||||
'https://cdn.bsky.app/img/avatar/plain/did:plc:qjeavhlw222ppsre4rscd3n2/bafkreiehdi7v7bmz64anqam72ybtkkeodfe22sh7dcsh7m6wei2cf5liye@jpeg',
|
||||
},
|
||||
{
|
||||
did: 'did:plc:qjeavhlw222ppsre4rscd3n2',
|
||||
handle: 'test.handle',
|
||||
displayName: 'dan',
|
||||
avatar:
|
||||
'https://cdn.bsky.app/img/avatar/plain/did:plc:fpruhuo22xkm5o7ttr2ktxdo/bafkreif65cz6sp4cvwue6dnsy7lhy7o7vdelknwd6hermcbzg2npealz5q@jpeg',
|
||||
},
|
||||
]
|
||||
|
||||
const PLACEHOLDER_FEEDS = [
|
||||
'at://did:plc:jfhpnnst6flqway4eaeqzj2a/app.bsky.feed.generator/for-science',
|
||||
'at://did:plc:upmfcx5muayjhkg5sltj625o/app.bsky.feed.generator/aaachrckxlsh2',
|
||||
]
|
||||
|
||||
export function StarterPackScreen({}: NativeStackScreenProps<
|
||||
CommonNavigatorParams,
|
||||
'StarterPackLanding'
|
||||
>) {
|
||||
// const {id} = route.params
|
||||
|
||||
return (
|
||||
<CenteredView style={[a.h_full_vh]}>
|
||||
<StarterPackScreenInner />
|
||||
</CenteredView>
|
||||
)
|
||||
}
|
||||
|
||||
function StarterPackScreenInner() {
|
||||
const isOwn = true
|
||||
|
||||
return (
|
||||
<View style={isWeb ? {minHeight: '100%'} : {height: '100%'}}>
|
||||
<PagerWithHeader
|
||||
items={['Profiles', 'Feeds']}
|
||||
isHeaderReady={true}
|
||||
renderHeader={() => <Header isOwn={isOwn} />}>
|
||||
{({headerHeight, scrollElRef}) => (
|
||||
<ProfilesList
|
||||
key={0}
|
||||
profiles={PLACEHOLDER_USERS}
|
||||
headerHeight={headerHeight}
|
||||
// @ts-expect-error
|
||||
scrollElRef={scrollElRef}
|
||||
/>
|
||||
)}
|
||||
{({headerHeight, scrollElRef}) => (
|
||||
<FeedsList
|
||||
key={1}
|
||||
feeds={PLACEHOLDER_FEEDS}
|
||||
headerHeight={headerHeight}
|
||||
// @ts-expect-error
|
||||
scrollElRef={scrollElRef}
|
||||
/>
|
||||
)}
|
||||
</PagerWithHeader>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function Header({isOwn}: {isOwn: boolean}) {
|
||||
const {_} = useLingui()
|
||||
|
||||
return (
|
||||
<>
|
||||
<ProfileSubpageHeader
|
||||
isLoading={false}
|
||||
href=""
|
||||
title="Science"
|
||||
isOwner={false}
|
||||
avatar={undefined}
|
||||
creator={undefined}
|
||||
avatarType="starter-pack">
|
||||
<View style={[a.flex_row, a.gap_sm]}>
|
||||
<EventStopper onKeyDown={false}>
|
||||
<Menu.Root>
|
||||
<Menu.Trigger label={_(msg`Repost or quote post`)}>
|
||||
{({props}) => {
|
||||
return (
|
||||
<Button
|
||||
label={_(msg`Share`)}
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="small"
|
||||
{...props}>
|
||||
<ButtonText>
|
||||
<Trans>Share</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
)
|
||||
}}
|
||||
</Menu.Trigger>
|
||||
<Menu.Outer style={{minWidth: 170}}>
|
||||
<Menu.Group>
|
||||
<Menu.Item
|
||||
label={_(msg`Share link`)}
|
||||
testID="shareStarterPackLinkBtn"
|
||||
onPress={() => {}}>
|
||||
<Menu.ItemText>
|
||||
<Trans>Share link</Trans>
|
||||
</Menu.ItemText>
|
||||
<Menu.ItemIcon icon={ArrowOutOfBox} position="right" />
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
label={_(msg`Create QR code`)}
|
||||
testID="createQRCodeBtn"
|
||||
onPress={() => {}}>
|
||||
<Menu.ItemText>
|
||||
<Trans>Create QR code</Trans>
|
||||
</Menu.ItemText>
|
||||
<Menu.ItemIcon icon={QrCode} position="right" />
|
||||
</Menu.Item>
|
||||
</Menu.Group>
|
||||
</Menu.Outer>
|
||||
</Menu.Root>
|
||||
</EventStopper>
|
||||
{isOwn && (
|
||||
<Button
|
||||
label={_(msg`Edit`)}
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="small"
|
||||
onPress={() => {}}>
|
||||
<ButtonText>
|
||||
<Trans>Edit</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
)}
|
||||
</View>
|
||||
</ProfileSubpageHeader>
|
||||
<View style={[a.px_md, a.py_lg]}>
|
||||
<Text style={[a.text_md]}>
|
||||
(This is the description) A collection of feeds and users to get you
|
||||
started with the science community on Bluesky!
|
||||
</Text>
|
||||
</View>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import React from 'react'
|
||||
import {AppBskyActorDefs} from '@atproto/api'
|
||||
|
||||
const steps = ['Landing', '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'; uri: string}
|
||||
| {type: 'RemoveFeed'; uri: string}
|
||||
| {type: 'SetProcessing'; processing: boolean}
|
||||
|
||||
interface State {
|
||||
canNext: boolean
|
||||
currentStep: Step
|
||||
name?: string
|
||||
description?: string
|
||||
avatar?: string
|
||||
profiles: AppBskyActorDefs.ProfileViewBasic[]
|
||||
feedUris: string[]
|
||||
processing: boolean
|
||||
}
|
||||
|
||||
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
|
||||
if (action.type === 'Next' && state.currentStep !== 'Feeds') {
|
||||
const currentIndex = steps.indexOf(state.currentStep)
|
||||
updatedState = {...state, currentStep: steps[currentIndex + 1]}
|
||||
} else if (action.type === 'Back' && state.currentStep !== 'Landing') {
|
||||
const currentIndex = steps.indexOf(state.currentStep)
|
||||
updatedState = {...state, currentStep: steps[currentIndex - 1]}
|
||||
}
|
||||
|
||||
switch (action.type) {
|
||||
case 'SetName':
|
||||
updatedState = {...state, name: action.name}
|
||||
break
|
||||
case 'SetDescription':
|
||||
updatedState = {...state, description: action.description}
|
||||
break
|
||||
case 'AddProfile':
|
||||
updatedState = {...state, profiles: [...state.profiles, action.profile]}
|
||||
break
|
||||
case 'RemoveProfile':
|
||||
updatedState = {
|
||||
...state,
|
||||
profiles: state.profiles.filter(
|
||||
profile => profile.did !== action.profileDid,
|
||||
),
|
||||
}
|
||||
break
|
||||
case 'AddFeed':
|
||||
updatedState = {...state, feedUris: [...state.feedUris, action.uri]}
|
||||
break
|
||||
case 'RemoveFeed':
|
||||
updatedState = {
|
||||
...state,
|
||||
feedUris: state.feedUris.filter(uri => uri !== action.uri),
|
||||
}
|
||||
break
|
||||
case 'SetProcessing':
|
||||
updatedState = {...state, processing: action.processing}
|
||||
break
|
||||
}
|
||||
|
||||
switch (updatedState.currentStep) {
|
||||
case 'Landing':
|
||||
updatedState = {
|
||||
...updatedState,
|
||||
canNext: true,
|
||||
}
|
||||
break
|
||||
case 'Details':
|
||||
updatedState = {
|
||||
...updatedState,
|
||||
canNext: Boolean(updatedState.description),
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
return updatedState
|
||||
}
|
||||
|
||||
// TODO supply the initial state to this component
|
||||
export function Provider({
|
||||
initialState,
|
||||
initialStep = 'Landing',
|
||||
children,
|
||||
}: {
|
||||
initialState?: any // TODO update this type
|
||||
initialStep?: Step
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const stateAndReducer = React.useReducer(
|
||||
reducer,
|
||||
initialState
|
||||
? {
|
||||
...initialState,
|
||||
step: initialStep,
|
||||
}
|
||||
: {
|
||||
canNext: true,
|
||||
currentStep: initialStep,
|
||||
profiles: [],
|
||||
feedUris: [],
|
||||
processing: false,
|
||||
},
|
||||
)
|
||||
|
||||
return (
|
||||
<StateContext.Provider value={stateAndReducer}>
|
||||
{children}
|
||||
</StateContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
type Action as WizardAction,
|
||||
type State as WizardState,
|
||||
type Step as WizardStep,
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {msg} 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} from '#/alf'
|
||||
import * as TextField from '#/components/forms/TextField'
|
||||
import {StarterPackIcon} from '#/components/icons/StarterPackIcon'
|
||||
|
||||
export function StepDetails() {
|
||||
const {_} = useLingui()
|
||||
const [state, dispatch] = useWizardState()
|
||||
|
||||
const {currentAccount} = useSession()
|
||||
const {data: currentProfile} = useProfileQuery({
|
||||
did: currentAccount?.did,
|
||||
staleTime: 300,
|
||||
})
|
||||
|
||||
return (
|
||||
<View style={[a.px_xl, a.gap_xl, a.mt_md]}>
|
||||
<View style={[{height: 65, marginTop: 20, marginBottom: 50}]}>
|
||||
<StarterPackIcon />
|
||||
</View>
|
||||
<View>
|
||||
<TextField.LabelText>{_(msg`Starter pack name`)}</TextField.LabelText>
|
||||
<TextField.Input
|
||||
label={_(
|
||||
msg`${
|
||||
currentProfile?.displayName || currentProfile?.handle
|
||||
}'s starter pack`,
|
||||
)}
|
||||
value={state.name}
|
||||
onChangeText={text => dispatch({type: 'SetName', name: text})}
|
||||
/>
|
||||
</View>
|
||||
<View>
|
||||
<TextField.LabelText>{_(msg`Description`)}</TextField.LabelText>
|
||||
<TextField.Root>
|
||||
<TextField.Input
|
||||
label={_(
|
||||
msg`Write a short description of your starter pack. What can new users expect?`,
|
||||
)}
|
||||
value={state.description}
|
||||
onChangeText={text =>
|
||||
dispatch({type: 'SetDescription', description: text})
|
||||
}
|
||||
multiline
|
||||
style={{minHeight: 150}}
|
||||
/>
|
||||
</TextField.Root>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import React from 'react'
|
||||
import {ListRenderItemInfo, View} from 'react-native'
|
||||
import {GeneratorView} from '@atproto/api/dist/client/types/app/bsky/feed/defs'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useProfileFeedgensQuery} from 'state/queries/profile-feedgens'
|
||||
import {useSession} from 'state/session'
|
||||
import {List} from 'view/com/util/List'
|
||||
import {UserAvatar} from 'view/com/util/UserAvatar'
|
||||
import {useWizardState} from '#/screens/StarterPack/Wizard/State'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
function renderItem({item}: ListRenderItemInfo<GeneratorView>) {
|
||||
return <FeedCard generator={item} />
|
||||
}
|
||||
|
||||
function keyExtractor(item: GeneratorView) {
|
||||
return item.uri
|
||||
}
|
||||
|
||||
export function StepFeeds() {
|
||||
const {currentAccount} = useSession()
|
||||
const {data} = useProfileFeedgensQuery(currentAccount!.did)
|
||||
const feeds = data?.pages.flatMap(page => page.feeds) || []
|
||||
|
||||
return (
|
||||
<List
|
||||
data={feeds}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={keyExtractor}
|
||||
style={[a.flex_1]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FeedCard({generator}: {generator: GeneratorView}) {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const [state, dispatch] = useWizardState()
|
||||
|
||||
const includesFeed = state.feedUris.includes(generator.uri)
|
||||
const onAdd = () => {
|
||||
if (includesFeed) {
|
||||
dispatch({type: 'RemoveFeed', uri: generator.uri})
|
||||
} else {
|
||||
dispatch({type: 'AddFeed', uri: generator.uri})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.px_md,
|
||||
a.py_sm,
|
||||
a.border_b,
|
||||
a.gap_md,
|
||||
t.atoms.border_contrast_low,
|
||||
]}>
|
||||
<UserAvatar type="algo" size={45} avatar={generator.avatar} />
|
||||
<View style={[a.flex_1]}>
|
||||
<Text style={[a.flex_1, a.font_bold, a.text_md]} numberOfLines={1}>
|
||||
{generator.displayName}
|
||||
</Text>
|
||||
<Text
|
||||
style={[a.flex_1, t.atoms.text_contrast_medium]}
|
||||
numberOfLines={1}>
|
||||
{_(msg`Feed by @${generator.creator.handle}`)}
|
||||
</Text>
|
||||
</View>
|
||||
<Button
|
||||
label={includesFeed ? _(msg`Remove`) : _(msg`Add`)}
|
||||
variant="solid"
|
||||
color={includesFeed ? 'secondary' : 'primary'}
|
||||
size="small"
|
||||
style={{paddingVertical: 6}}
|
||||
onPress={onAdd}>
|
||||
<ButtonText>
|
||||
{includesFeed ? <Trans>Remove</Trans> : <Trans>Add</Trans>}
|
||||
</ButtonText>
|
||||
</Button>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {Trans} from '@lingui/macro'
|
||||
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {StarterPackIcon} from '#/components/icons/StarterPackIcon'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export function StepLanding() {
|
||||
const t = useTheme()
|
||||
|
||||
return (
|
||||
<View style={[a.flex_1, a.justify_center, {marginTop: 100}]}>
|
||||
<View style={[{height: 150, marginBottom: 50}]}>
|
||||
<StarterPackIcon />
|
||||
</View>
|
||||
<View style={[a.gap_lg, a.align_center, a.px_md]}>
|
||||
<Text style={[a.font_bold, a.text_lg, t.atoms.text_contrast_medium]}>
|
||||
<Trans>Starter packs</Trans>
|
||||
</Text>
|
||||
<Text style={[a.font_bold, a.text_4xl]}>
|
||||
<Trans>Invites, but personal</Trans>
|
||||
</Text>
|
||||
<Text style={[a.text_center, a.text_md, a.px_md]}>
|
||||
<Trans>
|
||||
Create your own Bluesky starter packs and invite people directly to
|
||||
your favorite feeds, profiles, and more.
|
||||
</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import React, {useLayoutEffect, useRef, useState} from 'react'
|
||||
import type {ListRenderItemInfo, TextInput as RNTextInput} from 'react-native'
|
||||
import {View} from 'react-native'
|
||||
import {AppBskyActorDefs} from '@atproto/api'
|
||||
import {BottomSheetFlatListMethods} from '@discord/bottom-sheet'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {isWeb} from '#/platform/detection'
|
||||
import {useActorAutocompleteQuery} from 'state/queries/actor-autocomplete'
|
||||
import {useProfileFollowsQuery} from 'state/queries/profile-follows'
|
||||
import {useSession} from 'state/session'
|
||||
import {WizardAction, WizardState} from '#/screens/StarterPack/Wizard/State'
|
||||
import {WizardProfileCard} from '#/screens/StarterPack/Wizard/StepProfiles/WizardProfileCard'
|
||||
import {atoms as a, native, useTheme, web} from '#/alf'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {TextInput} from '#/components/dms/dialogs/TextInput'
|
||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||
import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
function keyExtractor(item: AppBskyActorDefs.ProfileViewBasic) {
|
||||
return item.did
|
||||
}
|
||||
|
||||
export function WizardAddProfilesDialog({
|
||||
control,
|
||||
state,
|
||||
dispatch,
|
||||
}: {
|
||||
control: Dialog.DialogControlProps
|
||||
state: WizardState
|
||||
dispatch: (action: WizardAction) => void
|
||||
}) {
|
||||
const [searchText, setSearchText] = useState('')
|
||||
|
||||
const {currentAccount} = useSession()
|
||||
const {data: results} = useActorAutocompleteQuery(searchText, true, 12)
|
||||
const {data: followsPages, fetchNextPage} = useProfileFollowsQuery(
|
||||
currentAccount?.did,
|
||||
)
|
||||
const follows = followsPages?.pages.flatMap(page => page.follows) || []
|
||||
|
||||
const listRef = useRef<BottomSheetFlatListMethods>(null)
|
||||
const inputRef = useRef<RNTextInput>(null)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (isWeb) {
|
||||
setImmediate(() => {
|
||||
inputRef?.current?.focus()
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
|
||||
const renderItem = ({
|
||||
item,
|
||||
}: ListRenderItemInfo<AppBskyActorDefs.ProfileViewBasic>) => {
|
||||
return (
|
||||
<WizardProfileCard profile={item} state={state} dispatch={dispatch} />
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog.Outer
|
||||
control={control}
|
||||
testID="newChatDialog"
|
||||
nativeOptions={{sheet: {snapPoints: ['100%']}}}>
|
||||
<Dialog.InnerFlatList
|
||||
ref={listRef}
|
||||
data={searchText.length > 0 ? results : follows}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={keyExtractor}
|
||||
ListHeaderComponent={
|
||||
<ListHeader
|
||||
searchText={searchText}
|
||||
setSearchText={setSearchText}
|
||||
inputRef={inputRef}
|
||||
/>
|
||||
}
|
||||
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"
|
||||
onEndReached={() => fetchNextPage()}
|
||||
onEndReachedThreshold={2}
|
||||
removeClippedSubviews={true}
|
||||
/>
|
||||
</Dialog.Outer>
|
||||
)
|
||||
}
|
||||
|
||||
function ListHeader({
|
||||
searchText,
|
||||
setSearchText,
|
||||
inputRef,
|
||||
}: {
|
||||
searchText: string
|
||||
setSearchText: (text: string) => void
|
||||
inputRef: React.Ref<RNTextInput>
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {
|
||||
state: hovered,
|
||||
onIn: onMouseEnter,
|
||||
onOut: onMouseLeave,
|
||||
} = useInteractionState()
|
||||
const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
|
||||
const interacted = hovered || focused
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.relative,
|
||||
a.pt_md,
|
||||
a.pb_xs,
|
||||
a.px_lg,
|
||||
a.border_b,
|
||||
t.atoms.border_contrast_low,
|
||||
t.atoms.bg,
|
||||
native([a.pt_lg]),
|
||||
]}>
|
||||
<View
|
||||
style={[
|
||||
a.relative,
|
||||
native(a.align_center),
|
||||
a.justify_center,
|
||||
{height: 32},
|
||||
]}>
|
||||
<Text
|
||||
style={[
|
||||
a.z_10,
|
||||
a.text_lg,
|
||||
a.font_bold,
|
||||
a.leading_tight,
|
||||
t.atoms.text_contrast_high,
|
||||
]}>
|
||||
<Trans>Select profiles to add</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={[native([a.pt_sm]), web([a.pt_xs])]}>
|
||||
<View
|
||||
{...web({
|
||||
onMouseEnter,
|
||||
onMouseLeave,
|
||||
})}
|
||||
style={[a.flex_row, a.align_center, a.gap_sm]}>
|
||||
<Search
|
||||
size="md"
|
||||
fill={interacted ? t.palette.primary_500 : t.palette.contrast_300}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
// @ts-ignore bottom sheet input types issue — esb
|
||||
ref={inputRef}
|
||||
placeholder={_(msg`Search`)}
|
||||
value={searchText}
|
||||
onChangeText={setSearchText}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
style={[a.flex_1, a.py_md, a.text_md, t.atoms.text]}
|
||||
placeholderTextColor={t.palette.contrast_500}
|
||||
keyboardAppearance={t.name === 'light' ? 'light' : 'dark'}
|
||||
returnKeyType="search"
|
||||
clearButtonMode="while-editing"
|
||||
maxLength={50}
|
||||
autoCorrect={false}
|
||||
autoComplete="off"
|
||||
autoCapitalize="none"
|
||||
autoFocus
|
||||
accessibilityLabel={_(msg`Search profiles`)}
|
||||
accessibilityHint={_(msg`Search profiles`)}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {AppBskyActorDefs} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {UserAvatar} from 'view/com/util/UserAvatar'
|
||||
import {WizardAction, WizardState} from '#/screens/StarterPack/Wizard/State'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export function WizardProfileCard({
|
||||
state,
|
||||
dispatch,
|
||||
profile,
|
||||
}: {
|
||||
state: WizardState
|
||||
dispatch: (action: WizardAction) => void
|
||||
profile: AppBskyActorDefs.ProfileViewBasic
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
|
||||
const includesProfile = state.profiles.some(p => p.did === profile.did)
|
||||
|
||||
const onPressAddRemove = () => {
|
||||
if (!profile?.did) return
|
||||
|
||||
if (!includesProfile) {
|
||||
dispatch({type: 'AddProfile', profile})
|
||||
} else {
|
||||
dispatch({type: 'RemoveProfile', profileDid: profile.did})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.px_md,
|
||||
a.py_sm,
|
||||
a.gap_md,
|
||||
a.border_b,
|
||||
t.atoms.border_contrast_low,
|
||||
]}>
|
||||
<UserAvatar size={45} avatar={profile?.avatar} />
|
||||
<View style={[a.flex_1]}>
|
||||
<Text style={[a.flex_1, a.font_bold, a.text_md]} numberOfLines={1}>
|
||||
{profile?.displayName || profile?.handle}
|
||||
</Text>
|
||||
<Text
|
||||
style={[a.flex_1, t.atoms.text_contrast_medium]}
|
||||
numberOfLines={1}>
|
||||
@{profile?.handle}
|
||||
</Text>
|
||||
</View>
|
||||
<Button
|
||||
label={includesProfile ? _(msg`Remove`) : _(msg`Add`)}
|
||||
variant="solid"
|
||||
color={includesProfile ? 'secondary' : 'primary'}
|
||||
size="small"
|
||||
style={{paddingVertical: 6}}
|
||||
onPress={onPressAddRemove}>
|
||||
<ButtonText>
|
||||
{includesProfile ? <Trans>Remove</Trans> : <Trans>Add</Trans>}
|
||||
</ButtonText>
|
||||
</Button>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import React from 'react'
|
||||
import {ListRenderItemInfo, View} from 'react-native'
|
||||
import {AppBskyActorDefs} from '@atproto/api'
|
||||
import {Trans} from '@lingui/macro'
|
||||
|
||||
import {List} from 'view/com/util/List'
|
||||
import {useWizardState} from '#/screens/StarterPack/Wizard/State'
|
||||
import {WizardProfileCard} from '#/screens/StarterPack/Wizard/StepProfiles/WizardProfileCard'
|
||||
import {atoms as a} from '#/alf'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
function keyExtractor(item: AppBskyActorDefs.ProfileViewBasic) {
|
||||
return item.did
|
||||
}
|
||||
|
||||
export function StepProfiles() {
|
||||
const [state, dispatch] = useWizardState()
|
||||
|
||||
const renderItem = ({
|
||||
item,
|
||||
}: ListRenderItemInfo<AppBskyActorDefs.ProfileViewBasic>) => {
|
||||
return (
|
||||
<WizardProfileCard profile={item} state={state} dispatch={dispatch} />
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<View style={[a.flex_1]}>
|
||||
{state.profiles.length > 0 ? (
|
||||
<List
|
||||
data={state.profiles}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={keyExtractor}
|
||||
/>
|
||||
) : (
|
||||
<ListEmpty />
|
||||
)}
|
||||
</View>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function ListEmpty() {
|
||||
return (
|
||||
<View style={[a.flex_1, a.px_md, a.align_center]}>
|
||||
<Text style={[a.font_bold, a.text_xl, a.text_center, {marginTop: 100}]}>
|
||||
<Trans>Add the people you recommend to your starter pack!</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
import React from 'react'
|
||||
import {Keyboard, View} from 'react-native'
|
||||
import {
|
||||
KeyboardAwareScrollView,
|
||||
KeyboardStickyView,
|
||||
} from 'react-native-keyboard-controller'
|
||||
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 {useBottomBarOffset} from 'lib/hooks/useBottomBarOffset'
|
||||
import {CommonNavigatorParams, NavigationProp} from 'lib/routes/types'
|
||||
import {useProfileQuery} from 'state/queries/profile'
|
||||
import {useSession} from 'state/session'
|
||||
import {ViewHeader} from 'view/com/util/ViewHeader'
|
||||
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 {StepLanding} from '#/screens/StarterPack/Wizard/StepLanding'
|
||||
import {StepProfiles} from '#/screens/StarterPack/Wizard/StepProfiles'
|
||||
import {WizardAddProfilesDialog} from '#/screens/StarterPack/Wizard/StepProfiles/WizardAddProfilesDialog'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import {useDialogControl} from '#/components/Dialog'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {Provider} from './State'
|
||||
|
||||
export function Wizard({
|
||||
route,
|
||||
}: NativeStackScreenProps<CommonNavigatorParams, 'StarterPackWizard'>) {
|
||||
const params = route.params
|
||||
const {mode, initialStep, id} = params ?? {}
|
||||
|
||||
// TODO load query here
|
||||
const starterPack = {}
|
||||
|
||||
// TODO use this to wait for loading the starterpack for editing
|
||||
if (mode === 'Edit' && false) {
|
||||
// Await here
|
||||
return
|
||||
}
|
||||
|
||||
return (
|
||||
<WizardReady
|
||||
mode={mode}
|
||||
id={id}
|
||||
initialStep={initialStep}
|
||||
starterPack={starterPack}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function WizardReady({
|
||||
mode,
|
||||
initialStep,
|
||||
starterPack,
|
||||
}: {
|
||||
mode: 'Create' | 'Edit'
|
||||
id?: string
|
||||
initialStep?: 'Details' | 'Profiles' | 'Feeds'
|
||||
starterPack?: any
|
||||
}) {
|
||||
return (
|
||||
<Provider
|
||||
initialState={mode === 'Edit' ? starterPack : undefined}
|
||||
initialStep={initialStep}>
|
||||
<WizardInner />
|
||||
</Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function WizardInner() {
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const bottomOffset = useBottomBarOffset()
|
||||
const [state, dispatch] = useWizardState()
|
||||
const {currentAccount} = useSession()
|
||||
const {data: currentProfile} = useProfileQuery({
|
||||
did: currentAccount?.did,
|
||||
staleTime: 0,
|
||||
})
|
||||
const bottomBarOffset = useBottomBarOffset()
|
||||
const addProfilesControl = useDialogControl()
|
||||
|
||||
React.useEffect(() => {
|
||||
navigation.setOptions({
|
||||
gestureEnabled: false,
|
||||
})
|
||||
}, [navigation])
|
||||
|
||||
const wizardUiStrings: Record<WizardStep, {header: string; button: string}> =
|
||||
{
|
||||
Landing: {
|
||||
header: _(msg`Create a starter pack`),
|
||||
button: _(msg`Create`),
|
||||
},
|
||||
Details: {
|
||||
header: _(msg`Details`),
|
||||
button: _(msg`Add profiles`),
|
||||
},
|
||||
Profiles: {
|
||||
header: _(msg`Add profiles`),
|
||||
button: _(msg`Add feeds`),
|
||||
},
|
||||
Feeds: {
|
||||
header: _(msg`Add feeds`),
|
||||
button: _(msg`Finish`),
|
||||
},
|
||||
}
|
||||
|
||||
const uiStrings = wizardUiStrings[state.currentStep]
|
||||
|
||||
const onNext = () => {
|
||||
if (state.currentStep === 'Details' && !state.name) {
|
||||
dispatch({
|
||||
type: 'SetName',
|
||||
name: _(
|
||||
msg`${currentProfile?.displayName || currentProfile?.handle}'s`,
|
||||
),
|
||||
})
|
||||
} else if (state.currentStep === 'Feeds') {
|
||||
dispatch({type: 'SetProcessing', processing: true})
|
||||
return
|
||||
}
|
||||
|
||||
const keyboardVisible = Keyboard.isVisible()
|
||||
Keyboard.dismiss()
|
||||
setTimeout(
|
||||
() => {
|
||||
dispatch({type: 'Next'})
|
||||
},
|
||||
keyboardVisible ? 16 : 0,
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<CenteredView
|
||||
style={[a.flex_1, {marginBottom: bottomOffset + 20}]}
|
||||
sideBorders>
|
||||
<ViewHeader
|
||||
title={uiStrings.header}
|
||||
onBackPress={
|
||||
state.currentStep !== 'Landing'
|
||||
? () => dispatch({type: 'Back'})
|
||||
: undefined
|
||||
}
|
||||
showBorder={true}
|
||||
showOnDesktop={true}
|
||||
renderButton={
|
||||
state.currentStep === 'Profiles'
|
||||
? () => (
|
||||
<Button
|
||||
label={_(msg`Cancel`)}
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="xsmall"
|
||||
onPress={addProfilesControl.open}
|
||||
style={{marginLeft: -15}}>
|
||||
<ButtonText>
|
||||
<Trans>Add</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<Container>
|
||||
<StepView />
|
||||
</Container>
|
||||
<KeyboardStickyView offset={{opened: bottomBarOffset}}>
|
||||
<View style={a.px_md}>
|
||||
<Button
|
||||
label={uiStrings.button}
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="large"
|
||||
onPress={onNext}
|
||||
disabled={!state.canNext || state.processing}>
|
||||
<ButtonText>{uiStrings.button}</ButtonText>
|
||||
{state.processing && (
|
||||
<Loader size="md" style={t.atoms.text_contrast_low} />
|
||||
)}
|
||||
</Button>
|
||||
</View>
|
||||
</KeyboardStickyView>
|
||||
|
||||
<WizardAddProfilesDialog
|
||||
control={addProfilesControl}
|
||||
state={state}
|
||||
dispatch={dispatch}
|
||||
/>
|
||||
</CenteredView>
|
||||
)
|
||||
}
|
||||
|
||||
function Container({children}: {children: React.ReactNode}) {
|
||||
const [state] = useWizardState()
|
||||
|
||||
if (state.currentStep === 'Profiles' || state.currentStep === 'Feeds') {
|
||||
return <View style={[a.flex_1]}>{children}</View>
|
||||
}
|
||||
|
||||
return (
|
||||
<KeyboardAwareScrollView style={[a.flex_1]}>
|
||||
{children}
|
||||
</KeyboardAwareScrollView>
|
||||
)
|
||||
}
|
||||
|
||||
function StepView() {
|
||||
const [state] = useWizardState()
|
||||
|
||||
if (state.currentStep === 'Landing') {
|
||||
return <StepLanding />
|
||||
}
|
||||
if (state.currentStep === 'Details') {
|
||||
return <StepDetails />
|
||||
}
|
||||
if (state.currentStep === 'Profiles') {
|
||||
return <StepProfiles />
|
||||
}
|
||||
if (state.currentStep === 'Feeds') {
|
||||
return <StepFeeds />
|
||||
}
|
||||
}
|
||||
@@ -308,6 +308,9 @@ const styles = StyleSheet.create({
|
||||
flex: 1,
|
||||
gap: 14,
|
||||
},
|
||||
border: {
|
||||
borderTopWidth: hairlineWidth,
|
||||
},
|
||||
headerContainer: {
|
||||
flexDirection: 'row',
|
||||
},
|
||||
|
||||
@@ -21,6 +21,7 @@ import {Text} from '../util/text/Text'
|
||||
import {UserAvatar, UserAvatarType} from '../util/UserAvatar'
|
||||
import {CenteredView} from '../util/Views'
|
||||
import hairlineWidth = StyleSheet.hairlineWidth
|
||||
import {StarterPackIcon} from '#/components/icons/StarterPackIcon'
|
||||
|
||||
export function ProfileSubpageHeader({
|
||||
isLoading,
|
||||
@@ -43,7 +44,7 @@ export function ProfileSubpageHeader({
|
||||
handle: string
|
||||
}
|
||||
| undefined
|
||||
avatarType: UserAvatarType
|
||||
avatarType: UserAvatarType | 'starter-pack'
|
||||
}>) {
|
||||
const setDrawerOpen = useSetDrawerOpen()
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
@@ -130,7 +131,11 @@ export function ProfileSubpageHeader({
|
||||
accessibilityLabel={_(msg`View the avatar`)}
|
||||
accessibilityHint=""
|
||||
style={{width: 58}}>
|
||||
<UserAvatar type={avatarType} size={58} avatar={avatar} />
|
||||
{avatarType === 'starter-pack' ? (
|
||||
<StarterPackIcon width={58} height={58} />
|
||||
) : (
|
||||
<UserAvatar type={avatarType} size={58} avatar={avatar} />
|
||||
)}
|
||||
</Pressable>
|
||||
<View style={{flex: 1}}>
|
||||
{isLoading ? (
|
||||
|
||||
@@ -28,6 +28,7 @@ export function ViewHeader({
|
||||
showOnDesktop,
|
||||
showBorder,
|
||||
renderButton,
|
||||
onBackPress: onPressBackOverride,
|
||||
}: {
|
||||
title: string
|
||||
subtitle?: string
|
||||
@@ -37,6 +38,7 @@ export function ViewHeader({
|
||||
showOnDesktop?: boolean
|
||||
showBorder?: boolean
|
||||
renderButton?: () => JSX.Element
|
||||
onBackPress?: () => void
|
||||
}) {
|
||||
const pal = usePalette('default')
|
||||
const {_} = useLingui()
|
||||
@@ -48,11 +50,15 @@ export function ViewHeader({
|
||||
|
||||
const onPressBack = React.useCallback(() => {
|
||||
if (navigation.canGoBack()) {
|
||||
navigation.goBack()
|
||||
if (typeof onPressBackOverride === 'function') {
|
||||
onPressBackOverride()
|
||||
} else {
|
||||
navigation.goBack()
|
||||
}
|
||||
} else {
|
||||
navigation.navigate('Home')
|
||||
}
|
||||
}, [navigation])
|
||||
}, [navigation, onPressBackOverride])
|
||||
|
||||
const onPressMenu = React.useCallback(() => {
|
||||
track('ViewHeader:MenuButtonClicked')
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, {useMemo} from 'react'
|
||||
import React, {useCallback, useMemo} from 'react'
|
||||
import {StyleSheet} from 'react-native'
|
||||
import {
|
||||
AppBskyActorDefs,
|
||||
@@ -34,6 +34,7 @@ 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'
|
||||
@@ -162,6 +163,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 +185,21 @@ function ProfileScreenLoaded({
|
||||
const showMediaTab = !hasLabeler
|
||||
const showLikesTab = isMe
|
||||
const showFeedsTab = isMe || (profile.associated?.feedgens || 0) > 0
|
||||
const showStarterPacksTab = isMe || true
|
||||
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 +208,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 +228,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,18 +249,21 @@ 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()
|
||||
}
|
||||
},
|
||||
[
|
||||
feedsIndex,
|
||||
filtersIndex,
|
||||
likesIndex,
|
||||
listsIndex,
|
||||
mediaIndex,
|
||||
postsIndex,
|
||||
repliesIndex,
|
||||
mediaIndex,
|
||||
likesIndex,
|
||||
feedsIndex,
|
||||
listsIndex,
|
||||
starterPacksIndex,
|
||||
],
|
||||
)
|
||||
|
||||
@@ -290,7 +289,7 @@ function ProfileScreenLoaded({
|
||||
// events
|
||||
// =
|
||||
|
||||
const onPressCompose = React.useCallback(() => {
|
||||
const onPressCompose = () => {
|
||||
track('ProfileScreen:PressCompose')
|
||||
const mention =
|
||||
profile.handle === currentAccount?.handle ||
|
||||
@@ -298,23 +297,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 +323,7 @@ function ProfileScreenLoaded({
|
||||
/>
|
||||
</ExpoScrollForwarderView>
|
||||
)
|
||||
}, [
|
||||
scrollViewTag,
|
||||
profile,
|
||||
labelerInfo,
|
||||
hasDescription,
|
||||
descriptionRT,
|
||||
moderationOpts,
|
||||
hideBackButton,
|
||||
showPlaceholder,
|
||||
])
|
||||
}
|
||||
|
||||
return (
|
||||
<ScreenHider
|
||||
@@ -442,6 +429,18 @@ function ProfileScreenLoaded({
|
||||
/>
|
||||
)
|
||||
: null}
|
||||
{showStarterPacksTab
|
||||
? ({headerHeight, isFocused, scrollElRef}) => (
|
||||
<ProfileStarterPacks
|
||||
ref={starterPacksSectionRef}
|
||||
did={profile.did}
|
||||
scrollElRef={scrollElRef as ListRef}
|
||||
headerOffset={headerHeight}
|
||||
enabled={isFocused}
|
||||
setScrollViewTag={setScrollViewTag}
|
||||
/>
|
||||
)
|
||||
: null}
|
||||
{showListsTab && !profile.associated?.labeler
|
||||
? ({headerHeight, isFocused, scrollElRef}) => (
|
||||
<ProfileLists
|
||||
|
||||
@@ -864,6 +864,42 @@ export function SettingsScreen({}: Props) {
|
||||
<Trans>Delete chat declaration record</Trans>
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[pal.view, styles.linkCardNoIcon]}
|
||||
onPress={() => {
|
||||
navigation.navigate('StarterPack', {id: '123'})
|
||||
}}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Navigate to Starter Pack`)}
|
||||
accessibilityHint={_(msg`Navigate to Starter Pack`)}>
|
||||
<Text type="lg" style={pal.text}>
|
||||
<Trans>Navigate to Starter Pack</Trans>
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[pal.view, styles.linkCardNoIcon]}
|
||||
onPress={() => {
|
||||
navigation.navigate('StarterPackLanding', {id: '123'})
|
||||
}}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Navigate to Starter Pack Landing`)}
|
||||
accessibilityHint={_(msg`Navigate to Starter Pack Landing`)}>
|
||||
<Text type="lg" style={pal.text}>
|
||||
<Trans>Navigate to Starter Pack Landing</Trans>
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[pal.view, styles.linkCardNoIcon]}
|
||||
onPress={() =>
|
||||
navigation.navigate('StarterPackWizard', {mode: 'Create'})
|
||||
}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Navigate to Starter Pack Wizard`)}
|
||||
accessibilityHint={_(msg`Navigates to Starter Pack Wizard`)}>
|
||||
<Text type="lg" style={pal.text}>
|
||||
<Trans>Navigate to Starter Pack Wizard</Trans>
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[pal.view, styles.linkCardNoIcon]}
|
||||
onPress={onPressResetOnboarding}
|
||||
|
||||
Reference in New Issue
Block a user