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>
This commit is contained in:
@@ -302,6 +302,18 @@ export const atoms = {
|
|||||||
border_0: {
|
border_0: {
|
||||||
borderWidth: 0,
|
borderWidth: 0,
|
||||||
},
|
},
|
||||||
|
border_t_0: {
|
||||||
|
borderTopWidth: 0,
|
||||||
|
},
|
||||||
|
border_b_0: {
|
||||||
|
borderBottomWidth: 0,
|
||||||
|
},
|
||||||
|
border_l_0: {
|
||||||
|
borderLeftWidth: 0,
|
||||||
|
},
|
||||||
|
border_r_0: {
|
||||||
|
borderRightWidth: 0,
|
||||||
|
},
|
||||||
border: {
|
border: {
|
||||||
borderWidth: StyleSheet.hairlineWidth,
|
borderWidth: StyleSheet.hairlineWidth,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -280,8 +280,8 @@ export function ProfileGrid({
|
|||||||
profile={profile}
|
profile={profile}
|
||||||
moderationOpts={moderationOpts}
|
moderationOpts={moderationOpts}
|
||||||
logContext="FeedInterstitial"
|
logContext="FeedInterstitial"
|
||||||
color="secondary_inverted"
|
|
||||||
shape="round"
|
shape="round"
|
||||||
|
colorInverted
|
||||||
/>
|
/>
|
||||||
</ProfileCard.Header>
|
</ProfileCard.Header>
|
||||||
<ProfileCard.Description profile={profile} numberOfLines={2} />
|
<ProfileCard.Description profile={profile} numberOfLines={2} />
|
||||||
|
|||||||
@@ -285,6 +285,7 @@ export type FollowButtonProps = {
|
|||||||
moderationOpts: ModerationOpts
|
moderationOpts: ModerationOpts
|
||||||
logContext: LogEvents['profile:follow']['logContext'] &
|
logContext: LogEvents['profile:follow']['logContext'] &
|
||||||
LogEvents['profile:unfollow']['logContext']
|
LogEvents['profile:unfollow']['logContext']
|
||||||
|
colorInverted?: boolean
|
||||||
} & Partial<ButtonProps>
|
} & Partial<ButtonProps>
|
||||||
|
|
||||||
export function FollowButton(props: FollowButtonProps) {
|
export function FollowButton(props: FollowButtonProps) {
|
||||||
@@ -297,6 +298,8 @@ export function FollowButtonInner({
|
|||||||
profile: profileUnshadowed,
|
profile: profileUnshadowed,
|
||||||
moderationOpts,
|
moderationOpts,
|
||||||
logContext,
|
logContext,
|
||||||
|
onPress: onPressProp,
|
||||||
|
colorInverted,
|
||||||
...rest
|
...rest
|
||||||
}: FollowButtonProps) {
|
}: FollowButtonProps) {
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
@@ -321,6 +324,7 @@ export function FollowButtonInner({
|
|||||||
)}`,
|
)}`,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
onPressProp?.(e)
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (err?.name !== 'AbortError') {
|
if (err?.name !== 'AbortError') {
|
||||||
Toast.show(_(msg`An issue occurred, please try again.`), 'xmark')
|
Toast.show(_(msg`An issue occurred, please try again.`), 'xmark')
|
||||||
@@ -341,6 +345,7 @@ export function FollowButtonInner({
|
|||||||
)}`,
|
)}`,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
onPressProp?.(e)
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (err?.name !== 'AbortError') {
|
if (err?.name !== 'AbortError') {
|
||||||
Toast.show(_(msg`An issue occurred, please try again.`), 'xmark')
|
Toast.show(_(msg`An issue occurred, please try again.`), 'xmark')
|
||||||
@@ -387,7 +392,7 @@ export function FollowButtonInner({
|
|||||||
label={followLabel}
|
label={followLabel}
|
||||||
size="small"
|
size="small"
|
||||||
variant="solid"
|
variant="solid"
|
||||||
color="primary"
|
color={colorInverted ? 'secondary_inverted' : 'primary'}
|
||||||
{...rest}
|
{...rest}
|
||||||
onPress={onPressFollow}>
|
onPress={onPressFollow}>
|
||||||
<ButtonIcon icon={Plus} position={isRound ? undefined : 'left'} />
|
<ButtonIcon icon={Plus} position={isRound ? undefined : 'left'} />
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,12 +10,15 @@ import {atoms as a, useTheme} from '#/alf'
|
|||||||
import {Button, ButtonIcon} from '#/components/Button'
|
import {Button, ButtonIcon} from '#/components/Button'
|
||||||
import {TimesLarge_Stroke2_Corner0_Rounded as Times} from '#/components/icons/Times'
|
import {TimesLarge_Stroke2_Corner0_Rounded as Times} from '#/components/icons/Times'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
|
import {FollowDialog} from './FollowDialog'
|
||||||
import {ProgressGuideTask} from './Task'
|
import {ProgressGuideTask} from './Task'
|
||||||
|
|
||||||
export function ProgressGuideList({style}: {style?: StyleProp<ViewStyle>}) {
|
export function ProgressGuideList({style}: {style?: StyleProp<ViewStyle>}) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const {_} = useLingui()
|
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()
|
const {endProgressGuide} = useProgressGuideControls()
|
||||||
|
|
||||||
if (guide) {
|
if (guide) {
|
||||||
@@ -41,6 +44,19 @@ export function ProgressGuideList({style}: {style?: StyleProp<ViewStyle>}) {
|
|||||||
<ButtonIcon icon={Times} size="sm" />
|
<ButtonIcon icon={Times} size="sm" />
|
||||||
</Button>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
|
{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
|
<ProgressGuideTask
|
||||||
current={guide.numLikes + 1}
|
current={guide.numLikes + 1}
|
||||||
total={10 + 1}
|
total={10 + 1}
|
||||||
@@ -53,6 +69,8 @@ export function ProgressGuideList({style}: {style?: StyleProp<ViewStyle>}) {
|
|||||||
title={_(msg`Follow 7 accounts`)}
|
title={_(msg`Follow 7 accounts`)}
|
||||||
subtitle={_(msg`Bluesky is better with friends!`)}
|
subtitle={_(msg`Bluesky is better with friends!`)}
|
||||||
/>
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,11 +10,13 @@ export function ProgressGuideTask({
|
|||||||
total,
|
total,
|
||||||
title,
|
title,
|
||||||
subtitle,
|
subtitle,
|
||||||
|
tabularNumsTitle,
|
||||||
}: {
|
}: {
|
||||||
current: number
|
current: number
|
||||||
total: number
|
total: number
|
||||||
title: string
|
title: string
|
||||||
subtitle?: string
|
subtitle?: string
|
||||||
|
tabularNumsTitle?: boolean
|
||||||
}) {
|
}) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
|
|
||||||
@@ -33,8 +35,16 @@ export function ProgressGuideTask({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<View style={[a.flex_col, a.gap_2xs, {marginTop: -2}]}>
|
<View style={[a.flex_col, a.gap_2xs, subtitle && {marginTop: -2}]}>
|
||||||
<Text style={[a.text_sm, a.font_bold, a.leading_tight]}>{title}</Text>
|
<Text
|
||||||
|
style={[
|
||||||
|
a.text_sm,
|
||||||
|
a.font_bold,
|
||||||
|
a.leading_tight,
|
||||||
|
tabularNumsTitle && {fontVariant: ['tabular-nums']},
|
||||||
|
]}>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
{subtitle && (
|
{subtitle && (
|
||||||
<Text
|
<Text
|
||||||
style={[a.text_sm, t.atoms.text_contrast_medium, a.leading_tight]}>
|
style={[a.text_sm, t.atoms.text_contrast_medium, a.leading_tight]}>
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ export function SearchablePeopleList({
|
|||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const moderationOpts = useModerationOpts()
|
const moderationOpts = useModerationOpts()
|
||||||
const control = Dialog.useDialogContext()
|
const control = Dialog.useDialogContext()
|
||||||
|
const [headerHeight, setHeaderHeight] = useState(0)
|
||||||
const listRef = useRef<ListMethods>(null)
|
const listRef = useRef<ListMethods>(null)
|
||||||
const {currentAccount} = useSession()
|
const {currentAccount} = useSession()
|
||||||
const inputRef = useRef<TextInput>(null)
|
const inputRef = useRef<TextInput>(null)
|
||||||
@@ -237,6 +238,7 @@ export function SearchablePeopleList({
|
|||||||
const listHeader = useMemo(() => {
|
const listHeader = useMemo(() => {
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
|
onLayout={evt => setHeaderHeight(evt.nativeEvent.layout.height)}
|
||||||
style={[
|
style={[
|
||||||
a.relative,
|
a.relative,
|
||||||
web(a.pt_lg),
|
web(a.pt_lg),
|
||||||
@@ -315,6 +317,7 @@ export function SearchablePeopleList({
|
|||||||
]}
|
]}
|
||||||
webInnerContentContainerStyle={a.py_0}
|
webInnerContentContainerStyle={a.py_0}
|
||||||
webInnerStyle={[a.py_0, {maxWidth: 500, minWidth: 200}]}
|
webInnerStyle={[a.py_0, {maxWidth: 500, minWidth: 200}]}
|
||||||
|
scrollIndicatorInsets={{top: headerHeight}}
|
||||||
keyboardDismissMode="on-drag"
|
keyboardDismissMode="on-drag"
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -162,6 +162,7 @@ export type LogEvents = {
|
|||||||
| 'StarterPackProfilesList'
|
| 'StarterPackProfilesList'
|
||||||
| 'FeedInterstitial'
|
| 'FeedInterstitial'
|
||||||
| 'ProfileHeaderSuggestedFollows'
|
| 'ProfileHeaderSuggestedFollows'
|
||||||
|
| 'PostOnboardingFindFollows'
|
||||||
}
|
}
|
||||||
'profile:unfollow': {
|
'profile:unfollow': {
|
||||||
logContext:
|
logContext:
|
||||||
@@ -177,6 +178,7 @@ export type LogEvents = {
|
|||||||
| 'StarterPackProfilesList'
|
| 'StarterPackProfilesList'
|
||||||
| 'FeedInterstitial'
|
| 'FeedInterstitial'
|
||||||
| 'ProfileHeaderSuggestedFollows'
|
| 'ProfileHeaderSuggestedFollows'
|
||||||
|
| 'PostOnboardingFindFollows'
|
||||||
}
|
}
|
||||||
'chat:create': {
|
'chat:create': {
|
||||||
logContext: 'ProfileHeader' | 'NewChatDialog' | 'SendViaChatDialog'
|
logContext: 'ProfileHeader' | 'NewChatDialog' | 'SendViaChatDialog'
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
export type Gate =
|
export type Gate =
|
||||||
// Keep this alphabetic please.
|
// Keep this alphabetic please.
|
||||||
'debug_show_feedcontext' | 'debug_subscriptions' | 'remove_show_latest_button'
|
| 'debug_show_feedcontext'
|
||||||
|
| 'debug_subscriptions'
|
||||||
|
| 'new_postonboarding'
|
||||||
|
| 'remove_show_latest_button'
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
TIMELINE_SAVED_FEED,
|
TIMELINE_SAVED_FEED,
|
||||||
} from '#/lib/constants'
|
} from '#/lib/constants'
|
||||||
import {useRequestNotificationsPermission} from '#/lib/notifications/notifications'
|
import {useRequestNotificationsPermission} from '#/lib/notifications/notifications'
|
||||||
import {logEvent} from '#/lib/statsig/statsig'
|
import {logEvent, useGate} from '#/lib/statsig/statsig'
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
import {useSetHasCheckedForStarterPack} from '#/state/preferences/used-starter-packs'
|
import {useSetHasCheckedForStarterPack} from '#/state/preferences/used-starter-packs'
|
||||||
import {getAllListMembers} from '#/state/queries/list-members'
|
import {getAllListMembers} from '#/state/queries/list-members'
|
||||||
@@ -57,6 +57,7 @@ export function StepFinished() {
|
|||||||
const setActiveStarterPack = useSetActiveStarterPack()
|
const setActiveStarterPack = useSetActiveStarterPack()
|
||||||
const setHasCheckedForStarterPack = useSetHasCheckedForStarterPack()
|
const setHasCheckedForStarterPack = useSetHasCheckedForStarterPack()
|
||||||
const {startProgressGuide} = useProgressGuideControls()
|
const {startProgressGuide} = useProgressGuideControls()
|
||||||
|
const gate = useGate()
|
||||||
|
|
||||||
const finishOnboarding = React.useCallback(async () => {
|
const finishOnboarding = React.useCallback(async () => {
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
@@ -190,7 +191,9 @@ export function StepFinished() {
|
|||||||
setSaving(false)
|
setSaving(false)
|
||||||
setActiveStarterPack(undefined)
|
setActiveStarterPack(undefined)
|
||||||
setHasCheckedForStarterPack(true)
|
setHasCheckedForStarterPack(true)
|
||||||
startProgressGuide('like-10-and-follow-7')
|
startProgressGuide(
|
||||||
|
gate('new_postonboarding') ? 'follow-10' : 'like-10-and-follow-7',
|
||||||
|
)
|
||||||
dispatch({type: 'finish'})
|
dispatch({type: 'finish'})
|
||||||
onboardDispatch({type: 'finish'})
|
onboardDispatch({type: 'finish'})
|
||||||
logEvent('onboarding:finished:nextPressed', {
|
logEvent('onboarding:finished:nextPressed', {
|
||||||
@@ -221,6 +224,7 @@ export function StepFinished() {
|
|||||||
setActiveStarterPack,
|
setActiveStarterPack,
|
||||||
setHasCheckedForStarterPack,
|
setHasCheckedForStarterPack,
|
||||||
startProgressGuide,
|
startProgressGuide,
|
||||||
|
gate,
|
||||||
])
|
])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -72,6 +72,19 @@ export type ApiResponseMap = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// most popular selected interests
|
||||||
|
export const popularInterests = [
|
||||||
|
'art',
|
||||||
|
'gaming',
|
||||||
|
'sports',
|
||||||
|
'comics',
|
||||||
|
'music',
|
||||||
|
'politics',
|
||||||
|
'photography',
|
||||||
|
'science',
|
||||||
|
'news',
|
||||||
|
]
|
||||||
|
|
||||||
export function useInterestsDisplayNames() {
|
export function useInterestsDisplayNames() {
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {AppBskyActorDefs, AppBskyActorSearchActors} from '@atproto/api'
|
import {AppBskyActorDefs, AppBskyActorSearchActors} from '@atproto/api'
|
||||||
import {
|
import {
|
||||||
InfiniteData,
|
InfiniteData,
|
||||||
|
keepPreviousData,
|
||||||
QueryClient,
|
QueryClient,
|
||||||
QueryKey,
|
QueryKey,
|
||||||
useInfiniteQuery,
|
useInfiniteQuery,
|
||||||
@@ -13,10 +14,8 @@ import {useAgent} from '#/state/session'
|
|||||||
const RQKEY_ROOT = 'actor-search'
|
const RQKEY_ROOT = 'actor-search'
|
||||||
export const RQKEY = (query: string) => [RQKEY_ROOT, query]
|
export const RQKEY = (query: string) => [RQKEY_ROOT, query]
|
||||||
|
|
||||||
export const RQKEY_PAGINATED = (query: string) => [
|
const RQKEY_ROOT_PAGINATED = `${RQKEY_ROOT}_paginated`
|
||||||
`${RQKEY_ROOT}_paginated`,
|
export const RQKEY_PAGINATED = (query: string) => [RQKEY_ROOT_PAGINATED, query]
|
||||||
query,
|
|
||||||
]
|
|
||||||
|
|
||||||
export function useActorSearch({
|
export function useActorSearch({
|
||||||
query,
|
query,
|
||||||
@@ -42,9 +41,11 @@ export function useActorSearch({
|
|||||||
export function useActorSearchPaginated({
|
export function useActorSearchPaginated({
|
||||||
query,
|
query,
|
||||||
enabled,
|
enabled,
|
||||||
|
maintainData,
|
||||||
}: {
|
}: {
|
||||||
query: string
|
query: string
|
||||||
enabled?: boolean
|
enabled?: boolean
|
||||||
|
maintainData?: boolean
|
||||||
}) {
|
}) {
|
||||||
const agent = useAgent()
|
const agent = useAgent()
|
||||||
return useInfiniteQuery<
|
return useInfiniteQuery<
|
||||||
@@ -67,6 +68,7 @@ export function useActorSearchPaginated({
|
|||||||
enabled: enabled && !!query,
|
enabled: enabled && !!query,
|
||||||
initialPageParam: undefined,
|
initialPageParam: undefined,
|
||||||
getNextPageParam: lastPage => lastPage.cursor,
|
getNextPageParam: lastPage => lastPage.cursor,
|
||||||
|
placeholderData: maintainData ? keepPreviousData : undefined,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,4 +91,20 @@ export function* findAllProfilesInQueryData(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const queryDatasPaginated = queryClient.getQueriesData<
|
||||||
|
InfiniteData<AppBskyActorSearchActors.OutputSchema>
|
||||||
|
>({
|
||||||
|
queryKey: [RQKEY_ROOT_PAGINATED],
|
||||||
|
})
|
||||||
|
for (const [_queryKey, queryData] of queryDatasPaginated) {
|
||||||
|
if (!queryData) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for (const actor of queryData.pages.flatMap(page => page.actors)) {
|
||||||
|
if (actor.did === did) {
|
||||||
|
yield actor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,7 +103,13 @@ export function useSuggestedFollowsQuery(options?: SuggestedFollowsOptions) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useSuggestedFollowsByActorQuery({did}: {did: string}) {
|
export function useSuggestedFollowsByActorQuery({
|
||||||
|
did,
|
||||||
|
enabled,
|
||||||
|
}: {
|
||||||
|
did: string
|
||||||
|
enabled?: boolean
|
||||||
|
}) {
|
||||||
const agent = useAgent()
|
const agent = useAgent()
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: suggestedFollowsByActorQueryKey(did),
|
queryKey: suggestedFollowsByActorQueryKey(did),
|
||||||
@@ -116,6 +122,7 @@ export function useSuggestedFollowsByActorQuery({did}: {did: string}) {
|
|||||||
: res.data.suggestions.filter(profile => !profile.viewer?.following)
|
: res.data.suggestions.filter(profile => !profile.viewer?.following)
|
||||||
return {suggestions}
|
return {suggestions}
|
||||||
},
|
},
|
||||||
|
enabled,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React from 'react'
|
import React, {useMemo} from 'react'
|
||||||
import {msg} from '@lingui/macro'
|
import {msg} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
@@ -16,20 +16,32 @@ export enum ProgressGuideAction {
|
|||||||
Follow = 'follow',
|
Follow = 'follow',
|
||||||
}
|
}
|
||||||
|
|
||||||
type ProgressGuideName = 'like-10-and-follow-7'
|
type ProgressGuideName = 'like-10-and-follow-7' | 'follow-10'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Progress Guides that extend this interface must specify their name in the `guide` field, so it can be used as a discriminated union
|
||||||
|
*/
|
||||||
interface BaseProgressGuide {
|
interface BaseProgressGuide {
|
||||||
guide: string
|
guide: ProgressGuideName
|
||||||
isComplete: boolean
|
isComplete: boolean
|
||||||
[key: string]: any
|
[key: string]: any
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Like10AndFollow7ProgressGuide extends BaseProgressGuide {
|
export interface Like10AndFollow7ProgressGuide extends BaseProgressGuide {
|
||||||
|
guide: 'like-10-and-follow-7'
|
||||||
numLikes: number
|
numLikes: number
|
||||||
numFollows: number
|
numFollows: number
|
||||||
}
|
}
|
||||||
|
|
||||||
type ProgressGuide = Like10AndFollow7ProgressGuide | undefined
|
export interface Follow10ProgressGuide extends BaseProgressGuide {
|
||||||
|
guide: 'follow-10'
|
||||||
|
numFollows: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProgressGuide =
|
||||||
|
| Like10AndFollow7ProgressGuide
|
||||||
|
| Follow10ProgressGuide
|
||||||
|
| undefined
|
||||||
|
|
||||||
const ProgressGuideContext = React.createContext<ProgressGuide>(undefined)
|
const ProgressGuideContext = React.createContext<ProgressGuide>(undefined)
|
||||||
|
|
||||||
@@ -61,16 +73,29 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
|||||||
const {mutateAsync, variables, isPending} =
|
const {mutateAsync, variables, isPending} =
|
||||||
useSetActiveProgressGuideMutation()
|
useSetActiveProgressGuideMutation()
|
||||||
|
|
||||||
const activeProgressGuide = (
|
const activeProgressGuide = useMemo(() => {
|
||||||
|
const rawProgressGuide = (
|
||||||
isPending ? variables : preferences?.bskyAppState?.activeProgressGuide
|
isPending ? variables : preferences?.bskyAppState?.activeProgressGuide
|
||||||
) as ProgressGuide
|
) as ProgressGuide
|
||||||
|
|
||||||
|
if (!rawProgressGuide) return undefined
|
||||||
|
|
||||||
// ensure the unspecced attributes have the correct types
|
// ensure the unspecced attributes have the correct types
|
||||||
if (activeProgressGuide?.guide === 'like-10-and-follow-7') {
|
// clone then mutate
|
||||||
activeProgressGuide.numLikes = Number(activeProgressGuide.numLikes) || 0
|
const {...maybeWronglyTypedProgressGuide} = rawProgressGuide
|
||||||
activeProgressGuide.numFollows = Number(activeProgressGuide.numFollows) || 0
|
if (maybeWronglyTypedProgressGuide?.guide === 'like-10-and-follow-7') {
|
||||||
|
maybeWronglyTypedProgressGuide.numLikes =
|
||||||
|
Number(maybeWronglyTypedProgressGuide.numLikes) || 0
|
||||||
|
maybeWronglyTypedProgressGuide.numFollows =
|
||||||
|
Number(maybeWronglyTypedProgressGuide.numFollows) || 0
|
||||||
|
} else if (maybeWronglyTypedProgressGuide?.guide === 'follow-10') {
|
||||||
|
maybeWronglyTypedProgressGuide.numFollows =
|
||||||
|
Number(maybeWronglyTypedProgressGuide.numFollows) || 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return maybeWronglyTypedProgressGuide
|
||||||
|
}, [isPending, variables, preferences])
|
||||||
|
|
||||||
const [localGuideState, setLocalGuideState] =
|
const [localGuideState, setLocalGuideState] =
|
||||||
React.useState<ProgressGuide>(undefined)
|
React.useState<ProgressGuide>(undefined)
|
||||||
|
|
||||||
@@ -82,7 +107,9 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
|||||||
const firstLikeToastRef = React.useRef<ProgressGuideToastRef | null>(null)
|
const firstLikeToastRef = React.useRef<ProgressGuideToastRef | null>(null)
|
||||||
const fifthLikeToastRef = React.useRef<ProgressGuideToastRef | null>(null)
|
const fifthLikeToastRef = React.useRef<ProgressGuideToastRef | null>(null)
|
||||||
const tenthLikeToastRef = React.useRef<ProgressGuideToastRef | null>(null)
|
const tenthLikeToastRef = React.useRef<ProgressGuideToastRef | null>(null)
|
||||||
const guideCompleteToastRef = React.useRef<ProgressGuideToastRef | null>(null)
|
|
||||||
|
const fifthFollowToastRef = React.useRef<ProgressGuideToastRef | null>(null)
|
||||||
|
const tenthFollowToastRef = React.useRef<ProgressGuideToastRef | null>(null)
|
||||||
|
|
||||||
const controls = React.useMemo(() => {
|
const controls = React.useMemo(() => {
|
||||||
return {
|
return {
|
||||||
@@ -93,7 +120,15 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
|||||||
numLikes: 0,
|
numLikes: 0,
|
||||||
numFollows: 0,
|
numFollows: 0,
|
||||||
isComplete: false,
|
isComplete: false,
|
||||||
}
|
} satisfies ProgressGuide
|
||||||
|
setLocalGuideState(guideObj)
|
||||||
|
mutateAsync(guideObj)
|
||||||
|
} else if (guide === 'follow-10') {
|
||||||
|
const guideObj = {
|
||||||
|
guide: 'follow-10',
|
||||||
|
numFollows: 0,
|
||||||
|
isComplete: false,
|
||||||
|
} satisfies ProgressGuide
|
||||||
setLocalGuideState(guideObj)
|
setLocalGuideState(guideObj)
|
||||||
mutateAsync(guideObj)
|
mutateAsync(guideObj)
|
||||||
}
|
}
|
||||||
@@ -137,6 +172,26 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
|||||||
isComplete: true,
|
isComplete: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if (guide?.guide === 'follow-10') {
|
||||||
|
if (action === ProgressGuideAction.Follow) {
|
||||||
|
guide = {
|
||||||
|
...guide,
|
||||||
|
numFollows: (Number(guide.numFollows) || 0) + count,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (guide.numFollows === 5) {
|
||||||
|
fifthFollowToastRef.current?.open()
|
||||||
|
}
|
||||||
|
if (guide.numFollows === 10) {
|
||||||
|
tenthFollowToastRef.current?.open()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Number(guide.numFollows) >= 10) {
|
||||||
|
guide = {
|
||||||
|
...guide,
|
||||||
|
isComplete: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setLocalGuideState(guide)
|
setLocalGuideState(guide)
|
||||||
@@ -167,9 +222,14 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
|||||||
subtitle={_(msg`The Discover feed now knows what you like`)}
|
subtitle={_(msg`The Discover feed now knows what you like`)}
|
||||||
/>
|
/>
|
||||||
<ProgressGuideToast
|
<ProgressGuideToast
|
||||||
ref={guideCompleteToastRef}
|
ref={fifthFollowToastRef}
|
||||||
title={_(msg`Algorithm training complete!`)}
|
title={_(msg`Half way there!`)}
|
||||||
subtitle={_(msg`The Discover feed now knows what you like`)}
|
subtitle={_(msg`Follow 10 accounts`)}
|
||||||
|
/>
|
||||||
|
<ProgressGuideToast
|
||||||
|
ref={tenthFollowToastRef}
|
||||||
|
title={_(msg`Task complete - 10 follows!`)}
|
||||||
|
subtitle={_(msg`You've found some people to follow`)}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -253,9 +253,11 @@ let PostFeed = ({
|
|||||||
}
|
}
|
||||||
}, [pollInterval])
|
}, [pollInterval])
|
||||||
|
|
||||||
const progressGuide = useProgressGuide('like-10-and-follow-7')
|
const followProgressGuide = useProgressGuide('follow-10')
|
||||||
|
const followAndLikeProgressGuide = useProgressGuide('like-10-and-follow-7')
|
||||||
const {isDesktop} = useWebMediaQueries()
|
const {isDesktop} = useWebMediaQueries()
|
||||||
const showProgressIntersitial = progressGuide && !isDesktop
|
const showProgressIntersitial =
|
||||||
|
(followProgressGuide || followAndLikeProgressGuide) && !isDesktop
|
||||||
|
|
||||||
const feedItems: FeedRow[] = React.useMemo(() => {
|
const feedItems: FeedRow[] = React.useMemo(() => {
|
||||||
let feedKind: 'following' | 'discover' | 'profile' | undefined
|
let feedKind: 'following' | 'discover' | 'profile' | undefined
|
||||||
|
|||||||
@@ -155,7 +155,11 @@ let List = React.forwardRef<ListMethods, ListProps>(
|
|||||||
automaticallyAdjustsScrollIndicatorInsets={
|
automaticallyAdjustsScrollIndicatorInsets={
|
||||||
automaticallyAdjustsScrollIndicatorInsets
|
automaticallyAdjustsScrollIndicatorInsets
|
||||||
}
|
}
|
||||||
scrollIndicatorInsets={{top: headerOffset, right: 1}}
|
scrollIndicatorInsets={{
|
||||||
|
top: headerOffset,
|
||||||
|
right: 1,
|
||||||
|
...props.scrollIndicatorInsets,
|
||||||
|
}}
|
||||||
contentOffset={contentOffset}
|
contentOffset={contentOffset}
|
||||||
refreshControl={refreshControl}
|
refreshControl={refreshControl}
|
||||||
onScroll={scrollHandler}
|
onScroll={scrollHandler}
|
||||||
|
|||||||
@@ -196,7 +196,9 @@ function Toast({
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
<View style={[a.h_full, a.justify_center, a.flex_1]}>
|
<View style={[a.h_full, a.justify_center, a.flex_1]}>
|
||||||
<Text style={a.text_md}>{message}</Text>
|
<Text style={a.text_md} emoji>
|
||||||
|
{message}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</GestureDetector>
|
</GestureDetector>
|
||||||
|
|||||||
Reference in New Issue
Block a user