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(isParts);
|
||||||
}, [setIsPartsEntry]);
|
}, [setIsPartsEntry]);
|
||||||
|
|
||||||
//const b = Grid.useBreakpoint();
|
|
||||||
// console.log("Breakpoints:", b);
|
|
||||||
|
|
||||||
// Associate event listeners, memoize to prevent multiple listeners being added
|
// Associate event listeners, memoize to prevent multiple listeners being added
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const offlineListener = () => {
|
const offlineListener = () => {
|
||||||
@@ -165,7 +162,7 @@ export function App({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<NotificationProvider>
|
<NotificationProvider>
|
||||||
<SoundWrapper>
|
<SoundWrapper bodyshop={bodyshop}>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route
|
<Route
|
||||||
path="*"
|
path="*"
|
||||||
|
|||||||
@@ -1,19 +1,23 @@
|
|||||||
// src/app/SoundWrapper.jsx
|
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useNotification } from "../contexts/Notifications/notificationContext.jsx";
|
import { useNotification } from "../contexts/Notifications/notificationContext.jsx";
|
||||||
import newMessageWav from "./../audio/messageTone.wav";
|
|
||||||
import { initNewMessageSound, unlockAudio } from "./../utils/soundManager";
|
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 { t } = useTranslation();
|
||||||
const notification = useNotification();
|
const notification = useNotification();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Initialize base audio
|
if (!bodyshop?.id) return;
|
||||||
initNewMessageSound(newMessageWav, 0.7);
|
|
||||||
|
|
||||||
// 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 = () => {
|
const onNeedsUnlock = () => {
|
||||||
notification.info({
|
notification.info({
|
||||||
description: t("audio.manager.description"),
|
description: t("audio.manager.description"),
|
||||||
@@ -22,17 +26,18 @@ export default function SoundWrapper({ children }) {
|
|||||||
};
|
};
|
||||||
window.addEventListener("sound-needs-unlock", onNeedsUnlock);
|
window.addEventListener("sound-needs-unlock", onNeedsUnlock);
|
||||||
|
|
||||||
// Proactively unlock on first gesture (once per session)
|
// 4) Proactively unlock on first gesture (once per session)
|
||||||
const handler = () => unlockAudio();
|
const gesture = () => unlockAudio(bodyshop.id);
|
||||||
window.addEventListener("click", handler, { once: true, passive: true });
|
window.addEventListener("click", gesture, { once: true, passive: true });
|
||||||
window.addEventListener("touchstart", handler, { once: true, passive: true });
|
window.addEventListener("touchstart", gesture, { once: true, passive: true });
|
||||||
window.addEventListener("keydown", handler, { once: true });
|
window.addEventListener("keydown", gesture, { once: true });
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
cleanupLeader();
|
||||||
window.removeEventListener("sound-needs-unlock", onNeedsUnlock);
|
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}</>;
|
return <>{children}</>;
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
@@ -36,7 +36,7 @@ export function ChatAffixContainer({ bodyshop, chatVisible, currentUser }) {
|
|||||||
|
|
||||||
// Register WebSocket handlers
|
// Register WebSocket handlers
|
||||||
if (socket?.connected) {
|
if (socket?.connected) {
|
||||||
registerMessagingHandlers({ socket, client, currentUser });
|
registerMessagingHandlers({ socket, client, currentUser, bodyshop, t });
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
unregisterMessagingHandlers({ socket });
|
unregisterMessagingHandlers({ socket });
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
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";
|
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) => {
|
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) {
|
||||||
@@ -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;
|
if (!(socket && client)) return;
|
||||||
|
|
||||||
const handleNewMessageSummary = async (message) => {
|
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
|
// True only when DB value is strictly true; falls back to true on cache miss
|
||||||
const isNewMessageSoundEnabled = (client) => {
|
const isNewMessageSoundEnabled = (client) => {
|
||||||
try {
|
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
|
if (!email) return true; // default allow if we can't resolve user
|
||||||
const res = client.readQuery({
|
const res = client.readQuery({
|
||||||
query: QUERY_ACTIVE_ASSOCIATION_SOUND,
|
query: QUERY_ACTIVE_ASSOCIATION_SOUND,
|
||||||
@@ -64,9 +67,9 @@ export const registerMessagingHandlers = ({ socket, client, currentUser }) => {
|
|||||||
const queryVariables = { offset: 0 };
|
const queryVariables = { offset: 0 };
|
||||||
|
|
||||||
if (!isoutbound) {
|
if (!isoutbound) {
|
||||||
// Play notification sound for new inbound message
|
// Play notification sound for new inbound message (scoped to bodyshop)
|
||||||
if (isNewMessageSoundEnabled(client)) {
|
if (isLeaderTab(bodyshop.id) && isNewMessageSoundEnabled(client)) {
|
||||||
playNewMessageSound();
|
playNewMessageSound(bodyshop.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -325,8 +328,6 @@ export const registerMessagingHandlers = ({ socket, client, currentUser }) => {
|
|||||||
|
|
||||||
case "conversation-unarchived":
|
case "conversation-unarchived":
|
||||||
case "conversation-archived":
|
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 {
|
try {
|
||||||
const listQueryVariables = { offset: 0 };
|
const listQueryVariables = { offset: 0 };
|
||||||
const detailsQueryVariables = { conversationId };
|
const detailsQueryVariables = { conversationId };
|
||||||
|
|||||||
@@ -39,11 +39,15 @@ const NotificationCenterContainer = ({ visible, onClose, bodyshop, unreadCount,
|
|||||||
return showUnreadOnly ? { ...baseWhereClause, read: { _is_null: true } } : baseWhereClause;
|
return showUnreadOnly ? { ...baseWhereClause, read: { _is_null: true } } : baseWhereClause;
|
||||||
}, [baseWhereClause, showUnreadOnly]);
|
}, [baseWhereClause, showUnreadOnly]);
|
||||||
|
|
||||||
|
// before you call useQuery, compute skip once so you can reuse it
|
||||||
|
const skipQuery = !userAssociationId || !isEmployee;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data,
|
data,
|
||||||
fetchMore,
|
fetchMore,
|
||||||
loading: queryLoading,
|
loading: queryLoading,
|
||||||
refetch
|
refetch,
|
||||||
|
error
|
||||||
} = useQuery(GET_NOTIFICATIONS, {
|
} = useQuery(GET_NOTIFICATIONS, {
|
||||||
variables: {
|
variables: {
|
||||||
limit: INITIAL_NOTIFICATIONS,
|
limit: INITIAL_NOTIFICATIONS,
|
||||||
@@ -52,14 +56,26 @@ const NotificationCenterContainer = ({ visible, onClose, bodyshop, unreadCount,
|
|||||||
},
|
},
|
||||||
fetchPolicy: "cache-and-network",
|
fetchPolicy: "cache-and-network",
|
||||||
notifyOnNetworkStatusChange: true,
|
notifyOnNetworkStatusChange: true,
|
||||||
|
errorPolicy: "all",
|
||||||
pollInterval: isConnected ? 0 : day.duration(NOTIFICATION_POLL_INTERVAL_SECONDS, "seconds").asMilliseconds(),
|
pollInterval: isConnected ? 0 : day.duration(NOTIFICATION_POLL_INTERVAL_SECONDS, "seconds").asMilliseconds(),
|
||||||
skip: !userAssociationId || !isEmployee,
|
skip: skipQuery
|
||||||
onError: (err) => {
|
|
||||||
console.error(`Error polling Notifications: ${err?.message || ""}`);
|
|
||||||
setTimeout(() => refetch(), day.duration(2, "seconds").asMilliseconds());
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 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(() => {
|
useEffect(() => {
|
||||||
const handleClickOutside = (event) => {
|
const handleClickOutside = (event) => {
|
||||||
// Prevent open + close behavior from the header
|
// Prevent open + close behavior from the header
|
||||||
|
|||||||
@@ -147,7 +147,7 @@ export default connect(
|
|||||||
|
|
||||||
{association && (
|
{association && (
|
||||||
<Col span={24}>
|
<Col span={24}>
|
||||||
<Card title={t("user.labels.notification_sound")}>
|
<Card title={t("user.labels.user_settings")}>
|
||||||
<Space align="center" size="large">
|
<Space align="center" size="large">
|
||||||
<Typography.Text>{t("user.labels.play_sound_for_new_messages")}</Typography.Text>
|
<Typography.Text>{t("user.labels.play_sound_for_new_messages")}</Typography.Text>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
|
|||||||
@@ -3812,7 +3812,7 @@
|
|||||||
"actions": "Actions",
|
"actions": "Actions",
|
||||||
"changepassword": "Change Password",
|
"changepassword": "Change Password",
|
||||||
"profileinfo": "Profile Info",
|
"profileinfo": "Profile Info",
|
||||||
"notification_sound": "Notification sound",
|
"user_settings": "User Settings",
|
||||||
"play_sound_for_new_messages": "Play a sound for new messages",
|
"play_sound_for_new_messages": "Play a sound for new messages",
|
||||||
"notification_sound_on": "Sound is ON",
|
"notification_sound_on": "Sound is ON",
|
||||||
"notification_sound_off": "Sound is OFF",
|
"notification_sound_off": "Sound is OFF",
|
||||||
|
|||||||
@@ -3813,7 +3813,7 @@
|
|||||||
"actions": "",
|
"actions": "",
|
||||||
"changepassword": "",
|
"changepassword": "",
|
||||||
"profileinfo": "",
|
"profileinfo": "",
|
||||||
"notification_sound": "",
|
"user_settings": "",
|
||||||
"play_sound_for_new_messages": "",
|
"play_sound_for_new_messages": "",
|
||||||
"notification_sound_on": "",
|
"notification_sound_on": "",
|
||||||
"notification_sound_off": "",
|
"notification_sound_off": "",
|
||||||
|
|||||||
@@ -3813,7 +3813,7 @@
|
|||||||
"actions": "",
|
"actions": "",
|
||||||
"changepassword": "",
|
"changepassword": "",
|
||||||
"profileinfo": "",
|
"profileinfo": "",
|
||||||
"notification_sound": "",
|
"user_settings": "",
|
||||||
"play_sound_for_new_messages": "",
|
"play_sound_for_new_messages": "",
|
||||||
"notification_sound_on": "",
|
"notification_sound_on": "",
|
||||||
"notification_sound_off": "",
|
"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 baseAudio = null;
|
||||||
let unlocked = false;
|
let unlocked = false;
|
||||||
let queuedPlays = 0;
|
let queuedPlays = 0;
|
||||||
@@ -5,8 +11,8 @@ let installingUnlockHandlers = false;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize the new-message sound.
|
* Initialize the new-message sound.
|
||||||
* @param url
|
* @param {string} url
|
||||||
* @param volume
|
* @param {number} volume
|
||||||
*/
|
*/
|
||||||
export function initNewMessageSound(url, volume = 0.7) {
|
export function initNewMessageSound(url, volume = 0.7) {
|
||||||
baseAudio = new Audio(url);
|
baseAudio = new Audio(url);
|
||||||
@@ -14,27 +20,35 @@ export function initNewMessageSound(url, volume = 0.7) {
|
|||||||
baseAudio.volume = volume;
|
baseAudio.volume = volume;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Has this tab unlocked audio? (optional helper) */
|
||||||
|
export function isAudioUnlocked() {
|
||||||
|
return unlocked;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Unlocks audio if not already 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;
|
if (unlocked) return;
|
||||||
try {
|
try {
|
||||||
// Chrome/Safari: playing any media (even muted) after a gesture unlocks audio.
|
// Chrome/Safari: playing any media (even muted) after a gesture unlocks audio.
|
||||||
const a = new Audio();
|
const a = new Audio();
|
||||||
a.muted = true;
|
a.muted = true;
|
||||||
await a.play().catch(() => {
|
await a.play().catch(() => {
|
||||||
//
|
// ignore
|
||||||
});
|
});
|
||||||
unlocked = true;
|
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)
|
// Flush exactly one queued ding (avoid spamming if many queued while locked)
|
||||||
if (queuedPlays > 0 && baseAudio) {
|
if (queuedPlays > 0 && baseAudio) {
|
||||||
queuedPlays = 0;
|
queuedPlays = 0;
|
||||||
const b = baseAudio.cloneNode(true);
|
const b = baseAudio.cloneNode(true);
|
||||||
b.play().catch(() => {
|
b.play().catch(() => {
|
||||||
//
|
// ignore
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -42,31 +56,26 @@ export async function unlockAudio() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Installs listeners to unlock audio on first gesture. */
|
||||||
* Installs listeners to unlock audio on first gesture.
|
function addUnlockListeners(bodyshopId) {
|
||||||
*/
|
|
||||||
function addUnlockListeners() {
|
|
||||||
if (installingUnlockHandlers) return;
|
if (installingUnlockHandlers) return;
|
||||||
installingUnlockHandlers = true;
|
installingUnlockHandlers = true;
|
||||||
const handler = () => unlockAudio();
|
const handler = () => unlockAudio(bodyshopId);
|
||||||
window.addEventListener("click", handler, { once: true, passive: true });
|
window.addEventListener("click", handler, { once: true, passive: true });
|
||||||
window.addEventListener("touchstart", handler, { once: true, passive: true });
|
window.addEventListener("touchstart", handler, { once: true, passive: true });
|
||||||
window.addEventListener("keydown", handler, { once: 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() {
|
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;
|
installingUnlockHandlers = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Plays the new-message ding. If blocked, queue one and wait for first gesture.
|
* 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;
|
if (!baseAudio) return;
|
||||||
try {
|
try {
|
||||||
const a = baseAudio.cloneNode(true);
|
const a = baseAudio.cloneNode(true);
|
||||||
@@ -75,14 +84,14 @@ export async function playNewMessageSound() {
|
|||||||
// Most common: NotAllowedError due to missing prior gesture
|
// Most common: NotAllowedError due to missing prior gesture
|
||||||
if (err?.name === "NotAllowedError") {
|
if (err?.name === "NotAllowedError") {
|
||||||
queuedPlays = Math.min(queuedPlays + 1, 1); // cap at 1
|
queuedPlays = Math.min(queuedPlays + 1, 1); // cap at 1
|
||||||
addUnlockListeners();
|
addUnlockListeners(bodyshopId);
|
||||||
|
|
||||||
// Let the app know we need user interaction (optional UI prompt)
|
// Let the app know we need user interaction (optional UI prompt)
|
||||||
window.dispatchEvent(new CustomEvent("sound-needs-unlock"));
|
window.dispatchEvent(new CustomEvent("sound-needs-unlock"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Other errors can be logged
|
// Other errors can be logged
|
||||||
|
|
||||||
console.error("Audio play error:", err);
|
console.error("Audio play error:", err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user