feat: deletion of messages
This commit is contained in:
parent
97f0483f9a
commit
4d87f86ff7
9 changed files with 111 additions and 24 deletions
|
|
@ -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("");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,22 +27,39 @@ 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 className="relative">
|
||||||
<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 && (
|
||||||
|
<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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 }),
|
||||||
|
|
|
||||||
|
|
@ -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":
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue