Skip to content
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

feat: example stories mini app integration #42

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
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
7 changes: 4 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
"@farcaster/core": "^0.13.3",
"@farcaster/hub-nodejs": "^0.10.3",
"@farcaster/hub-web": "^0.6.0",
"@frames.js/render": "^0.2.20",
"@frames.js/render": "^0.3.8",
"@headlessui/react": "^1.7.2",
"@heroicons/react": "^2.0.11",
"@noble/ed25519": "^2.0.0",
Expand All @@ -27,7 +27,7 @@
"clsx": "^1.2.1",
"firebase": "^9.9.4",
"framer-motion": "^7.2.1",
"frames.js": "^0.17.1",
"frames.js": "^0.19.1",
"lodash": "^4.17.21",
"lru-cache": "^10.0.1",
"metadata-scraper": "^0.2.61",
Expand All @@ -42,7 +42,8 @@
"swr": "^1.3.0",
"validator": "^13.11.0",
"viem": "2.x",
"wagmi": "^2.10.9"
"wagmi": "^2.10.9",
"zod": "^3.23.8"
},
"devDependencies": {
"@testing-library/jest-dom": "^5.16.4",
Expand Down
178 changes: 177 additions & 1 deletion src/app/frames/route.ts
Original file line number Diff line number Diff line change
@@ -1 +1,177 @@
export { GET, POST } from '@frames.js/render/next';
import { type FrameActionPayload, getFrame } from 'frames.js';
import { type NextRequest } from 'next/server';
import type { SupportedParsingSpecification } from 'frames.js';
import { z } from 'zod';
import type { ParseResult } from 'frames.js/frame-parsers';
import type { ParsingReport } from 'frames.js';

export type ParseActionResult =
| {
status: 'success';
action: any;
/**
* Reports contain only warnings that should not have any impact on the frame's functionality.
*/
reports: Record<string, ParsingReport[]>;
}
| {
status: 'failure';
action: any;
/**
* Reports contain warnings and errors that should be addressed before the frame can be used.
*/
reports: Record<string, ParsingReport[]>;
};

const castActionMessageParser = z.object({
type: z.literal('message'),
message: z.string().min(1)
});

const castActionFrameParser = z.object({
type: z.literal('frame'),
frameUrl: z.string().min(1).url()
});

const composerActionFormParser = z.object({
type: z.literal('form'),
url: z.string().min(1).url(),
title: z.string().min(1)
});

const jsonResponseParser = z.preprocess((data) => {
if (typeof data === 'object' && data !== null && !('type' in data)) {
return {
type: 'message',
...data
};
}

return data;
}, z.discriminatedUnion('type', [castActionFrameParser, castActionMessageParser, composerActionFormParser]));

const errorResponseParser = z.object({
message: z.string().min(1)
});

export type CastActionDefinitionResponse = ParseActionResult & {
type: 'action';
url: string;
};

export type FrameDefinitionResponse = ParseResult & {
type: 'frame';
};

export function isSpecificationValid(
specification: unknown
): specification is SupportedParsingSpecification {
return (
typeof specification === 'string' &&
['farcaster', 'openframes'].includes(specification)
);
}

export { GET } from '@frames.js/render/next';

/** Proxies frame actions to avoid CORS issues and preserve user IP privacy */
export async function POST(req: NextRequest): Promise<Response> {
const body = (await req.clone().json()) as FrameActionPayload;
const isPostRedirect =
req.nextUrl.searchParams.get('postType') === 'post_redirect';
const isTransactionRequest =
req.nextUrl.searchParams.get('postType') === 'tx';
const postUrl = req.nextUrl.searchParams.get('postUrl');
const specification =
req.nextUrl.searchParams.get('specification') ?? 'farcaster';

if (!isSpecificationValid(specification)) {
return Response.json({ message: 'Invalid specification' }, { status: 400 });
}

if (!postUrl) {
return Response.error();
}

try {
const r = await fetch(postUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
redirect: isPostRedirect ? 'manual' : undefined,
body: JSON.stringify(body)
});

if (r.status === 302) {
return Response.json(
{
location: r.headers.get('location')
},
{ status: 302 }
);
}

// this is an error, just return response as is
if (r.status >= 500) {
return Response.json(await r.text(), { status: r.status });
}

if (r.status >= 400 && r.status < 500) {
const parseResult = await z
.promise(errorResponseParser)
.safeParseAsync(r.clone().json());

if (!parseResult.success) {
return Response.json(
{ message: await r.clone().text() },
{ status: r.status }
);
}

return r.clone();
}

if (isPostRedirect && r.status !== 302) {
return Response.json(
{
message: `Invalid response status code for post redirect button, 302 expected, got ${r.status}`
},
{ status: 400 }
);
}

if (isTransactionRequest) {
const transaction = (await r.json()) as JSON;
return Response.json(transaction);
}

// Content type is JSON, could be an action
if (r.headers.get('content-type')?.includes('application/json')) {
const parseResult = await z
.promise(jsonResponseParser)
.safeParseAsync(r.clone().json());

if (!parseResult.success) {
throw new Error('Invalid frame response');
}

return r.clone();
}

const htmlString = await r.text();

const result = getFrame({
htmlString,
url: body.untrustedData.url,
specification,
fromRequestMethod: 'POST'
});

return Response.json(result);
} catch (err) {
// eslint-disable-next-line no-console -- provide feedback to the user
console.error(err);
return Response.error();
}
}
74 changes: 74 additions & 0 deletions src/components/aside/aside-online.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import Link from 'next/link';
import { motion } from 'framer-motion';
import { UserCard } from '@components/user/user-card';
import { Loading } from '@components/ui/loading';
import { Error } from '@components/ui/error';
import { variants } from './aside-trends';
import { useAuth } from '../../lib/context/auth-context';
import useSWR from 'swr';
import { fetchJSON } from '../../lib/fetch';
import { OnlineUsersResponse } from '../../lib/types/online';
import { UserCards } from '../user/user-cards';
import { UserAvatar } from '../user/user-avatar';

