diff --git a/src/components/Embeds/PostEmbed.tsx b/src/components/Embeds/PostEmbed.tsx new file mode 100644 index 0000000000..d8d28bccdf --- /dev/null +++ b/src/components/Embeds/PostEmbed.tsx @@ -0,0 +1,507 @@ +import React from 'react' +import { + InteractionManager, + StyleProp, + StyleSheet, + View, + ViewStyle, +} from 'react-native' +import {MeasuredDimensions, runOnJS, runOnUI} from 'react-native-reanimated' +import {Image} from 'expo-image' +import { + $Typed, + AppBskyFeedDefs, + AppBskyFeedPost, + AtUri, + ModerationDecision, + RichText as RichTextAPI, +} from '@atproto/api' +import {Trans} from '@lingui/macro' +import {useQueryClient} from '@tanstack/react-query' + +import {HandleRef, measureHandle} from '#/lib/hooks/useHandleRef' +import {usePalette} from '#/lib/hooks/usePalette' +import {InfoCircleIcon} from '#/lib/icons' +import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped' +import {makeProfileLink} from '#/lib/routes/links' +import {useLightboxControls} from '#/state/lightbox' +import {useModerationOpts} from '#/state/preferences/moderation-opts' +import {unstableCacheProfileView} from '#/state/queries/profile' +import {useSession} from '#/state/session' +import {FeedSourceCard} from '#/view/com/feeds/FeedSourceCard' +import {Dimensions} from '#/view/com/lightbox/ImageViewing/@types' +import {AutoSizedImage} from '#/view/com/util/images/AutoSizedImage' +import {ImageLayoutGrid} from '#/view/com/util/images/ImageLayoutGrid' +import {Link} from '#/view/com/util/Link' +import {ExternalLinkEmbed} from '#/view/com/util/post-embeds/ExternalLinkEmbed' +import { + PostEmbedViewContext, + QuoteEmbedViewContext, +} from '#/view/com/util/post-embeds/types' +import {VideoEmbed} from '#/view/com/util/post-embeds/VideoEmbed' +import {PostMeta} from '#/view/com/util/PostMeta' +import {Text} from '#/view/com/util/text/Text' +import {atoms as a, useTheme} from '#/alf' +import * as ListCard from '#/components/ListCard' +import {ContentHider} from '#/components/moderation/ContentHider' +import {PostAlerts} from '#/components/moderation/PostAlerts' +import {RichText} from '#/components/RichText' +import {Embed as StarterPackCard} from '#/components/StarterPack/StarterPackCard' +import {SubtleWebHover} from '#/components/SubtleWebHover' +import * as atp from '#/types/atproto' +import {Embed, EmbedType,parseEmbed} from '#/types/atproto/post' + +export type CommonProps = { + moderation?: ModerationDecision + onOpen?: () => void + style?: StyleProp + allowNestedQuotes?: boolean + viewContext?: PostEmbedViewContext +} + +export type PostEmbedProps = CommonProps & { + embed?: AppBskyFeedDefs.PostView['embed'] +} + +export function PostEmbed({embed: rawEmbed, ...rest}: PostEmbedProps) { + const embed = parseEmbed(rawEmbed) + + switch (embed.type) { + case 'images': + case 'link': + case 'video': { + return + } + case 'feed': + case 'list': + case 'starter_pack': + case 'labeler': + case 'post': + case 'post_not_found': + case 'post_blocked': + case 'post_detached': { + return + } + case 'post_with_media': { + return ( + + + + + ) + } + default: { + return null + } + } +} + +function MediaEmbed({ + embed, + ...rest +}: CommonProps & { + embed: Embed +}) { + switch (embed.type) { + case 'images': { + return ( + + + + ) + } + case 'link': { + return ( + + + + ) + } + case 'video': { + return ( + + + + ) + } + default: { + return null + } + } +} + +function RecordEmbed({ + embed, + ...rest +}: CommonProps & { + embed: Embed +}) { + switch (embed.type) { + case 'feed': { + return ( + + + + + + ) + } + case 'list': { + return ( + + + + + + ) + } + case 'starter_pack': { + return ( + + + + ) + } + case 'labeler': { + // not implemented + return null + } + case 'post': { + return ( + + ) + } + case 'post_not_found': { + return + } + case 'post_blocked': { + return + } + case 'post_detached': { + return + } + default: { + return null + } + } +} + +export function ListEmbed({ + embed, +}: CommonProps & { + embed: EmbedType<'list'> +}) { + const t = useTheme() + return ( + + + + ) +} + +export function FeedEmbed({ + embed, +}: CommonProps & { + embed: EmbedType<'feed'> +}) { + const pal = usePalette('default') + return ( + + ) +} + +export function ImagesEmbed({ + embed, + ...rest +}: CommonProps & { + embed: EmbedType<'images'> +}) { + const {openLightbox} = useLightboxControls() + const {images} = embed.view + + if (images.length > 0) { + const items = images.map(img => ({ + uri: img.fullsize, + thumbUri: img.thumb, + alt: img.alt, + dimensions: img.aspectRatio ?? null, + })) + const _openLightbox = ( + index: number, + thumbRects: (MeasuredDimensions | null)[], + fetchedDims: (Dimensions | null)[], + ) => { + openLightbox({ + images: items.map((item, i) => ({ + ...item, + thumbRect: thumbRects[i] ?? null, + thumbDimensions: fetchedDims[i] ?? null, + type: 'image', + })), + index, + }) + } + const onPress = ( + index: number, + refs: HandleRef[], + fetchedDims: (Dimensions | null)[], + ) => { + const handles = refs.map(r => r.current) + runOnUI(() => { + 'worklet' + const rects = handles.map(measureHandle) + runOnJS(_openLightbox)(index, rects, fetchedDims) + })() + } + const onPressIn = (_: number) => { + InteractionManager.runAfterInteractions(() => { + Image.prefetch(items.map(i => i.uri)) + }) + } + + if (images.length === 1) { + const image = images[0] + return ( + + onPress(0, [containerRef], [dims])} + onPressIn={() => onPressIn(0)} + hideBadge={ + rest.viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia + } + /> + + ) + } + + return ( + + + + ) + } +} + +export function PostBlockedEmbed() { + const t = useTheme() + const pal = usePalette('default') + return ( + + + + Blocked + + + ) +} + +export function PostNotFoundEmbed() { + const t = useTheme() + const pal = usePalette('default') + return ( + + + + Deleted + + + ) +} + +export function PostDetachedEmbed({ + embed, +}: { + embed: EmbedType<'post_detached'> +}) { + const t = useTheme() + const pal = usePalette('default') + const {currentAccount} = useSession() + const isViewerOwner = currentAccount?.did + ? embed.view.uri.includes(currentAccount.did) + : false + return ( + + + + {isViewerOwner ? ( + Removed by you + ) : ( + Removed by author + )} + + + ) +} + +function QuoteEmbed({ + embed, + onOpen, + style, +}: Omit & { + embed: EmbedType<'post'> + viewContext?: QuoteEmbedViewContext +}) { + const moderationOpts = useModerationOpts() + const quote = React.useMemo<$Typed>( + () => ({ + ...embed.view, + $type: 'app.bsky.feed.defs#postView', + record: embed.view.value, + embed: embed.view.embeds?.[0], + }), + [embed], + ) + const moderation = React.useMemo(() => { + return moderationOpts ? moderatePost(quote, moderationOpts) : undefined + }, [quote, moderationOpts]) + + const t = useTheme() + const queryClient = useQueryClient() + const pal = usePalette('default') + const itemUrip = new AtUri(quote.uri) + const itemHref = makeProfileLink(quote.author, 'post', itemUrip.rkey) + const itemTitle = `Post by ${quote.author.handle}` + + const richText = React.useMemo(() => { + if ( + !atp.dangerousIsType( + quote.record, + AppBskyFeedPost.isRecord, + ) + ) + return undefined + const {text, facets} = quote.record + return text.trim() + ? new RichTextAPI({text: text, facets: facets}) + : undefined + }, [quote.record]) + + const onBeforePress = React.useCallback(() => { + unstableCacheProfileView(queryClient, quote.author) + onOpen?.() + }, [queryClient, quote.author, onOpen]) + + const [hover, setHover] = React.useState(false) + return ( + { + setHover(true) + }} + onPointerLeave={() => { + setHover(false) + }}> + + + + + + + {moderation ? ( + + ) : null} + {richText ? ( + + ) : null} + {quote.embed && ( + + )} + + + + ) +} + +const styles = StyleSheet.create({ + altContainer: { + backgroundColor: 'rgba(0, 0, 0, 0.75)', + borderRadius: 6, + paddingHorizontal: 6, + paddingVertical: 3, + position: 'absolute', + right: 6, + bottom: 6, + }, + alt: { + color: 'white', + fontSize: 7, + fontWeight: '600', + }, + customFeedOuter: { + borderWidth: StyleSheet.hairlineWidth, + borderRadius: 8, + paddingHorizontal: 12, + paddingVertical: 12, + }, + errorContainer: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + borderRadius: 8, + marginTop: 8, + paddingVertical: 14, + paddingHorizontal: 14, + borderWidth: StyleSheet.hairlineWidth, + }, +}) diff --git a/src/components/dms/MessageItemEmbed.tsx b/src/components/dms/MessageItemEmbed.tsx index f9eb4d3af7..8265c5c7ff 100644 --- a/src/components/dms/MessageItemEmbed.tsx +++ b/src/components/dms/MessageItemEmbed.tsx @@ -1,22 +1,23 @@ import React from 'react' import {View} from 'react-native' -import {AppBskyEmbedRecord} from '@atproto/api' +import {$Typed,AppBskyEmbedRecord} from '@atproto/api' -import {PostEmbeds, PostEmbedViewContext} from '#/view/com/util/post-embeds' +import {PostEmbedViewContext} from '#/view/com/util/post-embeds' import {atoms as a, native, useTheme} from '#/alf' +import {PostEmbed} from '#/components/embeds/PostEmbed' import {MessageContextProvider} from './MessageContext' let MessageItemEmbed = ({ embed, }: { - embed: AppBskyEmbedRecord.View + embed: $Typed }): React.ReactNode => { const t = useTheme() return ( - -