Replace Chat compose dead-end spinners with bounded network requests and stable retry/back error recovery
Hop-State: A_06FPC4H813DQ7CC2FH8EW90 Hop-Proposal: R_06FPC4GJZVS16ZX6G5Y1N08 Hop-Task: T_06FPC3561KZXPYN0FRX2B70 Hop-Attempt: AT_06FPC3561JKBTYMC4AQVS1R
This commit is contained in:
+114
-25
@@ -67,6 +67,13 @@ type ConversationEncryptionState =
|
|||||||
}
|
}
|
||||||
| { status: 'error'; conversationKey: string; code: string; message: string };
|
| { status: 'error'; conversationKey: string; code: string; message: string };
|
||||||
|
|
||||||
|
interface ComposeIntentError {
|
||||||
|
handle: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CHAT_REQUEST_TIMEOUT_MS = 15_000;
|
||||||
|
|
||||||
interface PreparedSend {
|
interface PreparedSend {
|
||||||
accountDid: string;
|
accountDid: string;
|
||||||
conversationKey: string;
|
conversationKey: string;
|
||||||
@@ -106,6 +113,9 @@ export default function ChatPage() {
|
|||||||
const [sendError, setSendError] = useState<string | null>(null);
|
const [sendError, setSendError] = useState<string | null>(null);
|
||||||
const [conversationsError, setConversationsError] = useState<string | null>(null);
|
const [conversationsError, setConversationsError] = useState<string | null>(null);
|
||||||
const [messagesError, setMessagesError] = useState<string | null>(null);
|
const [messagesError, setMessagesError] = useState<string | null>(null);
|
||||||
|
const [composeIntentError, setComposeIntentError] = useState<ComposeIntentError | null>(null);
|
||||||
|
const [dismissedComposeHandle, setDismissedComposeHandle] = useState<string | null>(null);
|
||||||
|
const [composeRetryVersion, setComposeRetryVersion] = useState(0);
|
||||||
const [conversationEncryption, setConversationEncryption] = useState<ConversationEncryptionState>({ status: 'idle' });
|
const [conversationEncryption, setConversationEncryption] = useState<ConversationEncryptionState>({ status: 'idle' });
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [loadingMessages, setLoadingMessages] = useState(false);
|
const [loadingMessages, setLoadingMessages] = useState(false);
|
||||||
@@ -192,12 +202,14 @@ export default function ChatPage() {
|
|||||||
const loadConversations = useCallback(async (isInitialLoad = true) => {
|
const loadConversations = useCallback(async (isInitialLoad = true) => {
|
||||||
const requestId = ++conversationsRequestRef.current;
|
const requestId = ++conversationsRequestRef.current;
|
||||||
const requestAccountDid = user?.did ?? null;
|
const requestAccountDid = user?.did ?? null;
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = window.setTimeout(() => controller.abort(), CHAT_REQUEST_TIMEOUT_MS);
|
||||||
try {
|
try {
|
||||||
if (isInitialLoad) {
|
if (isInitialLoad) {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setConversationsError(null);
|
setConversationsError(null);
|
||||||
}
|
}
|
||||||
const res = await fetch('/api/swarm/chat/conversations');
|
const res = await fetch('/api/swarm/chat/conversations', { signal: controller.signal });
|
||||||
if (!res.ok) throw new Error('Conversations could not be loaded');
|
if (!res.ok) throw new Error('Conversations could not be loaded');
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
let nextConversations = (Array.isArray(data.conversations) ? data.conversations : []) as Conversation[];
|
let nextConversations = (Array.isArray(data.conversations) ? data.conversations : []) as Conversation[];
|
||||||
@@ -246,9 +258,14 @@ export default function ChatPage() {
|
|||||||
console.error("Failed to load conversations", e);
|
console.error("Failed to load conversations", e);
|
||||||
if (requestId === conversationsRequestRef.current
|
if (requestId === conversationsRequestRef.current
|
||||||
&& renderedAccountDidRef.current === requestAccountDid) {
|
&& renderedAccountDidRef.current === requestAccountDid) {
|
||||||
setConversationsError(e instanceof Error ? e.message : 'Conversations could not be loaded');
|
setConversationsError(controller.signal.aborted
|
||||||
|
? 'The node took too long to load conversations. Try again.'
|
||||||
|
: e instanceof TypeError
|
||||||
|
? 'The connection to the node was interrupted. Try again.'
|
||||||
|
: e instanceof Error ? e.message : 'Conversations could not be loaded');
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
window.clearTimeout(timeout);
|
||||||
if (requestId === conversationsRequestRef.current
|
if (requestId === conversationsRequestRef.current
|
||||||
&& renderedAccountDidRef.current === requestAccountDid) {
|
&& renderedAccountDidRef.current === requestAccountDid) {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -613,6 +630,9 @@ export default function ChatPage() {
|
|||||||
setSendError(null);
|
setSendError(null);
|
||||||
setConversationsError(null);
|
setConversationsError(null);
|
||||||
setMessagesError(null);
|
setMessagesError(null);
|
||||||
|
setComposeIntentError(null);
|
||||||
|
setDismissedComposeHandle(null);
|
||||||
|
setComposeRetryVersion(0);
|
||||||
setSending(false);
|
setSending(false);
|
||||||
setIsAtBottom(true);
|
setIsAtBottom(true);
|
||||||
setConversationEncryption({ status: 'idle' });
|
setConversationEncryption({ status: 'idle' });
|
||||||
@@ -636,30 +656,49 @@ export default function ChatPage() {
|
|||||||
|
|
||||||
// Handle Compose Intent
|
// Handle Compose Intent
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (composeHandle && !selectedConversation && conversations.length >= 0) {
|
if (!composeHandle || selectedConversation || loading || dismissedComposeHandle === composeHandle) return;
|
||||||
// Check if we already have a conversation with this user
|
|
||||||
|
// Check if we already have a conversation with this user.
|
||||||
const existing = conversations.find(c =>
|
const existing = conversations.find(c =>
|
||||||
c.participant2.handle.toLowerCase() === composeHandle.toLowerCase()
|
c.participant2.handle.toLowerCase() === composeHandle.toLowerCase()
|
||||||
);
|
);
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
|
setComposeIntentError(null);
|
||||||
selectConversation(existing);
|
selectConversation(existing);
|
||||||
// Clear the query param so refresh doesn't keep resetting state
|
// Clear the query param so refresh doesn't keep resetting state.
|
||||||
router.replace('/chat', { scroll: false });
|
router.replace('/chat', { scroll: false });
|
||||||
} else if (!loading) {
|
return;
|
||||||
// Fetch user details to create a draft conversation
|
}
|
||||||
const fetchUserAndInitDraft = async () => {
|
|
||||||
|
// Keep a failed intent stable until the user retries. Conversation
|
||||||
|
// polling replaces the array every few seconds and must not turn the
|
||||||
|
// visible error back into a spinner on each successful poll.
|
||||||
|
if (composeIntentError?.handle === composeHandle) return;
|
||||||
|
|
||||||
const requestId = ++composeRequestRef.current;
|
const requestId = ++composeRequestRef.current;
|
||||||
const requestAccountDid = renderedAccountDidRef.current;
|
const requestAccountDid = renderedAccountDidRef.current;
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = window.setTimeout(() => controller.abort(), CHAT_REQUEST_TIMEOUT_MS);
|
||||||
|
setComposeIntentError(null);
|
||||||
|
|
||||||
|
// Fetch user details to create a draft conversation. A transient node
|
||||||
|
// restart must surface a retry action instead of leaving the compose
|
||||||
|
// route behind an unconditional full-page spinner.
|
||||||
|
const fetchUserAndInitDraft = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/users/${encodeURIComponent(composeHandle)}`);
|
const res = await fetch(`/api/users/${encodeURIComponent(composeHandle)}`, {
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`Account lookup failed (${res.status})`);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (requestId !== composeRequestRef.current
|
if (requestId !== composeRequestRef.current
|
||||||
|| renderedAccountDidRef.current !== requestAccountDid) return;
|
|| renderedAccountDidRef.current !== requestAccountDid) return;
|
||||||
if (data.user) {
|
if (data.user) {
|
||||||
if (data.user.isBot || data.user.canReceiveDms === false) {
|
if (data.user.isBot || data.user.canReceiveDms === false) {
|
||||||
console.error('Cannot DM this account due to privacy settings');
|
setComposeIntentError({
|
||||||
router.replace('/chat');
|
handle: composeHandle,
|
||||||
|
message: 'This account cannot receive direct messages.',
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const draftConv: Conversation = {
|
const draftConv: Conversation = {
|
||||||
@@ -677,24 +716,33 @@ export default function ChatPage() {
|
|||||||
selectConversation(draftConv);
|
selectConversation(draftConv);
|
||||||
router.replace('/chat', { scroll: false });
|
router.replace('/chat', { scroll: false });
|
||||||
} else {
|
} else {
|
||||||
// User not found, clear compose param to show list
|
setComposeIntentError({
|
||||||
console.error('User not found for compose');
|
handle: composeHandle,
|
||||||
router.replace('/chat');
|
message: 'That account could not be found.',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (error) {
|
||||||
if (requestId !== composeRequestRef.current
|
if (requestId !== composeRequestRef.current
|
||||||
|| renderedAccountDidRef.current !== requestAccountDid) return;
|
|| renderedAccountDidRef.current !== requestAccountDid) return;
|
||||||
console.error("Failed to load user for compose", e);
|
console.error('Failed to load user for compose', error);
|
||||||
router.replace('/chat');
|
setComposeIntentError({
|
||||||
|
handle: composeHandle,
|
||||||
|
message: controller.signal.aborted
|
||||||
|
? 'The node took too long to respond. Try again.'
|
||||||
|
: 'The connection to the node was interrupted. Try again.',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
window.clearTimeout(timeout);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
fetchUserAndInitDraft();
|
void fetchUserAndInitDraft();
|
||||||
}
|
|
||||||
}
|
|
||||||
return () => {
|
return () => {
|
||||||
composeRequestRef.current += 1;
|
composeRequestRef.current += 1;
|
||||||
|
controller.abort();
|
||||||
|
window.clearTimeout(timeout);
|
||||||
};
|
};
|
||||||
}, [composeHandle, selectedConversation, conversations, loading, router, selectConversation]);
|
}, [composeHandle, selectedConversation, conversations, loading, router, selectConversation, composeIntentError, dismissedComposeHandle, composeRetryVersion]);
|
||||||
|
|
||||||
// Redirect if not logged in
|
// Redirect if not logged in
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -830,11 +878,52 @@ export default function ChatPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prevent flash of list view while processing compose intent
|
// Prevent a flash of the list view while processing a compose intent, but
|
||||||
if (composeHandle && !selectedConversation) {
|
// never hide a failed request behind an endless spinner.
|
||||||
|
if (composeHandle && !selectedConversation && dismissedComposeHandle !== composeHandle) {
|
||||||
|
const currentComposeError = composeIntentError?.handle === composeHandle
|
||||||
|
? composeIntentError.message
|
||||||
|
: null;
|
||||||
|
if (currentComposeError) {
|
||||||
return (
|
return (
|
||||||
<div aria-busy="true" style={{ display: 'flex', flexDirection: 'column', height: '100vh', alignItems: 'center', justifyContent: 'center' }}>
|
<main style={{ minHeight: '100vh', display: 'grid', placeItems: 'center', padding: 24 }}>
|
||||||
|
<section role="alert" style={{ width: '100%', maxWidth: 420, textAlign: 'center' }}>
|
||||||
|
<h1 style={{ fontSize: 20, marginBottom: 10 }}>Couldn't open this conversation</h1>
|
||||||
|
<p style={{ color: 'var(--foreground-secondary)', lineHeight: 1.5 }}>
|
||||||
|
{currentComposeError}
|
||||||
|
</p>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', gap: 10, marginTop: 18 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={() => {
|
||||||
|
setComposeIntentError(null);
|
||||||
|
setDismissedComposeHandle(null);
|
||||||
|
setComposeRetryVersion((version) => version + 1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Try again
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost"
|
||||||
|
onClick={() => {
|
||||||
|
setComposeIntentError(null);
|
||||||
|
setDismissedComposeHandle(composeHandle);
|
||||||
|
router.replace('/chat', { scroll: false });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Back to messages
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div aria-busy="true" style={{ display: 'flex', flexDirection: 'column', gap: 12, height: '100vh', alignItems: 'center', justifyContent: 'center', color: 'var(--foreground-secondary)' }}>
|
||||||
<Loader2 className="animate-spin" size={32} aria-label="Opening conversation" />
|
<Loader2 className="animate-spin" size={32} aria-label="Opening conversation" />
|
||||||
|
<p>Opening conversation…</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user