mirror of
https://github.com/lingble/chatwoot.git
synced 2025-11-01 19:48:08 +00:00
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:
@@ -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);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
5
app/javascript/dashboard/constants/appEvents.js
Normal file
5
app/javascript/dashboard/constants/appEvents.js
Normal 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';
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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' };
|
||||||
|
|
||||||
|
|||||||
@@ -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 = () => {};
|
|
||||||
});
|
|
||||||
|
|||||||
BIN
public/audio/dashboard/chime.mp3
Normal file
BIN
public/audio/dashboard/chime.mp3
Normal file
Binary file not shown.
BIN
public/audio/dashboard/magic.mp3
Normal file
BIN
public/audio/dashboard/magic.mp3
Normal file
Binary file not shown.
BIN
public/audio/dashboard/ping.mp3
Normal file
BIN
public/audio/dashboard/ping.mp3
Normal file
Binary file not shown.
Reference in New Issue
Block a user