// Progressive enhancement only. Without this file the page still shows the // media in a swipeable rail and the original URL as selectable text. import { 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, which is the one thing the button // must not offer: followed in the browser the rewrite rules are installed // in, it redirects straight back to this page. So the link ships hidden and // is only revealed once a browser has been picked to hand it to. With no // choice made -- or none this system has a scheme for -- the copy button // and the URL below it are the whole of it. function applyBrowser(id) { const os = detectOs(); for (const link of document.querySelectorAll('.original__open')) { link.dataset.original ??= link.getAttribute('href'); const href = openUrlFor(link.dataset.original, id, os); link.href = href; link.hidden = href === link.dataset.original; } } // 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 }); } } // On iOS a video playing inline gets the "ambient" audio session, which the // Ring/Silent switch mutes -- so the post plays perfectly and says nothing, // and the only way to hear it is to go fullscreen. Claiming "playback" says // what is true here: the sound is the point, not decoration. The session is // not activated until something actually plays, so declaring it up front // interrupts nothing; it is declared only on a page that has a video so an // ordinary text post never claims it at all. function setupAudioSession() { if (!('audioSession' in navigator)) return; if (!document.querySelector('video')) return; navigator.audioSession.type = 'playback'; } // Every comment is a
, 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); setupAudioSession(); 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();