Compare commits

...
Author SHA1 Message Date
Muhsin KelothandGitHub 73abdfc1b7 Merge branch 'develop' into feat/add_last_snoozed_option 2025-10-08 18:06:36 +05:30
Muhsin KelothandGitHub b7454c5d87 Merge branch 'develop' into feat/add_last_snoozed_option 2025-09-23 19:47:02 +05:30
Vishnu NarayananandGitHub 7b9ebaf90c Merge branch 'develop' into feat/add_last_snoozed_option 2025-09-02 20:36:53 +05:30
Vishnu NarayananandGitHub d3ad267227 Merge branch 'develop' into feat/add_last_snoozed_option 2025-08-29 13:57:37 +05:30
Vishnu Narayanan 2762da5636 feat: Add dynamic 'Last snoozed' option with reactive updates
- Add 'UNTIL_LAST_CUSTOM_TIME' snooze option that shows saved custom time
- Implement smart expiration: times expire when passed OR after 7 days
- Add user-friendly format: 'Sat, 23 Aug, 8.16pm'
- Fix caching issue: actions now update dynamically without page refresh
- Auto-save custom times to localStorage for quick reuse
- Add reactive event system for real-time UI updates
- Include option filtering to show/hide based on saved time availability

Improves UX by allowing users to quickly reuse their last custom snooze time
without having to re-enter it every time.
2025-08-22 18:41:18 +05:30
Vishnu Narayanan 1fed175e65 feat: add last snoozed time as a snooze option 2025-08-20 17:48:13 +05:30
11 changed files with 303 additions and 20 deletions
@@ -1,6 +1,8 @@
<script>
import DatePicker from 'vue-datepicker-next';
import NextButton from 'dashboard/components-next/button/Button.vue';
import { getUnixTime } from 'date-fns';
import { saveLastCustomSnoozeTime } from 'dashboard/helper/customSnoozeStorage';
export default {
components: {
@@ -25,7 +27,13 @@ export default {
this.$emit('close');
},
chooseTime() {
this.$emit('chooseTime', this.snoozeTime);
if (this.snoozeTime) {
const unixTimestamp = getUnixTime(this.snoozeTime);
// Save the custom time to localStorage for future use
saveLastCustomSnoozeTime(unixTimestamp);
// The saveLastCustomSnoozeTime function will emit the event automatically
this.$emit('chooseTime', this.snoozeTime);
}
},
disabledDate(date) {
// Disable all the previous dates
@@ -1,6 +1,7 @@
<script>
import { getUnixTime } from 'date-fns';
import { findSnoozeTime } from 'dashboard/helper/snoozeHelpers';
import { getLastCustomSnoozeTime } from 'dashboard/helper/customSnoozeStorage';
import { emitter } from 'shared/helpers/mitt';
import wootConstants from 'dashboard/constants/globals';
import {
@@ -100,6 +101,17 @@ export default {
onCmdSnoozeConversation(snoozeType) {
if (snoozeType === wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME) {
this.showCustomTimeSnoozeModal = true;
} else if (
snoozeType === wootConstants.SNOOZE_OPTIONS.UNTIL_LAST_CUSTOM_TIME
) {
// Use the saved custom time
const lastCustomTime = getLastCustomSnoozeTime();
if (lastCustomTime) {
this.updateConversations('snoozed', lastCustomTime);
} else {
// Fallback to showing custom modal if no saved time exists
this.showCustomTimeSnoozeModal = true;
}
} else {
this.updateConversations('snoozed', findSnoozeTime(snoozeType) || null);
}
@@ -66,8 +66,9 @@ export function useBulkActionsHotKeys() {
const prepareActions = actions => {
return actions.map(action => ({
...action,
title: t(action.title),
section: t(action.section),
title: typeof action.title === 'string' ? t(action.title) : action.title,
section:
typeof action.section === 'string' ? t(action.section) : action.section,
}));
};
@@ -1,4 +1,4 @@
import { computed } from 'vue';
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useRoute } from 'vue-router';
@@ -31,22 +31,25 @@ import {
import {
OPEN_CONVERSATION_ACTIONS,
SNOOZE_CONVERSATION_ACTIONS,
RESOLVED_CONVERSATION_ACTIONS,
SEND_TRANSCRIPT_ACTION,
UNMUTE_ACTION,
MUTE_ACTION,
createSnoozeHandlers,
} from 'dashboard/helper/commandbar/actions';
import {
isAConversationRoute,
isAInboxViewRoute,
} from 'dashboard/helper/routeHelpers';
import { CMD_SNOOZE_CONVERSATION } from 'dashboard/helper/commandbar/events';
import { ICON_SNOOZE_CONVERSATION } from 'dashboard/helper/commandbar/icons';
const prepareActions = (actions, t) => {
return actions.map(action => ({
...action,
title: t(action.title),
section: t(action.section),
title: typeof action.title === 'string' ? t(action.title) : action.title,
section:
typeof action.section === 'string' ? t(action.section) : action.section,
}));
};
@@ -144,6 +147,14 @@ export function useConversationHotKeys() {
const store = useStore();
const route = useRoute();
// Create a reactive trigger for localStorage changes
const localStorageTrigger = ref(0);
// Watch for localStorage changes (we'll trigger this manually when needed)
const forceSnoozeActionsUpdate = () => {
localStorageTrigger.value += 1;
};
const {
activeLabels,
inactiveLabels,
@@ -197,6 +208,28 @@ export function useConversationHotKeys() {
});
};
// Create reactive snooze conversation actions that update when localStorage changes
const snoozeConversationActions = computed(() => {
// This computed will re-run when localStorageTrigger changes
// eslint-disable-next-line no-unused-expressions
localStorageTrigger.value; // Access to make it reactive
return [
{
id: 'snooze_conversation',
title: 'COMMAND_BAR.COMMANDS.SNOOZE_CONVERSATION',
section: 'COMMAND_BAR.SECTIONS.CONVERSATION',
icon: ICON_SNOOZE_CONVERSATION,
children: Object.values(wootConstants.SNOOZE_OPTIONS),
},
...createSnoozeHandlers(
CMD_SNOOZE_CONVERSATION,
'snooze_conversation',
'COMMAND_BAR.SECTIONS.SNOOZE_CONVERSATION'
),
];
});
const statusActions = computed(() => {
const isOpen = currentChat.value?.status === wootConstants.STATUS_TYPE.OPEN;
const isSnoozed =
@@ -206,7 +239,10 @@ export function useConversationHotKeys() {
let actions = [];
if (isOpen) {
actions = [...OPEN_CONVERSATION_ACTIONS, ...SNOOZE_CONVERSATION_ACTIONS];
actions = [
...OPEN_CONVERSATION_ACTIONS,
...snoozeConversationActions.value,
];
} else if (isResolved || isSnoozed) {
actions = RESOLVED_CONVERSATION_ACTIONS;
}
@@ -394,7 +430,7 @@ export function useConversationHotKeys() {
const conversationHotKeys = computed(() => {
if (shouldShowSnoozeOption.value) {
return prepareActions(SNOOZE_CONVERSATION_ACTIONS, t);
return prepareActions(snoozeConversationActions.value, t);
}
if (isConversationOrInboxRoute.value) {
return getDefaultConversationHotKeys.value;
@@ -404,5 +440,6 @@ export function useConversationHotKeys() {
return {
conversationHotKeys,
forceSnoozeActionsUpdate,
};
}
@@ -45,6 +45,7 @@ export default {
UNTIL_TOMORROW: 'until_tomorrow',
UNTIL_NEXT_WEEK: 'until_next_week',
UNTIL_NEXT_MONTH: 'until_next_month',
UNTIL_LAST_CUSTOM_TIME: 'until_last_custom_time',
UNTIL_CUSTOM_TIME: 'until_custom_time',
},
EXAMPLE_URL: 'example.com',
@@ -32,14 +32,97 @@ export const OPEN_CONVERSATION_ACTIONS = [
];
export const createSnoozeHandlers = (busEventName, parentId, section) => {
return Object.values(SNOOZE_OPTIONS).map(option => ({
id: option,
title: `COMMAND_BAR.COMMANDS.${option.toUpperCase()}`,
parent: parentId,
section: section,
icon: ICON_SNOOZE_CONVERSATION,
handler: () => emitter.emit(busEventName, option),
}));
// Import the helper functions inline to avoid import issues
const LAST_CUSTOM_SNOOZE_KEY = 'chatwoot_last_custom_snooze_time';
const getLastCustomSnoozeTime = () => {
try {
const stored = localStorage.getItem(LAST_CUSTOM_SNOOZE_KEY);
if (!stored) return null;
const data = JSON.parse(stored);
const now = Date.now();
const nowUnix = Math.floor(now / 1000);
const savedAt = data.savedAt;
const sevenDaysInMs = 7 * 24 * 60 * 60 * 1000;
// Check if the saved time has already passed OR if it was saved more than 7 days ago
const timeHasPassed = data.timestamp <= nowUnix;
const tooOld = now - savedAt > sevenDaysInMs;
if (timeHasPassed || tooOld) {
localStorage.removeItem(LAST_CUSTOM_SNOOZE_KEY);
return null;
}
return data.timestamp;
} catch (error) {
localStorage.removeItem(LAST_CUSTOM_SNOOZE_KEY);
return null;
}
};
const formatLastCustomSnoozeTime = () => {
const timestamp = getLastCustomSnoozeTime();
if (!timestamp) return null;
try {
const date = new Date(timestamp * 1000);
// Format as "Sat, 23 Aug, 8.16pm"
const dayOfWeek = date.toLocaleDateString('en-US', { weekday: 'short' });
const day = date.getDate();
const month = date.toLocaleDateString('en-US', { month: 'short' });
// Format time as 8.16pm (with dots instead of colons)
let hours = date.getHours();
const minutes = date.getMinutes().toString().padStart(2, '0');
const ampm = hours >= 12 ? 'pm' : 'am';
// Convert to 12-hour format
hours %= 12;
if (hours === 0) hours = 12; // 12am/12pm instead of 0am/0pm
const timeString = `${hours}.${minutes}${ampm}`;
return `${dayOfWeek}, ${day} ${month}, ${timeString}`;
} catch (error) {
return null;
}
};
const hasLastCustomSnoozeTime = () => {
return getLastCustomSnoozeTime() !== null;
};
const availableOptions = Object.values(SNOOZE_OPTIONS);
const snoozeOptions = availableOptions.filter(option => {
if (option === SNOOZE_OPTIONS.UNTIL_LAST_CUSTOM_TIME) {
return hasLastCustomSnoozeTime();
}
return true;
});
return snoozeOptions.map(option => {
let title = `COMMAND_BAR.COMMANDS.${option.toUpperCase()}`;
// For the last custom time option, show the formatted time instead of using translation
if (option === SNOOZE_OPTIONS.UNTIL_LAST_CUSTOM_TIME) {
const formattedTime = formatLastCustomSnoozeTime();
if (formattedTime) {
title = `Last snoozed ${formattedTime}`;
}
}
return {
id: option,
title: title,
parent: parentId,
section: section,
icon: ICON_SNOOZE_CONVERSATION,
handler: () => emitter.emit(busEventName, option),
};
});
};
export const SNOOZE_CONVERSATION_ACTIONS = [
@@ -0,0 +1,92 @@
const LAST_CUSTOM_SNOOZE_KEY = 'chatwoot_last_custom_snooze_time';
export const saveLastCustomSnoozeTime = snoozedUntil => {
if (!snoozedUntil) return;
try {
localStorage.setItem(
LAST_CUSTOM_SNOOZE_KEY,
JSON.stringify({
timestamp: snoozedUntil,
savedAt: Date.now(),
})
);
// Emit event to update command bar
if (typeof window !== 'undefined' && window.emitter) {
window.emitter.emit('CUSTOM_SNOOZE_TIME_CHANGED');
}
} catch (error) {
// eslint-disable-next-line no-console
console.error('Failed to save custom snooze time:', error);
}
};
export const getLastCustomSnoozeTime = () => {
try {
const stored = localStorage.getItem(LAST_CUSTOM_SNOOZE_KEY);
if (!stored) return null;
const data = JSON.parse(stored);
const now = Date.now();
const nowUnix = Math.floor(now / 1000);
const savedAt = data.savedAt;
const sevenDaysInMs = 7 * 24 * 60 * 60 * 1000;
// Check if the saved time has already passed OR if it was saved more than 7 days ago
const timeHasPassed = data.timestamp <= nowUnix;
const tooOld = now - savedAt > sevenDaysInMs;
if (timeHasPassed || tooOld) {
localStorage.removeItem(LAST_CUSTOM_SNOOZE_KEY);
// Emit event when data is cleared due to expiration
if (typeof window !== 'undefined' && window.emitter) {
window.emitter.emit('CUSTOM_SNOOZE_TIME_CHANGED');
}
return null;
}
return data.timestamp;
} catch (error) {
// eslint-disable-next-line no-console
console.error('Failed to get custom snooze time:', error);
localStorage.removeItem(LAST_CUSTOM_SNOOZE_KEY);
// Emit event when data is cleared due to error
if (typeof window !== 'undefined' && window.emitter) {
window.emitter.emit('CUSTOM_SNOOZE_TIME_CHANGED');
}
return null;
}
};
export const hasLastCustomSnoozeTime = () => {
return getLastCustomSnoozeTime() !== null;
};
export const formatLastCustomSnoozeTime = () => {
const timestamp = getLastCustomSnoozeTime();
if (!timestamp) return null;
try {
const date = new Date(timestamp * 1000); // Convert Unix timestamp to milliseconds
// Format as "Sat, 23 Aug, 8.16pm"
const dayOfWeek = date.toLocaleDateString('en-US', { weekday: 'short' });
const day = date.getDate();
const month = date.toLocaleDateString('en-US', { month: 'short' });
// Format time as 8.16pm (with dots instead of colons)
let hours = date.getHours();
const minutes = date.getMinutes().toString().padStart(2, '0');
const ampm = hours >= 12 ? 'pm' : 'am';
// Convert to 12-hour format
hours %= 12;
if (hours === 0) hours = 12; // 12am/12pm instead of 0am/0pm
const timeString = `${hours}.${minutes}${ampm}`;
return `${dayOfWeek}, ${day} ${month}, ${timeString}`;
} catch (error) {
return null;
}
};
@@ -12,6 +12,7 @@ import {
setSeconds,
} from 'date-fns';
import wootConstants from 'dashboard/constants/globals';
import { getLastCustomSnoozeTime } from './customSnoozeStorage';
const SNOOZE_OPTIONS = wootConstants.SNOOZE_OPTIONS;
@@ -51,6 +52,9 @@ export const findSnoozeTime = (snoozeType, currentDate = new Date()) => {
parsedDate = setHoursToNine(findStartOfNextWeek(currentDate));
} else if (snoozeType === SNOOZE_OPTIONS.UNTIL_NEXT_MONTH) {
parsedDate = setHoursToNine(findStartOfNextMonth(currentDate));
} else if (snoozeType === SNOOZE_OPTIONS.UNTIL_LAST_CUSTOM_TIME) {
// Return the stored timestamp directly (already in Unix format)
return getLastCustomSnoozeTime();
}
return parsedDate ? getUnixTime(parsedDate) : null;
@@ -233,6 +233,7 @@
"UNTIL_TOMORROW": "Until tomorrow",
"UNTIL_NEXT_MONTH": "Until next month",
"AN_HOUR_FROM_NOW": "Until an hour from now",
"UNTIL_LAST_CUSTOM_TIME": "Last snoozed {time}",
"UNTIL_CUSTOM_TIME": "Custom...",
"CHANGE_APPEARANCE": "Change Appearance",
"LIGHT_MODE": "Light",
@@ -6,6 +6,7 @@ import { useI18n } from 'vue-i18n';
import { useEmitter } from 'dashboard/composables/emitter';
import { getUnixTime } from 'date-fns';
import { findSnoozeTime } from 'dashboard/helper/snoozeHelpers';
import { getLastCustomSnoozeTime } from 'dashboard/helper/customSnoozeStorage';
import { CMD_SNOOZE_CONVERSATION } from 'dashboard/helper/commandbar/events';
import wootConstants from 'dashboard/constants/globals';
import CustomSnoozeModal from 'dashboard/components/CustomSnoozeModal.vue';
@@ -31,6 +32,17 @@ const toggleStatus = async (status, snoozedUntil) => {
const onCmdSnoozeConversation = snoozeType => {
if (snoozeType === wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME) {
showCustomSnoozeModal.value = true;
} else if (
snoozeType === wootConstants.SNOOZE_OPTIONS.UNTIL_LAST_CUSTOM_TIME
) {
// Use the saved custom time
const lastCustomTime = getLastCustomSnoozeTime();
if (lastCustomTime) {
toggleStatus(wootConstants.STATUS_TYPE.SNOOZED, lastCustomTime);
} else {
// Fallback to showing custom modal if no saved time exists
showCustomSnoozeModal.value = true;
}
} else {
toggleStatus(
wootConstants.STATUS_TYPE.SNOOZED,
@@ -1,6 +1,6 @@
<script setup>
import '@chatwoot/ninja-keys';
import { ref, computed, watchEffect, onMounted } from 'vue';
import { ref, computed, watchEffect, onMounted, onUnmounted } from 'vue';
import { useStore } from 'dashboard/composables/store';
import { useTrack } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
@@ -11,6 +11,7 @@ import { useBulkActionsHotKeys } from 'dashboard/composables/commands/useBulkAct
import { useConversationHotKeys } from 'dashboard/composables/commands/useConversationHotKeys';
import wootConstants from 'dashboard/constants/globals';
import { GENERAL_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import { emitter } from 'shared/helpers/mitt';
const store = useStore();
const { t } = useI18n();
@@ -26,7 +27,8 @@ const { goToAppearanceHotKeys } = useAppearanceHotKeys();
const { inboxHotKeys } = useInboxHotKeys();
const { goToCommandHotKeys } = useGoToCommandHotKeys();
const { bulkActionsHotKeys } = useBulkActionsHotKeys();
const { conversationHotKeys } = useConversationHotKeys();
const { conversationHotKeys, forceSnoozeActionsUpdate } =
useConversationHotKeys();
const placeholder = computed(() => t('COMMAND_BAR.SEARCH_PLACEHOLDER'));
@@ -78,7 +80,37 @@ watchEffect(() => {
}
});
onMounted(setCommandBarData);
onMounted(() => {
setCommandBarData();
// Make emitter globally available for storage helpers
window.emitter = emitter;
// Listen for custom snooze time changes to update actions
emitter.on('CUSTOM_SNOOZE_TIME_CHANGED', () => {
if (forceSnoozeActionsUpdate) {
forceSnoozeActionsUpdate();
}
});
// Backward compatibility
emitter.on('CUSTOM_SNOOZE_TIME_SAVED', () => {
if (forceSnoozeActionsUpdate) {
forceSnoozeActionsUpdate();
}
});
});
onUnmounted(() => {
// Clean up event listeners
emitter.off('CUSTOM_SNOOZE_TIME_CHANGED');
emitter.off('CUSTOM_SNOOZE_TIME_SAVED');
// Clean up global reference
if (window.emitter === emitter) {
delete window.emitter;
}
});
</script>
<!-- eslint-disable vue/attribute-hyphenation -->