Merge pull request #9 from neynarxyz/veganbeef/share-button

feat: share button [NEYN-5380]
This commit is contained in:
Lucas Myers 2025-06-13 14:14:24 -07:00 committed by GitHub
commit 6a056cb30c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 458 additions and 138 deletions

View File

@ -239,6 +239,24 @@ export async function init() {
}
]);
// Ask about wallet and transaction tooling
const walletAnswer = await inquirer.prompt([
{
type: 'confirm',
name: 'useWallet',
message: 'Would you like to include wallet and transaction tooling in your mini app?\n' +
'This includes:\n' +
'- EVM wallet connection\n' +
'- Transaction signing\n' +
'- Message signing\n' +
'- Chain switching\n' +
'- Solana support\n\n' +
'Include wallet and transaction features?',
default: true
}
]);
answers.useWallet = walletAnswer.useWallet;
// Ask about localhost vs tunnel
const hostingAnswer = await inquirer.prompt([
{
@ -328,7 +346,7 @@ export async function init() {
"@farcaster/frame-node": ">=0.0.18 <1.0.0",
"@farcaster/frame-sdk": ">=0.0.31 <1.0.0",
"@farcaster/frame-wagmi-connector": ">=0.0.19 <1.0.0",
"@farcaster/mini-app-solana": "^0.0.5",
"@farcaster/mini-app-solana": ">=0.0.17 <1.0.0",
"@neynar/react": "^1.2.2",
"@radix-ui/react-label": "^2.1.1",
"@solana/wallet-adapter-react": "^0.15.38",
@ -388,6 +406,7 @@ export async function init() {
fs.appendFileSync(envPath, `\nNEXT_PUBLIC_FRAME_TAGS="${answers.tags.join(',')}"`);
fs.appendFileSync(envPath, `\nNEXT_PUBLIC_FRAME_BUTTON_TEXT="${answers.buttonText}"`);
fs.appendFileSync(envPath, `\nNEXT_PUBLIC_ANALYTICS_ENABLED="${answers.enableAnalytics}"`);
fs.appendFileSync(envPath, `\nNEXT_PUBLIC_USE_WALLET="${answers.useWallet}"`);
fs.appendFileSync(envPath, `\nNEXTAUTH_SECRET="${crypto.randomBytes(32).toString('hex')}"`);
if (useNeynar && neynarApiKey && neynarClientId) {

View File

@ -1,6 +1,6 @@
{
"name": "@neynar/create-farcaster-mini-app",
"version": "1.3.1",
"version": "1.3.2",
"type": "module",
"private": false,
"access": "public",

View File

@ -0,0 +1,54 @@
import { NeynarAPIClient } from '@neynar/nodejs-sdk';
import { NextResponse } from 'next/server';
export async function GET(request: Request) {
const apiKey = process.env.NEYNAR_API_KEY;
const { searchParams } = new URL(request.url);
const fid = searchParams.get('fid');
if (!apiKey) {
return NextResponse.json(
{ error: 'Neynar API key is not configured. Please add NEYNAR_API_KEY to your environment variables.' },
{ status: 500 }
);
}
if (!fid) {
return NextResponse.json(
{ error: 'FID parameter is required' },
{ status: 400 }
);
}
try {
const neynar = new NeynarAPIClient({ apiKey });
// TODO: update to use best friends endpoint once SDK merged in
const response = await fetch(
`https://api.neynar.com/v2/farcaster/user/best_friends?fid=${fid}&limit=3`,
{
headers: {
"x-api-key": apiKey,
},
}
);
if (!response.ok) {
throw new Error(`Neynar API error: ${response.statusText}`);
}
const { users } = await response.json() as { users: { user: { fid: number; username: string } }[] };
const bestFriends = users.map(user => ({
fid: user.user?.fid,
username: user.user?.username,
}));
return NextResponse.json({ bestFriends });
} catch (error) {
console.error('Failed to fetch best friends:', error);
return NextResponse.json(
{ error: 'Failed to fetch best friends. Please check your Neynar API key and try again.' },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,39 @@
import { NeynarAPIClient } from '@neynar/nodejs-sdk';
import { NextResponse } from 'next/server';
export async function GET(request: Request) {
const apiKey = process.env.NEYNAR_API_KEY;
const { searchParams } = new URL(request.url);
const fids = searchParams.get('fids');
if (!apiKey) {
return NextResponse.json(
{ error: 'Neynar API key is not configured. Please add NEYNAR_API_KEY to your environment variables.' },
{ status: 500 }
);
}
if (!fids) {
return NextResponse.json(
{ error: 'FIDs parameter is required' },
{ status: 400 }
);
}
try {
const neynar = new NeynarAPIClient({ apiKey });
const fidsArray = fids.split(',').map(fid => parseInt(fid.trim()));
const { users } = await neynar.fetchBulkUsers({
fids: fidsArray,
});
return NextResponse.json({ users });
} catch (error) {
console.error('Failed to fetch users:', error);
return NextResponse.json(
{ error: 'Failed to fetch users. Please check your Neynar API key and try again.' },
{ status: 500 }
);
}
}

View File

@ -32,6 +32,11 @@ import { useSession } from "next-auth/react";
import { useMiniApp } from "@neynar/react";
import { Label } from "~/components/ui/label";
import { PublicKey, SystemProgram, Transaction } from '@solana/web3.js';
import { Header } from "~/components/ui/Header";
import { Footer } from "~/components/ui/Footer";
import { USE_WALLET } from "~/lib/constants";
export type Tab = 'home' | 'actions' | 'context' | 'wallet';
export default function Demo(
{ title }: { title?: string } = { title: "Frames v2 Demo" }
@ -41,16 +46,14 @@ export default function Demo(
context,
added,
notificationDetails,
lastEvent,
addFrame,
addFrameResult,
openUrl,
close,
actions,
} = useMiniApp();
const [isContextOpen, setIsContextOpen] = useState(false);
const [activeTab, setActiveTab] = useState<Tab>('home');
const [txHash, setTxHash] = useState<string | null>(null);
const [sendNotificationResult, setSendNotificationResult] = useState("");
const [copied, setCopied] = useState(false);
const [neynarUser, setNeynarUser] = useState<any | null>(null);
const { address, isConnected } = useAccount();
const chainId = useChainId();
@ -70,6 +73,25 @@ export default function Demo(
console.log("chainId", chainId);
}, [context, address, isConnected, chainId, isSDKLoaded]);
// Fetch Neynar user object when context is available
useEffect(() => {
const fetchNeynarUserObject = async () => {
if (context?.user?.fid) {
try {
const response = await fetch(`/api/users?fids=${context.user.fid}`);
const data = await response.json();
if (data.users?.[0]) {
setNeynarUser(data.users[0]);
}
} catch (error) {
console.error('Failed to fetch Neynar user object:', error);
}
}
};
fetchNeynarUserObject();
}, [context?.user?.fid]);
const {
sendTransaction,
error: sendTxError,
@ -198,123 +220,76 @@ export default function Demo(
paddingRight: context?.client.safeAreaInsets?.right ?? 0,
}}
>
<div className="w-[300px] mx-auto py-2 px-2">
<div className="mx-auto py-2 px-2 pb-20">
<Header neynarUser={neynarUser} />
<h1 className="text-2xl font-bold text-center mb-4">{title}</h1>
<div className="mb-4">
<h2 className="font-2xl font-bold">Context</h2>
<button
onClick={toggleContext}
className="flex items-center gap-2 transition-colors"
>
<span
className={`transform transition-transform ${
isContextOpen ? "rotate-90" : ""
}`}
>
</span>
Tap to expand
</button>
{isContextOpen && (
<div className="p-4 mt-2 bg-gray-100 dark:bg-gray-800 rounded-lg">
<pre className="font-mono text-xs whitespace-pre-wrap break-words max-w-[260px] overflow-x-">
{JSON.stringify(context, null, 2)}
</pre>
{activeTab === 'home' && (
<div className="flex items-center justify-center h-[calc(100vh-200px)] px-6">
<div className="text-center w-full max-w-md mx-auto">
<p className="text-lg mb-2">Put your content here!</p>
<p className="text-sm text-gray-500">Powered by Neynar 🪐</p>
</div>
)}
</div>
</div>
)}
<div>
<h2 className="font-2xl font-bold">Actions</h2>
<div className="mb-4">
<div className="p-2 bg-gray-100 dark:bg-gray-800 rounded-lg my-2">
<pre className="font-mono text-xs whitespace-pre-wrap break-words max-w-[260px] overflow-x-">
{activeTab === 'actions' && (
<div className="space-y-3 px-6 w-full max-w-md mx-auto">
<div className="p-2 bg-gray-100 dark:bg-gray-800 rounded-lg w-full">
<pre className="font-mono text-xs whitespace-pre-wrap break-words w-full">
sdk.actions.signIn
</pre>
</div>
<SignIn />
</div>
<div className="mb-4">
<div className="p-2 bg-gray-100 dark:bg-gray-800 rounded-lg my-2">
<pre className="font-mono text-xs whitespace-pre-wrap break-words max-w-[260px] overflow-x-">
<div className="p-2 bg-gray-100 dark:bg-gray-800 rounded-lg w-full">
<pre className="font-mono text-xs whitespace-pre-wrap break-words w-full">
sdk.actions.openUrl
</pre>
</div>
<Button onClick={() => openUrl("https://www.youtube.com/watch?v=dQw4w9WgXcQ")}>Open Link</Button>
</div>
<Button onClick={() => actions.openUrl("https://www.youtube.com/watch?v=dQw4w9WgXcQ")} className="w-full">Open Link</Button>
<div className="mb-4">
<div className="p-2 bg-gray-100 dark:bg-gray-800 rounded-lg my-2">
<pre className="font-mono text-xs whitespace-pre-wrap break-words max-w-[260px] overflow-x-">
<div className="p-2 bg-gray-100 dark:bg-gray-800 rounded-lg w-full">
<pre className="font-mono text-xs whitespace-pre-wrap break-words w-full">
sdk.actions.viewProfile
</pre>
</div>
<ViewProfile />
</div>
<div className="mb-4">
<div className="p-2 bg-gray-100 dark:bg-gray-800 rounded-lg my-2">
<pre className="font-mono text-xs whitespace-pre-wrap break-words max-w-[260px] overflow-x-">
<div className="p-2 bg-gray-100 dark:bg-gray-800 rounded-lg w-full">
<pre className="font-mono text-xs whitespace-pre-wrap break-words w-full">
sdk.actions.close
</pre>
</div>
<Button onClick={close}>Close Frame</Button>
</div>
</div>
<Button onClick={actions.close} className="w-full">Close Frame</Button>
<div className="mb-4">
<h2 className="font-2xl font-bold">Last event</h2>
<div className="text-sm w-full">
Client fid {context?.client.clientFid},
{added ? " frame added to client," : " frame not added to client,"}
{notificationDetails
? " notifications enabled"
: " notifications disabled"}
</div>
<div className="p-4 mt-2 bg-gray-100 dark:bg-gray-800 rounded-lg">
<pre className="font-mono text-xs whitespace-pre-wrap break-words max-w-[260px] overflow-x-">
{lastEvent || "none"}
</pre>
</div>
</div>
<div>
<h2 className="font-2xl font-bold">Add to client & notifications</h2>
<div className="mt-2 mb-4 text-sm">
Client fid {context?.client.clientFid},
{added ? " frame added to client," : " frame not added to client,"}
{notificationDetails
? " notifications enabled"
: " notifications disabled"}
</div>
<div className="mb-4">
<div className="p-2 bg-gray-100 dark:bg-gray-800 rounded-lg my-2">
<pre className="font-mono text-xs whitespace-pre-wrap break-words max-w-[260px] overflow-x-">
sdk.actions.addFrame
<div className="p-2 bg-gray-100 dark:bg-gray-800 rounded-lg w-full">
<pre className="font-mono text-xs whitespace-pre-wrap break-words w-full">
sdk.actions.addMiniApp
</pre>
</div>
{addFrameResult && (
<div className="mb-2 text-sm">
Add frame result: {addFrameResult}
</div>
)}
<Button onClick={addFrame} disabled={added}>
<Button onClick={actions.addMiniApp} disabled={added} className="w-full">
Add frame to client
</Button>
</div>
{sendNotificationResult && (
<div className="mb-2 text-sm">
Send notification result: {sendNotificationResult}
</div>
)}
<div className="mb-4">
<Button onClick={sendNotification} disabled={!notificationDetails}>
{sendNotificationResult && (
<div className="text-sm w-full">
Send notification result: {sendNotificationResult}
</div>
)}
<Button onClick={sendNotification} disabled={!notificationDetails} className="w-full">
Send notification
</Button>
</div>
<div className="mb-4">
<Button
onClick={async () => {
if (context?.user?.fid) {
@ -325,28 +300,38 @@ export default function Demo(
}
}}
disabled={!context?.user?.fid}
className="w-full"
>
{copied ? "Copied!" : "Copy share URL"}
</Button>
</div>
</div>
)}
<div>
<h2 className="font-2xl font-bold">Wallet</h2>
{address && (
<div className="my-2 text-xs">
Address: <pre className="inline">{truncateAddress(address)}</pre>
{activeTab === 'context' && (
<div className="mx-6">
<h2 className="text-lg font-semibold mb-2">Context</h2>
<div className="p-4 bg-gray-100 dark:bg-gray-800 rounded-lg">
<pre className="font-mono text-xs whitespace-pre-wrap break-words w-full">
{JSON.stringify(context, null, 2)}
</pre>
</div>
)}
</div>
)}
{chainId && (
<div className="my-2 text-xs">
Chain ID: <pre className="inline">{chainId}</pre>
</div>
)}
{activeTab === 'wallet' && USE_WALLET && (
<div className="space-y-3 px-6 w-full max-w-md mx-auto">
{address && (
<div className="text-xs w-full">
Address: <pre className="inline w-full">{truncateAddress(address)}</pre>
</div>
)}
{chainId && (
<div className="text-xs w-full">
Chain ID: <pre className="inline w-full">{chainId}</pre>
</div>
)}
<div className="mb-4">
{isConnected ? (
<Button
onClick={() => disconnect()}
@ -355,7 +340,6 @@ export default function Demo(
Disconnect
</Button>
) : context ? (
/* if context is not null, mini app is running in frame client */
<Button
onClick={() => connect({ connector: connectors[0] })}
className="w-full"
@ -363,8 +347,7 @@ export default function Demo(
Connect
</Button>
) : (
/* if context is null, mini app is running in browser */
<div className="space-y-2">
<div className="space-y-3 w-full">
<Button
onClick={() => connect({ connector: connectors[1] })}
className="w-full"
@ -379,28 +362,23 @@ export default function Demo(
</Button>
</div>
)}
</div>
<div className="mb-4">
<SignEvmMessage />
</div>
{isConnected && (
<>
<div className="mb-4">
{isConnected && (
<>
<SendEth />
</div>
<div className="mb-4">
<Button
onClick={sendTx}
disabled={!isConnected || isSendTxPending}
isLoading={isSendTxPending}
className="w-full"
>
Send Transaction (contract)
</Button>
{isSendTxError && renderError(sendTxError)}
{txHash && (
<div className="mt-2 text-xs">
<div className="text-xs w-full">
<div>Hash: {truncateAddress(txHash)}</div>
<div>
Status:{" "}
@ -412,43 +390,30 @@ export default function Demo(
</div>
</div>
)}
</div>
<div className="mb-4">
<Button
onClick={signTyped}
disabled={!isConnected || isSignTypedPending}
isLoading={isSignTypedPending}
className="w-full"
>
Sign Typed Data
</Button>
{isSignTypedError && renderError(signTypedError)}
</div>
<div className="mb-4">
<Button
onClick={handleSwitchChain}
disabled={isSwitchChainPending}
isLoading={isSwitchChainPending}
className="w-full"
>
Switch to {nextChain.name}
</Button>
{isSwitchChainError && renderError(switchChainError)}
</div>
</>
)}
</div>
{solanaAddress && (
<div>
<h2 className="font-2xl font-bold">Solana</h2>
<div className="my-2 text-xs">
Address: <pre className="inline">{truncateAddress(solanaAddress)}</pre>
</div>
<SignSolanaMessage signMessage={solanaSignMessage} />
<div className="mb-4">
<SendSolana />
</div>
</>
)}
</div>
)}
<Footer activeTab={activeTab} setActiveTab={setActiveTab} showWallet={USE_WALLET} />
</div>
</div>
);

View File

@ -0,0 +1,53 @@
import React from "react";
import type { Tab } from "~/components/Demo";
interface FooterProps {
activeTab: Tab;
setActiveTab: (tab: Tab) => void;
showWallet?: boolean;
}
export const Footer: React.FC<FooterProps> = ({ activeTab, setActiveTab, showWallet = false }) => (
<div className="fixed bottom-0 left-0 right-0 mx-4 mb-4 bg-gray-100 dark:bg-gray-800 border-[3px] border-double border-purple-500 px-2 py-2 rounded-lg z-50">
<div className="flex justify-around items-center h-14">
<button
onClick={() => setActiveTab('home')}
className={`flex flex-col items-center justify-center w-full h-full ${
activeTab === 'home' ? 'text-purple-500' : 'text-gray-500'
}`}
>
<span className="text-xl">🏠</span>
<span className="text-xs mt-1">Home</span>
</button>
<button
onClick={() => setActiveTab('actions')}
className={`flex flex-col items-center justify-center w-full h-full ${
activeTab === 'actions' ? 'text-purple-500' : 'text-gray-500'
}`}
>
<span className="text-xl"></span>
<span className="text-xs mt-1">Actions</span>
</button>
<button
onClick={() => setActiveTab('context')}
className={`flex flex-col items-center justify-center w-full h-full ${
activeTab === 'context' ? 'text-purple-500' : 'text-gray-500'
}`}
>
<span className="text-xl">📋</span>
<span className="text-xs mt-1">Context</span>
</button>
{showWallet && (
<button
onClick={() => setActiveTab('wallet')}
className={`flex flex-col items-center justify-center w-full h-full ${
activeTab === 'wallet' ? 'text-purple-500' : 'text-gray-500'
}`}
>
<span className="text-xl">👛</span>
<span className="text-xs mt-1">Wallet</span>
</button>
)}
</div>
</div>
);

View File

@ -0,0 +1,82 @@
"use client";
import { useState } from "react";
import { APP_NAME } from "~/lib/constants";
import sdk from "@farcaster/frame-sdk";
import { useMiniApp } from "@neynar/react";
type HeaderProps = {
neynarUser: any;
};
export function Header({ neynarUser }: HeaderProps) {
const { context } = useMiniApp();
const [isUserDropdownOpen, setIsUserDropdownOpen] = useState(false);
const [hasClickedPfp, setHasClickedPfp] = useState(false);
return (
<div className="relative">
<div
className="mb-1 py-2 px-3 bg-gray-100 dark:bg-gray-800 rounded-lg flex items-center justify-between border-[3px] border-double border-purple-500"
>
<div className="text-lg font-light">
Welcome to {APP_NAME}!
</div>
{context?.user && (
<div
className="cursor-pointer"
onClick={() => {
setIsUserDropdownOpen(!isUserDropdownOpen);
setHasClickedPfp(true);
}}
>
{context.user.pfpUrl && (
<img
src={context.user.pfpUrl}
alt="Profile"
className="w-10 h-10 rounded-full border-2 border-purple-500"
/>
)}
</div>
)}
</div>
{context?.user && (
<>
{!hasClickedPfp && (
<div className="absolute right-0 -bottom-6 text-xs text-purple-500 flex items-center justify-end gap-1 pr-2">
<span className="text-[10px]"></span> Click PFP! <span className="text-[10px]"></span>
</div>
)}
{isUserDropdownOpen && (
<div className="absolute top-full right-0 z-50 w-fit mt-1 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700">
<div className="p-3 space-y-2">
<div className="text-right">
<h3
className="font-bold text-sm hover:underline cursor-pointer inline-block"
onClick={() => sdk.actions.viewProfile({ fid: context.user.fid })}
>
{context.user.displayName || context.user.username}
</h3>
<p className="text-xs text-gray-600 dark:text-gray-400">
@{context.user.username}
</p>
<p className="text-xs text-gray-500 dark:text-gray-500">
FID: {context.user.fid}
</p>
{neynarUser && (
<>
<p className="text-xs text-gray-500 dark:text-gray-500">
Neynar Score: {neynarUser.score}
</p>
</>
)}
</div>
</div>
</div>
)}
</>
)}
</div>
);
}

107
src/components/ui/Share.tsx Normal file
View File

@ -0,0 +1,107 @@
'use client';
import { useCallback, useState, useEffect } from 'react';
import { Button } from './Button';
import { useMiniApp } from '@neynar/react';
import { type ComposeCast } from "@farcaster/frame-sdk";
interface CastConfig extends ComposeCast.Options {
bestFriends?: boolean;
}
interface ShareButtonProps {
buttonText: string;
cast: CastConfig;
className?: string;
isLoading?: boolean;
}
export function ShareButton({ buttonText, cast, className = '', isLoading = false }: ShareButtonProps) {
const [isProcessing, setIsProcessing] = useState(false);
const [bestFriends, setBestFriends] = useState<{ fid: number; username: string; }[] | null>(null);
const { context, actions } = useMiniApp();
// Fetch best friends if needed
useEffect(() => {
if (cast.bestFriends && context?.user?.fid) {
setIsProcessing(true);
fetch(`/api/best-friends?fid=${context.user.fid}`)
.then(res => res.json())
.then(data => setBestFriends(data.bestFriends))
.catch(err => console.error('Failed to fetch best friends:', err))
.finally(() => setIsProcessing(false));
}
}, [cast.bestFriends, context?.user?.fid]);
const handleShare = useCallback(async () => {
try {
setIsProcessing(true);
let finalText = cast.text || '';
// Process best friends if enabled and data is loaded
if (cast.bestFriends && bestFriends) {
// Replace @N with usernames
finalText = finalText.replace(/@\d+/g, (match) => {
const friendIndex = parseInt(match.slice(1)) - 1;
const friend = bestFriends[friendIndex];
if (friend) {
return `@${friend.username}`;
}
return match;
});
}
// Process embeds
const processedEmbeds = await Promise.all(
(cast.embeds || []).map(async (embed) => {
if (typeof embed === 'string') {
return embed;
}
if (embed.path) {
const baseUrl = process.env.NEXT_PUBLIC_URL || window.location.origin;
const url = new URL(`${baseUrl}${embed.path}`);
// Add UTM parameters
url.searchParams.set('utm_source', `share-cast-${context?.user?.fid || 'unknown'}`);
// If custom image generator is provided, use it
if (embed.imageUrl) {
const imageUrl = await embed.imageUrl();
url.searchParams.set('share_image_url', imageUrl);
}
return url.toString();
}
return embed.url || '';
})
);
// Open cast composer with all supported intents
await actions.composeCast({
text: finalText,
embeds: processedEmbeds as [string] | [string, string] | undefined,
parent: cast.parent,
channel: cast.channelKey,
close: cast.close,
}, 'share-button');
} catch (error) {
console.error('Failed to share:', error);
} finally {
setIsProcessing(false);
}
}, [cast, bestFriends, context?.user?.fid, actions]);
const isButtonDisabled = cast.bestFriends && !bestFriends;
return (
<Button
onClick={handleShare}
className={className}
isLoading={isLoading || isProcessing}
disabled={isButtonDisabled}
>
{buttonText}
</Button>
);
}

View File

@ -11,3 +11,4 @@ export const APP_BUTTON_TEXT = process.env.NEXT_PUBLIC_FRAME_BUTTON_TEXT;
export const APP_WEBHOOK_URL = process.env.NEYNAR_API_KEY && process.env.NEYNAR_CLIENT_ID
? `https://api.neynar.com/f/app/${process.env.NEYNAR_CLIENT_ID}/event`
: `${APP_URL}/api/webhook`;
export const USE_WALLET = process.env.NEXT_PUBLIC_USE_WALLET === 'true';