feat: editing of messages

This commit is contained in:
Alexander Daichendt 2025-02-12 13:28:28 +01:00
parent 4d87f86ff7
commit f27adc3360
7 changed files with 115 additions and 11 deletions

View file

@ -1,4 +1,4 @@
import React from "react"; import React, { useState } from "react";
import { ChatMessage } from "../../../shared"; import { ChatMessage } from "../../../shared";
function ChatMessageDisplay({ function ChatMessageDisplay({
@ -10,14 +10,28 @@ function ChatMessageDisplay({
message: ChatMessage; message: ChatMessage;
userId: string | undefined; userId: string | undefined;
onDelete: (messageId: string) => void; onDelete: (messageId: string) => void;
onEdit: (messageId: string) => void; onEdit: (messageId: string, newContent: string) => void;
}) { }) {
const [isEditing, setIsEditing] = useState(false);
const [editContent, setEditContent] = useState(message.content);
const isOwn = userId === message.author.userId; const isOwn = userId === message.author.userId;
const timestamp = new Date(message.timestamp).toLocaleTimeString([], { const timestamp = new Date(message.timestamp).toLocaleTimeString([], {
hour: "2-digit", hour: "2-digit",
minute: "2-digit", minute: "2-digit",
}); });
const handleSave = () => {
if (editContent.trim() !== message.content) {
onEdit(message.id, editContent);
}
setIsEditing(false);
};
const handleCancel = () => {
setEditContent(message.content);
setIsEditing(false);
};
return ( return (
<div key={message.id} className={`mb-4 ${isOwn ? "ml-auto" : "mr-auto"}`}> <div key={message.id} className={`mb-4 ${isOwn ? "ml-auto" : "mr-auto"}`}>
<div <div
@ -33,19 +47,47 @@ function ChatMessageDisplay({
)} )}
</div> </div>
<div className="relative"> <div className="relative">
{isEditing ? (
<div className={`max-w-[80%] ${isOwn ? "ml-auto" : "mr-auto"}`}>
<textarea
value={editContent}
onChange={(e) => setEditContent(e.target.value)}
className="w-full p-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
rows={2}
autoFocus
/>
<div
className={`flex gap-2 mt-2 ${isOwn ? "justify-end" : "justify-start"}`}
>
<button
onClick={handleSave}
className="px-3 py-1 text-sm text-white bg-blue-600 rounded hover:bg-blue-700"
>
Save
</button>
<button
onClick={handleCancel}
className="px-3 py-1 text-sm text-gray-600 bg-gray-200 rounded hover:bg-gray-300"
>
Cancel
</button>
</div>
</div>
) : (
<div <div
className={`max-w-[80%] p-3 rounded-2xl shadow-sm bg-gray-200 className={`max-w-[80%] p-3 rounded-2xl shadow-sm bg-gray-200
${isOwn ? "ml-auto rounded-tr-none" : "mr-auto rounded-tl-none"}`} ${isOwn ? "ml-auto rounded-tr-none" : "mr-auto rounded-tl-none"}`}
> >
{message.content} {message.content}
</div> </div>
{isOwn && ( )}
{isOwn && !isEditing && (
<div <div
className={`flex gap-2 mt-0.5 text-[10px] className={`flex gap-2 mt-0.5 text-[10px]
${isOwn ? "justify-end" : "justify-start"}`} ${isOwn ? "justify-end" : "justify-start"}`}
> >
<button <button
onClick={() => onEdit?.(message.id)} onClick={() => setIsEditing(true)}
className="text-blue-600 hover:text-blue-800 active:text-blue-900" className="text-blue-600 hover:text-blue-800 active:text-blue-900"
> >
Edit Edit

View file

@ -14,6 +14,8 @@ export function ChatProvider({ children }: { children: React.ReactNode }) {
state, state,
dispatch, dispatch,
actions: { actions: {
editMessage: (messageId: string, newContent: string) =>
dispatch({ type: "EDIT_MESSAGE", payload: { messageId, newContent } }),
deleteMessage: (messageId: string) => deleteMessage: (messageId: string) =>
dispatch({ type: "DELETE_MESSAGE", payload: messageId }), dispatch({ type: "DELETE_MESSAGE", payload: messageId }),
addMessage: (message: Message) => addMessage: (message: Message) =>

View file

@ -1,6 +1,7 @@
import React, { useEffect, useRef } from "react"; import React, { useEffect, useRef } from "react";
import { import {
ClientDeleteMessage, ClientDeleteMessage,
ClientEditMessage,
ClientMessage, ClientMessage,
ServerMessage, ServerMessage,
} from "../../../shared"; } from "../../../shared";
@ -31,7 +32,17 @@ function ChatMessages({ sendMessage }: MessageDisplayProps) {
sendMessage(deleteMessage); sendMessage(deleteMessage);
}; };
const onEdit = (messageId: string) => {}; const onEdit = (messageId: string, newContent: string) => {
const editMessage: ClientEditMessage = {
type: "EDIT_MESSAGE",
payload: {
messageId,
content: newContent,
},
};
sendMessage(editMessage);
};
// scroll to bottom when messages change // scroll to bottom when messages change
useEffect(() => { useEffect(() => {

View file

@ -18,6 +18,7 @@ export type ChatState = {
}; };
export type ChatAction = export type ChatAction =
| { type: "EDIT_MESSAGE"; payload: { messageId: string; newContent: string } }
| { type: "ADD_MESSAGE"; payload: Message } | { type: "ADD_MESSAGE"; payload: Message }
| { type: "DELETE_MESSAGE"; payload: string } | { type: "DELETE_MESSAGE"; payload: string }
| { type: "SET_USER"; payload: User } | { type: "SET_USER"; payload: User }
@ -25,6 +26,7 @@ export type ChatAction =
| { type: "CLEAR_MESSAGES" }; | { type: "CLEAR_MESSAGES" };
export type ChatActions = { export type ChatActions = {
editMessage: (messageId: string, newContent: string) => void;
addMessage: (message: Message) => void; addMessage: (message: Message) => void;
deleteMessage: (messageId: string) => void; deleteMessage: (messageId: string) => void;
setUser: (user: User) => void; setUser: (user: User) => void;

View file

@ -32,6 +32,13 @@ export function useWebSocket(roomId: string) {
actions.deleteMessage(message.payload.messageId); actions.deleteMessage(message.payload.messageId);
break; break;
case "MESSAGE_EDITED":
actions.editMessage(
message.payload.messageId,
message.payload.content,
);
break;
default: default:
console.error("Unknown message type", message); console.error("Unknown message type", message);
} }

View file

@ -2,6 +2,25 @@ import { ChatAction, ChatState } from "../contexts/ChatContext";
export function chatReducer(state: ChatState, action: ChatAction): ChatState { export function chatReducer(state: ChatState, action: ChatAction): ChatState {
switch (action.type) { switch (action.type) {
case "EDIT_MESSAGE":
return {
...state,
messages: state.messages.map((message) => {
if ("type" in message && message.type === "CHAT_MESSAGE") {
if (message.payload.id === action.payload.messageId) {
return {
...message,
payload: {
...message.payload,
content: action.payload.newContent,
},
};
}
}
return message;
}),
};
case "DELETE_MESSAGE": case "DELETE_MESSAGE":
return { return {
...state, ...state,

View file

@ -3,6 +3,7 @@ import type {
ClientMessage, ClientMessage,
ServerChatMessage, ServerChatMessage,
ServerMessageDeletedMessage, ServerMessageDeletedMessage,
ServerMessageEditedMessage,
ServerRegistrationConfirmed, ServerRegistrationConfirmed,
ServerUserJoinedMessage, ServerUserJoinedMessage,
User, User,
@ -77,5 +78,25 @@ export default function handleClientMessage(
for (const [socket] of room.userConnections) { for (const [socket] of room.userConnections) {
socket.send(JSON.stringify(deleteMessage)); socket.send(JSON.stringify(deleteMessage));
} }
break;
case "EDIT_MESSAGE":
// verify that the sender is the author of the message
// TODO: implement this, would need to store all messages on the server side
const editMessage: ServerMessageEditedMessage = {
type: "MESSAGE_EDITED",
payload: {
messageId: data.payload.messageId,
content: data.payload.content,
},
};
for (const [socket] of room.userConnections) {
socket.send(JSON.stringify(editMessage));
}
break;
default:
console.error("Unknown message type", data);
} }
} }