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/macro'
10
11import {useActorStatus} from '#/lib/actor-status'
12import {MAX_POST_LINES} from '#/lib/constants'
13import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
14import {makeProfileLink} from '#/lib/routes/links'
15import {countLines} from '#/lib/strings/helpers'
16import {
17 POST_TOMBSTONE,
18 type Shadow,
19 usePostShadow,
20} from '#/state/cache/post-shadow'
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 {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
27import {
28 LINEAR_AVI_WIDTH,
29 OUTER_SPACE,
30 REPLY_LINE_WIDTH,
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 {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 {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
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>Skeet 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 })
241 }, [openComposer, post, record, onPostSuccess, moderation])
242
243 const onPressShowMore = useCallback(() => {
244 setLimitLines(false)
245 }, [setLimitLines])
246
247 const {isActive: live} = useActorStatus(post.author)
248
249 return (
250 <SubtleHoverWrapper>
251 <ThreadItemPostOuterWrapper item={item} overrides={overrides}>
252 <PostHider
253 testID={`postThreadItem-by-${post.author.handle}`}
254 href={postHref}
255 disabled={overrides?.moderation === true}
256 modui={moderation.ui('contentList')}
257 hiderStyle={[a.pl_0, a.pr_2xs, a.bg_transparent]}
258 iconSize={LINEAR_AVI_WIDTH}
259 iconStyles={[a.mr_xs]}
260 profile={post.author}
261 interpretFilterAsBlur>
262 <ThreadItemPostParentReplyLine item={item} />
263
264 <View style={[a.flex_row, a.gap_md]}>
265 <View>
266 <PreviewableUserAvatar
267 size={LINEAR_AVI_WIDTH}
268 profile={post.author}
269 moderation={moderation.ui('avatar')}
270 type={post.author.associated?.labeler ? 'labeler' : 'user'}
271 live={live}
272 />
273
274 {(item.ui.showChildReplyLine ||
275 item.ui.precedesChildReadMore) && (
276 <View
277 style={[
278 a.mx_auto,
279 a.mt_xs,
280 a.flex_1,
281 {
282 width: REPLY_LINE_WIDTH,
283 backgroundColor: t.atoms.border_contrast_low.borderColor,
284 },
285 ]}
286 />
287 )}
288 </View>
289
290 <View style={[a.flex_1]}>
291 <PostMeta
292 author={post.author}
293 moderation={moderation}
294 timestamp={post.indexedAt}
295 postHref={postHref}
296 style={[a.pb_xs]}
297 />
298 <LabelsOnMyPost post={post} style={[a.pb_xs]} />
299 <PostAlerts
300 modui={moderation.ui('contentList')}
301 style={[a.pb_2xs]}
302 additionalCauses={additionalPostAlerts}
303 />
304 {richText?.text ? (
305 <>
306 <RichText
307 enableTags
308 value={richText}
309 style={[a.flex_1, a.text_md]}
310 numberOfLines={limitLines ? MAX_POST_LINES : undefined}
311 authorHandle={post.author.handle}
312 shouldProxyLinks={true}
313 />
314 {limitLines && (
315 <ShowMoreTextButton
316 style={[a.text_md]}
317 onPress={onPressShowMore}
318 />
319 )}
320 </>
321 ) : undefined}
322 {post.embed && (
323 <View style={[a.pb_xs]}>
324 <Embed
325 embed={post.embed}
326 moderation={moderation}
327 viewContext={PostEmbedViewContext.Feed}
328 />
329 </View>
330 )}
331 <PostControls
332 post={postShadow}
333 record={record}
334 richText={richText}
335 onPressReply={onPressReply}
336 logContext="PostThreadItem"
337 threadgateRecord={threadgateRecord}
338 />
339 <DebugFieldDisplay subject={post} />
340 </View>
341 </View>
342 </PostHider>
343 </ThreadItemPostOuterWrapper>
344 </SubtleHoverWrapper>
345 )
346})
347
348function SubtleHoverWrapper({children}: {children: ReactNode}) {
349 const {
350 state: hover,
351 onIn: onHoverIn,
352 onOut: onHoverOut,
353 } = useInteractionState()
354 return (
355 <View
356 onPointerEnter={onHoverIn}
357 onPointerLeave={onHoverOut}
358 style={a.pointer}>
359 <SubtleHover hover={hover} />
360 {children}
361 </View>
362 )
363}
364
365export function ThreadItemPostSkeleton({index}: {index: number}) {
366 const even = index % 2 === 0
367 return (
368 <View
369 style={[
370 {paddingHorizontal: OUTER_SPACE, paddingVertical: OUTER_SPACE / 1.5},
371 a.gap_md,
372 ]}>
373 <Skele.Row style={[a.align_start, a.gap_md]}>
374 <Skele.Circle size={LINEAR_AVI_WIDTH} />
375
376 <Skele.Col style={[a.gap_xs]}>
377 <Skele.Row style={[a.gap_sm]}>
378 <Skele.Text style={[a.text_md, {width: '20%'}]} />
379 <Skele.Text blend style={[a.text_md, {width: '30%'}]} />
380 </Skele.Row>
381
382 <Skele.Col>
383 {even ? (
384 <>
385 <Skele.Text blend style={[a.text_md, {width: '100%'}]} />
386 <Skele.Text blend style={[a.text_md, {width: '60%'}]} />
387 </>
388 ) : (
389 <Skele.Text blend style={[a.text_md, {width: '60%'}]} />
390 )}
391 </Skele.Col>
392
393 <PostControlsSkeleton />
394 </Skele.Col>
395 </Skele.Row>
396 </View>
397 )
398}