Skip to content
Merged
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
23 changes: 23 additions & 0 deletions lib/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -267,3 +267,26 @@ export async function getAgentUserFromDB(

return byPrimary ?? null;
}

/**
* 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 | null; agentName: string; agentId: number } | null> {
"use server";
const agentUser = await getAgentUserFromDB(writerAddress);
if (!agentUser?.agent_id) return null;

const ownerProfile = agentUser.agent_owner
? await getFarcasterProfile(agentUser.agent_owner)
: null;

return {
ownerProfile,
agentName: agentUser.agent_name || `Agent #${agentUser.agent_id}`,
agentId: agentUser.agent_id,
};
}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "plotlink",
"version": "0.1.17",
"version": "0.1.18",
"private": true,
"workspaces": [
"packages/*"
Expand Down
30 changes: 23 additions & 7 deletions src/app/profile/[address]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,10 @@
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";

Check warning on line 14 in src/app/profile/[address]/page.tsx

View workflow job for this annotation

GitHub Actions / lint-and-typecheck

'TokenPriceInfo' is defined but never used
import { browserClient } from "../../../../lib/rpc";
import type { FarcasterProfile } from "../../../../lib/farcaster";
import type { AgentMetadata } from "../../../../lib/contracts/erc8004";
Expand Down Expand Up @@ -133,7 +133,7 @@
if (r <= 0) clearInterval(interval);
}, 1000);
return () => clearInterval(interval);
}, [dbUser?.steemhunt_fetched_at]);

Check warning on line 136 in src/app/profile/[address]/page.tsx

View workflow job for this annotation

GitHub Actions / lint-and-typecheck

React Hook useEffect has a missing dependency: 'COOLDOWN_MS'. Either include it or remove the dependency array

const onCooldown = cooldownRemaining > 0;

Expand Down Expand Up @@ -237,6 +237,15 @@
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;
Expand Down Expand Up @@ -430,12 +439,19 @@
</span>
</div>
)}
{agentMeta.owner && agentMeta.owner.toLowerCase() !== address.toLowerCase() && (
<div className="text-xs">
<span className="text-muted">Owner: </span>
<Link href={`/profile/${agentMeta.owner}`} className="text-accent hover:underline font-mono text-[11px]">
{agentMeta.owner.slice(0, 6)}...{agentMeta.owner.slice(-4)}
</Link>
{hasOwner && (
<div className="border-border border-t mt-2 pt-2">
<span className="text-muted text-[10px] font-medium uppercase tracking-wider">Operated by</span>
<div className="mt-1 flex items-center gap-2">
{ownerFcProfile?.pfpUrl && (
// eslint-disable-next-line @next/next/no-img-element
<img src={ownerFcProfile.pfpUrl} alt="" width={20} height={20} className="rounded-full" />
)}
<Link href={`/profile/${ownerAddress}`} className="text-accent hover:underline text-xs font-medium">
{ownerFcProfile?.displayName || ownerFcProfile?.username || `${ownerAddress!.slice(0, 6)}...${ownerAddress!.slice(-4)}`}
</Link>
<span className="bg-accent/10 text-accent rounded px-1 py-0.5 text-[8px] font-medium">ERC-8004 Verified</span>
</div>
</div>
)}
</div>
Expand Down Expand Up @@ -667,7 +683,7 @@
});

// Claimable royalties (own profile only)
const { data: royaltyInfo } = useQuery({

Check warning on line 686 in src/app/profile/[address]/page.tsx

View workflow job for this annotation

GitHub Actions / lint-and-typecheck

'royaltyInfo' is assigned a value but never used
queryKey: ["profile-royalties", address],
queryFn: async () => {
const [balance, claimed] = await browserClient.readContract({
Expand Down Expand Up @@ -824,7 +840,7 @@
}) {
const tokenAddr = storyline.token_address as Address;

const { data: priceInfo } = useQuery({

Check warning on line 843 in src/app/profile/[address]/page.tsx

View workflow job for this annotation

GitHub Actions / lint-and-typecheck

'priceInfo' is assigned a value but never used
queryKey: ["profile-story-price", storyline.token_address],
queryFn: () => getTokenPrice(tokenAddr, browserClient),
enabled: !!storyline.token_address,
Expand Down Expand Up @@ -1109,7 +1125,7 @@

const DONATION_PAGE_SIZE = 10;

function ProfileDonationHistory({ storylineId }: { storylineId: number }) {

Check warning on line 1128 in src/app/profile/[address]/page.tsx

View workflow job for this annotation

GitHub Actions / lint-and-typecheck

'ProfileDonationHistory' is defined but never used
const { data: plotUsd } = usePlotUsdPrice();
const {
data,
Expand Down Expand Up @@ -1391,7 +1407,7 @@
const {
data: donationPages,
isLoading: donGivenLoading,
isFetchingNextPage: donFetchingNext,

Check warning on line 1410 in src/app/profile/[address]/page.tsx

View workflow job for this annotation

GitHub Actions / lint-and-typecheck

'donFetchingNext' is assigned a value but never used
fetchNextPage: donFetchNext,
hasNextPage: donHasNext,
} = useInfiniteQuery({
Expand Down
2 changes: 1 addition & 1 deletion src/app/story/[storylineId]/[plotIndex]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ export default async function PlotDetailPage({ params }: { params: Params }) {
<span>
by{" "}
<Suspense fallback={<span className="text-foreground">{truncateAddress(sl.writer_address)}</span>}>
<WriterIdentity address={sl.writer_address} />
<WriterIdentity address={sl.writer_address} writerType={sl.writer_type} />
</Suspense>
</span>
{p.block_timestamp && (
Expand Down
2 changes: 1 addition & 1 deletion src/app/story/[storylineId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -364,7 +364,7 @@ function StoryHeader({
<div className="flex items-center gap-1.5">
<span className="text-muted w-12 shrink-0">Writer</span>
<Suspense fallback={<span className="text-foreground font-medium">{truncateAddress(storyline.writer_address)}</span>}>
<WriterIdentity address={storyline.writer_address} />
<WriterIdentity address={storyline.writer_address} writerType={storyline.writer_type} />
</Suspense>
{storyline.writer_type === 1 && <AgentBadge />}
</div>
Expand Down
2 changes: 1 addition & 1 deletion src/components/StoryCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ export function StoryCard({
{/* Metadata below notebook: author → TVL → rating */}
<div className="mt-2.5 flex flex-col gap-0.5 pl-1 pr-1 text-[10px] text-[var(--text-muted)]">
<span className="inline-flex items-center gap-1">
<WriterIdentityClient address={storyline.writer_address} />
<WriterIdentityClient address={storyline.writer_address} writerType={storyline.writer_type} />
{storyline.writer_type === 1 && <AgentBadge />}
</span>
{storyline.token_address && (
Expand Down
52 changes: 36 additions & 16 deletions src/components/WriterIdentity.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,47 @@
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".
* 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 }: { 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) {
// Agent with owner FID: "{owner}'s AI Writer"
if (ownerInfo.ownerProfile) {
return (
<Link
href={`/profile/${address}`}
className="inline-flex items-center gap-1 text-foreground hover:text-accent transition-colors"
>
{ownerInfo.ownerProfile.pfpUrl && (
// eslint-disable-next-line @next/next/no-img-element
<img src={ownerInfo.ownerProfile.pfpUrl} alt="" width={14} height={14} className="rounded-full" />
)}
{ownerInfo.ownerProfile.displayName || ownerInfo.ownerProfile.username}&apos;s AI Writer
</Link>
);
}
// Agent without owner FID: plain "AI Writer #{id}"
return (
<Link href={`/profile/${address}`} className="text-foreground hover:text-accent transition-colors">
AI Writer #{ownerInfo.agentId}
</Link>
);
}
}

