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