@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/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
+ }