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:
+12
-1
@@ -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 />
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user