feat: deletion of messages

This commit is contained in:
Alexander Daichendt 2025-02-12 13:18:15 +01:00
parent 97f0483f9a
commit 4d87f86ff7
9 changed files with 111 additions and 24 deletions

View file

@ -1,17 +1,25 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { ClientChatMessage, ClientMessage } from "../../../shared";
interface ChatInputProps { interface ChatInputProps {
onSendMessage: (content: string) => void; sendMessage: (message: ClientMessage) => void;
} }
function ChatInput({ onSendMessage }: ChatInputProps) { function ChatInput({ sendMessage }: ChatInputProps) {
const [message, setMessage] = useState(""); const [message, setMessage] = useState("");
function handleSubmit(e: React.FormEvent) { function handleSubmit(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
if (!message.trim()) return; if (!message.trim()) return;
onSendMessage(message); const chatMessage: ClientChatMessage = {
type: "CHAT_MESSAGE",
payload: {
content: message,
},
};
sendMessage(chatMessage);
setMessage(""); setMessage("");
} }

View file

@ -4,11 +4,19 @@ import { ChatMessage } from "../../../shared";
function ChatMessageDisplay({ function ChatMessageDisplay({
message, message,
userId, userId,
onDelete,
onEdit,
}: { }: {
message: ChatMessage; message: ChatMessage;
userId: string | undefined; // pass userId as string to avoid rerendering. if we useChatState here, it would rerender every single message every time a new chat message is added. userId: string | undefined;
onDelete: (messageId: string) => void;
onEdit: (messageId: string) => void;
}) { }) {
const isOwn = userId === message.author.userId; const isOwn = userId === message.author.userId;
const timestamp = new Date(message.timestamp).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
});
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"}`}>
@ -19,21 +27,38 @@ function ChatMessageDisplay({
{!isOwn && ( {!isOwn && (
<span className="font-bold text-gray-700">{message.author.name}</span> <span className="font-bold text-gray-700">{message.author.name}</span>
)} )}
<span className="font-medium opacity-45"> <span className="font-medium opacity-45">{timestamp}</span>
{new Date(message.timestamp).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
})}
</span>
{isOwn && ( {isOwn && (
<span className="font-bold text-gray-700">{message.author.name}</span> <span className="font-bold text-gray-700">{message.author.name}</span>
)} )}
</div> </div>
<div <div className="relative">
className={`max-w-[80%] p-3 rounded-2xl shadow-sm bg-gray-200 <div
${isOwn ? "ml-auto rounded-tr-none" : "mr-auto rounded-tl-none"}`} className={`max-w-[80%] p-3 rounded-2xl shadow-sm bg-gray-200
> ${isOwn ? "ml-auto rounded-tr-none" : "mr-auto rounded-tl-none"}`}
{message.content} >
{message.content}
</div>
{isOwn && (
<div
className={`flex gap-2 mt-0.5 text-[10px]
${isOwn ? "justify-end" : "justify-start"}`}
>
<button
onClick={() => onEdit?.(message.id)}
className="text-blue-600 hover:text-blue-800 active:text-blue-900"
>
Edit
</button>
<span className="text-gray-400"></span>
<button
onClick={() => onDelete?.(message.id)}
className="text-red-600 hover:text-red-800 active:text-red-900"
>
Delete
</button>
</div>
)}
</div> </div>
</div> </div>
); );

View file

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

View file

