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