mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-18 11:27:04 +02:00
fix: improve chat architecture robustness and error handling (#66)
* fix: improve chat architecture robustness and error handling - Fix scroll-to-message index mismatch (was searching in wrong array) - Fix subscription memory leaks by tracking and cleaning up subscriptions - Add error handling for conversation resolution with retry UI - Add error handling for send message with toast notifications - Fix array mutation bugs in NIP-53 relay handling - Add type guards for LiveActivityMetadata - Fix RelaysDropdown O(n²) performance issue - Add loading state for send button * refactor: add stronger types and optimize message sorting - Add discriminated union types for ProtocolIdentifier (GroupIdentifier, LiveActivityIdentifier, DMIdentifier, NIP05Identifier, ChannelIdentifier) - Optimize message sorting using reverse() instead of full sort (O(n) vs O(n log n)) - Add type narrowing in adapter resolveConversation methods - Remove unused Observable import from ChatViewer --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,9 +1,10 @@
|
||||
import { useMemo, useState, memo, useCallback, useRef } from "react";
|
||||
import { useMemo, useState, memo, useCallback, useRef, useEffect } from "react";
|
||||
import { use$ } from "applesauce-react/hooks";
|
||||
import { from } from "rxjs";
|
||||
import { from, catchError, of, map } from "rxjs";
|
||||
import { Virtuoso, VirtuosoHandle } from "react-virtuoso";
|
||||
import { Loader2, Reply, Zap } from "lucide-react";
|
||||
import { Loader2, Reply, Zap, AlertTriangle, RefreshCw } from "lucide-react";
|
||||
import { getZapRequest } from "applesauce-common/helpers/zap";
|
||||
import { toast } from "sonner";
|
||||
import accountManager from "@/services/accounts";
|
||||
import eventStore from "@/services/event-store";
|
||||
import type {
|
||||
@@ -100,6 +101,28 @@ function isDifferentDay(timestamp1: number, timestamp2: number): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard for LiveActivityMetadata
|
||||
*/
|
||||
function isLiveActivityMetadata(value: unknown): value is LiveActivityMetadata {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const obj = value as Record<string, unknown>;
|
||||
return (
|
||||
typeof obj.status === "string" &&
|
||||
typeof obj.hostPubkey === "string" &&
|
||||
Array.isArray(obj.hashtags) &&
|
||||
Array.isArray(obj.relays)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Conversation resolution result - either success with conversation or error
|
||||
*/
|
||||
type ConversationResult =
|
||||
| { status: "loading" }
|
||||
| { status: "success"; conversation: Conversation }
|
||||
| { status: "error"; error: string };
|
||||
|
||||
/**
|
||||
* ComposerReplyPreview - Shows who is being replied to in the composer
|
||||
*/
|
||||
@@ -308,12 +331,47 @@ export function ChatViewer({
|
||||
// Get the appropriate adapter for this protocol
|
||||
const adapter = useMemo(() => getAdapter(protocol), [protocol]);
|
||||
|
||||
// Resolve conversation from identifier (async operation)
|
||||
const conversation = use$(
|
||||
() => from(adapter.resolveConversation(identifier)),
|
||||
[adapter, identifier],
|
||||
// State for retry trigger
|
||||
const [retryCount, setRetryCount] = useState(0);
|
||||
|
||||
// Resolve conversation from identifier with error handling
|
||||
const conversationResult = use$(
|
||||
() =>
|
||||
from(adapter.resolveConversation(identifier)).pipe(
|
||||
map(
|
||||
(conv): ConversationResult => ({
|
||||
status: "success",
|
||||
conversation: conv,
|
||||
}),
|
||||
),
|
||||
catchError((err) => {
|
||||
console.error("[Chat] Failed to resolve conversation:", err);
|
||||
const errorMessage =
|
||||
err instanceof Error ? err.message : "Failed to load conversation";
|
||||
return of<ConversationResult>({
|
||||
status: "error",
|
||||
error: errorMessage,
|
||||
});
|
||||
}),
|
||||
),
|
||||
[adapter, identifier, retryCount],
|
||||
);
|
||||
|
||||
// Extract conversation from result (null while loading or on error)
|
||||
const conversation =
|
||||
conversationResult?.status === "success"
|
||||
? conversationResult.conversation
|
||||
: null;
|
||||
|
||||
// Cleanup subscriptions when conversation changes or component unmounts
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (conversation) {
|
||||
adapter.cleanup(conversation.id);
|
||||
}
|
||||
};
|
||||
}, [adapter, conversation]);
|
||||
|
||||
// Load messages for this conversation (reactive)
|
||||
const messages = use$(
|
||||
() => (conversation ? adapter.loadMessages(conversation) : undefined),
|
||||
@@ -368,18 +426,33 @@ export function ChatViewer({
|
||||
// Ref to MentionEditor for programmatic submission
|
||||
const editorRef = useRef<MentionEditorHandle>(null);
|
||||
|
||||
// Handle sending messages
|
||||
// State for send in progress (prevents double-sends)
|
||||
const [isSending, setIsSending] = useState(false);
|
||||
|
||||
// Handle sending messages with error handling
|
||||
const handleSend = async (
|
||||
content: string,
|
||||
replyToId?: string,
|
||||
emojiTags?: EmojiTag[],
|
||||
) => {
|
||||
if (!conversation || !hasActiveAccount) return;
|
||||
await adapter.sendMessage(conversation, content, {
|
||||
replyTo: replyToId,
|
||||
emojiTags,
|
||||
});
|
||||
setReplyTo(undefined); // Clear reply context after sending
|
||||
if (!conversation || !hasActiveAccount || isSending) return;
|
||||
|
||||
setIsSending(true);
|
||||
try {
|
||||
await adapter.sendMessage(conversation, content, {
|
||||
replyTo: replyToId,
|
||||
emojiTags,
|
||||
});
|
||||
setReplyTo(undefined); // Clear reply context only on success
|
||||
} catch (error) {
|
||||
console.error("[Chat] Failed to send message:", error);
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "Failed to send message";
|
||||
toast.error(errorMessage);
|
||||
// Don't clear replyTo so user can retry
|
||||
} finally {
|
||||
setIsSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle reply button click
|
||||
@@ -388,10 +461,14 @@ export function ChatViewer({
|
||||
}, []);
|
||||
|
||||
// Handle scroll to message (when clicking on reply preview)
|
||||
// Must search in messagesWithMarkers since that's what Virtuoso renders
|
||||
const handleScrollToMessage = useCallback(
|
||||
(messageId: string) => {
|
||||
if (!messages) return;
|
||||
const index = messages.findIndex((m) => m.id === messageId);
|
||||
if (!messagesWithMarkers) return;
|
||||
// Find index in the rendered array (which includes day markers)
|
||||
const index = messagesWithMarkers.findIndex(
|
||||
(item) => item.type === "message" && item.data.id === messageId,
|
||||
);
|
||||
if (index !== -1 && virtuosoRef.current) {
|
||||
virtuosoRef.current.scrollToIndex({
|
||||
index,
|
||||
@@ -400,7 +477,7 @@ export function ChatViewer({
|
||||
});
|
||||
}
|
||||
},
|
||||
[messages],
|
||||
[messagesWithMarkers],
|
||||
);
|
||||
|
||||
// Handle loading older messages
|
||||
@@ -438,10 +515,12 @@ export function ChatViewer({
|
||||
}
|
||||
}, [conversation?.protocol, addWindow]);
|
||||
|
||||
// Get live activity metadata if this is a NIP-53 chat
|
||||
const liveActivity = conversation?.metadata?.liveActivity as
|
||||
| LiveActivityMetadata
|
||||
| undefined;
|
||||
// Get live activity metadata if this is a NIP-53 chat (with type guard)
|
||||
const liveActivity = isLiveActivityMetadata(
|
||||
conversation?.metadata?.liveActivity,
|
||||
)
|
||||
? conversation?.metadata?.liveActivity
|
||||
: undefined;
|
||||
|
||||
// Derive participants from messages for live activities (unique pubkeys who have chatted)
|
||||
const derivedParticipants = useMemo(() => {
|
||||
@@ -474,14 +553,40 @@ export function ChatViewer({
|
||||
liveActivity?.hostPubkey,
|
||||
]);
|
||||
|
||||
if (!conversation) {
|
||||
// Handle loading state
|
||||
if (!conversationResult || conversationResult.status === "loading") {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-muted-foreground">
|
||||
Loading conversation...
|
||||
<div className="flex h-full flex-col items-center justify-center gap-2 text-muted-foreground">
|
||||
<Loader2 className="size-6 animate-spin" />
|
||||
<span>Loading conversation...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Handle error state with retry option
|
||||
if (conversationResult.status === "error") {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 text-muted-foreground p-4">
|
||||
<AlertTriangle className="size-8 text-destructive" />
|
||||
<span className="text-center text-sm">{conversationResult.error}</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setRetryCount((c) => c + 1)}
|
||||
className="gap-2"
|
||||
>
|
||||
<RefreshCw className="size-3" />
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// At this point conversation is guaranteed to exist
|
||||
if (!conversation) {
|
||||
return null; // Should never happen, but satisfies TypeScript
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Header with conversation info and controls */}
|
||||
@@ -675,11 +780,12 @@ export function ChatViewer({
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="flex-shrink-0 h-[2.5rem]"
|
||||
disabled={isSending}
|
||||
onClick={() => {
|
||||
editorRef.current?.submit();
|
||||
}}
|
||||
>
|
||||
Send
|
||||
{isSending ? <Loader2 className="size-4 animate-spin" /> : "Send"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -21,35 +21,34 @@ interface RelaysDropdownProps {
|
||||
export function RelaysDropdown({ conversation }: RelaysDropdownProps) {
|
||||
const { relays: relayStates } = useRelayState();
|
||||
|
||||
// Get relays for this conversation
|
||||
const relays: string[] = [];
|
||||
// Get relays for this conversation (immutable pattern)
|
||||
const liveActivityRelays = conversation.metadata?.liveActivity?.relays;
|
||||
const relays: string[] =
|
||||
Array.isArray(liveActivityRelays) && liveActivityRelays.length > 0
|
||||
? liveActivityRelays
|
||||
: conversation.metadata?.relayUrl
|
||||
? [conversation.metadata.relayUrl]
|
||||
: [];
|
||||
|
||||
// NIP-53: Multiple relays from liveActivity
|
||||
const liveActivityRelays = conversation.metadata?.liveActivity?.relays as
|
||||
| string[]
|
||||
| undefined;
|
||||
if (liveActivityRelays?.length) {
|
||||
relays.push(...liveActivityRelays);
|
||||
}
|
||||
// NIP-29: Single group relay (fallback)
|
||||
else if (conversation.metadata?.relayUrl) {
|
||||
relays.push(conversation.metadata.relayUrl);
|
||||
}
|
||||
|
||||
// Normalize URLs for state lookup
|
||||
const normalizedRelays = relays.map((url) => {
|
||||
// Pre-compute normalized URLs and state lookups in a single pass (O(n))
|
||||
const relayData = relays.map((url) => {
|
||||
let normalizedUrl: string;
|
||||
try {
|
||||
return normalizeRelayURL(url);
|
||||
normalizedUrl = normalizeRelayURL(url);
|
||||
} catch {
|
||||
return url;
|
||||
normalizedUrl = url;
|
||||
}
|
||||
const state = relayStates[normalizedUrl];
|
||||
return {
|
||||
url,
|
||||
normalizedUrl,
|
||||
state,
|
||||
isConnected: state?.connectionState === "connected",
|
||||
};
|
||||
});
|
||||
|
||||
// Count connected relays
|
||||
const connectedCount = normalizedRelays.filter((url) => {
|
||||
const state = relayStates[url];
|
||||
return state?.connectionState === "connected";
|
||||
}).length;
|
||||
const connectedCount = relayData.filter((r) => r.isConnected).length;
|
||||
|
||||
if (relays.length === 0) {
|
||||
return null; // Don't show if no relays
|
||||
@@ -70,9 +69,7 @@ export function RelaysDropdown({ conversation }: RelaysDropdownProps) {
|
||||
Relays ({relays.length})
|
||||
</div>
|
||||
<div className="space-y-1 p-1">
|
||||
{relays.map((url) => {
|
||||
const normalizedUrl = normalizedRelays[relays.indexOf(url)];
|
||||
const state = relayStates[normalizedUrl];
|
||||
{relayData.map(({ url, state }) => {
|
||||
const connIcon = getConnectionIcon(state);
|
||||
const authIcon = getAuthIcon(state);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user