Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 84 additions & 9 deletions resources/js/components/guest-join-sheet.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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);
Expand All @@ -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 (
<section
aria-labelledby="guest-join-sheet"
Expand Down Expand Up @@ -90,12 +97,80 @@ export function GuestJoinSheet() {
</li>
</ul>

<Button className="mt-1.5 h-12 rounded-[10px] text-base" asChild>
<a href={login().url} data-test="guest-join-sheet-login-button">
<GitHubIcon className="size-[18px]" />
Join with GitHub
</a>
</Button>
{browser === null ? (
<Button
className="mt-1.5 h-12 rounded-[10px] text-base"
asChild
>
<a
href={login().url}
data-test="guest-join-sheet-login-button"
>
<GitHubIcon className="size-[18px]" />
Join with GitHub
</a>
</Button>
) : (
<OpenInPhoneBrowser browser={browser} />
)}
</section>
);
}

/**
* 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 (
<>
<p
className="text-[15px] leading-snug text-muted-foreground"
data-test="guest-join-sheet-in-app"
>
You&rsquo;re in {browser.app ? `${browser.app}’s` : 'an app’s'}{' '}
browser, which isn&rsquo;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.`}
</p>
<div className="mt-1.5 flex flex-col items-center gap-3">
<Button
className="h-12 w-full rounded-[10px] text-base"
asChild
>
<a
href={phoneBrowserUrl(
new URL(window.location.href),
browser.platform,
)}
onClick={open}
data-test="guest-join-sheet-open-browser-button"
>
<Compass className="size-[18px]" />
Open in{' '}
{browser.platform === 'ios' ? 'Safari' : 'browser'}
</a>
</Button>
<a
href={login().url}
className="text-sm text-muted-foreground underline underline-offset-4"
data-test="guest-join-sheet-login-button"
>
Join with GitHub here instead
</a>
</div>
</>
);
}
55 changes: 55 additions & 0 deletions resources/js/lib/in-app-browser.ts
Original file line number Diff line number Diff line change
@@ -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;
Comment thread
mnapoli marked this conversation as resolved.

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}${url.hash}#Intent;scheme=https;end`;
}
46 changes: 46 additions & 0 deletions tests/Unit/InAppBrowser.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
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#reply-1');

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',
);
});
Loading