mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
1import {memo, type ReactNode, useCallback, useMemo, useState} from 'react'
2import {View} from 'react-native'
3import {
4 type AppBskyFeedDefs,
5 type AppBskyFeedThreadgate,
6 AtUri,
7 RichText as RichTextAPI,
8} from '@atproto/api'
9import {Trans} from '@lingui/macro'
10
11import {useActorStatus} from '#/lib/actor-status'
12import {MAX_POST_LINES} from '#/lib/constants'
13import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
14import {makeProfileLink} from '#/lib/routes/links'
15import {countLines} from '#/lib/strings/helpers'
16import {
17 POST_TOMBSTONE,
18 type Shadow,
19 usePostShadow,
20} from '#/state/cache/post-shadow'
21import {type ThreadItem} from '#/state/queries/usePostThread/types'
22import {useSession} from '#/state/session'
23import {type OnPostSuccessData} from '#/state/shell/composer'
24import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies'
25import {PostMeta} from '#/view/com/util/PostMeta'
26import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
27import {
28 LINEAR_AVI_WIDTH,
29 OUTER_SPACE,
30 REPLY_LINE_WIDTH,
31} from '#/screens/PostThread/const'
32import {atoms as a, useTheme} from '#/alf'
33import {useInteractionState} from '#/components/hooks/useInteractionState'
34import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash'
35import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe'
36import {PostAlerts} from '#/components/moderation/PostAlerts'
37import {PostHider} from '#/components/moderation/PostHider'
38import {type AppModerationCause} from '#/components/Pills'
39import {Embed, PostEmbedViewContext} from '#/components/Post/Embed'
40import {ShowMoreTextButton} from '#/components/Post/ShowMoreTextButton'
41import {PostControls} from '#/components/PostControls'
42import {RichText} from '#/components/RichText'
43import * as Skele from '#/components/Skeleton'
44import {SubtleWebHover} from '#/components/SubtleWebHover'
45import {Text} from '#/components/Typography'
46
47export type ThreadItemPostProps = {
48 item: Extract<ThreadItem, {type: 'threadPost'}>
49 overrides?: {
50 moderation?: boolean
51 topBorder?: boolean
52 }
53 onPostSuccess?: (data: OnPostSuccessData) => void
54 threadgateRecord?: AppBskyFeedThreadgate.Record
55}
56
57export function ThreadItemPost({
58 item,
59 overrides,
60 onPostSuccess,
61 threadgateRecord,
62}: ThreadItemPostProps) {
63 const postShadow = usePostShadow(item.value.post)
64
65 if (postShadow === POST_TOMBSTONE) {
66 return <ThreadItemPostDeleted item={item} overrides={overrides} />
67 }
68
69 return (
70 <ThreadItemPostInner
71 item={item}
72 postShadow={postShadow}
73 threadgateRecord={threadgateRecord}
74 overrides={overrides}
75 onPostSuccess={onPostSuccess}
76 />
77 )
78}
79
80function ThreadItemPostDeleted({
81 item,
82 overrides,
83}: Pick<ThreadItemPostProps, 'item' | 'overrides'>) {
84 const t = useTheme()
85
86 return (
87 <ThreadItemPostOuterWrapper item={item} overrides={overrides}>
88 <ThreadItemPostParentReplyLine item={item} />
89
90 <View
91 style={[
92 a.flex_row,
93 a.align_center,
94 a.py_md,
95 a.rounded_sm,
96 t.atoms.bg_contrast_25,
97 ]}>
98 <View
99 style={[
100 a.flex_row,
101 a.align_center,
102 a.justify_center,
103 {
104 width: LINEAR_AVI_WIDTH,
105 },
106 ]}>
107 <TrashIcon style={[t.atoms.text_contrast_medium]} />
108 </View>
109 <Text
110 style={[a.text_md, a.font_semi_bold, t.atoms.text_contrast_medium]}>
111 <Trans>Post has been deleted</Trans>
112 </Text>
113 </View>
114
115 <View style={[{height: 4}]} />
116 </ThreadItemPostOuterWrapper>
117 )
118}
119
120const ThreadItemPostOuterWrapper = memo(function ThreadItemPostOuterWrapper({
121 item,
122 overrides,
123 children,
124}: Pick<ThreadItemPostProps, 'item' | 'overrides'> & {
125 children: ReactNode
126}) {
127 const t = useTheme()
128 const showTopBorder =
129 !item.ui.showParentReplyLine && overrides?.topBorder !== true
130
131 return (
132 <View
133 style={[
134 showTopBorder && [a.border_t, t.atoms.border_contrast_low],
135 {
136 paddingHorizontal: OUTER_SPACE,
137 },
138 // If there's no next child, add a little padding to bottom
139 !item.ui.showChildReplyLine &&
140 !item.ui.precedesChildReadMore && {
141 paddingBottom: OUTER_SPACE / 2,
142 },
143 ]}>
144 {children}
145 </View>
146 )
147})
148
149/**
150 * Provides some space between posts as well as contains the reply line
151 */
152const ThreadItemPostParentReplyLine = memo(
153 function ThreadItemPostParentReplyLine({
154 item,
155 }: Pick<ThreadItemPostProps, 'item'>) {
156 const t = useTheme()
157 return (
158 <View style={[a.flex_row, {height: 12}]}>
159 <View style={{width: LINEAR_AVI_WIDTH}}>
160 {item.ui.showParentReplyLine && (
161 <View
162 style={[
163 a.mx_auto,
164 a.flex_1,
165 a.mb_xs,
166 {
167 width: REPLY_LINE_WIDTH,
168 backgroundColor: t.atoms.border_contrast_low.borderColor,
169 },
170 ]}
171 />
172 )}
173 </View>
174 </View>
175 )
176 },
177)
178
179const ThreadItemPostInner = memo(function ThreadItemPostInner({
180 item,
181 postShadow,
182 overrides,
183 onPostSuccess,
184 threadgateRecord,
185}: ThreadItemPostProps & {
186 postShadow: Shadow<AppBskyFeedDefs.PostView>
187}) {
188 const t = useTheme()
189 const {openComposer} = useOpenComposer()
190 const {currentAccount} = useSession()
191
192 const post = item.value.post
193 const record = item.value.post.record
194 const moderation = item.moderation
195 const richText = useMemo(
196 () =>
197 new RichTextAPI({
198 text: record.text,
199 facets: record.facets,
200 }),
201 [record],
202 )
203 const [limitLines, setLimitLines] = useState(
204 () => countLines(richText?.text) >= MAX_POST_LINES,
205 )
206 const threadRootUri = record.reply?.root?.uri || post.uri
207 const postHref = useMemo(() => {
208 const urip = new AtUri(post.uri)
209 return makeProfileLink(post.author, 'post', urip.rkey)
210 }, [post.uri, post.author])
211 const threadgateHiddenReplies = useMergedThreadgateHiddenReplies({
212 threadgateRecord,
213 })
214 const additionalPostAlerts: AppModerationCause[] = useMemo(() => {
215 const isPostHiddenByThreadgate = threadgateHiddenReplies.has(post.uri)
216 const isControlledByViewer =
217 new AtUri(threadRootUri).host === currentAccount?.did
218 return isControlledByViewer && isPostHiddenByThreadgate
219 ? [
220 {
221 type: 'reply-hidden',
222 source: {type: 'user', did: currentAccount?.did},
223 priority: 6,
224 },
225 ]
226 : []
227 }, [post, currentAccount?.did, threadgateHiddenReplies, threadRootUri])
228
229 const onPressReply = useCallback(() => {
230 openComposer({
231 replyTo: {
232 uri: post.uri,
233 cid: post.cid,
234 text: record.text,
235 author: post.author,
236 embed: post.embed,
237 moderation,
238 langs: post.record.langs,
239 },
240 onPostSuccess: onPostSuccess,
241 })
242 }, [openComposer, post, record, onPostSuccess, moderation])
243
244 const onPressShowMore = useCallback(() => {
245 setLimitLines(false)
246 }, [setLimitLines])
247
248 const {isActive: live} = useActorStatus(post.author)
249
250 return (
251 <SubtleHover>
252 <ThreadItemPostOuterWrapper item={item} overrides={overrides}>
253 <PostHider
254 testID={`postThreadItem-by-${post.author.handle}`}
255 href={postHref}
256 disabled={overrides?.moderation === true}
257 modui={moderation.ui('contentList')}
258 iconSize={LINEAR_AVI_WIDTH}
259 iconStyles={{marginLeft: 2, marginRight: 2}}
260 profile={post.author}
261 interpretFilterAsBlur>
262 <ThreadItemPostParentReplyLine item={item} />
263
264 <View style={[a.flex_row, a.gap_md]}>
265 <View>
266 <PreviewableUserAvatar
267 size={LINEAR_AVI_WIDTH}
268 profile={post.author}
269 moderation={moderation.ui('avatar')}
270 type={post.author.associated?.labeler ? 'labeler' : 'user'}
271 live={live}
272 />
273
274 {(item.ui.showChildReplyLine ||
275 item.ui.precedesChildReadMore) && (
276 <View
277 style={[
278 a.mx_auto,
279 a.mt_xs,
280 a.flex_1,
281 {
282 width: REPLY_LINE_WIDTH,
283 backgroundColor: t.atoms.border_contrast_low.borderColor,
284 },
285 ]}
286 />
287 )}
288 </View>
289
290 <View style={[a.flex_1]}>
291 <PostMeta
292 author={post.author}
293 moderation={moderation}
294 timestamp={post.indexedAt}
295 postHref={postHref}
296 style={[a.pb_xs]}
297 />
298 <LabelsOnMyPost post={post} style={[a.pb_xs]} />
299 <PostAlerts
300 modui={moderation.ui('contentList')}
301 style={[a.pb_2xs]}
302 additionalCauses={additionalPostAlerts}
303 />
304 {richText?.text ? (
305 <>
306 <RichText
307 enableTags
308 value={richText}
309 style={[a.flex_1, a.text_md]}
310 numberOfLines={limitLines ? MAX_POST_LINES : undefined}
311 authorHandle={post.author.handle}
312 shouldProxyLinks={true}
313 />
314 {limitLines && (
315 <ShowMoreTextButton
316 style={[a.text_md]}
317 onPress={onPressShowMore}
318 />
319 )}
320 </>
321 ) : undefined}
322 {post.embed && (
323 <View style={[a.pb_xs]}>
324 <Embed
325 embed={post.embed}
326 moderation={moderation}
327 viewContext={PostEmbedViewContext.Feed}
328 />
329 </View>
330 )}
331 <PostControls
332 post={postShadow}
333 record={record}
334 richText={richText}
335 onPressReply={onPressReply}
336 logContext="PostThreadItem"
337 threadgateRecord={threadgateRecord}
338 />
339 </View>
340 </View>
341 </PostHider>
342 </ThreadItemPostOuterWrapper>
343 </SubtleHover>
344 )
345})
346
347function SubtleHover({children}: {children: ReactNode}) {
348 const {
349 state: hover,
350 onIn: onHoverIn,
351 onOut: onHoverOut,
352 } = useInteractionState()
353 return (
354 <View
355 onPointerEnter={onHoverIn}
356 onPointerLeave={onHoverOut}
357 style={a.pointer}>
358 <SubtleWebHover hover={hover} />
359 {children}
360 </View>
361 )
362}
363
364export function ThreadItemPostSkeleton({index}: {index: number}) {
365 const even = index % 2 === 0
366 return (
367 <View
368 style={[
369 {paddingHorizontal: OUTER_SPACE, paddingVertical: OUTER_SPACE / 1.5},
370 a.gap_md,
371 ]}>
372 <Skele.Row style={[a.align_start, a.gap_md]}>
373 <Skele.Circle size={LINEAR_AVI_WIDTH} />
374
375 <Skele.Col style={[a.gap_xs]}>
376 <Skele.Row style={[a.gap_sm]}>
377 <Skele.Text style={[a.text_md, {width: '20%'}]} />
378 <Skele.Text blend style={[a.text_md, {width: '30%'}]} />
379 </Skele.Row>
380
381 <Skele.Col>
382 {even ? (
383 <>
384 <Skele.Text blend style={[a.text_md, {width: '100%'}]} />
385 <Skele.Text blend style={[a.text_md, {width: '60%'}]} />
386 </>
387 ) : (
388 <Skele.Text blend style={[a.text_md, {width: '60%'}]} />
389 )}
390 </Skele.Col>
391
392 <Skele.Row style={[a.justify_between, a.pt_xs]}>
393 <Skele.Pill blend size={16} />
394 <Skele.Pill blend size={16} />
395 <Skele.Pill blend size={16} />
396 <Skele.Circle blend size={16} />
397 <View />
398 </Skele.Row>
399 </Skele.Col>
400 </Skele.Row>
401 </View>
402 )
403}