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