Bluesky app fork with some witchin' additions 馃挮
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at 6982eb4fb4d44105dc8b44e898d452d4e5d32c82 305 lines 9.0 kB view raw
1import {useMemo} from 'react' 2import {Pressable, View} from 'react-native' 3import {type AppBskyUnspeccedDefs, moderateProfile} from '@atproto/api' 4import {msg, plural, Trans} from '@lingui/macro' 5import {useLingui} from '@lingui/react' 6 7import {logger} from '#/logger' 8import {useEnableSquareButtons} from '#/state/preferences/enable-square-buttons' 9import {useModerationOpts} from '#/state/preferences/moderation-opts' 10import {useTrendingSettings} from '#/state/preferences/trending' 11import {useGetTrendsQuery} from '#/state/queries/trending/useGetTrendsQuery' 12import {useTrendingConfig} from '#/state/service-config' 13import {LoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' 14import {formatCount} from '#/view/com/util/numeric/format' 15import {atoms as a, useGutters, useTheme, type ViewStyleProp, web} from '#/alf' 16import {AvatarStack} from '#/components/AvatarStack' 17import {type Props as SVGIconProps} from '#/components/icons/common' 18import {Flame_Stroke2_Corner1_Rounded as FlameIcon} from '#/components/icons/Flame' 19import {Trending3_Stroke2_Corner1_Rounded as TrendingIcon} from '#/components/icons/Trending' 20import {Link} from '#/components/Link' 21import {SubtleHover} from '#/components/SubtleHover' 22import {Text} from '#/components/Typography' 23 24const TOPIC_COUNT = 5 25 26export function ExploreTrendingTopics() { 27 const {enabled} = useTrendingConfig() 28 const {trendingDisabled} = useTrendingSettings() 29 return enabled && !trendingDisabled ? <Inner /> : null 30} 31 32function Inner() { 33 const {data: trending, error, isLoading, isRefetching} = useGetTrendsQuery() 34 const noTopics = !isLoading && !error && !trending?.trends?.length 35 36 return isLoading || isRefetching ? ( 37 Array.from({length: TOPIC_COUNT}).map((__, i) => ( 38 <TrendingTopicRowSkeleton key={i} withPosts={i === 0} /> 39 )) 40 ) : error || !trending?.trends || noTopics ? null : ( 41 <> 42 {trending.trends.map((trend, index) => ( 43 <TrendRow 44 key={trend.link} 45 trend={trend} 46 rank={index + 1} 47 onPress={() => { 48 logger.metric( 49 'trendingTopic:click', 50 {context: 'explore'}, 51 {statsig: true}, 52 ) 53 }} 54 /> 55 ))} 56 </> 57 ) 58} 59 60export function TrendRow({ 61 trend, 62 rank, 63 children, 64 onPress, 65}: ViewStyleProp & { 66 trend: AppBskyUnspeccedDefs.TrendView 67 rank: number 68 children?: React.ReactNode 69 onPress?: () => void 70}) { 71 const t = useTheme() 72 const {_, i18n} = useLingui() 73 const gutters = useGutters([0, 'base']) 74 75 const category = useCategoryDisplayName(trend?.category || 'other') 76 const age = Math.floor( 77 (Date.now() - new Date(trend.startedAt || Date.now()).getTime()) / 78 (1000 * 60 * 60), 79 ) 80 const badgeType = trend.status === 'hot' ? 'hot' : age < 2 ? 'new' : age 81 const postCount = trend.postCount 82 ? _( 83 plural(trend.postCount, { 84 other: `${formatCount(i18n, trend.postCount)} skeets`, 85 }), 86 ) 87 : null 88 89 const actors = useModerateTrendingActors(trend.actors) 90 91 return ( 92 <Link 93 testID={trend.link} 94 label={_(msg`Browse topic ${trend.displayName}`)} 95 to={trend.link} 96 onPress={onPress} 97 style={[a.border_b, t.atoms.border_contrast_low]} 98 PressableComponent={Pressable}> 99 {({hovered, pressed}) => ( 100 <> 101 <SubtleHover hover={hovered || pressed} native /> 102 <View style={[gutters, a.w_full, a.py_lg, a.flex_row, a.gap_2xs]}> 103 <View style={[a.flex_1, a.gap_xs]}> 104 <View style={[a.flex_row]}> 105 <Text 106 style={[ 107 a.text_md, 108 a.font_semi_bold, 109 a.leading_tight, 110 {width: 20}, 111 ]}> 112 <Trans comment='The trending topic rank, i.e. "1. March Madness", "2. The Bachelor"'> 113 {rank}. 114 </Trans> 115 </Text> 116 <Text 117 style={[a.text_md, a.font_semi_bold, a.leading_tight]} 118 numberOfLines={1}> 119 {trend.displayName} 120 </Text> 121 </View> 122 <View 123 style={[ 124 a.flex_row, 125 a.gap_sm, 126 a.align_center, 127 {paddingLeft: 20}, 128 ]}> 129 {actors.length > 0 && ( 130 <AvatarStack size={20} profiles={actors} /> 131 )} 132 <Text 133 style={[ 134 a.text_sm, 135 t.atoms.text_contrast_medium, 136 web(a.leading_snug), 137 ]} 138 numberOfLines={1}> 139 {postCount} 140 {postCount && category && <> &middot; </>} 141 {category} 142 </Text> 143 </View> 144 </View> 145 <View style={[a.flex_shrink_0]}> 146 <TrendingIndicator type={badgeType} /> 147 </View> 148 </View> 149 150 {children} 151 </> 152 )} 153 </Link> 154 ) 155} 156 157type TrendingIndicatorType = 'hot' | 'new' | number 158 159function TrendingIndicator({type}: {type: TrendingIndicatorType | 'skeleton'}) { 160 const t = useTheme() 161 const {_} = useLingui() 162 163 const enableSquareButtons = useEnableSquareButtons() 164 165 const pillStyles = [ 166 a.flex_row, 167 a.align_center, 168 a.gap_xs, 169 enableSquareButtons ? a.rounded_sm : a.rounded_full, 170 {height: 28, paddingHorizontal: 10}, 171 ] 172 173 let Icon: React.ComponentType<SVGIconProps> | null = null 174 let text: string | null = null 175 let color: string | null = null 176 let backgroundColor: string | null = null 177 178 switch (type) { 179 case 'skeleton': { 180 return ( 181 <View 182 style={[ 183 pillStyles, 184 {backgroundColor: t.palette.contrast_25, width: 65, height: 28}, 185 ]} 186 /> 187 ) 188 } 189 case 'hot': { 190 Icon = FlameIcon 191 color = 192 t.scheme === 'light' ? t.palette.negative_500 : t.palette.negative_950 193 backgroundColor = 194 t.scheme === 'light' ? t.palette.negative_50 : t.palette.negative_200 195 text = _(msg`Hot`) 196 break 197 } 198 case 'new': { 199 Icon = TrendingIcon 200 text = _(msg`New`) 201 color = t.palette.positive_600 202 backgroundColor = t.palette.positive_50 203 break 204 } 205 default: { 206 text = _( 207 msg({ 208 message: `${type}h ago`, 209 comment: 210 'trending topic time spent trending. should be as short as possible to fit in a pill', 211 }), 212 ) 213 color = t.atoms.text_contrast_medium.color 214 backgroundColor = t.atoms.bg_contrast_25.backgroundColor 215 break 216 } 217 } 218 219 return ( 220 <View style={[pillStyles, {backgroundColor}]}> 221 {Icon && <Icon size="sm" style={{color}} />} 222 <Text style={[a.text_sm, a.font_medium, {color}]}>{text}</Text> 223 </View> 224 ) 225} 226 227function useCategoryDisplayName( 228 category: AppBskyUnspeccedDefs.TrendView['category'], 229) { 230 const {_} = useLingui() 231 232 switch (category) { 233 case 'sports': 234 return _(msg`Sports`) 235 case 'politics': 236 return _(msg`Politics`) 237 case 'video-games': 238 return _(msg`Video Games`) 239 case 'pop-culture': 240 return _(msg`Entertainment`) 241 case 'news': 242 return _(msg`News`) 243 case 'other': 244 default: 245 return null 246 } 247} 248 249export function TrendingTopicRowSkeleton({}: {withPosts: boolean}) { 250 const t = useTheme() 251 const gutters = useGutters([0, 'base']) 252 253 const enableSquareButtons = useEnableSquareButtons() 254 255 return ( 256 <View 257 style={[ 258 gutters, 259 a.w_full, 260 a.py_lg, 261 a.flex_row, 262 a.gap_2xs, 263 a.border_b, 264 t.atoms.border_contrast_low, 265 ]}> 266 <View style={[a.flex_1, a.gap_sm]}> 267 <View style={[a.flex_row, a.align_center]}> 268 <View style={[{width: 20}]}> 269 <LoadingPlaceholder 270 width={12} 271 height={12} 272 style={[enableSquareButtons ? a.rounded_sm : a.rounded_full]} 273 /> 274 </View> 275 <LoadingPlaceholder width={90} height={17} /> 276 </View> 277 <View style={[a.flex_row, a.gap_sm, a.align_center, {paddingLeft: 20}]}> 278 <LoadingPlaceholder width={70} height={16} /> 279 <LoadingPlaceholder width={40} height={16} /> 280 <LoadingPlaceholder width={60} height={16} /> 281 </View> 282 </View> 283 <View style={[a.flex_shrink_0]}> 284 <TrendingIndicator type="skeleton" /> 285 </View> 286 </View> 287 ) 288} 289 290function useModerateTrendingActors( 291 actors: AppBskyUnspeccedDefs.TrendView['actors'], 292) { 293 const moderationOpts = useModerationOpts() 294 295 return useMemo(() => { 296 if (!moderationOpts) return [] 297 298 return actors 299 .filter(actor => { 300 const decision = moderateProfile(actor, moderationOpts) 301 return !decision.ui('avatar').filter && !decision.ui('avatar').blur 302 }) 303 .slice(0, 3) 304 }, [actors, moderationOpts]) 305}