From 5ca3f0788e9e771e65ff4cc9ea685ebe84dc3825 Mon Sep 17 00:00:00 2001 From: Stephan Cilliers <5469870+stephancill@users.noreply.github.com> Date: Sun, 15 Sep 2024 19:26:45 +0200 Subject: [PATCH] feat: stories mini app --- package.json | 7 +- src/app/frames/route.ts | 178 ++++++++++++++++++++++- src/components/aside/aside-online.tsx | 74 ++++++++++ src/components/aside/aside.tsx | 2 +- src/components/frames/Frame.tsx | 76 +--------- src/components/layout/common-layout.tsx | 2 + src/components/modal/composer-action.tsx | 58 ++++++++ src/components/user/user-avatar.tsx | 14 +- src/lib/awaitable-controller.ts | 53 +++++++ src/lib/context/frame-config-context.tsx | 115 +++++++++++++++ src/lib/farcaster/utils.ts | 54 +++++++ src/lib/types/stories.ts | 26 ++++ src/pages/_app.tsx | 9 +- src/pages/api/online/index.ts | 19 +++ src/pages/api/stories/index.ts | 53 +++++++ src/pages/home.tsx | 161 ++++++++++++++++---- yarn.lock | 34 ++--- 17 files changed, 807 insertions(+), 128 deletions(-) create mode 100644 src/components/aside/aside-online.tsx create mode 100644 src/components/modal/composer-action.tsx create mode 100644 src/lib/awaitable-controller.ts create mode 100644 src/lib/context/frame-config-context.tsx create mode 100644 src/lib/types/stories.ts create mode 100644 src/pages/api/stories/index.ts diff --git a/package.json b/package.json index e2fac0f..a62aef3 100644 --- a/package.json +++ b/package.json @@ -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", @@ -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", @@ -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", diff --git a/src/app/frames/route.ts b/src/app/frames/route.ts index 9bd621b..ae49a7e 100644 --- a/src/app/frames/route.ts +++ b/src/app/frames/route.ts @@ -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; + } + | { + status: 'failure'; + action: any; + /** + * Reports contain warnings and errors that should be addressed before the frame can be used. + */ + reports: Record; + }; + +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 { + 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(); + } +} diff --git a/src/components/aside/aside-online.tsx b/src/components/aside/aside-online.tsx new file mode 100644 index 0000000..16e5158 --- /dev/null +++ b/src/components/aside/aside-online.tsx @@ -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(url)).result, + { revalidateOnFocus: false, refreshInterval: 10_000 } + ); + + return ( +
+ {!onlineResponse && onlineUsersLoading && ( + + )} + + {onlineResponse ? ( + +

Online

+
+ {onlineResponse && onlineResponse.users?.length === 0 && ( +
No users online
+ )} + {onlineResponse && + onlineResponse.users?.map(({ user, appFid }) => ( +
+
+
+ +
+ {onlineResponse.appProfilesMap[appFid] && ( + {onlineResponse.appProfilesMap[appFid].display} + )} +
+
+
+ ))} +
+ + {/* + Show more + */} +
+ ) : ( + + )} +
+ ); +} diff --git a/src/components/aside/aside.tsx b/src/components/aside/aside.tsx index b62fbc2..6a84422 100644 --- a/src/components/aside/aside.tsx +++ b/src/components/aside/aside.tsx @@ -29,7 +29,7 @@ export function Aside({ children }: AsideProps): JSX.Element | null { ); }} /> - {children} +
{children}
); diff --git a/src/components/frames/Frame.tsx b/src/components/frames/Frame.tsx index 6dd2d99..119435a 100644 --- a/src/components/frames/Frame.tsx +++ b/src/components/frames/Frame.tsx @@ -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 = { @@ -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 ( diff --git a/src/components/layout/common-layout.tsx b/src/components/layout/common-layout.tsx index 43bf6d5..57c7835 100644 --- a/src/components/layout/common-layout.tsx +++ b/src/components/layout/common-layout.tsx @@ -3,6 +3,7 @@ import { Placeholder } from '@components/common/placeholder'; import { useRequireAuth } from '@lib/hooks/useRequireAuth'; import type { ReactNode } from 'react'; import { AsideTrends } from '../aside/trends'; +import { AsideOnline } from '../aside/aside-online'; export type LayoutProps = { children: ReactNode; @@ -23,6 +24,7 @@ export function HomeLayout({ children }: LayoutProps): JSX.Element { ); diff --git a/src/components/modal/composer-action.tsx b/src/components/modal/composer-action.tsx new file mode 100644 index 0000000..41a5dfa --- /dev/null +++ b/src/components/modal/composer-action.tsx @@ -0,0 +1,58 @@ +import { useEffect, useRef } from 'react'; + +type ComposerFormActionDialogProps = { + composerActionForm: any; + onClose: () => void; + onSave: (arg: { composerState: any }) => void; +}; + +export function ComposerFormActionDialog({ + composerActionForm, + onClose, + onSave +}: ComposerFormActionDialogProps) { + const onSaveRef = useRef(onSave); + onSaveRef.current = onSave; + + useEffect(() => { + const handleMessage = (event: MessageEvent) => { + const result = event.data; + + // on error is not called here because there can be different messages that don't have anything to do with composer form actions + // instead we are just waiting for the correct message + if (!result.success) { + console.warn('Invalid message received', event.data); + return; + } + + if (result.data.data.cast.embeds.length > 2) { + console.warn('Only first 2 embeds are shown in the cast'); + } + + onSaveRef.current({ + composerState: result.data.data.cast + }); + }; + + window.addEventListener('message', handleMessage); + + return () => { + window.removeEventListener('message', handleMessage); + }; + }, []); + + return ( +
+
+ +
+ + {new URL(composerActionForm.url).hostname} + +
+ ); +} diff --git a/src/components/user/user-avatar.tsx b/src/components/user/user-avatar.tsx index 905dede..4e95a5a 100644 --- a/src/components/user/user-avatar.tsx +++ b/src/components/user/user-avatar.tsx @@ -8,6 +8,7 @@ type UserAvatarProps = { size?: number; username?: string; className?: string; + onClick?: () => void; }; export function UserAvatar({ @@ -15,12 +16,21 @@ export function UserAvatar({ alt, size, username, - className + className, + onClick }: UserAvatarProps): JSX.Element { const pictureSize = size ?? 48; return ( - + { + if (onClick) { + e.preventDefault(); + onClick(); + } + }} + >
+ implements Promise +{ + public readonly data: TData; + + private promise: Promise; + private resolvePromise!: (value: TResolveValue) => void; + private rejectPromise!: (reason?: any) => void; + + constructor(data: TData) { + this.data = data; + this.promise = new Promise((resolve, reject) => { + this.resolvePromise = resolve; + this.rejectPromise = reject; + }); + } + + [Symbol.toStringTag] = "AwaitableController"; + + resolve(value: TResolveValue) { + this.resolvePromise(value); + } + + reject(reason?: any) { + this.rejectPromise(reason); + } + + then( + onfulfilled?: + | ((value: TResolveValue) => TResult1 | PromiseLike) + | null + | undefined, + onrejected?: + | ((reason: any) => TResult2 | PromiseLike) + | null + | undefined + ): Promise { + return this.promise.then(onfulfilled, onrejected); + } + + catch( + onrejected?: + | ((reason: any) => TResult | PromiseLike) + | null + | undefined + ): Promise { + return this.promise.catch(onrejected); + } + + finally(onfinally?: (() => void) | null | undefined): Promise { + return this.promise.finally(onfinally); + } +} diff --git a/src/lib/context/frame-config-context.tsx b/src/lib/context/frame-config-context.tsx new file mode 100644 index 0000000..d76de74 --- /dev/null +++ b/src/lib/context/frame-config-context.tsx @@ -0,0 +1,115 @@ +import { OnTransactionFunc, UseFrameOptions } from '@frames.js/render'; +import { FarcasterSigner, signFrameAction } from '@frames.js/render/farcaster'; +import { useAuth } from '@lib/context/auth-context'; +import React, { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState +} from 'react'; +import { + useAccount, + useChainId, + useSendTransaction, + useSwitchChain +} from 'wagmi'; + +type FrameContextType = { + frameConfig: Omit; +}; + +const FrameContext = createContext(undefined); + +export const useFrameConfig = () => { + const context = useContext(FrameContext); + if (!context) { + throw new Error('useFrameContext must be used within a FrameProvider'); + } + return context; +}; + +export const FrameConfigProvider: React.FC<{ children: React.ReactNode }> = ({ + children +}) => { + const { user } = useAuth(); + const { address: connectedAddress } = useAccount(); + const [farcasterSigner, setFarcasterSigner] = useState< + FarcasterSigner | undefined + >(undefined); + const { sendTransactionAsync } = 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 + }); + + console.log('farcasterSigner', farcasterSigner); + } else { + setFarcasterSigner(undefined); + } + }, [user]); + + const onTransaction: OnTransactionFunc = useCallback( + 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; + }, + [currentChainId, switchChainAsync, sendTransactionAsync] + ); + + const frameConfig = useMemo( + () => + ({ + frameActionProxy: '/frames', + connectedAddress, + frameGetProxy: '/frames', + signerState: { + hasSigner: farcasterSigner !== undefined, + signer: farcasterSigner || null, + onSignerlessFramePress: async () => { + // 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"); + }, + // @ts-ignore -- TODO: Fix this + signFrameAction, + isLoadingSigner: false, + logout: async () => {} + }, + onTransaction + } as FrameContextType['frameConfig']), + [connectedAddress, farcasterSigner, onTransaction] + ); + + const contextValue = useMemo(() => ({ frameConfig }), [frameConfig]); + + return ( + + {children} + + ); +}; diff --git a/src/lib/farcaster/utils.ts b/src/lib/farcaster/utils.ts index 6b90d59..28904ef 100644 --- a/src/lib/farcaster/utils.ts +++ b/src/lib/farcaster/utils.ts @@ -13,7 +13,10 @@ import { NobleEd25519Signer, ReactionType } from '@farcaster/hub-web'; +import { ed25519 } from '@noble/curves/ed25519'; import { blake3 } from '@noble/hashes/blake3'; +import { Buffer } from 'buffer'; +import { toHex } from 'viem'; import { getActiveKeyPair } from '../keys'; function getSigner(privateKey: string): NobleEd25519Signer { @@ -221,3 +224,54 @@ export async function batchSubmitHubMessages(messages: Message[]) { }); return res.ok; } + +function base64ToBase64Url(base64: string): string { + return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); +} + +export async function generateAuthToken( + payload: Record, + fid: number, + expirationSeconds: number = 300 +): Promise { + const signer = await getSignerFromStorage(); + const keyPair = await getActiveKeyPair(); + + if (!keyPair) throw new Error('No active key pair found'); + + const header = { + fid, + type: 'app_key', + key: keyPair.publicKey + }; + + const encodedHeader = base64ToBase64Url( + Buffer.from(JSON.stringify(header)).toString('base64') + ); + + const fullPayload = { + ...payload, + exp: Math.floor(Date.now() / 1000) + expirationSeconds + }; + const encodedPayload = base64ToBase64Url( + Buffer.from(JSON.stringify(fullPayload)).toString('base64') + ); + + const message = Buffer.from(`${encodedHeader}.${encodedPayload}`, 'utf-8'); + + const signatureResult = await signer.signMessageHash(message); + + if (signatureResult.isErr()) throw new Error('Failed to sign message'); + + const encodedSignature = base64ToBase64Url( + Buffer.from(signatureResult.value).toString('base64') + ); + + const validate = ed25519.verify( + signatureResult.value, + message, + Buffer.from(keyPair.publicKey.slice(2), 'hex') + ); + + return `${encodedHeader}.${encodedPayload}.${encodedSignature}`; +} diff --git a/src/lib/types/stories.ts b/src/lib/types/stories.ts new file mode 100644 index 0000000..1672c2f --- /dev/null +++ b/src/lib/types/stories.ts @@ -0,0 +1,26 @@ +import { BaseResponse } from './responses'; + +export type StoriesAPIResponse = { + stories: { + streak?: { + streakLength: number; + currentStreak: number; + }; + posts: { + viewed: boolean; + storyId: string; + createdAt: string; + viewCount: number | null | undefined; + }[]; + user: { + pfp_url: string; + display_name: string; + username: string; + fid: number; + }; + startIndex: number; + viewedAll: boolean; + }[]; +}; + +export type StoriesResponse = BaseResponse; diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index 03ec342..8bd4ee4 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -11,6 +11,7 @@ import type { ReactElement, ReactNode } from 'react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { WagmiProvider } from 'wagmi'; import { arbitrum, base, mainnet, optimism, polygon, zora } from 'wagmi/chains'; +import { FrameConfigProvider } from '../lib/context/frame-config-context'; const queryClient = new QueryClient(); @@ -47,9 +48,11 @@ export default function App({ - - {getLayout()} - + + + {getLayout()} + + diff --git a/src/pages/api/online/index.ts b/src/pages/api/online/index.ts index a494f19..48e27c1 100644 --- a/src/pages/api/online/index.ts +++ b/src/pages/api/online/index.ts @@ -117,6 +117,25 @@ export default async function handle( return acc; }, {} as Record); + // Fill in missing app profiles + const appProfilesNotFound = new Set( + Object.keys(appProfilesMap) + .filter((key) => !appProfilesMap[key].username) + .map((fid) => BigInt(fid)) + ); + const appProfiles = await Promise.all( + Array.from(appProfilesNotFound).map((fid) => resolveUserFromFid(fid)) + ); + appProfiles.forEach((profile) => { + if (profile) { + appProfilesMap[profile.id.toString()] = { + display: profile.name, + username: profile.username, + pfp: profile.photoURL + }; + } + }); + const appFidsByUserFid = signers.reduce((acc, cur) => { acc[cur.user_fid.toString()] = cur.requester_fid.toString(); return acc; diff --git a/src/pages/api/stories/index.ts b/src/pages/api/stories/index.ts new file mode 100644 index 0000000..70dee0b --- /dev/null +++ b/src/pages/api/stories/index.ts @@ -0,0 +1,53 @@ +import { NextApiRequest, NextApiResponse } from 'next'; +import { prisma } from '../../../lib/prisma'; +import { StoriesResponse } from '../../../lib/types/stories'; + +export default async function handle( + req: NextApiRequest, + res: NextApiResponse +) { + const { method } = req; + switch (method) { + case 'POST': + const authToken = req.headers.authorization; + const fid = req.query.fid as string; + + if (!authToken) { + res.status(401).json({ message: 'Unauthorized' }); + return; + } + + const fids = await prisma.links.findMany({ + where: { + fid: BigInt(fid), + type: 'follow', + deleted_at: null + }, + distinct: ['target_fid', 'fid'], + select: { + target_fid: true + } + }); + + const stories = await fetch( + `${process.env.STORIES_API_URL}/api/stories/feed`, + { + headers: { + Authorization: authToken + }, + method: 'POST', + body: JSON.stringify({ + fids: fids.map((fid) => Number(fid.target_fid)) + }) + } + ); + + const storiesData = await stories.json(); + + res.json({ result: storiesData }); + break; + default: + res.setHeader('Allow', ['POST']); + res.status(405).end(`Method ${method} Not Allowed`); + } +} diff --git a/src/pages/home.tsx b/src/pages/home.tsx index 6dc8f2d..60edb41 100644 --- a/src/pages/home.tsx +++ b/src/pages/home.tsx @@ -4,30 +4,98 @@ import { MainHeader } from '@components/home/main-header'; import { Input } from '@components/input/input'; import { HomeLayout, ProtectedLayout } from '@components/layout/common-layout'; import { MainLayout } from '@components/layout/main-layout'; +import { + fallbackFrameContext, + OnComposeFormActionFuncReturnType +} from '@frames.js/render'; +import { useFrame } from '@frames.js/render/use-frame'; import { useWindow } from '@lib/context/window-context'; -import { useState, type ReactElement, type ReactNode } from 'react'; +import cn from 'clsx'; +import { useEffect, useState, type ReactElement, type ReactNode } from 'react'; import useSWR from 'swr'; import { TweetFeed } from '../components/feed/tweet-feed'; +import { ComposerFormActionDialog } from '../components/modal/composer-action'; +import { Modal } from '../components/modal/modal'; import { FeedOrderingSelector } from '../components/ui/feed-ordering-selector'; +import { HeroIcon } from '../components/ui/hero-icon'; +import { NextImage } from '../components/ui/next-image'; import { UserAvatar } from '../components/user/user-avatar'; +import { AwaitableController } from '../lib/awaitable-controller'; import { useAuth } from '../lib/context/auth-context'; -import { fetchJSON } from '../lib/fetch'; +import { useFrameConfig } from '../lib/context/frame-config-context'; +import { generateAuthToken } from '../lib/farcaster/utils'; +import { useModal } from '../lib/hooks/useModal'; import { FeedOrderingType } from '../lib/types/feed'; -import { OnlineUsersResponse } from '../lib/types/online'; -import { NextImage } from '../components/ui/next-image'; +import { StoriesResponse } from '../lib/types/stories'; export default function Home(): JSX.Element { const { isMobile } = useWindow(); const { user, userNotifications } = useAuth(); + const { openModal, open, closeModal } = useModal(); + const [composeFormActionDialogSignal, setComposerFormActionDialogSignal] = + useState | null>(null); + const { frameConfig } = useFrameConfig(); + + const actionFrameState = useFrame({ + homeframeUrl: 'https://stories.steer.fun/frames/actions/post', + frameContext: fallbackFrameContext, + ...frameConfig, + async onComposerFormAction({ form }) { + try { + const dialogSignal = new AwaitableController< + OnComposeFormActionFuncReturnType, + any + >(form); + + setComposerFormActionDialogSignal(dialogSignal); + + const result = await dialogSignal; + + return result; + } catch (e) { + console.error(e); + } finally { + setComposerFormActionDialogSignal(null); + } + } + }); - const { data: onlineResponse, isValidating: onlineUsersLoading } = useSWR( - `/api/online?fid=${user?.id}`, - async (url) => (await fetchJSON(url)).result, - { revalidateOnFocus: false, refreshInterval: 10_000 } + const { data: storiesResponse, isValidating: storiesLoading } = useSWR( + `/api/stories?fid=${user?.id}`, + async (url) => { + if (!user?.id) return; + const authToken = await generateAuthToken({}, parseInt(user.id)); + const response = await fetch(url, { + headers: { + Authorization: `Bearer ${authToken}` + }, + method: 'POST' + }); + + const responseJson: StoriesResponse = await response.json(); + + return responseJson.result; + }, + { + revalidateOnFocus: false, + refreshInterval: 10_000, + onError: (error) => { + console.log('error', error); + } + } ); const [feedOrdering, setFeedOrdering] = useState('latest'); + useEffect(() => { + if (composeFormActionDialogSignal) { + openModal(); + } + }, [composeFormActionDialogSignal]); + return ( + + {composeFormActionDialogSignal && ( + { + composeFormActionDialogSignal.resolve(undefined); + }} + onSave={({ composerState }) => { + composeFormActionDialogSignal.resolve({ + composerActionState: composerState + }); + }} + /> + )} +
-
- {onlineUsersLoading && !onlineResponse && ( +
+ {storiesLoading && !storiesResponse && (
)} -
- {onlineResponse && onlineResponse.users?.length === 0 && ( -
No users online
+
+ {user && ( + )} - {onlineResponse && - onlineResponse.users?.map(({ user, appFid }) => ( -
-
+ {storiesResponse?.stories && + storiesResponse?.stories.map(({ user, viewedAll }) => ( +
+
console.log('on story')} username={user.username} - src={user.photoURL} - alt={user.name} + src={user.pfp_url} + alt={user.display_name} size={64} /> -
- {onlineResponse.appProfilesMap[appFid] && ( - {onlineResponse.appProfilesMap[appFid].display} - )}
diff --git a/yarn.lock b/yarn.lock index 05a55f2..188ad21 100644 --- a/yarn.lock +++ b/yarn.lock @@ -939,13 +939,14 @@ resolved "https://registry.yarnpkg.com/@firebase/webchannel-wrapper/-/webchannel-wrapper-0.10.1.tgz#60bb2aaf129f9e00621f8d698722ddba6ee1f8ac" integrity sha512-Dq5rYfEpdeel0bLVN+nfD1VWmzCkK+pJbSjIawGE+RY4+NIJqhbUDDQjvV0NUK84fMfwxvtFoCtEe70HfZjFcw== -"@frames.js/render@^0.2.20": - version "0.2.20" - resolved "https://registry.yarnpkg.com/@frames.js/render/-/render-0.2.20.tgz#8e4c92c8f0ffcfb5781dd342802ab55106bb64e7" - integrity sha512-NiZRAlM/KuIegFeazEp/CMjMtyziLVMmjILzgco6stCmv8tTCqDkL1qlZ4/xmAipqUSm5N8ZeKDilnV6bpCsIA== +"@frames.js/render@^0.3.8": + version "0.3.8" + resolved "https://registry.yarnpkg.com/@frames.js/render/-/render-0.3.8.tgz#3e3410591d6933c78fe2ec567019573d1870fc2d" + integrity sha512-6y/fkillOm1wXWDTmMLwmGSf/pDTv9GYFuwWIb3618I4YcXznWb81mRC/UtnlyUn/X4yrbyoRz4/n+JtOXav5A== dependencies: "@farcaster/core" "^0.14.7" - frames.js "^0.17.4" + "@noble/ed25519" "^2.0.0" + frames.js "^0.19.1" "@grpc/grpc-js@~1.7.0": version "1.7.3" @@ -5185,20 +5186,10 @@ framer-motion@^7.2.1: optionalDependencies: "@emotion/is-prop-valid" "^0.8.2" -frames.js@^0.17.1: - version "0.17.1" - resolved "https://registry.yarnpkg.com/frames.js/-/frames.js-0.17.1.tgz#e5029eb0eb914e928cd0965d264c9550a5d56136" - integrity sha512-aCkxFSCx4jnaLyiIoAaPir3XUbxZRYcHonGt+VGzArOz6pnOjue3TM0v45YNIjrmJ+4Gw3Wplgx7em2xo8JuTw== - dependencies: - "@vercel/og" "^0.6.2" - cheerio "^1.0.0-rc.12" - protobufjs "^7.2.6" - viem "^2.7.8" - -frames.js@^0.17.4: - version "0.17.4" - resolved "https://registry.yarnpkg.com/frames.js/-/frames.js-0.17.4.tgz#b7ebc3d47cd4aa05e77fe0de029d0996ede5f48d" - integrity sha512-nkeFPaTBAQK2q6eMvCT9ow69MLX8PDZKCdFaNldU3U7zhGGTC3ccFCGqlG5SyujYukb6zchlWfeOw1YdMcRCgQ== +frames.js@^0.19.1: + version "0.19.1" + resolved "https://registry.yarnpkg.com/frames.js/-/frames.js-0.19.1.tgz#fe0257e5e7bc6db119052909f1bbc3a471e4783c" + integrity sha512-4u4RWS7pyHtR+86IvAZyr+eXOUB2ySns5wTH0gowNb8+SExbaobwEO1yKOAPoGNB+Ed9bomHQjskiPKsfXE+xg== dependencies: "@vercel/og" "^0.6.2" cheerio "^1.0.0-rc.12" @@ -9939,6 +9930,11 @@ yoga-wasm-web@0.3.3, yoga-wasm-web@^0.3.3: resolved "https://registry.yarnpkg.com/yoga-wasm-web/-/yoga-wasm-web-0.3.3.tgz#eb8e9fcb18e5e651994732f19a220cb885d932ba" integrity sha512-N+d4UJSJbt/R3wqY7Coqs5pcV0aUj2j9IaQ3rNj9bVCLld8tTGKRa2USARjnvZJWVx1NDmQev8EknoczaOQDOA== +zod@^3.23.8: + version "3.23.8" + resolved "https://registry.yarnpkg.com/zod/-/zod-3.23.8.tgz#e37b957b5d52079769fb8097099b592f0ef4067d" + integrity sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g== + zustand@4.4.1: version "4.4.1" resolved "https://registry.yarnpkg.com/zustand/-/zustand-4.4.1.tgz#0cd3a3e4756f21811bd956418fdc686877e8b3b0"