Files
antisocial/public/app.js
T
thatguygriffandClaude Opus 5 abf8ec317c
CI / Typecheck, test, build (pull_request) Successful in 28s
Let the "Open on" button choose a browser
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
2026-08-29 19:36:00 -03:00

336 lines
11 KiB
JavaScript

// 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')) {
button.addEventListener('click', async () => {
const url = button.dataset.url;
if (!url) return;
try {
await navigator.clipboard.writeText(url);
} catch {
// No clipboard permission, or an insecure context. Select the URL
// instead so one keystroke finishes the job.
const code = button.closest('.original')?.querySelector('code');
if (code) {
const range = document.createRange();
range.selectNodeContents(code);
const selection = getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
}
return;
}
button.dataset.copied = 'true';
button.querySelector('.copy__idle')?.setAttribute('hidden', '');
button.querySelector('.copy__done')?.removeAttribute('hidden');
setTimeout(() => {
delete button.dataset.copied;
button.querySelector('.copy__idle')?.removeAttribute('hidden');
button.querySelector('.copy__done')?.setAttribute('hidden', '');
}, 1600);
});
}
}
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) {
const rail = media.querySelector('.media__rail');
const items = [...media.querySelectorAll('.item')];
const at = media.querySelector('.media__at');
const buttons = [...media.querySelectorAll('.media__view')];
if (!rail || items.length < 2) return;
function applyView(view, remember) {
media.dataset.view = view;
for (const button of buttons) {
button.setAttribute('aria-pressed', String(button.dataset.view === view));
}
if (remember) {
try {
localStorage.setItem(VIEW_KEY, view);
} catch {
// Private browsing. The choice just won't survive the page.
}
}
}
let stored = null;
try {
stored = localStorage.getItem(VIEW_KEY);
} catch {
stored = null;
}
applyView(stored === 'grid' ? 'grid' : 'swipe', false);
for (const button of buttons) {
button.addEventListener('click', () => applyView(button.dataset.view, true));
}
// Tapping a thumbnail goes back to the swiper, at that image.
for (const item of items) {
item.addEventListener('click', (event) => {
if (media.dataset.view !== 'grid') return;
if (event.target.closest('video')) return;
applyView('swipe', true);
item.scrollIntoView({ inline: 'center', block: 'nearest' });
});
}
// Keep the counter honest as the rail scrolls.
if (at) {
let frame = 0;
rail.addEventListener('scroll', () => {
if (frame) return;
frame = requestAnimationFrame(() => {
frame = 0;
const index = Math.round(rail.scrollLeft / rail.clientWidth);
at.textContent = String(Math.min(items.length, Math.max(1, index + 1)));
});
}, { passive: true });
}
}
// Every comment is a <details open>, so folding one already works with this
// file missing. All this adds is doing the whole page at once, which is why
// the button ships hidden and is only revealed here.
function setupComments() {
const toggle = document.querySelector('.thread__toggle');
if (!toggle) return;
const all = [...document.querySelectorAll('details.c')];
if (all.length === 0) return;
toggle.hidden = false;
toggle.addEventListener('click', () => {
const collapsing = toggle.dataset.collapsed !== 'true';
for (const comment of all) comment.open = !collapsing;
toggle.dataset.collapsed = String(collapsing);
toggle.textContent = collapsing ? 'Expand all' : 'Collapse all';
});
// Reopening one by hand means the page is no longer collapsed, so the
// button should offer the other thing.
for (const comment of all) {
comment.addEventListener('toggle', () => {
if (comment.open && toggle.dataset.collapsed === 'true') {
toggle.dataset.collapsed = 'false';
toggle.textContent = 'Collapse all';
}
});
}
}
setupCopy();
applyBrowser(storedBrowser());
setupBrowserPicker();
for (const media of document.querySelectorAll('.media')) setupMedia(media);
setupComments();
// --- Verification puzzles -------------------------------------------------
//
// The puzzle is rendered in the browser on the server, not this one. So the
// page shows a live screenshot of it and forwards pointer events back to be
// replayed. Solving it leaves the cookie in that browser, and the post then
// loads normally.
function setupChallenge() {
const article = document.querySelector('[data-challenge]');
if (!article) return;
const id = article.dataset.challenge;
const frame = document.getElementById('frame');
const status = document.getElementById('status');
const stage = document.getElementById('stage');
const giveUp = document.getElementById('give-up');
let box = parseBox(stage.dataset.box);
let dragging = false;
let lastSent = 0;
let stopped = false;
function parseBox(value) {
const parts = (value || '').split(',').map(Number);
return parts.length === 4 && parts.every(Number.isFinite)
? { x: parts[0], y: parts[1], width: parts[2], height: parts[3] }
: null;
}
// Self-paced: the next frame is only asked for once the last one painted,
// so a slow screenshot slows the stream instead of queueing behind itself.
function pump() {
if (stopped) return;
frame.src = `/challenge/${id}/frame?t=${Date.now()}`;
}
frame.addEventListener('load', () => {
status.hidden = true;
setTimeout(pump, dragging ? 60 : 220);
});
frame.addEventListener('error', () => {
if (!stopped) setTimeout(pump, 1000);
});
pump();
function toPageCoords(event) {
const rect = frame.getBoundingClientRect();
if (!rect.width || !rect.height) return null;
const fx = (event.clientX - rect.left) / rect.width;
const fy = (event.clientY - rect.top) / rect.height;
// Without a clip box the screenshot is the whole viewport, whose size
// the server knows; fall back to the image's own pixel dimensions.
const region = box ?? { x: 0, y: 0, width: frame.naturalWidth / 2, height: frame.naturalHeight / 2 };
return { x: region.x + fx * region.width, y: region.y + fy * region.height };
}
async function send(type, point) {
try {
const response = await fetch(`/challenge/${id}/input`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ type, x: point.x, y: point.y }),
});
if (response.status === 410) finish();
} catch {
// A dropped event during a drag is survivable; the next one follows.
}
}
frame.addEventListener('pointerdown', (event) => {
const point = toPageCoords(event);
if (!point) return;
event.preventDefault();
dragging = true;
frame.setPointerCapture(event.pointerId);
void send('down', point);
});
frame.addEventListener('pointermove', (event) => {
if (!dragging) return;
const now = Date.now();
if (now - lastSent < 25) return;
lastSent = now;
const point = toPageCoords(event);
if (point) void send('move', point);
});
for (const type of ['pointerup', 'pointercancel']) {
frame.addEventListener(type, async (event) => {
if (!dragging) return;
dragging = false;
const point = toPageCoords(event);
if (point) await send('up', point);
status.hidden = false;
status.textContent = 'Checking…';
void poll();
});
}
async function poll() {
for (let attempt = 0; attempt < 12 && !stopped; attempt += 1) {
await new Promise((r) => setTimeout(r, 500));
try {
const response = await fetch(`/challenge/${id}/state`);
if (response.status === 410) return finish();
const state = await response.json();
if (state.box) box = state.box;
if (state.solved) return finish();
} catch {
// Keep trying; the page may be mid-navigation.
}
}
status.textContent = 'Still there — have another go.';
}
async function finish() {
if (stopped) return;
stopped = true;
status.hidden = false;
status.textContent = 'Got it. Loading the post…';
try {
const response = await fetch(`/challenge/${id}/done`, { method: 'POST' });
const { returnTo } = await response.json();
location.href = returnTo || '/';
} catch {
location.reload();
}
}
giveUp?.addEventListener('click', () => {
stopped = true;
void fetch(`/challenge/${id}/done`, { method: 'POST' }).finally(() => {
article.querySelector('.original__url')?.scrollIntoView({ block: 'center' });
status.textContent = 'Gave up. The original link is below.';
status.hidden = false;
});
});
}
setupChallenge();