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, a.pt_2xs, {width: TREE_AVI_PLUS_SPACE}]}>
222 {item.ui.showChildReplyLine && (
223 <View
224 style={[
225 a.flex_1,
226 t.atoms.border_contrast_low,
227 {borderRightWidth: 2, width: '50%', left: -1},
228 ]}
229 />
230 )}
231 </View>
232 )
233 },
234)
235
236const ThreadItemTreePostInner = memo(function ThreadItemTreePostInner({
237 item,
238 postShadow,
239 overrides,
240 onPostSuccess,
241 threadgateRecord,
242}: {
243 item: Extract<ThreadItem, {type: 'threadPost'}>
244 postShadow: Shadow<AppBskyFeedDefs.PostView>
245 overrides?: {
246 moderation?: boolean
247 topBorder?: boolean
248 }
249 onPostSuccess?: (data: OnPostSuccessData) => void
250 threadgateRecord?: AppBskyFeedThreadgate.Record
251}): React.ReactNode {
252 const {openComposer} = useOpenComposer()
253 const {currentAccount} = useSession()
254
255 const post = item.value.post
256 const record = item.value.post.record
257 const moderation = item.moderation
258 const richText = useMemo(
259 () =>
260 new RichTextAPI({
261 text: record.text,
262 facets: record.facets,
263 }),
264 [record],
265 )
266 const [limitLines, setLimitLines] = useState(
267 () => countLines(richText?.text) >= MAX_POST_LINES,
268 )
269 const threadRootUri = record.reply?.root?.uri || post.uri
270 const postHref = useMemo(() => {
271 const urip = new AtUri(post.uri)
272 return makeProfileLink(post.author, 'post', urip.rkey)
273 }, [post.uri, post.author])
274 const threadgateHiddenReplies = useMergedThreadgateHiddenReplies({
275 threadgateRecord,
276 })
277 const additionalPostAlerts: AppModerationCause[] = useMemo(() => {
278 const isPostHiddenByThreadgate = threadgateHiddenReplies.has(post.uri)
279 const isControlledByViewer =
280 new AtUri(threadRootUri).host === currentAccount?.did
281 return isControlledByViewer && isPostHiddenByThreadgate
282 ? [
283 {
284 type: 'reply-hidden',
285 source: {type: 'user', did: currentAccount?.did},
286 priority: 6,
287 },
288 ]
289 : []
290 }, [post, currentAccount?.did, threadgateHiddenReplies, threadRootUri])
291
292 const onPressReply = useCallback(() => {
293 openComposer({
294 replyTo: {
295 uri: post.uri,
296 cid: post.cid,
297 text: record.text,
298 author: post.author,
299 embed: post.embed,
300 moderation,
301 langs: post.record.langs,
302 },
303 onPostSuccess: onPostSuccess,
304 })
305 }, [openComposer, post, record, onPostSuccess, moderation])
306
307 const onPressShowMore = useCallback(() => {
308 setLimitLines(false)
309 }, [setLimitLines])
310
311 return (
312 <ThreadItemTreePostOuterWrapper item={item}>
313 <SubtleHover>
314 <PostHider
315 testID={`postThreadItem-by-${post.author.handle}`}
316 href={postHref}
317 disabled={overrides?.moderation === true}
318 modui={moderation.ui('contentList')}
319 iconSize={42}
320 iconStyles={{marginLeft: 2, marginRight: 2}}
321 profile={post.author}
322 interpretFilterAsBlur>
323 <ThreadItemTreePostInnerWrapper item={item}>
324 <View style={[a.flex_1]}>
325 <PostMeta
326 author={post.author}
327 moderation={moderation}
328 timestamp={post.indexedAt}
329 postHref={postHref}
330 avatarSize={TREE_AVI_WIDTH}
331 style={[a.pb_0]}
332 showAvatar
333 />
334 <View style={[a.flex_row]}>
335 <ThreadItemTreeReplyChildReplyLine item={item} />
336 <View style={[a.flex_1, a.pl_2xs]}>
337 <LabelsOnMyPost post={post} style={[a.pb_2xs]} />
338 <PostAlerts
339 modui={moderation.ui('contentList')}
340 style={[a.pb_2xs]}
341 additionalCauses={additionalPostAlerts}
342 />
343 {richText?.text ? (
344 <>
345 <RichText
346 enableTags
347 value={richText}
348 style={[a.flex_1, a.text_md]}
349 numberOfLines={limitLines ? MAX_POST_LINES : undefined}
350 authorHandle={post.author.handle}
351 shouldProxyLinks={true}
352 />
353 {limitLines && (
354 <ShowMoreTextButton
355 style={[a.text_md]}
356 onPress={onPressShowMore}
357 />
358 )}
359 </>
360 ) : null}
361 {post.embed && (
362 <View style={[a.pb_xs]}>
363 <Embed
364 embed={post.embed}
365 moderation={moderation}
366 viewContext={PostEmbedViewContext.Feed}
367 />
368 </View>
369 )}
370 <PostControls
371 variant="compact"
372 post={postShadow}
373 record={record}
374 richText={richText}
375 onPressReply={onPressReply}
376 logContext="PostThreadItem"
377 threadgateRecord={threadgateRecord}
378 />
379 </View>
380 </View>
381 </View>
382 </ThreadItemTreePostInnerWrapper>
383 </PostHider>
384 </SubtleHover>
385 </ThreadItemTreePostOuterWrapper>
386 )
387})
388
389function SubtleHover({children}: {children: React.ReactNode}) {
390 const {
391 state: hover,
392 onIn: onHoverIn,
393 onOut: onHoverOut,
394 } = useInteractionState()
395 return (
396 <View
397 onPointerEnter={onHoverIn}
398 onPointerLeave={onHoverOut}
399 style={[a.flex_1, a.pointer]}>
400 <SubtleWebHover hover={hover} />
401 {children}
402 </View>
403 )
404}
405
406export function ThreadItemTreePostSkeleton({index}: {index: number}) {
407 const t = useTheme()
408 const even = index % 2 === 0
409 return (
410 <View
411 style={[
412 {paddingHorizontal: OUTER_SPACE, paddingVertical: OUTER_SPACE / 1.5},
413 a.gap_md,
414 a.border_t,
415 t.atoms.border_contrast_low,
416 ]}>
417 <Skele.Row style={[a.align_start, a.gap_md]}>
418 <Skele.Circle size={TREE_AVI_WIDTH} />
419
420 <Skele.Col style={[a.gap_xs]}>
421 <Skele.Row style={[a.gap_sm]}>
422 <Skele.Text style={[a.text_md, {width: '20%'}]} />
423 <Skele.Text blend style={[a.text_md, {width: '30%'}]} />
424 </Skele.Row>
425
426 <Skele.Col>
427 {even ? (
428 <>
429 <Skele.Text blend style={[a.text_md, {width: '100%'}]} />
430 <Skele.Text blend style={[a.text_md, {width: '60%'}]} />
431 </>
432 ) : (
433 <Skele.Text blend style={[a.text_md, {width: '60%'}]} />
434 )}
435 </Skele.Col>
436
437 <Skele.Row style={[a.justify_between, a.pt_xs]}>
438 <Skele.Pill blend size={16} />
439 <Skele.Pill blend size={16} />
440 <Skele.Pill blend size={16} />
441 <Skele.Circle blend size={16} />
442 <View />
443 </Skele.Row>
444 </Skele.Col>
445 </Skele.Row>
446 </View>
447 )
448}