diff --git a/.changeset/brown-baboons-complain.md b/.changeset/brown-baboons-complain.md new file mode 100644 index 00000000..70f87832 --- /dev/null +++ b/.changeset/brown-baboons-complain.md @@ -0,0 +1,5 @@ +--- +"@mod-protocol/react-ui-shadcn": patch +--- + +feat: add support for action mod types diff --git a/.changeset/little-suits-brush.md b/.changeset/little-suits-brush.md new file mode 100644 index 00000000..276b994a --- /dev/null +++ b/.changeset/little-suits-brush.md @@ -0,0 +1,5 @@ +--- +"@mod-protocol/mod-registry": minor +--- + +feat: add `tip-eth` mod diff --git a/.changeset/tricky-hairs-confess.md b/.changeset/tricky-hairs-confess.md new file mode 100644 index 00000000..25c492e6 --- /dev/null +++ b/.changeset/tricky-hairs-confess.md @@ -0,0 +1,5 @@ +--- +"@mod-protocol/core": patch +--- + +feat: add action mod type diff --git a/docs/pages/create-mod/getting-started.mdx b/docs/pages/create-mod/getting-started.mdx index 668ee714..da68fc2e 100644 --- a/docs/pages/create-mod/getting-started.mdx +++ b/docs/pages/create-mod/getting-started.mdx @@ -1,6 +1,6 @@ # Getting started making a Mod -A Mod consists of a manifest JSON file and optionally a backend which can handle requests from the Mod and make external requests. +A Mod consists of a manifest JSON file and optionally a backend which can handle requests from the Mod and make external requests. The Manifest contains metadata about the Mod, such as it's name and unique identifier, which Mod Elements it renders onto the page, and conditions under which to render them. Our convention is to write Mods in TypeScript, for better autocompletion and legibility, with the Mods being built to JSON. @@ -26,8 +26,10 @@ There's a couple reasons, depending on what your goals are: ## Types of Mods -There's currently two types of Mods supported: +There's currently three types of Mods supported: + 1. Rich-embed Mods 2. Creation Mods +3. Action Mods -We're planning to support more types of Mods in the near future, including Action Mods and Full screen Mods. \ No newline at end of file +We're planning to support more types of Mods in the near future. diff --git a/docs/pages/create-mod/reference.mdx b/docs/pages/create-mod/reference.mdx index 45d17ce1..996b66a4 100644 --- a/docs/pages/create-mod/reference.mdx +++ b/docs/pages/create-mod/reference.mdx @@ -38,6 +38,8 @@ type ModManifest = { creationEntrypoints?: ModElement[]; /** Interface this Mod exposes, if any, for Content Rendering */ richEmbedEntrypoints?: ModConditionalElement[]; + /** Interface this Mod exposes, if any, for Action Execution */ + actionEntrypoints?: ModElement[]; /** A definition map of reusable elements, using their id as the key */ elements?: Record; /** Permissions requested by the Mod */ @@ -52,6 +54,7 @@ export type ModElement = | { type: "text"; label: string; + variant?: "bold" | "secondary" | "regular"; } | { type: "image"; @@ -60,13 +63,14 @@ export type ModElement = | { type: "link"; label: string; + onclick?: ModEvent; variant?: "link" | "primary" | "secondary" | "destructive"; url: string; - onclick?: ModEvent; } | { type: "button"; label: string; + loadingLabel?: string; variant?: "primary" | "secondary" | "destructive"; onclick: ModEvent; } @@ -84,35 +88,23 @@ export type ModElement = onload?: ModEvent; } | { + type: "textarea"; ref?: string; - type: "select"; - options: Array<{ label: string; value: any }>; placeholder?: string; - isClearable?: boolean; onchange?: ModEvent; onsubmit?: ModEvent; } | { - type: "combobox"; - ref?: string; - isClearable?: boolean; - placeholder?: string; - optionsRef?: string; - valueRef?: string; - onload?: ModEvent; - onpick?: ModEvent; - onchange?: ModEvent; - } - | { + type: "select"; + options: Array<{ label: string; value: any }>; ref?: string; - type: "textarea"; placeholder?: string; + isClearable?: boolean; onchange?: ModEvent; - onsubmit?: ModEvent; } | { - ref?: string; type: "input"; + ref?: string; placeholder?: string; isClearable?: boolean; onchange?: ModEvent; @@ -123,16 +115,27 @@ export type ModElement = videoSrc: string; } | { - ref?: string; type: "tabs"; + ref?: string; values: string[]; names: string[]; onload?: ModEvent; onchange?: ModEvent; } - | ({ + | { + type: "combobox"; ref?: string; + isClearable?: boolean; + placeholder?: string; + optionsRef?: string; + valueRef?: string; + onload?: ModEvent; + onpick?: ModEvent; + onchange?: ModEvent; + } + | ({ type: "image-grid-list"; + ref?: string; onload?: ModEvent; onpick?: ModEvent; } & ( diff --git a/docs/pages/integrate/creation.mdx b/docs/pages/integrate/creation.mdx index 97a08708..158f55af 100644 --- a/docs/pages/integrate/creation.mdx +++ b/docs/pages/integrate/creation.mdx @@ -29,7 +29,7 @@ import { CreationMod } from "@mod-protocol/react"; /> ``` -if you want to support the user choosing between Mods, you can give them a UI to select a Mod, or use our component. +If you want to support the user choosing between Mods, you can give them a UI to select a Mod, or use our component. ## Full example with the Mod Editor diff --git a/docs/pages/integrate/getting-started.mdx b/docs/pages/integrate/getting-started.mdx index 91fb27c4..02de9b99 100644 --- a/docs/pages/integrate/getting-started.mdx +++ b/docs/pages/integrate/getting-started.mdx @@ -8,13 +8,15 @@ You can integrate as much or as little of Mod as you'd like, as Mod supports pro You also have free choice of which Mods you want to support in your App. You can pick and choose from our open source ones, or even build and bring your own. -There are two kinds of Mods currently: +There are three kinds of Mods currently: 1. [Creation Mods](creation.mdx): Enable users to use Mods when creating a post, such as Mods for adding Gifs, Images, Videos, Polls or using AI. You can integrate these with or without the Mod Editor, which is a great Farcaster cast creator with batteries included. 2. [Rich-embed Mods](rich-embeds.mdx): Turn urls into rich embeds, with a fallback to an open graph style card embed. These enable Images, Videos, Polls, Games, Minting NFTs, or any other mini-interaction to happen directly in the interface. You can integrate these with or without the Mod Metadata Cache. +3. [Post Action Mods](post-actions.mdx): Enable users to use Mods when interacting with posts, such as Mods for tipping, sharing, traslating or other mini-interactions that require the context of the post such as its author and contents. + You can integrate these with or without the Mod Metadata Cache. ## Support @@ -22,4 +24,4 @@ Mod currently only has SDKs for React, and we're working on adding React-native ## Boilerplate starter -Fork the [Mod-starter repo](https://github.com/mod-protocol/mod-starter) to get started \ No newline at end of file +Fork the [Mod-starter repo](https://github.com/mod-protocol/mod-starter) to get started diff --git a/docs/pages/integrate/post-actions.mdx b/docs/pages/integrate/post-actions.mdx new file mode 100644 index 00000000..cc3d10cd --- /dev/null +++ b/docs/pages/integrate/post-actions.mdx @@ -0,0 +1,153 @@ +import Image from "next/image"; + +# Post Action Mods + +Post Action Mods enable users to use Mods when interacting with posts, such as Mods for tipping, sharing, traslating or other mini-interactions that require the context of the post such as its author and contents. + +Here's an example of where post action Mods will typically be used: + +Post Action Mod example + +You can integrate Post Action Mods with or without our [Mod Metadata Cache](../metadata-cache.mdx). + +## Integration Example + +```tsx +import { ActionMod } from "@mod-protocol/react"; + +... + + +``` + +## Full Example with Mod Search + +```tsx +import { Embed, ModManifest, handleOpenFile } from "@mod-protocol/core"; +import { actionMods, actionModsExperimental } from "@mod-protocol/mod-registry"; +import { ActionMod } from "@mod-protocol/react"; +import { ModsSearch } from "@mod-protocol/react-ui-shadcn/dist/components/creation-mods-search"; +import { Button } from "@mod-protocol/react-ui-shadcn/dist/components/ui/button"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@mod-protocol/react-ui-shadcn/dist/components/ui/popover"; +import { renderers } from "@mod-protocol/react-ui-shadcn/dist/renderers"; +import { KebabHorizontalIcon } from "@primer/octicons-react"; +import React, { useMemo } from "react"; +import { getAddress } from "viem"; +import { useAccount } from "wagmi"; +import { API_URL } from "./constants"; +import { useExperimentalMods } from "./use-experimental-mods"; +import { sendEthTransaction } from "./utils"; + +export function Actions({ + author, + post, +}: { + author: { + farcaster: { + fid: string; + }; + }; + post: { + id: string; + text: string; + embeds: Embed[]; + }; +}) { + const experimentalMods = useExperimentalMods(); + const [currentMod, setCurrentMod] = React.useState(null); + + const { address: unchecksummedAddress } = useAccount(); + const checksummedAddress = React.useMemo(() => { + if (!unchecksummedAddress) return null; + return getAddress(unchecksummedAddress); + }, [unchecksummedAddress]); + const user = React.useMemo(() => { + return { + wallet: { + address: checksummedAddress, + }, + }; + }, [checksummedAddress]); + + const onSendEthTransactionAction = useMemo(() => sendEthTransaction, []); + + return ( + { + if (!op) setCurrentMod(null); + }} + > + + + + + +
+

