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