diff options
author | Samuel Newman <mozzius@protonmail.com> | 2024-04-13 03:58:40 +0100 |
---|---|---|
committer | GitHub <noreply@github.com> | 2024-04-13 03:58:40 +0100 |
commit | 4b3ec5573241b9c71504dfd0bd5f181cbde19a49 (patch) | |
tree | 698c2463b389cdf6e14536610e8f96f200ddaaa3 /bskyembed/src/components/embed.tsx | |
parent | 8e29b1f63309ef9ac2da21f62e03b66d477244e9 (diff) | |
download | voidsky-4b3ec5573241b9c71504dfd0bd5f181cbde19a49.tar.zst |
[Embeds] Embed subdomain landing page (#3501)
* add build output to web build * simplify post-build step by copying everything at once * make script that converts placeholder -> iframe * dynamically resize iframe based on inner content Requires the iframe content to `postMessage` its height back up to the parent * add lang to embed * svg explicit height -> viewBox * add build output to web build * simplify post-build step by copying everything at once * attempt to fix go embed issue * rm changes to bskyweb * remove another bskyweb change * embed landing page * Drop xl breakpoint, too far down * Remove pointer enter behavior * Avoid button width jump * Escape HTML --------- Co-authored-by: Dan Abramov <dan.abramov@gmail.com>
Diffstat (limited to 'bskyembed/src/components/embed.tsx')
-rw-r--r-- | bskyembed/src/components/embed.tsx | 299 |
1 files changed, 299 insertions, 0 deletions
diff --git a/bskyembed/src/components/embed.tsx b/bskyembed/src/components/embed.tsx new file mode 100644 index 000000000..2f9f6b3cd --- /dev/null +++ b/bskyembed/src/components/embed.tsx @@ -0,0 +1,299 @@ +import { + AppBskyEmbedExternal, + AppBskyEmbedImages, + AppBskyEmbedRecord, + AppBskyEmbedRecordWithMedia, + AppBskyFeedDefs, + AppBskyFeedPost, + AppBskyGraphDefs, + AppBskyLabelerDefs, +} from '@atproto/api' +import {ComponentChildren, h} from 'preact' + +import infoIcon from '../../assets/circleInfo_stroke2_corner0_rounded.svg' +import {getRkey} from '../utils' +import {Link} from './link' + +export function Embed({content}: {content: AppBskyFeedDefs.PostView['embed']}) { + if (!content) return null + + try { + // Case 1: Image + if (AppBskyEmbedImages.isView(content)) { + return <ImageEmbed content={content} /> + } + + // Case 2: External link + if (AppBskyEmbedExternal.isView(content)) { + return <ExternalEmbed content={content} /> + } + + // Case 3: Record (quote or linked post) + if (AppBskyEmbedRecord.isView(content)) { + const record = content.record + + // Case 3.1: Post + if (AppBskyEmbedRecord.isViewRecord(record)) { + const pwiOptOut = !!record.author.labels?.find( + label => label.val === '!no-unauthenticated', + ) + if (pwiOptOut) { + return ( + <Info> + The author of the quoted post has requested their posts not be + displayed on external sites. + </Info> + ) + } + + let text + if (AppBskyFeedPost.isRecord(record.value)) { + text = record.value.text + } + return ( + <Link + href={`/profile/${record.author.did}/post/${getRkey(record)}`} + className="transition-colors hover:bg-neutral-100 border rounded-lg p-2 gap-1.5 w-full flex flex-col"> + <div className="flex gap-1.5 items-center"> + <img + src={record.author.avatar} + className="w-4 h-4 rounded-full bg-neutral-300 shrink-0" + /> + <p className="line-clamp-1 text-sm"> + <span className="font-bold">{record.author.displayName}</span> + <span className="text-textLight ml-1"> + @{record.author.handle} + </span> + </p> + </div> + {text && <p className="text-sm">{text}</p>} + {record.embeds + ?.filter(embed => { + if (AppBskyEmbedImages.isView(embed)) return true + if (AppBskyEmbedExternal.isView(embed)) return true + return false + }) + .map(embed => ( + <Embed key={embed.$type} content={embed} /> + ))} + </Link> + ) + } + + // Case 3.2: List + if (AppBskyGraphDefs.isListView(record)) { + return ( + <GenericWithImage + image={record.avatar} + title={record.name} + href={`/profile/${record.creator.did}/lists/${getRkey(record)}`} + subtitle={ + record.purpose === AppBskyGraphDefs.MODLIST + ? `Moderation list by @${record.creator.handle}` + : `User list by @${record.creator.handle}` + } + description={record.description} + /> + ) + } + + // Case 3.3: Feed + if (AppBskyFeedDefs.isGeneratorView(record)) { + return ( + <GenericWithImage + image={record.avatar} + title={record.displayName} + href={`/profile/${record.creator.did}/feed/${getRkey(record)}`} + subtitle={`Feed by @${record.creator.handle}`} + description={`Liked by ${record.likeCount ?? 0} users`} + /> + ) + } + + // Case 3.4: Labeler + if (AppBskyLabelerDefs.isLabelerView(record)) { + return ( + <GenericWithImage + image={record.creator.avatar} + title={record.creator.displayName || record.creator.handle} + href={`/profile/${record.creator.did}`} + subtitle="Labeler" + description={`Liked by ${record.likeCount ?? 0} users`} + /> + ) + } + + // Case 3.5: Post not found + if (AppBskyEmbedRecord.isViewNotFound(record)) { + return <Info>Quoted post not found, it may have been deleted.</Info> + } + + // Case 3.6: Post blocked + if (AppBskyEmbedRecord.isViewBlocked(record)) { + return <Info>The quoted post is blocked.</Info> + } + + throw new Error('Unknown embed type') + } + + // Case 4: Record with media + if (AppBskyEmbedRecordWithMedia.isView(content)) { + return ( + <div className="flex flex-col gap-2"> + <Embed content={content.media} /> + <Embed + content={{ + $type: 'app.bsky.embed.record#view', + record: content.record.record, + }} + /> + </div> + ) + } + + throw new Error('Unsupported embed type') + } catch (err) { + return ( + <Info>{err instanceof Error ? err.message : 'An error occurred'}</Info> + ) + } +} + +function Info({children}: {children: ComponentChildren}) { + return ( + <div className="w-full rounded-lg border py-2 px-2.5 flex-row flex gap-2 bg-neutral-50"> + <img src={infoIcon as string} className="w-4 h-4 shrink-0 mt-0.5" /> + <p className="text-sm text-textLight">{children}</p> + </div> + ) +} + +function ImageEmbed({content}: {content: AppBskyEmbedImages.View}) { + switch (content.images.length) { + case 1: + return ( + <img + src={content.images[0].thumb} + alt={content.images[0].alt} + className="w-full rounded-lg overflow-hidden object-cover h-auto max-h-[1000px]" + /> + ) + case 2: + return ( + <div className="flex gap-1 rounded-lg overflow-hidden w-full aspect-[2/1]"> + {content.images.map((image, i) => ( + <img + key={i} + src={image.thumb} + alt={image.alt} + className="w-1/2 h-full object-cover rounded-sm" + /> + ))} + </div> + ) + case 3: + return ( + <div className="flex gap-1 rounded-lg overflow-hidden w-full aspect-[2/1]"> + <img + src={content.images[0].thumb} + alt={content.images[0].alt} + className="flex-[3] object-cover rounded-sm" + /> + <div className="flex flex-col gap-1 flex-[2]"> + {content.images.slice(1).map((image, i) => ( + <img + key={i} + src={image.thumb} + alt={image.alt} + className="w-full h-full object-cover rounded-sm" + /> + ))} + </div> + </div> + ) + case 4: + return ( + <div className="grid grid-cols-2 gap-1 rounded-lg overflow-hidden"> + {content.images.map((image, i) => ( + <img + key={i} + src={image.thumb} + alt={image.alt} + className="aspect-square w-full object-cover rounded-sm" + /> + ))} + </div> + ) + default: + return null + } +} + +function ExternalEmbed({content}: {content: AppBskyEmbedExternal.View}) { + function toNiceDomain(url: string): string { + try { + const urlp = new URL(url) + return urlp.host ? urlp.host : url + } catch (e) { + return url + } + } + return ( + <Link + href={content.external.uri} + className="w-full rounded-lg overflow-hidden border flex flex-col items-stretch"> + {content.external.thumb && ( + <img + src={content.external.thumb} + className="aspect-[1.91/1] object-cover" + /> + )} + <div className="py-3 px-4"> + <p className="text-sm text-textLight line-clamp-1"> + {toNiceDomain(content.external.uri)} + </p> + <p className="font-semibold line-clamp-3">{content.external.title}</p> + <p className="text-sm text-textLight line-clamp-2 mt-0.5"> + {content.external.description} + </p> + </div> + </Link> + ) +} + +function GenericWithImage({ + title, + subtitle, + href, + image, + description, +}: { + title: string + subtitle: string + href: string + image?: string + description?: string +}) { + return ( + <Link + href={href} + className="w-full rounded-lg border py-2 px-3 flex flex-col gap-2"> + <div className="flex gap-2.5 items-center"> + {image ? ( + <img + src={image} + alt={title} + className="w-8 h-8 rounded-md bg-neutral-300 shrink-0" + /> + ) : ( + <div className="w-8 h-8 rounded-md bg-brand shrink-0" /> + )} + <div className="flex-1"> + <p className="font-bold text-sm">{title}</p> + <p className="text-textLight text-sm">{subtitle}</p> + </div> + </div> + {description && <p className="text-textLight text-sm">{description}</p>} + </Link> + ) +} |