Bluesky app fork with some witchin' additions 💫

Cleaner sidebar layout (#9603)

authored by

Alex Benzer and committed by
GitHub
c4fd9980 966ae68d

+419 -126
+8 -1
src/components/FeedInterstitials.tsx
··· 933 934 export function ProgressGuide() { 935 const t = useTheme() 936 return ( 937 - <View style={[t.atoms.border_contrast_low, a.px_lg, a.py_lg, a.pb_lg]}> 938 <ProgressGuideList /> 939 </View> 940 )
··· 933 934 export function ProgressGuide() { 935 const t = useTheme() 936 + const {gtMobile} = useBreakpoints() 937 return ( 938 + <View 939 + style={[ 940 + t.atoms.border_contrast_low, 941 + a.px_lg, 942 + a.py_lg, 943 + !gtMobile && {marginTop: 4}, 944 + ]}> 945 <ProgressGuideList /> 946 </View> 947 )
+12 -7
src/components/ProgressGuide/FollowDialog.tsx
··· 31 import {Button, ButtonIcon, ButtonText} from '#/components/Button' 32 import * as Dialog from '#/components/Dialog' 33 import {useInteractionState} from '#/components/hooks/useInteractionState' 34 import {MagnifyingGlass_Stroke2_Corner0_Rounded as SearchIcon} from '#/components/icons/MagnifyingGlass' 35 - import {PersonGroup_Stroke2_Corner2_Rounded as PersonGroupIcon} from '#/components/icons/Person' 36 import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' 37 import {boostInterests, InterestTabs} from '#/components/InterestTabs' 38 import * as ProfileCard from '#/components/ProfileCard' ··· 60 key: string 61 } 62 63 - export function FollowDialog({guide}: {guide: Follow10ProgressGuide}) { 64 const {_} = useLingui() 65 const control = Dialog.useDialogControl() 66 - const {gtMobile} = useBreakpoints() 67 const {height: minHeight} = useWindowDimensions() 68 69 return ( ··· 74 control.open() 75 logEvent('progressGuide:followDialog:open', {}) 76 }} 77 - size={gtMobile ? 'small' : 'large'} 78 - color="primary" 79 - variant="solid"> 80 - <ButtonIcon icon={PersonGroupIcon} /> 81 <ButtonText> 82 <Trans>Find people to follow</Trans> 83 </ButtonText> 84 </Button> 85 <Dialog.Outer control={control} nativeOptions={{minHeight}}> 86 <Dialog.Handle />
··· 31 import {Button, ButtonIcon, ButtonText} from '#/components/Button' 32 import * as Dialog from '#/components/Dialog' 33 import {useInteractionState} from '#/components/hooks/useInteractionState' 34 + import {ArrowRight_Stroke2_Corner0_Rounded as ArrowRightIcon} from '#/components/icons/Arrow' 35 import {MagnifyingGlass_Stroke2_Corner0_Rounded as SearchIcon} from '#/components/icons/MagnifyingGlass' 36 import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' 37 import {boostInterests, InterestTabs} from '#/components/InterestTabs' 38 import * as ProfileCard from '#/components/ProfileCard' ··· 60 key: string 61 } 62 63 + export function FollowDialog({ 64 + guide, 65 + showArrow, 66 + }: { 67 + guide: Follow10ProgressGuide 68 + showArrow?: boolean 69 + }) { 70 const {_} = useLingui() 71 const control = Dialog.useDialogControl() 72 + const {gtPhone} = useBreakpoints() 73 const {height: minHeight} = useWindowDimensions() 74 75 return ( ··· 80 control.open() 81 logEvent('progressGuide:followDialog:open', {}) 82 }} 83 + size={gtPhone ? 'small' : 'large'} 84 + color="primary"> 85 <ButtonText> 86 <Trans>Find people to follow</Trans> 87 </ButtonText> 88 + {showArrow && <ButtonIcon icon={ArrowRightIcon} />} 89 </Button> 90 <Dialog.Outer control={control} nativeOptions={{minHeight}}> 91 <Dialog.Handle />
+124 -21
src/components/ProgressGuide/List.tsx
··· 2 import {msg, Trans} from '@lingui/macro' 3 import {useLingui} from '@lingui/react' 4 5 import { 6 useProgressGuide, 7 useProgressGuideControls, 8 } from '#/state/shell/progress-guide' 9 - import {atoms as a, useTheme} from '#/alf' 10 import {Button, ButtonIcon} from '#/components/Button' 11 import {TimesLarge_Stroke2_Corner0_Rounded as Times} from '#/components/icons/Times' 12 import {Text} from '#/components/Typography' 13 import {FollowDialog} from './FollowDialog' 14 import {ProgressGuideTask} from './Task' 15 16 export function ProgressGuideList({style}: {style?: StyleProp<ViewStyle>}) { 17 const t = useTheme() 18 const {_} = useLingui() 19 const followProgressGuide = useProgressGuide('follow-10') 20 const followAndLikeProgressGuide = useProgressGuide('like-10-and-follow-7') 21 const guide = followProgressGuide || followAndLikeProgressGuide 22 const {endProgressGuide} = useProgressGuideControls() 23 24 if (guide) { 25 return ( 26 - <View style={[a.flex_col, a.gap_md, style]}> 27 <View style={[a.flex_row, a.align_center, a.justify_between]}> 28 - <Text 29 - style={[ 30 - t.atoms.text_contrast_medium, 31 - a.font_semi_bold, 32 - a.text_sm, 33 - {textTransform: 'uppercase'}, 34 - ]}> 35 - <Trans>Getting started</Trans> 36 </Text> 37 <Button 38 variant="ghost" ··· 40 color="secondary" 41 shape="round" 42 label={_(msg`Dismiss getting started guide`)} 43 - onPress={endProgressGuide}> 44 - <ButtonIcon icon={Times} size="sm" /> 45 </Button> 46 </View> 47 {guide.guide === 'follow-10' && ( 48 - <> 49 - <ProgressGuideTask 50 - current={guide.numFollows + 1} 51 - total={10 + 1} 52 - title={_(msg`Follow 10 accounts`)} 53 - subtitle={_(msg`Bluesky is better with friends!`)} 54 - /> 55 - <FollowDialog guide={guide} /> 56 - </> 57 )} 58 {guide.guide === 'like-10-and-follow-7' && ( 59 <> ··· 76 } 77 return null 78 }
··· 2 import {msg, Trans} from '@lingui/macro' 3 import {useLingui} from '@lingui/react' 4 5 + import {useProfileFollowsQuery} from '#/state/queries/profile-follows' 6 + import {useSession} from '#/state/session' 7 import { 8 useProgressGuide, 9 useProgressGuideControls, 10 } from '#/state/shell/progress-guide' 11 + import {UserAvatar} from '#/view/com/util/UserAvatar' 12 + import {atoms as a, useBreakpoints, useLayoutBreakpoints, useTheme} from '#/alf' 13 import {Button, ButtonIcon} from '#/components/Button' 14 + import {Person_Stroke2_Corner2_Rounded as PersonIcon} from '#/components/icons/Person' 15 import {TimesLarge_Stroke2_Corner0_Rounded as Times} from '#/components/icons/Times' 16 import {Text} from '#/components/Typography' 17 + import type * as bsky from '#/types/bsky' 18 import {FollowDialog} from './FollowDialog' 19 import {ProgressGuideTask} from './Task' 20 21 + const TOTAL_AVATARS = 10 22 + 23 export function ProgressGuideList({style}: {style?: StyleProp<ViewStyle>}) { 24 const t = useTheme() 25 const {_} = useLingui() 26 + const {gtPhone} = useBreakpoints() 27 + const {rightNavVisible} = useLayoutBreakpoints() 28 + const {currentAccount} = useSession() 29 const followProgressGuide = useProgressGuide('follow-10') 30 const followAndLikeProgressGuide = useProgressGuide('like-10-and-follow-7') 31 const guide = followProgressGuide || followAndLikeProgressGuide 32 const {endProgressGuide} = useProgressGuideControls() 33 + const {data: follows} = useProfileFollowsQuery(currentAccount?.did, { 34 + limit: TOTAL_AVATARS, 35 + }) 36 + 37 + const actualFollowsCount = follows?.pages?.[0]?.follows?.length ?? 0 38 + 39 + // Hide if user already follows 10+ people 40 + if (guide?.guide === 'follow-10' && actualFollowsCount >= TOTAL_AVATARS) { 41 + return null 42 + } 43 + 44 + // Inline layout when left nav visible but no right sidebar (800-1100px) 45 + const inlineLayout = gtPhone && !rightNavVisible 46 47 if (guide) { 48 return ( 49 + <View 50 + style={[ 51 + a.flex_col, 52 + a.gap_md, 53 + a.rounded_md, 54 + t.atoms.bg_contrast_25, 55 + a.p_lg, 56 + style, 57 + ]}> 58 <View style={[a.flex_row, a.align_center, a.justify_between]}> 59 + <Text style={[t.atoms.text, a.font_semi_bold, a.text_md]}> 60 + <Trans>Follow 10 people to get started</Trans> 61 </Text> 62 <Button 63 variant="ghost" ··· 65 color="secondary" 66 shape="round" 67 label={_(msg`Dismiss getting started guide`)} 68 + onPress={endProgressGuide} 69 + style={[a.bg_transparent, {marginTop: -6, marginRight: -6}]}> 70 + <ButtonIcon icon={Times} size="xs" /> 71 </Button> 72 </View> 73 {guide.guide === 'follow-10' && ( 74 + <View 75 + style={[ 76 + inlineLayout 77 + ? [ 78 + a.flex_row, 79 + a.flex_wrap, 80 + a.align_center, 81 + a.justify_between, 82 + a.gap_sm, 83 + ] 84 + : a.flex_col, 85 + !inlineLayout && a.gap_md, 86 + ]}> 87 + <StackedAvatars follows={follows?.pages?.[0]?.follows} /> 88 + <FollowDialog guide={guide} showArrow={inlineLayout} /> 89 + </View> 90 )} 91 {guide.guide === 'like-10-and-follow-7' && ( 92 <> ··· 109 } 110 return null 111 } 112 + 113 + function StackedAvatars({follows}: {follows?: bsky.profile.AnyProfileView[]}) { 114 + const t = useTheme() 115 + const {centerColumnOffset} = useLayoutBreakpoints() 116 + 117 + // Smaller avatars for narrower viewport 118 + const avatarSize = centerColumnOffset ? 30 : 37 119 + const overlap = centerColumnOffset ? 9 : 11 120 + const iconSize = centerColumnOffset ? 14 : 18 121 + 122 + // Use actual follows count, not the guide's event counter 123 + const followedAvatars = follows?.slice(0, TOTAL_AVATARS) ?? [] 124 + const remainingSlots = TOTAL_AVATARS - followedAvatars.length 125 + 126 + // Total width calculation: first avatar + (remaining * visible portion) 127 + const totalWidth = avatarSize + (TOTAL_AVATARS - 1) * (avatarSize - overlap) 128 + 129 + return ( 130 + <View style={[a.flex_row, a.self_start, {width: totalWidth}]}> 131 + {/* Show followed user avatars */} 132 + {followedAvatars.map((follow, i) => ( 133 + <View 134 + key={follow.did} 135 + style={[ 136 + a.rounded_full, 137 + { 138 + marginLeft: i === 0 ? 0 : -overlap, 139 + zIndex: TOTAL_AVATARS - i, 140 + borderWidth: 2, 141 + borderColor: t.atoms.bg_contrast_25.backgroundColor, 142 + }, 143 + ]}> 144 + <UserAvatar 145 + type="user" 146 + size={avatarSize - 4} 147 + avatar={follow.avatar} 148 + /> 149 + </View> 150 + ))} 151 + {/* Show placeholder avatars for remaining slots */} 152 + {Array(remainingSlots) 153 + .fill(0) 154 + .map((_, i) => ( 155 + <View 156 + key={`placeholder-${i}`} 157 + style={[ 158 + a.align_center, 159 + a.justify_center, 160 + a.rounded_full, 161 + t.atoms.bg_contrast_100, 162 + { 163 + width: avatarSize, 164 + height: avatarSize, 165 + marginLeft: 166 + followedAvatars.length === 0 && i === 0 ? 0 : -overlap, 167 + zIndex: TOTAL_AVATARS - followedAvatars.length - i, 168 + borderWidth: 2, 169 + borderColor: t.atoms.bg_contrast_25.backgroundColor, 170 + }, 171 + ]}> 172 + <PersonIcon 173 + width={iconSize} 174 + height={iconSize} 175 + fill={t.atoms.text_contrast_low.color} 176 + /> 177 + </View> 178 + ))} 179 + </View> 180 + ) 181 + }
+2 -2
src/components/ProgressGuide/Task.tsx
··· 31 size={20} 32 thickness={3} 33 borderWidth={0} 34 - unfilledColor={t.palette.contrast_50} 35 /> 36 )} 37 38 - <View style={[a.flex_col, a.gap_2xs, subtitle && {marginTop: -2}]}> 39 <Text 40 style={[ 41 a.text_sm,
··· 31 size={20} 32 thickness={3} 33 borderWidth={0} 34 + unfilledColor={t.palette.contrast_100} 35 /> 36 )} 37 38 + <View style={[a.flex_col, a.gap_xs, subtitle && {marginTop: -2}]}> 39 <Text 40 style={[ 41 a.text_sm,
+10 -9
src/components/TrendingTopics.tsx
··· 20 topic: raw, 21 size, 22 style, 23 - }: {topic: TrendingTopic; size?: 'large' | 'small'} & ViewStyleProp) { 24 - const t = useTheme() 25 const topic = useTopic(raw) 26 27 const isSmall = size === 'small' ··· 33 style={[ 34 a.flex_row, 35 a.align_center, 36 - a.rounded_full, 37 - a.border, 38 - t.atoms.border_contrast_medium, 39 - t.atoms.bg, 40 isSmall 41 ? [ 42 { 43 - paddingVertical: 5, 44 - paddingHorizontal: 10, 45 }, 46 ] 47 - : [a.py_sm, a.px_md], 48 hasIcon && {gap: 6}, 49 style, 50 ]}> ··· 93 a.font_semi_bold, 94 a.leading_tight, 95 isSmall ? [a.text_sm] : [a.text_md, {paddingBottom: 1}], 96 ]} 97 numberOfLines={1}> 98 {topic.displayName}
··· 20 topic: raw, 21 size, 22 style, 23 + hovered, 24 + }: { 25 + topic: TrendingTopic 26 + size?: 'large' | 'small' 27 + hovered?: boolean 28 + } & ViewStyleProp) { 29 const topic = useTopic(raw) 30 31 const isSmall = size === 'small' ··· 37 style={[ 38 a.flex_row, 39 a.align_center, 40 isSmall 41 ? [ 42 { 43 + paddingVertical: 2, 44 + paddingHorizontal: 4, 45 }, 46 ] 47 + : [a.py_xs, a.px_sm], 48 hasIcon && {gap: 6}, 49 style, 50 ]}> ··· 93 a.font_semi_bold, 94 a.leading_tight, 95 isSmall ? [a.text_sm] : [a.text_md, {paddingBottom: 1}], 96 + hovered && {textDecorationLine: 'underline'}, 97 ]} 98 numberOfLines={1}> 99 {topic.displayName}
+1 -2
src/components/interstitials/Trending.tsx
··· 99 <View style={[a.py_lg]}> 100 <Text 101 style={[ 102 - t.atoms.text, 103 a.text_sm, 104 a.font_semi_bold, 105 - {opacity: 0.7}, // NOTE: we use opacity 0.7 instead of a color to match the color of the home pager tab bar 106 ]}> 107 {topic.topic} 108 </Text>
··· 99 <View style={[a.py_lg]}> 100 <Text 101 style={[ 102 + t.atoms.text_contrast_medium, 103 a.text_sm, 104 a.font_semi_bold, 105 ]}> 106 {topic.topic} 107 </Text>
+45
src/state/queries/profile.ts
··· 4 type AppBskyActorGetProfile, 5 type AppBskyActorGetProfiles, 6 type AppBskyActorProfile, 7 AtUri, 8 type BskyAgent, 9 type ComAtprotoRepoUploadBlob, 10 type Un$Typed, 11 } from '@atproto/api' 12 import { 13 keepPreviousData, 14 type QueryClient, 15 useMutation, ··· 26 import {type ImageMeta} from '#/state/gallery' 27 import {STALE} from '#/state/queries' 28 import {resetProfilePostsQueries} from '#/state/queries/post-feed' 29 import { 30 unstableCacheProfileView, 31 useUnstableProfileViewCache, ··· 247 ) { 248 const agent = useAgent() 249 const queryClient = useQueryClient() 250 const did = profile.did 251 const initialFollowingUri = profile.viewer?.following 252 const followMutation = useProfileFollowMutation( ··· 282 updateProfileShadow(queryClient, did, { 283 followingUri: finalFollowingUri, 284 }) 285 286 if (finalFollowingUri) { 287 agent.app.bsky.graph
··· 4 type AppBskyActorGetProfile, 5 type AppBskyActorGetProfiles, 6 type AppBskyActorProfile, 7 + type AppBskyGraphGetFollows, 8 AtUri, 9 type BskyAgent, 10 type ComAtprotoRepoUploadBlob, 11 type Un$Typed, 12 } from '@atproto/api' 13 import { 14 + type InfiniteData, 15 keepPreviousData, 16 type QueryClient, 17 useMutation, ··· 28 import {type ImageMeta} from '#/state/gallery' 29 import {STALE} from '#/state/queries' 30 import {resetProfilePostsQueries} from '#/state/queries/post-feed' 31 + import {RQKEY as PROFILE_FOLLOWS_RQKEY} from '#/state/queries/profile-follows' 32 import { 33 unstableCacheProfileView, 34 useUnstableProfileViewCache, ··· 250 ) { 251 const agent = useAgent() 252 const queryClient = useQueryClient() 253 + const {currentAccount} = useSession() 254 const did = profile.did 255 const initialFollowingUri = profile.viewer?.following 256 const followMutation = useProfileFollowMutation( ··· 286 updateProfileShadow(queryClient, did, { 287 followingUri: finalFollowingUri, 288 }) 289 + 290 + // Optimistically update profile follows cache for avatar displays 291 + if (currentAccount?.did) { 292 + type FollowsQueryData = 293 + InfiniteData<AppBskyGraphGetFollows.OutputSchema> 294 + queryClient.setQueryData<FollowsQueryData>( 295 + PROFILE_FOLLOWS_RQKEY(currentAccount.did), 296 + old => { 297 + if (!old?.pages?.[0]) return old 298 + if (finalFollowingUri) { 299 + // Add the followed profile to the beginning 300 + const alreadyExists = old.pages[0].follows.some( 301 + f => f.did === profile.did, 302 + ) 303 + if (alreadyExists) return old 304 + return { 305 + ...old, 306 + pages: [ 307 + { 308 + ...old.pages[0], 309 + follows: [ 310 + profile as AppBskyActorDefs.ProfileView, 311 + ...old.pages[0].follows, 312 + ], 313 + }, 314 + ...old.pages.slice(1), 315 + ], 316 + } 317 + } else { 318 + // Remove the unfollowed profile 319 + return { 320 + ...old, 321 + pages: old.pages.map(page => ({ 322 + ...page, 323 + follows: page.follows.filter(f => f.did !== profile.did), 324 + })), 325 + } 326 + } 327 + }, 328 + ) 329 + } 330 331 if (finalFollowingUri) { 332 agent.app.bsky.graph
+145 -33
src/view/shell/desktop/Feeds.tsx
··· 1 - import {View} from 'react-native' 2 import {msg} from '@lingui/macro' 3 import {useLingui} from '@lingui/react' 4 import {useNavigation, useNavigationState} from '@react-navigation/native' ··· 7 import {type NavigationProp} from '#/lib/routes/types' 8 import {logger} from '#/logger' 9 import {emitSoftReset} from '#/state/events' 10 - import {usePinnedFeedsInfos} from '#/state/queries/feed' 11 import {useSelectedFeed, useSetSelectedFeed} from '#/state/shell/selected-feed' 12 import {atoms as a, useTheme, web} from '#/alf' 13 - import {createStaticClick, InlineLinkText} from '#/components/Link' 14 15 export function DesktopFeeds() { 16 const t = useTheme() ··· 57 style={[ 58 a.flex_1, 59 web({ 60 - gap: 10, 61 /* 62 * Small padding prevents overflow prior to actually overflowing the 63 * height of the screen with lots of feeds. 64 */ 65 - paddingVertical: 2, 66 - marginHorizontal: -2, 67 overflowY: 'auto', 68 }), 69 ]}> ··· 72 const current = route.name === 'Home' && feed === selectedFeed 73 74 return ( 75 - <InlineLinkText 76 key={feedInfo.uri} 77 - label={feedInfo.displayName} 78 - {...createStaticClick(() => { 79 logger.metric( 80 'desktopFeeds:feed:click', 81 { ··· 89 if (route.name === 'Home' && feed === selectedFeed) { 90 emitSoftReset() 91 } 92 - })} 93 - style={[ 94 - a.text_md, 95 - a.leading_snug, 96 - a.flex_shrink_0, 97 - current 98 - ? [a.font_semi_bold, t.atoms.text] 99 - : [t.atoms.text_contrast_medium], 100 - web({ 101 - marginHorizontal: 2, 102 - width: 'calc(100% - 4px)', 103 - }), 104 - ]} 105 - numberOfLines={1}> 106 - {feedInfo.displayName} 107 - </InlineLinkText> 108 ) 109 })} 110 111 - <InlineLinkText 112 to="/feeds" 113 label={_(msg`More feeds`)} 114 style={[ 115 a.text_md, 116 a.leading_snug, 117 - web({ 118 - marginHorizontal: 2, 119 - width: 'calc(100% - 4px)', 120 - }), 121 ]} 122 numberOfLines={1}> 123 - {_(msg`More feeds`)} 124 - </InlineLinkText> 125 - </View> 126 ) 127 }
··· 1 + import {Pressable, View} from 'react-native' 2 import {msg} from '@lingui/macro' 3 import {useLingui} from '@lingui/react' 4 import {useNavigation, useNavigationState} from '@react-navigation/native' ··· 7 import {type NavigationProp} from '#/lib/routes/types' 8 import {logger} from '#/logger' 9 import {emitSoftReset} from '#/state/events' 10 + import { 11 + type SavedFeedSourceInfo, 12 + usePinnedFeedsInfos, 13 + } from '#/state/queries/feed' 14 import {useSelectedFeed, useSetSelectedFeed} from '#/state/shell/selected-feed' 15 + import {UserAvatar} from '#/view/com/util/UserAvatar' 16 import {atoms as a, useTheme, web} from '#/alf' 17 + import {useInteractionState} from '#/components/hooks/useInteractionState' 18 + import {FilterTimeline_Stroke2_Corner0_Rounded as FilterTimeline} from '#/components/icons/FilterTimeline' 19 + import {PlusSmall_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' 20 + import {Link} from '#/components/Link' 21 + import {Text} from '#/components/Typography' 22 23 export function DesktopFeeds() { 24 const t = useTheme() ··· 65 style={[ 66 a.flex_1, 67 web({ 68 + gap: 2, 69 /* 70 * Small padding prevents overflow prior to actually overflowing the 71 * height of the screen with lots of feeds. 72 */ 73 + paddingTop: 2, 74 overflowY: 'auto', 75 }), 76 ]}> ··· 79 const current = route.name === 'Home' && feed === selectedFeed 80 81 return ( 82 + <FeedItem 83 key={feedInfo.uri} 84 + feedInfo={feedInfo} 85 + current={current} 86 + onPress={() => { 87 logger.metric( 88 'desktopFeeds:feed:click', 89 { ··· 97 if (route.name === 'Home' && feed === selectedFeed) { 98 emitSoftReset() 99 } 100 + }} 101 + /> 102 ) 103 })} 104 105 + <Link 106 to="/feeds" 107 label={_(msg`More feeds`)} 108 style={[ 109 + a.flex_row, 110 + a.align_center, 111 + a.gap_sm, 112 + a.self_start, 113 + a.rounded_sm, 114 + {paddingVertical: 6, paddingHorizontal: 8}, 115 + route.name === 'Feeds' && {backgroundColor: t.palette.primary_50}, 116 + ]}> 117 + {({hovered}) => { 118 + const isActive = route.name === 'Feeds' 119 + return ( 120 + <> 121 + <View 122 + style={[ 123 + a.align_center, 124 + a.justify_center, 125 + a.rounded_xs, 126 + isActive 127 + ? {backgroundColor: t.palette.primary_100} 128 + : t.atoms.bg_contrast_50, 129 + { 130 + width: 20, 131 + height: 20, 132 + }, 133 + ]}> 134 + <Plus 135 + style={{width: 16, height: 16}} 136 + fill={ 137 + isActive || hovered 138 + ? t.atoms.text.color 139 + : t.atoms.text_contrast_medium.color 140 + } 141 + /> 142 + </View> 143 + <Text 144 + style={[ 145 + a.text_md, 146 + a.leading_snug, 147 + isActive 148 + ? [t.atoms.text, a.font_semi_bold] 149 + : hovered 150 + ? t.atoms.text 151 + : t.atoms.text_contrast_medium, 152 + ]} 153 + numberOfLines={1}> 154 + {_(msg`More feeds`)} 155 + </Text> 156 + </> 157 + ) 158 + }} 159 + </Link> 160 + </View> 161 + ) 162 + } 163 + 164 + function FeedItem({ 165 + feedInfo, 166 + current, 167 + onPress, 168 + }: { 169 + feedInfo: SavedFeedSourceInfo 170 + current: boolean 171 + onPress: () => void 172 + }) { 173 + const t = useTheme() 174 + const {_} = useLingui() 175 + const { 176 + state: hovered, 177 + onIn: onHoverIn, 178 + onOut: onHoverOut, 179 + } = useInteractionState() 180 + const isFollowing = feedInfo.feedDescriptor === 'following' 181 + 182 + return ( 183 + <Pressable 184 + accessibilityRole="link" 185 + accessibilityLabel={feedInfo.displayName} 186 + accessibilityHint={_(msg`Opens ${feedInfo.displayName} feed`)} 187 + onPress={onPress} 188 + onHoverIn={onHoverIn} 189 + onHoverOut={onHoverOut} 190 + style={[ 191 + a.flex_row, 192 + a.align_center, 193 + a.gap_sm, 194 + a.self_start, 195 + a.rounded_sm, 196 + {paddingVertical: 6, paddingHorizontal: 8}, 197 + current && {backgroundColor: t.palette.primary_50}, 198 + ]}> 199 + {isFollowing ? ( 200 + <View 201 + style={[ 202 + a.align_center, 203 + a.justify_center, 204 + a.rounded_xs, 205 + { 206 + width: 20, 207 + height: 20, 208 + backgroundColor: t.palette.primary_500, 209 + }, 210 + ]}> 211 + <FilterTimeline 212 + style={{width: 14, height: 14}} 213 + fill={t.palette.white} 214 + /> 215 + </View> 216 + ) : ( 217 + <UserAvatar 218 + type={feedInfo.type === 'list' ? 'list' : 'algo'} 219 + size={20} 220 + avatar={feedInfo.avatar} 221 + noBorder 222 + /> 223 + )} 224 + <Text 225 + style={[ 226 a.text_md, 227 a.leading_snug, 228 + current 229 + ? [t.atoms.text, a.font_semi_bold] 230 + : hovered 231 + ? t.atoms.text 232 + : t.atoms.text_contrast_medium, 233 ]} 234 numberOfLines={1}> 235 + {feedInfo.displayName} 236 + </Text> 237 + </Pressable> 238 ) 239 }
+11 -7
src/view/shell/desktop/RightNav.tsx
··· 18 web, 19 } from '#/alf' 20 import {AppLanguageDropdown} from '#/components/AppLanguageDropdown' 21 - import {Divider} from '#/components/Divider' 22 import {CENTER_COLUMN_OFFSET} from '#/components/Layout' 23 import {InlineLinkText} from '#/components/Link' 24 import {ProgressGuideList} from '#/components/ProgressGuide/List' ··· 86 87 {hasSession && ( 88 <> 89 - <ProgressGuideList /> 90 <DesktopFeeds /> 91 - <Divider /> 92 </> 93 )} 94 ··· 102 email: currentAccount?.email, 103 handle: currentAccount?.handle, 104 })} 105 label={_(msg`Feedback`)}> 106 {_(msg`Feedback`)} 107 </InlineLinkText> 108 - {' • '} 109 </> 110 )} 111 <InlineLinkText 112 to="https://bsky.social/about/support/privacy-policy" 113 label={_(msg`Privacy`)}> 114 {_(msg`Privacy`)} 115 </InlineLinkText> 116 - {' • '} 117 <InlineLinkText 118 to="https://bsky.social/about/support/tos" 119 label={_(msg`Terms`)}> 120 {_(msg`Terms`)} 121 </InlineLinkText> 122 - {' • '} 123 - <InlineLinkText label={_(msg`Help`)} to={HELP_DESK_URL}> 124 {_(msg`Help`)} 125 </InlineLinkText> 126 </Text>
··· 18 web, 19 } from '#/alf' 20 import {AppLanguageDropdown} from '#/components/AppLanguageDropdown' 21 import {CENTER_COLUMN_OFFSET} from '#/components/Layout' 22 import {InlineLinkText} from '#/components/Link' 23 import {ProgressGuideList} from '#/components/ProgressGuide/List' ··· 85 86 {hasSession && ( 87 <> 88 <DesktopFeeds /> 89 + <ProgressGuideList /> 90 </> 91 )} 92 ··· 100 email: currentAccount?.email, 101 handle: currentAccount?.handle, 102 })} 103 + style={[t.atoms.text_contrast_medium]} 104 label={_(msg`Feedback`)}> 105 {_(msg`Feedback`)} 106 </InlineLinkText> 107 + <Text style={[t.atoms.text_contrast_low]}>{' ∙ '}</Text> 108 </> 109 )} 110 <InlineLinkText 111 to="https://bsky.social/about/support/privacy-policy" 112 + style={[t.atoms.text_contrast_medium]} 113 label={_(msg`Privacy`)}> 114 {_(msg`Privacy`)} 115 </InlineLinkText> 116 + <Text style={[t.atoms.text_contrast_low]}>{' ∙ '}</Text> 117 <InlineLinkText 118 to="https://bsky.social/about/support/tos" 119 + style={[t.atoms.text_contrast_medium]} 120 label={_(msg`Terms`)}> 121 {_(msg`Terms`)} 122 </InlineLinkText> 123 + <Text style={[t.atoms.text_contrast_low]}>{' ∙ '}</Text> 124 + <InlineLinkText 125 + label={_(msg`Help`)} 126 + to={HELP_DESK_URL} 127 + style={[t.atoms.text_contrast_medium]}> 128 {_(msg`Help`)} 129 </InlineLinkText> 130 </Text>
+61 -44
src/view/shell/desktop/SidebarTrendingTopics.tsx
··· 1 - import React from 'react' 2 import {View} from 'react-native' 3 import {msg, Trans} from '@lingui/macro' 4 import {useLingui} from '@lingui/react' 5 6 - import {logEvent} from '#/lib/statsig/statsig' 7 import { 8 useTrendingSettings, 9 useTrendingSettingsApi, ··· 12 import {useTrendingConfig} from '#/state/service-config' 13 import {atoms as a, useTheme} from '#/alf' 14 import {Button, ButtonIcon} from '#/components/Button' 15 - import {Divider} from '#/components/Divider' 16 - import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' 17 - import {Trending2_Stroke2_Corner2_Rounded as Graph} from '#/components/icons/Trending' 18 import * as Prompt from '#/components/Prompt' 19 - import { 20 - TrendingTopic, 21 - TrendingTopicLink, 22 - TrendingTopicSkeleton, 23 - } from '#/components/TrendingTopics' 24 import {Text} from '#/components/Typography' 25 26 - const TRENDING_LIMIT = 6 27 28 export function SidebarTrendingTopics() { 29 const {enabled} = useTrendingConfig() ··· 39 const {data: trending, error, isLoading} = useTrendingTopics() 40 const noTopics = !isLoading && !error && !trending?.topics?.length 41 42 - const onConfirmHide = React.useCallback(() => { 43 - logEvent('trendingTopics:hide', {context: 'sidebar'}) 44 setTrendingDisabled(true) 45 - }, [setTrendingDisabled]) 46 47 return error || noTopics ? null : ( 48 <> 49 - <View style={[a.gap_sm, {paddingBottom: 2}]}> 50 - <View style={[a.flex_row, a.align_center, a.gap_xs]}> 51 - <Graph size="sm" /> 52 - <Text 53 - style={[ 54 - a.flex_1, 55 - a.text_sm, 56 - a.font_semi_bold, 57 - t.atoms.text_contrast_medium, 58 - ]}> 59 <Trans>Trending</Trans> 60 </Text> 61 <Button 62 - label={_(msg`Hide trending topics`)} 63 size="tiny" 64 - variant="ghost" 65 color="secondary" 66 shape="round" 67 - onPress={() => trendingPrompt.open()}> 68 - <ButtonIcon icon={X} /> 69 </Button> 70 </View> 71 72 - <View style={[a.flex_row, a.flex_wrap, {gap: '6px 4px'}]}> 73 {isLoading ? ( 74 Array(TRENDING_LIMIT) 75 .fill(0) 76 .map((_n, i) => ( 77 - <TrendingTopicSkeleton key={i} size="small" index={i} /> 78 )) 79 ) : !trending?.topics ? null : ( 80 <> 81 - {trending.topics.slice(0, TRENDING_LIMIT).map(topic => ( 82 <TrendingTopicLink 83 key={topic.link} 84 topic={topic} 85 - style={a.rounded_full} 86 onPress={() => { 87 - logEvent('trendingTopic:click', {context: 'sidebar'}) 88 }}> 89 {({hovered}) => ( 90 - <TrendingTopic 91 - size="small" 92 - topic={topic} 93 - style={[ 94 - hovered && [ 95 - t.atoms.border_contrast_high, 96 - t.atoms.bg_contrast_25, 97 - ], 98 - ]} 99 - /> 100 )} 101 </TrendingTopicLink> 102 ))} ··· 111 confirmButtonCta={_(msg`Hide`)} 112 onConfirm={onConfirmHide} 113 /> 114 - <Divider /> 115 </> 116 ) 117 }
··· 1 import {View} from 'react-native' 2 import {msg, Trans} from '@lingui/macro' 3 import {useLingui} from '@lingui/react' 4 5 + import {logger} from '#/logger' 6 import { 7 useTrendingSettings, 8 useTrendingSettingsApi, ··· 11 import {useTrendingConfig} from '#/state/service-config' 12 import {atoms as a, useTheme} from '#/alf' 13 import {Button, ButtonIcon} from '#/components/Button' 14 + import {DotGrid_Stroke2_Corner0_Rounded as Ellipsis} from '#/components/icons/DotGrid' 15 + import {Trending3_Stroke2_Corner1_Rounded as TrendingIcon} from '#/components/icons/Trending' 16 import * as Prompt from '#/components/Prompt' 17 + import {TrendingTopicLink} from '#/components/TrendingTopics' 18 import {Text} from '#/components/Typography' 19 20 + const TRENDING_LIMIT = 5 21 22 export function SidebarTrendingTopics() { 23 const {enabled} = useTrendingConfig() ··· 33 const {data: trending, error, isLoading} = useTrendingTopics() 34 const noTopics = !isLoading && !error && !trending?.topics?.length 35 36 + const onConfirmHide = () => { 37 + logger.metric('trendingTopics:hide', {context: 'sidebar'}) 38 setTrendingDisabled(true) 39 + } 40 41 return error || noTopics ? null : ( 42 <> 43 + <View 44 + style={[a.p_lg, a.rounded_md, a.border, t.atoms.border_contrast_low]}> 45 + <View style={[a.flex_row, a.align_center, a.gap_xs, a.pb_md]}> 46 + <TrendingIcon width={16} height={16} fill={t.atoms.text.color} /> 47 + <Text style={[a.flex_1, a.text_md, a.font_semi_bold, t.atoms.text]}> 48 <Trans>Trending</Trans> 49 </Text> 50 <Button 51 + variant="ghost" 52 size="tiny" 53 color="secondary" 54 shape="round" 55 + label={_(msg`Trending options`)} 56 + onPress={() => trendingPrompt.open()} 57 + style={[a.bg_transparent, {marginTop: -6, marginRight: -6}]}> 58 + <ButtonIcon icon={Ellipsis} size="xs" /> 59 </Button> 60 </View> 61 62 + <View style={[a.gap_xs]}> 63 {isLoading ? ( 64 Array(TRENDING_LIMIT) 65 .fill(0) 66 .map((_n, i) => ( 67 + <View key={i} style={[a.flex_row, a.align_center, a.gap_sm]}> 68 + <Text 69 + style={[ 70 + a.text_sm, 71 + t.atoms.text_contrast_low, 72 + {minWidth: 16}, 73 + ]}> 74 + {i + 1}. 75 + </Text> 76 + <View 77 + style={[ 78 + a.rounded_xs, 79 + t.atoms.bg_contrast_50, 80 + {height: 14, width: i % 2 === 0 ? 80 : 100}, 81 + ]} 82 + /> 83 + </View> 84 )) 85 ) : !trending?.topics ? null : ( 86 <> 87 + {trending.topics.slice(0, TRENDING_LIMIT).map((topic, i) => ( 88 <TrendingTopicLink 89 key={topic.link} 90 topic={topic} 91 + style={[a.self_start]} 92 onPress={() => { 93 + logger.metric('trendingTopic:click', {context: 'sidebar'}) 94 }}> 95 {({hovered}) => ( 96 + <View style={[a.flex_row, a.align_center, a.gap_xs]}> 97 + <Text 98 + style={[ 99 + a.text_sm, 100 + a.leading_snug, 101 + t.atoms.text_contrast_low, 102 + {minWidth: 16}, 103 + ]}> 104 + {i + 1}. 105 + </Text> 106 + <Text 107 + style={[ 108 + a.text_sm, 109 + a.leading_snug, 110 + hovered 111 + ? [t.atoms.text, a.underline] 112 + : t.atoms.text_contrast_medium, 113 + ]} 114 + numberOfLines={1}> 115 + {topic.displayName ?? topic.topic} 116 + </Text> 117 + </View> 118 )} 119 </TrendingTopicLink> 120 ))} ··· 129 confirmButtonCta={_(msg`Hide`)} 130 onConfirm={onConfirmHide} 131 /> 132 </> 133 ) 134 }