|
1 | 1 | "use client"; |
2 | 2 |
|
| 3 | +import { useEffect, useMemo, useRef } from "react"; |
3 | 4 | import { usePathname } from "next/navigation"; |
4 | 5 | import Link from "next/link"; |
5 | 6 | import { useQuery } from "convex/react"; |
@@ -27,6 +28,13 @@ import { |
27 | 28 | X, |
28 | 29 | } from "lucide-react"; |
29 | 30 | import { useAuth } from "@/contexts/AuthContext"; |
| 31 | +import { |
| 32 | + INBOX_CUE_PREFERENCES_UPDATED_EVENT, |
| 33 | + buildUnreadSnapshot, |
| 34 | + getUnreadIncreases, |
| 35 | + loadInboxCuePreferences, |
| 36 | +} from "@/lib/inboxNotificationCues"; |
| 37 | +import { playInboxBingSound } from "@/lib/playInboxBingSound"; |
30 | 38 | import { WorkspaceSelector } from "./WorkspaceSelector"; |
31 | 39 |
|
32 | 40 | type SidebarNavItem = { |
@@ -81,16 +89,92 @@ export function AppSidebar({ |
81 | 89 | }: AppSidebarProps): React.JSX.Element { |
82 | 90 | const pathname = usePathname(); |
83 | 91 | const { activeWorkspace, logout, user } = useAuth(); |
| 92 | + const isAdmin = activeWorkspace?.role === "owner" || activeWorkspace?.role === "admin"; |
84 | 93 | const integrationSignals = useQuery( |
85 | 94 | api.workspaces.getHostedOnboardingIntegrationSignals, |
86 | 95 | activeWorkspace?._id ? { workspaceId: activeWorkspace._id } : "skip" |
87 | 96 | ); |
| 97 | + const sidebarConversations = useQuery( |
| 98 | + api.conversations.list, |
| 99 | + activeWorkspace?._id && isAdmin ? { workspaceId: activeWorkspace._id } : "skip" |
| 100 | + ); |
| 101 | + const inboxCuePreferencesRef = useRef<{ |
| 102 | + browserNotifications: boolean; |
| 103 | + sound: boolean; |
| 104 | + }>({ |
| 105 | + browserNotifications: false, |
| 106 | + sound: true, |
| 107 | + }); |
| 108 | + const unreadSnapshotRef = useRef<Record<string, number> | null>(null); |
88 | 109 | const hasActiveWidgetOrSdk = (integrationSignals?.integrations ?? []).some( |
89 | 110 | (signal) => signal.isActiveNow |
90 | 111 | ); |
91 | 112 | const navItems: SidebarNavItem[] = hasActiveWidgetOrSdk |
92 | 113 | ? [...CORE_NAV_ITEMS, ONBOARDING_NAV_ITEM, SETTINGS_NAV_ITEM] |
93 | 114 | : [ONBOARDING_NAV_ITEM, ...CORE_NAV_ITEMS, SETTINGS_NAV_ITEM]; |
| 115 | + const inboxUnreadCount = useMemo( |
| 116 | + () => |
| 117 | + sidebarConversations?.reduce((sum, conversation) => sum + (conversation.unreadByAgent ?? 0), 0) ?? |
| 118 | + 0, |
| 119 | + [sidebarConversations] |
| 120 | + ); |
| 121 | + const showInboxUnreadBadge = isAdmin && inboxUnreadCount > 0; |
| 122 | + const inboxUnreadBadgeLabel = inboxUnreadCount > 99 ? "99+" : String(inboxUnreadCount); |
| 123 | + |
| 124 | + useEffect(() => { |
| 125 | + if (typeof window === "undefined") { |
| 126 | + return; |
| 127 | + } |
| 128 | + |
| 129 | + const refreshCuePreferences = () => { |
| 130 | + inboxCuePreferencesRef.current = loadInboxCuePreferences(window.localStorage); |
| 131 | + }; |
| 132 | + refreshCuePreferences(); |
| 133 | + window.addEventListener("storage", refreshCuePreferences); |
| 134 | + window.addEventListener(INBOX_CUE_PREFERENCES_UPDATED_EVENT, refreshCuePreferences); |
| 135 | + return () => { |
| 136 | + window.removeEventListener("storage", refreshCuePreferences); |
| 137 | + window.removeEventListener(INBOX_CUE_PREFERENCES_UPDATED_EVENT, refreshCuePreferences); |
| 138 | + }; |
| 139 | + }, []); |
| 140 | + |
| 141 | + useEffect(() => { |
| 142 | + unreadSnapshotRef.current = null; |
| 143 | + }, [activeWorkspace?._id, isAdmin]); |
| 144 | + |
| 145 | + useEffect(() => { |
| 146 | + if (!isAdmin || !sidebarConversations || typeof window === "undefined") { |
| 147 | + return; |
| 148 | + } |
| 149 | + |
| 150 | + const previousSnapshot = unreadSnapshotRef.current; |
| 151 | + const currentSnapshot = buildUnreadSnapshot( |
| 152 | + sidebarConversations.map((conversation) => ({ |
| 153 | + _id: conversation._id, |
| 154 | + unreadByAgent: conversation.unreadByAgent, |
| 155 | + })) |
| 156 | + ); |
| 157 | + unreadSnapshotRef.current = currentSnapshot; |
| 158 | + |
| 159 | + if (!previousSnapshot || pathname === "/inbox" || pathname.startsWith("/inbox/")) { |
| 160 | + return; |
| 161 | + } |
| 162 | + |
| 163 | + const increasedConversationIds = getUnreadIncreases({ |
| 164 | + previous: previousSnapshot, |
| 165 | + conversations: sidebarConversations.map((conversation) => ({ |
| 166 | + _id: conversation._id, |
| 167 | + unreadByAgent: conversation.unreadByAgent, |
| 168 | + })), |
| 169 | + }); |
| 170 | + if (increasedConversationIds.length === 0) { |
| 171 | + return; |
| 172 | + } |
| 173 | + |
| 174 | + if (inboxCuePreferencesRef.current.sound) { |
| 175 | + playInboxBingSound(); |
| 176 | + } |
| 177 | + }, [isAdmin, pathname, sidebarConversations]); |
94 | 178 |
|
95 | 179 | return ( |
96 | 180 | <aside className={`w-64 bg-white border-r flex flex-col h-full ${className ?? ""}`}> |
@@ -138,7 +222,15 @@ export function AppSidebar({ |
138 | 222 | }`} |
139 | 223 | > |
140 | 224 | <Icon className="h-5 w-5" /> |
141 | | - {item.label} |
| 225 | + <span className="flex-1">{item.label}</span> |
| 226 | + {item.href === "/inbox" && showInboxUnreadBadge && ( |
| 227 | + <span |
| 228 | + className="inline-flex min-w-5 items-center justify-center rounded-full bg-primary px-1.5 py-0.5 text-[11px] font-semibold leading-none text-white" |
| 229 | + data-testid="sidebar-inbox-unread-badge" |
| 230 | + > |
| 231 | + {inboxUnreadBadgeLabel} |
| 232 | + </span> |
| 233 | + )} |
142 | 234 | </Link> |
143 | 235 | </li> |
144 | 236 | ); |
|
0 commit comments