import { lazy, type CSSProperties, type ReactNode, Suspense } from 'react';
import type { AppBskyEmbedExternal } from '@atcute/bluesky';
import { clsx } from 'clsx';
import { cleanError } from '#/lib/errors';
import type { Gif } from '#/lib/gif';
import { toNiceDomain } from '#/lib/links/nice-domain';
import { getBlobUrl } from '#/lib/utils/blob-url';
import { useResolveGifQuery, useResolveLinkQuery } from '#/state/queries/resolve-link';
import { ExternalEmbedRemoveBtn } from '#/features/composer/ExternalEmbedRemoveBtn';
import { ExternalEmbed } from '#/components/ExternalEmbed';
import { NavigationDisabled } from '#/components/NavigationDisabled';
import { ModeratedFeedEmbed } from '#/components/Post/Embed/FeedEmbed';
import { ModeratedListEmbed } from '#/components/Post/Embed/ListEmbed';
import { StandardSiteEmbed } from '#/components/Post/Embed/StandardSiteEmbed';
import { isStandardSiteEmbed } from '#/components/Post/Embed/StandardSiteEmbed/utils';
import { parseTangledStringUrl } from '#/components/Post/Embed/TangledStringEmbed/detect';
import { TangledStringPlaceholder } from '#/components/Post/Embed/TangledStringEmbed/Placeholder';
import { Spinner } from '#/components/Spinner';
import { Embed as StarterPackEmbed } from '#/components/StarterPack/StarterPackCard';
import { Text } from '#/components/Text';
import { m } from '#/paraglide/messages';
import * as styles from './ExternalEmbed.css';
const TangledStringEmbed = lazy(() =>
import('#/components/Post/Embed/TangledStringEmbed').then((mod) => ({ default: mod.TangledStringEmbed })),
);
export const ExternalEmbedGif = ({ onRemove, gif }: { onRemove: () => void; gif: Gif }) => {
const { data, error } = useResolveGifQuery(gif);
const thumbUrl = getBlobUrl(data?.thumb?.source.blob);
let linkInfo: AppBskyEmbedExternal.ViewExternal | undefined;
if (data) {
// oxlint-disable-next-line typescript/no-unsafe-type-assertion -- a tenor url and a `blob:` object url, both absolute
linkInfo = {
title: data.title ?? data.uri,
uri: data.uri,
description: data.description ?? '',
thumb: thumbUrl,
} as AppBskyEmbedExternal.ViewExternal;
}
const loadingStyle: CSSProperties = {
aspectRatio: (() => {
const dims = gif.media_formats.gif?.dims;
if (dims && dims[0] > 0 && dims[1] > 0) {
return dims[0] / dims[1];
}
return 16 / 9; // Default aspect ratio
})(),
width: '100%',
};
return (
<div className={styles.container} role="group" aria-label={m['view.composer.embed.a11y.gifPreview']()}>
{linkInfo ? (
<NavigationDisabled>
<ExternalEmbed link={linkInfo} hideAlt />
</NavigationDisabled>
) : error ? (
<Container className={styles.errorContainer}>
<Text numberOfLines={1} color="textContrastHigh">
{gif.url}
</Text>
<Text numberOfLines={2} className={styles.textNegative}>
{cleanError(error)}
</Text>
</Container>
) : (
<Container style={loadingStyle}>
<Spinner color="default" label={m['common.status.loading']()} size="_2xl" />
</Container>
)}
<ExternalEmbedRemoveBtn onRemove={onRemove} />
</div>
);
};
export const ExternalEmbedLink = ({
uri,
hasQuote,
onRemove,
}: {
uri: string;
hasQuote: boolean;
onRemove: () => void;
}) => {
const { data, error } = useResolveLinkQuery(uri);
const thumbUrl = getBlobUrl(data?.type === 'external' ? data.thumb?.source.blob : undefined);
const tangledTarget = parseTangledStringUrl(uri);
let linkComponent: ReactNode;
if (tangledTarget) {
linkComponent = (
<Suspense fallback={<TangledStringPlaceholder />}>
<TangledStringEmbed target={tangledTarget} />
</Suspense>
);
} else if (data) {
if (data.type === 'external') {
const external = data.view?.external;
if (external && isStandardSiteEmbed(external)) {
linkComponent = (
<StandardSiteEmbed
preview
view={
// oxlint-disable-next-line typescript/no-unsafe-type-assertion -- a resolved http(s) link and a `blob:` object url, both absolute
{
...external,
title: external.title || data.title || uri,
uri,
description: external.description || data.description,
// prefer opengraph data to atproto record-derived image
thumb: thumbUrl || external.thumb,
} as AppBskyEmbedExternal.ViewExternal
}
/>
);
} else {
linkComponent = (
<ExternalEmbed
link={
// oxlint-disable-next-line typescript/no-unsafe-type-assertion -- a resolved http(s) link and a `blob:` object url, both absolute
{
title: data.title || uri,
uri,
description: data.description,
thumb: thumbUrl,
} as AppBskyEmbedExternal.ViewExternal
}
hideAlt
/>
);
}
} else if (data.kind === 'feed') {
linkComponent = (
<ModeratedFeedEmbed
embed={{
$type: 'app.bsky.feed.defs#generatorView',
...data.view,
}}
/>
);
} else if (data.kind === 'list') {
linkComponent = (
<ModeratedListEmbed
embed={{
$type: 'app.bsky.graph.defs#listView',
...data.view,
}}
/>
);
} else if (data.kind === 'starterPack') {
linkComponent = <StarterPackEmbed starterPack={data.view} />;
}
}
if (data?.type === 'record' && hasQuote) {
// This is not currently supported by the data model so don't preview it.
return null;
}
return (
<div
className={styles.linkContainer}
role="group"
aria-label={m['view.composer.embed.a11y.linkPreview']({ niceUrl: toNiceDomain(uri) })}
>
{linkComponent ? (
<NavigationDisabled>{linkComponent}</NavigationDisabled>
) : error ? (
<Container className={styles.errorContainer}>
<Text numberOfLines={1} color="textContrastHigh">
{uri}
</Text>
<Text numberOfLines={2} className={styles.textNegative}>
{cleanError(error)}
</Text>
</Container>
) : (
<Container>
<Spinner color="default" label={m['common.status.loading']()} size="_2xl" />
</Container>
)}
<ExternalEmbedRemoveBtn onRemove={onRemove} />
</div>
);
};
function Container({
style,
className,
children,
}: {
style?: CSSProperties;
className?: string;
children: ReactNode;
}) {
return (
<div className={clsx(styles.contentContainer, className)} style={style}>
{children}
</div>
);
}