{currentMod?.name}

+
+ setCurrentMod(null)} + onSendEthTransactionAction={onSendEthTransactionAction} + author={author} + post={{ + text: post.text, + embeds: post.embeds, + }} + /> +
+
+
+ ); +} +``` + +This component can then be included in your post to allow it to be invoked by the user: + +```tsx +... +
+ +
+... +``` diff --git a/docs/public/post-action.png b/docs/public/post-action.png new file mode 100644 index 00000000..7b5e7e90 Binary files /dev/null and b/docs/public/post-action.png differ diff --git a/examples/api/.env.example b/examples/api/.env.example index 11526295..22537ee0 100644 --- a/examples/api/.env.example +++ b/examples/api/.env.example @@ -13,3 +13,4 @@ PRIVATE_KEY="REQUIRED" CHATGPT_ORGANIZATION_ID="REQUIRED" NFT_STORAGE_API_KEY="REQUIRED" ZORA_ADMIN_PRIVATE_KEY="REQUIRED" +HUB_HTTP_ENDPOINT="REQUIRED" \ No newline at end of file diff --git a/examples/api/src/app/api/hello-world/route.ts b/examples/api/src/app/api/hello-world/route.ts new file mode 100644 index 00000000..8b9006d0 --- /dev/null +++ b/examples/api/src/app/api/hello-world/route.ts @@ -0,0 +1,7 @@ +import { NextRequest, NextResponse } from "next/server"; + +export async function GET(request: NextRequest) { + return NextResponse.json({ + data: "pong", + }); +} diff --git a/examples/api/src/app/api/tip-eth/lib/util.ts b/examples/api/src/app/api/tip-eth/lib/util.ts new file mode 100644 index 00000000..0402256a --- /dev/null +++ b/examples/api/src/app/api/tip-eth/lib/util.ts @@ -0,0 +1,82 @@ +import { createPublicClient, http } from "viem"; +import * as chains from "viem/chains"; + +export function numberWithCommas(x: string | number) { + var parts = x.toString().split("."); + parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ","); + return parts.join("."); +} +export async function getEthUsdPrice(): Promise { + const client = createPublicClient({ + transport: http(), + chain: chains.mainnet, + }); + + // roundId uint80, answer int256, startedAt uint256, updatedAt uint256, answeredInRound uint80 + const [, answer] = await client.readContract({ + abi: [ + { + inputs: [], + name: "latestRoundData", + outputs: [ + { internalType: "uint80", name: "roundId", type: "uint80" }, + { internalType: "int256", name: "answer", type: "int256" }, + { internalType: "uint256", name: "startedAt", type: "uint256" }, + { internalType: "uint256", name: "updatedAt", type: "uint256" }, + { internalType: "uint80", name: "answeredInRound", type: "uint80" }, + ], + stateMutability: "view", + type: "function", + }, + ], + functionName: "latestRoundData", + // https://docs.chain.link/data-feeds/price-feeds/addresses?network=ethereum&page=1&search=usdc#ethereum-mainnet + address: "0x986b5E1e1755e3C2440e960477f25201B0a8bbD4", + }); + + const ethPriceUsd = (1 / Number(answer)) * 1000000000000000000; + + return ethPriceUsd; +} +export async function getBalancesOnChains({ + address, + chains, + minBalance = BigInt(0), +}: { + address: `0x${string}`; + chains: chains.Chain[]; + minBalance?: bigint; +}) { + const balances = await Promise.all( + chains.map(async (chain) => { + const client = createPublicClient({ + transport: http(), + chain, + }); + const balance = await client.getBalance({ address }); + return { + chain, + balance, + }; + }) + ); + return balances + .filter((b) => b.balance > minBalance) + .sort((a, b) => Number(b.balance - a.balance)); +} +export async function getVerifiedAddress( + fid: string +): Promise<`0x${string}` | null> { + const verificationsRes = await fetch( + `${ + process.env.HUB_HTTP_ENDPOINT || "https://nemes.farcaster.xyz:2281" + }/v1/verificationsByFid?fid=${fid}` + ); + const verificationsResJson = await verificationsRes.json(); + const verification = verificationsResJson.messages[0]; + if (!verification) { + return null; + } + const { address } = verification.data.verificationAddEthAddressBody; + return address; +} diff --git a/examples/api/src/app/api/tip-eth/route.ts b/examples/api/src/app/api/tip-eth/route.ts new file mode 100644 index 00000000..803967a0 --- /dev/null +++ b/examples/api/src/app/api/tip-eth/route.ts @@ -0,0 +1,92 @@ +import { NextRequest } from "next/server"; +import { formatEther, parseEther } from "viem"; +import * as chains from "viem/chains"; +import { + getBalancesOnChains, + getEthUsdPrice, + getVerifiedAddress, + numberWithCommas, +} from "./lib/util"; + +export async function GET(request: NextRequest) { + const fid = request.nextUrl.searchParams.get("fid"); + const amountUsd = request.nextUrl.searchParams.get("amountUsd"); + const fromAddress = request.nextUrl.searchParams.get("fromAddress"); + + if (!fid || !amountUsd || !fromAddress) { + return new Response("Missing parameters", { status: 400 }); + } + + // TODO: Add message via tx data + + const [address, ethPriceUsd] = await Promise.all([ + getVerifiedAddress(fid), + getEthUsdPrice(), + ]); + if (!address) { + return new Response("No verified addresses for user", { status: 404 }); + } + + const amountEth = parseEther( + (parseFloat(amountUsd) / ethPriceUsd).toString() + ); + + // Find a chain where both users have balances + const candidateChains = [ + chains.arbitrum, + chains.optimism, + chains.base, + chains.zora, + ]; + + const [senderBalances, recipientBalances] = await Promise.all([ + getBalancesOnChains({ + address: fromAddress as `0x${string}`, + chains: candidateChains, + minBalance: amountEth, + }), + getBalancesOnChains({ + address: address, + chains: candidateChains, + }), + ]); + const senderChainIds = senderBalances.map(({ chain }) => chain.id); + + // Suggested chain is the one where the sender has enough balance and the recipient has the most balance + const suggestedChain = + recipientBalances.filter(({ chain }) => + senderChainIds.includes(chain.id) + )[0]?.chain || senderBalances[0]?.chain; + + if (!suggestedChain) { + return Response.json( + { message: "No chain with enough ETH balance" }, + { status: 404 } + ); + } + + return Response.json({ + tx: { + to: address, + from: fromAddress, + value: amountEth.toString(), + data: "0x", + }, + valueUsdFormatted: `${numberWithCommas(parseFloat(amountUsd).toFixed(2))}`, + valueEthFormatted: parseFloat(formatEther(amountEth)).toPrecision(4), + suggestedChain: suggestedChain, + chainBalances: senderBalances.map(({ chain, balance }) => ({ + chain, + balance: balance.toString(), + balanceUsd: ethPriceUsd + ? ethPriceUsd * parseFloat(formatEther(balance)) + : undefined, + })), + }); +} + +// needed for preflight requests to succeed +export const OPTIONS = async (request: NextRequest) => { + // Return Response + return Response.json({}); +}; diff --git a/examples/nextjs-shadcn/src/app/actions.tsx b/examples/nextjs-shadcn/src/app/actions.tsx new file mode 100644 index 00000000..6b7f9370 --- /dev/null +++ b/examples/nextjs-shadcn/src/app/actions.tsx @@ -0,0 +1,89 @@ +import { Embed, ModManifest, handleOpenFile } from "@mod-protocol/core"; +import { actionMods, actionModsExperimental } from "@mod-protocol/mod-registry"; +import { ActionMod } from "@mod-protocol/react"; +import { ModsSearch } from "@mod-protocol/react-ui-shadcn/dist/components/creation-mods-search"; +import { Button } from "@mod-protocol/react-ui-shadcn/dist/components/ui/button"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@mod-protocol/react-ui-shadcn/dist/components/ui/popover"; +import { renderers } from "@mod-protocol/react-ui-shadcn/dist/renderers"; +import { KebabHorizontalIcon } from "@primer/octicons-react"; +import React, { useMemo } from "react"; +import { getAddress } from "viem"; +import { useAccount } from "wagmi"; +import { API_URL } from "./constants"; +import { useExperimentalMods } from "./use-experimental-mods"; +import { sendEthTransaction } from "./utils"; + +export function Actions({ + author, + post, +}: { + author: { + farcaster: { + fid: string; + }; + }; + post: { + id: string; + text: string; + embeds: Embed[]; + }; +}) { + const experimentalMods = useExperimentalMods(); + const [currentMod, setCurrentMod] = React.useState(null); + + const { address: unchecksummedAddress } = useAccount(); + const checksummedAddress = React.useMemo(() => { + if (!unchecksummedAddress) return null; + return getAddress(unchecksummedAddress); + }, [unchecksummedAddress]); + const user = React.useMemo(() => { + return { + wallet: { + address: checksummedAddress, + }, + }; + }, [checksummedAddress]); + + const onSendEthTransactionAction = useMemo(() => sendEthTransaction, []); + + return ( + { + if (!op) setCurrentMod(null); + }} + > + + + + + +
+

{currentMod?.name}

+
+ setCurrentMod(null)} + onSendEthTransactionAction={onSendEthTransactionAction} + author={author} + post={post} + /> +
+
+
+ ); +} diff --git a/examples/nextjs-shadcn/src/app/cast.tsx b/examples/nextjs-shadcn/src/app/cast.tsx index 3318477a..2f6a5aaa 100644 --- a/examples/nextjs-shadcn/src/app/cast.tsx +++ b/examples/nextjs-shadcn/src/app/cast.tsx @@ -1,12 +1,4 @@ -import { useRelativeDate } from "./relative-date"; -import React from "react"; -import { - CommentIcon, - HeartIcon, - ShareIcon, - SyncIcon, - BookmarkIcon, -} from "@primer/octicons-react"; +import { Embed } from "@mod-protocol/core"; import { StructuredCastImageUrl, StructuredCastMention, @@ -18,8 +10,17 @@ import { StructuredCastVideo, convertCastPlainTextToStructured, } from "@mod-protocol/farcaster"; -import { Embed } from "@mod-protocol/core"; +import { + BookmarkIcon, + CommentIcon, + HeartIcon, + ShareIcon, + SyncIcon, +} from "@primer/octicons-react"; +import React from "react"; +import { Actions } from "./actions"; import { Embeds } from "./embeds"; +import { useRelativeDate } from "./relative-date"; export const structuredCastToReactDOMComponentsConfig: Record< StructuredCastUnit["type"], @@ -91,9 +92,11 @@ export function convertStructuredCastToReactDOMComponents( export function Cast(props: { cast: { + hash: string; avatar_url: string; display_name: string; username: string; + fid: string; timestamp: string; text: string; embeds: Array; @@ -128,10 +131,29 @@ export function Cast(props: {
- - {props.cast.display_name} - {" "} - @{props.cast.username} · {publishedAt} +
+
+ + {props.cast.display_name} + {" "} + @{props.cast.username}{" "} + · {publishedAt} +
+
+ +
+
{convertStructuredCastToReactDOMComponents(structuredCast, {})} diff --git a/examples/nextjs-shadcn/src/app/constants.ts b/examples/nextjs-shadcn/src/app/constants.ts new file mode 100644 index 00000000..478e7456 --- /dev/null +++ b/examples/nextjs-shadcn/src/app/constants.ts @@ -0,0 +1,3 @@ +// Optionally replace with your API_URL here +export const API_URL = + process.env.NEXT_PUBLIC_API_URL ?? "https://api.modprotocol.org/api"; diff --git a/examples/nextjs-shadcn/src/app/dummy-casts.ts b/examples/nextjs-shadcn/src/app/dummy-casts.ts index 3968f8f1..800e5230 100644 --- a/examples/nextjs-shadcn/src/app/dummy-casts.ts +++ b/examples/nextjs-shadcn/src/app/dummy-casts.ts @@ -1,18 +1,22 @@ import { Embed } from "@mod-protocol/core"; export const dummyCastData: Array<{ + hash: string; avatar_url: string; display_name: string; username: string; + fid: string; timestamp: string; text: string; embeds: Array; }> = [ { + hash: "0x39a8237a349b9bc95ae96b4d838b39d3699e0701", avatar_url: "https://res.cloudinary.com/merkle-manufactory/image/fetch/c_fill,f_png,w_144/https%3A%2F%2Flh3.googleusercontent.com%2F-S5cdhOpZtJ_Qzg9iPWELEsRTkIsZ7qGYmVlwEORgFB00WWAtZGefRnS4Bjcz5ah40WVOOWeYfU5pP9Eekikb3cLMW2mZQOMQHlWhg", display_name: "David Furlong", username: "df", + fid: "1214", timestamp: "2023-08-17 09:16:52.293739", text: "[Automated] @df just starred the repo 0xOlias/ponder on Github", embeds: [ @@ -31,10 +35,12 @@ export const dummyCastData: Array<{ ], }, { + hash: "0x39a8237a349b9bc95ae96b4d838b39d3699e0702", avatar_url: "https://res.cloudinary.com/merkle-manufactory/image/fetch/c_fill,f_png,w_144/https%3A%2F%2Flh3.googleusercontent.com%2F-S5cdhOpZtJ_Qzg9iPWELEsRTkIsZ7qGYmVlwEORgFB00WWAtZGefRnS4Bjcz5ah40WVOOWeYfU5pP9Eekikb3cLMW2mZQOMQHlWhg", display_name: "David Furlong", username: "df", + fid: "1214", timestamp: "2023-08-17 09:16:52.293739", text: "a fluke", embeds: [ @@ -52,10 +58,12 @@ export const dummyCastData: Array<{ ], }, { + hash: "0x39a8237a349b9bc95ae96b4d838b39d3699e0703", avatar_url: "https://res.cloudinary.com/merkle-manufactory/image/fetch/c_fill,f_png,w_144/https%3A%2F%2Flh3.googleusercontent.com%2F-S5cdhOpZtJ_Qzg9iPWELEsRTkIsZ7qGYmVlwEORgFB00WWAtZGefRnS4Bjcz5ah40WVOOWeYfU5pP9Eekikb3cLMW2mZQOMQHlWhg", display_name: "David Furlong", username: "df", + fid: "1214", timestamp: "2023-08-17 09:16:52.293739", text: "I just minted Farcaster v3", embeds: [ @@ -104,10 +112,12 @@ export const dummyCastData: Array<{ ], }, { + hash: "0x39a8237a349b9bc95ae96b4d838b39d3699e0704", avatar_url: "https://res.cloudinary.com/merkle-manufactory/image/fetch/c_fill,f_png,w_144/https%3A%2F%2Flh3.googleusercontent.com%2F-S5cdhOpZtJ_Qzg9iPWELEsRTkIsZ7qGYmVlwEORgFB00WWAtZGefRnS4Bjcz5ah40WVOOWeYfU5pP9Eekikb3cLMW2mZQOMQHlWhg", display_name: "David Furlong", username: "df", + fid: "1214", timestamp: "2023-08-17 09:16:52.293739", text: "This is an example of an embedded video", embeds: [ @@ -120,10 +130,12 @@ export const dummyCastData: Array<{ ], }, { + hash: "0x39a8237a349b9bc95ae96b4d838b39d3699e0705", avatar_url: "https://res.cloudinary.com/merkle-manufactory/image/fetch/c_fill,f_png,w_144/https%3A%2F%2Flh3.googleusercontent.com%2F-S5cdhOpZtJ_Qzg9iPWELEsRTkIsZ7qGYmVlwEORgFB00WWAtZGefRnS4Bjcz5ah40WVOOWeYfU5pP9Eekikb3cLMW2mZQOMQHlWhg", display_name: "David Furlong", username: "df", + fid: "1214", timestamp: "2023-08-17 09:16:52.293739", text: "I just minted this straight from my feed", embeds: [ diff --git a/examples/nextjs-shadcn/src/app/editor-example.tsx b/examples/nextjs-shadcn/src/app/editor-example.tsx index ee89db34..3668b2e9 100644 --- a/examples/nextjs-shadcn/src/app/editor-example.tsx +++ b/examples/nextjs-shadcn/src/app/editor-example.tsx @@ -47,10 +47,7 @@ import { } from "@mod-protocol/react-ui-shadcn/dist/components/ui/popover"; import { renderers } from "@mod-protocol/react-ui-shadcn/dist/renderers"; import { useExperimentalMods } from "./use-experimental-mods"; - -// Optionally replace with your API_URL here -const API_URL = - process.env.NEXT_PUBLIC_API_URL ?? "https://api.modprotocol.org/api"; +import { API_URL } from "./constants"; const getMentions = getFarcasterMentions(API_URL); const getChannels = getFarcasterChannels(API_URL); diff --git a/examples/nextjs-shadcn/src/app/embeds.tsx b/examples/nextjs-shadcn/src/app/embeds.tsx index c753175b..91803367 100644 --- a/examples/nextjs-shadcn/src/app/embeds.tsx +++ b/examples/nextjs-shadcn/src/app/embeds.tsx @@ -1,72 +1,35 @@ "use client"; import { - ContextType, Embed, + RichEmbedContext, SendEthTransactionActionResolverEvents, SendEthTransactionActionResolverInit, } from "@mod-protocol/core"; import { - richEmbedMods, defaultRichEmbedMod, + richEmbedMods, richEmbedModsExperimental, } from "@mod-protocol/mod-registry"; import { RichEmbed } from "@mod-protocol/react"; +import "@mod-protocol/react-ui-shadcn/dist/public/video-js.css"; import { renderers } from "@mod-protocol/react-ui-shadcn/dist/renderers"; -import { - sendTransaction, - switchNetwork, - waitForTransaction, -} from "@wagmi/core"; + import { useMemo } from "react"; import { useAccount } from "wagmi"; +import { API_URL } from "./constants"; import { useExperimentalMods } from "./use-experimental-mods"; -import "@mod-protocol/react-ui-shadcn/dist/public/video-js.css"; +import { sendEthTransaction } from "./utils"; export function Embeds(props: { embeds: Array }) { const experimentalMods = useExperimentalMods(); const { address } = useAccount(); - const onSendEthTransactionAction = useMemo( - () => - async ( - { data, chainId }: SendEthTransactionActionResolverInit, - { - onConfirmed, - onError, - onSubmitted, - }: SendEthTransactionActionResolverEvents - ) => { - try { - const parsedChainId = parseInt(chainId); - - // Switch chains if the user is not on the right one - await switchNetwork({ chainId: parsedChainId }); - - // Send the transaction - const { hash } = await sendTransaction({ - ...data, - chainId: parsedChainId, - }); - onSubmitted(hash); - - // Wait for the transaction to be confirmed - const { status } = await waitForTransaction({ - hash, - chainId: parsedChainId, - }); - - onConfirmed(hash, status === "success"); - } catch (e) { - onError(e); - } - }, - [] - ); + const onSendEthTransactionAction = useMemo(() => sendEthTransaction, []); - const context = useMemo>(() => { + const context = useMemo>(() => { return { - api: process.env.NEXT_PUBLIC_API_URL, + api: API_URL, user: { wallet: { address, diff --git a/examples/nextjs-shadcn/src/app/utils.ts b/examples/nextjs-shadcn/src/app/utils.ts new file mode 100644 index 00000000..f7f12003 --- /dev/null +++ b/examples/nextjs-shadcn/src/app/utils.ts @@ -0,0 +1,38 @@ +import { + SendEthTransactionActionResolverEvents, + SendEthTransactionActionResolverInit, +} from "@mod-protocol/core"; +import { + sendTransaction, + switchNetwork, + waitForTransaction, +} from "@wagmi/core"; + +export async function sendEthTransaction( + { data, chainId }: SendEthTransactionActionResolverInit, + { onConfirmed, onError, onSubmitted }: SendEthTransactionActionResolverEvents +) { + try { + const parsedChainId = parseInt(chainId); + + // Switch chains if the user is not on the right one + await switchNetwork({ chainId: parsedChainId }); + + // Send the transaction + const { hash } = await sendTransaction({ + ...data, + chainId: parsedChainId, + }); + onSubmitted(hash); + + // Wait for the transaction to be confirmed + const { status } = await waitForTransaction({ + hash, + chainId: parsedChainId, + }); + + onConfirmed(hash, status === "success"); + } catch (e) { + onError(e); + } +} diff --git a/mods/tip-eth/index.ts b/mods/tip-eth/index.ts new file mode 100644 index 00000000..8e71bc78 --- /dev/null +++ b/mods/tip-eth/index.ts @@ -0,0 +1 @@ +export { default as default } from "./src/manifest"; diff --git a/mods/tip-eth/package.json b/mods/tip-eth/package.json new file mode 100644 index 00000000..675d6ad8 --- /dev/null +++ b/mods/tip-eth/package.json @@ -0,0 +1,10 @@ +{ + "name": "@mods/tip-eth", + "main": "./index.ts", + "types": "./index.ts", + "version": "0.1.0", + "private": true, + "dependencies": { + "@mod-protocol/core": "^0.1.0" + } +} \ No newline at end of file diff --git a/mods/tip-eth/src/action.ts b/mods/tip-eth/src/action.ts new file mode 100644 index 00000000..bd48dc56 --- /dev/null +++ b/mods/tip-eth/src/action.ts @@ -0,0 +1,157 @@ +import { ModElement } from "@mod-protocol/core"; + +const action: ModElement[] = [ + { + type: "vertical-layout", + elements: [ + { + if: { + value: "{{refs.tipReq.response.data}}", + match: { + NOT: { + equals: "", + }, + }, + }, + then: { + if: { + value: "{{refs.sendEthTx.hash}}", + match: { + NOT: { + equals: "", + }, + }, + }, + then: { + if: { + value: "{{refs.sendEthTx.isSuccess}}", + match: { + equals: "true", + }, + }, + then: { + type: "horizontal-layout", + elements: [ + { + type: "text", + label: "Transaction successful", + variant: "secondary", + }, + { + type: "link", + label: "Explorer", + url: "{{refs.tipReq.response.data.suggestedChain.blockExplorers.default.url}}/tx/{{refs.sendEthTx.hash}}", + }, + ], + }, + else: { + if: { + value: "{{refs.sendEthTx.isSuccess}}", + match: { + equals: "false", + }, + }, + then: { + type: "link", + label: "Failed", + variant: "link", + url: "{{refs.tipReq.response.data.suggestedChain.blockExplorers.default.url}}/tx/{{refs.sendEthTx.hash}}", + }, + else: { + type: "horizontal-layout", + elements: [ + { + type: "link", + label: "Confirming...", + variant: "link", + url: "{{refs.tipReq.response.data.suggestedChain.blockExplorers.default.url}}/tx/{{refs.sendEthTx.hash}}", + }, + ], + }, + }, + }, + else: { + type: "vertical-layout", + elements: [ + { + type: "text", + label: + "Sending {{refs.tipReq.response.data.valueEthFormatted}} (${{refs.tipReq.response.data.valueUsdFormatted}}) to {{refs.tipReq.response.data.tx.to}}", + }, + { + type: "text", + label: + "Suggested chain: {{refs.tipReq.response.data.suggestedChain.name}}", + variant: "secondary", + }, + { + type: "button", + label: "Send", + onclick: { + type: "SENDETHTRANSACTION", + ref: "sendEthTx", + txData: { + from: "{{user.wallet.address}}", + to: "{{refs.tipReq.response.data.tx.to}}", + value: "{{refs.tipReq.response.data.tx.value}}", + data: "{{refs.tipReq.response.data.tx.data}}", + }, + chainId: "{{refs.tipReq.response.data.suggestedChain.id}}", + }, + }, + ], + }, + }, + else: { + type: "horizontal-layout", + elements: [ + { + type: "button", + label: "$1.00", + onclick: { + type: "GET", + ref: "tipReq", + url: "{{api}}/tip-eth?fid={{author.farcaster.fid}}&amountUsd=1.00&fromAddress={{user.wallet.address}}", + }, + }, + { + type: "button", + label: "$5.00", + onclick: { + type: "GET", + ref: "tipReq", + url: "{{api}}/tip-eth?fid={{author.farcaster.fid}}&amountUsd=5.00&fromAddress={{user.wallet.address}}", + }, + }, + { + type: "button", + label: "$10.00", + onclick: { + type: "GET", + ref: "tipReq", + url: "{{api}}/tip-eth?fid={{author.farcaster.fid}}&amountUsd=10.00&fromAddress={{user.wallet.address}}", + }, + }, + ], + }, + }, + { + if: { + value: "{{refs.tipReq.error}}", + match: { + NOT: { + equals: "", + }, + }, + }, + then: { + type: "text", + label: "Error: {{refs.tipReq.error.error.message}}", + variant: "secondary", + }, + }, + ], + }, +]; + +export default action; diff --git a/mods/tip-eth/src/manifest.ts b/mods/tip-eth/src/manifest.ts new file mode 100644 index 00000000..a7d086ee --- /dev/null +++ b/mods/tip-eth/src/manifest.ts @@ -0,0 +1,14 @@ +import { ModManifest } from "@mod-protocol/core"; +import action from "./action"; + +const manifest: ModManifest = { + slug: "tip-eth", + name: "Send the author a tip", + custodyAddress: "stephancill.eth", + version: "0.0.1", + logo: "https://i.imgur.com/MKmOtSU.png", + custodyGithubUsername: "stephancill", + actionEntrypoints: action, +}; + +export default manifest; diff --git a/mods/tip-eth/tsconfig.json b/mods/tip-eth/tsconfig.json new file mode 100644 index 00000000..12f50f96 --- /dev/null +++ b/mods/tip-eth/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "tsconfig/base.json", + "include": [ + "." + ], + "exclude": [ + "dist", + "build", + "node_modules" + ] +} \ No newline at end of file diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e52b62a5..b6da7dfc 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -32,6 +32,7 @@ export type { ContextType, RichEmbedContext, CreationContext, + ActionContext, } from "./renderer"; export { Renderer, canRenderEntrypointWithContext } from "./renderer"; export * from "./embeds"; diff --git a/packages/core/src/manifest.ts b/packages/core/src/manifest.ts index 3e47b32c..818b0962 100644 --- a/packages/core/src/manifest.ts +++ b/packages/core/src/manifest.ts @@ -27,6 +27,8 @@ export type ModManifest = { creationEntrypoints?: ModElement[]; /** Interface this Mod exposes, if any, for RichEmbed Rendering */ richEmbedEntrypoints?: ModConditionalElement[]; + /** Interface this Mod exposes, if any, for Action Execution */ + actionEntrypoints?: ModElement[]; /** A definition map of reusable elements, using their id as the key */ elements?: Record; /** Permissions requested by the Mod */ diff --git a/packages/core/src/renderer.ts b/packages/core/src/renderer.ts index adce7deb..a25e3c8d 100644 --- a/packages/core/src/renderer.ts +++ b/packages/core/src/renderer.ts @@ -158,31 +158,41 @@ export type ModElementRef = }; }; -export type BaseContext = { - user?: { - wallet?: { - address?: string; - }; - farcaster?: { - fid?: string; - }; +export type UserData = { + wallet?: { + address?: string; }; + farcaster?: { + fid?: string; + }; +}; + +export type BaseContext = { + user?: UserData; + /** The url of the api hosting the mod backends. (including /api) **/ + api: string; }; export type CreationContext = BaseContext & { input: any; embeds: Embed[]; - /** The url of the api hosting the mod backends. (including /api) **/ - api: string; }; // Render Mini-apps only are triggered by a single embed right now export type RichEmbedContext = BaseContext & { embed: Embed; - api: string; }; -export type ContextType = CreationContext | RichEmbedContext; +export type ActionContext = BaseContext & { + user: UserData; + author: Omit; + post: { + text: string; + embeds: Embed[]; + }; +}; + +export type ContextType = CreationContext | RichEmbedContext | ActionContext; function nonNullable(value: T): value is NonNullable { return value !== null && value !== undefined; @@ -433,7 +443,7 @@ export type RendererOptions = { onEthPersonalSignAction: EthPersonalSignActionResolver; onSendEthTransactionAction: SendEthTransactionActionResolver; onExitAction: ExitActionResolver; -} & ( +} & ( // TODO: Variant-specific actions | { variant: "creation"; context: CreationContext; @@ -442,6 +452,10 @@ export type RendererOptions = { variant: "richEmbed"; context: RichEmbedContext; } + | { + variant: "action"; + context: ActionContext; + } ); export class Renderer { @@ -480,7 +494,7 @@ export class Renderer { if (options.variant === "creation") { this.currentTree = options.manifest.creationEntrypoints || []; - } else { + } else if (options.variant === "richEmbed") { const entrypoints = options.manifest.richEmbedEntrypoints; for (const entrypoint of entrypoints || []) { @@ -489,6 +503,8 @@ export class Renderer { break; } } + } else if (options.variant === "action") { + this.currentTree = options.manifest.actionEntrypoints || []; } } diff --git a/packages/mod-registry/src/index.ts b/packages/mod-registry/src/index.ts index 1b13ac01..964334cb 100644 --- a/packages/mod-registry/src/index.ts +++ b/packages/mod-registry/src/index.ts @@ -13,6 +13,7 @@ import ZoraNftMinter from "@mods/zora-nft-minter"; import ImgurUpload from "@mods/imgur-upload"; import DALLE from "@mods/dall-e"; import ZoraCreate from "@mods/zora-create"; +import TipEthMod from "@mods/tip-eth"; /** All - Stable, suitable for use */ @@ -39,6 +40,11 @@ export const richEmbedMods: ModManifest[] = allMods.filter( manifest.richEmbedEntrypoints && manifest.richEmbedEntrypoints.length !== 0 ); +export const actionMods: ModManifest[] = allMods.filter( + (manifest) => + manifest.actionEntrypoints && manifest.actionEntrypoints.length !== 0 +); + /** All + Experimental - Potentially unstable, unsuitable for production use */ export const allModsExperimental = [ @@ -54,6 +60,7 @@ export const allModsExperimental = [ ChatGPTShorten, ChatGPT, DALLE, + TipEthMod, ]; export const creationModsExperimental: ModManifest[] = @@ -69,5 +76,10 @@ export const richEmbedModsExperimental: ModManifest[] = manifest.richEmbedEntrypoints.length !== 0 ); +export const actionModsExperimental: ModManifest[] = allModsExperimental.filter( + (manifest) => + manifest.actionEntrypoints && manifest.actionEntrypoints.length !== 0 +); + /** When no renderMod matches an embed, this one will be used **/ export const defaultRichEmbedMod: ModManifest = UrlRender; diff --git a/packages/react-ui-shadcn/src/components/creation-mods-search.tsx b/packages/react-ui-shadcn/src/components/creation-mods-search.tsx index 53220897..7843bd08 100644 --- a/packages/react-ui-shadcn/src/components/creation-mods-search.tsx +++ b/packages/react-ui-shadcn/src/components/creation-mods-search.tsx @@ -17,7 +17,7 @@ type Props = { onSelect: (value: ModManifest) => void; }; -export function ModsSearch(props: Props) { +export function ModsSearch(props: Props & { children?: React.ReactNode }) { const { mods, onSelect } = props; const [open, setOpen] = React.useState(false); @@ -36,14 +36,18 @@ export function ModsSearch(props: Props) { return ( - + {props.children ? ( + props.children + ) : ( + + )} diff --git a/packages/react/src/index.tsx b/packages/react/src/index.tsx index 7a24b53f..cb4d7fc9 100644 --- a/packages/react/src/index.tsx +++ b/packages/react/src/index.tsx @@ -13,6 +13,7 @@ import { AddEmbedActionResolver, RichEmbedContext, CreationContext, + ActionContext, EthPersonalSignActionResolver, SendEthTransactionActionResolver, } from "@mod-protocol/core"; @@ -483,6 +484,59 @@ export const CreationMod = ( return ; }; +export const ActionMod = ( + props: Props & ActionContext & { variant: "action" } +) => { + const { + manifest, + variant, + onHttpAction = actionResolverHttp, + onOpenFileAction = actionResolverOpenFile, + onSetInputAction = actionResolverSetInput, + onAddEmbedAction = actionResolverAddEmbed, + onOpenLinkAction = actionResolverOpenLink, + onSendEthTransactionAction = actionResolverSendEthTransaction, + onEthPersonalSignAction = actionResolverEthPersonalSign, + onExitAction = actionResolverExit, + } = props; + + const forceRerender = useForceRerender(); + + const context = React.useMemo(() => { + return { + api: props.api, + author: props.author, + user: props.user, + post: props.post, + }; + }, [props.api, props.user, props.post, props.author]); + + const [renderer] = React.useState( + () => + new Renderer({ + manifest, + context, + variant: "action", + onTreeChange: forceRerender, + onHttpAction, + onOpenFileAction, + onSetInputAction, + onAddEmbedAction, + onOpenLinkAction, + onEthPersonalSignAction, + onSendEthTransactionAction, + onExitAction, + }) + ); + + React.useEffect(() => { + renderer.setContext(context); + forceRerender(); + }, [forceRerender, context, renderer]); + + return ; +}; + export const RenderMod = ( props: Props & ({ variant: "richEmbed" } & RichEmbedContext) ) => { diff --git a/turbo.json b/turbo.json index 4d0611b9..98c9bea9 100644 --- a/turbo.json +++ b/turbo.json @@ -39,6 +39,7 @@ "IMGUR_CLIENT_ID", "NEXT_PUBLIC_EXPERIMENTAL_MODS", "ZORA_ADMIN_PRIVATE_KEY", - "NFT_STORAGE_API_KEY" + "NFT_STORAGE_API_KEY", + "HUB_HTTP_ENDPOINT" ] } \ No newline at end of file