feat: update notification settings (#10529)

https://github.com/user-attachments/assets/52ecf3f8-0329-4268-906e-d6102338f4af

---------

Co-authored-by: Pranav <pranav@chatwoot.com>
Co-authored-by: Pranav <pranavrajs@gmail.com>
This commit is contained in:
Shivam Mishra
2024-12-05 19:05:04 +05:30
committed by GitHub
parent d635be4b2a
commit cdff624a0a
21 changed files with 794 additions and 292 deletions

View File

@@ -26,7 +26,13 @@ export default {
emitter.off('newToastMessage', this.onNewToastMessage); emitter.off('newToastMessage', this.onNewToastMessage);
}, },
methods: { methods: {
onNewToastMessage({ message, action }) { onNewToastMessage({ message: originalMessage, action }) {
// FIX ME: This is a temporary workaround to pass string from functions
// that doesn't have the context of the VueApp.
const usei18n = action?.usei18n;
const duration = action?.duration || this.duration;
const message = usei18n ? this.$t(originalMessage) : originalMessage;
this.snackMessages.push({ this.snackMessages.push({
key: new Date().getTime(), key: new Date().getTime(),
message, message,
@@ -34,7 +40,7 @@ export default {
}); });
window.setTimeout(() => { window.setTimeout(() => {
this.snackMessages.splice(0, 1); this.snackMessages.splice(0, 1);
}, this.duration); }, duration);
}, },
}, },
}; };

View File

@@ -0,0 +1,5 @@
export const CHATWOOT_SET_USER = 'CHATWOOT_SET_USER';
export const CHATWOOT_RESET = 'CHATWOOT_RESET';
export const ANALYTICS_IDENTITY = 'ANALYTICS_IDENTITY';
export const ANALYTICS_RESET = 'ANALYTICS_RESET';

View File

@@ -0,0 +1,6 @@
export const getAssignee = message => message?.conversation?.assignee_id;
export const isConversationUnassigned = message => !getAssignee(message);
export const isConversationAssignedToMe = (message, currentUserId) =>
getAssignee(message) === currentUserId;
export const isMessageFromCurrentUser = (message, currentUserId) =>
message?.sender?.id === currentUserId;

View File

@@ -0,0 +1,37 @@
import {
ROLES,
CONVERSATION_PERMISSIONS,
} from 'dashboard/constants/permissions';
import { getUserPermissions } from 'dashboard/helper/permissionsHelper';
class AudioNotificationStore {
constructor(store) {
this.store = store;
}
hasUnreadConversation = () => {
const mineConversation = this.store.getters.getMineChats({
assigneeType: 'me',
status: 'open',
});
return mineConversation.some(conv => conv.unread_count > 0);
};
isMessageFromCurrentConversation = message => {
return this.store.getters.getSelectedChat?.id === message.conversation_id;
};
hasConversationPermission = user => {
const currentAccountId = this.store.getters.getCurrentAccountId;
// Get the user permissions for the current account
const userPermissions = getUserPermissions(user, currentAccountId);
// Check if the user has the required permissions
const hasRequiredPermission = [...ROLES, ...CONVERSATION_PERMISSIONS].some(
permission => userPermissions.includes(permission)
);
return hasRequiredPermission;
};
}
export default AudioNotificationStore;

View File

