mirror of
				https://github.com/lingble/chatwoot.git
				synced 2025-10-31 02:57:57 +00:00 
			
		
		
		
	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 <img width="196" height="113" alt="image" src="https://github.com/user-attachments/assets/8b4a1aa9-c75c-4169-b4a2-e89148647e91" /> ## 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 <muhsinkeramam@gmail.com>
This commit is contained in:
		| @@ -131,6 +131,8 @@ const props = defineProps({ | |||||||
|   sourceId: { type: String, default: '' }, // eslint-disable-line vue/no-unused-properties |   sourceId: { type: String, default: '' }, // eslint-disable-line vue/no-unused-properties | ||||||
| }); | }); | ||||||
|  |  | ||||||
|  | const emit = defineEmits(['retry']); | ||||||
|  |  | ||||||
| const contextMenuPosition = ref({}); | const contextMenuPosition = ref({}); | ||||||
| const showBackgroundHighlight = ref(false); | const showBackgroundHighlight = ref(false); | ||||||
| const showContextMenu = ref(false); | const showContextMenu = ref(false); | ||||||
| @@ -524,6 +526,7 @@ provideMessageContext({ | |||||||
|         class="[grid-area:meta]" |         class="[grid-area:meta]" | ||||||
|         :class="flexOrientationClass" |         :class="flexOrientationClass" | ||||||
|         :error="contentAttributes.externalError" |         :error="contentAttributes.externalError" | ||||||
|  |         @retry="emit('retry')" | ||||||
|       /> |       /> | ||||||
|     </div> |     </div> | ||||||
|     <div v-if="shouldShowContextMenu" class="context-menu-wrap"> |     <div v-if="shouldShowContextMenu" class="context-menu-wrap"> | ||||||
|   | |||||||
| @@ -1,16 +1,22 @@ | |||||||
| <script setup> | <script setup> | ||||||
|  | import { computed } from 'vue'; | ||||||
| import Icon from 'next/icon/Icon.vue'; | import Icon from 'next/icon/Icon.vue'; | ||||||
| import { useI18n } from 'vue-i18n'; | import { useI18n } from 'vue-i18n'; | ||||||
| import { useMessageContext } from './provider.js'; | import { useMessageContext } from './provider.js'; | ||||||
| import { ORIENTATION } from './constants'; | import { hasOneDayPassed } from 'shared/helpers/timeHelper'; | ||||||
|  | import { ORIENTATION, MESSAGE_STATUS } from './constants'; | ||||||
|  |  | ||||||
| defineProps({ | defineProps({ | ||||||
|   error: { type: String, required: true }, |   error: { type: String, required: true }, | ||||||
| }); | }); | ||||||
|  |  | ||||||
| const { orientation } = useMessageContext(); | const emit = defineEmits(['retry']); | ||||||
|  |  | ||||||
|  | const { orientation, status, createdAt } = useMessageContext(); | ||||||
|  |  | ||||||
| const { t } = useI18n(); | const { t } = useI18n(); | ||||||
|  |  | ||||||
|  | const canRetry = computed(() => !hasOneDayPassed(createdAt.value)); | ||||||
| </script> | </script> | ||||||
|  |  | ||||||
| <template> | <template> | ||||||
| @@ -35,5 +41,14 @@ const { t } = useI18n(); | |||||||
|         {{ error }} |         {{ error }} | ||||||
|       </div> |       </div> | ||||||
|     </div> |     </div> | ||||||
|  |     <button | ||||||
|  |       v-if="canRetry" | ||||||
|  |       type="button" | ||||||
|  |       :disabled="status !== MESSAGE_STATUS.FAILED" | ||||||
|  |       class="bg-n-alpha-2 rounded-md size-5 grid place-content-center cursor-pointer" | ||||||
|  |       @click="emit('retry')" | ||||||
|  |     > | ||||||
|  |       <Icon icon="i-lucide-refresh-ccw" class="text-n-ruby-11 size-[14px]" /> | ||||||
|  |     </button> | ||||||
|   </div> |   </div> | ||||||
| </template> | </template> | ||||||
|   | |||||||
| @@ -37,6 +37,8 @@ const props = defineProps({ | |||||||
|   }, |   }, | ||||||
| }); | }); | ||||||
|  |  | ||||||
|  | const emit = defineEmits(['retry']); | ||||||
|  |  | ||||||
| const allMessages = computed(() => { | const allMessages = computed(() => { | ||||||
|   return useCamelCase(props.messages, { deep: true }); |   return useCamelCase(props.messages, { deep: true }); | ||||||
| }); | }); | ||||||
| @@ -113,6 +115,7 @@ const getInReplyToMessage = parentMessage => { | |||||||
|         :inbox-supports-reply-to="inboxSupportsReplyTo" |         :inbox-supports-reply-to="inboxSupportsReplyTo" | ||||||
|         :current-user-id="currentUserId" |         :current-user-id="currentUserId" | ||||||
|         data-clarity-mask="True" |         data-clarity-mask="True" | ||||||
|  |         @retry="emit('retry', message)" | ||||||
|       /> |       /> | ||||||
|     </template> |     </template> | ||||||
|     <slot name="after" /> |     <slot name="after" /> | ||||||
|   | |||||||
| @@ -4,6 +4,7 @@ import { ref, provide } from 'vue'; | |||||||
| import { useConfig } from 'dashboard/composables/useConfig'; | import { useConfig } from 'dashboard/composables/useConfig'; | ||||||
| import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents'; | import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents'; | ||||||
| import { useAI } from 'dashboard/composables/useAI'; | import { useAI } from 'dashboard/composables/useAI'; | ||||||
|  | import { useSnakeCase } from 'dashboard/composables/useTransformKeys'; | ||||||
|  |  | ||||||
| // components | // components | ||||||
| import ReplyBox from './ReplyBox.vue'; | import ReplyBox from './ReplyBox.vue'; | ||||||
| @@ -437,6 +438,11 @@ export default { | |||||||
|     makeMessagesRead() { |     makeMessagesRead() { | ||||||
|       this.$store.dispatch('markMessagesRead', { id: this.currentChat.id }); |       this.$store.dispatch('markMessagesRead', { id: this.currentChat.id }); | ||||||
|     }, |     }, | ||||||
|  |     async handleMessageRetry(message) { | ||||||
|  |       if (!message) return; | ||||||
|  |       const payload = useSnakeCase(message); | ||||||
|  |       await this.$store.dispatch('sendMessageWithData', payload); | ||||||
|  |     }, | ||||||
|   }, |   }, | ||||||
| }; | }; | ||||||
| </script> | </script> | ||||||
| @@ -465,6 +471,7 @@ export default { | |||||||
|       :is-an-email-channel="isAnEmailChannel" |       :is-an-email-channel="isAnEmailChannel" | ||||||
|       :inbox-supports-reply-to="inboxSupportsReplyTo" |       :inbox-supports-reply-to="inboxSupportsReplyTo" | ||||||
|       :messages="getMessages" |       :messages="getMessages" | ||||||
|  |       @retry="handleMessageRetry" | ||||||
|     > |     > | ||||||
|       <template #beforeAll> |       <template #beforeAll> | ||||||
|         <transition name="slide-up"> |         <transition name="slide-up"> | ||||||
|   | |||||||
| @@ -4,6 +4,8 @@ import { | |||||||
|   dynamicTime, |   dynamicTime, | ||||||
|   dateFormat, |   dateFormat, | ||||||
|   shortTimestamp, |   shortTimestamp, | ||||||
|  |   getDayDifferenceFromNow, | ||||||
|  |   hasOneDayPassed, | ||||||
| } from 'shared/helpers/timeHelper'; | } from 'shared/helpers/timeHelper'; | ||||||
|  |  | ||||||
| beforeEach(() => { | beforeEach(() => { | ||||||
| @@ -90,3 +92,148 @@ describe('#shortTimestamp', () => { | |||||||
|     expect(shortTimestamp('4 years ago', true)).toEqual('4y ago'); |     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); | ||||||
|  |   }); | ||||||
|  | }); | ||||||
|   | |||||||
| @@ -3,6 +3,7 @@ import { | |||||||
|   isSameYear, |   isSameYear, | ||||||
|   fromUnixTime, |   fromUnixTime, | ||||||
|   formatDistanceToNow, |   formatDistanceToNow, | ||||||
|  |   differenceInDays, | ||||||
| } from 'date-fns'; | } from 'date-fns'; | ||||||
|  |  | ||||||
| /** | /** | ||||||
| @@ -91,3 +92,25 @@ export const shortTimestamp = (time, withAgo = false) => { | |||||||
|     .replace(' years ago', `y${suffix}`); |     .replace(' years ago', `y${suffix}`); | ||||||
|   return convertToShortTime; |   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; | ||||||
|  | }; | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user
	 Sivin Varghese
					Sivin Varghese