Skip to content

[Waiting] mod: Lit protocol token gated casts #92

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 6 commits into
base: main
Choose a base branch
from
Draft
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"typescript.disableAutomaticTypeAcquisition": true
}
29 changes: 28 additions & 1 deletion docs/pages/create-mini-app/reference.mdx
Original file line number Diff line number Diff line change
@@ -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;
}
85 changes: 77 additions & 8 deletions docs/pages/metadata-cache.mdx
Original file line number Diff line number Diff line change
@@ -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 <RenderEmbed embed={embedData} />
})
```
cast.embeds.map((embed) => {
const metadata: UrlMetadata = metadataResponse[cast.hash][embed.url];
return <RenderEmbed metadata={metadata} />;
});
```

## `/api/cast-embeds-metadata/by-url`
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should consider renaming these endpoints. I suggest /embed-metadata/by-casts and /embed-metadata/by-urls

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

agreed, but that means no backwards compat; will have to let integrators know

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

agreed, but that means no backwards compat; will have to let integrators know


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.

<Tabs items={["Shell", "JS (fetch)", "Node.js v18+ (fetch)"]}>
<Tabs.Tab>
```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"]'
```
</Tabs.Tab>
<Tabs.Tab>
```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();
```
</Tabs.Tab>
<Tabs.Tab>
```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();
```
</Tabs.Tab>
</Tabs>

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 <RenderEmbed metadata={metadata} />;
```
7 changes: 6 additions & 1 deletion examples/api/.env.example
Original file line number Diff line number Diff line change
@@ -6,4 +6,9 @@ MICROLINK_API_KEY="REQUIRED"
OPENSEA_API_KEY="REQUIRED"
CHATGPT_API_SECRET="REQUIRED"
NEYNAR_API_SECRET="REQUIRED"
LIVEPEER_API_SECRET="REQUIRED"
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"
6 changes: 5 additions & 1 deletion examples/api/next.config.js
Original file line number Diff line number Diff line change
@@ -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",
],
};
12 changes: 10 additions & 2 deletions examples/api/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
130 changes: 130 additions & 0 deletions examples/api/src/app/api/cast-embeds-metadata/by-url/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
}
78 changes: 78 additions & 0 deletions examples/api/src/app/api/lit-protocol-renderer/route.ts
Original file line number Diff line number Diff line change
@@ -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<null | string> {
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({});
};
226 changes: 226 additions & 0 deletions examples/api/src/app/api/lit-protocol/route.ts
Original file line number Diff line number Diff line change
@@ -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
// <Blob> encryptedString
// <Uint8Array(32)> dataToEncryptHash`
const { ciphertext, dataToEncryptHash } = await LitJsSdk.encryptString(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Assuming encryption is happening on server to save client bundle size?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah, also Mini-apps can't load arbitrary JS today

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

theoretically this backend function here could be run a secure JS env on a client; not sure how much their lib depends on node apis directly

{
authSig,
accessControlConditions,
dataToEncrypt: dataToEncrypt,
chain,
},
litNodeClient
);
return { ciphertext, dataToEncryptHash, accessControlConditions };
}

async function storeOnIrys(jsonPayload: object): Promise<string | null> {
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(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment can be removed

// 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 {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can be removed if debug comment

// 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,
},
};
}
49 changes: 49 additions & 0 deletions examples/api/src/app/api/lit-protocol/search-nfts/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { NextResponse, NextRequest } from "next/server";

export async function GET(
request: NextRequest
): Promise<NextResponse<Array<{ value: any; label: string }>>> {
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,
},
}))
);
}
53 changes: 53 additions & 0 deletions examples/api/src/app/api/open-graph/lib/url-handlers/arweave.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { UrlMetadata } from "@mod-protocol/core";
import { UrlHandler } from "../../types/url-handler";

