forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
1import {memo, useMemo, useState} from 'react'
2import {type StyleProp, View, type ViewStyle} from 'react-native'
3import {
4 type AppBskyFeedDefs,
5 type AppBskyFeedPost,
6 type AppBskyFeedThreadgate,
7 type RichText as RichTextAPI,
8} from '@atproto/api'
9import {plural} from '@lingui/core/macro'
10import {useLingui} from '@lingui/react/macro'
11
12import {CountWheel} from '#/lib/custom-animations/CountWheel'
13import {AnimatedLikeIcon} from '#/lib/custom-animations/LikeIcon'
14import {useHaptics} from '#/lib/haptics'
15import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
16import {type Shadow} from '#/state/cache/types'
17import {useFeedFeedbackContext} from '#/state/feed-feedback'
18import {useDisableLikesMetrics} from '#/state/preferences/disable-likes-metrics'
19import {useDisableQuotesMetrics} from '#/state/preferences/disable-quotes-metrics'
20import {useDisableReplyMetrics} from '#/state/preferences/disable-reply-metrics'
21import {useDisableRepostsMetrics} from '#/state/preferences/disable-reposts-metrics'
22import {
23 usePostLikeMutationQueue,
24 usePostRepostMutationQueue,
25} from '#/state/queries/post'
26import {useRequireAuth} from '#/state/session'
27import {
28 ProgressGuideAction,
29 useProgressGuideControls,
30} from '#/state/shell/progress-guide'
31import {atoms as a, useBreakpoints, useTheme} from '#/alf'
32import {Reply as Bubble} from '#/components/icons/Reply'
33import {useFormatPostStatCount} from '#/components/PostControls/util'
34import * as Skele from '#/components/Skeleton'
35import * as Toast from '#/components/Toast'
36import {useAnalytics} from '#/analytics'
37import {useAutoLikeOnRepost} from '../../state/preferences/auto-like-on-repost.tsx'
38import {BookmarkButton} from './BookmarkButton'
39import {
40 PostControlButton,
41 PostControlButtonIcon,
42 PostControlButtonText,
43} from './PostControlButton'
44import {PostMenuButton} from './PostMenu'
45import {RepostButton} from './RepostButton'
46import {ShareMenuButton} from './ShareMenu'
47
48let PostControls = ({
49 big,
50 post,
51 record,
52 richText,
53 feedContext,
54 reqId,
55 style,
56 onPressReply,
57 onPostReply,
58 logContext,
59 threadgateRecord,
60 onShowLess,
61 viaRepost,
62 variant,
63 forceGoogleTranslate = false,
64}: {
65 big?: boolean
66 post: Shadow<AppBskyFeedDefs.PostView>
67 record: AppBskyFeedPost.Record
68 richText: RichTextAPI
69 feedContext?: string | undefined
70 reqId?: string | undefined
71 style?: StyleProp<ViewStyle>
72 onPressReply: () => void
73 onPostReply?: (postUri: string | undefined) => void
74 logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo'
75 threadgateRecord?: AppBskyFeedThreadgate.Record
76 onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void
77 viaRepost?: {uri: string; cid: string}
78 variant?: 'compact' | 'normal' | 'large'
79 forceGoogleTranslate?: boolean
80}): React.ReactNode => {
81 const ax = useAnalytics()
82 const t = useTheme()
83 const {t: l} = useLingui()
84 const {openComposer} = useOpenComposer()
85 const {feedDescriptor} = useFeedFeedbackContext()
86 const [queueLike, queueUnlike] = usePostLikeMutationQueue(
87 post,
88 viaRepost,
89 feedDescriptor,
90 logContext,
91 )
92 const [queueRepost, queueUnrepost] = usePostRepostMutationQueue(
93 post,
94 viaRepost,
95 feedDescriptor,
96 logContext,
97 )
98 const requireAuth = useRequireAuth()
99 const {sendInteraction} = useFeedFeedbackContext()
100 const {captureAction} = useProgressGuideControls()
101 const playHaptic = useHaptics()
102 const isBlocked = Boolean(
103 post.author.viewer?.blocking ||
104 post.author.viewer?.blockedBy ||
105 post.author.viewer?.blockingByList,
106 )
107 const replyDisabled = post.viewer?.replyDisabled
108 const {gtPhone} = useBreakpoints()
109 const formatPostStatCount = useFormatPostStatCount()
110
111 const [hasLikeIconBeenToggled, setHasLikeIconBeenToggled] = useState(false)
112
113 // disable metrics
114 const disableLikesMetrics = useDisableLikesMetrics()
115 const disableRepostsMetrics = useDisableRepostsMetrics()
116 const disableReplyMetrics = useDisableReplyMetrics()
117 const disableQuotesMetrics = useDisableQuotesMetrics()
118
119 const autoLikeOnRepost = useAutoLikeOnRepost()
120
121 const onPressToggleLike = async () => {
122 if (isBlocked) {
123 Toast.show(l`Cannot interact with a blocked user`, {
124 type: 'warning',
125 })
126 return
127 }
128
129 try {
130 setHasLikeIconBeenToggled(true)
131 if (!post.viewer?.like) {
132 playHaptic('Light')
133 sendInteraction({
134 item: post.uri,
135 event: 'app.bsky.feed.defs#interactionLike',
136 feedContext,
137 reqId,
138 })
139 captureAction(ProgressGuideAction.Like)
140 await queueLike()
141 } else {
142 await queueUnlike()
143 }
144 } catch (err) {
145 const e = err as Error
146 if (e?.name !== 'AbortError') {
147 throw e
148 }
149 }
150 }
151
152 const onRepost = async () => {
153 if (isBlocked) {
154 Toast.show(l`Cannot interact with a blocked user`, {
155 type: 'warning',
156 })
157 return
158 }
159
160 try {
161 if (!post.viewer?.repost) {
162 sendInteraction({
163 item: post.uri,
164 event: 'app.bsky.feed.defs#interactionRepost',
165 feedContext,
166 reqId,
167 })
168 await queueRepost()
169 setHasLikeIconBeenToggled(true)
170 if (!post.viewer?.like && autoLikeOnRepost) {
171 sendInteraction({
172 item: post.uri,
173 event: 'app.bsky.feed.defs#interactionLike',
174 feedContext,
175 reqId,
176 })
177 captureAction(ProgressGuideAction.Like)
178 await queueLike()
179 }
180 } else {
181 await queueUnrepost()
182 }
183 } catch (err) {
184 const e = err as Error
185 if (e?.name !== 'AbortError') {
186 throw e
187 }
188 }
189 }
190
191 const onQuote = () => {
192 if (isBlocked) {
193 Toast.show(l`Cannot interact with a blocked user`, {
194 type: 'warning',
195 })
196 return
197 }
198
199 sendInteraction({
200 item: post.uri,
201 event: 'app.bsky.feed.defs#interactionQuote',
202 feedContext,
203 reqId,
204 })
205 ax.metric('post:clickQuotePost', {
206 uri: post.uri,
207 authorDid: post.author.did,
208 logContext,
209 feedDescriptor,
210 })
211 openComposer({
212 quote: post,
213 onPost: onPostReply,
214 logContext: 'QuotePost',
215 })
216 }
217
218 const onShare = () => {
219 sendInteraction({
220 item: post.uri,
221 event: 'app.bsky.feed.defs#interactionShare',
222 feedContext,
223 reqId,
224 })
225 }
226
227 const secondaryControlSpacingStyles = useSecondaryControlSpacingStyles({
228 variant,
229 big,
230 gtPhone,
231 })
232
233 return (
234 <View
235 style={[
236 a.flex_row,
237 a.justify_between,
238 a.align_center,
239 !big && a.pt_2xs,
240 a.gap_md,
241 style,
242 ]}>
243 <View style={[a.flex_row, a.flex_1, {maxWidth: 320}]}>
244 <View
245 style={[
246 a.flex_1,
247 a.align_start,
248 {marginLeft: big ? -2 : -6},
249 replyDisabled ? {opacity: 0.6} : undefined,
250 ]}>
251 <PostControlButton
252 testID="replyBtn"
253 onPress={
254 !replyDisabled
255 ? () =>
256 requireAuth(() => {
257 ax.metric('post:clickReply', {
258 uri: post.uri,
259 authorDid: post.author.did,
260 logContext,
261 feedDescriptor,
262 })
263 onPressReply()
264 })
265 : undefined
266 }
267 label={l({
268 message: `Reply (${plural(post.replyCount || 0, {
269 one: '# reply',
270 other: '# replies',
271 })})`,
272 comment:
273 'Accessibility label for the reply button, verb form followed by number of replies and noun form',
274 })}
275 big={big}>
276 <PostControlButtonIcon icon={Bubble} />
277 {typeof post.replyCount !== 'undefined' &&
278 post.replyCount > 0 &&
279 !disableReplyMetrics && (
280 <PostControlButtonText>
281 {formatPostStatCount(post.replyCount)}
282 </PostControlButtonText>
283 )}
284 </PostControlButton>
285 </View>
286 <View style={[a.flex_1, a.align_start]}>
287 <RepostButton
288 isReposted={!!post.viewer?.repost}
289 repostCount={
290 (!disableRepostsMetrics ? (post.repostCount ?? 0) : 0) +
291 (!disableQuotesMetrics ? (post.quoteCount ?? 0) : 0)
292 }
293 onRepost={() => void onRepost()}
294 onQuote={onQuote}
295 big={big}
296 embeddingDisabled={Boolean(post.viewer?.embeddingDisabled)}
297 />
298 </View>
299 <View style={[a.flex_1, a.align_start]}>
300 <PostControlButton
301 testID="likeBtn"
302 big={big}
303 active={Boolean(post.viewer?.like)}
304 activeColor={t.palette.pink}
305 onPress={() => requireAuth(() => onPressToggleLike())}
306 label={
307 post.viewer?.like
308 ? l({
309 message: `Unlike (${plural(post.likeCount || 0, {
310 one: '# like',
311 other: '# likes',
312 })})`,
313 comment:
314 'Accessibility label for the like button when the post has been liked, verb followed by number of likes and noun',
315 })
316 : l({
317 message: `Like (${plural(post.likeCount || 0, {
318 one: '# like',
319 other: '# likes',
320 })})`,
321 comment:
322 'Accessibility label for the like button when the post has not been liked, verb form followed by number of likes and noun form',
323 })
324 }>
325 <AnimatedLikeIcon
326 isLiked={Boolean(post.viewer?.like)}
327 big={big}
328 hasBeenToggled={hasLikeIconBeenToggled}
329 />
330 {!disableLikesMetrics ? (
331 <CountWheel
332 count={post.likeCount ?? 0}
333 isToggled={Boolean(post.viewer?.like)}
334 hasBeenToggled={hasLikeIconBeenToggled}
335 renderCount={({count}) => (
336 <PostControlButtonText>
337 {formatPostStatCount(count)}
338 </PostControlButtonText>
339 )}
340 />
341 ) : null}
342 </PostControlButton>
343 </View>
344 {/* Spacer! */}
345 <View />
346 </View>
347 <View style={[a.flex_row, a.justify_end, secondaryControlSpacingStyles]}>
348 <BookmarkButton
349 post={post}
350 big={big}
351 logContext={logContext}
352 hitSlop={{
353 right: secondaryControlSpacingStyles.gap / 2,
354 }}
355 />
356 <ShareMenuButton
357 testID="postShareBtn"
358 post={post}
359 big={big}
360 record={record}
361 richText={richText}
362 timestamp={post.indexedAt}
363 threadgateRecord={threadgateRecord}
364 onShare={onShare}
365 hitSlop={{
366 left: secondaryControlSpacingStyles.gap / 2,
367 right: secondaryControlSpacingStyles.gap / 2,
368 }}
369 logContext={logContext}
370 />
371 <PostMenuButton
372 testID="postDropdownBtn"
373 post={post}
374 postFeedContext={feedContext}
375 postReqId={reqId}
376 big={big}
377 record={record}
378 richText={richText}
379 timestamp={post.indexedAt}
380 threadgateRecord={threadgateRecord}
381 onShowLess={onShowLess}
382 hitSlop={{
383 left: secondaryControlSpacingStyles.gap / 2,
384 }}
385 logContext={logContext}
386 forceGoogleTranslate={forceGoogleTranslate}
387 />
388 </View>
389 </View>
390 )
391}
392PostControls = memo(PostControls)
393export {PostControls}
394
395export function PostControlsSkeleton({
396 big,
397 style,
398 variant,
399}: {
400 big?: boolean
401 style?: StyleProp<ViewStyle>
402 variant?: 'compact' | 'normal' | 'large'
403}) {
404 const {gtPhone} = useBreakpoints()
405
406 const rowHeight = big ? 32 : 28
407 const padding = 4
408 const size = rowHeight - padding * 2
409
410 const secondaryControlSpacingStyles = useSecondaryControlSpacingStyles({
411 variant,
412 big,
413 gtPhone,
414 })
415
416 const itemStyles = {
417 padding,
418 }
419
420 return (
421 <Skele.Row
422 style={[a.flex_row, a.justify_between, a.align_center, a.gap_md, style]}>
423 <View style={[a.flex_row, a.flex_1, {maxWidth: 320}]}>
424 <View
425 style={[itemStyles, a.flex_1, a.align_start, {marginLeft: -padding}]}>
426 <Skele.Pill blend size={size} />
427 </View>
428
429 <View style={[itemStyles, a.flex_1, a.align_start]}>
430 <Skele.Pill blend size={size} />
431 </View>
432
433 <View style={[itemStyles, a.flex_1, a.align_start]}>
434 <Skele.Pill blend size={size} />
435 </View>
436 </View>
437 <View style={[a.flex_row, a.justify_end, secondaryControlSpacingStyles]}>
438 <View style={itemStyles}>
439 <Skele.Circle blend size={size} />
440 </View>
441 <View style={itemStyles}>
442 <Skele.Circle blend size={size} />
443 </View>
444 <View style={itemStyles}>
445 <Skele.Circle blend size={size} />
446 </View>
447 </View>
448 </Skele.Row>
449 )
450}
451
452function useSecondaryControlSpacingStyles({
453 variant,
454 big,
455 gtPhone,
456}: {
457 variant?: 'compact' | 'normal' | 'large'
458 big?: boolean
459 gtPhone: boolean
460}) {
461 return useMemo(() => {
462 let gap = 0 // default, we want `gap` to be defined on the resulting object
463 if (variant !== 'compact') gap = a.gap_xs.gap
464 if (big || gtPhone) gap = a.gap_sm.gap
465 return {gap}
466 }, [variant, big, gtPhone])
467}