diff --git a/packages/api/src/EmbeddedChatApi.ts b/packages/api/src/EmbeddedChatApi.ts index 72e25a046..a58d1c1ee 100644 --- a/packages/api/src/EmbeddedChatApi.ts +++ b/packages/api/src/EmbeddedChatApi.ts @@ -775,6 +775,7 @@ export default class EmbeddedChatApi { return await response.json(); } catch (err) { console.error(err); + return { success: false, error: err }; } } diff --git a/packages/react/package.json b/packages/react/package.json index 82cc80268..0ca5b26d4 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -99,6 +99,7 @@ "json5": "^2.2.3", "normalize.css": "^8.0.1", "prop-types": "^15.8.1", + "react-icons": "^5.5.0", "swiper": "^11.1.0", "zustand": "^4.3.8" } diff --git a/packages/react/src/store/messageStore.js b/packages/react/src/store/messageStore.js index 4f84f8c1f..e68464939 100644 --- a/packages/react/src/store/messageStore.js +++ b/packages/react/src/store/messageStore.js @@ -2,8 +2,46 @@ import { create } from 'zustand'; import cloneArray from '../lib/cloneArray'; import { upsertMessage } from '../lib/messageListHelpers'; +const PERSISTENCE_KEY = 'ec_offline_messages'; + +const getPersistedMessages = (rid) => { + try { + const saved = localStorage.getItem(PERSISTENCE_KEY); + const allOffline = saved ? JSON.parse(saved) : {}; + return rid ? allOffline[rid] || [] : []; + } catch (e) { + console.error('Error loading persisted messages', e); + return []; + } +}; + +const savePersistedMessages = (rid, messages) => { + try { + const saved = localStorage.getItem(PERSISTENCE_KEY); + const allOffline = saved ? JSON.parse(saved) : {}; + if (messages && messages.length > 0) { + allOffline[rid] = messages; + } else { + delete allOffline[rid]; + } + localStorage.setItem(PERSISTENCE_KEY, JSON.stringify(allOffline)); + } catch (e) { + console.error('Error saving persisted messages', e); + } +}; + +const clearOfflineMessages = () => { + try { + localStorage.removeItem(PERSISTENCE_KEY); + } catch (e) { + console.error('Error clearing persisted messages', e); + } +}; + const useMessageStore = create((set, get) => ({ messages: [], + rid: null, + setRid: (rid) => set({ rid }), isMessageLoaded: false, threadMessages: [], filtered: false, @@ -25,16 +63,23 @@ const useMessageStore = create((set, get) => ({ set((state) => { const allMessages = append ? [...state.messages, ...newMessages] - : newMessages; + : [...getPersistedMessages(state.rid), ...newMessages]; + const uniqueMessages = Array.from( new Map(allMessages.map((msg) => [msg._id, msg])).values() - ); + ).sort((a, b) => new Date(b.ts) - new Date(a.ts)); return { messages: uniqueMessages, isMessageLoaded: true, }; }), upsertMessage: (message, enableThreads = false) => { + if (message.isError) { + const offlineMessages = getPersistedMessages(message.rid); + const updatedOffline = upsertMessage(offlineMessages, message); + savePersistedMessages(message.rid, updatedOffline); + } + if (message.tmid && enableThreads) { if (get().threadMainMessage?._id === message.tmid) { set((state) => ({ @@ -43,13 +88,22 @@ const useMessageStore = create((set, get) => ({ } } else { set((state) => ({ - messages: upsertMessage(state.messages, message), + messages: upsertMessage(state.messages, message).sort( + (a, b) => new Date(b.ts) - new Date(a.ts) + ), })); } }, removeMessage: (messageId) => { + const currentMessages = get().messages; + const targetMessage = currentMessages.find((m) => m._id === messageId); + if (targetMessage && targetMessage.isError) { + const offlineMessages = getPersistedMessages(targetMessage.rid); + const updatedOffline = offlineMessages.filter((m) => m._id !== messageId); + savePersistedMessages(targetMessage.rid, updatedOffline); + } + const threadMessage = get().threadMessages.find((m) => m._id === messageId); - const message = get().messages.find((m) => m._id === messageId); if (threadMessage) { return set((state) => ({ deletedMessage: threadMessage, @@ -58,14 +112,23 @@ const useMessageStore = create((set, get) => ({ ), })); } - if (message) { + if (targetMessage) { return set((state) => ({ - deletedMessage: message, + deletedMessage: targetMessage, messages: cloneArray(state.messages).filter((m) => m._id !== messageId), })); } }, replaceMessage: (oldMessageId, newMessage) => { + const offlineMessages = getPersistedMessages(newMessage.rid); + let updatedOffline; + if (newMessage.isError) { + updatedOffline = upsertMessage(offlineMessages, newMessage); + } else { + updatedOffline = offlineMessages.filter((m) => m._id !== oldMessageId); + } + savePersistedMessages(newMessage.rid, updatedOffline); + const threadMessage = get().threadMessages.find( (m) => m._id === oldMessageId ); @@ -79,9 +142,9 @@ const useMessageStore = create((set, get) => ({ } if (message) { return set((state) => ({ - messages: cloneArray(state.messages).map((m) => - m._id === oldMessageId ? newMessage : m - ), + messages: cloneArray(state.messages) + .map((m) => (m._id === oldMessageId ? newMessage : m)) + .sort((a, b) => new Date(b.ts) - new Date(a.ts)), })); } }, @@ -135,6 +198,9 @@ const useMessageStore = create((set, get) => ({ set((state) => ({ ...state, forceDeleteMessageRoles })), setThreadMessages: (messages) => set(() => ({ threadMessages: messages })), setHeaderTitle: (title) => set(() => ({ headerTitle: title })), + clearOfflineMessages: () => { + clearOfflineMessages(); + }, })); export default useMessageStore; diff --git a/packages/react/src/views/ChatBody/ChatBody.js b/packages/react/src/views/ChatBody/ChatBody.js index 34f5c8bf4..54ab91b59 100644 --- a/packages/react/src/views/ChatBody/ChatBody.js +++ b/packages/react/src/views/ChatBody/ChatBody.js @@ -174,6 +174,12 @@ const ChatBody = ({ }); }, [RCInstance, anonymousMode, getMessagesAndRoles]); + const setRid = useMessageStore((state) => state.setRid); + + useEffect(() => { + setRid(RCInstance.rid); + }, [RCInstance.rid, setRid]); + useEffect(() => { RCInstance.auth.onAuthChange((user) => { if (user) { diff --git a/packages/react/src/views/ChatHeader/ChatHeader.js b/packages/react/src/views/ChatHeader/ChatHeader.js index 0986104ae..384977a32 100644 --- a/packages/react/src/views/ChatHeader/ChatHeader.js +++ b/packages/react/src/views/ChatHeader/ChatHeader.js @@ -140,6 +140,7 @@ const ChatHeader = ({ setChannelInfo({}); setShowSidebar(false); setUserAvatarUrl(null); + useMessageStore.getState().clearOfflineMessages(); useMessageStore.setState({ isMessageLoaded: false }); } catch (e) { console.error(e); diff --git a/packages/react/src/views/ChatInput/ChatInput.js b/packages/react/src/views/ChatInput/ChatInput.js index e753b689a..90f5e4bdf 100644 --- a/packages/react/src/views/ChatInput/ChatInput.js +++ b/packages/react/src/views/ChatInput/ChatInput.js @@ -323,6 +323,16 @@ const ChatInput = ({ scrollToBottom, clearUnreadDividerRef }) => { upsertMessage(pendingMessage, ECOptions.enableThreads); + if (!navigator.onLine) { + const erroredMessage = { + ...pendingMessage, + isError: true, + isPending: false, + }; + replaceMessage(pendingMessage._id, erroredMessage); + return; + } + const res = await RCInstance.sendMessage( { msg: pendingMessage.msg, @@ -333,7 +343,14 @@ const ChatInput = ({ scrollToBottom, clearUnreadDividerRef }) => { if (res.success) { clearQuoteMessages(); - replaceMessage(pendingMessage, res.message); + replaceMessage(pendingMessage._id, res.message); + } else { + const erroredMessage = { + ...pendingMessage, + isError: true, + isPending: false, + }; + replaceMessage(pendingMessage._id, erroredMessage); } }; diff --git a/packages/react/src/views/Message/Message.js b/packages/react/src/views/Message/Message.js index 355cde9b4..cb81a5782 100644 --- a/packages/react/src/views/Message/Message.js +++ b/packages/react/src/views/Message/Message.js @@ -1,4 +1,6 @@ -import React, { memo, useContext } from 'react'; +import React, { memo, useContext, useState } from 'react'; +import { css } from '@emotion/react'; +import { RiWifiOffLine } from 'react-icons/ri'; import PropTypes from 'prop-types'; import { format } from 'date-fns'; import { @@ -9,6 +11,7 @@ import { useTheme, lighten, darken, + Icon, } from '@embeddedchat/ui-elements'; import { Attachments } from '../AttachmentHandler'; import { Markdown } from '../Markdown'; @@ -81,6 +84,11 @@ const Message = ({ const forceDeleteMessagePermissions = useMessageStore( (state) => state.forceDeleteMessageRoles.roles ); + const { removeMessage, replaceMessage } = useMessageStore((state) => ({ + removeMessage: state.removeMessage, + replaceMessage: state.replaceMessage, + })); + const [isErrorMenuOpen, setIsErrorMenuOpen] = useState(false); const isMe = message.u._id === authenticatedUserId; @@ -193,6 +201,14 @@ const Message = ({ }; const handleDeleteMessage = async (msg) => { + if (msg.isError) { + removeMessage(msg._id); + dispatchToastMessage({ + type: 'success', + message: 'Message deleted successfully', + }); + return; + } const res = await RCInstance.deleteMessage(msg._id); if (res.success) { @@ -209,6 +225,47 @@ const Message = ({ getStarredMessages(); }; + const handleResendMessage = async () => { + const now = new Date().toISOString(); + const pendingMessage = { + ...message, + isError: false, + isPending: true, + ts: now, + _updatedAt: now, + }; + replaceMessage(message._id, pendingMessage); + + if (!navigator.onLine) { + const erroredMessage = { + ...pendingMessage, + isError: true, + isPending: false, + }; + replaceMessage(pendingMessage._id, erroredMessage); + return; + } + + const res = await RCInstance.sendMessage( + { + msg: message.msg, + _id: message._id, + }, + message.tmid + ); + + if (res.success) { + replaceMessage(pendingMessage._id, res.message); + } else { + const erroredMessage = { + ...pendingMessage, + isError: true, + isPending: false, + }; + replaceMessage(pendingMessage._id, erroredMessage); + } + }; + const handleEmojiClick = async (e, msg, canReact) => { const emoji = (e.names?.[0] || e.name).replace(/\s/g, '_'); await RCInstance.reactToMessage(emoji, msg._id, canReact); @@ -236,6 +293,10 @@ const Message = ({ variantStyles.messageParent || styles.main, hoverStyle, editMessage._id === message._id && styles.messageEditing, + message.isError && + css` + color: ${theme.theme.colors.destructive}; + `, ]} style={styleOverrides} > @@ -296,7 +357,7 @@ const Message = ({ /> )} - {!message.t && showToolbox ? ( + {!message.t && showToolbox && !message.isError ? ( - ) : ( - <> - )} + ) : null} + {message.isError && ( + + { + e.stopPropagation(); + setIsErrorMenuOpen(!isErrorMenuOpen); + }} + /> + {isErrorMenuOpen && ( + + { + handleResendMessage(); + setIsErrorMenuOpen(false); + }} + > + + Resend + + { + handleDeleteMessage(message); + setIsErrorMenuOpen(false); + }} + > + + Delete + + + )} + + )} {isLinkPreview && message.urls && diff --git a/yarn.lock b/yarn.lock index 23b67cfd1..cb88523db 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2501,6 +2501,7 @@ __metadata: prop-types: ^15.8.1 react: ^17.0.2 react-dom: ^17.0.2 + react-icons: ^5.5.0 rimraf: ^5.0.1 rollup: ^2.70.1 rollup-plugin-analyzer: ^4.0.0 @@ -26877,6 +26878,15 @@ __metadata: languageName: node linkType: hard +"react-icons@npm:^5.5.0": + version: 5.5.0 + resolution: "react-icons@npm:5.5.0" + peerDependencies: + react: "*" + checksum: cbd74f4b7982e6e18d59798a6b578268c8eb0909d78d87bcf9b25f99b3e544fd189a76551cb5e770d17f154a60b668551aee108aaf8471309b23f7af3b2c5b07 + languageName: node + linkType: hard + "react-inspector@npm:^5.1.0": version: 5.1.1 resolution: "react-inspector@npm:5.1.1"