@replyke/core
Version:
Replyke: Build interactive apps with social features like comments, votes, feeds, user lists, notifications, and more.
346 lines • 17.8 kB
JavaScript
import { createSlice } from "@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 = 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 ──────────────────────────────────────────────────────────
export const { setConversation, setConversationLoading, setConversationList, setConversationListLoading, setConversationListHasMore, setConversationListCursor, upsertConversationPreview, incrementUnread, clearUnread, setUnreadSummary, setMessagesLoading, setMessagesHasMore, upsertMessage, addOptimisticMessage, failOptimisticMessage, removeMessage, updateReactions, setThreadReplies, setThreadLoading, setTypingUsers, setSocketConnected, } = chatSlice.actions;
export default chatSlice.reducer;
// ─── Selectors ───────────────────────────────────────────────────────────────
export const selectConversation = (conversationId) => (state) => state.replyke.chat.conversations[conversationId]?.data ?? null;
export const selectConversationLoading = (conversationId) => (state) => state.replyke.chat.conversations[conversationId]?.loading ?? false;
export const selectConversationList = (state) => state.replyke.chat.conversationList.items;
export const selectConversationListLoading = (state) => state.replyke.chat.conversationList.loading;
export const selectConversationListHasMore = (state) => state.replyke.chat.conversationList.hasMore;
export const selectConversationListCursor = (state) => state.replyke.chat.conversationList.cursor;
export const selectMessages = (conversationId) => (state) => state.replyke.chat.messages[conversationId]?.items ?? [];
export const selectMessagesLoading = (conversationId) => (state) => state.replyke.chat.messages[conversationId]?.loading ?? false;
export const selectMessagesHasMore = (conversationId) => (state) => state.replyke.chat.messages[conversationId]?.hasMore ?? true;
export const selectOldestMessageId = (conversationId) => (state) => state.replyke.chat.messages[conversationId]?.oldestMessageId ?? null;
export const selectNewestMessageId = (conversationId) => (state) => state.replyke.chat.messages[conversationId]?.newestMessageId ?? null;
export const selectThreadReplies = (parentMessageId) => (state) => state.replyke.chat.threads[parentMessageId]?.items ?? [];
export const selectThreadLoading = (parentMessageId) => (state) => state.replyke.chat.threads[parentMessageId]?.loading ?? false;
export const selectThreadHasMore = (parentMessageId) => (state) => state.replyke.chat.threads[parentMessageId]?.hasMore ?? true;
export const selectTypingUsers = (conversationId) => (state) => state.replyke.chat.typingUsers[conversationId] ?? [];
export const selectSocketConnected = (state) => state.replyke.chat.socketConnected;
export const selectTotalUnreadCount = (state) => state.replyke.chat.totalUnreadCount;
export const selectUnreadConversationCount = (state) => state.replyke.chat.unreadConversationCount;
//# sourceMappingURL=chatSlice.js.map