fix(desktop): render market events as cards

Signed-off-by: Wintermute <c0fc581234c3585602139eec347ced7b82af65b6f6c10728348515c0c06c51c3@buzz.block.builderlab.xyz>
This commit is contained in:
Wintermute
2026-09-01 11:55:46 -04:00
parent 3f65a9ea07
commit fbbed967d7
6 changed files with 305 additions and 1 deletions

View File

@@ -0,0 +1,60 @@
import assert from "node:assert/strict";
import test from "node:test";
import { presentMarketEvent } from "./marketEventPresentation.ts";
import { MARKET_PROTOCOL, parseMarketEnvelope } from "./marketProtocol.ts";
const channelId = "123e4567-e89b-12d3-a456-426614174000";
const listingEventId = "1".repeat(64);
function parsed(value) {
const envelope = parseMarketEnvelope(JSON.stringify(value));
assert.ok(envelope);
return envelope;
}
test("announcement presentation exposes useful listing fields, not JSON", () => {
const presentation = presentMarketEvent(
parsed({
protocol: MARKET_PROTOCOL,
type: "announcement",
channelId,
version: 1,
listingEventId,
listing: {
actorName: "Moneypenny",
direction: "offer",
mechanism: "fixed",
title: "Daily standup digest",
summary: "One cited digest delivered as a channel message.",
quantity: 1,
priceSats: 50,
},
}),
);
assert.deepEqual(presentation, {
amount: "50 fake sats per unit",
eyebrow: "offer · fixed",
summary: "One cited digest delivered as a channel message.",
title: "Daily standup digest",
});
});
test("channel lifecycle events get human-readable cards", () => {
const presentation = presentMarketEvent(
parsed({
protocol: MARKET_PROTOCOL,
type: "fulfillment",
channelId,
listingEventId,
awardEventId: "2".repeat(64),
actorName: "Seller Agent",
message: "The cited digest is attached.",
}),
);
assert.equal(presentation.eyebrow, "Market fulfillment");
assert.equal(presentation.title, "Seller Agent delivered");
assert.equal(presentation.summary, "The cited digest is attached.");
});

View File

@@ -0,0 +1,68 @@
import type {
MarketEnvelope,
MarketListing,
} from "@/features/market/lib/marketProtocol";
export type MarketEventPresentation = {
amount: string | null;
eyebrow: string;
summary: string;
title: string;
};
function listingAmount(listing: MarketListing): string | null {
if (listing.priceSats) return `${listing.priceSats} fake sats per unit`;
if (listing.maxBudgetSats) return `Up to ${listing.maxBudgetSats} fake sats`;
return null;
}
export function presentMarketEvent(
envelope: MarketEnvelope,
): MarketEventPresentation {
switch (envelope.type) {
case "announcement":
return {
amount: listingAmount(envelope.listing),
eyebrow: `${envelope.listing.direction} · ${envelope.listing.mechanism.replace("-", " ")}`,
summary: envelope.listing.summary,
title: envelope.listing.title,
};
case "contract":
return {
amount: listingAmount(envelope.listing),
eyebrow: "Market contract opened",
summary: envelope.listing.summary,
title: envelope.listing.title,
};
case "response":
return {
amount: envelope.amountSats
? `${envelope.amountSats} fake sats per unit`
: null,
eyebrow: "Market response",
summary: envelope.message,
title: `${envelope.actorName} responded for ${envelope.quantity} ${envelope.quantity === 1 ? "unit" : "units"}`,
};
case "award":
return {
amount: `${envelope.amountSats * envelope.quantity} fake sats total`,
eyebrow: "Market award",
summary: `${envelope.quantity} ${envelope.quantity === 1 ? "unit" : "units"} awarded at ${envelope.amountSats} fake sats each.`,
title: `${envelope.actorName} awarded a response`,
};
case "fulfillment":
return {
amount: null,
eyebrow: "Market fulfillment",
summary: envelope.message,
title: `${envelope.actorName} delivered`,
};
case "settlement":
return {
amount: `${envelope.amountSats} fake sats`,
eyebrow: "Sandbox receipt",
summary: "Signed settlement metadata recorded. No real payment moved.",
title: `${envelope.actorName} marked the award settled`,
};
}
}

View File

