Fix mobile notification tap routing (#1871)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
2e5ac1c8eb
commit
9562d9776f
|
|
@ -1,4 +1,4 @@
|
|||
import { useCallback, useEffect } from 'react'
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import { View, StyleSheet } from 'react-native'
|
||||
import { Stack, useRouter } from 'expo-router'
|
||||
import { StatusBar } from 'expo-status-bar'
|
||||
|
|
@ -8,6 +8,8 @@ import * as Linking from 'expo-linking'
|
|||
import { colors } from '../src/theme/mobile-theme'
|
||||
import { OrcaLogo } from '../src/components/OrcaLogo'
|
||||
import { RpcClientProvider } from '../src/transport/client-context'
|
||||
import { getNotificationNavigationPath } from '../src/notifications/notification-routing'
|
||||
import { loadHosts } from '../src/transport/host-store'
|
||||
|
||||
// Why: keeps the native splash screen visible until the React tree is mounted
|
||||
// and ready to render. Without this the user sees a blank white/black frame
|
||||
|
|
@ -49,6 +51,7 @@ function extractPairCode(url: string): string | null {
|
|||
|
||||
export default function RootLayout() {
|
||||
const router = useRouter()
|
||||
const handledNotificationIdsRef = useRef<Set<string>>(new Set())
|
||||
|
||||
// Why: route `orca://pair#<code>` deep links to the confirm screen so
|
||||
// the same pairing flow runs whether the link arrived via QR scan,
|
||||
|
|
@ -71,6 +74,72 @@ export default function RootLayout() {
|
|||
return () => sub.remove()
|
||||
}, [router])
|
||||
|
||||
// Why: iOS delivers local notification taps through expo-notifications,
|
||||
// not Linking. Route both cold-start and warm-start responses to the host
|
||||
// and worktree that scheduled the notification.
|
||||
useEffect(() => {
|
||||
let disposed = false
|
||||
|
||||
function clearLastNotificationResponse() {
|
||||
try {
|
||||
Notifications.clearLastNotificationResponse()
|
||||
} catch {
|
||||
// Older native shells may not expose the clear API; duplicate guards
|
||||
// still protect the current JS runtime.
|
||||
}
|
||||
}
|
||||
|
||||
function getInitialNotificationResponse(): Notifications.NotificationResponse | null {
|
||||
try {
|
||||
return Notifications.getLastNotificationResponse()
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function getNavigationPath(data: unknown): Promise<string | null> {
|
||||
const hosts = await loadHosts().catch(() => null)
|
||||
return getNotificationNavigationPath(data, {
|
||||
knownHostIds: hosts ? new Set(hosts.map((host) => host.id)) : undefined
|
||||
})
|
||||
}
|
||||
|
||||
async function handleNotificationResponse(response: Notifications.NotificationResponse) {
|
||||
if (response.actionIdentifier !== Notifications.DEFAULT_ACTION_IDENTIFIER) {
|
||||
clearLastNotificationResponse()
|
||||
return
|
||||
}
|
||||
|
||||
const notificationId = response.notification.request.identifier
|
||||
if (handledNotificationIdsRef.current.has(notificationId)) {
|
||||
return
|
||||
}
|
||||
handledNotificationIdsRef.current.add(notificationId)
|
||||
|
||||
const path = await getNavigationPath(response.notification.request.content.data)
|
||||
clearLastNotificationResponse()
|
||||
if (disposed) {
|
||||
return
|
||||
}
|
||||
if (path) {
|
||||
router.push(path)
|
||||
}
|
||||
}
|
||||
|
||||
const initialResponse = getInitialNotificationResponse()
|
||||
if (initialResponse) {
|
||||
void handleNotificationResponse(initialResponse)
|
||||
}
|
||||
|
||||
const sub = Notifications.addNotificationResponseReceivedListener((response) => {
|
||||
void handleNotificationResponse(response)
|
||||
})
|
||||
return () => {
|
||||
disposed = true
|
||||
sub.remove()
|
||||
}
|
||||
}, [router])
|
||||
|
||||
// Why: hide the native splash only once the navigation Stack has been laid
|
||||
// out — this is the earliest moment the user will see actual app content.
|
||||
// Previously the splash hid when a placeholder View rendered, leaving a
|
||||
|
|
|
|||
|
|
@ -400,7 +400,7 @@ export default function HomeScreen() {
|
|||
const wireUp = (state: ConnectionState) => {
|
||||
if (state === 'connected') {
|
||||
if (!unsubNotif) {
|
||||
unsubNotif = subscribeToDesktopNotifications(entry.client)
|
||||
unsubNotif = subscribeToDesktopNotifications(entry.client, entry.hostId)
|
||||
}
|
||||
if (!unsubAccounts) {
|
||||
unsubAccounts = entry.client.subscribe('accounts.subscribe', null, (payload) => {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import { Linking, Platform, Pressable, StyleSheet, Text, View } from 'react-nati
|
|||
import { router } from 'expo-router'
|
||||
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
|
||||
import type { CompatVerdict } from '../transport/protocol-compat'
|
||||
import { MOBILE_PROTOCOL_VERSION } from '../transport/protocol-version'
|
||||
|
||||
const RELEASES_URL = 'https://github.com/stablyai/orca/releases'
|
||||
const IOS_APP_STORE_URL = 'itms-apps://apps.apple.com/app/orca-ide/id6766130217'
|
||||
|
|
@ -23,10 +22,12 @@ export function ProtocolBlockScreen({ verdict }: Props) {
|
|||
: null
|
||||
: { label: 'Open GitHub Releases', url: RELEASES_URL }
|
||||
|
||||
const title = isMobileTooOld ? 'Update Orca Mobile' : 'Update Orca desktop'
|
||||
const title = isMobileTooOld ? 'Update Orca Mobile' : 'Update Orca on your computer'
|
||||
const body = isMobileTooOld
|
||||
? `The Orca desktop on this host requires Orca Mobile v${verdict.requiredMobileVersion ?? '?'}+. You have v${MOBILE_PROTOCOL_VERSION}.\n\nUpdate Orca Mobile from ${mobileUpdateTarget.storeName} to continue.`
|
||||
: `Orca Mobile requires Orca desktop v${verdict.requiredDesktopVersion ?? '?'}+ to use this host. The desktop is reporting v${verdict.desktopVersion}.`
|
||||
? `This desktop needs a newer Orca Mobile app. Update Orca Mobile from ${mobileUpdateTarget.storeName}, then try this host again.`
|
||||
: 'This paired desktop app is too old for your current Orca Mobile app. Update Orca on your computer, then try this host again.'
|
||||
const recoveryNote =
|
||||
'Already updated? Go back to Hosts and refresh the connection. If this message stays, remove this host and pair it again.'
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
|
|
@ -53,8 +54,9 @@ export function ProtocolBlockScreen({ verdict }: Props) {
|
|||
router.replace('/')
|
||||
}}
|
||||
>
|
||||
<Text style={styles.secondaryButtonText}>Pair a different host</Text>
|
||||
<Text style={styles.secondaryButtonText}>Back to hosts</Text>
|
||||
</Pressable>
|
||||
<Text style={styles.recoveryNote}>{recoveryNote}</Text>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
|
|
@ -109,6 +111,12 @@ const styles = StyleSheet.create({
|
|||
fontWeight: '600',
|
||||
color: colors.textPrimary
|
||||
},
|
||||
recoveryNote: {
|
||||
fontSize: typography.metaSize,
|
||||
color: colors.textMuted,
|
||||
lineHeight: 17,
|
||||
marginTop: spacing.md
|
||||
},
|
||||
pressed: {
|
||||
opacity: 0.7
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,10 +2,11 @@ import * as Notifications from 'expo-notifications'
|
|||
import { Platform } from 'react-native'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { loadPushNotificationsEnabled } from '../storage/preferences'
|
||||
import { buildLocalNotificationData, type DesktopNotificationSource } from './notification-routing'
|
||||
|
||||
type NotificationEvent = {
|
||||
type: 'notification'
|
||||
source: 'agent-task-complete' | 'terminal-bell' | 'test'
|
||||
source: DesktopNotificationSource
|
||||
title: string
|
||||
body: string
|
||||
worktreeId?: string
|
||||
|
|
@ -55,7 +56,7 @@ function configureNotificationChannel(): void {
|
|||
}
|
||||
}
|
||||
|
||||
async function showLocalNotification(event: NotificationEvent): Promise<void> {
|
||||
async function showLocalNotification(event: NotificationEvent, hostId: string): Promise<void> {
|
||||
const enabled = await loadPushNotificationsEnabled()
|
||||
if (!enabled) return
|
||||
|
||||
|
|
@ -66,7 +67,7 @@ async function showLocalNotification(event: NotificationEvent): Promise<void> {
|
|||
content: {
|
||||
title: event.title,
|
||||
body: event.body,
|
||||
data: { source: event.source, worktreeId: event.worktreeId },
|
||||
data: buildLocalNotificationData(event, hostId),
|
||||
...(Platform.OS === 'android' ? { channelId: 'orca-desktop' } : {})
|
||||
},
|
||||
trigger: null
|
||||
|
|
@ -76,7 +77,7 @@ async function showLocalNotification(event: NotificationEvent): Promise<void> {
|
|||
// Why: each host connection gets its own notification subscription. When the
|
||||
// connection drops, the unsubscribe function cleans up the streaming RPC.
|
||||
// Returns an unsubscribe function.
|
||||
export function subscribeToDesktopNotifications(client: RpcClient): () => void {
|
||||
export function subscribeToDesktopNotifications(client: RpcClient, hostId: string): () => void {
|
||||
configureNotificationChannel()
|
||||
|
||||
let subscriptionId: string | null = null
|
||||
|
|
@ -103,7 +104,7 @@ export function subscribeToDesktopNotifications(client: RpcClient): () => void {
|
|||
}
|
||||
if (disposed) return
|
||||
if (event.type === 'notification') {
|
||||
void showLocalNotification(event as NotificationEvent)
|
||||
void showLocalNotification(event as NotificationEvent, hostId)
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,46 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { buildLocalNotificationData, getNotificationNavigationPath } from './notification-routing'
|
||||
|
||||
describe('notification routing', () => {
|
||||
it('includes the host id in locally scheduled notification data', () => {
|
||||
expect(
|
||||
buildLocalNotificationData(
|
||||
{
|
||||
source: 'agent-task-complete',
|
||||
worktreeId: 'repo::/Users/me/orca/workspaces/feature'
|
||||
},
|
||||
'host-1'
|
||||
)
|
||||
).toEqual({
|
||||
source: 'agent-task-complete',
|
||||
hostId: 'host-1',
|
||||
worktreeId: 'repo::/Users/me/orca/workspaces/feature'
|
||||
})
|
||||
})
|
||||
|
||||
it('routes notification taps to the worktree terminal screen', () => {
|
||||
expect(
|
||||
getNotificationNavigationPath({
|
||||
hostId: 'host-1',
|
||||
worktreeId: 'repo::/Users/me/orca/workspaces/feature'
|
||||
})
|
||||
).toBe('/h/host-1/session/repo%3A%3A%2FUsers%2Fme%2Forca%2Fworkspaces%2Ffeature')
|
||||
})
|
||||
|
||||
it('falls back to the host screen when the payload has no worktree id', () => {
|
||||
expect(getNotificationNavigationPath({ hostId: 'host-1' })).toBe('/h/host-1')
|
||||
})
|
||||
|
||||
it('ignores payloads that cannot identify the paired host', () => {
|
||||
expect(getNotificationNavigationPath({ worktreeId: 'repo::/tmp/worktree' })).toBeNull()
|
||||
})
|
||||
|
||||
it('ignores payloads for hosts that are no longer paired', () => {
|
||||
expect(
|
||||
getNotificationNavigationPath(
|
||||
{ hostId: 'removed-host', worktreeId: 'repo::/tmp/worktree' },
|
||||
{ knownHostIds: new Set(['host-1']) }
|
||||
)
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
export type DesktopNotificationSource = 'agent-task-complete' | 'terminal-bell' | 'test'
|
||||
|
||||
export type DesktopNotificationEvent = {
|
||||
source: DesktopNotificationSource
|
||||
worktreeId?: string
|
||||
}
|
||||
|
||||
export type LocalNotificationData = {
|
||||
source: DesktopNotificationSource
|
||||
hostId: string
|
||||
worktreeId?: string
|
||||
}
|
||||
|
||||
export type NotificationNavigationOptions = {
|
||||
knownHostIds?: ReadonlySet<string>
|
||||
}
|
||||
|
||||
function readNonEmptyString(value: unknown): string | null {
|
||||
return typeof value === 'string' && value.trim().length > 0 ? value : null
|
||||
}
|
||||
|
||||
export function buildLocalNotificationData(
|
||||
event: DesktopNotificationEvent,
|
||||
hostId: string
|
||||
): LocalNotificationData {
|
||||
const data: LocalNotificationData = {
|
||||
source: event.source,
|
||||
hostId
|
||||
}
|
||||
if (event.worktreeId) {
|
||||
data.worktreeId = event.worktreeId
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
export function getNotificationNavigationPath(
|
||||
data: unknown,
|
||||
options: NotificationNavigationOptions = {}
|
||||
): string | null {
|
||||
if (!data || typeof data !== 'object') {
|
||||
return null
|
||||
}
|
||||
|
||||
const record = data as Record<string, unknown>
|
||||
const hostId = readNonEmptyString(record.hostId)
|
||||
if (!hostId) {
|
||||
return null
|
||||
}
|
||||
if (options.knownHostIds && !options.knownHostIds.has(hostId)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const hostPath = `/h/${encodeURIComponent(hostId)}`
|
||||
const worktreeId = readNonEmptyString(record.worktreeId)
|
||||
if (!worktreeId) {
|
||||
return hostPath
|
||||
}
|
||||
|
||||
return `${hostPath}/session/${encodeURIComponent(worktreeId)}`
|
||||
}
|
||||
Loading…
Reference in New Issue