Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions apps/mobile/src/app/task/[id].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down
24 changes: 18 additions & 6 deletions apps/mobile/src/features/notifications/stores/pushTokenStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>;
Expand Down Expand Up @@ -44,15 +49,17 @@ async function writeSecure(key: string, value: string | null): Promise<void> {
export const usePushTokenStore = create<PushTokenState>((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 () => {
Expand All @@ -66,12 +73,16 @@ export const usePushTokenStore = create<PushTokenState>((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
Expand All @@ -94,7 +105,8 @@ export const usePushTokenStore = create<PushTokenState>((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 });
},
}));
62 changes: 62 additions & 0 deletions apps/mobile/src/features/tasks/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,68 @@ export async function getTask(taskId: string): Promise<Task> {
return await parseJsonResponse<Task>(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<void> {
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<void> {
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<TaskAutomation[]> {
const baseUrl = getBaseUrl();
const projectId = getProjectId();
Expand Down
104 changes: 104 additions & 0 deletions apps/mobile/src/features/tasks/hooks/useTaskPresence.ts
Original file line number Diff line number Diff line change
@@ -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<string | null>(null);
const deviceIdRef = useRef<string | null>(deviceId);
deviceIdRef.current = deviceId;

useEffect(() => {
if (!taskId || !deviceId) {
currentTaskIdRef.current = null;
return;
}
currentTaskIdRef.current = taskId;

let cancelled = false;
let intervalHandle: ReturnType<typeof setInterval> | 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]);
}
8 changes: 7 additions & 1 deletion apps/mobile/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ export function createTimeoutSignal(ms: number): AbortSignal {
export async function registerPushToken(args: {
token: string;
platform: string;
}): Promise<void> {
}): Promise<{ id: string }> {
const baseUrl = getBaseUrl();
const headers = getHeaders();

Expand All @@ -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<void> {
Expand Down
Loading