diff --git a/apps/mobile/src/app/task/[id].tsx b/apps/mobile/src/app/task/[id].tsx index 53de78337..bef450392 100644 --- a/apps/mobile/src/app/task/[id].tsx +++ b/apps/mobile/src/app/task/[id].tsx @@ -31,6 +31,7 @@ import { type ReasoningEffort, } from "@/features/tasks/composer/options"; import { TaskChatComposer } from "@/features/tasks/composer/TaskChatComposer"; +import { useTaskPresence } from "@/features/tasks/hooks/useTaskPresence"; import { taskKeys } from "@/features/tasks/hooks/useTasks"; import { useTaskSessionStore } from "@/features/tasks/stores/taskSessionStore"; import { useTaskStore } from "@/features/tasks/stores/taskStore"; @@ -77,6 +78,12 @@ export default function TaskDetailScreen() { setFocusedTaskId, } = useTaskSessionStore(); + // Beacon presence to the server while this screen is open + foregrounded. + // The server suppresses push notifications about this task to other + // registered devices for the same user — kills the "desktop is open, mobile + // still rings" noise. + useTaskPresence(taskId); + useEffect(() => { if (!taskId) return; setFocusedTaskId(taskId); diff --git a/apps/mobile/src/features/notifications/stores/pushTokenStore.ts b/apps/mobile/src/features/notifications/stores/pushTokenStore.ts index 4f334f258..289ab2038 100644 --- a/apps/mobile/src/features/notifications/stores/pushTokenStore.ts +++ b/apps/mobile/src/features/notifications/stores/pushTokenStore.ts @@ -9,10 +9,15 @@ const log = logger.scope("push-token-store"); const TOKEN_KEY = "posthog_expo_push_token"; const LAST_UPLOADED_KEY = "posthog_expo_push_token_uploaded"; +// Server-side `UserPushToken.id` (UUID). Used as the `device_id` for the +// task presence beacon — the server resolves it back to the user's token row +// so we don't have to invent our own opaque identifier. +const DEVICE_ID_KEY = "posthog_push_token_device_id"; interface PushTokenState { expoPushToken: string | null; lastUploadedToken: string | null; + deviceId: string | null; isHydrated: boolean; hydrate: () => Promise; @@ -44,15 +49,17 @@ async function writeSecure(key: string, value: string | null): Promise { export const usePushTokenStore = create((set, get) => ({ expoPushToken: null, lastUploadedToken: null, + deviceId: null, isHydrated: false, hydrate: async () => { if (get().isHydrated) return; - const [expoPushToken, lastUploadedToken] = await Promise.all([ + const [expoPushToken, lastUploadedToken, deviceId] = await Promise.all([ readSecure(TOKEN_KEY), readSecure(LAST_UPLOADED_KEY), + readSecure(DEVICE_ID_KEY), ]); - set({ expoPushToken, lastUploadedToken, isHydrated: true }); + set({ expoPushToken, lastUploadedToken, deviceId, isHydrated: true }); }, registerAndUpload: async () => { @@ -66,12 +73,16 @@ export const usePushTokenStore = create((set, get) => ({ set({ expoPushToken: token }); } - if (token === get().lastUploadedToken) return; + if (token === get().lastUploadedToken && get().deviceId) return; try { - await registerPushToken({ token, platform: Platform.OS }); + const { id } = await registerPushToken({ token, platform: Platform.OS }); await writeSecure(LAST_UPLOADED_KEY, token); - set({ lastUploadedToken: token }); + const nextDeviceId = id || null; + if (nextDeviceId !== get().deviceId) { + await writeSecure(DEVICE_ID_KEY, nextDeviceId); + } + set({ lastUploadedToken: token, deviceId: nextDeviceId }); } catch (err) { // Surface as warn so a misconfigured OAuth scope or backend regression // doesn't fail silently — push notifications won't work until this row @@ -94,7 +105,8 @@ export const usePushTokenStore = create((set, get) => ({ await Promise.all([ writeSecure(TOKEN_KEY, null), writeSecure(LAST_UPLOADED_KEY, null), + writeSecure(DEVICE_ID_KEY, null), ]); - set({ expoPushToken: null, lastUploadedToken: null }); + set({ expoPushToken: null, lastUploadedToken: null, deviceId: null }); }, })); diff --git a/apps/mobile/src/features/tasks/api.ts b/apps/mobile/src/features/tasks/api.ts index d09d2afc5..ea5175112 100644 --- a/apps/mobile/src/features/tasks/api.ts +++ b/apps/mobile/src/features/tasks/api.ts @@ -178,6 +178,68 @@ export async function getTask(taskId: string): Promise { return await parseJsonResponse(response); } +/** + * Claim presence on a task for this device. The server suppresses push + * notifications about this task to any device with a non-expired presence + * row (TTL ~60s) — so we keep posting on a 30s cadence while the user is + * actively viewing the task. + * + * `deviceId` is the UUID returned by `registerPushToken` (the + * `UserPushToken.id` row on the server). Failures are non-fatal: this is + * notification routing, not core functionality. + */ +export async function postTaskPresence( + taskId: string, + deviceId: string, +): Promise { + const baseUrl = getBaseUrl(); + const projectId = getProjectId(); + const headers = getHeaders(); + + const response = await fetch( + `${baseUrl}/api/projects/${projectId}/tasks/${taskId}/presence/`, + { + method: "POST", + headers, + body: JSON.stringify({ device_id: deviceId }), + }, + ); + + if (!response.ok) { + throw new HttpError( + response.status, + response.statusText, + "Failed to claim task presence", + ); + } +} + +export async function deleteTaskPresence( + taskId: string, + deviceId: string, +): Promise { + const baseUrl = getBaseUrl(); + const projectId = getProjectId(); + const headers = getHeaders(); + + const response = await fetch( + `${baseUrl}/api/projects/${projectId}/tasks/${taskId}/presence/`, + { + method: "DELETE", + headers, + body: JSON.stringify({ device_id: deviceId }), + }, + ); + + if (!response.ok) { + throw new HttpError( + response.status, + response.statusText, + "Failed to release task presence", + ); + } +} + export async function getTaskAutomations(): Promise { const baseUrl = getBaseUrl(); const projectId = getProjectId(); diff --git a/apps/mobile/src/features/tasks/hooks/useTaskPresence.ts b/apps/mobile/src/features/tasks/hooks/useTaskPresence.ts new file mode 100644 index 000000000..7bb90d2e5 --- /dev/null +++ b/apps/mobile/src/features/tasks/hooks/useTaskPresence.ts @@ -0,0 +1,104 @@ +import { useEffect, useRef } from "react"; +import { AppState, type AppStateStatus } from "react-native"; +import { usePushTokenStore } from "@/features/notifications/stores/pushTokenStore"; +import { logger } from "@/lib/logger"; +import { deleteTaskPresence, postTaskPresence } from "../api"; + +const log = logger.scope("task-presence"); + +// Server-side TTL is 60s; refresh well before expiry so the suppression +// window never gaps. Keep it short enough that briefly putting the phone +// down doesn't drop us out of "active" before we re-beacon on resume. +const PRESENCE_REFRESH_INTERVAL_MS = 30_000; + +/** + * Beacons "this device is actively viewing task X" to the server while the + * task screen is mounted and the app is foregrounded. The server suppresses + * push fanout to devices with active presence — so opening a task on mobile + * stops the user's desktop / other phone from also ringing. + * + * Behaviour: + * - On mount (and on AppState transitions back to "active"): POST. + * - Every PRESENCE_REFRESH_INTERVAL_MS while mounted + active: POST again. + * - On AppState going inactive/background, on unmount, on taskId change: + * DELETE. + * + * Failures are intentionally swallowed — this is notification routing, not + * functional behaviour. Worst case the user gets a duplicate notification. + */ +export function useTaskPresence(taskId: string | null | undefined): void { + const deviceId = usePushTokenStore((s) => s.deviceId); + // We also wait for hydration so we don't miss the device_id on a cold + // start where the screen mounts before SecureStore reads complete. + const hydrate = usePushTokenStore((s) => s.hydrate); + const isHydrated = usePushTokenStore((s) => s.isHydrated); + + useEffect(() => { + if (!isHydrated) { + hydrate().catch(() => {}); + } + }, [isHydrated, hydrate]); + + // Stable ref tracking so the active-AppState handler can read the latest + // taskId/deviceId without resubscribing on every render. + const currentTaskIdRef = useRef(null); + const deviceIdRef = useRef(deviceId); + deviceIdRef.current = deviceId; + + useEffect(() => { + if (!taskId || !deviceId) { + currentTaskIdRef.current = null; + return; + } + currentTaskIdRef.current = taskId; + + let cancelled = false; + let intervalHandle: ReturnType | null = null; + + const beacon = () => { + if (cancelled) return; + postTaskPresence(taskId, deviceId).catch((err) => { + log.debug("postTaskPresence failed", { taskId, error: err }); + }); + }; + + const startBeacon = () => { + if (intervalHandle) return; + beacon(); + intervalHandle = setInterval(beacon, PRESENCE_REFRESH_INTERVAL_MS); + }; + + const stopBeacon = (release: boolean) => { + if (intervalHandle) { + clearInterval(intervalHandle); + intervalHandle = null; + } + if (release) { + deleteTaskPresence(taskId, deviceId).catch((err) => { + log.debug("deleteTaskPresence failed", { taskId, error: err }); + }); + } + }; + + if (AppState.currentState === "active") { + startBeacon(); + } + + const subscription = AppState.addEventListener( + "change", + (next: AppStateStatus) => { + if (next === "active") { + startBeacon(); + } else { + stopBeacon(true); + } + }, + ); + + return () => { + cancelled = true; + subscription.remove(); + stopBeacon(true); + }; + }, [taskId, deviceId]); +} diff --git a/apps/mobile/src/lib/api.ts b/apps/mobile/src/lib/api.ts index 0aaa6d5dc..901c4d8c4 100644 --- a/apps/mobile/src/lib/api.ts +++ b/apps/mobile/src/lib/api.ts @@ -60,7 +60,7 @@ export function createTimeoutSignal(ms: number): AbortSignal { export async function registerPushToken(args: { token: string; platform: string; -}): Promise { +}): Promise<{ id: string }> { const baseUrl = getBaseUrl(); const headers = getHeaders(); @@ -85,6 +85,12 @@ export async function registerPushToken(args: { `registerPushToken failed: ${response.status} ${response.statusText} — ${body.slice(0, 200)}`, ); } + + // The `id` is the UserPushToken UUID, used as `device_id` for the task + // presence beacon. Returning a sentinel rather than throwing on a missing + // field — older server versions don't return the body shape. + const body = (await response.json().catch(() => ({}))) as { id?: string }; + return { id: typeof body.id === "string" ? body.id : "" }; } export async function deletePushToken(args: { token: string }): Promise {