diff --git a/client/src/App/App.jsx b/client/src/App/App.jsx index ab68d6e42..ea3be8f5a 100644 --- a/client/src/App/App.jsx +++ b/client/src/App/App.jsx @@ -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({ /> - + { - // 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}; } diff --git a/client/src/audio/messageTone.wav b/client/src/audio/messageTone.wav deleted file mode 100644 index 4bed79ac1..000000000 Binary files a/client/src/audio/messageTone.wav and /dev/null differ diff --git a/client/src/components/chat-affix/chat-affix.container.jsx b/client/src/components/chat-affix/chat-affix.container.jsx index fe8ad12b3..06d501dca 100644 --- a/client/src/components/chat-affix/chat-affix.container.jsx +++ b/client/src/components/chat-affix/chat-affix.container.jsx @@ -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 }); diff --git a/client/src/components/chat-affix/registerMessagingSocketHandlers.js b/client/src/components/chat-affix/registerMessagingSocketHandlers.js index 766cc1f01..d5519661e 100644 --- a/client/src/components/chat-affix/registerMessagingSocketHandlers.js +++ b/client/src/components/chat-affix/registerMessagingSocketHandlers.js @@ -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 }; diff --git a/client/src/components/notification-center/notification-center.container.jsx b/client/src/components/notification-center/notification-center.container.jsx index d0024c9d3..e534375f6 100644 --- a/client/src/components/notification-center/notification-center.container.jsx +++ b/client/src/components/notification-center/notification-center.container.jsx @@ -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 hook’s `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 diff --git a/client/src/components/profile-my/profile-my.component.jsx b/client/src/components/profile-my/profile-my.component.jsx index dbe50360a..e9c944c11 100644 --- a/client/src/components/profile-my/profile-my.component.jsx +++ b/client/src/components/profile-my/profile-my.component.jsx @@ -147,7 +147,7 @@ export default connect( {association && ( - + {t("user.labels.play_sound_for_new_messages")} "] = { id, ts } +// Channel: new BroadcastChannel("imex:sound:") + +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(); + } + }; +} diff --git a/client/src/utils/soundManager.js b/client/src/utils/soundManager.js index 119a208c0..bab9eec97 100644 --- a/client/src/utils/soundManager.js +++ b/client/src/utils/soundManager.js @@ -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} + * 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} */ -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); } }