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