this repo has no description
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} 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: l} = useLingui()
83 const {openComposer} = useOpenComposer()
84 const {feedDescriptor} = useFeedFeedbackContext()
85 const [queueLike, queueUnlike] = usePostLikeMutationQueue(
86 post,
87 viaRepost,
88 feedDescriptor,
89 logContext,
90 )
91 const [queueRepost, queueUnrepost] = usePostRepostMutationQueue(
92 post,
93 viaRepost,
94 feedDescriptor,
95 logContext,
96 )
97 const requireAuth = useRequireAuth()
98 const {sendInteraction} = useFeedFeedbackContext()
99 const {captureAction} = useProgressGuideControls()
100 const playHaptic = useHaptics()
101 const isBlocked = Boolean(
102 post.author.viewer?.blocking ||
103 post.author.viewer?.blockedBy ||
104 post.author.viewer?.blockingByList,
105 )
106 const replyDisabled = post.viewer?.replyDisabled
107 const {gtPhone} = useBreakpoints()
108 const formatPostStatCount = useFormatPostStatCount()
109
110 const [hasLikeIconBeenToggled, setHasLikeIconBeenToggled] = useState(false)
111
112 // disable metrics
113 const disableLikesMetrics = useDisableLikesMetrics()
114 const disableRepostsMetrics = useDisableRepostsMetrics()
115 const disableReplyMetrics = useDisableReplyMetrics()
116 const disableQuotesMetrics = useDisableQuotesMetrics()
117
118 const autoLikeOnRepost = useAutoLikeOnRepost()
119
120 const onPressToggleLike = async () => {
121 if (isBlocked) {
122 Toast.show(l`Cannot interact with a blocked user`, {
123 type: 'warning',
124 })
125 return
126 }
127
128 try {
129 setHasLikeIconBeenToggled(true)
130 if (!post.viewer?.like) {
131 playHaptic('Light')
132 sendInteraction({
133 item: post.uri,
134 event: 'app.bsky.feed.defs#interactionLike',
135 feedContext,
136 reqId,
137 })
138 captureAction(ProgressGuideAction.Like)
139 await queueLike()
140 } else {
141 await queueUnlike()
142 }
143 } catch (err) {
144 const e = err as Error
145 if (e?.name !== 'AbortError') {
146 throw e
147 }
148 }
149 }
150
151 const onRepost = async () => {
152 if (isBlocked) {
153 Toast.show(l`Cannot interact with a blocked user`, {
154 type: 'warning',
155 })
156 return
157 }
158
159 try {
160 if (!post.viewer?.repost) {
161 sendInteraction({
162 item: post.uri,
163 event: 'app.bsky.feed.defs#interactionRepost',
164 feedContext,
165 reqId,
166 })
167 await queueRepost()
168 setHasLikeIconBeenToggled(true)
169 if (!post.viewer?.like && autoLikeOnRepost) {
170 sendInteraction({
171 item: post.uri,
172 event: 'app.bsky.feed.defs#interactionLike',
173 feedContext,
174 reqId,
175 })
176 captureAction(ProgressGuideAction.Like)
177 await queueLike()
178 }
179 } else {
180 await queueUnrepost()
181 }
182 } catch (err) {
183 const e = err as Error
184 if (e?.name !== 'AbortError') {
185 throw e
186 }
187 }
188 }
189
190 const onQuote = () => {
191 if (isBlocked) {
192 Toast.show(l`Cannot interact with a blocked user`, {
193 type: 'warning',
194 })
195 return
196 }
197
198 sendInteraction({
199 item: post.uri,
200 event: 'app.bsky.feed.defs#interactionQuote',
201 feedContext,
202 reqId,
203 })
204 ax.metric('post:clickQuotePost', {
205 uri: post.uri,
206 authorDid: post.author.did,
207 logContext,
208 feedDescriptor,
209 })
210 openComposer({
211 quote: post,
212 onPost: onPostReply,
213 logContext: 'QuotePost',
214 })
215 }
216
217 const onShare = () => {
218 sendInteraction({
219 item: post.uri,
220 event: 'app.bsky.feed.defs#interactionShare',
221 feedContext,
222 reqId,
223 })
224 }
225
226 const secondaryControlSpacingStyles = useSecondaryControlSpacingStyles({
227 variant,
228 big,
229 gtPhone,
230 })
231
232 return (
233 <View
234 style={[
235 a.flex_row,
236 a.justify_between,
237 a.align_center,
238 !big && a.pt_2xs,
239 a.gap_md,
240 style,
241 ]}>
242 <View style={[a.flex_row, a.flex_1, {maxWidth: 320}]}>
243 <View
244 style={[
245 a.flex_1,
246 a.align_start,
247 {marginLeft: big ? -2 : -6},
248 replyDisabled ? {opacity: 0.6} : undefined,
249 ]}>
250 <PostControlButton
251 testID="replyBtn"
252 onPress={
253 !replyDisabled
254 ? () =>
255 requireAuth(() => {
256 ax.metric('post:clickReply', {
257 uri: post.uri,
258 authorDid: post.author.did,
259 logContext,
260 feedDescriptor,
261 })
262 onPressReply()
263 })
264 : undefined
265 }
266 label={l({
267 message: `Reply (${plural(post.replyCount || 0, {
268 one: '# reply',
269 other: '# replies',
270 })})`,
271 comment:
272 'Accessibility label for the reply button, verb form followed by number of replies and noun form',
273 })}
274 big={big}>
275 <PostControlButtonIcon icon={Bubble} />
276 {typeof post.replyCount !== 'undefined' &&
277 post.replyCount > 0 &&
278 !disableReplyMetrics && (
279 <PostControlButtonText>
280 {formatPostStatCount(post.replyCount)}
281 </PostControlButtonText>
282 )}
283 </PostControlButton>
284 </View>
285 <View style={[a.flex_1, a.align_start]}>
286 <RepostButton
287 isReposted={!!post.viewer?.repost}
288 repostCount={
289 (!disableRepostsMetrics ? (post.repostCount ?? 0) : 0) +
290 (!disableQuotesMetrics ? (post.quoteCount ?? 0) : 0)
291 }
292 onRepost={() => void onRepost()}
293 onQuote={onQuote}
294 big={big}
295 embeddingDisabled={Boolean(post.viewer?.embeddingDisabled)}
296 />
297 </View>
298 <View style={[a.flex_1, a.align_start]}>
299 <PostControlButton
300 testID="likeBtn"
301 big={big}
302 onPress={() => requireAuth(() => onPressToggleLike())}
303 label={
304 post.viewer?.like
305 ? l({
306 message: `Unlike (${plural(post.likeCount || 0, {
307 one: '# like',
308 other: '# likes',
309 })})`,
310 comment:
311 'Accessibility label for the like button when the post has been liked, verb followed by number of likes and noun',
312 })
313 : l({
314 message: `Like (${plural(post.likeCount || 0, {
315 one: '# like',
316 other: '# likes',
317 })})`,
318 comment:
319 'Accessibility label for the like button when the post has not been liked, verb form followed by number of likes and noun form',
320 })
321 }>
322 <AnimatedLikeIcon
323 isLiked={Boolean(post.viewer?.like)}
324 big={big}
325 hasBeenToggled={hasLikeIconBeenToggled}
326 />
327 {!disableLikesMetrics ? (
328 <CountWheel
329 likeCount={post.likeCount ?? 0}
330 big={big}
331 isLiked={Boolean(post.viewer?.like)}
332 hasBeenToggled={hasLikeIconBeenToggled}
333 />
334 ) : null}
335 </PostControlButton>
336 </View>
337 {/* Spacer! */}
338 <View />
339 </View>
340 <View style={[a.flex_row, a.justify_end, secondaryControlSpacingStyles]}>
341 <BookmarkButton
342 post={post}
343 big={big}
344 logContext={logContext}
345 hitSlop={{
346 right: secondaryControlSpacingStyles.gap / 2,
347 }}
348 />
349 <ShareMenuButton
350 testID="postShareBtn"
351 post={post}
352 big={big}
353 record={record}
354 richText={richText}
355 timestamp={post.indexedAt}
356 threadgateRecord={threadgateRecord}
357 onShare={onShare}
358 hitSlop={{
359 left: secondaryControlSpacingStyles.gap / 2,
360 right: secondaryControlSpacingStyles.gap / 2,
361 }}
362 logContext={logContext}
363 />
364 <PostMenuButton
365 testID="postDropdownBtn"
366 post={post}
367 postFeedContext={feedContext}
368 postReqId={reqId}
369 big={big}
370 record={record}
371 richText={richText}
372 timestamp={post.indexedAt}
373 threadgateRecord={threadgateRecord}
374 onShowLess={onShowLess}
375 hitSlop={{
376 left: secondaryControlSpacingStyles.gap / 2,
377 }}
378 logContext={logContext}
379 forceGoogleTranslate={forceGoogleTranslate}
380 />
381 </View>
382 </View>
383 )
384}
385PostControls = memo(PostControls)
386export {PostControls}
387
388export function PostControlsSkeleton({
389 big,
390 style,
391 variant,
392}: {
393 big?: boolean
394 style?: StyleProp<ViewStyle>
395 variant?: 'compact' | 'normal' | 'large'
396}) {
397 const {gtPhone} = useBreakpoints()
398
399 const rowHeight = big ? 32 : 28
400 const padding = 4
401 const size = rowHeight - padding * 2
402
403 const secondaryControlSpacingStyles = useSecondaryControlSpacingStyles({
404 variant,
405 big,
406 gtPhone,
407 })
408
409 const itemStyles = {
410 padding,
411 }
412
413 return (
414 <Skele.Row
415 style={[a.flex_row, a.justify_between, a.align_center, a.gap_md, style]}>
416 <View style={[a.flex_row, a.flex_1, {maxWidth: 320}]}>
417 <View
418 style={[itemStyles, a.flex_1, a.align_start, {marginLeft: -padding}]}>
419 <Skele.Pill blend size={size} />
420 </View>
421
422 <View style={[itemStyles, a.flex_1, a.align_start]}>
423 <Skele.Pill blend size={size} />
424 </View>
425
426 <View style={[itemStyles, a.flex_1, a.align_start]}>
427 <Skele.Pill blend size={size} />
428 </View>
429 </View>
430 <View style={[a.flex_row, a.justify_end, secondaryControlSpacingStyles]}>
431 <View style={itemStyles}>
432 <Skele.Circle blend size={size} />
433 </View>
434 <View style={itemStyles}>
435 <Skele.Circle blend size={size} />
436 </View>
437 <View style={itemStyles}>
438 <Skele.Circle blend size={size} />
439 </View>
440 </View>
441 </Skele.Row>
442 )
443}
444
445function useSecondaryControlSpacingStyles({
446 variant,
447 big,
448 gtPhone,
449}: {
450 variant?: 'compact' | 'normal' | 'large'
451 big?: boolean
452 gtPhone: boolean
453}) {
454 return useMemo(() => {
455 let gap = 0 // default, we want `gap` to be defined on the resulting object
456 if (variant !== 'compact') gap = a.gap_xs.gap
457 if (big || gtPhone) gap = a.gap_sm.gap
458 return {gap}
459 }, [variant, big, gtPhone])
460}