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