UNPKG

@replyke/core

Version:

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

368 lines 20.7 kB
"use strict"; var _a; Object.defineProperty(exports, "__esModule", { value: true }); exports.selectUnreadConversationCount = exports.selectTotalUnreadCount = exports.selectSocketConnected = exports.selectTypingUsers = exports.selectThreadHasMore = exports.selectThreadLoading = exports.selectThreadReplies = exports.selectNewestMessageId = exports.selectOldestMessageId = exports.selectMessagesHasMore = exports.selectMessagesLoading = exports.selectMessages = exports.selectConversationListCursor = exports.selectConversationListHasMore = exports.selectConversationListLoading = exports.selectConversationList = exports.selectConversationLoading = exports.selectConversation = exports.setSocketConnected = exports.setTypingUsers = exports.setThreadLoading = exports.setThreadReplies = exports.updateReactions = exports.removeMessage = exports.failOptimisticMessage = exports.addOptimisticMessage = exports.upsertMessage = exports.setMessagesHasMore = exports.setMessagesLoading = exports.setUnreadSummary = exports.clearUnread = exports.incrementUnread = exports.upsertConversationPreview = exports.setConversationListCursor = exports.setConversationListHasMore = exports.setConversationListLoading = exports.setConversationList = exports.setConversationLoading = exports.setConversation = void 0; const toolkit_1 = require("@reduxjs/toolkit"); const initialState = { conversations: {}, conversationList: { items: [], loading: false, hasMore: true, cursor: null, }, messages: {}, threads: {}, typingUsers: {}, socketConnected: false, totalUnreadCount: null, unreadConversationCount: null, }; // ─── Helpers ───────────────────────────────────────────────────────────────── /** Sort previews by lastMessageAt DESC, placing null values last. */ function sortPreviews(items) { items.sort((a, b) => { if (!a.lastMessageAt && !b.lastMessageAt) return 0; if (!a.lastMessageAt) return 1; if (!b.lastMessageAt) return -1; return (new Date(b.lastMessageAt).getTime() - new Date(a.lastMessageAt).getTime()); }); } /** Return a fresh MessagesBucket with safe defaults. */ function emptyBucket() { return { items: [], loading: false, hasMore: true, oldestMessageId: null, newestMessageId: null, }; } /** * Recompute oldestMessageId / newestMessageId cursors from the current items. * Temp IDs (prefixed "temp-") are excluded from cursor tracking because they * cannot be used as valid server-side pagination cursors. */ function refreshCursors(bucket) { const realItems = bucket.items.filter((m) => !m.id.startsWith("temp-")); bucket.oldestMessageId = realItems.length > 0 ? realItems[0].id : null; bucket.newestMessageId = realItems.length > 0 ? realItems[realItems.length - 1].id : null; } // ─── Slice ─────────────────────────────────────────────────────────────────── // Not exported directly — use the actions below and the default reducer export. // Exporting the slice itself causes TS4023 due to immer's internal WritableNonArrayDraft type. const chatSlice = (0, toolkit_1.createSlice)({ name: "chat", initialState, reducers: { // ── Conversation actions ───────────────────────────────────────────────── /** * Upsert a single conversation into the conversations map AND patch the * matching preview in conversationList.items so both stores stay in sync. */ setConversation(state, action) { const conversation = action.payload; const { id } = conversation; if (!state.conversations[id]) { state.conversations[id] = { data: null, loading: false, error: null }; } state.conversations[id].data = conversation; state.conversations[id].loading = false; state.conversations[id].error = null; // Patch the matching preview in the list if it exists const previewIndex = state.conversationList.items.findIndex((c) => c.id === id); if (previewIndex !== -1) { Object.assign(state.conversationList.items[previewIndex], conversation); } }, setConversationLoading(state, action) { const { conversationId, loading } = action.payload; if (!state.conversations[conversationId]) { state.conversations[conversationId] = { data: null, loading, error: null, }; } else { state.conversations[conversationId].loading = loading; } }, /** Replace the entire conversation list (first-page load or refresh). */ setConversationList(state, action) { state.conversationList.items = action.payload; }, setConversationListLoading(state, action) { state.conversationList.loading = action.payload; }, setConversationListHasMore(state, action) { state.conversationList.hasMore = action.payload; }, setConversationListCursor(state, action) { state.conversationList.cursor = action.payload; }, /** * Update lastMessageAt / lastMessage / unreadCount on a preview item, then * re-sort the list by lastMessageAt DESC NULLS LAST. Also patches * conversations[id].data so a split-screen layout (sidebar + chat view) * stays in sync without a separate fetch. */ upsertConversationPreview(state, action) { const { conversationId, patch } = action.payload; const previewIndex = state.conversationList.items.findIndex((c) => c.id === conversationId); if (previewIndex !== -1) { Object.assign(state.conversationList.items[previewIndex], patch); sortPreviews(state.conversationList.items); } // Mirror the patch into the individual conversation entry if loaded if (state.conversations[conversationId]?.data) { Object.assign(state.conversations[conversationId].data, patch); } }, incrementUnread(state, action) { const conversationId = action.payload; const preview = state.conversationList.items.find((c) => c.id === conversationId); if (preview) { const wasZero = (preview.unreadCount ?? 0) === 0; preview.unreadCount = (preview.unreadCount ?? 0) + 1; // Only bump conversation count if this conversation just became unread if (wasZero && state.unreadConversationCount !== null) { state.unreadConversationCount += 1; } } // Always bump total — even if conversation not in the loaded list if (state.totalUnreadCount !== null) { state.totalUnreadCount += 1; } }, clearUnread(state, action) { const conversationId = action.payload; const preview = state.conversationList.items.find((c) => c.id === conversationId); if (preview) { const prevCount = preview.unreadCount ?? 0; preview.unreadCount = 0; if (prevCount > 0) { if (state.totalUnreadCount !== null) { state.totalUnreadCount = Math.max(0, state.totalUnreadCount - prevCount); } if (state.unreadConversationCount !== null) { state.unreadConversationCount = Math.max(0, state.unreadConversationCount - 1); } } } }, /** Initialize global unread totals from the server on ChatProvider mount. */ setUnreadSummary(state, action) { state.totalUnreadCount = action.payload.totalUnread; state.unreadConversationCount = action.payload.unreadConversationCount; }, // ── Message actions ────────────────────────────────────────────────────── setMessagesLoading(state, action) { const { conversationId, loading } = action.payload; if (!state.messages[conversationId]) { state.messages[conversationId] = emptyBucket(); } state.messages[conversationId].loading = loading; }, setMessagesHasMore(state, action) { const { conversationId, hasMore } = action.payload; if (!state.messages[conversationId]) { state.messages[conversationId] = emptyBucket(); } state.messages[conversationId].hasMore = hasMore; }, /** * Add or update a message in its conversation bucket. * * Deduplication order: * 1. Match by real `id` → update in-place (handles socket events after REST) * 2. Match by `localId` → replace optimistic placeholder with confirmed message * 3. Otherwise insert, maintaining chronological ASC order */ upsertMessage(state, action) { const message = action.payload; const { conversationId } = message; if (!state.messages[conversationId]) { state.messages[conversationId] = emptyBucket(); } const bucket = state.messages[conversationId]; const items = bucket.items; // 1. Match by real id const byIdIndex = items.findIndex((m) => m.id === message.id); if (byIdIndex !== -1) { items[byIdIndex] = message; refreshCursors(bucket); return; } // 2. Match by localId (replace optimistic placeholder) if (message.localId) { const byClientIndex = items.findIndex((m) => m.localId === message.localId); if (byClientIndex !== -1) { items[byClientIndex] = message; refreshCursors(bucket); return; } } // 3. New message — insert at the correct chronological position const insertAt = items.findIndex((m) => new Date(m.createdAt).getTime() > new Date(message.createdAt).getTime()); if (insertAt === -1) { items.push(message); } else { items.splice(insertAt, 0, message); } refreshCursors(bucket); }, /** * Insert a pending optimistic message with a `temp-{uuid}` id immediately * before the POST fires. The message is replaced by upsertMessage when the * server response arrives (matched via localId). */ addOptimisticMessage(state, action) { const message = action.payload; const { conversationId } = message; if (!state.messages[conversationId]) { state.messages[conversationId] = emptyBucket(); } // Optimistic messages are always the newest — append to end state.messages[conversationId].items.push(message); // Do NOT update cursor IDs — temp IDs are not valid pagination cursors }, /** * Mark a failed optimistic message with sendFailed: true so the UI can * show a retry prompt. The message is NOT removed from the list. */ failOptimisticMessage(state, action) { const { conversationId, localId } = action.payload; const bucket = state.messages[conversationId]; if (!bucket) return; const message = bucket.items.find((m) => m.localId === localId); if (message) { message.sendFailed = true; } }, /** * Soft-delete a message by marking it with the current time as userDeletedAt * and clearing its content. Matches server behavior (Reddit-style placeholder). */ removeMessage(state, action) { const { conversationId, messageId } = action.payload; const bucket = state.messages[conversationId]; if (!bucket) return; const message = bucket.items.find((m) => m.id === messageId); if (message) { message.userDeletedAt = new Date(); message.content = null; message.gif = null; message.mentions = []; message.metadata = {}; message.files = undefined; } }, /** * Update the reactionCounts map on a message. If userId matches the current * user, add or remove the emoji from userReactions accordingly. * Dispatched by ChatProvider on `message:reaction` socket events. */ updateReactions(state, action) { const { conversationId, messageId, reactionCounts, userId, emoji, delta, currentUserId, } = action.payload; const bucket = state.messages[conversationId]; if (!bucket) return; const message = bucket.items.find((m) => m.id === messageId); if (!message) return; message.reactionCounts = reactionCounts; // Keep userReactions in sync for the current user only if (userId === currentUserId) { if (delta === 1) { if (!message.userReactions.includes(emoji)) { message.userReactions.push(emoji); } } else { message.userReactions = message.userReactions.filter((e) => e !== emoji); } } }, // ── Thread actions ─────────────────────────────────────────────────────── setThreadReplies(state, action) { const { parentMessageId, messages, hasMore } = action.payload; state.threads[parentMessageId] = { items: messages, loading: false, hasMore, }; }, setThreadLoading(state, action) { const { parentMessageId, loading } = action.payload; if (!state.threads[parentMessageId]) { state.threads[parentMessageId] = { items: [], loading, hasMore: true, }; } else { state.threads[parentMessageId].loading = loading; } }, // ── Typing indicator actions ───────────────────────────────────────────── /** Replace the full typing-user list for a conversation. */ setTypingUsers(state, action) { const { conversationId, userIds } = action.payload; state.typingUsers[conversationId] = userIds; }, // ── Socket connection ──────────────────────────────────────────────────── setSocketConnected(state, action) { state.socketConnected = action.payload; }, }, }); // ─── Action exports ────────────────────────────────────────────────────────── _a = chatSlice.actions, exports.setConversation = _a.setConversation, exports.setConversationLoading = _a.setConversationLoading, exports.setConversationList = _a.setConversationList, exports.setConversationListLoading = _a.setConversationListLoading, exports.setConversationListHasMore = _a.setConversationListHasMore, exports.setConversationListCursor = _a.setConversationListCursor, exports.upsertConversationPreview = _a.upsertConversationPreview, exports.incrementUnread = _a.incrementUnread, exports.clearUnread = _a.clearUnread, exports.setUnreadSummary = _a.setUnreadSummary, exports.setMessagesLoading = _a.setMessagesLoading, exports.setMessagesHasMore = _a.setMessagesHasMore, exports.upsertMessage = _a.upsertMessage, exports.addOptimisticMessage = _a.addOptimisticMessage, exports.failOptimisticMessage = _a.failOptimisticMessage, exports.removeMessage = _a.removeMessage, exports.updateReactions = _a.updateReactions, exports.setThreadReplies = _a.setThreadReplies, exports.setThreadLoading = _a.setThreadLoading, exports.setTypingUsers = _a.setTypingUsers, exports.setSocketConnected = _a.setSocketConnected; exports.default = chatSlice.reducer; // ─── Selectors ─────────────────────────────────────────────────────────────── const selectConversation = (conversationId) => (state) => state.replyke.chat.conversations[conversationId]?.data ?? null; exports.selectConversation = selectConversation; const selectConversationLoading = (conversationId) => (state) => state.replyke.chat.conversations[conversationId]?.loading ?? false; exports.selectConversationLoading = selectConversationLoading; const selectConversationList = (state) => state.replyke.chat.conversationList.items; exports.selectConversationList = selectConversationList; const selectConversationListLoading = (state) => state.replyke.chat.conversationList.loading; exports.selectConversationListLoading = selectConversationListLoading; const selectConversationListHasMore = (state) => state.replyke.chat.conversationList.hasMore; exports.selectConversationListHasMore = selectConversationListHasMore; const selectConversationListCursor = (state) => state.replyke.chat.conversationList.cursor; exports.selectConversationListCursor = selectConversationListCursor; const selectMessages = (conversationId) => (state) => state.replyke.chat.messages[conversationId]?.items ?? []; exports.selectMessages = selectMessages; const selectMessagesLoading = (conversationId) => (state) => state.replyke.chat.messages[conversationId]?.loading ?? false; exports.selectMessagesLoading = selectMessagesLoading; const selectMessagesHasMore = (conversationId) => (state) => state.replyke.chat.messages[conversationId]?.hasMore ?? true; exports.selectMessagesHasMore = selectMessagesHasMore; const selectOldestMessageId = (conversationId) => (state) => state.replyke.chat.messages[conversationId]?.oldestMessageId ?? null; exports.selectOldestMessageId = selectOldestMessageId; const selectNewestMessageId = (conversationId) => (state) => state.replyke.chat.messages[conversationId]?.newestMessageId ?? null; exports.selectNewestMessageId = selectNewestMessageId; const selectThreadReplies = (parentMessageId) => (state) => state.replyke.chat.threads[parentMessageId]?.items ?? []; exports.selectThreadReplies = selectThreadReplies; const selectThreadLoading = (parentMessageId) => (state) => state.replyke.chat.threads[parentMessageId]?.loading ?? false; exports.selectThreadLoading = selectThreadLoading; const selectThreadHasMore = (parentMessageId) => (state) => state.replyke.chat.threads[parentMessageId]?.hasMore ?? true; exports.selectThreadHasMore = selectThreadHasMore; const selectTypingUsers = (conversationId) => (state) => state.replyke.chat.typingUsers[conversationId] ?? []; exports.selectTypingUsers = selectTypingUsers; const selectSocketConnected = (state) => state.replyke.chat.socketConnected; exports.selectSocketConnected = selectSocketConnected; const selectTotalUnreadCount = (state) => state.replyke.chat.totalUnreadCount; exports.selectTotalUnreadCount = selectTotalUnreadCount; const selectUnreadConversationCount = (state) => state.replyke.chat.unreadConversationCount; exports.selectUnreadConversationCount = selectUnreadConversationCount; //# sourceMappingURL=chatSlice.js.map