@@ -1,92 +1,125 @@
import { MESSAGE_TYPE } from 'shared/constants/messages'; import { MESSAGE_TYPE } from 'shared/constants/messages';
import { showBadgeOnFavicon } from './faviconHelper'; import { showBadgeOnFavicon } from './faviconHelper';
import { initFaviconSwitcher } from './faviconHelper'; import { initFaviconSwitcher } from './faviconHelper';
import { EVENT_TYPES } from 'dashboard/routes/dashboard/settings/profile/constants.js';
import GlobalStore from 'dashboard/store';
import AudioNotificationStore from './AudioNotificationStore';
import { import {
getAlertAudio, isConversationAssignedToMe,
initOnEvents, isConversationUnassigned,
} from 'shared/helpers/AudioNotificationHelper'; isMessageFromCurrentUser,
import { } from './AudioMessageHelper';
ROLES, import WindowVisibilityHelper from './WindowVisibilityHelper';
CONVERSATION_PERMISSIONS, import { useAlert } from 'dashboard/composables';
} from 'dashboard/constants/permissions.js';
import { getUserPermissions } from 'dashboard/helper/permissionsHelper.js';
const NOTIFICATION_TIME = 30000; const NOTIFICATION_TIME = 30000;
const ALERT_DURATION = 10000;
const ALERT_PATH_PREFIX = '/audio/dashboard/';
const DEFAULT_TONE = 'ding';
const DEFAULT_ALERT_TYPE = ['none'];
export class DashboardAudioNotificationHelper {
constructor(store) {
if (!store) {
throw new Error('store is required');
}
this.store = new AudioNotificationStore(store);
this.notificationConfig = {
audioAlertType: DEFAULT_ALERT_TYPE,
playAlertOnlyWhenHidden: true,
alertIfUnreadConversationExist: false,
};
class DashboardAudioNotificationHelper {
constructor() {
this.recurringNotificationTimer = null; this.recurringNotificationTimer = null;
this.audioAlertType = 'none';
this.playAlertOnlyWhenHidden = true;
this.alertIfUnreadConversationExist = false;
this.currentUser = null;
this.currentUserId = null;
this.audioAlertTone = 'ding';
this.onAudioListenEvent = async () => { this.audioConfig = {
audio: null,
tone: DEFAULT_TONE,
hasSentSoundPermissionsRequest: false,
};
this.currentUser = null;
}
intializeAudio = () => {
const resourceUrl = `${ALERT_PATH_PREFIX}${this.audioConfig.tone}.mp3`;
this.audioConfig.audio = new Audio(resourceUrl);
return this.audioConfig.audio.load();
};
playAudioAlert = async () => {
try { try {
await getAlertAudio('', { await this.audioConfig.audio.play();
type: 'dashboard',
alertTone: this.audioAlertTone,
});
initOnEvents.forEach(event => {
document.removeEventListener(event, this.onAudioListenEvent, false);
});
this.playAudioEvery30Seconds();
} catch (error) { } catch (error) {
// Ignore audio fetch errors if (
error.name === 'NotAllowedError' &&
!this.hasSentSoundPermissionsRequest
) {
this.hasSentSoundPermissionsRequest = true;
useAlert(
'PROFILE_SETTINGS.FORM.AUDIO_NOTIFICATIONS_SECTION.SOUND_PERMISSION_ERROR',
{ usei18n: true, duration: ALERT_DURATION }
);
}
} }
}; };
}
setInstanceValues = ({ set = ({
currentUser, currentUser,
alwaysPlayAudioAlert, alwaysPlayAudioAlert,
alertIfUnreadConversationExist, alertIfUnreadConversationExist,
audioAlertType, audioAlertType = DEFAULT_ALERT_TYPE,
audioAlertTone, audioAlertTone = DEFAULT_TONE,
}) => { }) => {
this.audioAlertType = audioAlertType; this.notificationConfig = {
this.playAlertOnlyWhenHidden = !alwaysPlayAudioAlert; ...this.notificationConfig,
this.alertIfUnreadConversationExist = alertIfUnreadConversationExist; audioAlertType: audioAlertType.split('+').filter(Boolean),
playAlertOnlyWhenHidden: !alwaysPlayAudioAlert,
alertIfUnreadConversationExist: alertIfUnreadConversationExist,
};
this.currentUser = currentUser; this.currentUser = currentUser;
this.currentUserId = currentUser.id;
this.audioAlertTone = audioAlertTone; const previousAudioTone = this.audioConfig.tone;
initOnEvents.forEach(e => { this.audioConfig = {
document.addEventListener(e, this.onAudioListenEvent, { ...this.audioConfig,
once: true, tone: audioAlertTone,
}); };
});
if (previousAudioTone !== audioAlertTone) {
this.intializeAudio();
}
initFaviconSwitcher(); initFaviconSwitcher();
this.clearRecurringTimer();
this.playAudioEvery30Seconds();
};
shouldPlayAlert = () => {
if (this.notificationConfig.playAlertOnlyWhenHidden) {
return !WindowVisibilityHelper.isWindowVisible();
}
return true;
}; };
executeRecurringNotification = () => { executeRecurringNotification = () => {
if (!window.WOOT_STORE) { if (this.store.hasUnreadConversation() && this.shouldPlayAlert()) {
this.clearSetTimeout(); this.playAudioAlert();
return;
}
const mineConversation = window.WOOT_STORE.getters.getMineChats({
assigneeType: 'me',
status: 'open',
});
const hasUnreadConversation = mineConversation.some(conv => {
return conv.unread_count > 0;
});
const shouldPlayAlert = !this.playAlertOnlyWhenHidden || document.hidden;
if (hasUnreadConversation && shouldPlayAlert) {
window.playAudioAlert();
showBadgeOnFavicon(); showBadgeOnFavicon();
} }
this.clearSetTimeout(); this.resetRecurringTimer();
}; };
clearSetTimeout = () => { clearRecurringTimer = () => {
if (this.recurringNotificationTimer) { if (this.recurringNotificationTimer) {
clearTimeout(this.recurringNotificationTimer); clearTimeout(this.recurringNotificationTimer);
} }
};
resetRecurringTimer = () => {
this.clearRecurringTimer();
this.recurringNotificationTimer = setTimeout( this.recurringNotificationTimer = setTimeout(
this.executeRecurringNotification, this.executeRecurringNotification,
NOTIFICATION_TIME NOTIFICATION_TIME
@@ -94,67 +127,57 @@ class DashboardAudioNotificationHelper {
}; };
playAudioEvery30Seconds = () => { playAudioEvery30Seconds = () => {
const { audioAlertType, alertIfUnreadConversationExist } =
this.notificationConfig;
// Audio alert is disabled dismiss the timer // Audio alert is disabled dismiss the timer
if (this.audioAlertType === 'none') { if (audioAlertType.includes('none')) return;
return;
}
// If assigned conversation flag is disabled dismiss the timer
if (!this.alertIfUnreadConversationExist) {
return;
}
this.clearSetTimeout(); // If unread conversation flag is disabled, dismiss the timer
}; if (!alertIfUnreadConversationExist) return;
isConversationAssignedToCurrentUser = message => { this.resetRecurringTimer();
const conversationAssigneeId = message?.conversation?.assignee_id;
return conversationAssigneeId === this.currentUserId;
};
// eslint-disable-next-line class-methods-use-this
isMessageFromCurrentConversation = message => {
return (
window.WOOT_STORE.getters.getSelectedChat?.id === message.conversation_id
);
};
isMessageFromCurrentUser = message => {
return message?.sender_id === this.currentUserId;
};
isUserHasConversationPermission = () => {
const currentAccountId = window.WOOT_STORE.getters.getCurrentAccountId;
// Get the user permissions for the current account
const userPermissions = getUserPermissions(
this.currentUser,
currentAccountId
);
// Check if the user has the required permissions
const hasRequiredPermission = [...ROLES, ...CONVERSATION_PERMISSIONS].some(
permission => userPermissions.includes(permission)
);
return hasRequiredPermission;
}; };
shouldNotifyOnMessage = message => { shouldNotifyOnMessage = message => {
if (this.audioAlertType === 'mine') { const { audioAlertType } = this.notificationConfig;
return this.isConversationAssignedToCurrentUser(message); if (audioAlertType.includes('none')) return false;
if (audioAlertType.includes('all')) return true;
const assignedToMe = isConversationAssignedToMe(
message,
this.currentUser.id
);
const isUnassigned = isConversationUnassigned(message);
const shouldPlayAudio = [];
if (audioAlertType.includes(EVENT_TYPES.ASSIGNED)) {
shouldPlayAudio.push(assignedToMe);
} }
return this.audioAlertType === 'all'; if (audioAlertType.includes(EVENT_TYPES.UNASSIGNED)) {
shouldPlayAudio.push(isUnassigned);
}
if (audioAlertType.includes(EVENT_TYPES.NOTME)) {
shouldPlayAudio.push(!isUnassigned && !assignedToMe);
}
return shouldPlayAudio.some(Boolean);
}; };
onNewMessage = message => { onNewMessage = message => {
// If the user does not have the permission to view the conversation, then dismiss the alert // If the user does not have the permission to view the conversation, then dismiss the alert
if (!this.isUserHasConversationPermission()) { // FIX ME: There shouldn't be a new message if the user has no access to the conversation.
if (!this.store.hasConversationPermission(this.currentUser)) {
return; return;
} }
// If the message is sent by the current user or the // If the message is sent by the current user then dismiss the alert
// correct notification is not enabled, then dismiss the alert if (isMessageFromCurrentUser(message, this.currentUser.id)) {
if ( return;
this.isMessageFromCurrentUser(message) || }
!this.shouldNotifyOnMessage(message)
) { if (!this.shouldNotifyOnMessage(message)) {
return; return;
} }
@@ -164,21 +187,22 @@ class DashboardAudioNotificationHelper {
return; return;
} }
if (WindowVisibilityHelper.isWindowVisible()) {
// If the user looking at the conversation, then dismiss the alert // If the user looking at the conversation, then dismiss the alert
if (this.isMessageFromCurrentConversation(message) && !document.hidden) { if (this.store.isMessageFromCurrentConversation(message)) {
return;
}
// If the user has disabled alerts when active on the dashboard, the dismiss the alert
if (this.playAlertOnlyWhenHidden && !document.hidden) {
return; return;
} }
window.playAudioAlert(); // If the user has disabled alerts when active on the dashboard, the dismiss the alert
if (this.notificationConfig.playAlertOnlyWhenHidden) {
return;
}
}
this.playAudioAlert();
showBadgeOnFavicon(); showBadgeOnFavicon();
this.playAudioEvery30Seconds(); this.playAudioEvery30Seconds();
}; };
} }
const notifHelper = new DashboardAudioNotificationHelper(); export default new DashboardAudioNotificationHelper(GlobalStore);
window.notifHelper = notifHelper;
export default notifHelper;

