diff options
author | Samuel Newman <mozzius@protonmail.com> | 2024-12-17 17:13:18 +0000 |
---|---|---|
committer | GitHub <noreply@github.com> | 2024-12-17 17:13:18 +0000 |
commit | 0cbb03cd14c226bcbfd146a586d97c62a0fc4c9d (patch) | |
tree | c9053654e8d1813b6c8108bce53ac5eb883fed43 /src/components/ProgressGuide | |
parent | 32611391a35cbfe3f4a57882c117d52de022fb89 (diff) | |
download | voidsky-0cbb03cd14c226bcbfd146a586d97c62a0fc4c9d.tar.zst |
New progress guide - 10 follows (#7128)
* new follow-10 progress guide * find follows dialog * wip tabs * flatlist version with search * hardcode out jake gold * lazy load followup suggestions * Update src/components/ProgressGuide/FollowDialog.tsx Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * comment out replacing, enable paging * rm autofocus * find shadow profiles in paginated search * clear search when press tabs * better tab a11y * fix label * adjust scroll indicator insets * do the same scroll indicator adjustment for searchable people list * hardcode jake to just be 'tech' * Retain state on close/reopen * only change follow btn color when not followed * add guide to inside dialog * fix task alignment * Enable contextual suggestions * WIP: show multiple suggestions * Rework so it animates well * Show more items * remove card style * move tabs to own component * split out header top * scroll active tab into view * rm log * Improve perf a bit * boost popular interests over alphabetical ones * scroll active tab into view * revert back to round buttons * Fix overrenders of the tab bar items * Fix unintended animation * Scroll initial into view if needed * Unlift state, the dialog thing breaks lifting * Persist simply * Fix empty state * Fix incorrect gate exposure * Fix another bad useGate * Nit --------- Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> Co-authored-by: Dan Abramov <dan.abramov@gmail.com>
Diffstat (limited to 'src/components/ProgressGuide')
-rw-r--r-- | src/components/ProgressGuide/FollowDialog.tsx | 829 | ||||
-rw-r--r-- | src/components/ProgressGuide/List.tsx | 44 | ||||
-rw-r--r-- | src/components/ProgressGuide/Task.tsx | 14 |
3 files changed, 872 insertions, 15 deletions
diff --git a/src/components/ProgressGuide/FollowDialog.tsx b/src/components/ProgressGuide/FollowDialog.tsx new file mode 100644 index 000000000..6ac3200df --- /dev/null +++ b/src/components/ProgressGuide/FollowDialog.tsx @@ -0,0 +1,829 @@ +import {memo, useCallback, useEffect, useMemo, useRef, useState} from 'react' +import {ScrollView, TextInput, useWindowDimensions, View} from 'react-native' +import Animated, { + LayoutAnimationConfig, + LinearTransition, + ZoomInEasyDown, +} from 'react-native-reanimated' +import {AppBskyActorDefs, ModerationOpts} from '@atproto/api' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' +import {cleanError} from '#/lib/strings/errors' +import {logger} from '#/logger' +import {isWeb} from '#/platform/detection' +import {useModerationOpts} from '#/state/preferences/moderation-opts' +import {useActorSearchPaginated} from '#/state/queries/actor-search' +import {usePreferencesQuery} from '#/state/queries/preferences' +import {useSuggestedFollowsByActorQuery} from '#/state/queries/suggested-follows' +import {useSession} from '#/state/session' +import {Follow10ProgressGuide} from '#/state/shell/progress-guide' +import {ListMethods} from '#/view/com/util/List' +import { + popularInterests, + useInterestsDisplayNames, +} from '#/screens/Onboarding/state' +import { + atoms as a, + native, + tokens, + useBreakpoints, + useTheme, + ViewStyleProp, + web, +} from '#/alf' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import * as Dialog from '#/components/Dialog' +import {useInteractionState} from '#/components/hooks/useInteractionState' +import {MagnifyingGlass2_Stroke2_Corner0_Rounded as SearchIcon} from '#/components/icons/MagnifyingGlass2' +import {PersonGroup_Stroke2_Corner2_Rounded as PersonGroupIcon} from '#/components/icons/Person' +import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' +import * as ProfileCard from '#/components/ProfileCard' +import {Text} from '#/components/Typography' +import {ListFooter} from '../Lists' +import {ProgressGuideTask} from './Task' + +type Item = + | { + type: 'profile' + key: string + profile: AppBskyActorDefs.ProfileView + isSuggestion: boolean + } + | { + type: 'empty' + key: string + message: string + } + | { + type: 'placeholder' + key: string + } + | { + type: 'error' + key: string + } + +export function FollowDialog({guide}: {guide: Follow10ProgressGuide}) { + const {_} = useLingui() + const control = Dialog.useDialogControl() + const {gtMobile} = useBreakpoints() + const {height: minHeight} = useWindowDimensions() + + return ( + <> + <Button + label={_(msg`Find people to follow`)} + onPress={control.open} + size={gtMobile ? 'small' : 'large'} + color="primary" + variant="solid"> + <ButtonIcon icon={PersonGroupIcon} /> + <ButtonText> + <Trans>Find people to follow</Trans> + </ButtonText> + </Button> + <Dialog.Outer control={control} nativeOptions={{minHeight}}> + <Dialog.Handle /> + <DialogInner guide={guide} /> + </Dialog.Outer> + </> + ) +} + +// Fine to keep this top-level. +let lastSelectedInterest = '' +let lastSearchText = '' + +function DialogInner({guide}: {guide: Follow10ProgressGuide}) { + const {_} = useLingui() + const interestsDisplayNames = useInterestsDisplayNames() + const {data: preferences} = usePreferencesQuery() + const personalizedInterests = preferences?.interests?.tags + const interests = Object.keys(interestsDisplayNames) + .sort(boostInterests(popularInterests)) + .sort(boostInterests(personalizedInterests)) + const [selectedInterest, setSelectedInterest] = useState( + () => + lastSelectedInterest || + (personalizedInterests && interests.includes(personalizedInterests[0]) + ? personalizedInterests[0] + : interests[0]), + ) + const [searchText, setSearchText] = useState(lastSearchText) + const moderationOpts = useModerationOpts() + const listRef = useRef<ListMethods>(null) + const inputRef = useRef<TextInput>(null) + const [headerHeight, setHeaderHeight] = useState(0) + const {currentAccount} = useSession() + const [suggestedAccounts, setSuggestedAccounts] = useState< + Map<string, AppBskyActorDefs.ProfileView[]> + >(() => new Map()) + + useEffect(() => { + lastSearchText = searchText + lastSelectedInterest = selectedInterest + }, [searchText, selectedInterest]) + + const query = searchText || selectedInterest + const { + data: searchResults, + isFetching, + error, + isError, + hasNextPage, + isFetchingNextPage, + fetchNextPage, + } = useActorSearchPaginated({ + query, + }) + + const hasSearchText = !!searchText + + const items = useMemo(() => { + const results = searchResults?.pages.flatMap(r => r.actors) + let _items: Item[] = [] + const seen = new Set<string>() + + if (isError) { + _items.push({ + type: 'empty', + key: 'empty', + message: _(msg`We're having network issues, try again`), + }) + } else if (results) { + // First pass: search results + for (const profile of results) { + if (profile.did === currentAccount?.did) continue + if (profile.viewer?.following) continue + // my sincere apologies to Jake Gold - your bio is too keyword-filled and + // your page-rank too high, so you're at the top of half the categories -sfn + if ( + !hasSearchText && + profile.did === 'did:plc:tpg43qhh4lw4ksiffs4nbda3' && + // constrain to 'tech' + selectedInterest !== 'tech' + ) { + continue + } + seen.add(profile.did) + _items.push({ + type: 'profile', + // Don't share identity across tabs or typing attempts + key: query + ':' + profile.did, + profile, + isSuggestion: false, + }) + } + // Second pass: suggestions + _items = _items.flatMap(item => { + if (item.type !== 'profile') { + return item + } + const suggestions = suggestedAccounts.get(item.profile.did) + if (!suggestions) { + return item + } + const itemWithSuggestions = [item] + for (const suggested of suggestions) { + if (seen.has(suggested.did)) { + // Skip search results from previous step or already seen suggestions + continue + } + seen.add(suggested.did) + itemWithSuggestions.push({ + type: 'profile', + key: suggested.did, + profile: suggested, + isSuggestion: true, + }) + if (itemWithSuggestions.length === 1 + 3) { + break + } + } + return itemWithSuggestions + }) + } else { + const placeholders: Item[] = Array(10) + .fill(0) + .map((__, i) => ({ + type: 'placeholder', + key: i + '', + })) + + _items.push(...placeholders) + } + + return _items + }, [ + _, + searchResults, + isError, + currentAccount?.did, + hasSearchText, + selectedInterest, + suggestedAccounts, + query, + ]) + + if (searchText && !isFetching && !items.length && !isError) { + items.push({type: 'empty', key: 'empty', message: _(msg`No results`)}) + } + + const renderItems = useCallback( + ({item, index}: {item: Item; index: number}) => { + switch (item.type) { + case 'profile': { + return ( + <FollowProfileCard + profile={item.profile} + isSuggestion={item.isSuggestion} + moderationOpts={moderationOpts!} + setSuggestedAccounts={setSuggestedAccounts} + noBorder={index === 0} + /> + ) + } + case 'placeholder': { + return <ProfileCardSkeleton key={item.key} /> + } + case 'empty': { + return <Empty key={item.key} message={item.message} /> + } + default: + return null + } + }, + [moderationOpts], + ) + + const onSelectTab = useCallback( + (interest: string) => { + setSelectedInterest(interest) + inputRef.current?.clear() + setSearchText('') + listRef.current?.scrollToOffset({ + offset: 0, + animated: false, + }) + }, + [setSelectedInterest, setSearchText], + ) + + const listHeader = ( + <Header + guide={guide} + inputRef={inputRef} + listRef={listRef} + searchText={searchText} + onSelectTab={onSelectTab} + setHeaderHeight={setHeaderHeight} + setSearchText={setSearchText} + interests={interests} + selectedInterest={selectedInterest} + interestsDisplayNames={interestsDisplayNames} + /> + ) + + const onEndReached = useCallback(async () => { + if (isFetchingNextPage || !hasNextPage || isError) return + try { + await fetchNextPage() + } catch (err) { + logger.error('Failed to load more people to follow', {message: err}) + } + }, [isFetchingNextPage, hasNextPage, isError, fetchNextPage]) + + return ( + <Dialog.InnerFlatList + ref={listRef} + data={items} + renderItem={renderItems} + ListHeaderComponent={listHeader} + stickyHeaderIndices={[0]} + keyExtractor={(item: Item) => item.key} + style={[ + a.px_0, + web([a.py_0, {height: '100vh', maxHeight: 600}]), + native({height: '100%'}), + ]} + webInnerContentContainerStyle={a.py_0} + webInnerStyle={[a.py_0, {maxWidth: 500, minWidth: 200}]} + keyboardDismissMode="on-drag" + scrollIndicatorInsets={{top: headerHeight}} + initialNumToRender={8} + maxToRenderPerBatch={8} + onEndReached={onEndReached} + itemLayoutAnimation={LinearTransition} + ListFooterComponent={ + <ListFooter + isFetchingNextPage={isFetchingNextPage} + error={cleanError(error)} + onRetry={fetchNextPage} + /> + } + /> + ) +} + +let Header = ({ + guide, + inputRef, + listRef, + searchText, + onSelectTab, + setHeaderHeight, + setSearchText, + interests, + selectedInterest, + interestsDisplayNames, +}: { + guide: Follow10ProgressGuide + inputRef: React.RefObject<TextInput> + listRef: React.RefObject<ListMethods> + onSelectTab: (v: string) => void + searchText: string + setHeaderHeight: (v: number) => void + setSearchText: (v: string) => void + interests: string[] + selectedInterest: string + interestsDisplayNames: Record<string, string> +}): React.ReactNode => { + const t = useTheme() + const control = Dialog.useDialogContext() + return ( + <View + onLayout={evt => setHeaderHeight(evt.nativeEvent.layout.height)} + style={[ + a.relative, + web(a.pt_lg), + native(a.pt_4xl), + a.pb_xs, + a.border_b, + t.atoms.border_contrast_low, + t.atoms.bg, + ]}> + <HeaderTop guide={guide} /> + + <View style={[web(a.pt_xs), a.pb_xs]}> + <SearchInput + inputRef={inputRef} + defaultValue={searchText} + onChangeText={text => { + setSearchText(text) + listRef.current?.scrollToOffset({offset: 0, animated: false}) + }} + onEscape={control.close} + /> + <Tabs + onSelectTab={onSelectTab} + interests={interests} + selectedInterest={selectedInterest} + hasSearchText={!!searchText} + interestsDisplayNames={interestsDisplayNames} + /> + </View> + </View> + ) +} +Header = memo(Header) + +function HeaderTop({guide}: {guide: Follow10ProgressGuide}) { + const {_} = useLingui() + const t = useTheme() + const control = Dialog.useDialogContext() + return ( + <View + style={[ + a.px_lg, + a.relative, + a.flex_row, + a.justify_between, + a.align_center, + ]}> + <Text + style={[ + a.z_10, + a.text_lg, + a.font_heavy, + a.leading_tight, + t.atoms.text_contrast_high, + ]}> + <Trans>Find people to follow</Trans> + </Text> + <View style={isWeb && {paddingRight: 36}}> + <ProgressGuideTask + current={guide.numFollows + 1} + total={10 + 1} + title={`${guide.numFollows} / 10`} + tabularNumsTitle + /> + </View> + {isWeb ? ( + <Button + label={_(msg`Close`)} + size="small" + shape="round" + variant={isWeb ? 'ghost' : 'solid'} + color="secondary" + style={[ + a.absolute, + a.z_20, + web({right: -4}), + native({right: 0}), + native({height: 32, width: 32, borderRadius: 16}), + ]} + onPress={() => control.close()}> + <ButtonIcon icon={X} size="md" /> + </Button> + ) : null} + </View> + ) +} + +let Tabs = ({ + onSelectTab, + interests, + selectedInterest, + hasSearchText, + interestsDisplayNames, +}: { + onSelectTab: (tab: string) => void + interests: string[] + selectedInterest: string + hasSearchText: boolean + interestsDisplayNames: Record<string, string> +}): React.ReactNode => { + const listRef = useRef<ScrollView>(null) + const [scrollX, setScrollX] = useState(0) + const [totalWidth, setTotalWidth] = useState(0) + const pendingTabOffsets = useRef<{x: number; width: number}[]>([]) + const [tabOffsets, setTabOffsets] = useState<{x: number; width: number}[]>([]) + + const onInitialLayout = useNonReactiveCallback(() => { + const index = interests.indexOf(selectedInterest) + scrollIntoViewIfNeeded(index) + }) + + useEffect(() => { + if (tabOffsets) { + onInitialLayout() + } + }, [tabOffsets, onInitialLayout]) + + function scrollIntoViewIfNeeded(index: number) { + const btnLayout = tabOffsets[index] + if (!btnLayout) return + + const viewportLeftEdge = scrollX + const viewportRightEdge = scrollX + totalWidth + const shouldScrollToLeftEdge = viewportLeftEdge > btnLayout.x + const shouldScrollToRightEdge = + viewportRightEdge < btnLayout.x + btnLayout.width + + if (shouldScrollToLeftEdge) { + listRef.current?.scrollTo({ + x: btnLayout.x - tokens.space.lg, + animated: true, + }) + } else if (shouldScrollToRightEdge) { + listRef.current?.scrollTo({ + x: btnLayout.x - totalWidth + btnLayout.width + tokens.space.lg, + animated: true, + }) + } + } + + function handleSelectTab(index: number) { + const tab = interests[index] + onSelectTab(tab) + scrollIntoViewIfNeeded(index) + } + + function handleTabLayout(index: number, x: number, width: number) { + if (!tabOffsets.length) { + pendingTabOffsets.current[index] = {x, width} + if (pendingTabOffsets.current.length === interests.length) { + setTabOffsets(pendingTabOffsets.current) + } + } + } + + return ( + <ScrollView + ref={listRef} + horizontal + contentContainerStyle={[a.gap_sm, a.px_lg]} + showsHorizontalScrollIndicator={false} + decelerationRate="fast" + snapToOffsets={ + tabOffsets.length === interests.length + ? tabOffsets.map(o => o.x - tokens.space.xl) + : undefined + } + onLayout={evt => setTotalWidth(evt.nativeEvent.layout.width)} + scrollEventThrottle={200} // big throttle + onScroll={evt => setScrollX(evt.nativeEvent.contentOffset.x)}> + {interests.map((interest, i) => { + const active = interest === selectedInterest && !hasSearchText + return ( + <Tab + key={interest} + onSelectTab={handleSelectTab} + active={active} + index={i} + interest={interest} + interestsDisplayName={interestsDisplayNames[interest]} + onLayout={handleTabLayout} + /> + ) + })} + </ScrollView> + ) +} +Tabs = memo(Tabs) + +let Tab = ({ + onSelectTab, + interest, + active, + index, + interestsDisplayName, + onLayout, +}: { + onSelectTab: (index: number) => void + interest: string + active: boolean + index: number + interestsDisplayName: string + onLayout: (index: number, x: number, width: number) => void +}): React.ReactNode => { + const {_} = useLingui() + const activeText = active ? _(msg` (active)`) : '' + return ( + <View + key={interest} + onLayout={e => + onLayout(index, e.nativeEvent.layout.x, e.nativeEvent.layout.width) + }> + <Button + label={_(msg`Search for "${interestsDisplayName}"${activeText}`)} + variant={active ? 'solid' : 'outline'} + color={active ? 'primary' : 'secondary'} + size="small" + onPress={() => onSelectTab(index)}> + <ButtonIcon icon={SearchIcon} /> + <ButtonText>{interestsDisplayName}</ButtonText> + </Button> + </View> + ) +} +Tab = memo(Tab) + +let FollowProfileCard = ({ + profile, + moderationOpts, + isSuggestion, + setSuggestedAccounts, + noBorder, +}: { + profile: AppBskyActorDefs.ProfileView + moderationOpts: ModerationOpts + isSuggestion: boolean + setSuggestedAccounts: ( + updater: ( + v: Map<string, AppBskyActorDefs.ProfileView[]>, + ) => Map<string, AppBskyActorDefs.ProfileView[]>, + ) => void + noBorder?: boolean +}): React.ReactNode => { + const [hasFollowed, setHasFollowed] = useState(false) + const followupSuggestion = useSuggestedFollowsByActorQuery({ + did: profile.did, + enabled: hasFollowed, + }) + const candidates = followupSuggestion.data?.suggestions + + useEffect(() => { + // TODO: Move out of effect. + if (hasFollowed && candidates && candidates.length > 0) { + setSuggestedAccounts(suggestions => { + const newSuggestions = new Map(suggestions) + newSuggestions.set(profile.did, candidates) + return newSuggestions + }) + } + }, [hasFollowed, profile.did, candidates, setSuggestedAccounts]) + + return ( + <LayoutAnimationConfig skipEntering={!isSuggestion}> + <Animated.View entering={native(ZoomInEasyDown)}> + <FollowProfileCardInner + profile={profile} + moderationOpts={moderationOpts} + onFollow={() => setHasFollowed(true)} + noBorder={noBorder} + /> + </Animated.View> + </LayoutAnimationConfig> + ) +} +FollowProfileCard = memo(FollowProfileCard) + +function FollowProfileCardInner({ + profile, + moderationOpts, + onFollow, + noBorder, +}: { + profile: AppBskyActorDefs.ProfileView + moderationOpts: ModerationOpts + onFollow?: () => void + noBorder?: boolean +}) { + const control = Dialog.useDialogContext() + const t = useTheme() + return ( + <ProfileCard.Link + profile={profile} + style={[a.flex_1]} + onPress={() => control.close()}> + {({hovered, pressed}) => ( + <CardOuter + style={[ + a.flex_1, + noBorder && a.border_t_0, + (hovered || pressed) && t.atoms.border_contrast_high, + ]}> + <ProfileCard.Outer> + <ProfileCard.Header> + <ProfileCard.Avatar + profile={profile} + moderationOpts={moderationOpts} + /> + <ProfileCard.NameAndHandle + profile={profile} + moderationOpts={moderationOpts} + /> + <ProfileCard.FollowButton + profile={profile} + moderationOpts={moderationOpts} + logContext="PostOnboardingFindFollows" + shape="round" + onPress={onFollow} + colorInverted + /> + </ProfileCard.Header> + <ProfileCard.Description profile={profile} numberOfLines={2} /> + </ProfileCard.Outer> + </CardOuter> + )} + </ProfileCard.Link> + ) +} + +function CardOuter({ + children, + style, +}: {children: React.ReactNode | React.ReactNode[]} & ViewStyleProp) { + const t = useTheme() + return ( + <View + style={[ + a.w_full, + a.py_md, + a.px_lg, + a.border_t, + t.atoms.border_contrast_low, + style, + ]}> + {children} + </View> + ) +} + +function SearchInput({ + onChangeText, + onEscape, + inputRef, + defaultValue, +}: { + onChangeText: (text: string) => void + onEscape: () => void + inputRef: React.RefObject<TextInput> + defaultValue: string +}) { + 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 + {...web({ + onMouseEnter, + onMouseLeave, + })} + style={[a.flex_row, a.align_center, a.gap_sm, a.px_lg, a.py_xs]}> + <SearchIcon + size="md" + fill={interacted ? t.palette.primary_500 : t.palette.contrast_300} + /> + + <TextInput + ref={inputRef} + placeholder={_(msg`Search`)} + defaultValue={defaultValue} + onChangeText={onChangeText} + 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} + onKeyPress={({nativeEvent}) => { + if (nativeEvent.key === 'Escape') { + onEscape() + } + }} + autoCorrect={false} + autoComplete="off" + autoCapitalize="none" + accessibilityLabel={_(msg`Search profiles`)} + accessibilityHint={_(msg`Search profiles`)} + /> + </View> + ) +} + +function ProfileCardSkeleton() { + const t = useTheme() + + return ( + <View + style={[ + a.flex_1, + a.py_md, + a.px_lg, + a.gap_md, + a.align_center, + a.flex_row, + ]}> + <View + style={[ + a.rounded_full, + {width: 42, height: 42}, + t.atoms.bg_contrast_25, + ]} + /> + + <View style={[a.flex_1, a.gap_sm]}> + <View + style={[ + a.rounded_xs, + {width: 80, height: 14}, + t.atoms.bg_contrast_25, + ]} + /> + <View + style={[ + a.rounded_xs, + {width: 120, height: 10}, + t.atoms.bg_contrast_25, + ]} + /> + </View> + </View> + ) +} + +function Empty({message}: {message: string}) { + const t = useTheme() + return ( + <View style={[a.p_lg, a.py_xl, a.align_center, a.gap_md]}> + <Text style={[a.text_sm, a.italic, t.atoms.text_contrast_high]}> + {message} + </Text> + + <Text style={[a.text_xs, t.atoms.text_contrast_low]}>(╯°□°)╯︵ ┻━┻</Text> + </View> + ) +} + +function boostInterests(boosts?: string[]) { + return (_a: string, _b: string) => { + const indexA = boosts?.indexOf(_a) ?? -1 + const indexB = boosts?.indexOf(_b) ?? -1 + const rankA = indexA === -1 ? Infinity : indexA + const rankB = indexB === -1 ? Infinity : indexB + return rankA - rankB + } +} diff --git a/src/components/ProgressGuide/List.tsx b/src/components/ProgressGuide/List.tsx index 299d1e69f..bbc5a0177 100644 --- a/src/components/ProgressGuide/List.tsx +++ b/src/components/ProgressGuide/List.tsx @@ -10,12 +10,15 @@ import {atoms as a, useTheme} from '#/alf' import {Button, ButtonIcon} from '#/components/Button' import {TimesLarge_Stroke2_Corner0_Rounded as Times} from '#/components/icons/Times' import {Text} from '#/components/Typography' +import {FollowDialog} from './FollowDialog' import {ProgressGuideTask} from './Task' export function ProgressGuideList({style}: {style?: StyleProp<ViewStyle>}) { const t = useTheme() const {_} = useLingui() - const guide = useProgressGuide('like-10-and-follow-7') + const followProgressGuide = useProgressGuide('follow-10') + const followAndLikeProgressGuide = useProgressGuide('like-10-and-follow-7') + const guide = followProgressGuide || followAndLikeProgressGuide const {endProgressGuide} = useProgressGuideControls() if (guide) { @@ -41,18 +44,33 @@ export function ProgressGuideList({style}: {style?: StyleProp<ViewStyle>}) { <ButtonIcon icon={Times} size="sm" /> </Button> </View> - <ProgressGuideTask - current={guide.numLikes + 1} - total={10 + 1} - title={_(msg`Like 10 posts`)} - subtitle={_(msg`Teach our algorithm what you like`)} - /> - <ProgressGuideTask - current={guide.numFollows + 1} - total={7 + 1} - title={_(msg`Follow 7 accounts`)} - subtitle={_(msg`Bluesky is better with friends!`)} - /> + {guide.guide === 'follow-10' && ( + <> + <ProgressGuideTask + current={guide.numFollows + 1} + total={10 + 1} + title={_(msg`Follow 10 accounts`)} + subtitle={_(msg`Bluesky is better with friends!`)} + /> + <FollowDialog guide={guide} /> + </> + )} + {guide.guide === 'like-10-and-follow-7' && ( + <> + <ProgressGuideTask + current={guide.numLikes + 1} + total={10 + 1} + title={_(msg`Like 10 posts`)} + subtitle={_(msg`Teach our algorithm what you like`)} + /> + <ProgressGuideTask + current={guide.numFollows + 1} + total={7 + 1} + title={_(msg`Follow 7 accounts`)} + subtitle={_(msg`Bluesky is better with friends!`)} + /> + </> + )} </View> ) } diff --git a/src/components/ProgressGuide/Task.tsx b/src/components/ProgressGuide/Task.tsx index 973ee1ac7..b9ba3fd9a 100644 --- a/src/components/ProgressGuide/Task.tsx +++ b/src/components/ProgressGuide/Task.tsx @@ -10,11 +10,13 @@ export function ProgressGuideTask({ total, title, subtitle, + tabularNumsTitle, }: { current: number total: number title: string subtitle?: string + tabularNumsTitle?: boolean }) { const t = useTheme() @@ -33,8 +35,16 @@ export function ProgressGuideTask({ /> )} - <View style={[a.flex_col, a.gap_2xs, {marginTop: -2}]}> - <Text style={[a.text_sm, a.font_bold, a.leading_tight]}>{title}</Text> + <View style={[a.flex_col, a.gap_2xs, subtitle && {marginTop: -2}]}> + <Text + style={[ + a.text_sm, + a.font_bold, + a.leading_tight, + tabularNumsTitle && {fontVariant: ['tabular-nums']}, + ]}> + {title} + </Text> {subtitle && ( <Text style={[a.text_sm, t.atoms.text_contrast_medium, a.leading_tight]}> |