init commit,
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
import React from 'https://esm.sh/react@18.2.0?deno-std=0.140.0'
|
||||
import type { FormattedTweet } from './types.ts'
|
||||
|
||||
/**
|
||||
* Supports plain text, images, quote tweets.
|
||||
*
|
||||
* Needs support for images, GIFs, and replies maybe?
|
||||
* Styles use !important to override Tailwind .prose inside MDX.
|
||||
*/
|
||||
export default function Tweet({
|
||||
id,
|
||||
text,
|
||||
author,
|
||||
media,
|
||||
created_at,
|
||||
public_metrics,
|
||||
referenced_tweets,
|
||||
}: FormattedTweet) {
|
||||
const authorUrl = `https://twitter.com/${author.username}`
|
||||
const likeUrl = `https://twitter.com/intent/like?tweet_id=${id}`
|
||||
const retweetUrl = `https://twitter.com/intent/retweet?tweet_id=${id}`
|
||||
const replyUrl = `https://twitter.com/intent/tweet?in_reply_to=${id}`
|
||||
const tweetUrl = `https://twitter.com/${author.username}/status/${id}`
|
||||
const createdAt = new Date(created_at)
|
||||
|
||||
const formattedText = text.replace(/https:\/\/[\n\S]+/g, '').replace('&', '&')
|
||||
const quoteTweet = referenced_tweets && referenced_tweets.find((t) => t.type === 'quoted')
|
||||
|
||||
return (
|
||||
<div tw="flex">
|
||||
<div tw="flex flex-col md:flex-row w-full py-12 px-4 md:items-center justify-between p-8">
|
||||
<h2 tw="flex flex-col text-3xl sm:text-4xl font-bold tracking-tight text-left">
|
||||
<span>{formattedText}</span>
|
||||
<span tw="text-gray-300">{`- @${author.username}`}</span>
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
@@ -0,0 +1,61 @@
|
||||
import type { TwitterApiResponse, Tweet, FormattedTweet } from './types.ts'
|
||||
|
||||
export const getTweets: (ids: string[]) => Promise<FormattedTweet[]> = async (ids: string[]) => {
|
||||
if (ids.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const queryParams = new URLSearchParams({
|
||||
ids: ids.join(','),
|
||||
expansions:
|
||||
'author_id,attachments.media_keys,referenced_tweets.id,referenced_tweets.id.author_id',
|
||||
'tweet.fields':
|
||||
'attachments,author_id,public_metrics,created_at,id,in_reply_to_user_id,referenced_tweets,text',
|
||||
'user.fields': 'id,name,profile_image_url,protected,url,username,verified',
|
||||
'media.fields': 'duration_ms,height,media_key,preview_image_url,type,url,width,public_metrics',
|
||||
})
|
||||
|
||||
const response = await fetch(`https://api.twitter.com/2/tweets?${queryParams}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${Deno.env.get('TWITTER_API_TOKEN')}`,
|
||||
},
|
||||
})
|
||||
|
||||
const tweets: TwitterApiResponse = await response.json()
|
||||
|
||||
const getAuthorInfo = (author_id: string) => {
|
||||
return tweets.includes.users.find((user) => user.id === author_id)
|
||||
}
|
||||
|
||||
const getReferencedTweets = (mainTweet: Tweet) => {
|
||||
return (
|
||||
mainTweet?.referenced_tweets?.map((referencedTweet) => {
|
||||
const fullReferencedTweet = tweets.includes.tweets.find(
|
||||
(tweet) => tweet.id === referencedTweet.id
|
||||
)
|
||||
|
||||
return {
|
||||
type: referencedTweet.type,
|
||||
author: getAuthorInfo(fullReferencedTweet!.author_id),
|
||||
...fullReferencedTweet,
|
||||
}
|
||||
}) || []
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
tweets.data.reduce<any>((allTweets, tweet) => {
|
||||
const tweetWithAuthor = {
|
||||
...tweet,
|
||||
media:
|
||||
tweet?.attachments?.media_keys.map((key: string) =>
|
||||
tweets.includes.media.find((media) => media.media_key === key)
|
||||
) || [],
|
||||
referenced_tweets: getReferencedTweets(tweet),
|
||||
author: getAuthorInfo(tweet.author_id),
|
||||
}
|
||||
|
||||
return [tweetWithAuthor, ...allTweets]
|
||||
}, []) || [] // If the Twitter API key isn't set, don't break the build
|
||||
)
|
||||
}
|
@@ -0,0 +1,93 @@
|
||||
import React from 'https://esm.sh/react@18.2.0?deno-std=0.140.0'
|
||||
import { ImageResponse } from 'https://deno.land/x/og_edge@0.0.4/mod.ts'
|
||||
import { createClient } from 'jsr:@supabase/supabase-js@2'
|
||||
import { corsHeaders } from '../_shared/cors.ts'
|
||||
import { getTweets } from './getTweet.ts'
|
||||
import Tweet from './Tweet.tsx'
|
||||
|
||||
const STORAGE_URL =
|
||||
'https://obuldanrptloktxcffvn.supabase.co/storage/v1/object/public/images/tweet-to-image'
|
||||
|
||||
// Load custom font
|
||||
const FONT_URL = `${STORAGE_URL}/CircularStd-Book.otf`
|
||||
const font = fetch(new URL(FONT_URL, import.meta.url)).then((res) => res.arrayBuffer())
|
||||
|
||||
export async function handler(req: Request) {
|
||||
const url = new URL(req.url)
|
||||
const tweetId = url.searchParams.get('tweetId')
|
||||
|
||||
if (!tweetId)
|
||||
return new Response(JSON.stringify({ error: 'missing tweetId param' }), {
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
status: 400,
|
||||
})
|
||||
|
||||
try {
|
||||
// Try to get image from Supabase Storage CDN.
|
||||
const storageResponse = await fetch(`${STORAGE_URL}/${tweetId}.png`)
|
||||
if (storageResponse.ok) return storageResponse
|
||||
|
||||
// Else, generate image and upload to storage.
|
||||
const fontData = await font
|
||||
|
||||
const tweets = await getTweets([tweetId])
|
||||
const tweet = tweets[0]
|
||||
console.log('formattedTweets', JSON.stringify(tweets, null, 2))
|
||||
const generatedImage = new ImageResponse(
|
||||
(
|
||||
<div
|
||||
style={{
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: '#1c1c1c',
|
||||
color: '#EDEDED',
|
||||
}}
|
||||
>
|
||||
<Tweet {...tweet} />
|
||||
</div>
|
||||
),
|
||||
{
|
||||
width: 1200,
|
||||
height: 630,
|
||||
// Supported options: 'twemoji', 'blobmoji', 'noto', 'openmoji', 'fluent', 'fluentFlat'
|
||||
// Default to 'twemoji'
|
||||
emoji: 'twemoji',
|
||||
fonts: [
|
||||
{
|
||||
name: 'Circular',
|
||||
data: fontData,
|
||||
style: 'normal',
|
||||
},
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
const supabaseAdminClient = createClient(
|
||||
// Supabase API URL - env var exported by default when deployed.
|
||||
Deno.env.get('SUPABASE_URL') ?? '',
|
||||
// Supabase API SERVICE ROLE KEY - env var exported by default when deployed.
|
||||
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? ''
|
||||
)
|
||||
|
||||
// Upload image to storage.
|
||||
const { error } = await supabaseAdminClient.storage
|
||||
.from('images')
|
||||
.upload(`tweet-to-image/${tweetId}.png`, generatedImage.body!, {
|
||||
contentType: 'image/png',
|
||||
cacheControl: '31536000',
|
||||
upsert: false,
|
||||
})
|
||||
if (error) throw error
|
||||
|
||||
return generatedImage
|
||||
} catch (error) {
|
||||
return new Response(JSON.stringify({ error: error.message }), {
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
status: 400,
|
||||
})
|
||||
}
|
||||
}
|
@@ -0,0 +1,9 @@
|
||||
// Follow this setup guide to integrate the Deno language server with your editor:
|
||||
// https://deno.land/manual/getting_started/setup_your_environment
|
||||
// This enables autocomplete, go to definition, etc.
|
||||
|
||||
import { handler } from './handler.tsx'
|
||||
|
||||
console.log(`Function "tweet-to-image" up and running!`)
|
||||
|
||||
Deno.serve(handler)
|
@@ -0,0 +1,74 @@
|
||||
export type Tweet = {
|
||||
edit_history_tweet_ids: string[]
|
||||
text: string
|
||||
referenced_tweets:
|
||||
| Array<{
|
||||
type: string
|
||||
id: string
|
||||
}>
|
||||
| undefined
|
||||
attachments: {
|
||||
media_keys: string[]
|
||||
}
|
||||
id: string
|
||||
created_at: string
|
||||
public_metrics: {
|
||||
retweet_count: number
|
||||
reply_count: number
|
||||
like_count: number
|
||||
quote_count: number
|
||||
impression_count: number
|
||||
}
|
||||
author_id: string
|
||||
author?: User
|
||||
}
|
||||
|
||||
export type Media = {
|
||||
width: number
|
||||
media_key: string
|
||||
type: string
|
||||
url: string
|
||||
height: number
|
||||
}
|
||||
|
||||
export type User = {
|
||||
name: string
|
||||
url: string
|
||||
verified: boolean
|
||||
profile_image_url: string
|
||||
id: string
|
||||
username: string
|
||||
protected: boolean
|
||||
}
|
||||
|
||||
// raw
|
||||
export type TwitterApiResponse = {
|
||||
data: Tweet[]
|
||||
includes: {
|
||||
media: Media[]
|
||||
users: User[]
|
||||
tweets: Tweet[]
|
||||
}
|
||||
}
|
||||
|
||||
export type FormattedTweet = {
|
||||
type: string
|
||||
edit_history_tweet_ids: string[]
|
||||
text: string
|
||||
referenced_tweets: FormattedTweet[]
|
||||
attachments: {
|
||||
media_keys: string[]
|
||||
}
|
||||
id: string
|
||||
created_at: string
|
||||
public_metrics: {
|
||||
retweet_count: number
|
||||
reply_count: number
|
||||
like_count: number
|
||||
quote_count: number
|
||||
impression_count: number
|
||||
}
|
||||
author_id: string
|
||||
media: Media[]
|
||||
author: User
|
||||
}
|
Reference in New Issue
Block a user