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