diff --git a/apps/web/src/app/inbox/page.tsx b/apps/web/src/app/inbox/page.tsx index 178fb9a..3df42de 100644 --- a/apps/web/src/app/inbox/page.tsx +++ b/apps/web/src/app/inbox/page.tsx @@ -36,11 +36,13 @@ import { useIsCompactViewport, } from "@/components/ResponsiveLayout"; import { + INBOX_CUE_PREFERENCES_UPDATED_EVENT, buildUnreadSnapshot, getUnreadIncreases, loadInboxCuePreferences, shouldSuppressAttentionCue, } from "@/lib/inboxNotificationCues"; +import { playInboxBingSound } from "@/lib/playInboxBingSound"; function PresenceIndicator({ visitorId }: { visitorId: Id<"visitors"> }) { const isOnline = useQuery(api.visitors.isOnline, { visitorId }); @@ -123,7 +125,7 @@ function InboxContent(): React.JSX.Element | null { sound: boolean; }>({ browserNotifications: false, - sound: false, + sound: true, }); const unreadSnapshotRef = useRef | null>(null); const defaultTitleRef = useRef(null); @@ -259,34 +261,6 @@ function InboxContent(): React.JSX.Element | null { setActiveCompactPanel((current) => (current === panel ? null : panel)); }; - const playInboxCueSound = () => { - if (typeof window === "undefined") { - return; - } - const AudioContextCtor = - window.AudioContext || - (window as typeof window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext; - if (!AudioContextCtor) { - return; - } - - const context = new AudioContextCtor(); - const oscillator = context.createOscillator(); - const gainNode = context.createGain(); - oscillator.type = "sine"; - oscillator.frequency.setValueAtTime(880, context.currentTime); - gainNode.gain.setValueAtTime(0.05, context.currentTime); - gainNode.gain.exponentialRampToValueAtTime(0.0001, context.currentTime + 0.18); - - oscillator.connect(gainNode); - gainNode.connect(context.destination); - oscillator.start(); - oscillator.stop(context.currentTime + 0.18); - oscillator.onended = () => { - void context.close(); - }; - }; - // Keyboard shortcut for knowledge search (Ctrl+K / Cmd+K) useEffect(() => { const handleGlobalKeyDown = (e: KeyboardEvent) => { @@ -472,8 +446,10 @@ function InboxContent(): React.JSX.Element | null { }; refreshCuePreferences(); window.addEventListener("storage", refreshCuePreferences); + window.addEventListener(INBOX_CUE_PREFERENCES_UPDATED_EVENT, refreshCuePreferences); return () => { window.removeEventListener("storage", refreshCuePreferences); + window.removeEventListener(INBOX_CUE_PREFERENCES_UPDATED_EVENT, refreshCuePreferences); }; }, []); @@ -547,7 +523,7 @@ function InboxContent(): React.JSX.Element | null { const preferences = inboxCuePreferencesRef.current; if (preferences.sound) { - playInboxCueSound(); + playInboxBingSound(); } if ( diff --git a/apps/web/src/app/settings/NotificationSettingsSection.tsx b/apps/web/src/app/settings/NotificationSettingsSection.tsx index 5439f5d..03cd884 100644 --- a/apps/web/src/app/settings/NotificationSettingsSection.tsx +++ b/apps/web/src/app/settings/NotificationSettingsSection.tsx @@ -6,7 +6,11 @@ import { Bell } from "lucide-react"; import { Button, Card } from "@opencom/ui"; import { api } from "@opencom/convex"; import type { Id } from "@opencom/convex/dataModel"; -import { loadInboxCuePreferences, saveInboxCuePreferences } from "@/lib/inboxNotificationCues"; +import { + broadcastInboxCuePreferencesUpdated, + loadInboxCuePreferences, + saveInboxCuePreferences, +} from "@/lib/inboxNotificationCues"; interface NotificationSettingsSectionProps { workspaceId?: Id<"workspaces">; @@ -130,6 +134,7 @@ export function NotificationSettingsSection({ }, window.localStorage ); + broadcastInboxCuePreferencesUpdated(window); } finally { setSavingCues(false); } diff --git a/apps/web/src/components/AppSidebar.tsx b/apps/web/src/components/AppSidebar.tsx index a49b42b..98fc677 100644 --- a/apps/web/src/components/AppSidebar.tsx +++ b/apps/web/src/components/AppSidebar.tsx @@ -1,5 +1,6 @@ "use client"; +import { useEffect, useMemo, useRef } from "react"; import { usePathname } from "next/navigation"; import Link from "next/link"; import { useQuery } from "convex/react"; @@ -27,6 +28,13 @@ import { X, } from "lucide-react"; import { useAuth } from "@/contexts/AuthContext"; +import { + INBOX_CUE_PREFERENCES_UPDATED_EVENT, + buildUnreadSnapshot, + getUnreadIncreases, + loadInboxCuePreferences, +} from "@/lib/inboxNotificationCues"; +import { playInboxBingSound } from "@/lib/playInboxBingSound"; import { WorkspaceSelector } from "./WorkspaceSelector"; type SidebarNavItem = { @@ -81,16 +89,92 @@ export function AppSidebar({ }: AppSidebarProps): React.JSX.Element { const pathname = usePathname(); const { activeWorkspace, logout, user } = useAuth(); + const isAdmin = activeWorkspace?.role === "owner" || activeWorkspace?.role === "admin"; const integrationSignals = useQuery( api.workspaces.getHostedOnboardingIntegrationSignals, activeWorkspace?._id ? { workspaceId: activeWorkspace._id } : "skip" ); + const sidebarConversations = useQuery( + api.conversations.list, + activeWorkspace?._id && isAdmin ? { workspaceId: activeWorkspace._id } : "skip" + ); + const inboxCuePreferencesRef = useRef<{ + browserNotifications: boolean; + sound: boolean; + }>({ + browserNotifications: false, + sound: true, + }); + const unreadSnapshotRef = useRef | null>(null); const hasActiveWidgetOrSdk = (integrationSignals?.integrations ?? []).some( (signal) => signal.isActiveNow ); const navItems: SidebarNavItem[] = hasActiveWidgetOrSdk ? [...CORE_NAV_ITEMS, ONBOARDING_NAV_ITEM, SETTINGS_NAV_ITEM] : [ONBOARDING_NAV_ITEM, ...CORE_NAV_ITEMS, SETTINGS_NAV_ITEM]; + const inboxUnreadCount = useMemo( + () => + sidebarConversations?.reduce((sum, conversation) => sum + (conversation.unreadByAgent ?? 0), 0) ?? + 0, + [sidebarConversations] + ); + const showInboxUnreadBadge = isAdmin && inboxUnreadCount > 0; + const inboxUnreadBadgeLabel = inboxUnreadCount > 99 ? "99+" : String(inboxUnreadCount); + + useEffect(() => { + if (typeof window === "undefined") { + return; + } + + const refreshCuePreferences = () => { + inboxCuePreferencesRef.current = loadInboxCuePreferences(window.localStorage); + }; + refreshCuePreferences(); + window.addEventListener("storage", refreshCuePreferences); + window.addEventListener(INBOX_CUE_PREFERENCES_UPDATED_EVENT, refreshCuePreferences); + return () => { + window.removeEventListener("storage", refreshCuePreferences); + window.removeEventListener(INBOX_CUE_PREFERENCES_UPDATED_EVENT, refreshCuePreferences); + }; + }, []); + + useEffect(() => { + unreadSnapshotRef.current = null; + }, [activeWorkspace?._id, isAdmin]); + + useEffect(() => { + if (!isAdmin || !sidebarConversations || typeof window === "undefined") { + return; + } + + const previousSnapshot = unreadSnapshotRef.current; + const currentSnapshot = buildUnreadSnapshot( + sidebarConversations.map((conversation) => ({ + _id: conversation._id, + unreadByAgent: conversation.unreadByAgent, + })) + ); + unreadSnapshotRef.current = currentSnapshot; + + if (!previousSnapshot || pathname === "/inbox" || pathname.startsWith("/inbox/")) { + return; + } + + const increasedConversationIds = getUnreadIncreases({ + previous: previousSnapshot, + conversations: sidebarConversations.map((conversation) => ({ + _id: conversation._id, + unreadByAgent: conversation.unreadByAgent, + })), + }); + if (increasedConversationIds.length === 0) { + return; + } + + if (inboxCuePreferencesRef.current.sound) { + playInboxBingSound(); + } + }, [isAdmin, pathname, sidebarConversations]); return (