diff --git a/bskyogcard/src/components/PostEmbed.tsx b/bskyogcard/src/components/PostEmbed.tsx
index 068f83f893..585394375a 100644
--- a/bskyogcard/src/components/PostEmbed.tsx
+++ b/bskyogcard/src/components/PostEmbed.tsx
@@ -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
}
case 'video': {
- return null
+ return
}
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 (
+
+
+
+
+ GIF
+
+
+ )
+ }
return (
)
}
+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
+ }
+
+ const img = rest.data.images.get(thumbnail)
+ if (img) {
+ return (
+
+
+
+
+
+
+
+
+ )
+ } 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
+ }
if (images.length > 0) {
const imgs = images
diff --git a/bskyogcard/src/components/icons/Play.tsx b/bskyogcard/src/components/icons/Play.tsx
new file mode 100644
index 0000000000..364e8ff5c6
--- /dev/null
+++ b/bskyogcard/src/components/icons/Play.tsx
@@ -0,0 +1,16 @@
+import React from 'react'
+
+import {IconProps} from './types.js'
+
+export function PlayFilled({size, fill}: IconProps) {
+ return (
+
+ )
+}
diff --git a/bskyogcard/src/data/getImage.ts b/bskyogcard/src/data/getImage.ts
index e58bf8e4da..b95cae3b9b 100644
--- a/bskyogcard/src/data/getImage.ts
+++ b/bskyogcard/src/data/getImage.ts
@@ -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),
}
}
diff --git a/bskyogcard/src/data/getPostData.ts b/bskyogcard/src/data/getPostData.ts
index 5087b48ea8..aa7e62e420 100644
--- a/bskyogcard/src/data/getPostData.ts
+++ b/bskyogcard/src/data/getPostData.ts
@@ -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) {
},
})
}
+ 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': {
diff --git a/bskyogcard/src/util/parseEmbedPlayerFromUrl.ts b/bskyogcard/src/util/parseEmbedPlayerFromUrl.ts
new file mode 100644
index 0000000000..f35220032f
--- /dev/null
+++ b/bskyogcard/src/util/parseEmbedPlayerFromUrl.ts
@@ -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 = {
+ 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 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
+ }
+}
diff --git a/bskyogcard/test.html b/bskyogcard/test.html
new file mode 100644
index 0000000000..61cee38258
--- /dev/null
+++ b/bskyogcard/test.html
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+
+
+
+
Base embeds
+
Image
+

+
Image w/ label
+

+
Video
+

+
Video w/ label
+

+
GIF
+

+
+
+