UNPKG

@replyke/core

Version:

Replyke: Build interactive apps with social features like comments, votes, feeds, user lists, notifications, and more.

304 lines 15.5 kB
import { jsx as _jsx } from "react/jsx-runtime"; import { createContext, useCallback, useContext, useEffect, useRef, useState, } from "react"; import { io } from "socket.io-client"; import { useReplykeDispatch, useReplykeSelector } from "../store/hooks"; import { selectAccessToken } from "../store/slices/authSlice"; import { selectUser } from "../store/slices/userSlice"; import { selectUser as selectAuthUser } from "../store/slices/authSlice"; import useProject from "../hooks/projects/useProject"; import { BASE_URL } from "../config/axios"; import { upsertMessage, upsertConversationPreview, incrementUnread, setSocketConnected, setTypingUsers, updateReactions, setConversation, setUnreadSummary, } from "../store/slices/chatSlice"; import useAxiosPrivate from "../config/useAxiosPrivate"; export const ChatContext = createContext({ socket: null, connected: false, registerActiveConversation: () => { }, unregisterActiveConversation: () => { }, }); // ─── Hook ──────────────────────────────────────────────────────────────────── export function useChatContext() { return useContext(ChatContext); } /** Derive the socket.io server URL from the REST API base URL. */ function getSocketUrl() { // socket.io mounts at the origin; strip the path from BASE_URL try { return new URL(BASE_URL).origin; } catch { return "https://api.replyke.com"; } } export const ChatProvider = ({ children }) => { const dispatch = useReplykeDispatch(); const { projectId } = useProject(); const axiosPrivate = useAxiosPrivate(); const accessToken = useReplykeSelector(selectAccessToken); const user = useReplykeSelector(selectUser); const authUser = useReplykeSelector(selectAuthUser); const currentUser = user || authUser; // Keep mutable refs for values the socket handlers need without closing over stale state const currentUserIdRef = useRef(currentUser?.id ?? null); useEffect(() => { currentUserIdRef.current = currentUser?.id ?? null; }, [currentUser]); // Fresh snapshot of messages for socket handlers that need to find a message by id const messagesRef = useRef({}); const allMessages = useReplykeSelector((state) => state.replyke.chat.messages); useEffect(() => { messagesRef.current = allMessages; }, [allMessages]); // Fresh snapshot of conversations for conversation:updated merging const conversationsRef = useRef({}); const allConversations = useReplykeSelector((state) => state.replyke.chat.conversations); useEffect(() => { conversationsRef.current = allConversations; }, [allConversations]); // Socket state (tracked in React state so context consumers re-render when it changes) const [socketState, setSocketState] = useState(null); const [connected, setConnected] = useState(false); // Mutable refs that don't need to trigger re-renders const socketRef = useRef(null); const prevTokenRef = useRef(null); // Set of conversationIds currently open — prevents unread bumps for active views const activeConversationIds = useRef(new Set()); // Typing timers per conversation: conversationId → Map<userId, timer> const typingTimers = useRef(new Map()); const registerActiveConversation = useCallback((id) => { activeConversationIds.current.add(id); }, []); const unregisterActiveConversation = useCallback((id) => { activeConversationIds.current.delete(id); }, []); // Helper: cancel a pending typing-timeout for a user in a conversation const clearTypingTimer = useCallback((conversationId, userId) => { const convMap = typingTimers.current.get(conversationId); if (!convMap) return; const timer = convMap.get(userId); if (timer !== undefined) { clearTimeout(timer); convMap.delete(userId); } }, []); // Helper: find a message by id across all loaded conversation buckets const findMessage = useCallback((messageId) => { for (const bucket of Object.values(messagesRef.current)) { const found = bucket.items?.find((m) => m.id === messageId); if (found) return found; } return null; }, []); // Helper: remove a userId from the Redux typing list for a conversation const removeTypingUser = useCallback((conversationId, userId) => { // We can't use the selector inside a callback without re-reading, // so we derive the current array from the state ref we keep in messagesRef. // Actually for typing we need the current typingUsers — but that's a separate ref. // Simplest: dispatch with the current stored typing users derived from our own // typing timer map (we know who is in the map = who is typing). const convMap = typingTimers.current.get(conversationId); const typingUserIds = convMap ? Array.from(convMap.keys()) : []; dispatch(setTypingUsers({ conversationId, userIds: typingUserIds.filter((id) => id !== userId), })); }, [dispatch]); // ── Unread summary fetch ───────────────────────────────────────────────────── // Fetch total unread counts once auth is ready so global badges (e.g. sidebar) // are accurate before the user ever loads the conversation list. useEffect(() => { if (!projectId || !accessToken) return; axiosPrivate .get(`/${projectId}/chat/conversations/unread-count`) .then(({ data }) => { dispatch(setUnreadSummary({ totalUnread: data.totalUnread, unreadConversationCount: data.unreadConversationCount, })); }) .catch(() => { // Non-critical — badge will update via socket events as messages arrive }); // Re-fetch when auth changes (e.g. user switches accounts) // eslint-disable-next-line react-hooks/exhaustive-deps }, [projectId, accessToken]); // ── Main socket creation effect ───────────────────────────────────────────── const hasToken = Boolean(accessToken); useEffect(() => { if (!projectId || !accessToken) return; const socketUrl = getSocketUrl(); const socket = io(socketUrl, { auth: { token: accessToken }, query: { projectId }, autoConnect: true, }); socketRef.current = socket; setSocketState(socket); prevTokenRef.current = accessToken; // ── Connection state ──────────────────────────────────────────────────── socket.on("connect", () => { setConnected(true); dispatch(setSocketConnected(true)); }); socket.on("disconnect", () => { setConnected(false); dispatch(setSocketConnected(false)); }); // ── message:created ───────────────────────────────────────────────────── socket.on("message:created", (message) => { dispatch(upsertMessage(message)); dispatch(upsertConversationPreview({ conversationId: message.conversationId, patch: { lastMessageAt: message.createdAt, lastMessage: message, }, })); if (!activeConversationIds.current.has(message.conversationId)) { dispatch(incrementUnread(message.conversationId)); } }); // ── message:updated ───────────────────────────────────────────────────── socket.on("message:updated", (payload) => { const existing = findMessage(payload.messageId); if (existing) { dispatch(upsertMessage({ ...existing, content: payload.content, gif: payload.gif, mentions: payload.mentions, metadata: payload.metadata, editedAt: payload.editedAt, })); } }); // ── message:deleted ───────────────────────────────────────────────────── socket.on("message:deleted", (payload) => { const existing = findMessage(payload.messageId); if (existing) { dispatch(upsertMessage({ ...existing, userDeletedAt: payload.userDeletedAt, content: null, gif: null, mentions: [], metadata: {}, files: undefined, })); } }); // ── message:removed ───────────────────────────────────────────────────── socket.on("message:removed", (payload) => { const existing = findMessage(payload.messageId); if (existing) { dispatch(upsertMessage({ ...existing, moderationStatus: "removed", })); } }); // ── message:reaction ──────────────────────────────────────────────────── socket.on("message:reaction", (payload) => { dispatch(updateReactions({ conversationId: payload.conversationId, messageId: payload.messageId, reactionCounts: payload.reactionCounts, userId: payload.userId, emoji: payload.emoji, delta: payload.delta, currentUserId: currentUserIdRef.current ?? "", })); }); // ── thread:reply_count ────────────────────────────────────────────────── socket.on("thread:reply_count", (payload) => { const existing = findMessage(payload.messageId); if (existing) { dispatch(upsertMessage({ ...existing, threadReplyCount: payload.threadReplyCount, })); } }); // ── typing:start ──────────────────────────────────────────────────────── socket.on("typing:start", ({ userId, conversationId }) => { // Ignore self-loop (multiple tabs) if (userId === currentUserIdRef.current) return; // Reset the 5-second auto-timeout for this user clearTypingTimer(conversationId, userId); if (!typingTimers.current.has(conversationId)) { typingTimers.current.set(conversationId, new Map()); } const convMap = typingTimers.current.get(conversationId); // Add to Redux typing list if not already present const currentTyping = Array.from(convMap.keys()); if (!currentTyping.includes(userId)) { dispatch(setTypingUsers({ conversationId, userIds: [...currentTyping, userId], })); } // Set timeout to auto-remove after 5 seconds without a keep-alive const timer = setTimeout(() => { removeTypingUser(conversationId, userId); typingTimers.current.get(conversationId)?.delete(userId); }, 5000); convMap.set(userId, timer); }); // ── typing:stop ───────────────────────────────────────────────────────── socket.on("typing:stop", ({ userId, conversationId }) => { clearTypingTimer(conversationId, userId); removeTypingUser(conversationId, userId); }); // ── conversation:updated ──────────────────────────────────────────────── socket.on("conversation:updated", (patch) => { const existing = conversationsRef.current[patch.id]?.data; if (existing) { dispatch(setConversation({ ...existing, ...patch })); } }); // ── Cleanup ───────────────────────────────────────────────────────────── return () => { socket.removeAllListeners(); socket.disconnect(); socketRef.current = null; setSocketState(null); setConnected(false); dispatch(setSocketConnected(false)); // Clear all pending typing timers Array.from(typingTimers.current.values()).forEach((convMap) => { Array.from(convMap.values()).forEach((timer) => { clearTimeout(timer); }); }); typingTimers.current.clear(); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [projectId, hasToken]); // ── Token refresh effect ──────────────────────────────────────────────────── // When the access token rotates (same socket, new credentials), update socket.auth // and force a fresh handshake. Must NOT re-create the socket — that would clear all // room memberships. Instead: update auth object, then disconnect().connect(). useEffect(() => { const socket = socketRef.current; if (!socket || !accessToken) return; if (prevTokenRef.current === accessToken) return; prevTokenRef.current = accessToken; // socket.auth must be updated BEFORE disconnect().connect() — // Socket.io auto-reconnect reuses the original auth object. socket.auth = { token: accessToken }; socket.disconnect().connect(); }, [accessToken]); return (_jsx(ChatContext.Provider, { value: { socket: socketState, connected, registerActiveConversation, unregisterActiveConversation, }, children: children })); }; //# sourceMappingURL=chat-context.js.map