diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index 77c0731cf0..bbd9027f32 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -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, }); diff --git a/desktop/src/features/market/lib/marketProtocol.test.mjs b/desktop/src/features/market/lib/marketProtocol.test.mjs index 693291933f..9ac50bcb9a 100644 --- a/desktop/src/features/market/lib/marketProtocol.test.mjs +++ b/desktop/src/features/market/lib/marketProtocol.test.mjs @@ -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, diff --git a/desktop/src/features/market/lib/marketProtocol.ts b/desktop/src/features/market/lib/marketProtocol.ts index 6405342895..d146ddcecf 100644 --- a/desktop/src/features/market/lib/marketProtocol.ts +++ b/desktop/src/features/market/lib/marketProtocol.ts @@ -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: { diff --git a/desktop/src/features/market/lib/marketTimeline.test.mjs b/desktop/src/features/market/lib/marketTimeline.test.mjs new file mode 100644 index 0000000000..95be49858f --- /dev/null +++ b/desktop/src/features/market/lib/marketTimeline.test.mjs @@ -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], + ); +}); diff --git a/desktop/src/features/market/lib/marketTimeline.ts b/desktop/src/features/market/lib/marketTimeline.ts new file mode 100644 index 0000000000..1d7c983475 --- /dev/null +++ b/desktop/src/features/market/lib/marketTimeline.ts @@ -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], + ); +} diff --git a/desktop/src/features/market/ui/MarketChannelIntro.tsx b/desktop/src/features/market/ui/MarketChannelIntro.tsx index 00cde8e92e..ed407f5ff3 100644 --- a/desktop/src/features/market/ui/MarketChannelIntro.tsx +++ b/desktop/src/features/market/ui/MarketChannelIntro.tsx @@ -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 ( +
+
+

Bids

+ + {bids.length} {bids.length === 1 ? "bid" : "bids"} + +
+ {bids.length === 0 ? ( +

No bids yet.

+ ) : ( +
+ {bids.map((bid) => ( +
+ +
+
+ {bid.actorName} + + {bid.amountSats != null + ? `${bid.amountSats} sats per unit` + : "Terms in thread"} + {` ยท ${bid.quantity} ${bid.quantity === 1 ? "unit" : "units"}`} + +
+

+ {bid.message} +

+
+ +
+ ))} +
+ )} +
+ ); +} export function MarketChannelIntro(): React.ReactNode { const projection = useMarketChannel(); return projection ? ( - +
+ + +
) : undefined; } diff --git a/desktop/src/features/market/ui/MarketMessageBody.tsx b/desktop/src/features/market/ui/MarketMessageBody.tsx index 9ae03f440f..a4f0b44307 100644 --- a/desktop/src/features/market/ui/MarketMessageBody.tsx +++ b/desktop/src/features/market/ui/MarketMessageBody.tsx @@ -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) { return parseMarketEnvelope(content) ? ( ) : 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 ; } - - const presentation = presentMarketEvent(envelope); + if (isThreadReply) { + return ( +

+ {envelope.type === "contract" + ? envelope.listing.summary + : presentMarketEvent(envelope).summary} +

+ ); + } + 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 (
); 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) {