mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
1import React, {memo, useCallback} from 'react'
2import {
3 Pressable,
4 type PressableStateCallbackType,
5 type StyleProp,
6 View,
7 type ViewStyle,
8} from 'react-native'
9import * as Clipboard from 'expo-clipboard'
10import {
11 AppBskyFeedDefs,
12 AppBskyFeedPost,
13 AppBskyFeedThreadgate,
14 AtUri,
15 RichText as RichTextAPI,
16} from '@atproto/api'
17import {msg, plural} from '@lingui/macro'
18import {useLingui} from '@lingui/react'
19
20import {IS_INTERNAL} from '#/lib/app-info'
21import {DISCOVER_DEBUG_DIDS, POST_CTRL_HITSLOP} from '#/lib/constants'
22import {CountWheel} from '#/lib/custom-animations/CountWheel'
23import {AnimatedLikeIcon} from '#/lib/custom-animations/LikeIcon'
24import {useHaptics} from '#/lib/haptics'
25import {makeProfileLink} from '#/lib/routes/links'
26import {shareUrl} from '#/lib/sharing'
27import {toShareUrl} from '#/lib/strings/url-helpers'
28import {Shadow} from '#/state/cache/types'
29import {useFeedFeedbackContext} from '#/state/feed-feedback'
30import {
31 usePostLikeMutationQueue,
32 usePostRepostMutationQueue,
33} from '#/state/queries/post'
34import {useRequireAuth, useSession} from '#/state/session'
35import {useComposerControls} from '#/state/shell/composer'
36import {
37 ProgressGuideAction,
38 useProgressGuideControls,
39} from '#/state/shell/progress-guide'
40import {atoms as a, useTheme} from '#/alf'
41import {useDialogControl} from '#/components/Dialog'
42import {ArrowOutOfBox_Stroke2_Corner0_Rounded as ArrowOutOfBox} from '#/components/icons/ArrowOutOfBox'
43import {Bubble_Stroke2_Corner2_Rounded as Bubble} from '#/components/icons/Bubble'
44import * as Prompt from '#/components/Prompt'
45import {PostDropdownBtn} from '../forms/PostDropdownBtn'
46import {formatCount} from '../numeric/format'
47import {Text} from '../text/Text'
48import * as Toast from '../Toast'
49import {RepostButton} from './RepostButton'
50
51let PostCtrls = ({
52 big,
53 post,
54 record,
55 richText,
56 feedContext,
57 style,
58 onPressReply,
59 onPostReply,
60 logContext,
61 threadgateRecord,
62}: {
63 big?: boolean
64 post: Shadow<AppBskyFeedDefs.PostView>
65 record: AppBskyFeedPost.Record
66 richText: RichTextAPI
67 feedContext?: string | undefined
68 style?: StyleProp<ViewStyle>
69 onPressReply: () => void
70 onPostReply?: (postUri: string | undefined) => void
71 logContext: 'FeedItem' | 'PostThreadItem' | 'Post'
72 threadgateRecord?: AppBskyFeedThreadgate.Record
73}): React.ReactNode => {
74 const t = useTheme()
75 const {_, i18n} = useLingui()
76 const {openComposer} = useComposerControls()
77 const {currentAccount} = useSession()
78 const [queueLike, queueUnlike] = usePostLikeMutationQueue(post, logContext)
79 const [queueRepost, queueUnrepost] = usePostRepostMutationQueue(
80 post,
81 logContext,
82 )
83 const requireAuth = useRequireAuth()
84 const loggedOutWarningPromptControl = useDialogControl()
85 const {sendInteraction} = useFeedFeedbackContext()
86 const {captureAction} = useProgressGuideControls()
87 const playHaptic = useHaptics()
88 const isDiscoverDebugUser =
89 IS_INTERNAL || DISCOVER_DEBUG_DIDS[currentAccount?.did ?? '']
90 const isBlocked = Boolean(
91 post.author.viewer?.blocking ||
92 post.author.viewer?.blockedBy ||
93 post.author.viewer?.blockingByList,
94 )
95
96 const shouldShowLoggedOutWarning = React.useMemo(() => {
97 return (
98 post.author.did !== currentAccount?.did &&
99 !!post.author.labels?.find(label => label.val === '!no-unauthenticated')
100 )
101 }, [currentAccount, post])
102
103 const defaultCtrlColor = React.useMemo(
104 () => ({
105 color: t.palette.contrast_500,
106 }),
107 [t],
108 ) as StyleProp<ViewStyle>
109
110 const [hasLikeIconBeenToggled, setHasLikeIconBeenToggled] =
111 React.useState(false)
112
113 const onPressToggleLike = React.useCallback(async () => {
114 if (isBlocked) {
115 Toast.show(
116 _(msg`Cannot interact with a blocked user`),
117 'exclamation-circle',
118 )
119 return
120 }
121
122 try {
123 setHasLikeIconBeenToggled(true)
124 if (!post.viewer?.like) {
125 playHaptic('Light')
126 sendInteraction({
127 item: post.uri,
128 event: 'app.bsky.feed.defs#interactionLike',
129 feedContext,
130 })
131 captureAction(ProgressGuideAction.Like)
132 await queueLike()
133 } else {
134 await queueUnlike()
135 }
136 } catch (e: any) {
137 if (e?.name !== 'AbortError') {
138 throw e
139 }
140 }
141 }, [
142 _,
143 playHaptic,
144 post.uri,
145 post.viewer?.like,
146 queueLike,
147 queueUnlike,
148 sendInteraction,
149 captureAction,
150 feedContext,
151 isBlocked,
152 ])
153
154 const onRepost = useCallback(async () => {
155 if (isBlocked) {
156 Toast.show(
157 _(msg`Cannot interact with a blocked user`),
158 'exclamation-circle',
159 )
160 return
161 }
162
163 try {
164 if (!post.viewer?.repost) {
165 sendInteraction({
166 item: post.uri,
167 event: 'app.bsky.feed.defs#interactionRepost',
168 feedContext,
169 })
170 await queueRepost()
171 } else {
172 await queueUnrepost()
173 }
174 } catch (e: any) {
175 if (e?.name !== 'AbortError') {
176 throw e
177 }
178 }
179 }, [
180 _,
181 post.uri,
182 post.viewer?.repost,
183 queueRepost,
184 queueUnrepost,
185 sendInteraction,
186 feedContext,
187 isBlocked,
188 ])
189
190 const onQuote = useCallback(() => {
191 if (isBlocked) {
192 Toast.show(
193 _(msg`Cannot interact with a blocked user`),
194 'exclamation-circle',
195 )
196 return
197 }
198
199 sendInteraction({
200 item: post.uri,
201 event: 'app.bsky.feed.defs#interactionQuote',
202 feedContext,
203 })
204 openComposer({
205 quote: post,
206 onPost: onPostReply,
207 })
208 }, [
209 _,
210 sendInteraction,
211 post,
212 feedContext,
213 openComposer,
214 onPostReply,
215 isBlocked,
216 ])
217
218 const onShare = useCallback(() => {
219 const urip = new AtUri(post.uri)
220 const href = makeProfileLink(post.author, 'post', urip.rkey)
221 const url = toShareUrl(href)
222 shareUrl(url)
223 sendInteraction({
224 item: post.uri,
225 event: 'app.bsky.feed.defs#interactionShare',
226 feedContext,
227 })
228 }, [post.uri, post.author, sendInteraction, feedContext])
229
230 const btnStyle = React.useCallback(
231 ({pressed, hovered}: PressableStateCallbackType) => [
232 a.gap_xs,
233 a.rounded_full,
234 a.flex_row,
235 a.justify_center,
236 a.align_center,
237 a.overflow_hidden,
238 {padding: 5},
239 (pressed || hovered) && t.atoms.bg_contrast_25,
240 ],
241 [t.atoms.bg_contrast_25],
242 )
243
244 return (
245 <View style={[a.flex_row, a.justify_between, a.align_center, style]}>
246 <View
247 style={[
248 big ? a.align_center : [a.flex_1, a.align_start, {marginLeft: -6}],
249 post.viewer?.replyDisabled ? {opacity: 0.5} : undefined,
250 ]}>
251 <Pressable
252 testID="replyBtn"
253 style={btnStyle}
254 onPress={() => {
255 if (!post.viewer?.replyDisabled) {
256 playHaptic('Light')
257 requireAuth(() => onPressReply())
258 }
259 }}
260 accessibilityRole="button"
261 accessibilityLabel={_(
262 msg`Reply (${plural(post.replyCount || 0, {
263 one: '# reply',
264 other: '# replies',
265 })})`,
266 )}
267 accessibilityHint=""
268 hitSlop={POST_CTRL_HITSLOP}>
269 <Bubble
270 style={[defaultCtrlColor, {pointerEvents: 'none'}]}
271 width={big ? 22 : 18}
272 />
273 {typeof post.replyCount !== 'undefined' && post.replyCount > 0 ? (
274 <Text
275 style={[
276 defaultCtrlColor,
277 big ? a.text_md : {fontSize: 15},
278 a.user_select_none,
279 ]}>
280 {formatCount(i18n, post.replyCount)}
281 </Text>
282 ) : undefined}
283 </Pressable>
284 </View>
285 <View style={big ? a.align_center : [a.flex_1, a.align_start]}>
286 <RepostButton
287 isReposted={!!post.viewer?.repost}
288 repostCount={(post.repostCount ?? 0) + (post.quoteCount ?? 0)}
289 onRepost={onRepost}
290 onQuote={onQuote}
291 big={big}
292 embeddingDisabled={Boolean(post.viewer?.embeddingDisabled)}
293 />
294 </View>
295 <View style={big ? a.align_center : [a.flex_1, a.align_start]}>
296 <Pressable
297 testID="likeBtn"
298 style={btnStyle}
299 onPress={() => requireAuth(() => onPressToggleLike())}
300 accessibilityRole="button"
301 accessibilityLabel={
302 post.viewer?.like
303 ? _(
304 msg`Unlike (${plural(post.likeCount || 0, {
305 one: '# like',
306 other: '# likes',
307 })})`,
308 )
309 : _(
310 msg`Like (${plural(post.likeCount || 0, {
311 one: '# like',
312 other: '# likes',
313 })})`,
314 )
315 }
316 accessibilityHint=""
317 hitSlop={POST_CTRL_HITSLOP}>
318 <AnimatedLikeIcon
319 isLiked={Boolean(post.viewer?.like)}
320 big={big}
321 hasBeenToggled={hasLikeIconBeenToggled}
322 />
323 <CountWheel
324 likeCount={post.likeCount ?? 0}
325 big={big}
326 isLiked={Boolean(post.viewer?.like)}
327 hasBeenToggled={hasLikeIconBeenToggled}
328 />
329 </Pressable>
330 </View>
331 {big && (
332 <>
333 <View style={a.align_center}>
334 <Pressable
335 testID="shareBtn"
336 style={btnStyle}
337 onPress={() => {
338 if (shouldShowLoggedOutWarning) {
339 loggedOutWarningPromptControl.open()
340 } else {
341 onShare()
342 }
343 }}
344 accessibilityRole="button"
345 accessibilityLabel={_(msg`Share`)}
346 accessibilityHint=""
347 hitSlop={POST_CTRL_HITSLOP}>
348 <ArrowOutOfBox
349 style={[defaultCtrlColor, {pointerEvents: 'none'}]}
350 width={22}
351 />
352 </Pressable>
353 </View>
354 <Prompt.Basic
355 control={loggedOutWarningPromptControl}
356 title={_(msg`Note about sharing`)}
357 description={_(
358 msg`This post is only visible to logged-in users. It won't be visible to people who aren't logged in.`,
359 )}
360 onConfirm={onShare}
361 confirmButtonCta={_(msg`Share anyway`)}
362 />
363 </>
364 )}
365 <View style={big ? a.align_center : [a.flex_1, a.align_start]}>
366 <PostDropdownBtn
367 testID="postDropdownBtn"
368 post={post}
369 postFeedContext={feedContext}
370 record={record}
371 richText={richText}
372 style={{padding: 5}}
373 hitSlop={POST_CTRL_HITSLOP}
374 timestamp={post.indexedAt}
375 threadgateRecord={threadgateRecord}
376 />
377 </View>
378 {isDiscoverDebugUser && feedContext && (
379 <Pressable
380 accessible={false}
381 style={{
382 position: 'absolute',
383 top: 0,
384 bottom: 0,
385 right: 0,
386 display: 'flex',
387 justifyContent: 'center',
388 }}
389 onPress={e => {
390 e.stopPropagation()
391 Clipboard.setStringAsync(feedContext)
392 Toast.show(_(msg`Copied to clipboard`), 'clipboard-check')
393 }}>
394 <Text
395 style={{
396 color: t.palette.contrast_400,
397 fontSize: 7,
398 }}>
399 {feedContext}
400 </Text>
401 </Pressable>
402 )}
403 </View>
404 )
405}
406PostCtrls = memo(PostCtrls)
407export {PostCtrls}