Bluesky app fork with some witchin' additions 馃挮
witchsky.app
bluesky
fork
client
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/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 {
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 {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'
47
48/**
49 * Mimic the space in PostMeta
50 */
51const TREE_AVI_PLUS_SPACE = TREE_AVI_WIDTH + a.gap_xs.gap
52
53export function ThreadItemTreePost({
54 item,
55 overrides,
56 onPostSuccess,
57 threadgateRecord,
58}: {
59 item: Extract<ThreadItem, {type: 'threadPost'}>
60 overrides?: {
61 moderation?: boolean
62 topBorder?: boolean
63 }
64 onPostSuccess?: (data: OnPostSuccessData) => void
65 threadgateRecord?: AppBskyFeedThreadgate.Record
66}) {
67 const postShadow = usePostShadow(item.value.post)
68
69 if (postShadow === POST_TOMBSTONE) {
70 return <ThreadItemTreePostDeleted item={item} />
71 }
72
73 return (
74 <ThreadItemTreePostInner
75 // Safeguard from clobbering per-post state below:
76 key={postShadow.uri}
77 item={item}
78 postShadow={postShadow}
79 threadgateRecord={threadgateRecord}
80 overrides={overrides}
81 onPostSuccess={onPostSuccess}
82 />
83 )
84}
85
86function ThreadItemTreePostDeleted({
87 item,
88}: {
89 item: Extract<ThreadItem, {type: 'threadPost'}>
90}) {
91 const t = useTheme()
92 return (
93 <ThreadItemTreePostOuterWrapper item={item}>
94 <ThreadItemTreePostInnerWrapper item={item}>
95 <View
96 style={[
97 a.flex_row,
98 a.align_center,
99 a.rounded_sm,
100 t.atoms.bg_contrast_25,
101 {
102 gap: 6,
103 paddingHorizontal: OUTER_SPACE / 2,
104 height: TREE_AVI_WIDTH,
105 },
106 ]}>
107 <TrashIcon style={[t.atoms.text]} width={14} />
108 <Text style={[t.atoms.text_contrast_medium, a.mt_2xs]}>
109 <Trans>Post has been deleted</Trans>
110 </Text>
111 </View>
112 {item.ui.isLastChild && !item.ui.precedesChildReadMore && (
113 <View style={{height: OUTER_SPACE / 2}} />
114 )}
115 </ThreadItemTreePostInnerWrapper>
116 </ThreadItemTreePostOuterWrapper>
117 )
118}
119
120const ThreadItemTreePostOuterWrapper = memo(
121 function ThreadItemTreePostOuterWrapper({
122 item,
123 children,
124 }: {
125 item: Extract<ThreadItem, {type: 'threadPost'}>
126 children: React.ReactNode
127 }) {
128 const t = useTheme()
129 const indents = Math.max(0, item.ui.indent - 1)
130
131 return (
132 <View
133 style={[
134 a.flex_row,
135 item.ui.indent === 1 &&
136 !item.ui.showParentReplyLine && [
137 a.border_t,
138 t.atoms.border_contrast_low,
139 ],
140 ]}>
141 {Array.from(Array(indents)).map((_, n: number) => {
142 const isSkipped = item.ui.skippedIndentIndices.has(n)
143 return (
144 <View
145 key={`${item.value.post.uri}-padding-${n}`}
146 style={[
147 t.atoms.border_contrast_low,
148 {
149 borderRightWidth: isSkipped ? 0 : REPLY_LINE_WIDTH,
150 width: TREE_INDENT + TREE_AVI_WIDTH / 2,
151 left: 1,
152 },
153 ]}
154 />
155 )
156 })}
157 {children}
158 </View>
159 )
160 },
161)
162
163const ThreadItemTreePostInnerWrapper = memo(
164 function ThreadItemTreePostInnerWrapper({
165 item,
166 children,
167 }: {
168 item: Extract<ThreadItem, {type: 'threadPost'}>
169 children: React.ReactNode
170 }) {
171 const t = useTheme()
172 return (
173 <View
174 style={[
175 a.flex_1, // TODO check on ios
176 {
177 paddingHorizontal: OUTER_SPACE,
178 paddingTop: OUTER_SPACE / 2,
179 },
180 item.ui.indent === 1 && [
181 !item.ui.showParentReplyLine && {paddingTop: OUTER_SPACE / 1.5},
182 !item.ui.showChildReplyLine && a.pb_sm,
183 ],
184 item.ui.isLastChild &&
185 !item.ui.precedesChildReadMore && [
186 {
187 paddingBottom: OUTER_SPACE / 2,
188 },
189 ],
190 ]}>
191 {item.ui.indent > 1 && (
192 <View
193 style={[
194 a.absolute,
195 t.atoms.border_contrast_low,
196 {
197 left: -1,
198 top: 0,
199 height:
200 TREE_AVI_WIDTH / 2 + REPLY_LINE_WIDTH / 2 + OUTER_SPACE / 2,
201 width: OUTER_SPACE,
202 borderLeftWidth: REPLY_LINE_WIDTH,
203 borderBottomWidth: REPLY_LINE_WIDTH,
204 borderBottomLeftRadius: a.rounded_sm.borderRadius,
205 },
206 ]}
207 />
208 )}
209 {children}
210 </View>
211 )
212 },
213)
214
215const ThreadItemTreeReplyChildReplyLine = memo(
216 function ThreadItemTreeReplyChildReplyLine({
217 item,
218 }: {
219 item: Extract<ThreadItem, {type: 'threadPost'}>
220 }) {
221 const t = useTheme()
222 return (
223 <View style={[a.relative, a.pt_2xs, {width: TREE_AVI_PLUS_SPACE}]}>
224 {item.ui.showChildReplyLine && (
225 <View
226 style={[
227 a.flex_1,
228 t.atoms.border_contrast_low,
229 {borderRightWidth: 2, width: '50%', left: -1},
230 ]}
231 />
232 )}
233 </View>
234 )
235 },
236)
237
238const ThreadItemTreePostInner = memo(function ThreadItemTreePostInner({
239 item,
240 postShadow,
241 overrides,
242 onPostSuccess,
243 threadgateRecord,
244}: {
245 item: Extract<ThreadItem, {type: 'threadPost'}>
246 postShadow: Shadow<AppBskyFeedDefs.PostView>
247 overrides?: {
248 moderation?: boolean
249 topBorder?: boolean
250 }
251 onPostSuccess?: (data: OnPostSuccessData) => void
252 threadgateRecord?: AppBskyFeedThreadgate.Record
253}): React.ReactNode {
254 const {openComposer} = useOpenComposer()
255 const {currentAccount} = useSession()
256
257 const post = item.value.post
258 const record = item.value.post.record
259 const moderation = item.moderation
260 const richText = useMemo(
261 () =>
262 new RichTextAPI({
263 text: record.text,
264 facets: record.facets,
265 }),
266 [record],
267 )
268 const [limitLines, setLimitLines] = useState(
269 () => countLines(richText?.text) >= MAX_POST_LINES,
270 )
271 const threadRootUri = record.reply?.root?.uri || post.uri
272 const postHref = useMemo(() => {
273 const urip = new AtUri(post.uri)
274 return makeProfileLink(post.author, 'post', urip.rkey)
275 }, [post.uri, post.author])
276 const threadgateHiddenReplies = useMergedThreadgateHiddenReplies({
277 threadgateRecord,
278 })
279 const additionalPostAlerts: AppModerationCause[] = useMemo(() => {
280 const isPostHiddenByThreadgate = threadgateHiddenReplies.has(post.uri)
281 const isControlledByViewer =
282 new AtUri(threadRootUri).host === currentAccount?.did
283 return isControlledByViewer && isPostHiddenByThreadgate
284 ? [
285 {
286 type: 'reply-hidden',
287 source: {type: 'user', did: currentAccount?.did},
288 priority: 6,
289 },
290 ]
291 : []
292 }, [post, currentAccount?.did, threadgateHiddenReplies, threadRootUri])
293
294 const onPressReply = useCallback(() => {
295 openComposer({
296 replyTo: {
297 uri: post.uri,
298 cid: post.cid,
299 text: record.text,
300 author: post.author,
301 embed: post.embed,
302 moderation,
303 langs: post.record.langs,
304 },
305 onPostSuccess: onPostSuccess,
306 logContext: 'PostReply',
307 })
308 }, [openComposer, post, record, onPostSuccess, moderation])
309
310 const onPressShowMore = useCallback(() => {
311 setLimitLines(false)
312 }, [setLimitLines])
313
314 return (
315 <ThreadItemTreePostOuterWrapper item={item}>
316 <SubtleHoverWrapper>
317 <PostHider
318 testID={`postThreadItem-by-${post.author.handle}`}
319 href={postHref}
320 disabled={overrides?.moderation === true}
321 modui={moderation.ui('contentList')}
322 iconSize={42}
323 iconStyles={{marginLeft: 2, marginRight: 2}}
324 profile={post.author}
325 interpretFilterAsBlur>
326 <ThreadItemTreePostInnerWrapper item={item}>
327 <View style={[a.flex_1]}>
328 <PostMeta
329 author={post.author}
330 moderation={moderation}
331 timestamp={post.indexedAt}
332 postHref={postHref}
333 avatarSize={TREE_AVI_WIDTH}
334 style={[a.pb_0]}
335 showAvatar
336 />
337 <View style={[a.flex_row]}>
338 <ThreadItemTreeReplyChildReplyLine item={item} />
339 <View style={[a.flex_1, a.pl_2xs]}>
340 <LabelsOnMyPost post={post} style={[a.pb_2xs]} />
341 <PostAlerts
342 modui={moderation.ui('contentList')}
343 style={[a.pb_2xs]}
344 additionalCauses={additionalPostAlerts}
345 />
346 {richText?.text ? (
347 <View style={[a.mb_2xs]}>
348 <RichText
349 enableTags
350 value={richText}
351 style={[a.flex_1, a.text_md]}
352 numberOfLines={limitLines ? MAX_POST_LINES : undefined}
353 authorHandle={post.author.handle}
354 shouldProxyLinks={true}
355 />
356 {limitLines && (
357 <ShowMoreTextButton
358 style={[a.text_md]}
359 onPress={onPressShowMore}
360 />
361 )}
362 </View>
363 ) : null}
364 <TranslatedPost
365 hideTranslateLink={true}
366 post={post}
367 postText={record.text}
368 />
369 {post.embed && (
370 <View style={[a.pb_xs]}>
371 <Embed
372 embed={post.embed}
373 moderation={moderation}
374 viewContext={PostEmbedViewContext.Feed}
375 />
376 </View>
377 )}
378 <PostControls
379 variant="compact"
380 post={postShadow}
381 record={record}
382 richText={richText}
383 onPressReply={onPressReply}
384 logContext="PostThreadItem"
385 threadgateRecord={threadgateRecord}
386 />
387 <DebugFieldDisplay subject={post} />
388 </View>
389 </View>
390 </View>
391 </ThreadItemTreePostInnerWrapper>
392 </PostHider>
393 </SubtleHoverWrapper>
394 </ThreadItemTreePostOuterWrapper>
395 )
396})
397
398function SubtleHoverWrapper({children}: {children: React.ReactNode}) {
399 const {
400 state: hover,
401 onIn: onHoverIn,
402 onOut: onHoverOut,
403 } = useInteractionState()
404 return (
405 <View
406 onPointerEnter={onHoverIn}
407 onPointerLeave={onHoverOut}
408 style={[a.flex_1, a.pointer]}>
409 <SubtleHover hover={hover} />
410 {children}
411 </View>
412 )
413}
414
415export function ThreadItemTreePostSkeleton({index}: {index: number}) {
416 const t = useTheme()
417 const even = index % 2 === 0
418 return (
419 <View
420 style={[
421 {paddingHorizontal: OUTER_SPACE, paddingVertical: OUTER_SPACE / 1.5},
422 a.border_t,
423 t.atoms.border_contrast_low,
424 ]}>
425 <Skele.Row style={[a.align_start, a.gap_xs]}>
426 <Skele.Circle size={TREE_AVI_WIDTH} />
427
428 <Skele.Col style={[a.gap_xs]}>
429 <Skele.Row style={[a.gap_sm]}>
430 <Skele.Text style={[a.text_md, {width: '20%'}]} />
431 <Skele.Text blend style={[a.text_md, {width: '30%'}]} />
432 </Skele.Row>
433
434 <Skele.Col>
435 {even ? (
436 <>
437 <Skele.Text blend style={[a.text_md, {width: '100%'}]} />
438 <Skele.Text blend style={[a.text_md, {width: '60%'}]} />
439 </>
440 ) : (
441 <Skele.Text blend style={[a.text_md, {width: '60%'}]} />
442 )}
443 </Skele.Col>
444
445 <PostControlsSkeleton />
446 </Skele.Col>
447 </Skele.Row>
448 </View>
449 )
450}