mirror of
https://github.com/block/buzz.git
synced 2026-10-29 13:31:44 +00:00
feat(market): move bids into threaded board
Signed-off-by: Wintermute <c0fc581234c3585602139eec347ced7b82af65b6f6c10728348515c0c06c51c3@buzz.block.builderlab.xyz>
This commit is contained in:
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
117
desktop/src/features/market/lib/marketTimeline.test.mjs
Normal file
117
desktop/src/features/market/lib/marketTimeline.test.mjs
Normal 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],
|
||||
);
|
||||
});
|
||||
22
desktop/src/features/market/lib/marketTimeline.ts
Normal file
22
desktop/src/features/market/lib/marketTimeline.ts
Normal 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],
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user