@@ -0,0 +1,155 @@
import { useQuery } from "@tanstack/react-query";
import {
ArrowRight,
BadgeCheck,
CircleAlert,
Radio,
Store,
} from "lucide-react";
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
import { presentMarketEvent } from "@/features/market/lib/marketEventPresentation";
import {
marketAnnouncementMatchesProjection,
parseMarketEnvelope,
projectMarketChannel,
} from "@/features/market/lib/marketProtocol";
import { getChannelWindowEvents } from "@/shared/api/channelWindow";
import { Badge } from "@/shared/ui/badge";
export function MarketEventCard({
authorPubkey,
content,
}: {
authorPubkey: string;
content: string;
}) {
const { goChannel } = useAppNavigation();
const envelope = parseMarketEnvelope(content);
const announcement = envelope?.type === "announcement" ? envelope : null;
const verificationQuery = useQuery({
enabled: announcement !== null,
queryKey: ["market-announcement-verification", announcement?.channelId],
queryFn: () =>
getChannelWindowEvents(announcement?.channelId ?? "", null, 100),
staleTime: 30_000,
});
if (!envelope) return null;
const presentation = presentMarketEvent(envelope);
const verification = announcement
? verifyAnnouncement(
announcement,
authorPubkey,
verificationQuery.data ?? null,
)
: "channel-event";
const canOpen = announcement !== null && verification === "verified";
return (
<section
className="mt-1.5 max-w-2xl rounded-2xl border bg-card p-4 text-left shadow-xs"
data-testid={`market-event-${envelope.type}`}
>
<div className="flex items-start gap-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-muted text-muted-foreground">
{announcement ? (
<Radio className="h-4 w-4" />
) : (
<Store className="h-4 w-4" />
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<Badge className="capitalize" variant="secondary">
{presentation.eyebrow}
</Badge>
{announcement ? <VerificationBadge state={verification} /> : null}
</div>
<h3 className="mt-2 font-semibold leading-snug">
{presentation.title}
</h3>
<p className="mt-1 text-sm leading-relaxed text-muted-foreground">
{presentation.summary}
</p>
<div className="mt-3 flex flex-wrap items-center justify-between gap-3 text-xs">
<span className="font-medium text-foreground">
{presentation.amount ?? "Signed market event"}
</span>
{announcement ? (
<button
className="inline-flex items-center gap-1 font-medium text-muted-foreground enabled:hover:text-foreground disabled:cursor-not-allowed disabled:opacity-60"
disabled={!canOpen}
onClick={() => void goChannel(announcement.channelId)}
type="button"
>
{verification === "checking"
? "Checking channel…"
: verification === "verified"
? "Open market channel"
: "Channel claim did not verify"}
{canOpen ? <ArrowRight className="h-3.5 w-3.5" /> : null}
</button>
) : null}
</div>
</div>
</div>
</section>
);
}
type VerificationState = "channel-event" | "checking" | "invalid" | "verified";
type Announcement = Extract<
NonNullable<ReturnType<typeof parseMarketEnvelope>>,
{ type: "announcement" }
>;
function verifyAnnouncement(
announcement: Announcement,
authorPubkey: string,
events: Awaited<ReturnType<typeof getChannelWindowEvents>> | null,
): VerificationState {
if (!events) return "checking";
const projection = projectMarketChannel(
events.map((event) => ({
id: event.id,
pubkey: event.pubkey,
createdAt: event.created_at,
content: event.content,
tags: event.tags,
})),
announcement.channelId,
);
if (!projection) return "invalid";
return marketAnnouncementMatchesProjection(
{
announcementEventId: "",
channelId: announcement.channelId,
createdAt: 0,
listingEventId: announcement.listingEventId,
listing: announcement.listing,
publisherPubkey: authorPubkey,
},
projection,
)
? "verified"
: "invalid";
}
function VerificationBadge({ state }: { state: VerificationState }) {
if (state === "checking")
return <Badge variant="outline">Checking channel</Badge>;
if (state === "verified") {
return (
<Badge variant="outline">
<BadgeCheck className="mr-1 h-3 w-3" /> Verified contract
</Badge>
);
}
return (
<Badge variant="destructive">
<CircleAlert className="mr-1 h-3 w-3" /> Unverified claim
</Badge>
);
}

View File

@@ -0,0 +1,8 @@
import { parseMarketEnvelope } from "@/features/market/lib/marketProtocol";
import { MarketEventCard } from "@/features/market/ui/MarketEventCard";
export function MarketMessageBody(content: string, authorPubkey?: string) {
return parseMarketEnvelope(content) ? (
<MarketEventCard authorPubkey={authorPubkey ?? ""} content={content} />
) : null;
}

View File

@@ -13,6 +13,7 @@ import {
import type { TimelineMessage } from "@/features/messages/types";
import { useKnownAgentPubkeys } from "@/features/agents/useKnownAgentPubkeys";
import { HuddleAttachment } from "@/features/huddle/components/HuddleAttachment";
import { MarketMessageBody } from "@/features/market/ui/MarketMessageBody";
import { MessageReactions } from "@/features/messages/ui/MessageReactions";
import { useReactionHandler } from "@/features/messages/ui/useReactionHandler";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
@@ -408,6 +409,8 @@ export const MessageRow = React.memo(
/>
);
default: {
const marketBody = MarketMessageBody(message.body, message.pubkey);
if (marketBody) return marketBody;
const waveMessage = parseWaveMessageContent(message.body);
if (waveMessage) {
return (

View File

@@ -12,6 +12,8 @@ import { useUserProfileQuery } from "@/features/profile/hooks";
import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover";
import { useNoteByIdQuery } from "@/features/pulse/hooks";
import { getReplyParent, noteSnippet } from "@/features/pulse/lib/replies";
import { parseMarketEnvelope } from "@/features/market/lib/marketProtocol";
import { MarketEventCard } from "@/features/market/ui/MarketEventCard";
import type { UserNote } from "@/shared/api/socialTypes";
import type { ChannelMember, UserProfileSummary } from "@/shared/api/types";
import { AnimatedCount } from "@/shared/ui/AnimatedCount";
@@ -134,6 +136,14 @@ function formatRelativeTime(unixSeconds: number): string {
});
}
function MarketNoteBody({ note }: { note: UserNote }) {
return parseMarketEnvelope(note.content) ? (
<MarketEventCard authorPubkey={note.pubkey} content={note.content} />
) : (
<Markdown content={note.content} />
);
}
export function NoteCard({
note,
profile,
@@ -221,7 +231,7 @@ export function NoteCard({
) : null}
<div className="mt-0.5 pb-3 text-sm text-foreground">
<Markdown content={note.content} />
<MarketNoteBody note={note} />
</div>
<div className="flex flex-wrap items-center gap-5 text-xs font-medium">