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