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