Files
Toju/toju-app/src/app/store/messages/messages.reducer.ts
T
myxelium 54e8b9a5e4
Queue Release Build / prepare (push) Successful in 23s
Deploy Web Apps / deploy (push) Successful in 7m36s
Queue Release Build / build-windows (push) Successful in 28m3s
Queue Release Build / build-linux (push) Successful in 44m14s
Queue Release Build / finalize (push) Successful in 39s
feat: Update how messages load and sync, allow plugins to import messages
2026-05-18 23:21:09 +02:00

267 lines
6.8 KiB
TypeScript

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<Message> {
/** 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<string, true>;
}
export const messagesAdapter: EntityAdapter<Message> = createEntityAdapter<Message>({
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: {}
})
)
);