-
Notifications
You must be signed in to change notification settings - Fork 3
feat(mobile): add remote push notifications #386
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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; | ||
| } |
| 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(); | ||
| }); | ||
| }); |
| 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'; | ||
|
|
@@ -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(); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When notifications are enabled and the user revokes the current phone from 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. | ||
|
|
||
| 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)}`; | ||
| } |
| 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 }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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: truedespite 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 👍 / 👎.