Skip to content

Commit c3ff492

Browse files
authored
Merge pull request #7 from opencom-org/pr/web-admin-notifs
add notifications to web admin
2 parents 0163b8e + 101f536 commit c3ff492

6 files changed

Lines changed: 172 additions & 34 deletions

File tree

apps/web/src/app/inbox/page.tsx

Lines changed: 6 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -36,11 +36,13 @@ import {
3636
useIsCompactViewport,
3737
} from "@/components/ResponsiveLayout";
3838
import {
39+
INBOX_CUE_PREFERENCES_UPDATED_EVENT,
3940
buildUnreadSnapshot,
4041
getUnreadIncreases,
4142
loadInboxCuePreferences,
4243
shouldSuppressAttentionCue,
4344
} from "@/lib/inboxNotificationCues";
45+
import { playInboxBingSound } from "@/lib/playInboxBingSound";
4446

4547
function PresenceIndicator({ visitorId }: { visitorId: Id<"visitors"> }) {
4648
const isOnline = useQuery(api.visitors.isOnline, { visitorId });
@@ -123,7 +125,7 @@ function InboxContent(): React.JSX.Element | null {
123125
sound: boolean;
124126
}>({
125127
browserNotifications: false,
126-
sound: false,
128+
sound: true,
127129
});
128130
const unreadSnapshotRef = useRef<Record<string, number> | null>(null);
129131
const defaultTitleRef = useRef<string | null>(null);
@@ -259,34 +261,6 @@ function InboxContent(): React.JSX.Element | null {
259261
setActiveCompactPanel((current) => (current === panel ? null : panel));
260262
};
261263

262-
const playInboxCueSound = () => {
263-
if (typeof window === "undefined") {
264-
return;
265-
}
266-
const AudioContextCtor =
267-
window.AudioContext ||
268-
(window as typeof window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
269-
if (!AudioContextCtor) {
270-
return;
271-
}
272-
273-
const context = new AudioContextCtor();
274-
const oscillator = context.createOscillator();
275-
const gainNode = context.createGain();
276-
oscillator.type = "sine";
277-
oscillator.frequency.setValueAtTime(880, context.currentTime);
278-
gainNode.gain.setValueAtTime(0.05, context.currentTime);
279-
gainNode.gain.exponentialRampToValueAtTime(0.0001, context.currentTime + 0.18);
280-
281-
oscillator.connect(gainNode);
282-
gainNode.connect(context.destination);
283-
oscillator.start();
284-
oscillator.stop(context.currentTime + 0.18);
285-
oscillator.onended = () => {
286-
void context.close();
287-
};
288-
};
289-
290264
// Keyboard shortcut for knowledge search (Ctrl+K / Cmd+K)
291265
useEffect(() => {
292266
const handleGlobalKeyDown = (e: KeyboardEvent) => {
@@ -472,8 +446,10 @@ function InboxContent(): React.JSX.Element | null {
472446
};
473447
refreshCuePreferences();
474448
window.addEventListener("storage", refreshCuePreferences);
449+
window.addEventListener(INBOX_CUE_PREFERENCES_UPDATED_EVENT, refreshCuePreferences);
475450
return () => {
476451
window.removeEventListener("storage", refreshCuePreferences);
452+
window.removeEventListener(INBOX_CUE_PREFERENCES_UPDATED_EVENT, refreshCuePreferences);
477453
};
478454
}, []);
479455

@@ -547,7 +523,7 @@ function InboxContent(): React.JSX.Element | null {
547523

548524
const preferences = inboxCuePreferencesRef.current;
549525
if (preferences.sound) {
550-
playInboxCueSound();
526+
playInboxBingSound();
551527
}
552528

553529
if (

apps/web/src/app/settings/NotificationSettingsSection.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,11 @@ import { Bell } from "lucide-react";
66
import { Button, Card } from "@opencom/ui";
77
import { api } from "@opencom/convex";
88
import type { Id } from "@opencom/convex/dataModel";
9-
import { loadInboxCuePreferences, saveInboxCuePreferences } from "@/lib/inboxNotificationCues";
9+
import {
10+
broadcastInboxCuePreferencesUpdated,
11+
loadInboxCuePreferences,
12+
saveInboxCuePreferences,
13+
} from "@/lib/inboxNotificationCues";
1014

1115
interface NotificationSettingsSectionProps {
1216
workspaceId?: Id<"workspaces">;
@@ -130,6 +134,7 @@ export function NotificationSettingsSection({
130134
},
131135
window.localStorage
132136
);
137+
broadcastInboxCuePreferencesUpdated(window);
133138
} finally {
134139
setSavingCues(false);
135140
}

apps/web/src/components/AppSidebar.tsx

Lines changed: 93 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"use client";
22

3+
import { useEffect, useMemo, useRef } from "react";
34
import { usePathname } from "next/navigation";
45
import Link from "next/link";
56
import { useQuery } from "convex/react";
@@ -27,6 +28,13 @@ import {
2728
X,
2829
} from "lucide-react";
2930
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";
3038
import { WorkspaceSelector } from "./WorkspaceSelector";
3139

3240
type SidebarNavItem = {
@@ -81,16 +89,92 @@ export function AppSidebar({
8189
}: AppSidebarProps): React.JSX.Element {
8290
const pathname = usePathname();
8391
const { activeWorkspace, logout, user } = useAuth();
92+
const isAdmin = activeWorkspace?.role === "owner" || activeWorkspace?.role === "admin";
8493
const integrationSignals = useQuery(
8594
api.workspaces.getHostedOnboardingIntegrationSignals,
8695
activeWorkspace?._id ? { workspaceId: activeWorkspace._id } : "skip"
8796
);
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);
88109
const hasActiveWidgetOrSdk = (integrationSignals?.integrations ?? []).some(
89110
(signal) => signal.isActiveNow
90111
);
91112
const navItems: SidebarNavItem[] = hasActiveWidgetOrSdk
92113
? [...CORE_NAV_ITEMS, ONBOARDING_NAV_ITEM, SETTINGS_NAV_ITEM]
93114
: [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]);
94178

95179
return (
96180
<aside className={`w-64 bg-white border-r flex flex-col h-full ${className ?? ""}`}>
@@ -138,7 +222,15 @@ export function AppSidebar({
138222
}`}
139223
>
140224
<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+
)}
142234
</Link>
143235
</li>
144236
);

apps/web/src/lib/__tests__/inboxNotificationCues.test.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { describe, expect, it } from "vitest";
22
import {
3+
INBOX_CUE_PREFERENCES_UPDATED_EVENT,
4+
broadcastInboxCuePreferencesUpdated,
35
buildUnreadSnapshot,
46
getUnreadIncreases,
57
loadInboxCuePreferences,
@@ -22,7 +24,7 @@ describe("inboxNotificationCues", () => {
2224
const storage = createStorage();
2325
expect(loadInboxCuePreferences(storage as Storage)).toEqual({
2426
browserNotifications: false,
25-
sound: false,
27+
sound: true,
2628
});
2729
});
2830

@@ -83,4 +85,18 @@ describe("inboxNotificationCues", () => {
8385
})
8486
).toBe(false);
8587
});
88+
89+
it("broadcasts cue preference updates for same-tab listeners", () => {
90+
const seenEvents: string[] = [];
91+
const dispatcher = {
92+
dispatchEvent: (event: Event) => {
93+
seenEvents.push(event.type);
94+
return true;
95+
},
96+
};
97+
98+
broadcastInboxCuePreferencesUpdated(dispatcher);
99+
100+
expect(seenEvents).toEqual([INBOX_CUE_PREFERENCES_UPDATED_EVENT]);
101+
});
86102
});

apps/web/src/lib/inboxNotificationCues.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,10 @@ export interface InboxCuePreferences {
44
}
55

66
const STORAGE_KEY = "opencom.web.inboxCuePreferences";
7+
export const INBOX_CUE_PREFERENCES_UPDATED_EVENT = "opencom:inbox-cue-preferences-updated";
78
const DEFAULT_PREFERENCES: InboxCuePreferences = {
89
browserNotifications: false,
9-
sound: false,
10+
sound: true,
1011
};
1112

1213
type StorageLike = Pick<Storage, "getItem" | "setItem">;
@@ -43,6 +44,15 @@ export function saveInboxCuePreferences(
4344
storage.setItem(STORAGE_KEY, JSON.stringify(preferences));
4445
}
4546

47+
type EventDispatcher = Pick<Window, "dispatchEvent">;
48+
49+
export function broadcastInboxCuePreferencesUpdated(eventDispatcher?: EventDispatcher): void {
50+
if (!eventDispatcher) {
51+
return;
52+
}
53+
eventDispatcher.dispatchEvent(new Event(INBOX_CUE_PREFERENCES_UPDATED_EVENT));
54+
}
55+
4656
export function buildUnreadSnapshot(
4757
conversations: Array<{ _id: string; unreadByAgent?: number }>
4858
): Record<string, number> {
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
export function playInboxBingSound(): void {
2+
if (typeof window === "undefined") {
3+
return;
4+
}
5+
6+
const AudioContextCtor =
7+
window.AudioContext ||
8+
(window as typeof window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
9+
if (!AudioContextCtor) {
10+
return;
11+
}
12+
13+
const context = new AudioContextCtor();
14+
const gainNode = context.createGain();
15+
gainNode.connect(context.destination);
16+
17+
// Two quick tones create a "bing" cue without shipping an audio asset.
18+
const firstTone = context.createOscillator();
19+
firstTone.type = "sine";
20+
firstTone.frequency.setValueAtTime(784, context.currentTime);
21+
firstTone.connect(gainNode);
22+
firstTone.start(context.currentTime);
23+
firstTone.stop(context.currentTime + 0.09);
24+
25+
const secondTone = context.createOscillator();
26+
secondTone.type = "sine";
27+
secondTone.frequency.setValueAtTime(1046, context.currentTime + 0.1);
28+
secondTone.connect(gainNode);
29+
secondTone.start(context.currentTime + 0.1);
30+
secondTone.stop(context.currentTime + 0.24);
31+
32+
gainNode.gain.setValueAtTime(0.0001, context.currentTime);
33+
gainNode.gain.exponentialRampToValueAtTime(0.06, context.currentTime + 0.015);
34+
gainNode.gain.exponentialRampToValueAtTime(0.0001, context.currentTime + 0.26);
35+
36+
secondTone.onended = () => {
37+
void context.close();
38+
};
39+
}

0 commit comments

Comments
 (0)