fix: improve NIP-17 inbox UX and chat command support

Inbox Improvements:
- Fix stats not updating properly with debounced updates (500ms)
- Add relay connected/auth status indicators in dropdown
- Display connected/total relay count (e.g., "3/5")
- Use UserName component for conversation authors
- Reduce gap between message preview and timestamp (gap-1)

Chat Command Enhancements:
- Add support for 'chat $me' to DM yourself
- Add NIP-05 resolution support (e.g., 'chat alice@example.com')
- Improve parseIdentifier to handle all formats
- Update help text with new command variations

Technical Changes:
- Add debouncedUpdateStats() to GiftWrapManager
- Subscribe to relay state changes in InboxViewer
- Import relay state manager and types
- Add connection and auth status icons (Plug, PlugZap, Lock)
- Clean up unused imports (useProfile, getDisplayName)

All tests passing (980/980). Build successful.
This commit is contained in:
Claude
2026-01-20 11:03:39 +00:00
parent eef82e7871
commit 7215ac6277
4 changed files with 151 additions and 22 deletions

View File

@@ -8,7 +8,7 @@
* - Conversation list (compact view)
*/
import { useState, useMemo } from "react";
import { useState, useMemo, useEffect } from "react";
import { use$ } from "applesauce-react/hooks";
import { nip19 } from "nostr-tools";
import { useGrimoire } from "@/core/state";
@@ -17,9 +17,7 @@ import {
useGiftWrapStats,
useGiftWrapConversations,
} from "@/hooks/useGiftWrap";
import { useProfile } from "@/hooks/useProfile";
import eventStore from "@/services/event-store";
import { getDisplayName } from "@/lib/nostr-utils";
import {
Settings,
MessageSquare,
@@ -27,10 +25,16 @@ import {
ShieldCheck,
ShieldAlert,
Loader2,
Plug,
PlugZap,
Lock,
} from "lucide-react";
import { toast } from "sonner";
import giftWrapManager from "@/services/gift-wrap";
import relayStateManager from "@/services/relay-state-manager";
import type { GlobalRelayState } from "@/types/relay-state";
import { RelayLink } from "@/components/nostr/RelayLink";
import { UserName } from "@/components/nostr/UserName";
import {
DropdownMenu,
DropdownMenuContent,
@@ -56,10 +60,24 @@ export function InboxViewer(_props: InboxViewerProps) {
const conversations = useGiftWrapConversations();
const [conversationsPage, setConversationsPage] = useState(1);
const [isLoadingOlder, setIsLoadingOlder] = useState(false);
const [relayState, setRelayState] = useState<GlobalRelayState | null>(null);
const syncEnabled = state.giftWrapSettings?.syncEnabled ?? false;
const autoDecrypt = state.giftWrapSettings?.autoDecrypt ?? false;
// Subscribe to relay state changes
useEffect(() => {
// Get initial state
setRelayState(relayStateManager.getState());
// Subscribe to updates
const unsubscribe = relayStateManager.subscribe((newState) => {
setRelayState(newState);
});
return unsubscribe;
}, []);
// Get DM relays (kind 10050)
const dmRelayEvent = use$(() => {
if (!pubkey) return undefined;
@@ -73,6 +91,15 @@ export function InboxViewer(_props: InboxViewerProps) {
.map((t: string[]) => t[1]);
}, [dmRelayEvent]);
// Calculate connected relay count
const connectedRelayCount = useMemo(() => {
if (!relayState || dmRelays.length === 0) return 0;
return dmRelays.filter((url) => {
const state = relayState.relays[url];
return state?.connectionState === "connected";
}).length;
}, [relayState, dmRelays]);
// Convert conversations map to sorted array with pagination
const { conversationsList, totalConversations, hasMoreConversations } =
useMemo(() => {
@@ -270,7 +297,9 @@ export function InboxViewer(_props: InboxViewerProps) {
<DropdownMenuTrigger asChild>
<button className="flex items-center gap-1 text-muted-foreground/80 hover:text-foreground transition-colors">
<Radio className="size-3" />
<span>{dmRelays.length}</span>
<span>
{connectedRelayCount}/{dmRelays.length}
</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-80">
@@ -278,13 +307,61 @@ export function InboxViewer(_props: InboxViewerProps) {
<DropdownMenuSeparator />
<div className="max-h-64 overflow-y-auto space-y-1 p-2">
{dmRelays.length > 0 ? (
dmRelays.map((relay) => (
<RelayLink
key={relay}
url={relay}
showInboxOutbox={false}
/>
))
dmRelays.map((relay) => {
const state = relayState?.relays[relay];
const isConnected = state?.connectionState === "connected";
const isAuth = state?.authStatus === "authenticated";
return (
<div
key={relay}
className="flex items-center justify-between gap-2"
>
<div className="flex-1 min-w-0">
<RelayLink
key={relay}
url={relay}
showInboxOutbox={false}
/>
</div>
<div className="flex items-center gap-1 shrink-0">
{/* Connection status */}
<Tooltip>
<TooltipTrigger asChild>
<div className="cursor-help">
{isConnected ? (
<Plug className="size-3 text-green-600/70" />
) : (
<PlugZap className="size-3 text-muted-foreground/50" />
)}
</div>
</TooltipTrigger>
<TooltipContent>
<p>
{isConnected
? "Connected"
: state?.connectionState || "Disconnected"}
</p>
</TooltipContent>
</Tooltip>
{/* Auth status */}
{isAuth && (
<Tooltip>
<TooltipTrigger asChild>
<div className="cursor-help">
<Lock className="size-3 text-blue-600/70" />
</div>
</TooltipTrigger>
<TooltipContent>
<p>Authenticated</p>
</TooltipContent>
</Tooltip>
)}
</div>
</div>
);
})
) : (
<p className="text-xs text-muted-foreground p-2">
No DM relays configured. Using general relays from kind
@@ -451,9 +528,6 @@ function ConversationRow({
latestMessage,
onClick,
}: ConversationRowProps) {
const profile = useProfile(otherPubkey);
const displayName = getDisplayName(otherPubkey, profile);
// Format timestamp
const timestamp = new Date(latestMessage.createdAt * 1000);
const now = new Date();
@@ -475,12 +549,12 @@ function ConversationRow({
return (
<div
onClick={onClick}
className="flex cursor-pointer items-center gap-2 border-b px-3 py-1.5 hover:bg-muted/30 last:border-b-0 font-mono text-xs"
className="flex cursor-pointer items-center gap-1 border-b px-3 py-1.5 hover:bg-muted/30 last:border-b-0 font-mono text-xs"
>
{/* Name */}
<span className="w-28 shrink-0 truncate font-medium text-muted-foreground">
{displayName}
</span>
<div className="w-28 shrink-0 truncate">
<UserName pubkey={otherPubkey} className="text-xs font-medium" />
</div>
{/* Message preview */}
<span className="min-w-0 flex-1 truncate text-muted-foreground/70">

View File

@@ -91,8 +91,10 @@ Currently supported formats:
Examples:
chat nevent1qqsxyz... (thread with relay hints)
chat note1abc... (thread with event ID only)
- npub1.../nprofile1.../hex (NIP-17 private DMs)
- $me/NIP-05/npub1.../nprofile1.../hex (NIP-17 private DMs)
Examples:
chat $me (DM with yourself)
chat alice@example.com (NIP-05 identifier)
chat npub1abc... (DM with pubkey)
chat nprofile1xyz... (DM with relay hints)
chat 1a2b3c... (DM with hex pubkey)

View File

@@ -24,6 +24,7 @@ import eventStore from "@/services/event-store";
import accountManager from "@/services/accounts";
import { publishEventToRelays } from "@/services/hub";
import type { UnsealedDM } from "@/services/db";
import { isNip05, resolveNip05 } from "@/lib/nip05";
/**
* NIP-17 Adapter - Private Direct Messages
@@ -38,13 +39,35 @@ export class Nip17Adapter extends ChatProtocolAdapter {
readonly type = "dm" as const;
/**
* Parse identifier - accepts npub, nprofile, or hex pubkey
* Parse identifier - accepts $me, NIP-05, npub, nprofile, or hex pubkey
* Examples:
* - $me (DM with yourself)
* - alice@example.com (NIP-05 identifier)
* - npub1abc... (public key)
* - nprofile1xyz... (profile with relay hints)
* - 1a2b3c... (hex pubkey)
*/
parseIdentifier(input: string): ProtocolIdentifier | null {
// Handle $me alias (DM with yourself)
if (input === "$me") {
const activePubkey = accountManager.active$.value?.pubkey;
if (!activePubkey) {
throw new Error("No active account. Log in to use $me.");
}
return {
type: "dm-recipient",
value: activePubkey,
};
}
// Try NIP-05 format (user@domain.com)
if (isNip05(input)) {
return {
type: "chat-partner-nip05",
value: input,
};
}
// Try npub format
if (input.startsWith("npub1")) {
try {
@@ -89,17 +112,30 @@ export class Nip17Adapter extends ChatProtocolAdapter {
/**
* Resolve conversation from DM identifier
* Handles both direct pubkeys and NIP-05 identifiers
*/
async resolveConversation(
identifier: ProtocolIdentifier,
): Promise<Conversation> {
if (identifier.type !== "dm-recipient") {
let recipientPubkey: string;
// Resolve NIP-05 identifier to pubkey
if (identifier.type === "chat-partner-nip05") {
const resolvedPubkey = await resolveNip05(identifier.value);
if (!resolvedPubkey) {
throw new Error(
`Failed to resolve NIP-05 identifier: ${identifier.value}`,
);
}
recipientPubkey = resolvedPubkey;
} else if (identifier.type === "dm-recipient") {
recipientPubkey = identifier.value;
} else {
throw new Error(
`NIP-17 adapter cannot handle identifier type: ${identifier.type}`,
);
}
const recipientPubkey = identifier.value;
const activePubkey = accountManager.active$.value?.pubkey;
if (!activePubkey) {

View File

@@ -61,6 +61,7 @@ class GiftWrapManager {
private isAuthenticating = false;
private authenticated = new Set<string>(); // Track which relays are authenticated
private isSyncing = false; // Prevent concurrent sync attempts
private statsUpdateTimer: NodeJS.Timeout | null = null; // Debounce stats updates
/**
* Start syncing gift wraps for the active account
@@ -610,6 +611,9 @@ class GiftWrapManager {
`[GiftWrap] Failed to decrypt ${giftWrapId.slice(0, 8)}:`,
error,
);
} finally {
// Update stats after processing (debounced to batch multiple updates)
this.debouncedUpdateStats();
}
}
@@ -781,6 +785,19 @@ class GiftWrapManager {
this.stats$.next(stats);
}
/**
* Debounced stats update - delays stats calculation to batch multiple updates
*/
private debouncedUpdateStats(): void {
if (this.statsUpdateTimer) {
clearTimeout(this.statsUpdateTimer);
}
this.statsUpdateTimer = setTimeout(() => {
this.updateStats();
this.statsUpdateTimer = null;
}, 500); // Wait 500ms after last gift wrap before updating stats
}
/**
* Get statistics observable
*/