Handle video and gifs, add test page
This commit is contained in:
@@ -7,16 +7,20 @@ import {
|
||||
import {ModeratorData} from '../data/getModeratorData.js'
|
||||
import {Image as ImageSource, PostData} from '../data/getPostData.js'
|
||||
import {atoms as a, theme as t} from '../theme/index.js'
|
||||
import {getModerationCauseInfo} from '../util/getModerationCauseInfo.js'
|
||||
import {getStarterPackImageUri} from '../util/getStarterPackImageUri.js'
|
||||
import {Embed, EmbedType, parseEmbed} from '../util/parseEmbed.js'
|
||||
import {parseEmbedPlayerFromUrl} from '../util/parseEmbedPlayerFromUrl.js'
|
||||
import {sanitizeHandle} from '../util/sanitizeHandle.js'
|
||||
import {Box} from './Box.js'
|
||||
import {FeedCard} from './FeedCard.js'
|
||||
import * as Grid from './Grid.js'
|
||||
import {PlayFilled as Play} from './icons/Play.js'
|
||||
import {StarterPack} from './icons/StarterPack.js'
|
||||
import {Image, SquareImage} from './Image.js'
|
||||
import {LinkCard} from './LinkCard.js'
|
||||
import {ListCard} from './ListCard.js'
|
||||
import {ModeratedEmbed} from './ModeratedEmbed.js'
|
||||
import {NotQuotePost, QuotePost} from './PostEmbed/QuotePost.js'
|
||||
import {Text} from './Text.js'
|
||||
|
||||
@@ -76,7 +80,7 @@ export function MediaEmbeds({
|
||||
return <LinkEmbed embed={embed} {...rest} />
|
||||
}
|
||||
case 'video': {
|
||||
return null
|
||||
return <VideoEmbed embed={embed} {...rest} />
|
||||
}
|
||||
default:
|
||||
return null
|
||||
@@ -231,19 +235,123 @@ export function LinkEmbed({
|
||||
const {title, description, uri, thumb} = embed.view.external
|
||||
if (!thumb) return null
|
||||
const image = rest.data.images.get(thumb)
|
||||
const player = parseEmbedPlayerFromUrl(uri)
|
||||
const gif = rest.data.images.get(player?.playerUri)
|
||||
if (gif) {
|
||||
return (
|
||||
<Box cx={[a.rounded_sm, a.overflow_hidden]}>
|
||||
<Image image={gif} />
|
||||
<Box
|
||||
cx={[
|
||||
a.absolute,
|
||||
a.inset_0,
|
||||
{
|
||||
top: '75%',
|
||||
backgroundImage:
|
||||
'linear-gradient(to bottom, transparent, rgba(0, 0, 0, 0.75))',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Box
|
||||
cx={[
|
||||
a.absolute,
|
||||
a.rounded_xs,
|
||||
a.px_xs,
|
||||
a.py_2xs,
|
||||
{
|
||||
backgroundColor: t.palette.contrast_25,
|
||||
bottom: a.p_md.padding,
|
||||
right: a.p_md.padding,
|
||||
},
|
||||
]}>
|
||||
<Text cx={[t.atoms.text, a.font_heavy, a.text_sm]}>GIF</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<LinkCard image={image} title={title} description={description} uri={uri} />
|
||||
)
|
||||
}
|
||||
|
||||
export function VideoEmbed({
|
||||
embed,
|
||||
...rest
|
||||
}: CommonProps & {
|
||||
embed: EmbedType<'video'>
|
||||
}) {
|
||||
const {thumbnail} = embed.view
|
||||
const modui = rest.moderation.ui('contentMedia')
|
||||
const info = getModerationCauseInfo({
|
||||
cause: modui.blurs.at(0),
|
||||
moderatorData: rest.moderatorData,
|
||||
})
|
||||
|
||||
if (info) {
|
||||
return <ModeratedEmbed info={info} />
|
||||
}
|
||||
|
||||
const img = rest.data.images.get(thumbnail)
|
||||
if (img) {
|
||||
return (
|
||||
<Box cx={[a.relative, a.rounded_sm, a.w_full, a.overflow_hidden]}>
|
||||
<Image image={img} />
|
||||
<Box
|
||||
cx={[
|
||||
a.absolute,
|
||||
a.inset_0,
|
||||
a.rounded_full,
|
||||
{
|
||||
margin: 'auto',
|
||||
width: '48px',
|
||||
height: '48px',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
},
|
||||
]}>
|
||||
<Box
|
||||
cx={[
|
||||
a.align_center,
|
||||
a.justify_center,
|
||||
a.absolute,
|
||||
a.inset_0,
|
||||
a.rounded_full,
|
||||
{
|
||||
margin: 'auto',
|
||||
width: '48px',
|
||||
height: '48px',
|
||||
backgroundColor: t.palette.contrast_25,
|
||||
opacity: 0.7,
|
||||
},
|
||||
]}>
|
||||
<Play size={32} />
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function ImagesEmbed({
|
||||
embed,
|
||||
...rest
|
||||
}: CommonProps & {
|
||||
embed: EmbedType<'images'>
|
||||
}) {
|
||||
const {images} = embed.view
|
||||
const gutter = a.p_2xs.padding
|
||||
const {images} = embed.view
|
||||
const modui = rest.moderation.ui('contentMedia')
|
||||
const info = getModerationCauseInfo({
|
||||
cause: modui.blurs.at(0),
|
||||
moderatorData: rest.moderatorData,
|
||||
})
|
||||
|
||||
if (info) {
|
||||
return <ModeratedEmbed info={info} />
|
||||
}
|
||||
|
||||
if (images.length > 0) {
|
||||
const imgs = images
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import React from 'react'
|
||||
|
||||
import {IconProps} from './types.js'
|
||||
|
||||
export function PlayFilled({size, fill}: IconProps) {
|
||||
return (
|
||||
<svg fill="none" viewBox="0 0 24 24" width={size} height={size}>
|
||||
<path
|
||||
fill={fill}
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M6.514 2.143A1 1 0 0 0 5 3v18a1 1 0 0 0 1.514.858l15-9a1 1 0 0 0 0-1.716l-15-9Z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -5,8 +5,13 @@ export async function getImage(url: string) {
|
||||
const mimeType = new MIMEType(response.headers.get('content-type') ?? '')
|
||||
const arrayBuf = await response.arrayBuffer() // must drain body even if it will be discarded
|
||||
if (response.status !== 200) return null
|
||||
// TODO suss
|
||||
const overwriteOctectStream =
|
||||
url.endsWith('.jpg') && mimeType.essence === 'application/octet-stream'
|
||||
return {
|
||||
mime: mimeType.essence as string | undefined,
|
||||
mime: overwriteOctectStream
|
||||
? 'image/jpeg'
|
||||
: (mimeType.essence as string | undefined),
|
||||
image: Buffer.from(arrayBuf),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {Vibrant} from 'node-vibrant/node'
|
||||
import {httpLogger} from '../logger.js'
|
||||
import {getStarterPackImageUri} from '../util/getStarterPackImageUri.js'
|
||||
import {Embed, parseEmbed} from '../util/parseEmbed.js'
|
||||
import {parseEmbedPlayerFromUrl} from '../util/parseEmbedPlayerFromUrl.js'
|
||||
import {getImage} from './getImage.js'
|
||||
|
||||
export type Metadata = {
|
||||
@@ -61,6 +62,22 @@ export function getEmbedData(embed: Embed, images: Map<string, Metadata>) {
|
||||
},
|
||||
})
|
||||
}
|
||||
const player = parseEmbedPlayerFromUrl(embed.view.external.uri)
|
||||
if (player.type === 'giphy_gif') {
|
||||
images.set(player.playerUri, {
|
||||
aspectRatio: {
|
||||
width: 1000,
|
||||
height: 1000,
|
||||
},
|
||||
})
|
||||
} else if (player.type === 'tenor_gif') {
|
||||
images.set(player.playerUri, {
|
||||
aspectRatio: {
|
||||
width: 1000,
|
||||
height: 1000,
|
||||
},
|
||||
})
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'video': {
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
export const embedPlayerSources = ['giphy', 'tenor'] as const
|
||||
|
||||
export type EmbedPlayerSource = (typeof embedPlayerSources)[number]
|
||||
|
||||
export type EmbedPlayerType = 'giphy_gif' | 'tenor_gif'
|
||||
|
||||
export const externalEmbedLabels: Record<EmbedPlayerSource, string> = {
|
||||
giphy: 'GIPHY',
|
||||
tenor: 'Tenor',
|
||||
}
|
||||
|
||||
export interface EmbedPlayerParams {
|
||||
type: EmbedPlayerType
|
||||
playerUri: string
|
||||
isGif?: boolean
|
||||
source: EmbedPlayerSource
|
||||
metaUri?: string
|
||||
hideDetails?: boolean
|
||||
dimensions?: {
|
||||
height: number
|
||||
width: number
|
||||
}
|
||||
}
|
||||
|
||||
const giphyRegex = /media(?:[0-4]\.giphy\.com|\.giphy\.com)/i
|
||||
const gifFilenameRegex = /^(\S+)\.(webp|gif|mp4)$/i
|
||||
|
||||
export function parseEmbedPlayerFromUrl(
|
||||
url: string,
|
||||
): EmbedPlayerParams | undefined {
|
||||
let urlp
|
||||
try {
|
||||
urlp = new URL(url)
|
||||
} catch (e) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (urlp.hostname === 'giphy.com' || urlp.hostname === 'www.giphy.com') {
|
||||
const [_, gifs, nameAndId] = urlp.pathname.split('/')
|
||||
|
||||
/*
|
||||
* nameAndId is a string that consists of the name (dash separated) and the id of the gif (the last part of the name)
|
||||
* We want to get the id of the gif, then direct to media.giphy.com/media/{id}/giphy.webp so we can
|
||||
* use it in an <Image> component
|
||||
*/
|
||||
|
||||
if (gifs === 'gifs' && nameAndId) {
|
||||
const gifId = nameAndId.split('-').pop()
|
||||
|
||||
if (gifId) {
|
||||
return {
|
||||
type: 'giphy_gif',
|
||||
source: 'giphy',
|
||||
isGif: true,
|
||||
hideDetails: true,
|
||||
metaUri: `https://giphy.com/gifs/${gifId}`,
|
||||
playerUri: `https://i.giphy.com/media/${gifId}/200.webp`,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// There are five possible hostnames that also can be giphy urls: media.giphy.com and media0-4.giphy.com
|
||||
// These can include (presumably) a tracking id in the path name, so we have to check for that as well
|
||||
if (giphyRegex.test(urlp.hostname)) {
|
||||
// We can link directly to the gif, if its a proper link
|
||||
const [_, media, trackingOrId, idOrFilename, filename] =
|
||||
urlp.pathname.split('/')
|
||||
|
||||
if (media === 'media') {
|
||||
if (idOrFilename && gifFilenameRegex.test(idOrFilename)) {
|
||||
return {
|
||||
type: 'giphy_gif',
|
||||
source: 'giphy',
|
||||
isGif: true,
|
||||
hideDetails: true,
|
||||
metaUri: `https://giphy.com/gifs/${trackingOrId}`,
|
||||
playerUri: `https://i.giphy.com/media/${trackingOrId}/200.webp`,
|
||||
}
|
||||
} else if (filename && gifFilenameRegex.test(filename)) {
|
||||
return {
|
||||
type: 'giphy_gif',
|
||||
source: 'giphy',
|
||||
isGif: true,
|
||||
hideDetails: true,
|
||||
metaUri: `https://giphy.com/gifs/${idOrFilename}`,
|
||||
playerUri: `https://i.giphy.com/media/${idOrFilename}/200.webp`,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Finally, we should see if it is a link to i.giphy.com. These links don't necessarily end in .gif but can also
|
||||
// be .webp
|
||||
if (urlp.hostname === 'i.giphy.com' || urlp.hostname === 'www.i.giphy.com') {
|
||||
const [_, mediaOrFilename, filename] = urlp.pathname.split('/')
|
||||
|
||||
if (mediaOrFilename === 'media' && filename) {
|
||||
const gifId = filename.split('.')[0]
|
||||
return {
|
||||
type: 'giphy_gif',
|
||||
source: 'giphy',
|
||||
isGif: true,
|
||||
hideDetails: true,
|
||||
metaUri: `https://giphy.com/gifs/${gifId}`,
|
||||
playerUri: `https://i.giphy.com/media/${gifId}/200.webp`,
|
||||
}
|
||||
} else if (mediaOrFilename) {
|
||||
const gifId = mediaOrFilename.split('.')[0]
|
||||
return {
|
||||
type: 'giphy_gif',
|
||||
source: 'giphy',
|
||||
isGif: true,
|
||||
hideDetails: true,
|
||||
metaUri: `https://giphy.com/gifs/${gifId}`,
|
||||
playerUri: `https://i.giphy.com/media/${
|
||||
mediaOrFilename.split('.')[0]
|
||||
}/200.webp`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const tenorGif = parseTenorGif(urlp)
|
||||
if (tenorGif.success) {
|
||||
const {playerUri, dimensions} = tenorGif
|
||||
|
||||
return {
|
||||
type: 'tenor_gif',
|
||||
source: 'tenor',
|
||||
isGif: true,
|
||||
hideDetails: true,
|
||||
playerUri,
|
||||
dimensions,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getGifDims(
|
||||
originalHeight: number,
|
||||
originalWidth: number,
|
||||
viewWidth: number,
|
||||
) {
|
||||
const scaledHeight = (originalHeight / originalWidth) * viewWidth
|
||||
|
||||
return {
|
||||
height: scaledHeight > 250 ? 250 : scaledHeight,
|
||||
width: (250 / scaledHeight) * viewWidth,
|
||||
}
|
||||
}
|
||||
|
||||
export function getGiphyMetaUri(url: URL) {
|
||||
if (giphyRegex.test(url.hostname) || url.hostname === 'i.giphy.com') {
|
||||
const params = parseEmbedPlayerFromUrl(url.toString())
|
||||
if (params && params.type === 'giphy_gif') {
|
||||
return params.metaUri
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function parseTenorGif(urlp: URL):
|
||||
| {success: false}
|
||||
| {
|
||||
success: true
|
||||
playerUri: string
|
||||
dimensions: {height: number; width: number}
|
||||
} {
|
||||
if (urlp.hostname !== 'media.tenor.com') {
|
||||
return {success: false}
|
||||
}
|
||||
|
||||
let [_, id, filename] = urlp.pathname.split('/')
|
||||
|
||||
if (!id || !filename) {
|
||||
return {success: false}
|
||||
}
|
||||
|
||||
if (!id.includes('AAAAC')) {
|
||||
return {success: false}
|
||||
}
|
||||
|
||||
const h = urlp.searchParams.get('hh')
|
||||
const w = urlp.searchParams.get('ww')
|
||||
|
||||
if (!h || !w) {
|
||||
return {success: false}
|
||||
}
|
||||
|
||||
const dimensions = {
|
||||
height: Number(h),
|
||||
width: Number(w),
|
||||
}
|
||||
|
||||
// if (isWeb) {
|
||||
// id = id.replace('AAAAC', 'AAAP3')
|
||||
// filename = filename.replace('.gif', '.webm')
|
||||
// } else {
|
||||
id = id.replace('AAAAC', 'AAAAM')
|
||||
// }
|
||||
|
||||
return {
|
||||
success: true,
|
||||
playerUri: `https://t.gifs.bsky.app/${id}/${filename}`,
|
||||
dimensions,
|
||||
}
|
||||
}
|
||||
|
||||
export function isTenorGifUri(url: URL | string) {
|
||||
try {
|
||||
return parseTenorGif(typeof url === 'string' ? new URL(url) : url).success
|
||||
} catch {
|
||||
// Invalid URL
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://unpkg.com/svbstrate@5.1.0/svbstrate.css" />
|
||||
<style>
|
||||
img {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="p12">
|
||||
<div
|
||||
class="mxa w f"
|
||||
style="max-width: 400px; flex-direction: column; gap: 1rem">
|
||||
|
||||
<h1>Base embeds</h1>
|
||||
<h3>Image</h3>
|
||||
<img src="http://localhost:3000/profile/test-all-embeds-a.bsky.social/post/3lkch7rnmqs23?mat=1" />
|
||||
<h3>Image w/ label</h3>
|
||||
<img src="http://localhost:3000/profile/test-all-embeds-a.bsky.social/post/3lkchac5lbs23?mat=1" />
|
||||
<h3>Video</h3>
|
||||
<img src="http://localhost:3000/profile/test-all-embeds-a.bsky.social/post/3lkchd5laes23?mat=1" />
|
||||
<h3>Video w/ label</h3>
|
||||
<img src="http://localhost:3000/profile/test-all-embeds-a.bsky.social/post/3lkchdnqmzc23?mat=1" />
|
||||
<h3>GIF</h3>
|
||||
<img src="http://localhost:3000/profile/test-all-embeds-a.bsky.social/post/3lkchef5c2c23?mat=1" />
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user