const profile = await getFarcasterProfile(address);

if (!profile) {
return (
<Link
href={`/profile/${address}`}
className="text-foreground hover:text-accent transition-colors"
>
<Link href={`/profile/${address}`} className="text-foreground hover:text-accent transition-colors">
{truncateAddress(address)}
</Link>
);
Expand All @@ -28,13 +54,7 @@ export async function WriterIdentity({ address }: { address: string }) {
>
{profile.pfpUrl && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={profile.pfpUrl}
alt=""
width={14}
height={14}
className="rounded-full"
/>
<img src={profile.pfpUrl} alt="" width={14} height={14} className="rounded-full" />
)}
@{profile.username}
</Link>
Expand Down
132 changes: 92 additions & 40 deletions src/components/WriterIdentityClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,72 +2,124 @@

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 | null;
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<FarcasterProfile | null>(null);
const [ownerInfo, setOwnerInfo] = useState<OwnerInfo | null>(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 <span>{label}</span>;
return (
<Link
href={`/profile/${address}`}
className="text-foreground hover:text-accent transition-colors"
>
<Link href={`/profile/${address}`} className="text-foreground hover:text-accent transition-colors">
{label}
</Link>
);
}

const inner = (
<span className="inline-flex items-center gap-1">
{profile.pfpUrl && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={profile.pfpUrl}
alt=""
width={14}
height={14}
className="rounded-full"
/>
)}
<span>@{profile.username}</span>
</span>
);
// Agent with owner Farcaster profile: "{owner}'s AI Writer"
if (ownerInfo && ownerInfo.ownerProfile) {
const inner = (
<span className="inline-flex items-center gap-1">
{ownerInfo.ownerProfile.pfpUrl && (
// eslint-disable-next-line @next/next/no-img-element
<img src={ownerInfo.ownerProfile.pfpUrl} alt="" width={14} height={14} className="rounded-full" />
)}
<span>{ownerInfo.ownerProfile.displayName || ownerInfo.ownerProfile.username}&apos;s AI Writer</span>
</span>
);
if (!linkProfile) return inner;
return (
<Link href={`/profile/${address}`} className="text-foreground hover:text-accent transition-colors">
{inner}
</Link>
);
}

// Agent without owner FID: plain "AI Writer #{id}"
if (ownerInfo) {
const label = `AI Writer #${ownerInfo.agentId}`;
if (!linkProfile) return <span>{label}</span>;
return (
<Link href={`/profile/${address}`} className="text-foreground hover:text-accent transition-colors">
{label}
</Link>
);
}

if (!linkProfile) return inner;
// Regular writer with Farcaster profile
if (profile) {
const inner = (
<span className="inline-flex items-center gap-1">
{profile.pfpUrl && (
// eslint-disable-next-line @next/next/no-img-element
<img src={profile.pfpUrl} alt="" width={14} height={14} className="rounded-full" />
)}
<span>@{profile.username}</span>
</span>
);
if (!linkProfile) return inner;
return (
<Link href={`/profile/${address}`} className="text-foreground hover:text-accent transition-colors">
{inner}
</Link>
);
}

// Fallback: truncated address
const label = truncateAddress(address);
if (!linkProfile) return <span>{label}</span>;
return (
<Link
href={`/profile/${address}`}
className="text-foreground hover:text-accent transition-colors"
>
{inner}
<Link href={`/profile/${address}`} className="text-foreground hover:text-accent transition-colors">
{label}
</Link>
);
}
Loading