mirror of
https://github.com/block/buzz.git
synced 2026-10-29 13:31:44 +00:00
fix(desktop): render market events as cards
Signed-off-by: Wintermute <c0fc581234c3585602139eec347ced7b82af65b6f6c10728348515c0c06c51c3@buzz.block.builderlab.xyz>
This commit is contained in:
@@ -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.");
|
||||
});
|
||||
68
desktop/src/features/market/lib/marketEventPresentation.ts
Normal file
68
desktop/src/features/market/lib/marketEventPresentation.ts
Normal 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`,
|
||||
};
|
||||
}
|
||||
}
|
||||
155
desktop/src/features/market/ui/MarketEventCard.tsx
Normal file
155
desktop/src/features/market/ui/MarketEventCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
8
desktop/src/features/market/ui/MarketMessageBody.tsx
Normal file
8
desktop/src/features/market/ui/MarketMessageBody.tsx
Normal 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;
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user