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