Compare commits

...

13 Commits

Author SHA1 Message Date
Hailey cdae4454e3 add QR code icon 2024-06-03 11:13:18 -07:00
Hailey 7ebe72324b full width 2024-06-03 10:51:33 -07:00
Hailey 6ed7f9fb1e fix params 2024-06-03 10:48:54 -07:00
Hailey e717e18dae Merge branch 'starter-packs' into hailey/starter-packs-4 2024-06-03 09:26:09 -07:00
Hailey 64ccace6b6 add link wrapper 2024-06-03 09:25:59 -07:00
Hailey 02542f9ad3 adjust padding 2024-06-03 09:24:22 -07:00
Hailey cf109da792 rm log 2024-06-03 09:23:44 -07:00
Hailey b57a184002 add icon to card 2024-06-03 09:23:24 -07:00
Hailey a373cd056d replace with new card 2024-06-03 09:08:20 -07:00
Hailey 8e7ee4c94c add a tab for starter packs 2024-06-03 09:06:32 -07:00
Hailey 55c2ca5b92 [🏁 #3] Logged-in Starter Pack View (#4292)
* minimal landing page

add a minimal gate

remove overlap row

add bg to avatar set

implement basic mock up

add header of mock

add route

* add navigation

* create starter pack landing page

* add state reducer

* make value an array

* implement state w/ steps

* add back the flex

* view header back button

* add button text

* add button text

* add details screen

* add button states

* proper use of button

* oops

* rm extra state

* use keyboard aware scrollview

* rm log

* remove some stuff

* remove avatar from landing

* add list for profiles

* add profile card w/ button

* add feeds

* add processing state

* support editing a pack

* fix undefined error

* add logged-in view routes

* fix navigation link

* add dummy button to settings

* add starter pack screen

* update prop

* add a pager for starter packs

* add placeholder for feeds

* add share/edit buttons

* tsignore for now, fix merge issues
2024-06-02 18:41:41 -07:00
Hailey 30686702a6 [🏁 #2] Starter pack wizard (#4266)
* minimal landing page

add a minimal gate

remove overlap row

add bg to avatar set

implement basic mock up

add header of mock

add route

* add navigation

* create starter pack landing page

* add state reducer

* make value an array

* implement state w/ steps

* add back the flex

* view header back button

* add button text

* add button text

* add details screen

* add button states

* proper use of button

* oops

* rm extra state

* use keyboard aware scrollview

* rm log

* remove some stuff

* remove avatar from landing

* add list for profiles

* add profile card w/ button

* add feeds

* add processing state

* support editing a pack

* fix undefined error

* add desktop props

* add entire profile to state

* scope the profile list better

* scoping

* add key extractor to feeds

* add fab

* add icon to details

* changes

* add dialog for adding profiles

* remove log

* rm unused iports

* fix nav type
2024-06-02 18:14:08 -07:00
Hailey cb581bffd1 [🏁 #1] Starter pack landing screen (#4247)
* minimal landing page

add a minimal gate

remove overlap row

add bg to avatar set

implement basic mock up

add header of mock

add route

* remove avatar from landing

* cleanup some web styles

* rm `now`

* hairline width
2024-05-31 14:36:47 -07:00
30 changed files with 1809 additions and 53 deletions
@@ -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

+1
View File
@@ -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

+20
View File
@@ -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>
)
}
+2
View File
@@ -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,
]}
/>
+5
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',
})
+26
View File
@@ -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>
)
}
+14
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
)
}
+13
View File
@@ -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
+1
View File
@@ -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'
+3
View File
@@ -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',
})
+204
View File
@@ -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}
/>
)
},
)
+219
View File
@@ -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>
</>
)
}
+137
View File
@@ -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>
)
}
+228
View File
@@ -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 />
}
}
+3
View File
@@ -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 ? (
+8 -2
View File
@@ -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')
+48 -49
View File
@@ -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
+36
View File
@@ -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}