hackernews-tui 0.1.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/README.md +72 -0
- package/bin/hackernews-tui +2 -0
- package/package.json +48 -0
- package/readme.png +0 -0
- package/src/app/constants.ts +58 -0
- package/src/hooks/use-hn-browser-state.ts +337 -0
- package/src/index.tsx +502 -0
- package/src/lib/comment-loader.test.ts +34 -0
- package/src/lib/comment-loader.ts +45 -0
- package/src/lib/hn-api.ts +74 -0
- package/src/lib/hn-utils.test.ts +59 -0
- package/src/lib/hn-utils.ts +157 -0
- package/src/lib/open-external-url.ts +17 -0
package/README.md
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# Hacker News TUI
|
|
2
|
+
|
|
3
|
+

|
|
4
|
+
|
|
5
|
+
A full terminal UI for browsing Hacker News using the official [Hacker News API](https://github.com/HackerNews/API) and OpenTUI.
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- Browse feeds: Top, New, Best, Ask, Show, Jobs
|
|
10
|
+
- Search loaded stories by title, author, URL, and text
|
|
11
|
+
- Filter stories by post class and minimum points
|
|
12
|
+
- Sort stories by rank, newest, points, or comment count
|
|
13
|
+
- Open article URLs and HN discussion pages from the terminal
|
|
14
|
+
- Deep post view with recursively loaded nested comments
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
bun install
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Run
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
bun dev
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Run from npm with Bun:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
bunx hackernews-tui
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Watch mode (auto-restart on file changes):
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
bun run dev:watch
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Quality Checks
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
bun run typecheck
|
|
44
|
+
bun run lint
|
|
45
|
+
bun run format
|
|
46
|
+
bun run test
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Publish
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
npm login
|
|
53
|
+
npm publish --access public
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Keybindings
|
|
57
|
+
|
|
58
|
+
- `Tab`: cycle focus (`list -> detail -> search`)
|
|
59
|
+
- `/`: focus search box
|
|
60
|
+
- `Esc` / `Enter` (from search): return to story list
|
|
61
|
+
- `1-6`: switch feed (`Top, New, Best, Ask, Show, Jobs`)
|
|
62
|
+
- `s`: cycle sort mode
|
|
63
|
+
- `t`: cycle post type filter
|
|
64
|
+
- `p`: cycle minimum points filter
|
|
65
|
+
- `j` / `k` or `Down` / `Up`: move through stories
|
|
66
|
+
- `Enter` / `l` / `Right`: move focus to detail pane
|
|
67
|
+
- `h` / `Left`: move focus back to story list
|
|
68
|
+
- `n`: load more stories from current feed
|
|
69
|
+
- `r`: refresh current feed
|
|
70
|
+
- `o`: open selected story URL (or HN link fallback)
|
|
71
|
+
- `i`: open selected HN discussion page
|
|
72
|
+
- `q`: quit
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "hackernews-tui",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "A terminal UI for browsing Hacker News with search, filters, and deep threads.",
|
|
5
|
+
"module": "src/index.tsx",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"private": false,
|
|
8
|
+
"bin": {
|
|
9
|
+
"hackernews-tui": "bin/hackernews-tui"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"bin",
|
|
13
|
+
"src",
|
|
14
|
+
"README.md",
|
|
15
|
+
"readme.png"
|
|
16
|
+
],
|
|
17
|
+
"publishConfig": {
|
|
18
|
+
"access": "public"
|
|
19
|
+
},
|
|
20
|
+
"keywords": [
|
|
21
|
+
"hackernews",
|
|
22
|
+
"hn",
|
|
23
|
+
"tui",
|
|
24
|
+
"terminal",
|
|
25
|
+
"opentui",
|
|
26
|
+
"bun"
|
|
27
|
+
],
|
|
28
|
+
"scripts": {
|
|
29
|
+
"dev": "bun run src/index.tsx",
|
|
30
|
+
"dev:watch": "bun run --watch src/index.tsx",
|
|
31
|
+
"test": "bun test",
|
|
32
|
+
"typecheck": "tsc --noEmit",
|
|
33
|
+
"lint": "biome lint .",
|
|
34
|
+
"format": "biome format --write .",
|
|
35
|
+
"check": "bun run lint && bun run typecheck && bun run test",
|
|
36
|
+
"prepublishOnly": "bun run check"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@biomejs/biome": "^2.4.5",
|
|
40
|
+
"@types/bun": "latest",
|
|
41
|
+
"typescript": "^5.9.3"
|
|
42
|
+
},
|
|
43
|
+
"dependencies": {
|
|
44
|
+
"@opentui/core": "^0.1.84",
|
|
45
|
+
"@opentui/react": "^0.1.84",
|
|
46
|
+
"react": "^19.2.4"
|
|
47
|
+
}
|
|
48
|
+
}
|
package/readme.png
ADDED
|
Binary file
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
export const STORY_FETCH_STEP = 120;
|
|
2
|
+
export const STORY_FETCH_CONCURRENCY = 16;
|
|
3
|
+
export const COMMENT_INITIAL_BATCH = 40;
|
|
4
|
+
export const COMMENT_BATCH_SIZE = 30;
|
|
5
|
+
export const COMMENT_NEAR_BOTTOM_THRESHOLD = 4;
|
|
6
|
+
|
|
7
|
+
export const FEED_OPTIONS = [
|
|
8
|
+
{ value: "topstories", label: "Top" },
|
|
9
|
+
{ value: "newstories", label: "New" },
|
|
10
|
+
{ value: "beststories", label: "Best" },
|
|
11
|
+
{ value: "askstories", label: "Ask" },
|
|
12
|
+
{ value: "showstories", label: "Show" },
|
|
13
|
+
{ value: "jobstories", label: "Jobs" },
|
|
14
|
+
] as const;
|
|
15
|
+
|
|
16
|
+
export const SORT_OPTIONS = ["rank", "newest", "points", "comments"] as const;
|
|
17
|
+
export const SORT_LABELS: Record<(typeof SORT_OPTIONS)[number], string> = {
|
|
18
|
+
rank: "Rank",
|
|
19
|
+
newest: "Newest",
|
|
20
|
+
points: "Points",
|
|
21
|
+
comments: "Comments",
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export const TYPE_FILTER_OPTIONS = ["all", "story", "ask", "show", "job"] as const;
|
|
25
|
+
export const TYPE_FILTER_LABELS: Record<(typeof TYPE_FILTER_OPTIONS)[number], string> = {
|
|
26
|
+
all: "All",
|
|
27
|
+
story: "Stories",
|
|
28
|
+
ask: "Ask",
|
|
29
|
+
show: "Show",
|
|
30
|
+
job: "Jobs",
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export const SCORE_FILTER_OPTIONS = [0, 10, 50, 100, 250] as const;
|
|
34
|
+
|
|
35
|
+
export const UI = {
|
|
36
|
+
appBackground: "#111722",
|
|
37
|
+
panelBackground: "#16202d",
|
|
38
|
+
border: "#9aa8be",
|
|
39
|
+
text: "#f4f7ff",
|
|
40
|
+
muted: "#cad2df",
|
|
41
|
+
accent: "#8dd6ff",
|
|
42
|
+
accentWarm: "#ffd58a",
|
|
43
|
+
success: "#b4ef8b",
|
|
44
|
+
link: "#9fc4ff",
|
|
45
|
+
listBackground: "#141d2a",
|
|
46
|
+
listFocusedBackground: "#1b2738",
|
|
47
|
+
listSelectedBackground: "#355984",
|
|
48
|
+
listSelectedText: "#fff6cc",
|
|
49
|
+
inputBackground: "#0f1622",
|
|
50
|
+
inputText: "#f4f7ff",
|
|
51
|
+
inputPlaceholder: "#98a8be",
|
|
52
|
+
danger: "#ff879a",
|
|
53
|
+
} as const;
|
|
54
|
+
|
|
55
|
+
export type FeedKey = (typeof FEED_OPTIONS)[number]["value"];
|
|
56
|
+
export type SortMode = (typeof SORT_OPTIONS)[number];
|
|
57
|
+
export type StoryClass = (typeof TYPE_FILTER_OPTIONS)[number];
|
|
58
|
+
export type FocusZone = "list" | "detail" | "search";
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import {
|
|
3
|
+
COMMENT_BATCH_SIZE,
|
|
4
|
+
COMMENT_INITIAL_BATCH,
|
|
5
|
+
STORY_FETCH_CONCURRENCY,
|
|
6
|
+
STORY_FETCH_STEP,
|
|
7
|
+
type FeedKey,
|
|
8
|
+
type SortMode,
|
|
9
|
+
type StoryClass,
|
|
10
|
+
} from "../app/constants";
|
|
11
|
+
import {
|
|
12
|
+
createCommentStack,
|
|
13
|
+
loadNextCommentBatch,
|
|
14
|
+
type CommentStackNode,
|
|
15
|
+
type FlatComment,
|
|
16
|
+
} from "../lib/comment-loader";
|
|
17
|
+
import { getFeedIds, getItemCached, mapWithConcurrency } from "../lib/hn-api";
|
|
18
|
+
import { classifyStory, getErrorMessage, htmlToText, type HNItem } from "../lib/hn-utils";
|
|
19
|
+
|
|
20
|
+
interface CommentCursorState {
|
|
21
|
+
storyId: number;
|
|
22
|
+
stack: CommentStackNode[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function useHnBrowserState() {
|
|
26
|
+
const commentCursorRef = useRef<CommentCursorState | null>(null);
|
|
27
|
+
const commentLoadInFlightRef = useRef(false);
|
|
28
|
+
|
|
29
|
+
const [feed, setFeed] = useState<FeedKey>("topstories");
|
|
30
|
+
const [refreshTick, setRefreshTick] = useState(0);
|
|
31
|
+
const [storyLimit, setStoryLimit] = useState(STORY_FETCH_STEP);
|
|
32
|
+
const [feedIds, setFeedIds] = useState<number[]>([]);
|
|
33
|
+
const [stories, setStories] = useState<HNItem[]>([]);
|
|
34
|
+
const [selectedStoryIndex, setSelectedStoryIndex] = useState(0);
|
|
35
|
+
|
|
36
|
+
const [feedLoading, setFeedLoading] = useState(false);
|
|
37
|
+
const [storyLoading, setStoryLoading] = useState(false);
|
|
38
|
+
const [detailLoading, setDetailLoading] = useState(false);
|
|
39
|
+
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
|
40
|
+
|
|
41
|
+
const [searchQuery, setSearchQuery] = useState("");
|
|
42
|
+
const [sortMode, setSortMode] = useState<SortMode>("rank");
|
|
43
|
+
const [storyClass, setStoryClass] = useState<StoryClass>("all");
|
|
44
|
+
const [minScore, setMinScore] = useState(0);
|
|
45
|
+
|
|
46
|
+
const [detailItem, setDetailItem] = useState<HNItem | null>(null);
|
|
47
|
+
const [detailComments, setDetailComments] = useState<FlatComment[]>([]);
|
|
48
|
+
const [hasMoreComments, setHasMoreComments] = useState(false);
|
|
49
|
+
const [loadingMoreComments, setLoadingMoreComments] = useState(false);
|
|
50
|
+
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
let cancelled = false;
|
|
53
|
+
|
|
54
|
+
setFeedLoading(true);
|
|
55
|
+
setStoryLoading(false);
|
|
56
|
+
setDetailLoading(false);
|
|
57
|
+
setErrorMessage(null);
|
|
58
|
+
setFeedIds([]);
|
|
59
|
+
setStories([]);
|
|
60
|
+
setStoryLimit(STORY_FETCH_STEP);
|
|
61
|
+
setSelectedStoryIndex(0);
|
|
62
|
+
setDetailItem(null);
|
|
63
|
+
setDetailComments([]);
|
|
64
|
+
setHasMoreComments(false);
|
|
65
|
+
setLoadingMoreComments(false);
|
|
66
|
+
commentCursorRef.current = null;
|
|
67
|
+
commentLoadInFlightRef.current = false;
|
|
68
|
+
|
|
69
|
+
void (async () => {
|
|
70
|
+
try {
|
|
71
|
+
const ids = await getFeedIds(feed, refreshTick);
|
|
72
|
+
if (cancelled) {
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
setFeedIds(ids);
|
|
76
|
+
} catch (error) {
|
|
77
|
+
if (cancelled) {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
setErrorMessage(`Failed to load feed IDs: ${getErrorMessage(error)}`);
|
|
81
|
+
} finally {
|
|
82
|
+
if (!cancelled) {
|
|
83
|
+
setFeedLoading(false);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
})();
|
|
87
|
+
|
|
88
|
+
return () => {
|
|
89
|
+
cancelled = true;
|
|
90
|
+
};
|
|
91
|
+
}, [feed, refreshTick]);
|
|
92
|
+
|
|
93
|
+
useEffect(() => {
|
|
94
|
+
if (feedIds.length === 0) {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
let cancelled = false;
|
|
99
|
+
const idsToLoad = feedIds.slice(0, storyLimit);
|
|
100
|
+
|
|
101
|
+
setStoryLoading(true);
|
|
102
|
+
setErrorMessage(null);
|
|
103
|
+
|
|
104
|
+
void (async () => {
|
|
105
|
+
try {
|
|
106
|
+
const items = await mapWithConcurrency(idsToLoad, STORY_FETCH_CONCURRENCY, getItemCached);
|
|
107
|
+
if (cancelled) {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const filtered = items.filter((item): item is HNItem => {
|
|
112
|
+
if (!item) {
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
return item.type === "story" || item.type === "job" || item.type === "poll";
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
setStories(filtered);
|
|
119
|
+
} catch (error) {
|
|
120
|
+
if (cancelled) {
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
setErrorMessage(`Failed to load stories: ${getErrorMessage(error)}`);
|
|
124
|
+
} finally {
|
|
125
|
+
if (!cancelled) {
|
|
126
|
+
setStoryLoading(false);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
})();
|
|
130
|
+
|
|
131
|
+
return () => {
|
|
132
|
+
cancelled = true;
|
|
133
|
+
};
|
|
134
|
+
}, [feedIds, storyLimit]);
|
|
135
|
+
|
|
136
|
+
const feedOrder = useMemo(() => {
|
|
137
|
+
const order = new Map<number, number>();
|
|
138
|
+
feedIds.forEach((id, index) => {
|
|
139
|
+
order.set(id, index);
|
|
140
|
+
});
|
|
141
|
+
return order;
|
|
142
|
+
}, [feedIds]);
|
|
143
|
+
|
|
144
|
+
const normalizedSearch = searchQuery.trim().toLowerCase();
|
|
145
|
+
const filteredStories = useMemo(() => {
|
|
146
|
+
const matches = stories.filter((story) => {
|
|
147
|
+
if ((story.score ?? 0) < minScore) {
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
if (storyClass !== "all" && classifyStory(story) !== storyClass) {
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
if (!normalizedSearch) {
|
|
154
|
+
return true;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const haystack = [story.title ?? "", story.by ?? "", story.url ?? "", htmlToText(story.text)]
|
|
158
|
+
.join(" ")
|
|
159
|
+
.toLowerCase();
|
|
160
|
+
return haystack.includes(normalizedSearch);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
const sorted = [...matches];
|
|
164
|
+
sorted.sort((a, b) => {
|
|
165
|
+
switch (sortMode) {
|
|
166
|
+
case "newest":
|
|
167
|
+
return (b.time ?? 0) - (a.time ?? 0);
|
|
168
|
+
case "points":
|
|
169
|
+
return (b.score ?? 0) - (a.score ?? 0);
|
|
170
|
+
case "comments":
|
|
171
|
+
return (b.descendants ?? 0) - (a.descendants ?? 0);
|
|
172
|
+
default:
|
|
173
|
+
return (
|
|
174
|
+
(feedOrder.get(a.id) ?? Number.MAX_SAFE_INTEGER) -
|
|
175
|
+
(feedOrder.get(b.id) ?? Number.MAX_SAFE_INTEGER)
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
return sorted;
|
|
180
|
+
}, [stories, minScore, storyClass, normalizedSearch, sortMode, feedOrder]);
|
|
181
|
+
|
|
182
|
+
useEffect(() => {
|
|
183
|
+
if (filteredStories.length === 0 && selectedStoryIndex !== 0) {
|
|
184
|
+
setSelectedStoryIndex(0);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (filteredStories.length > 0 && selectedStoryIndex >= filteredStories.length) {
|
|
189
|
+
setSelectedStoryIndex(filteredStories.length - 1);
|
|
190
|
+
}
|
|
191
|
+
}, [filteredStories.length, selectedStoryIndex]);
|
|
192
|
+
|
|
193
|
+
const selectedStory = filteredStories[selectedStoryIndex] ?? null;
|
|
194
|
+
const selectedStoryId = selectedStory?.id ?? null;
|
|
195
|
+
|
|
196
|
+
const loadMoreComments = useCallback(async (storyId: number, batchSize: number = COMMENT_BATCH_SIZE) => {
|
|
197
|
+
const cursor = commentCursorRef.current;
|
|
198
|
+
if (!cursor || cursor.storyId !== storyId || commentLoadInFlightRef.current) {
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
if (cursor.stack.length === 0) {
|
|
202
|
+
setHasMoreComments(false);
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
commentLoadInFlightRef.current = true;
|
|
207
|
+
setLoadingMoreComments(true);
|
|
208
|
+
|
|
209
|
+
try {
|
|
210
|
+
const loadedBatch = await loadNextCommentBatch(cursor.stack, batchSize, getItemCached);
|
|
211
|
+
|
|
212
|
+
if (commentCursorRef.current?.storyId !== storyId) {
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
if (loadedBatch.length > 0) {
|
|
216
|
+
setDetailComments((previous) => [...previous, ...loadedBatch]);
|
|
217
|
+
}
|
|
218
|
+
setHasMoreComments(cursor.stack.length > 0);
|
|
219
|
+
} catch (error) {
|
|
220
|
+
if (commentCursorRef.current?.storyId !== storyId) {
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
setErrorMessage(`Failed to load comments: ${getErrorMessage(error)}`);
|
|
224
|
+
} finally {
|
|
225
|
+
if (commentCursorRef.current?.storyId === storyId) {
|
|
226
|
+
setLoadingMoreComments(false);
|
|
227
|
+
}
|
|
228
|
+
commentLoadInFlightRef.current = false;
|
|
229
|
+
}
|
|
230
|
+
}, []);
|
|
231
|
+
|
|
232
|
+
useEffect(() => {
|
|
233
|
+
let cancelled = false;
|
|
234
|
+
|
|
235
|
+
if (!selectedStoryId) {
|
|
236
|
+
setDetailLoading(false);
|
|
237
|
+
setDetailItem(null);
|
|
238
|
+
setDetailComments([]);
|
|
239
|
+
setHasMoreComments(false);
|
|
240
|
+
setLoadingMoreComments(false);
|
|
241
|
+
commentCursorRef.current = null;
|
|
242
|
+
commentLoadInFlightRef.current = false;
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
setDetailItem(selectedStory);
|
|
247
|
+
setDetailComments([]);
|
|
248
|
+
setHasMoreComments(false);
|
|
249
|
+
setLoadingMoreComments(false);
|
|
250
|
+
commentCursorRef.current = null;
|
|
251
|
+
commentLoadInFlightRef.current = false;
|
|
252
|
+
setDetailLoading(true);
|
|
253
|
+
setErrorMessage(null);
|
|
254
|
+
|
|
255
|
+
void (async () => {
|
|
256
|
+
try {
|
|
257
|
+
const item = await getItemCached(selectedStoryId);
|
|
258
|
+
if (!item) {
|
|
259
|
+
throw new Error(`Story ${selectedStoryId} not found`);
|
|
260
|
+
}
|
|
261
|
+
if (cancelled) {
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
setDetailItem(item);
|
|
265
|
+
setDetailComments([]);
|
|
266
|
+
const stack = createCommentStack(item.kids);
|
|
267
|
+
|
|
268
|
+
commentCursorRef.current = {
|
|
269
|
+
storyId: selectedStoryId,
|
|
270
|
+
stack,
|
|
271
|
+
};
|
|
272
|
+
setHasMoreComments(stack.length > 0);
|
|
273
|
+
|
|
274
|
+
if (stack.length > 0) {
|
|
275
|
+
await loadMoreComments(selectedStoryId, COMMENT_INITIAL_BATCH);
|
|
276
|
+
}
|
|
277
|
+
} catch (error) {
|
|
278
|
+
if (cancelled) {
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
setDetailItem(selectedStory ?? null);
|
|
282
|
+
setDetailComments([]);
|
|
283
|
+
setHasMoreComments(false);
|
|
284
|
+
setLoadingMoreComments(false);
|
|
285
|
+
commentCursorRef.current = null;
|
|
286
|
+
commentLoadInFlightRef.current = false;
|
|
287
|
+
setErrorMessage(`Failed to load post details: ${getErrorMessage(error)}`);
|
|
288
|
+
} finally {
|
|
289
|
+
if (!cancelled) {
|
|
290
|
+
setDetailLoading(false);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
})();
|
|
294
|
+
|
|
295
|
+
return () => {
|
|
296
|
+
cancelled = true;
|
|
297
|
+
};
|
|
298
|
+
}, [selectedStoryId, selectedStory, loadMoreComments]);
|
|
299
|
+
|
|
300
|
+
const refreshFeed = () => {
|
|
301
|
+
setRefreshTick((current) => current + 1);
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
return {
|
|
305
|
+
feed,
|
|
306
|
+
setFeed,
|
|
307
|
+
storyLimit,
|
|
308
|
+
setStoryLimit,
|
|
309
|
+
feedIds,
|
|
310
|
+
stories,
|
|
311
|
+
selectedStoryIndex,
|
|
312
|
+
setSelectedStoryIndex,
|
|
313
|
+
feedLoading,
|
|
314
|
+
storyLoading,
|
|
315
|
+
detailLoading,
|
|
316
|
+
errorMessage,
|
|
317
|
+
searchQuery,
|
|
318
|
+
setSearchQuery,
|
|
319
|
+
sortMode,
|
|
320
|
+
setSortMode,
|
|
321
|
+
storyClass,
|
|
322
|
+
setStoryClass,
|
|
323
|
+
minScore,
|
|
324
|
+
setMinScore,
|
|
325
|
+
detailItem,
|
|
326
|
+
detailComments,
|
|
327
|
+
hasMoreComments,
|
|
328
|
+
loadingMoreComments,
|
|
329
|
+
feedOrder,
|
|
330
|
+
filteredStories,
|
|
331
|
+
selectedStory,
|
|
332
|
+
selectedStoryId,
|
|
333
|
+
loadMoreComments,
|
|
334
|
+
refreshFeed,
|
|
335
|
+
isCommentLoadInFlight: commentLoadInFlightRef,
|
|
336
|
+
};
|
|
337
|
+
}
|