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