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 style={[a.text_md, a.font_bold, t.atoms.text_contrast_medium]}>
110 <Trans>Post has been deleted</Trans>
111 </Text>
112 </View>
113
114 <View style={[{height: 4}]} />
115 </ThreadItemPostOuterWrapper>
116 )
117}
118
119const ThreadItemPostOuterWrapper = memo(function ThreadItemPostOuterWrapper({
120 item,
121 overrides,
122 children,
123}: Pick<ThreadItemPostProps, 'item' | 'overrides'> & {
124 children: ReactNode
125}) {
126 const t = useTheme()
127 const showTopBorder =
128 !item.ui.showParentReplyLine && overrides?.topBorder !== true
129
130 return (
131 <View
132 style={[
133 showTopBorder && [a.border_t, t.atoms.border_contrast_low],
134 {
135 paddingHorizontal: OUTER_SPACE,
136 },
137 // If there's no next child, add a little padding to bottom
138 !item.ui.showChildReplyLine &&
139 !item.ui.precedesChildReadMore && {
140 paddingBottom: OUTER_SPACE / 2,
141 },
142 ]}>
143 {children}
144 </View>
145 )
146})
147
148/**
149 * Provides some space between posts as well as contains the reply line
150 */
151const ThreadItemPostParentReplyLine = memo(
152 function ThreadItemPostParentReplyLine({
153 item,
154 }: Pick<ThreadItemPostProps, 'item'>) {
155 const t = useTheme()
156 return (
157 <View style={[a.flex_row, {height: 12}]}>
158 <View style={{width: LINEAR_AVI_WIDTH}}>
159 {item.ui.showParentReplyLine && (
160 <View
161 style={[
162 a.mx_auto,
163 a.flex_1,
164 a.mb_xs,
165 {
166 width: REPLY_LINE_WIDTH,
167 backgroundColor: t.atoms.border_contrast_low.borderColor,
168 },
169 ]}
170 />
171 )}
172 </View>
173 </View>
174 )
175 },
176)
177
178const ThreadItemPostInner = memo(function ThreadItemPostInner({
179 item,
180 postShadow,
181 overrides,
182 onPostSuccess,
183 threadgateRecord,
184}: ThreadItemPostProps & {
185 postShadow: Shadow<AppBskyFeedDefs.PostView>
186}) {
187 const t = useTheme()
188 const {openComposer} = useOpenComposer()
189 const {currentAccount} = useSession()
190
191 const post = item.value.post
192 const record = item.value.post.record
193 const moderation = item.moderation
194 const richText = useMemo(
195 () =>
196 new RichTextAPI({
197 text: record.text,
198 facets: record.facets,
199 }),
200 [record],
201 )
202 const [limitLines, setLimitLines] = useState(
203 () => countLines(richText?.text) >= MAX_POST_LINES,
204 )
205 const threadRootUri = record.reply?.root?.uri || post.uri
206 const postHref = useMemo(() => {
207 const urip = new AtUri(post.uri)
208 return makeProfileLink(post.author, 'post', urip.rkey)
209 }, [post.uri, post.author])
210 const threadgateHiddenReplies = useMergedThreadgateHiddenReplies({
211 threadgateRecord,
212 })
213 const additionalPostAlerts: AppModerationCause[] = useMemo(() => {
214 const isPostHiddenByThreadgate = threadgateHiddenReplies.has(post.uri)
215 const isControlledByViewer =
216 new AtUri(threadRootUri).host === currentAccount?.did
217 return isControlledByViewer && isPostHiddenByThreadgate
218 ? [
219 {
220 type: 'reply-hidden',
221 source: {type: 'user', did: currentAccount?.did},
222 priority: 6,
223 },
224 ]
225 : []
226 }, [post, currentAccount?.did, threadgateHiddenReplies, threadRootUri])
227
228 const onPressReply = useCallback(() => {
229 openComposer({
230 replyTo: {
231 uri: post.uri,
232 cid: post.cid,
233 text: record.text,
234 author: post.author,
235 embed: post.embed,
236 moderation,
237 },
238 onPostSuccess: onPostSuccess,
239 })
240 }, [openComposer, post, record, onPostSuccess, moderation])
241
242 const onPressShowMore = useCallback(() => {
243 setLimitLines(false)
244 }, [setLimitLines])
245
246 const {isActive: live} = useActorStatus(post.author)
247
248 return (
249 <SubtleHover>
250 <ThreadItemPostOuterWrapper item={item} overrides={overrides}>
251 <PostHider
252 testID={`postThreadItem-by-${post.author.handle}`}
253 href={postHref}
254 disabled={overrides?.moderation === true}
255 modui={moderation.ui('contentList')}
256 iconSize={LINEAR_AVI_WIDTH}
257 iconStyles={{marginLeft: 2, marginRight: 2}}
258 profile={post.author}
259 interpretFilterAsBlur>
260 <ThreadItemPostParentReplyLine item={item} />
261
262 <View style={[a.flex_row, a.gap_md]}>
263 <View>
264 <PreviewableUserAvatar
265 size={LINEAR_AVI_WIDTH}
266 profile={post.author}
267 moderation={moderation.ui('avatar')}
268 type={post.author.associated?.labeler ? 'labeler' : 'user'}
269 live={live}
270 />
271
272 {(item.ui.showChildReplyLine ||
273 item.ui.precedesChildReadMore) && (
274 <View
275 style={[
276 a.mx_auto,
277 a.mt_xs,
278 a.flex_1,
279 {
280 width: REPLY_LINE_WIDTH,
281 backgroundColor: t.atoms.border_contrast_low.borderColor,
282 },
283 ]}
284 />
285 )}
286 </View>
287
288 <View style={[a.flex_1]}>
289 <PostMeta
290 author={post.author}
291 moderation={moderation}
292 timestamp={post.indexedAt}
293 postHref={postHref}
294 style={[a.pb_xs]}
295 />
296 <LabelsOnMyPost post={post} style={[a.pb_xs]} />
297 <PostAlerts
298 modui={moderation.ui('contentList')}
299 style={[a.pb_2xs]}
300 additionalCauses={additionalPostAlerts}
301 />
302 {richText?.text ? (
303 <>
304 <RichText
305 enableTags
306 value={richText}
307 style={[a.flex_1, a.text_md]}
308 numberOfLines={limitLines ? MAX_POST_LINES : undefined}
309 authorHandle={post.author.handle}
310 shouldProxyLinks={true}
311 />
312 {limitLines && (
313 <ShowMoreTextButton
314 style={[a.text_md]}
315 onPress={onPressShowMore}
316 />
317 )}
318 </>
319 ) : undefined}
320 {post.embed && (
321 <View style={[a.pb_xs]}>
322 <Embed
323 embed={post.embed}
324 moderation={moderation}
325 viewContext={PostEmbedViewContext.Feed}
326 />
327 </View>
328 )}
329 <PostControls
330 post={postShadow}
331 record={record}
332 richText={richText}
333 onPressReply={onPressReply}
334 logContext="PostThreadItem"
335 threadgateRecord={threadgateRecord}
336 />
337 </View>
338 </View>
339 </PostHider>
340 </ThreadItemPostOuterWrapper>
341 </SubtleHover>
342 )
343})
344
345function SubtleHover({children}: {children: ReactNode}) {
346 const {
347 state: hover,
348 onIn: onHoverIn,
349 onOut: onHoverOut,
350 } = useInteractionState()
351 return (
352 <View
353 onPointerEnter={onHoverIn}
354 onPointerLeave={onHoverOut}
355 style={a.pointer}>
356 <SubtleWebHover hover={hover} />
357 {children}
358 </View>
359 )
360}
361
362export function ThreadItemPostSkeleton({index}: {index: number}) {
363 const even = index % 2 === 0
364 return (
365 <View
366 style={[
367 {paddingHorizontal: OUTER_SPACE, paddingVertical: OUTER_SPACE / 1.5},
368 a.gap_md,
369 ]}>
370 <Skele.Row style={[a.align_start, a.gap_md]}>
371 <Skele.Circle size={LINEAR_AVI_WIDTH} />
372
373 <Skele.Col style={[a.gap_xs]}>
374 <Skele.Row style={[a.gap_sm]}>
375 <Skele.Text style={[a.text_md, {width: '20%'}]} />
376 <Skele.Text blend style={[a.text_md, {width: '30%'}]} />
377 </Skele.Row>
378
379 <Skele.Col>
380 {even ? (
381 <>
382 <Skele.Text blend style={[a.text_md, {width: '100%'}]} />
383 <Skele.Text blend style={[a.text_md, {width: '60%'}]} />
384 </>
385 ) : (
386 <Skele.Text blend style={[a.text_md, {width: '60%'}]} />
387 )}
388 </Skele.Col>
389
390 <Skele.Row style={[a.justify_between, a.pt_xs]}>
391 <Skele.Pill blend size={16} />
392 <Skele.Pill blend size={16} />
393 <Skele.Pill blend size={16} />
394 <Skele.Circle blend size={16} />
395 <View />
396 </Skele.Row>
397 </Skele.Col>
398 </Skele.Row>
399 </View>
400 )
401}