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} +