diff --git a/examples/edge-functions/supabase/functions/tweet-to-image/Tweet.tsx b/examples/edge-functions/supabase/functions/tweet-to-image/Tweet.tsx new file mode 100644 index 00000000000..969a2353e21 --- /dev/null +++ b/examples/edge-functions/supabase/functions/tweet-to-image/Tweet.tsx @@ -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 ( +
+
+

+ {formattedText} + {`- @${author.username}`} +

+
+
+ ) +} diff --git a/examples/edge-functions/supabase/functions/tweet-to-image/getTweet.ts b/examples/edge-functions/supabase/functions/tweet-to-image/getTweet.ts new file mode 100644 index 00000000000..00930cf31d5 --- /dev/null +++ b/examples/edge-functions/supabase/functions/tweet-to-image/getTweet.ts @@ -0,0 +1,61 @@ +import type { TwitterApiResponse, Tweet, FormattedTweet } from './types.ts' + +export const getTweets: (ids: string[]) => Promise = 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((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 + ) +} diff --git a/examples/edge-functions/supabase/functions/tweet-to-image/handler.tsx b/examples/edge-functions/supabase/functions/tweet-to-image/handler.tsx new file mode 100644 index 00000000000..c55060c77ec --- /dev/null +++ b/examples/edge-functions/supabase/functions/tweet-to-image/handler.tsx @@ -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 'https://esm.sh/@supabase/supabase-js@2.5.0' +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( + ( +
+ +
+ ), + { + 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, + }) + } +} diff --git a/examples/edge-functions/supabase/functions/tweet-to-image/index.ts b/examples/edge-functions/supabase/functions/tweet-to-image/index.ts new file mode 100644 index 00000000000..fe187018a89 --- /dev/null +++ b/examples/edge-functions/supabase/functions/tweet-to-image/index.ts @@ -0,0 +1,11 @@ +// 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 { serve } from 'https://deno.land/std@0.140.0/http/server.ts' + +import { handler } from './handler.tsx' + +console.log(`Function "tweet-to-image" up and running!`) + +serve(handler) diff --git a/examples/edge-functions/supabase/functions/tweet-to-image/types.ts b/examples/edge-functions/supabase/functions/tweet-to-image/types.ts new file mode 100644 index 00000000000..c1ab36294ed --- /dev/null +++ b/examples/edge-functions/supabase/functions/tweet-to-image/types.ts @@ -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 +}