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.
This commit is contained in:
Vishnu Narayanan
2025-08-22 18:41:18 +05:30
parent 1fed175e65
commit 2762da5636
7 changed files with 194 additions and 42 deletions
@@ -31,6 +31,7 @@ export default {
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);
}
},
@@ -2,7 +2,6 @@ import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store';
import wootConstants from 'dashboard/constants/globals';
import { hasLastCustomSnoozeTime } from 'dashboard/helper/customSnoozeStorage';
import {
CMD_BULK_ACTION_SNOOZE_CONVERSATION,
@@ -28,16 +27,7 @@ const SNOOZE_CONVERSATION_BULK_ACTIONS = [
title: 'COMMAND_BAR.COMMANDS.SNOOZE_CONVERSATION',
section: 'COMMAND_BAR.SECTIONS.BULK_ACTIONS',
icon: ICON_SNOOZE_CONVERSATION,
children: () => {
const availableOptions = Object.values(SNOOZE_OPTIONS);
// Only include UNTIL_LAST_CUSTOM_TIME if there's a saved custom time
return availableOptions.filter(option => {
if (option === SNOOZE_OPTIONS.UNTIL_LAST_CUSTOM_TIME) {
return hasLastCustomSnoozeTime();
}
return true;
});
},
children: Object.values(SNOOZE_OPTIONS),
},
...createSnoozeHandlers(
CMD_BULK_ACTION_SNOOZE_CONVERSATION,
@@ -76,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,
};
}
@@ -32,21 +32,69 @@ export const OPEN_CONVERSATION_ACTIONS = [
];
export const createSnoozeHandlers = (busEventName, parentId, section) => {
// Only include UNTIL_LAST_CUSTOM_TIME in handlers if there's a saved custom time
// Import the helper functions inline to avoid import issues
const LAST_CUSTOM_SNOOZE_KEY = 'chatwoot_last_custom_snooze_time';
const hasLastCustomSnoozeTime = () => {
const getLastCustomSnoozeTime = () => {
try {
const stored = localStorage.getItem(LAST_CUSTOM_SNOOZE_KEY);
if (!stored) return false;
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;
return now - data.savedAt <= sevenDaysInMs;
} catch {
return false;
// 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) {
@@ -55,14 +103,26 @@ export const createSnoozeHandlers = (busEventName, parentId, section) => {
return true;
});
return snoozeOptions.map(option => ({
id: option,
title: `COMMAND_BAR.COMMANDS.${option.toUpperCase()}`,
parent: parentId,
section: section,
icon: ICON_SNOOZE_CONVERSATION,
handler: () => emitter.emit(busEventName, option),
}));
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 = [
@@ -11,6 +11,10 @@ export const saveLastCustomSnoozeTime = 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);
@@ -23,15 +27,21 @@ export const getLastCustomSnoozeTime = () => {
if (!stored) return null;
const data = JSON.parse(stored);
// Check if the saved time is still in the future (within 7 days of when it was saved)
const now = Date.now();
const nowUnix = Math.floor(now / 1000);
const savedAt = data.savedAt;
const sevenDaysInMs = 7 * 24 * 60 * 60 * 1000;
// If saved more than 7 days ago, consider it stale
if (now - savedAt > sevenDaysInMs) {
// 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;
}
@@ -40,6 +50,10 @@ export const getLastCustomSnoozeTime = () => {
// 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;
}
};
@@ -54,7 +68,24 @@ export const formatLastCustomSnoozeTime = () => {
try {
const date = new Date(timestamp * 1000); // Convert Unix timestamp to milliseconds
return date.toLocaleString();
// 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;
}
@@ -233,7 +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": "Use last custom time",
"UNTIL_LAST_CUSTOM_TIME": "Last snoozed {time}",
"UNTIL_CUSTOM_TIME": "Custom...",
"CHANGE_APPEARANCE": "Change Appearance",
"LIGHT_MODE": "Light",
@@ -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 -->