import { h, render } from 'https://esm.sh/preact@10.25.4'; import { useState, useEffect, useCallback, useRef, useMemo, useReducer } from 'https://esm.sh/preact@10.25.4/hooks'; import htm from 'https://esm.sh/htm@3.1.1'; const html = htm.bind(h); // ── Constants & helpers ───────────────────────────────────────────── const ALGOLIA = 'https://hn.algolia.com/api/v1'; const CACHE_PFX = 'hn3_'; const READ_KEY = 'hn3_read'; const COLLAPSED_PFX = 'hn3_col_'; const DAY_MS = 86400000; const DAY_S = 86400; const COLLAPSED_TTL = 7 * DAY_MS; const todayUTC = () => { const d = new Date(); return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate())); }; const dateKey = d => d.toISOString().slice(0, 10); const fmtDate = d => d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' }); const ago = t => { const s = Math.floor(Date.now() / 1000 - t); if (s < 60) return s + 's'; if (s < 3600) return Math.floor(s / 60) + 'm'; if (s < DAY_S) return Math.floor(s / 3600) + 'h'; return Math.floor(s / DAY_S) + 'd'; }; const host = url => { if (!url) return ''; try { return new URL(url).hostname.replace(/^www\./, ''); } catch { return ''; } }; const hnItemId = url => { try { const u = new URL(url); if (u.hostname === 'news.ycombinator.com' && u.pathname === '/item') { const id = parseInt(u.searchParams.get('id')); if (id) return id; } } catch {} return null; }; // ── LocalStorage helpers ──────────────────────────────────────────── const cacheSet = (k, v) => { try { localStorage.setItem(CACHE_PFX + k, JSON.stringify(v)); } catch {} }; const cacheGet = k => { try { return JSON.parse(localStorage.getItem(CACHE_PFX + k)); } catch { return null; } }; const loadReadIds = () => { try { return new Set(JSON.parse(localStorage.getItem(READ_KEY) || '[]')); } catch { return new Set(); } }; const saveReadIds = ids => { try { localStorage.setItem(READ_KEY, JSON.stringify([...ids])); } catch {} }; const loadCollapsed = storyId => { try { const raw = JSON.parse(localStorage.getItem(COLLAPSED_PFX + storyId)); if (!raw || Date.now() - raw.ts > COLLAPSED_TTL) return new Set(); return new Set(raw.ids); } catch { return new Set(); } }; const saveCollapsed = (storyId, set) => { try { if (set.size === 0) localStorage.removeItem(COLLAPSED_PFX + storyId); else localStorage.setItem(COLLAPSED_PFX + storyId, JSON.stringify({ ids: [...set], ts: Date.now() })); } catch {} }; // ── API ───────────────────────────────────────────────────────────── const fetchStories = async (day) => { const start = day.getTime() / 1000; const end = start + DAY_S; const url = `${ALGOLIA}/search_by_date?tags=story&hitsPerPage=1000&numericFilters=created_at_i>=${start},created_at_i<${end}`; const r = await fetch(url); if (!r.ok) throw new Error('HTTP ' + r.status); const json = await r.json(); return (json.hits || []).map(h => ({ id: parseInt(h.objectID), title: h.title || '(untitled)', url: h.url || null, text: h.story_text || null, score: h.points || 0, by: h.author || '', time: h.created_at_i || 0, descendants: h.num_comments || 0, })); }; const fetchComments = async (storyId, storyAuthor) => { const url = `${ALGOLIA}/search?tags=comment,story_${storyId}&hitsPerPage=500`; const r = await fetch(url); const json = await r.json(); const hits = (json.hits || []).filter(h => h.author && !h._deleted_); if (!hits.length) return []; const childrenOf = {}; hits.forEach(h => { const p = String(h.parent_id); if (!childrenOf[p]) childrenOf[p] = []; childrenOf[p].push(h); }); const flat = []; const walk = (parentId, depth) => { (childrenOf[parentId] || []).sort((a, b) => a.created_at_i - b.created_at_i).forEach(h => { flat.push({ id: h.objectID, by: h.author, text: h.comment_text || '', time: h.created_at_i || 0, depth, isOp: h.author === storyAuthor, }); walk(h.objectID, depth + 1); }); }; walk(String(storyId), 0); return flat; }; const fetchStoryItem = async (id) => { const r = await fetch(`${ALGOLIA}/items/${id}`); const item = await r.json(); return { id: item.id, title: item.title || '(untitled)', url: item.url || null, text: item.text || null, score: item.points || 0, by: item.author || '', time: item.created_at_i || 0, descendants: item.children ? item.children.length : 0, }; }; // ── Hash / history helpers ────────────────────────────────────────── const parseHash = () => { const h = location.hash.slice(1); if (!h) return { date: null, storyId: null }; const parts = h.split('/'); if (/^\d{4}-\d{2}-\d{2}$/.test(parts[0])) { return { date: parts[0], storyId: parts[1] ? parseInt(parts[1]) : null }; } return { date: null, storyId: parseInt(parts[0]) || null }; }; const dateHash = (day, storyId) => { const d = dateKey(day); return storyId ? `${d}/${storyId}` : d; }; // ── Reducer ───────────────────────────────────────────────────────── const initState = (day) => ({ currentDay: day, stories: [], readIds: loadReadIds(), loading: false, loadGeneration: 0, error: null, drawerStoryId: null, drawerStory: null, drawerOwnsHistory: false, comments: null, // null | 'loading' | [] | 'error' offlineFlash: false, }); const reducer = (state, action) => { switch (action.type) { case 'SET_DAY': return { ...state, currentDay: action.day, stories: [], error: null, loadGeneration: state.loadGeneration + 1 }; case 'LOAD_START': return { ...state, loading: true, error: null }; case 'LOAD_CACHED': if (action.generation !== state.loadGeneration) return state; return { ...state, stories: action.stories }; case 'LOAD_SUCCESS': if (action.generation !== state.loadGeneration) return state; return { ...state, stories: action.stories, loading: false }; case 'LOAD_FAIL': if (action.generation !== state.loadGeneration) return state; return { ...state, error: action.error, loading: false }; case 'MARK_READ': { const readIds = new Set(state.readIds); readIds.add(action.id); return { ...state, readIds }; } case 'OPEN_DRAWER': return { ...state, drawerStoryId: action.storyId, drawerStory: action.story || null, drawerOwnsHistory: action.pushHistory, comments: 'loading' }; case 'SET_DRAWER_STORY': return { ...state, drawerStory: action.story }; case 'SET_COMMENTS': return { ...state, comments: action.comments }; case 'CLOSE_DRAWER': return { ...state, drawerStoryId: null, drawerStory: null, drawerOwnsHistory: false, comments: null }; case 'FLASH_OFFLINE': return { ...state, offlineFlash: action.show }; default: return state; } }; // ── Components ────────────────────────────────────────────────────── function Status({ icon, message, isError }) { return html`
${message}
`; } function Story({ story, index, isRead, animate, onOpen }) { const d = host(story.url); return html`
onOpen(story.id)}>
${index + 1}
${!isRead && html``} ${story.title} ${d ? html` ${d}` : null}
${ago(story.time)} ${story.by}
`; } function Feed({ stories, readIds, loading, error, animate, onOpenStory }) { const filtered = useMemo( () => stories.filter(s => s.descendants > 0 || s.score > 0).sort((a, b) => b.descendants - a.descendants), [stories] ); if (loading && !filtered.length) { return html`
<${Status} icon="\u25CC" message="loading\u2026" />
`; } if (error && !filtered.length) { return html`
<${Status} icon="\u2715" message="failed to load \u2014 check connection" isError />
`; } if (!filtered.length) { return html`
<${Status} icon="\u25EF" message="no stories" />
`; } return html`
${filtered.map((s, i) => html` <${Story} key=${s.id} story=${s} index=${i} isRead=${readIds.has(s.id)} animate=${animate} onOpen=${onOpenStory} /> `)}
`; } function Comment({ comment, collapsed, onToggle }) { return html`
{ if (e.target.closest('a') || window.getSelection().toString()) return; onToggle(comment.id); }}>
${comment.by}${comment.isOp ? ' \u2605' : ''} ${ago(comment.time)}
thread collapsed
`; } function CommentList({ comments, storyId, onOpenStory }) { const [collapsed, setCollapsed] = useState(() => loadCollapsed(storyId)); // Load persisted collapsed state when story changes useEffect(() => { setCollapsed(loadCollapsed(storyId)); }, [storyId]); // Persist collapsed state useEffect(() => { if (storyId) saveCollapsed(storyId, collapsed); }, [storyId, collapsed]); const toggle = useCallback((id) => { setCollapsed(prev => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; }); }, []); if (comments === 'loading') { return html`
loading comments\u2026
`; } if (comments === 'error') { return html`
failed to load comments
`; } if (!comments || !comments.length) { return html`
no comments yet
`; } // Filter out children of collapsed comments const visible = []; const collapsedDepths = []; // stack of [id, depth] of collapsed ancestors for (const c of comments) { // Pop ancestors that are at same or deeper depth (we've moved past their subtree) while (collapsedDepths.length && collapsedDepths[collapsedDepths.length - 1][1] >= c.depth) { collapsedDepths.pop(); } if (collapsedDepths.length) continue; // hidden by a collapsed ancestor visible.push(c); if (collapsed.has(c.id)) collapsedDepths.push([c.id, c.depth]); } const handleClick = useCallback(e => { const a = e.target.closest('a[href]'); if (!a) return; const id = hnItemId(a.href); if (!id) return; e.preventDefault(); onOpenStory(id); }, [onOpenStory]); return html`
${visible.map(c => html` <${Comment} key=${c.id} comment=${c} collapsed=${collapsed.has(c.id)} onToggle=${toggle} /> `)}
`; } function Drawer({ storyId, story, comments, onClose, onOpenStory, drawerRef }) { if (!story) { return html`
loading\u2026
`; } const d = host(story.url); return html`
${story.title}
${story.descendants} comments ${story.by} ${ago(story.time)}
${story.url ? html` ${'\u2197 ' + (d || 'open article')} ` : null}
${story.text ? html`
` : null} <${CommentList} comments=${comments} storyId=${storyId} onOpenStory=${onOpenStory} />
`; } function PullToRefresh({ state }) { return html`
${ state === 'ready' ? 'release to refresh' : state === 'refreshing' ? 'refreshing\u2026' : 'pull to refresh' }
`; } function Header({ currentDay, onPrev, onNext }) { const isToday = dateKey(currentDay) >= dateKey(todayUTC()); return html` `; } function OfflineToast({ show }) { return html`
\u26A1 offline \u2014 cached stories
`; } // ── App ───────────────────────────────────────────────────────────── function App() { const { date: initDate } = parseHash(); const initDay = useMemo(() => { if (initDate) { const d = new Date(initDate + 'T00:00:00Z'); if (d <= todayUTC()) return d; } return todayUTC(); }, []); const [state, dispatch] = useReducer(reducer, initDay, initState); const { currentDay, stories, readIds, loading, loadGeneration, error, drawerStoryId, drawerStory, drawerOwnsHistory, comments, offlineFlash } = state; const animateRef = useRef(true); const drawerRef = useRef(null); const dismissingRef = useRef(false); const ptrYRef = useRef(0); const ptrOnRef = useRef(false); const ptrFiredRef = useRef(false); const [ptrState, setPtrState] = useState('idle'); // ── Load stories ────────────────────────────────────────────── const load = useCallback(async (day, generation) => { dispatch({ type: 'LOAD_START' }); const key = 's_' + dateKey(day); const cached = cacheGet(key); if (cached) { animateRef.current = false; dispatch({ type: 'LOAD_CACHED', stories: cached, generation }); } try { const fresh = await fetchStories(day); cacheSet(key, fresh); if (!cached) animateRef.current = true; dispatch({ type: 'LOAD_SUCCESS', stories: fresh, generation }); } catch (err) { console.error(err); if (!cached) { dispatch({ type: 'LOAD_FAIL', error: err.message, generation }); } else { dispatch({ type: 'FLASH_OFFLINE', show: true }); setTimeout(() => dispatch({ type: 'FLASH_OFFLINE', show: false }), 3000); } } }, []); // Initial load + loads when day changes useEffect(() => { load(currentDay, loadGeneration); }, [currentDay, loadGeneration]); // ── Read tracking persistence ───────────────────────────────── useEffect(() => { saveReadIds(readIds); }, [readIds]); // ── Drawer: body overflow ───────────────────────────────────── useEffect(() => { document.body.style.overflow = drawerStoryId ? 'hidden' : ''; }, [drawerStoryId]); // ── Drawer: fetch comments + story metadata ─────────────────── useEffect(() => { if (!drawerStoryId) return; let cancelled = false; // Find story in feed or fetch independently const s = stories.find(x => x.id === drawerStoryId); if (s) { dispatch({ type: 'SET_DRAWER_STORY', story: s }); } else { fetchStoryItem(drawerStoryId) .then(item => { if (!cancelled) dispatch({ type: 'SET_DRAWER_STORY', story: item }); }) .catch(() => {}); } // Fetch comments const author = s ? s.by : null; fetchComments(drawerStoryId, author) .then(flat => { if (cancelled) return; dispatch({ type: 'SET_COMMENTS', comments: flat.length ? flat : [] }); }) .catch(() => { if (!cancelled) dispatch({ type: 'SET_COMMENTS', comments: 'error' }); }); return () => { cancelled = true; }; }, [drawerStoryId, stories]); // ── Open story ──────────────────────────────────────────────── const openStory = useCallback((id, pushHistory = true) => { dispatch({ type: 'MARK_READ', id }); const s = stories.find(x => x.id === id); dispatch({ type: 'OPEN_DRAWER', storyId: id, story: s || null, pushHistory }); if (pushHistory) { history.pushState({ storyId: id }, '', '#' + dateHash(currentDay, id)); } }, [stories, currentDay]); const dismissDrawer = useCallback(() => { dispatch({ type: 'CLOSE_DRAWER' }); if (drawerOwnsHistory) { dismissingRef.current = true; history.back(); } }, [drawerOwnsHistory]); // ── Drawer swipe-to-close ───────────────────────────────────── useEffect(() => { const el = drawerRef.current; if (!el) return; let tsY = 0; const onStart = e => { tsY = e.touches[0].clientY; }; const onEnd = e => { const scrollEl = el.querySelector('#d-scroll'); if (e.changedTouches[0].clientY - tsY > 80 && scrollEl && scrollEl.scrollTop === 0) { dismissDrawer(); } }; el.addEventListener('touchstart', onStart, { passive: true }); el.addEventListener('touchend', onEnd, { passive: true }); return () => { el.removeEventListener('touchstart', onStart); el.removeEventListener('touchend', onEnd); }; }, [dismissDrawer]); // ── Date navigation ─────────────────────────────────────────── const navigate = useCallback((delta) => { const newDay = new Date(currentDay.getTime() + delta * DAY_MS); if (delta > 0 && dateKey(newDay) > dateKey(todayUTC())) return; dispatch({ type: 'SET_DAY', day: newDay }); animateRef.current = true; history.pushState({ date: dateKey(newDay) }, '', '#' + dateKey(newDay)); }, [currentDay]); // ── Pull-to-refresh ─────────────────────────────────────────── useEffect(() => { const onStart = e => { if (window.scrollY === 0 && !drawerStoryId) { ptrYRef.current = e.touches[0].clientY; ptrOnRef.current = true; ptrFiredRef.current = false; } }; const onMove = e => { if (!ptrOnRef.current) return; const dy = e.touches[0].clientY - ptrYRef.current; if (dy > 10) { setPtrState(dy > 75 ? 'ready' : 'pulling'); if (dy > 75) ptrFiredRef.current = true; } }; const onEnd = async () => { if (!ptrOnRef.current) return; ptrOnRef.current = false; if (ptrFiredRef.current) { setPtrState('refreshing'); await load(currentDay, loadGeneration); setPtrState('idle'); } else { setPtrState('idle'); } ptrFiredRef.current = false; }; document.addEventListener('touchstart', onStart, { passive: true }); document.addEventListener('touchmove', onMove, { passive: true }); document.addEventListener('touchend', onEnd, { passive: true }); return () => { document.removeEventListener('touchstart', onStart); document.removeEventListener('touchmove', onMove); document.removeEventListener('touchend', onEnd); }; }, [drawerStoryId, currentDay, loadGeneration, load]); // ── Popstate ────────────────────────────────────────────────── useEffect(() => { const handler = () => { if (dismissingRef.current) { dismissingRef.current = false; return; } const { date, storyId } = parseHash(); const newDay = date ? new Date(date + 'T00:00:00Z') : todayUTC(); if (dateKey(newDay) !== dateKey(currentDay)) { dispatch({ type: 'CLOSE_DRAWER' }); dispatch({ type: 'SET_DAY', day: newDay }); } else if (storyId) { openStory(storyId, false); } else { dispatch({ type: 'CLOSE_DRAWER' }); } }; window.addEventListener('popstate', handler); return () => window.removeEventListener('popstate', handler); }, [currentDay, openStory]); // ── Offline event ───────────────────────────────────────────── useEffect(() => { const handler = () => { dispatch({ type: 'FLASH_OFFLINE', show: true }); setTimeout(() => dispatch({ type: 'FLASH_OFFLINE', show: false }), 3000); }; window.addEventListener('offline', handler); return () => window.removeEventListener('offline', handler); }, []); // ── Open story from initial hash ────────────────────────────── useEffect(() => { const { storyId } = parseHash(); if (storyId) openStory(storyId, false); }, []); // ── Service worker ──────────────────────────────────────────── useEffect(() => { if ('serviceWorker' in navigator) { navigator.serviceWorker.register('sw.js').catch(console.error); } }, []); return html` <${PullToRefresh} state=${ptrState} /> <${Feed} stories=${stories} readIds=${readIds} loading=${loading} error=${error} animate=${animateRef.current} onOpenStory=${openStory} />
<${Drawer} storyId=${drawerStoryId} story=${drawerStory} comments=${comments} onClose=${dismissDrawer} onOpenStory=${openStory} drawerRef=${drawerRef} /> <${Header} currentDay=${currentDay} onPrev=${() => navigate(-1)} onNext=${() => navigate(1)} /> <${OfflineToast} show=${offlineFlash} /> `; } render(html`<${App} />`, document.getElementById('app'));