Bluesky app fork with some witchin' additions 馃挮
witchsky.app
bluesky
fork
client
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 {ContentHider} from '#/components/moderation/ContentHider'
27import {PostAlerts} from '#/components/moderation/PostAlerts'
28import {RichText} from '#/components/RichText'
29import {Embed as StarterPackCard} from '#/components/StarterPack/StarterPackCard'
30import {SubtleHover} from '#/components/SubtleHover'
31import * as bsky from '#/types/bsky'
32import {
33 type Embed as TEmbed,
34 type EmbedType,
35 parseEmbed,
36} from '#/types/bsky/post'
37import {ExternalEmbed} from './ExternalEmbed'
38import {ModeratedFeedEmbed} from './FeedEmbed'
39import {ImageEmbed} from './ImageEmbed'
40import {ModeratedListEmbed} from './ListEmbed'
41import {PostPlaceholder as PostPlaceholderText} from './PostPlaceholder'
42import {
43 type CommonProps,
44 type EmbedProps,
45 PostEmbedViewContext,
46 QuoteEmbedViewContext,
47} from './types'
48import {VideoEmbed} from './VideoEmbed'
49
50export {PostEmbedViewContext, QuoteEmbedViewContext} 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={
185 rest.viewContext === PostEmbedViewContext.Feed
186 ? QuoteEmbedViewContext.FeedEmbedRecordWithMedia
187 : undefined
188 }
189 isWithinQuote={rest.isWithinQuote}
190 allowNestedQuotes={rest.allowNestedQuotes}
191 />
192 )
193 }
194 case 'post_not_found': {
195 return (
196 <PostPlaceholderText>
197 <Trans>Deleted</Trans>
198 </PostPlaceholderText>
199 )
200 }
201 case 'post_blocked': {
202 const record = directRecord.data
203 if (record !== undefined) {
204 return (
205 <DirectFetchEmbed
206 {...rest}
207 embed={record}
208 visibilityLabel={_(msg`Blocked`)}
209 />
210 )
211 }
212
213 return (
214 <PostPlaceholderText directFetchEnabled={directFetchEnabled}>
215 <Trans>Blocked</Trans>
216 </PostPlaceholderText>
217 )
218 }
219 case 'post_detached': {
220 const record = directRecord.data
221 if (record !== undefined) {
222 return (
223 <DirectFetchEmbed
224 {...rest}
225 embed={record}
226 visibilityLabel={_(msg`Removed by author`)}
227 visibilityLabelOwner={_(msg`Removed by you`)}
228 />
229 )
230 }
231
232 return (
233 <PostDetachedEmbed
234 embed={embed}
235 directFetchEnabled={directFetchEnabled}
236 />
237 )
238 }
239 default: {
240 return null
241 }
242 }
243}
244
245export function DirectFetchEmbed({
246 embed,
247 visibilityLabel,
248 visibilityLabelOwner,
249 ...rest
250}: Omit<CommonProps, 'viewContext'> & {
251 embed: EmbedType<'post'>
252 viewContext?: PostEmbedViewContext
253 visibilityLabel: string
254 visibilityLabelOwner?: string
255}) {
256 const {currentAccount} = useSession()
257 const isViewerOwner = currentAccount?.did
258 ? embed.view.uri.includes(currentAccount.did)
259 : false
260
261 return (
262 <View>
263 <QuoteEmbed
264 {...rest}
265 embed={embed}
266 viewContext={
267 rest.viewContext === PostEmbedViewContext.Feed
268 ? QuoteEmbedViewContext.FeedEmbedRecordWithMedia
269 : undefined
270 }
271 isWithinQuote={rest.isWithinQuote}
272 allowNestedQuotes={rest.allowNestedQuotes}
273 visibilityLabel={
274 isViewerOwner && visibilityLabelOwner
275 ? visibilityLabelOwner
276 : visibilityLabel
277 }
278 />
279 </View>
280 )
281}
282
283export function PostDetachedEmbed({
284 embed,
285 directFetchEnabled,
286}: {
287 embed: EmbedType<'post_detached'>
288 directFetchEnabled?: boolean
289}) {
290 const {currentAccount} = useSession()
291 const isViewerOwner = currentAccount?.did
292 ? embed.view.uri.includes(currentAccount.did)
293 : false
294
295 return (
296 <PostPlaceholderText directFetchEnabled={directFetchEnabled}>
297 {isViewerOwner ? (
298 <Trans>Removed by you</Trans>
299 ) : (
300 <Trans>Removed by author</Trans>
301 )}
302 </PostPlaceholderText>
303 )
304}
305
306/*
307 * Nests parent `Embed` component and therefore must live in this file to avoid
308 * circular imports.
309 */
310export function QuoteEmbed({
311 embed,
312 onOpen,
313 style,
314 linkDisabled,
315 isWithinQuote: parentIsWithinQuote,
316 allowNestedQuotes: parentAllowNestedQuotes,
317 showPronouns,
318}: Omit<CommonProps, 'viewContext'> & {
319 embed: EmbedType<'post'>
320 viewContext?: QuoteEmbedViewContext
321 visibilityLabel?: string
322 linkDisabled?: boolean
323}) {
324 const moderationOpts = useModerationOpts()
325 const quote = useMemo<$Typed<AppBskyFeedDefs.PostView>>(
326 () => ({
327 ...embed.view,
328 $type: 'app.bsky.feed.defs#postView',
329 record: embed.view.value,
330 embed: embed.view.embeds?.[0],
331 }),
332 [embed],
333 )
334 const moderation = useMemo(() => {
335 return moderationOpts ? moderatePost(quote, moderationOpts) : undefined
336 }, [quote, moderationOpts])
337
338 const t = useTheme()
339 const queryClient = useQueryClient()
340 const itemUrip = new AtUri(quote.uri)
341 const itemHref = makeProfileLink(quote.author, 'post', itemUrip.rkey)
342 const itemTitle = `Post by ${quote.author.handle}`
343
344 const richText = useMemo(() => {
345 if (
346 !bsky.dangerousIsType<AppBskyFeedPost.Record>(
347 quote.record,
348 AppBskyFeedPost.isRecord,
349 )
350 )
351 return undefined
352 const {text, facets} = quote.record
353 return text.trim()
354 ? new RichTextAPI({text: text, facets: facets})
355 : undefined
356 }, [quote.record])
357
358 const onBeforePress = useCallback(() => {
359 unstableCacheProfileView(queryClient, quote.author)
360 onOpen?.()
361 }, [queryClient, quote.author, onOpen])
362
363 const {
364 state: hover,
365 onIn: onPointerEnter,
366 onOut: onPointerLeave,
367 } = useInteractionState()
368 const {
369 state: pressed,
370 onIn: onPressIn,
371 onOut: onPressOut,
372 } = useInteractionState()
373
374 const contents = (
375 <>
376 <PostMeta
377 author={quote.author}
378 moderation={moderation}
379 showAvatar
380 showPronouns={showPronouns}
381 postHref={itemHref}
382 timestamp={quote.indexedAt}
383 linkDisabled
384 />
385 {moderation ? (
386 <PostAlerts modui={moderation.ui('contentView')} style={[a.py_xs]} />
387 ) : null}
388 {richText ? (
389 <RichText
390 value={richText}
391 style={a.text_md}
392 numberOfLines={20}
393 disableLinks
394 />
395 ) : null}
396 {quote.embed && (
397 <Embed
398 embed={quote.embed}
399 moderation={moderation}
400 isWithinQuote={parentIsWithinQuote ?? true}
401 // already within quote? override nested
402 allowNestedQuotes={
403 parentIsWithinQuote ? false : parentAllowNestedQuotes
404 }
405 />
406 )}
407 </>
408 )
409
410 return (
411 <View
412 style={[a.mt_sm]}
413 onPointerEnter={linkDisabled ? undefined : onPointerEnter}
414 onPointerLeave={linkDisabled ? undefined : onPointerLeave}>
415 <ContentHider
416 modui={moderation?.ui('contentList')}
417 style={[a.rounded_md, a.border, t.atoms.border_contrast_low, style]}
418 activeStyle={[a.p_md, a.pt_sm]}
419 childContainerStyle={[a.pt_sm]}>
420 {({active}) => (
421 <>
422 {!active && !linkDisabled && (
423 <SubtleHover
424 native
425 hover={hover || pressed}
426 style={[a.rounded_md]}
427 />
428 )}
429 {linkDisabled ? (
430 <View style={[!active && a.p_md]} pointerEvents="none">
431 {contents}
432 </View>
433 ) : (
434 <Link
435 style={[!active && a.p_md]}
436 hoverStyle={t.atoms.border_contrast_high}
437 href={itemHref}
438 title={itemTitle}
439 onBeforePress={onBeforePress}
440 onPressIn={onPressIn}
441 onPressOut={onPressOut}>
442 {contents}
443 </Link>
444 )}
445 </>
446 )}
447 </ContentHider>
448 </View>
449 )
450}