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