mirror of
https://github.com/lingble/chatwoot.git
synced 2025-11-01 11:37:58 +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