@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
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { TextAttributes } from "@opentui/core"
|
|
2
|
+
import { selectionColors, useTheme } from "../theme"
|
|
3
|
+
|
|
4
|
+
interface Binding {
|
|
5
|
+
keys: string
|
|
6
|
+
desc: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface Group {
|
|
10
|
+
title: string
|
|
11
|
+
bindings: Binding[]
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const NAVIGATION: Binding[] = [
|
|
15
|
+
{ keys: "j k ↑ ↓", desc: "move down / up" },
|
|
16
|
+
{ keys: "g g / G", desc: "jump to top / bottom" },
|
|
17
|
+
{ keys: "Ctrl+d / Ctrl+u", desc: "half-page down / up" },
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
const LIST_GROUPS: Group[] = [
|
|
21
|
+
{
|
|
22
|
+
title: "Navigation",
|
|
23
|
+
bindings: [
|
|
24
|
+
...NAVIGATION,
|
|
25
|
+
{ keys: "h / l", desc: "previous / next category" },
|
|
26
|
+
{ keys: "Tab / ⇧Tab", desc: "cycle categories" },
|
|
27
|
+
{ keys: "1 – 6", desc: "jump to category" },
|
|
28
|
+
],
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
title: "Actions",
|
|
32
|
+
bindings: [
|
|
33
|
+
{ keys: "c / ⏎", desc: "open comments" },
|
|
34
|
+
{ keys: "o", desc: "open post link in browser" },
|
|
35
|
+
{ keys: "y", desc: "open HN page in browser" },
|
|
36
|
+
{ keys: "s", desc: "save / unsave" },
|
|
37
|
+
{ keys: "r", desc: "refresh feed" },
|
|
38
|
+
{ keys: "x", desc: "clear history (History view)" },
|
|
39
|
+
],
|
|
40
|
+
},
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
const DETAIL_GROUPS: Group[] = [
|
|
44
|
+
{
|
|
45
|
+
title: "Navigation",
|
|
46
|
+
bindings: NAVIGATION,
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
title: "Actions",
|
|
50
|
+
bindings: [
|
|
51
|
+
{ keys: "space", desc: "collapse / expand" },
|
|
52
|
+
{ keys: "⏎", desc: "comment links (HN posts open in-app)" },
|
|
53
|
+
{ keys: "o", desc: "open post link in browser" },
|
|
54
|
+
{ keys: "y", desc: "open HN page in browser" },
|
|
55
|
+
{ keys: "s", desc: "save / unsave" },
|
|
56
|
+
{ keys: "h / esc", desc: "go back" },
|
|
57
|
+
],
|
|
58
|
+
},
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
const GENERAL: Group = {
|
|
62
|
+
title: "General",
|
|
63
|
+
bindings: [
|
|
64
|
+
{ keys: "S", desc: "saved posts" },
|
|
65
|
+
{ keys: "H", desc: "view history" },
|
|
66
|
+
{ keys: "t", desc: "toggle theme" },
|
|
67
|
+
{ keys: "?", desc: "toggle this help" },
|
|
68
|
+
{ keys: "q", desc: "quit" },
|
|
69
|
+
],
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
interface Props {
|
|
73
|
+
view: "list" | "detail"
|
|
74
|
+
onClose: () => void
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function HelpOverlay({ view, onClose }: Props) {
|
|
78
|
+
const t = useTheme()
|
|
79
|
+
const groups = [...(view === "list" ? LIST_GROUPS : DETAIL_GROUPS), GENERAL]
|
|
80
|
+
const keyWidth = groups.reduce(
|
|
81
|
+
(w, g) => g.bindings.reduce((m, b) => Math.max(m, b.keys.length), w),
|
|
82
|
+
0,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
return (
|
|
86
|
+
<box
|
|
87
|
+
position="absolute"
|
|
88
|
+
top={0}
|
|
89
|
+
left={0}
|
|
90
|
+
width="100%"
|
|
91
|
+
height="100%"
|
|
92
|
+
alignItems="center"
|
|
93
|
+
justifyContent="center"
|
|
94
|
+
zIndex={150}
|
|
95
|
+
backgroundColor="#00000088"
|
|
96
|
+
onMouseDown={onClose}
|
|
97
|
+
>
|
|
98
|
+
<box
|
|
99
|
+
flexDirection="column"
|
|
100
|
+
width="60%"
|
|
101
|
+
backgroundColor={t.body}
|
|
102
|
+
border={true}
|
|
103
|
+
borderStyle="rounded"
|
|
104
|
+
borderColor={t.accent}
|
|
105
|
+
paddingTop={1}
|
|
106
|
+
paddingBottom={1}
|
|
107
|
+
paddingLeft={2}
|
|
108
|
+
paddingRight={2}
|
|
109
|
+
title=" Keyboard shortcuts "
|
|
110
|
+
onMouseDown={(ev) => ev.stopPropagation()}
|
|
111
|
+
>
|
|
112
|
+
{groups.map((group, gi) => (
|
|
113
|
+
<box key={group.title} flexDirection="column" marginTop={gi === 0 ? 0 : 1}>
|
|
114
|
+
<text fg={t.accent} {...selectionColors(t)} attributes={TextAttributes.BOLD}>
|
|
115
|
+
{group.title}
|
|
116
|
+
</text>
|
|
117
|
+
{group.bindings.map((b, bi) => (
|
|
118
|
+
<text key={bi} {...selectionColors(t)}>
|
|
119
|
+
<span fg={t.text}>{` ${b.keys.padEnd(keyWidth)}`}</span>
|
|
120
|
+
<span fg={t.textMuted}>{` ${b.desc}`}</span>
|
|
121
|
+
</text>
|
|
122
|
+
))}
|
|
123
|
+
</box>
|
|
124
|
+
))}
|
|
125
|
+
<box marginTop={1}>
|
|
126
|
+
<text fg={t.statusHint} {...selectionColors(t)}>
|
|
127
|
+
esc / ? close
|
|
128
|
+
</text>
|
|
129
|
+
</box>
|
|
130
|
+
</box>
|
|
131
|
+
</box>
|
|
132
|
+
)
|
|
133
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { TextAttributes } from "@opentui/core"
|
|
2
|
+
import type { Link } from "../utils/format"
|
|
3
|
+
import { selectionColors, useTheme } from "../theme"
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
links: Link[]
|
|
7
|
+
cursor: number
|
|
8
|
+
onSelect: (idx: number) => void
|
|
9
|
+
onActivate: (idx: number) => void
|
|
10
|
+
onClose: () => void
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function LinksPopup({ links, cursor, onSelect, onActivate, onClose }: Props) {
|
|
14
|
+
const t = useTheme()
|
|
15
|
+
|
|
16
|
+
return (
|
|
17
|
+
<box
|
|
18
|
+
position="absolute"
|
|
19
|
+
top={0}
|
|
20
|
+
left={0}
|
|
21
|
+
width="100%"
|
|
22
|
+
height="100%"
|
|
23
|
+
alignItems="center"
|
|
24
|
+
justifyContent="center"
|
|
25
|
+
zIndex={100}
|
|
26
|
+
backgroundColor="#00000088"
|
|
27
|
+
onMouseDown={onClose}
|
|
28
|
+
>
|
|
29
|
+
<box
|
|
30
|
+
flexDirection="column"
|
|
31
|
+
width="70%"
|
|
32
|
+
backgroundColor={t.body}
|
|
33
|
+
border={true}
|
|
34
|
+
borderStyle="rounded"
|
|
35
|
+
borderColor={t.accent}
|
|
36
|
+
paddingTop={1}
|
|
37
|
+
paddingBottom={1}
|
|
38
|
+
paddingLeft={2}
|
|
39
|
+
paddingRight={2}
|
|
40
|
+
title={` Links (${links.length}) `}
|
|
41
|
+
onMouseDown={(ev) => ev.stopPropagation()}
|
|
42
|
+
>
|
|
43
|
+
{links.map((link, idx) => (
|
|
44
|
+
<LinkRow
|
|
45
|
+
key={`${idx}-${link.url}`}
|
|
46
|
+
link={link}
|
|
47
|
+
index={idx}
|
|
48
|
+
selected={idx === cursor}
|
|
49
|
+
isFirst={idx === 0}
|
|
50
|
+
onSelect={() => onSelect(idx)}
|
|
51
|
+
onActivate={() => onActivate(idx)}
|
|
52
|
+
/>
|
|
53
|
+
))}
|
|
54
|
+
<box marginTop={1}>
|
|
55
|
+
<text fg={t.statusHint} {...selectionColors(t)}>j/k move · ⏎ open · o browser · esc close</text>
|
|
56
|
+
</box>
|
|
57
|
+
</box>
|
|
58
|
+
</box>
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
interface RowProps {
|
|
63
|
+
link: Link
|
|
64
|
+
index: number
|
|
65
|
+
selected: boolean
|
|
66
|
+
isFirst: boolean
|
|
67
|
+
onSelect: () => void
|
|
68
|
+
onActivate: () => void
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function LinkRow({ link, index, selected, isFirst, onSelect, onActivate }: RowProps) {
|
|
72
|
+
const t = useTheme()
|
|
73
|
+
let lastClick = 0
|
|
74
|
+
const handleClick = () => {
|
|
75
|
+
const now = Date.now()
|
|
76
|
+
if (selected || now - lastClick < 400) {
|
|
77
|
+
onActivate()
|
|
78
|
+
} else {
|
|
79
|
+
onSelect()
|
|
80
|
+
}
|
|
81
|
+
lastClick = now
|
|
82
|
+
}
|
|
83
|
+
return (
|
|
84
|
+
<box
|
|
85
|
+
flexDirection="column"
|
|
86
|
+
marginTop={isFirst ? 0 : 1}
|
|
87
|
+
backgroundColor={selected ? t.rowHighlight : undefined}
|
|
88
|
+
paddingLeft={1}
|
|
89
|
+
paddingRight={1}
|
|
90
|
+
onMouseDown={handleClick}
|
|
91
|
+
>
|
|
92
|
+
<text {...selectionColors(t)}>
|
|
93
|
+
<span fg={selected ? t.accent : t.textDim}>{selected ? "▶ " : " "}</span>
|
|
94
|
+
<span fg={t.text} attributes={selected ? TextAttributes.BOLD : TextAttributes.NONE}>
|
|
95
|
+
{`${index + 1}. ${link.text}`}
|
|
96
|
+
</span>
|
|
97
|
+
</text>
|
|
98
|
+
<text fg={t.link} {...selectionColors(t)}>{` ${link.url}`}</text>
|
|
99
|
+
</box>
|
|
100
|
+
)
|
|
101
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { RGBA } from "@opentui/core"
|
|
2
|
+
import { createColors, createFrames } from "../spinner"
|
|
3
|
+
|
|
4
|
+
const HN_ORANGE_RAMP = [
|
|
5
|
+
RGBA.fromHex("#ff6600"),
|
|
6
|
+
RGBA.fromHex("#ffaa66"),
|
|
7
|
+
RGBA.fromHex("#dd5500"),
|
|
8
|
+
RGBA.fromHex("#aa4400"),
|
|
9
|
+
RGBA.fromHex("#773300"),
|
|
10
|
+
RGBA.fromHex("#442200"),
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
const HN_INACTIVE = RGBA.fromHex("#2a1500")
|
|
14
|
+
|
|
15
|
+
const OPTIONS = {
|
|
16
|
+
style: "blocks" as const,
|
|
17
|
+
colors: HN_ORANGE_RAMP,
|
|
18
|
+
defaultColor: HN_INACTIVE,
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const FRAMES = createFrames(OPTIONS)
|
|
22
|
+
const COLOR = createColors(OPTIONS)
|
|
23
|
+
|
|
24
|
+
interface Props {
|
|
25
|
+
interval?: number
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function Loader({ interval = 40 }: Props) {
|
|
29
|
+
return <spinner frames={FRAMES} color={COLOR} interval={interval} />
|
|
30
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { Loader } from "./Loader"
|
|
2
|
+
import type { Category } from "../api/types"
|
|
3
|
+
import { selectionColors, useTheme } from "../theme"
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
view: "list" | "detail"
|
|
7
|
+
category?: Category
|
|
8
|
+
loading?: boolean
|
|
9
|
+
message?: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function StatusBar({ view, category, loading, message }: Props) {
|
|
13
|
+
const t = useTheme()
|
|
14
|
+
const hints =
|
|
15
|
+
view === "list"
|
|
16
|
+
? category === "history"
|
|
17
|
+
? "j/k move · c/⏎ open · x clear history · q quit"
|
|
18
|
+
: "j/k move · c/⏎ open · s save · q quit"
|
|
19
|
+
: "j/k move · space collapse · ⏎ links · h/esc back"
|
|
20
|
+
return (
|
|
21
|
+
<box
|
|
22
|
+
flexDirection="row"
|
|
23
|
+
flexShrink={0}
|
|
24
|
+
height={2}
|
|
25
|
+
paddingLeft={1}
|
|
26
|
+
paddingRight={1}
|
|
27
|
+
gap={1}
|
|
28
|
+
alignItems="center"
|
|
29
|
+
backgroundColor={t.strip}
|
|
30
|
+
border={["top"]}
|
|
31
|
+
borderStyle="single"
|
|
32
|
+
borderColor={t.border}
|
|
33
|
+
>
|
|
34
|
+
{loading ? <Loader /> : null}
|
|
35
|
+
<text fg={t.statusHint} {...selectionColors(t)}>{message ?? hints}</text>
|
|
36
|
+
<box flexGrow={1} />
|
|
37
|
+
<text fg={t.statusHint} {...selectionColors(t)}>? help</text>
|
|
38
|
+
</box>
|
|
39
|
+
)
|
|
40
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { MouseEvent } from "@opentui/core"
|
|
2
|
+
import { TextAttributes } from "@opentui/core"
|
|
3
|
+
import type { Item } from "../api/types"
|
|
4
|
+
import { hostname, relativeTime } from "../utils/format"
|
|
5
|
+
import { selectionColors, useTheme } from "../theme"
|
|
6
|
+
|
|
7
|
+
interface Props {
|
|
8
|
+
rank: number
|
|
9
|
+
item: Item
|
|
10
|
+
selected: boolean
|
|
11
|
+
saved?: boolean
|
|
12
|
+
visited?: boolean
|
|
13
|
+
onSelect: () => void
|
|
14
|
+
onActivate: () => void
|
|
15
|
+
onContextMenu?: (ev: MouseEvent) => void
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function StoryRow({ rank, item, selected, saved, visited, onSelect, onActivate, onContextMenu }: Props) {
|
|
19
|
+
const t = useTheme()
|
|
20
|
+
const host = hostname(item.url)
|
|
21
|
+
const title = item.title ?? "(untitled)"
|
|
22
|
+
const score = item.score ?? 0
|
|
23
|
+
const author = item.by ?? "?"
|
|
24
|
+
const age = relativeTime(item.time)
|
|
25
|
+
const comments = item.descendants ?? 0
|
|
26
|
+
const bg = selected ? t.rowHighlight : undefined
|
|
27
|
+
const titleFg = visited ? t.textVisited : selected ? t.text : t.textBody
|
|
28
|
+
const rankFg = visited ? t.textVisited : selected ? t.accent : t.textDim
|
|
29
|
+
const voteFg = visited ? t.textVisited : t.accent
|
|
30
|
+
const dimFg = visited ? t.textVisited : t.textDim
|
|
31
|
+
const mutedFg = visited ? t.textVisited : t.textMuted
|
|
32
|
+
|
|
33
|
+
let lastClick = 0
|
|
34
|
+
const handleClick = (ev: MouseEvent) => {
|
|
35
|
+
if (ev.button === 2) {
|
|
36
|
+
onSelect()
|
|
37
|
+
onContextMenu?.(ev)
|
|
38
|
+
return
|
|
39
|
+
}
|
|
40
|
+
const now = Date.now()
|
|
41
|
+
if (selected || now - lastClick < 400) {
|
|
42
|
+
onActivate()
|
|
43
|
+
} else {
|
|
44
|
+
onSelect()
|
|
45
|
+
}
|
|
46
|
+
lastClick = now
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<box
|
|
51
|
+
id={`row-${rank}`}
|
|
52
|
+
flexDirection="column"
|
|
53
|
+
paddingLeft={1}
|
|
54
|
+
paddingRight={1}
|
|
55
|
+
backgroundColor={bg}
|
|
56
|
+
onMouseDown={handleClick}
|
|
57
|
+
>
|
|
58
|
+
<text {...selectionColors(t)}>
|
|
59
|
+
<span fg={rankFg}>{`${String(rank).padStart(3, " ")}. `}</span>
|
|
60
|
+
{saved ? <span fg={t.accent}>{"★ "}</span> : null}
|
|
61
|
+
<span fg={titleFg} attributes={selected ? TextAttributes.BOLD : TextAttributes.NONE}>
|
|
62
|
+
{title}
|
|
63
|
+
</span>
|
|
64
|
+
{host ? <span fg={dimFg}>{` (${host})`}</span> : null}
|
|
65
|
+
</text>
|
|
66
|
+
<text {...selectionColors(t)}>
|
|
67
|
+
<span fg={t.textDim}> </span>
|
|
68
|
+
<span fg={voteFg}>▲ {score}</span>
|
|
69
|
+
<span fg={dimFg}>{` by `}</span>
|
|
70
|
+
<span fg={mutedFg}>{author}</span>
|
|
71
|
+
<span fg={dimFg}>{` ${age} | `}</span>
|
|
72
|
+
<span fg={mutedFg}>{`${comments} comment${comments === 1 ? "" : "s"}`}</span>
|
|
73
|
+
</text>
|
|
74
|
+
</box>
|
|
75
|
+
)
|
|
76
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { useEffect, useState } from "react"
|
|
2
|
+
import { Effect, Fiber } from "effect"
|
|
3
|
+
import { AppRuntime } from "../runtime"
|
|
4
|
+
import { fetchItem } from "../api/hn"
|
|
5
|
+
import type { HnApi } from "../api/hn"
|
|
6
|
+
import type { Item } from "../api/types"
|
|
7
|
+
|
|
8
|
+
export interface CommentNode {
|
|
9
|
+
item: Item
|
|
10
|
+
children: CommentNode[]
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface FlatComment {
|
|
14
|
+
node: CommentNode
|
|
15
|
+
depth: number
|
|
16
|
+
hiddenChildren: number
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const loadTree = (
|
|
20
|
+
id: number,
|
|
21
|
+
depth: number,
|
|
22
|
+
maxDepth: number,
|
|
23
|
+
): Effect.Effect<CommentNode | null, never, HnApi> =>
|
|
24
|
+
Effect.gen(function* () {
|
|
25
|
+
// a comment that fails to load is simply omitted, like before
|
|
26
|
+
const item = yield* fetchItem(id).pipe(Effect.orElseSucceed(() => null))
|
|
27
|
+
if (!item || item.deleted || item.dead) return null
|
|
28
|
+
const kidIds = item.kids ?? []
|
|
29
|
+
let children: CommentNode[] = []
|
|
30
|
+
if (depth < maxDepth && kidIds.length > 0) {
|
|
31
|
+
const loaded = yield* Effect.forEach(
|
|
32
|
+
kidIds,
|
|
33
|
+
(kid) => loadTree(kid, depth + 1, maxDepth),
|
|
34
|
+
{ concurrency: "unbounded" },
|
|
35
|
+
)
|
|
36
|
+
children = loaded.filter((x): x is CommentNode => x !== null)
|
|
37
|
+
}
|
|
38
|
+
return { item, children }
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
export function useCommentTree(rootIds: number[] | undefined, maxDepth = 8, refreshKey = 0) {
|
|
42
|
+
const idsKey = rootIds && rootIds.length > 0 ? rootIds.join(",") : ""
|
|
43
|
+
// refreshKey re-runs the load for the same story: failed fetches were
|
|
44
|
+
// evicted from the item cache, so a retry actually refetches them
|
|
45
|
+
const key = idsKey === "" ? "" : `${refreshKey}|${idsKey}`
|
|
46
|
+
// The tree is tagged with the key it was loaded for, so switching stories
|
|
47
|
+
// DERIVES empty+loading state on the very same render
|
|
48
|
+
const [result, setResult] = useState<{ key: string; tree: CommentNode[] }>({
|
|
49
|
+
key: "",
|
|
50
|
+
tree: [],
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
if (key === "") return
|
|
55
|
+
const ids = key.slice(key.indexOf("|") + 1).split(",").map(Number)
|
|
56
|
+
const fiber = AppRuntime.runFork(
|
|
57
|
+
Effect.forEach(ids, (id) => loadTree(id, 0, maxDepth), {
|
|
58
|
+
concurrency: "unbounded",
|
|
59
|
+
}).pipe(
|
|
60
|
+
Effect.andThen((nodes) =>
|
|
61
|
+
Effect.sync(() => {
|
|
62
|
+
setResult({ key, tree: nodes.filter((x): x is CommentNode => x !== null) })
|
|
63
|
+
}),
|
|
64
|
+
),
|
|
65
|
+
),
|
|
66
|
+
)
|
|
67
|
+
return () => {
|
|
68
|
+
// interrupts the ENTIRE tree of pending comment fetches at once
|
|
69
|
+
AppRuntime.runFork(Fiber.interrupt(fiber))
|
|
70
|
+
}
|
|
71
|
+
}, [key, maxDepth])
|
|
72
|
+
|
|
73
|
+
const fresh = key !== "" && result.key === key
|
|
74
|
+
return { tree: fresh ? result.tree : [], loading: key !== "" && !fresh }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function flattenTree(tree: CommentNode[], collapsed: Set<number>): FlatComment[] {
|
|
78
|
+
const out: FlatComment[] = []
|
|
79
|
+
const walk = (nodes: CommentNode[], depth: number) => {
|
|
80
|
+
for (const node of nodes) {
|
|
81
|
+
const isCollapsed = collapsed.has(node.item.id)
|
|
82
|
+
const hidden = isCollapsed ? countAll(node) : 0
|
|
83
|
+
out.push({ node, depth, hiddenChildren: hidden })
|
|
84
|
+
if (!isCollapsed) walk(node.children, depth + 1)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
walk(tree, 0)
|
|
88
|
+
return out
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function countAll(node: CommentNode): number {
|
|
92
|
+
let n = node.children.length
|
|
93
|
+
for (const c of node.children) n += countAll(c)
|
|
94
|
+
return n
|
|
95
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
|
2
|
+
import type { HistoryEntry } from "../utils/historyStore"
|
|
3
|
+
import { HISTORY_CAP, loadHistory, persistHistory } from "../utils/historyStore"
|
|
4
|
+
|
|
5
|
+
export function useHistory() {
|
|
6
|
+
const [entries, setEntries] = useState<HistoryEntry[]>([])
|
|
7
|
+
const [loaded, setLoaded] = useState(false)
|
|
8
|
+
const firstLoad = useRef(true)
|
|
9
|
+
|
|
10
|
+
useEffect(() => {
|
|
11
|
+
loadHistory().then((data) => {
|
|
12
|
+
setEntries(data)
|
|
13
|
+
setLoaded(true)
|
|
14
|
+
})
|
|
15
|
+
}, [])
|
|
16
|
+
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
if (firstLoad.current) {
|
|
19
|
+
firstLoad.current = false
|
|
20
|
+
return
|
|
21
|
+
}
|
|
22
|
+
if (loaded) void persistHistory(entries)
|
|
23
|
+
}, [entries, loaded])
|
|
24
|
+
|
|
25
|
+
const idSet = useMemo(() => new Set(entries.map((e) => e.id)), [entries])
|
|
26
|
+
|
|
27
|
+
const isViewed = useCallback((id: number) => idSet.has(id), [idSet])
|
|
28
|
+
|
|
29
|
+
// Move the post to the front (most recent) and evict the oldest beyond the cap.
|
|
30
|
+
const markViewed = useCallback((id: number) => {
|
|
31
|
+
setEntries((prev) => {
|
|
32
|
+
const rest = prev.filter((e) => e.id !== id)
|
|
33
|
+
return [{ id, viewedAt: Date.now() }, ...rest].slice(0, HISTORY_CAP)
|
|
34
|
+
})
|
|
35
|
+
}, [])
|
|
36
|
+
|
|
37
|
+
const clear = useCallback(() => setEntries([]), [])
|
|
38
|
+
|
|
39
|
+
return { entries, idSet, isViewed, markViewed, clear, loaded }
|
|
40
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { useEffect, useState } from "react"
|
|
2
|
+
import { Effect, Fiber } from "effect"
|
|
3
|
+
import { AppRuntime } from "../runtime"
|
|
4
|
+
import { fetchItems } from "../api/hn"
|
|
5
|
+
import type { Item } from "../api/types"
|
|
6
|
+
|
|
7
|
+
export function useItems(ids: number[]) {
|
|
8
|
+
const [items, setItems] = useState<Item[]>([])
|
|
9
|
+
const [loading, setLoading] = useState(false)
|
|
10
|
+
|
|
11
|
+
useEffect(() => {
|
|
12
|
+
if (ids.length === 0) {
|
|
13
|
+
setItems([])
|
|
14
|
+
return
|
|
15
|
+
}
|
|
16
|
+
setItems([])
|
|
17
|
+
setLoading(true)
|
|
18
|
+
const fiber = AppRuntime.runFork(
|
|
19
|
+
fetchItems(ids).pipe(
|
|
20
|
+
Effect.andThen((data) =>
|
|
21
|
+
Effect.sync(() => {
|
|
22
|
+
setItems(data)
|
|
23
|
+
setLoading(false)
|
|
24
|
+
}),
|
|
25
|
+
),
|
|
26
|
+
),
|
|
27
|
+
)
|
|
28
|
+
return () => {
|
|
29
|
+
AppRuntime.runFork(Fiber.interrupt(fiber))
|
|
30
|
+
}
|
|
31
|
+
}, [ids.join(",")])
|
|
32
|
+
|
|
33
|
+
return { items, loading }
|
|
34
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
|
2
|
+
import type { SavedEntry } from "../utils/savedStore"
|
|
3
|
+
import { loadSaved, persistSaved } from "../utils/savedStore"
|
|
4
|
+
|
|
5
|
+
export function useSaved() {
|
|
6
|
+
const [entries, setEntries] = useState<SavedEntry[]>([])
|
|
7
|
+
const [loaded, setLoaded] = useState(false)
|
|
8
|
+
const firstLoad = useRef(true)
|
|
9
|
+
|
|
10
|
+
useEffect(() => {
|
|
11
|
+
loadSaved().then((data) => {
|
|
12
|
+
setEntries(data)
|
|
13
|
+
setLoaded(true)
|
|
14
|
+
})
|
|
15
|
+
}, [])
|
|
16
|
+
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
if (firstLoad.current) {
|
|
19
|
+
firstLoad.current = false
|
|
20
|
+
return
|
|
21
|
+
}
|
|
22
|
+
if (loaded) void persistSaved(entries)
|
|
23
|
+
}, [entries, loaded])
|
|
24
|
+
|
|
25
|
+
const idSet = useMemo(() => new Set(entries.map((e) => e.id)), [entries])
|
|
26
|
+
|
|
27
|
+
const isSaved = useCallback((id: number) => idSet.has(id), [idSet])
|
|
28
|
+
|
|
29
|
+
const toggle = useCallback((id: number) => {
|
|
30
|
+
setEntries((prev) => {
|
|
31
|
+
if (prev.some((e) => e.id === id)) return prev.filter((e) => e.id !== id)
|
|
32
|
+
return [{ id, savedAt: Date.now() }, ...prev]
|
|
33
|
+
})
|
|
34
|
+
}, [])
|
|
35
|
+
|
|
36
|
+
return { entries, idSet, isSaved, toggle, loaded }
|
|
37
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { useEffect, useState } from "react"
|
|
2
|
+
import { Effect, Fiber } from "effect"
|
|
3
|
+
import { AppRuntime } from "../runtime"
|
|
4
|
+
import { fetchIds } from "../api/hn"
|
|
5
|
+
import type { HnError } from "../api/hn"
|
|
6
|
+
import type { FeedCategory } from "../api/types"
|
|
7
|
+
|
|
8
|
+
export function useStoryIds(category: FeedCategory, refreshKey = 0) {
|
|
9
|
+
const [ids, setIds] = useState<number[]>([])
|
|
10
|
+
const [loading, setLoading] = useState(true)
|
|
11
|
+
const [error, setError] = useState<HnError | null>(null)
|
|
12
|
+
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
setIds([])
|
|
15
|
+
setLoading(true)
|
|
16
|
+
setError(null)
|
|
17
|
+
const fiber = AppRuntime.runFork(
|
|
18
|
+
fetchIds(category).pipe(
|
|
19
|
+
Effect.match({
|
|
20
|
+
onSuccess: (data) => {
|
|
21
|
+
setIds(data)
|
|
22
|
+
setLoading(false)
|
|
23
|
+
},
|
|
24
|
+
onFailure: (err) => {
|
|
25
|
+
setError(err)
|
|
26
|
+
setLoading(false)
|
|
27
|
+
},
|
|
28
|
+
}),
|
|
29
|
+
),
|
|
30
|
+
)
|
|
31
|
+
return () => {
|
|
32
|
+
// interrupting the fiber aborts the in-flight HTTP request
|
|
33
|
+
AppRuntime.runFork(Fiber.interrupt(fiber))
|
|
34
|
+
}
|
|
35
|
+
}, [category, refreshKey])
|
|
36
|
+
|
|
37
|
+
return { ids, loading, error }
|
|
38
|
+
}
|
package/src/index.tsx
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { createCliRenderer } from "@opentui/core"
|
|
3
|
+
import { createRoot } from "@opentui/react"
|
|
4
|
+
import "opentui-spinner/react"
|
|
5
|
+
import { App } from "./App"
|
|
6
|
+
|
|
7
|
+
const renderer = await createCliRenderer({ useMouse: true })
|
|
8
|
+
createRoot(renderer).render(<App />)
|
package/src/runtime.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { Layer, ManagedRuntime } from "effect"
|
|
2
|
+
import { HnApiLive, HnTransportLive } from "./api/hn"
|
|
3
|
+
import { HnTransportLinkDemo } from "./api/hnDemo"
|
|
4
|
+
|
|
5
|
+
// The app's single composition point: the whole layer graph is decided here,
|
|
6
|
+
// once, at the edge. Everything below runs against whatever this provides —
|
|
7
|
+
// HN_DEMO=1 swaps the transport and no other file knows or cares.
|
|
8
|
+
// (A real app would keep demo code out of the production bundle via separate
|
|
9
|
+
// entry points; for a TUI this trade is fine.)
|
|
10
|
+
const transport = process.env.HN_DEMO === "1" ? HnTransportLinkDemo : HnTransportLive
|
|
11
|
+
|
|
12
|
+
export const AppRuntime = ManagedRuntime.make(HnApiLive.pipe(Layer.provide(transport)))
|