import {memo, useMemo, useState} from 'react' import {type StyleProp, View, type ViewStyle} from 'react-native' import { type AppBskyFeedDefs, type AppBskyFeedPost, type AppBskyFeedThreadgate, type RichText as RichTextAPI, } from '@atproto/api' import {msg, plural} from '@lingui/macro' import {useLingui} from '@lingui/react' import {CountWheel} from '#/lib/custom-animations/CountWheel' import {AnimatedLikeIcon} from '#/lib/custom-animations/LikeIcon' import {useHaptics} from '#/lib/haptics' import {useOpenComposer} from '#/lib/hooks/useOpenComposer' import {type Shadow} from '#/state/cache/types' import {useFeedFeedbackContext} from '#/state/feed-feedback' import {useDisableLikesMetrics} from '#/state/preferences/disable-likes-metrics' import {useDisableQuotesMetrics} from '#/state/preferences/disable-quotes-metrics' import {useDisableReplyMetrics} from '#/state/preferences/disable-reply-metrics' import {useDisableRepostsMetrics} from '#/state/preferences/disable-reposts-metrics' import { usePostLikeMutationQueue, usePostRepostMutationQueue, } from '#/state/queries/post' import {useRequireAuth} from '#/state/session' import { ProgressGuideAction, useProgressGuideControls, } from '#/state/shell/progress-guide' import * as Toast from '#/view/com/util/Toast' import {atoms as a, useBreakpoints} from '#/alf' import {Reply as Bubble} from '#/components/icons/Reply' import {useFormatPostStatCount} from '#/components/PostControls/util' import * as Skele from '#/components/Skeleton' import {useAnalytics} from '#/analytics' import {BookmarkButton} from './BookmarkButton' import { PostControlButton, PostControlButtonIcon, PostControlButtonText, } from './PostControlButton' import {PostMenuButton} from './PostMenu' import {RepostButton} from './RepostButton' import {ShareMenuButton} from './ShareMenu' let PostControls = ({ big, post, record, richText, feedContext, reqId, style, onPressReply, onPostReply, logContext, threadgateRecord, onShowLess, viaRepost, variant, }: { big?: boolean post: Shadow record: AppBskyFeedPost.Record richText: RichTextAPI feedContext?: string | undefined reqId?: string | undefined style?: StyleProp onPressReply: () => void onPostReply?: (postUri: string | undefined) => void logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo' threadgateRecord?: AppBskyFeedThreadgate.Record onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void viaRepost?: {uri: string; cid: string} variant?: 'compact' | 'normal' | 'large' }): React.ReactNode => { const ax = useAnalytics() const {_} = useLingui() const {openComposer} = useOpenComposer() const {feedDescriptor} = useFeedFeedbackContext() const [queueLike, queueUnlike] = usePostLikeMutationQueue( post, viaRepost, feedDescriptor, logContext, ) const [queueRepost, queueUnrepost] = usePostRepostMutationQueue( post, viaRepost, feedDescriptor, logContext, ) const requireAuth = useRequireAuth() const {sendInteraction} = useFeedFeedbackContext() const {captureAction} = useProgressGuideControls() const playHaptic = useHaptics() const isBlocked = Boolean( post.author.viewer?.blocking || post.author.viewer?.blockedBy || post.author.viewer?.blockingByList, ) const replyDisabled = post.viewer?.replyDisabled const {gtPhone} = useBreakpoints() const formatPostStatCount = useFormatPostStatCount() const [hasLikeIconBeenToggled, setHasLikeIconBeenToggled] = useState(false) // disable metrics const disableLikesMetrics = useDisableLikesMetrics() const disableRepostsMetrics = useDisableRepostsMetrics() const disableReplyMetrics = useDisableReplyMetrics() const disableQuotesMetrics = useDisableQuotesMetrics() const onPressToggleLike = async () => { if (isBlocked) { Toast.show( _(msg`Cannot interact with a blocked user`), 'exclamation-circle', ) return } try { setHasLikeIconBeenToggled(true) if (!post.viewer?.like) { playHaptic('Light') sendInteraction({ item: post.uri, event: 'app.bsky.feed.defs#interactionLike', feedContext, reqId, }) captureAction(ProgressGuideAction.Like) await queueLike() } else { await queueUnlike() } } catch (e: any) { if (e?.name !== 'AbortError') { throw e } } } const onRepost = async () => { if (isBlocked) { Toast.show( _(msg`Cannot interact with a blocked user`), 'exclamation-circle', ) return } try { if (!post.viewer?.repost) { sendInteraction({ item: post.uri, event: 'app.bsky.feed.defs#interactionRepost', feedContext, reqId, }) await queueRepost() } else { await queueUnrepost() } } catch (e: any) { if (e?.name !== 'AbortError') { throw e } } } const onQuote = () => { if (isBlocked) { Toast.show( _(msg`Cannot interact with a blocked user`), 'exclamation-circle', ) return } sendInteraction({ item: post.uri, event: 'app.bsky.feed.defs#interactionQuote', feedContext, reqId, }) ax.metric('post:clickQuotePost', { uri: post.uri, authorDid: post.author.did, logContext, feedDescriptor, }) openComposer({ quote: post, onPost: onPostReply, logContext: 'QuotePost', }) } const onShare = () => { sendInteraction({ item: post.uri, event: 'app.bsky.feed.defs#interactionShare', feedContext, reqId, }) } const secondaryControlSpacingStyles = useSecondaryControlSpacingStyles({ variant, big, gtPhone, }) return ( requireAuth(() => { ax.metric('post:clickReply', { uri: post.uri, authorDid: post.author.did, logContext, feedDescriptor, }) onPressReply() }) : undefined } label={_( msg({ message: `Reply (${plural(post.replyCount || 0, { one: '# reply', other: '# replies', })})`, comment: 'Accessibility label for the reply button, verb form followed by number of replies and noun form', }), )} big={big}> {typeof post.replyCount !== 'undefined' && post.replyCount > 0 && !disableReplyMetrics && ( {formatPostStatCount(post.replyCount)} )} requireAuth(() => onPressToggleLike())} label={ post.viewer?.like ? _( msg({ message: `Unlike (${plural(post.likeCount || 0, { one: '# like', other: '# likes', })})`, comment: 'Accessibility label for the like button when the post has been liked, verb followed by number of likes and noun', }), ) : _( msg({ message: `Like (${plural(post.likeCount || 0, { one: '# like', other: '# likes', })})`, comment: 'Accessibility label for the like button when the post has not been liked, verb form followed by number of likes and noun form', }), ) }> {!disableLikesMetrics ? ( ) : null} {/* Spacer! */} ) } PostControls = memo(PostControls) export {PostControls} export function PostControlsSkeleton({ big, style, variant, }: { big?: boolean style?: StyleProp variant?: 'compact' | 'normal' | 'large' }) { const {gtPhone} = useBreakpoints() const rowHeight = big ? 32 : 28 const padding = 4 const size = rowHeight - padding * 2 const secondaryControlSpacingStyles = useSecondaryControlSpacingStyles({ variant, big, gtPhone, }) const itemStyles = { padding, } return ( ) } function useSecondaryControlSpacingStyles({ variant, big, gtPhone, }: { variant?: 'compact' | 'normal' | 'large' big?: boolean gtPhone: boolean }) { return useMemo(() => { let gap = 0 // default, we want `gap` to be defined on the resulting object if (variant !== 'compact') gap = a.gap_xs.gap if (big || gtPhone) gap = a.gap_sm.gap return {gap} }, [variant, big, gtPhone]) }