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