View File

@@ -0,0 +1,21 @@
export class WindowVisibilityHelper {
constructor() {
this.isVisible = true;
this.initializeEvent();
}
initializeEvent = () => {
window.addEventListener('blur', () => {
this.isVisible = false;
});
window.addEventListener('focus', () => {
this.isVisible = true;
});
};
isWindowVisible() {
return !document.hidden && this.isVisible;
}
}
export default new WindowVisibilityHelper();

View File

@@ -0,0 +1,79 @@
import {
getAssignee,
isConversationUnassigned,
isConversationAssignedToMe,
isMessageFromCurrentUser,
} from '../AudioMessageHelper';
describe('getAssignee', () => {
it('should return assignee_id when present', () => {
const message = { conversation: { assignee_id: 1 } };
expect(getAssignee(message)).toBe(1);
});
it('should return undefined when no assignee_id', () => {
const message = { conversation: null };
expect(getAssignee(message)).toBeUndefined();
});
it('should handle null message', () => {
expect(getAssignee(null)).toBeUndefined();
});
});
describe('isConversationUnassigned', () => {
it('should return true when no assignee', () => {
const message = { conversation: { assignee_id: null } };
expect(isConversationUnassigned(message)).toBe(true);
});
it('should return false when has assignee', () => {
const message = { conversation: { assignee_id: 1 } };
expect(isConversationUnassigned(message)).toBe(false);
});
it('should handle null message', () => {
expect(isConversationUnassigned(null)).toBe(true);
});
});
describe('isConversationAssignedToMe', () => {
const currentUserId = 1;
it('should return true when assigned to current user', () => {
const message = { conversation: { assignee_id: 1 } };
expect(isConversationAssignedToMe(message, currentUserId)).toBe(true);
});
it('should return false when assigned to different user', () => {
const message = { conversation: { assignee_id: 2 } };
expect(isConversationAssignedToMe(message, currentUserId)).toBe(false);
});
it('should return false when unassigned', () => {
const message = { conversation: { assignee_id: null } };
expect(isConversationAssignedToMe(message, currentUserId)).toBe(false);
});
it('should handle null message', () => {
expect(isConversationAssignedToMe(null, currentUserId)).toBe(false);
});
});
describe('isMessageFromCurrentUser', () => {
const currentUserId = 1;
it('should return true when message is from current user', () => {
const message = { sender: { id: 1 } };
expect(isMessageFromCurrentUser(message, currentUserId)).toBe(true);
});
it('should return false when message is from different user', () => {
const message = { sender: { id: 2 } };
expect(isMessageFromCurrentUser(message, currentUserId)).toBe(false);
});
it('should handle null message', () => {
expect(isMessageFromCurrentUser(null, currentUserId)).toBe(false);
});
});

View File

