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