Bluesky app fork with some witchin' additions 馃挮 witchsky.app
bluesky fork client
at main 450 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 {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}