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} 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}