feat(market): move bids into threaded board

Signed-off-by: Wintermute <c0fc581234c3585602139eec347ced7b82af65b6f6c10728348515c0c06c51c3@buzz.block.builderlab.xyz>
This commit is contained in:
Wintermute
2026-09-02 13:19:30 -04:00
parent 1b893365ae
commit 3f2e226df0
8 changed files with 279 additions and 19 deletions

View File

@@ -66,7 +66,7 @@ import * as agentSessionSelection from "@/features/channels/ui/agentSessionSelec
import { usePrepareDmSendChannel } from "@/features/channels/ui/usePrepareDmSendChannel";
import { useChannelPaneMessages } from "@/features/channels/ui/useChannelPaneMessages";
import { useRoutedMessageEdit } from "@/features/channels/ui/useRoutedMessageEdit";
import { MarketChannelIntro } from "@/features/market/ui/MarketChannelIntro"; import { useMarketObserver } from "@/features/market/ui/marketChannelComposer";
import { useMarketTimelineMessages } from "@/features/market/lib/marketTimeline"; import { MarketChannelIntro } from "@/features/market/ui/MarketChannelIntro"; import { useMarketObserver } from "@/features/market/ui/marketChannelComposer";
import { Button } from "@/shared/ui/button";
import { useRenderScopedReactionHydration } from "@/features/messages/lib/useRenderScopedReactionHydration";
import { isWelcomeExperienceChannel as isWelcomeExperience } from "@/features/onboarding/welcome";
@@ -203,6 +203,7 @@ export const ChannelPane = React.memo(function ChannelPane({
);
const [isMainDeferredEditPending, setMainDeferredEditPending] = React.useState(false);
const isMarketObserver = useMarketObserver(agentPubkeys, currentPubkey);
const marketTimelineMessages = useMarketTimelineMessages(messages);
const isNonMemberView =
activeChannel !== null &&
!activeChannel.isMember &&
@@ -373,7 +374,7 @@ export const ChannelPane = React.memo(function ChannelPane({
useChannelPaneMessages({
activeChannel,
isHuddleTranscript,
messages,
messages: marketTimelineMessages,
profiles,
threadSummaries,
});

View File

@@ -141,6 +141,16 @@ test("projectMarketChannel folds a channel contract through fake settlement", ()
assert.ok(projection);
assert.equal(projection.channelId, CHANNEL_ID);
assert.equal(projection.contractAuthorPubkey, SELLER);
assert.deepEqual(projection.bids, [
{
eventId: RESPONSE_ID,
actorName: "Buyer Agent",
amountSats: 50,
bidderPubkey: BUYER,
message: "I reserve the report.",
quantity: 1,
},
]);
assert.equal(projection.scenario.status, "Fulfilled");
assert.equal(projection.scenario.activity.length, 5);
assert.equal(
@@ -257,6 +267,10 @@ test("reverse auction enforces the minimum decrement", () => {
);
assert.ok(projection);
assert.deepEqual(
projection.bids.map(({ eventId }) => eventId),
[RESPONSE_ID],
);
assert.equal(projection.scenario.liveMetrics[1].value, "1");
assert.equal(
projection.rejected[0].reason,

View File

@@ -100,11 +100,21 @@ export type MarketProjection = {
contract: MarketContractEnvelope;
contractAuthorPubkey: string;
listingEventId: string;
bids: MarketBid[];
scenario: MarketScenario;
rejected: Array<{ eventId: string; reason: string }>;
wallet: MarketWallet;
};
export type MarketBid = {
eventId: string;
actorName: string;
amountSats?: number;
bidderPubkey: string;
message: string;
quantity: number;
};
export type MarketWallet = {
/** Awarded but not yet settled, in fake sats. */
escrowedSats: number;
@@ -500,6 +510,16 @@ export function projectMarketChannel(
.reverse();
const settled = settlements.size > 0;
const bids: MarketBid[] = [...responses.entries()].map(
([eventId, { envelope, note }]) => ({
eventId,
actorName: envelope.actorName,
amountSats: envelope.amountSats,
bidderPubkey: note.pubkey,
message: envelope.message,
quantity: envelope.quantity,
}),
);
const fulfilled = fulfillments.size;
const scenarioId = scenarioIdForListing(listing.listing, settled);
const quantity = listing.listing.quantity;
@@ -532,6 +552,7 @@ export function projectMarketChannel(
contract: listing,
contractAuthorPubkey: listingEntry.note.pubkey,
listingEventId,
bids,
rejected,
wallet: { escrowedSats, settledSats },
scenario: {

View File

@@ -0,0 +1,117 @@
import assert from "node:assert/strict";
import test from "node:test";
import { selectMarketTimelineMessages } from "./marketTimeline.ts";
import { buildIndependentThreadPanel } from "@/features/messages/lib/independentThreadPanel";
const PUBKEY = "f".repeat(64);
const CHANNEL = "123e4567-e89b-12d3-a456-426614174000";
const envelope = (type) =>
JSON.stringify({
protocol: "buzz-market/v0",
type,
channelId: CHANNEL,
...(type === "contract"
? {
version: 1,
listing: {
actorName: "Seller",
direction: "offer",
mechanism: "fixed",
title: "Report",
summary: "Deliver a report",
quantity: 1,
priceSats: 50,
},
}
: {
listingEventId: "1".repeat(64),
actorName: "Buyer",
quantity: 1,
amountSats: 50,
message: "I bid",
}),
});
const message = (id, body, parentId = null, rootId = null) => ({
id,
body,
parentId,
rootId,
});
test("market timeline hides protocol events but leaves negotiation in its bid thread", () => {
const contract = message("1".repeat(64), envelope("contract"));
const bid = message("2".repeat(64), envelope("response"));
const negotiation = message(
"3".repeat(64),
"Can you deliver sooner?",
bid.id,
bid.id,
);
const nestedProtocol = message(
"4".repeat(64),
envelope("response"),
bid.id,
bid.id,
);
const ordinary = message("5".repeat(64), "General market note");
const projection = {
listingEventId: contract.id,
bids: [{ eventId: bid.id }],
};
assert.deepEqual(
selectMarketTimelineMessages(
[contract, bid, negotiation, nestedProtocol, ordinary],
projection,
).map(({ id }) => id),
[negotiation.id, ordinary.id],
);
assert.equal(selectMarketTimelineMessages([contract, bid], null).length, 2);
});
const relayEvent = (id, content, tags = []) => ({
id,
pubkey: PUBKEY,
kind: 9,
created_at: 1,
content,
tags: [["h", CHANNEL], ...tags],
sig: "s",
});
function threadPanel(channelEvents, replyEvents, rootId) {
return buildIndependentThreadPanel(
channelEvents,
replyEvents,
rootId,
rootId,
new Set(),
null,
PUBKEY,
null,
undefined,
undefined,
new Map(),
new Map(),
null,
undefined,
);
}
test("a bid filtered from the main timeline still loads as a thread root", () => {
const bidId = "6".repeat(64);
const replyId = "7".repeat(64);
const bid = relayEvent(bidId, envelope("response"));
const reply = relayEvent(replyId, "Can you deliver sooner?", [
["e", bidId, "", "root"],
["e", bidId, "", "reply"],
]);
const panel = threadPanel([bid], [reply], bidId);
assert.equal(panel.threadHead?.id, bidId);
assert.deepEqual(
panel.visibleReplies.map(({ message: entry }) => entry.id),
[replyId],
);
});

View File

@@ -0,0 +1,22 @@
import * as React from "react";
import { useMarketChannel } from "@/features/market/lib/MarketChannelContext";
import type { MarketProjection } from "@/features/market/lib/marketProtocol";
import { parseMarketEnvelope } from "@/features/market/lib/marketProtocol";
import type { TimelineMessage } from "@/features/messages/types";
/** Keep protocol state out of the market's main chat; negotiation stays in threads. */
export function selectMarketTimelineMessages(
messages: TimelineMessage[],
projection: MarketProjection | null,
): TimelineMessage[] {
if (!projection) return messages;
return messages.filter((message) => !parseMarketEnvelope(message.body));
}
export function useMarketTimelineMessages(messages: TimelineMessage[]) {
const projection = useMarketChannel();
return React.useMemo(
() => selectMarketTimelineMessages(messages, projection),
[messages, projection],
);
}

View File

@@ -1,11 +1,89 @@
import type * as React from "react";
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
import { MessageCircle } from "lucide-react";
import { useMarketChannel } from "@/features/market/lib/MarketChannelContext";
import type { MarketBid } from "@/features/market/lib/marketProtocol";
import { MarketContractCard } from "@/features/market/ui/MarketContractCard";
import { Button } from "@/shared/ui/button";
import { UserAvatar } from "@/shared/ui/UserAvatar";
function BidList({
bids,
channelId,
}: {
bids: MarketBid[];
channelId: string;
}) {
const { goChannel } = useAppNavigation();
return (
<section
className="mt-4 border-t border-border/70 pt-4"
data-testid="market-bid-list"
>
<div className="mb-2 flex items-center justify-between gap-3">
<h3 className="text-sm font-semibold">Bids</h3>
<span className="text-xs text-muted-foreground">
{bids.length} {bids.length === 1 ? "bid" : "bids"}
</span>
</div>
{bids.length === 0 ? (
<p className="text-sm text-muted-foreground">No bids yet.</p>
) : (
<div className="divide-y divide-border/60 overflow-hidden rounded-xl border border-border/70">
{bids.map((bid) => (
<div
className="flex items-center gap-3 px-3 py-2.5"
key={bid.eventId}
>
<UserAvatar
avatarUrl={null}
className="h-8 w-8 shrink-0 text-xs"
displayName={bid.actorName}
shape="squircle"
size="sm"
/>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-baseline gap-x-2">
<span className="text-sm font-medium">{bid.actorName}</span>
<span className="text-xs text-muted-foreground">
{bid.amountSats != null
? `${bid.amountSats} sats per unit`
: "Terms in thread"}
{` · ${bid.quantity} ${bid.quantity === 1 ? "unit" : "units"}`}
</span>
</div>
<p className="truncate text-sm text-muted-foreground">
{bid.message}
</p>
</div>
<Button
aria-label={`Open negotiation with ${bid.actorName}`}
onClick={() => {
void goChannel(channelId, { thread: bid.eventId });
}}
size="sm"
type="button"
variant="ghost"
>
<MessageCircle className="mr-1.5 h-4 w-4" />
Thread
</Button>
</div>
))}
</div>
)}
</section>
);
}
export function MarketChannelIntro(): React.ReactNode {
const projection = useMarketChannel();
return projection ? (
<MarketContractCard scenario={projection.scenario} />
<div>
<MarketContractCard scenario={projection.scenario} />
<BidList bids={projection.bids} channelId={projection.channelId} />
</div>
) : undefined;
}

View File

@@ -3,16 +3,20 @@ import { presentMarketEvent } from "@/features/market/lib/marketEventPresentatio
import { parseMarketEnvelope } from "@/features/market/lib/marketProtocol";
import { MarketEventCard } from "@/features/market/ui/MarketEventCard";
export function MarketMessageBody(
content: string,
authorPubkey?: string,
eventId?: string,
) {
import type { TimelineMessage } from "@/features/messages/types";
export function MarketMessageBody({
body: content,
pubkey: authorPubkey,
id: eventId,
parentId,
}: Pick<TimelineMessage, "body" | "pubkey" | "id" | "parentId">) {
return parseMarketEnvelope(content) ? (
<MarketEventMessage
authorPubkey={authorPubkey ?? ""}
content={content}
eventId={eventId}
isThreadReply={parentId != null}
/>
) : null;
}
@@ -22,10 +26,12 @@ function MarketEventMessage({
authorPubkey,
content,
eventId,
isThreadReply,
}: {
authorPubkey: string;
content: string;
eventId?: string;
isThreadReply: boolean;
}) {
const projection = useMarketChannel();
const envelope = parseMarketEnvelope(content);
@@ -33,15 +39,20 @@ function MarketEventMessage({
if (!projection) {
return <MarketEventCard authorPubkey={authorPubkey} content={content} />;
}
const presentation = presentMarketEvent(envelope);
if (isThreadReply) {
return (
<p className="max-w-full text-message">
{envelope.type === "contract"
? envelope.listing.summary
: presentMarketEvent(envelope).summary}
</p>
);
}
if (envelope.type !== "response") return null;
const rejection = eventId
? projection.rejected.find((entry) => entry.eventId === eventId)
: undefined;
const body =
envelope.type === "contract"
? `Opened this market: ${envelope.listing.title}`
: presentation.summary;
const body = envelope.message;
return (
<div

View File

@@ -408,11 +408,7 @@ export const MessageRow = React.memo(
/>
);
default: {
const marketBody = MarketMessageBody(
message.body,
message.pubkey,
message.id,
);
const marketBody = MarketMessageBody(message);
if (marketBody) return marketBody;
const waveMessage = parseWaveMessageContent(message.body);
if (waveMessage) {