diff --git a/src/alf/atoms.ts b/src/alf/atoms.ts
index ad4929ec85..df2b29d8ac 100644
--- a/src/alf/atoms.ts
+++ b/src/alf/atoms.ts
@@ -302,6 +302,18 @@ export const atoms = {
border_0: {
borderWidth: 0,
},
+ border_t_0: {
+ borderTopWidth: 0,
+ },
+ border_b_0: {
+ borderBottomWidth: 0,
+ },
+ border_l_0: {
+ borderLeftWidth: 0,
+ },
+ border_r_0: {
+ borderRightWidth: 0,
+ },
border: {
borderWidth: StyleSheet.hairlineWidth,
},
diff --git a/src/components/FeedInterstitials.tsx b/src/components/FeedInterstitials.tsx
index 85ed58280d..ec224eeae0 100644
--- a/src/components/FeedInterstitials.tsx
+++ b/src/components/FeedInterstitials.tsx
@@ -280,8 +280,8 @@ export function ProfileGrid({
profile={profile}
moderationOpts={moderationOpts}
logContext="FeedInterstitial"
- color="secondary_inverted"
shape="round"
+ colorInverted
/>
diff --git a/src/components/ProfileCard.tsx b/src/components/ProfileCard.tsx
index 668bd0f3c7..7bec14b9cc 100644
--- a/src/components/ProfileCard.tsx
+++ b/src/components/ProfileCard.tsx
@@ -285,6 +285,7 @@ export type FollowButtonProps = {
moderationOpts: ModerationOpts
logContext: LogEvents['profile:follow']['logContext'] &
LogEvents['profile:unfollow']['logContext']
+ colorInverted?: boolean
} & Partial
export function FollowButton(props: FollowButtonProps) {
@@ -297,6 +298,8 @@ export function FollowButtonInner({
profile: profileUnshadowed,
moderationOpts,
logContext,
+ onPress: onPressProp,
+ colorInverted,
...rest
}: FollowButtonProps) {
const {_} = useLingui()
@@ -321,6 +324,7 @@ export function FollowButtonInner({
)}`,
),
)
+ onPressProp?.(e)
} catch (err: any) {
if (err?.name !== 'AbortError') {
Toast.show(_(msg`An issue occurred, please try again.`), 'xmark')
@@ -341,6 +345,7 @@ export function FollowButtonInner({
)}`,
),
)
+ onPressProp?.(e)
} catch (err: any) {
if (err?.name !== 'AbortError') {
Toast.show(_(msg`An issue occurred, please try again.`), 'xmark')
@@ -387,7 +392,7 @@ export function FollowButtonInner({
label={followLabel}
size="small"
variant="solid"
- color="primary"
+ color={colorInverted ? 'secondary_inverted' : 'primary'}
{...rest}
onPress={onPressFollow}>
diff --git a/src/components/ProgressGuide/FollowDialog.tsx b/src/components/ProgressGuide/FollowDialog.tsx
new file mode 100644
index 0000000000..6ac3200df1
--- /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 (
+ <>
+
+
+
+
+
+ >
+ )
+}
+
+// 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(null)
+ const inputRef = useRef(null)
+ const [headerHeight, setHeaderHeight] = useState(0)
+ const {currentAccount} = useSession()
+ const [suggestedAccounts, setSuggestedAccounts] = useState<
+ Map
+ >(() => 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()
+
+ 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 (
+
+ )
+ }
+ case 'placeholder': {
+ return
+ }
+ case 'empty': {
+ return
+ }
+ 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 = (
+
+ )
+
+ 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 (
+ 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={
+
+ }
+ />
+ )
+}
+
+let Header = ({
+ guide,
+ inputRef,
+ listRef,
+ searchText,
+ onSelectTab,
+ setHeaderHeight,
+ setSearchText,
+ interests,
+ selectedInterest,
+ interestsDisplayNames,
+}: {
+ guide: Follow10ProgressGuide
+ inputRef: React.RefObject
+ listRef: React.RefObject
+ onSelectTab: (v: string) => void
+ searchText: string
+ setHeaderHeight: (v: number) => void
+ setSearchText: (v: string) => void
+ interests: string[]
+ selectedInterest: string
+ interestsDisplayNames: Record
+}): React.ReactNode => {
+ const t = useTheme()
+ const control = Dialog.useDialogContext()
+ return (
+ 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,
+ ]}>
+
+
+
+ {
+ setSearchText(text)
+ listRef.current?.scrollToOffset({offset: 0, animated: false})
+ }}
+ onEscape={control.close}
+ />
+
+
+
+ )
+}
+Header = memo(Header)
+
+function HeaderTop({guide}: {guide: Follow10ProgressGuide}) {
+ const {_} = useLingui()
+ const t = useTheme()
+ const control = Dialog.useDialogContext()
+ return (
+
+
+ Find people to follow
+
+
+
+
+ {isWeb ? (
+
+ ) : null}
+
+ )
+}
+
+let Tabs = ({
+ onSelectTab,
+ interests,
+ selectedInterest,
+ hasSearchText,
+ interestsDisplayNames,
+}: {
+ onSelectTab: (tab: string) => void
+ interests: string[]
+ selectedInterest: string
+ hasSearchText: boolean
+ interestsDisplayNames: Record
+}): React.ReactNode => {
+ const listRef = useRef(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 (
+ 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 (
+
+ )
+ })}
+
+ )
+}
+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 (
+
+ onLayout(index, e.nativeEvent.layout.x, e.nativeEvent.layout.width)
+ }>
+
+
+ )
+}
+Tab = memo(Tab)
+
+let FollowProfileCard = ({
+ profile,
+ moderationOpts,
+ isSuggestion,
+ setSuggestedAccounts,
+ noBorder,
+}: {
+ profile: AppBskyActorDefs.ProfileView
+ moderationOpts: ModerationOpts
+ isSuggestion: boolean
+ setSuggestedAccounts: (
+ updater: (
+ v: Map,
+ ) => Map,
+ ) => 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 (
+
+
+ setHasFollowed(true)}
+ noBorder={noBorder}
+ />
+
+
+ )
+}
+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 (
+ control.close()}>
+ {({hovered, pressed}) => (
+
+
+
+
+
+
+
+
+
+
+ )}
+
+ )
+}
+
+function CardOuter({
+ children,
+ style,
+}: {children: React.ReactNode | React.ReactNode[]} & ViewStyleProp) {
+ const t = useTheme()
+ return (
+
+ {children}
+
+ )
+}
+
+function SearchInput({
+ onChangeText,
+ onEscape,
+ inputRef,
+ defaultValue,
+}: {
+ onChangeText: (text: string) => void
+ onEscape: () => void
+ inputRef: React.RefObject
+ 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 (
+
+
+
+ {
+ if (nativeEvent.key === 'Escape') {
+ onEscape()
+ }
+ }}
+ autoCorrect={false}
+ autoComplete="off"
+ autoCapitalize="none"
+ accessibilityLabel={_(msg`Search profiles`)}
+ accessibilityHint={_(msg`Search profiles`)}
+ />
+
+ )
+}
+
+function ProfileCardSkeleton() {
+ const t = useTheme()
+
+ return (
+
+
+
+
+
+
+
+
+ )
+}
+
+function Empty({message}: {message: string}) {
+ const t = useTheme()
+ return (
+
+
+ {message}
+
+
+ (╯°□°)╯︵ ┻━┻
+
+ )
+}
+
+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 299d1e69fc..bbc5a0177f 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}) {
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}) {
-
-
+ {guide.guide === 'follow-10' && (
+ <>
+
+
+ >
+ )}
+ {guide.guide === 'like-10-and-follow-7' && (
+ <>
+
+
+ >
+ )}
)
}
diff --git a/src/components/ProgressGuide/Task.tsx b/src/components/ProgressGuide/Task.tsx
index 973ee1ac7f..b9ba3fd9ab 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({
/>
)}
-
- {title}
+
+
+ {title}
+
{subtitle && (
diff --git a/src/components/dms/dialogs/SearchablePeopleList.tsx b/src/components/dms/dialogs/SearchablePeopleList.tsx
index 0946d2a27c..50090cbcbb 100644
--- a/src/components/dms/dialogs/SearchablePeopleList.tsx
+++ b/src/components/dms/dialogs/SearchablePeopleList.tsx
@@ -63,6 +63,7 @@ export function SearchablePeopleList({
const {_} = useLingui()
const moderationOpts = useModerationOpts()
const control = Dialog.useDialogContext()
+ const [headerHeight, setHeaderHeight] = useState(0)
const listRef = useRef(null)
const {currentAccount} = useSession()
const inputRef = useRef(null)
@@ -237,6 +238,7 @@ export function SearchablePeopleList({
const listHeader = useMemo(() => {
return (
setHeaderHeight(evt.nativeEvent.layout.height)}
style={[
a.relative,
web(a.pt_lg),
@@ -315,6 +317,7 @@ export function SearchablePeopleList({
]}
webInnerContentContainerStyle={a.py_0}
webInnerStyle={[a.py_0, {maxWidth: 500, minWidth: 200}]}
+ scrollIndicatorInsets={{top: headerHeight}}
keyboardDismissMode="on-drag"
/>
)
diff --git a/src/lib/statsig/events.ts b/src/lib/statsig/events.ts
index e6c9c5d135..f1dfb0a947 100644
--- a/src/lib/statsig/events.ts
+++ b/src/lib/statsig/events.ts
@@ -162,6 +162,7 @@ export type LogEvents = {
| 'StarterPackProfilesList'
| 'FeedInterstitial'
| 'ProfileHeaderSuggestedFollows'
+ | 'PostOnboardingFindFollows'
}
'profile:unfollow': {
logContext:
@@ -177,6 +178,7 @@ export type LogEvents = {
| 'StarterPackProfilesList'
| 'FeedInterstitial'
| 'ProfileHeaderSuggestedFollows'
+ | 'PostOnboardingFindFollows'
}
'chat:create': {
logContext: 'ProfileHeader' | 'NewChatDialog' | 'SendViaChatDialog'
diff --git a/src/lib/statsig/gates.ts b/src/lib/statsig/gates.ts
index 6876f18c53..a6c2492548 100644
--- a/src/lib/statsig/gates.ts
+++ b/src/lib/statsig/gates.ts
@@ -1,3 +1,6 @@
export type Gate =
// Keep this alphabetic please.
- 'debug_show_feedcontext' | 'debug_subscriptions' | 'remove_show_latest_button'
+ | 'debug_show_feedcontext'
+ | 'debug_subscriptions'
+ | 'new_postonboarding'
+ | 'remove_show_latest_button'
diff --git a/src/screens/Onboarding/StepFinished.tsx b/src/screens/Onboarding/StepFinished.tsx
index 0d8971b6f6..fc0ea6a247 100644
--- a/src/screens/Onboarding/StepFinished.tsx
+++ b/src/screens/Onboarding/StepFinished.tsx
@@ -14,7 +14,7 @@ import {
TIMELINE_SAVED_FEED,
} from '#/lib/constants'
import {useRequestNotificationsPermission} from '#/lib/notifications/notifications'
-import {logEvent} from '#/lib/statsig/statsig'
+import {logEvent, useGate} from '#/lib/statsig/statsig'
import {logger} from '#/logger'
import {useSetHasCheckedForStarterPack} from '#/state/preferences/used-starter-packs'
import {getAllListMembers} from '#/state/queries/list-members'
@@ -57,6 +57,7 @@ export function StepFinished() {
const setActiveStarterPack = useSetActiveStarterPack()
const setHasCheckedForStarterPack = useSetHasCheckedForStarterPack()
const {startProgressGuide} = useProgressGuideControls()
+ const gate = useGate()
const finishOnboarding = React.useCallback(async () => {
setSaving(true)
@@ -190,7 +191,9 @@ export function StepFinished() {
setSaving(false)
setActiveStarterPack(undefined)
setHasCheckedForStarterPack(true)
- startProgressGuide('like-10-and-follow-7')
+ startProgressGuide(
+ gate('new_postonboarding') ? 'follow-10' : 'like-10-and-follow-7',
+ )
dispatch({type: 'finish'})
onboardDispatch({type: 'finish'})
logEvent('onboarding:finished:nextPressed', {
@@ -221,6 +224,7 @@ export function StepFinished() {
setActiveStarterPack,
setHasCheckedForStarterPack,
startProgressGuide,
+ gate,
])
return (
diff --git a/src/screens/Onboarding/state.ts b/src/screens/Onboarding/state.ts
index 70fa696408..20d3ef2170 100644
--- a/src/screens/Onboarding/state.ts
+++ b/src/screens/Onboarding/state.ts
@@ -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() {
const {_} = useLingui()
diff --git a/src/state/queries/actor-search.ts b/src/state/queries/actor-search.ts
index 479fc1a9f0..6d6c46e040 100644
--- a/src/state/queries/actor-search.ts
+++ b/src/state/queries/actor-search.ts
@@ -1,6 +1,7 @@
import {AppBskyActorDefs, AppBskyActorSearchActors} from '@atproto/api'
import {
InfiniteData,
+ keepPreviousData,
QueryClient,
QueryKey,
useInfiniteQuery,
@@ -13,10 +14,8 @@ import {useAgent} from '#/state/session'
const RQKEY_ROOT = 'actor-search'
export const RQKEY = (query: string) => [RQKEY_ROOT, query]
-export const RQKEY_PAGINATED = (query: string) => [
- `${RQKEY_ROOT}_paginated`,
- query,
-]
+const RQKEY_ROOT_PAGINATED = `${RQKEY_ROOT}_paginated`
+export const RQKEY_PAGINATED = (query: string) => [RQKEY_ROOT_PAGINATED, query]
export function useActorSearch({
query,
@@ -42,9 +41,11 @@ export function useActorSearch({
export function useActorSearchPaginated({
query,
enabled,
+ maintainData,
}: {
query: string
enabled?: boolean
+ maintainData?: boolean
}) {
const agent = useAgent()
return useInfiniteQuery<
@@ -67,6 +68,7 @@ export function useActorSearchPaginated({
enabled: enabled && !!query,
initialPageParam: undefined,
getNextPageParam: lastPage => lastPage.cursor,
+ placeholderData: maintainData ? keepPreviousData : undefined,
})
}
@@ -89,4 +91,20 @@ export function* findAllProfilesInQueryData(
}
}
}
+
+ const queryDatasPaginated = queryClient.getQueriesData<
+ InfiniteData
+ >({
+ 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
+ }
+ }
+ }
}
diff --git a/src/state/queries/suggested-follows.ts b/src/state/queries/suggested-follows.ts
index 07e16946e7..22033c0a8c 100644
--- a/src/state/queries/suggested-follows.ts
+++ b/src/state/queries/suggested-follows.ts
@@ -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()
return useQuery({
queryKey: suggestedFollowsByActorQueryKey(did),
@@ -116,6 +122,7 @@ export function useSuggestedFollowsByActorQuery({did}: {did: string}) {
: res.data.suggestions.filter(profile => !profile.viewer?.following)
return {suggestions}
},
+ enabled,
})
}
diff --git a/src/state/shell/progress-guide.tsx b/src/state/shell/progress-guide.tsx
index d64e9984f5..af3d60ebbd 100644
--- a/src/state/shell/progress-guide.tsx
+++ b/src/state/shell/progress-guide.tsx
@@ -1,4 +1,4 @@
-import React from 'react'
+import React, {useMemo} from 'react'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
@@ -16,20 +16,32 @@ export enum ProgressGuideAction {
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 {
- guide: string
+ guide: ProgressGuideName
isComplete: boolean
[key: string]: any
}
-interface Like10AndFollow7ProgressGuide extends BaseProgressGuide {
+export interface Like10AndFollow7ProgressGuide extends BaseProgressGuide {
+ guide: 'like-10-and-follow-7'
numLikes: 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(undefined)
@@ -61,15 +73,28 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
const {mutateAsync, variables, isPending} =
useSetActiveProgressGuideMutation()
- const activeProgressGuide = (
- isPending ? variables : preferences?.bskyAppState?.activeProgressGuide
- ) as ProgressGuide
+ const activeProgressGuide = useMemo(() => {
+ const rawProgressGuide = (
+ isPending ? variables : preferences?.bskyAppState?.activeProgressGuide
+ ) as ProgressGuide
+
+ if (!rawProgressGuide) return undefined
+
+ // ensure the unspecced attributes have the correct types
+ // clone then mutate
+ const {...maybeWronglyTypedProgressGuide} = rawProgressGuide
+ 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
+ }
- // ensure the unspecced attributes have the correct types
- if (activeProgressGuide?.guide === 'like-10-and-follow-7') {
- activeProgressGuide.numLikes = Number(activeProgressGuide.numLikes) || 0
- activeProgressGuide.numFollows = Number(activeProgressGuide.numFollows) || 0
- }
+ return maybeWronglyTypedProgressGuide
+ }, [isPending, variables, preferences])
const [localGuideState, setLocalGuideState] =
React.useState(undefined)
@@ -82,7 +107,9 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
const firstLikeToastRef = React.useRef(null)
const fifthLikeToastRef = React.useRef(null)
const tenthLikeToastRef = React.useRef(null)
- const guideCompleteToastRef = React.useRef(null)
+
+ const fifthFollowToastRef = React.useRef(null)
+ const tenthFollowToastRef = React.useRef(null)
const controls = React.useMemo(() => {
return {
@@ -93,7 +120,15 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
numLikes: 0,
numFollows: 0,
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)
mutateAsync(guideObj)
}
@@ -137,6 +172,26 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
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)
@@ -167,9 +222,14 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
subtitle={_(msg`The Discover feed now knows what you like`)}
/>
+
>
)}
diff --git a/src/view/com/posts/PostFeed.tsx b/src/view/com/posts/PostFeed.tsx
index 55d7ba053f..10eb47d0a4 100644
--- a/src/view/com/posts/PostFeed.tsx
+++ b/src/view/com/posts/PostFeed.tsx
@@ -253,9 +253,11 @@ let PostFeed = ({
}
}, [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 showProgressIntersitial = progressGuide && !isDesktop
+ const showProgressIntersitial =
+ (followProgressGuide || followAndLikeProgressGuide) && !isDesktop
const feedItems: FeedRow[] = React.useMemo(() => {
let feedKind: 'following' | 'discover' | 'profile' | undefined
diff --git a/src/view/com/util/List.tsx b/src/view/com/util/List.tsx
index 62c91cec66..5084af6129 100644
--- a/src/view/com/util/List.tsx
+++ b/src/view/com/util/List.tsx
@@ -155,7 +155,11 @@ let List = React.forwardRef(
automaticallyAdjustsScrollIndicatorInsets={
automaticallyAdjustsScrollIndicatorInsets
}
- scrollIndicatorInsets={{top: headerOffset, right: 1}}
+ scrollIndicatorInsets={{
+ top: headerOffset,
+ right: 1,
+ ...props.scrollIndicatorInsets,
+ }}
contentOffset={contentOffset}
refreshControl={refreshControl}
onScroll={scrollHandler}
diff --git a/src/view/com/util/Toast.tsx b/src/view/com/util/Toast.tsx
index 7dc6837e23..c602886745 100644
--- a/src/view/com/util/Toast.tsx
+++ b/src/view/com/util/Toast.tsx
@@ -196,7 +196,9 @@ function Toast({
/>
- {message}
+
+ {message}
+