feat: add useConversations hook

This commit is contained in:
2026-07-18 00:59:16 +05:30
parent 256a5dcea0
commit 0ffe21a0c2
2 changed files with 94 additions and 0 deletions
@@ -0,0 +1,49 @@
import { describe, it, expect, vi } from 'vitest';
import { renderHook, waitFor, act } from '@testing-library/react';
import type { ReactNode } from 'react';
import { MessagingProvider } from '../provider';
import { MockAdapter } from '../adapters/mock';
import { useConversations } from './use-conversations';
import type { MessagingAdapter } from '../adapter';
const wrap = (adapter: MessagingAdapter) =>
function Wrapper({ children }: { children: ReactNode }) {
return <MessagingProvider adapter={adapter}>{children}</MessagingProvider>;
};
describe('useConversations', () => {
it('starts loading, then resolves the adapter list', async () => {
const { result } = renderHook(() => useConversations(), { wrapper: wrap(new MockAdapter()) });
expect(result.current.loading).toBe(true);
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.conversations).toHaveLength(2);
expect(result.current.conversations[0]!.threadId).toBe('th_mock_1');
expect(result.current.error).toBeNull();
});
it('surfaces adapter failure as error state and never throws', async () => {
const adapter = new MockAdapter();
vi.spyOn(adapter, 'listConversations').mockRejectedValue(new Error('data door down'));
const { result } = renderHook(() => useConversations(), { wrapper: wrap(adapter) });
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.error).toBe('data door down');
expect(result.current.conversations).toEqual([]);
});
it('refetch picks up newly opened threads', async () => {
const adapter = new MockAdapter();
const { result } = renderHook(() => useConversations(), { wrapper: wrap(adapter) });
await waitFor(() => expect(result.current.conversations).toHaveLength(2));
await act(async () => {
await adapter.openThread({ participantIds: ['pp_dan'] });
});
await act(async () => {
result.current.refetch();
});
await waitFor(() => expect(result.current.conversations).toHaveLength(3));
});
});
@@ -0,0 +1,45 @@
import { useCallback, useEffect, useState } from 'react';
import { useAdapter } from '../provider';
import type { Conversation } from '../types';
export interface ConversationsState {
conversations: Conversation[];
loading: boolean;
error: string | null;
refetch: () => void;
}
export function useConversations(): ConversationsState {
const adapter = useAdapter();
const [conversations, setConversations] = useState<Conversation[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [nonce, setNonce] = useState(0);
useEffect(() => {
let alive = true;
setLoading(true);
adapter
.listConversations()
.then((list) => {
if (!alive) return;
setConversations(list);
setError(null);
})
.catch((e: unknown) => {
if (!alive) return;
setError(e instanceof Error ? e.message : String(e));
setConversations([]);
})
.finally(() => {
if (alive) setLoading(false);
});
return () => {
alive = false;
};
}, [adapter, nonce]);
const refetch = useCallback(() => setNonce((n) => n + 1), []);
return { conversations, loading, error, refetch };
}