From 1f3e7240a362d6d674dc725de8059a5b4bce5693 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20Kvasni=C4=8D=C3=A1k?= Date: Fri, 20 Dec 2024 16:59:53 +0100 Subject: [PATCH] feat: frames v2 support (#531) * chore: frames v2 test app template * fix: cast action message response definition * feat: basic frames v2 parsing * fix: do not cause infinite render cycle when using initialPendingExtra * feat: basic frames v2 support * fix: properly type current frame stack item * refactor: types * feat: useCastAction hook and fixing issues with signer copy * refactor: use new cast action hook and unstable frame hooks in debugger * feat: export signer instance types * chore: add farcaster v2 to protocol selector * fix: make frame v2 parsing compatible with spec * fix: parsing frame button * feat: allow multi spec signer * feat: allow debugger to lock specification * fix: launch button type * chore: define handler for frame launching * feat: add new hook to handle frames v2 app * feat: add app frame dialog * chore: basic frames 2 example * fix: test * test: add farcaster v2 tests * chore: update sdk * fix: unregister exposed comlink listeners * chore: unregister all exposed listeners * feat: add helpers to detect fully valid frames * chore: split parse result types * feat: add json farcaster signature utils * fix: make json signatures browser compatible * feat: add domain account association generator to debugger * chore: add comments * test: signature signing * chore: eslint fix * feat: parse and validate farcaster v2 frames and manifest * fix: make route handlers compatible with async parsing * fix: use 3:2 aspect ratio for frame * feat: support react native * chore: remove our starter * fix: add missing parse result * feat: show fc v1/v2 alert * feat: manifest debugger tab * chore: update peer dep and properly handle events * chore: enable debug mode * fix: throw an error because the error message is not the same as sdk expects * feat: allow to hook into tx, messages and typed data signing * feat: add an option to enable manifest parsing * fix: wait for wallet client * feat: allow to use different connectors * chore: use new provider in debugger * feat: fetch frame in useFrameApp hook * fix: peer dep * chore: return props as object * fix: use iframeProps * fix: eslint issue * chore: export return and option types * fix: check if emitter is really set * chore: update peer deps * fix: frame app dialog layout * chore: remove unnecessary handler * chore: allow to debug eth provider requests * feat: allow to launch app in different contexts * fix: respect abort signal * feat: allow to resolve client * feat: simple notifications support * chore: do not allow to run frame app debugger from cast action debugger * feat: allow to manage frame and notification settings * feat: notifications event log and webhooks * chore: use bigger ttl * chore: add cli options for kv store * chore: integrate farcaster implementation of frames v2 * feat: emit events * chore: rename variables * fix: compilation error * fix: import as type * chore: emit events * fix: do not reload frame app on notifications state changes * chore: do not store signer key * feat: use sqlite if redis is not available * feat: allow non strict parsing * chore: changeset * chore: remove package that doesn't exist * chore: bump facaster/core and protobuf * chore: allow non https urls in debugger notifications endpoints * chore: make sqlite optional dep * fix: event log scrolling * chore: add alert on unsupported signer state * chore: allow impersonated signers * fix: correcly show notifications panel --- .changeset/ninety-ducks-melt.md | 7 + packages/debugger/.env.sample | 6 +- packages/debugger/.gitignore | 3 +- .../action-debugger-properties-table.tsx | 120 ++ .../app/components/action-debugger.tsx | 343 +--- .../debugger/app/components/action-info.tsx | 54 + .../app/components/cast-action-debugger.tsx | 185 ++ .../debugger/app/components/cast-composer.tsx | 13 +- .../components/composer-action-debugger.tsx | 15 +- ...ster-domain-account-association-dialog.tsx | 226 +++ .../frame-app-debugger-notifications.tsx | 221 +++ .../app/components/frame-app-debugger.tsx | 382 ++++ .../frame-app-notifications-control-panel.tsx | 209 +++ .../components/frame-debugger-diagnostics.tsx | 266 +++ ...me-debugger-farcaster-manifest-details.tsx | 158 ++ .../frame-debugger-request-card-content.tsx | 135 ++ .../frame-debugger-request-details.tsx | 14 +- .../app/components/frame-debugger.tsx | 664 +++---- .../app/components/protocol-config-button.tsx | 226 ++- .../app/components/shortened-text.tsx | 22 + packages/debugger/app/constants.ts | 3 + packages/debugger/app/debugger-page.tsx | 798 +++------ packages/debugger/app/frames/route.ts | 13 +- .../debugger/app/hooks/useCopyToClipboad.ts | 37 + .../app/hooks/useDebuggerFrameState.ts | 105 +- .../app/hooks/useFarcasterIdentity.tsx | 17 +- .../app/hooks/useSharedFrameEventHandlers.tsx | 239 +++ .../[namespaceId]/events/route.ts | 54 + .../app/notifications/[namespaceId]/route.ts | 354 ++++ .../notifications/[namespaceId]/send/route.ts | 58 + .../debugger/app/notifications/helpers.ts | 8 + .../debugger/app/notifications/parsers.ts | 17 + packages/debugger/app/notifications/route.ts | 55 + .../debugger/app/notifications/storage.ts | 15 + .../app/notifications/storage/redis.ts | 219 +++ .../app/notifications/storage/sqlite.ts | 301 ++++ packages/debugger/app/notifications/types.ts | 96 + .../FrameAppNotificationsManagerProvider.tsx | 230 +++ .../app/providers/FrameContextProvider.tsx | 38 + .../providers/ProtocolSelectorProvider.tsx | 13 + packages/debugger/bin/debugger.js | 22 + packages/debugger/globals.d.ts | 6 + packages/debugger/package.json | 12 +- packages/frames.js/package.json | 36 +- packages/frames.js/src/core/cast-actions.ts | 10 +- packages/frames.js/src/core/types.ts | 2 + packages/frames.js/src/farcaster-v2/events.ts | 64 + .../src/farcaster-v2/json-signature.test.ts | 156 ++ .../src/farcaster-v2/json-signature.ts | 310 ++++ packages/frames.js/src/farcaster-v2/types.ts | 12 + .../src/farcaster/generated/message.ts | 790 +++------ .../src/frame-parsers/farcasterV2.test.ts | 1554 +++++++++++++++++ .../src/frame-parsers/farcasterV2.ts | 411 +++++ packages/frames.js/src/frame-parsers/types.ts | 125 +- packages/frames.js/src/frame-parsers/utils.ts | 48 + packages/frames.js/src/getFrame.test.ts | 52 +- packages/frames.js/src/getFrame.ts | 17 +- packages/frames.js/src/getFrameFlattened.ts | 17 +- packages/frames.js/src/getFrameHtml.test.ts | 44 +- packages/frames.js/src/getFrameHtml.ts | 25 +- packages/frames.js/src/lib/base64url.test.ts | 21 + packages/frames.js/src/lib/base64url.ts | 19 + .../src/parseFramesWithReports.test.ts | 20 +- .../frames.js/src/parseFramesWithReports.ts | 28 +- packages/frames.js/src/types.ts | 22 +- packages/render/package.json | 110 +- packages/render/src/assert-never.ts | 3 + packages/render/src/collapsed-frame-ui.tsx | 23 +- packages/render/src/farcaster/frames.tsx | 83 +- packages/render/src/farcaster/signers.tsx | 8 +- packages/render/src/frame-app/iframe.ts | 186 ++ .../render/src/frame-app/provider/helpers.ts | 40 + .../render/src/frame-app/provider/wagmi.ts | 172 ++ packages/render/src/frame-app/types.ts | 86 + .../src/frame-app/use-fetch-frame-app.ts | 140 ++ .../src/frame-app/use-resolve-client.ts | 97 + packages/render/src/frame-app/web-view.ts | 186 ++ packages/render/src/frame-ui.tsx | 24 +- packages/render/src/helpers.ts | 35 +- packages/render/src/hooks/use-debug-log.ts | 22 + .../anonymous/use-anonymous-identity.tsx | 7 +- .../render/src/identity/farcaster/index.ts | 6 +- .../farcaster/use-farcaster-context.tsx | 2 + .../farcaster/use-farcaster-identity.tsx | 90 +- .../use-farcaster-multi-identity.tsx | 97 +- packages/render/src/identity/lens/index.ts | 3 +- .../src/identity/lens/use-lens-identity.tsx | 206 ++- packages/render/src/identity/xmtp/index.ts | 6 +- .../src/identity/xmtp/use-xmtp-identity.tsx | 101 +- packages/render/src/next/GET.tsx | 24 +- packages/render/src/next/POST.tsx | 6 +- packages/render/src/next/validators.ts | 2 +- packages/render/src/types.ts | 22 +- packages/render/src/ui/frame.base.tsx | 208 ++- packages/render/src/ui/types.ts | 99 +- packages/render/src/ui/utils.ts | 101 +- packages/render/src/unstable-types.ts | 262 ++- .../render/src/unstable-use-fetch-frame.ts | 17 +- .../render/src/unstable-use-frame-state.ts | 94 +- packages/render/src/unstable-use-frame.tsx | 20 +- packages/render/src/use-cast-action.ts | 543 ++++++ packages/render/src/use-frame-app.ts | 435 +++++ packages/render/src/use-frame-stack.ts | 2 +- yarn.lock | 385 +++- 104 files changed, 11275 insertions(+), 2440 deletions(-) create mode 100644 .changeset/ninety-ducks-melt.md create mode 100644 packages/debugger/app/components/action-debugger-properties-table.tsx create mode 100644 packages/debugger/app/components/action-info.tsx create mode 100644 packages/debugger/app/components/cast-action-debugger.tsx create mode 100644 packages/debugger/app/components/farcaster-domain-account-association-dialog.tsx create mode 100644 packages/debugger/app/components/frame-app-debugger-notifications.tsx create mode 100644 packages/debugger/app/components/frame-app-debugger.tsx create mode 100644 packages/debugger/app/components/frame-app-notifications-control-panel.tsx create mode 100644 packages/debugger/app/components/frame-debugger-diagnostics.tsx create mode 100644 packages/debugger/app/components/frame-debugger-farcaster-manifest-details.tsx create mode 100644 packages/debugger/app/components/frame-debugger-request-card-content.tsx create mode 100644 packages/debugger/app/components/shortened-text.tsx create mode 100644 packages/debugger/app/hooks/useCopyToClipboad.ts create mode 100644 packages/debugger/app/hooks/useSharedFrameEventHandlers.tsx create mode 100644 packages/debugger/app/notifications/[namespaceId]/events/route.ts create mode 100644 packages/debugger/app/notifications/[namespaceId]/route.ts create mode 100644 packages/debugger/app/notifications/[namespaceId]/send/route.ts create mode 100644 packages/debugger/app/notifications/helpers.ts create mode 100644 packages/debugger/app/notifications/parsers.ts create mode 100644 packages/debugger/app/notifications/route.ts create mode 100644 packages/debugger/app/notifications/storage.ts create mode 100644 packages/debugger/app/notifications/storage/redis.ts create mode 100644 packages/debugger/app/notifications/storage/sqlite.ts create mode 100644 packages/debugger/app/notifications/types.ts create mode 100644 packages/debugger/app/providers/FrameAppNotificationsManagerProvider.tsx create mode 100644 packages/debugger/app/providers/FrameContextProvider.tsx create mode 100644 packages/debugger/app/providers/ProtocolSelectorProvider.tsx create mode 100644 packages/frames.js/src/farcaster-v2/events.ts create mode 100644 packages/frames.js/src/farcaster-v2/json-signature.test.ts create mode 100644 packages/frames.js/src/farcaster-v2/json-signature.ts create mode 100644 packages/frames.js/src/farcaster-v2/types.ts create mode 100644 packages/frames.js/src/frame-parsers/farcasterV2.test.ts create mode 100644 packages/frames.js/src/frame-parsers/farcasterV2.ts create mode 100644 packages/frames.js/src/lib/base64url.test.ts create mode 100644 packages/frames.js/src/lib/base64url.ts create mode 100644 packages/render/src/assert-never.ts create mode 100644 packages/render/src/frame-app/iframe.ts create mode 100644 packages/render/src/frame-app/provider/helpers.ts create mode 100644 packages/render/src/frame-app/provider/wagmi.ts create mode 100644 packages/render/src/frame-app/types.ts create mode 100644 packages/render/src/frame-app/use-fetch-frame-app.ts create mode 100644 packages/render/src/frame-app/use-resolve-client.ts create mode 100644 packages/render/src/frame-app/web-view.ts create mode 100644 packages/render/src/hooks/use-debug-log.ts create mode 100644 packages/render/src/use-cast-action.ts create mode 100644 packages/render/src/use-frame-app.ts diff --git a/.changeset/ninety-ducks-melt.md b/.changeset/ninety-ducks-melt.md new file mode 100644 index 000000000..ca93c5310 --- /dev/null +++ b/.changeset/ninety-ducks-melt.md @@ -0,0 +1,7 @@ +--- +"frames.js": minor +"@frames.js/debugger": minor +"@frames.js/render": minor +--- + +feat: farcaster v2 support diff --git a/packages/debugger/.env.sample b/packages/debugger/.env.sample index 430fe1cb7..001b3deb9 100644 --- a/packages/debugger/.env.sample +++ b/packages/debugger/.env.sample @@ -8,4 +8,8 @@ FARCASTER_DEVELOPER_MNEMONIC= # Example: FARCASTER_DEVELOPER_FID=1214 FARCASTER_DEVELOPER_FID= -NEXT_PUBLIC_WALLETCONNECT_ID= \ No newline at end of file +NEXT_PUBLIC_WALLETCONNECT_ID= + +# Required to debug Farcaster Frames v2 notifications +KV_REST_API_TOKEN="" +KV_REST_API_URL="" \ No newline at end of file diff --git a/packages/debugger/.gitignore b/packages/debugger/.gitignore index ad3f29cb7..28b12902b 100644 --- a/packages/debugger/.gitignore +++ b/packages/debugger/.gitignore @@ -36,4 +36,5 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts -mocks.json \ No newline at end of file +mocks.json +/notifications.db \ No newline at end of file diff --git a/packages/debugger/app/components/action-debugger-properties-table.tsx b/packages/debugger/app/components/action-debugger-properties-table.tsx new file mode 100644 index 000000000..8a89203c1 --- /dev/null +++ b/packages/debugger/app/components/action-debugger-properties-table.tsx @@ -0,0 +1,120 @@ +import { useMemo } from "react"; +import type { CastActionDefinitionResponse } from "../frames/route"; +import type { ParsingReport } from "frames.js"; +import { Table, TableBody, TableCell, TableRow } from "@/components/table"; +import { AlertTriangleIcon, CheckCircle2Icon, XCircleIcon } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { ShortenedText } from "./shortened-text"; + +function isPropertyExperimental([key, value]: [string, string]) { + return false; +} + +type ActionDebuggerPropertiesTableProps = { + actionMetadataItem: CastActionDefinitionResponse; +}; + +export function ActionDebuggerPropertiesTable({ + actionMetadataItem, +}: ActionDebuggerPropertiesTableProps) { + const properties = useMemo(() => { + /** tuple of key and value */ + const validProperties: [string, string][] = []; + /** tuple of key and error message */ + const invalidProperties: [string, ParsingReport[]][] = []; + const visitedInvalidProperties: string[] = []; + const result = actionMetadataItem; + + // we need to check validation errors first because getFrame incorrectly return a value for a key even if it's invalid + for (const [key, reports] of Object.entries(result.reports)) { + invalidProperties.push([key, reports]); + visitedInvalidProperties.push(key); + } + + for (const [key, value] of Object.entries(result.action)) { + if (visitedInvalidProperties.includes(key) || value == null) { + continue; + } + + if (typeof value === "object") { + validProperties.push([key, JSON.stringify(value)]); + } else { + validProperties.push([key, value]); + } + } + + return { + validProperties, + invalidProperties, + isValid: invalidProperties.length === 0, + hasExperimentalProperties: false, + }; + }, [actionMetadataItem]); + + return ( + + + {properties.validProperties.map(([propertyKey, value]) => { + return ( + + + {isPropertyExperimental([propertyKey, value]) ? ( + +
+ +
+
*
+
+ ) : ( + + )} +
+ {propertyKey} + + + +
+ ); + })} + {properties.invalidProperties.flatMap( + ([propertyKey, errorMessages]) => { + return errorMessages.map((errorMessage, i) => { + return ( + + + {errorMessage.level === "error" ? ( + + ) : ( + + )} + + {propertyKey} + +

+ {errorMessage.message} +

+
+
+ ); + }); + } + )} + {properties.hasExperimentalProperties && ( + + + *This property is experimental and may not have been adopted in + clients yet + + + )} +
+
+ ); +} diff --git a/packages/debugger/app/components/action-debugger.tsx b/packages/debugger/app/components/action-debugger.tsx index 851358855..478ffcd2a 100644 --- a/packages/debugger/app/components/action-debugger.tsx +++ b/packages/debugger/app/components/action-debugger.tsx @@ -1,184 +1,18 @@ -import { Table, TableBody, TableCell, TableRow } from "@/components/table"; -import { Card, CardContent } from "@/components/ui/card"; -import { - HoverCard, - HoverCardContent, - HoverCardTrigger, -} from "@/components/ui/hover-card"; -import { cn } from "@/lib/utils"; -import { - type FarcasterFrameContext, - type FrameActionBodyPayload, - defaultTheme, -} from "@frames.js/render"; -import { ParsingReport } from "frames.js"; -import { - AlertTriangle, - CheckCircle2, - InfoIcon, - RefreshCwIcon, - XCircle, -} from "lucide-react"; import React, { type Dispatch, type SetStateAction, useEffect, useImperativeHandle, - useMemo, useState, } from "react"; -import { Button } from "../../@/components/ui/button"; -import { FrameDebugger } from "./frame-debugger"; -import IconByName from "./octicons"; -import { MockHubActionContext } from "../utils/mock-hub-utils"; -import { useFrame } from "@frames.js/render/use-frame"; -import { WithTooltip } from "./with-tooltip"; -import { useToast } from "@/components/ui/use-toast"; +import type { MockHubActionContext } from "../utils/mock-hub-utils"; import type { CastActionDefinitionResponse } from "../frames/route"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import type { FarcasterSigner } from "@frames.js/render/identity/farcaster"; import { ComposerActionDebugger } from "./composer-action-debugger"; - -type FrameDebuggerFramePropertiesTableRowsProps = { - actionMetadataItem: CastActionDefinitionResponse; -}; - -function isPropertyExperimental([key, value]: [string, string]) { - // tx is experimental - return false; -} - -function ActionDebuggerPropertiesTableRow({ - actionMetadataItem, -}: FrameDebuggerFramePropertiesTableRowsProps) { - const properties = useMemo(() => { - /** tuple of key and value */ - const validProperties: [string, string][] = []; - /** tuple of key and error message */ - const invalidProperties: [string, ParsingReport[]][] = []; - const visitedInvalidProperties: string[] = []; - const result = actionMetadataItem; - - // we need to check validation errors first because getFrame incorrectly return a value for a key even if it's invalid - for (const [key, reports] of Object.entries(result.reports)) { - invalidProperties.push([key, reports]); - visitedInvalidProperties.push(key); - } - - for (const [key, value] of Object.entries(result.action)) { - if (visitedInvalidProperties.includes(key) || value == null) { - continue; - } - - if (typeof value === "object") { - validProperties.push([key, JSON.stringify(value)]); - } else { - validProperties.push([key, value]); - } - } - - return { - validProperties, - invalidProperties, - isValid: invalidProperties.length === 0, - hasExperimentalProperties: false, - }; - }, [actionMetadataItem]); - - return ( - - - {properties.validProperties.map(([propertyKey, value]) => { - return ( - - - {isPropertyExperimental([propertyKey, value]) ? ( - -
- -
-
*
-
- ) : ( - - )} -
- {propertyKey} - - - -
- ); - })} - {properties.invalidProperties.flatMap( - ([propertyKey, errorMessages]) => { - return errorMessages.map((errorMessage, i) => { - return ( - - - {errorMessage.level === "error" ? ( - - ) : ( - - )} - - {propertyKey} - -

- {errorMessage.message} -

-
-
- ); - }); - } - )} - {properties.hasExperimentalProperties && ( - - - *This property is experimental and may not have been adopted in - clients yet - - - )} -
-
- ); -} - -function ShortenedText({ - maxLength, - text, -}: { - maxLength: number; - text: string; -}) { - if (text.length < maxLength) return text; - - return ( - - {text.slice(0, maxLength - 3)}... - {text} - - ); -} +import { CastActionDebugger } from "./cast-action-debugger"; type ActionDebuggerProps = { actionMetadataItem: CastActionDefinitionResponse; - farcasterFrameConfig: Parameters< - typeof useFrame< - FarcasterSigner | null, - FrameActionBodyPayload, - FarcasterFrameContext - > - >[0]; refreshUrl: (arg0?: string) => void; mockHubContext?: Partial; setMockHubContext?: Dispatch>>; @@ -198,7 +32,6 @@ export const ActionDebugger = React.forwardRef< ( { actionMetadataItem, - farcasterFrameConfig, refreshUrl, mockHubContext, setMockHubContext, @@ -206,7 +39,6 @@ export const ActionDebugger = React.forwardRef< }, ref ) => { - const { toast } = useToast(); const [activeTab, setActiveTab] = useState( "type" in actionMetadataItem.action && actionMetadataItem.action.type === "composer" @@ -214,6 +46,7 @@ export const ActionDebugger = React.forwardRef< : "cast-action" ); const [copySuccess, setCopySuccess] = useState(false); + useEffect(() => { if (copySuccess) { setTimeout(() => { @@ -222,14 +55,6 @@ export const ActionDebugger = React.forwardRef< } }, [copySuccess, setCopySuccess]); - const actionFrameState = useFrame({ - ...farcasterFrameConfig, - }); - const [castActionDefinition, setCastActionDefinition] = useState | null>(null); - useImperativeHandle( ref, () => { @@ -258,114 +83,24 @@ export const ActionDebugger = React.forwardRef< - refreshUrl()} - > -
-
-
-
- -
-
-
- {actionMetadataItem.action.name} -
-
- {actionMetadataItem.action.description} -
-
-
-
- - - -
-
- -
-
- - {!!castActionDefinition && - !("type" in castActionDefinition.action) && ( -
-
- -
- )} + mockHubContext={mockHubContext} + setMockHubContext={setMockHubContext} + />
- refreshUrl()} - > - { - setActiveTab("cast-action"); - }} - /> - + onToggleToCastActionDebugger={() => { + setActiveTab("cast-action"); + }} + /> @@ -374,51 +109,3 @@ export const ActionDebugger = React.forwardRef< ); ActionDebugger.displayName = "ActionDebugger"; - -type ActionInfoProps = { - actionMetadataItem: CastActionDefinitionResponse; - children: React.ReactNode; - onRefreshUrl: () => void; -}; - -function ActionInfo({ - actionMetadataItem, - children, - onRefreshUrl, -}: ActionInfoProps) { - return ( -
-
-
- Reload URL

}> - -
-
-
-
- - {children} - -
-
-
- - - - - -
-
-
- ); -} diff --git a/packages/debugger/app/components/action-info.tsx b/packages/debugger/app/components/action-info.tsx new file mode 100644 index 000000000..7b32bd105 --- /dev/null +++ b/packages/debugger/app/components/action-info.tsx @@ -0,0 +1,54 @@ +import { Button } from "@/components/ui/button"; +import type { CastActionDefinitionResponse } from "../frames/route"; +import { WithTooltip } from "./with-tooltip"; +import { RefreshCwIcon } from "lucide-react"; +import { Card, CardContent } from "@/components/ui/card"; +import { ActionDebuggerPropertiesTable } from "./action-debugger-properties-table"; + +type ActionInfoProps = { + actionMetadataItem: CastActionDefinitionResponse; + children: React.ReactNode; + onRefreshUrl: () => void; +}; + +export function ActionInfo({ + actionMetadataItem, + children, + onRefreshUrl, +}: ActionInfoProps) { + return ( +
+
+
+ Reload URL

}> + +
+
+
+
+ + {children} + +
+
+
+ + + + + +
+
+
+ ); +} diff --git a/packages/debugger/app/components/cast-action-debugger.tsx b/packages/debugger/app/components/cast-action-debugger.tsx new file mode 100644 index 000000000..9fcea10fb --- /dev/null +++ b/packages/debugger/app/components/cast-action-debugger.tsx @@ -0,0 +1,185 @@ +import { InfoIcon } from "lucide-react"; +import type { CastActionDefinitionResponse } from "../frames/route"; +import IconByName from "./octicons"; +import { useToast } from "@/components/ui/use-toast"; +import { ActionInfo } from "./action-info"; +import { defaultTheme } from "@frames.js/render"; +import { useCastAction } from "@frames.js/render/use-cast-action"; +import { FrameDebugger } from "./frame-debugger"; +import { useFarcasterIdentity } from "../hooks/useFarcasterIdentity"; +import { type Dispatch, type SetStateAction, useState } from "react"; +import type { MockHubActionContext } from "../utils/mock-hub-utils"; +import { useFrameContext } from "../providers/FrameContextProvider"; + +type CastActionDebuggerProps = { + actionMetadataItem: CastActionDefinitionResponse; + onRefreshUrl: () => void; + mockHubContext?: Partial; + setMockHubContext?: Dispatch>>; + hasExamples: boolean; +}; + +export function CastActionDebugger({ + actionMetadataItem, + onRefreshUrl, + mockHubContext, + setMockHubContext, + hasExamples, +}: CastActionDebuggerProps) { + const { toast } = useToast(); + const farcasterIdentity = useFarcasterIdentity(); + const [postUrl, setPostUrl] = useState(null); + const frameContext = useFrameContext(); + const castAction = useCastAction({ + ...(postUrl + ? { + enabled: true, + postUrl, + } + : { + enabled: false, + postUrl: "", + }), + castId: frameContext.farcaster.castId, + proxyUrl: "/frames", + signer: farcasterIdentity, + onInvalidResponse(response) { + console.error(response); + + toast({ + title: "Invalid action response", + description: + "Please check the browser developer console for more information", + variant: "destructive", + }); + }, + onMessageResponse(response) { + console.log(response); + toast({ + description: response.message, + }); + }, + onError(error) { + console.error(error); + + toast({ + title: "Unexpected error happened", + description: + "Please check the browser developer console for more information", + variant: "destructive", + }); + }, + }); + + return ( + <> + +
+
+
+
+ +
+
+
+ {actionMetadataItem.action.name} +
+
+ {actionMetadataItem.action.description} +
+
+
+
+ + + +
+
+ +
+
+ + {castAction.status === "success" && castAction.type === "frame" && ( + { + toast({ + title: "Frame v2 is not supported in cast action debugger.", + description: "Please use the frame debugger instead.", + variant: "destructive", + }); + }} + /> + )} + + ); +} diff --git a/packages/debugger/app/components/cast-composer.tsx b/packages/debugger/app/components/cast-composer.tsx index a0def6594..7d213dd86 100644 --- a/packages/debugger/app/components/cast-composer.tsx +++ b/packages/debugger/app/components/cast-composer.tsx @@ -12,6 +12,7 @@ import { } from "lucide-react"; import IconByName from "./octicons"; import { useFrame_unstable } from "@frames.js/render/use-frame"; +import { isValidPartialFrame } from "@frames.js/render/ui/utils"; import { WithTooltip } from "./with-tooltip"; import { fallbackFrameContext } from "@frames.js/render"; import { FrameUI } from "./frame-ui"; @@ -20,7 +21,6 @@ import { ToastAction } from "@radix-ui/react-toast"; import Link from "next/link"; import { useFarcasterIdentity } from "../hooks/useFarcasterIdentity"; import { useAccount } from "wagmi"; -import { FrameStackDone } from "@frames.js/render/unstable-types"; import { useDebuggerFrameState } from "../hooks/useDebuggerFrameState"; type CastComposerProps = { @@ -122,15 +122,6 @@ function createDebugUrl(frameUrl: string, currentUrl: string) { return debugUrl.toString(); } -function isAtLeastPartialFrame(stackItem: FrameStackDone): boolean { - return ( - stackItem.frameResult.status === "success" || - (!!stackItem.frameResult.frame && - !!stackItem.frameResult.frame.buttons && - stackItem.frameResult.frame.buttons.length > 0) - ); -} - function CastEmbedPreview({ onRemove, url }: CastEmbedPreviewProps) { const account = useAccount(); const { toast } = useToast(); @@ -212,7 +203,7 @@ function CastEmbedPreview({ onRemove, url }: CastEmbedPreviewProps) { if ( frame.currentFrameStackItem?.status === "done" && - isAtLeastPartialFrame(frame.currentFrameStackItem) + isValidPartialFrame(frame.currentFrameStackItem.frameResult) ) { return (
diff --git a/packages/debugger/app/components/composer-action-debugger.tsx b/packages/debugger/app/components/composer-action-debugger.tsx index faa620999..ea1fdcb34 100644 --- a/packages/debugger/app/components/composer-action-debugger.tsx +++ b/packages/debugger/app/components/composer-action-debugger.tsx @@ -2,20 +2,26 @@ import type { ComposerActionResponse, ComposerActionState, } from "frames.js/types"; -import { CastComposer, CastComposerRef } from "./cast-composer"; +import { CastComposer, type CastComposerRef } from "./cast-composer"; import { useRef, useState } from "react"; import { ComposerFormActionDialog } from "./composer-form-action-dialog"; import { useFarcasterIdentity } from "../hooks/useFarcasterIdentity"; +import { ActionInfo } from "./action-info"; +import type { CastActionDefinitionResponse } from "../frames/route"; type ComposerActionDebuggerProps = { url: string; + actionMetadataItem: CastActionDefinitionResponse; actionMetadata: Partial; onToggleToCastActionDebugger: () => void; + onRefreshUrl: () => void; }; export function ComposerActionDebugger({ actionMetadata, + actionMetadataItem, url, + onRefreshUrl, onToggleToCastActionDebugger, }: ComposerActionDebuggerProps) { const castComposerRef = useRef(null); @@ -25,7 +31,10 @@ export function ComposerActionDebugger({ ); return ( - <> + )} - + ); } diff --git a/packages/debugger/app/components/farcaster-domain-account-association-dialog.tsx b/packages/debugger/app/components/farcaster-domain-account-association-dialog.tsx new file mode 100644 index 000000000..b65c1403c --- /dev/null +++ b/packages/debugger/app/components/farcaster-domain-account-association-dialog.tsx @@ -0,0 +1,226 @@ +import { + constructJSONFarcasterSignatureAccountAssociationPaylod, + sign, + type SignResult, +} from "frames.js/farcaster-v2/json-signature"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { useAccount, useSignMessage, useSwitchChain } from "wagmi"; +import { FormEvent, useCallback, useState } from "react"; +import { CopyIcon, CopyCheckIcon, CopyXIcon, Loader2Icon } from "lucide-react"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { useFarcasterIdentity } from "../hooks/useFarcasterIdentity"; +import { optimism } from "viem/chains"; +import { useToast } from "@/components/ui/use-toast"; +import { useCopyToClipboard } from "../hooks/useCopyToClipboad"; + +type FarcasterDomainAccountAssociationDialogProps = { + onClose: () => void; +}; + +export function FarcasterDomainAccountAssociationDialog({ + onClose, +}: FarcasterDomainAccountAssociationDialogProps) { + const copyCompact = useCopyToClipboard(); + const copyJSON = useCopyToClipboard(); + const account = useAccount(); + const { toast } = useToast(); + const farcasterSigner = useFarcasterIdentity(); + const { switchChainAsync } = useSwitchChain(); + const { signMessageAsync } = useSignMessage(); + const [isGenerating, setIsGenerating] = useState(false); + const [associationResult, setAssociationResult] = useState( + null + ); + + const handleSubmit = useCallback( + async (event: FormEvent) => { + event.preventDefault(); + + const data = new FormData(event.currentTarget); + + try { + if (farcasterSigner.signer?.status !== "approved") { + throw new Error("Farcaster signer is not approved"); + } + + if (!account.address) { + throw new Error("Account address is not available"); + } + + const domain = data.get("domain"); + + if (typeof domain !== "string" || !domain) { + throw new Error("Domain is required"); + } + + setIsGenerating(true); + + await switchChainAsync({ + chainId: optimism.id, + }); + + const result = await sign({ + fid: farcasterSigner.signer.fid, + payload: + constructJSONFarcasterSignatureAccountAssociationPaylod(domain), + signer: { + type: "custody", + custodyAddress: account.address, + }, + signMessage(message) { + return signMessageAsync({ + message, + }); + }, + }); + + setAssociationResult(result); + } catch (e) { + console.error(e); + toast({ + title: "An error occurred", + description: "Please check the console for more information", + variant: "destructive", + }); + } finally { + setIsGenerating(false); + } + }, + [ + account.address, + farcasterSigner.signer, + signMessageAsync, + switchChainAsync, + toast, + ] + ); + + return ( + { + if (!isOpen) { + onClose(); + } + }} + > + + + Domain Account Association + + {!associationResult && ( +
+ + +
+ )} + {associationResult && ( +
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+ )} + + {associationResult && ( + + )} + {!associationResult && ( + + )} + +
+
+ ); +} diff --git a/packages/debugger/app/components/frame-app-debugger-notifications.tsx b/packages/debugger/app/components/frame-app-debugger-notifications.tsx new file mode 100644 index 000000000..408b517ac --- /dev/null +++ b/packages/debugger/app/components/frame-app-debugger-notifications.tsx @@ -0,0 +1,221 @@ +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Console } from "console-feed"; +import { + AlertTriangleIcon, + InboxIcon, + Loader2Icon, + TrashIcon, +} from "lucide-react"; +import { useEffect, useState } from "react"; +import { Message } from "console-feed/lib/definitions/Component"; +import { useQuery } from "@tanstack/react-query"; +import { FrameAppNotificationsControlPanel } from "./frame-app-notifications-control-panel"; +import { useFrameAppNotificationsManagerContext } from "../providers/FrameAppNotificationsManagerProvider"; +import type { GETEventsResponseBody } from "../notifications/[namespaceId]/events/route"; +import { Button } from "@/components/ui/button"; +import { WithTooltip } from "./with-tooltip"; +import type { UseFrameAppInIframeReturn } from "@frames.js/render/frame-app/iframe"; +import { isValidPartialFrameV2 } from "@frames.js/render/ui/utils"; +import type { FarcasterSigner } from "@frames.js/render/identity/farcaster"; + +type FrameAppDebuggerNotificationsProps = { + frameApp: Extract; + farcasterSigner: FarcasterSigner | null; +}; + +export function FrameAppDebuggerNotifications({ + frameApp, + farcasterSigner, +}: FrameAppDebuggerNotificationsProps) { + const frame = frameApp.frame; + const frameAppNotificationManager = useFrameAppNotificationsManagerContext(); + const [events, setEvents] = useState([]); + const notificationsQuery = useQuery({ + initialData: [], + enabled: !!frameAppNotificationManager.state?.namespaceUrl, + queryKey: [ + "frame-app-notifications-log", + frameAppNotificationManager.state?.namespaceUrl, + ], + async queryFn() { + if (!frameAppNotificationManager.state?.namespaceUrl) { + return [] as Message[]; + } + + const response = await fetch( + `${frameAppNotificationManager.state.namespaceUrl}/events`, + { + method: "GET", + } + ); + + if (!response.ok) { + return [] as Message[]; + } + + const events = (await response.json()) as GETEventsResponseBody; + + return events.map((event): Message => { + switch (event.type) { + case "notification": + return { + method: "log", + id: crypto.randomUUID(), + data: ["🔔 Received notification", event.notification], + }; + case "event": + return { + method: "info", + id: crypto.randomUUID(), + data: ["➡️ Send event", event], + }; + case "event_failure": { + return { + method: "error", + id: crypto.randomUUID(), + data: ["❗ Received invalid response for event", event], + }; + } + case "event_success": { + return { + method: "log", + id: crypto.randomUUID(), + data: ["✅ Received successful response for event", event], + }; + } + default: + event as never; + return { + method: "error", + id: crypto.randomUUID(), + data: ["Received unknown event", event], + }; + } + }); + }, + refetchInterval: 5000, + }); + + useEffect(() => { + if (notificationsQuery.data) { + setEvents((prev) => [...prev, ...notificationsQuery.data]); + } + }, [notificationsQuery.data]); + + if (!isValidPartialFrameV2(frameApp.frame)) { + return ( + <> + + Invalid frame! + + Please check the diagnostics of initial frame + + + + ); + } + + if (!frame.manifest) { + return ( + <> + + Missing manifest + + Please check the diagnostics of initial frame + + + + ); + } + + if (!frame.manifest.manifest.frame?.webhookUrl) { + return ( + <> + + Missing webhookUrl + + Frame manifest must contain webhookUrl property in order to support + notifications. + + + + ); + } + + const notificationsEnabled = + frameAppNotificationManager.state?.frame.status === "added" && + !!frameAppNotificationManager.state.frame.notificationDetails; + + return ( +
+
+ {frame.manifest.status === "failure" && ( + + + Invalid manifest! + + Please check the diagnostics of initial frame + + + )} + {farcasterSigner?.status === "impersonating" && ( + + + Warning: Unsupported Farcaster signer + + You are using an impersonated signer, please approve a real signer + to use the debugger's Frames V2 webhooks + + + )} +
+ {farcasterSigner?.status === "approved" && ( +
+
+ +
+ {events.length === 0 ? ( +
+
+
+ + {!notificationsEnabled && ( + + )} +
+

+ {!notificationsEnabled + ? "Notifications are not enabled" + : "No notifications"} +

+

+ {!notificationsEnabled + ? "Notifications will appear here once they are enabled and the application sents any of them." + : "No notifications received yet."} +

+
+
+ ) : ( +
+

+ Event log + + + +

+
+ +
+
+ )} +
+ )} +
+ ); +} diff --git a/packages/debugger/app/components/frame-app-debugger.tsx b/packages/debugger/app/components/frame-app-debugger.tsx new file mode 100644 index 000000000..e86b19402 --- /dev/null +++ b/packages/debugger/app/components/frame-app-debugger.tsx @@ -0,0 +1,382 @@ +import { Button } from "@/components/ui/button"; +import type { FrameLaunchedInContext } from "./frame-debugger"; +import { WithTooltip } from "./with-tooltip"; +import { Loader2Icon, RefreshCwIcon } from "lucide-react"; +import { Card, CardContent } from "@/components/ui/card"; +import { useFrameAppInIframe } from "@frames.js/render/frame-app/iframe"; +import { useCallback, useRef, useState } from "react"; +import { useWagmiProvider } from "@frames.js/render/frame-app/provider/wagmi"; +import { useToast } from "@/components/ui/use-toast"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { cn } from "@/lib/utils"; +import { DebuggerConsole } from "./debugger-console"; +import Image from "next/image"; +import { fallbackFrameContext } from "@frames.js/render"; +import type { FarcasterSignerInstance } from "@frames.js/render/identity/farcaster"; +import { FrameAppDebuggerNotifications } from "./frame-app-debugger-notifications"; +import { + FrameAppNotificationsManagerProvider, + useFrameAppNotificationsManager, +} from "../providers/FrameAppNotificationsManagerProvider"; +import { ToastAction } from "@/components/ui/toast"; +import type { + FramePrimaryButton, + ResolveClientFunction, +} from "@frames.js/render/frame-app/types"; + +type TabValues = "events" | "console" | "notifications"; + +type FrameAppDebuggerProps = { + context: FrameLaunchedInContext; + farcasterSigner: FarcasterSignerInstance; + onClose: () => void; +}; + +// in debugger we don't want to automatically reject repeated add frame calls +const addFrameRequestsCache = new (class extends Set { + has(key: string) { + return false; + } + + add(key: string) { + return this; + } + + delete(key: string) { + return true; + } +})(); + +export function FrameAppDebugger({ + context, + farcasterSigner, + onClose, +}: FrameAppDebuggerProps) { + const farcasterSignerRef = useRef(farcasterSigner); + farcasterSignerRef.current = farcasterSigner; + const frameAppNotificationManager = useFrameAppNotificationsManager({ + farcasterSigner, + context, + }); + const { toast } = useToast(); + const debuggerConsoleTabRef = useRef(null); + const iframeRef = useRef(null); + const [activeTab, setActiveTab] = useState("notifications"); + const [isAppReady, setIsAppReady] = useState(false); + const [primaryButton, setPrimaryButton] = useState<{ + button: FramePrimaryButton; + callback: () => void; + } | null>(null); + const provider = useWagmiProvider({ + debug: true, + }); + /** + * we have to store promise in ref otherwise it will always invalidate the frame app hooks + * which happens for example when you disable notifications from notifications panel + */ + const frameAppNotificationManagerPromiseRef = useRef( + frameAppNotificationManager.promise + ); + const resolveClient: ResolveClientFunction = useCallback(async () => { + try { + const { manager } = await frameAppNotificationManagerPromiseRef.current; + const clientFid = parseInt(process.env.FARCASTER_DEVELOPER_FID ?? "-1"); + + if (!manager.state || manager.state.frame.status === "removed") { + return { + clientFid, + added: false, + }; + } + + return { + clientFid: parseInt(process.env.FARCASTER_DEVELOPER_FID ?? "-1"), + added: true, + notificationDetails: + manager.state.frame.notificationDetails ?? undefined, + }; + } catch (e) { + console.error(e); + + toast({ + title: "Unexpected error", + description: + "Failed to load notifications settings. Check the console for more details.", + variant: "destructive", + }); + } + + return { + clientFid: parseInt(process.env.FARCASTER_DEVELOPER_FID ?? "-1"), + added: false, + }; + }, [toast]); + const frameApp = useFrameAppInIframe({ + debug: true, + source: context.parseResult, + client: resolveClient, + location: + context.context === "button_press" + ? { + type: "launcher", + } + : { + type: "cast_embed", + cast: fallbackFrameContext.castId, + }, + farcasterSigner, + provider, + proxyUrl: "/frames", + addFrameRequestsCache, + onReady(options) { + console.info("sdk.actions.ready() called", { options }); + setIsAppReady(true); + }, + onClose() { + console.info("sdk.actions.close() called"); + toast({ + title: "Frame app closed", + description: + "The frame app called close() action. Would you like to close it?", + action: ( + { + onClose(); + }} + > + Close + + ), + }); + }, + onOpenUrl(url) { + console.info("sdk.actions.openUrl() called", { url }); + window.open(url, "_blank"); + }, + onPrimaryButtonSet(button, buttonCallback) { + console.info("sdk.actions.setPrimaryButton() called", { button }); + setPrimaryButton({ + button, + callback: () => { + console.info("primary button clicked"); + buttonCallback(); + }, + }); + }, + async onAddFrameRequested(parseResult) { + console.info("sdk.actions.addFrame() called"); + + if (frameAppNotificationManager.status === "pending") { + toast({ + title: "Notifications manager not ready", + description: + "Notifications manager is not ready. Please wait a moment.", + variant: "destructive", + }); + + throw new Error("Notifications manager is not ready"); + } + + if (frameAppNotificationManager.status === "error") { + toast({ + title: "Notifications manager error", + description: + "Notifications manager failed to load. Please check the console for more details.", + variant: "destructive", + }); + + throw new Error("Notifications manager failed to load"); + } + + const webhookUrl = parseResult.manifest?.manifest.frame?.webhookUrl; + + if (!webhookUrl) { + toast({ + title: "Webhook URL not found", + description: + "Webhook URL is not found in the manifest. It is required in order to enable notifications.", + variant: "destructive", + }); + + return false; + } + + // check what is the status of notifications for this app and signer + // if there are no settings ask for user's consent and store the result + const consent = window.confirm( + "Do you want to add the frame to the app?" + ); + + if (!consent) { + return false; + } + + try { + const result = + await frameAppNotificationManager.data.manager.addFrame(); + + return { + added: true, + notificationDetails: result, + }; + } catch (e) { + console.error(e); + + toast({ + title: "Failed to add frame", + description: + "Failed to add frame to the notifications manager. Check the console for more details.", + variant: "destructive", + }); + + throw e; + } + }, + }); + + return ( +
+
+
+ Reload frame app

}> + +
+
+
+
+
+ {frameApp.status === "pending" || + (!isAppReady && ( +
+ {context.frame.button.action.splashImageUrl && ( +
+ {`${name} +
+ +
+
+ )} +
+ ))} + {frameApp.status === "success" && ( + <> +