Bluesky app fork with some witchin' additions 馃挮
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 446 lines 12 kB view raw
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}