bsky-app/src/view/com/util/post-ctrls/PostCtrls.tsx

403 lines
11 KiB
TypeScript

import React, {memo, useCallback} from 'react'
import {
Pressable,
type PressableStateCallbackType,
type StyleProp,
View,
type ViewStyle,
} from 'react-native'
import * as Clipboard from 'expo-clipboard'
import {
AppBskyFeedDefs,
AppBskyFeedPost,
AppBskyFeedThreadgate,
AtUri,
RichText as RichTextAPI,
} from '@atproto/api'
import {msg, plural} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {POST_CTRL_HITSLOP} from '#/lib/constants'
import {useHaptics} from '#/lib/haptics'
import {makeProfileLink} from '#/lib/routes/links'
import {shareUrl} from '#/lib/sharing'
import {useGate} from '#/lib/statsig/statsig'
import {toShareUrl} from '#/lib/strings/url-helpers'
import {Shadow} from '#/state/cache/types'
import {useFeedFeedbackContext} from '#/state/feed-feedback'
import {
usePostLikeMutationQueue,
usePostRepostMutationQueue,
} from '#/state/queries/post'
import {useRequireAuth, useSession} from '#/state/session'
import {useComposerControls} from '#/state/shell/composer'
import {
ProgressGuideAction,
useProgressGuideControls,
} from '#/state/shell/progress-guide'
import {CountWheel} from 'lib/custom-animations/CountWheel'
import {AnimatedLikeIcon} from 'lib/custom-animations/LikeIcon'
import {atoms as a, useTheme} from '#/alf'
import {useDialogControl} from '#/components/Dialog'
import {ArrowOutOfBox_Stroke2_Corner0_Rounded as ArrowOutOfBox} from '#/components/icons/ArrowOutOfBox'
import {Bubble_Stroke2_Corner2_Rounded as Bubble} from '#/components/icons/Bubble'
import * as Prompt from '#/components/Prompt'
import {PostDropdownBtn} from '../forms/PostDropdownBtn'
import {formatCount} from '../numeric/format'
import {Text} from '../text/Text'
import * as Toast from '../Toast'
import {RepostButton} from './RepostButton'
let PostCtrls = ({
big,
post,
record,
richText,
feedContext,
style,
onPressReply,
onPostReply,
logContext,
threadgateRecord,
}: {
big?: boolean
post: Shadow<AppBskyFeedDefs.PostView>
record: AppBskyFeedPost.Record
richText: RichTextAPI
feedContext?: string | undefined
style?: StyleProp<ViewStyle>
onPressReply: () => void
onPostReply?: (postUri: string | undefined) => void
logContext: 'FeedItem' | 'PostThreadItem' | 'Post'
threadgateRecord?: AppBskyFeedThreadgate.Record
}): React.ReactNode => {
const t = useTheme()
const {_, i18n} = useLingui()
const {openComposer} = useComposerControls()
const {currentAccount} = useSession()
const [queueLike, queueUnlike] = usePostLikeMutationQueue(post, logContext)
const [queueRepost, queueUnrepost] = usePostRepostMutationQueue(
post,
logContext,
)
const requireAuth = useRequireAuth()
const loggedOutWarningPromptControl = useDialogControl()
const {sendInteraction} = useFeedFeedbackContext()
const {captureAction} = useProgressGuideControls()
const playHaptic = useHaptics()
const gate = useGate()
const isBlocked = Boolean(
post.author.viewer?.blocking ||
post.author.viewer?.blockedBy ||
post.author.viewer?.blockingByList,
)
const shouldShowLoggedOutWarning = React.useMemo(() => {
return (
post.author.did !== currentAccount?.did &&
!!post.author.labels?.find(label => label.val === '!no-unauthenticated')
)
}, [currentAccount, post])
const defaultCtrlColor = React.useMemo(
() => ({
color: t.palette.contrast_500,
}),
[t],
) as StyleProp<ViewStyle>
const likeValue = post.viewer?.like ? 1 : 0
const nextExpectedLikeValue = React.useRef(likeValue)
const onPressToggleLike = React.useCallback(async () => {
if (isBlocked) {
Toast.show(
_(msg`Cannot interact with a blocked user`),
'exclamation-circle',
)
return
}
try {
if (!post.viewer?.like) {
nextExpectedLikeValue.current = 1
playHaptic()
sendInteraction({
item: post.uri,
event: 'app.bsky.feed.defs#interactionLike',
feedContext,
})
captureAction(ProgressGuideAction.Like)
await queueLike()
} else {
nextExpectedLikeValue.current = 0
await queueUnlike()
}
} catch (e: any) {
if (e?.name !== 'AbortError') {
throw e
}
}
}, [
_,
playHaptic,
post.uri,
post.viewer?.like,
queueLike,
queueUnlike,
sendInteraction,
captureAction,
feedContext,
isBlocked,
])
const onRepost = useCallback(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,
})
await queueRepost()
} else {
await queueUnrepost()
}
} catch (e: any) {
if (e?.name !== 'AbortError') {
throw e
}
}
}, [
_,
post.uri,
post.viewer?.repost,
queueRepost,
queueUnrepost,
sendInteraction,
feedContext,
isBlocked,
])
const onQuote = useCallback(() => {
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,
})
openComposer({
quote: {
uri: post.uri,
cid: post.cid,
text: record.text,
author: post.author,
indexedAt: post.indexedAt,
},
quoteCount: post.quoteCount,
onPost: onPostReply,
})
}, [
_,
sendInteraction,
post.uri,
post.cid,
post.author,
post.indexedAt,
post.quoteCount,
feedContext,
openComposer,
record.text,
onPostReply,
isBlocked,
])
const onShare = useCallback(() => {
const urip = new AtUri(post.uri)
const href = makeProfileLink(post.author, 'post', urip.rkey)
const url = toShareUrl(href)
shareUrl(url)
sendInteraction({
item: post.uri,
event: 'app.bsky.feed.defs#interactionShare',
feedContext,
})
}, [post.uri, post.author, sendInteraction, feedContext])
const btnStyle = React.useCallback(
({pressed, hovered}: PressableStateCallbackType) => [
a.gap_xs,
a.rounded_full,
a.flex_row,
a.justify_center,
a.align_center,
{padding: 5},
(pressed || hovered) && t.atoms.bg_contrast_25,
],
[t.atoms.bg_contrast_25],
)
return (
<View style={[a.flex_row, a.justify_between, a.align_center, style]}>
<View
style={[
big ? a.align_center : [a.flex_1, a.align_start, {marginLeft: -6}],
post.viewer?.replyDisabled ? {opacity: 0.5} : undefined,
]}>
<Pressable
testID="replyBtn"
style={btnStyle}
onPress={() => {
if (!post.viewer?.replyDisabled) {
requireAuth(() => onPressReply())
}
}}
accessibilityLabel={plural(post.replyCount || 0, {
one: 'Reply (# reply)',
other: 'Reply (# replies)',
})}
accessibilityHint=""
hitSlop={POST_CTRL_HITSLOP}>
<Bubble
style={[defaultCtrlColor, {pointerEvents: 'none'}]}
width={big ? 22 : 18}
/>
{typeof post.replyCount !== 'undefined' && post.replyCount > 0 ? (
<Text
style={[
defaultCtrlColor,
big ? a.text_md : {fontSize: 15},
a.user_select_none,
]}>
{formatCount(i18n, post.replyCount)}
</Text>
) : undefined}
</Pressable>
</View>
<View style={big ? a.align_center : [a.flex_1, a.align_start]}>
<RepostButton
isReposted={!!post.viewer?.repost}
repostCount={(post.repostCount ?? 0) + (post.quoteCount ?? 0)}
onRepost={onRepost}
onQuote={onQuote}
big={big}
embeddingDisabled={Boolean(post.viewer?.embeddingDisabled)}
/>
</View>
<View style={big ? a.align_center : [a.flex_1, a.align_start]}>
<Pressable
testID="likeBtn"
style={btnStyle}
onPress={() => requireAuth(() => onPressToggleLike())}
accessibilityLabel={
post.viewer?.like
? plural(post.likeCount || 0, {
one: 'Unlike (# like)',
other: 'Unlike (# likes)',
})
: plural(post.likeCount || 0, {
one: 'Like (# like)',
other: 'Like (# likes)',
})
}
accessibilityHint=""
hitSlop={POST_CTRL_HITSLOP}>
<AnimatedLikeIcon isLiked={Boolean(post.viewer?.like)} big={big} />
<CountWheel
likeCount={post.likeCount ?? 0}
big={big}
isLiked={Boolean(post.viewer?.like)}
/>
</Pressable>
</View>
{big && (
<>
<View style={a.align_center}>
<Pressable
testID="shareBtn"
style={btnStyle}
onPress={() => {
if (shouldShowLoggedOutWarning) {
loggedOutWarningPromptControl.open()
} else {
onShare()
}
}}
accessibilityLabel={_(msg`Share`)}
accessibilityHint=""
hitSlop={POST_CTRL_HITSLOP}>
<ArrowOutOfBox
style={[defaultCtrlColor, {pointerEvents: 'none'}]}
width={22}
/>
</Pressable>
</View>
<Prompt.Basic
control={loggedOutWarningPromptControl}
title={_(msg`Note about sharing`)}
description={_(
msg`This post is only visible to logged-in users. It won't be visible to people who aren't logged in.`,
)}
onConfirm={onShare}
confirmButtonCta={_(msg`Share anyway`)}
/>
</>
)}
<View style={big ? a.align_center : [a.flex_1, a.align_start]}>
<PostDropdownBtn
testID="postDropdownBtn"
post={post}
postFeedContext={feedContext}
record={record}
richText={richText}
style={{padding: 5}}
hitSlop={POST_CTRL_HITSLOP}
timestamp={post.indexedAt}
threadgateRecord={threadgateRecord}
/>
</View>
{gate('debug_show_feedcontext') && feedContext && (
<Pressable
accessible={false}
style={{
position: 'absolute',
top: 0,
bottom: 0,
right: 0,
display: 'flex',
justifyContent: 'center',
}}
onPress={e => {
e.stopPropagation()
Clipboard.setStringAsync(feedContext)
Toast.show(_(msg`Copied to clipboard`), 'clipboard-check')
}}>
<Text
style={{
color: t.palette.contrast_400,
fontSize: 7,
}}>
{feedContext}
</Text>
</Pressable>
)}
</View>
)
}
PostCtrls = memo(PostCtrls)
export {PostCtrls}