├── .gitignore ├── bun.lockb ├── public └── icon.png ├── lib ├── redis.ts ├── upthumb.ts └── ui.ts ├── README.md ├── package.json ├── tsconfig.json ├── LICENSE └── api └── index.tsx /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .env 3 | .vercel 4 | -------------------------------------------------------------------------------- /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/horsefacts/upthumbs/HEAD/bun.lockb -------------------------------------------------------------------------------- /public/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/horsefacts/upthumbs/HEAD/public/icon.png -------------------------------------------------------------------------------- /lib/redis.ts: -------------------------------------------------------------------------------- 1 | import { Redis } from "ioredis"; 2 | 3 | export const redis = new Redis( 4 | process.env.REDIS_URL ?? "redis://localhost:6379", 5 | ); 6 | 7 | export default redis; 8 | -------------------------------------------------------------------------------- /lib/upthumb.ts: -------------------------------------------------------------------------------- 1 | import redis from "./redis.js"; 2 | 3 | export async function upthumb(fid: number, username: string) { 4 | const id = fid.toString(); 5 | await redis.zincrby("upthumbs", 1, id); 6 | await redis.hset("usernames", id, username); 7 | } 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 👍 Upthumbs 2 | 3 | A Farcaster [Cast Actions](https://warpcast.notion.site/Frames-Cast-Actions-v1-84d5a85d479a43139ea883f6823d8caa) example app. 4 | 5 | 6 | ``` 7 | npm install 8 | npm run dev 9 | ``` 10 | 11 | Head to http://localhost:5173/api 12 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "upthumbs", 3 | "private": true, 4 | "type": "module", 5 | "scripts": { 6 | "build": "frog vercel-build", 7 | "dev": "frog dev", 8 | "deploy": "vercel" 9 | }, 10 | "dependencies": { 11 | "@neynar/nodejs-sdk": "^1.14.0", 12 | "frog": "latest", 13 | "hono": "^4", 14 | "ioredis": "^5.3.2" 15 | }, 16 | "devDependencies": { 17 | "tsx": "^4.7.1", 18 | "typescript": "^5.3.3", 19 | "vercel": "^32.4.1" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /lib/ui.ts: -------------------------------------------------------------------------------- 1 | import { createSystem } from "frog/ui"; 2 | 3 | export const { Box, Heading, Text, VStack, vars } = createSystem({ 4 | colors: { 5 | white: "white", 6 | black: "black", 7 | fcPurple: "rgb(138, 99, 210)" 8 | }, 9 | fonts: { 10 | default: [ 11 | { 12 | name: "Inter", 13 | source: "google", 14 | weight: 400, 15 | }, 16 | { 17 | name: "Inter", 18 | source: "google", 19 | weight: 600, 20 | }, 21 | ], 22 | }, 23 | }); 24 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "module": "NodeNext", 5 | "skipLibCheck": true, 6 | 7 | "moduleResolution": "NodeNext", 8 | "resolveJsonModule": true, 9 | "isolatedModules": true, 10 | "jsx": "react-jsx", 11 | "jsxImportSource": "frog/jsx", 12 | 13 | /* Linting */ 14 | "strict": true, 15 | "noUnusedLocals": true, 16 | "noUnusedParameters": true, 17 | "noFallthroughCasesInSwitch": true, 18 | 19 | "types": ["frog/client"] 20 | }, 21 | "include": ["**/*.ts", "**/*.tsx", "**/*.mtsx"] 22 | } 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2024 Merkle Manufactory and others 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /api/index.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Frog } from "frog"; 2 | import { devtools } from "frog/dev"; 3 | import { serveStatic } from "frog/serve-static"; 4 | import { neynar as neynarHub } from "frog/hubs"; 5 | import { neynar } from "frog/middlewares"; 6 | import { handle } from "frog/vercel"; 7 | import { CastParamType, NeynarAPIClient } from "@neynar/nodejs-sdk"; 8 | import { upthumb } from "../lib/upthumb.js"; 9 | import { Box, Heading, Text, VStack, vars } from "../lib/ui.js"; 10 | import redis from "../lib/redis.js"; 11 | 12 | const NEYNAR_API_KEY = process.env.NEYNAR_API_KEY ?? ""; 13 | const neynarClient = new NeynarAPIClient(NEYNAR_API_KEY); 14 | 15 | const ADD_URL = 16 | "https://warpcast.com/~/add-cast-action?url=https://upthumbs.app/api/upthumb"; 17 | 18 | export const app = new Frog({ 19 | assetsPath: "/", 20 | basePath: "/api", 21 | ui: { vars }, 22 | hub: neynarHub({ apiKey: NEYNAR_API_KEY }), 23 | browserLocation: ADD_URL, 24 | }).use( 25 | neynar({ 26 | apiKey: NEYNAR_API_KEY, 27 | features: ["interactor", "cast"], 28 | }) 29 | ); 30 | 31 | // Cast action GET handler 32 | app.hono.get("/upthumb", async (c) => { 33 | return c.json({ 34 | name: "Upthumb", 35 | icon: "thumbsup", 36 | description: "Give casts 'upthumbs' and see them on a leaderboard.", 37 | aboutUrl: "https://github.com/horsefacts/upthumbs", 38 | action: { 39 | type: "post", 40 | }, 41 | }); 42 | }); 43 | 44 | // Cast action POST handler 45 | app.hono.post("/upthumb", async (c) => { 46 | const { 47 | trustedData: { messageBytes }, 48 | } = await c.req.json(); 49 | 50 | const result = await neynarClient.validateFrameAction(messageBytes); 51 | if (result.valid) { 52 | const cast = await neynarClient.lookUpCastByHashOrWarpcastUrl( 53 | result.action.cast.hash, 54 | CastParamType.Hash 55 | ); 56 | const { 57 | cast: { 58 | author: { fid, username }, 59 | }, 60 | } = cast; 61 | if (result.action.interactor.fid === fid) { 62 | return c.json({ message: "Nice try." }, 400); 63 | } 64 | 65 | await upthumb(fid, username); 66 | 67 | let message = `You upthumbed ${username}`; 68 | if (message.length > 30) { 69 | message = "Upthumbed!"; 70 | } 71 | 72 | return c.json({ message, link: "https://warpcast.com/horsefacts.eth/0x09d647a9" }); 73 | } else { 74 | return c.json({ message: "Unauthorized" }, 401); 75 | } 76 | }); 77 | 78 | // Frame handlers 79 | app.frame("/", (c) => { 80 | return c.res({ 81 | image: ( 82 | 89 | 90 | 91 | Upthumbs 👍 92 | 93 | 94 | 95 | ), 96 | intents: [ 97 | Add Action, 98 | , 101 | , 104 | ], 105 | }); 106 | }); 107 | 108 | app.frame("/leaderboard", async (c) => { 109 | const leaders = await redis.zrevrange("upthumbs", 0, 3, "WITHSCORES"); 110 | const [firstFid, firstScore, secondFid, secondScore, thirdFid, thirdScore] = 111 | leaders; 112 | 113 | const firstName = await redis.hget("usernames", firstFid); 114 | const secondName = await redis.hget("usernames", secondFid); 115 | const thirdName = await redis.hget("usernames", thirdFid); 116 | 117 | return c.res({ 118 | image: ( 119 | 126 | 127 | 128 | Leaderboard 129 | 130 | 131 | 132 | 🥇 {firstName}: {firstScore} 133 | 134 | 135 | 🥈 {secondName}: {secondScore} 136 | 137 | 138 | 🥉 {thirdName}: {thirdScore} 139 | 140 | 141 | 142 | 143 | ), 144 | intents: [⬅️ Back], 145 | }); 146 | }); 147 | 148 | app.frame("/upthumbs", async (c) => { 149 | const fid = c.var.interactor?.fid ?? 0; 150 | let upthumbs = "0"; 151 | try { 152 | upthumbs = (await redis.zscore("upthumbs", fid)) ?? "0"; 153 | } catch (e) {} 154 | 155 | return c.res({ 156 | image: ( 157 | 164 | 165 | 166 | Your Upthumbs: 167 | 168 | 169 | {upthumbs} 170 | 171 | 172 | 173 | ), 174 | intents: [⬅️ Back], 175 | }); 176 | }); 177 | 178 | // @ts-ignore 179 | const isEdgeFunction = typeof EdgeFunction !== "undefined"; 180 | const isProduction = isEdgeFunction || import.meta.env?.MODE !== "development"; 181 | devtools(app, isProduction ? { assetsPath: "/.frog" } : { serveStatic }); 182 | 183 | export const GET = handle(app); 184 | export const POST = handle(app); 185 | --------------------------------------------------------------------------------