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