@@ -0,0 +1,131 @@
import AudioNotificationStore from '../AudioNotificationStore';
import {
ROLES,
CONVERSATION_PERMISSIONS,
} from 'dashboard/constants/permissions';
import { getUserPermissions } from 'dashboard/helper/permissionsHelper';
vi.mock('dashboard/helper/permissionsHelper', () => ({
getUserPermissions: vi.fn(),
}));
describe('AudioNotificationStore', () => {
let store;
let audioNotificationStore;
beforeEach(() => {
store = {
getters: {
getMineChats: vi.fn(),
getSelectedChat: null,
getCurrentAccountId: 1,
},
};
audioNotificationStore = new AudioNotificationStore(store);
});
describe('hasUnreadConversation', () => {
it('should return true when there are unread conversations', () => {
store.getters.getMineChats.mockReturnValue([
{ id: 1, unread_count: 2 },
{ id: 2, unread_count: 0 },
]);
expect(audioNotificationStore.hasUnreadConversation()).toBe(true);
});
it('should return false when there are no unread conversations', () => {
store.getters.getMineChats.mockReturnValue([
{ id: 1, unread_count: 0 },
{ id: 2, unread_count: 0 },
]);
expect(audioNotificationStore.hasUnreadConversation()).toBe(false);
});
it('should return false when there are no conversations', () => {
store.getters.getMineChats.mockReturnValue([]);
expect(audioNotificationStore.hasUnreadConversation()).toBe(false);
});
it('should call getMineChats with correct parameters', () => {
store.getters.getMineChats.mockReturnValue([]);
audioNotificationStore.hasUnreadConversation();
expect(store.getters.getMineChats).toHaveBeenCalledWith({
assigneeType: 'me',
status: 'open',
});
});
});
describe('isMessageFromCurrentConversation', () => {
it('should return true when message is from selected chat', () => {
store.getters.getSelectedChat = { id: 6179 };
const message = { conversation_id: 6179 };
expect(
audioNotificationStore.isMessageFromCurrentConversation(message)
).toBe(true);
});
it('should return false when message is from different chat', () => {
store.getters.getSelectedChat = { id: 6179 };
const message = { conversation_id: 1337 };
expect(
audioNotificationStore.isMessageFromCurrentConversation(message)
).toBe(false);
});
it('should return false when no chat is selected', () => {
store.getters.getSelectedChat = null;
const message = { conversation_id: 6179 };
expect(
audioNotificationStore.isMessageFromCurrentConversation(message)
).toBe(false);
});
});
describe('hasConversationPermission', () => {
const mockUser = { id: 'user123' };
beforeEach(() => {
getUserPermissions.mockReset();
});
it('should return true when user has a required role', () => {
getUserPermissions.mockReturnValue([ROLES[0]]);
expect(audioNotificationStore.hasConversationPermission(mockUser)).toBe(
true
);
expect(getUserPermissions).toHaveBeenCalledWith(mockUser, 1);
});
it('should return true when user has a conversation permission', () => {
getUserPermissions.mockReturnValue([CONVERSATION_PERMISSIONS[0]]);
expect(audioNotificationStore.hasConversationPermission(mockUser)).toBe(
true
);
});
it('should return false when user has no required permissions', () => {
getUserPermissions.mockReturnValue(['some-other-permission']);
expect(audioNotificationStore.hasConversationPermission(mockUser)).toBe(
false
);
});
it('should return false when user has no permissions', () => {
getUserPermissions.mockReturnValue([]);
expect(audioNotificationStore.hasConversationPermission(mockUser)).toBe(
false
);
});
});
});

View File

@@ -0,0 +1,114 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { WindowVisibilityHelper } from '../WindowVisibilityHelper';
describe('WindowVisibilityHelper', () => {
let blurCallback;
let focusCallback;
let windowEventListeners;
let documentHiddenValue = false;
beforeEach(() => {
vi.resetModules();
vi.resetAllMocks();
// Reset event listeners before each test
windowEventListeners = {};
// Mock window.addEventListener
window.addEventListener = vi.fn((event, callback) => {
windowEventListeners[event] = callback;
if (event === 'blur') blurCallback = callback;
if (event === 'focus') focusCallback = callback;
});
// Mock document.hidden with a getter that returns our controlled value
Object.defineProperty(document, 'hidden', {
configurable: true,
get: () => documentHiddenValue,
});
});
afterEach(() => {
vi.clearAllMocks();
documentHiddenValue = false;
});
describe('initialization', () => {
it('should add blur and focus event listeners', () => {
const helper = new WindowVisibilityHelper();
expect(helper.isVisible).toBe(true);
expect(window.addEventListener).toHaveBeenCalledTimes(2);
expect(window.addEventListener).toHaveBeenCalledWith(
'blur',
expect.any(Function)
);
expect(window.addEventListener).toHaveBeenCalledWith(
'focus',
expect.any(Function)
);
});
});
describe('window events', () => {
it('should set isVisible to false on blur', () => {
const helper = new WindowVisibilityHelper();
blurCallback();
expect(helper.isVisible).toBe(false);
});
it('should set isVisible to true on focus', () => {
const helper = new WindowVisibilityHelper();
blurCallback(); // First blur the window
focusCallback(); // Then focus it
expect(helper.isVisible).toBe(true);
});
it('should handle multiple blur/focus events', () => {
const helper = new WindowVisibilityHelper();
blurCallback();
expect(helper.isVisible).toBe(false);
focusCallback();
expect(helper.isVisible).toBe(true);
blurCallback();
expect(helper.isVisible).toBe(false);
});
});
describe('isWindowVisible', () => {
it('should return true when document is visible and window is focused', () => {
const helper = new WindowVisibilityHelper();
documentHiddenValue = false;
helper.isVisible = true;
expect(helper.isWindowVisible()).toBe(true);
});
it('should return false when document is hidden', () => {
const helper = new WindowVisibilityHelper();
documentHiddenValue = true;
helper.isVisible = true;
expect(helper.isWindowVisible()).toBe(false);
});
it('should return false when window is not focused', () => {
const helper = new WindowVisibilityHelper();
documentHiddenValue = false;
helper.isVisible = false;
expect(helper.isWindowVisible()).toBe(false);
});
it('should return false when both document is hidden and window is not focused', () => {
const helper = new WindowVisibilityHelper();
documentHiddenValue = true;
helper.isVisible = false;
expect(helper.isWindowVisible()).toBe(false);
});
});
});

View File