async function handleArweave(url: string): Promise<UrlMetadata | null> {
// `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;
Original file line number Diff line number Diff line change
@@ -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<UrlMetadata> {
// A versatile user agent for which most sites will return opengraph data
@@ -14,6 +15,21 @@ async function localFetchHandler(url: string): Promise<UrlMetadata> {

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<UrlMetadata> {
});
}

const groupLinkedDataByType: Record<string, object[]> = 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<UrlMetadata> {
url: data.ogLogo,
}
: undefined,
"json-ld": groupLinkedDataByType,
publisher: data.ogSiteName,
mimeType: response["headers"]["content-type"],
nft: nftMetadata,
4 changes: 3 additions & 1 deletion examples/nextjs-shadcn/.env.example
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
NEXT_PUBLIC_API_URL="http://localhost:3001/api"
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=""
NEXT_PUBLIC_URL="http://localhost:3000"
NEXT_PUBLIC_HOST="localhost:3000"
1 change: 1 addition & 0 deletions examples/nextjs-shadcn/next.config.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
module.exports = {
reactStrictMode: true,
transpilePackages: ["@mod-protocol/react"],

images: {
domains: [
"*.i.imgur.com",
2 changes: 1 addition & 1 deletion examples/nextjs-shadcn/package.json
Original file line number Diff line number Diff line change
@@ -37,4 +37,4 @@
"tsconfig": "*",
"typescript": "^5.2.2"
}
}
}
62 changes: 62 additions & 0 deletions examples/nextjs-shadcn/src/app/editor-example.tsx
Original file line number Diff line number Diff line change
@@ -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<void> => {
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<ModManifest | null>(null);

const user = React.useMemo(() => {
return {
wallet: {
address: checksummedAddress,
},
};
}, [checksummedAddress]);

return (
<form onSubmit={handleSubmit}>
<div className="p-2 border border-input rounded-md">
@@ -134,13 +194,15 @@ export default function EditorExample() {
input={getText()}
embeds={getEmbeds()}
api={API_URL}
user={user}
variant="creation"
manifest={currentMiniapp}
renderers={renderers}
onOpenFileAction={handleOpenFile}
onExitAction={() => setCurrentMiniapp(null)}
onSetInputAction={handleSetInput(setText)}
onAddEmbedAction={handleAddEmbed(addEmbed)}
onEthPersonalSignAction={getAuthSig}
/>
</div>
</PopoverContent>
16 changes: 14 additions & 2 deletions examples/nextjs-shadcn/src/app/embeds.tsx
Original file line number Diff line number Diff line change
@@ -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<Embed> }) {

const onSendEthTransactionAction = useMemo(
() =>
async ({ data, chainId }, { onConfirmed, onError, onSubmitted }) => {
async (
{ data, chainId }: SendEthTransactionActionResolverInit,
{
onConfirmed,
onError,
onSubmitted,
}: SendEthTransactionActionResolverEvents
) => {
try {
const parsedChainId = parseInt(chainId);

2 changes: 1 addition & 1 deletion miniapps/chatgpt/src/action.ts
Original file line number Diff line number Diff line change
@@ -7,7 +7,7 @@ const action: ModElement[] = [
{
type: "input",
placeholder: "Ask the AI anything",
clearable: true,
isClearable: true,
ref: "prompt",
// onchange: {
// ref: "mySearchQueryRequest",
2 changes: 1 addition & 1 deletion miniapps/giphy-picker/src/error.ts
Original file line number Diff line number Diff line change
@@ -22,7 +22,7 @@ const error: ModElement[] = [
ref: "myInput",
type: "input",
placeholder: "Search",
clearable: true,
isClearable: true,
onchange: {
ref: "mySearchQueryRequest",
type: "GET",
2 changes: 1 addition & 1 deletion miniapps/giphy-picker/src/loading.ts
Original file line number Diff line number Diff line change
@@ -22,7 +22,7 @@ const loading: ModElement[] = [
ref: "myInput",
type: "input",
placeholder: "Search",
clearable: true,
isClearable: true,
onchange: {
ref: "mySearchQueryRequest",
type: "GET",
2 changes: 1 addition & 1 deletion miniapps/giphy-picker/src/success.ts
Original file line number Diff line number Diff line change
@@ -22,7 +22,7 @@ const success: ModElement[] = [
ref: "myInput",
type: "input",
placeholder: "Search",
clearable: true,
isClearable: true,
onchange: {
ref: "mySearchQueryRequest",
type: "GET",
1 change: 1 addition & 0 deletions miniapps/lit-protocol-renderer/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default as default } from "./src/manifest";
10 changes: 10 additions & 0 deletions miniapps/lit-protocol-renderer/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
116 changes: 116 additions & 0 deletions miniapps/lit-protocol-renderer/src/decrypt.ts
Original file line number Diff line number Diff line change
@@ -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;
22 changes: 22 additions & 0 deletions miniapps/lit-protocol-renderer/src/error.ts
Original file line number Diff line number Diff line change
@@ -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;
13 changes: 13 additions & 0 deletions miniapps/lit-protocol-renderer/src/loading.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { ModElement } from "@mod-protocol/core";

const loading: ModElement[] = [
{
type: "text",
label: "Decrypting",
},
{
type: "circular-progress",
},
];

export default loading;
24 changes: 24 additions & 0 deletions miniapps/lit-protocol-renderer/src/manifest.ts
Original file line number Diff line number Diff line change
@@ -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;
43 changes: 43 additions & 0 deletions miniapps/lit-protocol-renderer/src/rendering.ts
Original file line number Diff line number Diff line change
@@ -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;
19 changes: 19 additions & 0 deletions miniapps/lit-protocol-renderer/src/success.ts
Original file line number Diff line number Diff line change
@@ -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;
5 changes: 5 additions & 0 deletions miniapps/lit-protocol-renderer/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"extends": "tsconfig/base.json",
"include": ["."],
"exclude": ["dist", "build", "node_modules"]
}
1 change: 1 addition & 0 deletions miniapps/lit-protocol/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default as default } from "./src/manifest";
10 changes: 10 additions & 0 deletions miniapps/lit-protocol/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
100 changes: 100 additions & 0 deletions miniapps/lit-protocol/src/action.ts
Original file line number Diff line number Diff line change
@@ -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",
},
Comment on lines +7 to +11
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a reason why this doesn't use the cast text content?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good question - the cast contents includes mentions (that won't work) and special renderers (like textcuts) that Mini-apps can't directly use. Additionally casts include embeds that we can't easily render if we added them to the encrypted contents, and token gated casts would probably still include or be able to include the cast body; so it'd be a bit confusing to have the cast text appearing/disappearing, or for it to somehow be encrypted when submitted.

In short, I'm not sure our abstractions are particularly close to supporting it in a graceful manner

{
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;
123 changes: 123 additions & 0 deletions miniapps/lit-protocol/src/advanced-form.ts
Original file line number Diff line number Diff line change
@@ -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;
15 changes: 15 additions & 0 deletions miniapps/lit-protocol/src/error.ts
Original file line number Diff line number Diff line change
@@ -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;
13 changes: 13 additions & 0 deletions miniapps/lit-protocol/src/loading.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { ModElement } from "@mod-protocol/core";

const loading: ModElement[] = [
{
type: "text",
label: "Encrypting",
},
{
type: "circular-progress",
},
];

export default loading;
61 changes: 61 additions & 0 deletions miniapps/lit-protocol/src/manifest.ts
Original file line number Diff line number Diff line change
@@ -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;
31 changes: 31 additions & 0 deletions miniapps/lit-protocol/src/sign.ts
Original file line number Diff line number Diff line change
@@ -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;
5 changes: 5 additions & 0 deletions miniapps/lit-protocol/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"extends": "tsconfig/base.json",
"include": ["."],
"exclude": ["dist", "build", "node_modules"]
}
3 changes: 2 additions & 1 deletion miniapps/nft-minter/src/view.ts
Original file line number Diff line number Diff line change
@@ -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}}",
6 changes: 1 addition & 5 deletions miniapps/zora-nft-minter/src/view.ts
Original file line number Diff line number Diff line change
@@ -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",
3 changes: 2 additions & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
5 changes: 4 additions & 1 deletion packages/core/src/embeds.ts
Original file line number Diff line number Diff line change
@@ -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<string, object[]>;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a reason why this property isn't camel cased? Makes referencing it awkward

description?: string;
alt?: string;
title?: string;
11 changes: 10 additions & 1 deletion packages/core/src/index.ts
Original file line number Diff line number Diff line change
@@ -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,
56 changes: 50 additions & 6 deletions packages/core/src/manifest.ts
Original file line number Diff line number Diff line change
@@ -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<string, JSONSchema7>;
creationEntrypoints?: ModElement[];
contentEntrypoints?: ModConditionalElement[];
elements?: Record<string, ModElement[]>;
permissions?: string[];
// perhaps data.user.wallet.address is better.
permissions?: Array<"user.wallet.address" | "web3.eth.personal.sign">;
Comment on lines +24 to +25
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree

};

export type ModEvent =
@@ -77,9 +81,10 @@ type HTTPBody =
formData: Record<string, FormDataType>;
};

type HTTPAction = BaseAction & { url: string } & (
export type HTTPAction = BaseAction & { url: string } & (
| {
type: "GET";
searchParams?: Record<string, string>;
}
| {
type: "POST";
@@ -117,13 +122,24 @@ type OpenLinkAction = BaseAction & {
url: string;
};

export type EthPersonalSignData = {
statement: string;
version: string;
chainId: string;
};

export type EthTransactionData = {
to: string;
from: string;
data?: string;
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;
} & (
364 changes: 318 additions & 46 deletions packages/core/src/renderer.ts

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions packages/miniapp-registry/src/index.ts
Original file line number Diff line number Diff line change
@@ -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(
4 changes: 2 additions & 2 deletions packages/react-editor/package.json
Original file line number Diff line number Diff line change
@@ -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",
2 changes: 1 addition & 1 deletion packages/react-editor/src/create-editor-config.tsx
Original file line number Diff line number Diff line change
@@ -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: {
11 changes: 6 additions & 5 deletions packages/react-ui-shadcn/package.json
Original file line number Diff line number Diff line change
@@ -8,17 +8,18 @@
"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",
"@radix-ui/react-dialog": "^1.0.5",
"@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"
}
}
}
5 changes: 2 additions & 3 deletions packages/react-ui-shadcn/src/components/channel-picker.tsx
Original file line number Diff line number Diff line change
@@ -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<Channel[]>(
@@ -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) => {
120 changes: 120 additions & 0 deletions packages/react-ui-shadcn/src/components/ui/select.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-9 w-full items-center justify-between rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<CaretSortIcon className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;

const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
</SelectPrimitive.Viewport>
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
SelectContent.displayName = SelectPrimitive.Content.displayName;

const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
{...props}
/>
));
SelectLabel.displayName = SelectPrimitive.Label.displayName;

const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
));
SelectItem.displayName = SelectPrimitive.Item.displayName;

const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
));
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;

export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
};
24 changes: 24 additions & 0 deletions packages/react-ui-shadcn/src/components/ui/textarea.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import * as React from "react";

import { cn } from "lib/utils";

export interface TextareaProps
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}

const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
);
}
);
Textarea.displayName = "Textarea";

