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