From d45527b851843f4c40822019eab63c720fd22f14 Mon Sep 17 00:00:00 2001
From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Date: Tue, 16 Sep 2025 21:00:15 +0530
Subject: [PATCH] feat: Retry failed messages within 24h (#12436)
Fixes [CW-5540](https://linear.app/chatwoot/issue/CW-5540/feat-allow-retry-a-failed-message-sendout),
https://github.com/chatwoot/chatwoot/issues/12333
## Type of change
- [x] New feature (non-breaking change which adds functionality)
## How Has This Been Tested?
### Screenshot
## Checklist:
- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
---------
Co-authored-by: Muhsin Keloth
---
.../components-next/message/Message.vue | 3 +
.../components-next/message/MessageError.vue | 19 ++-
.../components-next/message/MessageList.vue | 3 +
.../widgets/conversation/MessagesView.vue | 7 +
.../shared/helpers/specs/timeHelper.spec.js | 147 ++++++++++++++++++
app/javascript/shared/helpers/timeHelper.js | 23 +++
6 files changed, 200 insertions(+), 2 deletions(-)
diff --git a/app/javascript/dashboard/components-next/message/Message.vue b/app/javascript/dashboard/components-next/message/Message.vue
index 586a4b4cb..dd655d0cc 100644
--- a/app/javascript/dashboard/components-next/message/Message.vue
+++ b/app/javascript/dashboard/components-next/message/Message.vue
@@ -131,6 +131,8 @@ const props = defineProps({
sourceId: { type: String, default: '' }, // eslint-disable-line vue/no-unused-properties
});
+const emit = defineEmits(['retry']);
+
const contextMenuPosition = ref({});
const showBackgroundHighlight = ref(false);
const showContextMenu = ref(false);
@@ -524,6 +526,7 @@ provideMessageContext({
class="[grid-area:meta]"
:class="flexOrientationClass"
:error="contentAttributes.externalError"
+ @retry="emit('retry')"
/>
+
+
+
diff --git a/app/javascript/dashboard/components-next/message/MessageList.vue b/app/javascript/dashboard/components-next/message/MessageList.vue
index 44b317c56..4c4fe1a1d 100644
--- a/app/javascript/dashboard/components-next/message/MessageList.vue
+++ b/app/javascript/dashboard/components-next/message/MessageList.vue
@@ -37,6 +37,8 @@ const props = defineProps({
},
});
+const emit = defineEmits(['retry']);
+
const allMessages = computed(() => {
return useCamelCase(props.messages, { deep: true });
});
@@ -113,6 +115,7 @@ const getInReplyToMessage = parentMessage => {
:inbox-supports-reply-to="inboxSupportsReplyTo"
:current-user-id="currentUserId"
data-clarity-mask="True"
+ @retry="emit('retry', message)"
/>
diff --git a/app/javascript/dashboard/components/widgets/conversation/MessagesView.vue b/app/javascript/dashboard/components/widgets/conversation/MessagesView.vue
index 1f850cd74..3cb46c05f 100644
--- a/app/javascript/dashboard/components/widgets/conversation/MessagesView.vue
+++ b/app/javascript/dashboard/components/widgets/conversation/MessagesView.vue
@@ -4,6 +4,7 @@ import { ref, provide } from 'vue';
import { useConfig } from 'dashboard/composables/useConfig';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { useAI } from 'dashboard/composables/useAI';
+import { useSnakeCase } from 'dashboard/composables/useTransformKeys';
// components
import ReplyBox from './ReplyBox.vue';
@@ -437,6 +438,11 @@ export default {
makeMessagesRead() {
this.$store.dispatch('markMessagesRead', { id: this.currentChat.id });
},
+ async handleMessageRetry(message) {
+ if (!message) return;
+ const payload = useSnakeCase(message);
+ await this.$store.dispatch('sendMessageWithData', payload);
+ },
},
};
@@ -465,6 +471,7 @@ export default {
:is-an-email-channel="isAnEmailChannel"
:inbox-supports-reply-to="inboxSupportsReplyTo"
:messages="getMessages"
+ @retry="handleMessageRetry"
>
diff --git a/app/javascript/shared/helpers/specs/timeHelper.spec.js b/app/javascript/shared/helpers/specs/timeHelper.spec.js
index 13d04568f..e7a4e025f 100644
--- a/app/javascript/shared/helpers/specs/timeHelper.spec.js
+++ b/app/javascript/shared/helpers/specs/timeHelper.spec.js
@@ -4,6 +4,8 @@ import {
dynamicTime,
dateFormat,
shortTimestamp,
+ getDayDifferenceFromNow,
+ hasOneDayPassed,
} from 'shared/helpers/timeHelper';
beforeEach(() => {
@@ -90,3 +92,148 @@ describe('#shortTimestamp', () => {
expect(shortTimestamp('4 years ago', true)).toEqual('4y ago');
});
});
+
+describe('#getDayDifferenceFromNow', () => {
+ it('returns 0 for timestamps from today', () => {
+ // Mock current date: May 5, 2023
+ const now = new Date(Date.UTC(2023, 4, 5, 12, 0, 0)); // 12:00 PM
+ const todayTimestamp = Math.floor(now.getTime() / 1000); // Same day
+
+ expect(getDayDifferenceFromNow(now, todayTimestamp)).toEqual(0);
+ });
+
+ it('returns 2 for timestamps from 2 days ago', () => {
+ const now = new Date(Date.UTC(2023, 4, 5, 12, 0, 0)); // May 5, 2023
+ const twoDaysAgoTimestamp = Math.floor(
+ new Date(Date.UTC(2023, 4, 3, 10, 0, 0)).getTime() / 1000
+ ); // May 3, 2023
+
+ expect(getDayDifferenceFromNow(now, twoDaysAgoTimestamp)).toEqual(2);
+ });
+
+ it('returns 7 for timestamps from a week ago', () => {
+ const now = new Date(Date.UTC(2023, 4, 5, 12, 0, 0)); // May 5, 2023
+ const weekAgoTimestamp = Math.floor(
+ new Date(Date.UTC(2023, 3, 28, 8, 0, 0)).getTime() / 1000
+ ); // April 28, 2023
+
+ expect(getDayDifferenceFromNow(now, weekAgoTimestamp)).toEqual(7);
+ });
+
+ it('returns 30 for timestamps from a month ago', () => {
+ const now = new Date(Date.UTC(2023, 4, 5, 12, 0, 0)); // May 5, 2023
+ const monthAgoTimestamp = Math.floor(
+ new Date(Date.UTC(2023, 3, 5, 12, 0, 0)).getTime() / 1000
+ ); // April 5, 2023
+
+ expect(getDayDifferenceFromNow(now, monthAgoTimestamp)).toEqual(30);
+ });
+
+ it('handles edge case with different times on same day', () => {
+ const now = new Date(Date.UTC(2023, 4, 5, 23, 59, 59)); // May 5, 2023 11:59:59 PM
+ const morningTimestamp = Math.floor(
+ new Date(Date.UTC(2023, 4, 5, 0, 0, 1)).getTime() / 1000
+ ); // May 5, 2023 12:00:01 AM
+
+ expect(getDayDifferenceFromNow(now, morningTimestamp)).toEqual(0);
+ });
+
+ it('handles cross-month boundaries correctly', () => {
+ const now = new Date(Date.UTC(2023, 4, 1, 12, 0, 0)); // May 1, 2023
+ const lastMonthTimestamp = Math.floor(
+ new Date(Date.UTC(2023, 3, 30, 12, 0, 0)).getTime() / 1000
+ ); // April 30, 2023
+
+ expect(getDayDifferenceFromNow(now, lastMonthTimestamp)).toEqual(1);
+ });
+
+ it('handles cross-year boundaries correctly', () => {
+ const now = new Date(Date.UTC(2023, 0, 2, 12, 0, 0)); // January 2, 2023
+ const lastYearTimestamp = Math.floor(
+ new Date(Date.UTC(2022, 11, 31, 12, 0, 0)).getTime() / 1000
+ ); // December 31, 2022
+
+ expect(getDayDifferenceFromNow(now, lastYearTimestamp)).toEqual(2);
+ });
+});
+
+describe('#hasOneDayPassed', () => {
+ beforeEach(() => {
+ // Mock current date: May 5, 2023, 12:00 PM UTC (1683288000)
+ const mockDate = new Date(1683288000 * 1000);
+ vi.setSystemTime(mockDate);
+ });
+
+ it('returns false for timestamps from today', () => {
+ // Same day, different time - May 5, 2023 8:00 AM UTC
+ const todayTimestamp = 1683273600;
+
+ expect(hasOneDayPassed(todayTimestamp)).toBe(false);
+ });
+
+ it('returns false for timestamps from yesterday (less than 24 hours)', () => {
+ // Yesterday but less than 24 hours ago - May 4, 2023 6:00 PM UTC (18 hours ago)
+ const yesterdayTimestamp = 1683230400;
+
+ expect(hasOneDayPassed(yesterdayTimestamp)).toBe(false);
+ });
+
+ it('returns true for timestamps from exactly 1 day ago', () => {
+ // Exactly 24 hours ago - May 4, 2023 12:00 PM UTC
+ const oneDayAgoTimestamp = 1683201600;
+
+ expect(hasOneDayPassed(oneDayAgoTimestamp)).toBe(true);
+ });
+
+ it('returns true for timestamps from more than 1 day ago', () => {
+ // 2 days ago - May 3, 2023 10:00 AM UTC
+ const twoDaysAgoTimestamp = 1683108000;
+
+ expect(hasOneDayPassed(twoDaysAgoTimestamp)).toBe(true);
+ });
+
+ it('returns true for timestamps from a week ago', () => {
+ // 7 days ago - April 28, 2023 8:00 AM UTC
+ const weekAgoTimestamp = 1682668800;
+
+ expect(hasOneDayPassed(weekAgoTimestamp)).toBe(true);
+ });
+
+ it('returns true for null timestamp (defensive check)', () => {
+ expect(hasOneDayPassed(null)).toBe(true);
+ });
+
+ it('returns true for undefined timestamp (defensive check)', () => {
+ expect(hasOneDayPassed(undefined)).toBe(true);
+ });
+
+ it('returns true for zero timestamp (defensive check)', () => {
+ expect(hasOneDayPassed(0)).toBe(true);
+ });
+
+ it('returns true for empty string timestamp (defensive check)', () => {
+ expect(hasOneDayPassed('')).toBe(true);
+ });
+
+ it('handles cross-month boundaries correctly', () => {
+ // Set current time to May 1, 2023 12:00 PM UTC (1682942400)
+ const mayFirst = new Date(1682942400 * 1000);
+ vi.setSystemTime(mayFirst);
+
+ // April 29, 2023 12:00 PM UTC (1682769600) - 2 days ago, crossing month boundary
+ const crossMonthTimestamp = 1682769600;
+
+ expect(hasOneDayPassed(crossMonthTimestamp)).toBe(true);
+ });
+
+ it('handles cross-year boundaries correctly', () => {
+ // Set current time to January 2, 2023 12:00 PM UTC (1672660800)
+ const newYear = new Date(1672660800 * 1000);
+ vi.setSystemTime(newYear);
+
+ // December 30, 2022 12:00 PM UTC (1672401600) - 3 days ago, crossing year boundary
+ const crossYearTimestamp = 1672401600;
+
+ expect(hasOneDayPassed(crossYearTimestamp)).toBe(true);
+ });
+});
diff --git a/app/javascript/shared/helpers/timeHelper.js b/app/javascript/shared/helpers/timeHelper.js
index 6e041c7ec..5347d2410 100644
--- a/app/javascript/shared/helpers/timeHelper.js
+++ b/app/javascript/shared/helpers/timeHelper.js
@@ -3,6 +3,7 @@ import {
isSameYear,
fromUnixTime,
formatDistanceToNow,
+ differenceInDays,
} from 'date-fns';
/**
@@ -91,3 +92,25 @@ export const shortTimestamp = (time, withAgo = false) => {
.replace(' years ago', `y${suffix}`);
return convertToShortTime;
};
+
+/**
+ * Calculates the difference in days between now and a given timestamp.
+ * @param {Date} now - Current date/time.
+ * @param {number} timestampInSeconds - Unix timestamp in seconds.
+ * @returns {number} Number of days difference.
+ */
+export const getDayDifferenceFromNow = (now, timestampInSeconds) => {
+ const date = new Date(timestampInSeconds * 1000);
+ return differenceInDays(now, date);
+};
+
+/**
+ * Checks if more than 24 hours have passed since a given timestamp.
+ * Useful for determining if retry/refresh actions should be disabled.
+ * @param {number} timestamp - Unix timestamp.
+ * @returns {boolean} True if more than 24 hours have passed.
+ */
+export const hasOneDayPassed = timestamp => {
+ if (!timestamp) return true; // Defensive check
+ return getDayDifferenceFromNow(new Date(), timestamp) >= 1;
+};