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