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 {msg, 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 useGetPost,
24 usePostLikeMutationQueue,
25 usePostRepostMutationQueue,
26} from '#/state/queries/post'
27import {useRequireAuth, useSession} from '#/state/session'
28import {
29 ProgressGuideAction,
30 useProgressGuideControls,
31} from '#/state/shell/progress-guide'
32import * as userActionHistory from '#/state/userActionHistory'
33import {atoms as a, useBreakpoints, useTheme} from '#/alf'
34import {Reply as Bubble} from '#/components/icons/Reply'
35import {useFormatPostStatCount} from '#/components/PostControls/util'
36import * as Skele from '#/components/Skeleton'
37import * as Toast from '#/components/Toast'
38import {EphemeralAccountSwitcher} from '#/components/EphemeralAccountSwitcher'
39import {useAnalytics} from '#/analytics'
40import {useRunWithEphemeralAgent} from '../hooks/useRunWithEphemeralAgent'
41import {useAutoLikeOnRepost} from '../../state/preferences/auto-like-on-repost.tsx'
42import {BookmarkButton} from './BookmarkButton'
43import {
44 PostControlButton,
45 PostControlButtonIcon,
46 PostControlButtonText,
47} from './PostControlButton'
48import {PostMenuButton} from './PostMenu'
49import {RepostButton} from './RepostButton'
50import {ShareMenuButton} from './ShareMenu'
51
52let PostControls = ({
53 big,
54 post,
55 record,
56 richText,
57 feedContext,
58 reqId,
59 style,
60 onPressReply,
61 onPostReply,
62 logContext,
63 threadgateRecord,
64 onShowLess,
65 viaRepost,
66 variant,
67 forceGoogleTranslate = false,
68}: {
69 big?: boolean
70 post: Shadow<AppBskyFeedDefs.PostView>
71 record: AppBskyFeedPost.Record
72 richText: RichTextAPI
73 feedContext?: string | undefined
74 reqId?: string | undefined
75 style?: StyleProp<ViewStyle>
76 onPressReply: () => void
77 onPostReply?: (postUri: string | undefined) => void
78 logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo'
79 threadgateRecord?: AppBskyFeedThreadgate.Record
80 onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void
81 viaRepost?: {uri: string; cid: string}
82 variant?: 'compact' | 'normal' | 'large'
83 forceGoogleTranslate?: boolean
84}): React.ReactNode => {
85 const ax = useAnalytics()
86 const t = useTheme()
87 const {t: l} = useLingui()
88 const {openComposer} = useOpenComposer()
89 const {feedDescriptor} = useFeedFeedbackContext()
90 const {accounts, currentAccount} = useSession()
91 const getPost = useGetPost()
92 const runWithEphemeralAgent = useRunWithEphemeralAgent()
93 const [queueLike, queueUnlike] = usePostLikeMutationQueue(
94 post,
95 viaRepost,
96 feedDescriptor,
97 logContext,
98 )
99 const [queueRepost, queueUnrepost] = usePostRepostMutationQueue(
100 post,
101 viaRepost,
102 feedDescriptor,
103 logContext,
104 )
105 const requireAuth = useRequireAuth()
106 const {sendInteraction} = useFeedFeedbackContext()
107 const {captureAction} = useProgressGuideControls()
108 const playHaptic = useHaptics()
109 const isBlocked = Boolean(
110 post.author.viewer?.blocking ||
111 post.author.viewer?.blockedBy ||
112 post.author.viewer?.blockingByList,
113 )
114 const replyDisabled = post.viewer?.replyDisabled
115 const {gtPhone} = useBreakpoints()
116 const formatPostStatCount = useFormatPostStatCount()
117
118 const [hasLikeIconBeenToggled, setHasLikeIconBeenToggled] = useState(false)
119
120 // disable metrics
121 const disableLikesMetrics = useDisableLikesMetrics()
122 const disableRepostsMetrics = useDisableRepostsMetrics()
123 const disableReplyMetrics = useDisableReplyMetrics()
124 const disableQuotesMetrics = useDisableQuotesMetrics()
125
126 const autoLikeOnRepost = useAutoLikeOnRepost()
127
128 const shouldAutoLikeOnRepost = async () => {
129 if (post.viewer?.like) return false
130
131 if (userActionHistory.getActionHistory().likes.includes(post.uri)) {
132 return false
133 }
134
135 try {
136 const latestPost = await getPost({uri: post.uri})
137 return !latestPost.viewer?.like
138 } catch {
139 return false
140 }
141 }
142
143 const onPressToggleLike = async () => {
144 if (isBlocked) {
145 Toast.show(l`Cannot interact with a blocked user`, {
146 type: 'warning',
147 })
148 return
149 }
150
151 try {
152 setHasLikeIconBeenToggled(true)
153 if (!post.viewer?.like) {
154 playHaptic('Light')
155 sendInteraction({
156 item: post.uri,
157 event: 'app.bsky.feed.defs#interactionLike',
158 feedContext,
159 reqId,
160 })
161 captureAction(ProgressGuideAction.Like)
162 await queueLike()
163 } else {
164 await queueUnlike()
165 }
166 } catch (err) {
167 const e = err as Error
168 if (e?.name !== 'AbortError') {
169 throw e
170 }
171 }
172 }
173
174 const onRepost = async () => {
175 if (isBlocked) {
176 Toast.show(l`Cannot interact with a blocked user`, {
177 type: 'warning',
178 })
179 return
180 }
181
182 try {
183 if (!post.viewer?.repost) {
184 sendInteraction({
185 item: post.uri,
186 event: 'app.bsky.feed.defs#interactionRepost',
187 feedContext,
188 reqId,
189 })
190 await queueRepost()
191 setHasLikeIconBeenToggled(true)
192 if (autoLikeOnRepost && (await shouldAutoLikeOnRepost())) {
193 sendInteraction({
194 item: post.uri,
195 event: 'app.bsky.feed.defs#interactionLike',
196 feedContext,
197 reqId,
198 })
199 captureAction(ProgressGuideAction.Like)
200 await queueLike()
201 }
202 } else {
203 await queueUnrepost()
204 }
205 } catch (err) {
206 const e = err as Error
207 if (e?.name !== 'AbortError') {
208 throw e
209 }
210 }
211 }
212
213 const onQuote = () => {
214 if (isBlocked) {
215 Toast.show(l`Cannot interact with a blocked user`, {
216 type: 'warning',
217 })
218 return
219 }
220
221 sendInteraction({
222 item: post.uri,
223 event: 'app.bsky.feed.defs#interactionQuote',
224 feedContext,
225 reqId,
226 })
227 ax.metric('post:clickQuotePost', {
228 uri: post.uri,
229 authorDid: post.author.did,
230 logContext,
231 feedDescriptor,
232 })
233 openComposer({
234 quote: post,
235 onPost: onPostReply,
236 logContext: 'QuotePost',
237 })
238 }
239
240 const onShare = () => {
241 sendInteraction({
242 item: post.uri,
243 event: 'app.bsky.feed.defs#interactionShare',
244 feedContext,
245 reqId,
246 })
247 }
248
249 const onReplyAsAccount = (accountDid: string) => {
250 setTimeout(() => {
251 ax.metric('post:clickReply', {
252 uri: post.uri,
253 authorDid: post.author.did,
254 logContext,
255 feedDescriptor,
256 })
257 openComposer({
258 activeAccountDid: accountDid,
259 replyTo: {
260 uri: post.uri,
261 cid: post.cid,
262 text: record.text || '',
263 author: post.author,
264 embed: post.embed,
265 langs: record.langs,
266 },
267 onPost: onPostReply,
268 logContext: 'PostReply',
269 })
270 }, 0)
271 }
272
273 const secondaryControlSpacingStyles = useSecondaryControlSpacingStyles({
274 variant,
275 big,
276 gtPhone,
277 })
278 const hasAlternateAccounts = accounts.some(
279 account => account.did !== currentAccount?.did,
280 )
281
282 const onSelectLikeAccount = async (account: (typeof accounts)[number]) => {
283 try {
284 const wasLiked = await runWithEphemeralAgent(account, async agent => {
285 const res = await agent.getPosts({uris: [post.uri]})
286 const target = res.data.posts[0]
287 const likeUri = target?.viewer?.like
288
289 if (likeUri) {
290 await agent.deleteLike(likeUri)
291 return true
292 }
293
294 await agent.like(post.uri, post.cid)
295 return false
296 })
297
298 Toast.show(
299 wasLiked
300 ? l`Removed like as @${account.handle}`
301 : l`Liked as @${account.handle}`,
302 )
303 } catch (e) {
304 Toast.show(l`An issue occurred, please try again.`, {
305 type: 'error',
306 })
307 }
308 }
309
310 const onSelectRepostAccount = async (account: (typeof accounts)[number]) => {
311 try {
312 const wasReposted = await runWithEphemeralAgent(account, async agent => {
313 const res = await agent.getPosts({uris: [post.uri]})
314 const target = res.data.posts[0]
315 const repostUri = target?.viewer?.repost
316
317 if (repostUri) {
318 await agent.deleteRepost(repostUri)
319 return true
320 }
321
322 await agent.repost(post.uri, post.cid)
323 return false
324 })
325
326 Toast.show(
327 wasReposted
328 ? l`Removed repost as @${account.handle}`
329 : l`Reposted as @${account.handle}`,
330 )
331 } catch (e) {
332 Toast.show(l`An issue occurred, please try again.`, {
333 type: 'error',
334 })
335 }
336 }
337
338 const onSelectBookmarkAccount = async (
339 account: (typeof accounts)[number],
340 ) => {
341 try {
342 const wasBookmarked = await runWithEphemeralAgent(account, async agent => {
343 const res = await agent.getPosts({uris: [post.uri]})
344 const target = res.data.posts[0]
345
346 if (target?.viewer?.bookmarked) {
347 await agent.app.bsky.bookmark.deleteBookmark({uri: post.uri})
348 return true
349 }
350
351 await agent.app.bsky.bookmark.createBookmark({
352 uri: post.uri,
353 cid: post.cid,
354 })
355 return false
356 })
357
358 Toast.show(
359 wasBookmarked
360 ? l`Removed save as @${account.handle}`
361 : l`Saved as @${account.handle}`,
362 )
363 } catch (e) {
364 Toast.show(l`An issue occurred, please try again.`, {
365 type: 'error',
366 })
367 }
368 }
369
370 const renderLikeButton = (onLongPress?: () => void) => (
371 <PostControlButton
372 testID="likeBtn"
373 big={big}
374 active={Boolean(post.viewer?.like)}
375 activeColor={t.palette.pink}
376 onPress={() => requireAuth(() => onPressToggleLike())}
377 onLongPress={onLongPress}
378 label={
379 post.viewer?.like
380 ? l({
381 message: `Unlike (${plural(post.likeCount || 0, {
382 one: '# like',
383 other: '# likes',
384 })})`,
385 comment:
386 'Accessibility label for the like button when the post has been liked, verb followed by number of likes and noun',
387 })
388 : l({
389 message: `Like (${plural(post.likeCount || 0, {
390 one: '# like',
391 other: '# likes',
392 })})`,
393 comment:
394 'Accessibility label for the like button when the post has not been liked, verb form followed by number of likes and noun form',
395 })
396 }>
397 <AnimatedLikeIcon
398 isLiked={Boolean(post.viewer?.like)}
399 big={big}
400 hasBeenToggled={hasLikeIconBeenToggled}
401 />
402 {!disableLikesMetrics ? (
403 <CountWheel
404 count={post.likeCount ?? 0}
405 isToggled={Boolean(post.viewer?.like)}
406 hasBeenToggled={hasLikeIconBeenToggled}
407 renderCount={({count}) => (
408 <PostControlButtonText testID="likeCount">
409 {formatPostStatCount(count)}
410 </PostControlButtonText>
411 )}
412 />
413 ) : null}
414 </PostControlButton>
415 )
416
417 return (
418 <>
419 <View
420 style={[
421 a.flex_row,
422 a.justify_between,
423 a.align_center,
424 !big && a.pt_2xs,
425 a.gap_md,
426 style,
427 ]}>
428 <View style={[a.flex_row, a.flex_1, {maxWidth: 320}]}>
429 <View
430 style={[
431 a.flex_1,
432 a.align_start,
433 {marginLeft: big ? -2 : -6},
434 replyDisabled ? {opacity: 0.6} : undefined,
435 ]}>
436 {currentAccount && hasAlternateAccounts && !replyDisabled ? (
437 <EphemeralAccountSwitcher
438 selectedDid={currentAccount.did}
439 title={l`Reply as`}
440 triggerBehavior="longPress"
441 onSelectAccount={account => {
442 onReplyAsAccount(account.did)
443 }}
444 renderTrigger={({triggerProps}) => (
445 <PostControlButton
446 testID="replyBtn"
447 onPress={() =>
448 requireAuth(() => {
449 ax.metric('post:clickReply', {
450 uri: post.uri,
451 authorDid: post.author.did,
452 logContext,
453 feedDescriptor,
454 })
455 onPressReply()
456 })
457 }
458 onLongPress={triggerProps.onLongPress}
459 label={l({
460 message: `Reply (${plural(post.replyCount || 0, {
461 one: '# reply',
462 other: '# replies',
463 })})`,
464 comment:
465 'Accessibility label for the reply button, verb form followed by number of replies and noun form',
466 })}
467 big={big}>
468 <PostControlButtonIcon icon={Bubble} />
469 {typeof post.replyCount !== 'undefined' &&
470 post.replyCount > 0 &&
471 !disableReplyMetrics && (
472 <PostControlButtonText>
473 {formatPostStatCount(post.replyCount)}
474 </PostControlButtonText>
475 )}
476 </PostControlButton>
477 )}
478 />
479 ) : (
480 <PostControlButton
481 testID="replyBtn"
482 onPress={
483 !replyDisabled
484 ? () =>
485 requireAuth(() => {
486 ax.metric('post:clickReply', {
487 uri: post.uri,
488 authorDid: post.author.did,
489 logContext,
490 feedDescriptor,
491 })
492 onPressReply()
493 })
494 : undefined
495 }
496 label={l({
497 message: `Reply (${plural(post.replyCount || 0, {
498 one: '# reply',
499 other: '# replies',
500 })})`,
501 comment:
502 'Accessibility label for the reply button, verb form followed by number of replies and noun form',
503 })}
504 big={big}>
505 <PostControlButtonIcon icon={Bubble} />
506 {typeof post.replyCount !== 'undefined' &&
507 post.replyCount > 0 &&
508 !disableReplyMetrics && (
509 <PostControlButtonText>
510 {formatPostStatCount(post.replyCount)}
511 </PostControlButtonText>
512 )}
513 </PostControlButton>
514 )}
515 </View>
516 <View style={[a.flex_1, a.align_start]}>
517 {currentAccount && hasAlternateAccounts ? (
518 <EphemeralAccountSwitcher
519 selectedDid={currentAccount.did}
520 title={l`Repost as`}
521 triggerBehavior="longPress"
522 onSelectAccount={account => {
523 void onSelectRepostAccount(account)
524 }}
525 renderTrigger={({triggerProps}) => (
526 <RepostButton
527 isReposted={!!post.viewer?.repost}
528 repostCount={
529 (!disableRepostsMetrics ? (post.repostCount ?? 0) : 0) +
530 (!disableQuotesMetrics ? (post.quoteCount ?? 0) : 0)
531 }
532 onRepost={() => void onRepost()}
533 onQuote={onQuote}
534 onLongPress={triggerProps.onLongPress}
535 big={big}
536 embeddingDisabled={Boolean(post.viewer?.embeddingDisabled)}
537 />
538 )}
539 />
540 ) : (
541 <RepostButton
542 isReposted={!!post.viewer?.repost}
543 repostCount={
544 (!disableRepostsMetrics ? (post.repostCount ?? 0) : 0) +
545 (!disableQuotesMetrics ? (post.quoteCount ?? 0) : 0)
546 }
547 onRepost={() => void onRepost()}
548 onQuote={onQuote}
549 big={big}
550 embeddingDisabled={Boolean(post.viewer?.embeddingDisabled)}
551 />
552 )}
553 </View>
554 <View style={[a.flex_1, a.align_start]}>
555 {currentAccount && hasAlternateAccounts ? (
556 <EphemeralAccountSwitcher
557 selectedDid={currentAccount.did}
558 title={l`Like as`}
559 triggerBehavior="longPress"
560 onSelectAccount={account => {
561 void onSelectLikeAccount(account)
562 }}
563 renderTrigger={({triggerProps}) =>
564 renderLikeButton(triggerProps.onLongPress)
565 }
566 />
567 ) : (
568 renderLikeButton()
569 )}
570 </View>
571 {/* Spacer! */}
572 <View />
573 </View>
574 <View style={[a.flex_row, a.justify_end, secondaryControlSpacingStyles]}>
575 {currentAccount && hasAlternateAccounts ? (
576 <EphemeralAccountSwitcher
577 selectedDid={currentAccount.did}
578 title={l`Save as`}
579 triggerBehavior="longPress"
580 onSelectAccount={account => {
581 void onSelectBookmarkAccount(account)
582 }}
583 renderTrigger={({triggerProps}) => (
584 <BookmarkButton
585 post={post}
586 big={big}
587 logContext={logContext}
588 onLongPress={triggerProps.onLongPress}
589 hitSlop={{
590 right: secondaryControlSpacingStyles.gap / 2,
591 }}
592 />
593 )}
594 />
595 ) : (
596 <BookmarkButton
597 post={post}
598 big={big}
599 logContext={logContext}
600 hitSlop={{
601 right: secondaryControlSpacingStyles.gap / 2,
602 }}
603 />
604 )}
605 <ShareMenuButton
606 testID="postShareBtn"
607 post={post}
608 big={big}
609 record={record}
610 richText={richText}
611 timestamp={post.indexedAt}
612 threadgateRecord={threadgateRecord}
613 onShare={onShare}
614 hitSlop={{
615 left: secondaryControlSpacingStyles.gap / 2,
616 right: secondaryControlSpacingStyles.gap / 2,
617 }}
618 logContext={logContext}
619 />
620 <PostMenuButton
621 testID="postDropdownBtn"
622 post={post}
623 postFeedContext={feedContext}
624 postReqId={reqId}
625 big={big}
626 record={record}
627 richText={richText}
628 timestamp={post.indexedAt}
629 threadgateRecord={threadgateRecord}
630 onShowLess={onShowLess}
631 hitSlop={{
632 left: secondaryControlSpacingStyles.gap / 2,
633 }}
634 logContext={logContext}
635 forceGoogleTranslate={forceGoogleTranslate}
636 />
637 </View>
638 </View>
639 </>
640 )
641}
642PostControls = memo(PostControls)
643export {PostControls}
644
645export function PostControlsSkeleton({
646 big,
647 style,
648 variant,
649}: {
650 big?: boolean
651 style?: StyleProp<ViewStyle>
652 variant?: 'compact' | 'normal' | 'large'
653}) {
654 const {gtPhone} = useBreakpoints()
655
656 const rowHeight = big ? 32 : 28
657 const padding = 4
658 const size = rowHeight - padding * 2
659
660 const secondaryControlSpacingStyles = useSecondaryControlSpacingStyles({
661 variant,
662 big,
663 gtPhone,
664 })
665
666 const itemStyles = {
667 padding,
668 }
669
670 return (
671 <Skele.Row
672 style={[a.flex_row, a.justify_between, a.align_center, a.gap_md, style]}>
673 <View style={[a.flex_row, a.flex_1, {maxWidth: 320}]}>
674 <View
675 style={[itemStyles, a.flex_1, a.align_start, {marginLeft: -padding}]}>
676 <Skele.Pill blend size={size} />
677 </View>
678
679 <View style={[itemStyles, a.flex_1, a.align_start]}>
680 <Skele.Pill blend size={size} />
681 </View>
682
683 <View style={[itemStyles, a.flex_1, a.align_start]}>
684 <Skele.Pill blend size={size} />
685 </View>
686 </View>
687 <View style={[a.flex_row, a.justify_end, secondaryControlSpacingStyles]}>
688 <View style={itemStyles}>
689 <Skele.Circle blend size={size} />
690 </View>
691 <View style={itemStyles}>
692 <Skele.Circle blend size={size} />
693 </View>
694 <View style={itemStyles}>
695 <Skele.Circle blend size={size} />
696 </View>
697 </View>
698 </Skele.Row>
699 )
700}
701
702function useSecondaryControlSpacingStyles({
703 variant,
704 big,
705 gtPhone,
706}: {
707 variant?: 'compact' | 'normal' | 'large'
708 big?: boolean
709 gtPhone: boolean
710}) {
711 return useMemo(() => {
712 let gap = 0 // default, we want `gap` to be defined on the resulting object
713 if (variant !== 'compact') gap = a.gap_xs.gap
714 if (big || gtPhone) gap = a.gap_sm.gap
715 return {gap}
716 }, [variant, big, gtPhone])
717}