feautre/IO-3377-Add-Notification-Tone-For-Messaging - Complete
This commit is contained in:
@@ -24,6 +24,7 @@ import InstanceRenderMgr from "../utils/instanceRenderMgr";
|
|||||||
import ProductFruitsWrapper from "./ProductFruitsWrapper.jsx";
|
import ProductFruitsWrapper from "./ProductFruitsWrapper.jsx";
|
||||||
import { NotificationProvider } from "../contexts/Notifications/notificationContext.jsx";
|
import { NotificationProvider } from "../contexts/Notifications/notificationContext.jsx";
|
||||||
import SocketProvider from "../contexts/SocketIO/socketProvider.jsx";
|
import SocketProvider from "../contexts/SocketIO/socketProvider.jsx";
|
||||||
|
import SoundWrapper from "./SoundWrapper.jsx";
|
||||||
|
|
||||||
const ResetPassword = lazy(() => import("../pages/reset-password/reset-password.component"));
|
const ResetPassword = lazy(() => import("../pages/reset-password/reset-password.component"));
|
||||||
const ManagePage = lazy(() => import("../pages/manage/manage.page.container"));
|
const ManagePage = lazy(() => import("../pages/manage/manage.page.container"));
|
||||||
@@ -164,85 +165,87 @@ export function App({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<NotificationProvider>
|
<NotificationProvider>
|
||||||
<Routes>
|
<SoundWrapper>
|
||||||
<Route
|
<Routes>
|
||||||
path="*"
|
<Route
|
||||||
element={
|
path="*"
|
||||||
<ErrorBoundary>
|
element={
|
||||||
<LandingPage />
|
<ErrorBoundary>
|
||||||
</ErrorBoundary>
|
<LandingPage />
|
||||||
}
|
</ErrorBoundary>
|
||||||
/>
|
}
|
||||||
<Route
|
/>
|
||||||
path="/signin"
|
<Route
|
||||||
element={
|
path="/signin"
|
||||||
<ErrorBoundary>
|
element={
|
||||||
<SignInPage />
|
<ErrorBoundary>
|
||||||
</ErrorBoundary>
|
<SignInPage />
|
||||||
}
|
</ErrorBoundary>
|
||||||
/>
|
}
|
||||||
<Route
|
/>
|
||||||
path="/resetpassword"
|
<Route
|
||||||
element={
|
path="/resetpassword"
|
||||||
<ErrorBoundary>
|
element={
|
||||||
<ResetPassword />
|
<ErrorBoundary>
|
||||||
</ErrorBoundary>
|
<ResetPassword />
|
||||||
}
|
</ErrorBoundary>
|
||||||
/>
|
}
|
||||||
<Route
|
/>
|
||||||
path="/csi/:surveyId"
|
<Route
|
||||||
element={
|
path="/csi/:surveyId"
|
||||||
<ErrorBoundary>
|
element={
|
||||||
<CsiPage />
|
<ErrorBoundary>
|
||||||
</ErrorBoundary>
|
<CsiPage />
|
||||||
}
|
</ErrorBoundary>
|
||||||
/>
|
}
|
||||||
<Route
|
/>
|
||||||
path="/disclaimer"
|
<Route
|
||||||
element={
|
path="/disclaimer"
|
||||||
<ErrorBoundary>
|
element={
|
||||||
<DisclaimerPage />
|
<ErrorBoundary>
|
||||||
</ErrorBoundary>
|
<DisclaimerPage />
|
||||||
}
|
</ErrorBoundary>
|
||||||
/>
|
}
|
||||||
<Route
|
/>
|
||||||
path="/manage/*"
|
<Route
|
||||||
element={
|
path="/manage/*"
|
||||||
<ErrorBoundary>
|
element={
|
||||||
<SocketProvider bodyshop={bodyshop} navigate={navigate} currentUser={currentUser}>
|
<ErrorBoundary>
|
||||||
|
<SocketProvider bodyshop={bodyshop} navigate={navigate} currentUser={currentUser}>
|
||||||
|
<PrivateRoute isAuthorized={currentUser.authorized} />
|
||||||
|
</SocketProvider>
|
||||||
|
</ErrorBoundary>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Route path="*" element={<ManagePage />} />
|
||||||
|
</Route>
|
||||||
|
<Route
|
||||||
|
path="/tech/*"
|
||||||
|
element={
|
||||||
|
<ErrorBoundary>
|
||||||
|
<SocketProvider bodyshop={bodyshop} navigate={navigate} currentUser={currentUser}>
|
||||||
|
<PrivateRoute isAuthorized={currentUser.authorized} />
|
||||||
|
</SocketProvider>
|
||||||
|
</ErrorBoundary>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Route path="*" element={<TechPageContainer />} />
|
||||||
|
</Route>
|
||||||
|
<Route
|
||||||
|
path="/parts/*"
|
||||||
|
element={
|
||||||
|
<ErrorBoundary>
|
||||||
<PrivateRoute isAuthorized={currentUser.authorized} />
|
<PrivateRoute isAuthorized={currentUser.authorized} />
|
||||||
</SocketProvider>
|
</ErrorBoundary>
|
||||||
</ErrorBoundary>
|
}
|
||||||
}
|
>
|
||||||
>
|
<Route path="*" element={<SimplifiedPartsPageContainer />} />
|
||||||
<Route path="*" element={<ManagePage />} />
|
</Route>
|
||||||
</Route>
|
<Route path="/edit/*" element={<PrivateRoute isAuthorized={currentUser.authorized} />}>
|
||||||
<Route
|
<Route path="*" element={<DocumentEditorContainer />} />
|
||||||
path="/tech/*"
|
</Route>
|
||||||
element={
|
</Routes>
|
||||||
<ErrorBoundary>
|
</SoundWrapper>
|
||||||
<SocketProvider bodyshop={bodyshop} navigate={navigate} currentUser={currentUser}>
|
|
||||||
<PrivateRoute isAuthorized={currentUser.authorized} />
|
|
||||||
</SocketProvider>
|
|
||||||
</ErrorBoundary>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Route path="*" element={<TechPageContainer />} />
|
|
||||||
</Route>
|
|
||||||
<Route
|
|
||||||
path="/parts/*"
|
|
||||||
element={
|
|
||||||
<ErrorBoundary>
|
|
||||||
<PrivateRoute isAuthorized={currentUser.authorized} />
|
|
||||||
</ErrorBoundary>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Route path="*" element={<SimplifiedPartsPageContainer />} />
|
|
||||||
</Route>
|
|
||||||
<Route path="/edit/*" element={<PrivateRoute isAuthorized={currentUser.authorized} />}>
|
|
||||||
<Route path="*" element={<DocumentEditorContainer />} />
|
|
||||||
</Route>
|
|
||||||
</Routes>
|
|
||||||
</NotificationProvider>
|
</NotificationProvider>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
|
|||||||
38
client/src/App/SoundWrapper.jsx
Normal file
38
client/src/App/SoundWrapper.jsx
Normal file
@@ -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}</>;
|
||||||
|
}
|
||||||
BIN
client/src/audio/messageTone.wav
Normal file
BIN
client/src/audio/messageTone.wav
Normal file
Binary file not shown.
@@ -9,13 +9,13 @@ import "./chat-affix.styles.scss";
|
|||||||
import { registerMessagingHandlers, unregisterMessagingHandlers } from "./registerMessagingSocketHandlers";
|
import { registerMessagingHandlers, unregisterMessagingHandlers } from "./registerMessagingSocketHandlers";
|
||||||
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
|
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
|
||||||
|
|
||||||
export function ChatAffixContainer({ bodyshop, chatVisible }) {
|
export function ChatAffixContainer({ bodyshop, chatVisible, currentUser }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const client = useApolloClient();
|
const client = useApolloClient();
|
||||||
const { socket } = useSocket();
|
const { socket } = useSocket();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!bodyshop || !bodyshop.messagingservicesid) return;
|
if (!bodyshop?.messagingservicesid) return;
|
||||||
|
|
||||||
async function SubscribeToTopicForFCMNotification() {
|
async function SubscribeToTopicForFCMNotification() {
|
||||||
try {
|
try {
|
||||||
@@ -35,8 +35,8 @@ export function ChatAffixContainer({ bodyshop, chatVisible }) {
|
|||||||
SubscribeToTopicForFCMNotification();
|
SubscribeToTopicForFCMNotification();
|
||||||
|
|
||||||
// Register WebSocket handlers
|
// Register WebSocket handlers
|
||||||
if (socket && socket.connected) {
|
if (socket?.connected) {
|
||||||
registerMessagingHandlers({ socket, client });
|
registerMessagingHandlers({ socket, client, currentUser });
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
unregisterMessagingHandlers({ socket });
|
unregisterMessagingHandlers({ socket });
|
||||||
@@ -44,11 +44,11 @@ export function ChatAffixContainer({ bodyshop, chatVisible }) {
|
|||||||
}
|
}
|
||||||
}, [bodyshop, socket, t, client]);
|
}, [bodyshop, socket, t, client]);
|
||||||
|
|
||||||
if (!bodyshop || !bodyshop.messagingservicesid) return <></>;
|
if (!bodyshop?.messagingservicesid) return <></>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`chat-affix ${chatVisible ? "chat-affix-open" : ""}`}>
|
<div className={`chat-affix ${chatVisible ? "chat-affix-open" : ""}`}>
|
||||||
{bodyshop && bodyshop.messagingservicesid ? <ChatPopupComponent /> : null}
|
{bodyshop?.messagingservicesid ? <ChatPopupComponent /> : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { CONVERSATION_LIST_QUERY, GET_CONVERSATION_DETAILS } from "../../graphql/conversations.queries";
|
import { CONVERSATION_LIST_QUERY, GET_CONVERSATION_DETAILS } from "../../graphql/conversations.queries";
|
||||||
import { gql } from "@apollo/client";
|
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) => {
|
const logLocal = (message, ...args) => {
|
||||||
if (import.meta.env.VITE_APP_IS_TEST || !import.meta.env.PROD) {
|
if (import.meta.env.VITE_APP_IS_TEST || !import.meta.env.PROD) {
|
||||||
@@ -26,16 +28,48 @@ const enrichConversation = (conversation, isOutbound) => ({
|
|||||||
__typename: "conversations"
|
__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;
|
if (!(socket && client)) return;
|
||||||
|
|
||||||
const handleNewMessageSummary = async (message) => {
|
const handleNewMessageSummary = async (message) => {
|
||||||
const { conversationId, newConversation, existingConversation, isoutbound } = 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 });
|
logLocal("handleNewMessageSummary - Start", { message, isNew: !existingConversation });
|
||||||
|
|
||||||
const queryVariables = { offset: 0 };
|
const queryVariables = { offset: 0 };
|
||||||
|
|
||||||
|
if (!isoutbound) {
|
||||||
|
// Play notification sound for new inbound message
|
||||||
|
if (isNewMessageSoundEnabled(client)) {
|
||||||
|
playNewMessageSound();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!existingConversation && conversationId) {
|
if (!existingConversation && conversationId) {
|
||||||
// Attempt to read from the cache to determine if this is actually a new conversation
|
// Attempt to read from the cache to determine if this is actually a new conversation
|
||||||
try {
|
try {
|
||||||
@@ -328,7 +362,8 @@ export const registerMessagingHandlers = ({ socket, client }) => {
|
|||||||
}
|
}
|
||||||
break;
|
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) => ({
|
const formattedJobConversations = job_conversations.map((jc) => ({
|
||||||
__typename: "job_conversations",
|
__typename: "job_conversations",
|
||||||
jobid: jc.jobid || jc.job?.id,
|
jobid: jc.jobid || jc.job?.id,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Button, Card, Col, Form, Input } from "antd";
|
import { Button, Card, Col, Form, Input, Space, Switch, Tooltip, Typography } from "antd";
|
||||||
import { LockOutlined } from "@ant-design/icons";
|
import { AudioMutedOutlined, LockOutlined, SoundOutlined } from "@ant-design/icons";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { connect } from "react-redux";
|
import { connect } from "react-redux";
|
||||||
import { createStructuredSelector } from "reselect";
|
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 { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
||||||
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
|
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
|
||||||
import NotificationSettingsForm from "../notification-settings/notification-settings-form.component.jsx";
|
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({
|
const mapStateToProps = createStructuredSelector({
|
||||||
currentUser: selectCurrentUser
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Col span={24}>
|
<Col span={24}>
|
||||||
@@ -80,6 +104,7 @@ export default connect(
|
|||||||
</Card>
|
</Card>
|
||||||
</Form>
|
</Form>
|
||||||
</Col>
|
</Col>
|
||||||
|
|
||||||
<Col span={24}>
|
<Col span={24}>
|
||||||
<Form onFinish={handleChangePassword} autoComplete={"no"} initialValues={currentUser} layout="vertical">
|
<Form onFinish={handleChangePassword} autoComplete={"no"} initialValues={currentUser} layout="vertical">
|
||||||
<Card
|
<Card
|
||||||
@@ -119,6 +144,52 @@ export default connect(
|
|||||||
</Card>
|
</Card>
|
||||||
</Form>
|
</Form>
|
||||||
</Col>
|
</Col>
|
||||||
|
|
||||||
|
{association && (
|
||||||
|
<Col span={24}>
|
||||||
|
<Card title={t("user.labels.notification_sound")}>
|
||||||
|
<Space align="center" size="large">
|
||||||
|
<Typography.Text>{t("user.labels.play_sound_for_new_messages")}</Typography.Text>
|
||||||
|
<Tooltip
|
||||||
|
title={soundEnabled ? t("user.labels.notification_sound_on") : t("user.labels.notification_sound_off")}
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
checkedChildren={<SoundOutlined />}
|
||||||
|
unCheckedChildren={<AudioMutedOutlined />}
|
||||||
|
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" });
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</Space>
|
||||||
|
<Typography.Paragraph type="secondary" style={{ marginTop: 8 }}>
|
||||||
|
{t("user.labels.notification_sound_help")}
|
||||||
|
</Typography.Paragraph>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
)}
|
||||||
|
|
||||||
{scenarioNotificationsOn && (
|
{scenarioNotificationsOn && (
|
||||||
<Col span={24}>
|
<Col span={24}>
|
||||||
<NotificationSettingsForm />
|
<NotificationSettingsForm />
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export const QUERY_SHOP_ASSOCIATIONS = gql`
|
|||||||
associations(where: { shopid: { _eq: $shopid } }) {
|
associations(where: { shopid: { _eq: $shopid } }) {
|
||||||
id
|
id
|
||||||
authlevel
|
authlevel
|
||||||
|
new_message_sound
|
||||||
shopid
|
shopid
|
||||||
user {
|
user {
|
||||||
email
|
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`
|
export const INSERT_EULA_ACCEPTANCE = gql`
|
||||||
mutation INSERT_EULA_ACCEPTANCE($eulaAcceptance: eula_acceptances_insert_input!) {
|
mutation INSERT_EULA_ACCEPTANCE($eulaAcceptance: eula_acceptances_insert_input!) {
|
||||||
insert_eula_acceptances_one(object: $eulaAcceptance) {
|
insert_eula_acceptances_one(object: $eulaAcceptance) {
|
||||||
@@ -77,6 +98,7 @@ export const QUERY_KANBAN_SETTINGS = gql`
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const UPDATE_KANBAN_SETTINGS = gql`
|
export const UPDATE_KANBAN_SETTINGS = gql`
|
||||||
mutation UPDATE_KANBAN_SETTINGS($id: uuid!, $ks: jsonb) {
|
mutation UPDATE_KANBAN_SETTINGS($id: uuid!, $ks: jsonb) {
|
||||||
update_associations_by_pk(pk_columns: { id: $id }, _set: { kanban_settings: $ks }) {
|
update_associations_by_pk(pk_columns: { id: $id }, _set: { kanban_settings: $ks }) {
|
||||||
|
|||||||
@@ -20,7 +20,12 @@ import LoadingSpinner from "../../components/loading-spinner/loading-spinner.com
|
|||||||
import PartnerPingComponent from "../../components/partner-ping/partner-ping.component";
|
import PartnerPingComponent from "../../components/partner-ping/partner-ping.component";
|
||||||
import ShopSubStatusComponent from "../../components/shop-sub-status/shop-sub-status.component";
|
import ShopSubStatusComponent from "../../components/shop-sub-status/shop-sub-status.component";
|
||||||
import UpdateAlert from "../../components/update-alert/update-alert.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 InstanceRenderManager from "../../utils/instanceRenderMgr.js";
|
||||||
import useAlertsNotifications from "../../hooks/useAlertsNotifications.jsx";
|
import useAlertsNotifications from "../../hooks/useAlertsNotifications.jsx";
|
||||||
import { selectDarkMode } from "../../redux/application/application.selectors.js";
|
import { selectDarkMode } from "../../redux/application/application.selectors.js";
|
||||||
@@ -109,10 +114,11 @@ const mapStateToProps = createStructuredSelector({
|
|||||||
conflict: selectInstanceConflict,
|
conflict: selectInstanceConflict,
|
||||||
bodyshop: selectBodyshop,
|
bodyshop: selectBodyshop,
|
||||||
partsManagementOnly: selectPartsManagementOnly,
|
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 { t } = useTranslation();
|
||||||
const [chatVisible] = useState(false);
|
const [chatVisible] = useState(false);
|
||||||
const didMount = useRef(false);
|
const didMount = useRef(false);
|
||||||
@@ -588,7 +594,7 @@ export function Manage({ conflict, bodyshop, partsManagementOnly, isDarkMode })
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ChatAffixContainer bodyshop={bodyshop} chatVisible={chatVisible} />
|
<ChatAffixContainer bodyshop={bodyshop} currentUser={currentUser} chatVisible={chatVisible} />
|
||||||
<Layout style={{ minHeight: "100vh" }} className="layout-container">
|
<Layout style={{ minHeight: "100vh" }} className="layout-container">
|
||||||
<UpdateAlert />
|
<UpdateAlert />
|
||||||
<HeaderContainer />
|
<HeaderContainer />
|
||||||
|
|||||||
@@ -144,6 +144,11 @@
|
|||||||
"tasks_updated": "Task '{{title}}' updated by {{updatedBy}}"
|
"tasks_updated": "Task '{{title}}' updated by {{updatedBy}}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"audio": {
|
||||||
|
"manager": {
|
||||||
|
"description": "Click anywhere to enable the message ding."
|
||||||
|
}
|
||||||
|
},
|
||||||
"billlines": {
|
"billlines": {
|
||||||
"actions": {
|
"actions": {
|
||||||
"newline": "New Line"
|
"newline": "New Line"
|
||||||
@@ -3806,7 +3811,14 @@
|
|||||||
"labels": {
|
"labels": {
|
||||||
"actions": "Actions",
|
"actions": "Actions",
|
||||||
"changepassword": "Change Password",
|
"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": {
|
"successess": {
|
||||||
"passwordchanged": "Password changed successfully. "
|
"passwordchanged": "Password changed successfully. "
|
||||||
|
|||||||
@@ -144,6 +144,11 @@
|
|||||||
"tasks_updated": ""
|
"tasks_updated": ""
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"audio": {
|
||||||
|
"manager": {
|
||||||
|
"description": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
"billlines": {
|
"billlines": {
|
||||||
"actions": {
|
"actions": {
|
||||||
"newline": ""
|
"newline": ""
|
||||||
@@ -3807,7 +3812,14 @@
|
|||||||
"labels": {
|
"labels": {
|
||||||
"actions": "",
|
"actions": "",
|
||||||
"changepassword": "",
|
"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": {
|
"successess": {
|
||||||
"passwordchanged": ""
|
"passwordchanged": ""
|
||||||
|
|||||||
@@ -144,6 +144,11 @@
|
|||||||
"tasks_updated": ""
|
"tasks_updated": ""
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"audio": {
|
||||||
|
"manager": {
|
||||||
|
"description": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
"billlines": {
|
"billlines": {
|
||||||
"actions": {
|
"actions": {
|
||||||
"newline": ""
|
"newline": ""
|
||||||
@@ -3807,7 +3812,14 @@
|
|||||||
"labels": {
|
"labels": {
|
||||||
"actions": "",
|
"actions": "",
|
||||||
"changepassword": "",
|
"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": {
|
"successess": {
|
||||||
"passwordchanged": ""
|
"passwordchanged": ""
|
||||||
|
|||||||
88
client/src/utils/soundManager.js
Normal file
88
client/src/utils/soundManager.js
Normal file
@@ -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<void>}
|
||||||
|
*/
|
||||||
|
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<void>}
|
||||||
|
*/
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -215,6 +215,7 @@
|
|||||||
- default_prod_list_view
|
- default_prod_list_view
|
||||||
- id
|
- id
|
||||||
- kanban_settings
|
- kanban_settings
|
||||||
|
- new_message_sound
|
||||||
- notification_settings
|
- notification_settings
|
||||||
- notifications_autoadd
|
- notifications_autoadd
|
||||||
- qbo_realmId
|
- qbo_realmId
|
||||||
@@ -232,6 +233,7 @@
|
|||||||
- authlevel
|
- authlevel
|
||||||
- default_prod_list_view
|
- default_prod_list_view
|
||||||
- kanban_settings
|
- kanban_settings
|
||||||
|
- new_message_sound
|
||||||
- notification_settings
|
- notification_settings
|
||||||
- notifications_autoadd
|
- notifications_autoadd
|
||||||
- qbo_realmId
|
- qbo_realmId
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
alter table "public"."associations" add column "new_message_sound" boolean
|
||||||
|
null;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "public"."associations" ALTER COLUMN "new_message_sound" drop default;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
alter table "public"."associations" alter column "new_message_sound" set default 'true';
|
||||||
Reference in New Issue
Block a user