@@ -13,9 +13,12 @@ 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.
|
||||
* Sets the foreground notification handler and tap routing. Safe to call
|
||||
* multiple times. Does NOT subscribe to Expo's token-rotation listener —
|
||||
* that's the auth store's job via {@link startPushTokenSync}, so token sync
|
||||
* only runs while a session is active. Subscribing here at module scope used
|
||||
* to fire `apiClient.registerPushToken` before SecureStore hydration completed
|
||||
* and silently invalidated the user's real session.
|
||||
*/
|
||||
export function configureNotifications(): void {
|
||||
if (configured) return;
|
||||
@@ -30,12 +33,6 @@ export function configureNotifications(): void {
|
||||
}),
|
||||
});
|
||||
|
||||
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.
|
||||
@@ -48,6 +45,27 @@ export function configureNotifications(): void {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes to Expo's token-rotation listener. Called by the auth store
|
||||
* after a successful sign-in or session restore so rotation events only fire
|
||||
* `syncPushToken` while authenticated. Idempotent.
|
||||
*/
|
||||
export function startPushTokenSync(): void {
|
||||
if (tokenListenerSubscription) return;
|
||||
tokenListenerSubscription = Notifications.addPushTokenListener((event) => {
|
||||
void syncPushToken(event.data);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Tears down the rotation listener. Called by the auth store on sign-out and
|
||||
* session invalidation. Idempotent.
|
||||
*/
|
||||
export function stopPushTokenSync(): void {
|
||||
tokenListenerSubscription?.remove();
|
||||
tokenListenerSubscription = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
@@ -7,14 +7,14 @@ import {
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { PusherClient, type ConnectionState } from "./pusher-client";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { appConfig } from "@/config/env";
|
||||
|
||||
const PusherContext = createContext<PusherClient | null>(null);
|
||||
const PusherStateContext = createContext<ConnectionState>("disconnected");
|
||||
|
||||
export function PusherProvider({ children }: { children: ReactNode }) {
|
||||
const token = useSessionStore((s) => s.token);
|
||||
const token = useAuthStore((s) => s.token);
|
||||
const clientRef = useRef<PusherClient | null>(null);
|
||||
const [connectionState, setConnectionState] =
|
||||
useState<ConnectionState>("disconnected");
|
||||
@@ -31,7 +31,7 @@ export function PusherProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
const client = new PusherClient({
|
||||
url: appConfig.pusherUrl,
|
||||
getToken: () => useSessionStore.getState().token,
|
||||
getToken: () => useAuthStore.getState().token,
|
||||
});
|
||||
|
||||
clientRef.current = client;
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
} from "@tanstack/react-query";
|
||||
import { toast } from "sonner-native";
|
||||
import { ApiError, logError, reportError, toUserMessage } from "@/lib/errors";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
|
||||
declare module "@tanstack/react-query" {
|
||||
interface Register {
|
||||
@@ -22,6 +23,16 @@ function shouldRetryQuery(failureCount: number, err: unknown): boolean {
|
||||
return failureCount < 2;
|
||||
}
|
||||
|
||||
// A 401 surfaced through react-query means the server rejected our bearer
|
||||
// token. This is the *only* place that turns that into an auth state change —
|
||||
// the apiClient is a dumb transport. Direct apiClient callers (signIn,
|
||||
// restoreSession, signInToFirebase) handle their own 401s explicitly.
|
||||
function handleUnauthorized(err: unknown): void {
|
||||
if (err instanceof ApiError && err.status === 401) {
|
||||
void useAuthStore.getState().invalidateSession();
|
||||
}
|
||||
}
|
||||
|
||||
export function createQueryClient(): QueryClient {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
@@ -36,6 +47,7 @@ export function createQueryClient(): QueryClient {
|
||||
},
|
||||
queryCache: new QueryCache({
|
||||
onError: (err, query) => {
|
||||
handleUnauthorized(err);
|
||||
logError(err, { scope: "query", queryKey: query.queryKey });
|
||||
if (query.meta?.toastOnError) {
|
||||
toast.error(toUserMessage(err));
|
||||
@@ -44,6 +56,7 @@ export function createQueryClient(): QueryClient {
|
||||
}),
|
||||
mutationCache: new MutationCache({
|
||||
onError: (err, _variables, _context, mutation) => {
|
||||
handleUnauthorized(err);
|
||||
reportError(err, {
|
||||
scope: "mutation",
|
||||
mutationKey: mutation.options.mutationKey,
|
||||
|
||||
Reference in New Issue
Block a user