diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..40eeba45 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "typescript.disableAutomaticTypeAcquisition": true +} \ No newline at end of file diff --git a/docs/pages/create-mini-app/reference.mdx b/docs/pages/create-mini-app/reference.mdx index 5c109b28..f11733e1 100644 --- a/docs/pages/create-mini-app/reference.mdx +++ b/docs/pages/create-mini-app/reference.mdx @@ -72,11 +72,38 @@ export type ModElement = elements?: string | ElementOrConditionalFlow[]; onload?: ModEvent; } + | { + 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; + } + | { + ref?: string; + type: "textarea"; + placeholder?: string; + onchange?: ModEvent; + onsubmit?: ModEvent; + } | { ref?: string; type: "input"; placeholder?: string; - clearable?: boolean; + isClearable?: boolean; onchange?: ModEvent; onsubmit?: ModEvent; } diff --git a/docs/pages/metadata-cache.mdx b/docs/pages/metadata-cache.mdx index e953445f..c676cdbb 100644 --- a/docs/pages/metadata-cache.mdx +++ b/docs/pages/metadata-cache.mdx @@ -6,7 +6,9 @@ The metadata cache can be used to retrieve Open Graph metadata for embeds in cas It makes use of the [Metadata Indexer](https://github.com/mod-protocol/mod/tree/main/examples/metadata-indexer), an open source and self-hostable service that indexes casts, embeds, and their metadata. -## Usage +We are hosting a free instance of the Metadata indexer that can be reached at https://api.modprotocol.org/api/cast-embeds-metadata + +## `/api/cast-embeds-metadata` Fetching metadata from the cache is as simple as making a POST request to the following endpoint with a list of cast hashes in the body. @@ -58,8 +60,8 @@ This will return a JSON object with the following structure: "metadata": { "title": "Example Title", "description": "Example Description", - "image": { - url: "https://example.com/image.png" + "image": { + "url": "https://example.com/image.png" } // ... } @@ -73,8 +75,75 @@ Returned metadata objects conform to the `UrlMetadata` type. This can then be us ```typescript import { UrlMetadata } from "@mod-protocol/core"; -cast.embeds.map((embed, index) => { - const embedData: UrlMetadata = metadataResponse[cast.hash][index] - return -}) -``` \ No newline at end of file +cast.embeds.map((embed) => { + const metadata: UrlMetadata = metadataResponse[cast.hash][embed.url]; + return ; +}); +``` + +## `/api/cast-embeds-metadata/by-url` + +Fetching metadata from the cache by url is as simple as making a POST request to the following endpoint with a list of urls in the body. + + + + ```bash + curl --request POST \ + --url https://api.modprotocol.org/api/cast-embeds-metadata/by-url \ + --header 'Content-Type: application/json' \ + --data '["https://google.com","https://twitter.com"]' + ``` + + + ```js + const request = await fetch("https://api.modprotocol.org/api/cast-embeds-metadata/by-url", { + body: JSON.stringify(["https://google.com","https://twitter.com"]), + method: 'POST', + headers: { + 'Content-Type': "application/json" + } + }); + const metadata = await request.json(); + ``` + + + ```js + const request = await fetch("https://api.modprotocol.org/api/cast-embeds-metadata/by-url", { + body: JSON.stringify(["https://google.com","https://twitter.com"]), + method: 'POST', + headers: { + 'Content-Type': "application/json" + } + }); + const metadata = await request.json(); + ``` + + + +This will return a JSON object with the following structure: + +```json +{ + "https://google.com": { + "title": "Example Title", + "description": "Example Description", + "image": "https://example.com/image.png" + // ... + }, + "https://twitter.com": { + "title": "Example Title", + "description": "Example Description", + "image": "https://example.com/image.png" + // ... + } +} +``` + +Returned metadata objects conform to the `UrlMetadata` type. This can then be used to render embeds in a cast. + +```typescript +import { UrlMetadata } from "@mod-protocol/core"; + +const metadata: UrlMetadata = metadataResponse[embed.url]; +return ; +``` diff --git a/examples/api/.env.example b/examples/api/.env.example index b7ad61ee..bcea8706 100644 --- a/examples/api/.env.example +++ b/examples/api/.env.example @@ -6,4 +6,9 @@ MICROLINK_API_KEY="REQUIRED" OPENSEA_API_KEY="REQUIRED" CHATGPT_API_SECRET="REQUIRED" NEYNAR_API_SECRET="REQUIRED" -LIVEPEER_API_SECRET="REQUIRED" \ No newline at end of file +LIVEPEER_API_SECRET="REQUIRED" +DATABASE_URL="REQUIRED" +# Must be funded with MATIC on Mumbai for Irys https://mumbaifaucet.com/ +PRIVATE_KEY="REQUIRED" +GATEWAY_URL="REQUIRED" +SIMPLEHASH_API_KEY="REQUIRED" \ No newline at end of file diff --git a/examples/api/next.config.js b/examples/api/next.config.js index ef820681..945d78c7 100644 --- a/examples/api/next.config.js +++ b/examples/api/next.config.js @@ -1,4 +1,8 @@ module.exports = { reactStrictMode: true, - transpilePackages: ["@mod-protocol/react"], + transpilePackages: [ + "@mod-protocol/react", + // Fixes https://discord.com/channels/896185694857343026/1174716239508156496 + "@lit-protocol/bls-sdk", + ], }; diff --git a/examples/api/package.json b/examples/api/package.json index 37d512ce..d4b8c45d 100644 --- a/examples/api/package.json +++ b/examples/api/package.json @@ -10,16 +10,24 @@ "lint": "next lint" }, "dependencies": { + "@irys/sdk": "^0.0.4", + "@lit-protocol/lit-node-client": "^3.0.24", + "@lit-protocol/types": "^2.2.61", "@mod-protocol/core": "^0.0.2", "@reservoir0x/reservoir-sdk": "^1.8.4", "@vercel/postgres-kysely": "^0.6.0", + "bip39": "^3.1.0", "chatgpt": "^5.2.5", + "cheerio": "^1.0.0-rc.12", + "ethers": "^5.6.9", "kysely": "^0.26.3", "next": "^13.5.6", "open-graph-scraper": "^6.3.2", "pg": "^8.11.3", "react": "^18.2.0", - "react-dom": "^18.2.0" + "react-dom": "^18.2.0", + "siwe": "^1.1.6", + "uint8arrays": "^3.0.0" }, "devDependencies": { "@types/node": "^17.0.12", @@ -35,4 +43,4 @@ "tsconfig": "*", "typescript": "^5.2.2" } -} +} \ No newline at end of file diff --git a/examples/api/src/app/api/cast-embeds-metadata/by-url/route.ts b/examples/api/src/app/api/cast-embeds-metadata/by-url/route.ts new file mode 100644 index 00000000..5d7941dc --- /dev/null +++ b/examples/api/src/app/api/cast-embeds-metadata/by-url/route.ts @@ -0,0 +1,130 @@ +export const dynamic = "force-dynamic"; + +import { NextResponse, NextRequest } from "next/server"; +import { NFTMetadata, UrlMetadata } from "@mod-protocol/core"; +import { db } from "../lib/db"; +import { chainById } from "../lib/chain-index"; + +export async function POST(request: NextRequest) { + try { + const urls = await request.json(); + + // todo: consider normalizing urls here? currently clients are responsible for doing this. + + // Fetch metadata for each url + const metadata = await db + .selectFrom("url_metadata") + .where("url_metadata.url", "in", urls) + .leftJoin( + "nft_metadata", + "nft_metadata.id", + "url_metadata.nft_metadata_id" + ) + .leftJoin( + "nft_collections", + "url_metadata.nft_collection_id", + "nft_collections.id" + ) + .select([ + /* Select all columns with aliases to prevent collisions */ + + // URL metadata + "url_metadata.image_url as url_image_url", + "url_metadata.image_height as url_image_height", + "url_metadata.image_width as url_image_width", + "url_metadata.alt as url_alt", + "url_metadata.url as url_url", + "url_metadata.description as url_description", + "url_metadata.title as url_title", + "url_metadata.publisher as url_publisher", + "url_metadata.logo_url as url_logo_url", + "url_metadata.mime_type as url_mime_type", + "url_metadata.nft_collection_id as nft_collection_id", + "url_metadata.nft_metadata_id as nft_metadata_id", + + // NFT Collection metadata + "nft_collections.creator_address as collection_creator_address", + "nft_collections.description as collection_description", + "nft_collections.image_url as collection_image_url", + "nft_collections.item_count as collection_item_count", + "nft_collections.mint_url as collection_mint_url", + "nft_collections.name as collection_name", + "nft_collections.open_sea_url as collection_open_sea_url", + "nft_collections.owner_count as collection_owner_count", + + // NFT metadata + "nft_metadata.token_id as nft_token_id", + "nft_metadata.media_url as nft_media_url", + ]) + .execute(); + + const rowsFormatted = metadata.map((row) => { + let nftMetadata: NFTMetadata | undefined; + + if (row.nft_collection_id) { + const [, , prefixAndChainId, prefixAndContractAddress, tokenId] = + row.nft_collection_id.split("/"); + + const [, chainId] = prefixAndChainId.split(":"); + const [, contractAddress] = prefixAndContractAddress.split(":"); + + const chain = chainById[chainId]; + + nftMetadata = { + mediaUrl: row.nft_media_url || undefined, + tokenId: row.nft_token_id || undefined, + collection: { + chain: chain.network, + contractAddress, + creatorAddress: row.collection_creator_address, + description: row.collection_description, + id: row.nft_collection_id, + imageUrl: row.collection_image_url, + itemCount: row.collection_item_count, + mintUrl: row.collection_mint_url, + name: row.collection_name, + openSeaUrl: row.collection_open_sea_url || undefined, + ownerCount: row.collection_owner_count || undefined, + creator: undefined, // TODO: Look up farcaster user by FID + }, + }; + } + + const urlMetadata: UrlMetadata = { + image: row.url_image_url + ? { + url: row.url_image_url, + height: row.url_image_height || undefined, + width: row.url_image_width || undefined, + } + : undefined, + alt: row.url_alt || undefined, + description: row.url_description || undefined, + title: row.url_title || undefined, + publisher: row.url_publisher || undefined, + logo: row.url_logo_url ? { url: row.url_logo_url } : undefined, + mimeType: row.url_mime_type || undefined, + nft: nftMetadata, + }; + + return { + url: row.url_url, + urlMetadata, + }; + }); + + const metadataByUrl: { + [key: string]: UrlMetadata; + } = rowsFormatted.reduce((acc, cur) => { + return { + ...acc, + [cur.url]: cur.urlMetadata, + }; + }, {}); + + return NextResponse.json(metadataByUrl); + } catch (err) { + console.error(err); + return NextResponse.json({ message: err.message }, { status: err.status }); + } +} diff --git a/examples/api/src/app/api/lit-protocol-renderer/route.ts b/examples/api/src/app/api/lit-protocol-renderer/route.ts new file mode 100644 index 00000000..de67ffa9 --- /dev/null +++ b/examples/api/src/app/api/lit-protocol-renderer/route.ts @@ -0,0 +1,78 @@ +import { NextRequest, NextResponse } from "next/server"; +import * as LitJsSdk from "@lit-protocol/lit-node-client"; + +async function getLitNodeClient() { + const litNodeClient = new LitJsSdk.LitNodeClient({ + alertWhenUnauthorized: false, + litNetwork: "cayenne", + }); + await litNodeClient.connect(); + + return litNodeClient; +} + +async function decryptData({ + authSig, + ciphertext, + dataToEncryptHash, + accessControlConditions, +}): Promise { + const litNodeClient = await getLitNodeClient(); + + let decryptedString; + try { + decryptedString = await LitJsSdk.decryptToString( + { + authSig, + accessControlConditions, + ciphertext, + dataToEncryptHash, + // FIXME + chain: "ethereum", + }, + litNodeClient + ); + } catch (e) { + console.error(e); + return null; + } + + return decryptedString; +} + +export async function POST(request: NextRequest) { + const { + authSig, + payload: { + cipherTextRetrieved, + dataToEncryptHashRetrieved, + accessControlConditions, + }, + } = await request.json(); + + // 4. Decrypt data + const decryptedString = await decryptData({ + ciphertext: cipherTextRetrieved, + authSig: { ...authSig, derivedVia: "web3.eth.personal.sign" }, + dataToEncryptHash: dataToEncryptHashRetrieved, + accessControlConditions, + }); + + if (decryptedString === null) { + return NextResponse.json( + { + message: "An unknown error occurred", + }, + { status: 500 } + ); + } + + return NextResponse.json({ + decryptedString: "", + }); +} + +// needed for preflight requests to succeed +export const OPTIONS = async (request: NextRequest) => { + return NextResponse.json({}); +}; diff --git a/examples/api/src/app/api/lit-protocol/route.ts b/examples/api/src/app/api/lit-protocol/route.ts new file mode 100644 index 00000000..87b7d195 --- /dev/null +++ b/examples/api/src/app/api/lit-protocol/route.ts @@ -0,0 +1,226 @@ +import { NextRequest, NextResponse } from "next/server"; +import Irys from "@irys/sdk"; +import * as LitJsSdk from "@lit-protocol/lit-node-client"; +import { AccessControlConditions, AuthSig } from "@lit-protocol/types"; +import { generatePrivateKey } from "viem/accounts"; +import { getAddress } from "viem"; + +/** + * Built using the guide + * https://developer.litprotocol.com/v3/integrations/storage/irys/ + */ + +async function getLitNodeClient() { + // Initialize LitNodeClient + const litNodeClient = new LitJsSdk.LitNodeClient({ + alertWhenUnauthorized: true, + litNetwork: "cayenne", + }); + await litNodeClient.connect(); + + return litNodeClient; +} + +function getAccessControlConditions({ + chain, + contract, + tokens, + standardContractType, +}: { + chain: string; + contract: string; + tokens: string; + standardContractType: "ERC721" | "ERC1155"; +}): AccessControlConditions { + return [ + { + conditionType: "evmBasic" as const, + contractAddress: getAddress(contract), + standardContractType: standardContractType, + chain: chain, + method: "balanceOf", + parameters: [":userAddress"], + returnValueTest: { + comparator: ">=", + value: tokens, + }, + }, + ]; +} + +async function encryptData({ + dataToEncrypt, + authSig, + chain, + standardContractType, + contract, + tokens, +}: { + dataToEncrypt: string; + authSig: AuthSig; + chain: string; + standardContractType: "ERC721" | "ERC1155"; + contract: string; + tokens: string; +}) { + const accessControlConditions: AccessControlConditions = + getAccessControlConditions({ + chain, + contract, + standardContractType, + tokens, + }); + const litNodeClient = await getLitNodeClient(); + + // 1. Encryption + // encryptedString + // dataToEncryptHash` + const { ciphertext, dataToEncryptHash } = await LitJsSdk.encryptString( + { + authSig, + accessControlConditions, + dataToEncrypt: dataToEncrypt, + chain, + }, + litNodeClient + ); + return { ciphertext, dataToEncryptHash, accessControlConditions }; +} + +async function storeOnIrys(jsonPayload: object): Promise { + const irys = await getIrys(); + + try { + const receipt = await irys.upload(JSON.stringify(jsonPayload), { + tags: [{ name: "Content-Type", value: "application/json" }], + }); + + return receipt.id; + } catch (e) { + console.log("Error uploading data ", e); + return null; + } +} + +async function getIrys() { + // Uint8Array with length 32 + // const key = Uint8Array.from( + // Buffer.from( + // bip39.mnemonicToSeedSync(process.env.PRIVATE_KEY).toString("hex"), + // "hex" + // ) + // ); + // if (!bip39.validateMnemonic(process.env.PRIVATE_KEY)) { + // throw new Error("Invalid mnemonic"); + // } + // mnemonicToAccount(process.env.PRIVATE_KEY); + // const key = (await bip39.mnemonicToSeed(process.env.PRIVATE_KEY)).toString( + // "hex" + // ); + const key = generatePrivateKey(); + + const irys = new Irys({ + url: "https://node2.irys.xyz", + // url: "https://node2.irys.xyz", // URL of the node you want to connect to + token: "matic", // Token used for payment + // under 100kb is free. + key: key, + config: { providerUrl: "https://polygon-mainnet.infura.io" }, // Optional provider URL, only required when using Devnet + }); + + // try { + // const fundTx = await irys.fund(irys.utils.toAtomic(0.05)); + // console.log( + // `Successfully funded ${irys.utils.fromAtomic(fundTx.quantity)} ${ + // irys.token + // }` + // ); + // } catch (e) { + // console.log("Error uploading data ", e); + // } + + return irys; +} + +// encrypts the text +export async function POST(request: NextRequest) { + try { + const { + // await signAuthMessage(); + authSig, + messageToEncrypt, + standardContractType = "ERC721", + chain = "ethereum", + // https://developer.litprotocol.com/v3/sdk/access-control/evm/basic-examples#must-be-a-member-of-a-dao-molochdaov21-also-supports-daohaus + // https://lit-share-modal-v3-playground.netlify.app/ + tokens = "1", + contract, + } = await request.json(); + + // TODO: Zod validation to get types + + const { ciphertext, dataToEncryptHash, accessControlConditions } = + await encryptData({ + chain, + tokens, + contract, + standardContractType, + authSig: { ...authSig, derivedVia: "web3.eth.personal.sign" }, + dataToEncrypt: messageToEncrypt, + }); + + const irysTransactionId = await storeOnIrys( + createSchemaMetadata({ + cipherText: ciphertext, + dataToEncryptHash, + accessControlConditions: accessControlConditions, + }) + ); + + if (!irysTransactionId) { + return new Response(null, { + status: 500, + }); + } + + console.log(irysTransactionId); + + return NextResponse.json({ + url: `${process.env.GATEWAY_URL}/f/embed/${encodeURIComponent( + // https://github.com/ChainAgnostic/namespaces/blob/main/arweave/caip2.md + `arweave:7wIU:${irysTransactionId}` + // `https://gateway.irys.xyz/${irysTransactionId}` + )}`, + }); + } catch (err) { + console.error(err); + return new Response(null, { + status: 500, + }); + } +} + +// needed for preflight requests to succeed +export const OPTIONS = async (request: NextRequest) => { + return NextResponse.json({}); +}; + +function createSchemaMetadata(payload: { + cipherText: string; + dataToEncryptHash: string; + accessControlConditions: AccessControlConditions; +}) { + return { + "@context": ["https://schema.org", "https://schema.modprotocol.org"], + "@type": "WebPage", + name: "Token gated content", + image: "https://i.imgur.com/RO76xMR.png", + description: + "It looks like this app doesn't support Mods yet, but if it did, you'd see the Mod here. Click here to unlock the content if you have access", + "mod:model": { + // unique identifier for the renderer of this miniapp + "@type": "schema.modprotocol.org/lit-protocol/0.0.1/EncryptedData", + payload, + }, + }; +} diff --git a/examples/api/src/app/api/lit-protocol/search-nfts/route.ts b/examples/api/src/app/api/lit-protocol/search-nfts/route.ts new file mode 100644 index 00000000..7bff12bd --- /dev/null +++ b/examples/api/src/app/api/lit-protocol/search-nfts/route.ts @@ -0,0 +1,49 @@ +import { NextResponse, NextRequest } from "next/server"; + +export async function GET( + request: NextRequest +): Promise>> { + const wallet_address = request.nextUrl.searchParams.get("wallet_address"); + const q = request.nextUrl.searchParams.get("q") || ""; + + // paginate over all of them + let hasNext = true; + const allResults: any[] = []; + let query = `https://api.simplehash.com/api/v0/nfts/owners?chains=polygon,ethereum,optimism&wallet_addresses=${wallet_address}&limit=50`; + while (hasNext) { + const response = await fetch(query, { + method: "GET", + headers: { + accept: "application/json", + "X-API-KEY": process.env.SIMPLEHASH_API_KEY, + }, + }) + .then((response) => response.json()) + .catch((err) => console.error(err)); + + allResults.push(...response.nfts); + if (response.next) { + query = response.next; + } else { + hasNext = false; + } + } + + // todo: remove spam tokens + return NextResponse.json( + allResults + .filter((nft) => + nft?.contract?.name?.toLowerCase().includes(q?.toLowerCase()) + ) + .map((nft) => ({ + label: nft.contract.name, + value: { + contract_type: nft.contract.type, + chain: nft.chain, + contract_address: nft.contract_address, + image: nft.previews.image_small_url, + description: nft.description, + }, + })) + ); +} diff --git a/examples/api/src/app/api/open-graph/lib/url-handlers/arweave.ts b/examples/api/src/app/api/open-graph/lib/url-handlers/arweave.ts new file mode 100644 index 00000000..ba97e734 --- /dev/null +++ b/examples/api/src/app/api/open-graph/lib/url-handlers/arweave.ts @@ -0,0 +1,53 @@ +import { UrlMetadata } from "@mod-protocol/core"; +import { UrlHandler } from "../../types/url-handler"; + +async function handleArweave(url: string): Promise { + // `https://gateway.irys.xyz/${irysTransactionId}` + const transactionId = url.split(":")[2]; + + const reformattedUrl = `https://gateway.irys.xyz/${transactionId}`; + + const response = await fetch(reformattedUrl, { + method: "HEAD", + }); + + // Get content-type + const mimeType = response.headers.get("content-type"); + + if (mimeType === "application/json") { + try { + const arweaveData = await fetch(reformattedUrl); + + const body = await arweaveData.json(); + + // Check for schema + if (body["@type"] === "WebPage") + return { + image: { + url: body.image, + }, + "json-ld": { WebPage: [body] }, + description: body.description, + alt: body.name, + title: body.name, + logo: { + url: body.image, + }, + mimeType: "application/json", + }; + } catch (err) { + console.error(err); + } + } + // TODO: handle html + + return null; +} + +const urlHandler: UrlHandler = { + name: "Arweave", + matchers: ["arweave:7wIU:*"], + handler: handleArweave, +}; + +export default urlHandler; diff --git a/examples/api/src/app/api/open-graph/lib/url-handlers/local-fetch.ts b/examples/api/src/app/api/open-graph/lib/url-handlers/local-fetch.ts index 52a83454..4b03ed7c 100644 --- a/examples/api/src/app/api/open-graph/lib/url-handlers/local-fetch.ts +++ b/examples/api/src/app/api/open-graph/lib/url-handlers/local-fetch.ts @@ -3,6 +3,7 @@ import ogs from "open-graph-scraper"; import { UrlHandler } from "../../types/url-handler"; import { chainById } from "../chains/chain-index"; import { fetchNFTMetadata } from "../util"; +import * as cheerio from "cheerio"; async function localFetchHandler(url: string): Promise { // A versatile user agent for which most sites will return opengraph data @@ -14,6 +15,21 @@ async function localFetchHandler(url: string): Promise { const html = await response.text(); + const $ = cheerio.load(html, { decodeEntities: false, xmlMode: true }, false); + const jsonLdScripts = $('script[type="application/ld+json"]'); + + const linkedData = jsonLdScripts + .map((i, el) => { + try { + const html = $(el).text(); + return JSON.parse(html); + } catch (e) { + console.error("Error parsing JSON-LD:", e); + return null; + } + }) + .get(); + const { result: data } = await ogs({ html, customMetaTags: [ @@ -76,6 +92,16 @@ async function localFetchHandler(url: string): Promise { }); } + const groupLinkedDataByType: Record = linkedData.reduce( + (prev, next) => { + return { + ...prev, + [next["@type"]]: [...(prev[next["@type"]] ?? []), next], + }; + }, + {} + ); + const urlMetadata: UrlMetadata = { title: data.ogTitle, description: data.ogDescription || data.twitterDescription, @@ -85,6 +111,7 @@ async function localFetchHandler(url: string): Promise { url: data.ogLogo, } : undefined, + "json-ld": groupLinkedDataByType, publisher: data.ogSiteName, mimeType: response["headers"]["content-type"], nft: nftMetadata, diff --git a/examples/nextjs-shadcn/.env.example b/examples/nextjs-shadcn/.env.example index 36de561f..4a7db9d6 100644 --- a/examples/nextjs-shadcn/.env.example +++ b/examples/nextjs-shadcn/.env.example @@ -1,2 +1,4 @@ NEXT_PUBLIC_API_URL="http://localhost:3001/api" -NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID= \ No newline at end of file +NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID="" +NEXT_PUBLIC_URL="http://localhost:3000" +NEXT_PUBLIC_HOST="localhost:3000" \ No newline at end of file diff --git a/examples/nextjs-shadcn/next.config.js b/examples/nextjs-shadcn/next.config.js index 7cae711b..77bf1e82 100644 --- a/examples/nextjs-shadcn/next.config.js +++ b/examples/nextjs-shadcn/next.config.js @@ -1,6 +1,7 @@ module.exports = { reactStrictMode: true, transpilePackages: ["@mod-protocol/react"], + images: { domains: [ "*.i.imgur.com", diff --git a/examples/nextjs-shadcn/package.json b/examples/nextjs-shadcn/package.json index 30ba9a77..63bdfb34 100644 --- a/examples/nextjs-shadcn/package.json +++ b/examples/nextjs-shadcn/package.json @@ -37,4 +37,4 @@ "tsconfig": "*", "typescript": "^5.2.2" } -} +} \ No newline at end of file diff --git a/examples/nextjs-shadcn/src/app/editor-example.tsx b/examples/nextjs-shadcn/src/app/editor-example.tsx index a66382f4..dc2d7a16 100644 --- a/examples/nextjs-shadcn/src/app/editor-example.tsx +++ b/examples/nextjs-shadcn/src/app/editor-example.tsx @@ -1,6 +1,7 @@ "use client"; import * as React from "react"; +import { getAddress } from "viem"; // Core import { @@ -15,12 +16,15 @@ import { useEditor, EditorContent } from "@mod-protocol/react-editor"; import { creationMiniApps } from "@mod-protocol/miniapp-registry"; import { Embed, + EthPersonalSignActionResolverInit, ModManifest, fetchUrlMetadata, handleAddEmbed, handleOpenFile, handleSetInput, } from "@mod-protocol/core"; +import { SiweMessage } from "siwe"; +import { useAccount, useSignMessage } from "wagmi"; // UI implementation import { createRenderMentionsSuggestionConfig } from "@mod-protocol/react-ui-shadcn/dist/lib/mentions"; @@ -94,9 +98,65 @@ export default function EditorExample() { }), }); + const { address: unchecksummedAddress } = useAccount(); + const checksummedAddress = React.useMemo(() => { + if (!unchecksummedAddress) return null; + return getAddress(unchecksummedAddress); + }, [unchecksummedAddress]); + + const { signMessageAsync } = useSignMessage(); + + const getAuthSig = React.useCallback( + async ( + { + data: { statement, version, chainId }, + }: EthPersonalSignActionResolverInit, + { onSuccess, onError } + ): Promise => { + if (!checksummedAddress) { + window.alert("please connect your wallet"); + return; + } + try { + const siweMessage = new SiweMessage({ + domain: process.env.NEXT_PUBLIC_HOST, + address: checksummedAddress, + statement, + uri: process.env.NEXT_PUBLIC_URL, + version, + chainId: Number(chainId), + }); + const messageToSign = siweMessage.prepareMessage(); + + // Sign the message and format the authSig + const signature = await signMessageAsync({ message: messageToSign }); + const authSig = { + signature, + // derivedVia: "web3.eth.personal.sign", + signedMessage: messageToSign, + address: checksummedAddress, + }; + + onSuccess(authSig); + } catch (err) { + console.error(err); + onError(err); + } + }, + [signMessageAsync, checksummedAddress] + ); + const [currentMiniapp, setCurrentMiniapp] = React.useState(null); + const user = React.useMemo(() => { + return { + wallet: { + address: checksummedAddress, + }, + }; + }, [checksummedAddress]); + return (
@@ -134,6 +194,7 @@ export default function EditorExample() { input={getText()} embeds={getEmbeds()} api={API_URL} + user={user} variant="creation" manifest={currentMiniapp} renderers={renderers} @@ -141,6 +202,7 @@ export default function EditorExample() { onExitAction={() => setCurrentMiniapp(null)} onSetInputAction={handleSetInput(setText)} onAddEmbedAction={handleAddEmbed(addEmbed)} + onEthPersonalSignAction={getAuthSig} />
diff --git a/examples/nextjs-shadcn/src/app/embeds.tsx b/examples/nextjs-shadcn/src/app/embeds.tsx index 242d0a97..e4e1a548 100644 --- a/examples/nextjs-shadcn/src/app/embeds.tsx +++ b/examples/nextjs-shadcn/src/app/embeds.tsx @@ -1,6 +1,11 @@ "use client"; -import { ContextType, Embed } from "@mod-protocol/core"; +import { + ContextType, + Embed, + SendEthTransactionActionResolverEvents, + SendEthTransactionActionResolverInit, +} from "@mod-protocol/core"; import { contentMiniApps, defaultContentMiniApp, @@ -20,7 +25,14 @@ export function Embeds(props: { embeds: Array }) { const onSendEthTransactionAction = useMemo( () => - async ({ data, chainId }, { onConfirmed, onError, onSubmitted }) => { + async ( + { data, chainId }: SendEthTransactionActionResolverInit, + { + onConfirmed, + onError, + onSubmitted, + }: SendEthTransactionActionResolverEvents + ) => { try { const parsedChainId = parseInt(chainId); diff --git a/miniapps/chatgpt/src/action.ts b/miniapps/chatgpt/src/action.ts index 9ea1f3f4..cdd2bb51 100644 --- a/miniapps/chatgpt/src/action.ts +++ b/miniapps/chatgpt/src/action.ts @@ -7,7 +7,7 @@ const action: ModElement[] = [ { type: "input", placeholder: "Ask the AI anything", - clearable: true, + isClearable: true, ref: "prompt", // onchange: { // ref: "mySearchQueryRequest", diff --git a/miniapps/giphy-picker/src/error.ts b/miniapps/giphy-picker/src/error.ts index 4d6f4c5d..f22cb81f 100644 --- a/miniapps/giphy-picker/src/error.ts +++ b/miniapps/giphy-picker/src/error.ts @@ -22,7 +22,7 @@ const error: ModElement[] = [ ref: "myInput", type: "input", placeholder: "Search", - clearable: true, + isClearable: true, onchange: { ref: "mySearchQueryRequest", type: "GET", diff --git a/miniapps/giphy-picker/src/loading.ts b/miniapps/giphy-picker/src/loading.ts index 75775ac8..7f9312c7 100644 --- a/miniapps/giphy-picker/src/loading.ts +++ b/miniapps/giphy-picker/src/loading.ts @@ -22,7 +22,7 @@ const loading: ModElement[] = [ ref: "myInput", type: "input", placeholder: "Search", - clearable: true, + isClearable: true, onchange: { ref: "mySearchQueryRequest", type: "GET", diff --git a/miniapps/giphy-picker/src/success.ts b/miniapps/giphy-picker/src/success.ts index a3a328f9..2a697c55 100644 --- a/miniapps/giphy-picker/src/success.ts +++ b/miniapps/giphy-picker/src/success.ts @@ -22,7 +22,7 @@ const success: ModElement[] = [ ref: "myInput", type: "input", placeholder: "Search", - clearable: true, + isClearable: true, onchange: { ref: "mySearchQueryRequest", type: "GET", diff --git a/miniapps/lit-protocol-renderer/index.ts b/miniapps/lit-protocol-renderer/index.ts new file mode 100644 index 00000000..8e71bc78 --- /dev/null +++ b/miniapps/lit-protocol-renderer/index.ts @@ -0,0 +1 @@ +export { default as default } from "./src/manifest"; diff --git a/miniapps/lit-protocol-renderer/package.json b/miniapps/lit-protocol-renderer/package.json new file mode 100644 index 00000000..47a0ae13 --- /dev/null +++ b/miniapps/lit-protocol-renderer/package.json @@ -0,0 +1,10 @@ +{ + "name": "@miniapps/lit-protocol-renderer", + "main": "./index.ts", + "types": "./index.ts", + "version": "0.0.1", + "private": true, + "dependencies": { + "@mod-protocol/core": "^0.0.2" + } +} \ No newline at end of file diff --git a/miniapps/lit-protocol-renderer/src/decrypt.ts b/miniapps/lit-protocol-renderer/src/decrypt.ts new file mode 100644 index 00000000..c3865a41 --- /dev/null +++ b/miniapps/lit-protocol-renderer/src/decrypt.ts @@ -0,0 +1,116 @@ +import { ModElement } from "@mod-protocol/core"; + +const decrypt: ModElement[] = [ + { + type: "vertical-layout", + elements: [], + onload: { + ref: "decryption-request", + type: "POST", + body: { + json: { + type: "object", + value: { + authSig: { + type: "object", + value: { + sig: { + type: "string", + value: "{{refs.authSig.signature}}", + }, + signedMessage: { + type: "string", + value: "{{refs.authSig.signedMessage}}", + }, + address: { + type: "string", + value: "{{refs.authSig.address}}", + }, + }, + }, + payload: { + type: "object", + value: { + cipherTextRetrieved: { + type: "string", + value: + "{{embed.metadata.json-ld.WebPage[0].mod:model.payload.cipherText}}", + }, + dataToEncryptHashRetrieved: { + type: "string", + value: + "{{embed.metadata.json-ld.WebPage[0].mod:model.payload.dataToEncryptHash}}", + }, + accessControlConditions: { + type: "array", + value: [ + { + type: "object", + value: { + conditionType: { + type: "string", + value: + "{{embed.metadata.json-ld.WebPage[0].mod:model.payload.accessControlConditions[0].conditionType}}", + }, + contractAddress: { + type: "string", + value: + "{{embed.metadata.json-ld.WebPage[0].mod:model.payload.accessControlConditions[0].contractAddress}}", + }, + standardContractType: { + type: "string", + value: + "{{embed.metadata.json-ld.WebPage[0].mod:model.payload.accessControlConditions[0].standardContractType}}", + }, + chain: { + type: "string", + value: + "{{embed.metadata.json-ld.WebPage[0].mod:model.payload.accessControlConditions[0].chain}}", + }, + method: { + type: "string", + value: + "{{embed.metadata.json-ld.WebPage[0].mod:model.payload.accessControlConditions[0].method}}", + }, + parameters: { + type: "array", + value: [ + { + type: "string", + value: + "{{embed.metadata.json-ld.WebPage[0].mod:model.payload.accessControlConditions[0].parameters[0]}}", + }, + ], + }, + returnValueTest: { + type: "object", + value: { + comparator: { + type: "string", + value: + "{{embed.metadata.json-ld.WebPage[0].mod:model.payload.accessControlConditions[0].returnValueTest.comparator}}", + }, + value: { + type: "string", + value: + "{{embed.metadata.json-ld.WebPage[0].mod:model.payload.accessControlConditions[0].returnValueTest.value}}", + }, + }, + }, + }, + }, + ], + }, + }, + }, + }, + }, + }, + url: "{{api}}/lit-protocol-renderer", + onsuccess: "#success", + onerror: "#error", + onloading: "#loading", + }, + }, +]; +export default decrypt; diff --git a/miniapps/lit-protocol-renderer/src/error.ts b/miniapps/lit-protocol-renderer/src/error.ts new file mode 100644 index 00000000..11dbd9e2 --- /dev/null +++ b/miniapps/lit-protocol-renderer/src/error.ts @@ -0,0 +1,22 @@ +import { ModElement } from "@mod-protocol/core"; + +const error: ModElement[] = [ + { + type: "text", + label: "Failed to decrypt", + }, + // Buy token link? + { + type: "button", + label: "Get a token", + onclick: { + type: "OPENLINK", + url: "https://mint.fun/{{embed.metadata.json-ld.WebPage[0].mod:model.payload.accessControlConditions[0].chain}}/{{embed.metadata.json-ld.WebPage[0].mod:model.payload.accessControlConditions[0].contractAddress}}", + onsuccess: "#error", + onerror: "#error", + onloading: "#error", + }, + }, +]; + +export default error; diff --git a/miniapps/lit-protocol-renderer/src/loading.ts b/miniapps/lit-protocol-renderer/src/loading.ts new file mode 100644 index 00000000..662c54a9 --- /dev/null +++ b/miniapps/lit-protocol-renderer/src/loading.ts @@ -0,0 +1,13 @@ +import { ModElement } from "@mod-protocol/core"; + +const loading: ModElement[] = [ + { + type: "text", + label: "Decrypting", + }, + { + type: "circular-progress", + }, +]; + +export default loading; diff --git a/miniapps/lit-protocol-renderer/src/manifest.ts b/miniapps/lit-protocol-renderer/src/manifest.ts new file mode 100644 index 00000000..83b9fdc7 --- /dev/null +++ b/miniapps/lit-protocol-renderer/src/manifest.ts @@ -0,0 +1,24 @@ +import { ModManifest } from "@mod-protocol/core"; +import loading from "./loading"; +import rendering from "./rendering"; +import error from "./error"; +import success from "./success"; +import decrypt from "./decrypt"; + +const manifest: ModManifest = { + slug: "lit-protocol-renderer", + name: "Read token gated casts", + custodyAddress: "furlong.eth", + logo: "https://openseauserdata.com/files/2105703ca9fbe5116c26b9967a596abe.png", + custodyGithubUsername: "davidfurlong", + version: "0.0.1", + contentEntrypoints: rendering, + elements: { + "#loading": loading, + "#decrypt": decrypt, + "#error": error, + "#success": success, + }, +}; + +export default manifest; diff --git a/miniapps/lit-protocol-renderer/src/rendering.ts b/miniapps/lit-protocol-renderer/src/rendering.ts new file mode 100644 index 00000000..61e38f61 --- /dev/null +++ b/miniapps/lit-protocol-renderer/src/rendering.ts @@ -0,0 +1,43 @@ +import { ModConditionalElement } from "@mod-protocol/core"; + +const rendering: ModConditionalElement[] = [ + { + if: { + value: "{{embed.metadata.json-ld.WebPage[0].mod:model.@type}}", + match: { + equals: "schema.modprotocol.org/lit-protocol/0.0.1/EncryptedData", + }, + }, + element: [ + { + type: "card", + aspectRatio: 1200 / 630, + imageSrc: "{{embed.metadata.image.url}}", + elements: [ + { + type: "button", + loadingLabel: "Sign the message", + label: "Sign to decrypt the secret message", + onclick: { + onsuccess: "#decrypt", + onerror: "#error", + type: "web3.eth.personal.sign", + ref: "authSig", + data: { + // domain: "localhost:3000", + // address: "{{user.wallet.address}}", + statement: + "You are signing a message to prove you own this account", + // uri: "http://localhost:3000", + version: "1", + // FIXME + chainId: "1", + }, + }, + }, + ], + }, + ], + }, +]; +export default rendering; diff --git a/miniapps/lit-protocol-renderer/src/success.ts b/miniapps/lit-protocol-renderer/src/success.ts new file mode 100644 index 00000000..f0d85a8d --- /dev/null +++ b/miniapps/lit-protocol-renderer/src/success.ts @@ -0,0 +1,19 @@ +import { ModElement } from "@mod-protocol/core"; + +const success: ModElement[] = [ + { + type: "horizontal-layout", + elements: [ + { + type: "avatar", + src: "https://cdn-icons-png.flaticon.com/512/102/102288.png", + }, + { + type: "text", + label: "{{refs.decryption-request.response.data.decryptedString}}", + }, + ], + }, +]; + +export default success; diff --git a/miniapps/lit-protocol-renderer/tsconfig.json b/miniapps/lit-protocol-renderer/tsconfig.json new file mode 100644 index 00000000..37906aab --- /dev/null +++ b/miniapps/lit-protocol-renderer/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "tsconfig/base.json", + "include": ["."], + "exclude": ["dist", "build", "node_modules"] +} diff --git a/miniapps/lit-protocol/index.ts b/miniapps/lit-protocol/index.ts new file mode 100644 index 00000000..8e71bc78 --- /dev/null +++ b/miniapps/lit-protocol/index.ts @@ -0,0 +1 @@ +export { default as default } from "./src/manifest"; diff --git a/miniapps/lit-protocol/package.json b/miniapps/lit-protocol/package.json new file mode 100644 index 00000000..02e29abb --- /dev/null +++ b/miniapps/lit-protocol/package.json @@ -0,0 +1,10 @@ +{ + "name": "@miniapps/lit-protocol", + "main": "./index.ts", + "types": "./index.ts", + "version": "0.0.1", + "private": true, + "dependencies": { + "@mod-protocol/core": "^0.0.2" + } +} \ No newline at end of file diff --git a/miniapps/lit-protocol/src/action.ts b/miniapps/lit-protocol/src/action.ts new file mode 100644 index 00000000..c7f1a2b5 --- /dev/null +++ b/miniapps/lit-protocol/src/action.ts @@ -0,0 +1,100 @@ +import { ModElement } from "@mod-protocol/core"; + +const action: ModElement[] = [ + { + type: "vertical-layout", + elements: [ + { + type: "textarea", + ref: "plaintext", + placeholder: "Content to token gate", + }, + { + type: "combobox", + onload: { + ref: "getOptions", + type: "GET", + onsuccess: "#action", + onerror: "#error", + url: "{{api}}/lit-protocol/search-nfts?wallet_address={{user.wallet.address}}&q={{refs.contract.value}}", + }, + ref: "contract", + valueRef: "selectedContract", + optionsRef: "refs.getOptions.response.data", + placeholder: "Pick token to gate by", + }, + { + type: "button", + label: "Publish", + onclick: { + type: "POST", + ref: "encryption", + url: "{{api}}/lit-protocol", + body: { + json: { + type: "object", + value: { + authSig: { + type: "object", + value: { + sig: { + type: "string", + value: "{{refs.authSig.signature}}", + }, + signedMessage: { + type: "string", + value: "{{refs.authSig.signedMessage}}", + }, + address: { + type: "string", + value: "{{refs.authSig.address}}", + }, + }, + }, + standardContractType: { + type: "string", + value: "{{refs.selectedContract.value.contract_type}}", + }, + messageToEncrypt: { + type: "string", + value: "{{refs.plaintext.value}}", + }, + chain: { + type: "string", + value: "{{refs.selectedContract.value.chain}}", + }, + tokens: { + type: "string", + value: "1", + }, + contract: { + type: "string", + value: "{{refs.selectedContract.value.contract_address}}", + }, + }, + }, + }, + onsuccess: { + type: "ADDEMBED", + url: "{{refs.encryption.response.data.url}}", + name: "Encrypted data", + mimeType: "application/ld+json", + onsuccess: { + type: "EXIT", + }, + }, + onloading: "#loading", + onerror: "#error", + }, + }, + { + type: "button", + variant: "secondary", + label: "Manual entry", + onclick: "#advanced-form", + }, + ], + }, +]; + +export default action; diff --git a/miniapps/lit-protocol/src/advanced-form.ts b/miniapps/lit-protocol/src/advanced-form.ts new file mode 100644 index 00000000..9689e324 --- /dev/null +++ b/miniapps/lit-protocol/src/advanced-form.ts @@ -0,0 +1,123 @@ +import { ModElement } from "@mod-protocol/core"; + +const action: ModElement[] = [ + { + type: "vertical-layout", + elements: [ + { + type: "input", + ref: "plaintext", + placeholder: "Content to token gate", + }, + { + type: "select", + options: [ + { + value: "ethereum", + label: "Ethereum", + }, + { + value: "optimism", + label: "Optimism", + }, + { + value: "polygon", + label: "Polygon", + }, + ], + ref: "chain", + placeholder: "Chain", + }, + { + type: "select", + options: [ + { + value: "ERC721", + label: "ERC721", + }, + { + value: "ERC1155", + label: "ERC1155", + }, + ], + ref: "standardContractType", + placeholder: "Contract type", + }, + { + type: "input", + isClearable: true, + ref: "contract", + placeholder: "Contract address", + }, + { + type: "button", + label: "Publish", + onclick: { + type: "POST", + ref: "encryption", + url: "{{api}}/lit-protocol", + body: { + json: { + type: "object", + value: { + authSig: { + type: "object", + value: { + sig: { + type: "string", + value: "{{refs.authSig.signature}}", + }, + signedMessage: { + type: "string", + value: "{{refs.authSig.signedMessage}}", + }, + address: { + type: "string", + value: "{{refs.authSig.address}}", + }, + }, + }, + standardContractType: { + type: "string", + value: "{{refs.standardContractType.value}}", + }, + messageToEncrypt: { + type: "string", + value: "{{refs.plaintext.value}}", + }, + chain: { + type: "string", + value: "{{refs.chain.value}}", + }, + tokens: { + type: "string", + value: "1", + }, + contract: { + type: "string", + value: "{{refs.contract.value}}", + }, + }, + }, + }, + onsuccess: { + type: "ADDEMBED", + url: "{{refs.encryption.response.data.url}}", + name: "Encrypted data", + mimeType: "application/ld+json", + onsuccess: { + type: "EXIT", + }, + }, + onloading: "#loading", + onerror: { + // fixme: show error + type: "EXIT", + }, + }, + }, + ], + }, +]; + +export default action; diff --git a/miniapps/lit-protocol/src/error.ts b/miniapps/lit-protocol/src/error.ts new file mode 100644 index 00000000..a69c6e5b --- /dev/null +++ b/miniapps/lit-protocol/src/error.ts @@ -0,0 +1,15 @@ +import { ModElement } from "@mod-protocol/core"; + +const error: ModElement[] = [ + { + type: "text", + label: "ERROR: Something went wrong", + }, + { + type: "button", + label: "Retry", + onclick: "#sign", + }, +]; + +export default error; diff --git a/miniapps/lit-protocol/src/loading.ts b/miniapps/lit-protocol/src/loading.ts new file mode 100644 index 00000000..e4e1b699 --- /dev/null +++ b/miniapps/lit-protocol/src/loading.ts @@ -0,0 +1,13 @@ +import { ModElement } from "@mod-protocol/core"; + +const loading: ModElement[] = [ + { + type: "text", + label: "Encrypting", + }, + { + type: "circular-progress", + }, +]; + +export default loading; diff --git a/miniapps/lit-protocol/src/manifest.ts b/miniapps/lit-protocol/src/manifest.ts new file mode 100644 index 00000000..2e45f334 --- /dev/null +++ b/miniapps/lit-protocol/src/manifest.ts @@ -0,0 +1,61 @@ +import { ModManifest } from "@mod-protocol/core"; +import action from "./action"; +import loading from "./loading"; +import error from "./error"; +import sign from "./sign"; +import advancedForm from "./advanced-form"; + +const manifest: ModManifest = { + slug: "lit-protocol", + name: "Token gate", + custodyAddress: "furlong.eth", + logo: "https://openseauserdata.com/files/2105703ca9fbe5116c26b9967a596abe.png", + custodyGithubUsername: "davidfurlong", + version: "0.0.1", + creationEntrypoints: sign, + permissions: ["web3.eth.personal.sign"], + modelDefinitions: { + EncryptedData: { + type: "object", + properties: { + cipherText: { type: "string" }, + dataToEncryptHash: { type: "string" }, + accessControlConditions: { + type: "array", + minItems: 1, + items: { + type: "object", + properties: { + conditionType: { type: "string" }, + contractAddress: { type: "string" }, + standardContractType: { type: "string" }, + chain: { type: "string" }, + method: { type: "string" }, + parameters: { + type: "array", + minItems: 1, + items: { type: "string" }, + }, + returnValueTest: { + type: "object", + properties: { + comparator: { type: "string" }, + value: { type: "string" }, + }, + }, + }, + }, + }, + }, + }, + }, + elements: { + "#sign": sign, + "#error": error, + "#action": action, + "#loading": loading, + "#advanced-form": advancedForm, + }, +}; + +export default manifest; diff --git a/miniapps/lit-protocol/src/sign.ts b/miniapps/lit-protocol/src/sign.ts new file mode 100644 index 00000000..85160eba --- /dev/null +++ b/miniapps/lit-protocol/src/sign.ts @@ -0,0 +1,31 @@ +import { ModElement } from "@mod-protocol/core"; + +const action: ModElement[] = [ + { + type: "vertical-layout", + elements: [ + { + type: "text", + label: "Sign to prove ownership of this account", + }, + { + type: "button", + label: "Sign", + onclick: { + onsuccess: "#action", + onerror: "#error", + type: "web3.eth.personal.sign", + ref: "authSig", + data: { + statement: "", + version: "1", + // FIXME + chainId: "1", + }, + }, + }, + ], + }, +]; + +export default action; diff --git a/miniapps/lit-protocol/tsconfig.json b/miniapps/lit-protocol/tsconfig.json new file mode 100644 index 00000000..37906aab --- /dev/null +++ b/miniapps/lit-protocol/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "tsconfig/base.json", + "include": ["."], + "exclude": ["dist", "build", "node_modules"] +} diff --git a/miniapps/nft-minter/src/view.ts b/miniapps/nft-minter/src/view.ts index dfd6cffd..66863811 100644 --- a/miniapps/nft-minter/src/view.ts +++ b/miniapps/nft-minter/src/view.ts @@ -5,7 +5,8 @@ const view: ModElement[] = [ type: "card", imageSrc: "{{embed.metadata.image.url}}", aspectRatio: 16 / 11, - topLeftBadge: "@{{embed.metadata.nft.collection.creator.username}}", + // fixme: may be undefined, in that case dont render. + topLeftBadge: "{{embed.metadata.nft.collection.creator.username}}", onclick: { type: "OPENLINK", url: "{{embed.metadata.nft.collection.openSeaUrl}}", diff --git a/miniapps/zora-nft-minter/src/view.ts b/miniapps/zora-nft-minter/src/view.ts index 015d777e..301d2726 100644 --- a/miniapps/zora-nft-minter/src/view.ts +++ b/miniapps/zora-nft-minter/src/view.ts @@ -5,11 +5,7 @@ const view: ModElement[] = [ type: "card", imageSrc: "{{embed.metadata.image.url}}", aspectRatio: 16 / 11, - topLeftBadge: "@{{embed.metadata.nft.collection.creator.username}}", - onclick: { - type: "OPENLINK", - url: "{{embed.url}}", - }, + topLeftBadge: "{{embed.metadata.nft.collection.creator.username}}", elements: [ { type: "horizontal-layout", diff --git a/packages/core/package.json b/packages/core/package.json index a9dcd362..e92e4689 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -26,6 +26,7 @@ }, "dependencies": { "@mod-protocol/farcaster": "^0.0.1", + "json-schema": "^0.4.0", "lodash.get": "^4.4.2", "lodash.isarray": "^4.0.0", "lodash.isstring": "^4.0.1", @@ -34,4 +35,4 @@ "lodash.tonumber": "^4.0.3", "lodash.tostring": "^4.1.4" } -} \ No newline at end of file +} diff --git a/packages/core/src/embeds.ts b/packages/core/src/embeds.ts index d338fd3b..e19c171c 100644 --- a/packages/core/src/embeds.ts +++ b/packages/core/src/embeds.ts @@ -16,7 +16,8 @@ export function isVideoEmbed(embed: Embed) { export function hasFullSizedImage(embed: Embed) { return ( embed.metadata?.image?.url && - embed.metadata?.image.width !== embed.metadata.image.height + (embed.metadata?.image.width !== embed.metadata.image.height || + (!embed.metadata?.image.width && !embed.metadata.image.height)) ); } @@ -55,6 +56,8 @@ export type UrlMetadata = { width?: number; height?: number; }; + // map of schema.org types to arrays of schema.org definitions + "json-ld"?: Record; description?: string; alt?: string; title?: string; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e07c8cf6..69b326e8 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,4 +1,10 @@ -export type { ModElement, ModAction, ModManifest, ModEvent } from "./manifest"; +export type { + ModElement, + ModAction, + ModManifest, + ModEvent, + ModConditionalElement, +} from "./manifest"; export type { ModElementRef, HttpActionResolver, @@ -16,6 +22,9 @@ export type { OpenLinkActionResolver, OpenLinkActionResolverInit, OpenLinkActionResolverEvents, + EthPersonalSignActionResolver, + EthPersonalSignActionResolverEvents, + EthPersonalSignActionResolverInit, SendEthTransactionActionResolverInit, SendEthTransactionActionResolverEvents, SendEthTransactionActionResolver, diff --git a/packages/core/src/manifest.ts b/packages/core/src/manifest.ts index 7894670e..823adb18 100644 --- a/packages/core/src/manifest.ts +++ b/packages/core/src/manifest.ts @@ -1,4 +1,6 @@ -type ModConditionalElement = { +import type { JSONSchema7 } from "json-schema"; + +export type ModConditionalElement = { element: ModElement[]; if: ValueOp; }; @@ -15,10 +17,12 @@ export type ModManifest = { /** A valid url pointing to an image file, it should be a square */ logo: string; version: string; + modelDefinitions?: Record; creationEntrypoints?: ModElement[]; contentEntrypoints?: ModConditionalElement[]; elements?: Record; - permissions?: string[]; + // perhaps data.user.wallet.address is better. + permissions?: Array<"user.wallet.address" | "web3.eth.personal.sign">; }; export type ModEvent = @@ -77,9 +81,10 @@ type HTTPBody = formData: Record; }; -type HTTPAction = BaseAction & { url: string } & ( +export type HTTPAction = BaseAction & { url: string } & ( | { type: "GET"; + searchParams?: Record; } | { type: "POST"; @@ -117,6 +122,12 @@ type OpenLinkAction = BaseAction & { url: string; }; +export type EthPersonalSignData = { + statement: string; + version: string; + chainId: string; +}; + export type EthTransactionData = { to: string; from: string; @@ -124,6 +135,11 @@ export type EthTransactionData = { value?: string; }; +type EthPersonalSignAction = BaseAction & { + type: "web3.eth.personal.sign"; + data: EthPersonalSignData; +}; + type SendEthTransactionAction = BaseAction & { type: "SENDETHTRANSACTION"; chainId: string; @@ -147,6 +163,7 @@ export type ModAction = | AddEmbedAction | SetInputAction | OpenLinkAction + | EthPersonalSignAction | SendEthTransactionAction | ExitAction; @@ -171,6 +188,7 @@ export type ModElement = | { type: "button"; label: string; + loadingLabel?: string; variant?: "primary" | "secondary" | "destructive"; onclick: ModEvent; } @@ -188,10 +206,25 @@ export type ModElement = onload?: ModEvent; } | { + type: "textarea"; ref?: string; + placeholder?: string; + onchange?: ModEvent; + onsubmit?: ModEvent; + } + | { + type: "select"; + options: Array<{ label: string; value: any }>; + ref?: string; + placeholder?: string; + isClearable?: boolean; + onchange?: ModEvent; + } + | { type: "input"; + ref?: string; placeholder?: string; - clearable?: boolean; + isClearable?: boolean; onchange?: ModEvent; onsubmit?: ModEvent; } @@ -200,16 +233,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/packages/core/src/renderer.ts b/packages/core/src/renderer.ts index e89d9ad1..2e5465f4 100644 --- a/packages/core/src/renderer.ts +++ b/packages/core/src/renderer.ts @@ -12,6 +12,8 @@ import { Op, ConditionalFlow, EthTransactionData, + EthPersonalSignData, + HTTPAction, } from "./manifest"; import { Embed } from "./embeds"; @@ -41,6 +43,7 @@ export type ModElementRef = | { type: "button"; label: string; + loadingLabel?: string; variant?: "primary" | "secondary" | "destructive"; isLoading: boolean; isDisabled: boolean; @@ -66,6 +69,34 @@ export type ModElementRef = }; elements?: T[]; } + | { + type: "combobox"; + isClearable?: boolean; + placeholder?: string; + options: Array<{ label: string; value: any }> | null; + events: { + onLoad: () => void; + onChange: (input: string) => void; + onPick: (newValue: any) => void; + }; + } + | { + type: "textarea"; + placeholder?: string; + events: { + onChange: (input: string) => void; + onSubmit: (input: string) => void; + }; + } + | { + type: "select"; + isClearable: boolean; + placeholder?: string; + options: Array<{ label: string; value: any }>; + events: { + onChange: (input: string) => void; + }; + } | { type: "input"; isClearable: boolean; @@ -128,6 +159,11 @@ export type ModElementRef = export type CreationContext = { input: any; + user?: { + wallet?: { + address: string; + }; + }; embeds: Embed[]; /** The url of the api hosting the mini-app backends. (including /api) **/ api: string; @@ -235,6 +271,26 @@ export interface OpenLinkActionResolver { ): void; } +export type EthPersonalSignActionResolverInit = { + data: EthPersonalSignData; +}; + +export type EthPersonalSignActionResolverEvents = { + onSuccess: (data: { + signature: string; + signedMessage: string; + address: string; + }) => void; + onError(error: { message: string }): void; +}; + +export interface EthPersonalSignActionResolver { + ( + init: EthPersonalSignActionResolverInit, + events: EthPersonalSignActionResolverEvents + ): void; +} + export type SendEthTransactionActionResolverInit = { data: EthTransactionData; chainId: string; @@ -370,6 +426,7 @@ export type RendererOptions = { onSetInputAction: SetInputActionResolver; onAddEmbedAction: AddEmbedActionResolver; onOpenLinkAction: OpenLinkActionResolver; + onEthPersonalSignAction: EthPersonalSignActionResolver; onSendEthTransactionAction: SendEthTransactionActionResolver; onExitAction: ExitActionResolver; } & ( @@ -390,7 +447,37 @@ export class Renderer { promise: Promise; ref: ModAction; } | null = null; - private refs: Record = {}; + private refs: Record = { + // mintTx: { + // hash: "0x3b0801f89481830aa9e49999356125482a85e41d353a7d09d9257185020a40cd", + // }, + // txDataRequest: { + // response: { + // data: { + // status: "incomplete", + // orderIds: ["mint:0x22be0b70893648875d862b4473057e1e82a812a6"], + // data: { + // from: "0x8d25687829d6b85d9e0020b8c89e3ca24de20a89", + // to: "0x22be0b70893648875d862b4473057e1e82a812a6", + // data: "0x9dbb844d00000000000000000000000004e2516a2c207e84a1839755675dfd8ef6302f0a0000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000a00000000000000000000000008d25687829d6b85d9e0020b8c89e3ca24de20a8900000000000000000000000000000000000000000000000000000000000000200000000000000000000000008d25687829d6b85d9e0020b8c89e3ca24de20a891d4da48b00000000", + // value: "0x02c2ad68fd9000", + // }, + // check: { + // endpoint: "/execute/status/v1", + // method: "POST", + // body: { + // kind: "transaction", + // }, + // }, + // chainId: "7777777", + // explorer: { + // name: "Explorer", + // url: "https://explorer.zora.energy", + // }, + // }, + // }, + // }, + }; private context: Readonly; private manifestContext: Record = {}; private readonly manifest: ModManifest; @@ -401,6 +488,7 @@ export class Renderer { private onAddEmbedAction: AddEmbedActionResolver; private onOpenLinkAction: OpenLinkActionResolver; private onSendEthTransactionAction: SendEthTransactionActionResolver; + private onEthPersonalSignAction: EthPersonalSignActionResolver; private onExitAction: ExitActionResolver; constructor(options: RendererOptions) { @@ -412,6 +500,7 @@ export class Renderer { this.onSetInputAction = options.onSetInputAction; this.onAddEmbedAction = options.onAddEmbedAction; this.onOpenLinkAction = options.onOpenLinkAction; + this.onEthPersonalSignAction = options.onEthPersonalSignAction; this.onSendEthTransactionAction = options.onSendEthTransactionAction; this.onExitAction = options.onExitAction; @@ -453,6 +542,10 @@ export class Renderer { this.onAddEmbedAction = resolver; } + setEthPersonalSignActionResolver(resolver: EthPersonalSignActionResolver) { + this.onEthPersonalSignAction = resolver; + } + setSendEthTransactionActionResolver( resolver: SendEthTransactionActionResolver ) { @@ -478,7 +571,59 @@ export class Renderer { refs: this.refs, }); } + private constructHttpAction(action: HTTPAction) { + const url = new URL(this.replaceInlineContext(action.url)); + // set query params + if (action.type === "GET" && action.searchParams) { + Object.entries(action.searchParams).forEach(([key, value]) => { + url.searchParams.set(key, this.replaceInlineContext(value)); + }); + } + let options: HttpActionResolverInit = { + url: url.toString(), + method: action.type, + }; + if ( + (action.type === "POST" || + action.type === "PUT" || + action.type === "PATCH") && + action.body + ) { + if ("json" in action.body) { + const interpretJson = (json: JsonType): any => { + if (json.type === "object") { + return mapValues(json.value, (val) => interpretJson(val)); + } + if (json.type === "array") { + return json.value.map((val) => interpretJson(val)); + } + if (json.type === "string") { + return this.replaceInlineContext(json.value); + } + return json.value; + }; + options.body = JSON.stringify(interpretJson(action.body.json)); + options.headers = { + "content-type": "application/json", + }; + } else { + const formData = new FormData(); + + for (const name in action.body.formData) { + const l = action.body.formData[name]; + if (l.type === "string") { + formData.append(name, this.replaceInlineContext(l.value)); + } else { + formData.append(name, get({ refs: this.refs }, l.value)); + } + } + + options.body = formData; + } + } + return options; + } private executeAction(action: ModAction) { switch (action.type) { case "GET": @@ -486,49 +631,7 @@ export class Renderer { case "PUT": case "PATCH": case "DELETE": { - let options: HttpActionResolverInit = { - url: this.replaceInlineContext(action.url), - method: action.type, - }; - if ( - (action.type === "POST" || - action.type === "PUT" || - action.type === "PATCH") && - action.body - ) { - if ("json" in action.body) { - const interpretJson = (json: JsonType): any => { - if (json.type === "object") { - return mapValues(json.value, (val) => interpretJson(val)); - } - if (json.type === "array") { - return json.value.map((val) => interpretJson(val)); - } - if (json.type === "string") { - return this.replaceInlineContext(json.value); - } - return json.value; - }; - options.body = JSON.stringify(interpretJson(action.body.json)); - options.headers = { - "content-type": "application/json", - }; - } else { - const formData = new FormData(); - - for (const name in action.body.formData) { - const l = action.body.formData[name]; - - if (l.type === "string") { - formData.append(name, this.replaceInlineContext(l.value)); - } else { - formData.append(name, get({ refs: this.refs }, l.value)); - } - } - - options.body = formData; - } - } + const options = this.constructHttpAction(action); if (action.ref) { set(this.refs, action.ref, { progress: 0 }); @@ -543,7 +646,6 @@ export class Renderer { if (this.asyncAction?.promise !== promise) { return; } - // CODE GOES HERE }, onSuccess: (response) => { resolve(); @@ -779,6 +881,73 @@ export class Renderer { }; break; } + case "web3.eth.personal.sign": { + const promise = new Promise((resolve) => { + setTimeout(() => { + this.onEthPersonalSignAction( + { + data: { + // domain: this.replaceInlineContext(action.data.domain), + // address: this.replaceInlineContext(action.data.address), + statement: this.replaceInlineContext(action.data.statement), + // uri: this.replaceInlineContext(action.data.uri), + version: this.replaceInlineContext(action.data.version), + chainId: this.replaceInlineContext(action.data.chainId), + }, + }, + { + onSuccess: ({ signature, signedMessage, address }) => { + resolve(); + + if (this.asyncAction?.promise !== promise) { + return; + } + + this.asyncAction = null; + + if (action.ref) { + set(this.refs, action.ref, { + signature, + signedMessage, + address, + }); + } + + if (action.onsuccess) { + this.stepIntoOrTriggerAction(action.onsuccess); + } + }, + onError: (error) => { + resolve(); + + if (this.asyncAction?.promise !== promise) { + return; + } + + if (action.ref) { + set(this.refs, action.ref, { error }); + } + + this.asyncAction = null; + + if (action.onerror) { + this.stepIntoOrTriggerAction(action.onerror); + } + + this.onTreeChange(); + }, + } + ); + }, 1); + }); + + this.asyncAction = { + promise, + ref: action, + }; + + break; + } case "SENDETHTRANSACTION": { const promise = new Promise((resolve) => { setTimeout(() => { @@ -1001,6 +1170,7 @@ export class Renderer { return fn( { type: "button", + loadingLabel: this.replaceInlineContext(el.loadingLabel ?? ""), label: this.replaceInlineContext(el.label), isLoading: this.asyncAction?.ref === el.onclick, isDisabled: Boolean(this.asyncAction), @@ -1060,11 +1230,113 @@ export class Renderer { key ); } + case "select": { + return fn( + { + type: "select", + isClearable: el.isClearable || false, + placeholder: el.placeholder + ? this.replaceInlineContext(el.placeholder) + : el.placeholder, + options: el.options.map( + ( + option + ): { + label: string; + value: any; + } => ({ + value: option.value, + label: this.replaceInlineContext(option.label), + }) + ), + events: { + onChange: (value: string) => { + if (el.ref) { + set(this.refs, el.ref, { value }); + } + + if (el.onchange) { + this.stepIntoOrTriggerAction(el.onchange); + } + }, + }, + }, + key + ); + } + case "textarea": { + return fn( + { + type: "textarea", + placeholder: el.placeholder, + events: { + onChange: (value: string) => { + if (el.ref) { + set(this.refs, el.ref, { value }); + } + + if (el.onchange) { + this.stepIntoOrTriggerAction(el.onchange); + } + }, + onSubmit: (value: string) => { + if (el.ref) { + set(this.refs, el.ref, { value }); + } + + if (el.onchange) { + this.stepIntoOrTriggerAction(el.onchange); + } + }, + }, + }, + key + ); + } + case "combobox": { + const resolvedResults: Array<{ label: string; value: any }> | null = + isString(el.optionsRef) + ? get({ refs: this.refs }, el.optionsRef, null) + : null; + return fn( + { + type: "combobox", + isClearable: el.isClearable, + placeholder: this.replaceInlineContext(el.placeholder ?? ""), + options: resolvedResults, + events: { + onLoad: () => { + if (el.onload) { + this.stepIntoOrTriggerAction(el.onload); + } + }, + onChange: (value: string) => { + if (el.ref) { + set(this.refs, el.ref, { value }); + } + + if (el.onchange) { + this.stepIntoOrTriggerAction(el.onchange); + } + }, + onPick: (value: any) => { + if (el.valueRef) { + set(this.refs, el.valueRef, { value }); + } + if (el.onpick) { + this.stepIntoOrTriggerAction(el.onpick); + } + }, + }, + }, + key + ); + } case "input": { return fn( { type: "input", - isClearable: el.clearable || false, + isClearable: el.isClearable || false, placeholder: el.placeholder, events: { onChange: (value: string) => { diff --git a/packages/miniapp-registry/src/index.ts b/packages/miniapp-registry/src/index.ts index 7300e2d5..93f26c79 100644 --- a/packages/miniapp-registry/src/index.ts +++ b/packages/miniapp-registry/src/index.ts @@ -9,16 +9,22 @@ import NFTMinter from "@miniapps/nft-minter"; import UrlRender from "@miniapps/url-render"; import ImageRender from "@miniapps/image-render"; import ChatGPTShorten from "@miniapps/chatgpt-shorten"; +// import LitProtocol from "@miniapps/lit-protocol"; +// import LitProtocolRenderer from "@miniapps/lit-protocol-renderer"; +import ZoraNftMinter from "@miniapps/zora-nft-minter"; export const allMiniApps = [ InfuraIPFSUpload, LivepeerVideo, GiphyPicker, VideoRender, + ZoraNftMinter, NFTMinter, ImageRender, ChatGPTShorten, ChatGPT, + // LitProtocol, + // LitProtocolRenderer, ]; export const creationMiniApps: ModManifest[] = allMiniApps.filter( diff --git a/packages/react-editor/package.json b/packages/react-editor/package.json index 8cf43732..9115107f 100644 --- a/packages/react-editor/package.json +++ b/packages/react-editor/package.json @@ -11,8 +11,8 @@ }, "dependencies": { "@tiptap/core": "^2.0.4", - "@mod-protocol/core": "^0.0.2", - "@mod-protocol/farcaster": "^0.0.1", + "@mod-protocol/core": "*", + "@mod-protocol/farcaster": "*", "@tiptap/extension-document": "^2.0.4", "@tiptap/extension-hard-break": "^2.0.4", "@tiptap/extension-history": "^2.0.4", diff --git a/packages/react-editor/src/create-editor-config.tsx b/packages/react-editor/src/create-editor-config.tsx index b294ccbe..f552f081 100644 --- a/packages/react-editor/src/create-editor-config.tsx +++ b/packages/react-editor/src/create-editor-config.tsx @@ -29,7 +29,7 @@ export function createEditorConfig({ editorProps: { attributes: { // min-height allows clicking in the box and creating focus on the input - // FIXME: configurable/options + // TODO: configurable/options style: "outline: 0; min-height: 200px;", }, // attributes: { diff --git a/packages/react-ui-shadcn/package.json b/packages/react-ui-shadcn/package.json index 7bd71088..abc529f6 100644 --- a/packages/react-ui-shadcn/package.json +++ b/packages/react-ui-shadcn/package.json @@ -8,10 +8,10 @@ "dev": "npm run build -- --watch" }, "dependencies": { - "@mod-protocol/core": "^0.0.2", - "@mod-protocol/farcaster": "^0.0.1", - "@mod-protocol/react": "^0.0.2", - "@mod-protocol/react-editor": "^0.0.2", + "@mod-protocol/core": "*", + "@mod-protocol/farcaster": "*", + "@mod-protocol/react": "*", + "@mod-protocol/react-editor": "*", "@primer/octicons-react": "^19.5.0", "@radix-ui/react-aspect-ratio": "^1.0.3", "@radix-ui/react-avatar": "^1.0.4", @@ -19,6 +19,7 @@ "@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-popover": "^1.0.6", "@radix-ui/react-scroll-area": "^1.0.4", + "@radix-ui/react-select": "^2.0.0", "@radix-ui/react-slot": "^1.0.2", "@radix-ui/react-tabs": "^1.0.4", "class-variance-authority": "^0.7.0", @@ -42,4 +43,4 @@ "tsconfig": "*", "typescript": "^5.2.2" } -} +} \ No newline at end of file diff --git a/packages/react-ui-shadcn/src/components/channel-picker.tsx b/packages/react-ui-shadcn/src/components/channel-picker.tsx index d6ffd4b2..65b00180 100644 --- a/packages/react-ui-shadcn/src/components/channel-picker.tsx +++ b/packages/react-ui-shadcn/src/components/channel-picker.tsx @@ -23,7 +23,6 @@ type Props = { export function ChannelPicker(props: Props) { const { getChannels, onSelect } = props; - const [query, setQuery] = React.useState(""); const [open, setOpen] = React.useState(false); const [channelResults, setChannelResults] = React.useState( @@ -32,12 +31,12 @@ export function ChannelPicker(props: Props) { React.useEffect(() => { async function getChannelResults() { - const channels = await getChannels(query); + const channels = await getChannels(""); setChannelResults(channels); } getChannelResults(); - }, [query, setChannelResults, getChannels]); + }, [setChannelResults, getChannels]); const handleSelect = React.useCallback( (channel: Channel) => { diff --git a/packages/react-ui-shadcn/src/components/ui/select.tsx b/packages/react-ui-shadcn/src/components/ui/select.tsx new file mode 100644 index 00000000..5fbaa513 --- /dev/null +++ b/packages/react-ui-shadcn/src/components/ui/select.tsx @@ -0,0 +1,120 @@ +"use client"; + +import * as React from "react"; +import { CaretSortIcon, CheckIcon } from "@radix-ui/react-icons"; +import * as SelectPrimitive from "@radix-ui/react-select"; + +import { cn } from "lib/utils"; + +const Select = SelectPrimitive.Root; + +const SelectGroup = SelectPrimitive.Group; + +const SelectValue = SelectPrimitive.Value; + +const SelectTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + {children} + + + + +)); +SelectTrigger.displayName = SelectPrimitive.Trigger.displayName; + +const SelectContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, position = "popper", ...props }, ref) => ( + + + + {children} + + + +)); +SelectContent.displayName = SelectPrimitive.Content.displayName; + +const SelectLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +SelectLabel.displayName = SelectPrimitive.Label.displayName; + +const SelectItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)); +SelectItem.displayName = SelectPrimitive.Item.displayName; + +const SelectSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +SelectSeparator.displayName = SelectPrimitive.Separator.displayName; + +export { + Select, + SelectGroup, + SelectValue, + SelectTrigger, + SelectContent, + SelectLabel, + SelectItem, + SelectSeparator, +}; diff --git a/packages/react-ui-shadcn/src/components/ui/textarea.tsx b/packages/react-ui-shadcn/src/components/ui/textarea.tsx new file mode 100644 index 00000000..f45c5891 --- /dev/null +++ b/packages/react-ui-shadcn/src/components/ui/textarea.tsx @@ -0,0 +1,24 @@ +import * as React from "react"; + +import { cn } from "lib/utils"; + +export interface TextareaProps + extends React.TextareaHTMLAttributes {} + +const Textarea = React.forwardRef( + ({ className, ...props }, ref) => { + return ( +