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
+12 -1
View File
@@ -9,12 +9,20 @@ import {
} from "react-native-safe-area-context";
import { Toaster } from "sonner-native";
import { createQueryClient } from "@/lib/query-client";
import {
flushPendingNavigation,
navigationRef,
} from "@/lib/notification-routing";
import { configureNotifications } from "@/lib/push-notifications";
import { PusherProvider } from "@/lib/pusher-provider";
import { RootNavigator } from "@/navigation/RootNavigator";
import { useAuthStore } from "@/stores/auth-store";
const queryClient = createQueryClient();
// One-time setup: foreground handler + push-token rotation listener. Idempotent.
configureNotifications();
export default function App() {
const restoreSession = useAuthStore((s) => s.restoreSession);
@@ -27,7 +35,10 @@ export default function App() {
<QueryClientProvider client={queryClient}>
<PusherProvider>
<SafeAreaProvider initialMetrics={initialWindowMetrics}>
<NavigationContainer>
<NavigationContainer
ref={navigationRef}
onReady={flushPendingNavigation}
>
<RootNavigator />
</NavigationContainer>
<Toaster />
+14
View File
@@ -139,6 +139,20 @@ class ApiClient {
await this.requestVoid("PATCH", "/humans/me/settings", data);
}
// --- Push notification tokens ---
async registerPushToken(data: {
token: string;
platform: "ios" | "android";
app_version: string;
}): Promise<void> {
await this.requestVoid("POST", "/humans/me/push-tokens", data);
}
async unregisterPushToken(token: string): Promise<void> {
await this.requestVoid("DELETE", "/humans/me/push-tokens", { token });
}
// --- Depot ---
async prepareUpload(data: PrepareUploadRequest) {
+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;
}
+9
View File
@@ -7,6 +7,10 @@ import { apiClient } from "@/api/client";
import type { Human } from "@/api/types";
import { firebaseAuth } from "@/firebase";
import { logError, ApiError } from "@/lib/errors";
import {
syncPushToken,
unregisterPushToken,
} from "@/lib/push-notifications";
import { hydrateSession, useSessionStore } from "./session-store";
async function signInToFirebase(): Promise<void> {
@@ -60,6 +64,7 @@ export const useAuthStore = create<AuthState>((set) => ({
const user = await apiClient.me();
await signInToFirebase();
set({ status: "authenticated", user });
void syncPushToken();
} catch (err) {
// Expected on expired/invalid tokens — fall back to the login screen.
logError(err, { scope: "auth.restore" });
@@ -89,6 +94,7 @@ export const useAuthStore = create<AuthState>((set) => ({
await useSessionStore.getState().setToken(token);
await signInToFirebase();
set({ status: "authenticated", user: human });
void syncPushToken();
} catch (e) {
const message = e instanceof ApiError ? e.message : "Failed to sign in";
set({ error: message });
@@ -100,6 +106,9 @@ export const useAuthStore = create<AuthState>((set) => ({
signOut: async () => {
set({ isSigningOut: true });
// Unregister the push token first — once the session token is cleared the
// backend call would 401. Best-effort: failures must not block sign-out.
await unregisterPushToken();
try {
await apiClient.signOut();
} catch (err) {