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 })
197 }
198
199 const onShare = () => {
200 sendInteraction({
201 item: post.uri,
202 event: 'app.bsky.feed.defs#interactionShare',
203 feedContext,
204 reqId,
205 })
206 }
207
208 const secondaryControlSpacingStyles = useSecondaryControlSpacingStyles({
209 variant,
210 big,
211 gtPhone,
212 })
213
214 return (
215 <View
216 style={[
217 a.flex_row,
218 a.justify_between,
219 a.align_center,
220 !big && a.pt_2xs,
221 a.gap_md,
222 style,
223 ]}>
224 <View style={[a.flex_row, a.flex_1, {maxWidth: 320}]}>
225 <View
226 style={[
227 a.flex_1,
228 a.align_start,
229 {marginLeft: big ? -2 : -6},
230 replyDisabled ? {opacity: 0.6} : undefined,
231 ]}>
232 <PostControlButton
233 testID="replyBtn"
234 onPress={
235 !replyDisabled
236 ? () =>
237 requireAuth(() => {
238 ax.metric('post:clickReply', {
239 uri: post.uri,
240 authorDid: post.author.did,
241 logContext,
242 feedDescriptor,
243 })
244 onPressReply()
245 })
246 : undefined
247 }
248 label={_(
249 msg({
250 message: `Reply (${plural(post.replyCount || 0, {
251 one: '# reply',
252 other: '# replies',
253 })})`,
254 comment:
255 'Accessibility label for the reply button, verb form followed by number of replies and noun form',
256 }),
257 )}
258 big={big}>
259 <PostControlButtonIcon icon={Bubble} />
260 {typeof post.replyCount !== 'undefined' &&
261 post.replyCount > 0 &&
262 !disableReplyMetrics && (
263 <PostControlButtonText>
264 {formatPostStatCount(post.replyCount)}
265 </PostControlButtonText>
266 )}
267 </PostControlButton>
268 </View>
269 <View style={[a.flex_1, a.align_start]}>
270 <RepostButton
271 isReposted={!!post.viewer?.repost}
272 repostCount={
273 !disableRepostsMetrics
274 ? (post.repostCount ?? 0) + (post.quoteCount ?? 0)
275 : 0
276 }
277 onRepost={onRepost}
278 onQuote={onQuote}
279 big={big}
280 embeddingDisabled={Boolean(post.viewer?.embeddingDisabled)}
281 />
282 </View>
283 <View style={[a.flex_1, a.align_start]}>
284 <PostControlButton
285 testID="likeBtn"
286 big={big}
287 onPress={() => requireAuth(() => onPressToggleLike())}
288 label={
289 post.viewer?.like
290 ? _(
291 msg({
292 message: `Unlike (${plural(post.likeCount || 0, {
293 one: '# like',
294 other: '# likes',
295 })})`,
296 comment:
297 'Accessibility label for the like button when the post has been liked, verb followed by number of likes and noun',
298 }),
299 )
300 : _(
301 msg({
302 message: `Like (${plural(post.likeCount || 0, {
303 one: '# like',
304 other: '# likes',
305 })})`,
306 comment:
307 'Accessibility label for the like button when the post has not been liked, verb form followed by number of likes and noun form',
308 }),
309 )
310 }>
311 <AnimatedLikeIcon
312 isLiked={Boolean(post.viewer?.like)}
313 big={big}
314 hasBeenToggled={hasLikeIconBeenToggled}
315 />
316 {!disableLikesMetrics ? (
317 <CountWheel
318 likeCount={post.likeCount ?? 0}
319 big={big}
320 isLiked={Boolean(post.viewer?.like)}
321 hasBeenToggled={hasLikeIconBeenToggled}
322 />
323 ) : null}
324 </PostControlButton>
325 </View>
326 {/* Spacer! */}
327 <View />
328 </View>
329 <View style={[a.flex_row, a.justify_end, secondaryControlSpacingStyles]}>
330 <BookmarkButton
331 post={post}
332 big={big}
333 logContext={logContext}
334 hitSlop={{
335 right: secondaryControlSpacingStyles.gap / 2,
336 }}
337 />
338 <ShareMenuButton
339 testID="postShareBtn"
340 post={post}
341 big={big}
342 record={record}
343 richText={richText}
344 timestamp={post.indexedAt}
345 threadgateRecord={threadgateRecord}
346 onShare={onShare}
347 hitSlop={{
348 left: secondaryControlSpacingStyles.gap / 2,
349 right: secondaryControlSpacingStyles.gap / 2,
350 }}
351 logContext={logContext}
352 />
353 <PostMenuButton
354 testID="postDropdownBtn"
355 post={post}
356 postFeedContext={feedContext}
357 postReqId={reqId}
358 big={big}
359 record={record}
360 richText={richText}
361 timestamp={post.indexedAt}
362 threadgateRecord={threadgateRecord}
363 onShowLess={onShowLess}
364 hitSlop={{
365 left: secondaryControlSpacingStyles.gap / 2,
366 }}
367 logContext={logContext}
368 />
369 </View>
370 </View>
371 )
372}
373PostControls = memo(PostControls)
374export {PostControls}
375
376export function PostControlsSkeleton({
377 big,
378 style,
379 variant,
380}: {
381 big?: boolean
382 style?: StyleProp<ViewStyle>
383 variant?: 'compact' | 'normal' | 'large'
384}) {
385 const {gtPhone} = useBreakpoints()
386
387 const rowHeight = big ? 32 : 28
388 const padding = 4
389 const size = rowHeight - padding * 2
390
391 const secondaryControlSpacingStyles = useSecondaryControlSpacingStyles({
392 variant,
393 big,
394 gtPhone,
395 })
396
397 const itemStyles = {
398 padding,
399 }
400
401 return (
402 <Skele.Row
403 style={[a.flex_row, a.justify_between, a.align_center, a.gap_md, style]}>
404 <View style={[a.flex_row, a.flex_1, {maxWidth: 320}]}>
405 <View
406 style={[itemStyles, a.flex_1, a.align_start, {marginLeft: -padding}]}>
407 <Skele.Pill blend size={size} />
408 </View>
409
410 <View style={[itemStyles, a.flex_1, a.align_start]}>
411 <Skele.Pill blend size={size} />
412 </View>
413
414 <View style={[itemStyles, a.flex_1, a.align_start]}>
415 <Skele.Pill blend size={size} />
416 </View>
417 </View>
418 <View style={[a.flex_row, a.justify_end, secondaryControlSpacingStyles]}>
419 <View style={itemStyles}>
420 <Skele.Circle blend size={size} />
421 </View>
422 <View style={itemStyles}>
423 <Skele.Circle blend size={size} />
424 </View>
425 <View style={itemStyles}>
426 <Skele.Circle blend size={size} />
427 </View>
428 </View>
429 </Skele.Row>
430 )
431}
432
433function useSecondaryControlSpacingStyles({
434 variant,
435 big,
436 gtPhone,
437}: {
438 variant?: 'compact' | 'normal' | 'large'
439 big?: boolean
440 gtPhone: boolean
441}) {
442 return useMemo(() => {
443 let gap = 0 // default, we want `gap` to be defined on the resulting object
444 if (variant !== 'compact') gap = a.gap_xs.gap
445 if (big || gtPhone) gap = a.gap_sm.gap
446 return {gap}
447 }, [variant, big, gtPhone])
448}