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

Configure Feed

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

at fbd1138d97dda2df66bee13ad3ca6e83d55ebc25 350 lines 9.2 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 {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}