diff --git a/src/components/FeedInterstitials.tsx b/src/components/FeedInterstitials.tsx index a482d5a482..cad86facb4 100644 --- a/src/components/FeedInterstitials.tsx +++ b/src/components/FeedInterstitials.tsx @@ -933,8 +933,15 @@ export function SuggestedFeeds() { export function ProgressGuide() { const t = useTheme() + const {gtMobile} = useBreakpoints() return ( - + ) diff --git a/src/components/ProgressGuide/FollowDialog.tsx b/src/components/ProgressGuide/FollowDialog.tsx index bf567091b3..ca91665e94 100644 --- a/src/components/ProgressGuide/FollowDialog.tsx +++ b/src/components/ProgressGuide/FollowDialog.tsx @@ -31,8 +31,8 @@ import { import {Button, ButtonIcon, ButtonText} from '#/components/Button' import * as Dialog from '#/components/Dialog' import {useInteractionState} from '#/components/hooks/useInteractionState' +import {ArrowRight_Stroke2_Corner0_Rounded as ArrowRightIcon} from '#/components/icons/Arrow' import {MagnifyingGlass_Stroke2_Corner0_Rounded as SearchIcon} from '#/components/icons/MagnifyingGlass' -import {PersonGroup_Stroke2_Corner2_Rounded as PersonGroupIcon} from '#/components/icons/Person' import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' import {boostInterests, InterestTabs} from '#/components/InterestTabs' import * as ProfileCard from '#/components/ProfileCard' @@ -60,10 +60,16 @@ type Item = key: string } -export function FollowDialog({guide}: {guide: Follow10ProgressGuide}) { +export function FollowDialog({ + guide, + showArrow, +}: { + guide: Follow10ProgressGuide + showArrow?: boolean +}) { const {_} = useLingui() const control = Dialog.useDialogControl() - const {gtMobile} = useBreakpoints() + const {gtPhone} = useBreakpoints() const {height: minHeight} = useWindowDimensions() return ( @@ -74,13 +80,12 @@ export function FollowDialog({guide}: {guide: Follow10ProgressGuide}) { control.open() logEvent('progressGuide:followDialog:open', {}) }} - size={gtMobile ? 'small' : 'large'} - color="primary" - variant="solid"> - + size={gtPhone ? 'small' : 'large'} + color="primary"> Find people to follow + {showArrow && } diff --git a/src/components/ProgressGuide/List.tsx b/src/components/ProgressGuide/List.tsx index cae307a6dd..81abc586f3 100644 --- a/src/components/ProgressGuide/List.tsx +++ b/src/components/ProgressGuide/List.tsx @@ -2,37 +2,62 @@ import {type StyleProp, View, type ViewStyle} from 'react-native' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' +import {useProfileFollowsQuery} from '#/state/queries/profile-follows' +import {useSession} from '#/state/session' import { useProgressGuide, useProgressGuideControls, } from '#/state/shell/progress-guide' -import {atoms as a, useTheme} from '#/alf' +import {UserAvatar} from '#/view/com/util/UserAvatar' +import {atoms as a, useBreakpoints, useLayoutBreakpoints, useTheme} from '#/alf' import {Button, ButtonIcon} from '#/components/Button' +import {Person_Stroke2_Corner2_Rounded as PersonIcon} from '#/components/icons/Person' import {TimesLarge_Stroke2_Corner0_Rounded as Times} from '#/components/icons/Times' import {Text} from '#/components/Typography' +import type * as bsky from '#/types/bsky' import {FollowDialog} from './FollowDialog' import {ProgressGuideTask} from './Task' +const TOTAL_AVATARS = 10 + export function ProgressGuideList({style}: {style?: StyleProp}) { const t = useTheme() const {_} = useLingui() + const {gtPhone} = useBreakpoints() + const {rightNavVisible} = useLayoutBreakpoints() + const {currentAccount} = useSession() const followProgressGuide = useProgressGuide('follow-10') const followAndLikeProgressGuide = useProgressGuide('like-10-and-follow-7') const guide = followProgressGuide || followAndLikeProgressGuide const {endProgressGuide} = useProgressGuideControls() + const {data: follows} = useProfileFollowsQuery(currentAccount?.did, { + limit: TOTAL_AVATARS, + }) + + const actualFollowsCount = follows?.pages?.[0]?.follows?.length ?? 0 + + // Hide if user already follows 10+ people + if (guide?.guide === 'follow-10' && actualFollowsCount >= TOTAL_AVATARS) { + return null + } + + // Inline layout when left nav visible but no right sidebar (800-1100px) + const inlineLayout = gtPhone && !rightNavVisible if (guide) { return ( - + - - Getting started + + Follow 10 people to get started {guide.guide === 'follow-10' && ( - <> - - - + + + + )} {guide.guide === 'like-10-and-follow-7' && ( <> @@ -76,3 +109,73 @@ export function ProgressGuideList({style}: {style?: StyleProp}) { } return null } + +function StackedAvatars({follows}: {follows?: bsky.profile.AnyProfileView[]}) { + const t = useTheme() + const {centerColumnOffset} = useLayoutBreakpoints() + + // Smaller avatars for narrower viewport + const avatarSize = centerColumnOffset ? 30 : 37 + const overlap = centerColumnOffset ? 9 : 11 + const iconSize = centerColumnOffset ? 14 : 18 + + // Use actual follows count, not the guide's event counter + const followedAvatars = follows?.slice(0, TOTAL_AVATARS) ?? [] + const remainingSlots = TOTAL_AVATARS - followedAvatars.length + + // Total width calculation: first avatar + (remaining * visible portion) + const totalWidth = avatarSize + (TOTAL_AVATARS - 1) * (avatarSize - overlap) + + return ( + + {/* Show followed user avatars */} + {followedAvatars.map((follow, i) => ( + + + + ))} + {/* Show placeholder avatars for remaining slots */} + {Array(remainingSlots) + .fill(0) + .map((_, i) => ( + + + + ))} + + ) +} diff --git a/src/components/ProgressGuide/Task.tsx b/src/components/ProgressGuide/Task.tsx index 449a28fcd3..997c777af6 100644 --- a/src/components/ProgressGuide/Task.tsx +++ b/src/components/ProgressGuide/Task.tsx @@ -31,11 +31,11 @@ export function ProgressGuideTask({ size={20} thickness={3} borderWidth={0} - unfilledColor={t.palette.contrast_50} + unfilledColor={t.palette.contrast_100} /> )} - + @@ -93,6 +93,7 @@ export function TrendingTopic({ a.font_semi_bold, a.leading_tight, isSmall ? [a.text_sm] : [a.text_md, {paddingBottom: 1}], + hovered && {textDecorationLine: 'underline'}, ]} numberOfLines={1}> {topic.displayName} diff --git a/src/components/interstitials/Trending.tsx b/src/components/interstitials/Trending.tsx index 2580ef28f3..3d47385ee8 100644 --- a/src/components/interstitials/Trending.tsx +++ b/src/components/interstitials/Trending.tsx @@ -99,10 +99,9 @@ export function Inner() { {topic.topic} diff --git a/src/state/queries/profile.ts b/src/state/queries/profile.ts index 9d30288d40..94b362657b 100644 --- a/src/state/queries/profile.ts +++ b/src/state/queries/profile.ts @@ -4,12 +4,14 @@ import { type AppBskyActorGetProfile, type AppBskyActorGetProfiles, type AppBskyActorProfile, + type AppBskyGraphGetFollows, AtUri, type BskyAgent, type ComAtprotoRepoUploadBlob, type Un$Typed, } from '@atproto/api' import { + type InfiniteData, keepPreviousData, type QueryClient, useMutation, @@ -26,6 +28,7 @@ import {type Shadow} from '#/state/cache/types' import {type ImageMeta} from '#/state/gallery' import {STALE} from '#/state/queries' import {resetProfilePostsQueries} from '#/state/queries/post-feed' +import {RQKEY as PROFILE_FOLLOWS_RQKEY} from '#/state/queries/profile-follows' import { unstableCacheProfileView, useUnstableProfileViewCache, @@ -247,6 +250,7 @@ export function useProfileFollowMutationQueue( ) { const agent = useAgent() const queryClient = useQueryClient() + const {currentAccount} = useSession() const did = profile.did const initialFollowingUri = profile.viewer?.following const followMutation = useProfileFollowMutation( @@ -283,6 +287,47 @@ export function useProfileFollowMutationQueue( followingUri: finalFollowingUri, }) + // Optimistically update profile follows cache for avatar displays + if (currentAccount?.did) { + type FollowsQueryData = + InfiniteData + queryClient.setQueryData( + PROFILE_FOLLOWS_RQKEY(currentAccount.did), + old => { + if (!old?.pages?.[0]) return old + if (finalFollowingUri) { + // Add the followed profile to the beginning + const alreadyExists = old.pages[0].follows.some( + f => f.did === profile.did, + ) + if (alreadyExists) return old + return { + ...old, + pages: [ + { + ...old.pages[0], + follows: [ + profile as AppBskyActorDefs.ProfileView, + ...old.pages[0].follows, + ], + }, + ...old.pages.slice(1), + ], + } + } else { + // Remove the unfollowed profile + return { + ...old, + pages: old.pages.map(page => ({ + ...page, + follows: page.follows.filter(f => f.did !== profile.did), + })), + } + } + }, + ) + } + if (finalFollowingUri) { agent.app.bsky.graph .getSuggestedFollowsByActor({ diff --git a/src/view/shell/desktop/Feeds.tsx b/src/view/shell/desktop/Feeds.tsx index 641b90f3e3..57b078e0c1 100644 --- a/src/view/shell/desktop/Feeds.tsx +++ b/src/view/shell/desktop/Feeds.tsx @@ -1,4 +1,4 @@ -import {View} from 'react-native' +import {Pressable, View} from 'react-native' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useNavigation, useNavigationState} from '@react-navigation/native' @@ -7,10 +7,18 @@ import {getCurrentRoute} from '#/lib/routes/helpers' import {type NavigationProp} from '#/lib/routes/types' import {logger} from '#/logger' import {emitSoftReset} from '#/state/events' -import {usePinnedFeedsInfos} from '#/state/queries/feed' +import { + type SavedFeedSourceInfo, + usePinnedFeedsInfos, +} from '#/state/queries/feed' import {useSelectedFeed, useSetSelectedFeed} from '#/state/shell/selected-feed' +import {UserAvatar} from '#/view/com/util/UserAvatar' import {atoms as a, useTheme, web} from '#/alf' -import {createStaticClick, InlineLinkText} from '#/components/Link' +import {useInteractionState} from '#/components/hooks/useInteractionState' +import {FilterTimeline_Stroke2_Corner0_Rounded as FilterTimeline} from '#/components/icons/FilterTimeline' +import {PlusSmall_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' +import {Link} from '#/components/Link' +import {Text} from '#/components/Typography' export function DesktopFeeds() { const t = useTheme() @@ -57,13 +65,12 @@ export function DesktopFeeds() { style={[ a.flex_1, web({ - gap: 10, + gap: 2, /* * Small padding prevents overflow prior to actually overflowing the * height of the screen with lots of feeds. */ - paddingVertical: 2, - marginHorizontal: -2, + paddingTop: 2, overflowY: 'auto', }), ]}> @@ -72,10 +79,11 @@ export function DesktopFeeds() { const current = route.name === 'Home' && feed === selectedFeed return ( - { + feedInfo={feedInfo} + current={current} + onPress={() => { logger.metric( 'desktopFeeds:feed:click', { @@ -89,39 +97,143 @@ export function DesktopFeeds() { if (route.name === 'Home' && feed === selectedFeed) { emitSoftReset() } - })} - style={[ - a.text_md, - a.leading_snug, - a.flex_shrink_0, - current - ? [a.font_semi_bold, t.atoms.text] - : [t.atoms.text_contrast_medium], - web({ - marginHorizontal: 2, - width: 'calc(100% - 4px)', - }), - ]} - numberOfLines={1}> - {feedInfo.displayName} - + }} + /> ) })} - - {_(msg`More feeds`)} - + a.flex_row, + a.align_center, + a.gap_sm, + a.self_start, + a.rounded_sm, + {paddingVertical: 6, paddingHorizontal: 8}, + route.name === 'Feeds' && {backgroundColor: t.palette.primary_50}, + ]}> + {({hovered}) => { + const isActive = route.name === 'Feeds' + return ( + <> + + + + + {_(msg`More feeds`)} + + + ) + }} + ) } + +function FeedItem({ + feedInfo, + current, + onPress, +}: { + feedInfo: SavedFeedSourceInfo + current: boolean + onPress: () => void +}) { + const t = useTheme() + const {_} = useLingui() + const { + state: hovered, + onIn: onHoverIn, + onOut: onHoverOut, + } = useInteractionState() + const isFollowing = feedInfo.feedDescriptor === 'following' + + return ( + + {isFollowing ? ( + + + + ) : ( + + )} + + {feedInfo.displayName} + + + ) +} diff --git a/src/view/shell/desktop/RightNav.tsx b/src/view/shell/desktop/RightNav.tsx index 1d097fc9a8..788df6e64c 100644 --- a/src/view/shell/desktop/RightNav.tsx +++ b/src/view/shell/desktop/RightNav.tsx @@ -18,7 +18,6 @@ import { web, } from '#/alf' import {AppLanguageDropdown} from '#/components/AppLanguageDropdown' -import {Divider} from '#/components/Divider' import {CENTER_COLUMN_OFFSET} from '#/components/Layout' import {InlineLinkText} from '#/components/Link' import {ProgressGuideList} from '#/components/ProgressGuide/List' @@ -86,9 +85,8 @@ export function DesktopRightNav({routeName}: {routeName: string}) { {hasSession && ( <> - - + )} @@ -102,25 +100,31 @@ export function DesktopRightNav({routeName}: {routeName: string}) { email: currentAccount?.email, handle: currentAccount?.handle, })} + style={[t.atoms.text_contrast_medium]} label={_(msg`Feedback`)}> {_(msg`Feedback`)} - {' • '} + {' ∙ '} )} {_(msg`Privacy`)} - {' • '} + {' ∙ '} {_(msg`Terms`)} - {' • '} - + {' ∙ '} + {_(msg`Help`)} diff --git a/src/view/shell/desktop/SidebarTrendingTopics.tsx b/src/view/shell/desktop/SidebarTrendingTopics.tsx index 11dcff3a4d..913b9ccab9 100644 --- a/src/view/shell/desktop/SidebarTrendingTopics.tsx +++ b/src/view/shell/desktop/SidebarTrendingTopics.tsx @@ -1,9 +1,8 @@ -import React from 'react' import {View} from 'react-native' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {logEvent} from '#/lib/statsig/statsig' +import {logger} from '#/logger' import { useTrendingSettings, useTrendingSettingsApi, @@ -12,18 +11,13 @@ import {useTrendingTopics} from '#/state/queries/trending/useTrendingTopics' import {useTrendingConfig} from '#/state/service-config' import {atoms as a, useTheme} from '#/alf' import {Button, ButtonIcon} from '#/components/Button' -import {Divider} from '#/components/Divider' -import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' -import {Trending2_Stroke2_Corner2_Rounded as Graph} from '#/components/icons/Trending' +import {DotGrid_Stroke2_Corner0_Rounded as Ellipsis} from '#/components/icons/DotGrid' +import {Trending3_Stroke2_Corner1_Rounded as TrendingIcon} from '#/components/icons/Trending' import * as Prompt from '#/components/Prompt' -import { - TrendingTopic, - TrendingTopicLink, - TrendingTopicSkeleton, -} from '#/components/TrendingTopics' +import {TrendingTopicLink} from '#/components/TrendingTopics' import {Text} from '#/components/Typography' -const TRENDING_LIMIT = 6 +const TRENDING_LIMIT = 5 export function SidebarTrendingTopics() { const {enabled} = useTrendingConfig() @@ -39,64 +33,88 @@ function Inner() { const {data: trending, error, isLoading} = useTrendingTopics() const noTopics = !isLoading && !error && !trending?.topics?.length - const onConfirmHide = React.useCallback(() => { - logEvent('trendingTopics:hide', {context: 'sidebar'}) + const onConfirmHide = () => { + logger.metric('trendingTopics:hide', {context: 'sidebar'}) setTrendingDisabled(true) - }, [setTrendingDisabled]) + } return error || noTopics ? null : ( <> - - - - + + + + Trending - + {isLoading ? ( Array(TRENDING_LIMIT) .fill(0) .map((_n, i) => ( - + + + {i + 1}. + + + )) ) : !trending?.topics ? null : ( <> - {trending.topics.slice(0, TRENDING_LIMIT).map(topic => ( + {trending.topics.slice(0, TRENDING_LIMIT).map((topic, i) => ( { - logEvent('trendingTopic:click', {context: 'sidebar'}) + logger.metric('trendingTopic:click', {context: 'sidebar'}) }}> {({hovered}) => ( - + + + {i + 1}. + + + {topic.displayName ?? topic.topic} + + )} ))} @@ -111,7 +129,6 @@ function Inner() { confirmButtonCta={_(msg`Hide`)} onConfirm={onConfirmHide} /> - ) }