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 {msg} from '@lingui/core/macro'
12import {useLingui} from '@lingui/react'
13import {Trans} from '@lingui/react/macro'
14import {useQueryClient} from '@tanstack/react-query'
15
16import {makeProfileLink} from '#/lib/routes/links'
17import {useDirectFetchRecords} from '#/state/preferences/direct-fetch-records'
18import {useModerationOpts} from '#/state/preferences/moderation-opts'
19import {useDirectFetchEmbedRecord} from '#/state/queries/direct-fetch-record'
20import {unstableCacheProfileView} from '#/state/queries/profile'
21import {useSession} from '#/state/session'
22import {Link} from '#/view/com/util/Link'
23import {PostMeta} from '#/view/com/util/PostMeta'
24import {atoms as a, useTheme} from '#/alf'
25import {useInteractionState} from '#/components/hooks/useInteractionState'
26import {GalleryBleed} from '#/components/images/Gallery'
27import {ContentHider} from '#/components/moderation/ContentHider'
28import {PostAlerts} from '#/components/moderation/PostAlerts'
29import {RichText} from '#/components/RichText'
30import {Embed as StarterPackCard} from '#/components/StarterPack/StarterPackCard'
31import {SubtleHover} from '#/components/SubtleHover'
32import * as bsky from '#/types/bsky'
33import {
34 type Embed as TEmbed,
35 type EmbedType,
36 parseEmbed,
37} from '#/types/bsky/post'
38import {ExternalEmbed} from './ExternalEmbed'
39import {ModeratedFeedEmbed} from './FeedEmbed'
40import {ImageEmbed} from './ImageEmbed'
41import {ModeratedListEmbed} from './ListEmbed'
42import {PostPlaceholder as PostPlaceholderText} from './PostPlaceholder'
43import {
44 type CommonProps,
45 type EmbedProps,
46 type PostEmbedViewContext,
47} from './types'
48import {VideoEmbed} from './VideoEmbed'
49
50export {PostEmbedViewContext} from './types'
51
52export function Embed({embed: rawEmbed, ...rest}: EmbedProps) {
53 const embed = parseEmbed(rawEmbed)
54
55 switch (embed.type) {
56 case 'images':
57 case 'link':
58 case 'video': {
59 return <MediaEmbed embed={embed} {...rest} />
60 }
61 case 'feed':
62 case 'list':
63 case 'starter_pack':
64 case 'labeler':
65 case 'post':
66 case 'post_not_found':
67 case 'post_blocked':
68 case 'post_detached': {
69 return <RecordEmbed embed={embed} {...rest} />
70 }
71 case 'post_with_media': {
72 return (
73 <View style={rest.style}>
74 <MediaEmbed embed={embed.media} {...rest} />
75 <RecordEmbed embed={embed.view} {...rest} />
76 </View>
77 )
78 }
79 default: {
80 return null
81 }
82 }
83}
84
85function MediaEmbed({
86 embed,
87 ...rest
88}: CommonProps & {
89 embed: TEmbed
90}) {
91 switch (embed.type) {
92 case 'images': {
93 return (
94 <ContentHider
95 modui={rest.moderation?.ui('contentMedia')}
96 activeStyle={[a.mt_sm]}>
97 <ImageEmbed embed={embed} {...rest} />
98 </ContentHider>
99 )
100 }
101 case 'link': {
102 return (
103 <ContentHider
104 modui={rest.moderation?.ui('contentMedia')}
105 activeStyle={[a.mt_sm]}>
106 <ExternalEmbed
107 link={embed.view.external}
108 onOpen={rest.onOpen}
109 style={[a.mt_sm, rest.style]}
110 />
111 </ContentHider>
112 )
113 }
114 case 'video': {
115 return (
116 <ContentHider
117 modui={rest.moderation?.ui('contentMedia')}
118 activeStyle={[a.mt_sm]}>
119 <VideoEmbed embed={embed.view} />
120 </ContentHider>
121 )
122 }
123 default: {
124 return null
125 }
126 }
127}
128
129function RecordEmbed({
130 embed,
131 ...rest
132}: CommonProps & {
133 embed: TEmbed
134}) {
135 const {_} = useLingui()
136 const directFetchEnabled = useDirectFetchRecords()
137 const shouldDirectFetch =
138 (embed.type === 'post_blocked' || embed.type === 'post_detached') &&
139 directFetchEnabled
140
141 const directRecord = useDirectFetchEmbedRecord({
142 uri:
143 embed.type === 'post_blocked' || embed.type === 'post_detached'
144 ? embed.view.uri
145 : '',
146 enabled: shouldDirectFetch,
147 })
148
149 switch (embed.type) {
150 case 'feed': {
151 return (
152 <View style={a.mt_sm}>
153 <ModeratedFeedEmbed embed={embed} {...rest} />
154 </View>
155 )
156 }
157 case 'list': {
158 return (
159 <View style={a.mt_sm}>
160 <ModeratedListEmbed embed={embed} />
161 </View>
162 )
163 }
164 case 'starter_pack': {
165 return (
166 <View style={a.mt_sm}>
167 <StarterPackCard starterPack={embed.view} />
168 </View>
169 )
170 }
171 case 'labeler': {
172 // not implemented
173 return null
174 }
175 case 'post': {
176 if (rest.isWithinQuote && !rest.allowNestedQuotes) {
177 return null
178 }
179
180 return (
181 <QuoteEmbed
182 {...rest}
183 embed={embed}
184 viewContext={rest.viewContext}
185 isWithinQuote={rest.isWithinQuote}
186 allowNestedQuotes={rest.allowNestedQuotes}
187 />
188 )
189 }
190 case 'post_not_found': {
191 return (
192 <PostPlaceholderText>
193 <Trans>Deleted</Trans>
194 </PostPlaceholderText>
195 )
196 }
197 case 'post_blocked': {
198 const record = directRecord.data
199 if (record !== undefined) {
200 return (
201 <DirectFetchEmbed
202 {...rest}
203 embed={record}
204 visibilityLabel={_(msg`Blocked`)}
205 />
206 )
207 }
208
209 return (
210 <PostPlaceholderText directFetchEnabled={directFetchEnabled}>
211 <Trans>Blocked</Trans>
212 </PostPlaceholderText>
213 )
214 }
215 case 'post_detached': {
216 const record = directRecord.data
217 if (record !== undefined) {
218 return (
219 <DirectFetchEmbed
220 {...rest}
221 embed={record}
222 visibilityLabel={_(msg`Removed by author`)}
223 visibilityLabelOwner={_(msg`Removed by you`)}
224 />
225 )
226 }
227
228 return (
229 <PostDetachedEmbed
230 embed={embed}
231 directFetchEnabled={directFetchEnabled}
232 />
233 )
234 }
235 default: {
236 return null
237 }
238 }
239}
240
241export function DirectFetchEmbed({
242 embed,
243 visibilityLabel,
244 visibilityLabelOwner,
245 ...rest
246}: Omit<CommonProps, 'viewContext'> & {
247 embed: EmbedType<'post'>
248 viewContext?: PostEmbedViewContext
249 visibilityLabel: string
250 visibilityLabelOwner?: string
251}) {
252 const {currentAccount} = useSession()
253 const isViewerOwner = currentAccount?.did
254 ? embed.view.uri.includes(currentAccount.did)
255 : false
256
257 return (
258 <View>
259 <QuoteEmbed
260 {...rest}
261 embed={embed}
262 viewContext={rest.viewContext}
263 isWithinQuote={rest.isWithinQuote}
264 allowNestedQuotes={rest.allowNestedQuotes}
265 visibilityLabel={
266 isViewerOwner && visibilityLabelOwner
267 ? visibilityLabelOwner
268 : visibilityLabel
269 }
270 />
271 </View>
272 )
273}
274
275export function PostDetachedEmbed({
276 embed,
277 directFetchEnabled,
278}: {
279 embed: EmbedType<'post_detached'>
280 directFetchEnabled?: boolean
281}) {
282 const {currentAccount} = useSession()
283 const isViewerOwner = currentAccount?.did
284 ? embed.view.uri.includes(currentAccount.did)
285 : false
286
287 return (
288 <PostPlaceholderText directFetchEnabled={directFetchEnabled}>
289 {isViewerOwner ? (
290 <Trans>Removed by you</Trans>
291 ) : (
292 <Trans>Removed by author</Trans>
293 )}
294 </PostPlaceholderText>
295 )
296}
297
298/*
299 * Nests parent `Embed` component and therefore must live in this file to avoid
300 * circular imports.
301 */
302export function QuoteEmbed({
303 embed,
304 onOpen,
305 style,
306 linkDisabled,
307 isWithinQuote: parentIsWithinQuote,
308 allowNestedQuotes: parentAllowNestedQuotes,
309 showPronouns,
310 viewContext,
311}: Omit<CommonProps, 'viewContext'> & {
312 embed: EmbedType<'post'>
313 viewContext?: PostEmbedViewContext
314 visibilityLabel?: string
315 linkDisabled?: boolean
316}) {
317 const moderationOpts = useModerationOpts()
318 const quote = useMemo<$Typed<AppBskyFeedDefs.PostView>>(
319 () => ({
320 ...embed.view,
321 $type: 'app.bsky.feed.defs#postView',
322 record: embed.view.value,
323 embed: embed.view.embeds?.[0],
324 }),
325 [embed],
326 )
327 const moderation = useMemo(() => {
328 return moderationOpts ? moderatePost(quote, moderationOpts) : undefined
329 }, [quote, moderationOpts])
330
331 const t = useTheme()
332 const queryClient = useQueryClient()
333 const itemUrip = new AtUri(quote.uri)
334 const itemHref = makeProfileLink(quote.author, 'post', itemUrip.rkey)
335 const itemTitle = `Post by ${quote.author.handle}`
336
337 const richText = useMemo(() => {
338 if (
339 !bsky.dangerousIsType<AppBskyFeedPost.Record>(
340 quote.record,
341 AppBskyFeedPost.isRecord,
342 )
343 )
344 return undefined
345 const {text, facets} = quote.record
346 return text.trim()
347 ? new RichTextAPI({text: text, facets: facets})
348 : undefined
349 }, [quote.record])
350
351 const onBeforePress = useCallback(() => {
352 unstableCacheProfileView(queryClient, quote.author)
353 onOpen?.()
354 }, [queryClient, quote.author, onOpen])
355
356 const {
357 state: hover,
358 onIn: onPointerEnter,
359 onOut: onPointerLeave,
360 } = useInteractionState()
361 const {
362 state: pressed,
363 onIn: onPressIn,
364 onOut: onPressOut,
365 } = useInteractionState()
366
367 const contents = (
368 <>
369 <PostMeta
370 author={quote.author}
371 moderation={moderation}
372 showAvatar
373 showPronouns={showPronouns}
374 postHref={itemHref}
375 timestamp={quote.indexedAt}
376 linkDisabled
377 />
378 {moderation ? (
379 <PostAlerts modui={moderation.ui('contentView')} style={[a.py_xs]} />
380 ) : null}
381 {richText ? (
382 <RichText
383 value={richText}
384 style={a.text_md}
385 numberOfLines={20}
386 disableLinks
387 />
388 ) : null}
389 {quote.embed && (
390 <Embed
391 embed={quote.embed}
392 moderation={moderation}
393 viewContext={viewContext}
394 isWithinQuote={parentIsWithinQuote ?? true}
395 // already within quote? override nested
396 allowNestedQuotes={
397 parentIsWithinQuote ? false : parentAllowNestedQuotes
398 }
399 />
400 )}
401 </>
402 )
403
404 return (
405 <GalleryBleed>
406 <View
407 style={[a.mt_sm]}
408 onPointerEnter={linkDisabled ? undefined : onPointerEnter}
409 onPointerLeave={linkDisabled ? undefined : onPointerLeave}>
410 <ContentHider
411 modui={moderation?.ui('contentList')}
412 style={[a.rounded_md, a.border, t.atoms.border_contrast_low, style]}
413 activeStyle={[a.p_md, a.pt_sm]}
414 childContainerStyle={[a.pt_sm]}>
415 {({active}) => (
416 <>
417 {!active && !linkDisabled && (
418 <SubtleHover
419 native
420 hover={hover || pressed}
421 style={[a.rounded_md]}
422 />
423 )}
424 {linkDisabled ? (
425 <View style={[!active && a.p_md]} pointerEvents="none">
426 {contents}
427 </View>
428 ) : (
429 <Link
430 style={[!active && a.p_md]}
431 hoverStyle={t.atoms.border_contrast_high}
432 href={itemHref}
433 title={itemTitle}
434 onBeforePress={onBeforePress}
435 onPressIn={onPressIn}
436 onPressOut={onPressOut}>
437 {contents}
438 </Link>
439 )}
440 </>
441 )}
442 </ContentHider>
443 </View>
444 </GalleryBleed>
445 )
446}