[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>
This commit is contained in:
Samuel Newman 2024-04-13 03:58:40 +01:00 committed by GitHub
parent 8e29b1f633
commit 4b3ec55732
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 482 additions and 57 deletions

View file

@ -0,0 +1,55 @@
import {ComponentChildren, h} from 'preact'
import {useEffect, useRef} from 'preact/hooks'
import {Link} from './link'
export function Container({
children,
href,
}: {
children: ComponentChildren
href: string
}) {
const ref = useRef<HTMLDivElement>(null)
const prevHeight = useRef(0)
useEffect(() => {
if (ref.current) {
const observer = new ResizeObserver(entries => {
const entry = entries[0]
if (!entry) return
let {height} = entry.contentRect
height += 2 // border top and bottom
if (height !== prevHeight.current) {
prevHeight.current = height
window.parent.postMessage(
{height, id: new URLSearchParams(window.location.search).get('id')},
'*',
)
}
})
observer.observe(ref.current)
return () => observer.disconnect()
}
}, [])
return (
<div
ref={ref}
className="w-full bg-white hover:bg-neutral-50 relative transition-colors max-w-[600px] min-w-[300px] flex border rounded-xl"
onClick={() => {
if (ref.current) {
// forwardRef requires preact/compat - let's keep it simple
// to keep the bundle size down
const anchor = ref.current.querySelector('a')
if (anchor) {
anchor.click()
}
}
}}>
<Link href={href} />
<div className="flex-1 px-4 pt-3 pb-2.5">{children}</div>
</div>
)
}

View file

@ -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>
)
}

View file

@ -0,0 +1,21 @@
import {h} from 'preact'
export function Link({
href,
className,
...props
}: {
href: string
className?: string
} & h.JSX.HTMLAttributes<HTMLAnchorElement>) {
return (
<a
href={href.startsWith('http') ? href : `https://bsky.app${href}`}
target="_blank"
rel="noopener noreferrer nofollow"
onClick={evt => evt.stopPropagation()}
className={`cursor-pointer ${className || ''}`}
{...props}
/>
)
}

View file

@ -0,0 +1,150 @@
import {AppBskyFeedDefs, AppBskyFeedPost, RichText} from '@atproto/api'
import {h} from 'preact'
import replyIcon from '../../assets/bubble_filled_stroke2_corner2_rounded.svg'
import likeIcon from '../../assets/heart2_filled_stroke2_corner0_rounded.svg'
import logo from '../../assets/logo.svg'
import repostIcon from '../../assets/repost_stroke2_corner2_rounded.svg'
import {getRkey, niceDate} from '../utils'
import {Container} from './container'
import {Embed} from './embed'
import {Link} from './link'
interface Props {
thread: AppBskyFeedDefs.ThreadViewPost
}
export function Post({thread}: Props) {
const post = thread.post
let record: AppBskyFeedPost.Record | null = null
if (AppBskyFeedPost.isRecord(post.record)) {
record = post.record
}
const href = `/profile/${post.author.did}/post/${getRkey(post)}`
return (
<Container href={href}>
<div className="flex-1 flex-col flex gap-2" lang={record?.langs?.[0]}>
<div className="flex gap-2.5 items-center">
<Link href={`/profile/${post.author.did}`} className="rounded-full">
<img
src={post.author.avatar}
className="w-10 h-10 rounded-full bg-neutral-300 shrink-0"
/>
</Link>
<div className="flex-1">
<Link
href={`/profile/${post.author.did}`}
className="font-bold text-[17px] leading-5 line-clamp-1 hover:underline underline-offset-2 decoration-2">
<p>{post.author.displayName}</p>
</Link>
<Link
href={`/profile/${post.author.did}`}
className="text-[15px] text-textLight hover:underline line-clamp-1">
<p>@{post.author.handle}</p>
</Link>
</div>
<Link
href={href}
className="transition-transform hover:scale-110 shrink-0 self-start">
<img src={logo as string} className="h-8" />
</Link>
</div>
<PostContent record={record} />
<Embed content={post.embed} />
<time
datetime={new Date(post.indexedAt).toISOString()}
className="text-textLight mt-1 text-sm">
{niceDate(post.indexedAt)}
</time>
<div className="border-t w-full pt-2.5 flex items-center gap-5 text-sm">
{!!post.likeCount && (
<div className="flex items-center gap-2 cursor-pointer">
<img src={likeIcon as string} className="w-5 h-5" />
<p className="font-bold text-neutral-500 mb-px">
{post.likeCount}
</p>
</div>
)}
{!!post.repostCount && (
<div className="flex items-center gap-2 cursor-pointer">
<img src={repostIcon as string} className="w-5 h-5" />
<p className="font-bold text-neutral-500 mb-px">
{post.repostCount}
</p>
</div>
)}
<div className="flex items-center gap-2 cursor-pointer">
<img src={replyIcon as string} className="w-5 h-5" />
<p className="font-bold text-neutral-500 mb-px">Reply</p>
</div>
<div className="flex-1" />
<p className="cursor-pointer text-brand font-bold hover:underline hidden min-[450px]:inline">
{post.replyCount
? `Read ${post.replyCount} ${
post.replyCount > 1 ? 'replies' : 'reply'
} on Bluesky`
: `View on Bluesky`}
</p>
<p className="cursor-pointer text-brand font-bold hover:underline min-[450px]:hidden">
<span className="hidden min-[380px]:inline">View on </span>Bluesky
</p>
</div>
</div>
</Container>
)
}
function PostContent({record}: {record: AppBskyFeedPost.Record | null}) {
if (!record) return null
const rt = new RichText({
text: record.text,
facets: record.facets,
})
const richText = []
let counter = 0
for (const segment of rt.segments()) {
if (segment.isLink() && segment.link) {
richText.push(
<Link
key={counter}
href={segment.link.uri}
className="text-blue-400 hover:underline">
{segment.text}
</Link>,
)
} else if (segment.isMention() && segment.mention) {
richText.push(
<Link
key={counter}
href={`/profile/${segment.mention.did}`}
className="text-blue-500 hover:underline">
{segment.text}
</Link>,
)
} else if (segment.isTag() && segment.tag) {
richText.push(
<Link
key={counter}
href={`/tag/${segment.tag.tag}`}
className="text-blue-500 hover:underline">
{segment.text}
</Link>,
)
} else {
richText.push(segment.text)
}
counter++
}
return (
<p className="min-[300px]:text-lg leading-6 break-word break-words whitespace-pre-wrap">
{richText}
</p>
)
}