@ -1,9 +1,17 @@
import React, { useEffect, useRef } from "react"; import React, { useEffect, useRef } from "react";
import { ServerMessage } from "../../../shared"; import {
ClientDeleteMessage,
ClientMessage,
ServerMessage,
} from "../../../shared";
import { useChatState } from "../contexts/ChatContext"; import { useChatState } from "../contexts/ChatContext";
import ChatMessage from "./ChatMessage"; import ChatMessage from "./ChatMessage";
function ChatMessages() { interface MessageDisplayProps {
sendMessage: (message: ClientMessage) => void;
}
function ChatMessages({ sendMessage }: MessageDisplayProps) {
const { state } = useChatState(); const { state } = useChatState();
const userId = state.currentUser?.userId; const userId = state.currentUser?.userId;
const messagesEndRef = useRef<HTMLDivElement>(null); // ref to an empty div at the end to scroll to const messagesEndRef = useRef<HTMLDivElement>(null); // ref to an empty div at the end to scroll to
@ -12,6 +20,19 @@ function ChatMessages() {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}; };
const onDelete = (messageId: string) => {
const deleteMessage: ClientDeleteMessage = {
type: "DELETE_MESSAGE",
payload: {
messageId,
},
};
sendMessage(deleteMessage);
};
const onEdit = (messageId: string) => {};
// scroll to bottom when messages change // scroll to bottom when messages change
useEffect(() => { useEffect(() => {
scrollToBottom(); scrollToBottom();
@ -25,6 +46,8 @@ function ChatMessages() {
message={message.payload} message={message.payload}
userId={userId} userId={userId}
key={message.payload.id} key={message.payload.id}
onDelete={onDelete}
onEdit={onEdit}
/> />
); );
case "USER_JOINED": case "USER_JOINED":

View file

@ -19,12 +19,14 @@ export type ChatState = {
export type ChatAction = export type ChatAction =
| { type: "ADD_MESSAGE"; payload: Message } | { type: "ADD_MESSAGE"; payload: Message }
| { type: "DELETE_MESSAGE"; payload: string }
| { type: "SET_USER"; payload: User } | { type: "SET_USER"; payload: User }
| { type: "SET_CONNECTED"; payload: boolean } | { type: "SET_CONNECTED"; payload: boolean }
| { type: "CLEAR_MESSAGES" }; | { type: "CLEAR_MESSAGES" };
export type ChatActions = { export type ChatActions = {
addMessage: (message: Message) => void; addMessage: (message: Message) => void;
deleteMessage: (messageId: string) => void;
setUser: (user: User) => void; setUser: (user: User) => void;
setConnected: (isConnected: boolean) => void; setConnected: (isConnected: boolean) => void;
}; };

View file

@ -28,6 +28,10 @@ export function useWebSocket(roomId: string) {
actions.addMessage(message); actions.addMessage(message);
break; break;
case "MESSAGE_DELETED":
actions.deleteMessage(message.payload.messageId);
break;
default: default:
console.error("Unknown message type", message); console.error("Unknown message type", message);
} }
@ -37,12 +41,8 @@ export function useWebSocket(roomId: string) {
// Send a message to the server // Send a message to the server
const sendMessage = useCallback( const sendMessage = useCallback(
(content: string) => { (message: ClientMessage) => {
if (ws?.readyState === WebSocket.OPEN) { if (ws?.readyState === WebSocket.OPEN) {
const message: ClientMessage = {
type: "CHAT_MESSAGE",
payload: { content },
};
ws.send(JSON.stringify(message)); ws.send(JSON.stringify(message));
} else { } else {
console.error("WebSocket connection not established"); console.error("WebSocket connection not established");

View file

@ -33,8 +33,8 @@ function Room() {
{!isConnected && <Connect onConnect={connect} />} {!isConnected && <Connect onConnect={connect} />}
{isConnected && ( {isConnected && (
<> <>
<ChatMessages /> <ChatMessages sendMessage={sendMessage} />
<ChatInput onSendMessage={sendMessage} /> <ChatInput sendMessage={sendMessage} />
</> </>
)} )}
</section> </section>

View file

@ -2,6 +2,17 @@ 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 "DELETE_MESSAGE":
return {
...state,
messages: state.messages.filter((message) => {
// only potentially delete chat messages, dont touch system messages
if ("type" in message && message.type === "CHAT_MESSAGE") {
return message.payload.id !== action.payload;
}
return true;
}),
};
case "ADD_MESSAGE": case "ADD_MESSAGE":
return { return {
...state, ...state,

View file

@ -2,6 +2,7 @@ import { randomUUIDv7, type ServerWebSocket } from "bun";
import type { import type {
ClientMessage, ClientMessage,
ServerChatMessage, ServerChatMessage,
ServerMessageDeletedMessage,
ServerRegistrationConfirmed, ServerRegistrationConfirmed,
ServerUserJoinedMessage, ServerUserJoinedMessage,
User, User,
@ -61,5 +62,20 @@ export default function handleClientMessage(
socket.send(JSON.stringify(chatMessage)); socket.send(JSON.stringify(chatMessage));
} }
break; break;
case "DELETE_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 deleteMessage: ServerMessageDeletedMessage = {
type: "MESSAGE_DELETED",
payload: {
messageId: data.payload.messageId,
},
};
for (const [socket] of room.userConnections) {
socket.send(JSON.stringify(deleteMessage));
}
} }
} }