import { createReducer, on } from '@ngrx/store'; import { EntityState, EntityAdapter, createEntityAdapter } from '@ngrx/entity'; import { DELETED_MESSAGE_CONTENT, Message } from '../../shared-kernel'; import { MessagesActions } from './messages.actions'; /** State shape for the messages feature slice, extending NgRx EntityState. */ export interface MessagesState extends EntityState { /** Whether messages are being loaded from the database. */ loading: boolean; /** Whether a peer-to-peer sync cycle is in progress. */ syncing: boolean; /** Whether a scroll-up older-page fetch is currently in flight. */ loadingOlder: boolean; /** Most recent error message from message operations. */ error: string | null; /** ID of the room whose messages are currently loaded. */ currentRoomId: string | null; /** * Conversation keys (`${roomId}:${channelId}`) that have been paginated * all the way back to the start of the local DB history. Used by the * scroll-up handler to stop issuing further DB pages. */ exhaustedConversations: Record; } export const messagesAdapter: EntityAdapter = createEntityAdapter({ selectId: (message) => message.id, sortComparer: (messageA, messageB) => messageA.timestamp - messageB.timestamp }); export const initialState: MessagesState = messagesAdapter.getInitialState({ loading: false, syncing: false, loadingOlder: false, error: null, currentRoomId: null, exhaustedConversations: {} }); export const messagesReducer = createReducer( initialState, // Load messages - clear stale messages when switching to a different room on(MessagesActions.loadMessages, (state, { roomId }) => { if (state.currentRoomId && state.currentRoomId !== roomId) { return messagesAdapter.removeAll({ ...state, loading: true, error: null, currentRoomId: roomId, exhaustedConversations: {} }); } return { ...state, loading: true, error: null, currentRoomId: roomId }; }), on(MessagesActions.loadMessagesSuccess, (state, { messages }) => messagesAdapter.setAll(messages, { ...state, loading: false }) ), on(MessagesActions.loadMessagesFailure, (state, { error }) => ({ ...state, loading: false, error })), // Load older messages - paginate backwards from the DB on scroll-up. on(MessagesActions.loadOlderMessages, (state) => ({ ...state, loadingOlder: true, error: null })), on(MessagesActions.loadOlderMessagesSuccess, (state, { conversationKey, messages, reachedEnd }) => messagesAdapter.upsertMany(messages, { ...state, loadingOlder: false, exhaustedConversations: reachedEnd ? { ...state.exhaustedConversations, [conversationKey]: true } : state.exhaustedConversations }) ), on(MessagesActions.loadOlderMessagesFailure, (state, { error }) => ({ ...state, loadingOlder: false, error })), // Send message on(MessagesActions.sendMessage, (state) => ({ ...state, loading: true })), on(MessagesActions.sendMessageSuccess, (state, { message }) => messagesAdapter.addOne(message, { ...state, loading: false }) ), on(MessagesActions.sendMessageFailure, (state, { error }) => ({ ...state, loading: false, error })), // Receive message from peer on(MessagesActions.receiveMessage, (state, { message }) => messagesAdapter.upsertOne(message, state) ), // Edit message on(MessagesActions.editMessageSuccess, (state, { messageId, content, editedAt }) => messagesAdapter.updateOne( { id: messageId, changes: { content, editedAt } }, state ) ), // Delete message on(MessagesActions.deleteMessageSuccess, (state, { messageId }) => messagesAdapter.updateOne( { id: messageId, changes: { content: DELETED_MESSAGE_CONTENT, isDeleted: true, reactions: [] } }, state ) ), // Add reaction on(MessagesActions.addReactionSuccess, (state, { reaction }) => { const message = state.entities[reaction.messageId]; if (!message) return state; const existingReaction = message.reactions.find( (existing) => existing.emoji === reaction.emoji && existing.userId === reaction.userId ); if (existingReaction) return state; return messagesAdapter.updateOne( { id: reaction.messageId, changes: { reactions: [...message.reactions, reaction] } }, state ); }), // Remove reaction on(MessagesActions.removeReactionSuccess, (state, { messageId, emoji, oderId }) => { const message = state.entities[messageId]; if (!message) return state; return messagesAdapter.updateOne( { id: messageId, changes: { reactions: message.reactions.filter( (existingReaction) => !(existingReaction.emoji === emoji && existingReaction.userId === oderId) ) } }, state ); }), // Sync lifecycle on(MessagesActions.startSync, (state) => ({ ...state, syncing: true })), on(MessagesActions.syncComplete, (state) => ({ ...state, syncing: false })), // Sync messages from peer (merge reactions to avoid losing local-only reactions) on(MessagesActions.syncMessages, (state, { messages }) => { const merged = messages.map(message => { const existing = state.entities[message.id]; if (existing?.reactions?.length) { const combined = [...(message.reactions ?? [])]; for (const existingReaction of existing.reactions) { const alreadyExists = combined.some((combinedReaction) => combinedReaction.userId === existingReaction.userId && combinedReaction.emoji === existingReaction.emoji && combinedReaction.messageId === existingReaction.messageId ); if (!alreadyExists) { combined.push(existingReaction); } } return { ...message, reactions: combined }; } return message; }); return messagesAdapter.upsertMany(merged, { ...state, syncing: false, // Peer sync may have inserted messages older than our current oldest; // reopen pagination so the scroll-up handler revisits the DB. exhaustedConversations: {} }); }), // Update link metadata on a message on(MessagesActions.updateLinkMetadata, (state, { messageId, linkMetadata }) => messagesAdapter.updateOne( { id: messageId, changes: { linkMetadata } }, state ) ), // Clear messages on(MessagesActions.clearMessages, (state) => messagesAdapter.removeAll({ ...state, currentRoomId: null, exhaustedConversations: {} }) ) );