diff --git a/client/src/App/App.jsx b/client/src/App/App.jsx index fa24ef07e..ab68d6e42 100644 --- a/client/src/App/App.jsx +++ b/client/src/App/App.jsx @@ -24,6 +24,7 @@ import InstanceRenderMgr from "../utils/instanceRenderMgr"; import ProductFruitsWrapper from "./ProductFruitsWrapper.jsx"; import { NotificationProvider } from "../contexts/Notifications/notificationContext.jsx"; import SocketProvider from "../contexts/SocketIO/socketProvider.jsx"; +import SoundWrapper from "./SoundWrapper.jsx"; const ResetPassword = lazy(() => import("../pages/reset-password/reset-password.component")); const ManagePage = lazy(() => import("../pages/manage/manage.page.container")); @@ -164,85 +165,87 @@ export function App({ /> - - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - + + + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + + + } + > + } /> + + + + + + + } + > + } /> + + - - - } - > - } /> - - - - - - - } - > - } /> - - - - - } - > - } /> - - }> - } /> - - + + } + > + } /> + + }> + } /> + + + ); diff --git a/client/src/App/SoundWrapper.jsx b/client/src/App/SoundWrapper.jsx new file mode 100644 index 000000000..065745de5 --- /dev/null +++ b/client/src/App/SoundWrapper.jsx @@ -0,0 +1,38 @@ +// src/app/SoundWrapper.jsx +import { useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { useNotification } from "../contexts/Notifications/notificationContext.jsx"; +import newMessageWav from "./../audio/messageTone.wav"; +import { initNewMessageSound, unlockAudio } from "./../utils/soundManager"; + +export default function SoundWrapper({ children }) { + const { t } = useTranslation(); + const notification = useNotification(); + + useEffect(() => { + // Initialize base audio + initNewMessageSound(newMessageWav, 0.7); + + // Show a one-time prompt when a play was blocked by autoplay policy + const onNeedsUnlock = () => { + notification.info({ + description: t("audio.manager.description"), + duration: 3 + }); + }; + window.addEventListener("sound-needs-unlock", onNeedsUnlock); + + // Proactively unlock on first gesture (once per session) + const handler = () => unlockAudio(); + window.addEventListener("click", handler, { once: true, passive: true }); + window.addEventListener("touchstart", handler, { once: true, passive: true }); + window.addEventListener("keydown", handler, { once: true }); + + return () => { + window.removeEventListener("sound-needs-unlock", onNeedsUnlock); + // The gesture listeners were added with { once: true }, so they clean themselves up + }; + }, [notification, t]); + + return <>{children}; +} diff --git a/client/src/audio/messageTone.wav b/client/src/audio/messageTone.wav new file mode 100644 index 000000000..4bed79ac1 Binary files /dev/null and b/client/src/audio/messageTone.wav differ diff --git a/client/src/components/chat-affix/chat-affix.container.jsx b/client/src/components/chat-affix/chat-affix.container.jsx index b370b221c..fe8ad12b3 100644 --- a/client/src/components/chat-affix/chat-affix.container.jsx +++ b/client/src/components/chat-affix/chat-affix.container.jsx @@ -9,13 +9,13 @@ import "./chat-affix.styles.scss"; import { registerMessagingHandlers, unregisterMessagingHandlers } from "./registerMessagingSocketHandlers"; import { useSocket } from "../../contexts/SocketIO/useSocket.js"; -export function ChatAffixContainer({ bodyshop, chatVisible }) { +export function ChatAffixContainer({ bodyshop, chatVisible, currentUser }) { const { t } = useTranslation(); const client = useApolloClient(); const { socket } = useSocket(); useEffect(() => { - if (!bodyshop || !bodyshop.messagingservicesid) return; + if (!bodyshop?.messagingservicesid) return; async function SubscribeToTopicForFCMNotification() { try { @@ -35,8 +35,8 @@ export function ChatAffixContainer({ bodyshop, chatVisible }) { SubscribeToTopicForFCMNotification(); // Register WebSocket handlers - if (socket && socket.connected) { - registerMessagingHandlers({ socket, client }); + if (socket?.connected) { + registerMessagingHandlers({ socket, client, currentUser }); return () => { unregisterMessagingHandlers({ socket }); @@ -44,11 +44,11 @@ export function ChatAffixContainer({ bodyshop, chatVisible }) { } }, [bodyshop, socket, t, client]); - if (!bodyshop || !bodyshop.messagingservicesid) return <>; + if (!bodyshop?.messagingservicesid) return <>; return (
- {bodyshop && bodyshop.messagingservicesid ? : null} + {bodyshop?.messagingservicesid ? : null}
); } diff --git a/client/src/components/chat-affix/registerMessagingSocketHandlers.js b/client/src/components/chat-affix/registerMessagingSocketHandlers.js index 14d7d4a0e..766cc1f01 100644 --- a/client/src/components/chat-affix/registerMessagingSocketHandlers.js +++ b/client/src/components/chat-affix/registerMessagingSocketHandlers.js @@ -1,5 +1,7 @@ import { CONVERSATION_LIST_QUERY, GET_CONVERSATION_DETAILS } from "../../graphql/conversations.queries"; import { gql } from "@apollo/client"; +import { QUERY_ACTIVE_ASSOCIATION_SOUND } from "../../graphql/user.queries"; // the query you added earlier +import { playNewMessageSound } from "../../utils/soundManager.js"; const logLocal = (message, ...args) => { if (import.meta.env.VITE_APP_IS_TEST || !import.meta.env.PROD) { @@ -26,16 +28,48 @@ const enrichConversation = (conversation, isOutbound) => ({ __typename: "conversations" }); -export const registerMessagingHandlers = ({ socket, client }) => { +// Can be uncommonted to test the playback of the notification sound +// window.testTone = () => { +// const notificationSound = new Audio(newMessageSound); +// notificationSound.play().catch((error) => { +// console.error("Error playing notification sound:", error); +// }); +// }; + +export const registerMessagingHandlers = ({ socket, client, currentUser }) => { if (!(socket && client)) return; const handleNewMessageSummary = async (message) => { const { conversationId, newConversation, existingConversation, isoutbound } = message; + // True only when DB value is strictly true; falls back to true on cache miss + const isNewMessageSoundEnabled = (client) => { + try { + const email = currentUser?.email; // adjust if you keep email elsewhere + if (!email) return true; // default allow if we can't resolve user + const res = client.readQuery({ + query: QUERY_ACTIVE_ASSOCIATION_SOUND, + variables: { email } + }); + const flag = res?.associations?.[0]?.new_message_sound; + return flag === true; // strictly true => enabled + } catch { + // If the query hasn't been seeded in cache yet, default ON + return true; + } + }; + logLocal("handleNewMessageSummary - Start", { message, isNew: !existingConversation }); const queryVariables = { offset: 0 }; + if (!isoutbound) { + // Play notification sound for new inbound message + if (isNewMessageSoundEnabled(client)) { + playNewMessageSound(); + } + } + if (!existingConversation && conversationId) { // Attempt to read from the cache to determine if this is actually a new conversation try { @@ -328,7 +362,8 @@ export const registerMessagingHandlers = ({ socket, client }) => { } break; - case "tag-added": { // Ensure `job_conversations` is properly formatted + case "tag-added": { + // Ensure `job_conversations` is properly formatted const formattedJobConversations = job_conversations.map((jc) => ({ __typename: "job_conversations", jobid: jc.jobid || jc.job?.id, diff --git a/client/src/components/profile-my/profile-my.component.jsx b/client/src/components/profile-my/profile-my.component.jsx index 0b3180d0a..dbe50360a 100644 --- a/client/src/components/profile-my/profile-my.component.jsx +++ b/client/src/components/profile-my/profile-my.component.jsx @@ -1,5 +1,5 @@ -import { Button, Card, Col, Form, Input } from "antd"; -import { LockOutlined } from "@ant-design/icons"; +import { Button, Card, Col, Form, Input, Space, Switch, Tooltip, Typography } from "antd"; +import { AudioMutedOutlined, LockOutlined, SoundOutlined } from "@ant-design/icons"; import { useTranslation } from "react-i18next"; import { connect } from "react-redux"; import { createStructuredSelector } from "reselect"; @@ -10,6 +10,8 @@ import LayoutFormRow from "../layout-form-row/layout-form-row.component"; import { useNotification } from "../../contexts/Notifications/notificationContext.jsx"; import { useSocket } from "../../contexts/SocketIO/useSocket.js"; import NotificationSettingsForm from "../notification-settings/notification-settings-form.component.jsx"; +import { useMutation, useQuery } from "@apollo/client"; +import { QUERY_ACTIVE_ASSOCIATION_SOUND, UPDATE_NEW_MESSAGE_SOUND } from "../../graphql/user.queries.js"; const mapStateToProps = createStructuredSelector({ currentUser: selectCurrentUser @@ -48,6 +50,28 @@ export default connect( } }; + // ---- Notification sound (associations.new_message_sound) ---- + const email = currentUser?.email; + const { data: assocData, loading: assocLoading } = useQuery(QUERY_ACTIVE_ASSOCIATION_SOUND, { + variables: { email }, + skip: !email, + fetchPolicy: "network-only", + nextFetchPolicy: "cache-first" + }); + const association = assocData?.associations?.[0]; + // Treat null/undefined as ON for backward-compat + const soundEnabled = association?.new_message_sound === true; + + const [updateNewMessageSound, { loading: updatingSound }] = useMutation(UPDATE_NEW_MESSAGE_SOUND, { + update(cache, { data }) { + const updated = data?.update_associations_by_pk; + if (!updated) return; + cache.modify({ + id: cache.identify({ __typename: "associations", id: updated.id }), + fields: { new_message_sound: () => updated.new_message_sound } + }); + } + }); return ( <> @@ -80,6 +104,7 @@ export default connect( +
+ + {association && ( + + + + {t("user.labels.play_sound_for_new_messages")} + + } + unCheckedChildren={} + checked={!!soundEnabled} + loading={assocLoading || updatingSound} + onChange={(checked) => { + updateNewMessageSound({ + variables: { id: association.id, value: checked }, + optimisticResponse: { + update_associations_by_pk: { + __typename: "associations", + id: association.id, + new_message_sound: checked + } + } + }) + .then(() => { + notification.success({ + message: checked + ? t("user.labels.notification_sound_enabled") + : t("user.labels.notification_sound_disabled") + }); + }) + .catch((e) => { + notification.error({ message: e.message || "Failed to update setting" }); + }); + }} + /> + + + + {t("user.labels.notification_sound_help")} + + + + )} + {scenarioNotificationsOn && ( diff --git a/client/src/graphql/user.queries.js b/client/src/graphql/user.queries.js index c308453ee..682c0f541 100644 --- a/client/src/graphql/user.queries.js +++ b/client/src/graphql/user.queries.js @@ -5,6 +5,7 @@ export const QUERY_SHOP_ASSOCIATIONS = gql` associations(where: { shopid: { _eq: $shopid } }) { id authlevel + new_message_sound shopid user { email @@ -28,6 +29,26 @@ export const UPDATE_ASSOCIATION = gql` } `; +// Query to load the active association for a given user and get the new_message_sound flag +export const QUERY_ACTIVE_ASSOCIATION_SOUND = gql` + query QUERY_ACTIVE_ASSOCIATION_SOUND($email: String!) { + associations(where: { _and: { useremail: { _eq: $email }, active: { _eq: true } } }) { + id + new_message_sound + } + } +`; + +// Mutation to update just the new_message_sound field +export const UPDATE_NEW_MESSAGE_SOUND = gql` + mutation UPDATE_NEW_MESSAGE_SOUND($id: uuid!, $value: Boolean) { + update_associations_by_pk(pk_columns: { id: $id }, _set: { new_message_sound: $value }) { + id + new_message_sound + } + } +`; + export const INSERT_EULA_ACCEPTANCE = gql` mutation INSERT_EULA_ACCEPTANCE($eulaAcceptance: eula_acceptances_insert_input!) { insert_eula_acceptances_one(object: $eulaAcceptance) { @@ -77,6 +98,7 @@ export const QUERY_KANBAN_SETTINGS = gql` } } `; + export const UPDATE_KANBAN_SETTINGS = gql` mutation UPDATE_KANBAN_SETTINGS($id: uuid!, $ks: jsonb) { update_associations_by_pk(pk_columns: { id: $id }, _set: { kanban_settings: $ks }) { diff --git a/client/src/pages/manage/manage.page.component.jsx b/client/src/pages/manage/manage.page.component.jsx index 9685f469a..406371b32 100644 --- a/client/src/pages/manage/manage.page.component.jsx +++ b/client/src/pages/manage/manage.page.component.jsx @@ -20,7 +20,12 @@ import LoadingSpinner from "../../components/loading-spinner/loading-spinner.com import PartnerPingComponent from "../../components/partner-ping/partner-ping.component"; import ShopSubStatusComponent from "../../components/shop-sub-status/shop-sub-status.component"; import UpdateAlert from "../../components/update-alert/update-alert.component"; -import { selectBodyshop, selectInstanceConflict, selectPartsManagementOnly } from "../../redux/user/user.selectors"; +import { + selectBodyshop, + selectCurrentUser, + selectInstanceConflict, + selectPartsManagementOnly +} from "../../redux/user/user.selectors"; import InstanceRenderManager from "../../utils/instanceRenderMgr.js"; import useAlertsNotifications from "../../hooks/useAlertsNotifications.jsx"; import { selectDarkMode } from "../../redux/application/application.selectors.js"; @@ -109,10 +114,11 @@ const mapStateToProps = createStructuredSelector({ conflict: selectInstanceConflict, bodyshop: selectBodyshop, partsManagementOnly: selectPartsManagementOnly, - isDarkMode: selectDarkMode + isDarkMode: selectDarkMode, + currentUser: selectCurrentUser }); -export function Manage({ conflict, bodyshop, partsManagementOnly, isDarkMode }) { +export function Manage({ conflict, bodyshop, partsManagementOnly, isDarkMode, currentUser }) { const { t } = useTranslation(); const [chatVisible] = useState(false); const didMount = useRef(false); @@ -588,7 +594,7 @@ export function Manage({ conflict, bodyshop, partsManagementOnly, isDarkMode }) return ( <> - + diff --git a/client/src/translations/en_us/common.json b/client/src/translations/en_us/common.json index 39e11fe12..ec27c3a11 100644 --- a/client/src/translations/en_us/common.json +++ b/client/src/translations/en_us/common.json @@ -144,6 +144,11 @@ "tasks_updated": "Task '{{title}}' updated by {{updatedBy}}" } }, + "audio": { + "manager": { + "description": "Click anywhere to enable the message ding." + } + }, "billlines": { "actions": { "newline": "New Line" @@ -3806,7 +3811,14 @@ "labels": { "actions": "Actions", "changepassword": "Change Password", - "profileinfo": "Profile Info" + "profileinfo": "Profile Info", + "notification_sound": "Notification sound", + "play_sound_for_new_messages": "Play a sound for new messages", + "notification_sound_on": "Sound is ON", + "notification_sound_off": "Sound is OFF", + "notification_sound_enabled": "Notification sound enabled", + "notification_sound_disabled": "Notification sound disabled", + "notification_sound_help": "Toggle the ding for incoming chat messages." }, "successess": { "passwordchanged": "Password changed successfully. " diff --git a/client/src/translations/es/common.json b/client/src/translations/es/common.json index 84a6fcbf9..1ab65958b 100644 --- a/client/src/translations/es/common.json +++ b/client/src/translations/es/common.json @@ -144,6 +144,11 @@ "tasks_updated": "" } }, + "audio": { + "manager": { + "description": "" + } + }, "billlines": { "actions": { "newline": "" @@ -3807,7 +3812,14 @@ "labels": { "actions": "", "changepassword": "", - "profileinfo": "" + "profileinfo": "", + "notification_sound": "", + "play_sound_for_new_messages": "", + "notification_sound_on": "", + "notification_sound_off": "", + "notification_sound_enabled": "", + "notification_sound_disabled": "", + "notification_sound_help": "" }, "successess": { "passwordchanged": "" diff --git a/client/src/translations/fr/common.json b/client/src/translations/fr/common.json index 703afe3e2..bc29a79a8 100644 --- a/client/src/translations/fr/common.json +++ b/client/src/translations/fr/common.json @@ -144,6 +144,11 @@ "tasks_updated": "" } }, + "audio": { + "manager": { + "description": "" + } + }, "billlines": { "actions": { "newline": "" @@ -3807,7 +3812,14 @@ "labels": { "actions": "", "changepassword": "", - "profileinfo": "" + "profileinfo": "", + "notification_sound": "", + "play_sound_for_new_messages": "", + "notification_sound_on": "", + "notification_sound_off": "", + "notification_sound_enabled": "", + "notification_sound_disabled": "", + "notification_sound_help": "" }, "successess": { "passwordchanged": "" diff --git a/client/src/utils/soundManager.js b/client/src/utils/soundManager.js new file mode 100644 index 000000000..119a208c0 --- /dev/null +++ b/client/src/utils/soundManager.js @@ -0,0 +1,88 @@ +let baseAudio = null; +let unlocked = false; +let queuedPlays = 0; +let installingUnlockHandlers = false; + +/** + * Initialize the new-message sound. + * @param url + * @param volume + */ +export function initNewMessageSound(url, volume = 0.7) { + baseAudio = new Audio(url); + baseAudio.preload = "auto"; + baseAudio.volume = volume; +} + +/** + * Unlocks audio if not already unlocked. + * @returns {Promise} + */ +export async function unlockAudio() { + if (unlocked) return; + try { + // Chrome/Safari: playing any media (even muted) after a gesture unlocks audio. + const a = new Audio(); + a.muted = true; + await a.play().catch(() => { + // + }); + unlocked = true; + + // Flush exactly one queued ding (avoid spamming if many queued while locked) + if (queuedPlays > 0 && baseAudio) { + queuedPlays = 0; + const b = baseAudio.cloneNode(true); + b.play().catch(() => { + // + }); + } + } finally { + removeUnlockListeners(); + } +} + +/** + * Installs listeners to unlock audio on first gesture. + */ +function addUnlockListeners() { + if (installingUnlockHandlers) return; + installingUnlockHandlers = true; + const handler = () => unlockAudio(); + window.addEventListener("click", handler, { once: true, passive: true }); + window.addEventListener("touchstart", handler, { once: true, passive: true }); + window.addEventListener("keydown", handler, { once: true }); +} + +/** + * Removes listeners to unlock audio on first gesture. + */ +function removeUnlockListeners() { + // No need to remove explicitly with {once:true}, but keep this if you change it later + installingUnlockHandlers = false; +} + +/** + * Plays the new-message ding. If blocked, queue one and wait for first gesture. + * @returns {Promise} + */ +export async function playNewMessageSound() { + if (!baseAudio) return; + try { + const a = baseAudio.cloneNode(true); + await a.play(); + } catch (err) { + // Most common: NotAllowedError due to missing prior gesture + if (err?.name === "NotAllowedError") { + queuedPlays = Math.min(queuedPlays + 1, 1); // cap at 1 + addUnlockListeners(); + + // Let the app know we need user interaction (optional UI prompt) + window.dispatchEvent(new CustomEvent("sound-needs-unlock")); + return; + } + // Other errors can be logged + + console.error("Audio play error:", err); + } +} diff --git a/hasura/metadata/tables.yaml b/hasura/metadata/tables.yaml index 65ed49949..387e129ff 100644 --- a/hasura/metadata/tables.yaml +++ b/hasura/metadata/tables.yaml @@ -215,6 +215,7 @@ - default_prod_list_view - id - kanban_settings + - new_message_sound - notification_settings - notifications_autoadd - qbo_realmId @@ -232,6 +233,7 @@ - authlevel - default_prod_list_view - kanban_settings + - new_message_sound - notification_settings - notifications_autoadd - qbo_realmId diff --git a/hasura/migrations/1758722733666_alter_table_public_associations_add_column_new_message_sound/down.sql b/hasura/migrations/1758722733666_alter_table_public_associations_add_column_new_message_sound/down.sql new file mode 100644 index 000000000..6091a1d6a --- /dev/null +++ b/hasura/migrations/1758722733666_alter_table_public_associations_add_column_new_message_sound/down.sql @@ -0,0 +1,4 @@ +-- Could not auto-generate a down migration. +-- Please write an appropriate down migration for the SQL below: +-- alter table "public"."associations" add column "new_message_sound" boolean +-- null; diff --git a/hasura/migrations/1758722733666_alter_table_public_associations_add_column_new_message_sound/up.sql b/hasura/migrations/1758722733666_alter_table_public_associations_add_column_new_message_sound/up.sql new file mode 100644 index 000000000..b5da28510 --- /dev/null +++ b/hasura/migrations/1758722733666_alter_table_public_associations_add_column_new_message_sound/up.sql @@ -0,0 +1,2 @@ +alter table "public"."associations" add column "new_message_sound" boolean + null; diff --git a/hasura/migrations/1758723347874_alter_table_public_associations_alter_column_new_message_sound/down.sql b/hasura/migrations/1758723347874_alter_table_public_associations_alter_column_new_message_sound/down.sql new file mode 100644 index 000000000..394c43f97 --- /dev/null +++ b/hasura/migrations/1758723347874_alter_table_public_associations_alter_column_new_message_sound/down.sql @@ -0,0 +1 @@ +ALTER TABLE "public"."associations" ALTER COLUMN "new_message_sound" drop default; diff --git a/hasura/migrations/1758723347874_alter_table_public_associations_alter_column_new_message_sound/up.sql b/hasura/migrations/1758723347874_alter_table_public_associations_alter_column_new_message_sound/up.sql new file mode 100644 index 000000000..3168f3b74 --- /dev/null +++ b/hasura/migrations/1758723347874_alter_table_public_associations_alter_column_new_message_sound/up.sql @@ -0,0 +1 @@ +alter table "public"."associations" alter column "new_message_sound" set default 'true';