Merged in feature/IO-3377-Add-Notification-Tone-For-Messaging (pull request #2586)

Feature/IO-3377 Add Notification Tone For Messaging
This commit is contained in:
Dave Richer
2025-09-24 18:39:52 +00:00
12 changed files with 248 additions and 56 deletions

View File

@@ -73,9 +73,6 @@ export function App({
setIsPartsEntry(isParts);
}, [setIsPartsEntry]);
//const b = Grid.useBreakpoint();
// console.log("Breakpoints:", b);
// Associate event listeners, memoize to prevent multiple listeners being added
useEffect(() => {
const offlineListener = () => {
@@ -165,7 +162,7 @@ export function App({
/>
<NotificationProvider>
<SoundWrapper>
<SoundWrapper bodyshop={bodyshop}>
<Routes>
<Route
path="*"

View File

@@ -1,19 +1,23 @@
// 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";
import { initSingleTabAudioLeader } from "../utils/singleTabAudioLeader";
export default function SoundWrapper({ children }) {
export default function SoundWrapper({ children, bodyshop }) {
const { t } = useTranslation();
const notification = useNotification();
useEffect(() => {
// Initialize base audio
initNewMessageSound(newMessageWav, 0.7);
if (!bodyshop?.id) return;
// Show a one-time prompt when a play was blocked by autoplay policy
// 1) Init single-tab leader election (only one tab should play sounds), scoped by bodyshopId
const cleanupLeader = initSingleTabAudioLeader(bodyshop.id);
// 2) Initialize base audio
initNewMessageSound("https://images.imex.online/app/messageTone.wav", 0.7);
// 3) Show a one-time prompt when autoplay blocks first play
const onNeedsUnlock = () => {
notification.info({
description: t("audio.manager.description"),
@@ -22,17 +26,18 @@ export default function SoundWrapper({ children }) {
};
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 });
// 4) Proactively unlock on first gesture (once per session)
const gesture = () => unlockAudio(bodyshop.id);
window.addEventListener("click", gesture, { once: true, passive: true });
window.addEventListener("touchstart", gesture, { once: true, passive: true });
window.addEventListener("keydown", gesture, { once: true });
return () => {
cleanupLeader();
window.removeEventListener("sound-needs-unlock", onNeedsUnlock);
// The gesture listeners were added with { once: true }, so they clean themselves up
// gesture listeners were added with {once:true}
};
}, [notification, t]);
}, [notification, t, bodyshop?.id]); // include bodyshop.id so this runs when org changes
return <>{children}</>;
}

Binary file not shown.

View File

@@ -36,7 +36,7 @@ export function ChatAffixContainer({ bodyshop, chatVisible, currentUser }) {
// Register WebSocket handlers
if (socket?.connected) {
registerMessagingHandlers({ socket, client, currentUser });
registerMessagingHandlers({ socket, client, currentUser, bodyshop, t });
return () => {
unregisterMessagingHandlers({ socket });

View File

@@ -1,7 +1,10 @@
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";
import { isLeaderTab } from "../../utils/singleTabAudioLeader";
import { CONVERSATION_LIST_QUERY, GET_CONVERSATION_DETAILS } from "../../graphql/conversations.queries";
import { QUERY_ACTIVE_ASSOCIATION_SOUND } from "../../graphql/user.queries";
const logLocal = (message, ...args) => {
if (import.meta.env.VITE_APP_IS_TEST || !import.meta.env.PROD) {
@@ -36,7 +39,7 @@ const enrichConversation = (conversation, isOutbound) => ({
// });
// };
export const registerMessagingHandlers = ({ socket, client, currentUser }) => {
export const registerMessagingHandlers = ({ socket, client, currentUser, bodyshop }) => {
if (!(socket && client)) return;
const handleNewMessageSummary = async (message) => {
@@ -45,7 +48,7 @@ export const registerMessagingHandlers = ({ socket, client, currentUser }) => {
// 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
const email = currentUser?.email;
if (!email) return true; // default allow if we can't resolve user
const res = client.readQuery({
query: QUERY_ACTIVE_ASSOCIATION_SOUND,
@@ -64,9 +67,9 @@ export const registerMessagingHandlers = ({ socket, client, currentUser }) => {
const queryVariables = { offset: 0 };
if (!isoutbound) {
// Play notification sound for new inbound message
if (isNewMessageSoundEnabled(client)) {
playNewMessageSound();
// Play notification sound for new inbound message (scoped to bodyshop)
if (isLeaderTab(bodyshop.id) && isNewMessageSoundEnabled(client)) {
playNewMessageSound(bodyshop.id);
}
}
@@ -325,8 +328,6 @@ export const registerMessagingHandlers = ({ socket, client, currentUser }) => {
case "conversation-unarchived":
case "conversation-archived":
// Would like to someday figure out how to get this working without refetch queries,
// But I have but a solid 4 hours into it, and there are just too many weird occurrences
try {
const listQueryVariables = { offset: 0 };
const detailsQueryVariables = { conversationId };

View File

@@ -39,11 +39,15 @@ const NotificationCenterContainer = ({ visible, onClose, bodyshop, unreadCount,
return showUnreadOnly ? { ...baseWhereClause, read: { _is_null: true } } : baseWhereClause;
}, [baseWhereClause, showUnreadOnly]);
// before you call useQuery, compute skip once so you can reuse it
const skipQuery = !userAssociationId || !isEmployee;
const {
data,
fetchMore,
loading: queryLoading,
refetch
refetch,
error
} = useQuery(GET_NOTIFICATIONS, {
variables: {
limit: INITIAL_NOTIFICATIONS,
@@ -52,14 +56,26 @@ const NotificationCenterContainer = ({ visible, onClose, bodyshop, unreadCount,
},
fetchPolicy: "cache-and-network",
notifyOnNetworkStatusChange: true,
errorPolicy: "all",
pollInterval: isConnected ? 0 : day.duration(NOTIFICATION_POLL_INTERVAL_SECONDS, "seconds").asMilliseconds(),
skip: !userAssociationId || !isEmployee,
onError: (err) => {
console.error(`Error polling Notifications: ${err?.message || ""}`);
setTimeout(() => refetch(), day.duration(2, "seconds").asMilliseconds());
}
skip: skipQuery
});
// Replace onError with a side-effect that reacts to the hooks `error`
useEffect(() => {
if (!error || skipQuery) return;
console.error(`Error polling Notifications: ${error?.message || ""}`);
const t = setTimeout(() => {
// Guard: if component unmounted or query now skipped, do nothing
if (!skipQuery) {
refetch().catch((e) => console.error("Refetch failed:", e?.message || e));
}
}, day.duration(2, "seconds").asMilliseconds());
return () => clearTimeout(t);
}, [error, refetch, skipQuery]);
useEffect(() => {
const handleClickOutside = (event) => {
// Prevent open + close behavior from the header

View File

@@ -147,7 +147,7 @@ export default connect(
{association && (
<Col span={24}>
<Card title={t("user.labels.notification_sound")}>
<Card title={t("user.labels.user_settings")}>
<Space align="center" size="large">
<Typography.Text>{t("user.labels.play_sound_for_new_messages")}</Typography.Text>
<Tooltip

View File

@@ -3812,7 +3812,7 @@
"actions": "Actions",
"changepassword": "Change Password",
"profileinfo": "Profile Info",
"notification_sound": "Notification sound",
"user_settings": "User Settings",
"play_sound_for_new_messages": "Play a sound for new messages",
"notification_sound_on": "Sound is ON",
"notification_sound_off": "Sound is OFF",

View File

@@ -3813,7 +3813,7 @@
"actions": "",
"changepassword": "",
"profileinfo": "",
"notification_sound": "",
"user_settings": "",
"play_sound_for_new_messages": "",
"notification_sound_on": "",
"notification_sound_off": "",

View File

@@ -3813,7 +3813,7 @@
"actions": "",
"changepassword": "",
"profileinfo": "",
"notification_sound": "",
"user_settings": "",
"play_sound_for_new_messages": "",
"notification_sound_on": "",
"notification_sound_off": "",

View File

@@ -0,0 +1,164 @@
// src/utils/singleTabAudioLeader.js
// Ensures only one tab ("leader") plays sounds per bodyshop.
//
// Storage key: localStorage["imex:sound:leader:<bodyshopId>"] = { id, ts }
// Channel: new BroadcastChannel("imex:sound:<bodyshopId>")
const STORAGE_PREFIX = "imex:sound:leader:";
const CHANNEL_PREFIX = "imex:sound:";
const TTL_MS = 60_000; // leader expires after 60s without heartbeat
const HEARTBEAT_MS = 20_000; // leader refresh interval
const WATCHDOG_MS = 10_000; // how often non-leaders check for stale leader
const TAB_ID =
typeof crypto !== "undefined" && crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(36).slice(2);
function channelSupported() {
try {
return "BroadcastChannel" in window;
} catch {
return false;
}
}
function getChannel(bodyshopId) {
if (!channelSupported() || !bodyshopId) return null;
try {
return new BroadcastChannel(CHANNEL_PREFIX + String(bodyshopId));
} catch {
return null;
}
}
function lsKey(bodyshopId) {
return STORAGE_PREFIX + String(bodyshopId);
}
function readLeader(bodyshopId) {
if (!bodyshopId) return null;
try {
const raw = localStorage.getItem(lsKey(bodyshopId));
if (!raw) return null;
return JSON.parse(raw);
} catch {
return null;
}
}
function writeLeader(record, bodyshopId) {
if (!bodyshopId) return;
try {
localStorage.setItem(lsKey(bodyshopId), JSON.stringify(record));
const bc = getChannel(bodyshopId);
if (bc) {
bc.postMessage({ type: "leader-update", payload: { ...record, bodyshopId } });
bc.close();
}
} catch {
// ignore
}
}
function removeLeader(bodyshopId) {
if (!bodyshopId) return;
try {
const cur = readLeader(bodyshopId);
if (cur?.id === TAB_ID) {
localStorage.removeItem(lsKey(bodyshopId));
const bc = getChannel(bodyshopId);
if (bc) {
bc.postMessage({ type: "leader-removed", payload: { id: TAB_ID, bodyshopId } });
bc.close();
}
}
} catch {
// ignore
}
}
function now() {
return Date.now();
}
function isStale(rec) {
return !rec || now() - rec.ts > TTL_MS;
}
function claimLeadership(bodyshopId) {
const rec = { id: TAB_ID, ts: now() };
writeLeader(rec, bodyshopId);
return rec;
}
/** Is THIS tab currently the leader (and not stale)? */
export function isLeaderTab(bodyshopId) {
const rec = readLeader(bodyshopId);
return !!rec && rec.id === TAB_ID && !isStale(rec);
}
/** Force this tab to become the leader right now. */
export function claimLeadershipNow(bodyshopId) {
return claimLeadership(bodyshopId);
}
/**
* Initialize leader election/heartbeat for this tab (scoped by bodyshopId).
* Call once (e.g., in SoundWrapper). Returns a cleanup function.
*/
export function initSingleTabAudioLeader(bodyshopId) {
if (!bodyshopId)
return () => {
//
};
// If no leader or stale, try to claim after a tiny delay (reduce startup contention)
if (isStale(readLeader(bodyshopId))) {
setTimeout(() => claimLeadership(bodyshopId), 100);
}
// If this tab becomes focused/visible, it can claim leadership
const onFocus = () => claimLeadership(bodyshopId);
const onVis = () => {
if (document.visibilityState === "visible") claimLeadership(bodyshopId);
};
window.addEventListener("focus", onFocus);
document.addEventListener("visibilitychange", onVis);
// Heartbeat from the leader to keep record fresh
const heartbeat = setInterval(() => {
if (!isLeaderTab(bodyshopId)) return;
writeLeader({ id: TAB_ID, ts: now() }, bodyshopId);
}, HEARTBEAT_MS);
// Watchdog: if leader is stale, try to claim (even if we're not focused)
const watchdog = setInterval(() => {
const cur = readLeader(bodyshopId);
if (isStale(cur)) claimLeadership(bodyshopId);
}, WATCHDOG_MS);
// If this tab was the leader, clean up on unload
const onUnload = () => removeLeader(bodyshopId);
window.addEventListener("beforeunload", onUnload);
// Per-bodyshop BroadcastChannel listener (optional/no-op)
const bc = getChannel(bodyshopId);
const onBC = bc
? () => {
// No state kept here; localStorage read is the source of truth.
}
: null;
if (bc && onBC) bc.addEventListener("message", onBC);
return () => {
window.removeEventListener("focus", onFocus);
document.removeEventListener("visibilitychange", onVis);
window.removeEventListener("beforeunload", onUnload);
clearInterval(heartbeat);
clearInterval(watchdog);
if (bc && onBC) {
bc.removeEventListener("message", onBC);
bc.close();
}
};
}

View File

@@ -1,3 +1,9 @@
// src/utils/soundManager.js
// Handles audio init, autoplay unlock, and queued plays.
// When a tab successfully unlocks audio, it CLAIMS LEADERSHIP immediately for that bodyshop.
import { claimLeadershipNow } from "./singleTabAudioLeader";
let baseAudio = null;
let unlocked = false;
let queuedPlays = 0;
@@ -5,8 +11,8 @@ let installingUnlockHandlers = false;
/**
* Initialize the new-message sound.
* @param url
* @param volume
* @param {string} url
* @param {number} volume
*/
export function initNewMessageSound(url, volume = 0.7) {
baseAudio = new Audio(url);
@@ -14,27 +20,35 @@ export function initNewMessageSound(url, volume = 0.7) {
baseAudio.volume = volume;
}
/** Has this tab unlocked audio? (optional helper) */
export function isAudioUnlocked() {
return unlocked;
}
/**
* Unlocks audio if not already unlocked.
* @returns {Promise<void>}
* On success, this tab immediately becomes the sound LEADER for the given bodyshop.
*/
export async function unlockAudio() {
export async function unlockAudio(bodyshopId) {
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(() => {
//
// ignore
});
unlocked = true;
// Immediately become the leader because THIS tab can actually play sound.
claimLeadershipNow(bodyshopId);
// 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(() => {
//
// ignore
});
}
} finally {
@@ -42,31 +56,26 @@ export async function unlockAudio() {
}
}
/**
* Installs listeners to unlock audio on first gesture.
*/
function addUnlockListeners() {
/** Installs listeners to unlock audio on first gesture. */
function addUnlockListeners(bodyshopId) {
if (installingUnlockHandlers) return;
installingUnlockHandlers = true;
const handler = () => unlockAudio();
const handler = () => unlockAudio(bodyshopId);
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.
*/
/** 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
// With {once:true} they self-remove; we only reset the flag.
installingUnlockHandlers = false;
}
/**
* Plays the new-message ding. If blocked, queue one and wait for first gesture.
* @returns {Promise<void>}
*/
export async function playNewMessageSound() {
export async function playNewMessageSound(bodyshopId) {
if (!baseAudio) return;
try {
const a = baseAudio.cloneNode(true);
@@ -75,14 +84,14 @@ export async function playNewMessageSound() {
// Most common: NotAllowedError due to missing prior gesture
if (err?.name === "NotAllowedError") {
queuedPlays = Math.min(queuedPlays + 1, 1); // cap at 1
addUnlockListeners();
addUnlockListeners(bodyshopId);
// 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);
}
}