From 8e4e92ce7e39948b52b0f527e3a46ddbbd280f08 Mon Sep 17 00:00:00 2001 From: Matthieu Napoli Date: Tue, 27 Oct 2026 11:30:47 +0100 Subject: [PATCH 1/2] feat: offer to open the page in the phone's browser from an app's browser --- resources/js/components/guest-join-sheet.tsx | 93 ++++++++++++++++++-- resources/js/lib/in-app-browser.ts | 55 ++++++++++++ tests/Unit/InAppBrowser.test.ts | 43 +++++++++ 3 files changed, 182 insertions(+), 9 deletions(-) create mode 100644 resources/js/lib/in-app-browser.ts create mode 100644 tests/Unit/InAppBrowser.test.ts diff --git a/resources/js/components/guest-join-sheet.tsx b/resources/js/components/guest-join-sheet.tsx index f06e51327..1cf3de47a 100644 --- a/resources/js/components/guest-join-sheet.tsx +++ b/resources/js/components/guest-join-sheet.tsx @@ -1,7 +1,9 @@ -import { GitCommitVertical, UsersRound, X } from 'lucide-react'; -import { useSyncExternalStore } from 'react'; +import { Compass, GitCommitVertical, UsersRound, X } from 'lucide-react'; +import { useState, useSyncExternalStore } from 'react'; import { GitHubIcon } from '@/components/github-icon'; import { Button } from '@/components/ui/button'; +import type { InAppBrowser } from '@/lib/in-app-browser'; +import { inAppBrowser, phoneBrowserUrl } from '@/lib/in-app-browser'; import { login } from '@/routes'; const DISMISSED = 'devnull:join-sheet-dismissed'; @@ -35,7 +37,9 @@ function dismiss(): void { /** * On a phone, guests don't see the aside that tells desktop visitors what /dev/null is, so this sheet does, with the * button where the thumb is. It stays out of the server render and opens once the page has loaded, unless the guest - * closed it earlier in this browser session. Signing in leaves for GitHub, so the button is a plain link. + * closed it earlier in this browser session. Signing in leaves for GitHub, so the button is a plain link. In an app's + * browser, it first offers to open the page in the phone's browser, where the guest may already be signed in to + * GitHub. */ export function GuestJoinSheet() { const isHidden = useSyncExternalStore(subscribe, isDismissed, () => true); @@ -44,6 +48,9 @@ export function GuestJoinSheet() { return null; } + // The sheet never renders on the server, so the user agent is there to read... + const browser = inAppBrowser(navigator.userAgent); + return (
- + {browser === null ? ( + + ) : ( + + )}
); } + +/** + * Opening the phone's browser from a web view can fail silently, with an app that blocks the link or an iOS version + * that ignores it. If the page is still showing a moment later, the guest learns the way that always works. + */ +function OpenInPhoneBrowser({ browser }: { browser: InAppBrowser }) { + const [hasFailed, setHasFailed] = useState(false); + const name = browser.platform === 'ios' ? 'Safari' : 'your browser'; + + const open = () => { + setTimeout(() => { + if (document.visibilityState === 'visible') { + setHasFailed(true); + } + }, 1500); + }; + + return ( + <> +

+ You’re in {browser.app ? `${browser.app}’s` : 'an app’s'}{' '} + browser, which isn’t signed in to GitHub.{' '} + {hasFailed + ? `Nothing opened? Use the app’s menu (⋯) to open this page in ${name}.` + : `In ${name}, you may already be.`} +

+
+ + + Join with GitHub here instead + +
+ + ); +} diff --git a/resources/js/lib/in-app-browser.ts b/resources/js/lib/in-app-browser.ts new file mode 100644 index 000000000..e1e6a5419 --- /dev/null +++ b/resources/js/lib/in-app-browser.ts @@ -0,0 +1,55 @@ +export type InAppBrowser = { + /** The app showing the page, when its user agent tells. */ + app: string | null; + platform: 'ios' | 'android'; +}; + +const APPS: [RegExp, string][] = [ + [/Twitter/i, 'X'], + [/Instagram/, 'Instagram'], + [/FBAN|FBAV|FB_IAB/, 'Facebook'], + [/LinkedInApp/, 'LinkedIn'], + [/Barcelona/, 'Threads'], + [/BytedanceWebview|musical_ly/, 'TikTok'], + [/Snapchat/, 'Snapchat'], +]; + +/** + * The browser an app opens links in on a phone, rather than the phone's own browser. It is signed in to nothing, so + * signing in with GitHub there means typing a password and a 2FA code, without the passkey or saved password Safari or + * Chrome would offer. Known apps name themselves in the user agent; other web views drop Safari's token on iOS, or + * add `wv` on Android. Apps that open links in Safari's own view look like Safari and aren't told apart. + */ +export function inAppBrowser(userAgent: string): InAppBrowser | null { + const platform = /iPhone|iPad|iPod/.test(userAgent) + ? 'ios' + : /Android/.test(userAgent) + ? 'android' + : null; + + if (platform === null) { + return null; + } + + const app = APPS.find(([pattern]) => pattern.test(userAgent))?.[1] ?? null; + const isWebView = + app !== null || + (platform === 'ios' + ? !/Safari\//.test(userAgent) + : /; wv\)/.test(userAgent)); + + return isWebView ? { app, platform } : null; +} + +/** + * A link that opens the page in the phone's browser: Safari's own scheme on iOS, which Apple doesn't document but + * works from web views since iOS 17, or an intent for the default browser on Android. + */ +export function phoneBrowserUrl( + url: URL, + platform: InAppBrowser['platform'], +): string { + return platform === 'ios' + ? `x-safari-${url.href}` + : `intent://${url.host}${url.pathname}${url.search}#Intent;scheme=https;end`; +} diff --git a/tests/Unit/InAppBrowser.test.ts b/tests/Unit/InAppBrowser.test.ts new file mode 100644 index 000000000..d40bda9e1 --- /dev/null +++ b/tests/Unit/InAppBrowser.test.ts @@ -0,0 +1,43 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { inAppBrowser, phoneBrowserUrl } from '../../resources/js/lib/in-app-browser.ts'; + +const SAFARI = + 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.6 Mobile/15E148 Safari/604.1'; +const IOS_WEB_VIEW = + 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148'; +const CHROME_ANDROID = + 'Mozilla/5.0 (Linux; Android 15; Pixel 9) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Mobile Safari/537.36'; +const ANDROID_WEB_VIEW = + 'Mozilla/5.0 (Linux; Android 15; Pixel 9; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/141.0.0.0 Mobile Safari/537.36'; + +await test('tells the app whose browser shows the page, when it names itself', () => { + assert.deepEqual(inAppBrowser(`${IOS_WEB_VIEW} Twitter for iPhone`), { app: 'X', platform: 'ios' }); + assert.deepEqual(inAppBrowser(`${IOS_WEB_VIEW} Instagram 400.0.0.0`), { app: 'Instagram', platform: 'ios' }); + assert.deepEqual(inAppBrowser(`${ANDROID_WEB_VIEW} [FB_IAB/FB4A;FBAV/500.0.0.0;]`), { app: 'Facebook', platform: 'android' }); + assert.deepEqual(inAppBrowser(`${SAFARI} LinkedInApp/9.30`), { app: 'LinkedIn', platform: 'ios' }); +}); + +await test('tells web views of apps that do not name themselves', () => { + assert.deepEqual(inAppBrowser(IOS_WEB_VIEW), { app: null, platform: 'ios' }); + assert.deepEqual(inAppBrowser(ANDROID_WEB_VIEW), { app: null, platform: 'android' }); +}); + +await test('leaves the phone browsers and computers alone', () => { + for (const userAgent of [ + SAFARI, + CHROME_ANDROID, + 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/141.0.0.0 Mobile/15E148 Safari/604.1', + 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) FxiOS/144.0 Mobile/15E148 Safari/605.1.15', + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 Safari/605.1.15', + ]) { + assert.equal(inAppBrowser(userAgent), null); + } +}); + +await test('opens the page in Safari on iOS and in the default browser on Android', () => { + const url = new URL('https://dev-null.sh/@mnapoli/log/abc?tab=replies'); + + assert.equal(phoneBrowserUrl(url, 'ios'), 'x-safari-https://dev-null.sh/@mnapoli/log/abc?tab=replies'); + assert.equal(phoneBrowserUrl(url, 'android'), 'intent://dev-null.sh/@mnapoli/log/abc?tab=replies#Intent;scheme=https;end'); +}); From def5a6b0615322829d716468f5f33bc4d1629126 Mon Sep 17 00:00:00 2001 From: Matthieu Napoli Date: Tue, 27 Oct 2026 18:17:29 +0100 Subject: [PATCH 2/2] fix: keep the fragment when opening the page in the Android browser --- resources/js/lib/in-app-browser.ts | 2 +- tests/Unit/InAppBrowser.test.ts | 9 ++++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/resources/js/lib/in-app-browser.ts b/resources/js/lib/in-app-browser.ts index e1e6a5419..931d96709 100644 --- a/resources/js/lib/in-app-browser.ts +++ b/resources/js/lib/in-app-browser.ts @@ -51,5 +51,5 @@ export function phoneBrowserUrl( ): string { return platform === 'ios' ? `x-safari-${url.href}` - : `intent://${url.host}${url.pathname}${url.search}#Intent;scheme=https;end`; + : `intent://${url.host}${url.pathname}${url.search}${url.hash}#Intent;scheme=https;end`; } diff --git a/tests/Unit/InAppBrowser.test.ts b/tests/Unit/InAppBrowser.test.ts index d40bda9e1..50b6ada75 100644 --- a/tests/Unit/InAppBrowser.test.ts +++ b/tests/Unit/InAppBrowser.test.ts @@ -36,8 +36,11 @@ await test('leaves the phone browsers and computers alone', () => { }); await test('opens the page in Safari on iOS and in the default browser on Android', () => { - const url = new URL('https://dev-null.sh/@mnapoli/log/abc?tab=replies'); + const url = new URL('https://dev-null.sh/@mnapoli/log/abc?tab=replies#reply-1'); - assert.equal(phoneBrowserUrl(url, 'ios'), 'x-safari-https://dev-null.sh/@mnapoli/log/abc?tab=replies'); - assert.equal(phoneBrowserUrl(url, 'android'), 'intent://dev-null.sh/@mnapoli/log/abc?tab=replies#Intent;scheme=https;end'); + assert.equal(phoneBrowserUrl(url, 'ios'), 'x-safari-https://dev-null.sh/@mnapoli/log/abc?tab=replies#reply-1'); + assert.equal( + phoneBrowserUrl(url, 'android'), + 'intent://dev-null.sh/@mnapoli/log/abc?tab=replies#reply-1#Intent;scheme=https;end', + ); });