UNPKG

@replyke/core

Version:

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

312 lines 16.4 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.ChatProvider = exports.ChatContext = void 0; exports.useChatContext = useChatContext; const jsx_runtime_1 = require("react/jsx-runtime"); const react_1 = require("react"); const socket_io_client_1 = require("socket.io-client"); const hooks_1 = require("../store/hooks"); const authSlice_1 = require("../store/slices/authSlice"); const userSlice_1 = require("../store/slices/userSlice"); const authSlice_2 = require("../store/slices/authSlice"); const useProject_1 = __importDefault(require("../hooks/projects/useProject")); const axios_1 = require("../config/axios"); const chatSlice_1 = require("../store/slices/chatSlice"); const useAxiosPrivate_1 = __importDefault(require("../config/useAxiosPrivate")); exports.ChatContext = (0, react_1.createContext)({ socket: null, connected: false, registerActiveConversation: () => { }, unregisterActiveConversation: () => { }, }); // ─── Hook ──────────────────────────────────────────────────────────────────── function useChatContext() { return (0, react_1.useContext)(exports.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(axios_1.BASE_URL).origin; } catch { return "https://api.replyke.com"; } } const ChatProvider = ({ children }) => { const dispatch = (0, hooks_1.useReplykeDispatch)(); const { projectId } = (0, useProject_1.default)(); const axiosPrivate = (0, useAxiosPrivate_1.default)(); const accessToken = (0, hooks_1.useReplykeSelector)(authSlice_1.selectAccessToken); const user = (0, hooks_1.useReplykeSelector)(userSlice_1.selectUser); const authUser = (0, hooks_1.useReplykeSelector)(authSlice_2.selectUser); const currentUser = user || authUser; // Keep mutable refs for values the socket handlers need without closing over stale state const currentUserIdRef = (0, react_1.useRef)(currentUser?.id ?? null); (0, react_1.useEffect)(() => { currentUserIdRef.current = currentUser?.id ?? null; }, [currentUser]); // Fresh snapshot of messages for socket handlers that need to find a message by id const messagesRef = (0, react_1.useRef)({}); const allMessages = (0, hooks_1.useReplykeSelector)((state) => state.replyke.chat.messages); (0, react_1.useEffect)(() => { messagesRef.current = allMessages; }, [allMessages]); // Fresh snapshot of conversations for conversation:updated merging const conversationsRef = (0, react_1.useRef)({}); const allConversations = (0, hooks_1.useReplykeSelector)((state) => state.replyke.chat.conversations); (0, react_1.useEffect)(() => { conversationsRef.current = allConversations; }, [allConversations]); // Socket state (tracked in React state so context consumers re-render when it changes) const [socketState, setSocketState] = (0, react_1.useState)(null); const [connected, setConnected] = (0, react_1.useState)(false); // Mutable refs that don't need to trigger re-renders const socketRef = (0, react_1.useRef)(null); const prevTokenRef = (0, react_1.useRef)(null); // Set of conversationIds currently open — prevents unread bumps for active views const activeConversationIds = (0, react_1.useRef)(new Set()); // Typing timers per conversation: conversationId → Map<userId, timer> const typingTimers = (0, react_1.useRef)(new Map()); const registerActiveConversation = (0, react_1.useCallback)((id) => { activeConversationIds.current.add(id); }, []); const unregisterActiveConversation = (0, react_1.useCallback)((id) => { activeConversationIds.current.delete(id); }, []); // Helper: cancel a pending typing-timeout for a user in a conversation const clearTypingTimer = (0, react_1.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 = (0, react_1.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 = (0, react_1.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((0, chatSlice_1.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. (0, react_1.useEffect)(() => { if (!projectId || !accessToken) return; axiosPrivate .get(`/${projectId}/chat/conversations/unread-count`) .then(({ data }) => { dispatch((0, chatSlice_1.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); (0, react_1.useEffect)(() => { if (!projectId || !accessToken) return; const socketUrl = getSocketUrl(); const socket = (0, socket_io_client_1.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((0, chatSlice_1.setSocketConnected)(true)); }); socket.on("disconnect", () => { setConnected(false); dispatch((0, chatSlice_1.setSocketConnected)(false)); }); // ── message:created ───────────────────────────────────────────────────── socket.on("message:created", (message) => { dispatch((0, chatSlice_1.upsertMessage)(message)); dispatch((0, chatSlice_1.upsertConversationPreview)({ conversationId: message.conversationId, patch: { lastMessageAt: message.createdAt, lastMessage: message, }, })); if (!activeConversationIds.current.has(message.conversationId)) { dispatch((0, chatSlice_1.incrementUnread)(message.conversationId)); } }); // ── message:updated ───────────────────────────────────────────────────── socket.on("message:updated", (payload) => { const existing = findMessage(payload.messageId); if (existing) { dispatch((0, chatSlice_1.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((0, chatSlice_1.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((0, chatSlice_1.upsertMessage)({ ...existing, moderationStatus: "removed", })); } }); // ── message:reaction ──────────────────────────────────────────────────── socket.on("message:reaction", (payload) => { dispatch((0, chatSlice_1.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((0, chatSlice_1.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((0, chatSlice_1.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((0, chatSlice_1.setConversation)({ ...existing, ...patch })); } }); // ── Cleanup ───────────────────────────────────────────────────────────── return () => { socket.removeAllListeners(); socket.disconnect(); socketRef.current = null; setSocketState(null); setConnected(false); dispatch((0, chatSlice_1.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(). (0, react_1.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 ((0, jsx_runtime_1.jsx)(exports.ChatContext.Provider, { value: { socket: socketState, connected, registerActiveConversation, unregisterActiveConversation, }, children: children })); }; exports.ChatProvider = ChatProvider; //# sourceMappingURL=chat-context.js.map