diff --git a/web/components/links.tsx b/web/components/links.tsx index 6f2debb1..203a6806 100644 --- a/web/components/links.tsx +++ b/web/components/links.tsx @@ -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 ( - + {children} ) diff --git a/web/components/widgets/linkify.tsx b/web/components/widgets/linkify.tsx index fbc64e5e..fcadb60e 100644 --- a/web/components/widgets/linkify.tsx +++ b/web/components/widgets/linkify.tsx @@ -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' } diff --git a/web/lib/util/link.ts b/web/lib/util/link.ts new file mode 100644 index 00000000..c015a301 --- /dev/null +++ b/web/lib/util/link.ts @@ -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 +}