Skip to content

Commit cb899ba

Browse files
feat: enhance presence tracking and push notification suppression
- Introduced a new BrowserAttachment interface to manage session state, including foreground/background status and session keys. - Implemented recent disconnect tracking to suppress push notifications during brief network interruptions. - Updated ConnectionDO to utilize the new presence tracking logic for push notification suppression. - Enhanced foreground detection in the web app to notify ConnectionDO of session changes, improving user experience during channel switching.
1 parent d2b0936 commit cb899ba

3 files changed

Lines changed: 130 additions & 22 deletions

File tree

packages/api/src/do/connection-do.ts

Lines changed: 92 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,20 @@ import { sendApnsNotification, type ApnsConfig } from "../utils/apns.js";
55
import { generateId as generateIdUtil } from "../utils/id.js";
66
import { randomUUID } from "../utils/uuid.js";
77

8+
/** Presence info stored in browser WebSocket attachments (survives DO hibernation). */
9+
interface BrowserAttachment {
10+
authenticated: boolean;
11+
tag: string;
12+
foreground: boolean;
13+
sessionKey: string | null;
14+
/** Timestamp (ms) when the session last went to background. */
15+
backgroundAt: number | null;
16+
}
17+
18+
/** Grace period constants for push notification suppression. */
19+
const BG_GRACE_MS = 15_000; // 15 s after going background
20+
const DC_GRACE_MS = 30_000; // 30 s after WebSocket disconnect
21+
822
/**
923
* ConnectionDO — one Durable Object instance per BotsChat user.
1024
*
@@ -29,8 +43,12 @@ export class ConnectionDO implements DurableObject {
2943
/** Pending resolve for a real-time task.scan.request → task.scan.result round-trip. */
3044
private pendingScanResolve: ((tasks: Array<Record<string, unknown>>) => void) | null = null;
3145

32-
/** Browser sessions that report themselves in foreground (push notifications are suppressed). */
33-
private foregroundSessions = new Set<string>();
46+
/**
47+
* Recently disconnected browser sessions — provides a grace period so that
48+
* brief network blips don't immediately trigger push notifications.
49+
* In-memory only; if the DO hibernates, the grace period has expired anyway.
50+
*/
51+
private recentDisconnects = new Map<string, number>();
3452

3553
/** Timestamp of last accepted OpenClaw WebSocket (in-memory, no storage write). */
3654
private lastOpenClawAcceptedAt = 0;
@@ -150,9 +168,13 @@ export class ConnectionDO implements DurableObject {
150168
JSON.stringify({ type: "openclaw.disconnected" }),
151169
);
152170
}
153-
// Clean up foreground tracking for browser sessions
171+
// Disconnect grace: remember recently-disconnected browser sessions so
172+
// push notifications are still suppressed during brief network blips.
154173
if (tag?.startsWith("browser:")) {
155-
this.foregroundSessions.delete(tag);
174+
const att = ws.deserializeAttachment() as BrowserAttachment | null;
175+
if (att?.foreground) {
176+
this.recentDisconnects.set(tag, Date.now());
177+
}
156178
}
157179
}
158180

