diff --git a/packages/iios-messaging-ui/src/hooks/use-conversations.test.tsx b/packages/iios-messaging-ui/src/hooks/use-conversations.test.tsx new file mode 100644 index 0000000..0970680 --- /dev/null +++ b/packages/iios-messaging-ui/src/hooks/use-conversations.test.tsx @@ -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 {children}; + }; + +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)); + }); +}); diff --git a/packages/iios-messaging-ui/src/hooks/use-conversations.ts b/packages/iios-messaging-ui/src/hooks/use-conversations.ts new file mode 100644 index 0000000..f8507eb --- /dev/null +++ b/packages/iios-messaging-ui/src/hooks/use-conversations.ts @@ -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([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 }; +}