import { type ConversationSummary, isConversationMuted, listConversations, } from '@chat-app/shared/chat'; import { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useRef, useState, } from 'react'; import { playNotificationTone } from '../lib/notificationSound'; import { notify } from '../lib/osNotify'; import { supabase } from '../lib/supabase'; import { updateTrayUnread } from '../lib/trayBadge'; import { getIsWindowFocused, subscribeWindowFocus } from '../lib/windowFocus'; import { useAuth } from './AuthContext'; const LAST_READ_STORAGE_KEY = 'chatapp.conv_last_read'; const EPOCH = new Date(0).toISOString(); type LastReadMap = Record; function loadLastReadMap(): LastReadMap { try { const raw = window.localStorage.getItem(LAST_READ_STORAGE_KEY); if (!raw) return {}; const parsed = JSON.parse(raw) as unknown; return parsed && typeof parsed === 'object' ? (parsed as LastReadMap) : {}; } catch { return {}; } } function saveLastReadMap(map: LastReadMap): void { try { window.localStorage.setItem(LAST_READ_STORAGE_KEY, JSON.stringify(map)); } catch { /* quota / private mode */ } } interface ConversationsContextValue { conversations: ConversationSummary[]; loading: boolean; error: string | null; unread: Record; totalUnread: number; refresh: () => Promise; markRead: (conversationId: string) => void; setActiveConversation: (conversationId: string | null) => void; } const ConversationsContext = createContext(null); export function ConversationsProvider({ children }: { children: ReactNode }) { const { session, profile } = useAuth(); const myId = session?.user.id; const [conversations, setConversations] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [unread, setUnread] = useState>({}); const lastReadRef = useRef(loadLastReadMap()); const activeConvIdRef = useRef(null); const presenceRef = useRef(profile?.presenceState ?? 'offline'); // Ref-mirrored window-focus state so the realtime message handler can // read it synchronously without triggering a re-render per focus flip. // "App focused" = our window is the foreground window in the OS, // which is what we actually care about when deciding whether an // arriving message was seen by the user or should ping. const isWindowFocusedRef = useRef(getIsWindowFocused()); const conversationsRef = useRef([]); conversationsRef.current = conversations; useEffect(() => { presenceRef.current = profile?.presenceState ?? 'offline'; }, [profile?.presenceState]); useEffect(() => { return subscribeWindowFocus((focused) => { isWindowFocusedRef.current = focused; // Regaining focus on the active conversation: the user is back // and looking at those messages, so flip the unread counter to // zero and persist the last-read stamp. Without this, opening // the app after being away leaves a stale badge until the user // re-clicks the conversation. if (focused) { const active = activeConvIdRef.current; if (active) { const now = new Date().toISOString(); lastReadRef.current[active] = now; saveLastReadMap(lastReadRef.current); setUnread((prev) => (prev[active] ? { ...prev, [active]: 0 } : prev)); } } }); }, []); const computeUnreadForConv = useCallback( async (convId: string): Promise => { if (!myId) return 0; const since = lastReadRef.current[convId] ?? EPOCH; const { count, error: cErr } = await supabase .from('messages') .select('id', { count: 'exact', head: true }) .eq('conversation_id', convId) .gt('created_at', since) .neq('sender_id', myId); if (cErr) return 0; return count ?? 0; }, [myId], ); const refresh = useCallback(async () => { if (!myId) { setConversations([]); setUnread({}); setLoading(false); return; } try { // Only flag loading on initial fetch so background re-syncs (visibility // change, online event) don't blank the list each time. setLoading((prev) => (conversationsRef.current.length === 0 ? true : prev)); const convs = await listConversations(supabase); setConversations(convs); const entries = await Promise.all( convs.map(async (c) => [c.id, await computeUnreadForConv(c.id)] as const), ); const next: Record = {}; for (const [id, count] of entries) { // Active conversation is always read. next[id] = id === activeConvIdRef.current ? 0 : count; } setUnread(next); setError(null); } catch (err: unknown) { setError(err instanceof Error ? err.message : 'failed to load conversations'); } finally { setLoading(false); } }, [myId, computeUnreadForConv]); const markRead = useCallback((convId: string) => { const now = new Date().toISOString(); lastReadRef.current[convId] = now; saveLastReadMap(lastReadRef.current); setUnread((prev) => (prev[convId] ? { ...prev, [convId]: 0 } : prev)); }, []); const setActiveConversation = useCallback( (convId: string | null) => { activeConvIdRef.current = convId; if (convId) markRead(convId); }, [markRead], ); useEffect(() => { if (!myId) { setConversations([]); setUnread({}); setLoading(false); return; } void refresh(); const channel = supabase .channel('conv-ctx:' + myId) .on( 'postgres_changes', { event: '*', schema: 'public', table: 'conversation_members' }, () => { void refresh(); }, ) .on('postgres_changes', { event: '*', schema: 'public', table: 'conversations' }, () => { void refresh(); }) .on( 'postgres_changes', { event: 'UPDATE', schema: 'public', table: 'profiles' }, () => { // Peer updated their profile (e.g. uploaded avatar / changed name). // Re-pull conversations so members[].profile picks up the new data. void refresh(); }, ) .on( 'postgres_changes', { event: 'INSERT', schema: 'public', table: 'messages' }, (payload: { new: Record }) => { const row = payload.new as unknown as { conversation_id: string; sender_id: string }; const fromSelf = row.sender_id === myId; const active = row.conversation_id === activeConvIdRef.current; const appFocused = isWindowFocusedRef.current; // Only suppress sound + unread bump when the user is *actually* // looking at the conversation — i.e. the app window is in the // foreground AND the active conversation matches. Having the // app parked on monitor 2 while the user is in-game still // counts as "they didn't see it." const seenByUser = active && appFocused; if (!fromSelf) { if (seenByUser) { markRead(row.conversation_id); } else { setUnread((prev) => ({ ...prev, [row.conversation_id]: (prev[row.conversation_id] ?? 0) + 1, })); // Notification sound + OS notification — respect DND and // per-conversation mute. Body stays empty because message // content is E2E-encrypted and only decryptable in the // conversation view (not at this hook level). const convForMute = conversationsRef.current.find( (c) => c.id === row.conversation_id, ); const muted = isConversationMuted(convForMute?.mutedUntil ?? null); // "Mentions only" silences non-mention messages here. Mentions // still fire via the independent useMentionNotifications // subscription on message_mentions, so this branch doesn't // lose the @-alerts. const mentionsOnly = convForMute?.mentionsOnly ?? false; if (presenceRef.current !== 'dnd' && !muted && !mentionsOnly) { playNotificationTone(); const conv = conversationsRef.current.find( (c) => c.id === row.conversation_id, ); const sender = conv?.members.find( (m) => m.userId === row.sender_id, )?.profile; const senderName = sender?.displayName ?? '…'; const title = conv?.type === 'group' ? (conv.name ?? 'Neue Nachricht') + ' · ' + senderName : senderName; void notify({ title, body: 'Neue Nachricht' }); } } } // Re-pull conversations to update lastMessageAt ordering. void refresh(); }, ) .subscribe(); // Windows WebView2 aggressively throttles background WebSockets and // sometimes drops events entirely while the window is minimised. Force a // refresh + realtime reconnect on visibility/focus regain so we never // leave stale conversation lists on a Windows client after the user // returns to the app. let lastAwakeRefresh = 0; const AWAKE_THROTTLE_MS = 30_000; const onAwake = () => { if (document.visibilityState !== 'visible') return; const now = Date.now(); if (now - lastAwakeRefresh < AWAKE_THROTTLE_MS) return; lastAwakeRefresh = now; void refresh(); try { // If the socket got wedged during background throttle, a no-op // unsubscribe+resubscribe brings it back. `subscribe()` on an already // joined channel is a no-op so this is safe. channel.subscribe(); } catch { /* ignore — already live */ } }; document.addEventListener('visibilitychange', onAwake); window.addEventListener('online', onAwake); return () => { document.removeEventListener('visibilitychange', onAwake); window.removeEventListener('online', onAwake); void supabase.removeChannel(channel); }; }, [myId, refresh, markRead]); const totalUnread = useMemo(() => { let s = 0; for (const v of Object.values(unread)) s += v; return s; }, [unread]); // Mirror unread count into the tray tooltip + dock badge. Runs in Tauri // only; no-op in browser-preview. useEffect(() => { void updateTrayUnread(totalUnread); }, [totalUnread]); const value = useMemo( () => ({ conversations, loading, error, unread, totalUnread, refresh, markRead, setActiveConversation, }), [ conversations, loading, error, unread, totalUnread, refresh, markRead, setActiveConversation, ], ); return {children}; } export function useConversationsContext(): ConversationsContextValue { const ctx = useContext(ConversationsContext); if (!ctx) throw new Error('useConversationsContext must be used inside '); return ctx; }