[READ-ONLY] Mirror of https://github.com/thang-qt/ThreadLine. Alternative frontend for HackerNews and Lobsters threadline.thangqt.com/
0

Configure Feed

Select the types of activity you want to include in your feed.

Improve feed and discussion loading performance

Quang Thang (Jul 11, 2026, 12:26 PM +0700) 4155447e 6b29cfaa

+164 -34
+4 -2
functions/api/feed.ts
··· 26 26 const sourceEnabled = sourceEnabledFromSearch(url.searchParams); 27 27 28 28 const cacheUrl = new URL(context.request.url); 29 + cacheUrl.searchParams.delete('refresh'); 29 30 cacheUrl.searchParams.set('hn', hn); 30 31 cacheUrl.searchParams.set('lobsters', lobsters); 31 32 cacheUrl.searchParams.set('hnEnabled', sourceEnabled.hn ? '1' : '0'); 32 33 cacheUrl.searchParams.set('lobstersEnabled', sourceEnabled.lobsters ? '1' : '0'); 33 34 const cacheKey = new Request(cacheUrl.toString(), context.request); 34 35 const cache = edgeCache(); 35 - const cached = await cache.match(cacheKey); 36 + const bypassCache = url.searchParams.has('refresh'); 37 + const cached = bypassCache ? undefined : await cache.match(cacheKey); 36 38 if (cached) return cached; 37 39 38 40 const payload = await fetchAggregatedFeed({ hn, lobsters, sourceEnabled }); 39 41 const status = payload.stories.length ? 200 : 502; 40 42 const response = jsonResponse(payload, { 41 43 status, 42 - headers: { 'Cache-Control': 'public, max-age=60, stale-while-revalidate=600' } 44 + headers: { 'Cache-Control': 'public, max-age=180, stale-while-revalidate=1800' } 43 45 }); 44 46 if (payload.stories.length) context.waitUntil(cache.put(cacheKey, response.clone())); 45 47 return response;
+15 -10
src/App.tsx
··· 1 + import { lazy, Suspense } from 'react'; 1 2 import { Route, Routes } from 'react-router-dom'; 2 3 import { AppShell } from './layout/AppShell'; 3 - import { FeedPage } from './pages/FeedPage'; 4 - import { DiscussionPage } from './pages/DiscussionPage'; 5 - import { SettingsPage } from './pages/SettingsPage'; 6 - import { NotFoundPage } from './pages/NotFoundPage'; 7 4 import { useSettings } from './hooks/useSettings'; 8 5 import './styles.css'; 9 6 7 + const FeedPage = lazy(() => import('./pages/FeedPage').then(module => ({ default: module.FeedPage }))); 8 + const DiscussionPage = lazy(() => import('./pages/DiscussionPage').then(module => ({ default: module.DiscussionPage }))); 9 + const SettingsPage = lazy(() => import('./pages/SettingsPage').then(module => ({ default: module.SettingsPage }))); 10 + const NotFoundPage = lazy(() => import('./pages/NotFoundPage').then(module => ({ default: module.NotFoundPage }))); 11 + 12 + 10 13 function App() { 11 14 const { settings, setSettings, reset } = useSettings(); 12 15 return <AppShell settings={settings}> 13 - <Routes> 14 - <Route path="/" element={<FeedPage settings={settings} onChange={setSettings}/>} /> 15 - <Route path="/story/:source/:id" element={<DiscussionPage settings={settings} onChange={setSettings}/>} /> 16 - <Route path="/settings" element={<SettingsPage settings={settings} onChange={setSettings} onReset={reset}/>} /> 17 - <Route path="*" element={<NotFoundPage/>} /> 18 - </Routes> 16 + <Suspense fallback={<main className="front-page"><p className="loader">Loading…</p></main>}> 17 + <Routes> 18 + <Route path="/" element={<FeedPage settings={settings} onChange={setSettings}/>} /> 19 + <Route path="/story/:source/:id" element={<DiscussionPage settings={settings} onChange={setSettings}/>} /> 20 + <Route path="/settings" element={<SettingsPage settings={settings} onChange={setSettings} onReset={reset}/>} /> 21 + <Route path="*" element={<NotFoundPage/>} /> 22 + </Routes> 23 + </Suspense> 19 24 </AppShell>; 20 25 } 21 26 export default App;
+4 -3
src/api/index.ts
··· 6 6 7 7 export type { FeedResult } from '../types'; 8 8 9 - function feedApiUrl(settings: ReaderSettings): URL { 9 + function feedApiUrl(settings: ReaderSettings, bypassCache = false): URL { 10 10 const configured = import.meta.env.VITE_FEED_API_URL?.trim(); 11 11 const url = new URL(configured || '/api/feed', window.location.origin); 12 12 url.searchParams.set('hn', settings.hnFeed); 13 13 url.searchParams.set('lobsters', settings.lobstersFeed); 14 14 appendSourceEnabled(url.searchParams, settings.sourceEnabled); 15 + if (bypassCache) url.searchParams.set('refresh', String(Date.now())); 15 16 return url; 16 17 } 17 18 18 - export async function fetchCombinedFeed(settings: ReaderSettings, signal?: AbortSignal): Promise<FeedResult> { 19 + export async function fetchCombinedFeed(settings: ReaderSettings, signal?: AbortSignal, options: { bypassCache?: boolean } = {}): Promise<FeedResult> { 19 20 try { 20 - const payload = await checkedJson<FeedResult>(feedApiUrl(settings), { signal }); 21 + const payload = await checkedJson<FeedResult>(feedApiUrl(settings, options.bypassCache), { signal }); 21 22 if (Array.isArray(payload.stories)) { 22 23 return { 23 24 stories: payload.stories.filter(story => settings.sourceEnabled[story.source]),
+82
src/cache.ts
··· 1 + import type { CommentNode, FeedResult, ReaderSettings, Source } from './types'; 2 + 3 + const FEED_CACHE_PREFIX = 'threadline.feed.v1'; 4 + const COMMENTS_CACHE_PREFIX = 'threadline.comments.v1'; 5 + const MAX_COMMENTS_AGE_MS = 30 * 60 * 1000; 6 + 7 + export interface CachedFeed { 8 + result: FeedResult; 9 + cachedAt: number; 10 + } 11 + 12 + export interface CachedComments { 13 + comments: CommentNode[]; 14 + cachedAt: number; 15 + } 16 + 17 + function canUseStorage(): boolean { 18 + return typeof window !== 'undefined' && typeof window.localStorage !== 'undefined'; 19 + } 20 + 21 + function safeRead<T>(key: string): T | null { 22 + if (!canUseStorage()) return null; 23 + try { 24 + const raw = window.localStorage.getItem(key); 25 + return raw ? JSON.parse(raw) as T : null; 26 + } catch { 27 + return null; 28 + } 29 + } 30 + 31 + function safeWrite(key: string, value: unknown): void { 32 + if (!canUseStorage()) return; 33 + try { 34 + window.localStorage.setItem(key, JSON.stringify(value)); 35 + } catch { 36 + // Ignore quota/private-mode failures; cache must never block reading. 37 + } 38 + } 39 + 40 + export function feedCacheKey(settings: ReaderSettings): string { 41 + const enabled = (Object.entries(settings.sourceEnabled) as [Source, boolean][]) 42 + .filter(([, value]) => value) 43 + .map(([source]) => source) 44 + .sort() 45 + .join(','); 46 + return `${FEED_CACHE_PREFIX}:${settings.hnFeed}:${settings.lobstersFeed}:${enabled}`; 47 + } 48 + 49 + export function readCachedFeed(settings: ReaderSettings): CachedFeed | null { 50 + const cached = safeRead<CachedFeed>(feedCacheKey(settings)); 51 + if (!cached || !Array.isArray(cached.result?.stories) || typeof cached.cachedAt !== 'number') return null; 52 + return cached; 53 + } 54 + 55 + export function writeCachedFeed(settings: ReaderSettings, result: FeedResult): void { 56 + if (!result.stories.length) return; 57 + safeWrite(feedCacheKey(settings), { result, cachedAt: Date.now() } satisfies CachedFeed); 58 + } 59 + 60 + export function commentsCacheKey(source: Source, sourceId: string): string { 61 + return `${COMMENTS_CACHE_PREFIX}:${source}:${sourceId}`; 62 + } 63 + 64 + export function readCachedComments(source: Source, sourceId: string): CachedComments | null { 65 + const cached = safeRead<CachedComments>(commentsCacheKey(source, sourceId)); 66 + if (!cached || !Array.isArray(cached.comments) || typeof cached.cachedAt !== 'number') return null; 67 + if (Date.now() - cached.cachedAt > MAX_COMMENTS_AGE_MS) return null; 68 + return cached; 69 + } 70 + 71 + export function writeCachedComments(source: Source, sourceId: string, comments: CommentNode[]): void { 72 + safeWrite(commentsCacheKey(source, sourceId), { comments, cachedAt: Date.now() } satisfies CachedComments); 73 + } 74 + 75 + export function ageLabel(timestamp: number): string { 76 + const seconds = Math.max(0, Math.round((Date.now() - timestamp) / 1000)); 77 + if (seconds < 45) return 'just now'; 78 + const minutes = Math.round(seconds / 60); 79 + if (minutes < 60) return `${minutes} min ago`; 80 + const hours = Math.round(minutes / 60); 81 + return `${hours} hr ago`; 82 + }
+1 -1
src/components/discussion/DiscussionView.tsx
··· 46 46 47 47 const discussionColumn = <section className="discussion-column" aria-label="Discussion" style={readHere ? { flex: 1 } : undefined}> 48 48 <DiscussionToolbar commentsCount={visibleIds.length} readHere={readHere} settings={settings} onChange={onChange} onCloseReader={closeReader} /> 49 - {loading ? <p className="loader">Fetching discussion…</p> : error ? <p className="error-card">{error}</p> : comments.length ? <ol className="comment-list">{comments.map(node => <CommentThread key={node.id} node={node} depth={0} collapsed={collapsed} active={active} visibleIds={visibleIds} refs={refs} toggle={toggle} moveFrom={moveFrom} storyAuthor={story.author} source={story.source}/>)}</ol> : <p className="muted">No comments yet.</p>} 49 + {loading && comments.length === 0 ? <p className="loader">Fetching discussion…</p> : error && comments.length === 0 ? <p className="error-card">{error}</p> : comments.length ? <ol className="comment-list">{comments.map(node => <CommentThread key={node.id} node={node} depth={0} collapsed={collapsed} active={active} visibleIds={visibleIds} refs={refs} toggle={toggle} moveFrom={moveFrom} storyAuthor={story.author} source={story.source}/>)}</ol> : <p className="muted">No comments yet.</p>} 50 50 </section>; 51 51 52 52 return <div className={`discussion-page ${readHere ? 'reading' : ''} ${overlay ? 'discussion-overlay' : ''}`} ref={dialogRef} role={overlay ? 'dialog' : undefined} aria-modal={overlay ? true : undefined} aria-label={overlay ? `${story.title} reader and discussion` : undefined} tabIndex={overlay ? -1 : undefined}>
+9 -2
src/hooks/useComments.ts
··· 1 1 import { useEffect, useState } from 'react'; 2 2 import { fetchComments } from '../api'; 3 + import { readCachedComments, writeCachedComments } from '../cache'; 3 4 import type { CommentNode, Story } from '../types'; 4 5 5 6 export function useComments(story: Story) { 6 - const [comments, setComments] = useState<CommentNode[]>([]); 7 + const [comments, setComments] = useState<CommentNode[]>(() => readCachedComments(story.source, story.sourceId)?.comments ?? []); 7 8 const [error, setError] = useState<string>(); 8 9 const [loading, setLoading] = useState(true); 9 10 10 11 useEffect(() => { 11 12 const controller = new AbortController(); 13 + const cached = readCachedComments(story.source, story.sourceId); 14 + if (cached) setComments(cached.comments); 12 15 setLoading(true); 13 16 setError(undefined); 14 17 fetchComments(story, controller.signal) 15 - .then(setComments) 18 + .then(nextComments => { 19 + setComments(nextComments); 20 + writeCachedComments(story.source, story.sourceId, nextComments); 21 + }) 16 22 .catch(reason => { 17 23 if (reason instanceof DOMException && reason.name === 'AbortError') return; 24 + if (!cached) setComments([]); 18 25 setError(reason instanceof Error ? reason.message : 'Could not load comments'); 19 26 }) 20 27 .finally(() => {
+16 -5
src/hooks/useFeed.ts
··· 1 1 import { useCallback, useEffect, useMemo, useState } from 'react'; 2 2 import { fetchCombinedFeed, type FeedResult } from '../api'; 3 + import { readCachedFeed, writeCachedFeed } from '../cache'; 3 4 import { filterStories } from '../feed/filterStories'; 4 5 import type { ReaderSettings } from '../types'; 5 6 6 7 export function useFeed(settings: ReaderSettings) { 7 - const [feed, setFeed] = useState<FeedResult>({ stories: [], errors: {} }); 8 + const [feed, setFeed] = useState<FeedResult>(() => readCachedFeed(settings)?.result ?? { stories: [], errors: {} }); 9 + const [cacheTimestamp, setCacheTimestamp] = useState<number | null>(() => readCachedFeed(settings)?.cachedAt ?? null); 8 10 const [loading, setLoading] = useState(true); 9 11 const [refreshToken, setRefreshToken] = useState(0); 10 12 const refresh = useCallback(() => setRefreshToken(value => value + 1), []); 11 13 12 14 useEffect(() => { 13 15 const controller = new AbortController(); 16 + const cached = readCachedFeed(settings); 17 + if (cached) { 18 + setFeed(cached.result); 19 + setCacheTimestamp(cached.cachedAt); 20 + } 14 21 setLoading(true); 15 - fetchCombinedFeed(settings, controller.signal) 16 - .then(result => setFeed(result)) 22 + fetchCombinedFeed(settings, controller.signal, { bypassCache: refreshToken > 0 }) 23 + .then(result => { 24 + setFeed(result); 25 + setCacheTimestamp(Date.now()); 26 + writeCachedFeed(settings, result); 27 + }) 17 28 .catch(error => { 18 29 if (error instanceof DOMException && error.name === 'AbortError') return; 19 - setFeed({ stories: [], errors: { hn: error instanceof Error ? error.message : 'Feed failed' } }); 30 + setFeed(current => current.stories.length ? { ...current, errors: { ...current.errors, hn: error instanceof Error ? error.message : 'Feed refresh failed' } } : { stories: [], errors: { hn: error instanceof Error ? error.message : 'Feed failed' } }); 20 31 }) 21 32 .finally(() => { 22 33 if (!controller.signal.aborted) setLoading(false); ··· 25 36 }, [settings.hnFeed, settings.lobstersFeed, settings.sourceEnabled.hn, settings.sourceEnabled.lobsters, refreshToken]); 26 37 27 38 const filteredStories = useMemo(() => filterStories(feed.stories, settings), [feed.stories, settings]); 28 - return { feed, filteredStories, loading, refresh }; 39 + return { feed, filteredStories, loading, refresh, cacheTimestamp }; 29 40 }
+3 -3
src/pages/FeedPage.tsx
··· 28 28 <button className={`layout-btn ${settings.layout === 'list' ? 'active' : ''}`} onClick={() => onChange({ ...settings, layout: 'list' })} title="Classic List view">List</button> 29 29 <button className={`layout-btn ${settings.layout === 'newspaper' ? 'active' : ''}`} onClick={() => onChange({ ...settings, layout: 'newspaper' })} title="Newspaper Grid view">Grid</button> 30 30 </div> 31 - <button className="text-button refresh-btn" onClick={refresh}>Refresh</button> 31 + <button className="text-button refresh-btn" onClick={refresh} disabled={loading}>{loading ? 'Refreshing…' : 'Refresh'}</button> 32 32 </div> 33 33 </div> 34 - {Object.entries(feed.errors).map(([source, error]) => <aside className="notice" key={source}><strong>{source === 'hn' ? 'Hacker News' : 'Lobsters'} unavailable.</strong> {error}</aside>)} 35 - {loading ? <p className="loader">Loading today’s stories…</p> : ranked.length ? ( 34 + {Object.entries(feed.errors).map(([source, error]) => <aside className="notice" key={source}><strong>{source === 'hn' ? 'Hacker News' : 'Lobsters'} unavailable.</strong> {error}</aside>) } 35 + {loading && ranked.length === 0 ? <p className="loader">Loading today’s stories…</p> : ranked.length ? ( 36 36 settings.layout === 'list' ? <section className="list-layout" aria-label="Combined news feed"> 37 37 {ranked.map((story, index) => <div key={story.id} className="list-row"><span className="story-rank">{index + 1}</span><StoryCard story={story} featured={false} onReadHere={openReader}/></div>)} 38 38 </section> : <section className="newspaper-grid" aria-label="Combined news feed">
+2 -1
src/server/feed/fetchFeed.ts
··· 1 1 import type { FeedResult, HnFeed, LobstersFeed, Source, Story } from '../../types'; 2 2 import { hnHtmlPath, lobstersHtmlPath, normalizeSourceEnabled, SOURCE_ORIGIN, type SourceEnabled } from '../../lib/sources'; 3 3 import { parseHnHtml, parseLobstersHtml } from './parseHtml'; 4 + import { fetchWithTimeout } from './http'; 4 5 import { fetchHnFirebaseFallback, fetchLobstersJsonFallback } from './normalize'; 5 6 6 7 export interface FetchFeedOptions { ··· 11 12 } 12 13 13 14 async function fetchText(url: string): Promise<string> { 14 - const response = await fetch(url, { headers: { Accept: 'text/html,application/xhtml+xml' } }); 15 + const response = await fetchWithTimeout(url, { headers: { Accept: 'text/html,application/xhtml+xml' } }); 15 16 if (!response.ok) throw new Error(`${url} returned ${response.status}`); 16 17 return response.text(); 17 18 }
+12
src/server/feed/http.ts
··· 1 + export async function fetchWithTimeout(url: string, init: RequestInit = {}, timeoutMs = 4500): Promise<Response> { 2 + const controller = new AbortController(); 3 + const timeout = setTimeout(() => controller.abort(), timeoutMs); 4 + try { 5 + return await fetch(url, { ...init, signal: controller.signal }); 6 + } catch (error) { 7 + if (error instanceof DOMException && error.name === 'AbortError') throw new Error(`${url} timed out`); 8 + throw error; 9 + } finally { 10 + clearTimeout(timeout); 11 + } 12 + }
+4 -3
src/server/feed/normalize.ts
··· 1 1 import type { HnFeed, LobstersFeed, Story } from '../../types'; 2 2 import { hnFirebaseEndpoint, lobstersJsonPath, SOURCE_ORIGIN } from '../../lib/sources'; 3 3 import { normalizeHnFirebaseStory, normalizeLobstersJsonStory } from '../../feed/normalizeStories'; 4 + import { fetchWithTimeout } from './http'; 4 5 5 6 export async function fetchHnFirebaseFallback(feed: HnFeed, limit = 30): Promise<Story[]> { 6 - const idsResponse = await fetch(`https://hacker-news.firebaseio.com/v0/${hnFirebaseEndpoint(feed)}.json`); 7 + const idsResponse = await fetchWithTimeout(`https://hacker-news.firebaseio.com/v0/${hnFirebaseEndpoint(feed)}.json`, {}, 3500); 7 8 if (!idsResponse.ok) throw new Error(`Hacker News returned ${idsResponse.status}`); 8 9 const ids = await idsResponse.json() as unknown; 9 10 if (!Array.isArray(ids)) throw new Error('Hacker News returned invalid story ids'); 10 11 const items = await Promise.all(ids.slice(0, limit).map(async (id, index) => { 11 - const response = await fetch(`https://hacker-news.firebaseio.com/v0/item/${encodeURIComponent(String(id))}.json`); 12 + const response = await fetchWithTimeout(`https://hacker-news.firebaseio.com/v0/item/${encodeURIComponent(String(id))}.json`, {}, 3500); 12 13 if (!response.ok) return null; 13 14 return normalizeHnFirebaseStory(await response.json(), index); 14 15 })); ··· 16 17 } 17 18 18 19 export async function fetchLobstersJsonFallback(feed: LobstersFeed, limit = 30): Promise<Story[]> { 19 - const response = await fetch(`${SOURCE_ORIGIN.lobsters}/${lobstersJsonPath(feed)}`, { headers: { Accept: 'application/json' } }); 20 + const response = await fetchWithTimeout(`${SOURCE_ORIGIN.lobsters}/${lobstersJsonPath(feed)}`, { headers: { Accept: 'application/json' } }, 3500); 20 21 if (!response.ok) throw new Error(`Lobsters returned ${response.status}`); 21 22 const payload = await response.json() as unknown; 22 23 if (!Array.isArray(payload)) throw new Error('Lobsters returned invalid story list');
-1
src/styles.css
··· 1 - @import url('https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&family=JetBrains+Mono:ital,wght@0,100..800;1,100..800&family=Lora:ital,wght@0,400..700;1,400..700&family=Merriweather:ital,wght@0,300..900;1,300..900&display=swap'); 2 1 @import './styles/00-theme.css'; 3 2 @import './styles/01-base.css'; 4 3 @import './styles/02-layout.css';
+3 -3
src/styles/00-theme.css
··· 8 8 --transition-normal: 0.25s ease; 9 9 10 10 /* Font Family Stacks */ 11 - --font-sans: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; 12 - --font-serif: "Merriweather", "Lora", "Iowan Old Style", Charter, Georgia, serif; 13 - --font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Roboto Mono, monospace; 11 + --font-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; 12 + --font-serif: "Iowan Old Style", Charter, Georgia, "Times New Roman", serif; 13 + --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; 14 14 } 15 15 16 16 /* Light Theme Variables */
+9
src/styles/03-feed.css
··· 103 103 color: var(--color-accent); 104 104 } 105 105 106 + .feed-status { 107 + margin: -0.5rem 0 1rem; 108 + color: var(--color-muted); 109 + font-size: 0.75rem; 110 + font-weight: 700; 111 + letter-spacing: 0.08em; 112 + text-transform: uppercase; 113 + } 114 + 106 115 /* Elegant 1px Grid Gap system for Newspaper layouts */ 107 116 .newspaper-grid { 108 117 display: grid;