From 30b03bdda82bd219f2d8682a0b1f6452a58370ac Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Sun, 5 Apr 2026 08:19:44 +0100 Subject: [PATCH 1/2] [#846] Display "{human}'s AI Writer" via ERC-8004 owner + FID lookup Add getAgentOwnerProfile server action that looks up an agent writer's owner Farcaster profile. Update WriterIdentity (server) and WriterIdentityClient to show "{owner}'s AI Writer" when the writer is an ERC-8004 agent with an owner who has a Farcaster profile. Update StoryCard, story detail, and plot detail pages to pass writerType to identity components. Add "Operated by" section to profile page showing owner's Farcaster avatar + name with "ERC-8004 Verified" badge. Falls back to regular Farcaster profile or truncated address when owner has no FID. Bump to v0.1.18. Fixes #846 Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/actions.ts | 21 +++ package.json | 2 +- src/app/profile/[address]/page.tsx | 30 ++++- .../story/[storylineId]/[plotIndex]/page.tsx | 2 +- src/app/story/[storylineId]/page.tsx | 2 +- src/components/StoryCard.tsx | 2 +- src/components/WriterIdentity.tsx | 42 +++--- src/components/WriterIdentityClient.tsx | 121 ++++++++++++------ 8 files changed, 155 insertions(+), 67 deletions(-) diff --git a/lib/actions.ts b/lib/actions.ts index e2085473..fd7598b7 100644 --- a/lib/actions.ts +++ b/lib/actions.ts @@ -267,3 +267,24 @@ export async function getAgentUserFromDB( return byPrimary ?? null; } + +/** + * For a writer address, check if it's an ERC-8004 agent and return the + * owner's Farcaster profile. Returns null if not an agent or owner has no FID. + */ +export async function getAgentOwnerProfile( + writerAddress: string, +): Promise<{ ownerProfile: FarcasterProfile; agentName: string; agentId: number } | null> { + "use server"; + const agentUser = await getAgentUserFromDB(writerAddress); + if (!agentUser?.agent_id || !agentUser.agent_owner) return null; + + const ownerProfile = await getFarcasterProfile(agentUser.agent_owner); + if (!ownerProfile) return null; + + return { + ownerProfile, + agentName: agentUser.agent_name || `Agent #${agentUser.agent_id}`, + agentId: agentUser.agent_id, + }; +} diff --git a/package.json b/package.json index 4e921a7f..de308ca4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "plotlink", - "version": "0.1.17", + "version": "0.1.18", "private": true, "workspaces": [ "packages/*" diff --git a/src/app/profile/[address]/page.tsx b/src/app/profile/[address]/page.tsx index ae6294ba..aa2b95d8 100644 --- a/src/app/profile/[address]/page.tsx +++ b/src/app/profile/[address]/page.tsx @@ -8,7 +8,7 @@ import { formatUnits, type Address } from "viem"; import Link from "next/link"; import { supabase, type Storyline, type Donation, type TradeHistory, type User } from "../../../../lib/supabase"; import { STORY_FACTORY, RESERVE_LABEL, EXPLORER_URL, MCV2_BOND, PLOT_TOKEN } from "../../../../lib/contracts/constants"; -import { getFullUserProfile } from "../../../../lib/actions"; +import { getFullUserProfile, getFarcasterProfile } from "../../../../lib/actions"; import { truncateAddress } from "../../../../lib/utils"; import { formatPrice, formatSupply } from "../../../../lib/format"; import { getTokenPrice, mcv2BondAbi, erc20Abi, type TokenPriceInfo, get24hPriceChange, getTokenTVL } from "../../../../lib/price"; @@ -237,6 +237,15 @@ function ProfileHeader({ onCooldown: boolean; cooldownRemaining: number; }) { + // Fetch owner's Farcaster profile for "Operated by" section + const ownerAddress = agentMeta?.owner; + const hasOwner = !!ownerAddress && ownerAddress.toLowerCase() !== address.toLowerCase(); + const { data: ownerFcProfile } = useQuery({ + queryKey: ["owner-fc-profile", ownerAddress], + queryFn: () => getFarcasterProfile(ownerAddress!), + enabled: hasOwner, + }); + const displayName = agentMeta?.name ?? fcProfile?.displayName ?? null; const hasFarcaster = dbUser?.fid != null && dbUser?.username != null; const hasX = dbUser?.twitter != null; @@ -430,12 +439,19 @@ function ProfileHeader({ )} - {agentMeta.owner && agentMeta.owner.toLowerCase() !== address.toLowerCase() && ( -
- Owner: - - {agentMeta.owner.slice(0, 6)}...{agentMeta.owner.slice(-4)} - + {hasOwner && ( +
+ Operated by +
+ {ownerFcProfile?.pfpUrl && ( + // eslint-disable-next-line @next/next/no-img-element + + )} + + {ownerFcProfile?.displayName || ownerFcProfile?.username || `${ownerAddress!.slice(0, 6)}...${ownerAddress!.slice(-4)}`} + + ERC-8004 Verified +
)}
diff --git a/src/app/story/[storylineId]/[plotIndex]/page.tsx b/src/app/story/[storylineId]/[plotIndex]/page.tsx index d8d902ac..041ca1a6 100644 --- a/src/app/story/[storylineId]/[plotIndex]/page.tsx +++ b/src/app/story/[storylineId]/[plotIndex]/page.tsx @@ -136,7 +136,7 @@ export default async function PlotDetailPage({ params }: { params: Params }) { by{" "} {truncateAddress(sl.writer_address)}}> - + {p.block_timestamp && ( diff --git a/src/app/story/[storylineId]/page.tsx b/src/app/story/[storylineId]/page.tsx index 9309db02..77d72d5f 100644 --- a/src/app/story/[storylineId]/page.tsx +++ b/src/app/story/[storylineId]/page.tsx @@ -364,7 +364,7 @@ function StoryHeader({
Writer {truncateAddress(storyline.writer_address)}}> - + {storyline.writer_type === 1 && }
diff --git a/src/components/StoryCard.tsx b/src/components/StoryCard.tsx index 6d13c9e7..e9ae5f99 100644 --- a/src/components/StoryCard.tsx +++ b/src/components/StoryCard.tsx @@ -116,7 +116,7 @@ export function StoryCard({ {/* Metadata below notebook: author → TVL → rating */}
- + {storyline.writer_type === 1 && } {storyline.token_address && ( diff --git a/src/components/WriterIdentity.tsx b/src/components/WriterIdentity.tsx index 29d9bb3a..88e0de93 100644 --- a/src/components/WriterIdentity.tsx +++ b/src/components/WriterIdentity.tsx @@ -1,21 +1,37 @@ import Link from "next/link"; -import { getFarcasterProfile } from "../../lib/actions"; +import { getFarcasterProfile, getAgentOwnerProfile } from "../../lib/actions"; import { truncateAddress } from "../../lib/utils"; /** - * Server component that displays a Farcaster identity (avatar + username) - * when available, falling back to a truncated Ethereum address. - * Links to the internal profile page at /profile/[address]. + * Server component that displays a writer identity. + * For agents with an owner who has a Farcaster profile, shows "{owner}'s AI Writer". + * Falls back to Farcaster profile or truncated address. */ -export async function WriterIdentity({ address }: { address: string }) { +export async function WriterIdentity({ address, writerType }: { address: string; writerType?: number | null }) { + // For agents (or unknown), try owner lookup first + if (writerType === 1 || writerType === undefined || writerType === null) { + const ownerInfo = await getAgentOwnerProfile(address); + if (ownerInfo) { + return ( + + {ownerInfo.ownerProfile.pfpUrl && ( + // eslint-disable-next-line @next/next/no-img-element + + )} + {ownerInfo.ownerProfile.displayName || ownerInfo.ownerProfile.username}'s AI Writer + + ); + } + } + const profile = await getFarcasterProfile(address); if (!profile) { return ( - + {truncateAddress(address)} ); @@ -28,13 +44,7 @@ export async function WriterIdentity({ address }: { address: string }) { > {profile.pfpUrl && ( // eslint-disable-next-line @next/next/no-img-element - + )} @{profile.username} diff --git a/src/components/WriterIdentityClient.tsx b/src/components/WriterIdentityClient.tsx index fb683ad8..3604fc86 100644 --- a/src/components/WriterIdentityClient.tsx +++ b/src/components/WriterIdentityClient.tsx @@ -2,72 +2,113 @@ import { useEffect, useState } from "react"; import Link from "next/link"; -import { getFarcasterProfile } from "../../lib/actions"; +import { getFarcasterProfile, getAgentOwnerProfile } from "../../lib/actions"; import { truncateAddress } from "../../lib/utils"; import type { FarcasterProfile } from "../../lib/farcaster"; +interface OwnerInfo { + ownerProfile: FarcasterProfile; + agentName: string; + agentId: number; +} + /** - * Client component that resolves a Farcaster identity via server action. - * Shows a truncated address while loading, then replaces with avatar + username. - * Links to the internal profile page at /profile/[address]. + * Client component that resolves a writer identity via server action. + * For agents with an owner who has a Farcaster profile, shows "{owner}'s AI Writer". + * Falls back to Farcaster profile or truncated address. */ -export function WriterIdentityClient({ address, linkProfile = true }: { address: string; linkProfile?: boolean }) { +export function WriterIdentityClient({ + address, + linkProfile = true, + writerType, +}: { + address: string; + linkProfile?: boolean; + writerType?: number | null; +}) { const [profile, setProfile] = useState(null); + const [ownerInfo, setOwnerInfo] = useState(null); const [loaded, setLoaded] = useState(false); useEffect(() => { let cancelled = false; - getFarcasterProfile(address).then((p) => { + + async function resolve() { + // For agents (or unknown), try owner lookup first + if (writerType === 1 || writerType === undefined || writerType === null) { + const owner = await getAgentOwnerProfile(address); + if (!cancelled && owner) { + setOwnerInfo(owner); + setLoaded(true); + return; + } + } + // Fall back to writer's own Farcaster profile + const p = await getFarcasterProfile(address); if (!cancelled) { setProfile(p); setLoaded(true); } - }); - return () => { - cancelled = true; - }; - }, [address]); + } - const label = !loaded || !profile - ? truncateAddress(address) - : null; + resolve(); + return () => { cancelled = true; }; + }, [address, writerType]); - if (!loaded || !profile) { + if (!loaded) { + const label = truncateAddress(address); if (!linkProfile) return {label}; return ( - + {label} ); } - const inner = ( - - {profile.pfpUrl && ( - // eslint-disable-next-line @next/next/no-img-element - - )} - @{profile.username} - - ); + // Agent with owner Farcaster profile: "{owner}'s AI Writer" + if (ownerInfo) { + const inner = ( + + {ownerInfo.ownerProfile.pfpUrl && ( + // eslint-disable-next-line @next/next/no-img-element + + )} + {ownerInfo.ownerProfile.displayName || ownerInfo.ownerProfile.username}'s AI Writer + + ); + if (!linkProfile) return inner; + return ( + + {inner} + + ); + } - if (!linkProfile) return inner; + // Regular writer with Farcaster profile + if (profile) { + const inner = ( + + {profile.pfpUrl && ( + // eslint-disable-next-line @next/next/no-img-element + + )} + @{profile.username} + + ); + if (!linkProfile) return inner; + return ( + + {inner} + + ); + } + // Fallback: truncated address + const label = truncateAddress(address); + if (!linkProfile) return {label}; return ( - - {inner} + + {label} ); } From abc3b81608a8447cf11ad5b6a8a216696dba5332 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Sun, 5 Apr 2026 08:23:31 +0100 Subject: [PATCH 2/2] [#846] Fix: agent fallback shows "AI Writer #{id}" when owner has no FID When a writer is an agent but the owner has no Farcaster profile, show "AI Writer #{agentId}" instead of falling through to the agent wallet's own Farcaster identity. getAgentOwnerProfile now returns agent info even without owner FID (ownerProfile: null). Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/actions.ts | 14 +++++++----- src/components/WriterIdentity.tsx | 30 ++++++++++++++++--------- src/components/WriterIdentityClient.tsx | 15 +++++++++++-- 3 files changed, 41 insertions(+), 18 deletions(-) diff --git a/lib/actions.ts b/lib/actions.ts index fd7598b7..63b77e80 100644 --- a/lib/actions.ts +++ b/lib/actions.ts @@ -269,18 +269,20 @@ export async function getAgentUserFromDB( } /** - * For a writer address, check if it's an ERC-8004 agent and return the - * owner's Farcaster profile. Returns null if not an agent or owner has no FID. + * For a writer address, check if it's an ERC-8004 agent and return agent info + * plus the owner's Farcaster profile (if available). + * Returns null only if the address is NOT an agent. */ export async function getAgentOwnerProfile( writerAddress: string, -): Promise<{ ownerProfile: FarcasterProfile; agentName: string; agentId: number } | null> { +): Promise<{ ownerProfile: FarcasterProfile | null; agentName: string; agentId: number } | null> { "use server"; const agentUser = await getAgentUserFromDB(writerAddress); - if (!agentUser?.agent_id || !agentUser.agent_owner) return null; + if (!agentUser?.agent_id) return null; - const ownerProfile = await getFarcasterProfile(agentUser.agent_owner); - if (!ownerProfile) return null; + const ownerProfile = agentUser.agent_owner + ? await getFarcasterProfile(agentUser.agent_owner) + : null; return { ownerProfile, diff --git a/src/components/WriterIdentity.tsx b/src/components/WriterIdentity.tsx index 88e0de93..783af2b9 100644 --- a/src/components/WriterIdentity.tsx +++ b/src/components/WriterIdentity.tsx @@ -5,23 +5,33 @@ import { truncateAddress } from "../../lib/utils"; /** * Server component that displays a writer identity. * For agents with an owner who has a Farcaster profile, shows "{owner}'s AI Writer". - * Falls back to Farcaster profile or truncated address. + * For agents without owner FID, shows "AI Writer #{id}". + * Falls back to Farcaster profile or truncated address for non-agents. */ export async function WriterIdentity({ address, writerType }: { address: string; writerType?: number | null }) { // For agents (or unknown), try owner lookup first if (writerType === 1 || writerType === undefined || writerType === null) { const ownerInfo = await getAgentOwnerProfile(address); if (ownerInfo) { + // Agent with owner FID: "{owner}'s AI Writer" + if (ownerInfo.ownerProfile) { + return ( + + {ownerInfo.ownerProfile.pfpUrl && ( + // eslint-disable-next-line @next/next/no-img-element + + )} + {ownerInfo.ownerProfile.displayName || ownerInfo.ownerProfile.username}'s AI Writer + + ); + } + // Agent without owner FID: plain "AI Writer #{id}" return ( - - {ownerInfo.ownerProfile.pfpUrl && ( - // eslint-disable-next-line @next/next/no-img-element - - )} - {ownerInfo.ownerProfile.displayName || ownerInfo.ownerProfile.username}'s AI Writer + + AI Writer #{ownerInfo.agentId} ); } diff --git a/src/components/WriterIdentityClient.tsx b/src/components/WriterIdentityClient.tsx index 3604fc86..e9e42514 100644 --- a/src/components/WriterIdentityClient.tsx +++ b/src/components/WriterIdentityClient.tsx @@ -7,7 +7,7 @@ import { truncateAddress } from "../../lib/utils"; import type { FarcasterProfile } from "../../lib/farcaster"; interface OwnerInfo { - ownerProfile: FarcasterProfile; + ownerProfile: FarcasterProfile | null; agentName: string; agentId: number; } @@ -66,7 +66,7 @@ export function WriterIdentityClient({ } // Agent with owner Farcaster profile: "{owner}'s AI Writer" - if (ownerInfo) { + if (ownerInfo && ownerInfo.ownerProfile) { const inner = ( {ownerInfo.ownerProfile.pfpUrl && ( @@ -84,6 +84,17 @@ export function WriterIdentityClient({ ); } + // Agent without owner FID: plain "AI Writer #{id}" + if (ownerInfo) { + const label = `AI Writer #${ownerInfo.agentId}`; + if (!linkProfile) return {label}; + return ( + + {label} + + ); + } + // Regular writer with Farcaster profile if (profile) { const inner = (