code refactor, added animated message deletion, remove message button is not displayed only where you can actually use it

This commit is contained in:
slawk0
2024-12-22 23:58:11 +01:00
parent 0b06201ade
commit fb3501e21f
9 changed files with 135 additions and 84 deletions

View File

@@ -1,5 +1,5 @@
import { axiosClient } from '../App.tsx'; import { axiosClient } from '../App.tsx';
import { ChatMessages } from '../pages/Chat.tsx'; import { ChatMessagesProps } from '../pages/Chat.tsx';
import { ContactsProps } from '../pages/Chat.tsx'; import { ContactsProps } from '../pages/Chat.tsx';
export async function getContactsList(): Promise<ContactsProps[]> { export async function getContactsList(): Promise<ContactsProps[]> {
@@ -43,7 +43,7 @@ export async function getMessages(
contact: string | null, contact: string | null,
cursor: number | null = 0, cursor: number | null = 0,
limit: number = 50, limit: number = 50,
): Promise<{ messages: ChatMessages[] }> { ): Promise<{ messages: ChatMessagesProps[] }> {
if (contact === null || cursor === null) { if (contact === null || cursor === null) {
return { messages: [] }; return { messages: [] };
} }

View File

@@ -0,0 +1,68 @@
import { useState } from 'react';
import { Trash2 } from 'lucide-react';
import AttachmentPreview from './AttachmentPreview';
import { ChatMessagesProps } from '@/pages/Chat.tsx';
type AnimatedMessageProps = {
message: ChatMessagesProps;
isAdmin: boolean;
currentUsername: string | null;
onDelete: (messageId: number) => void;
};
const AnimatedMessage = ({
message,
isAdmin,
currentUsername,
onDelete,
}: AnimatedMessageProps) => {
const [isRemoving, setIsRemoving] = useState(false);
const handleDelete = () => {
setIsRemoving(true);
setTimeout(() => {
onDelete(message.message_id);
}, 300);
};
return (
<li
className={`whitespace-pre-wrap ml-2 rounded p-1 group transition-all duration-300 ${
message.pending ? 'text-gray-400' : 'hover:bg-gray-900'
} ${isRemoving ? 'opacity-0 -translate-x-full' : 'opacity-100'}`}
>
<div className="flex items-center justify-between">
<div>
<span
title={`${new Intl.DateTimeFormat('en-GB', {
timeStyle: 'medium',
dateStyle: 'short',
})?.format(message.sent_at)}`}
>
{message.sender}: {message.message}
</span>
{message.attachment_urls && (
<div className="mt-2 flex flex-col gap-2">
{message.attachment_urls.length > 0
? message.attachment_urls.map((url, index) => (
<AttachmentPreview
key={`${message.message_id}-${index}`}
url={url}
/>
))
: null}
</div>
)}
</div>
{message.sender === currentUsername || isAdmin ? (
<Trash2
className="opacity-0 group-hover:opacity-100 h-5 w-5 ml-2 flex-shrink-0 cursor-pointer text-gray-400 hover:text-red-500 transition-colors duration-200"
onClick={handleDelete}
/>
) : null}
</div>
</li>
);
};
export default AnimatedMessage;

View File

@@ -1,6 +1,6 @@
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import { getContactsList } from '../../api/contactsApi.tsx'; import { getContactsList } from '../../api/contactsApi.tsx';
import { ChatMessages, ContactsProps } from '../../pages/Chat.tsx'; import { ChatMessagesProps, ContactsProps } from '../../pages/Chat.tsx';
import { socket } from '../../socket/socket.tsx'; import { socket } from '../../socket/socket.tsx';
import GroupIcon from '../../../assets/group.svg'; import GroupIcon from '../../../assets/group.svg';
import { import {
@@ -21,7 +21,7 @@ type ContactsListProps = {
contactsList: ContactsProps[]; contactsList: ContactsProps[];
setCurrentContact: React.Dispatch<React.SetStateAction<ContactsProps | null>>; setCurrentContact: React.Dispatch<React.SetStateAction<ContactsProps | null>>;
updateContactStatus: (contactObj: ContactsProps, read: boolean) => void; updateContactStatus: (contactObj: ContactsProps, read: boolean) => void;
setMessages: React.Dispatch<React.SetStateAction<ChatMessages[]>>; setMessages: React.Dispatch<React.SetStateAction<ChatMessagesProps[]>>;
currentContact: ContactsProps | null; currentContact: ContactsProps | null;
setErrorMessage: React.Dispatch<React.SetStateAction<string | null>>; setErrorMessage: React.Dispatch<React.SetStateAction<string | null>>;
}; };

View File

@@ -2,7 +2,7 @@ import { useRef, useCallback, useEffect, useState } from 'react';
import { useForm, SubmitHandler } from 'react-hook-form'; import { useForm, SubmitHandler } from 'react-hook-form';
import type { KeyboardEventHandler } from 'react'; import type { KeyboardEventHandler } from 'react';
import { socket } from '../../socket/socket.tsx'; import { socket } from '../../socket/socket.tsx';
import { ChatMessages, ContactsProps } from '../../pages/Chat.tsx'; import { ChatMessagesProps, ContactsProps } from '../../pages/Chat.tsx';
import { axiosClient } from '../../App.tsx'; import { axiosClient } from '../../App.tsx';
import { File, Paperclip, X } from 'lucide-react'; import { File, Paperclip, X } from 'lucide-react';
import LoadingWheel from '@/components/chat/LoadingWheel.tsx'; import LoadingWheel from '@/components/chat/LoadingWheel.tsx';
@@ -14,7 +14,7 @@ type Input = {
type MessageFormProps = { type MessageFormProps = {
contact: ContactsProps; contact: ContactsProps;
messages: ChatMessages[]; messages: ChatMessagesProps[];
}; };
type FileWithPreview = { type FileWithPreview = {

View File

@@ -3,22 +3,22 @@ import { socket } from '../../socket/socket.tsx';
import { useOutletContext } from 'react-router-dom'; import { useOutletContext } from 'react-router-dom';
import { sendContact } from '../../api/contactsApi.tsx'; import { sendContact } from '../../api/contactsApi.tsx';
import LoadingWheel from './LoadingWheel.tsx'; import LoadingWheel from './LoadingWheel.tsx';
import { ChatMessages } from '../../pages/Chat.tsx'; import { ChatMessagesProps, MeProps } from '../../pages/Chat.tsx';
import { ContactsProps } from '../../pages/Chat.tsx'; import { ContactsProps } from '../../pages/Chat.tsx';
import { Trash2 } from 'lucide-react';
import AttachmentPreview from './AttachmentPreview.tsx';
import { UsernameType } from '@/utils/ProtectedRoutes.tsx'; import { UsernameType } from '@/utils/ProtectedRoutes.tsx';
import AnimatedMessage from '@/components/chat/AnimatedMessage.tsx';
type MessagesAreaProps = { type MessagesAreaProps = {
messages: ChatMessages[]; messages: ChatMessagesProps[];
setMessages: React.Dispatch<React.SetStateAction<ChatMessages[]>>; setMessages: React.Dispatch<React.SetStateAction<ChatMessagesProps[]>>;
currentContact: ContactsProps | null; currentContact: ContactsProps | null;
updateContactStatus: (contact: ContactsProps, read: boolean) => void; updateContactStatus: (contact: ContactsProps, read: boolean) => void;
messageHandler: (msg: ChatMessages) => void; messageHandler: (msg: ChatMessagesProps) => void;
setContactsList: React.Dispatch<React.SetStateAction<ContactsProps[]>>; setContactsList: React.Dispatch<React.SetStateAction<ContactsProps[]>>;
fetchPreviousMessages: (contact: string | null) => Promise<void>; fetchPreviousMessages: (contact: string | null) => Promise<void>;
errorMessage: string | null; errorMessage: string | null;
contactsList: ContactsProps[]; contactsList: ContactsProps[];
me: MeProps;
}; };
function MessagesArea({ function MessagesArea({
@@ -31,6 +31,7 @@ function MessagesArea({
errorMessage, errorMessage,
setMessages, setMessages,
contactsList, contactsList,
me,
}: MessagesAreaProps) { }: MessagesAreaProps) {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const user: UsernameType = useOutletContext(); const user: UsernameType = useOutletContext();
@@ -73,7 +74,6 @@ function MessagesArea({
const deleteMessage = async (message_id: number) => { const deleteMessage = async (message_id: number) => {
try { try {
console.log('delete message: ', message_id);
socket?.emit( socket?.emit(
'delete message', 'delete message',
{ {
@@ -101,7 +101,7 @@ function MessagesArea({
currentContainer.addEventListener('scroll', handleScroll); currentContainer.addEventListener('scroll', handleScroll);
} }
socket.on('chat message', (msg: ChatMessages) => { socket.on('chat message', (msg: ChatMessagesProps) => {
console.log('Received message: ', msg); console.log('Received message: ', msg);
if ( if (
msg.conversation_id !== currentContact?.conversation_id && msg.conversation_id !== currentContact?.conversation_id &&
@@ -233,52 +233,20 @@ function MessagesArea({
scrollToBottom(); scrollToBottom();
}, []); }, []);
const messageList = messages.map((msg: ChatMessages) => (
<li
className={`whitespace-pre-wrap ml-2 rounded p-1 group ${
msg.pending ? 'text-gray-400' : 'hover:bg-gray-900'
}`}
key={msg.message_id}
>
<div className="flex items-center justify-between">
<div>
<span
title={`${new Intl.DateTimeFormat('en-Gb', {
timeStyle: 'medium',
dateStyle: 'short',
})?.format(msg.sent_at)}`}
>
{msg.sender}: {msg.message}
</span>
{msg.attachment_urls && (
<div className="mt-2 flex flex-col gap-2">
{msg.attachment_urls.length > 0
? msg.attachment_urls.map((url, index) => (
<AttachmentPreview
key={`${msg.message_id}-${index}`}
url={url}
/>
))
: null}
</div>
)}
</div>
<Trash2
className="opacity-0 group-hover:opacity-100 h-5 w-5 ml-2 flex-shrink-0 cursor-pointer text-gray-400 hover:text-red-500"
onClick={() => {
deleteMessage(msg.message_id);
}}
/>
</div>
</li>
));
return ( return (
<div ref={containerRef} className="flex flex-col h-full overflow-y-auto"> <div ref={containerRef} className="flex flex-col h-full overflow-y-auto">
<p className="text-center text-gray-400">{errorMessage}</p> <p className="text-center text-gray-400">{errorMessage}</p>
<ul className="flex-grow list-none"> <ul className="flex-grow list-none">
{isLoading ? <LoadingWheel /> : null} {isLoading ? <LoadingWheel /> : null}
{messageList} {messages.map((msg: ChatMessagesProps) => (
<AnimatedMessage
key={msg.message_id}
message={msg}
isAdmin={me.isGroupAdmin || me.isGroupOwner}
currentUsername={user.username}
onDelete={deleteMessage}
/>
))}
</ul> </ul>
</div> </div>
); );

View File

@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { axiosClient } from '@/App.tsx'; import { axiosClient } from '@/App.tsx';
import { ContactsProps } from '@/pages/Chat.tsx'; import { ContactsProps, MeProps } from '@/pages/Chat.tsx';
import { socket } from '@/socket/socket.tsx'; import { socket } from '@/socket/socket.tsx';
import { Crown, Sword } from 'lucide-react'; import { Crown, Sword } from 'lucide-react';
import { import {
@@ -22,22 +22,17 @@ type ParticipantsProps = {
type ParticipantsBarProps = { type ParticipantsBarProps = {
initializeContact: (contact: ContactsProps) => void; initializeContact: (contact: ContactsProps) => void;
currentContact: ContactsProps | null; currentContact: ContactsProps | null;
}; setMe: React.Dispatch<React.SetStateAction<MeProps>>;
me: MeProps;
type MeProps = {
isGroupAdmin: boolean;
iGroupOwner: boolean;
}; };
function ParticipantsBar({ function ParticipantsBar({
initializeContact, initializeContact,
currentContact, currentContact,
setMe,
me,
}: ParticipantsBarProps) { }: ParticipantsBarProps) {
const [participants, setParticipants] = useState<ParticipantsProps[]>([]); const [participants, setParticipants] = useState<ParticipantsProps[]>([]);
const [me, setMe] = useState<MeProps>({
isGroupAdmin: false,
iGroupOwner: false,
});
const user: UsernameType = useOutletContext(); const user: UsernameType = useOutletContext();
const getParticipants = async () => { const getParticipants = async () => {
@@ -122,7 +117,7 @@ function ParticipantsBar({
participant.user_id === user.user_id && participant.isowner, participant.user_id === user.user_id && participant.isowner,
); );
setMe({ isGroupAdmin: userIsAdmin, iGroupOwner: userIsOwner }); setMe({ isGroupAdmin: userIsAdmin, isGroupOwner: userIsOwner });
} }
}, [participants, user?.user_id]); }, [participants, user?.user_id]);
@@ -216,6 +211,9 @@ function ParticipantsBar({
}) => { }) => {
console.log('(socket) removed administrator: ', msg); console.log('(socket) removed administrator: ', msg);
if (msg.group_id === currentContact.conversation_id) { if (msg.group_id === currentContact.conversation_id) {
if (msg.user_id === user?.user_id) {
setMe({ isGroupAdmin: false, isGroupOwner: false });
}
setParticipants((prevMembers) => setParticipants((prevMembers) =>
prevMembers.map((member) => prevMembers.map((member) =>
member.user_id === msg.user_id member.user_id === msg.user_id
@@ -262,7 +260,7 @@ function ParticipantsBar({
</ContextMenuTrigger> </ContextMenuTrigger>
{user.user_id !== participant.user_id && {user.user_id !== participant.user_id &&
me.isGroupAdmin && me.isGroupAdmin &&
(!participant.isadmin || me.iGroupOwner) ? ( (!participant.isadmin || me.isGroupOwner) ? (
<ContextMenuContent className="p-0"> <ContextMenuContent className="p-0">
<ContextMenuItem <ContextMenuItem
className="bg-zinc-900 text-white outline-1 hover:bg-zinc-800 hover:cursor-pointer" className="bg-zinc-900 text-white outline-1 hover:bg-zinc-800 hover:cursor-pointer"

View File

@@ -11,7 +11,12 @@ import { getMessages, setContactStatus } from '../api/contactsApi.tsx';
import axios from 'axios'; import axios from 'axios';
import ParticipantsBar from '@/components/chat/ParticipantsBar.tsx'; import ParticipantsBar from '@/components/chat/ParticipantsBar.tsx';
export type ChatMessages = { export type MeProps = {
isGroupAdmin: boolean;
isGroupOwner: boolean;
};
export type ChatMessagesProps = {
sender: string; sender: string;
message: string; message: string;
recipient: string; // conversation_id recipient: string; // conversation_id
@@ -39,9 +44,13 @@ function Chat() {
null, null,
); );
const [cursor, setCursor] = useState<number>(0); const [cursor, setCursor] = useState<number>(0);
const [messages, setMessages] = useState<ChatMessages[]>([]); const [messages, setMessages] = useState<ChatMessagesProps[]>([]);
const [errorMessage, setErrorMessage] = useState<string | null>(null); const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [hasMoreMessages, setHasMoreMessages] = useState<boolean>(true); const [hasMoreMessages, setHasMoreMessages] = useState<boolean>(true);
const [me, setMe] = useState<MeProps>({
isGroupAdmin: false,
isGroupOwner: false,
});
useEffect(() => { useEffect(() => {
const token = Cookies.get('token'); const token = Cookies.get('token');
@@ -161,7 +170,7 @@ function Chat() {
} }
}; };
function messageHandler(msg: ChatMessages) { function messageHandler(msg: ChatMessagesProps) {
setMessages((prevMessages) => { setMessages((prevMessages) => {
// Check if the message already exists in the state // Check if the message already exists in the state
if (!prevMessages.some((m) => m.message_id === msg.message_id)) { if (!prevMessages.some((m) => m.message_id === msg.message_id)) {
@@ -236,6 +245,7 @@ function Chat() {
fetchPreviousMessages={fetchPreviousMessages} fetchPreviousMessages={fetchPreviousMessages}
errorMessage={errorMessage} errorMessage={errorMessage}
contactsList={contactsList} contactsList={contactsList}
me={me}
/> />
</div> </div>
@@ -243,6 +253,8 @@ function Chat() {
{currentContact?.type == 'group' ? ( {currentContact?.type == 'group' ? (
<div className="w-80 bg-[#1E1E1E] flex-shrink-0"> <div className="w-80 bg-[#1E1E1E] flex-shrink-0">
<ParticipantsBar <ParticipantsBar
setMe={setMe}
me={me}
initializeContact={initializeContact} initializeContact={initializeContact}
currentContact={currentContact} currentContact={currentContact}
/> />

View File

@@ -810,7 +810,7 @@ async function contactSuggestion(username) {
} }
} }
async function deleteMessage(user_id, message_id) { async function deleteMessage(user_id, conversation_id, message_id) {
const checkMessageOwnershipQuery = ` const checkMessageOwnershipQuery = `
SELECT user_id FROM Messages WHERE message_id = $1; SELECT user_id FROM Messages WHERE message_id = $1;
`; `;
@@ -820,17 +820,19 @@ async function deleteMessage(user_id, message_id) {
`; `;
try { try {
const checkResult = await client.query(checkMessageOwnershipQuery, [ const isAdminResult = await isAdmin(user_id, conversation_id);
message_id, if (!isAdminResult) {
]); const ownershipResult = await client.query(checkMessageOwnershipQuery, [
if (checkResult.rows.length === 0) { message_id,
return { message: "Message not found." }; ]);
} if (ownershipResult.rows.length === 0) {
return { message: "Message not found." };
const messageOwnerId = checkResult.rows[0].user_id; }
if (user_id !== messageOwnerId) { const messageOwnerId = ownershipResult.rows[0].user_id;
console.error("User is not authorized to delete this message"); if (user_id !== messageOwnerId) {
return { message: "It's not your message bro" }; console.error("User is not authorized to delete this message");
return { message: "It's not your message bro" };
}
} }
const deleteResult = await client.query(deleteMessageQuery, [message_id]); const deleteResult = await client.query(deleteMessageQuery, [message_id]);
if (deleteResult.rowCount > 0) { if (deleteResult.rowCount > 0) {

View File

@@ -179,7 +179,6 @@ function initializeSocket(io) {
}); });
socket.on("delete message", async (msg, callback) => { socket.on("delete message", async (msg, callback) => {
console.log("(socket) delete message for message_id: ", msg);
const { conversation_id, message_id } = msg; const { conversation_id, message_id } = msg;
if (!message_id) { if (!message_id) {
return callback({ status: "error", message: "No message id provided" }); return callback({ status: "error", message: "No message id provided" });
@@ -191,7 +190,11 @@ function initializeSocket(io) {
}); });
} }
const result = await deleteMessage(socket.user_id, message_id); const result = await deleteMessage(
socket.user_id,
conversation_id,
message_id,
);
if (result?.message !== undefined) { if (result?.message !== undefined) {
return callback({ status: "error", message: result.message }); return callback({ status: "error", message: result.message });
} else { } else {