import React, { createContext, useContext, useEffect, useRef, useState, useCallback, useMemo } from 'react'; import { AppState, type AppStateStatus } from 'react-native'; import { useDubs } from '../provider'; import { ChatSocket } from './socket'; import type { ChatMessage, OnlineUser, ChatConnectionStatus, Conversation, FriendUser, FriendRequest, SentFriendRequest, DirectMessage, ChatNotification, } from './types'; import type { WhatsNewPost } from '../types'; export interface ChatContextValue { /** The underlying socket manager */ socket: ChatSocket; /** Connection status */ status: ChatConnectionStatus; /** Global chat messages (newest last) */ messages: ChatMessage[]; /** Currently online users */ onlineUsers: OnlineUser[]; /** Online user count */ onlineCount: number; /** Unread notification count */ unreadCount: number; /** DM conversations */ conversations: Conversation[]; /** Friends list */ friends: FriendUser[]; /** Pending friend requests (received) */ pendingRequests: FriendRequest[]; /** Pending friend requests this user has sent (still awaiting response) */ sentFriendRequests: SentFriendRequest[]; /** Reload messages from REST */ refreshMessages: () => Promise; /** Reload conversations from REST */ refreshConversations: () => Promise; /** Reload friends from REST */ refreshFriends: () => Promise; /** Reload pending friend requests (received) from REST */ refreshPendingRequests: () => Promise; /** Reload pending friend requests (sent) from REST */ refreshSentFriendRequests: () => Promise; /** What's New posts for this app */ whatsNewPosts: WhatsNewPost[]; /** Number of unread What's New posts */ whatsNewUnreadCount: number; /** Reload What's New posts + unread count from REST */ refreshWhatsNew: () => Promise; } const ChatContext = createContext(null); export interface ChatProviderProps { children: React.ReactNode; /** Set to false to disable auto-connect on mount. Default: true */ autoConnect?: boolean; } /** * Provides chat, DM, and social context to child components. * Must be rendered inside a . */ export function ChatProvider({ children, autoConnect = true }: ChatProviderProps) { const { client } = useDubs(); const socketRef = useRef(new ChatSocket()); const [status, setStatus] = useState('disconnected'); const [messages, setMessages] = useState([]); const [onlineUsers, setOnlineUsers] = useState([]); const [onlineCount, setOnlineCount] = useState(0); const [unreadCount, setUnreadCount] = useState(0); const [conversations, setConversations] = useState([]); const [friends, setFriends] = useState([]); const [pendingRequests, setPendingRequests] = useState([]); const [sentFriendRequests, setSentFriendRequests] = useState([]); const [whatsNewPosts, setWhatsNewPosts] = useState([]); const [whatsNewUnreadCount, setWhatsNewUnreadCount] = useState(0); // ── REST loaders ── const refreshMessages = useCallback(async () => { try { const res = await client.getChatMessages({ limit: 30 }); // The dev API returns messages oldest-first (chatService applies a // .reverse() for legacy chat-order callers). Inverted chat FlatLists // — and onNewMessage's prepend below — assume newest-first. Flip // here so the internal store is consistent everywhere. setMessages([...res.messages].reverse()); } catch (err) { console.error('[Dubs:ChatProvider] Failed to load messages:', err); } }, [client]); const refreshConversations = useCallback(async () => { try { const res = await client.getConversations(); setConversations(res.conversations); } catch (_) { // DM service may not be deployed yet — silent fail } }, [client]); const refreshFriends = useCallback(async () => { try { const res = await client.getFriends(); setFriends(res.friends); } catch (_) { // Social service may not be deployed yet — silent fail } }, [client]); const refreshPendingRequests = useCallback(async () => { try { const res = await client.getPendingFriendRequests(); setPendingRequests(res.requests); } catch (_) { // Social service may not be deployed yet — silent fail } }, [client]); const refreshSentFriendRequests = useCallback(async () => { try { const res = await client.getSentFriendRequests(); setSentFriendRequests(res.requests); } catch (_) { // Server may not yet expose /social/friend-requests/sent — silent fail } }, [client]); const refreshWhatsNew = useCallback(async () => { try { const res = await client.getWhatsNewPosts(); setWhatsNewPosts(res.posts); // Count unread from the same payload (avoids a second roundtrip) const unread = res.posts.filter((p: WhatsNewPost) => !p.is_read).length; setWhatsNewUnreadCount(unread); } catch (_) { // Server may not yet expose /whats-new endpoints — silent fail } }, [client]); // ── Socket setup ── useEffect(() => { const token = client.getToken(); if (!autoConnect || !token) return; const chatSocket = socketRef.current; // Derive host from client baseUrl: strip /api/developer/v1 const baseUrl = (client as any).baseUrl as string; const host = new URL(baseUrl).origin; chatSocket.setListeners({ // Refetch the message list whenever the socket reaches 'connected'. // Covers both the initial connect AND every reconnect (app resumed // from background, transient network blip, server restart). Without // this, the provider's `messages` state is frozen at whatever it was // when the app last connected — Socket.IO's auto-reconnect resumes // delivery of *new* events but does not replay the gap. onConnectionChange: (next) => { setStatus(next); if (next === 'connected') { refreshMessages().catch(() => {}); refreshConversations().catch(() => {}); } }, // Global chat onNewMessage: (msg) => { setMessages((prev) => { // Deduplicate by id if (prev.some((m) => m.id === msg.id)) return prev; // Prepend — newest-first for inverted FlatList return [msg, ...prev]; }); }, onOnlineUsers: setOnlineUsers, onOnlineCount: setOnlineCount, onUnreadCount: setUnreadCount, // Notifications trigger conversation refresh onDMNotification: () => { refreshConversations(); }, onNotification: (n: ChatNotification) => { setUnreadCount((prev) => prev + 1); // Refresh relevant lists on social notifications if (n.type === 'friend_request') refreshPendingRequests(); if (n.type === 'friend_request_accepted') { refreshFriends(); refreshSentFriendRequests(); } if (n.type === 'friend_request_declined') refreshSentFriendRequests(); // What's New posts: a developer published a new one for this app if (n.type === 'whats_new') refreshWhatsNew(); }, // These events now fan out to BOTH parties (server emits to actor's // own room as well), so each handler refreshes every list that could // be affected — works whether this client is the actor or the target. onFriendRequestAccepted: () => { refreshFriends(); refreshPendingRequests(); refreshSentFriendRequests(); }, onFriendRequestDeclined: () => { refreshPendingRequests(); refreshSentFriendRequests(); }, onFriendRequestCancelled: () => { refreshPendingRequests(); refreshSentFriendRequests(); }, onFriendRemoved: () => refreshFriends(), }); chatSocket.connect({ host, token }); // Load initial data — friends/social loaded silently (may not be deployed yet) refreshMessages(); refreshFriends().catch(() => {}); refreshPendingRequests().catch(() => {}); refreshSentFriendRequests().catch(() => {}); refreshWhatsNew().catch(() => {}); return () => { chatSocket.disconnect(); }; }, [client, autoConnect, refreshMessages, refreshConversations, refreshFriends, refreshPendingRequests, refreshSentFriendRequests, refreshWhatsNew]); // ── Reconnect on app foreground ── useEffect(() => { const handleAppState = (nextState: AppStateStatus) => { if (nextState === 'active') { const chatSocket = socketRef.current; if (!chatSocket.isConnected()) { const token = client.getToken(); if (token) { const baseUrl = (client as any).baseUrl as string; const host = new URL(baseUrl).origin; chatSocket.connect({ host, token }); } } // Refresh all key data to catch up on socket events that fired // while the app was backgrounded (declines, accepts, cancels, // new requests, friend removals, etc.) refreshMessages(); refreshConversations().catch(() => {}); refreshFriends().catch(() => {}); refreshPendingRequests().catch(() => {}); refreshSentFriendRequests().catch(() => {}); refreshWhatsNew().catch(() => {}); } }; const sub = AppState.addEventListener('change', handleAppState); return () => sub.remove(); }, [client, refreshMessages, refreshConversations, refreshFriends, refreshPendingRequests, refreshSentFriendRequests, refreshWhatsNew]); const value = useMemo( () => ({ socket: socketRef.current, status, messages, onlineUsers, onlineCount, unreadCount, conversations, friends, pendingRequests, sentFriendRequests, whatsNewPosts, whatsNewUnreadCount, refreshMessages, refreshConversations, refreshFriends, refreshPendingRequests, refreshSentFriendRequests, refreshWhatsNew, }), [status, messages, onlineUsers, onlineCount, unreadCount, conversations, friends, pendingRequests, sentFriendRequests, whatsNewPosts, whatsNewUnreadCount, refreshMessages, refreshConversations, refreshFriends, refreshPendingRequests, refreshSentFriendRequests, refreshWhatsNew], ); return {children}; } /** Access the chat context. Must be used inside . */ export function useChatContext(): ChatContextValue { const ctx = useContext(ChatContext); if (!ctx) { throw new Error('useChatContext must be used inside a '); } return ctx; }