@@ -217,7 +239,17 @@ export class ConnectionDO implements DurableObject {
217239

218240
const tag = `browser:${sessionId}`;
219241
this.state.acceptWebSocket(server, [tag]);
220-
server.serializeAttachment({ authenticated: false, tag });
242+
const att: BrowserAttachment = {
243+
authenticated: false,
244+
tag,
245+
foreground: false,
246+
sessionKey: null,
247+
backgroundAt: null,
248+
};
249+
server.serializeAttachment(att);
250+
251+
// Clear disconnect grace entry — this session is back.
252+
this.recentDisconnects.delete(tag);
221253

222254
return new Response(null, { status: 101, webSocket: client });
223255
}
@@ -377,10 +409,10 @@ export class ConnectionDO implements DurableObject {
377409
const { notifyPreview: _stripped, ...msgForBrowser } = msg;
378410
this.broadcastToBrowsers(JSON.stringify(msgForBrowser));
379411

380-
// Send push notification if no browser session is in foreground
412+
// Send push notification unless a device is (or was recently) in the foreground
381413
if (
382414
(msg.type === "agent.text" || msg.type === "agent.media" || msg.type === "agent.a2ui") &&
383-
this.foregroundSessions.size === 0 &&
415+
!this.shouldSuppressPush() &&
384416
(this.env.FCM_SERVICE_ACCOUNT_JSON || this.env.APNS_AUTH_KEY)
385417
) {
386418
this.sendPushNotifications(msg).catch((err) => {
@@ -393,7 +425,7 @@ export class ConnectionDO implements DurableObject {
393425
ws: WebSocket,
394426
msg: Record<string, unknown>,
395427
): Promise<void> {
396-
const attachment = ws.deserializeAttachment() as { authenticated: boolean; tag: string } | null;
428+
const attachment = ws.deserializeAttachment() as BrowserAttachment | null;
397429

398430
// Handle browser auth — verify JWT token
399431
if (msg.type === "auth") {
@@ -444,15 +476,29 @@ export class ConnectionDO implements DurableObject {
444476
return;
445477
}
446478

447-
// Handle foreground/background state tracking for push notifications
479+
// ---- Presence / focus tracking (stored in WS attachment, hibernation-safe) ----
448480
if (msg.type === "foreground.enter") {
449-
const tag = attachment.tag;
450-
if (tag) this.foregroundSessions.add(tag);
481+
ws.serializeAttachment({
482+
...attachment,
483+
foreground: true,
484+
sessionKey: (msg.sessionKey as string) ?? attachment.sessionKey ?? null,
485+
backgroundAt: null,
486+
} satisfies BrowserAttachment);
451487
return;
452488
}
453489
if (msg.type === "foreground.leave") {
454-
const tag = attachment.tag;
455-
if (tag) this.foregroundSessions.delete(tag);
490+
ws.serializeAttachment({
491+
...attachment,
492+
foreground: false,
493+
backgroundAt: Date.now(),
494+
} satisfies BrowserAttachment);
495+
return;
496+
}
497+
if (msg.type === "focus.update") {
498+
ws.serializeAttachment({
499+
...attachment,
500+
sessionKey: (msg.sessionKey as string) ?? null,
501+
} satisfies BrowserAttachment);
456502
return;
457503
}
458504

@@ -668,6 +714,39 @@ export class ConnectionDO implements DurableObject {
668714
}
669715
}
670716

717+
// ---- Presence helpers ----
718+
719+
/**
720+
* Determine whether push notifications should be suppressed because a device
721+
* is (or was very recently) in the foreground.
722+
*
723+
* Checks three layers:
724+
* 1. Any connected browser socket with `foreground === true`
725+
* 2. Background grace: socket went background < BG_GRACE_MS ago
726+
* 3. Disconnect grace: socket disconnected < DC_GRACE_MS ago
727+
*/
728+
private shouldSuppressPush(): boolean {
729+
const now = Date.now();
730+
731+
// 1 + 2: scan connected browser sockets
732+
const sockets = this.state.getWebSockets();
733+
for (const s of sockets) {
734+
const att = s.deserializeAttachment() as BrowserAttachment | null;
735+
if (!att || !att.tag?.startsWith("browser:") || !att.authenticated) continue;
736+
if (att.foreground) return true;
737+
if (att.backgroundAt && now - att.backgroundAt < BG_GRACE_MS) return true;
738+
}
739+
740+
// 3: recently disconnected sessions
741+
for (const [tag, disconnectedAt] of this.recentDisconnects) {
742+
if (now - disconnectedAt < DC_GRACE_MS) return true;
743+
// Expired — prune
744+
this.recentDisconnects.delete(tag);
745+
}
746+
747+
return false;
748+
}
749+
671750
// ---- Push notifications ----
672751

673752
/**

packages/web/src/App.tsx

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import { getToken, setToken, setRefreshToken, agentsApi, channelsApi, tasksApi,
1414
import { ModelSelect } from "./components/ModelSelect";
1515
import { BotsChatWSClient, type WSMessage } from "./ws";
1616
import { initPushNotifications, getPendingPushNav, clearPendingPushNav } from "./push";
17-
import { setupForegroundDetection } from "./foreground";
17+
import { setupForegroundDetection, sendFocusUpdate } from "./foreground";
1818
import { IconRail } from "./components/IconRail";
1919
import { Sidebar } from "./components/Sidebar";
2020
import { ChatWindow } from "./components/ChatWindow";
@@ -530,6 +530,14 @@ export default function App() {
530530
return () => { stale = true; };
531531
}, [state.user, state.selectedSessionKey, e2eReady, foregroundResumeCount]);
532532

533+
// ---- Notify ConnectionDO when the viewed session changes ----
534+
useEffect(() => {
535+
const client = wsClientRef.current;
536+
if (client?.connected) {
537+
sendFocusUpdate(client, state.selectedSessionKey);
538+
}
539+
}, [state.selectedSessionKey]);
540+
533541
// Keep a ref to state for use in WS handler (avoids stale closures)
534542
const stateRef = useRef(state);
535543
useEffect(() => {
@@ -903,8 +911,10 @@ export default function App() {
903911
.catch((err) => {
904912
dlog.warn("Push", `Push init failed: ${err}`);
905913
});
906-
const cleanupForeground = setupForegroundDetection(client, () => {
907-
setForegroundResumeCount((c) => c + 1);
914+
const cleanupForeground = setupForegroundDetection({
915+
wsClient: client,
916+
getActiveSessionKey: () => stateRef.current.selectedSessionKey,
917+
onResume: () => setForegroundResumeCount((c) => c + 1),
908918
});
909919

910920
return () => {

packages/web/src/foreground.ts

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,37 @@
11
/**
2-
* Foreground/background detection — notifies the ConnectionDO via WebSocket
3-
* so it knows whether to send push notifications.
2+
* Foreground/background detection & channel-level focus tracking.
3+
*
4+
* Notifies the ConnectionDO via WebSocket so it knows whether to send push
5+
* notifications and which session the user is currently viewing.
46
*/
57

68
import { Capacitor } from "@capacitor/core";
79
import type { BotsChatWSClient } from "./ws";
810
import { dlog } from "./debug-log";
911

10-
export function setupForegroundDetection(
12+
export interface ForegroundOptions {
13+
wsClient: BotsChatWSClient;
14+
getActiveSessionKey: () => string | null;
15+
onResume?: () => void;
16+
}
17+
18+
/**
19+
* Send a focus.update message when the user switches channels/sessions
20+
* while already in the foreground.
21+
*/
22+
export function sendFocusUpdate(
1123
wsClient: BotsChatWSClient,
12-
onResume?: () => void,
13-
): () => void {
24+
sessionKey: string | null,
25+
): void {
26+
wsClient.send({ type: "focus.update", sessionKey });
27+
dlog.info("Foreground", `Focus updated: ${sessionKey ?? "(none)"}`);
28+
}
29+
30+
export function setupForegroundDetection(opts: ForegroundOptions): () => void {
31+
const { wsClient, getActiveSessionKey, onResume } = opts;
32+
1433
const notifyForeground = () => {
15-
wsClient.send({ type: "foreground.enter" });
34+
wsClient.send({ type: "foreground.enter", sessionKey: getActiveSessionKey() });
1635
onResume?.();
1736
dlog.info("Foreground", "Entered foreground");
1837
};

0 commit comments

Comments
 (0)