@ahmd-sh/hntui 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +171 -0
- package/package.json +59 -0
- package/src/App.tsx +629 -0
- package/src/api/hn.test.ts +84 -0
- package/src/api/hn.ts +200 -0
- package/src/api/hnDemo.ts +70 -0
- package/src/api/types.ts +40 -0
- package/src/components/CommentNode.tsx +81 -0
- package/src/components/ContextMenu.tsx +78 -0
- package/src/components/Header.tsx +89 -0
- package/src/components/HelpOverlay.tsx +133 -0
- package/src/components/LinksPopup.tsx +101 -0
- package/src/components/Loader.tsx +30 -0
- package/src/components/StatusBar.tsx +40 -0
- package/src/components/StoryRow.tsx +76 -0
- package/src/hooks/useCommentTree.ts +95 -0
- package/src/hooks/useHistory.ts +40 -0
- package/src/hooks/useItems.ts +34 -0
- package/src/hooks/useSaved.ts +37 -0
- package/src/hooks/useStoryIds.ts +38 -0
- package/src/index.tsx +8 -0
- package/src/runtime.ts +12 -0
- package/src/spinner.ts +243 -0
- package/src/theme.ts +131 -0
- package/src/utils/configDir.ts +22 -0
- package/src/utils/errors.test.ts +21 -0
- package/src/utils/errors.ts +13 -0
- package/src/utils/format.test.ts +56 -0
- package/src/utils/format.ts +97 -0
- package/src/utils/historyStore.ts +38 -0
- package/src/utils/openUrl.ts +10 -0
- package/src/utils/savedStore.ts +34 -0
- package/src/views/MessageView.tsx +34 -0
- package/src/views/StoryDetailView.tsx +116 -0
- package/src/views/StoryListView.tsx +83 -0
package/src/App.tsx
ADDED
|
@@ -0,0 +1,629 @@
|
|
|
1
|
+
import { useEffect, useMemo, useRef, useState } from "react"
|
|
2
|
+
import { Effect, Fiber } from "effect"
|
|
3
|
+
import type { ScrollBoxRenderable } from "@opentui/core"
|
|
4
|
+
import { useKeyboard, useRenderer } from "@opentui/react"
|
|
5
|
+
import { Header } from "./components/Header"
|
|
6
|
+
import { StatusBar } from "./components/StatusBar"
|
|
7
|
+
import { StoryListView } from "./views/StoryListView"
|
|
8
|
+
import { StoryDetailView } from "./views/StoryDetailView"
|
|
9
|
+
import { MessageView } from "./views/MessageView"
|
|
10
|
+
import { useStoryIds } from "./hooks/useStoryIds"
|
|
11
|
+
import { useItems } from "./hooks/useItems"
|
|
12
|
+
import { flattenTree, useCommentTree } from "./hooks/useCommentTree"
|
|
13
|
+
import { useSaved } from "./hooks/useSaved"
|
|
14
|
+
import { useHistory } from "./hooks/useHistory"
|
|
15
|
+
import { ALL_CATEGORIES, FEED_CATEGORIES } from "./api/types"
|
|
16
|
+
import type { Category, FeedCategory, Item } from "./api/types"
|
|
17
|
+
import { openUrl } from "./utils/openUrl"
|
|
18
|
+
import { extractLinks, parseHnItemLink, type HnItemRef, type Link } from "./utils/format"
|
|
19
|
+
import { resolveStory } from "./api/hn"
|
|
20
|
+
import type { HnError, HnItemGone } from "./api/hn"
|
|
21
|
+
import { AppRuntime } from "./runtime"
|
|
22
|
+
import { hnErrorMessage } from "./utils/errors"
|
|
23
|
+
import { LinksPopup } from "./components/LinksPopup"
|
|
24
|
+
import { HelpOverlay } from "./components/HelpOverlay"
|
|
25
|
+
import { ContextMenu, type MenuItem } from "./components/ContextMenu"
|
|
26
|
+
import { ThemeContext, darkTheme, lightTheme } from "./theme"
|
|
27
|
+
|
|
28
|
+
const PAGE_SIZE = 30
|
|
29
|
+
|
|
30
|
+
type ResolveError = HnError | HnItemGone
|
|
31
|
+
|
|
32
|
+
type View =
|
|
33
|
+
| { kind: "list" }
|
|
34
|
+
| { kind: "detail"; story: Item }
|
|
35
|
+
// a link that couldn't be opened — keeps the ref so `r` can retry
|
|
36
|
+
| { kind: "resolveError"; ref: HnItemRef; error: ResolveError }
|
|
37
|
+
|
|
38
|
+
// A suspended detail view: enough state to resume it exactly where it was left
|
|
39
|
+
type DetailSnapshot = { story: Item; cursor: number; collapsed: Set<number> }
|
|
40
|
+
|
|
41
|
+
export function App() {
|
|
42
|
+
const renderer = useRenderer()
|
|
43
|
+
const [category, setCategory] = useState<Category>("top")
|
|
44
|
+
const [refreshKey, setRefreshKey] = useState(0)
|
|
45
|
+
const feedCategory: FeedCategory =
|
|
46
|
+
category === "saved" || category === "history" ? "top" : category
|
|
47
|
+
const { ids, loading: idsLoading, error: idsError } = useStoryIds(feedCategory, refreshKey)
|
|
48
|
+
const visibleIds = useMemo(() => ids.slice(0, PAGE_SIZE), [ids])
|
|
49
|
+
const { items: feedItems, loading: feedItemsLoading } = useItems(visibleIds)
|
|
50
|
+
const { entries: savedEntries, idSet: savedIds, isSaved, toggle: toggleSave } = useSaved()
|
|
51
|
+
const { entries: historyEntries, idSet: viewedIds, markViewed, clear: clearHistory } = useHistory()
|
|
52
|
+
|
|
53
|
+
const savedIdList = useMemo(() => savedEntries.map((e) => e.id), [savedEntries])
|
|
54
|
+
const { items: savedItemsRaw, loading: savedLoading } = useItems(
|
|
55
|
+
category === "saved" ? savedIdList : [],
|
|
56
|
+
)
|
|
57
|
+
const savedItems = useMemo(() => {
|
|
58
|
+
if (category !== "saved") return [] as Item[]
|
|
59
|
+
const byId = new Map(savedItemsRaw.map((i) => [i.id, i]))
|
|
60
|
+
return savedIdList.map((id) => byId.get(id)).filter((x): x is Item => Boolean(x))
|
|
61
|
+
}, [category, savedItemsRaw, savedIdList])
|
|
62
|
+
|
|
63
|
+
// History can hold up to HISTORY_CAP ids (for the visited-dimming lookup); only the
|
|
64
|
+
// most-recent page is fetched/browsable here. The full set still powers de-emphasis.
|
|
65
|
+
const historyIdList = useMemo(
|
|
66
|
+
() => historyEntries.slice(0, PAGE_SIZE).map((e) => e.id),
|
|
67
|
+
[historyEntries],
|
|
68
|
+
)
|
|
69
|
+
const { items: historyItemsRaw, loading: historyLoading } = useItems(
|
|
70
|
+
category === "history" ? historyIdList : [],
|
|
71
|
+
)
|
|
72
|
+
const historyItems = useMemo(() => {
|
|
73
|
+
if (category !== "history") return [] as Item[]
|
|
74
|
+
const byId = new Map(historyItemsRaw.map((i) => [i.id, i]))
|
|
75
|
+
return historyIdList.map((id) => byId.get(id)).filter((x): x is Item => Boolean(x))
|
|
76
|
+
}, [category, historyItemsRaw, historyIdList])
|
|
77
|
+
|
|
78
|
+
const items =
|
|
79
|
+
category === "saved" ? savedItems : category === "history" ? historyItems : feedItems
|
|
80
|
+
const listLoading =
|
|
81
|
+
category === "saved"
|
|
82
|
+
? savedLoading
|
|
83
|
+
: category === "history"
|
|
84
|
+
? historyLoading
|
|
85
|
+
: idsLoading || feedItemsLoading
|
|
86
|
+
|
|
87
|
+
const [view, setView] = useState<View>({ kind: "list" })
|
|
88
|
+
const [listCursor, setListCursor] = useState(0)
|
|
89
|
+
const [detailCursor, setDetailCursor] = useState(0)
|
|
90
|
+
const [collapsed, setCollapsed] = useState<Set<number>>(new Set())
|
|
91
|
+
const [theme, setTheme] = useState(darkTheme)
|
|
92
|
+
const [popup, setPopup] = useState<{ links: Link[]; cursor: number } | null>(null)
|
|
93
|
+
const [menu, setMenu] = useState<{ x: number; y: number; items: MenuItem[]; cursor: number } | null>(
|
|
94
|
+
null,
|
|
95
|
+
)
|
|
96
|
+
const [help, setHelp] = useState(false)
|
|
97
|
+
const [stack, setStack] = useState<DetailSnapshot[]>([])
|
|
98
|
+
const [resolving, setResolving] = useState(false)
|
|
99
|
+
const [pendingFocus, setPendingFocus] = useState<number | null>(null)
|
|
100
|
+
const [commentsRefresh, setCommentsRefresh] = useState(0)
|
|
101
|
+
const resolveFiber = useRef<Fiber.RuntimeFiber<void, never> | null>(null)
|
|
102
|
+
const lastG = useRef<number>(0)
|
|
103
|
+
const listScrollRef = useRef<ScrollBoxRenderable | null>(null)
|
|
104
|
+
const detailScrollRef = useRef<ScrollBoxRenderable | null>(null)
|
|
105
|
+
|
|
106
|
+
const story = view.kind === "detail" ? view.story : null
|
|
107
|
+
const { tree, loading: commentsLoading } = useCommentTree(story?.kids, 8, commentsRefresh)
|
|
108
|
+
const flat = useMemo(() => flattenTree(tree, collapsed), [tree, collapsed])
|
|
109
|
+
|
|
110
|
+
useEffect(() => {
|
|
111
|
+
if (listCursor >= items.length) setListCursor(Math.max(0, items.length - 1))
|
|
112
|
+
}, [items.length])
|
|
113
|
+
|
|
114
|
+
useEffect(() => {
|
|
115
|
+
// don't clamp while comments are (re)loading — a restored cursor from the
|
|
116
|
+
// view stack must survive the brief flat=[] window during the reload
|
|
117
|
+
if (!commentsLoading && detailCursor >= flat.length)
|
|
118
|
+
setDetailCursor(Math.max(0, flat.length - 1))
|
|
119
|
+
}, [flat.length, commentsLoading])
|
|
120
|
+
|
|
121
|
+
// After following an internal link, land the cursor on the linked comment
|
|
122
|
+
// (best effort — it may be deleted or deeper than the fetched tree)
|
|
123
|
+
useEffect(() => {
|
|
124
|
+
if (pendingFocus == null || commentsLoading) return
|
|
125
|
+
const idx = flat.findIndex((f) => f.node.item.id === pendingFocus)
|
|
126
|
+
if (idx >= 0) setDetailCursor(idx)
|
|
127
|
+
setPendingFocus(null)
|
|
128
|
+
}, [pendingFocus, commentsLoading, flat])
|
|
129
|
+
|
|
130
|
+
// Speculative prefetch: while the links popup is open, resolve any internal
|
|
131
|
+
// HN links in the background so ⏎ is instant. The item cache is the handoff —
|
|
132
|
+
// no state needed here. Closing the popup interrupts all in-flight hops.
|
|
133
|
+
const popupLinks = popup?.links ?? null
|
|
134
|
+
useEffect(() => {
|
|
135
|
+
if (!popupLinks) return
|
|
136
|
+
const refs = popupLinks
|
|
137
|
+
.map((l) => parseHnItemLink(l.url))
|
|
138
|
+
.filter((r): r is HnItemRef => r !== null)
|
|
139
|
+
if (refs.length === 0) return
|
|
140
|
+
const fiber = AppRuntime.runFork(
|
|
141
|
+
Effect.forEach(refs, (ref) => resolveStory(ref).pipe(Effect.ignore), {
|
|
142
|
+
concurrency: 4,
|
|
143
|
+
}),
|
|
144
|
+
)
|
|
145
|
+
return () => {
|
|
146
|
+
AppRuntime.runFork(Fiber.interrupt(fiber))
|
|
147
|
+
}
|
|
148
|
+
}, [popupLinks])
|
|
149
|
+
|
|
150
|
+
useEffect(() => {
|
|
151
|
+
if (popup || menu || help) {
|
|
152
|
+
detailScrollRef.current?.blur()
|
|
153
|
+
listScrollRef.current?.blur()
|
|
154
|
+
}
|
|
155
|
+
}, [popup, menu, help])
|
|
156
|
+
|
|
157
|
+
const openMenuForStory = (item: Item, x: number, y: number) => {
|
|
158
|
+
const items: MenuItem[] = [
|
|
159
|
+
{
|
|
160
|
+
label: isSaved(item.id) ? "★ Unsave" : "☆ Save",
|
|
161
|
+
action: () => toggleSave(item.id),
|
|
162
|
+
},
|
|
163
|
+
]
|
|
164
|
+
if (item.url) {
|
|
165
|
+
items.push({
|
|
166
|
+
label: "Open URL in browser",
|
|
167
|
+
action: () => {
|
|
168
|
+
markViewed(item.id)
|
|
169
|
+
openUrl(item.url!)
|
|
170
|
+
},
|
|
171
|
+
})
|
|
172
|
+
}
|
|
173
|
+
items.push({ label: "Open comments", action: () => enterDetail(item) })
|
|
174
|
+
setMenu({ x, y, items, cursor: 0 })
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const switchCategory = (c: Category) => {
|
|
178
|
+
setCategory(c)
|
|
179
|
+
setListCursor(0)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const cycleCategory = (dir: 1 | -1) => {
|
|
183
|
+
const idx = ALL_CATEGORIES.indexOf(category)
|
|
184
|
+
const next = ALL_CATEGORIES[(idx + dir + ALL_CATEGORIES.length) % ALL_CATEGORIES.length]!
|
|
185
|
+
switchCategory(next)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const cancelResolve = () => {
|
|
189
|
+
if (resolveFiber.current) {
|
|
190
|
+
AppRuntime.runFork(Fiber.interrupt(resolveFiber.current))
|
|
191
|
+
resolveFiber.current = null
|
|
192
|
+
}
|
|
193
|
+
setResolving(false)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Entering from the list starts fresh; entering from a detail view pushes
|
|
197
|
+
// the current view (with cursor + collapsed state) onto the stack first.
|
|
198
|
+
const enterDetail = (item: Item, focusId?: number) => {
|
|
199
|
+
cancelResolve()
|
|
200
|
+
markViewed(item.id)
|
|
201
|
+
if (view.kind === "detail") {
|
|
202
|
+
const snap = { story: view.story, cursor: detailCursor, collapsed }
|
|
203
|
+
setStack((s) => [...s, snap])
|
|
204
|
+
}
|
|
205
|
+
setView({ kind: "detail", story: item })
|
|
206
|
+
setDetailCursor(0)
|
|
207
|
+
setCollapsed(new Set())
|
|
208
|
+
setPendingFocus(focusId ?? null)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// esc/h goes back ONE level: resume the previous thread, or exit to the list
|
|
212
|
+
const popView = () => {
|
|
213
|
+
cancelResolve()
|
|
214
|
+
setPendingFocus(null)
|
|
215
|
+
const top = stack[stack.length - 1]
|
|
216
|
+
if (!top) {
|
|
217
|
+
setView({ kind: "list" })
|
|
218
|
+
return
|
|
219
|
+
}
|
|
220
|
+
setStack((s) => s.slice(0, -1))
|
|
221
|
+
setView({ kind: "detail", story: top.story })
|
|
222
|
+
setDetailCursor(top.cursor)
|
|
223
|
+
setCollapsed(top.collapsed)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// the Y tile / global shortcuts abandon the whole stack
|
|
227
|
+
const goHome = () => {
|
|
228
|
+
cancelResolve()
|
|
229
|
+
setPendingFocus(null)
|
|
230
|
+
setStack([])
|
|
231
|
+
setView({ kind: "list" })
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const showResolveError = (ref: HnItemRef, error: ResolveError) => {
|
|
235
|
+
// retrying from an existing error view replaces it — only a detail
|
|
236
|
+
// view being left behind needs a snapshot pushed
|
|
237
|
+
if (view.kind === "detail") {
|
|
238
|
+
const snap = { story: view.story, cursor: detailCursor, collapsed }
|
|
239
|
+
setStack((s) => [...s, snap])
|
|
240
|
+
}
|
|
241
|
+
setView({ kind: "resolveError", ref, error })
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const startResolve = (ref: HnItemRef) => {
|
|
245
|
+
cancelResolve()
|
|
246
|
+
setResolving(true)
|
|
247
|
+
resolveFiber.current = AppRuntime.runFork(
|
|
248
|
+
resolveStory(ref).pipe(
|
|
249
|
+
Effect.match({
|
|
250
|
+
onSuccess: (r) => {
|
|
251
|
+
resolveFiber.current = null
|
|
252
|
+
setResolving(false)
|
|
253
|
+
enterDetail(r.story, r.focusId)
|
|
254
|
+
},
|
|
255
|
+
onFailure: (error) => {
|
|
256
|
+
resolveFiber.current = null
|
|
257
|
+
setResolving(false)
|
|
258
|
+
showResolveError(ref, error)
|
|
259
|
+
},
|
|
260
|
+
}),
|
|
261
|
+
),
|
|
262
|
+
)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const openLink = (link: Link) => {
|
|
266
|
+
const ref = parseHnItemLink(link.url)
|
|
267
|
+
if (ref) {
|
|
268
|
+
setPopup(null)
|
|
269
|
+
startResolve(ref)
|
|
270
|
+
} else {
|
|
271
|
+
openUrl(link.url)
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const openHnLink = (id: number) => openUrl(`https://news.ycombinator.com/item?id=${id}`)
|
|
276
|
+
|
|
277
|
+
const openLinksFor = (id: number) => {
|
|
278
|
+
const fc = flat.find((f) => f.node.item.id === id)
|
|
279
|
+
if (!fc) return
|
|
280
|
+
const links = extractLinks(fc.node.item.text)
|
|
281
|
+
if (links.length > 0) setPopup({ links, cursor: 0 })
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const toggleCollapse = (id: number) => {
|
|
285
|
+
setCollapsed((prev) => {
|
|
286
|
+
const next = new Set(prev)
|
|
287
|
+
if (next.has(id)) next.delete(id)
|
|
288
|
+
else next.add(id)
|
|
289
|
+
return next
|
|
290
|
+
})
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const pageSize = (kind: "list" | "detail") => {
|
|
294
|
+
const sb = kind === "list" ? listScrollRef.current : detailScrollRef.current
|
|
295
|
+
const rowApprox = kind === "list" ? 2 : 4
|
|
296
|
+
return Math.max(1, Math.floor((sb?.viewport.height ?? 20) / rowApprox))
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
useKeyboard((ev) => {
|
|
300
|
+
const name = ev.name
|
|
301
|
+
if (name === "q" || (ev.ctrl && name === "c")) {
|
|
302
|
+
renderer?.destroy()
|
|
303
|
+
process.exit(0)
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const isHelpKey = name === "?" || (name === "/" && ev.shift)
|
|
307
|
+
|
|
308
|
+
if (help) {
|
|
309
|
+
if (name === "escape" || name === "backspace" || isHelpKey) setHelp(false)
|
|
310
|
+
return
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (menu) {
|
|
314
|
+
const max = menu.items.length - 1
|
|
315
|
+
if (name === "j" || name === "down") {
|
|
316
|
+
setMenu((m) => (m ? { ...m, cursor: Math.min(max, m.cursor + 1) } : m))
|
|
317
|
+
} else if (name === "k" || name === "up") {
|
|
318
|
+
setMenu((m) => (m ? { ...m, cursor: Math.max(0, m.cursor - 1) } : m))
|
|
319
|
+
} else if (name === "return" || name === "enter") {
|
|
320
|
+
const item = menu.items[menu.cursor]
|
|
321
|
+
if (item && !item.disabled) item.action()
|
|
322
|
+
setMenu(null)
|
|
323
|
+
} else if (name === "escape" || name === "backspace") {
|
|
324
|
+
setMenu(null)
|
|
325
|
+
}
|
|
326
|
+
return
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (popup) {
|
|
330
|
+
const max = popup.links.length - 1
|
|
331
|
+
if (name === "j" || name === "down") {
|
|
332
|
+
setPopup((p) => (p ? { ...p, cursor: Math.min(max, p.cursor + 1) } : p))
|
|
333
|
+
} else if (name === "k" || name === "up") {
|
|
334
|
+
setPopup((p) => (p ? { ...p, cursor: Math.max(0, p.cursor - 1) } : p))
|
|
335
|
+
} else if (name === "g" && ev.shift) {
|
|
336
|
+
setPopup((p) => (p ? { ...p, cursor: max } : p))
|
|
337
|
+
} else if (name === "g") {
|
|
338
|
+
setPopup((p) => (p ? { ...p, cursor: 0 } : p))
|
|
339
|
+
} else if (name === "return" || name === "enter") {
|
|
340
|
+
const link = popup.links[popup.cursor]
|
|
341
|
+
if (link) openLink(link)
|
|
342
|
+
} else if (name === "o") {
|
|
343
|
+
const link = popup.links[popup.cursor]
|
|
344
|
+
if (link) openUrl(link.url)
|
|
345
|
+
} else if (name === "escape" || name === "backspace") {
|
|
346
|
+
setPopup(null)
|
|
347
|
+
}
|
|
348
|
+
return
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// esc while a link is resolving cancels it (aborts the fetch chain)
|
|
352
|
+
if (resolving && (name === "escape" || name === "backspace")) {
|
|
353
|
+
cancelResolve()
|
|
354
|
+
return
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
if (isHelpKey) {
|
|
358
|
+
setHelp(true)
|
|
359
|
+
return
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
if (name === "t") {
|
|
363
|
+
setTheme((cur) => (cur.name === "dark" ? lightTheme : darkTheme))
|
|
364
|
+
return
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Capital S enters saved view from anywhere
|
|
368
|
+
if (name === "s" && ev.shift) {
|
|
369
|
+
goHome()
|
|
370
|
+
switchCategory("saved")
|
|
371
|
+
return
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Capital H enters history view from anywhere
|
|
375
|
+
if (name === "h" && ev.shift) {
|
|
376
|
+
goHome()
|
|
377
|
+
switchCategory("history")
|
|
378
|
+
return
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
if (view.kind === "list") {
|
|
382
|
+
const max = items.length - 1
|
|
383
|
+
const pg = pageSize("list")
|
|
384
|
+
if (name === "j" || name === "down") {
|
|
385
|
+
setListCursor((c) => Math.min(max, c + 1))
|
|
386
|
+
} else if (name === "k" || name === "up") {
|
|
387
|
+
setListCursor((c) => Math.max(0, c - 1))
|
|
388
|
+
} else if (name === "g" && ev.shift) {
|
|
389
|
+
setListCursor(max)
|
|
390
|
+
} else if (name === "g") {
|
|
391
|
+
const now = Date.now()
|
|
392
|
+
if (now - lastG.current < 500) setListCursor(0)
|
|
393
|
+
lastG.current = now
|
|
394
|
+
} else if ((ev.ctrl && name === "d") || name === "pagedown") {
|
|
395
|
+
setListCursor((c) => Math.min(max, c + pg))
|
|
396
|
+
} else if ((ev.ctrl && name === "u") || name === "pageup") {
|
|
397
|
+
setListCursor((c) => Math.max(0, c - pg))
|
|
398
|
+
} else if (name === "c" || name === "return" || name === "enter") {
|
|
399
|
+
const cur = items[listCursor]
|
|
400
|
+
if (cur) enterDetail(cur)
|
|
401
|
+
} else if (name === "h" || name === "left") {
|
|
402
|
+
cycleCategory(-1)
|
|
403
|
+
} else if (name === "l" || name === "right") {
|
|
404
|
+
cycleCategory(1)
|
|
405
|
+
} else if (name === "tab") {
|
|
406
|
+
cycleCategory(ev.shift ? -1 : 1)
|
|
407
|
+
} else if (name === "o") {
|
|
408
|
+
const cur = items[listCursor]
|
|
409
|
+
if (cur?.url) {
|
|
410
|
+
markViewed(cur.id)
|
|
411
|
+
openUrl(cur.url)
|
|
412
|
+
}
|
|
413
|
+
} else if (name === "y") {
|
|
414
|
+
const cur = items[listCursor]
|
|
415
|
+
if (cur) {
|
|
416
|
+
markViewed(cur.id)
|
|
417
|
+
openHnLink(cur.id)
|
|
418
|
+
}
|
|
419
|
+
} else if (name === "s") {
|
|
420
|
+
const cur = items[listCursor]
|
|
421
|
+
if (cur) toggleSave(cur.id)
|
|
422
|
+
} else if (name === "x" && category === "history") {
|
|
423
|
+
clearHistory()
|
|
424
|
+
} else if (/^[1-6]$/.test(name)) {
|
|
425
|
+
const c = FEED_CATEGORIES[parseInt(name, 10) - 1]
|
|
426
|
+
if (c) switchCategory(c.key)
|
|
427
|
+
} else if (name === "r" && category !== "saved" && category !== "history") {
|
|
428
|
+
setRefreshKey((k) => k + 1)
|
|
429
|
+
}
|
|
430
|
+
} else if (view.kind === "resolveError") {
|
|
431
|
+
if (name === "h" || name === "left" || name === "backspace" || name === "escape") {
|
|
432
|
+
popView()
|
|
433
|
+
} else if (name === "r" && view.error._tag !== "HnItemGone") {
|
|
434
|
+
// a gone post stays gone — only transient failures earn a retry
|
|
435
|
+
startResolve(view.ref)
|
|
436
|
+
}
|
|
437
|
+
} else {
|
|
438
|
+
const max = flat.length - 1
|
|
439
|
+
const pg = pageSize("detail")
|
|
440
|
+
if (name === "j" || name === "down") {
|
|
441
|
+
setDetailCursor((c) => Math.min(max, c + 1))
|
|
442
|
+
} else if (name === "k" || name === "up") {
|
|
443
|
+
setDetailCursor((c) => Math.max(0, c - 1))
|
|
444
|
+
} else if (name === "g" && ev.shift) {
|
|
445
|
+
setDetailCursor(max)
|
|
446
|
+
} else if (name === "g") {
|
|
447
|
+
const now = Date.now()
|
|
448
|
+
if (now - lastG.current < 500) setDetailCursor(0)
|
|
449
|
+
lastG.current = now
|
|
450
|
+
} else if ((ev.ctrl && name === "d") || name === "pagedown") {
|
|
451
|
+
setDetailCursor((c) => Math.min(max, c + pg))
|
|
452
|
+
} else if ((ev.ctrl && name === "u") || name === "pageup") {
|
|
453
|
+
setDetailCursor((c) => Math.max(0, c - pg))
|
|
454
|
+
} else if (name === "space") {
|
|
455
|
+
const cur = flat[detailCursor]
|
|
456
|
+
if (cur) toggleCollapse(cur.node.item.id)
|
|
457
|
+
} else if (name === "return" || name === "enter") {
|
|
458
|
+
const cur = flat[detailCursor]
|
|
459
|
+
if (cur) openLinksFor(cur.node.item.id)
|
|
460
|
+
} else if (name === "o") {
|
|
461
|
+
if (view.story.url) openUrl(view.story.url)
|
|
462
|
+
} else if (name === "y") {
|
|
463
|
+
openHnLink(view.story.id)
|
|
464
|
+
} else if (name === "s") {
|
|
465
|
+
toggleSave(view.story.id)
|
|
466
|
+
} else if (name === "r") {
|
|
467
|
+
// retry comments only when they failed to load entirely
|
|
468
|
+
if (!commentsLoading && flat.length === 0 && (view.story.descendants ?? 0) > 0) {
|
|
469
|
+
setCommentsRefresh((k) => k + 1)
|
|
470
|
+
}
|
|
471
|
+
} else if (name === "h" || name === "left" || name === "backspace" || name === "escape") {
|
|
472
|
+
popView()
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
})
|
|
476
|
+
|
|
477
|
+
const detailLoading = commentsLoading
|
|
478
|
+
const statusLoading =
|
|
479
|
+
(view.kind === "list" ? listLoading : view.kind === "detail" ? detailLoading : false) ||
|
|
480
|
+
resolving
|
|
481
|
+
|
|
482
|
+
return (
|
|
483
|
+
<ThemeContext.Provider value={theme}>
|
|
484
|
+
<box flexDirection="column" flexGrow={1}>
|
|
485
|
+
<Header
|
|
486
|
+
category={category}
|
|
487
|
+
onSelect={switchCategory}
|
|
488
|
+
onHome={() => {
|
|
489
|
+
if (view.kind !== "list") {
|
|
490
|
+
goHome()
|
|
491
|
+
} else if (category === "saved" || category === "history") {
|
|
492
|
+
// saved/history are local lists — nothing to refresh, just reset the cursor
|
|
493
|
+
setListCursor(0)
|
|
494
|
+
} else {
|
|
495
|
+
setRefreshKey((k) => k + 1)
|
|
496
|
+
}
|
|
497
|
+
}}
|
|
498
|
+
showTabs={view.kind === "list"}
|
|
499
|
+
depth={stack.length}
|
|
500
|
+
/>
|
|
501
|
+
<box flexGrow={1} flexDirection="column" backgroundColor={theme.body}>
|
|
502
|
+
{view.kind === "list" && idsError && category !== "saved" && category !== "history" ? (
|
|
503
|
+
<MessageView
|
|
504
|
+
art="(×_×)"
|
|
505
|
+
title="Couldn't load stories"
|
|
506
|
+
subtitle={hnErrorMessage(idsError)}
|
|
507
|
+
hint="r to retry"
|
|
508
|
+
/>
|
|
509
|
+
) : view.kind === "list" ? (
|
|
510
|
+
<StoryListView
|
|
511
|
+
key={category}
|
|
512
|
+
ref={listScrollRef}
|
|
513
|
+
items={items}
|
|
514
|
+
cursor={listCursor}
|
|
515
|
+
loading={listLoading}
|
|
516
|
+
savedIds={savedIds}
|
|
517
|
+
viewedIds={viewedIds}
|
|
518
|
+
emptyMessage={
|
|
519
|
+
category === "saved"
|
|
520
|
+
? "No saved posts yet. Press 's' on a story."
|
|
521
|
+
: category === "history"
|
|
522
|
+
? "No history yet. Posts you open will show up here."
|
|
523
|
+
: ids.length > 0
|
|
524
|
+
? "Couldn't load stories — press r to retry."
|
|
525
|
+
: "No stories"
|
|
526
|
+
}
|
|
527
|
+
loadingMessage={
|
|
528
|
+
category === "saved"
|
|
529
|
+
? "Loading saved posts…"
|
|
530
|
+
: category === "history"
|
|
531
|
+
? "Loading history…"
|
|
532
|
+
: "Loading stories…"
|
|
533
|
+
}
|
|
534
|
+
onSelect={setListCursor}
|
|
535
|
+
onActivate={(idx) => {
|
|
536
|
+
const cur = items[idx]
|
|
537
|
+
if (cur) enterDetail(cur)
|
|
538
|
+
}}
|
|
539
|
+
onContextMenu={(idx, ev) => {
|
|
540
|
+
const cur = items[idx]
|
|
541
|
+
if (cur) openMenuForStory(cur, ev.x, ev.y)
|
|
542
|
+
}}
|
|
543
|
+
/>
|
|
544
|
+
) : view.kind === "detail" ? (
|
|
545
|
+
<StoryDetailView
|
|
546
|
+
key={view.story.id}
|
|
547
|
+
ref={detailScrollRef}
|
|
548
|
+
story={view.story}
|
|
549
|
+
flat={flat}
|
|
550
|
+
cursor={detailCursor}
|
|
551
|
+
collapsed={collapsed}
|
|
552
|
+
loading={detailLoading}
|
|
553
|
+
saved={isSaved(view.story.id)}
|
|
554
|
+
emptyMessage={
|
|
555
|
+
(view.story.descendants ?? 0) > 0
|
|
556
|
+
? "Couldn't load comments — press r to retry."
|
|
557
|
+
: undefined
|
|
558
|
+
}
|
|
559
|
+
onSelectComment={setDetailCursor}
|
|
560
|
+
onToggleComment={toggleCollapse}
|
|
561
|
+
onOpenLinks={openLinksFor}
|
|
562
|
+
/>
|
|
563
|
+
) : view.error._tag === "HnItemGone" ? (
|
|
564
|
+
<MessageView
|
|
565
|
+
art={"¯\\_(ツ)_/¯"}
|
|
566
|
+
title="Post not found"
|
|
567
|
+
subtitle="This link points to a post that doesn't exist (or was deleted)."
|
|
568
|
+
hint="esc to go back"
|
|
569
|
+
/>
|
|
570
|
+
) : (
|
|
571
|
+
<MessageView
|
|
572
|
+
art="(×_×)"
|
|
573
|
+
title="Couldn't open that post"
|
|
574
|
+
subtitle={hnErrorMessage(view.error)}
|
|
575
|
+
hint="r to retry · esc to go back"
|
|
576
|
+
/>
|
|
577
|
+
)}
|
|
578
|
+
</box>
|
|
579
|
+
<StatusBar
|
|
580
|
+
view={view.kind === "detail" ? "detail" : "list"}
|
|
581
|
+
category={category}
|
|
582
|
+
loading={statusLoading}
|
|
583
|
+
message={
|
|
584
|
+
view.kind === "resolveError"
|
|
585
|
+
? view.error._tag === "HnItemGone"
|
|
586
|
+
? "h/esc go back · q quit"
|
|
587
|
+
: "r retry · h/esc go back · q quit"
|
|
588
|
+
: view.kind === "list" && idsError
|
|
589
|
+
? "r retry · q quit"
|
|
590
|
+
: undefined
|
|
591
|
+
}
|
|
592
|
+
/>
|
|
593
|
+
{menu ? (
|
|
594
|
+
<ContextMenu
|
|
595
|
+
x={menu.x}
|
|
596
|
+
y={menu.y}
|
|
597
|
+
items={menu.items}
|
|
598
|
+
cursor={menu.cursor}
|
|
599
|
+
onSelect={(idx) => setMenu((m) => (m ? { ...m, cursor: idx } : m))}
|
|
600
|
+
onActivate={(idx) => {
|
|
601
|
+
const item = menu.items[idx]
|
|
602
|
+
if (item && !item.disabled) item.action()
|
|
603
|
+
setMenu(null)
|
|
604
|
+
}}
|
|
605
|
+
onClose={() => setMenu(null)}
|
|
606
|
+
/>
|
|
607
|
+
) : null}
|
|
608
|
+
{popup ? (
|
|
609
|
+
<LinksPopup
|
|
610
|
+
links={popup.links}
|
|
611
|
+
cursor={popup.cursor}
|
|
612
|
+
onSelect={(idx) => setPopup((p) => (p ? { ...p, cursor: idx } : p))}
|
|
613
|
+
onActivate={(idx) => {
|
|
614
|
+
const link = popup.links[idx]
|
|
615
|
+
if (link) openLink(link)
|
|
616
|
+
}}
|
|
617
|
+
onClose={() => setPopup(null)}
|
|
618
|
+
/>
|
|
619
|
+
) : null}
|
|
620
|
+
{help ? (
|
|
621
|
+
<HelpOverlay
|
|
622
|
+
view={view.kind === "list" ? "list" : "detail"}
|
|
623
|
+
onClose={() => setHelp(false)}
|
|
624
|
+
/>
|
|
625
|
+
) : null}
|
|
626
|
+
</box>
|
|
627
|
+
</ThemeContext.Provider>
|
|
628
|
+
)
|
|
629
|
+
}
|