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" && ( + <> +