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
+}