Resolve Compass deep-link routing; handle internal URLs via client-side navigation instead of OS handoff

This commit is contained in:
MartinBraquet
2026-07-31 18:33:40 +02:00
parent 14c3887f03
commit ea10283614
3 changed files with 49 additions and 6 deletions

View File

@@ -1,4 +1,5 @@
import Link from 'next/link'
import {internalPathOf} from 'web/lib/util/link'
export const CustomLink = ({
href,
@@ -11,10 +12,12 @@ export const CustomLink = ({
}) => {
if (!href) return <>{children}</>
// If href is internal, use Next.js Link
if (href.startsWith('/')) {
// If href points back into Compass — including as an absolute compassmeet.com URL, which is how a
// shared link arrives in a chat message — route client-side instead of leaving the app.
const path = internalPathOf(href)
if (path) {
return (
<Link href={href} className={className}>
<Link href={path} className={className}>
{children}
</Link>
)

View File

@@ -1,6 +1,7 @@
import clsx from 'clsx'
import Link from 'next/link'
import {Fragment} from 'react'
import {internalPathOf} from 'web/lib/util/link'
import {linkClass} from './site-link'
@@ -25,10 +26,12 @@ export function Linkify(props: {text: string; className?: string}) {
const whitespace = match.match(/^\s/)
const symbol = match.trim().substring(0, 1)
const tag = match.trim().substring(1)
const href =
const rawHref =
{
'@': `/${tag}`,
}[symbol] ?? match.trim()
// A compassmeet.com URL routes client-side, so the app never hands it off to the OS browser.
const href = internalPathOf(rawHref) ?? rawHref
return (
<>
@@ -58,7 +61,7 @@ export function Linkify(props: {text: string; className?: string}) {
}
export const getLinkTarget = (href: string, newTab?: boolean) => {
// TODO: make this more robust against domain changes?
if (href.startsWith('http') && !href.startsWith(`https://compassmeet`)) return '_blank'
// mailto:/tel: keep the caller's default — only a genuine off-site page wants a new tab.
if (/^https?:\/\//i.test(href) && !internalPathOf(href)) return '_blank'
return newTab ? '_blank' : '_self'
}

37
web/lib/util/link.ts Normal file
View File

@@ -0,0 +1,37 @@
/**
* The hosts the Android app claims in its deep-link intent filter (`android/app/src/main/AndroidManifest.xml`).
* Other compassmeet subdomains (`dev.`, previews) stay external — the app doesn't intercept them, so
* rewriting one to a path would silently land the user on the *current* environment's copy of the page.
*/
const COMPASS_HOSTS = ['compassmeet.com', 'www.compassmeet.com']
/**
* The in-app path an href points at, or undefined if the link leaves Compass.
*
* A Compass link pasted into a chat message arrives as an absolute URL (`https://compassmeet.com/…`).
* Rendered as-is, the Android WebView hands it to the OS, which opens Chrome, which bounces back
* into the app through the deep-link intent filter — a visible round trip for what should be a
* client-side route change. Resolving it to a path lets `next/link` handle it directly.
*
* The current origin counts as internal too: in the WebView the app is served from `https://localhost`,
* and in local dev from `http://localhost:3000`.
*/
export const internalPathOf = (href: string): string | undefined => {
if (href.startsWith('/')) return href
const base = typeof window === 'undefined' ? undefined : window.location.href
let url: URL
try {
url = new URL(href, base)
} catch {
return undefined // mailto:, tel:, or not a URL at all
}
if (url.protocol !== 'http:' && url.protocol !== 'https:') return undefined
const isInternal =
COMPASS_HOSTS.includes(url.hostname) ||
(typeof window !== 'undefined' && url.host === window.location.host)
if (!isInternal) return undefined
return url.pathname + url.search + url.hash
}