Mobile notifications for iOS (#210)

* mobile: wire notification registration and listener

* implement backend components for push notifications

* refactor: agentic comment cleanup

* docs: use proper module name for particle processor

* set required env variables for push notifications

* bump version

* fix: always upsert push token on mobile start

* Revert "fix: always upsert push token on mobile start"

This reverts commit 90ff18a788.

* send push notifications regardless of online status
This commit was merged in pull request #210.
This commit is contained in:
Arjun Patel
2026-05-18 12:44:31 -07:00
committed by GitHub
parent a564ea819b
commit d262f734f0
61 changed files with 1682 additions and 531 deletions
+73
View File
@@ -0,0 +1,73 @@
import { createNavigationContainerRef } from "@react-navigation/native";
import type { Notification } from "expo-notifications";
import { logError } from "@/lib/errors";
// Shared ref so non-component code (notification handlers, deep links) can
// drive navigation without prop-drilling. Typed via the global
// ReactNavigation.RootParamList augmentation in navigation/types.ts.
export const navigationRef = createNavigationContainerRef();
// Shape the worker (go/internal/human/pushnotify/notifier.go::buildMessages)
// puts in `Notifications.notification.request.content.data`.
type ParticleCreatedData = {
kind: "particle_created";
network_id: string;
stream_id: string;
particle_id: string;
sender_human_id: string;
particle_kind: string;
};
function isParticleCreatedData(data: unknown): data is ParticleCreatedData {
return (
typeof data === "object" &&
data !== null &&
(data as { kind?: unknown }).kind === "particle_created" &&
typeof (data as { network_id?: unknown }).network_id === "string" &&
typeof (data as { stream_id?: unknown }).stream_id === "string"
);
}
// If a tap arrives before the navigator has mounted (cold start), stash it and
// replay as soon as the container reports ready.
let pendingNavigation: ParticleCreatedData | null = null;
/**
* Routes a single notification tap to the appropriate screen. Safe to call
* before the navigation container is ready — it queues the route and replays
* it once `navigationRef.isReady()` flips true.
*/
export function routeNotificationTap(notification: Notification): void {
try {
const data = notification.request.content.data;
if (!isParticleCreatedData(data)) return;
if (!navigationRef.isReady()) {
pendingNavigation = data;
return;
}
navigateToStream(data);
} catch (err) {
logError(err, { scope: "push.route" });
}
}
/**
* Called once by App.tsx when the NavigationContainer mounts. Drains any
* cold-start tap that arrived before navigation was ready.
*/
export function flushPendingNavigation(): void {
if (!pendingNavigation) return;
const data = pendingNavigation;
pendingNavigation = null;
if (navigationRef.isReady()) {
navigateToStream(data);
}
}
function navigateToStream(data: ParticleCreatedData): void {
navigationRef.navigate("StreamView", {
networkId: data.network_id,
streamId: data.stream_id,
});
}
+160
View File
@@ -0,0 +1,160 @@
import Constants from "expo-constants";
import * as Device from "expo-device";
import * as Notifications from "expo-notifications";
import * as SecureStore from "expo-secure-store";
import { Platform } from "react-native";
import { apiClient } from "@/api/client";
import { logError } from "@/lib/errors";
import { routeNotificationTap } from "@/lib/notification-routing";
const STORED_TOKEN_KEY = "expo_push_token";
let configured = false;
let tokenListenerSubscription: Notifications.Subscription | null = null;
/**
* Sets the foreground notification handler so banners show while the app is
* open, and subscribes to Expo's token-rotation listener so the backend stays
* in sync without the user needing to re-launch. Safe to call multiple times.
*/
export function configureNotifications(): void {
if (configured) return;
configured = true;
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowBanner: true,
shouldShowList: true,
shouldPlaySound: true,
shouldSetBadge: false,
}),
});
tokenListenerSubscription = Notifications.addPushTokenListener((event) => {
// Token rotated server-side by Expo or APNs. Sync immediately so we don't
// keep pushing to a dead token.
void syncPushToken(event.data);
});
// Warm-state taps (app in background or foreground). Cold-start taps are
// drained separately via getLastNotificationResponseAsync; see
// flushPendingNavigation in notification-routing.ts.
Notifications.addNotificationResponseReceivedListener((response) => {
routeNotificationTap(response.notification);
});
void Notifications.getLastNotificationResponseAsync().then((response) => {
if (response) routeNotificationTap(response.notification);
});
}
/**
* Acquires (or returns the cached) Expo push token for this device. Returns
* null on simulators, when permission is denied, or when any step fails — the
* caller should treat that as "no push, no further action".
*/
async function acquirePushToken(): Promise<string | null> {
if (!Device.isDevice) return null;
const existing = await Notifications.getPermissionsAsync();
let status = existing.status;
if (status !== "granted") {
const requested = await Notifications.requestPermissionsAsync();
status = requested.status;
}
if (status !== "granted") return null;
const projectId =
Constants.expoConfig?.extra?.eas?.projectId ??
Constants.easConfig?.projectId;
if (!projectId) {
logError(new Error("EAS projectId missing — cannot fetch push token"), {
scope: "push.acquire",
});
return null;
}
const tokenResult = await Notifications.getExpoPushTokenAsync({ projectId });
return tokenResult.data;
}
async function getStoredToken(): Promise<string | null> {
try {
return await SecureStore.getItemAsync(STORED_TOKEN_KEY);
} catch {
return null;
}
}
async function setStoredToken(token: string): Promise<void> {
try {
await SecureStore.setItemAsync(STORED_TOKEN_KEY, token);
} catch (err) {
logError(err, { scope: "push.store" });
}
}
async function clearStoredToken(): Promise<void> {
try {
await SecureStore.deleteItemAsync(STORED_TOKEN_KEY);
} catch {
// ignore
}
}
/**
* Compares the freshly-fetched token to whatever we last sent to Orion and
* only POSTs on a delta. Never throws — push registration is best-effort and
* must never block the auth path.
*/
export async function syncPushToken(token?: string | null): Promise<void> {
try {
const next = token ?? (await acquirePushToken());
if (!next) return;
const stored = await getStoredToken();
if (stored === next) return;
const platform = Platform.OS === "ios" ? "ios" : "android";
const appVersion = Constants.expoConfig?.version ?? "";
await apiClient.registerPushToken({
token: next,
platform,
app_version: appVersion,
});
await setStoredToken(next);
} catch (err) {
logError(err, { scope: "push.sync" });
}
}
/**
* Best-effort unregister at sign-out. Wipes the stored token even if the
* server call fails so the next signed-in user re-registers cleanly.
*/
export async function unregisterPushToken(): Promise<void> {
try {
const stored = await getStoredToken();
if (stored) {
try {
await apiClient.unregisterPushToken(stored);
} catch (err) {
logError(err, { scope: "push.unregister" });
}
}
} finally {
await clearStoredToken();
}
}
/**
* Test-only: tears down the module-level token listener. Not normally needed
* in the app lifecycle — Notifications subscriptions live as long as the JS
* runtime does.
*/
export function _resetPushNotificationsModule(): void {
tokenListenerSubscription?.remove();
tokenListenerSubscription = null;
configured = false;
}