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 {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 && <> · </>}
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}