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
267 lines
6.8 KiB
TypeScript
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: {}
|
|
})
|
|
)
|
|
);
|