diff --git a/desktop/src/features/market/lib/marketEventPresentation.test.mjs b/desktop/src/features/market/lib/marketEventPresentation.test.mjs new file mode 100644 index 0000000000..be9ee63335 --- /dev/null +++ b/desktop/src/features/market/lib/marketEventPresentation.test.mjs @@ -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."); +}); diff --git a/desktop/src/features/market/lib/marketEventPresentation.ts b/desktop/src/features/market/lib/marketEventPresentation.ts new file mode 100644 index 0000000000..478ad57cfe --- /dev/null +++ b/desktop/src/features/market/lib/marketEventPresentation.ts @@ -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`, + }; + } +} diff --git a/desktop/src/features/market/ui/MarketEventCard.tsx b/desktop/src/features/market/ui/MarketEventCard.tsx new file mode 100644 index 0000000000..958e61bb10 --- /dev/null +++ b/desktop/src/features/market/ui/MarketEventCard.tsx @@ -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 ( +
+
+
+ {announcement ? ( + + ) : ( + + )} +
+
+
+ + {presentation.eyebrow} + + {announcement ? : null} +
+

+ {presentation.title} +

+

+ {presentation.summary} +

+
+ + {presentation.amount ?? "Signed market event"} + + {announcement ? ( + + ) : null} +
+
+
+
+ ); +} + +type VerificationState = "channel-event" | "checking" | "invalid" | "verified"; + +type Announcement = Extract< + NonNullable>, + { type: "announcement" } +>; + +function verifyAnnouncement( + announcement: Announcement, + authorPubkey: string, + events: Awaited> | 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 Checking channel; + if (state === "verified") { + return ( + + Verified contract + + ); + } + return ( + + Unverified claim + + ); +} diff --git a/desktop/src/features/market/ui/MarketMessageBody.tsx b/desktop/src/features/market/ui/MarketMessageBody.tsx new file mode 100644 index 0000000000..79b4298ae7 --- /dev/null +++ b/desktop/src/features/market/ui/MarketMessageBody.tsx @@ -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) ? ( + + ) : null; +} diff --git a/desktop/src/features/messages/ui/MessageRow.tsx b/desktop/src/features/messages/ui/MessageRow.tsx index 02858dac6a..a705d91759 100644 --- a/desktop/src/features/messages/ui/MessageRow.tsx +++ b/desktop/src/features/messages/ui/MessageRow.tsx @@ -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 ( diff --git a/desktop/src/features/pulse/ui/NoteCard.tsx b/desktop/src/features/pulse/ui/NoteCard.tsx index 96f25a6ede..5dcc268ac6 100644 --- a/desktop/src/features/pulse/ui/NoteCard.tsx +++ b/desktop/src/features/pulse/ui/NoteCard.tsx @@ -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) ? ( + + ) : ( + + ); +} + export function NoteCard({ note, profile, @@ -221,7 +231,7 @@ export function NoteCard({ ) : null}
- +