refactor: improve NIP-10 thread chat UX and relay/participant handling

UI Improvements:
- Remove "thread root" marker from reply previews - treat all replies uniformly
- Hide "load older messages" for NIP-10 threads (all replies loaded reactively)
- Display "Thread" with note icon instead of "Group" for NIP-10 conversations
- Remove debug logging from chat command parser and ChatViewer

Participant Management:
- Derive participants dynamically from messages (like live-chat does)
- Root author (OP) always listed first with "op" role
- All unique message authors included in member list
- Updates in real-time as new people reply

Relay Management:
- Expand relay collection to include participant outbox relays
- Fetch relays from root author, provided event author, and p-tagged participants
- Check up to 5 participants for relay diversity
- Increase max relay limit from 7 to 10 for better coverage
- Add logging for relay collection debugging

This makes NIP-10 threads feel more like proper chat conversations with
accurate participant lists and better relay coverage across the thread.
This commit is contained in:
Claude
2026-01-19 11:52:18 +00:00
parent 5969e2f52f
commit ddb88ab78a
4 changed files with 116 additions and 50 deletions

View File

@@ -11,6 +11,7 @@ import {
Paperclip,
Copy,
CopyCheck,
FileText,
} from "lucide-react";
import { nip19 } from "nostr-tools";
import { getZapRequest } from "applesauce-common/helpers/zap";
@@ -437,10 +438,6 @@ export function ChatViewer({
customTitle,
headerPrefix,
}: ChatViewerProps) {
console.log("[ChatViewer] Received props:", {
protocol,
identifier: identifier?.type,
});
const { addWindow } = useGrimoire();
// Get active account with signing capability
@@ -752,33 +749,63 @@ export function ChatViewer({
? conversation?.metadata?.liveActivity
: undefined;
// Derive participants from messages for live activities (unique pubkeys who have chatted)
// Derive participants from messages for live activities and NIP-10 threads
const derivedParticipants = useMemo(() => {
if (conversation?.type !== "live-chat" || !messages) {
return conversation?.participants || [];
}
// NIP-10 threads: derive from messages with OP first
if (protocol === "nip-10" && messages && conversation) {
const rootAuthor = conversation.metadata?.rootEventId
? messages.find((m) => m.id === conversation.metadata?.rootEventId)
?.author
: undefined;
const hostPubkey = liveActivity?.hostPubkey;
const participants: { pubkey: string; role: "host" | "member" }[] = [];
const participants: { pubkey: string; role: "op" | "member" }[] = [];
// Host always first
if (hostPubkey) {
participants.push({ pubkey: hostPubkey, role: "host" });
}
// Add other participants from messages (excluding host)
const seen = new Set(hostPubkey ? [hostPubkey] : []);
for (const msg of messages) {
if (msg.type !== "system" && !seen.has(msg.author)) {
seen.add(msg.author);
participants.push({ pubkey: msg.author, role: "member" });
// OP (root author) always first
if (rootAuthor) {
participants.push({ pubkey: rootAuthor, role: "op" });
}
// Add other participants from messages (excluding OP)
const seen = new Set(rootAuthor ? [rootAuthor] : []);
for (const msg of messages) {
if (msg.type !== "system" && !seen.has(msg.author)) {
seen.add(msg.author);
participants.push({ pubkey: msg.author, role: "member" });
}
}
return participants;
}
return participants;
// Live activities: derive from messages with host first
if (conversation?.type === "live-chat" && messages) {
const hostPubkey = liveActivity?.hostPubkey;
const participants: { pubkey: string; role: "host" | "member" }[] = [];
// Host always first
if (hostPubkey) {
participants.push({ pubkey: hostPubkey, role: "host" });
}
// Add other participants from messages (excluding host)
const seen = new Set(hostPubkey ? [hostPubkey] : []);
for (const msg of messages) {
if (msg.type !== "system" && !seen.has(msg.author)) {
seen.add(msg.author);
participants.push({ pubkey: msg.author, role: "member" });
}
}
return participants;
}
// Other protocols: use static participants from conversation
return conversation?.participants || [];
}, [
protocol,
conversation?.type,
conversation?.participants,
conversation?.metadata?.rootEventId,
messages,
liveActivity?.hostPubkey,
]);
@@ -881,9 +908,16 @@ export function ChatViewer({
conversation.type === "live-chat") && (
<span className="text-primary-foreground/60"></span>
)}
<span className="capitalize text-primary-foreground/80">
{conversation.type}
</span>
{conversation.protocol === "nip-10" ? (
<span className="flex items-center gap-1 text-primary-foreground/80">
<FileText className="size-3" />
Thread
</span>
) : (
<span className="capitalize text-primary-foreground/80">
{conversation.type}
</span>
)}
</div>
{/* Live Activity Status */}
{liveActivity?.status && (
@@ -953,7 +987,9 @@ export function ChatViewer({
alignToBottom
components={{
Header: () =>
hasMore && conversationResult.status === "success" ? (
hasMore &&
conversationResult.status === "success" &&
protocol !== "nip-10" ? (
<div className="flex justify-center py-2">
<Button
onClick={handleLoadOlder}

View File

@@ -26,9 +26,6 @@ export const ReplyPreview = memo(function ReplyPreview({
// Load the event being replied to (reactive - updates when event arrives)
const replyEvent = use$(() => eventStore.event(replyToId), [replyToId]);
// Check if replying to thread root (NIP-10)
const isRoot = conversation.metadata?.rootEventId === replyToId;
// Fetch event from relays if not in store
useEffect(() => {
if (!replyEvent) {
@@ -50,7 +47,7 @@ export const ReplyPreview = memo(function ReplyPreview({
if (!replyEvent) {
return (
<div className="text-xs text-muted-foreground mb-0.5">
Replying to {isRoot ? "thread root" : replyToId.slice(0, 8)}...
Replying to {replyToId.slice(0, 8)}...
</div>
);
}
@@ -62,14 +59,10 @@ export const ReplyPreview = memo(function ReplyPreview({
title="Click to scroll to message"
>
<span className="flex-shrink-0"></span>
{isRoot ? (
<span className="font-medium flex-shrink-0">thread root</span>
) : (
<UserName
pubkey={replyEvent.pubkey}
className="font-medium flex-shrink-0"
/>
)}
<UserName
pubkey={replyEvent.pubkey}
className="font-medium flex-shrink-0"
/>
<div className="line-clamp-1 overflow-hidden flex-1 min-w-0">
<RichText
event={replyEvent}

View File

@@ -681,10 +681,11 @@ export class Nip10Adapter extends ChatProtocolAdapter {
/**
* Determine best relays for the thread
* Includes relays from root author, provided event author, p-tagged participants, and active user
*/
private async getThreadRelays(
rootEvent: NostrEvent,
_providedEvent: NostrEvent,
providedEvent: NostrEvent,
providedRelays: string[],
): Promise<string[]> {
const relays = new Set<string>();
@@ -692,7 +693,7 @@ export class Nip10Adapter extends ChatProtocolAdapter {
// 1. Provided relay hints
providedRelays.forEach((r) => relays.add(normalizeURL(r)));
// 2. Root author's outbox relays (NIP-65)
// 2. Root author's outbox relays (NIP-65) - highest priority
try {
const rootOutbox = await this.getOutboxRelays(rootEvent.pubkey);
rootOutbox.slice(0, 3).forEach((r) => relays.add(normalizeURL(r)));
@@ -700,9 +701,46 @@ export class Nip10Adapter extends ChatProtocolAdapter {
console.warn("[NIP-10] Failed to get root author outbox:", err);
}
// 3. Active user's outbox (for publishing replies)
// 3. Collect unique participant pubkeys from both events' p-tags
const participantPubkeys = new Set<string>();
// Add p-tags from root event
for (const tag of rootEvent.tags) {
if (tag[0] === "p" && tag[1]) {
participantPubkeys.add(tag[1]);
}
}
// Add p-tags from provided event
for (const tag of providedEvent.tags) {
if (tag[0] === "p" && tag[1]) {
participantPubkeys.add(tag[1]);
}
}
// Add provided event author if different from root
if (providedEvent.pubkey !== rootEvent.pubkey) {
participantPubkeys.add(providedEvent.pubkey);
}
// 4. Fetch outbox relays from participant subset (limit to avoid slowdown)
// Take first 5 participants to get relay diversity without excessive fetching
const participantsToCheck = Array.from(participantPubkeys).slice(0, 5);
for (const pubkey of participantsToCheck) {
try {
const outbox = await this.getOutboxRelays(pubkey);
// Add 1 relay from each participant for diversity
if (outbox.length > 0) {
relays.add(normalizeURL(outbox[0]));
}
} catch (err) {
// Silently continue if participant has no relay list
}
}
// 5. Active user's outbox (for publishing replies)
const activePubkey = accountManager.active$.value?.pubkey;
if (activePubkey) {
if (activePubkey && !participantPubkeys.has(activePubkey)) {
try {
const userOutbox = await this.getOutboxRelays(activePubkey);
userOutbox.slice(0, 2).forEach((r) => relays.add(normalizeURL(r)));
@@ -711,7 +749,7 @@ export class Nip10Adapter extends ChatProtocolAdapter {
}
}
// 4. Fallback to popular relays if we have too few
// 6. Fallback to popular relays if we have too few
if (relays.size < 3) {
[
"wss://relay.damus.io",
@@ -720,8 +758,12 @@ export class Nip10Adapter extends ChatProtocolAdapter {
].forEach((r) => relays.add(r));
}
// Limit to 7 relays max for performance
return Array.from(relays).slice(0, 7);
console.log(
`[NIP-10] Collected ${relays.size} relays from root, ${participantsToCheck.length} participants, and fallbacks`,
);
// Limit to 10 relays max for performance
return Array.from(relays).slice(0, 10);
}
/**

View File

@@ -580,12 +580,7 @@ export const manPages: Record<string, ManPageEntry> = {
appId: "chat",
category: "Nostr",
argParser: async (args: string[]) => {
console.log("[chat argParser] Input args:", args);
const result = parseChatCommand(args);
console.log("[chat argParser] Parsed result:", {
protocol: result.protocol,
identifier: result.identifier,
});
return {
protocol: result.protocol,
identifier: result.identifier,