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