Files
bsky-social-app/src/components/PostControls/index.tsx
T
2026-04-10 09:50:03 -07:00

437 lines
12 KiB
TypeScript

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 {plural} from '@lingui/core/macro'
import {useLingui} from '@lingui/react/macro'
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 {
usePostLikeMutationQueue,
usePostRepostMutationQueue,
} from '#/state/queries/post'
import {useRequireAuth} from '#/state/session'
import {
ProgressGuideAction,
useProgressGuideControls,
} from '#/state/shell/progress-guide'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {Reply as Bubble} from '#/components/icons/Reply'
import {useFormatPostStatCount} from '#/components/PostControls/util'
import * as Skele from '#/components/Skeleton'
import * as Toast from '#/components/Toast'
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,
forceGoogleTranslate = false,
}: {
big?: boolean
post: Shadow<AppBskyFeedDefs.PostView>
record: AppBskyFeedPost.Record
richText: RichTextAPI
feedContext?: string | undefined
reqId?: string | undefined
style?: StyleProp<ViewStyle>
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'
forceGoogleTranslate?: boolean
}): React.ReactNode => {
const ax = useAnalytics()
const t = useTheme()
const {t: l} = 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)
const onPressToggleLike = async () => {
if (isBlocked) {
Toast.show(l`Cannot interact with a blocked user`, {
type: 'warning',
})
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 (err) {
const e = err as Error
if (e?.name !== 'AbortError') {
throw e
}
}
}
const onRepost = async () => {
if (isBlocked) {
Toast.show(l`Cannot interact with a blocked user`, {
type: 'warning',
})
return
}
try {
if (!post.viewer?.repost) {
sendInteraction({
item: post.uri,
event: 'app.bsky.feed.defs#interactionRepost',
feedContext,
reqId,
})
await queueRepost()
} else {
await queueUnrepost()
}
} catch (err) {
const e = err as Error
if (e?.name !== 'AbortError') {
throw e
}
}
}
const onQuote = () => {
if (isBlocked) {
Toast.show(l`Cannot interact with a blocked user`, {
type: 'warning',
})
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 (
<View
style={[
a.flex_row,
a.justify_between,
a.align_center,
!big && a.pt_2xs,
a.gap_md,
style,
]}>
<View style={[a.flex_row, a.flex_1, {maxWidth: 320}]}>
<View
style={[
a.flex_1,
a.align_start,
{marginLeft: big ? -2 : -6},
replyDisabled ? {opacity: 0.6} : undefined,
]}>
<PostControlButton
testID="replyBtn"
onPress={
!replyDisabled
? () =>
requireAuth(() => {
ax.metric('post:clickReply', {
uri: post.uri,
authorDid: post.author.did,
logContext,
feedDescriptor,
})
onPressReply()
})
: undefined
}
label={l({
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}>
<PostControlButtonIcon icon={Bubble} />
{typeof post.replyCount !== 'undefined' && post.replyCount > 0 && (
<PostControlButtonText>
{formatPostStatCount(post.replyCount)}
</PostControlButtonText>
)}
</PostControlButton>
</View>
<View style={[a.flex_1, a.align_start]}>
<RepostButton
isReposted={!!post.viewer?.repost}
repostCount={(post.repostCount ?? 0) + (post.quoteCount ?? 0)}
onRepost={() => void onRepost()}
onQuote={onQuote}
big={big}
embeddingDisabled={Boolean(post.viewer?.embeddingDisabled)}
/>
</View>
<View style={[a.flex_1, a.align_start]}>
<PostControlButton
testID="likeBtn"
big={big}
active={Boolean(post.viewer?.like)}
activeColor={t.palette.pink}
onPress={() => requireAuth(() => onPressToggleLike())}
label={
post.viewer?.like
? l({
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',
})
: l({
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',
})
}>
<AnimatedLikeIcon
isLiked={Boolean(post.viewer?.like)}
big={big}
hasBeenToggled={hasLikeIconBeenToggled}
/>
<CountWheel
count={post.likeCount ?? 0}
isToggled={Boolean(post.viewer?.like)}
hasBeenToggled={hasLikeIconBeenToggled}
renderCount={({count}) => (
<PostControlButtonText testID="likeCount">
{formatPostStatCount(count)}
</PostControlButtonText>
)}
/>
</PostControlButton>
</View>
{/* Spacer! */}
<View />
</View>
<View style={[a.flex_row, a.justify_end, secondaryControlSpacingStyles]}>
<BookmarkButton
post={post}
big={big}
logContext={logContext}
hitSlop={{
right: secondaryControlSpacingStyles.gap / 2,
}}
/>
<ShareMenuButton
testID="postShareBtn"
post={post}
big={big}
record={record}
richText={richText}
timestamp={post.indexedAt}
threadgateRecord={threadgateRecord}
onShare={onShare}
hitSlop={{
left: secondaryControlSpacingStyles.gap / 2,
right: secondaryControlSpacingStyles.gap / 2,
}}
logContext={logContext}
/>
<PostMenuButton
testID="postDropdownBtn"
post={post}
postFeedContext={feedContext}
postReqId={reqId}
big={big}
record={record}
richText={richText}
timestamp={post.indexedAt}
threadgateRecord={threadgateRecord}
onShowLess={onShowLess}
hitSlop={{
left: secondaryControlSpacingStyles.gap / 2,
}}
logContext={logContext}
forceGoogleTranslate={forceGoogleTranslate}
/>
</View>
</View>
)
}
PostControls = memo(PostControls)
export {PostControls}
export function PostControlsSkeleton({
big,
style,
variant,
}: {
big?: boolean
style?: StyleProp<ViewStyle>
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 (
<Skele.Row
style={[a.flex_row, a.justify_between, a.align_center, a.gap_md, style]}>
<View style={[a.flex_row, a.flex_1, {maxWidth: 320}]}>
<View
style={[itemStyles, a.flex_1, a.align_start, {marginLeft: -padding}]}>
<Skele.Pill blend size={size} />
</View>
<View style={[itemStyles, a.flex_1, a.align_start]}>
<Skele.Pill blend size={size} />
</View>
<View style={[itemStyles, a.flex_1, a.align_start]}>
<Skele.Pill blend size={size} />
</View>
</View>
<View style={[a.flex_row, a.justify_end, secondaryControlSpacingStyles]}>
<View style={itemStyles}>
<Skele.Circle blend size={size} />
</View>
<View style={itemStyles}>
<Skele.Circle blend size={size} />
</View>
<View style={itemStyles}>
<Skele.Circle blend size={size} />
</View>
</View>
</Skele.Row>
)
}
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])
}