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:
@@ -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="*"
|
||||
|
||||
@@ -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.
@@ -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 });
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -3813,7 +3813,7 @@
|
||||
"actions": "",
|
||||
"changepassword": "",
|
||||
"profileinfo": "",
|
||||
"notification_sound": "",
|
||||
"user_settings": "",
|
||||
"play_sound_for_new_messages": "",
|
||||
"notification_sound_on": "",
|
||||
"notification_sound_off": "",
|
||||
|
||||
@@ -3813,7 +3813,7 @@
|
||||
"actions": "",
|
||||
"changepassword": "",
|
||||
"profileinfo": "",
|
||||
"notification_sound": "",
|
||||
"user_settings": "",
|
||||
"play_sound_for_new_messages": "",
|
||||
"notification_sound_on": "",
|
||||
"notification_sound_off": "",
|
||||
|
||||
164
client/src/utils/singleTabAudioLeader.js
Normal file
164
client/src/utils/singleTabAudioLeader.js
Normal 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();
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user