diff --git a/src/MessageQueueContext.tsx b/src/MessageQueueContext.tsx index 499b10b..3727b03 100644 --- a/src/MessageQueueContext.tsx +++ b/src/MessageQueueContext.tsx @@ -53,6 +53,7 @@ export const MessageQueueProvider = ({ children }) => { // Function to process the message queue const processQueue = useCallback((newMessages = [], groupDirectId) => { + processingPromiseRef.current = processingPromiseRef.current .then(() => processQueueInternal(newMessages, groupDirectId)) .catch((err) => console.error('Error in processQueue:', err)); @@ -61,33 +62,7 @@ export const MessageQueueProvider = ({ children }) => { // Internal function to handle queue processing const processQueueInternal = async (newMessages, groupDirectId) => { // Remove any messages from the queue that match the specialId from newMessages - if (newMessages.length > 0) { - messageQueueRef.current = messageQueueRef.current.filter((msg) => { - return !newMessages.some(newMsg => newMsg?.specialId === msg?.specialId); - }); - - // Remove corresponding entries in queueChats for the provided groupDirectId - setQueueChats((prev) => { - const updatedChats = { ...prev }; - if (updatedChats[groupDirectId]) { - updatedChats[groupDirectId] = updatedChats[groupDirectId].filter((chat) => { - return !newMessages.some(newMsg => newMsg?.specialId === chat?.message?.specialId); - }); - - // Remove messages with status 'failed-permanent' - updatedChats[groupDirectId] = updatedChats[groupDirectId].filter((chat) => { - return chat?.status !== 'failed-permanent'; - }); - - // If no more chats for this group, delete the groupDirectId entry - if (updatedChats[groupDirectId].length === 0) { - delete updatedChats[groupDirectId]; - } - } - return updatedChats; - }); - } - + // If the queue is empty, no need to process if (messageQueueRef.current.length === 0) return; @@ -112,11 +87,11 @@ export const MessageQueueProvider = ({ children }) => { try { // Execute the function stored in the messageQueueRef + await currentMessage.func(); // Remove the message from the queue after successful sending messageQueueRef.current.shift(); - // Remove the message from queueChats setQueueChats((prev) => { const updatedChats = { ...prev }; @@ -167,7 +142,33 @@ export const MessageQueueProvider = ({ children }) => { // Method to process with new messages and groupDirectId const processWithNewMessages = (newMessages, groupDirectId) => { - processQueue(newMessages, groupDirectId); + if (newMessages.length > 0) { + messageQueueRef.current = messageQueueRef.current.filter((msg) => { + return !newMessages.some(newMsg => newMsg?.specialId === msg?.specialId); + }); + + // Remove corresponding entries in queueChats for the provided groupDirectId + setQueueChats((prev) => { + const updatedChats = { ...prev }; + if (updatedChats[groupDirectId]) { + updatedChats[groupDirectId] = updatedChats[groupDirectId].filter((chat) => { + return !newMessages.some(newMsg => newMsg?.specialId === chat?.message?.specialId); + }); + + // Remove messages with status 'failed-permanent' + updatedChats[groupDirectId] = updatedChats[groupDirectId].filter((chat) => { + return chat?.status !== 'failed-permanent'; + }); + + // If no more chats for this group, delete the groupDirectId entry + if (updatedChats[groupDirectId].length === 0) { + delete updatedChats[groupDirectId]; + } + } + return updatedChats; + }); + } + }; return ( diff --git a/src/common/ErrorBoundary.tsx b/src/common/ErrorBoundary.tsx new file mode 100644 index 0000000..58b3185 --- /dev/null +++ b/src/common/ErrorBoundary.tsx @@ -0,0 +1,36 @@ +import React, { ReactNode } from 'react' + +interface ErrorBoundaryProps { + children: ReactNode + fallback: ReactNode +} + +interface ErrorBoundaryState { + hasError: boolean +} + +class ErrorBoundary extends React.Component< + ErrorBoundaryProps, + ErrorBoundaryState +> { + state: ErrorBoundaryState = { + hasError: false + } + + static getDerivedStateFromError(_: Error): ErrorBoundaryState { + return { hasError: true } + } + + componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void { + // You can log the error and errorInfo here, for example, to an error reporting service. + console.error('Error caught in ErrorBoundary:', error, errorInfo) + } + + render(): React.ReactNode { + if (this.state.hasError) return this.props.fallback + + return this.props.children + } +} + +export default ErrorBoundary diff --git a/src/components/Chat/ChatDirect.tsx b/src/components/Chat/ChatDirect.tsx index 634c83d..ab88847 100644 --- a/src/components/Chat/ChatDirect.tsx +++ b/src/components/Chat/ChatDirect.tsx @@ -28,6 +28,7 @@ const uid = new ShortUniqueId({ length: 5 }); export const ChatDirect = ({ myAddress, isNewChat, selectedDirect, setSelectedDirect, setNewChat, getTimestampEnterChat, myName, balance, close, setMobileViewModeKeepOpen}) => { const { queueChats, addToQueue, processWithNewMessages} = useMessageQueue(); const [isFocusedParent, setIsFocusedParent] = useState(false); + const [onEditMessage, setOnEditMessage] = useState(null) const [messages, setMessages] = useState([]) const [isSending, setIsSending] = useState(false) @@ -38,6 +39,8 @@ export const ChatDirect = ({ myAddress, isNewChat, selectedDirect, setSelectedDi const [infoSnack, setInfoSnack] = React.useState(null); const [publicKeyOfRecipient, setPublicKeyOfRecipient] = React.useState("") const hasInitializedWebsocket = useRef(false) + const [chatReferences, setChatReferences] = useState({}) + const editorRef = useRef(null); const socketRef = useRef(null); const timeoutIdRef = useRef(null); @@ -66,10 +69,19 @@ export const ChatDirect = ({ myAddress, isNewChat, selectedDirect, setSelectedDi const tempMessages = useMemo(()=> { if(!selectedDirect?.address) return [] if(queueChats[selectedDirect?.address]){ - return queueChats[selectedDirect?.address] + return queueChats[selectedDirect?.address]?.filter((item)=> !item?.chatReference) } return [] }, [selectedDirect?.address, queueChats]) + + const tempChatReferences = useMemo(()=> { + if(!selectedDirect?.address) return [] + if(queueChats[selectedDirect?.address]){ + return queueChats[selectedDirect?.address]?.filter((item)=> !!item?.chatReference) + } + return [] + }, [selectedDirect?.address, queueChats]) + useEffect(()=> { if(selectedDirect?.address){ publicKeyOfRecipientRef.current = selectedDirect?.address @@ -112,22 +124,54 @@ export const ChatDirect = ({ myAddress, isNewChat, selectedDirect, setSelectedDi res(response); if (isInitiated) { - const formatted = response.map((item) => ({ + const formatted = response.filter((rawItem) => !rawItem?.chatReference).map((item) => ({ ...item, id: item.signature, text: item.message, unread: item?.sender === myAddress ? false : true, })); setMessages((prev) => [...prev, ...formatted]); + setChatReferences((prev) => { + const organizedChatReferences = { ...prev }; + + response.filter((rawItem) => !!rawItem?.chatReference && rawItem?.type === 'edit').forEach((item) => { + try { + organizedChatReferences[item.chatReference] = { + ...(organizedChatReferences[item.chatReference] || {}), + edit: item + }; + } catch(error){ + + } + }) + return organizedChatReferences + }) } else { - const formatted = response.map((item) => ({ + hasInitialized.current = true; + const formatted = response.filter((rawItem) => !rawItem?.chatReference) + .map((item) => ({ ...item, id: item.signature, text: item.message, unread: false, })); setMessages(formatted); - hasInitialized.current = true; + + setChatReferences((prev) => { + const organizedChatReferences = { ...prev }; + + response.filter((rawItem) => !!rawItem?.chatReference && rawItem?.type === 'edit').forEach((item) => { + try { + organizedChatReferences[item.chatReference] = { + ...(organizedChatReferences[item.chatReference] || {}), + edit: item + }; + } catch(error){ + + } + }) + return organizedChatReferences + }) } return; } @@ -333,7 +377,7 @@ useEffect(() => { const sendMessage = async ()=> { try { - + if(+balance < 4) throw new Error('You need at least 4 QORT to send a message') if(isSending) return @@ -355,12 +399,16 @@ useEffect(() => { if (replyMessage?.chatReference) { repliedTo = replyMessage?.chatReference } + let chatReference = onEditMessage?.signature + const otherData = { + ...(onEditMessage?.decryptedData || {}), specialId: uid.rnd(), - repliedTo + repliedTo: onEditMessage ? onEditMessage?.repliedTo : repliedTo, + type: chatReference ? 'edit' : '' } const sendMessageFunc = async () => { - return await sendChatDirect({ chatReference: undefined, messageText: htmlContent, otherData}, selectedDirect?.address, publicKeyOfRecipient, false) + return await sendChatDirect({ chatReference, messageText: htmlContent, otherData}, selectedDirect?.address, publicKeyOfRecipient, false) }; @@ -368,13 +416,13 @@ useEffect(() => { // Add the function to the queue const messageObj = { message: { - text: htmlContent, timestamp: Date.now(), senderName: myName, sender: myAddress, - ...(otherData || {}) + ...(otherData || {}), + text: htmlContent, }, - + chatReference } addToQueue(sendMessageFunc, messageObj, 'chat-direct', selectedDirect?.address ); @@ -383,6 +431,8 @@ useEffect(() => { }, 150); clearEditorContent() setReplyMessage(null) + setOnEditMessage(null) + } // send chat message } catch (error) { @@ -399,12 +449,22 @@ useEffect(() => { } } - const onReply = useCallback((message)=> { - setReplyMessage(message) - editorRef?.current?.chain().focus() - - }, []) - + const onReply = useCallback((message)=> { + if(onEditMessage){ + editorRef.current.chain().focus().clearContent().run() + } + setReplyMessage(message) + setOnEditMessage(null) + editorRef?.current?.chain().focus() + }, [onEditMessage]) + + + const onEdit = useCallback((message)=> { + setOnEditMessage(message) + setReplyMessage(null) + editorRef.current.chain().focus().setContent(message?.text).run(); + + }, []) return (
{ )} - +
{ { setReplyMessage(null) + setOnEditMessage(null) + + }} + > + + + + )} + {onEditMessage && ( + + + + { + setReplyMessage(null) + setOnEditMessage(null) + + editorRef.current.chain().focus().clearContent().run() + }} > diff --git a/src/components/Chat/ChatGroup.tsx b/src/components/Chat/ChatGroup.tsx index aa0b01e..3222d87 100644 --- a/src/components/Chat/ChatGroup.tsx +++ b/src/components/Chat/ChatGroup.tsx @@ -35,6 +35,9 @@ export const ChatGroup = ({selectedGroup, secretKey, setSecretKey, getSecretKey, const hasInitialized = useRef(false) const [isFocusedParent, setIsFocusedParent] = useState(false); const [replyMessage, setReplyMessage] = useState(null) + const [onEditMessage, setOnEditMessage] = useState(null) + + const [messageSize, setMessageSize] = useState(0) const hasInitializedWebsocket = useRef(false) const socketRef = useRef(null); // WebSocket reference @@ -218,52 +221,59 @@ const [messageSize, setMessageSize] = useState(0) setChatReferences((prev) => { const organizedChatReferences = { ...prev }; - combineUIAndExtensionMsgs - .filter((rawItem) => rawItem && rawItem.chatReference && rawItem.decryptedData?.type === "reaction") + .filter((rawItem) => rawItem && rawItem.chatReference && (rawItem.decryptedData?.type === "reaction" || rawItem.decryptedData?.type === "edit")) .forEach((item) => { try { - const content = item.decryptedData?.content; - const sender = item.sender; - const newTimestamp = item.timestamp; - const contentState = item.decryptedData?.contentState; - - if (!content || typeof content !== "string" || !sender || typeof sender !== "string" || !newTimestamp) { - console.warn("Invalid content, sender, or timestamp in reaction data", item); - return; - } - - organizedChatReferences[item.chatReference] = { - ...(organizedChatReferences[item.chatReference] || {}), - reactions: organizedChatReferences[item.chatReference]?.reactions || {}, - }; - - organizedChatReferences[item.chatReference].reactions[content] = - organizedChatReferences[item.chatReference].reactions[content] || []; - - let latestTimestampForSender = null; - - organizedChatReferences[item.chatReference].reactions[content] = - organizedChatReferences[item.chatReference].reactions[content].filter((reaction) => { - if (reaction.sender === sender) { - latestTimestampForSender = Math.max(latestTimestampForSender || 0, reaction.timestamp); - } - return reaction.sender !== sender; - }); - - if (latestTimestampForSender && newTimestamp < latestTimestampForSender) { - return; - } - - if (contentState !== false) { - organizedChatReferences[item.chatReference].reactions[content].push(item); - } - - if (organizedChatReferences[item.chatReference].reactions[content].length === 0) { - delete organizedChatReferences[item.chatReference].reactions[content]; + if(item.decryptedData?.type === "edit"){ + organizedChatReferences[item.chatReference] = { + ...(organizedChatReferences[item.chatReference] || {}), + edit: item.decryptedData, + }; + } else { + const content = item.decryptedData?.content; + const sender = item.sender; + const newTimestamp = item.timestamp; + const contentState = item.decryptedData?.contentState; + + if (!content || typeof content !== "string" || !sender || typeof sender !== "string" || !newTimestamp) { + console.warn("Invalid content, sender, or timestamp in reaction data", item); + return; + } + + organizedChatReferences[item.chatReference] = { + ...(organizedChatReferences[item.chatReference] || {}), + reactions: organizedChatReferences[item.chatReference]?.reactions || {}, + }; + + organizedChatReferences[item.chatReference].reactions[content] = + organizedChatReferences[item.chatReference].reactions[content] || []; + + let latestTimestampForSender = null; + + organizedChatReferences[item.chatReference].reactions[content] = + organizedChatReferences[item.chatReference].reactions[content].filter((reaction) => { + if (reaction.sender === sender) { + latestTimestampForSender = Math.max(latestTimestampForSender || 0, reaction.timestamp); + } + return reaction.sender !== sender; + }); + + if (latestTimestampForSender && newTimestamp < latestTimestampForSender) { + return; + } + + if (contentState !== false) { + organizedChatReferences[item.chatReference].reactions[content].push(item); + } + + if (organizedChatReferences[item.chatReference].reactions[content].length === 0) { + delete organizedChatReferences[item.chatReference].reactions[content]; + } } + } catch (error) { - console.error("Error processing reaction item:", error, item); + console.error("Error processing reaction/edit item:", error, item); } }); @@ -295,9 +305,15 @@ const [messageSize, setMessageSize] = useState(0) const organizedChatReferences = { ...prev }; combineUIAndExtensionMsgs - .filter((rawItem) => rawItem && rawItem.chatReference && rawItem.decryptedData?.type === "reaction") + .filter((rawItem) => rawItem && rawItem.chatReference && (rawItem.decryptedData?.type === "reaction" || rawItem.decryptedData?.type === "edit")) .forEach((item) => { try { + if(item.decryptedData?.type === "edit"){ + organizedChatReferences[item.chatReference] = { + ...(organizedChatReferences[item.chatReference] || {}), + edit: item.decryptedData, + }; + } else { const content = item.decryptedData?.content; const sender = item.sender; const newTimestamp = item.timestamp; @@ -337,6 +353,7 @@ const [messageSize, setMessageSize] = useState(0) if (organizedChatReferences[item.chatReference].reactions[content].length === 0) { delete organizedChatReferences[item.chatReference].reactions[content]; } + } } catch (error) { console.error("Error processing reaction item:", error, item); } @@ -549,13 +566,17 @@ const clearEditorContent = () => { if (replyMessage?.chatReference) { repliedTo = replyMessage?.chatReference } + let chatReference = onEditMessage?.signature + const otherData = { + repliedTo, + ...(onEditMessage?.decryptedData || {}), + type: chatReference ? 'edit' : '', specialId: uid.rnd(), - repliedTo } const objectMessage = { - message, - ...(otherData || {}) + ...(otherData || {}), + message } const message64: any = await objectToBase64(objectMessage) @@ -563,7 +584,7 @@ const clearEditorContent = () => { // const res = await sendChatGroup({groupId: selectedGroup,messageText: encryptSingle}) const sendMessageFunc = async () => { - return await sendChatGroup({groupId: selectedGroup,messageText: encryptSingle}) + return await sendChatGroup({groupId: selectedGroup,messageText: encryptSingle, chatReference}) }; // Add the function to the queue @@ -575,7 +596,7 @@ const clearEditorContent = () => { sender: myAddress, ...(otherData || {}) }, - + chatReference } addToQueue(sendMessageFunc, messageObj, 'chat', selectedGroup ); @@ -584,6 +605,7 @@ const clearEditorContent = () => { }, 150); clearEditorContent() setReplyMessage(null) + setOnEditMessage(null) } // send chat message } catch (error) { @@ -627,10 +649,21 @@ const clearEditorContent = () => { }, [hide]); const onReply = useCallback((message)=> { + if(onEditMessage){ + editorRef.current.chain().focus().clearContent().run() + } setReplyMessage(message) + setOnEditMessage(null) editorRef?.current?.chain().focus() - }, []) + }, [onEditMessage]) + + const onEdit = useCallback((message)=> { + setOnEditMessage(message) + setReplyMessage(null) + editorRef.current.chain().focus().setContent(message?.text).run(); + + }, []) const handleReaction = useCallback(async (reaction, chatMessage, reactionState = true)=> { try { @@ -708,7 +741,7 @@ const clearEditorContent = () => { left: hide && '-100000px', }}> - +
{ { setReplyMessage(null) + + setOnEditMessage(null) + + }} + > + + + + )} + {onEditMessage && ( + + + + { + setReplyMessage(null) + setOnEditMessage(null) + + editorRef.current.chain().focus().clearContent().run() + }} > diff --git a/src/components/Chat/ChatList.tsx b/src/components/Chat/ChatList.tsx index eec5530..6e0e59a 100644 --- a/src/components/Chat/ChatList.tsx +++ b/src/components/Chat/ChatList.tsx @@ -9,8 +9,9 @@ import { useVirtualizer } from "@tanstack/react-virtual"; import { MessageItem } from "./MessageItem"; import { subscribeToEvent, unsubscribeFromEvent } from "../../utils/events"; import { useInView } from "react-intersection-observer"; -import { Box } from "@mui/material"; +import { Box, Typography } from "@mui/material"; import { ChatOptions } from "./ChatOptions"; +import ErrorBoundary from "../../common/ErrorBoundary"; export const ChatList = ({ initialMessages, @@ -18,6 +19,7 @@ export const ChatList = ({ tempMessages, chatId, onReply, + onEdit, handleReaction, chatReferences, tempChatReferences, @@ -155,7 +157,6 @@ export const ChatList = ({ // Check if the user is within 200px from the bottom const distanceFromBottom = scrollHeight - scrollTop - clientHeight; - console.log("distanceFromBottom", distanceFromBottom); if (distanceFromBottom <= 700) { scrollToBottom(); } @@ -177,7 +178,6 @@ export const ChatList = ({ const goToMessage = useCallback((idx) => { rowVirtualizer.scrollToIndex(idx); }, []); - return ( + {rowVirtualizer.getVirtualItems().map((virtualRow) => { const index = virtualRow.index; - let message = messages[index]; - let replyIndex = messages.findIndex( - (msg) => msg?.signature === message?.repliedTo - ); - let reply; + let message = messages[index] || null; // Safeguard against undefined + let replyIndex = -1; + let reply = null; let reactions = null; - - if (message?.repliedTo && replyIndex !== -1) { - reply = messages[replyIndex]; - } - - if (message?.message && message?.groupDirectId) { - replyIndex = messages.findIndex( - (msg) => msg?.signature === message?.message?.repliedTo - ); - if (message?.message?.repliedTo && replyIndex !== -1) { - reply = messages[replyIndex]; - } - message = { - ...(message?.message || {}), - isTemp: true, - unread: false, - status: message?.status, - }; - } - - if (chatReferences && chatReferences[message?.signature]) { - if (chatReferences[message.signature]?.reactions) { - reactions = chatReferences[message.signature]?.reactions; - } - } - let isUpdating = false; - if ( - tempChatReferences && - tempChatReferences?.find( - (item) => item?.chatReference === message?.signature - ) - ) { - isUpdating = true; + + try { + // Safeguard for message existence + if (message) { + // Check for repliedTo logic + replyIndex = messages.findIndex( + (msg) => msg?.signature === message?.repliedTo + ); + + if (message?.repliedTo && replyIndex !== -1) { + reply = { ...(messages[replyIndex] || {}) }; + if (chatReferences?.[reply?.signature]?.edit) { + reply.decryptedData = chatReferences[reply?.signature]?.edit; + reply.text = chatReferences[reply?.signature]?.edit?.message; + } + } + + // GroupDirectId logic + if (message?.message && message?.groupDirectId) { + replyIndex = messages.findIndex( + (msg) => msg?.signature === message?.message?.repliedTo + ); + if (message?.message?.repliedTo && replyIndex !== -1) { + reply = messages[replyIndex] || null; + } + message = { + ...(message?.message || {}), + isTemp: true, + unread: false, + status: message?.status, + }; + } + + // Check for reactions and edits + if (chatReferences?.[message.signature]) { + reactions = chatReferences[message.signature]?.reactions || null; + + if (chatReferences[message.signature]?.edit?.message && message?.text) { + message.text = chatReferences[message.signature]?.edit?.message; + message.isEdit = true + } + + + } + + // Check if message is updating + if ( + tempChatReferences?.some( + (item) => item?.chatReference === message?.signature + ) + ) { + isUpdating = true; + } + } + } catch (error) { + console.error("Error processing message:", error, { index, message }); + // Gracefully handle the error by providing fallback data + message = null; + reply = null; + reactions = null; } + // Render fallback if message is null + if (!message) { + return ( +
+ Error loading message. +
+ ); + } + return (
+ + Error loading content: Invalid Data + + } + > +
); })} +
diff --git a/src/components/Chat/MessageItem.tsx b/src/components/Chat/MessageItem.tsx index 51427ae..47d4417 100644 --- a/src/components/Chat/MessageItem.tsx +++ b/src/components/Chat/MessageItem.tsx @@ -16,6 +16,8 @@ import ReplyIcon from "@mui/icons-material/Reply"; import { Spacer } from "../../common/Spacer"; import { ReactionPicker } from "../ReactionPicker"; import KeyOffIcon from '@mui/icons-material/KeyOff'; +import EditIcon from '@mui/icons-material/Edit'; + export const MessageItem = ({ message, onSeen, @@ -30,7 +32,8 @@ export const MessageItem = ({ handleReaction, reactions, isUpdating, - lastSignature + lastSignature, + onEdit }) => { const { ref, inView } = useInView({ threshold: 0.7, // Fully visible @@ -46,6 +49,7 @@ export const MessageItem = ({ }, [inView, message.id, isLast]); + return ( <> {message?.divide && ( @@ -130,6 +134,15 @@ export const MessageItem = ({ gap: '10px', alignItems: 'center' }}> + {message?.sender === myAddress && !message?.isNotEncrypted && ( + { + onEdit(message); + }} + > + + + )} {!isShowingAsReply && ( { @@ -289,6 +302,19 @@ export const MessageItem = ({ {message?.status === 'failed-permanent' ? 'Failed to send' : 'Sending...'} ) : ( + <> + {message?.isEdit && ( + + Edited + + )} {formatTimestamp(message.timestamp)} + )} @@ -320,7 +347,7 @@ export const MessageItem = ({ }; -export const ReplyPreview = ({message})=> { +export const ReplyPreview = ({message, isEdit})=> { return ( { - Replied to {message?.senderName || message?.senderAddress} + {isEdit ? ( + Editing Message + ) : ( + Replied to {message?.senderName || message?.senderAddress} + )} + {message?.messageText && (