feat: add presence-gated Expo push notifications
This commit is contained in:
@@ -3,6 +3,7 @@ import { AppState, Platform } from "react-native";
|
||||
import type { DaemonClientV2 } from "@server/client/daemon-client-v2";
|
||||
|
||||
const HEARTBEAT_INTERVAL_MS = 15_000;
|
||||
const ACTIVITY_HEARTBEAT_THROTTLE_MS = 5_000;
|
||||
|
||||
interface ClientActivityOptions {
|
||||
client: DaemonClientV2;
|
||||
@@ -12,14 +13,15 @@ interface ClientActivityOptions {
|
||||
/**
|
||||
* Handles client activity reporting:
|
||||
* - Heartbeat sending every 15 seconds
|
||||
* - App visibility tracking (updates lastActivityAt on foreground)
|
||||
* - Sends heartbeat immediately when focused agent changes
|
||||
* - App visibility tracking
|
||||
* - Records lastActivityAt only on real user activity (not on heartbeat)
|
||||
*/
|
||||
export function useClientActivity({ client, focusedAgentId }: ClientActivityOptions): void {
|
||||
const lastActivityAtRef = useRef<Date>(new Date());
|
||||
const appVisibleRef = useRef(AppState.currentState === "active");
|
||||
const heartbeatIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const prevFocusedAgentIdRef = useRef<string | null>(focusedAgentId);
|
||||
const lastImmediateHeartbeatAtRef = useRef<number>(0);
|
||||
|
||||
const deviceType = Platform.OS === "web" ? "web" : "mobile";
|
||||
|
||||
@@ -28,17 +30,7 @@ export function useClientActivity({ client, focusedAgentId }: ClientActivityOpti
|
||||
}, []);
|
||||
|
||||
const sendHeartbeat = useCallback(() => {
|
||||
if (!client.isConnected) {
|
||||
console.log("[ClientActivity] sendHeartbeat skipped - not connected");
|
||||
return;
|
||||
}
|
||||
lastActivityAtRef.current = new Date();
|
||||
console.log("[ClientActivity] sendHeartbeat", {
|
||||
deviceType,
|
||||
focusedAgentId,
|
||||
lastActivityAt: lastActivityAtRef.current.toISOString(),
|
||||
appVisible: appVisibleRef.current,
|
||||
});
|
||||
if (!client.isConnected) return;
|
||||
client.sendHeartbeat({
|
||||
deviceType,
|
||||
focusedAgentId,
|
||||
@@ -47,35 +39,77 @@ export function useClientActivity({ client, focusedAgentId }: ClientActivityOpti
|
||||
});
|
||||
}, [client, deviceType, focusedAgentId]);
|
||||
|
||||
const maybeSendImmediateHeartbeat = useCallback(() => {
|
||||
if (!client.isConnected) return;
|
||||
const now = Date.now();
|
||||
if (now - lastImmediateHeartbeatAtRef.current < ACTIVITY_HEARTBEAT_THROTTLE_MS) {
|
||||
return;
|
||||
}
|
||||
lastImmediateHeartbeatAtRef.current = now;
|
||||
sendHeartbeat();
|
||||
}, [client, sendHeartbeat]);
|
||||
|
||||
// Track app visibility
|
||||
useEffect(() => {
|
||||
console.log("[ClientActivity] AppState effect mounted, current:", AppState.currentState);
|
||||
const subscription = AppState.addEventListener("change", (nextState) => {
|
||||
console.log("[ClientActivity] AppState changed:", nextState);
|
||||
appVisibleRef.current = nextState === "active";
|
||||
if (nextState === "active") {
|
||||
recordUserActivity();
|
||||
maybeSendImmediateHeartbeat();
|
||||
}
|
||||
});
|
||||
|
||||
return () => subscription.remove();
|
||||
}, [recordUserActivity]);
|
||||
}, [maybeSendImmediateHeartbeat, recordUserActivity]);
|
||||
|
||||
// Track user activity on web for accurate staleness.
|
||||
useEffect(() => {
|
||||
if (Platform.OS !== "web") return;
|
||||
if (typeof document === "undefined") return;
|
||||
|
||||
const handleUserActivity = () => {
|
||||
recordUserActivity();
|
||||
maybeSendImmediateHeartbeat();
|
||||
};
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
const visible = document.visibilityState === "visible";
|
||||
appVisibleRef.current = visible;
|
||||
if (visible) {
|
||||
recordUserActivity();
|
||||
maybeSendImmediateHeartbeat();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
window.addEventListener("focus", handleUserActivity);
|
||||
window.addEventListener("pointerdown", handleUserActivity, { passive: true });
|
||||
window.addEventListener("keydown", handleUserActivity);
|
||||
window.addEventListener("wheel", handleUserActivity, { passive: true });
|
||||
window.addEventListener("touchstart", handleUserActivity, { passive: true });
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
window.removeEventListener("focus", handleUserActivity);
|
||||
window.removeEventListener("pointerdown", handleUserActivity);
|
||||
window.removeEventListener("keydown", handleUserActivity);
|
||||
window.removeEventListener("wheel", handleUserActivity);
|
||||
window.removeEventListener("touchstart", handleUserActivity);
|
||||
};
|
||||
}, [maybeSendImmediateHeartbeat, recordUserActivity]);
|
||||
|
||||
// Send heartbeat on focused agent change
|
||||
useEffect(() => {
|
||||
if (prevFocusedAgentIdRef.current !== focusedAgentId) {
|
||||
console.log("[ClientActivity] focusedAgentId changed:", prevFocusedAgentIdRef.current, "->", focusedAgentId);
|
||||
prevFocusedAgentIdRef.current = focusedAgentId;
|
||||
recordUserActivity();
|
||||
sendHeartbeat();
|
||||
}
|
||||
}, [focusedAgentId, sendHeartbeat]);
|
||||
}, [focusedAgentId, recordUserActivity, sendHeartbeat]);
|
||||
|
||||
// Periodic heartbeat
|
||||
useEffect(() => {
|
||||
console.log("[ClientActivity] Heartbeat effect mounted, isConnected:", client.isConnected);
|
||||
|
||||
const startHeartbeat = () => {
|
||||
console.log("[ClientActivity] startHeartbeat called");
|
||||
if (heartbeatIntervalRef.current) {
|
||||
clearInterval(heartbeatIntervalRef.current);
|
||||
}
|
||||
@@ -84,7 +118,6 @@ export function useClientActivity({ client, focusedAgentId }: ClientActivityOpti
|
||||
};
|
||||
|
||||
const stopHeartbeat = () => {
|
||||
console.log("[ClientActivity] stopHeartbeat called");
|
||||
if (heartbeatIntervalRef.current) {
|
||||
clearInterval(heartbeatIntervalRef.current);
|
||||
heartbeatIntervalRef.current = null;
|
||||
@@ -92,7 +125,6 @@ export function useClientActivity({ client, focusedAgentId }: ClientActivityOpti
|
||||
};
|
||||
|
||||
const unsubscribe = client.subscribeConnectionStatus((state) => {
|
||||
console.log("[ClientActivity] Connection status changed:", state.status);
|
||||
if (state.status === "connected") {
|
||||
startHeartbeat();
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { Platform } from "react-native";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import * as Notifications from "expo-notifications";
|
||||
import Constants from "expo-constants";
|
||||
import type { DaemonClientV2 } from "@server/client/daemon-client-v2";
|
||||
|
||||
const STORAGE_PREFIX = "@paseo:expo-push-token:";
|
||||
|
||||
function getExpoProjectId(): string | null {
|
||||
const fromEas = (Constants as any)?.easConfig?.projectId;
|
||||
if (typeof fromEas === "string" && fromEas.trim()) return fromEas.trim();
|
||||
|
||||
const fromExtra = (Constants as any)?.expoConfig?.extra?.eas?.projectId;
|
||||
if (typeof fromExtra === "string" && fromExtra.trim()) return fromExtra.trim();
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function ensurePushPermission(): Promise<boolean> {
|
||||
const existing = await Notifications.getPermissionsAsync();
|
||||
if (existing.status === "granted") return true;
|
||||
if (!existing.canAskAgain) return false;
|
||||
const requested = await Notifications.requestPermissionsAsync();
|
||||
return requested.status === "granted";
|
||||
}
|
||||
|
||||
export function usePushTokenRegistration(params: {
|
||||
client: DaemonClientV2;
|
||||
serverId: string;
|
||||
}): void {
|
||||
const { client, serverId } = params;
|
||||
const tokenRef = useRef<string | null>(null);
|
||||
const lastSentTokenRef = useRef<string | null>(null);
|
||||
|
||||
const registerIfPossible = useCallback(async () => {
|
||||
if (Platform.OS === "web") return;
|
||||
if (!client.isConnected) return;
|
||||
const token = tokenRef.current;
|
||||
if (!token) return;
|
||||
if (lastSentTokenRef.current === token) return;
|
||||
lastSentTokenRef.current = token;
|
||||
client.registerPushToken(token);
|
||||
}, [client]);
|
||||
|
||||
useEffect(() => {
|
||||
if (Platform.OS === "web") return;
|
||||
|
||||
const storageKey = `${STORAGE_PREFIX}${serverId}`;
|
||||
let cancelled = false;
|
||||
|
||||
const run = async () => {
|
||||
const cached = await AsyncStorage.getItem(storageKey);
|
||||
if (cancelled) return;
|
||||
if (cached && typeof cached === "string") {
|
||||
tokenRef.current = cached;
|
||||
}
|
||||
|
||||
const granted = await ensurePushPermission();
|
||||
if (!granted || cancelled) return;
|
||||
|
||||
if (Platform.OS === "android") {
|
||||
await Notifications.setNotificationChannelAsync("default", {
|
||||
name: "default",
|
||||
importance: Notifications.AndroidImportance.DEFAULT,
|
||||
});
|
||||
}
|
||||
|
||||
const projectId = getExpoProjectId();
|
||||
if (!projectId) {
|
||||
console.warn("[PushToken] Missing EAS projectId; cannot fetch Expo push token");
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await Notifications.getExpoPushTokenAsync({ projectId });
|
||||
if (cancelled) return;
|
||||
|
||||
const token = result.data;
|
||||
if (typeof token !== "string" || !token.trim()) return;
|
||||
|
||||
tokenRef.current = token;
|
||||
await AsyncStorage.setItem(storageKey, token);
|
||||
await registerIfPossible();
|
||||
};
|
||||
|
||||
void run().catch((error) => {
|
||||
console.warn("[PushToken] Failed to register push token", error);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [registerIfPossible, serverId]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = client.subscribeConnectionStatus((state) => {
|
||||
if (state.status === "connected") {
|
||||
void registerIfPossible();
|
||||
} else {
|
||||
// Re-register on the next successful connect.
|
||||
lastSentTokenRef.current = null;
|
||||
}
|
||||
});
|
||||
if (client.isConnected) {
|
||||
void registerIfPossible();
|
||||
}
|
||||
return unsubscribe;
|
||||
}, [client, registerIfPossible]);
|
||||
}
|
||||
Reference in New Issue
Block a user