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';