export { Textarea };
18 changes: 16 additions & 2 deletions packages/react-ui-shadcn/src/renderers/button.tsx
Original file line number Diff line number Diff line change
@@ -6,7 +6,14 @@ import { Button } from "components/ui/button";
export const ButtonRenderer = (
props: React.ComponentProps<Renderers["Button"]>
) => {
const { label, isDisabled, isLoading, onClick, variant = "primary" } = props;
const {
label,
isDisabled,
isLoading,
onClick,
variant = "primary",
loadingLabel = "",
} = props;
return (
<Button
variant={variant}
@@ -17,7 +24,14 @@ export const ButtonRenderer = (
return onClick();
}}
>
{isLoading ? <CircularProgress size="sm" /> : label}
{isLoading ? (
<>
<CircularProgress size="sm" className={loadingLabel ? "mr-1" : ""} />
{loadingLabel}
</>
) : (
label
)}
</Button>
);
};
2 changes: 1 addition & 1 deletion packages/react-ui-shadcn/src/renderers/card.tsx
Original file line number Diff line number Diff line change
@@ -17,7 +17,7 @@ export const CardRenderer = (
onClick,
} = props;
return (
<div className="flex flex-col" onClick={onClick}>
<div className="flex flex-col -m-2" onClick={onClick}>
{imageSrc ? (
<AspectRatio ratio={aspectRatio || 1}>
<div className="w-full h-full bg-slate-900 relative">
78 changes: 78 additions & 0 deletions packages/react-ui-shadcn/src/renderers/combobox.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
"use client";

import * as React from "react";
import { Button } from "components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
} from "components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "components/ui/popover";
import { ChevronDownIcon } from "@radix-ui/react-icons";

type ResultType<T> = { value: T; label: string };

type Props<T> = {
options: Array<ResultType<T>> | null;
onPick: (value: T) => void;
onChange: (value: string) => void;
placeholder?: string;
};

export function ComboboxRenderer<T extends number | string = any>(
props: Props<T>
) {
const { options, onChange, onPick } = props;
const [value, setValue] = React.useState<ResultType<T> | null>(null);

const [open, setOpen] = React.useState(false);

React.useEffect(() => {
onChange("");
}, []);

const handlePick = React.useCallback(
(newValue: ResultType<T>) => {
setOpen(false);
setValue(newValue);
onPick(newValue.value);
},
[onChange, setOpen, setValue]
);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-full justify-between"
>
{value ? value.label : props.placeholder}
<ChevronDownIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="w-[300px] p-0">
<Command>
<CommandInput placeholder="Search" />
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup className="max-h-[300px] overflow-y-auto">
{!options ? "...loading" : null}
{options?.map((option) => (
<CommandItem
key={option.label}
value={String(option.label)}
className="cursor-pointer"
onSelect={() => handlePick(option)}
>
{option.label}
</CommandItem>
))}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
);
}
2 changes: 1 addition & 1 deletion packages/react-ui-shadcn/src/renderers/container.tsx
Original file line number Diff line number Diff line change
@@ -5,7 +5,7 @@ export const ContainerRenderer = (
props: React.ComponentProps<Renderers["Container"]>
) => {
return (
<div className="mt-2 rounded-md overflow-hidden border">
<div className="mt-2 rounded-md overflow-hidden border p-2">
{props.children}
</div>
);
6 changes: 6 additions & 0 deletions packages/react-ui-shadcn/src/renderers/index.tsx
Original file line number Diff line number Diff line change
@@ -15,9 +15,15 @@ import { CardRenderer } from "./card";
import { AvatarRenderer } from "./avatar";
import { ImageRenderer } from "./image";
import { ContainerRenderer } from "./container";
import { SelectRenderer } from "./select";
import { TextareaRenderer } from "./textarea";
import { ComboboxRenderer } from "./combobox";

export const renderers: Renderers = {
Select: SelectRenderer,
Link: LinkRenderer,
Combobox: ComboboxRenderer,
Textarea: TextareaRenderer,
Container: ContainerRenderer,
Text: TextRenderer,
Image: ImageRenderer,
62 changes: 62 additions & 0 deletions packages/react-ui-shadcn/src/renderers/select.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import React from "react";
import { Renderers } from "@mod-protocol/react";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "components/ui/select";
import { Button } from "components/ui/button";
import { Cross1Icon } from "@radix-ui/react-icons";

export const SelectRenderer = (
props: React.ComponentProps<Renderers["Select"]>
) => {
const { isClearable, placeholder, onChange, options } = props;
const [value, setValue] = React.useState<string>("");
const selectRef = React.useRef<HTMLSelectElement | null>(null);
return (
<div className="w-full flex flex-row items-center rounded-md flex-grow">
<Select
onValueChange={(value: string) => {
onChange(value);
setValue(value);
}}
value={value}
>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder={placeholder} ref={selectRef} />
</SelectTrigger>
<SelectContent>
{options.map((option, i) => {
return (
<SelectItem
value={option.value}
// items are stable
key={i}
>
{option.label}
</SelectItem>
);
})}
</SelectContent>
{isClearable && value ? (
<Button
type="button"
className="rounded-l-none text-gray-400 hover:bg-transparent"
variant="ghost"
size="icon"
onClick={() => {
onChange("");
setValue("");
selectRef.current?.focus();
}}
>
<Cross1Icon />
</Button>
) : null}
</Select>
</div>
);
};
29 changes: 29 additions & 0 deletions packages/react-ui-shadcn/src/renderers/textarea.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import React from "react";
import { Renderers } from "@mod-protocol/react";
import { Textarea } from "components/ui/textarea";

export const TextareaRenderer = (
props: React.ComponentProps<Renderers["Textarea"]>
) => {
const { placeholder, onChange, onSubmit } = props;
const [value, setValue] = React.useState<string>("");
const inputRef = React.useRef<HTMLTextAreaElement | null>(null);
return (
<div className="w-full flex flex-row items-center border rounded-md border-input">
<Textarea
ref={inputRef}
placeholder={placeholder}
className={"flex-1 border-none"}
onChange={(ev) => {
onChange(ev.currentTarget.value);
setValue(ev.currentTarget.value);
}}
onSubmit={(ev) => {
onSubmit(ev.currentTarget.value);
setValue(ev.currentTarget.value);
}}
value={value}
/>
</div>
);
};
2 changes: 1 addition & 1 deletion packages/react/package.json
Original file line number Diff line number Diff line change
@@ -10,7 +10,7 @@
"dev": "npm run build -- --watch"
},
"dependencies": {
"@mod-protocol/core": "^0.0.2"
"@mod-protocol/core": "*"
},
"peerDependencies": {
"react": "^18.2.0",
14 changes: 14 additions & 0 deletions packages/react/src/action-resolver-eth-personal-sign.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import {
EthPersonalSignActionResolverInit,
EthPersonalSignActionResolverEvents,
} from "@mod-protocol/core";

export default function actionResolverEthPersonalSign(
init: EthPersonalSignActionResolverInit,
events: EthPersonalSignActionResolverEvents
) {
// eslint-disable-next-line no-console
console.warn(
"Please implement 'EthPersonalSignActionResolver' and configure the MiniApp to use it"
);
}
132 changes: 130 additions & 2 deletions packages/react/src/index.tsx
Original file line number Diff line number Diff line change
@@ -13,13 +13,15 @@ import {
AddEmbedActionResolver,
ContentContext,
CreationContext,
EthPersonalSignActionResolver,
SendEthTransactionActionResolver,
} from "@mod-protocol/core";
import actionResolverHttp from "./action-resolver-http";
import actionResolverOpenFile from "./action-resolver-open-file";
import actionResolverOpenLink from "./action-resolver-open-link";
import actionResolverSetInput from "./action-resolver-set-input";
import actionResolverAddEmbed from "./action-resolver-add-embed";
import actionResolverEthPersonalSign from "./action-resolver-eth-personal-sign";
import actionResolverExit from "./action-resolver-exit";
import actionResolverSendEthTransaction from "./action-resolver-send-eth-transaction";
export * from "./render-embed";
@@ -32,6 +34,18 @@ export type Renderers = {
Image: React.ComponentType<{
imageSrc: string;
}>;
Select: React.ComponentType<{
isClearable: boolean;
placeholder?: string;
options: Array<{ value: any; label: string }>;
onChange: (value: string) => void;
}>;
Combobox: React.ComponentType<{
placeholder?: string;
options: Array<{ value: any; label: string }> | null;
onChange: (value: string) => void;
onPick: (value: any) => void;
}>;
Text: React.ComponentType<{ label: string }>;
Link: React.ComponentType<{
label: string;
@@ -42,6 +56,7 @@ export type Renderers = {
Button: React.ComponentType<{
label: string;
isLoading: boolean;
loadingLabel?: string;
variant?: "primary" | "secondary" | "destructive";
isDisabled: boolean;
onClick: () => void;
@@ -55,6 +70,11 @@ export type Renderers = {
onChange: (value: string) => void;
onSubmit: (value: string) => void;
}>;
Textarea: React.ComponentType<{
placeholder?: string;
onChange: (value: string) => void;
onSubmit: (value: string) => void;
}>;
Tabs: React.ComponentType<{
children: React.ReactNode;
values: string[];
@@ -175,6 +195,23 @@ const WrappedVerticalLayoutRenderer = <T extends React.ReactNode>(props: {
return <Component {...rest}>{elements}</Component>;
};

const WrappedSelectRenderer = <T extends React.ReactNode>(props: {
component: Renderers["Select"];
element: Extract<ModElementRef<T>, { type: "select" }>;
}) => {
const { component: Component, element } = props;
const { events, type, ...rest } = element;

const onChange = React.useCallback(
(input: string) => {
events.onChange(input);
},
[events]
);

return <Component {...rest} onChange={onChange} />;
};

const WrappedInputRenderer = <T extends React.ReactNode>(props: {
component: Renderers["Input"];
element: Extract<ModElementRef<T>, { type: "input" }>;
@@ -198,6 +235,64 @@ const WrappedInputRenderer = <T extends React.ReactNode>(props: {
return <Component {...rest} onChange={onChange} onSubmit={onSubmit} />;
};

const WrappedComboboxRenderer = <T extends React.ReactNode>(props: {
component: Renderers["Combobox"];
element: Extract<ModElementRef<T>, { type: "combobox" }>;
}) => {
const { component: Component, element } = props;
const { events, type, options, ...rest } = element;

const onChange = React.useCallback(
(input: string) => {
events.onChange(input);
},
[events]
);

React.useEffect(() => {
events.onLoad();
}, []);

const onPick = React.useCallback(
(value: string) => {
events.onPick(value);
},
[events]
);

return (
<Component
{...rest}
onChange={onChange}
options={options}
onPick={onPick}
/>
);
};

const WrappedTextareaRenderer = <T extends React.ReactNode>(props: {
component: Renderers["Textarea"];
element: Extract<ModElementRef<T>, { type: "textarea" }>;
}) => {
const { component: Component, element } = props;
const { events, type, ...rest } = element;

const onChange = React.useCallback(
(input: string) => {
events.onChange(input);
},
[events]
);
const onSubmit = React.useCallback(
(input: string) => {
events.onSubmit(input);
},
[events]
);

return <Component {...rest} onChange={onChange} onSubmit={onSubmit} />;
};

const WrappedTabsRenderer = <T extends React.ReactNode>(props: {
component: Renderers["Tabs"];
element: Extract<ModElementRef<T>, { type: "tabs" }>;
@@ -323,6 +418,7 @@ export type ResolverTypes = {
onSetInputAction?: SetInputActionResolver;
onAddEmbedAction?: AddEmbedActionResolver;
onOpenLinkAction?: OpenLinkActionResolver;
onEthPersonalSignAction?: EthPersonalSignActionResolver;
onSendEthTransactionAction?: SendEthTransactionActionResolver;
onExitAction?: ExitActionResolver;
};
@@ -344,15 +440,16 @@ export const CreationMiniApp = (
onAddEmbedAction = actionResolverAddEmbed,
onOpenLinkAction = actionResolverOpenLink,
onSendEthTransactionAction = actionResolverSendEthTransaction,
onEthPersonalSignAction = actionResolverEthPersonalSign,
onExitAction = actionResolverExit,
} = props;

const forceRerender = useForceRerender();

const input = variant === "creation" ? props.input : "";
const context = React.useMemo<CreationContext>(
() => ({ input, embeds: props.embeds, api: props.api }),
[input, props.api, props.embeds]
() => ({ input, embeds: props.embeds, api: props.api, user: props.user }),
[input, props.api, props.embeds, props.user]
);

const [renderer] = React.useState<Renderer>(
@@ -367,6 +464,7 @@ export const CreationMiniApp = (
onSetInputAction,
onAddEmbedAction,
onOpenLinkAction,
onEthPersonalSignAction,
onSendEthTransactionAction,
onExitAction,
})
@@ -390,6 +488,7 @@ export const RenderMiniApp = (
onSetInputAction = actionResolverSetInput,
onAddEmbedAction = actionResolverAddEmbed,
onOpenLinkAction = actionResolverOpenLink,
onEthPersonalSignAction = actionResolverEthPersonalSign,
onSendEthTransactionAction = actionResolverSendEthTransaction,
onExitAction = actionResolverExit,
} = props;
@@ -413,6 +512,7 @@ export const RenderMiniApp = (
onSetInputAction,
onAddEmbedAction,
onOpenLinkAction,
onEthPersonalSignAction,
onSendEthTransactionAction,
onExitAction,
})
@@ -440,6 +540,7 @@ export const MiniApp = (props: Props & { renderer: Renderer }) => {
onSetInputAction = actionResolverSetInput,
onAddEmbedAction = actionResolverAddEmbed,
onOpenLinkAction = actionResolverOpenLink,
onEthPersonalSignAction = actionResolverEthPersonalSign,
onSendEthTransactionAction = actionResolverSendEthTransaction,
onExitAction = actionResolverExit,
} = props;
@@ -461,6 +562,9 @@ export const MiniApp = (props: Props & { renderer: Renderer }) => {
React.useEffect(() => {
renderer.setOpenLinkActionResolver(onOpenLinkAction);
}, [onOpenLinkAction, renderer]);
React.useEffect(() => {
renderer.setEthPersonalSignActionResolver(onEthPersonalSignAction);
}, [onEthPersonalSignAction, renderer]);
React.useEffect(() => {
renderer.setSendEthTransactionActionResolver(onSendEthTransactionAction);
}, [onSendEthTransactionAction, renderer]);
@@ -537,6 +641,30 @@ export const MiniApp = (props: Props & { renderer: Renderer }) => {
element={el}
/>
);
case "combobox":
return (
<WrappedComboboxRenderer
key={key}
component={renderers["Combobox"]}
element={el}
/>
);
case "textarea":
return (
<WrappedTextareaRenderer
key={key}
component={renderers["Textarea"]}
element={el}
/>
);
case "select":
return (
<WrappedSelectRenderer
key={key}
component={renderers["Select"]}
element={el}
/>
);
case "input":
return (
<WrappedInputRenderer
3 changes: 2 additions & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
@@ -9,10 +9,11 @@
"esModuleInterop": true,
"preserveSymlinks": true,
"incremental": true,
"declarationMap": true,
"paths": {
"@miniapps/*": [
"./miniapps/*"
]
],
},
"jsx": "react-jsx",
"module": "ESNext",
4 changes: 3 additions & 1 deletion turbo.json
Original file line number Diff line number Diff line change
@@ -32,6 +32,8 @@
"MICROLINK_API_KEY",
"DATABASE_URL",
"NODE_ENV",
"NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID"
"NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID",
"GATEWAY_URL",
"SIMPLEHASH_API_KEY"
]
}
3,805 changes: 3,189 additions & 616 deletions yarn.lock

Large diffs are not rendered by default.