Skip to content

feat: deep linking set up#1878

Merged
cimigree merged 11 commits into
developfrom
feat/deep-linking
Jun 4, 2026
Merged

feat: deep linking set up#1878
cimigree merged 11 commits into
developfrom
feat/deep-linking

Conversation

@cimigree

@cimigree cimigree commented May 5, 2026

Copy link
Copy Markdown
Contributor

closes #1766

What this adds

Sets up the deep linking infrastructure needed for invite-over-the-internet. When a user taps an invite link (comapeo://invite/ or https://app.comapeo.org/invite/), the app opens and navigates to the InviteReceived screen (for now).

Changes:

  • Adds the configuration as specified in the react navigation docs for deep linking.
  • Also added logic in RootStackNavigator for two situations where the app isn't ready to navigate immediately: mid-onboarding and passcode locked. In both cases the invite ID is captured and held, then navigation happens once the app is ready. A requestAnimationFrame is used so that navigation only happens after the app navigator has finished mounting its screens.
  • To support the above, a flag (deepLinkReady) in deepLinkConfig.ts tracks whether the app is ready. AppNavigator reads this flag to suppress React Navigation's own automatic Link handling while the app is not ready — otherwise React Navigation would try to navigate before the right screens exist.

Questions:

  • Obscured mode: Currently, if the app is in obscured mode (passcode screen showing), an invite link is saved and shown after unlock — same as the passcode scenario. Should invite links be suppressed entirely in obscured mode, or is showing them after unlock the right behavior?

  • Has the domain for invite links been confirmed? The current implementation assumes app.comapeo.org based on the invite-over-internet in notion, but if that's not finalized the host will need to change in both app.json and deepLinkConfig.ts.

  • assetlinks.json: Am I responsible for setting this up on app.comapeo.org? Without it, Android will fall back to the custom scheme only and won't intercept https:// links.

To do next:

  • Serve assetlinks.json from app.comapeo.org (or wherever) to enable Android App Links verification for https:// links
  • Update InviteReceived to handle internet invites (the current screen is built for local wifi invites) or make new UI for it and it should handle expired and/ or invalid invite ids
  • The hash fragment handling for sensitive invite data (noted in deepLinkConfig.ts) — InviteReceived (or whatever we create) will need to parse window.location.hash or the raw URL to retrieve the secret
  • Web fallback page — for users without the app, https://app.comapeo.org/invite/ should serve a page explaining how to download CoMapeo, right?

Testing:

Integration tests would not really work for this because it is all about timing and navigation which is something we would have to mock so much it wouldn't actually test what we want to test.
Instead, you can test manually.

Requires a dev build on a physical Android device.

Scenario 1 — Happy path:

adb shell am start -W -a android.intent.action.VIEW -d "comapeo://invite/test-invite-id-123" com.comapeo.dev
Expected: app opens but there will be an error message since that invite id is not handled by InviteReceived. I mocked out the response to it in InviteReceived just to check, which you can do if you wish.

Scenario 2 — App backgrounded with passcode:

Set a passcode in Settings
Background the app and wait for it to lock
Run the adb command above
Enter the passcode Expected: same as above.

@awana-lockfile-bot

Copy link
Copy Markdown

package-lock.json changes

Click to toggle table visibility
Name Status Previous Current
expo-linking ADDED - 7.0.4

@cimigree cimigree requested a review from ErikSin May 5, 2026 16:58
@cimigree

cimigree commented May 18, 2026

Copy link
Copy Markdown
Contributor Author

@ErikSin I know you suggested moving the DeepLinkListener (new name) into the AppScreens file but I could not figure out how to do it because

ERROR [Error: A navigator can only contain 'Screen', 'Group' or 'React.Fragment' as its direct children (found 'DeepLinkListener'). To render this component in the navigator, pass it in the 'component' prop to 'Screen'.]

so if you could provide a bit of guidance about what you were thinking I would appreciate it. And, should I try to add an integration test to this somehow once our testing is updated? I will need a few tips about the approach there too, I am afraid.

@ErikSin ErikSin left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks for doing this work. I think your possibly dispatching the navigation event for 1 invite up to 3 times:

  1. config property of the deeplinking
  2. the DeepLinkListener
  3. the PendingInviteListener (this one im not sure about)

Also, I think that we can simplify the listener a bit if it is aware of the nav route.

Comment on lines +9 to +11
const [pendingInviteId, setPendingInviteId] = React.useState<string | null>(
null,
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I don't think I fully understand the architecture, but would the PendingInvitesListener also catch the invite? And if thats the case, would there be 2 navigation events?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

No — PendingInvitesListener watches useManyInvites() which returns local peer-to-peer invites not the deep link invites, which is a completely different path — it never appears in that list. So there's no double navigation from that source.

Comment thread src/frontend/Navigation/Stack/index.tsx Outdated
Comment on lines 61 to 69
const isNotReadyForInvite =
security.authState === 'unauthenticated' ||
!deviceInfo.name ||
!activeProjectId;

React.useEffect(() => {
setDeepLinkReady(!isNotReadyForInvite);
}, [isNotReadyForInvite]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

You can do this without a useEffect:

const isNotReadyForInvite =
    security.authState === 'unauthenticated' ||
    !deviceInfo.name ||
    !activeProjectId

//directly inline with no useEffecr
setDeepLinkReady(!isNotReadyForInvite);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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


export const DeepLinkListener = () => {
const navigation = useNavigationFromRoot();
const url = ExpoLinking.useURL();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This is deprecated and you should use useLinkingURL()

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Comment thread src/frontend/lib/deepLinkConfig.ts Outdated
Comment on lines +25 to +30
config: {
screens: {
InviteReceived: 'invite/:inviteId',
},
},
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

the config property here is used for matching screens with the url. ie, allows you to have a 1 to 1 matching with the url, so as long as your url matches your screen pattern you can open that screen directly. But we are not doing that. We are always intercepting the url, and, grabbing the invite id, and then dispatching a navigation based on that invite id. This would cause 2 navigation events, so I think we need to get rid of it

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Comment on lines +13 to +19
React.useEffect(() => {
if (!url) return;
const inviteId = parseInviteUrl(url);
if (inviteId) {
setPendingInviteId(inviteId);
}
}, [url]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

i think we can get rid of this useEffect entirely.

eg

const url = ExpoLinking.useURL();
const pendingInviteId = url ? parseInviteUrl(url) : undefined

React.useEffect(() => {
    if (!pendingInviteId) return;
    const waitUntilMount = requestAnimationFrame(() => {
      navigation.navigate('InviteReceived', {inviteId: pendingInviteId});
      setPendingInviteId(null);
    });
    return () => cancelAnimationFrame(waitUntilMount);
  }, [pendingInviteId, navigation]);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Comment on lines +23 to +24
const waitUntilMount = requestAnimationFrame(() => {
navigation.navigate('InviteReceived', {inviteId: pendingInviteId});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think the better thing to do is follow the architecture of the other listeners, where they are aware of the nav route and uses isInviteScreen and isEditingScreen. This is also related to this comment:

@ErikSin I know you suggested moving the DeepLinkListener (new name) into the AppScreens file but I could not figure out how to do it because
ERROR [Error: A navigator can only contain 'Screen', 'Group' or 'React.Fragment' as its direct children (found 'DeepLinkListener'). To render this component in the navigator, pass it in the 'component' prop to 'Screen'.]
so if you could provide a bit of guidance about what you were thinking I would appreciate it. And, should I try to add an integration test to this somehow once our testing is updated? I will need a few tips about the approach there too, I am afraid.

What i was originally suggesting was just an in passing comment when we were looking at the conditional navigation, it wasnt a fully formed suggestion, just something to explore.

But i think also we can avoid using requestAnimationFrame if the listener is aware of the nav route. The nav route is set after the navigation has fully finished. So if we are passing the navigation route to this listener we know the navigation has fully finished before we try and dispatch the navigation action

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

cimigree added 2 commits May 27, 2026 14:57
…ows same pattern as pending invites listener to check current route name. Updated to non deprecated useLinkingUrl. Eliminates unneeded use effect.
@socket-security

socket-security Bot commented May 27, 2026

Copy link
Copy Markdown

Warning

Review the following alerts detected in dependencies.

According to your organization's Security Policy, it is recommended to resolve "Warn" alerts. Learn more about Socket for GitHub.

Action Severity Alert  (click "▶" to expand/collapse)
Warn Critical
Critical CVE: WebdriverIO BrowserStack Service has a Command Injection issue in npm @wdio/browserstack-service

CVE: GHSA-5c46-x3qw-q7j7 WebdriverIO BrowserStack Service has a Command Injection issue (CRITICAL)

Affected versions: < 9.24.0

Patched version: 9.24.0

From: package-lock.jsonnpm/@wdio/browserstack-service@9.21.0

ℹ Read more on: This package | This alert | What is a critical CVE?

Next steps: Take a moment to review the security alert above. Review the linked package source code to understand the potential risk. Ensure the package is not malicious before proceeding. If you're unsure how to proceed, reach out to your security team or ask the Socket team for help at support@socket.dev.

Suggestion: Remove or replace dependencies that include known critical CVEs. Consumers can use dependency overrides or npm audit fix --force to remove vulnerable dependencies.

Mark the package as acceptable risk. To ignore this alert only in this pull request, reply with the comment @SocketSecurity ignore npm/@wdio/browserstack-service@9.21.0. You can also ignore all packages with @SocketSecurity ignore-all. To ignore an alert for all future pull requests, use Socket's Dashboard to change the triage state of this alert.

Warn High
Obfuscated code: npm @browserstack/ai-sdk-node is 100.0% likely obfuscated

Confidence: 1.00

Location: Package overview

From: package-lock.jsonnpm/@wdio/browserstack-service@9.21.0npm/@browserstack/ai-sdk-node@1.5.17

ℹ Read more on: This package | This alert | What is obfuscated code?

Next steps: Take a moment to review the security alert above. Review the linked package source code to understand the potential risk. Ensure the package is not malicious before proceeding. If you're unsure how to proceed, reach out to your security team or ask the Socket team for help at support@socket.dev.

Suggestion: Packages should not obfuscate their code. Consider not using packages with obfuscated code.

Mark the package as acceptable risk. To ignore this alert only in this pull request, reply with the comment @SocketSecurity ignore npm/@browserstack/ai-sdk-node@1.5.17. You can also ignore all packages with @SocketSecurity ignore-all. To ignore an alert for all future pull requests, use Socket's Dashboard to change the triage state of this alert.

Warn High
Obfuscated code: npm @react-native/debugger-frontend is 96.0% likely obfuscated

Confidence: 0.96

Location: Package overview

From: package-lock.jsonnpm/@react-native/debugger-frontend@0.81.5

ℹ Read more on: This package | This alert | What is obfuscated code?

Next steps: Take a moment to review the security alert above. Review the linked package source code to understand the potential risk. Ensure the package is not malicious before proceeding. If you're unsure how to proceed, reach out to your security team or ask the Socket team for help at support@socket.dev.

Suggestion: Packages should not obfuscate their code. Consider not using packages with obfuscated code.

Mark the package as acceptable risk. To ignore this alert only in this pull request, reply with the comment @SocketSecurity ignore npm/@react-native/debugger-frontend@0.81.5. You can also ignore all packages with @SocketSecurity ignore-all. To ignore an alert for all future pull requests, use Socket's Dashboard to change the triage state of this alert.

Warn High
Obfuscated code: npm @react-native/debugger-frontend is 96.0% likely obfuscated

Confidence: 0.96

Location: Package overview

From: package-lock.jsonnpm/@react-native/debugger-frontend@0.81.6

ℹ Read more on: This package | This alert | What is obfuscated code?

Next steps: Take a moment to review the security alert above. Review the linked package source code to understand the potential risk. Ensure the package is not malicious before proceeding. If you're unsure how to proceed, reach out to your security team or ask the Socket team for help at support@socket.dev.

Suggestion: Packages should not obfuscate their code. Consider not using packages with obfuscated code.

Mark the package as acceptable risk. To ignore this alert only in this pull request, reply with the comment @SocketSecurity ignore npm/@react-native/debugger-frontend@0.81.6. You can also ignore all packages with @SocketSecurity ignore-all. To ignore an alert for all future pull requests, use Socket's Dashboard to change the triage state of this alert.

View full report

@cimigree cimigree requested a review from ErikSin May 27, 2026 20:26

@ErikSin ErikSin left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Sorry for the delay, i just wanted to have the meeting about the invites before i fully did a review.

to answer your question:

assetlinks.json: Am I responsible for setting this up on app.comapeo.org? Without it, Android will fall back to the custom scheme only and won't intercept https:// links.

No, ill create an issue for this and we can do it later.

Just one non blocking comment that answers your question about obscure mode.

Comment thread src/frontend/Navigation/Stack/index.tsx Outdated
const activeProjectId = useActiveProjectId();
const {formatMessage} = useIntl();
const isNotReadyForInvite =
security.authState === 'unauthenticated' ||

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Obscured mode: Currently, if the app is in obscured mode (passcode screen showing), an invite link is saved and shown after unlock — same as the passcode scenario. Should invite links be suppressed entirely in obscured mode, or is showing them after unlock the right behavior?

I don't think we should show the invite link if the app is obscured. So this logic should be

cons isNotReadyForInvite = security.authstate !== authenticated ....rest

@socket-security

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Addednpm/​@​wdio/​browserstack-service@​9.21.0992510098100
Addednpm/​@​osm_borders/​maritime_10000m@​1.1.0501003776100
Addednpm/​@​formatjs/​cli@​6.8.2991004196100
Addednpm/​@​types/​lodash.isequal@​4.5.81001005780100
Addednpm/​@​react-native/​typescript-config@​0.76.91001006397100
Addednpm/​@​react-native/​metro-babel-transformer@​0.76.9991006597100
Addednpm/​@​types/​lint-staged@​13.3.01001006680100
Addednpm/​@​types/​react-native-zeroconf@​0.13.1971006978100
Addednpm/​@​comapeo/​nodejs-mobile-react-native@​18.20.4-26910010092100
Addednpm/​@​types/​react-native-indicators@​0.16.6891007178100
Addednpm/​@​react-native/​metro-config@​0.79.51001007297100
Addednpm/​@​tanstack/​eslint-plugin-query@​5.91.21001007499100
Addednpm/​babel-preset-expo@​54.0.1074100100100100
Addednpm/​@​types/​semver@​7.7.11001007581100
Addednpm/​@​react-navigation/​native-stack@​7.3.211001007599100
Addednpm/​@​comapeo/​core-react@​11.0.4751009198100
Addednpm/​@​react-navigation/​native@​7.2.21001007599100
Addednpm/​@​mapeo/​mock-data@​5.0.0751009792100
Addednpm/​@​react-navigation/​bottom-tabs@​7.15.91001007698100
Addednpm/​@​comapeo/​cloud@​0.4.0761008890100
Addednpm/​@​formatjs/​intl-pluralrules@​6.3.11001007698100
Addednpm/​@​types/​jest@​30.0.01001007781100
Addednpm/​@​formatjs/​intl-getcanonicallocales@​3.2.21001007796100
Addednpm/​@​babel/​preset-env@​7.28.5971007796100
Addednpm/​@​types/​mocha@​10.0.101001007780100
Addednpm/​@​formatjs/​intl-locale@​5.3.11001007796100
Addednpm/​@​react-native-documents/​picker@​12.0.11001007893100
Addednpm/​@​types/​react@​19.2.71001007996100
Updatednpm/​@​babel/​runtime@​7.27.1 ⏵ 7.28.41001007996100
Addednpm/​@​react-native-vector-icons/​fontisto@​13.0.0791008496100
Addednpm/​@​types/​utm@​1.1.4921008180100
Addednpm/​@​react-native-vector-icons/​octicons@​21.0.0801008496100
See 35 more rows in the dashboard

View full report

@cimigree cimigree merged commit 921f2fd into develop Jun 4, 2026
12 of 13 checks passed
@cimigree cimigree deleted the feat/deep-linking branch June 4, 2026 20:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Set Up Deep Linking

2 participants