export function AsideOnline(): JSX.Element {
const { user, userNotifications } = useAuth();

const { data: onlineResponse, isValidating: onlineUsersLoading } = useSWR(
`/api/online?fid=${user?.id}`,
async (url) => (await fetchJSON<OnlineUsersResponse>(url)).result,
{ revalidateOnFocus: false, refreshInterval: 10_000 }
);

return (
<section className='hover-animation top-[4.5rem] overflow-hidden rounded-2xl bg-main-sidebar-background'>
{!onlineResponse && onlineUsersLoading && (
<Loading className='flex h-52 items-center justify-center p-4' />
)}

{onlineResponse ? (
<motion.div className='inner:px-4 inner:py-3' {...variants}>
<h2 className='text-xl font-bold'>Online</h2>
<div className='flex gap-2 overflow-scroll px-2'>
{onlineResponse && onlineResponse.users?.length === 0 && (
<div>No users online</div>
)}
{onlineResponse &&
onlineResponse.users?.map(({ user, appFid }) => (
<div key={user.id} className='p-1'>
<div className='relative rounded-full bg-gradient-to-r from-blue-500 to-purple-500 p-[2px]'>
<div className='relative rounded-full bg-white'>
<UserAvatar
username={user.username}
src={user.photoURL}
alt={user.name}
size={64}
/>
<div className='absolute bottom-0.5 right-0.5 h-2 w-2 rounded-full bg-green-500'></div>
{onlineResponse.appProfilesMap[appFid] && (
<img
className='border-1 absolute bottom-0.5 h-5 w-5 rounded-md border border-gray-500'
src={onlineResponse.appProfilesMap[appFid].pfp}
alt={onlineResponse.appProfilesMap[appFid].display}
/>
)}
</div>
</div>
</div>
))}
</div>

{/* <Link
href='/online'
className='custom-button accent-tab hover-card block w-full rounded-2xl
rounded-t-none text-center text-main-accent'
>
Show more
</Link> */}
</motion.div>
) : (
<Error />
)}
</section>
);
}
2 changes: 1 addition & 1 deletion src/components/aside/aside.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export function Aside({ children }: AsideProps): JSX.Element | null {
);
}}
/>
{children}
<div className='sticky top-[4.5rem] flex flex-col gap-4'>{children}</div>
<AsideFooter />
</aside>
);
Expand Down
76 changes: 4 additions & 72 deletions src/components/frames/Frame.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,9 @@
'use client';

import {
FarcasterFrameContext,
FarcasterSigner,
signFrameAction
} from '@frames.js/render/farcaster';
import { FarcasterFrameContext } from '@frames.js/render/farcaster';
import { useFrame } from '@frames.js/render/use-frame';
import { useAuth } from '@lib/context/auth-context';
import { Frame as FrameType } from 'frames.js';
import { useEffect, useState } from 'react';
import * as chains from 'viem/chains';
import {
useAccount,
useChainId,
useSendTransaction,
useSwitchChain
} from 'wagmi';
import { useFrameConfig } from '../../lib/context/frame-config-context';
import { FrameUI } from './frame-ui';

type FrameProps = {
Expand All @@ -24,70 +12,14 @@ type FrameProps = {
frameContext: FarcasterFrameContext;
};

const getChainFromId = (id: number): chains.Chain | undefined => {
return Object.values(chains).find((chain) => chain.id === id);
};

export function Frame({ frame, frameContext, url }: FrameProps) {
const { user } = useAuth();
const { address: connectedAddress } = useAccount();
const [farcasterSigner, setFarcasterSigner] = useState<
FarcasterSigner | undefined
>(undefined);
const { sendTransactionAsync, sendTransaction } = useSendTransaction();
const currentChainId = useChainId();
const { switchChainAsync } = useSwitchChain();

useEffect(() => {
if (user?.keyPair) {
setFarcasterSigner({
fid: parseInt(user.id),
privateKey: user.keyPair.privateKey,
status: 'approved',
publicKey: user.keyPair.publicKey
});
} else {
setFarcasterSigner(undefined);
}
}, [user]);
const { frameConfig } = useFrameConfig();

const frameState = useFrame({
homeframeUrl: url,
frame,
frameActionProxy: '/frames',
connectedAddress,
frameGetProxy: '/frames',
frameContext,
signerState: {
hasSigner: farcasterSigner !== undefined,
signer: farcasterSigner,
onSignerlessFramePress: () => {
// Only run if `hasSigner` is set to `false`
// This is a good place to throw an error or prompt the user to login
// alert("A frame button was pressed without a signer. Perhaps you want to prompt a login");
},
signFrameAction: signFrameAction
},
onTransaction: async ({ transactionData }) => {
// Switch to the chain that the transaction is on
const chainId = parseInt(transactionData.chainId.split(':')[1]);
if (chainId !== currentChainId) {
const newChain = await switchChainAsync?.({ chainId });
if (!newChain) {
console.error('Failed to switch network');
return null;
}
}

const hash = await sendTransactionAsync({
...transactionData.params,
value: transactionData.params.value
? BigInt(transactionData.params.value)
: undefined,
chainId: parseInt(transactionData.chainId.split(':')[1])
});
return hash || null;
}
...frameConfig
});

return (
Expand Down
Loading