mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-06-16 17:48:34 +02:00
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:
@@ -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">
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user