diff --git a/package-lock.json b/package-lock.json index 9e978ed3..1a5a3ef0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,7 +21,7 @@ "@xmtp/content-type-remote-attachment": "^1.1.4", "@xmtp/content-type-reply": "^1.1.5", "@xmtp/experimental-content-type-screen-effect": "^1.0.2", - "@xmtp/frames-client": "0.3.2", + "@xmtp/frames-client": "0.4.2", "@xmtp/react-sdk": "^5.0.1", "buffer": "^6.0.3", "date-fns": "^2.29.3", @@ -56,6 +56,7 @@ }, "devDependencies": { "@babel/core": "^7.20.12", + "@open-frames/proxy-client": "0.2.0", "@storybook/addon-essentials": "^7.1.0-alpha.29", "@storybook/addon-interactions": "^7.1.0-alpha.29", "@storybook/addon-links": "^7.1.0-alpha.29", @@ -4777,6 +4778,25 @@ "node": ">= 8" } }, + "node_modules/@open-frames/proxy-client": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@open-frames/proxy-client/-/proxy-client-0.2.0.tgz", + "integrity": "sha512-elfy9da998KR65tQDt+Z/nvNnFeXHhYAxlIjI3lftJbg5Vt6zwAtoWvV3r10B9nTcaA4/MWipZezD5pGV5tazg==", + "dependencies": { + "@open-frames/proxy-types": "0.1.0" + } + }, + "node_modules/@open-frames/proxy-types": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@open-frames/proxy-types/-/proxy-types-0.1.0.tgz", + "integrity": "sha512-fFcUmUmnGn3JkiBL3az6sqcs62pOnKPWc3tKar6LV9VciHaRXC059kCxpLh2mcYVfpZ0HgRILBtttEupIrAPDw==", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "typescript": "^5.3.3" + } + }, "node_modules/@perma/map": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@perma/map/-/map-1.0.3.tgz", @@ -14602,12 +14622,13 @@ } }, "node_modules/@xmtp/frames-client": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/@xmtp/frames-client/-/frames-client-0.3.2.tgz", - "integrity": "sha512-61rxA7YcNUUKndQ9e5X44LNVwWJCrrZR6sBGOWLckfMK00LqRasoiLgom1PlR34c17a59PPZmagxoNQ2QCto7A==", + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@xmtp/frames-client/-/frames-client-0.4.2.tgz", + "integrity": "sha512-47fy35WGJ/QDzPKjuCCc45GLvLrDb92Jh5sDXY6B/HmWrssb8dZtrV5kw77kKsdGVhnqTQTRUM3WjMC6l83T0w==", "dependencies": { "@noble/hashes": "^1.3.3", - "@xmtp/proto": "3.41.0-beta.5", + "@open-frames/proxy-client": "^0.2.0", + "@xmtp/proto": "3.44.0", "long": "^5.2.3" }, "peerDependencies": { @@ -14625,21 +14646,10 @@ "url": "https://paulmillr.com/funding/" } }, - "node_modules/@xmtp/frames-client/node_modules/@xmtp/proto": { - "version": "3.41.0-beta.5", - "resolved": "https://registry.npmjs.org/@xmtp/proto/-/proto-3.41.0-beta.5.tgz", - "integrity": "sha512-vx5zqLpAVPjTEdyqY/woXrgvWMKjbTwwco+x9WE+T1iVlv+472yp2DwFJRLpfeQByC1cHl7XQyuO2Q+8t8HL4Q==", - "dependencies": { - "long": "^5.2.0", - "protobufjs": "^7.0.0", - "rxjs": "^7.8.0", - "undici": "^5.8.1" - } - }, "node_modules/@xmtp/proto": { - "version": "3.36.0", - "resolved": "https://registry.npmjs.org/@xmtp/proto/-/proto-3.36.0.tgz", - "integrity": "sha512-LTyoa1K5TgHv6ekGmwVTaMwfnhxZhglZLh/6zS3dgygGYgn2KvFvmkt+ssNSRdK8LAaJ2ZpqV/OTDjB/tEHjCA==", + "version": "3.44.0", + "resolved": "https://registry.npmjs.org/@xmtp/proto/-/proto-3.44.0.tgz", + "integrity": "sha512-5M23JsxONb/qluZF6QMjoXHT67xDRR7pWwHTd1tfw59jS+jXTxm/+v8/UeSOoK47PBSJhDD5I90bAeA5NrLRig==", "dependencies": { "long": "^5.2.0", "protobufjs": "^7.0.0", @@ -31123,15 +31133,15 @@ } }, "node_modules/typescript": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.0.4.tgz", - "integrity": "sha512-cW9T5W9xY37cc+jfEnaUvX91foxtHkza3Nw3wkoF4sSlKn0MONdkdEndig/qPBWXNkmplh3NzayQzCiHM4/hqw==", + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz", + "integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==", "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" }, "engines": { - "node": ">=12.20" + "node": ">=14.17" } }, "node_modules/ufo": { diff --git a/package.json b/package.json index 02fe1e81..2e8ef0f8 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,7 @@ "@xmtp/content-type-remote-attachment": "^1.1.4", "@xmtp/content-type-reply": "^1.1.5", "@xmtp/experimental-content-type-screen-effect": "^1.0.2", - "@xmtp/frames-client": "0.3.2", + "@xmtp/frames-client": "0.4.2", "@xmtp/react-sdk": "^5.0.1", "buffer": "^6.0.3", "date-fns": "^2.29.3", @@ -77,6 +77,7 @@ }, "devDependencies": { "@babel/core": "^7.20.12", + "@open-frames/proxy-client": "0.2.0", "@storybook/addon-essentials": "^7.1.0-alpha.29", "@storybook/addon-interactions": "^7.1.0-alpha.29", "@storybook/addon-links": "^7.1.0-alpha.29", diff --git a/src/component-library/components/Frame/Frame.tsx b/src/component-library/components/Frame/Frame.tsx index 151771d5..bb81389f 100644 --- a/src/component-library/components/Frame/Frame.tsx +++ b/src/component-library/components/Frame/Frame.tsx @@ -1,44 +1,68 @@ -import type { FrameButton } from "../../../helpers/getFrameInfo"; import { GhostButton } from "../GhostButton/GhostButton"; +import type { FrameButton } from "../../../helpers/frameInfo"; type FrameProps = { image: string; title: string; + textInput?: string; buttons: FrameButton[]; handleClick: ( buttonNumber: number, - action: FrameButton["action"], + action?: FrameButton["action"], ) => Promise; + onTextInputChange: (value: string) => void; frameButtonUpdating: number; + interactionsEnabled: boolean; }; export const Frame = ({ image, title, buttons, + textInput, handleClick, + onTextInputChange, frameButtonUpdating, + interactionsEnabled, }: FrameProps) => (
{title} -
- {buttons?.map((button, index) => { - if (!button) { - return null; + {!!textInput && interactionsEnabled && ( + + onTextInputChange((e.target as HTMLInputElement).value) } - const handlePress = () => { - void handleClick(index + 1, button.action); - }; - return ( - 0} - /> - ); - })} + placeholder={textInput} + /> + )} +
+ {interactionsEnabled ? ( + buttons.map((button) => { + if (!button) { + return null; + } + const handlePress = () => { + void handleClick(button.buttonIndex, button.action); + }; + return ( + 0} + /> + ); + }) + ) : ( + Frame interactions not supported + )}
); diff --git a/src/controllers/FullMessageController.tsx b/src/controllers/FullMessageController.tsx index e7bdee78..b08687ed 100644 --- a/src/controllers/FullMessageController.tsx +++ b/src/controllers/FullMessageController.tsx @@ -2,14 +2,20 @@ import type { CachedConversation, CachedMessageWithId } from "@xmtp/react-sdk"; import { useClient } from "@xmtp/react-sdk"; import { FramesClient } from "@xmtp/frames-client"; import { useEffect, useState } from "react"; +import type { GetMetadataResponse } from "@open-frames/proxy-client"; import { FullMessage } from "../component-library/components/FullMessage/FullMessage"; import { classNames, shortAddress } from "../helpers"; import MessageContentController from "./MessageContentController"; import { useXmtpStore } from "../store/xmtp"; import { Frame } from "../component-library/components/Frame/Frame"; -import type { FrameButton } from "../helpers/getFrameInfo"; -import { getFrameInfo } from "../helpers/getFrameInfo"; import { readMetadata } from "../helpers/openFrames"; +import type { FrameButton } from "../helpers/frameInfo"; +import { + getFrameTitle, + getOrderedButtons, + isValidFrame, + isXmtpFrame, +} from "../helpers/frameInfo"; interface FullMessageControllerProps { message: CachedMessageWithId; @@ -17,13 +23,6 @@ interface FullMessageControllerProps { isReply?: boolean; } -export type FrameInfo = { - image: string; - title: string; - buttons: FrameButton[]; - postUrl: string; -}; - export const FullMessageController = ({ message, conversation, @@ -33,39 +32,46 @@ export const FullMessageController = ({ const conversationTopic = useXmtpStore((state) => state.conversationTopic); - const [frameInfo, setFrameInfo] = useState(undefined); + const [frameMetadata, setFrameMetadata] = useState< + GetMetadataResponse | undefined + >(undefined); const [frameButtonUpdating, setFrameButtonUpdating] = useState(0); + const [textInputValue, setTextInputValue] = useState(""); const handleFrameButtonClick = async ( buttonIndex: number, - action: FrameButton["action"], + action: FrameButton["action"] = "post", ) => { - if (!frameInfo || !client) { + if (!frameMetadata || !client || !frameMetadata?.frameInfo?.buttons) { + return; + } + const { frameInfo, url: frameUrl } = frameMetadata; + if (!frameInfo.buttons) { return; } - const frameUrl = frameInfo.image; - const button = frameInfo.buttons[buttonIndex]; + const button = frameInfo.buttons[`${buttonIndex}`]; setFrameButtonUpdating(buttonIndex); const framesClient = new FramesClient(client); - + const postUrl = button.target || frameInfo.postUrl || frameUrl; const payload = await framesClient.signFrameAction({ frameUrl, + inputText: textInputValue || undefined, buttonIndex, conversationTopic: conversationTopic as string, participantAccountAddresses: [client.address, conversation.peerAddress], }); - if (action === "post" || !action) { + + if (action === "post") { const updatedFrameMetadata = await framesClient.proxy.post( - button?.target || frameInfo.postUrl, + postUrl, payload, ); - const updatedFrameInfo = getFrameInfo(updatedFrameMetadata.extractedTags); - setFrameInfo(updatedFrameInfo); + setFrameMetadata(updatedFrameMetadata); } else if (action === "post_redirect") { const { redirectedTo } = await framesClient.proxy.postRedirect( - button?.target || frameInfo.postUrl, + postUrl, payload, ); window.open(redirectedTo, "_blank"); @@ -88,8 +94,7 @@ export const FullMessageController = ({ if (isUrl) { const metadata = await readMetadata(word); if (metadata) { - const info = getFrameInfo(metadata.extractedTags); - setFrameInfo(info); + setFrameMetadata(metadata); } } }), @@ -103,6 +108,8 @@ export const FullMessageController = ({ ? "items-end justify-end" : "items-start justify-start"; + const showFrame = isValidFrame(frameMetadata); + return (
- {frameInfo?.image && ( + {showFrame && ( )}
diff --git a/src/helpers/frameInfo.ts b/src/helpers/frameInfo.ts new file mode 100644 index 00000000..9fb25763 --- /dev/null +++ b/src/helpers/frameInfo.ts @@ -0,0 +1,56 @@ +import type { + GetMetadataResponse, + OpenFrameButton, +} from "@open-frames/proxy-client"; +import { PROTOCOL_VERSION } from "@xmtp/frames-client"; + +const OG_TITLE_TAG = "og:title"; + +export type FrameButton = OpenFrameButton & { buttonIndex: number }; + +export function getOrderedButtons( + metadata: GetMetadataResponse, +): FrameButton[] { + const buttonMap = metadata?.frameInfo?.buttons; + + if (!buttonMap) { + return []; + } + + return Object.keys(buttonMap) + .sort() + .map((key) => { + const button = buttonMap[key]; + return { + ...button, + buttonIndex: parseInt(key, 10), + }; + }); +} + +export function isXmtpFrame(metadata: GetMetadataResponse): boolean { + const minXmtpVersion = metadata.frameInfo?.acceptedClients?.xmtp; + + return ( + !!minXmtpVersion && + minXmtpVersion.length > 0 && + minXmtpVersion <= PROTOCOL_VERSION + ); +} + +export function isValidFrame( + metadata: GetMetadataResponse | undefined, +): metadata is Required { + if (!metadata) { + return false; + } + + // NOTE: This is more lenient than the Farcaster spec, which lists the og:image tag as required + const hasImage = !!metadata.frameInfo?.image || !!metadata.frameInfo?.ogImage; + + return !!metadata?.frameInfo && hasImage; +} + +export function getFrameTitle(metadata: GetMetadataResponse): string { + return metadata.extractedTags[OG_TITLE_TAG] || ""; +} diff --git a/src/helpers/getFrameInfo.ts b/src/helpers/getFrameInfo.ts deleted file mode 100644 index 139ae563..00000000 --- a/src/helpers/getFrameInfo.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { getButtonIndex, mediaUrl } from "./openFrames"; - -const BUTTON_PREFIX = "fc:frame:button:"; -const IMAGE_PREFIX = "fc:frame:image"; -const POST_URL_PREFIX = "fc:frame:post_url"; -const TITLE_PREFIX = "og:title"; - -const ALLOWED_ACTIONS = ["post", "post_redirect", "link", "mint"] as const; - -export type FrameButton = { - text?: string; - action?: (typeof ALLOWED_ACTIONS)[number]; - target?: string; -}; - -function addButtonTag( - existingButtons: FrameButton[], - key: string, - value: string, -): FrameButton[] { - const buttons = existingButtons; - const buttonIndex = getButtonIndex(key); - if (buttonIndex) { - buttons[buttonIndex] = buttons[buttonIndex] || {}; - if ( - key.endsWith(":action") && - ALLOWED_ACTIONS.includes(value as (typeof ALLOWED_ACTIONS)[number]) - ) { - buttons[buttonIndex].action = value as (typeof ALLOWED_ACTIONS)[number]; - } else if (key.endsWith(":target")) { - buttons[buttonIndex].target = value; - } else if (key.endsWith(buttonIndex.toString())) { - buttons[buttonIndex].text = value; - } - } - return buttons; -} - -export function getFrameInfo(extractedTags: Record) { - let buttons: FrameButton[] = []; - let image = ""; - let postUrl = ""; - let title = ""; - for (const key in extractedTags) { - if (key) { - if (key.startsWith(BUTTON_PREFIX)) { - buttons = addButtonTag(buttons, key, extractedTags[key]); - } - if (key.startsWith(IMAGE_PREFIX)) { - const imageUrl = extractedTags[key]; - image = imageUrl.startsWith("data:") - ? extractedTags[key] - : mediaUrl(imageUrl); - } - if (key.startsWith(POST_URL_PREFIX)) { - postUrl = extractedTags[key]; - } - if (key.startsWith(TITLE_PREFIX)) { - title = extractedTags[key]; - } - } - } - return { - buttons: buttons.filter((b) => b), - image, - postUrl, - title, - }; -}