forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
1import {useCallback, useMemo} from 'react'
2import {View} from 'react-native'
3import {
4 type $Typed,
5 type AppBskyFeedDefs,
6 AppBskyFeedPost,
7 AtUri,
8 moderatePost,
9 RichText as RichTextAPI,
10} from '@atproto/api'
11import {Trans} from '@lingui/macro'
12import {useQueryClient} from '@tanstack/react-query'
13
14import {makeProfileLink} from '#/lib/routes/links'
15import {useModerationOpts} from '#/state/preferences/moderation-opts'
16import {unstableCacheProfileView} from '#/state/queries/profile'
17import {useSession} from '#/state/session'
18import {Link} from '#/view/com/util/Link'
19import {PostMeta} from '#/view/com/util/PostMeta'
20import {atoms as a, useTheme} from '#/alf'
21import {useInteractionState} from '#/components/hooks/useInteractionState'
22import {ContentHider} from '#/components/moderation/ContentHider'
23import {PostAlerts} from '#/components/moderation/PostAlerts'
24import {RichText} from '#/components/RichText'
25import {Embed as StarterPackCard} from '#/components/StarterPack/StarterPackCard'
26import {SubtleHover} from '#/components/SubtleHover'
27import * as bsky from '#/types/bsky'
28import {
29 type Embed as TEmbed,
30 type EmbedType,
31 parseEmbed,
32} from '#/types/bsky/post'
33import {ExternalEmbed} from './ExternalEmbed'
34import {ModeratedFeedEmbed} from './FeedEmbed'
35import {ImageEmbed} from './ImageEmbed'
36import {ModeratedListEmbed} from './ListEmbed'
37import {PostPlaceholder as PostPlaceholderText} from './PostPlaceholder'
38import {
39 type CommonProps,
40 type EmbedProps,
41 PostEmbedViewContext,
42 QuoteEmbedViewContext,
43} from './types'
44import {VideoEmbed} from './VideoEmbed'
45
46export {PostEmbedViewContext, QuoteEmbedViewContext} from './types'
47
48export function Embed({embed: rawEmbed, ...rest}: EmbedProps) {
49 const embed = parseEmbed(rawEmbed)
50
51 switch (embed.type) {
52 case 'images':
53 case 'link':
54 case 'video': {
55 return <MediaEmbed embed={embed} {...rest} />
56 }
57 case 'feed':
58 case 'list':
59 case 'starter_pack':
60 case 'labeler':
61 case 'post':
62 case 'post_not_found':
63 case 'post_blocked':
64 case 'post_detached': {
65 return <RecordEmbed embed={embed} {...rest} />
66 }
67 case 'post_with_media': {
68 return (
69 <View style={rest.style}>
70 <MediaEmbed embed={embed.media} {...rest} />
71 <RecordEmbed embed={embed.view} {...rest} />
72 </View>
73 )
74 }
75 default: {
76 return null
77 }
78 }
79}
80
81function MediaEmbed({
82 embed,
83 ...rest
84}: CommonProps & {
85 embed: TEmbed
86}) {
87 switch (embed.type) {
88 case 'images': {
89 return (
90 <ContentHider
91 modui={rest.moderation?.ui('contentMedia')}
92 activeStyle={[a.mt_sm]}>
93 <ImageEmbed embed={embed} {...rest} />
94 </ContentHider>
95 )
96 }
97 case 'link': {
98 return (
99 <ContentHider
100 modui={rest.moderation?.ui('contentMedia')}
101 activeStyle={[a.mt_sm]}>
102 <ExternalEmbed
103 link={embed.view.external}
104 onOpen={rest.onOpen}
105 style={[a.mt_sm, rest.style]}
106 />
107 </ContentHider>
108 )
109 }
110 case 'video': {
111 return (
112 <ContentHider
113 modui={rest.moderation?.ui('contentMedia')}
114 activeStyle={[a.mt_sm]}>
115 <VideoEmbed embed={embed.view} />
116 </ContentHider>
117 )
118 }
119 default: {
120 return null
121 }
122 }
123}
124
125function RecordEmbed({
126 embed,
127 ...rest
128}: CommonProps & {
129 embed: TEmbed
130}) {
131 switch (embed.type) {
132 case 'feed': {
133 return (
134 <View style={a.mt_sm}>
135 <ModeratedFeedEmbed embed={embed} {...rest} />
136 </View>
137 )
138 }
139 case 'list': {
140 return (
141 <View style={a.mt_sm}>
142 <ModeratedListEmbed embed={embed} />
143 </View>
144 )
145 }
146 case 'starter_pack': {
147 return (
148 <View style={a.mt_sm}>
149 <StarterPackCard starterPack={embed.view} />
150 </View>
151 )
152 }
153 case 'labeler': {
154 // not implemented
155 return null
156 }
157 case 'post': {
158 if (rest.isWithinQuote && !rest.allowNestedQuotes) {
159 return null
160 }
161
162 return (
163 <QuoteEmbed
164 {...rest}
165 embed={embed}
166 viewContext={
167 rest.viewContext === PostEmbedViewContext.Feed
168 ? QuoteEmbedViewContext.FeedEmbedRecordWithMedia
169 : undefined
170 }
171 isWithinQuote={rest.isWithinQuote}
172 allowNestedQuotes={rest.allowNestedQuotes}
173 />
174 )
175 }
176 case 'post_not_found': {
177 return (
178 <PostPlaceholderText>
179 <Trans>Deleted</Trans>
180 </PostPlaceholderText>
181 )
182 }
183 case 'post_blocked': {
184 return (
185 <PostPlaceholderText>
186 <Trans>Blocked</Trans>
187 </PostPlaceholderText>
188 )
189 }
190 case 'post_detached': {
191 return <PostDetachedEmbed embed={embed} />
192 }
193 default: {
194 return null
195 }
196 }
197}
198
199export function PostDetachedEmbed({
200 embed,
201}: {
202 embed: EmbedType<'post_detached'>
203}) {
204 const {currentAccount} = useSession()
205 const isViewerOwner = currentAccount?.did
206 ? embed.view.uri.includes(currentAccount.did)
207 : false
208
209 return (
210 <PostPlaceholderText>
211 {isViewerOwner ? (
212 <Trans>Removed by you</Trans>
213 ) : (
214 <Trans>Removed by author</Trans>
215 )}
216 </PostPlaceholderText>
217 )
218}
219
220/*
221 * Nests parent `Embed` component and therefore must live in this file to avoid
222 * circular imports.
223 */
224export function QuoteEmbed({
225 embed,
226 onOpen,
227 style,
228 isWithinQuote: parentIsWithinQuote,
229 allowNestedQuotes: parentAllowNestedQuotes,
230}: Omit<CommonProps, 'viewContext'> & {
231 embed: EmbedType<'post'>
232 viewContext?: QuoteEmbedViewContext
233}) {
234 const moderationOpts = useModerationOpts()
235 const quote = useMemo<$Typed<AppBskyFeedDefs.PostView>>(
236 () => ({
237 ...embed.view,
238 $type: 'app.bsky.feed.defs#postView',
239 record: embed.view.value,
240 embed: embed.view.embeds?.[0],
241 }),
242 [embed],
243 )
244 const moderation = useMemo(() => {
245 return moderationOpts ? moderatePost(quote, moderationOpts) : undefined
246 }, [quote, moderationOpts])
247
248 const t = useTheme()
249 const queryClient = useQueryClient()
250 const itemUrip = new AtUri(quote.uri)
251 const itemHref = makeProfileLink(quote.author, 'post', itemUrip.rkey)
252 const itemTitle = `Post by ${quote.author.handle}`
253
254 const richText = useMemo(() => {
255 if (
256 !bsky.dangerousIsType<AppBskyFeedPost.Record>(
257 quote.record,
258 AppBskyFeedPost.isRecord,
259 )
260 )
261 return undefined
262 const {text, facets} = quote.record
263 return text.trim()
264 ? new RichTextAPI({text: text, facets: facets})
265 : undefined
266 }, [quote.record])
267
268 const onBeforePress = useCallback(() => {
269 unstableCacheProfileView(queryClient, quote.author)
270 onOpen?.()
271 }, [queryClient, quote.author, onOpen])
272
273 const {
274 state: hover,
275 onIn: onPointerEnter,
276 onOut: onPointerLeave,
277 } = useInteractionState()
278 const {
279 state: pressed,
280 onIn: onPressIn,
281 onOut: onPressOut,
282 } = useInteractionState()
283 return (
284 <View
285 style={[a.mt_sm]}
286 onPointerEnter={onPointerEnter}
287 onPointerLeave={onPointerLeave}>
288 <ContentHider
289 modui={moderation?.ui('contentList')}
290 style={[a.rounded_md, a.border, t.atoms.border_contrast_low, style]}
291 activeStyle={[a.p_md, a.pt_sm]}
292 childContainerStyle={[a.pt_sm]}>
293 {({active}) => (
294 <>
295 {!active && (
296 <SubtleHover
297 native
298 hover={hover || pressed}
299 style={[a.rounded_md]}
300 />
301 )}
302 <Link
303 style={[!active && a.p_md]}
304 hoverStyle={t.atoms.border_contrast_high}
305 href={itemHref}
306 title={itemTitle}
307 onBeforePress={onBeforePress}
308 onPressIn={onPressIn}
309 onPressOut={onPressOut}>
310 <View pointerEvents="none">
311 <PostMeta
312 author={quote.author}
313 moderation={moderation}
314 showAvatar
315 postHref={itemHref}
316 timestamp={quote.indexedAt}
317 />
318 </View>
319 {moderation ? (
320 <PostAlerts
321 modui={moderation.ui('contentView')}
322 style={[a.py_xs]}
323 />
324 ) : null}
325 {richText ? (
326 <RichText
327 value={richText}
328 style={a.text_md}
329 numberOfLines={20}
330 disableLinks
331 />
332 ) : null}
333 {quote.embed && (
334 <Embed
335 embed={quote.embed}
336 moderation={moderation}
337 isWithinQuote={parentIsWithinQuote ?? true}
338 // already within quote? override nested
339 allowNestedQuotes={
340 parentIsWithinQuote ? false : parentAllowNestedQuotes
341 }
342 />
343 )}
344 </Link>
345 </>
346 )}
347 </ContentHider>
348 </View>
349 )
350}