Bluesky app fork with some witchin' additions 馃挮
at linkat-integration 302 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 {useEnableSquareButtons} from '#/state/preferences/enable-square-buttons' 8import {useModerationOpts} from '#/state/preferences/moderation-opts' 9import {useTrendingSettings} from '#/state/preferences/trending' 10import {useGetTrendsQuery} from '#/state/queries/trending/useGetTrendsQuery' 11import {useTrendingConfig} from '#/state/service-config' 12import {LoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' 13import {formatCount} from '#/view/com/util/numeric/format' 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 {_, i18n} = 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 const postCount = trend.postCount 79 ? _( 80 plural(trend.postCount, { 81 other: `${formatCount(i18n, trend.postCount)} skeets`, 82 }), 83 ) 84 : null 85 86 const actors = useModerateTrendingActors(trend.actors) 87 88 return ( 89 <Link 90 testID={trend.link} 91 label={_(msg`Browse topic ${trend.displayName}`)} 92 to={trend.link} 93 onPress={onPress} 94 style={[a.border_b, t.atoms.border_contrast_low]} 95 PressableComponent={Pressable}> 96 {({hovered, pressed}) => ( 97 <> 98 <SubtleHover hover={hovered || pressed} native /> 99 <View style={[gutters, a.w_full, a.py_lg, a.flex_row, a.gap_2xs]}> 100 <View style={[a.flex_1, a.gap_xs]}> 101 <View style={[a.flex_row]}> 102 <Text 103 style={[ 104 a.text_md, 105 a.font_semi_bold, 106 a.leading_tight, 107 {width: 20}, 108 ]}> 109 <Trans comment='The trending topic rank, i.e. "1. March Madness", "2. The Bachelor"'> 110 {rank}. 111 </Trans> 112 </Text> 113 <Text 114 style={[a.text_md, a.font_semi_bold, a.leading_tight]} 115 numberOfLines={1}> 116 {trend.displayName} 117 </Text> 118 </View> 119 <View 120 style={[ 121 a.flex_row, 122 a.gap_sm, 123 a.align_center, 124 {paddingLeft: 20}, 125 ]}> 126 {actors.length > 0 && ( 127 <AvatarStack size={20} profiles={actors} /> 128 )} 129 <Text 130 style={[ 131 a.text_sm, 132 t.atoms.text_contrast_medium, 133 web(a.leading_snug), 134 ]} 135 numberOfLines={1}> 136 {postCount} 137 {postCount && category && <> &middot; </>} 138 {category} 139 </Text> 140 </View> 141 </View> 142 <View style={[a.flex_shrink_0]}> 143 <TrendingIndicator type={badgeType} /> 144 </View> 145 </View> 146 147 {children} 148 </> 149 )} 150 </Link> 151 ) 152} 153 154type TrendingIndicatorType = 'hot' | 'new' | number 155 156function TrendingIndicator({type}: {type: TrendingIndicatorType | 'skeleton'}) { 157 const t = useTheme() 158 const {_} = useLingui() 159 160 const enableSquareButtons = useEnableSquareButtons() 161 162 const pillStyles = [ 163 a.flex_row, 164 a.align_center, 165 a.gap_xs, 166 enableSquareButtons ? a.rounded_sm : a.rounded_full, 167 {height: 28, paddingHorizontal: 10}, 168 ] 169 170 let Icon: React.ComponentType<SVGIconProps> | null = null 171 let text: string | null = null 172 let color: string | null = null 173 let backgroundColor: string | null = null 174 175 switch (type) { 176 case 'skeleton': { 177 return ( 178 <View 179 style={[ 180 pillStyles, 181 {backgroundColor: t.palette.contrast_25, width: 65, height: 28}, 182 ]} 183 /> 184 ) 185 } 186 case 'hot': { 187 Icon = FlameIcon 188 color = 189 t.scheme === 'light' ? t.palette.negative_500 : t.palette.negative_950 190 backgroundColor = 191 t.scheme === 'light' ? t.palette.negative_50 : t.palette.negative_200 192 text = _(msg`Hot`) 193 break 194 } 195 case 'new': { 196 Icon = TrendingIcon 197 text = _(msg`New`) 198 color = t.palette.positive_600 199 backgroundColor = t.palette.positive_50 200 break 201 } 202 default: { 203 text = _( 204 msg({ 205 message: `${type}h ago`, 206 comment: 207 'trending topic time spent trending. should be as short as possible to fit in a pill', 208 }), 209 ) 210 color = t.atoms.text_contrast_medium.color 211 backgroundColor = t.atoms.bg_contrast_25.backgroundColor 212 break 213 } 214 } 215 216 return ( 217 <View style={[pillStyles, {backgroundColor}]}> 218 {Icon && <Icon size="sm" style={{color}} />} 219 <Text style={[a.text_sm, a.font_medium, {color}]}>{text}</Text> 220 </View> 221 ) 222} 223 224function useCategoryDisplayName( 225 category: AppBskyUnspeccedDefs.TrendView['category'], 226) { 227 const {_} = useLingui() 228 229 switch (category) { 230 case 'sports': 231 return _(msg`Sports`) 232 case 'politics': 233 return _(msg`Politics`) 234 case 'video-games': 235 return _(msg`Video Games`) 236 case 'pop-culture': 237 return _(msg`Entertainment`) 238 case 'news': 239 return _(msg`News`) 240 case 'other': 241 default: 242 return null 243 } 244} 245 246export function TrendingTopicRowSkeleton({}: {withPosts: boolean}) { 247 const t = useTheme() 248 const gutters = useGutters([0, 'base']) 249 250 const enableSquareButtons = useEnableSquareButtons() 251 252 return ( 253 <View 254 style={[ 255 gutters, 256 a.w_full, 257 a.py_lg, 258 a.flex_row, 259 a.gap_2xs, 260 a.border_b, 261 t.atoms.border_contrast_low, 262 ]}> 263 <View style={[a.flex_1, a.gap_sm]}> 264 <View style={[a.flex_row, a.align_center]}> 265 <View style={[{width: 20}]}> 266 <LoadingPlaceholder 267 width={12} 268 height={12} 269 style={[enableSquareButtons ? a.rounded_sm : a.rounded_full]} 270 /> 271 </View> 272 <LoadingPlaceholder width={90} height={17} /> 273 </View> 274 <View style={[a.flex_row, a.gap_sm, a.align_center, {paddingLeft: 20}]}> 275 <LoadingPlaceholder width={70} height={16} /> 276 <LoadingPlaceholder width={40} height={16} /> 277 <LoadingPlaceholder width={60} height={16} /> 278 </View> 279 </View> 280 <View style={[a.flex_shrink_0]}> 281 <TrendingIndicator type="skeleton" /> 282 </View> 283 </View> 284 ) 285} 286 287function useModerateTrendingActors( 288 actors: AppBskyUnspeccedDefs.TrendView['actors'], 289) { 290 const moderationOpts = useModerationOpts() 291 292 return useMemo(() => { 293 if (!moderationOpts) return [] 294 295 return actors 296 .filter(actor => { 297 const decision = moderateProfile(actor, moderationOpts) 298 return !decision.ui('avatar').filter && !decision.ui('avatar').blur 299 }) 300 .slice(0, 3) 301 }, [actors, moderationOpts]) 302}