mirror of
				https://github.com/lingble/chatwoot.git
				synced 2025-10-31 11:08:04 +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 | ||||
| }); | ||||
|  | ||||
| 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')" | ||||
|       /> | ||||
|     </div> | ||||
|     <div v-if="shouldShowContextMenu" class="context-menu-wrap"> | ||||
|   | ||||
| @@ -1,16 +1,22 @@ | ||||
| <script setup> | ||||
| import { computed } from 'vue'; | ||||
| import Icon from 'next/icon/Icon.vue'; | ||||
| import { useI18n } from 'vue-i18n'; | ||||
| import { useMessageContext } from './provider.js'; | ||||
| import { ORIENTATION } from './constants'; | ||||
| import { hasOneDayPassed } from 'shared/helpers/timeHelper'; | ||||
| import { ORIENTATION, MESSAGE_STATUS } from './constants'; | ||||
|  | ||||
| defineProps({ | ||||
|   error: { type: String, required: true }, | ||||
| }); | ||||
|  | ||||
| const { orientation } = useMessageContext(); | ||||
| const emit = defineEmits(['retry']); | ||||
|  | ||||
| const { orientation, status, createdAt } = useMessageContext(); | ||||
|  | ||||
| const { t } = useI18n(); | ||||
|  | ||||
| const canRetry = computed(() => !hasOneDayPassed(createdAt.value)); | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
| @@ -35,5 +41,14 @@ const { t } = useI18n(); | ||||
|         {{ error }} | ||||
|       </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> | ||||
| </template> | ||||
|   | ||||
| @@ -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)" | ||||
|       /> | ||||
|     </template> | ||||
|     <slot name="after" /> | ||||
|   | ||||
| @@ -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); | ||||
|     }, | ||||
|   }, | ||||
| }; | ||||
| </script> | ||||
| @@ -465,6 +471,7 @@ export default { | ||||
|       :is-an-email-channel="isAnEmailChannel" | ||||
|       :inbox-supports-reply-to="inboxSupportsReplyTo" | ||||
|       :messages="getMessages" | ||||
|       @retry="handleMessageRetry" | ||||
|     > | ||||
|       <template #beforeAll> | ||||
|         <transition name="slide-up"> | ||||
|   | ||||
| @@ -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); | ||||
|   }); | ||||
| }); | ||||
|   | ||||
| @@ -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; | ||||
| }; | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 Sivin Varghese
					Sivin Varghese