feat(07-02): implement sendMessage async thunk with optimistic updates

Add sendMessage async thunk with complete optimistic UI flow:
- pending: Adds optimistic message immediately with temp ID
- fulfilled: Replaces optimistic message with real server response
- rejected: Removes optimistic message and sets error
- Optimistic messages marked with isOptimistic flag
- Channel initialization if not exists
- Preserves existing messages during replace/remove operations

All 53 unit tests passing.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Nuwan 2026-01-27 08:17:45 +05:30
parent 5d3b6d42e4
commit 7729f2767c
1 changed files with 83 additions and 1 deletions

View File

@ -1,5 +1,5 @@
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
import { getChatMessages } from '../../helpers/rest';
import { getChatMessages, sendChatMessage } from '../../helpers/rest';
/**
* Async thunk to fetch chat history for a channel
@ -20,6 +20,28 @@ export const fetchChatHistory = createAsyncThunk(
}
);
/**
* Async thunk to send a chat message with optimistic UI updates
* @param {Object} params - Request parameters
* @param {string} params.channel - Channel ID (session ID, lesson ID, or 'global')
* @param {string} params.sessionId - Session ID for session channels
* @param {string} params.message - Message content
* @param {string} params.optimisticId - Temporary ID for optimistic update
* @param {string} params.userId - Current user ID
* @param {string} params.userName - Current user name
*/
export const sendMessage = createAsyncThunk(
'sessionChat/sendMessage',
async ({ channel, sessionId, message }) => {
const response = await sendChatMessage({
channel: sessionId ? 'session' : 'global',
sessionId,
message
});
return response;
}
);
/**
* Initial state for session chat
* @type {Object}
@ -198,6 +220,66 @@ const sessionChatSlice = createSlice({
const channel = action.meta.arg.channel;
state.fetchStatus[channel] = 'failed';
state.fetchError[channel] = action.error.message;
})
// sendMessage pending - optimistic update
.addCase(sendMessage.pending, (state, action) => {
state.sendStatus = 'loading';
state.sendError = null;
// Optimistic update: add message immediately
const { channel, message, optimisticId, userId, userName } = action.meta.arg;
if (!state.messagesByChannel[channel]) {
state.messagesByChannel[channel] = [];
}
state.messagesByChannel[channel].push({
id: optimisticId,
senderId: userId,
senderName: userName,
message,
createdAt: new Date().toISOString(),
channel,
isOptimistic: true
});
})
// sendMessage fulfilled - replace optimistic message
.addCase(sendMessage.fulfilled, (state, action) => {
state.sendStatus = 'succeeded';
state.sendError = null;
// Replace optimistic message with real one
const { channel, optimisticId } = action.meta.arg;
const realMessage = action.payload.message;
const messages = state.messagesByChannel[channel];
if (messages) {
const index = messages.findIndex(m => m.id === optimisticId);
if (index !== -1) {
messages[index] = {
id: realMessage.id,
senderId: realMessage.sender_id,
senderName: realMessage.sender_name,
message: realMessage.message,
createdAt: realMessage.created_at,
channel: realMessage.channel
};
}
}
})
// sendMessage rejected - remove optimistic message
.addCase(sendMessage.rejected, (state, action) => {
state.sendStatus = 'failed';
state.sendError = action.error.message;
// Remove optimistic message on failure
const { channel, optimisticId } = action.meta.arg;
const messages = state.messagesByChannel[channel];
if (messages) {
const index = messages.findIndex(m => m.id === optimisticId);
if (index !== -1) {
messages.splice(index, 1);
}
}
});
}
});