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