Skip to content

Commit 4364ca7

Browse files
committed
fix(app): route the iOS share hand-off instead of Unmatched Route
The share extension opens `chronicle:///dataUrl=chronicleShareKey?nonce=…`, which is a signal rather than a path — the image stays in the app-group container and is read back from the native module. Expo Router matched it against the file routes, found nothing, and rendered Unmatched Route, so every share into Chronicle dead-ended. Add `+native-intent.tsx` to rewrite that URL to /share, and consolidate the two independent `useShareIntent` hooks onto one `ShareIntentProvider`: each held its own copy of the intent and cleared the shared native state on reset, so they raced to consume the same payload. Android still navigates from the hook (its share arrives as an Intent, with no URL to rewrite), now guarded so iOS does not push the modal twice. The confirm screen also waits for `isReady` before giving up and returning home — on iOS the redirect lands there before the native module has been read. Bump to 1.1.0 for the TestFlight build.
1 parent 4009c7e commit 4364ca7

4 files changed

Lines changed: 71 additions & 27 deletions

File tree

app/app.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"expo": {
33
"name": "chronicle",
44
"slug": "friend-lite-app",
5-
"version": "1.0.12",
5+
"version": "1.1.0",
66
"scheme": "chronicle",
77
"orientation": "portrait",
88
"icon": "./assets/icon.png",

app/app/+native-intent.tsx

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
// The iOS share extension hands off by opening a URL that is a signal, not a
2+
// path: `chronicle:///dataUrl=chronicleShareKey?nonce=…`. The shared image never
3+
// travels in that URL — it sits in the app-group container, and `useShareIntent`
4+
// reads it back out of the native module.
5+
//
6+
// Expo Router still tries to match the URL against a file route, finds nothing,
7+
// and renders Unmatched Route. This hook rewrites it to the confirm screen.
8+
9+
import { getShareExtensionKey } from 'expo-share-intent';
10+
11+
export function redirectSystemPath({ path }: { path: string; initial: boolean }): string {
12+
try {
13+
// Key is derived from the app scheme, so it stays correct if the scheme moves.
14+
if (path.includes(`dataUrl=${getShareExtensionKey()}`)) {
15+
return '/share';
16+
}
17+
return path;
18+
} catch {
19+
// Throwing here takes down link handling for every deep link, not just this
20+
// one, so an unreadable path degrades to the home route.
21+
return '/';
22+
}
23+
}

app/app/_layout.tsx

Lines changed: 38 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { useEffect } from 'react';
2-
import { Stack, useRouter } from 'expo-router';
3-
import { useShareIntent } from 'expo-share-intent';
2+
import { Stack, usePathname, useRouter } from 'expo-router';
3+
import { ShareIntentProvider, useShareIntentContext } from 'expo-share-intent';
44

55
import ErrorBoundary from '@/components/ErrorBoundary';
66
import { AppSettingsProvider } from '@/contexts/AppSettingsContext';
@@ -33,33 +33,49 @@ function ThemedStack() {
3333
);
3434
}
3535

36-
export default function RootLayout() {
36+
/**
37+
* Opens the confirm sheet for a share that arrived without a deep link.
38+
*
39+
* Android delivers the share as an Intent straight to the native module, so
40+
* there is no URL for `+native-intent` to rewrite and nothing navigates on its
41+
* own. iOS does arrive by URL and is already on `/share` by the time the intent
42+
* surfaces here, hence the guard — without it the modal would be pushed twice.
43+
*/
44+
function ShareIntentNavigator() {
3745
const router = useRouter();
38-
// Listening here rather than on the home screen so a share opens the confirm
39-
// sheet even when the app was launched cold straight into another route.
40-
const { hasShareIntent } = useShareIntent({ resetOnBackground: true });
41-
42-
useEffect(() => {
43-
initLogger().then(() => logInfo('RootLayout', 'app mounted'));
44-
}, []);
46+
const pathname = usePathname();
47+
const { hasShareIntent } = useShareIntentContext();
4548

4649
useEffect(() => {
47-
if (hasShareIntent) {
50+
if (hasShareIntent && pathname !== '/share') {
4851
logInfo('RootLayout', 'share intent received');
4952
router.push('/share');
5053
}
51-
}, [hasShareIntent, router]);
54+
}, [hasShareIntent, pathname, router]);
55+
56+
return <ThemedStack />;
57+
}
58+
59+
export default function RootLayout() {
60+
useEffect(() => {
61+
initLogger().then(() => logInfo('RootLayout', 'app mounted'));
62+
}, []);
5263

5364
return (
54-
// The provider sits outside the boundary so the crash screen is themed too.
55-
<ThemeProvider>
56-
<ErrorBoundary>
57-
<ConnectionLogProvider>
58-
<AppSettingsProvider>
59-
<ThemedStack />
60-
</AppSettingsProvider>
61-
</ConnectionLogProvider>
62-
</ErrorBoundary>
63-
</ThemeProvider>
65+
// One provider rather than a hook per screen: each `useShareIntent` call
66+
// holds its own copy of the intent and clears the shared native state when
67+
// it resets, so two of them race to consume the same payload.
68+
<ShareIntentProvider options={{ resetOnBackground: true }}>
69+
{/* The theme provider sits outside the boundary so the crash screen is themed too. */}
70+
<ThemeProvider>
71+
<ErrorBoundary>
72+
<ConnectionLogProvider>
73+
<AppSettingsProvider>
74+
<ShareIntentNavigator />
75+
</AppSettingsProvider>
76+
</ConnectionLogProvider>
77+
</ErrorBoundary>
78+
</ThemeProvider>
79+
</ShareIntentProvider>
6480
);
6581
}

app/app/share.tsx

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import { useCallback, useEffect, useState } from 'react';
1010
import { Image, StyleSheet } from 'react-native';
1111
import { useRouter } from 'expo-router';
12-
import { useShareIntent } from 'expo-share-intent';
12+
import { useShareIntentContext } from 'expo-share-intent';
1313

1414
import { Body, Button, ButtonRow, InlineAlert, Screen, TextField } from '@/components/ui';
1515
import { useSharedAppSettings } from '@/contexts/AppSettingsContext';
@@ -23,7 +23,9 @@ export default function ShareScreen() {
2323
const t = useTheme();
2424
const s = createStyles(t);
2525
const { webSocketUrl } = useSharedAppSettings();
26-
const { hasShareIntent, shareIntent, resetShareIntent } = useShareIntent();
26+
// Shares the root provider's state, so resetting here does not strand the
27+
// navigator holding a stale copy of the same intent.
28+
const { isReady, hasShareIntent, shareIntent, resetShareIntent } = useShareIntentContext();
2729

2830
const [caption, setCaption] = useState('');
2931
const [phase, setPhase] = useState<Phase>('ready');
@@ -37,11 +39,14 @@ export default function ShareScreen() {
3739
}, [resetShareIntent, router]);
3840

3941
// Nothing to confirm: the intent was consumed or arrived without an image.
42+
// `isReady` is load-bearing on iOS — the deep link lands here before the
43+
// native module has been read, so acting sooner bounces straight back home
44+
// on every share.
4045
useEffect(() => {
41-
if (!hasShareIntent && phase === 'ready' && !imageUri) {
46+
if (isReady && !hasShareIntent && phase === 'ready' && !imageUri) {
4247
router.replace('/');
4348
}
44-
}, [hasShareIntent, imageUri, phase, router]);
49+
}, [hasShareIntent, imageUri, isReady, phase, router]);
4550

4651
const send = useCallback(async () => {
4752
if (!imageUri) return;

0 commit comments

Comments
 (0)