fix(market): align bidder profile identity

Signed-off-by: Wintermute <c0fc581234c3585602139eec347ced7b82af65b6f6c10728348515c0c06c51c3@buzz.block.builderlab.xyz>
This commit is contained in:
Wintermute
2026-09-07 10:08:02 -04:00
parent ed20f88298
commit ea0352bdca
5 changed files with 111 additions and 44 deletions

View File

@@ -203,7 +203,7 @@ export const ChannelPane = React.memo(function ChannelPane({
);
const [isMainDeferredEditPending, setMainDeferredEditPending] = React.useState(false);
const isMarketObserver = useMarketObserver(agentPubkeys, currentPubkey);
const { isMarketChannel, messageFooters: marketMessageFooters, messages: marketTimelineMessages } = useMarketChannelTimeline(messages);
const { isMarketChannel, messageFooters: marketMessageFooters, messages: marketTimelineMessages } = useMarketChannelTimeline(messages, profiles);
const isNonMemberView =
activeChannel !== null &&
!activeChannel.isMember &&
@@ -369,7 +369,7 @@ export const ChannelPane = React.memo(function ChannelPane({
onOpenMembers,
onWelcomeAddAgent: onAddAgent ? handleWelcomeAddAgent : undefined,
});
const channelIntro = isHuddleTranscript ? null : standardChannelIntro;
const channelIntro = isHuddleTranscript || isMarketChannel ? null : standardChannelIntro;
const { mainTimelineEntries, recentMentions, visibleMessages } =
useChannelPaneMessages({
activeChannel,

View File

@@ -0,0 +1,32 @@
import assert from "node:assert/strict";
import test from "node:test";
import { resolveMarketBidIdentity } from "./marketBidIdentity.ts";
const BIDDER = "a".repeat(64);
const bid = { actorName: "Fizz envelope", bidderPubkey: BIDDER };
test("market bids use the signed bidder's profile identity", () => {
assert.deepEqual(
resolveMarketBidIdentity(bid, {
[BIDDER]: {
avatarUrl: "https://relay.example/fizz.png",
displayName: "Fizz",
nip05Handle: null,
ownerPubkey: null,
isAgent: true,
},
}),
{
avatarUrl: "https://relay.example/fizz.png",
displayName: "Fizz",
},
);
});
test("market bids fall back to the signed envelope name without a profile", () => {
assert.deepEqual(resolveMarketBidIdentity(bid), {
avatarUrl: null,
displayName: "Fizz envelope",
});
});

View File

@@ -0,0 +1,14 @@
import type { MarketBid } from "@/features/market/lib/marketProtocol";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import { normalizePubkey } from "@/shared/lib/pubkey";
export function resolveMarketBidIdentity(
bid: Pick<MarketBid, "actorName" | "bidderPubkey">,
profiles?: UserProfileLookup,
) {
const profile = profiles?.[normalizePubkey(bid.bidderPubkey)];
return {
avatarUrl: profile?.avatarUrl ?? null,
displayName: profile?.displayName?.trim() || bid.actorName,
};
}

View File

@@ -8,8 +8,12 @@ import {
} from "@/features/market/lib/marketTimeline";
import { MarketChannelIntro } from "@/features/market/ui/MarketChannelIntro";
import type { TimelineMessage } from "@/features/messages/types";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
export function useMarketChannelTimeline(messages: TimelineMessage[]) {
export function useMarketChannelTimeline(
messages: TimelineMessage[],
profiles?: UserProfileLookup,
) {
const projection = useMarketChannel();
const timelineMessages = useMarketTimelineMessages(messages);
const anchor = React.useMemo(
@@ -24,11 +28,12 @@ export function useMarketChannelTimeline(messages: TimelineMessage[]) {
<MarketChannelIntro
anchorMessage={anchor}
bids={projection?.bids ?? []}
profiles={profiles}
/>
),
}
: undefined,
[anchor, projection?.bids],
[anchor, profiles, projection?.bids],
);
return {

View File

@@ -4,6 +4,7 @@ import { useAppNavigation } from "@/app/navigation/useAppNavigation";
import { MessageCircle } from "lucide-react";
import { useMarketChannel } from "@/features/market/lib/MarketChannelContext";
import { resolveMarketBidIdentity } from "@/features/market/lib/marketBidIdentity";
import type { MarketBid } from "@/features/market/lib/marketProtocol";
import {
isMarketProtocolMessage,
@@ -11,15 +12,18 @@ import {
} from "@/features/market/lib/marketTimeline";
import { MarketContractCard } from "@/features/market/ui/MarketContractCard";
import type { TimelineMessage } from "@/features/messages/types";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import { Button } from "@/shared/ui/button";
import { UserAvatar } from "@/shared/ui/UserAvatar";
function BidList({
bids,
channelId,
profiles,
}: {
bids: MarketBid[];
channelId: string;
profiles?: UserProfileLookup;
}) {
const { goChannel } = useAppNavigation();
return (
@@ -34,46 +38,52 @@ function BidList({
<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"
{bids.map((bid) => {
const { avatarUrl, displayName } = resolveMarketBidIdentity(
bid,
profiles,
);
return (
<div
className="flex items-center gap-3 px-3 py-2.5"
key={bid.eventId}
>
<MessageCircle className="mr-1.5 h-4 w-4" />
Thread
</Button>
</div>
))}
<UserAvatar
avatarUrl={avatarUrl}
className="h-8 w-8 shrink-0 text-xs"
displayName={displayName}
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">{displayName}</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 ${displayName}`}
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>
@@ -83,9 +93,11 @@ function BidList({
export function MarketChannelIntro({
anchorMessage,
bids,
profiles,
}: {
anchorMessage: Pick<TimelineMessage, "body" | "createdAt" | "id">;
bids: MarketBid[];
profiles?: UserProfileLookup;
}): React.ReactNode {
const projection = useMarketChannel();
if (!projection || isMarketProtocolMessage(anchorMessage)) return undefined;
@@ -93,7 +105,11 @@ export function MarketChannelIntro({
return (
<div className="mt-2">
<MarketContractCard scenario={projection.scenario} />
<BidList bids={visibleBids} channelId={projection.channelId} />
<BidList
bids={visibleBids}
channelId={projection.channelId}
profiles={profiles}
/>
</div>
);
}