Skip to content

Commit 900ec5d

Browse files
jayleatonclaude
andcommitted
feat(mobile): direct push notifications from each server
With T3 Connect gone, the phone had no way to get notified. Each server now sends Expo pushes itself, so the iOS app registers its Expo push token with every connected server that advertises the pushNotifications capability, again on each reconnect and on return to the foreground, and unregisters when a server is switched off or removed (best effort, 3 s). Settings > Notifications shows the iOS permission and one switch per server, kept locally. Tapping a push opens its thread; a push for the thread on screen stays quiet. Android shows the screen but never registers yet. expo-notifications and its config plugin come back, skipped for Personal Team builds that cannot sign the push entitlement. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent cc4ade5 commit 900ec5d

22 files changed

Lines changed: 789 additions & 18 deletions

‎apps/mobile/README.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,8 @@ provisioning; the upstream Apple team and App Store Connect app binding are remo
136136
submission requires your own App Store Connect app record.
137137

138138
The app pairs directly with a server, over your network or Tailscale, and persists its
139-
credential in SecureStore. It has no accounts, relay, or push notifications.
139+
credential in SecureStore. It has no accounts or relay; on iOS each server sends push notifications
140+
directly through Expo.
140141

141142
For a standalone TestFlight app with embedded JavaScript and no Metro dependency:
142143

‎apps/mobile/app.config.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,11 @@ const dmSansFonts = {
111111
bold: "@expo-google-fonts/dm-sans/700Bold/DMSans_700Bold.ttf",
112112
} as const;
113113

