feat(chat): improve NIP-10/NIP-22 UI and relay selection

ChatViewer improvements:
- Make NIP-22 protocol badge clickable to open NIP specification
- Hide "load older messages" button for NIP-10 and NIP-22 (thread-based protocols)
- Show custom title with kind icon, author, and event title for thread-based chats

NIP-22 relay selection improvements:
- Extract relay hints from comment event tags (A/E tags with relay URLs)
- Fetch author's outbox relays (kind 10002) for better event discovery
- Merge relay hints from multiple sources: pointer, comment tags, author outbox
- Should significantly reduce "Comment root not found" errors

All 1114 tests passing.
This commit is contained in:
Claude
2026-01-22 21:18:46 +00:00
parent 26e515e510
commit 217ac46e7a
2 changed files with 171 additions and 16 deletions

View File

@@ -69,6 +69,8 @@ import {
TooltipTrigger,
} from "./ui/tooltip";
import { useBlossomUpload } from "@/hooks/useBlossomUpload";
import { getKindIcon } from "@/constants/kinds";
import { getEventDisplayTitle } from "@/lib/event-title";
interface ChatViewerProps {
protocol: ChatProtocol;
@@ -831,6 +833,8 @@ export function ChatViewer({
const handleNipClick = useCallback(() => {
if (conversation?.protocol === "nip-10") {
addWindow("nip", { number: 10 });
} else if (conversation?.protocol === "nip-22") {
addWindow("nip", { number: 22 });
} else if (conversation?.protocol === "nip-29") {
addWindow("nip", { number: 29 });
} else if (conversation?.protocol === "nip-53") {
@@ -906,6 +910,30 @@ export function ChatViewer({
liveActivity?.hostPubkey,
]);
// Get root event for NIP-10 and NIP-22 to build custom title
// Must be called before any early returns (React Hooks rules)
const rootEventId =
(protocol === "nip-10" || protocol === "nip-22") && conversation
? conversation.metadata?.rootEventId ||
conversation.metadata?.providedEventId
: undefined;
const rootEvent = use$(
() => (rootEventId ? eventStore.event(rootEventId) : undefined),
[rootEventId],
);
// Compute custom title for thread-based protocols (NIP-10, NIP-22)
const threadTitle = useMemo(() => {
if ((protocol !== "nip-10" && protocol !== "nip-22") || !rootEvent) {
return null;
}
const kindIcon = getKindIcon(rootEvent.kind);
const eventTitle = getEventDisplayTitle(rootEvent);
return { kindIcon, author: rootEvent.pubkey, eventTitle };
}, [protocol, rootEvent]);
// Handle loading state
if (!conversationResult || conversationResult.status === "loading") {
return (
@@ -951,10 +979,28 @@ export function ChatViewer({
<Tooltip open={tooltipOpen} onOpenChange={setTooltipOpen}>
<TooltipTrigger asChild>
<button
className="text-sm font-semibold truncate cursor-help text-left"
className="text-sm font-semibold truncate cursor-help text-left flex items-center gap-1.5 min-w-0"
onClick={() => setTooltipOpen(!tooltipOpen)}
>
{customTitle || conversation.title}
{threadTitle ? (
<>
{(() => {
const KindIcon = threadTitle.kindIcon;
return KindIcon ? (
<KindIcon className="size-4 flex-shrink-0" />
) : null;
})()}
<UserName
pubkey={threadTitle.author}
className="text-sm font-semibold flex-shrink-0"
/>
<span className="truncate">
{threadTitle.eventTitle}
</span>
</>
) : (
customTitle || conversation.title
)}
</button>
</TooltipTrigger>
<TooltipContent
@@ -988,7 +1034,9 @@ export function ChatViewer({
)}
{/* Protocol Type - Clickable */}
<div className="flex items-center gap-1.5 text-xs">
{(conversation.type === "group" ||
{(conversation.protocol === "nip-10" ||
conversation.protocol === "nip-22" ||
conversation.type === "group" ||
conversation.type === "live-chat") && (
<button
onClick={(e) => {
@@ -1000,11 +1048,14 @@ export function ChatViewer({
{conversation.protocol.toUpperCase()}
</button>
)}
{(conversation.type === "group" ||
{(conversation.protocol === "nip-10" ||
conversation.protocol === "nip-22" ||
conversation.type === "group" ||
conversation.type === "live-chat") && (
<span className="opacity-60"></span>
)}
{conversation.protocol === "nip-10" ? (
{conversation.protocol === "nip-10" ||
conversation.protocol === "nip-22" ? (
<span className="flex items-center gap-1 opacity-80">
<FileText className="size-3" />
Thread
@@ -1057,7 +1108,9 @@ export function ChatViewer({
<div className="flex items-center gap-2 text-xs text-muted-foreground p-1">
<MembersDropdown participants={derivedParticipants} />
<RelaysDropdown conversation={conversation} />
{(conversation.type === "group" ||
{(conversation.protocol === "nip-10" ||
conversation.protocol === "nip-22" ||
conversation.type === "group" ||
conversation.type === "live-chat") && (
<button
onClick={handleNipClick}
@@ -1083,7 +1136,8 @@ export function ChatViewer({
Header: () =>
hasMore &&
conversationResult.status === "success" &&
protocol !== "nip-10" ? (
protocol !== "nip-10" &&
protocol !== "nip-22" ? (
<div className="flex justify-center py-2">
<Button
onClick={handleLoadOlder}

View File

@@ -22,8 +22,7 @@ import pool from "@/services/relay-pool";
import { publishEventToRelays } from "@/services/hub";
import accountManager from "@/services/accounts";
import { AGGREGATOR_RELAYS } from "@/services/loaders";
import { mergeRelaySets } from "applesauce-core/helpers";
import { getOutboxes } from "applesauce-core/helpers/mailboxes";
import { mergeRelaySets, getOutboxes } from "applesauce-core/helpers";
import {
getEventPointerFromETag,
parseReplaceableAddress,
@@ -170,6 +169,7 @@ export class Nip22Adapter extends ChatProtocolAdapter {
const fetchedRoot = await this.fetchEventByPointer(
eventPointer,
relayHints,
providedEvent, // Pass comment event for relay hint extraction
);
if (!fetchedRoot) {
throw new Error("Comment root not found");
@@ -192,6 +192,7 @@ export class Nip22Adapter extends ChatProtocolAdapter {
const fetchedRoot = await this.fetchAddressableEvent(
addressPointer,
relayHints,
providedEvent, // Pass comment event for relay hint extraction
);
if (!fetchedRoot) {
throw new Error("Comment root not found");
@@ -868,22 +869,95 @@ export class Nip22Adapter extends ChatProtocolAdapter {
}
/**
* Helper: Fetch event by EventPointer
* Helper: Extract relay hints from comment event tags
* NIP-22 comments (kind 1111) may have relay hints in A/E tags
*/
private extractRelayHintsFromComment(
comment: NostrEvent,
targetEventId?: string,
targetCoordinate?: string,
): string[] {
const relays: string[] = [];
// Look for A-tag (addressable root) with relay hint
if (targetCoordinate) {
for (const tag of comment.tags) {
if (
(tag[0] === "A" || tag[0] === "a") &&
tag[1] === targetCoordinate &&
tag[2]
) {
relays.push(tag[2]);
}
}
}
// Look for E-tag (regular event root) with relay hint
if (targetEventId) {
for (const tag of comment.tags) {
if (
(tag[0] === "E" || tag[0] === "e") &&
tag[1] === targetEventId &&
tag[2]
) {
relays.push(tag[2]);
}
}
}
return relays;
}
/**
* Helper: Fetch event by EventPointer with improved relay selection
* Uses: relay hints from pointer, relay hints from comment tags, author's outbox relays
*/
private async fetchEventByPointer(
pointer: EventPointer,
additionalHints: string[] = [],
commentEvent?: NostrEvent,
): Promise<NostrEvent | null> {
const relayHints = mergeRelaySets(pointer.relays || [], additionalHints);
return this.fetchEvent({ id: pointer.id, kind: pointer.kind }, relayHints);
// Merge relay hints from multiple sources
const pointerHints = pointer.relays || [];
const commentHints = commentEvent
? this.extractRelayHintsFromComment(commentEvent, pointer.id)
: [];
// Get author's outbox relays if we know the author
let authorOutbox: string[] = [];
if (pointer.author) {
try {
const relayListEvent = await firstValueFrom(
eventStore.replaceable(10002, pointer.author, ""),
{ defaultValue: undefined },
);
if (relayListEvent) {
authorOutbox = getOutboxes(relayListEvent);
}
} catch {
// Ignore errors fetching relay list
}
}
// Merge all hints (prioritize pointer hints, then comment hints, then author outbox)
const allHints = mergeRelaySets(
pointerHints,
commentHints,
authorOutbox.slice(0, 3), // Limit outbox to 3 relays
additionalHints,
);
return this.fetchEvent({ id: pointer.id, kind: pointer.kind }, allHints);
}
/**
* Helper: Fetch addressable event by AddressPointer
* Helper: Fetch addressable event by AddressPointer with improved relay selection
* Uses: relay hints from pointer, relay hints from comment tags, author's outbox relays
*/
private async fetchAddressableEvent(
pointer: AddressPointer,
additionalHints: string[] = [],
commentEvent?: NostrEvent,
): Promise<NostrEvent | null> {
const { kind, pubkey, identifier } = pointer;
@@ -894,10 +968,37 @@ export class Nip22Adapter extends ChatProtocolAdapter {
);
if (cached) return cached;
// Not in store - fetch from relays
const relayHints = mergeRelaySets(pointer.relays || [], additionalHints);
// Merge relay hints from multiple sources
const pointerHints = pointer.relays || [];
const coordinate = `${kind}:${pubkey}:${identifier}`;
const commentHints = commentEvent
? this.extractRelayHintsFromComment(commentEvent, undefined, coordinate)
: [];
// Get author's outbox relays
let authorOutbox: string[] = [];
try {
const relayListEvent = await firstValueFrom(
eventStore.replaceable(10002, pubkey, ""),
{ defaultValue: undefined },
);
if (relayListEvent) {
authorOutbox = getOutboxes(relayListEvent);
}
} catch {
// Ignore errors fetching relay list
}
// Merge all hints (prioritize pointer hints, then comment hints, then author outbox)
const allHints = mergeRelaySets(
pointerHints,
commentHints,
authorOutbox.slice(0, 3), // Limit outbox to 3 relays
additionalHints,
);
const relays =
relayHints.length > 0 ? relayHints : await this.getDefaultRelays();
allHints.length > 0 ? allHints : await this.getDefaultRelays();
const filter: Filter = {
kinds: [kind],