diff --git a/src/app/profile/[address]/page.tsx b/src/app/profile/[address]/page.tsx
index 64c009ee..14676210 100644
--- a/src/app/profile/[address]/page.tsx
+++ b/src/app/profile/[address]/page.tsx
@@ -38,6 +38,20 @@ export default function ProfilePage() {
const isAgent = !agentLoading && agentMeta !== null && agentMeta !== undefined;
+ // Cumulative claimed royalties (on-chain)
+ const { data: claimedRoyalties } = useQuery({
+ queryKey: ["profile-claimed-royalties", address],
+ queryFn: async () => {
+ const [, claimed] = await browserClient.readContract({
+ address: MCV2_BOND,
+ abi: mcv2BondAbi,
+ functionName: "getRoyaltyInfo",
+ args: [address as Address, PLOT_TOKEN],
+ });
+ return claimed;
+ },
+ });
+
return (
{/* Tab navigation */}
@@ -92,6 +107,7 @@ function ProfileHeader({
agentMeta,
agentLoading,
isAgent,
+ claimedRoyalties,
}: {
address: string;
fcProfile: FarcasterProfile | null;
@@ -99,6 +115,7 @@ function ProfileHeader({
agentMeta: AgentMetadata | null;
agentLoading: boolean;
isAgent: boolean;
+ claimedRoyalties: bigint | null;
}) {
const displayName = agentMeta?.name ?? fcProfile?.displayName ?? null;
@@ -194,6 +211,13 @@ function ProfileHeader({
{!agentMeta?.description && fcProfile?.bio && (
{fcProfile.bio}
)}
+
+ {/* Cumulative claimed royalties */}
+ {claimedRoyalties && claimedRoyalties > BigInt(0) && (
+
+ Royalties claimed: {formatPrice(formatUnits(claimedRoyalties, 18))} {RESERVE_LABEL}
+
+ )}
@@ -258,7 +282,7 @@ function StoriesTab({
.from("trade_history")
.select("user_address, storyline_id")
.in("storyline_id", storylineIds)
- .eq("contract_address", STORY_FACTORY.toLowerCase());
+ .eq("contract_address", MCV2_BOND.toLowerCase());
if (!trades || trades.length === 0) return 0;
// Build map: token_address -> unique user addresses
@@ -452,7 +476,7 @@ function StoryRow({ storyline }: { storyline: Storyline }) {
.from("trade_history")
.select("user_address")
.eq("storyline_id", storyline.storyline_id)
- .eq("contract_address", STORY_FACTORY.toLowerCase());
+ .eq("contract_address", MCV2_BOND.toLowerCase());
if (!trades || trades.length === 0) return 0;
const uniqueUsers = [...new Set(
@@ -609,7 +633,7 @@ function PortfolioTab({ address }: { address: string }) {
.eq("user_address", address)
.eq("storyline_id", sl.storyline_id)
.eq("event_type", "mint")
- .eq("contract_address", STORY_FACTORY.toLowerCase())
+ .eq("contract_address", MCV2_BOND.toLowerCase())
.order("block_timestamp", { ascending: true })
.limit(1);
if (firstMint && firstMint.length > 0) {
@@ -620,7 +644,7 @@ function PortfolioTab({ address }: { address: string }) {
.select("block_timestamp")
.eq("user_address", address)
.eq("storyline_id", sl.storyline_id)
- .eq("contract_address", STORY_FACTORY.toLowerCase())
+ .eq("contract_address", MCV2_BOND.toLowerCase())
.order("block_timestamp", { ascending: false })
.limit(1);
if (lastTrade && lastTrade.length > 0) {
@@ -855,123 +879,244 @@ function PortfolioTab({ address }: { address: string }) {
}
// ---------------------------------------------------------------------------
-// Activity Tab
+// Activity Tab — unified reverse-chronological feed
// ---------------------------------------------------------------------------
-const ACTIVITY_PAGE_SIZE = 20;
+interface FeedEntry {
+ type: "created_storyline" | "published_plot" | "bought" | "sold" | "donated" | "rated" | "claimed_royalties";
+ timestamp: string;
+ storylineId: number;
+ storyTitle?: string;
+ txHash?: string;
+ detail?: string;
+}
+
+const FEED_PAGE_SIZE = 30;
function ActivityTab({ address }: { address: string }) {
- const { data: donations = [], isLoading: donLoading } = useQuery({
- queryKey: ["profile-donations", address],
- queryFn: async () => {
- if (!supabase) return [];
- const { data } = await supabase
- .from("donations")
- .select("*")
- .eq("donor_address", address)
- .eq("contract_address", STORY_FACTORY.toLowerCase())
- .order("block_timestamp", { ascending: false })
- .limit(ACTIVITY_PAGE_SIZE)
- .returns();
- return data ?? [];
- },
- });
+ const [visibleCount, setVisibleCount] = useState(FEED_PAGE_SIZE);
- const { data: ratings = [], isLoading: ratLoading } = useQuery({
- queryKey: ["profile-ratings", address],
- queryFn: async () => {
+ const { data: feed = [], isLoading } = useQuery({
+ queryKey: ["profile-activity-feed", address],
+ queryFn: async (): Promise => {
if (!supabase) return [];
- const { data } = await supabase
- .from("ratings")
- .select("*")
- .eq("rater_address", address)
- .eq("contract_address", STORY_FACTORY.toLowerCase())
- .order("created_at", { ascending: false })
- .limit(ACTIVITY_PAGE_SIZE);
- return data ?? [];
+
+ const PER_SOURCE_LIMIT = 200;
+
+ // Fetch all event sources in parallel (bounded per source)
+ const [storylinesRes, plotsRes, tradesRes, donationsRes, ratingsRes] = await Promise.all([
+ // Storylines created by this address
+ supabase
+ .from("storylines")
+ .select("storyline_id, title, block_timestamp, tx_hash")
+ .eq("writer_address", address)
+ .eq("hidden", false)
+ .eq("contract_address", STORY_FACTORY.toLowerCase())
+ .order("block_timestamp", { ascending: false })
+ .limit(PER_SOURCE_LIMIT),
+ // Plots published by this address
+ supabase
+ .from("plots")
+ .select("storyline_id, plot_index, title, block_timestamp, tx_hash")
+ .eq("writer_address", address)
+ .eq("hidden", false)
+ .eq("contract_address", STORY_FACTORY.toLowerCase())
+ .order("block_timestamp", { ascending: false })
+ .limit(PER_SOURCE_LIMIT),
+ // Trades by this address (trade_history uses MCV2_BOND as contract_address)
+ supabase
+ .from("trade_history")
+ .select("storyline_id, event_type, reserve_amount, price_per_token, block_timestamp, tx_hash")
+ .eq("user_address", address)
+ .eq("contract_address", MCV2_BOND.toLowerCase())
+ .order("block_timestamp", { ascending: false })
+ .limit(PER_SOURCE_LIMIT),
+ // Donations by this address
+ supabase
+ .from("donations")
+ .select("storyline_id, amount, block_timestamp, tx_hash")
+ .eq("donor_address", address)
+ .eq("contract_address", STORY_FACTORY.toLowerCase())
+ .order("block_timestamp", { ascending: false })
+ .limit(PER_SOURCE_LIMIT),
+ // Ratings by this address
+ supabase
+ .from("ratings")
+ .select("storyline_id, rating, created_at")
+ .eq("rater_address", address)
+ .eq("contract_address", STORY_FACTORY.toLowerCase())
+ .order("created_at", { ascending: false })
+ .limit(PER_SOURCE_LIMIT),
+ ]);
+
+ const entries: FeedEntry[] = [];
+
+ // Created storylines
+ for (const s of (storylinesRes.data ?? []) as { storyline_id: number; title: string; block_timestamp: string | null; tx_hash: string }[]) {
+ if (!s.block_timestamp) continue;
+ entries.push({
+ type: "created_storyline",
+ timestamp: s.block_timestamp,
+ storylineId: s.storyline_id,
+ storyTitle: s.title,
+ txHash: s.tx_hash,
+ });
+ }
+
+ // Published plots (skip genesis plot_index=0, already covered by created_storyline)
+ for (const p of (plotsRes.data ?? []) as { storyline_id: number; plot_index: number; title: string; block_timestamp: string | null; tx_hash: string }[]) {
+ if (!p.block_timestamp || p.plot_index === 0) continue;
+ entries.push({
+ type: "published_plot",
+ timestamp: p.block_timestamp,
+ storylineId: p.storyline_id,
+ detail: p.title || `Chapter ${p.plot_index}`,
+ txHash: p.tx_hash,
+ });
+ }
+
+ // Trades
+ for (const t of (tradesRes.data ?? []) as { storyline_id: number; event_type: string; reserve_amount: number; price_per_token: number; block_timestamp: string; tx_hash: string }[]) {
+ const tokenAmount = t.price_per_token > 0
+ ? formatPrice(t.reserve_amount / t.price_per_token)
+ : null;
+ entries.push({
+ type: t.event_type === "mint" ? "bought" : "sold",
+ timestamp: t.block_timestamp,
+ storylineId: t.storyline_id,
+ detail: tokenAmount
+ ? `${tokenAmount} tokens for ${formatPrice(t.reserve_amount)} ${RESERVE_LABEL}`
+ : `${formatPrice(t.reserve_amount)} ${RESERVE_LABEL}`,
+ txHash: t.tx_hash,
+ });
+ }
+
+ // Donations
+ for (const d of (donationsRes.data ?? []) as { storyline_id: number; amount: string; block_timestamp: string | null; tx_hash: string }[]) {
+ if (!d.block_timestamp) continue;
+ entries.push({
+ type: "donated",
+ timestamp: d.block_timestamp,
+ storylineId: d.storyline_id,
+ detail: `${formatPrice(formatUnits(BigInt(d.amount), 18))} ${RESERVE_LABEL}`,
+ txHash: d.tx_hash,
+ });
+ }
+
+ // Ratings
+ for (const r of (ratingsRes.data ?? []) as { storyline_id: number; rating: number; created_at: string }[]) {
+ entries.push({
+ type: "rated",
+ timestamp: r.created_at,
+ storylineId: r.storyline_id,
+ detail: `${"★".repeat(r.rating)}${"☆".repeat(5 - r.rating)}`,
+ });
+ }
+
+ // TODO: Claimed royalties feed entries require a dedicated claim event
+ // indexer. For now, cumulative claimed amount is shown in the profile header
+ // via on-chain getRoyaltyInfo.
+
+ // Sort reverse-chronological
+ entries.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
+ return entries;
},
});
- const isLoading = donLoading || ratLoading;
- const hasActivity = donations.length > 0 || ratings.length > 0;
-
if (isLoading) return Loading...
;
- if (!hasActivity) {
- return No activity yet.
;
+ if (feed.length === 0) {
+ return (
+
+
No activity yet.
+
+ This address has no on-chain activity on PlotLink.
+
+
+ );
}
+ const visible = feed.slice(0, visibleCount);
+ const hasMore = visibleCount < feed.length;
+
return (
-
- {donations.length > 0 && (
-
-
Donations
-
- {donations.map((d) => (
-
-
-
- Story #{d.storyline_id}
-
- {d.block_timestamp && (
-
- )}
-
-
-
- {formatPrice(formatUnits(BigInt(d.amount), 18))} {RESERVE_LABEL}
-
- {d.tx_hash && (
-
- ↗
-
- )}
-
-
- ))}
-
-
+
+
+ {visible.map((entry, i) => (
+
+ ))}
+
+ {hasMore && (
+
)}
+
+ );
+}
- {ratings.length > 0 && (
-
-
Ratings
-
- {ratings.map((r: { id: number; storyline_id: number; rating: number; comment: string | null; created_at: string }) => (
-
-
-
- Story #{r.storyline_id}
-
- {"★".repeat(r.rating)}{"☆".repeat(5 - r.rating)}
-
-
-
- ))}
-
-
- )}
+const EVENT_LABELS: Record
= {
+ created_storyline: "Created",
+ published_plot: "Published",
+ bought: "Bought",
+ sold: "Sold",
+ donated: "Donated",
+ rated: "Rated",
+ claimed_royalties: "Claimed",
+};
+
+const EVENT_COLORS: Record = {
+ created_storyline: "text-accent",
+ published_plot: "text-accent",
+ bought: "text-green-700",
+ sold: "text-red-700",
+ donated: "text-accent",
+ rated: "text-muted",
+ claimed_royalties: "text-green-700",
+};
+
+function FeedRow({ entry }: { entry: FeedEntry }) {
+ return (
+
+
+
+ {EVENT_LABELS[entry.type]}
+
+ {entry.storylineId > 0 ? (
+
+ {entry.storyTitle ?? `Story #${entry.storylineId}`}
+
+ ) : (
+ Royalties
+ )}
+ {entry.detail && (
+ {entry.detail}
+ )}
+
+
+
+ {entry.txHash && (
+
+ ↗
+
+ )}
+
);
}