114+
const notificationsPlugin: NonNullable<ExpoConfig["plugins"]>[number] = [
115+
"expo-notifications",
116+
{ mode: APP_VARIANT === "development" ? "development" : "production" },
117+
];
118+
114119
const widgetsPlugin: NonNullable<ExpoConfig["plugins"]>[number] = [
115120
"expo-widgets",
116121
{
@@ -314,6 +319,8 @@ const config: ExpoConfig = {
314319
...(isIosPersonalTeamBuild
315320
? [sharingPlugin]
316321
: ["./plugins/withShareExtensionDisplayName.cjs", sharingPlugin]),
322+
// Personal Teams cannot sign the push entitlement.
323+
...(isIosPersonalTeamBuild ? [] : [notificationsPlugin]),
317324
"expo-web-browser",
318325
[
319326
"expo-quick-actions",

‎apps/mobile/package.json‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@
100100
"expo-keep-awake": "~58.0.3",
101101
"expo-linking": "~58.0.16",
102102
"expo-network": "58.0.4",
103+
"expo-notifications": "~58.1.5",
103104
"expo-observe": "~58.0.18",
104105
"expo-paste-input": "^0.2.2",
105106
"expo-quick-actions": "^6.0.2",
@@ -167,6 +168,7 @@
167168
"buildFromSource": [
168169
"@expo/ui",
169170
"expo-ui",
171+
"expo-notifications",
170172
"react-native-screens",
171173
"react-native-reanimated",
172174
"@react-native-menu/menu",

‎apps/mobile/src/App.tsx‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,11 @@ import {
3434
pruneIncomingShareImports,
3535
startIncomingShareInbox,
3636
} from "./features/sharing/IncomingShareProvider";
37+
import {
38+
installNotificationNavigation,
39+
setNotificationPathname,
40+
} from "./features/notifications/notificationNavigation";
41+
import { installPushRegistration } from "./features/notifications/pushRegistration";
3742
import {
3843
installAppShortcuts,
3944
recordAppShortcutNavigationState,
@@ -43,7 +48,7 @@ import {
4348
installAppearanceRuntime,
4449
useAppearancePreferences,
4550
} from "./features/settings/appearance/AppearancePreferencesProvider";
46-
import { RootStack, rootPathnameFromState } from "./Stack";
51+
import { RootStack, rootPathnameFromState, workspaceLocationFromState } from "./Stack";
4752
import { appAtomRegistry } from "./state/atom-registry";
4853
import { subscribeComposerDraftModelReconciliation } from "./state/use-composer-drafts";
4954
import { environmentThreadShells, threadEnvironment } from "./state/threads";
@@ -163,6 +168,7 @@ function handleNavigationStateChange(state: NavigationState | undefined): void {
163168
resolvePendingNewTaskPromptLink(state);
164169
resolvePendingThreadPromptLink(state);
165170
if (!state) return;
171+
setNotificationPathname(workspaceLocationFromState(state).pathname);
166172
recordAppShortcutNavigationState(state);
167173
pruneNewTaskFlowStates(state);
168174
pruneNewTaskBranchCheckouts(state);
@@ -191,6 +197,8 @@ const appServiceDisposers = [
191197
installThreadOutboxDelivery(),
192198
installComposerAttachmentUploads(),
193199
startIncomingShareInbox(),
200+
installNotificationNavigation(linkToAppPath),
201+
installPushRegistration(),
194202
installAppShortcuts(linkToAppPath),
195203
installAppUpdateChecks(),
196204
installShowcaseCapture(rootPathnameFromState),

‎apps/mobile/src/Stack.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -350,7 +350,7 @@ const SETTINGS_SCREENS = {
350350
// iOS 26 header morphing requires one UINavigationBar; nested stacks make the whole screen slide.
351351
const THREAD_LINKING_PREFIX = "threads/:environmentId/:threadId";
352352

353-
function workspaceLocationFromState(state: NavigationState) {
353+
export function workspaceLocationFromState(state: NavigationState) {
354354
const routes = state.routes.filter((route) => !isWorkspaceOverlayRoute(route.name));
355355
const effectiveState =
356356
routes.length > 0 && routes.length !== state.routes.length

‎apps/mobile/src/features/connection/useConnectionController.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
updateBearerConnection,
77
} from "../../connection/onboarding";
88
import { useWorkspaceEnvironments } from "../../state/workspace";
9+
import { unregisterPushBeforeRemoval } from "../notifications/pushRegistration";
910
import { useAtomCommand } from "../../state/use-atom-command";
1011

1112
export function useConnectionController() {
@@ -26,8 +27,10 @@ export function useConnectionController() {
2627
pairingUrl,
2728
...(expectedEnvironmentId === undefined ? {} : { expectedEnvironmentId }),
2829
});
29-
const removeEnvironment = (environmentId: EnvironmentId) =>
30-
removeEnvironmentMutation(environmentId);
30+
const removeEnvironment = async (environmentId: EnvironmentId) => {
31+
await unregisterPushBeforeRemoval(environmentId);
32+
return removeEnvironmentMutation(environmentId);
33+
};
3134
const retryEnvironment = (environmentId: EnvironmentId) =>
3235
retryEnvironmentMutation(environmentId);
3336
const setEnvironmentEnabled = (environmentId: EnvironmentId, enabled: boolean) =>
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import type { Notification, NotificationBehavior } from "expo-notifications";
2+
3+
import { notificationThreadPath } from "./notificationPayload";
4+
5+
const SHOW: NotificationBehavior = {
6+
shouldShowBanner: true,
7+
shouldShowList: true,
8+
shouldPlaySound: true,
9+
shouldSetBadge: false,
10+
};
11+
12+
const SUPPRESS: NotificationBehavior = {
13+
shouldShowBanner: false,
14+
shouldShowList: false,
15+
shouldPlaySound: false,
16+
shouldSetBadge: false,
17+
};
18+
19+
export function foregroundNotificationBehavior(
20+
notification: Notification,
21+
threadPathOnScreen: string | null,
22+
): NotificationBehavior {
23+
const target = notificationThreadPath({ notification });
24+
return target !== null && target === threadPathOnScreen ? SUPPRESS : SHOW;
25+
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import type { NotificationResponse } from "expo-notifications";
2+
import { describe, expect, it, vi } from "vite-plus/test";
3+
4+
import { notificationThreadPath, routeNotificationResponseOnce } from "./notificationPayload";
5+
import { consumeLastNotificationResponse } from "./notificationResponseConsumer";
6+
7+
function responseWithData(data: Record<string, unknown>, identifier = "notification-1") {
8+
return { notification: { request: { identifier, content: { data } } } };
9+
}
10+
11+
describe("notificationThreadPath", () => {
12+
it("encodes the thread route from environment and thread ids", () => {
13+
expect(
14+
notificationThreadPath(responseWithData({ environmentId: "env 1", threadId: "thread/2" })),
15+
).toBe("/threads/env%201/thread%2F2");
16+
});
17+
18+
it("ignores pushes without both ids", () => {
19+
expect(notificationThreadPath(responseWithData({ environmentId: "env" }))).toBeNull();
20+
expect(
21+
notificationThreadPath(responseWithData({ environmentId: "", threadId: "t" })),
22+
).toBeNull();
23+
expect(notificationThreadPath({})).toBeNull();
24+
});
25+
});
26+
27+
describe("routeNotificationResponseOnce", () => {
28+
it("retries a failed cold-start tap and deduplicates it after navigation succeeds", async () => {
29+
using _consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined);
30+
const response = responseWithData({
31+
environmentId: "env",
32+
threadId: "thread",
33+
}) as NotificationResponse;
34+
const handledResponseIds = new Set<string>();
35+
const navigate = vi.fn().mockImplementationOnce(() => {
36+
throw new Error("navigation not ready");
37+
});
38+
const clearLastResponse = vi.fn(() => Promise.resolve());
39+
const consume = () =>
40+
consumeLastNotificationResponse({
41+
getLastResponse: () => Promise.resolve(response),
42+
clearLastResponse,
43+
handleResponse: (response) =>
44+
routeNotificationResponseOnce({ handledResponseIds, response, navigate }),
45+
});
46+
47+
await consume();
48+
expect(handledResponseIds.size).toBe(0);
49+
expect(clearLastResponse).not.toHaveBeenCalled();
50+
51+
await consume();
52+
expect(navigate).toHaveBeenCalledTimes(2);
53+
expect(navigate).toHaveBeenLastCalledWith("/threads/env/thread");
54+
expect(clearLastResponse).toHaveBeenCalledOnce();
55+
56+
routeNotificationResponseOnce({ handledResponseIds, response, navigate });
57+
expect(navigate).toHaveBeenCalledTimes(2);
58+
});
59+
60+
it("discards malformed ids without leaving the response pending", async () => {
61+
const response = responseWithData({
62+
environmentId: "env",
63+
threadId: "\ud800",
64+
}) as NotificationResponse;
65+
const handledResponseIds = new Set<string>();
66+
const navigate = vi.fn();
67+
const clearLastResponse = vi.fn(() => Promise.resolve());
68+
69+
await consumeLastNotificationResponse({
70+
getLastResponse: () => Promise.resolve(response),
71+
clearLastResponse,
72+
handleResponse: (response) =>
73+
routeNotificationResponseOnce({ handledResponseIds, response, navigate }),
74+
});
75+
76+
expect(navigate).not.toHaveBeenCalled();
77+
expect(clearLastResponse).toHaveBeenCalledOnce();
78+
expect(handledResponseIds.has("notification-1")).toBe(true);
79+
});
80+
});
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import * as Notifications from "expo-notifications";
2+
3+
import { foregroundNotificationBehavior } from "./foregroundNotificationBehavior";
4+
import { routeNotificationResponseOnce, threadPathOnScreen } from "./notificationPayload";
5+
import { consumeLastNotificationResponse } from "./notificationResponseConsumer";
6+
7+
let currentThreadPath: string | null = null;
8+
9+
/** Called with the workspace pathname on every root navigation state change. */
10+
export function setNotificationPathname(pathname: string): void {
11+
currentThreadPath = threadPathOnScreen(pathname);
12+
}
13+
14+
/**
15+
* App-lifetime notification routing: a push for the thread already on screen stays
16+
* quiet, and taps (including the cold-start tap) open its thread through `navigate`.
17+
*/
18+
export function installNotificationNavigation(navigate: (href: string) => void): () => void {
19+
const handledResponseIds = new Set<string>();
20+
Notifications.setNotificationHandler({
21+
handleNotification: (notification) =>
22+
Promise.resolve(foregroundNotificationBehavior(notification, currentThreadPath)),
23+
});
24+
25+
const handleResponse = (response: Notifications.NotificationResponse): void => {
26+
routeNotificationResponseOnce({ handledResponseIds, response, navigate });
27+
};
28+
const subscription = Notifications.addNotificationResponseReceivedListener(handleResponse);
29+
void consumeLastNotificationResponse({
30+
getLastResponse: () => Notifications.getLastNotificationResponseAsync(),
31+
clearLastResponse: () => Notifications.clearLastNotificationResponseAsync(),
32+
handleResponse,
33+
});
34+
35+
return () => {
36+
subscription.remove();
37+
Notifications.setNotificationHandler(null);
38+
};
39+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
type UnknownRecord = Record<string, unknown>;
2+
3+
function field(value: unknown, key: string): unknown {
4+
return typeof value === "object" && value !== null ? (value as UnknownRecord)[key] : undefined;
5+
}
6+
7+
function notificationRequest(response: unknown): unknown {
8+
return field(field(response, "notification"), "request");
9+
}
10+
11+
function encodeThreadPath(environmentId: string, threadId: string): string | null {
12+
if (environmentId.length === 0 || threadId.length === 0) return null;
13+
try {
14+
return `/threads/${encodeURIComponent(environmentId)}/${encodeURIComponent(threadId)}`;
15+
} catch {
16+
return null;
17+
}
18+
}
19+
20+
export function threadPathOnScreen(pathname: string): string | null {
21+
const match = /^\/threads\/([^/]+)\/([^/]+)(?:\/|$)/.exec(pathname);
22+
if (!match?.[1] || !match[2]) return null;
23+
return `/threads/${match[1]}/${match[2]}`;
24+
}
25+
26+
/** The thread a server push points at, from its `data.environmentId` and `data.threadId`. */
27+
export function notificationThreadPath(response: unknown): string | null {
28+
const data = field(field(notificationRequest(response), "content"), "data");
29+
const environmentId = field(data, "environmentId");
30+
const threadId = field(data, "threadId");
31+
return typeof environmentId === "string" && typeof threadId === "string"
32+
? encodeThreadPath(environmentId, threadId)
33+
: null;
34+
}
35+
36+
export function routeNotificationResponseOnce(input: {
37+
readonly handledResponseIds: Set<string>;
38+
readonly response: unknown;
39+
readonly navigate: (path: string) => void;
40+
}): void {
41+
const identifier = field(notificationRequest(input.response), "identifier");
42+
const responseId = typeof identifier === "string" ? identifier : null;
43+
if (responseId && input.handledResponseIds.has(responseId)) return;
44+
const path = notificationThreadPath(input.response);
45+
if (path) input.navigate(path);
46+
if (responseId) input.handledResponseIds.add(responseId);
47+
}

0 commit comments

Comments
 (0)