forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
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/react/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 {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
26import {
27 LINEAR_AVI_WIDTH,
28 OUTER_SPACE,
29 REPLY_LINE_WIDTH,
30} from '#/screens/PostThread/const'
31import {atoms as a, useTheme} from '#/alf'
32import {DebugFieldDisplay} from '#/components/DebugFieldDisplay'
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 {TranslatedPost} from '#/components/Post/Translated'
42import {PostControls, PostControlsSkeleton} from '#/components/PostControls'
43import {RichText} from '#/components/RichText'
44import * as Skele from '#/components/Skeleton'
45import {SubtleHover} from '#/components/SubtleHover'
46import {Text} from '#/components/Typography'
47import {useActorStatus} from '#/features/liveNow'
48
49export type ThreadItemPostProps = {
50 item: Extract<ThreadItem, {type: 'threadPost'}>
51 overrides?: {
52 moderation?: boolean
53 topBorder?: boolean
54 }
55 onPostSuccess?: (data: OnPostSuccessData) => void
56 threadgateRecord?: AppBskyFeedThreadgate.Record
57}
58
59export function ThreadItemPost({
60 item,
61 overrides,
62 onPostSuccess,
63 threadgateRecord,
64}: ThreadItemPostProps) {
65 const postShadow = usePostShadow(item.value.post)
66
67 if (postShadow === POST_TOMBSTONE) {
68 return <ThreadItemPostDeleted item={item} overrides={overrides} />
69 }
70
71 return (
72 <ThreadItemPostInner
73 item={item}
74 postShadow={postShadow}
75 threadgateRecord={threadgateRecord}
76 overrides={overrides}
77 onPostSuccess={onPostSuccess}
78 />
79 )
80}
81
82function ThreadItemPostDeleted({
83 item,
84 overrides,
85}: Pick<ThreadItemPostProps, 'item' | 'overrides'>) {
86 const t = useTheme()
87
88 return (
89 <ThreadItemPostOuterWrapper item={item} overrides={overrides}>
90 <ThreadItemPostParentReplyLine item={item} />
91
92 <View
93 style={[
94 a.flex_row,
95 a.align_center,
96 a.py_md,
97 a.rounded_sm,
98 t.atoms.bg_contrast_25,
99 ]}>
100 <View
101 style={[
102 a.flex_row,
103 a.align_center,
104 a.justify_center,
105 {
106 width: LINEAR_AVI_WIDTH,
107 },
108 ]}>
109 <TrashIcon style={[t.atoms.text_contrast_medium]} />
110 </View>
111 <Text
112 style={[a.text_md, a.font_semi_bold, t.atoms.text_contrast_medium]}>
113 <Trans>Post has been deleted</Trans>
114 </Text>
115 </View>
116
117 <View style={[{height: 4}]} />
118 </ThreadItemPostOuterWrapper>
119 )
120}
121
122const ThreadItemPostOuterWrapper = memo(function ThreadItemPostOuterWrapper({
123 item,
124 overrides,
125 children,
126}: Pick<ThreadItemPostProps, 'item' | 'overrides'> & {
127 children: ReactNode
128}) {
129 const t = useTheme()
130 const showTopBorder =
131 !item.ui.showParentReplyLine && overrides?.topBorder !== true
132
133 return (
134 <View
135 style={[
136 showTopBorder && [a.border_t, t.atoms.border_contrast_low],
137 {paddingHorizontal: OUTER_SPACE},
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 facets: record.facets,
236 author: post.author,
237 embed: post.embed,
238 moderation,
239 langs: post.record.langs,
240 },
241 onPostSuccess: onPostSuccess,
242 logContext: 'PostReply',
243 })
244 }, [openComposer, post, record, onPostSuccess, moderation])
245
246 const onPressShowMore = useCallback(() => {
247 setLimitLines(false)
248 }, [setLimitLines])
249
250 const {isActive: live} = useActorStatus(post.author)
251
252 return (
253 <SubtleHoverWrapper>
254 <ThreadItemPostOuterWrapper item={item} overrides={overrides}>
255 <PostHider
256 testID={`postThreadItem-by-${post.author.handle}`}
257 href={postHref}
258 disabled={overrides?.moderation === true}
259 modui={moderation.ui('contentList')}
260 hiderStyle={[a.pl_0, a.pr_2xs, a.bg_transparent]}
261 iconSize={LINEAR_AVI_WIDTH}
262 iconStyles={[a.mr_xs]}
263 profile={post.author}
264 interpretFilterAsBlur>
265 <ThreadItemPostParentReplyLine item={item} />
266
267 <View style={[a.flex_row, a.gap_md]}>
268 <View>
269 <PreviewableUserAvatar
270 size={LINEAR_AVI_WIDTH}
271 profile={post.author}
272 moderation={moderation.ui('avatar')}
273 type={post.author.associated?.labeler ? 'labeler' : 'user'}
274 live={live}
275 />
276
277 {(item.ui.showChildReplyLine ||
278 item.ui.precedesChildReadMore) && (
279 <View
280 style={[
281 a.mx_auto,
282 a.mt_xs,
283 a.flex_1,
284 {
285 width: REPLY_LINE_WIDTH,
286 backgroundColor: t.atoms.border_contrast_low.borderColor,
287 },
288 ]}
289 />
290 )}
291 </View>
292
293 <View style={[a.flex_1]}>
294 <PostMeta
295 author={post.author}
296 moderation={moderation}
297 timestamp={post.indexedAt}
298 postHref={postHref}
299 style={[a.pb_xs]}
300 />
301 <LabelsOnMyPost post={post} style={[a.pb_xs]} />
302 <PostAlerts
303 modui={moderation.ui('contentList')}
304 style={[a.pb_2xs]}
305 additionalCauses={additionalPostAlerts}
306 />
307 {richText?.text ? (
308 <View style={[a.mb_2xs]}>
309 <RichText
310 enableTags
311 value={richText}
312 style={[a.flex_1, a.text_md]}
313 numberOfLines={limitLines ? MAX_POST_LINES : undefined}
314 authorHandle={post.author.handle}
315 shouldProxyLinks={true}
316 />
317 {limitLines && (
318 <ShowMoreTextButton
319 style={[a.text_md]}
320 onPress={onPressShowMore}
321 />
322 )}
323 </View>
324 ) : undefined}
325 <TranslatedPost
326 hideTranslateLink={true}
327 post={post}
328 postText={record.text}
329 />
330 {post.embed && (
331 <View style={[a.pb_xs]}>
332 <Embed
333 embed={post.embed}
334 moderation={moderation}
335 viewContext={PostEmbedViewContext.Feed}
336 />
337 </View>
338 )}
339 <PostControls
340 post={postShadow}
341 record={record}
342 richText={richText}
343 onPressReply={onPressReply}
344 logContext="PostThreadItem"
345 threadgateRecord={threadgateRecord}
346 />
347 <DebugFieldDisplay subject={post} />
348 </View>
349 </View>
350 </PostHider>
351 </ThreadItemPostOuterWrapper>
352 </SubtleHoverWrapper>
353 )
354})
355
356function SubtleHoverWrapper({children}: {children: ReactNode}) {
357 const {
358 state: hover,
359 onIn: onHoverIn,
360 onOut: onHoverOut,
361 } = useInteractionState()
362 return (
363 <View
364 onPointerEnter={onHoverIn}
365 onPointerLeave={onHoverOut}
366 style={a.pointer}>
367 <SubtleHover hover={hover} />
368 {children}
369 </View>
370 )
371}
372
373export function ThreadItemPostSkeleton({index}: {index: number}) {
374 const even = index % 2 === 0
375 return (
376 <View
377 style={[
378 {paddingHorizontal: OUTER_SPACE, paddingVertical: OUTER_SPACE / 1.5},
379 a.gap_md,
380 ]}>
381 <Skele.Row style={[a.align_start, a.gap_md]}>
382 <Skele.Circle size={LINEAR_AVI_WIDTH} />
383
384 <Skele.Col style={[a.gap_xs]}>
385 <Skele.Row style={[a.gap_sm]}>
386 <Skele.Text style={[a.text_md, {width: '20%'}]} />
387 <Skele.Text blend style={[a.text_md, {width: '30%'}]} />
388 </Skele.Row>
389
390 <Skele.Col>
391 {even ? (
392 <>
393 <Skele.Text blend style={[a.text_md, {width: '100%'}]} />
394 <Skele.Text blend style={[a.text_md, {width: '60%'}]} />
395 </>
396 ) : (
397 <Skele.Text blend style={[a.text_md, {width: '60%'}]} />
398 )}
399 </Skele.Col>
400
401 <PostControlsSkeleton />
402 </Skele.Col>
403 </Skele.Row>
404 </View>
405 )
406}