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