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"