feat: improve NIP-17 chat UX and inbox functionality

- Remove "load older messages" button from NIP-17 chats (messages stored locally)
- Show "Saved Messages" for self-conversations, UserName for others
- Hide member list dropdown for self-conversations
- Fix inbox item click to properly open NIP-17 chat using addWindow()
- Add lock icon to NIP-17 heading indicating end-to-end encryption
- Add InboxRelaysDropdown showing participants' NIP-10050 relays
- Replace RelaysDropdown with InboxRelaysDropdown for NIP-17 conversations
This commit is contained in:
Claude
2026-01-20 11:49:02 +00:00
parent 5a19a695de
commit 58bdb3216f
3 changed files with 227 additions and 12 deletions

View File

@@ -12,6 +12,7 @@ import {
Copy,
CopyCheck,
FileText,
Lock,
} from "lucide-react";
import { nip19 } from "nostr-tools";
import { getZapRequest } from "applesauce-common/helpers/zap";
@@ -39,6 +40,7 @@ import Timestamp from "./Timestamp";
import { ReplyPreview } from "./chat/ReplyPreview";
import { MembersDropdown } from "./chat/MembersDropdown";
import { RelaysDropdown } from "./chat/RelaysDropdown";
import { InboxRelaysDropdown } from "./chat/InboxRelaysDropdown";
import { MessageReactions } from "./chat/MessageReactions";
import { StatusBadge } from "./live/StatusBadge";
import { ChatMessageContextMenu } from "./chat/ChatMessageContextMenu";
@@ -871,7 +873,33 @@ export function ChatViewer({
className="text-sm font-semibold truncate cursor-help text-left"
onClick={() => setTooltipOpen(!tooltipOpen)}
>
{customTitle || conversation.title}
{customTitle ||
(() => {
// For NIP-17, show "Saved Messages" or participant name
if (
protocol === "nip-17" &&
conversation.participants.length === 2
) {
const [p1, p2] = conversation.participants;
const isSelfConversation = p1.pubkey === p2.pubkey;
if (isSelfConversation) {
return "Saved Messages";
}
// Show the other participant's name
const otherPubkey =
p1.pubkey === pubkey ? p2.pubkey : p1.pubkey;
return (
<UserName
pubkey={otherPubkey}
className="text-sm font-semibold"
/>
);
}
return conversation.title;
})()}
</button>
</TooltipTrigger>
<TooltipContent
@@ -974,8 +1002,28 @@ export function ChatViewer({
)}
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground p-1">
<MembersDropdown participants={derivedParticipants} />
<RelaysDropdown conversation={conversation} />
{/* Hide member list for self-conversations (Saved Messages) */}
{!(
protocol === "nip-17" &&
conversation.participants.length === 2 &&
conversation.participants[0].pubkey ===
conversation.participants[1].pubkey
) && <MembersDropdown participants={derivedParticipants} />}
{/* Show inbox relays for NIP-17, regular relays for other protocols */}
{protocol === "nip-17" ? (
<InboxRelaysDropdown conversation={conversation} />
) : (
<RelaysDropdown conversation={conversation} />
)}
{/* Show lock icon for encrypted conversations */}
{protocol === "nip-17" && (
<div
className="text-muted-foreground"
title="End-to-end encrypted"
>
<Lock className="size-3" />
</div>
)}
<button
onClick={handleNipClick}
className="rounded bg-muted px-1.5 py-0.5 font-mono hover:bg-muted/80 transition-colors cursor-pointer"
@@ -999,7 +1047,8 @@ export function ChatViewer({
Header: () =>
hasMore &&
conversationResult.status === "success" &&
protocol !== "nip-10" ? (
protocol !== "nip-10" &&
protocol !== "nip-17" ? (
<div className="flex justify-center py-2">
<Button
onClick={handleLoadOlder}

View File

@@ -54,7 +54,7 @@ type InboxViewerProps = Record<string, never>;
const CONVERSATIONS_PAGE_SIZE = 50;
export function InboxViewer(_props: InboxViewerProps) {
const { state, updateGiftWrapSettings } = useGrimoire();
const { state, updateGiftWrapSettings, addWindow } = useGrimoire();
const { pubkey } = useAccount();
const stats = useGiftWrapStats();
const conversations = useGiftWrapConversations();
@@ -149,13 +149,14 @@ export function InboxViewer(_props: InboxViewerProps) {
_conversationKey: string,
otherPubkey: string,
) => {
// Open chat window with the other participant
const npub = nip19.npubEncode(otherPubkey);
window.dispatchEvent(
new CustomEvent("grimoire:execute-command", {
detail: `chat ${npub}`,
}),
);
// Open chat window with the other participant using NIP-17
addWindow("chat", {
protocol: "nip-17",
identifier: {
type: "dm-recipient",
value: otherPubkey,
},
});
};
const [isLoadingMore, setIsLoadingMore] = useState(false);

View File

@@ -0,0 +1,165 @@
/**
* InboxRelaysDropdown - Shows DM inbox relays (kind 10050) for NIP-17 participants
* Displays each participant's private inbox relays with connection status
*/
import { useMemo } from "react";
import { use$ } from "applesauce-react/hooks";
import { Inbox } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { RelayLink } from "@/components/nostr/RelayLink";
import { UserName } from "@/components/nostr/UserName";
import { useRelayState } from "@/hooks/useRelayState";
import { getConnectionIcon, getAuthIcon } from "@/lib/relay-status-utils";
import eventStore from "@/services/event-store";
import type { Conversation } from "@/types/chat";
interface InboxRelaysDropdownProps {
conversation: Conversation;
}
/**
* InboxRelaysDropdown - Shows inbox relays for NIP-17 conversation participants
*/
export function InboxRelaysDropdown({
conversation,
}: InboxRelaysDropdownProps) {
const { relays: relayStates } = useRelayState();
// Get all participant pubkeys
const participantPubkeys = useMemo(() => {
return conversation.participants.map((p) => p.pubkey);
}, [conversation.participants]);
// Fetch kind 10050 events for all participants
const inboxRelayEvents = use$(() => {
const observables = participantPubkeys.map((pubkey) =>
eventStore.replaceable(10050, pubkey, ""),
);
return observables;
}, [participantPubkeys]);
// Extract relays from events
const participantRelays = useMemo(() => {
if (!inboxRelayEvents) return [];
return participantPubkeys
.map((pubkey, index) => {
const event = inboxRelayEvents[index];
if (!event) return null;
const relays = event.tags
.filter((t: string[]) => t[0] === "relay" && t[1])
.map((t: string[]) => t[1]);
return {
pubkey,
relays,
};
})
.filter(
(p): p is { pubkey: string; relays: string[] } =>
p !== null && p.relays.length > 0,
);
}, [inboxRelayEvents, participantPubkeys]);
// Count total relays and connected relays
const { totalRelays, connectedCount } = useMemo(() => {
let total = 0;
let connected = 0;
for (const participant of participantRelays) {
for (const relay of participant.relays) {
total++;
const state = relayStates[relay];
if (state?.connectionState === "connected") {
connected++;
}
}
}
return { totalRelays: total, connectedCount: connected };
}, [participantRelays, relayStates]);
// Don't show if no inbox relays found
if (participantRelays.length === 0) {
return null;
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="flex items-center gap-1 text-muted-foreground hover:text-foreground transition-colors">
<Inbox className="size-3" />
<span>
{connectedCount}/{totalRelays}
</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="w-96 max-h-96 overflow-y-auto"
>
<div className="px-3 py-2 border-b border-border">
<div className="text-xs font-semibold text-muted-foreground">
Inbox Relays (NIP-10050)
</div>
</div>
<div className="p-2 space-y-3">
{participantRelays.map(({ pubkey, relays }) => (
<div key={pubkey}>
<div className="px-2 py-1 text-xs font-medium">
<UserName pubkey={pubkey} className="text-xs" />
</div>
<div className="space-y-1">
{relays.map((relay) => {
const state = relayStates[relay];
const connIcon = getConnectionIcon(state);
const authIcon = getAuthIcon(state);
return (
<div
key={relay}
className="flex items-center justify-between gap-2 p-1.5 rounded hover:bg-muted/50"
>
<div className="flex-1 min-w-0">
<RelayLink url={relay} showInboxOutbox={false} />
</div>
<div className="flex items-center gap-1.5 shrink-0">
<Tooltip>
<TooltipTrigger asChild>
<div className="cursor-help">{connIcon.icon}</div>
</TooltipTrigger>
<TooltipContent>
<p>{connIcon.label}</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<div className="cursor-help">{authIcon.icon}</div>
</TooltipTrigger>
<TooltipContent>
<p>{authIcon.label}</p>
</TooltipContent>
</Tooltip>
</div>
</div>
);
})}
</div>
</div>
))}
</div>
</DropdownMenuContent>
</DropdownMenu>
);
}