Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/mobile/src/app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
AppKeyboardProvider,
AppThemeProvider,
} from '@mobile/components/shell/app-providers';
import { NotificationObserver } from '@mobile/components/shell/notification-observer';
import { MobileProductAnalyticsProvider } from '@mobile/components/shell/product-analytics-provider';
import { RootNavigator } from '@mobile/components/shell/root-navigator';
import { ThemeController } from '@mobile/components/shell/theme-controller';
Expand Down Expand Up @@ -78,6 +79,7 @@ function RootLayout() {
]}
>
<ThemeController />
<NotificationObserver />
<RootNavigator />
</ComposeContextProvider>
</GestureHandlerRootView>
Expand Down
5 changes: 2 additions & 3 deletions apps/mobile/src/app/account.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { DevicesSection } from '@mobile/components/account/devices-section';
import { ProfileRow } from '@mobile/components/account/profile-row';
import { signOutOfCloud, useCloudAccount } from '@mobile/runtime/cloud/account';
import { Redirect, Stack } from 'expo-router';
import { Alert } from 'react-native';
import { useTranslations } from 'use-intl';

/** Account screen: profile, the account's device registry, and sign-out. */
Expand Down Expand Up @@ -30,9 +31,7 @@ export default function AccountScreen(): React.ReactNode {
<Button
role="destructive"
label={t('signOut')}
onPress={() => {
void signOutOfCloud();
}}
onPress={() => signOutOfCloud().catch(() => Alert.alert(t('signOutError')))}
/>
</Section>
</>
Expand Down
50 changes: 49 additions & 1 deletion apps/mobile/src/app/settings.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
import { Form, Host, Link, Picker, Section, Text, Toggle, VStack } from '@expo/ui/swift-ui';
import { font, foregroundStyle, pickerStyle, tag } from '@expo/ui/swift-ui/modifiers';
import { disabled, font, foregroundStyle, pickerStyle, tag } from '@expo/ui/swift-ui/modifiers';
import { AgentKindSchema, WIRE_PROTOCOL_VERSION } from '@linkcode/schema';
import { NavigationRow } from '@mobile/components/form/navigation-row';
import { useCloudAccount } from '@mobile/runtime/cloud/account';
import {
requestNotificationPermission,
revokeDevicePushToken,
syncDevicePushToken,
} from '@mobile/runtime/notifications';
import { setMobileProductAnalyticsEnabled } from '@mobile/runtime/product-analytics';
import { useAnalyticsPreferenceStore } from '@mobile/stores/analytics-store';
import type { ThemePreference } from '@mobile/stores/settings-store';
import { useSettingsStore } from '@mobile/stores/settings-store';
import { Stack, useRouter } from 'expo-router';
import { Alert, Linking } from 'react-native';
import { useTranslations } from 'use-intl';

const THEME_PREFERENCES: readonly ThemePreference[] = ['system', 'light', 'dark'];
Expand All @@ -32,7 +38,31 @@ export default function SettingsScreen(): React.ReactNode {
const account = useCloudAccount();
const productAnalyticsEnabled = useAnalyticsPreferenceStore((state) => state.enabled);
const themePreference = useSettingsStore((state) => state.themePreference);
const notificationsEnabled = useSettingsStore((state) => state.notificationsEnabled);
const setThemePreference = useSettingsStore((state) => state.setThemePreference);
const setNotificationsEnabled = useSettingsStore((state) => state.setNotificationsEnabled);

const updateNotifications = async (enabled: boolean) => {
try {
if (!enabled) {
await revokeDevicePushToken();
setNotificationsEnabled(false);
return;
}
if (account.status !== 'signed-in') return;
if (await requestNotificationPermission()) {
await syncDevicePushToken(account.user.id);
setNotificationsEnabled(true);
return;
}
Alert.alert(t('notificationsDeniedTitle'), t('notificationsDenied'), [
{ text: t('cancel'), style: 'cancel' },
{ text: t('openSettings'), onPress: () => Linking.openSettings() },
]);
} catch {
Alert.alert(t('notificationsErrorTitle'), t('notificationsError'));
}
};

return (
<>
Expand Down Expand Up @@ -79,6 +109,24 @@ export default function SettingsScreen(): React.ReactNode {
/>
</Section>

<Section
title={t('notifications')}
footer={
<Text>
{account.status === 'signed-in'
? t('notificationsHint')
: t('notificationsRequiresCloud')}
</Text>
}
>
<Toggle
isOn={notificationsEnabled}
onIsOnChange={updateNotifications}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Serialize notification preference updates

If the user toggles notifications again while permission or token synchronization is still pending, this callback starts a second unsynchronized request. The requests can finish out of order—for example, a disable DELETE can finish before the earlier enable PUT, after which the earlier operation restores both the token and notificationsEnabled: true despite disable being the last action. Disable the control while an update is pending or otherwise ensure only the latest request can commit state.

Useful? React with 👍 / 👎.

label={t('notifications')}
modifiers={[disabled(account.status !== 'signed-in')]}
/>
</Section>

{/* Native links open the URL themselves — no Linking.openURL fallback to get wrong. */}
<Section title={t('legalAndSupport')}>
<Link label={t('privacyPolicy')} destination={PRIVACY_POLICY_URL} />
Expand Down
58 changes: 58 additions & 0 deletions apps/mobile/src/components/shell/notification-observer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { useCloudAccount } from '@mobile/runtime/cloud/account';
import { resolveNotificationRoute } from '@mobile/runtime/notification-route';
import { syncDevicePushToken } from '@mobile/runtime/notifications';
import { useHostRegistryHydrated, useHostRegistryStore } from '@mobile/stores/host-store';
import { useSettingsStore } from '@mobile/stores/settings-store';
import * as Sentry from '@sentry/react-native';
import * as Notifications from 'expo-notifications';
import { useRouter } from 'expo-router';
import { useEffect } from 'react';
import { AppState } from 'react-native';

export function NotificationObserver(): null {
const router = useRouter();
const account = useCloudAccount();
const hydrated = useHostRegistryHydrated();
const hosts = useHostRegistryStore((state) => state.hosts);
const enabled = useSettingsStore((state) => state.notificationsEnabled);
const userId = account.status === 'signed-in' ? account.user.id : null;

useEffect(() => {
if (!hydrated) return;

const open = (response: Notifications.NotificationResponse) => {
const route = resolveNotificationRoute(response.notification.request.content.data, hosts);
if (route) router.push(route);
};

const initial = Notifications.getLastNotificationResponse();
if (initial) {
open(initial);
Notifications.clearLastNotificationResponse();
}
const subscription = Notifications.addNotificationResponseReceivedListener(open);
return () => subscription.remove();
}, [hosts, hydrated, router]);

useEffect(() => {
if (!enabled || !userId) return;

const sync = (devicePushToken?: Notifications.DevicePushToken) => {
syncDevicePushToken(userId, devicePushToken).catch((error: unknown) =>
Sentry.captureException(error),
);
};

sync();
const tokenSubscription = Notifications.addPushTokenListener(sync);
const appStateSubscription = AppState.addEventListener('change', (state) => {
if (state === 'active') sync();
});
return () => {
tokenSubscription.remove();
appStateSubscription.remove();
};
}, [enabled, userId]);

return null;
}
16 changes: 16 additions & 0 deletions apps/mobile/src/runtime/__tests__/notification-route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { describe, expect, it } from 'vitest';
import { resolveNotificationRoute } from '../notification-route';

describe('resolveNotificationRoute', () => {
const hosts = [{ id: 'local host', tunnelHostId: 'tunnel-1' }];

it('routes known tunnel hosts, falls back for unknown hosts, and rejects invalid data', () => {
expect(
resolveNotificationRoute({ tunnelHostId: 'tunnel-1', sessionId: 'session/1' }, hosts),
).toBe('/host/local%20host/session/session%2F1');
expect(
resolveNotificationRoute({ tunnelHostId: 'tunnel-2', sessionId: 'session-2' }, hosts),
).toBe('/connect');
expect(resolveNotificationRoute({ tunnelHostId: 'tunnel-1' }, hosts)).toBeNull();
});
});
7 changes: 7 additions & 0 deletions apps/mobile/src/runtime/cloud/account.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { revokeDevicePushToken } from '@mobile/runtime/notifications';
import { useSettingsStore } from '@mobile/stores/settings-store';
import { noop } from 'foxact/noop';
import { cloudAuthClient } from './client';
import { clearDeviceEnrollment } from './devices';
Expand Down Expand Up @@ -39,6 +41,11 @@ export async function signInToCloud(): Promise<void> {
}

export async function signOutOfCloud(): Promise<void> {
const settings = useSettingsStore.getState();
if (settings.notificationsEnabled) {
await revokeDevicePushToken();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve local sign-out after revoking this device

When notifications are enabled and the user revokes the current phone from DevicesSection, revokeDevice() first kills this device's cloud sessions, so this push-token request runs against the deliberately dead session and rejects. That prevents cloudAuthClient.signOut() from performing its local cookie cleanup; the caller suppresses the rejection and returns, leaving the app locally signed in after the UI promised a sign-out. Make push-token revocation best-effort or perform local sign-out cleanup even when it fails.

Useful? React with 👍 / 👎.

settings.setNotificationsEnabled(false);
}
await cloudAuthClient.signOut();
// Forget the enrollment so a different account signing in on this phone
// registers the device under itself instead of silently skipping.
Expand Down
15 changes: 15 additions & 0 deletions apps/mobile/src/runtime/cloud/devices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,21 @@ export async function clearDeviceEnrollment(): Promise<void> {
await SecureStore.deleteItemAsync(ENROLLMENT_KEY);
}

export async function registerDevicePushToken(expoPushToken: string): Promise<void> {
const { error } = await cloudAuthClient.$fetch<unknown>(`${CLOUD_URL}/devices/push-token`, {
method: 'PUT',
body: { expoPushToken },
});
if (error) throw new Error(`push token registration failed (${error.status})`);
}

export async function revokeDevicePushToken(): Promise<void> {
const { error } = await cloudAuthClient.$fetch<unknown>(`${CLOUD_URL}/devices/push-token`, {
method: 'DELETE',
});
if (error) throw new Error(`push token revocation failed (${error.status})`);
}

/** Client view of cloud device rows; timestamps arrive as ISO strings over JSON. */
export const CloudDeviceSchema = z.object({
id: z.string().min(1),
Expand Down
23 changes: 23 additions & 0 deletions apps/mobile/src/runtime/notification-route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { SessionIdSchema } from '@linkcode/schema';
import { z } from 'zod';

const NotificationDataSchema = z.object({
tunnelHostId: z.string().min(1),
sessionId: SessionIdSchema,
});

interface NotificationHost {
id: string;
tunnelHostId?: string;
}

export function resolveNotificationRoute(
data: unknown,
hosts: readonly NotificationHost[],
): string | null {
const parsed = NotificationDataSchema.safeParse(data);
if (!parsed.success) return null;
const host = hosts.find((candidate) => candidate.tunnelHostId === parsed.data.tunnelHostId);
if (!host) return '/connect';
return `/host/${encodeURIComponent(host.id)}/session/${encodeURIComponent(parsed.data.sessionId)}`;
}
61 changes: 61 additions & 0 deletions apps/mobile/src/runtime/notifications.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import {
ensureDeviceRegistered,
registerDevicePushToken,
revokeDevicePushToken,
} from '@mobile/runtime/cloud/devices';
import Constants from 'expo-constants';
import * as Device from 'expo-device';
import type { DevicePushToken } from 'expo-notifications';
import * as Notifications from 'expo-notifications';

export const NOTIFICATION_CHANNEL_ID = 'session-events';

Notifications.setNotificationHandler({
handleNotification: () =>
Promise.resolve({
shouldPlaySound: true,
shouldSetBadge: false,
shouldShowBanner: true,
shouldShowList: true,
}),
});

function permissionGranted(settings: Notifications.NotificationPermissionsStatus): boolean {
return (
settings.granted || settings.ios?.status === Notifications.IosAuthorizationStatus.PROVISIONAL
);
}

export async function requestNotificationPermission(): Promise<boolean> {
if (process.env.EXPO_OS === 'android') {
await Notifications.setNotificationChannelAsync(NOTIFICATION_CHANNEL_ID, {
name: 'Session events',
importance: Notifications.AndroidImportance.HIGH,
sound: 'default',
});
}

const current = await Notifications.getPermissionsAsync();
if (permissionGranted(current)) return true;
if (!current.canAskAgain) return false;
return permissionGranted(await Notifications.requestPermissionsAsync());
}

export async function syncDevicePushToken(
userId: string,
devicePushToken?: DevicePushToken,
): Promise<void> {
if (!Device.isDevice) throw new Error('push notifications require a physical device');
const projectId = Constants.expoConfig?.extra?.eas?.projectId;
if (typeof projectId !== 'string' || !projectId) {
throw new Error('Expo project ID is missing');
}
await ensureDeviceRegistered(userId);
const expoPushToken = await Notifications.getExpoPushTokenAsync({
projectId,
devicePushToken,
});
await registerDevicePushToken(expoPushToken.data);
}

export { revokeDevicePushToken };
16 changes: 14 additions & 2 deletions apps/mobile/src/stores/settings-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,25 +8,37 @@ export const ThemePreferenceSchema = z.enum(['system', 'light', 'dark']);
export type ThemePreference = z.infer<typeof ThemePreferenceSchema>;

/** Persisted subset — every field optional so partial/stale storage merges over the defaults. */
const PersistedSettingsSchema = z.object({ themePreference: ThemePreferenceSchema }).partial();
const PersistedSettingsSchema = z
.object({
themePreference: ThemePreferenceSchema,
notificationsEnabled: z.boolean(),
})
.partial();
type PersistedSettings = z.infer<typeof PersistedSettingsSchema>;

export interface SettingsState {
themePreference: ThemePreference;
notificationsEnabled: boolean;
setThemePreference: (preference: ThemePreference) => void;
setNotificationsEnabled: (enabled: boolean) => void;
}

export const useSettingsStore = create<SettingsState>()(
zodPersist<SettingsState, [], [], PersistedSettings, PersistedSettings>(
(set) => ({
themePreference: 'system',
notificationsEnabled: false,
setThemePreference: (preference) => set({ themePreference: preference }),
setNotificationsEnabled: (enabled) => set({ notificationsEnabled: enabled }),
}),
{
name: 'linkcode.mobile.settings:v1',
schema: PersistedSettingsSchema,
storage: createJSONStorage(() => Storage),
partialize: (state) => ({ themePreference: state.themePreference }),
partialize: (state) => ({
themePreference: state.themePreference,
notificationsEnabled: state.notificationsEnabled,
}),
},
),
);
10 changes: 10 additions & 0 deletions packages/presentation/i18n/src/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1189,6 +1189,7 @@ export const en = {
account: {
title: 'Account',
signOut: 'Sign out',
signOutError: 'Could not sign out. Check your connection and try again.',
devices: 'Devices',
refresh: 'Refresh',
devicesEmpty: 'No devices registered on this account yet.',
Expand Down Expand Up @@ -1313,6 +1314,15 @@ export const en = {
analytics: 'Share usage analytics',
analyticsHint:
'Send feature usage events without conversations, code, paths, or terminal content. Off by default.',
notifications: 'Notifications',
notificationsHint: 'Notify you when a Thread finishes a turn or needs approval.',
notificationsRequiresCloud: 'Sign in to LinkCode Cloud to enable remote notifications.',
notificationsDeniedTitle: 'Notifications are off',
notificationsDenied: 'Allow notifications in system settings to enable this feature.',
notificationsErrorTitle: 'Could not update notifications',
notificationsError: 'Try again after checking your network and system settings.',
openSettings: 'Open Settings',
cancel: 'Cancel',
legalAndSupport: 'Legal & Support',
privacyPolicy: 'Privacy Policy',
termsOfService: 'Terms of Service',
Expand Down
Loading