forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
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 && <> · </>}
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}