diff --git a/app/javascript/dashboard/components/SnackbarContainer.vue b/app/javascript/dashboard/components/SnackbarContainer.vue index 549678ebc..b55d3865c 100644 --- a/app/javascript/dashboard/components/SnackbarContainer.vue +++ b/app/javascript/dashboard/components/SnackbarContainer.vue @@ -26,7 +26,13 @@ export default { emitter.off('newToastMessage', this.onNewToastMessage); }, 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({ key: new Date().getTime(), message, @@ -34,7 +40,7 @@ export default { }); window.setTimeout(() => { this.snackMessages.splice(0, 1); - }, this.duration); + }, duration); }, }, }; diff --git a/app/javascript/dashboard/constants/appEvents.js b/app/javascript/dashboard/constants/appEvents.js new file mode 100644 index 000000000..71f044232 --- /dev/null +++ b/app/javascript/dashboard/constants/appEvents.js @@ -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'; diff --git a/app/javascript/dashboard/helper/AudioAlerts/AudioMessageHelper.js b/app/javascript/dashboard/helper/AudioAlerts/AudioMessageHelper.js new file mode 100644 index 000000000..0590b1a58 --- /dev/null +++ b/app/javascript/dashboard/helper/AudioAlerts/AudioMessageHelper.js @@ -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; diff --git a/app/javascript/dashboard/helper/AudioAlerts/AudioNotificationStore.js b/app/javascript/dashboard/helper/AudioAlerts/AudioNotificationStore.js new file mode 100644 index 000000000..233516b99 --- /dev/null +++ b/app/javascript/dashboard/helper/AudioAlerts/AudioNotificationStore.js @@ -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; diff --git a/app/javascript/dashboard/helper/AudioAlerts/DashboardAudioNotificationHelper.js b/app/javascript/dashboard/helper/AudioAlerts/DashboardAudioNotificationHelper.js index 26417bcad..ca823fc31 100644 --- a/app/javascript/dashboard/helper/AudioAlerts/DashboardAudioNotificationHelper.js +++ b/app/javascript/dashboard/helper/AudioAlerts/DashboardAudioNotificationHelper.js @@ -1,92 +1,125 @@ import { MESSAGE_TYPE } from 'shared/constants/messages'; import { showBadgeOnFavicon } 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 { - getAlertAudio, - initOnEvents, -} from 'shared/helpers/AudioNotificationHelper'; -import { - ROLES, - CONVERSATION_PERMISSIONS, -} from 'dashboard/constants/permissions.js'; -import { getUserPermissions } from 'dashboard/helper/permissionsHelper.js'; + isConversationAssignedToMe, + isConversationUnassigned, + isMessageFromCurrentUser, +} from './AudioMessageHelper'; +import WindowVisibilityHelper from './WindowVisibilityHelper'; +import { useAlert } from 'dashboard/composables'; const NOTIFICATION_TIME = 30000; +const ALERT_DURATION = 10000; +const ALERT_PATH_PREFIX = '/audio/dashboard/'; +const DEFAULT_TONE = 'ding'; +const DEFAULT_ALERT_TYPE = ['none']; -class DashboardAudioNotificationHelper { - constructor() { - this.recurringNotificationTimer = null; - this.audioAlertType = 'none'; - this.playAlertOnlyWhenHidden = true; - this.alertIfUnreadConversationExist = false; - this.currentUser = null; - this.currentUserId = null; - this.audioAlertTone = 'ding'; +export class DashboardAudioNotificationHelper { + constructor(store) { + if (!store) { + throw new Error('store is required'); + } + this.store = new AudioNotificationStore(store); - this.onAudioListenEvent = async () => { - try { - await getAlertAudio('', { - type: 'dashboard', - alertTone: this.audioAlertTone, - }); - initOnEvents.forEach(event => { - document.removeEventListener(event, this.onAudioListenEvent, false); - }); - this.playAudioEvery30Seconds(); - } catch (error) { - // Ignore audio fetch errors - } + this.notificationConfig = { + audioAlertType: DEFAULT_ALERT_TYPE, + playAlertOnlyWhenHidden: true, + alertIfUnreadConversationExist: false, }; + + this.recurringNotificationTimer = null; + + this.audioConfig = { + audio: null, + tone: DEFAULT_TONE, + hasSentSoundPermissionsRequest: false, + }; + + this.currentUser = null; } - setInstanceValues = ({ + intializeAudio = () => { + const resourceUrl = `${ALERT_PATH_PREFIX}${this.audioConfig.tone}.mp3`; + this.audioConfig.audio = new Audio(resourceUrl); + return this.audioConfig.audio.load(); + }; + + playAudioAlert = async () => { + try { + await this.audioConfig.audio.play(); + } catch (error) { + if ( + error.name === 'NotAllowedError' && + !this.hasSentSoundPermissionsRequest + ) { + this.hasSentSoundPermissionsRequest = true; + useAlert( + 'PROFILE_SETTINGS.FORM.AUDIO_NOTIFICATIONS_SECTION.SOUND_PERMISSION_ERROR', + { usei18n: true, duration: ALERT_DURATION } + ); + } + } + }; + + set = ({ currentUser, alwaysPlayAudioAlert, alertIfUnreadConversationExist, - audioAlertType, - audioAlertTone, + audioAlertType = DEFAULT_ALERT_TYPE, + audioAlertTone = DEFAULT_TONE, }) => { - this.audioAlertType = audioAlertType; - this.playAlertOnlyWhenHidden = !alwaysPlayAudioAlert; - this.alertIfUnreadConversationExist = alertIfUnreadConversationExist; + this.notificationConfig = { + ...this.notificationConfig, + audioAlertType: audioAlertType.split('+').filter(Boolean), + playAlertOnlyWhenHidden: !alwaysPlayAudioAlert, + alertIfUnreadConversationExist: alertIfUnreadConversationExist, + }; + this.currentUser = currentUser; - this.currentUserId = currentUser.id; - this.audioAlertTone = audioAlertTone; - initOnEvents.forEach(e => { - document.addEventListener(e, this.onAudioListenEvent, { - once: true, - }); - }); + + const previousAudioTone = this.audioConfig.tone; + this.audioConfig = { + ...this.audioConfig, + tone: audioAlertTone, + }; + + if (previousAudioTone !== audioAlertTone) { + this.intializeAudio(); + } + initFaviconSwitcher(); + this.clearRecurringTimer(); + this.playAudioEvery30Seconds(); + }; + + shouldPlayAlert = () => { + if (this.notificationConfig.playAlertOnlyWhenHidden) { + return !WindowVisibilityHelper.isWindowVisible(); + } + return true; }; executeRecurringNotification = () => { - if (!window.WOOT_STORE) { - this.clearSetTimeout(); - 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(); + if (this.store.hasUnreadConversation() && this.shouldPlayAlert()) { + this.playAudioAlert(); showBadgeOnFavicon(); } - this.clearSetTimeout(); + this.resetRecurringTimer(); }; - clearSetTimeout = () => { + clearRecurringTimer = () => { if (this.recurringNotificationTimer) { clearTimeout(this.recurringNotificationTimer); } + }; + + resetRecurringTimer = () => { + this.clearRecurringTimer(); this.recurringNotificationTimer = setTimeout( this.executeRecurringNotification, NOTIFICATION_TIME @@ -94,67 +127,57 @@ class DashboardAudioNotificationHelper { }; playAudioEvery30Seconds = () => { + const { audioAlertType, alertIfUnreadConversationExist } = + this.notificationConfig; + // Audio alert is disabled dismiss the timer - if (this.audioAlertType === 'none') { - return; - } - // If assigned conversation flag is disabled dismiss the timer - if (!this.alertIfUnreadConversationExist) { - return; - } + if (audioAlertType.includes('none')) return; - this.clearSetTimeout(); - }; + // If unread conversation flag is disabled, dismiss the timer + if (!alertIfUnreadConversationExist) return; - isConversationAssignedToCurrentUser = message => { - 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; + this.resetRecurringTimer(); }; shouldNotifyOnMessage = message => { - if (this.audioAlertType === 'mine') { - return this.isConversationAssignedToCurrentUser(message); + const { audioAlertType } = this.notificationConfig; + 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 => { // 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; } - // If the message is sent by the current user or the - // correct notification is not enabled, then dismiss the alert - if ( - this.isMessageFromCurrentUser(message) || - !this.shouldNotifyOnMessage(message) - ) { + // If the message is sent by the current user then dismiss the alert + if (isMessageFromCurrentUser(message, this.currentUser.id)) { + return; + } + + if (!this.shouldNotifyOnMessage(message)) { return; } @@ -164,21 +187,22 @@ class DashboardAudioNotificationHelper { return; } - // If the user looking at the conversation, then dismiss the alert - if (this.isMessageFromCurrentConversation(message) && !document.hidden) { - return; - } - // If the user has disabled alerts when active on the dashboard, the dismiss the alert - if (this.playAlertOnlyWhenHidden && !document.hidden) { - return; + if (WindowVisibilityHelper.isWindowVisible()) { + // If the user looking at the conversation, then dismiss the alert + if (this.store.isMessageFromCurrentConversation(message)) { + return; + } + + // If the user has disabled alerts when active on the dashboard, the dismiss the alert + if (this.notificationConfig.playAlertOnlyWhenHidden) { + return; + } } - window.playAudioAlert(); + this.playAudioAlert(); showBadgeOnFavicon(); this.playAudioEvery30Seconds(); }; } -const notifHelper = new DashboardAudioNotificationHelper(); -window.notifHelper = notifHelper; -export default notifHelper; +export default new DashboardAudioNotificationHelper(GlobalStore); diff --git a/app/javascript/dashboard/helper/AudioAlerts/WindowVisibilityHelper.js b/app/javascript/dashboard/helper/AudioAlerts/WindowVisibilityHelper.js new file mode 100644 index 000000000..23772c9bd --- /dev/null +++ b/app/javascript/dashboard/helper/AudioAlerts/WindowVisibilityHelper.js @@ -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(); diff --git a/app/javascript/dashboard/helper/AudioAlerts/specs/AudioMessageHelper.spec.js b/app/javascript/dashboard/helper/AudioAlerts/specs/AudioMessageHelper.spec.js new file mode 100644 index 000000000..9751cf729 --- /dev/null +++ b/app/javascript/dashboard/helper/AudioAlerts/specs/AudioMessageHelper.spec.js @@ -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); + }); +}); diff --git a/app/javascript/dashboard/helper/AudioAlerts/specs/AudioNotificationStore.spec.js b/app/javascript/dashboard/helper/AudioAlerts/specs/AudioNotificationStore.spec.js new file mode 100644 index 000000000..5e8971c2d --- /dev/null +++ b/app/javascript/dashboard/helper/AudioAlerts/specs/AudioNotificationStore.spec.js @@ -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 + ); + }); + }); +}); diff --git a/app/javascript/dashboard/helper/AudioAlerts/specs/WindowVisibilityHelper.spec.js b/app/javascript/dashboard/helper/AudioAlerts/specs/WindowVisibilityHelper.spec.js new file mode 100644 index 000000000..6440cc0e0 --- /dev/null +++ b/app/javascript/dashboard/helper/AudioAlerts/specs/WindowVisibilityHelper.spec.js @@ -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); + }); + }); +}); diff --git a/app/javascript/dashboard/helper/scriptHelpers.js b/app/javascript/dashboard/helper/scriptHelpers.js index dd007d0db..55ad6eb13 100644 --- a/app/javascript/dashboard/helper/scriptHelpers.js +++ b/app/javascript/dashboard/helper/scriptHelpers.js @@ -1,20 +1,19 @@ +import { + ANALYTICS_IDENTITY, + CHATWOOT_RESET, + CHATWOOT_SET_USER, +} from '../constants/appEvents'; import AnalyticsHelper from './AnalyticsHelper'; import DashboardAudioNotificationHelper from './AudioAlerts/DashboardAudioNotificationHelper'; 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 = () => { emitter.on(ANALYTICS_IDENTITY, ({ user }) => { AnalyticsHelper.identify(user); }); }; -const initializeAudioAlerts = user => { +export const initializeAudioAlerts = user => { const { ui_settings: uiSettings } = user || {}; const { always_play_audio_alert: alwaysPlayAudioAlert, @@ -25,7 +24,7 @@ const initializeAudioAlerts = user => { // entire payload for the user during the signup process. } = uiSettings || {}; - DashboardAudioNotificationHelper.setInstanceValues({ + DashboardAudioNotificationHelper.set({ currentUser: user, audioAlertType: audioAlertType || 'none', audioAlertTone: audioAlertTone || 'ding', diff --git a/app/javascript/dashboard/i18n/locale/en/settings.json b/app/javascript/dashboard/i18n/locale/en/settings.json index 9b2c5fde6..9b1ca70d2 100644 --- a/app/javascript/dashboard/i18n/locale/en/settings.json +++ b/app/javascript/dashboard/i18n/locale/en/settings.json @@ -61,15 +61,29 @@ "COPY": "Copy" }, "AUDIO_NOTIFICATIONS_SECTION": { - "TITLE": "Audio Notifications", - "NOTE": "Enable audio notifications in dashboard for new messages and conversations.", + "TITLE": "Audio Alerts", + "NOTE": "Enable audio alerts in dashboard for new messages and conversations.", + "PLAY": "Play sound", "ALERT_TYPES": { "NONE": "None", "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": { - "TITLE": "Alert events for conversations:", + "TITLE": "Alert events for conversations", "NONE": "None", "ASSIGNED": "Assigned Conversations", "ALL_CONVERSATIONS": "All Conversations" @@ -81,7 +95,9 @@ "TITLE": "Alert conditions:", "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" - } + }, + "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": { "TITLE": "Email Notifications", diff --git a/app/javascript/dashboard/routes/dashboard/settings/profile/AudioAlertCondition.vue b/app/javascript/dashboard/routes/dashboard/settings/profile/AudioAlertCondition.vue index 18ae1c07e..898d9afb6 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/profile/AudioAlertCondition.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/profile/AudioAlertCondition.vue @@ -10,6 +10,7 @@ defineProps({ required: true, }, }); + const emit = defineEmits(['change']); const onChange = (id, value) => { emit('change', id, value); @@ -23,18 +24,22 @@ const onChange = (id, value) => { > {{ label }} -
+
-
diff --git a/app/javascript/dashboard/routes/dashboard/settings/profile/AudioAlertEvent.vue b/app/javascript/dashboard/routes/dashboard/settings/profile/AudioAlertEvent.vue index ddc6545f6..890956f7e 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/profile/AudioAlertEvent.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/profile/AudioAlertEvent.vue @@ -1,6 +1,7 @@ diff --git a/app/javascript/dashboard/routes/dashboard/settings/profile/AudioAlertTone.vue b/app/javascript/dashboard/routes/dashboard/settings/profile/AudioAlertTone.vue index 232a7a74a..06d6932d4 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/profile/AudioAlertTone.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/profile/AudioAlertTone.vue @@ -1,12 +1,15 @@ diff --git a/app/javascript/dashboard/routes/dashboard/settings/profile/AudioNotifications.vue b/app/javascript/dashboard/routes/dashboard/settings/profile/AudioNotifications.vue index f09432f83..bd96f3721 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/profile/AudioNotifications.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/profile/AudioNotifications.vue @@ -1,98 +1,91 @@ - @@ -100,27 +93,19 @@ export default {
diff --git a/app/javascript/dashboard/routes/dashboard/settings/profile/constants.js b/app/javascript/dashboard/routes/dashboard/settings/profile/constants.js index e25d5b52a..a6c621c2b 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/profile/constants.js +++ b/app/javascript/dashboard/routes/dashboard/settings/profile/constants.js @@ -36,17 +36,23 @@ export const NOTIFICATION_TYPES = [ }, ]; +export const EVENT_TYPES = { + ASSIGNED: 'assigned', + NOTME: 'notme', + UNASSIGNED: 'unassigned', +}; + export const ALERT_EVENTS = [ { - value: 'none', - label: 'none', + value: EVENT_TYPES.ASSIGNED, + label: 'assigned', }, { - value: 'mine', - label: 'mine', + value: EVENT_TYPES.UNASSIGNED, + label: 'unassigned', }, { - value: 'all', - label: 'all', + value: EVENT_TYPES.NOTME, + label: 'notme', }, ]; diff --git a/app/javascript/dashboard/store/utils/api.js b/app/javascript/dashboard/store/utils/api.js index e8628300b..281b911b5 100644 --- a/app/javascript/dashboard/store/utils/api.js +++ b/app/javascript/dashboard/store/utils/api.js @@ -1,15 +1,15 @@ import fromUnixTime from 'date-fns/fromUnixTime'; import differenceInDays from 'date-fns/differenceInDays'; 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 { ANALYTICS_IDENTITY, ANALYTICS_RESET, CHATWOOT_RESET, CHATWOOT_SET_USER, -} from '../../helper/scriptHelpers'; -import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage'; -import { LocalStorage } from 'shared/helpers/localStorage'; -import { emitter } from 'shared/helpers/mitt'; +} from '../../constants/appEvents'; Cookies.defaults = { sameSite: 'Lax' }; diff --git a/app/javascript/entrypoints/dashboard.js b/app/javascript/entrypoints/dashboard.js index 857a62d12..a2184d074 100644 --- a/app/javascript/entrypoints/dashboard.js +++ b/app/javascript/entrypoints/dashboard.js @@ -6,7 +6,6 @@ import axios from 'axios'; import hljsVuePlugin from '@highlightjs/vue-plugin'; import Multiselect from 'vue-multiselect'; -// import VueFormulate from '@braid/vue-formulate'; import { plugin, defaultConfig } from '@formkit/vue'; import WootSwitch from 'components/ui/Switch.vue'; import WootWizard from 'components/ui/Wizard.vue'; @@ -22,7 +21,6 @@ import router, { initalizeRouter } from 'dashboard/routes'; import store from 'dashboard/store'; import constants from 'dashboard/constants/globals'; import * as Sentry from '@sentry/vue'; -// import { Integrations } from '@sentry/tracing'; import { initializeAnalyticsEvents, initializeChatwootEvents, @@ -101,7 +99,6 @@ app.directive('on-clickaway', onClickaway); // load common helpers into js commonHelpers(); -window.WOOT_STORE = store; window.WootConstants = constants; window.axios = createAxios(axios); // [VITE] Disabled this we don't need it, we can use `useEmitter` directly @@ -114,7 +111,3 @@ initalizeRouter(); window.onload = () => { app.mount('#app'); }; - -window.addEventListener('load', () => { - window.playAudioAlert = () => {}; -}); diff --git a/public/audio/dashboard/chime.mp3 b/public/audio/dashboard/chime.mp3 new file mode 100644 index 000000000..e48530e1f Binary files /dev/null and b/public/audio/dashboard/chime.mp3 differ diff --git a/public/audio/dashboard/magic.mp3 b/public/audio/dashboard/magic.mp3 new file mode 100644 index 000000000..ee8d03dc1 Binary files /dev/null and b/public/audio/dashboard/magic.mp3 differ diff --git a/public/audio/dashboard/ping.mp3 b/public/audio/dashboard/ping.mp3 new file mode 100644 index 000000000..d5304e2f9 Binary files /dev/null and b/public/audio/dashboard/ping.mp3 differ