Bluesky app fork with some witchin' additions 馃挮
at readme-update 436 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, 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}