Let the "Open on" button choose a browser
CI / Typecheck, test, build (pull_request) Successful in 28s
CI / Typecheck, test, build (pull_request) Successful in 28s
The StopTheMadness rules are indiscriminate, which is the point, but they catch the link on the way back out as well: in the browser they are installed in, "Open on <platform>" redirects straight back here. The one button meant to reach the app is the one that cannot. Handing the address to a different browser is the way past it, and the only way to do that from a page is that browser's own URL scheme. `/` gets a picker for which one; the choice lives in that browser's localStorage, because which browsers are installed is a fact about the device and the phone's answer is not the Mac's. The scheme table is its own module so a test can pin it -- every browser spells it differently, and Edge differs between macOS and iOS. Firefox has no scheme on macOS, so it is only offered on iOS, and an unknown or schemeless choice keeps the plain link rather than producing a dead one. The markup still carries the plain https address and the script swaps it afterwards, so nothing changes without JavaScript. Once a browser is chosen the button says which, since a scheme for a browser that is not installed opens nothing and the tap would otherwise be silent. Closes #7 Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01KF5YF3iZVKbezwALap8LYd
This commit is contained in:
@@ -242,6 +242,30 @@ main { max-width: 680px; margin: 0 auto; }
|
||||
.index__table td { padding: 8px 8px 8px 0; border-top: 1px solid var(--line); vertical-align: middle; }
|
||||
.index__table code { overflow-wrap: anywhere; }
|
||||
|
||||
.picker {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px 10px;
|
||||
margin-top: 18px;
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
/* The control ships hidden and is revealed by the script, and `display:
|
||||
flex` above would otherwise beat the browser's own rule for [hidden]. */
|
||||
.picker[hidden] { display: none; }
|
||||
.picker__label { font-size: 13px; color: var(--ink-dim); }
|
||||
.picker__select {
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
padding: 7px 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--card);
|
||||
color: var(--ink);
|
||||
}
|
||||
.picker__note { flex-basis: 100%; margin: 0; font-size: 12px; color: var(--ink-dim); }
|
||||
|
||||
/* ---------- verification puzzle ---------- */
|
||||
|
||||
.challenge__lede { margin: 14px; font-size: 14px; color: var(--ink-dim); }
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
// Progressive enhancement only. Without this file the page still shows the
|
||||
// media in a swipeable rail and the original URL as selectable text.
|
||||
|
||||
import { browserById, browsersFor, detectOs, openUrlFor } from './browsers.js';
|
||||
|
||||
const VIEW_KEY = 'antisocial:view';
|
||||
const BROWSER_KEY = 'antisocial:browser';
|
||||
|
||||
function setupCopy() {
|
||||
for (const button of document.querySelectorAll('.copy')) {
|
||||
@@ -37,6 +40,65 @@ function setupCopy() {
|
||||
}
|
||||
}
|
||||
|
||||
function storedBrowser() {
|
||||
try {
|
||||
return localStorage.getItem(BROWSER_KEY) ?? 'default';
|
||||
} catch {
|
||||
return 'default';
|
||||
}
|
||||
}
|
||||
|
||||
// The markup carries the plain address, so the link still goes somewhere
|
||||
// with this file missing. Only once a browser has been chosen is the href
|
||||
// swapped for that browser's scheme -- and the name goes onto the button,
|
||||
// because a scheme for a browser that is not installed opens nothing and
|
||||
// the tap would otherwise be a silent no-op.
|
||||
function applyBrowser(id) {
|
||||
const os = detectOs();
|
||||
const label = browserById(id)?.label;
|
||||
|
||||
for (const link of document.querySelectorAll('.original__open')) {
|
||||
link.dataset.original ??= link.getAttribute('href');
|
||||
link.dataset.label ??= link.textContent.trim();
|
||||
|
||||
const href = openUrlFor(link.dataset.original, id, os);
|
||||
link.href = href;
|
||||
link.textContent = href === link.dataset.original
|
||||
? link.dataset.label
|
||||
: `${link.dataset.label} in ${label}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Only on the index page, and only with this file running: the choice is
|
||||
// useless without the rewriting above, so the control ships hidden.
|
||||
function setupBrowserPicker() {
|
||||
const select = document.querySelector('.picker__select');
|
||||
if (!select) return;
|
||||
|
||||
for (const browser of browsersFor(detectOs())) {
|
||||
const option = document.createElement('option');
|
||||
option.value = browser.id;
|
||||
option.textContent = browser.label;
|
||||
select.append(option);
|
||||
}
|
||||
|
||||
// A stored id with no scheme on this system would leave the control
|
||||
// showing nothing at all, so fall back rather than render a blank.
|
||||
const stored = storedBrowser();
|
||||
select.value = [...select.options].some((option) => option.value === stored) ? stored : 'default';
|
||||
|
||||
select.addEventListener('change', () => {
|
||||
try {
|
||||
localStorage.setItem(BROWSER_KEY, select.value);
|
||||
} catch {
|
||||
// Private browsing. The choice just won't survive the page.
|
||||
}
|
||||
applyBrowser(select.value);
|
||||
});
|
||||
|
||||
select.closest('.picker').hidden = false;
|
||||
}
|
||||
|
||||
// One per rail: a post can carry several -- a thread of them, or a post and
|
||||
// the post it quotes -- and wiring only the first leaves the rest inert.
|
||||
function setupMedia(media) {
|
||||
@@ -127,6 +189,8 @@ function setupComments() {
|
||||
}
|
||||
|
||||
setupCopy();
|
||||
applyBrowser(storedBrowser());
|
||||
setupBrowserPicker();
|
||||
for (const media of document.querySelectorAll('.media')) setupMedia(media);
|
||||
setupComments();
|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
// The rewrite rules that send a shared link here catch the original link on
|
||||
// the way back out too, so in Safari "Open on <platform>" lands back on this
|
||||
// page. Handing the address to a different browser is the way out, and the
|
||||
// only way to do that from a web page is that browser's own URL scheme --
|
||||
// which every one of them spells differently. Some swap the scheme, some
|
||||
// prefix it, some take the whole address as a query parameter.
|
||||
|
||||
/** http -> googlechrome://, https -> googlechromes:// */
|
||||
const chrome = (url) => url.replace(/^http(s?):/, 'googlechrome$1:');
|
||||
|
||||
// Edge keeps the original scheme either way, but on macOS it stays in the
|
||||
// address and the scheme is prefixed, while on iOS it is folded into the
|
||||
// scheme itself.
|
||||
const edgeMacos = (url) => `microsoft-edge:${url}`;
|
||||
const edgeIos = (url) => url.replace(/^http(s?):/, 'microsoft-edge-http$1:');
|
||||
|
||||
/** Firefox and Orion take the address as a parameter instead. */
|
||||
const openUrl = (scheme) => (url) => `${scheme}://open-url?url=${encodeURIComponent(url)}`;
|
||||
|
||||
// `null` means the browser is there but ships no scheme to reach it on that
|
||||
// system, so there is nothing to offer beyond the plain link.
|
||||
export const BROWSERS = [
|
||||
{ id: 'default', label: 'Default browser', macos: null, ios: null },
|
||||
{ id: 'chrome', label: 'Chrome', macos: chrome, ios: chrome },
|
||||
{ id: 'edge', label: 'Edge', macos: edgeMacos, ios: edgeIos },
|
||||
{ id: 'firefox', label: 'Firefox', macos: null, ios: openUrl('firefox') },
|
||||
{ id: 'orion', label: 'Orion', macos: openUrl('orion'), ios: openUrl('orion') },
|
||||
];
|
||||
|
||||
/**
|
||||
* iPadOS calls itself MacIntel, so the platform string alone cannot tell the
|
||||
* two apart; the touch points can. Everything else gets the desktop table --
|
||||
* the only desktop this is ever opened on is a Mac.
|
||||
*
|
||||
* @param {{ platform?: string, maxTouchPoints?: number }} [nav]
|
||||
*/
|
||||
export function detectOs(nav = globalThis.navigator) {
|
||||
const platform = nav?.platform ?? '';
|
||||
if (/^iP(hone|ad|od)/.test(platform)) return 'ios';
|
||||
if (platform === 'MacIntel' && (nav?.maxTouchPoints ?? 0) > 1) return 'ios';
|
||||
return 'macos';
|
||||
}
|
||||
|
||||
/** The browsers worth offering here: the default, plus the ones this system
|
||||
* actually has a scheme for. */
|
||||
export function browsersFor(os) {
|
||||
return BROWSERS.filter((browser) => browser.id === 'default' || browser[os]);
|
||||
}
|
||||
|
||||
export function browserById(id) {
|
||||
return BROWSERS.find((browser) => browser.id === id);
|
||||
}
|
||||
|
||||
/**
|
||||
* The address to open. A browser with no scheme on this system, an unknown
|
||||
* id, or anything that is not an ordinary web link is handed back untouched:
|
||||
* a dead custom scheme opens nothing at all, which is worse than the plain
|
||||
* link opening in the wrong browser.
|
||||
*/
|
||||
export function openUrlFor(url, browserId, os) {
|
||||
const rewrite = browserById(browserId)?.[os];
|
||||
if (!rewrite || !/^https?:\/\//i.test(url)) return url;
|
||||
return rewrite(url);
|
||||
}
|
||||
Reference in New Issue
Block a user