From 04c5aa571fc4ab1f5c6f52fa5e3d2d260f61e9c3 Mon Sep 17 00:00:00 2001 From: TurtleWolfe Date: Sat, 6 Jun 2026 12:10:26 +0000 Subject: [PATCH] feat(messaging): #35 live-update the pending-connections badge via realtime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit useConnections only fetched on mount, so the Connections-tab pending badge (UnifiedSidebar → ConnectionManager) went stale until a remount or manual action. Add a Supabase realtime subscription to user_connections (already in the supabase_realtime publication with REPLICA IDENTITY FULL) that refetches on change, debounced 1s to avoid re-render cascades — mirroring the proven ConversationList/useConversationList pattern, with proper removeChannel cleanup. Scope: just the badge-latency half of #35. The "mobile drawer transition polish" half is left out (subjective, no defect/acceptance criteria), and the unread-badge debounce is intentionally untouched (documented regression risk). Tests: subscribes to user_connections on mount, debounced refetch on a change event, channel removed on unmount. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/hooks/useConnections.test.ts | 98 ++++++++++++++++++++++++++++++++ src/hooks/useConnections.ts | 44 ++++++++++++++ 2 files changed, 142 insertions(+) create mode 100644 src/hooks/useConnections.test.ts diff --git a/src/hooks/useConnections.test.ts b/src/hooks/useConnections.test.ts new file mode 100644 index 00000000..30503e05 --- /dev/null +++ b/src/hooks/useConnections.test.ts @@ -0,0 +1,98 @@ +/** + * Unit tests for useConnections — focuses on the #35 realtime subscription + * that keeps the pending-connections badge live. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, waitFor, act } from '@testing-library/react'; + +vi.mock('@/lib/logger/logger', () => ({ + createLogger: () => ({ + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }), +})); + +const emptyList = { + pending_sent: [], + pending_received: [], + accepted: [], + blocked: [], +}; +const getConnections = vi.fn(); +vi.mock('@/services/messaging/connection-service', () => ({ + connectionService: { + getConnections: (...a: unknown[]) => getConnections(...a), + }, +})); + +// Capture the postgres_changes handler the hook registers so we can fire it. +let changeHandler: ((payload: { eventType: string }) => void) | null = null; +const removeChannel = vi.fn(); +const mockChannel = { + on: vi.fn((_evt: string, _filter: unknown, cb: typeof changeHandler) => { + changeHandler = cb; + return mockChannel; + }), + subscribe: vi.fn(() => mockChannel), +}; +vi.mock('@/lib/supabase/client', () => ({ + createClient: () => ({ + channel: vi.fn(() => mockChannel), + removeChannel, + }), +})); + +import { useConnections } from './useConnections'; + +describe('useConnections realtime (#35)', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + changeHandler = null; + getConnections.mockResolvedValue(emptyList); + }); + + it('subscribes to user_connections changes on mount', async () => { + renderHook(() => useConnections()); + expect(mockChannel.on).toHaveBeenCalledWith( + 'postgres_changes', + expect.objectContaining({ table: 'user_connections', event: '*' }), + expect.any(Function) + ); + expect(mockChannel.subscribe).toHaveBeenCalled(); + vi.useRealTimers(); + }); + + it('refetches (debounced) when a user_connections change fires', async () => { + renderHook(() => useConnections()); + // 1 initial fetch on mount (flush the mount-effect microtask). + await act(async () => { + await Promise.resolve(); + }); + expect(getConnections).toHaveBeenCalledTimes(1); + + // Fire a realtime change → debounced 1s refetch (not immediate). + act(() => { + changeHandler?.({ eventType: 'INSERT' }); + }); + expect(getConnections).toHaveBeenCalledTimes(1); + + // Advance past the 1s debounce and flush the refetch. + await act(async () => { + vi.advanceTimersByTime(1000); + await Promise.resolve(); + }); + expect(getConnections).toHaveBeenCalledTimes(2); + vi.useRealTimers(); + }); + + it('removes the channel on unmount', () => { + const { unmount } = renderHook(() => useConnections()); + unmount(); + expect(removeChannel).toHaveBeenCalledWith(mockChannel); + vi.useRealTimers(); + }); +}); diff --git a/src/hooks/useConnections.ts b/src/hooks/useConnections.ts index 28ecd21b..7e1ca262 100644 --- a/src/hooks/useConnections.ts +++ b/src/hooks/useConnections.ts @@ -1,7 +1,11 @@ import { useState, useEffect } from 'react'; import { connectionService } from '@/services/messaging/connection-service'; +import { createClient } from '@/lib/supabase/client'; +import { createLogger } from '@/lib/logger/logger'; import type { ConnectionList } from '@/types/messaging'; +const logger = createLogger('hooks:useConnections'); + export function useConnections() { const [connections, setConnections] = useState({ pending_sent: [], @@ -93,6 +97,46 @@ export function useConnections() { fetchConnections(); }, []); + // Live-update the badge (#35): refetch on any user_connections change so the + // pending-connections count reflects accept/request/remove without a remount. + // user_connections is in the supabase_realtime publication with REPLICA + // IDENTITY FULL. Mirrors the debounced channel pattern in + // ConversationList/useConversationList.ts (1s debounce avoids re-render + // cascades from rapid-fire events); proper cleanup on unmount. + useEffect(() => { + const supabase = createClient(); + let debounceTimer: ReturnType | null = null; + const debouncedFetch = () => { + if (debounceTimer) clearTimeout(debounceTimer); + debounceTimer = setTimeout(() => fetchConnections(), 1000); + }; + + const channel = supabase + .channel('user-connections') + .on( + 'postgres_changes', + { event: '*', schema: 'public', table: 'user_connections' }, + (payload) => { + logger.debug('Realtime: user_connections change', { + event: payload.eventType, + }); + debouncedFetch(); + } + ) + .subscribe((status, err) => { + if (status === 'CHANNEL_ERROR') { + logger.error('Realtime subscription failed', { + error: err?.message, + }); + } + }); + + return () => { + if (debounceTimer) clearTimeout(debounceTimer); + supabase.removeChannel(channel); + }; + }, []); + return { connections, loading,