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 langs: post.record.langs,
238 },
239 onPostSuccess: onPostSuccess,
240 })
241 }, [openComposer, post, record, onPostSuccess, moderation])
242
243 const onPressShowMore = useCallback(() => {
244 setLimitLines(false)
245 }, [setLimitLines])
246
247 const {isActive: live} = useActorStatus(post.author)
248
249 return (
250 <SubtleHover>
251 <ThreadItemPostOuterWrapper item={item} overrides={overrides}>
252 <PostHider
253 testID={`postThreadItem-by-${post.author.handle}`}
254 href={postHref}
255 disabled={overrides?.moderation === true}
256 modui={moderation.ui('contentList')}
257 iconSize={LINEAR_AVI_WIDTH}
258 iconStyles={{marginLeft: 2, marginRight: 2}}
259 profile={post.author}
260 interpretFilterAsBlur>
261 <ThreadItemPostParentReplyLine item={item} />
262
263 <View style={[a.flex_row, a.gap_md]}>
264 <View>
265 <PreviewableUserAvatar
266 size={LINEAR_AVI_WIDTH}
267 profile={post.author}
268 moderation={moderation.ui('avatar')}
269 type={post.author.associated?.labeler ? 'labeler' : 'user'}
270 live={live}
271 />
272
273 {(item.ui.showChildReplyLine ||
274 item.ui.precedesChildReadMore) && (
275 <View
276 style={[
277 a.mx_auto,
278 a.mt_xs,
279 a.flex_1,
280 {
281 width: REPLY_LINE_WIDTH,
282 backgroundColor: t.atoms.border_contrast_low.borderColor,
283 },
284 ]}
285 />
286 )}
287 </View>
288
289 <View style={[a.flex_1]}>
290 <PostMeta
291 author={post.author}
292 moderation={moderation}
293 timestamp={post.indexedAt}
294 postHref={postHref}
295 style={[a.pb_xs]}
296 />
297 <LabelsOnMyPost post={post} style={[a.pb_xs]} />
298 <PostAlerts
299 modui={moderation.ui('contentList')}
300 style={[a.pb_2xs]}
301 additionalCauses={additionalPostAlerts}
302 />
303 {richText?.text ? (
304 <>
305 <RichText
306 enableTags
307 value={richText}
308 style={[a.flex_1, a.text_md]}
309 numberOfLines={limitLines ? MAX_POST_LINES : undefined}
310 authorHandle={post.author.handle}
311 shouldProxyLinks={true}
312 />
313 {limitLines && (
314 <ShowMoreTextButton
315 style={[a.text_md]}
316 onPress={onPressShowMore}
317 />
318 )}
319 </>
320 ) : undefined}
321 {post.embed && (
322 <View style={[a.pb_xs]}>
323 <Embed
324 embed={post.embed}
325 moderation={moderation}
326 viewContext={PostEmbedViewContext.Feed}
327 />
328 </View>
329 )}
330 <PostControls
331 post={postShadow}
332 record={record}
333 richText={richText}
334 onPressReply={onPressReply}
335 logContext="PostThreadItem"
336 threadgateRecord={threadgateRecord}
337 />
338 </View>
339 </View>
340 </PostHider>
341 </ThreadItemPostOuterWrapper>
342 </SubtleHover>
343 )
344})
345
346function SubtleHover({children}: {children: ReactNode}) {
347 const {
348 state: hover,
349 onIn: onHoverIn,
350 onOut: onHoverOut,
351 } = useInteractionState()
352 return (
353 <View
354 onPointerEnter={onHoverIn}
355 onPointerLeave={onHoverOut}
356 style={a.pointer}>
357 <SubtleWebHover hover={hover} />
358 {children}
359 </View>
360 )
361}
362
363export function ThreadItemPostSkeleton({index}: {index: number}) {
364 const even = index % 2 === 0
365 return (
366 <View
367 style={[
368 {paddingHorizontal: OUTER_SPACE, paddingVertical: OUTER_SPACE / 1.5},
369 a.gap_md,
370 ]}>
371 <Skele.Row style={[a.align_start, a.gap_md]}>
372 <Skele.Circle size={LINEAR_AVI_WIDTH} />
373
374 <Skele.Col style={[a.gap_xs]}>
375 <Skele.Row style={[a.gap_sm]}>
376 <Skele.Text style={[a.text_md, {width: '20%'}]} />
377 <Skele.Text blend style={[a.text_md, {width: '30%'}]} />
378 </Skele.Row>
379
380 <Skele.Col>
381 {even ? (
382 <>
383 <Skele.Text blend style={[a.text_md, {width: '100%'}]} />
384 <Skele.Text blend style={[a.text_md, {width: '60%'}]} />
385 </>
386 ) : (
387 <Skele.Text blend style={[a.text_md, {width: '60%'}]} />
388 )}
389 </Skele.Col>
390
391 <Skele.Row style={[a.justify_between, a.pt_xs]}>
392 <Skele.Pill blend size={16} />
393 <Skele.Pill blend size={16} />
394 <Skele.Pill blend size={16} />
395 <Skele.Circle blend size={16} />
396 <View />
397 </Skele.Row>
398 </Skele.Col>
399 </Skele.Row>
400 </View>
401 )
402}