@@ -1,20 +1,19 @@
import {
ANALYTICS_IDENTITY,
CHATWOOT_RESET,
CHATWOOT_SET_USER,
} from '../constants/appEvents';
import AnalyticsHelper from './AnalyticsHelper'; import AnalyticsHelper from './AnalyticsHelper';
import DashboardAudioNotificationHelper from './AudioAlerts/DashboardAudioNotificationHelper'; import DashboardAudioNotificationHelper from './AudioAlerts/DashboardAudioNotificationHelper';
import { emitter } from 'shared/helpers/mitt'; import { emitter } from 'shared/helpers/mitt';
export const CHATWOOT_SET_USER = 'CHATWOOT_SET_USER';
export const CHATWOOT_RESET = 'CHATWOOT_RESET';
export const ANALYTICS_IDENTITY = 'ANALYTICS_IDENTITY';
export const ANALYTICS_RESET = 'ANALYTICS_RESET';
export const initializeAnalyticsEvents = () => { export const initializeAnalyticsEvents = () => {
emitter.on(ANALYTICS_IDENTITY, ({ user }) => { emitter.on(ANALYTICS_IDENTITY, ({ user }) => {
AnalyticsHelper.identify(user); AnalyticsHelper.identify(user);
}); });
}; };
const initializeAudioAlerts = user => { export const initializeAudioAlerts = user => {
const { ui_settings: uiSettings } = user || {}; const { ui_settings: uiSettings } = user || {};
const { const {
always_play_audio_alert: alwaysPlayAudioAlert, always_play_audio_alert: alwaysPlayAudioAlert,
@@ -25,7 +24,7 @@ const initializeAudioAlerts = user => {
// entire payload for the user during the signup process. // entire payload for the user during the signup process.
} = uiSettings || {}; } = uiSettings || {};
DashboardAudioNotificationHelper.setInstanceValues({ DashboardAudioNotificationHelper.set({
currentUser: user, currentUser: user,
audioAlertType: audioAlertType || 'none', audioAlertType: audioAlertType || 'none',
audioAlertTone: audioAlertTone || 'ding', audioAlertTone: audioAlertTone || 'ding',

View File

@@ -61,15 +61,29 @@
"COPY": "Copy" "COPY": "Copy"
}, },
"AUDIO_NOTIFICATIONS_SECTION": { "AUDIO_NOTIFICATIONS_SECTION": {
"TITLE": "Audio Notifications", "TITLE": "Audio Alerts",
"NOTE": "Enable audio notifications in dashboard for new messages and conversations.", "NOTE": "Enable audio alerts in dashboard for new messages and conversations.",
"PLAY": "Play sound",
"ALERT_TYPES": { "ALERT_TYPES": {
"NONE": "None", "NONE": "None",
"MINE": "Assigned", "MINE": "Assigned",
"ALL": "All" "ALL": "All",
"ASSIGNED": "My assigned conversations",
"UNASSIGNED": "Unassigned conversations",
"NOTME": "Open conversations assigned to others"
},
"ALERT_COMBINATIONS": {
"NONE": "You haven't selected any options, you won't receive any audio alerts.",
"ASSIGNED": "You'll receive alerts for conversations assigned to you.",
"UNASSIGNED": "You'll receive alerts for any unassigned conversations.",
"NOTME": "You'll receive alerts for conversations assigned to others.",
"ASSIGNED+UNASSIGNED": "You'll receive alerts for your assigned conversations and any unattended ones.",
"ASSIGNED+NOTME": "You'll receive alerts for conversations assigned to you and to others, but not for unassigned ones.",
"NOTME+UNASSIGNED": "You'll receive alerts for unattended conversations and those assigned to others.",
"ASSIGNED+NOTME+UNASSIGNED": "You'll receive alerts for all conversations."
}, },
"ALERT_TYPE": { "ALERT_TYPE": {
"TITLE": "Alert events for conversations:", "TITLE": "Alert events for conversations",
"NONE": "None", "NONE": "None",
"ASSIGNED": "Assigned Conversations", "ASSIGNED": "Assigned Conversations",
"ALL_CONVERSATIONS": "All Conversations" "ALL_CONVERSATIONS": "All Conversations"
@@ -81,7 +95,9 @@
"TITLE": "Alert conditions:", "TITLE": "Alert conditions:",
"CONDITION_ONE": "Send audio alerts only if the browser window is not active", "CONDITION_ONE": "Send audio alerts only if the browser window is not active",
"CONDITION_TWO": "Send alerts every 30s until all the assigned conversations are read" "CONDITION_TWO": "Send alerts every 30s until all the assigned conversations are read"
} },
"SOUND_PERMISSION_ERROR": "Autoplay is disabled in your browser. To hear alerts automatically, enable sound permission in your browser settings or interact with the page.",
"READ_MORE": "Read more"
}, },
"EMAIL_NOTIFICATIONS_SECTION": { "EMAIL_NOTIFICATIONS_SECTION": {
"TITLE": "Email Notifications", "TITLE": "Email Notifications",

View File

@@ -10,6 +10,7 @@ defineProps({
required: true, required: true,
}, },
}); });
const emit = defineEmits(['change']); const emit = defineEmits(['change']);
const onChange = (id, value) => { const onChange = (id, value) => {
emit('change', id, value); emit('change', id, value);
@@ -23,18 +24,22 @@ const onChange = (id, value) => {
> >
{{ label }} {{ label }}
</label> </label>
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-3 mt-2">
<div <div
v-for="item in items" v-for="item in items"
:key="item.id" :key="item.id"
class="flex flex-row items-start gap-2" class="flex flex-row items-start gap-2"
> >
<CheckBox <CheckBox
:id="`checkbox-condition-${item.value}`"
:is-checked="item.model" :is-checked="item.model"
:value="item.value" :value="item.value"
@update="onChange" @update="onChange"
/> />
<label class="text-sm font-normal text-ash-900"> <label
class="text-sm font-normal text-ash-900"
:for="`checkbox-condition-${item.value}`"
>
{{ item.label }} {{ item.label }}
</label> </label>
</div> </div>

View File

@@ -1,6 +1,7 @@
<script setup> <script setup>
import { computed } from 'vue'; import { computed } from 'vue';
import { ALERT_EVENTS } from './constants'; import CheckBox from 'v3/components/Form/CheckBox.vue';
import { ALERT_EVENTS, EVENT_TYPES } from './constants';
const props = defineProps({ const props = defineProps({
label: { label: {
@@ -9,50 +10,83 @@ const props = defineProps({
}, },
value: { value: {
type: String, type: String,
default: 'all', default: '',
}, },
}); });
const emit = defineEmits(['update']); const emit = defineEmits(['update']);
const alertEvents = ALERT_EVENTS; const alertEvents = ALERT_EVENTS;
const alertEventValues = Object.values(EVENT_TYPES);
const selectedValue = computed({ const selectedValue = computed({
get: () => props.value, get: () => {
set: value => { // maintain backward compatibility
emit('update', value); if (props.value === 'none') return [];
if (props.value === 'mine') return [EVENT_TYPES.ASSIGNED];
if (props.value === 'all') return [...alertEventValues];
const validValues = props.value
.split('+')
.filter(value => alertEventValues.includes(value));
return [...new Set(validValues)];
}, },
set: value => {
const sortedValues = value.filter(Boolean).sort();
const uniqueValues = [...new Set(sortedValues)];
if (uniqueValues.length === 0) {
emit('update', 'none');
return;
}
emit('update', uniqueValues.join('+'));
},
});
const setValue = (isChecked, value) => {
let updatedValue = selectedValue.value;
if (isChecked) {
updatedValue.push(value);
} else {
updatedValue = updatedValue.filter(item => item !== value);
}
selectedValue.value = updatedValue;
};
const alertDescription = computed(() => {
const base =
'PROFILE_SETTINGS.FORM.AUDIO_NOTIFICATIONS_SECTION.ALERT_COMBINATIONS.';
if (props.value === '' || props.value === 'none') {
return base + 'NONE';
}
return base + selectedValue.value.join('+').toUpperCase();
}); });
</script> </script>
<template> <template>
<div> <div>
<label <label class="pb-1 text-sm font-medium leading-6 text-ash-900">
class="flex justify-between pb-1 text-sm font-medium leading-6 text-ash-900"
>
{{ label }} {{ label }}
</label> </label>
<div <div class="grid gap-3 mt-2">
class="flex flex-row justify-between h-10 max-w-xl p-2 border border-solid rounded-xl border-ash-200"
>
<div <div
v-for="option in alertEvents" v-for="option in alertEvents"
:key="option.value" :key="option.value"
class="flex flex-row items-center justify-center gap-2 px-4 border-r border-ash-200 grow last:border-r-0" class="flex items-center gap-2"
> >
<input <CheckBox
:id="`radio-${option.value}`" :id="`checkbox-${option.value}`"
v-model="selectedValue" :is-checked="selectedValue.includes(option.value)"
class="shadow-sm cursor-pointer grid place-items-center border-2 border-ash-200 appearance-none rounded-full w-4 h-4 checked:bg-primary-600 before:content-[''] before:bg-primary-600 before:border-4 before:rounded-full before:border-ash-25 checked:before:w-[14px] checked:before:h-[14px] checked:border checked:border-primary-600" @update="(_val, isChecked) => setValue(isChecked, option.value)"
type="radio"
:value="option.value"
/> />
<label <label
:for="`radio-${option.value}`" :for="`checkbox-${option.value}`"
class="text-sm font-medium" class="text-sm text-ash-900 font-normal"
:class="
selectedValue === option.value ? 'text-ash-900' : 'text-ash-800'
"
> >
{{ {{
$t( $t(
@@ -61,6 +95,9 @@ const selectedValue = computed({
}} }}
</label> </label>
</div> </div>
<div class="text-n-slate-11 text-sm font-medium mt-2">
{{ $t(alertDescription) }}
</div>
</div> </div>
</div> </div>
</template> </template>

View File

@@ -1,12 +1,15 @@
<script setup> <script setup>
import { computed } from 'vue'; import { computed } from 'vue';
import Icon from 'next/icon/Icon.vue';
import * as Sentry from '@sentry/vue';
import FormSelect from 'v3/components/Form/Select.vue'; import FormSelect from 'v3/components/Form/Select.vue';
const props = defineProps({ const props = defineProps({
value: { value: {
type: String, type: String,
required: true, required: true,
validator: value => ['ding', 'bell'].includes(value), validator: value =>
['ding', 'bell', 'chime', 'magic', 'ping'].includes(value),
}, },
label: { label: {
type: String, type: String,
@@ -25,6 +28,18 @@ const alertTones = computed(() => [
value: 'bell', value: 'bell',
label: 'Bell', label: 'Bell',
}, },
{
value: 'chime',
label: 'Chime',
},
{
value: 'magic',
label: 'Magic',
},
{
value: 'ping',
label: 'Ping',
},
]); ]);
const selectedValue = computed({ const selectedValue = computed({
@@ -33,17 +48,30 @@ const selectedValue = computed({
emit('change', value); emit('change', value);
}, },
}); });
const audio = new Audio();
const playAudio = async () => {
try {
// Has great support https://caniuse.com/mdn-api_htmlaudioelement
audio.src = `/audio/dashboard/${selectedValue.value}.mp3`;
await audio.play();
} catch (error) {
Sentry.captureException(error);
}
};
</script> </script>
<template> <template>
<div class="flex items-center gap-2">
<FormSelect <FormSelect
v-model="selectedValue" v-model="selectedValue"
name="alertTone" name="alertTone"
spacing="compact" spacing="compact"
class="flex-grow"
:value="selectedValue" :value="selectedValue"
:options="alertTones" :options="alertTones"
:label="label" :label="label"
class="max-w-xl"
> >
<option <option
v-for="tone in alertTones" v-for="tone in alertTones"
@@ -54,4 +82,14 @@ const selectedValue = computed({
{{ tone.label }} {{ tone.label }}
</option> </option>
</FormSelect> </FormSelect>
<button
v-tooltip.top="
$t('PROFILE_SETTINGS.FORM.AUDIO_NOTIFICATIONS_SECTION.PLAY')
"
class="border-0 shadow-sm outline-none flex justify-center items-center size-10 appearance-none rounded-xl ring-ash-200 ring-1 ring-inset focus:ring-2 focus:ring-inset focus:ring-primary-500 flex-shrink-0 mt-[28px]"
@click="playAudio"
>
<Icon icon="i-lucide-volume-2" />
</button>
</div>
</template> </template>

View File

@@ -1,98 +1,91 @@
<script> <script setup>
import { useAlert } from 'dashboard/composables'; import { useAlert } from 'dashboard/composables';
import { useUISettings } from 'dashboard/composables/useUISettings'; import { useUISettings } from 'dashboard/composables/useUISettings';
import AudioAlertTone from './AudioAlertTone.vue'; import AudioAlertTone from './AudioAlertTone.vue';
import AudioAlertEvent from './AudioAlertEvent.vue'; import AudioAlertEvent from './AudioAlertEvent.vue';
import AudioAlertCondition from './AudioAlertCondition.vue'; import AudioAlertCondition from './AudioAlertCondition.vue';
import { computed, onMounted, ref, watch } from 'vue';
import { useStore } from 'dashboard/composables/store';
const store = useStore();
import { useI18n } from 'vue-i18n';
import camelcaseKeys from 'camelcase-keys';
import { initializeAudioAlerts } from 'dashboard/helper/scriptHelpers';
import { useStoreGetters } from 'dashboard/composables/store';
const getters = useStoreGetters();
const currentUser = computed(() => getters.getCurrentUser.value);
export default {
components: {
AudioAlertEvent,
AudioAlertTone,
AudioAlertCondition,
},
setup() {
const { uiSettings, updateUISettings } = useUISettings(); const { uiSettings, updateUISettings } = useUISettings();
return { const { t } = useI18n();
uiSettings, const audioAlert = ref('');
updateUISettings, const playAudioWhenTabIsInactive = ref(false);
}; const alertIfUnreadConversationExist = ref(false);
}, const alertTone = ref('ding');
data() { const audioAlertConditions = ref([]);
return { const i18nKeyPrefix = 'PROFILE_SETTINGS.FORM.AUDIO_NOTIFICATIONS_SECTION';
audioAlert: '',
playAudioWhenTabIsInactive: false, const initializeNotificationUISettings = newUISettings => {
alertIfUnreadConversationExist: false, const updatedUISettings = camelcaseKeys(newUISettings);
alertTone: 'ding',
audioAlertConditions: [], audioAlert.value = updatedUISettings.enableAudioAlerts;
}; playAudioWhenTabIsInactive.value = !updatedUISettings.alwaysPlayAudioAlert;
}, alertIfUnreadConversationExist.value =
watch: { updatedUISettings.alertIfUnreadAssignedConversationExist;
uiSettings(value) { audioAlertConditions.value = [
this.notificationUISettings(value);
},
},
mounted() {
this.notificationUISettings(this.uiSettings);
this.$store.dispatch('userNotificationSettings/get');
},
methods: {
notificationUISettings(uiSettings) {
const {
enable_audio_alerts: audioAlert = '',
always_play_audio_alert: alwaysPlayAudioAlert,
alert_if_unread_assigned_conversation_exist:
alertIfUnreadConversationExist,
notification_tone: alertTone,
} = uiSettings;
this.audioAlert = audioAlert;
this.playAudioWhenTabIsInactive = !alwaysPlayAudioAlert;
this.alertIfUnreadConversationExist = alertIfUnreadConversationExist;
this.audioAlertConditions = [
{ {
id: 'audio1', id: 'audio1',
label: this.$t( label: t(`${i18nKeyPrefix}.CONDITIONS.CONDITION_ONE`),
'PROFILE_SETTINGS.FORM.AUDIO_NOTIFICATIONS_SECTION.CONDITIONS.CONDITION_ONE' model: playAudioWhenTabIsInactive.value,
),
model: this.playAudioWhenTabIsInactive,
value: 'tab_is_inactive', value: 'tab_is_inactive',
}, },
{ {
id: 'audio2', id: 'audio2',
label: this.$t( label: t(`${i18nKeyPrefix}.CONDITIONS.CONDITION_TWO`),
'PROFILE_SETTINGS.FORM.AUDIO_NOTIFICATIONS_SECTION.CONDITIONS.CONDITION_TWO' model: alertIfUnreadConversationExist.value,
),
model: this.alertIfUnreadConversationExist,
value: 'conversations_are_read', value: 'conversations_are_read',
}, },
]; ];
this.alertTone = alertTone || 'ding'; alertTone.value = updatedUISettings.notificationTone || 'ding';
};
watch(
uiSettings,
value => {
initializeNotificationUISettings(value);
}, },
handAudioAlertChange(value) { { immediate: true }
this.audioAlert = value; );
this.updateUISettings({
enable_audio_alerts: this.audioAlert, const handleAudioConfigChange = value => {
updateUISettings(value);
initializeAudioAlerts(currentUser.value);
useAlert(t('PROFILE_SETTINGS.FORM.API.UPDATE_SUCCESS'));
};
onMounted(() => {
store.dispatch('userNotificationSettings/get');
}); });
useAlert(this.$t('PROFILE_SETTINGS.FORM.API.UPDATE_SUCCESS'));
}, const handAudioAlertChange = value => {
handleAudioAlertConditions(id, value) { audioAlert.value = value;
handleAudioConfigChange({
enable_audio_alerts: value,
});
};
const handleAudioAlertConditions = (id, value) => {
if (id === 'tab_is_inactive') { if (id === 'tab_is_inactive') {
this.updateUISettings({ handleAudioConfigChange({
always_play_audio_alert: !value, always_play_audio_alert: !value,
}); });
} else if (id === 'conversations_are_read') { } else if (id === 'conversations_are_read') {
this.updateUISettings({ handleAudioConfigChange({
alert_if_unread_assigned_conversation_exist: value, alert_if_unread_assigned_conversation_exist: value,
}); });
} }
useAlert(this.$t('PROFILE_SETTINGS.FORM.API.UPDATE_SUCCESS')); };
}, const handleAudioToneChange = value => {
handleAudioToneChange(value) { handleAudioConfigChange({ notification_tone: value });
this.updateUISettings({ notification_tone: value });
useAlert(this.$t('PROFILE_SETTINGS.FORM.API.UPDATE_SUCCESS'));
},
},
}; };
</script> </script>
@@ -100,27 +93,19 @@ export default {
<div id="profile-settings-notifications" class="flex flex-col gap-6"> <div id="profile-settings-notifications" class="flex flex-col gap-6">
<AudioAlertTone <AudioAlertTone
:value="alertTone" :value="alertTone"
:label=" :label="$t(`${i18nKeyPrefix}.DEFAULT_TONE.TITLE`)"
$t(
'PROFILE_SETTINGS.FORM.AUDIO_NOTIFICATIONS_SECTION.DEFAULT_TONE.TITLE'
)
"
@change="handleAudioToneChange" @change="handleAudioToneChange"
/> />
<AudioAlertEvent <AudioAlertEvent
:label=" :label="$t(`${i18nKeyPrefix}.ALERT_TYPE.TITLE`)"
$t('PROFILE_SETTINGS.FORM.AUDIO_NOTIFICATIONS_SECTION.ALERT_TYPE.TITLE')
"
:value="audioAlert" :value="audioAlert"
@update="handAudioAlertChange" @update="handAudioAlertChange"
/> />
<AudioAlertCondition <AudioAlertCondition
:items="audioAlertConditions" :items="audioAlertConditions"
:label=" :label="$t(`${i18nKeyPrefix}.CONDITIONS.TITLE`)"
$t('PROFILE_SETTINGS.FORM.AUDIO_NOTIFICATIONS_SECTION.CONDITIONS.TITLE')
"
@change="handleAudioAlertConditions" @change="handleAudioAlertConditions"
/> />
</div> </div>

View File

@@ -36,17 +36,23 @@ export const NOTIFICATION_TYPES = [
}, },
]; ];
export const EVENT_TYPES = {
ASSIGNED: 'assigned',
NOTME: 'notme',
UNASSIGNED: 'unassigned',
};
export const ALERT_EVENTS = [ export const ALERT_EVENTS = [
{ {
value: 'none', value: EVENT_TYPES.ASSIGNED,
label: 'none', label: 'assigned',
}, },
{ {
value: 'mine', value: EVENT_TYPES.UNASSIGNED,
label: 'mine', label: 'unassigned',
}, },
{ {
value: 'all', value: EVENT_TYPES.NOTME,
label: 'all', label: 'notme',
}, },
]; ];

View File

@@ -1,15 +1,15 @@
import fromUnixTime from 'date-fns/fromUnixTime'; import fromUnixTime from 'date-fns/fromUnixTime';
import differenceInDays from 'date-fns/differenceInDays'; import differenceInDays from 'date-fns/differenceInDays';
import Cookies from 'js-cookie'; import Cookies from 'js-cookie';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import { LocalStorage } from 'shared/helpers/localStorage';
import { emitter } from 'shared/helpers/mitt';
import { import {
ANALYTICS_IDENTITY, ANALYTICS_IDENTITY,
ANALYTICS_RESET, ANALYTICS_RESET,
CHATWOOT_RESET, CHATWOOT_RESET,
CHATWOOT_SET_USER, CHATWOOT_SET_USER,
} from '../../helper/scriptHelpers'; } from '../../constants/appEvents';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import { LocalStorage } from 'shared/helpers/localStorage';
import { emitter } from 'shared/helpers/mitt';
Cookies.defaults = { sameSite: 'Lax' }; Cookies.defaults = { sameSite: 'Lax' };

View File

@@ -6,7 +6,6 @@ import axios from 'axios';
import hljsVuePlugin from '@highlightjs/vue-plugin'; import hljsVuePlugin from '@highlightjs/vue-plugin';
import Multiselect from 'vue-multiselect'; import Multiselect from 'vue-multiselect';
// import VueFormulate from '@braid/vue-formulate';
import { plugin, defaultConfig } from '@formkit/vue'; import { plugin, defaultConfig } from '@formkit/vue';
import WootSwitch from 'components/ui/Switch.vue'; import WootSwitch from 'components/ui/Switch.vue';
import WootWizard from 'components/ui/Wizard.vue'; import WootWizard from 'components/ui/Wizard.vue';
@@ -22,7 +21,6 @@ import router, { initalizeRouter } from 'dashboard/routes';
import store from 'dashboard/store'; import store from 'dashboard/store';
import constants from 'dashboard/constants/globals'; import constants from 'dashboard/constants/globals';
import * as Sentry from '@sentry/vue'; import * as Sentry from '@sentry/vue';
// import { Integrations } from '@sentry/tracing';
import { import {
initializeAnalyticsEvents, initializeAnalyticsEvents,
initializeChatwootEvents, initializeChatwootEvents,
@@ -101,7 +99,6 @@ app.directive('on-clickaway', onClickaway);
// load common helpers into js // load common helpers into js
commonHelpers(); commonHelpers();
window.WOOT_STORE = store;
window.WootConstants = constants; window.WootConstants = constants;
window.axios = createAxios(axios); window.axios = createAxios(axios);
// [VITE] Disabled this we don't need it, we can use `useEmitter` directly // [VITE] Disabled this we don't need it, we can use `useEmitter` directly
@@ -114,7 +111,3 @@ initalizeRouter();
window.onload = () => { window.onload = () => {
app.mount('#app'); app.mount('#app');
}; };
window.addEventListener('load', () => {
window.playAudioAlert = () => {};
});

Binary file not shown.

Binary file not shown.

Binary file not shown.