forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
1import {useCallback, useMemo, useState} from 'react'
2import {type StyleProp, StyleSheet, View, type ViewStyle} from 'react-native'
3import {
4 type AppBskyFeedDefs,
5 AppBskyFeedPost,
6 AtUri,
7 moderatePost,
8 type ModerationDecision,
9 RichText as RichTextAPI,
10} from '@atproto/api'
11import {useQueryClient} from '@tanstack/react-query'
12
13import {MAX_POST_LINES} from '#/lib/constants'
14import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
15import {usePalette} from '#/lib/hooks/usePalette'
16import {makeProfileLink} from '#/lib/routes/links'
17import {countLines} from '#/lib/strings/helpers'
18import {colors} from '#/lib/styles'
19import {
20 POST_TOMBSTONE,
21 type Shadow,
22 usePostShadow,
23} from '#/state/cache/post-shadow'
24import {useModerationOpts} from '#/state/preferences/moderation-opts'
25import {unstableCacheProfileView} from '#/state/queries/profile'
26import {Link} from '#/view/com/util/Link'
27import {PostMeta} from '#/view/com/util/PostMeta'
28import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
29import {atoms as a} from '#/alf'
30import {
31 GalleryBleed,
32 maybeApplyGalleryOffsetStyles,
33} from '#/components/images/Gallery'
34import {ContentHider} from '#/components/moderation/ContentHider'
35import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe'
36import {PostAlerts} from '#/components/moderation/PostAlerts'
37import {Embed, PostEmbedViewContext} from '#/components/Post/Embed'
38import {PostRepliedTo} from '#/components/Post/PostRepliedTo'
39import {ShowMoreTextButton} from '#/components/Post/ShowMoreTextButton'
40import {TranslatedPost} from '#/components/Post/Translated'
41import {PostControls} from '#/components/PostControls'
42import {RichText} from '#/components/RichText'
43import {SubtleHover} from '#/components/SubtleHover'
44import * as bsky from '#/types/bsky'
45
46export function Post({
47 post,
48 showReplyLine,
49 hideTopBorder,
50 style,
51 onBeforePress,
52}: {
53 post: AppBskyFeedDefs.PostView
54 showReplyLine?: boolean
55 hideTopBorder?: boolean
56 style?: StyleProp<ViewStyle>
57 onBeforePress?: () => void
58}) {
59 const moderationOpts = useModerationOpts()
60 const record = useMemo<AppBskyFeedPost.Record | undefined>(
61 () =>
62 bsky.validate(post.record, AppBskyFeedPost.validateRecord)
63 ? post.record
64 : undefined,
65 [post],
66 )
67 const postShadowed = usePostShadow(post)
68 const richText = useMemo(
69 () =>
70 record
71 ? new RichTextAPI({
72 text: record.text,
73 facets: record.facets,
74 })
75 : undefined,
76 [record],
77 )
78 const moderation = useMemo(
79 () => (moderationOpts ? moderatePost(post, moderationOpts) : undefined),
80 [moderationOpts, post],
81 )
82 if (postShadowed === POST_TOMBSTONE) {
83 return null
84 }
85 if (record && richText && moderation) {
86 return (
87 <PostInner
88 post={postShadowed}
89 record={record}
90 richText={richText}
91 moderation={moderation}
92 showReplyLine={showReplyLine}
93 hideTopBorder={hideTopBorder}
94 style={style}
95 onBeforePress={onBeforePress}
96 />
97 )
98 }
99 return null
100}
101
102function PostInner({
103 post,
104 record,
105 richText,
106 moderation,
107 showReplyLine,
108 hideTopBorder,
109 style,
110 onBeforePress: outerOnBeforePress,
111}: {
112 post: Shadow<AppBskyFeedDefs.PostView>
113 record: AppBskyFeedPost.Record
114 richText: RichTextAPI
115 moderation: ModerationDecision
116 showReplyLine?: boolean
117 hideTopBorder?: boolean
118 style?: StyleProp<ViewStyle>
119 onBeforePress?: () => void
120}) {
121 const queryClient = useQueryClient()
122 const pal = usePalette('default')
123 const {openComposer} = useOpenComposer()
124 const [limitLines, setLimitLines] = useState(
125 () => countLines(richText?.text) >= MAX_POST_LINES,
126 )
127 const itemUrip = new AtUri(post.uri)
128 const itemHref = makeProfileLink(post.author, 'post', itemUrip.rkey)
129 let replyAuthorDid = ''
130 if (record.reply) {
131 const urip = new AtUri(record.reply.parent?.uri || record.reply.root.uri)
132 replyAuthorDid = urip.hostname
133 }
134
135 const onPressReply = useCallback(() => {
136 openComposer({
137 replyTo: {
138 uri: post.uri,
139 cid: post.cid,
140 text: record.text,
141 author: post.author,
142 embed: post.embed,
143 moderation,
144 langs: record.langs,
145 },
146 logContext: 'PostReply',
147 })
148 }, [openComposer, post, record, moderation])
149
150 const onPressShowMore = useCallback(() => {
151 setLimitLines(false)
152 }, [setLimitLines])
153
154 const onBeforePress = useCallback(() => {
155 unstableCacheProfileView(queryClient, post.author)
156 outerOnBeforePress?.()
157 }, [queryClient, post.author, outerOnBeforePress])
158
159 const [hover, setHover] = useState(false)
160
161 return (
162 <GalleryBleed>
163 <Link
164 href={itemHref}
165 style={[
166 styles.outer,
167 pal.border,
168 !hideTopBorder && {borderTopWidth: StyleSheet.hairlineWidth},
169 style,
170 ]}
171 onBeforePress={onBeforePress}
172 onPointerEnter={() => {
173 setHover(true)
174 }}
175 onPointerLeave={() => {
176 setHover(false)
177 }}>
178 <SubtleHover hover={hover} />
179 {showReplyLine && <View style={styles.replyLine} />}
180 <View style={styles.layout}>
181 <View style={styles.layoutAvi}>
182 <PreviewableUserAvatar
183 size={42}
184 profile={post.author}
185 moderation={moderation.ui('avatar')}
186 type={post.author.associated?.labeler ? 'labeler' : 'user'}
187 />
188 </View>
189 <View
190 style={[
191 styles.layoutContent,
192 maybeApplyGalleryOffsetStyles('meta', {
193 post,
194 modui: moderation.ui('contentList'),
195 additionalCauses: [],
196 }),
197 ]}>
198 <PostMeta
199 author={post.author}
200 moderation={moderation}
201 timestamp={post.indexedAt}
202 postHref={itemHref}
203 />
204 {replyAuthorDid !== '' && (
205 <PostRepliedTo parentAuthor={replyAuthorDid} />
206 )}
207 <LabelsOnMyPost post={post} />
208 <ContentHider
209 modui={moderation.ui('contentView')}
210 style={styles.contentHider}
211 childContainerStyle={styles.contentHiderChild}>
212 <PostAlerts
213 modui={moderation.ui('contentView')}
214 style={[a.pb_xs]}
215 />
216 {richText.text ? (
217 <View style={[a.mb_2xs]}>
218 <RichText
219 enableTags
220 testID="postText"
221 value={richText}
222 numberOfLines={limitLines ? MAX_POST_LINES : undefined}
223 style={[a.flex_1, a.text_md]}
224 authorHandle={post.author.handle}
225 shouldProxyLinks={true}
226 />
227 {limitLines && (
228 <ShowMoreTextButton
229 style={[a.text_md]}
230 onPress={onPressShowMore}
231 />
232 )}
233 </View>
234 ) : undefined}
235 <TranslatedPost hideTranslateLink post={post} />
236 {post.embed ? (
237 <View
238 style={maybeApplyGalleryOffsetStyles('embed', {
239 post,
240 modui: moderation.ui('contentList'),
241 additionalCauses: [],
242 })}>
243 <Embed
244 embed={post.embed}
245 moderation={moderation}
246 viewContext={PostEmbedViewContext.Feed}
247 />
248 </View>
249 ) : null}
250 </ContentHider>
251 <PostControls
252 post={post}
253 record={record}
254 richText={richText}
255 onPressReply={onPressReply}
256 logContext="Post"
257 />
258 </View>
259 </View>
260 </Link>
261 </GalleryBleed>
262 )
263}
264
265const styles = StyleSheet.create({
266 outer: {
267 paddingTop: 10,
268 paddingRight: 15,
269 paddingBottom: 5,
270 paddingLeft: 10,
271 // @ts-ignore web only -prf
272 cursor: 'pointer',
273 },
274 layout: {
275 flexDirection: 'row',
276 gap: 10,
277 },
278 layoutAvi: {
279 paddingLeft: 8,
280 },
281 layoutContent: {
282 flex: 1,
283 },
284 alert: {
285 marginBottom: 6,
286 },
287 replyLine: {
288 position: 'absolute',
289 left: 36,
290 top: 70,
291 bottom: 0,
292 borderLeftWidth: 2,
293 borderLeftColor: colors.gray2,
294 },
295 contentHider: {
296 marginBottom: 2,
297 },
298 contentHiderChild: {
299 marginTop: 6,
300 },
301})