@replyke/core
Version:
Replyke: Build interactive apps with social features like comments, votes, feeds, user lists, notifications, and more.
312 lines • 16.4 kB
JavaScript
;
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