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