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