hn-bits 0.2.0 → 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/README.md +104 -12
- package/dist/api/algolia.js +31 -11
- package/dist/db/bookmarks.js +37 -0
- package/dist/db/db.js +64 -0
- package/dist/db/seen.js +12 -0
- package/dist/db/subscriptions.js +44 -0
- package/dist/index.js +75 -0
- package/dist/lib/listNavigation.js +7 -7
- package/dist/notify/notifier.js +1 -0
- package/dist/notify/telegram.js +58 -0
- package/dist/ui/App.js +77 -10
- package/dist/ui/Comments.js +8 -2
- package/dist/ui/SavedList.js +90 -0
- package/dist/ui/SearchResults.js +11 -3
- package/dist/ui/StoryList.js +12 -6
- package/dist/ui/StoryRow.js +5 -2
- package/dist/ui/SubscriptionForm.js +103 -0
- package/dist/ui/SubscriptionMatches.js +102 -0
- package/dist/ui/SubscriptionsView.js +82 -0
- package/dist/ui/TabBar.js +9 -7
- package/dist/ui/keymap.js +42 -0
- package/dist/ui/theme.js +1 -0
- package/dist/ui/useFlash.js +14 -0
- package/dist/watch.js +96 -0
- package/package.json +7 -1
package/dist/ui/App.js
CHANGED
|
@@ -2,37 +2,79 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
2
2
|
import { useState } from 'react';
|
|
3
3
|
import { useApp, useInput } from 'ink';
|
|
4
4
|
import { loadConfig } from '../lib/config.js';
|
|
5
|
+
import { nextTab, previousTab } from '../lib/listNavigation.js';
|
|
5
6
|
import { AskAI } from './AskAI.js';
|
|
6
7
|
import { Comments } from './Comments.js';
|
|
7
8
|
import { HelpOverlay } from './HelpOverlay.js';
|
|
8
|
-
import { COMMENTS_KEYS, LIST_KEYS, SEARCH_RESULTS_KEYS, footerHint } from './keymap.js';
|
|
9
|
+
import { COMMENTS_KEYS, LIST_KEYS, SAVED_KEYS, SEARCH_RESULTS_KEYS, SUB_MATCHES_KEYS, SUBS_KEYS, footerHint, } from './keymap.js';
|
|
9
10
|
import { Body, Footer, Header, Screen } from './Layout.js';
|
|
11
|
+
import { SavedList } from './SavedList.js';
|
|
10
12
|
import { SearchInput } from './SearchInput.js';
|
|
11
13
|
import { SearchResults } from './SearchResults.js';
|
|
12
14
|
import { StoryList } from './StoryList.js';
|
|
15
|
+
import { SubscriptionForm } from './SubscriptionForm.js';
|
|
16
|
+
import { SubscriptionMatches } from './SubscriptionMatches.js';
|
|
17
|
+
import { SubscriptionsView } from './SubscriptionsView.js';
|
|
13
18
|
import { TabBar } from './TabBar.js';
|
|
14
|
-
export function App({ initialQuery }) {
|
|
19
|
+
export function App({ initialQuery, initialView }) {
|
|
15
20
|
const [feed, setFeed] = useState('top');
|
|
16
|
-
const [view, setView] = useState(initialQuery
|
|
21
|
+
const [view, setView] = useState(initialQuery
|
|
22
|
+
? { name: 'search', query: initialQuery, from: 'cli' }
|
|
23
|
+
: initialView === 'saved'
|
|
24
|
+
? { name: 'saved' }
|
|
25
|
+
: initialView === 'subs'
|
|
26
|
+
? { name: 'subs' }
|
|
27
|
+
: { name: 'list' });
|
|
17
28
|
const [helpOpen, setHelpOpen] = useState(false);
|
|
18
29
|
const [config] = useState(loadConfig);
|
|
19
30
|
const { exit } = useApp();
|
|
20
31
|
useInput((input) => {
|
|
21
32
|
if (helpOpen)
|
|
22
33
|
return setHelpOpen(false);
|
|
23
|
-
if (view.name === 'search-input' || view.name === 'ask')
|
|
34
|
+
if (view.name === 'search-input' || view.name === 'ask' || view.name === 'sub-form')
|
|
24
35
|
return;
|
|
25
36
|
if (input === 'q')
|
|
26
37
|
return exit();
|
|
27
38
|
if (input === '?')
|
|
28
39
|
return setHelpOpen(true);
|
|
29
40
|
});
|
|
30
|
-
|
|
31
|
-
|
|
41
|
+
function changeFeed(target) {
|
|
42
|
+
setFeed(target);
|
|
43
|
+
setView({ name: 'list' });
|
|
44
|
+
}
|
|
45
|
+
function changeTab(direction) {
|
|
46
|
+
const next = direction === 1 ? nextTab(activeTab) : previousTab(activeTab);
|
|
47
|
+
if (next === 'saved')
|
|
48
|
+
return setView({ name: 'saved' });
|
|
49
|
+
if (next === 'subs')
|
|
50
|
+
return setView({ name: 'subs' });
|
|
51
|
+
changeFeed(next);
|
|
52
|
+
}
|
|
53
|
+
const activeTab = tabForView(view) ?? feed;
|
|
54
|
+
const ctx = { feed, config, setFeed: changeFeed, setTab: changeTab, setView, exit };
|
|
55
|
+
return (_jsxs(Screen, { children: [_jsx(Header, { children: _jsx(TabBar, { active: activeTab }) }), _jsx(Body, { children: helpOpen ? _jsx(HelpOverlay, { ...helpFor(view) }) : renderBody(view, ctx) }), _jsx(Footer, { children: renderFooter(view, ctx) })] }));
|
|
56
|
+
}
|
|
57
|
+
/** Traces returnTo chains back to the tab a nested view (comments/ask/sub-form) was opened from. */
|
|
58
|
+
function tabForView(view) {
|
|
59
|
+
if (view.name === 'saved' || view.name === 'subs')
|
|
60
|
+
return view.name;
|
|
61
|
+
if (view.name === 'sub-matches')
|
|
62
|
+
return 'subs';
|
|
63
|
+
if (view.name === 'comments' || view.name === 'ask' || view.name === 'sub-form')
|
|
64
|
+
return tabForView(view.returnTo);
|
|
65
|
+
return null;
|
|
32
66
|
}
|
|
33
67
|
function renderBody(view, ctx) {
|
|
34
68
|
if (view.name === 'list')
|
|
35
69
|
return renderList(ctx);
|
|
70
|
+
if (view.name === 'saved')
|
|
71
|
+
return renderSaved(ctx);
|
|
72
|
+
if (view.name === 'subs')
|
|
73
|
+
return renderSubs(ctx);
|
|
74
|
+
if (view.name === 'sub-matches')
|
|
75
|
+
return renderSubMatches(view, ctx);
|
|
76
|
+
if (view.name === 'sub-form')
|
|
77
|
+
return renderSubForm(view, ctx);
|
|
36
78
|
if (view.name === 'search')
|
|
37
79
|
return renderSearch(view, ctx);
|
|
38
80
|
if (view.name === 'search-input')
|
|
@@ -45,10 +87,16 @@ function renderFooter(view, ctx) {
|
|
|
45
87
|
if (view.name === 'search-input') {
|
|
46
88
|
return (_jsx(SearchInput, { onSubmit: (query) => ctx.setView({ name: 'search', query, from: view.from }), onCancel: () => ctx.setView({ name: 'list' }) }));
|
|
47
89
|
}
|
|
48
|
-
if (view.name === 'ask')
|
|
90
|
+
if (view.name === 'ask' || view.name === 'sub-form')
|
|
49
91
|
return null;
|
|
50
92
|
if (view.name === 'list')
|
|
51
93
|
return footerHint(LIST_KEYS);
|
|
94
|
+
if (view.name === 'saved')
|
|
95
|
+
return footerHint(SAVED_KEYS);
|
|
96
|
+
if (view.name === 'subs')
|
|
97
|
+
return footerHint(SUBS_KEYS);
|
|
98
|
+
if (view.name === 'sub-matches')
|
|
99
|
+
return footerHint(SUB_MATCHES_KEYS);
|
|
52
100
|
if (view.name === 'comments')
|
|
53
101
|
return footerHint(COMMENTS_KEYS);
|
|
54
102
|
return footerHint(SEARCH_RESULTS_KEYS);
|
|
@@ -58,14 +106,33 @@ function helpFor(view) {
|
|
|
58
106
|
return { title: 'comments', keys: COMMENTS_KEYS };
|
|
59
107
|
if (view.name === 'search')
|
|
60
108
|
return { title: 'search results', keys: SEARCH_RESULTS_KEYS };
|
|
109
|
+
if (view.name === 'saved')
|
|
110
|
+
return { title: 'saved', keys: SAVED_KEYS };
|
|
111
|
+
if (view.name === 'subs')
|
|
112
|
+
return { title: 'subs', keys: SUBS_KEYS };
|
|
113
|
+
if (view.name === 'sub-matches')
|
|
114
|
+
return { title: 'sub matches', keys: SUB_MATCHES_KEYS };
|
|
61
115
|
return { title: 'story list', keys: LIST_KEYS };
|
|
62
116
|
}
|
|
63
|
-
function renderList({ feed, config, setFeed, setView }) {
|
|
64
|
-
return (_jsx(StoryList, { feed: feed, config: config, onFeedChange: setFeed, onSelectStory: (story) => setView({ name: 'comments', story, returnTo: { name: 'list' } }), onSearchRequested: () => setView({ name: 'search-input', from: 'tui' }), onAskAI: (story) => setView({ name: 'ask', story, comments: null, returnTo: { name: 'list' } }) }));
|
|
117
|
+
function renderList({ feed, config, setFeed, setTab, setView }) {
|
|
118
|
+
return (_jsx(StoryList, { feed: feed, config: config, onFeedChange: setFeed, onTabChange: setTab, onSelectStory: (story) => setView({ name: 'comments', story, returnTo: { name: 'list' } }), onSearchRequested: () => setView({ name: 'search-input', from: 'tui' }), onAskAI: (story) => setView({ name: 'ask', story, comments: null, returnTo: { name: 'list' } }) }));
|
|
119
|
+
}
|
|
120
|
+
function renderSaved({ config, setFeed, setTab, setView }) {
|
|
121
|
+
return (_jsx(SavedList, { config: config, onFeedChange: setFeed, onTabChange: setTab, onSelectStory: (story) => setView({ name: 'comments', story, returnTo: { name: 'saved' } }), onSearchRequested: () => setView({ name: 'search-input', from: 'tui' }), onAskAI: (story) => setView({ name: 'ask', story, comments: null, returnTo: { name: 'saved' } }) }));
|
|
122
|
+
}
|
|
123
|
+
function renderSubs({ setFeed, setTab, setView }) {
|
|
124
|
+
return (_jsx(SubscriptionsView, { onSelectMatches: (subscription) => setView({ name: 'sub-matches', subscription }), onAdd: () => setView({ name: 'sub-form', mode: 'add', returnTo: { name: 'subs' } }), onEdit: (subscription) => setView({ name: 'sub-form', mode: 'edit', subscription, returnTo: { name: 'subs' } }), onFeedChange: setFeed, onTabChange: setTab }));
|
|
125
|
+
}
|
|
126
|
+
function renderSubMatches(view, { config, setView }) {
|
|
127
|
+
const returnTo = { name: 'sub-matches', subscription: view.subscription };
|
|
128
|
+
return (_jsx(SubscriptionMatches, { subscription: view.subscription, config: config, onSelectStory: (story) => setView({ name: 'comments', story, returnTo }), onBack: () => setView({ name: 'subs' }), onAskAI: (story) => setView({ name: 'ask', story, comments: null, returnTo }) }));
|
|
129
|
+
}
|
|
130
|
+
function renderSubForm(view, { setView }) {
|
|
131
|
+
return (_jsx(SubscriptionForm, { mode: view.mode, subscription: view.subscription, prefillQuery: view.prefillQuery, onSave: () => setView(view.returnTo), onCancel: () => setView(view.returnTo) }));
|
|
65
132
|
}
|
|
66
133
|
function renderSearch(view, { config, setView, exit }) {
|
|
67
134
|
const returnTo = { name: 'search', query: view.query, from: view.from };
|
|
68
|
-
return (_jsx(SearchResults, { query: view.query, config: config, onSelectStory: (story) => setView({ name: 'comments', story, returnTo }), onExit: () => (view.from === 'tui' ? setView({ name: 'list' }) : exit()), onSearchAgain: () => setView({ name: 'search-input', from: view.from }), onAskAI: (story) => setView({ name: 'ask', story, comments: null, returnTo }) }));
|
|
135
|
+
return (_jsx(SearchResults, { query: view.query, config: config, onSelectStory: (story) => setView({ name: 'comments', story, returnTo }), onExit: () => (view.from === 'tui' ? setView({ name: 'list' }) : exit()), onSearchAgain: () => setView({ name: 'search-input', from: view.from }), onAskAI: (story) => setView({ name: 'ask', story, comments: null, returnTo }), onSubscribe: () => setView({ name: 'sub-form', mode: 'add', prefillQuery: view.query, returnTo }) }));
|
|
69
136
|
}
|
|
70
137
|
function renderComments(view, { config, setView }) {
|
|
71
138
|
return (_jsx(Comments, { story: view.story, config: config, onBack: () => setView(view.returnTo), onAskAI: (comments) => setView({ name: 'ask', story: view.story, comments, returnTo: view }) }));
|
package/dist/ui/Comments.js
CHANGED
|
@@ -5,6 +5,7 @@ import open from 'open';
|
|
|
5
5
|
import { buildCommentsSummaryPrompt } from '../ai/summaryPrompts.js';
|
|
6
6
|
import { fetchComments } from '../api/algolia.js';
|
|
7
7
|
import { hnItemUrl } from '../api/firebase.js';
|
|
8
|
+
import { toggleBookmark } from '../db/bookmarks.js';
|
|
8
9
|
import { collapseAll, flattenTree, headerOnlyAll, rehideRevealed, revealHeaderOnly, toggleFold, } from '../lib/commentTree.js';
|
|
9
10
|
import { tokenizeContacts } from '../lib/contactHighlight.js';
|
|
10
11
|
import { formatAge } from '../lib/format.js';
|
|
@@ -16,6 +17,7 @@ import { HEADER_ROWS } from './Layout.js';
|
|
|
16
17
|
import { LoadingIndicator } from './LoadingIndicator.js';
|
|
17
18
|
import { SummaryPanel } from './SummaryPanel.js';
|
|
18
19
|
import { theme } from './theme.js';
|
|
20
|
+
import { useFlash } from './useFlash.js';
|
|
19
21
|
const HEADER_BORDER_LINES = 2;
|
|
20
22
|
// borderStyle (2 cols) + paddingX (2 cols) eaten from the card's interior width.
|
|
21
23
|
const HEADER_FRAME_WIDTH = 4;
|
|
@@ -43,6 +45,7 @@ export function Comments({ story, config, onBack, onAskAI }) {
|
|
|
43
45
|
});
|
|
44
46
|
const [selected, setSelected] = useState(0);
|
|
45
47
|
const [summaryOpen, setSummaryOpen] = useState(false);
|
|
48
|
+
const [flashMessage, flash] = useFlash();
|
|
46
49
|
const pendingTopJump = useRef(false);
|
|
47
50
|
const topLineRef = useRef(0);
|
|
48
51
|
const { folded, headerOnly, revealed } = fold;
|
|
@@ -119,6 +122,8 @@ export function Comments({ story, config, onBack, onAskAI }) {
|
|
|
119
122
|
return setSummaryOpen(true);
|
|
120
123
|
if (input === 'a')
|
|
121
124
|
return onAskAI(comments);
|
|
125
|
+
if (input === 'B')
|
|
126
|
+
return flash(toggleBookmark(story) ? 'bookmarked ✓' : 'bookmark removed');
|
|
122
127
|
}
|
|
123
128
|
useInput(handleInput, { isActive: !summaryOpen });
|
|
124
129
|
if (status === 'loading')
|
|
@@ -126,12 +131,13 @@ export function Comments({ story, config, onBack, onAskAI }) {
|
|
|
126
131
|
if (status === 'error')
|
|
127
132
|
return _jsxs(Text, { color: theme.colors.error, children: [error, " (r to retry)"] });
|
|
128
133
|
const panelHeight = summaryOpen ? Math.max(6, Math.floor(viewportLines / 2)) : 0;
|
|
129
|
-
const
|
|
134
|
+
const flashLineHeight = flashMessage ? 1 : 0;
|
|
135
|
+
const listViewportLines = Math.max(1, viewportLines - panelHeight - flashLineHeight);
|
|
130
136
|
const heights = rowsData.map((row) => row.lines.length);
|
|
131
137
|
const topLine = ensureVisibleLines(heights, clampedSelected, topLineRef.current, listViewportLines);
|
|
132
138
|
topLineRef.current = topLine;
|
|
133
139
|
const { first, last, clipTop, clipBottom } = sliceByLines(heights, topLine, listViewportLines);
|
|
134
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(CommentsHeader, { story: story }), flat.length === 0 && _jsx(Text, { dimColor: true, children: "no comments yet" }), rowsData.slice(first, last + 1).map((row, i) => {
|
|
140
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(CommentsHeader, { story: story }), flashMessage && _jsx(Text, { dimColor: true, children: flashMessage }), flat.length === 0 && _jsx(Text, { dimColor: true, children: "no comments yet" }), rowsData.slice(first, last + 1).map((row, i) => {
|
|
135
141
|
const rowIndex = first + i;
|
|
136
142
|
const start = rowIndex === first ? clipTop : 0;
|
|
137
143
|
const end = rowIndex === last ? row.lines.length - clipBottom : row.lines.length;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useRef, useState } from 'react';
|
|
3
|
+
import { Box, Text, useInput, useWindowSize } from 'ink';
|
|
4
|
+
import open from 'open';
|
|
5
|
+
import { buildListSummaryPrompt } from '../ai/summaryPrompts.js';
|
|
6
|
+
import { hnItemUrl } from '../api/firebase.js';
|
|
7
|
+
import { listBookmarks, toggleBookmark } from '../db/bookmarks.js';
|
|
8
|
+
import { clampSelection, mapFeedKey } from '../lib/listNavigation.js';
|
|
9
|
+
import { ensureVisibleLines } from '../lib/viewport.js';
|
|
10
|
+
import { footerRows, SAVED_KEYS } from './keymap.js';
|
|
11
|
+
import { HEADER_ROWS } from './Layout.js';
|
|
12
|
+
import { STORY_ROW_HEIGHT } from './StoryRow.js';
|
|
13
|
+
import { StoryListView } from './StoryListView.js';
|
|
14
|
+
import { SummaryPanel } from './SummaryPanel.js';
|
|
15
|
+
import { useFlash } from './useFlash.js';
|
|
16
|
+
export function SavedList({ config, onSelectStory, onFeedChange, onTabChange, onSearchRequested, onAskAI, }) {
|
|
17
|
+
const { columns, rows } = useWindowSize();
|
|
18
|
+
const bodyHeight = Math.max(1, rows - HEADER_ROWS - footerRows(SAVED_KEYS, columns));
|
|
19
|
+
const [stories, setStories] = useState(() => listBookmarks());
|
|
20
|
+
const [selected, setSelected] = useState(0);
|
|
21
|
+
const [summaryOpen, setSummaryOpen] = useState(false);
|
|
22
|
+
const [flashMessage, flash] = useFlash();
|
|
23
|
+
const pendingTopJump = useRef(false);
|
|
24
|
+
const topLineRef = useRef(0);
|
|
25
|
+
function reload() {
|
|
26
|
+
const next = listBookmarks();
|
|
27
|
+
setStories(next);
|
|
28
|
+
setSelected((s) => clampSelection(s, 0, next.length));
|
|
29
|
+
}
|
|
30
|
+
function openSelectedStory() {
|
|
31
|
+
const story = stories[selected];
|
|
32
|
+
if (story)
|
|
33
|
+
void open(story.url ?? hnItemUrl(story.id));
|
|
34
|
+
}
|
|
35
|
+
function removeSelectedBookmark() {
|
|
36
|
+
const story = stories[selected];
|
|
37
|
+
if (!story)
|
|
38
|
+
return;
|
|
39
|
+
toggleBookmark(story);
|
|
40
|
+
flash('bookmark removed');
|
|
41
|
+
reload();
|
|
42
|
+
}
|
|
43
|
+
function handleInput(input, key) {
|
|
44
|
+
const isG = input === 'g';
|
|
45
|
+
if (isG && pendingTopJump.current) {
|
|
46
|
+
pendingTopJump.current = false;
|
|
47
|
+
return setSelected(0);
|
|
48
|
+
}
|
|
49
|
+
pendingTopJump.current = isG;
|
|
50
|
+
const targetFeed = mapFeedKey(input);
|
|
51
|
+
if (targetFeed)
|
|
52
|
+
return onFeedChange(targetFeed);
|
|
53
|
+
if (key.leftArrow)
|
|
54
|
+
return onTabChange(-1);
|
|
55
|
+
if (key.rightArrow)
|
|
56
|
+
return onTabChange(1);
|
|
57
|
+
if (input === '/')
|
|
58
|
+
return onSearchRequested();
|
|
59
|
+
if (input === 'j' || key.downArrow)
|
|
60
|
+
return setSelected((s) => clampSelection(s, 1, stories.length));
|
|
61
|
+
if (input === 'k' || key.upArrow)
|
|
62
|
+
return setSelected((s) => clampSelection(s, -1, stories.length));
|
|
63
|
+
if (input === 'G')
|
|
64
|
+
return setSelected(Math.max(0, stories.length - 1));
|
|
65
|
+
if (input === 'o')
|
|
66
|
+
return openSelectedStory();
|
|
67
|
+
if (input === 'r')
|
|
68
|
+
return reload();
|
|
69
|
+
if (input === 'B' && stories[selected])
|
|
70
|
+
return removeSelectedBookmark();
|
|
71
|
+
if (input === 's' && stories[selected])
|
|
72
|
+
return setSummaryOpen(true);
|
|
73
|
+
if (input === 'a' && stories[selected])
|
|
74
|
+
return onAskAI(stories[selected]);
|
|
75
|
+
if (key.return && stories[selected])
|
|
76
|
+
return onSelectStory(stories[selected]);
|
|
77
|
+
}
|
|
78
|
+
useInput(handleInput, { isActive: !summaryOpen });
|
|
79
|
+
const panelHeight = summaryOpen ? Math.max(6, Math.floor(bodyHeight / 2)) : 0;
|
|
80
|
+
const statusLineHeight = flashMessage ? 1 : 0;
|
|
81
|
+
const listHeight = Math.max(1, bodyHeight - statusLineHeight - panelHeight);
|
|
82
|
+
const selectedStory = stories[selected];
|
|
83
|
+
if (stories.length === 0) {
|
|
84
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: "no bookmarks yet \u2014 press B on a story" }), flashMessage && _jsx(Text, { dimColor: true, children: flashMessage })] }));
|
|
85
|
+
}
|
|
86
|
+
const heights = stories.map(() => STORY_ROW_HEIGHT);
|
|
87
|
+
const topLine = ensureVisibleLines(heights, selected, topLineRef.current, listHeight);
|
|
88
|
+
topLineRef.current = topLine;
|
|
89
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(StoryListView, { stories: stories, selected: selected, topLine: topLine, height: listHeight, width: columns }), flashMessage && _jsx(Text, { dimColor: true, children: flashMessage }), summaryOpen && selectedStory && (_jsx(SummaryPanel, { config: config, buildPrompt: (signal) => buildListSummaryPrompt(selectedStory, signal), height: panelHeight, width: columns, onClose: () => setSummaryOpen(false) }))] }));
|
|
90
|
+
}
|
package/dist/ui/SearchResults.js
CHANGED
|
@@ -5,6 +5,7 @@ import open from 'open';
|
|
|
5
5
|
import { buildListSummaryPrompt } from '../ai/summaryPrompts.js';
|
|
6
6
|
import { searchStories } from '../api/algolia.js';
|
|
7
7
|
import { hnItemUrl } from '../api/firebase.js';
|
|
8
|
+
import { toggleBookmark } from '../db/bookmarks.js';
|
|
8
9
|
import { clampSelection } from '../lib/listNavigation.js';
|
|
9
10
|
import { ensureVisibleLines, shouldFetchMore } from '../lib/viewport.js';
|
|
10
11
|
import { footerRows, SEARCH_RESULTS_KEYS } from './keymap.js';
|
|
@@ -14,9 +15,10 @@ import { STORY_ROW_HEIGHT } from './StoryRow.js';
|
|
|
14
15
|
import { StoryListView } from './StoryListView.js';
|
|
15
16
|
import { SummaryPanel } from './SummaryPanel.js';
|
|
16
17
|
import { theme } from './theme.js';
|
|
18
|
+
import { useFlash } from './useFlash.js';
|
|
17
19
|
const FETCH_THRESHOLD = 10;
|
|
18
20
|
const HEADER_LINES = 1;
|
|
19
|
-
export function SearchResults({ query, config, onSelectStory, onExit, onSearchAgain, onAskAI, }) {
|
|
21
|
+
export function SearchResults({ query, config, onSelectStory, onExit, onSearchAgain, onAskAI, onSubscribe, }) {
|
|
20
22
|
const { columns, rows } = useWindowSize();
|
|
21
23
|
const bodyHeight = Math.max(1, rows - HEADER_ROWS - footerRows(SEARCH_RESULTS_KEYS, columns) - HEADER_LINES);
|
|
22
24
|
const [stories, setStories] = useState([]);
|
|
@@ -27,6 +29,7 @@ export function SearchResults({ query, config, onSelectStory, onExit, onSearchAg
|
|
|
27
29
|
const [loadingMore, setLoadingMore] = useState(false);
|
|
28
30
|
const [error, setError] = useState('');
|
|
29
31
|
const [summaryOpen, setSummaryOpen] = useState(false);
|
|
32
|
+
const [flashMessage, flash] = useFlash();
|
|
30
33
|
const token = useRef(0);
|
|
31
34
|
const nextPage = useRef(0);
|
|
32
35
|
const topLineRef = useRef(0);
|
|
@@ -110,6 +113,10 @@ export function SearchResults({ query, config, onSelectStory, onExit, onSearchAg
|
|
|
110
113
|
return setSummaryOpen(true);
|
|
111
114
|
if (input === 'a' && stories[selected])
|
|
112
115
|
return onAskAI(stories[selected]);
|
|
116
|
+
if (input === 'B' && stories[selected])
|
|
117
|
+
return flash(toggleBookmark(stories[selected]) ? 'bookmarked ✓' : 'bookmark removed');
|
|
118
|
+
if (input === 'S')
|
|
119
|
+
return onSubscribe();
|
|
113
120
|
if (key.return && stories[selected])
|
|
114
121
|
return onSelectStory(stories[selected]);
|
|
115
122
|
}
|
|
@@ -119,10 +126,11 @@ export function SearchResults({ query, config, onSelectStory, onExit, onSearchAg
|
|
|
119
126
|
if (status === 'error')
|
|
120
127
|
return _jsxs(Text, { color: theme.colors.error, children: [error, " (r to retry)"] });
|
|
121
128
|
const panelHeight = summaryOpen ? Math.max(6, Math.floor(bodyHeight / 2)) : 0;
|
|
122
|
-
const
|
|
129
|
+
const statusLineHeight = loadingMore || flashMessage ? 1 : 0;
|
|
130
|
+
const listHeight = Math.max(1, bodyHeight - statusLineHeight - panelHeight);
|
|
123
131
|
const heights = stories.map(() => STORY_ROW_HEIGHT);
|
|
124
132
|
const topLine = ensureVisibleLines(heights, selected, topLineRef.current, listHeight);
|
|
125
133
|
topLineRef.current = topLine;
|
|
126
134
|
const selectedStory = stories[selected];
|
|
127
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: ["search: ", query, totalHits !== null ? ` ${totalHits} results` : ''] }), _jsx(StoryListView, { stories: stories, selected: selected, topLine: topLine, height: listHeight, width: columns }), loadingMore && _jsx(Text, { dimColor: true, children: "loading more\u2026" }), summaryOpen && selectedStory && (_jsx(SummaryPanel, { config: config, buildPrompt: (signal) => buildListSummaryPrompt(selectedStory, signal), height: panelHeight, width: columns, onClose: () => setSummaryOpen(false) }))] }));
|
|
135
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: ["search: ", query, totalHits !== null ? ` ${totalHits} results` : ''] }), _jsx(StoryListView, { stories: stories, selected: selected, topLine: topLine, height: listHeight, width: columns }), flashMessage ? _jsx(Text, { dimColor: true, children: flashMessage }) : loadingMore && _jsx(Text, { dimColor: true, children: "loading more\u2026" }), summaryOpen && selectedStory && (_jsx(SummaryPanel, { config: config, buildPrompt: (signal) => buildListSummaryPrompt(selectedStory, signal), height: panelHeight, width: columns, onClose: () => setSummaryOpen(false) }))] }));
|
|
128
136
|
}
|
package/dist/ui/StoryList.js
CHANGED
|
@@ -4,7 +4,8 @@ import { Box, Text, useInput, useWindowSize } from 'ink';
|
|
|
4
4
|
import open from 'open';
|
|
5
5
|
import { buildListSummaryPrompt } from '../ai/summaryPrompts.js';
|
|
6
6
|
import { fetchStories, fetchStoryIds, hnItemUrl } from '../api/firebase.js';
|
|
7
|
-
import {
|
|
7
|
+
import { toggleBookmark } from '../db/bookmarks.js';
|
|
8
|
+
import { clampSelection, mapFeedKey } from '../lib/listNavigation.js';
|
|
8
9
|
import { ensureVisibleLines, shouldFetchMore } from '../lib/viewport.js';
|
|
9
10
|
import { footerRows, LIST_KEYS } from './keymap.js';
|
|
10
11
|
import { HEADER_ROWS } from './Layout.js';
|
|
@@ -13,9 +14,10 @@ import { STORY_ROW_HEIGHT } from './StoryRow.js';
|
|
|
13
14
|
import { StoryListView } from './StoryListView.js';
|
|
14
15
|
import { SummaryPanel } from './SummaryPanel.js';
|
|
15
16
|
import { theme } from './theme.js';
|
|
17
|
+
import { useFlash } from './useFlash.js';
|
|
16
18
|
const BATCH_SIZE = 30;
|
|
17
19
|
const FETCH_THRESHOLD = 10;
|
|
18
|
-
export function StoryList({ feed, config, onFeedChange, onSelectStory, onSearchRequested, onAskAI, }) {
|
|
20
|
+
export function StoryList({ feed, config, onFeedChange, onTabChange, onSelectStory, onSearchRequested, onAskAI, }) {
|
|
19
21
|
const { columns, rows } = useWindowSize();
|
|
20
22
|
const bodyHeight = Math.max(1, rows - HEADER_ROWS - footerRows(LIST_KEYS, columns));
|
|
21
23
|
const [storyIds, setStoryIds] = useState([]);
|
|
@@ -25,6 +27,7 @@ export function StoryList({ feed, config, onFeedChange, onSelectStory, onSearchR
|
|
|
25
27
|
const [loadingMore, setLoadingMore] = useState(false);
|
|
26
28
|
const [error, setError] = useState('');
|
|
27
29
|
const [summaryOpen, setSummaryOpen] = useState(false);
|
|
30
|
+
const [flashMessage, flash] = useFlash();
|
|
28
31
|
const token = useRef(0);
|
|
29
32
|
const pendingTopJump = useRef(false);
|
|
30
33
|
const topLineRef = useRef(0);
|
|
@@ -97,9 +100,9 @@ export function StoryList({ feed, config, onFeedChange, onSelectStory, onSearchR
|
|
|
97
100
|
if (targetFeed)
|
|
98
101
|
return onFeedChange(targetFeed);
|
|
99
102
|
if (key.leftArrow)
|
|
100
|
-
return
|
|
103
|
+
return onTabChange(-1);
|
|
101
104
|
if (key.rightArrow)
|
|
102
|
-
return
|
|
105
|
+
return onTabChange(1);
|
|
103
106
|
if (input === '/')
|
|
104
107
|
return onSearchRequested();
|
|
105
108
|
if (input === 'j' || key.downArrow)
|
|
@@ -116,6 +119,8 @@ export function StoryList({ feed, config, onFeedChange, onSelectStory, onSearchR
|
|
|
116
119
|
return setSummaryOpen(true);
|
|
117
120
|
if (input === 'a' && stories[selected])
|
|
118
121
|
return onAskAI(stories[selected]);
|
|
122
|
+
if (input === 'B' && stories[selected])
|
|
123
|
+
return flash(toggleBookmark(stories[selected]) ? 'bookmarked ✓' : 'bookmark removed');
|
|
119
124
|
if (key.return && stories[selected])
|
|
120
125
|
return onSelectStory(stories[selected]);
|
|
121
126
|
}
|
|
@@ -125,10 +130,11 @@ export function StoryList({ feed, config, onFeedChange, onSelectStory, onSearchR
|
|
|
125
130
|
if (status === 'error')
|
|
126
131
|
return _jsxs(Text, { color: theme.colors.error, children: [error, " (r to retry)"] });
|
|
127
132
|
const panelHeight = summaryOpen ? Math.max(6, Math.floor(bodyHeight / 2)) : 0;
|
|
128
|
-
const
|
|
133
|
+
const statusLineHeight = loadingMore || flashMessage ? 1 : 0;
|
|
134
|
+
const listHeight = Math.max(1, bodyHeight - statusLineHeight - panelHeight);
|
|
129
135
|
const heights = stories.map(() => STORY_ROW_HEIGHT);
|
|
130
136
|
const topLine = ensureVisibleLines(heights, selected, topLineRef.current, listHeight);
|
|
131
137
|
topLineRef.current = topLine;
|
|
132
138
|
const selectedStory = stories[selected];
|
|
133
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(StoryListView, { stories: stories, selected: selected, topLine: topLine, height: listHeight, width: columns }), loadingMore && _jsx(Text, { dimColor: true, children: "loading more\u2026" }), summaryOpen && selectedStory && (_jsx(SummaryPanel, { config: config, buildPrompt: (signal) => buildListSummaryPrompt(selectedStory, signal), height: panelHeight, width: columns, onClose: () => setSummaryOpen(false) }))] }));
|
|
139
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(StoryListView, { stories: stories, selected: selected, topLine: topLine, height: listHeight, width: columns }), flashMessage ? _jsx(Text, { dimColor: true, children: flashMessage }) : loadingMore && _jsx(Text, { dimColor: true, children: "loading more\u2026" }), summaryOpen && selectedStory && (_jsx(SummaryPanel, { config: config, buildPrompt: (signal) => buildListSummaryPrompt(selectedStory, signal), height: panelHeight, width: columns, onClose: () => setSummaryOpen(false) }))] }));
|
|
134
140
|
}
|
package/dist/ui/StoryRow.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { Box, Text } from 'ink';
|
|
3
|
+
import { isBookmarked } from '../db/bookmarks.js';
|
|
3
4
|
import { formatAge, truncateTitle } from '../lib/format.js';
|
|
4
5
|
import { getHostname } from '../lib/url.js';
|
|
5
6
|
import { theme } from './theme.js';
|
|
@@ -22,6 +23,8 @@ export function StoryRow({ story, rank, rankWidth, isSelected, width, showMeta =
|
|
|
22
23
|
const titleWidth = Math.max(1, safeWidth - prefix.length - hostnameSuffix.length);
|
|
23
24
|
const title = truncateTitle(story.title, titleWidth);
|
|
24
25
|
const background = isSelected ? theme.colors.selectionBackground : undefined;
|
|
25
|
-
const
|
|
26
|
-
|
|
26
|
+
const bookmarked = isBookmarked(story.id);
|
|
27
|
+
const starPrefix = bookmarked ? `${theme.glyphs.bookmark} ` : '';
|
|
28
|
+
const meta = `${starPrefix}${metaText(story)}`;
|
|
29
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { backgroundColor: background, children: [prefix, _jsx(Text, { bold: true, color: theme.colors.title, children: title }), hostname && _jsx(Text, { color: theme.colors.muted, children: hostnameSuffix }), fillPad(isSelected, safeWidth, prefix.length + title.length + hostnameSuffix.length)] }), showMeta && (_jsxs(Text, { backgroundColor: background, children: [' '.repeat(prefix.length), bookmarked && _jsx(Text, { color: theme.colors.accent, children: starPrefix }), _jsxs(Text, { color: theme.colors.score, children: [story.score, " points"] }), _jsxs(Text, { color: theme.colors.muted, children: [' ', "by ", story.by, " ", formatAge(story.time), " ago | ", story.descendants, " comments"] }), fillPad(isSelected, safeWidth, prefix.length + meta.length)] })), showSeparator && _jsx(Text, { children: " " })] }));
|
|
27
30
|
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useRef, useState } from 'react';
|
|
3
|
+
import { Box, Text, useInput, useWindowSize } from 'ink';
|
|
4
|
+
import { searchRecent } from '../api/algolia.js';
|
|
5
|
+
import { addSubscription, updateSubscription } from '../db/subscriptions.js';
|
|
6
|
+
import { truncateTitle } from '../lib/format.js';
|
|
7
|
+
import { theme } from './theme.js';
|
|
8
|
+
const PREVIEW_DEBOUNCE_MS = 300;
|
|
9
|
+
const PREVIEW_WINDOW_DAYS = 7;
|
|
10
|
+
const PREVIEW_LIMIT = 5;
|
|
11
|
+
const FIELD_ORDER = ['name', 'query', 'minPoints'];
|
|
12
|
+
function windowStart() {
|
|
13
|
+
return Math.floor(Date.now() / 1000) - PREVIEW_WINDOW_DAYS * 24 * 60 * 60;
|
|
14
|
+
}
|
|
15
|
+
export function SubscriptionForm({ mode, subscription, prefillQuery, onSave, onCancel, }) {
|
|
16
|
+
const { columns } = useWindowSize();
|
|
17
|
+
const [name, setName] = useState(subscription?.name ?? '');
|
|
18
|
+
const [query, setQuery] = useState(subscription?.query ?? prefillQuery ?? '');
|
|
19
|
+
const [minPoints, setMinPoints] = useState(String(subscription?.minPoints ?? 0));
|
|
20
|
+
const [field, setField] = useState('name');
|
|
21
|
+
const [error, setError] = useState('');
|
|
22
|
+
const [preview, setPreview] = useState([]);
|
|
23
|
+
const debounceRef = useRef(null);
|
|
24
|
+
const previewToken = useRef(0);
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
if (debounceRef.current)
|
|
27
|
+
clearTimeout(debounceRef.current);
|
|
28
|
+
if (!query.trim()) {
|
|
29
|
+
setPreview([]);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
debounceRef.current = setTimeout(() => void loadPreview(), PREVIEW_DEBOUNCE_MS);
|
|
33
|
+
return () => {
|
|
34
|
+
if (debounceRef.current)
|
|
35
|
+
clearTimeout(debounceRef.current);
|
|
36
|
+
};
|
|
37
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
38
|
+
}, [query, minPoints]);
|
|
39
|
+
async function loadPreview() {
|
|
40
|
+
const myToken = ++previewToken.current;
|
|
41
|
+
const points = Number(minPoints) || 0;
|
|
42
|
+
try {
|
|
43
|
+
const results = await searchRecent(query, { createdAfter: windowStart(), minPoints: points, hitsPerPage: PREVIEW_LIMIT });
|
|
44
|
+
if (myToken === previewToken.current)
|
|
45
|
+
setPreview(results);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
if (myToken === previewToken.current)
|
|
49
|
+
setPreview([]);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
function save() {
|
|
53
|
+
const trimmedName = name.trim();
|
|
54
|
+
const trimmedQuery = query.trim();
|
|
55
|
+
if (!trimmedName)
|
|
56
|
+
return setError('name is required');
|
|
57
|
+
if (!trimmedQuery)
|
|
58
|
+
return setError('query is required');
|
|
59
|
+
const points = Number(minPoints) || 0;
|
|
60
|
+
try {
|
|
61
|
+
if (mode === 'add')
|
|
62
|
+
addSubscription(trimmedName, trimmedQuery, points);
|
|
63
|
+
else
|
|
64
|
+
updateSubscription(subscription.id, { name: trimmedName, query: trimmedQuery, minPoints: points });
|
|
65
|
+
onSave();
|
|
66
|
+
}
|
|
67
|
+
catch (err) {
|
|
68
|
+
setError(err.message);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
function handleInput(input, key) {
|
|
72
|
+
if (key.escape)
|
|
73
|
+
return onCancel();
|
|
74
|
+
if (key.tab) {
|
|
75
|
+
const index = FIELD_ORDER.indexOf(field);
|
|
76
|
+
return setField(FIELD_ORDER[(index + 1) % FIELD_ORDER.length]);
|
|
77
|
+
}
|
|
78
|
+
if (key.return)
|
|
79
|
+
return save();
|
|
80
|
+
if (key.backspace || key.delete) {
|
|
81
|
+
if (field === 'name')
|
|
82
|
+
setName((v) => v.slice(0, -1));
|
|
83
|
+
if (field === 'query')
|
|
84
|
+
setQuery((v) => v.slice(0, -1));
|
|
85
|
+
if (field === 'minPoints')
|
|
86
|
+
setMinPoints((v) => v.slice(0, -1));
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
if (input && !key.ctrl && !key.meta) {
|
|
90
|
+
if (field === 'name')
|
|
91
|
+
setName((v) => v + input);
|
|
92
|
+
if (field === 'query')
|
|
93
|
+
setQuery((v) => v + input);
|
|
94
|
+
if (field === 'minPoints' && /^\d+$/.test(input)) {
|
|
95
|
+
setMinPoints((v) => (v === '0' ? input : v + input));
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
useInput(handleInput);
|
|
100
|
+
const cursor = (f) => (field === f ? '▏' : '');
|
|
101
|
+
const previewLabel = Number(minPoints) > 0 ? `≥${minPoints} pts` : 'any points';
|
|
102
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: theme.colors.title, children: mode === 'add' ? 'New subscription' : 'Edit subscription' }), _jsx(Text, { children: " " }), _jsxs(Text, { children: ["name: ", name, cursor('name')] }), _jsxs(Text, { children: ["query: ", query, cursor('query')] }), _jsxs(Text, { children: ["min points: ", minPoints, cursor('minPoints')] }), _jsx(Text, { children: " " }), query.trim() && (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { dimColor: true, children: ["preview (last 7 days, ", previewLabel, "):"] }), preview.length === 0 && _jsx(Text, { dimColor: true, children: " no matches" }), preview.map((story, i) => (_jsxs(Text, { children: [' ', i + 1, ". ", truncateTitle(story.title, Math.max(1, columns - 24)), " ", story.score, " pts"] }, story.id)))] })), error && _jsx(Text, { color: theme.colors.error, children: error }), _jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: "tab next field \u00B7 enter save \u00B7 esc cancel" })] }));
|
|
103
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useRef, useState } from 'react';
|
|
3
|
+
import { Box, Text, useInput, useWindowSize } from 'ink';
|
|
4
|
+
import open from 'open';
|
|
5
|
+
import { buildListSummaryPrompt } from '../ai/summaryPrompts.js';
|
|
6
|
+
import { searchRecent } from '../api/algolia.js';
|
|
7
|
+
import { hnItemUrl } from '../api/firebase.js';
|
|
8
|
+
import { toggleBookmark } from '../db/bookmarks.js';
|
|
9
|
+
import { clampSelection } from '../lib/listNavigation.js';
|
|
10
|
+
import { ensureVisibleLines } from '../lib/viewport.js';
|
|
11
|
+
import { footerRows, SUB_MATCHES_KEYS } from './keymap.js';
|
|
12
|
+
import { HEADER_ROWS } from './Layout.js';
|
|
13
|
+
import { LoadingIndicator } from './LoadingIndicator.js';
|
|
14
|
+
import { STORY_ROW_HEIGHT } from './StoryRow.js';
|
|
15
|
+
import { StoryListView } from './StoryListView.js';
|
|
16
|
+
import { SummaryPanel } from './SummaryPanel.js';
|
|
17
|
+
import { theme } from './theme.js';
|
|
18
|
+
import { useFlash } from './useFlash.js';
|
|
19
|
+
const WINDOW_DAYS = 7;
|
|
20
|
+
const HEADER_LINES = 1;
|
|
21
|
+
function windowStart() {
|
|
22
|
+
return Math.floor(Date.now() / 1000) - WINDOW_DAYS * 24 * 60 * 60;
|
|
23
|
+
}
|
|
24
|
+
export function SubscriptionMatches({ subscription, config, onSelectStory, onBack, onAskAI, }) {
|
|
25
|
+
const { columns, rows } = useWindowSize();
|
|
26
|
+
const bodyHeight = Math.max(1, rows - HEADER_ROWS - footerRows(SUB_MATCHES_KEYS, columns) - HEADER_LINES);
|
|
27
|
+
const [stories, setStories] = useState([]);
|
|
28
|
+
const [selected, setSelected] = useState(0);
|
|
29
|
+
const [status, setStatus] = useState('loading');
|
|
30
|
+
const [error, setError] = useState('');
|
|
31
|
+
const [summaryOpen, setSummaryOpen] = useState(false);
|
|
32
|
+
const [flashMessage, flash] = useFlash();
|
|
33
|
+
const pendingTopJump = useRef(false);
|
|
34
|
+
const topLineRef = useRef(0);
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
void load();
|
|
37
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
38
|
+
}, [subscription.id]);
|
|
39
|
+
async function load() {
|
|
40
|
+
setStatus('loading');
|
|
41
|
+
setSelected(0);
|
|
42
|
+
try {
|
|
43
|
+
const results = await searchRecent(subscription.query, {
|
|
44
|
+
createdAfter: windowStart(),
|
|
45
|
+
minPoints: subscription.minPoints,
|
|
46
|
+
});
|
|
47
|
+
setStories(results);
|
|
48
|
+
setStatus('ready');
|
|
49
|
+
}
|
|
50
|
+
catch (err) {
|
|
51
|
+
setStatus('error');
|
|
52
|
+
setError(err.message);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
function openSelectedStory() {
|
|
56
|
+
const story = stories[selected];
|
|
57
|
+
if (story)
|
|
58
|
+
void open(story.url ?? hnItemUrl(story.id));
|
|
59
|
+
}
|
|
60
|
+
function handleInput(input, key) {
|
|
61
|
+
const isG = input === 'g';
|
|
62
|
+
if (isG && pendingTopJump.current) {
|
|
63
|
+
pendingTopJump.current = false;
|
|
64
|
+
return setSelected(0);
|
|
65
|
+
}
|
|
66
|
+
pendingTopJump.current = isG;
|
|
67
|
+
if (key.escape)
|
|
68
|
+
return onBack();
|
|
69
|
+
if (input === 'j' || key.downArrow)
|
|
70
|
+
return setSelected((s) => clampSelection(s, 1, stories.length));
|
|
71
|
+
if (input === 'k' || key.upArrow)
|
|
72
|
+
return setSelected((s) => clampSelection(s, -1, stories.length));
|
|
73
|
+
if (input === 'G')
|
|
74
|
+
return setSelected(Math.max(0, stories.length - 1));
|
|
75
|
+
if (input === 'o')
|
|
76
|
+
return openSelectedStory();
|
|
77
|
+
if (input === 'r')
|
|
78
|
+
return void load();
|
|
79
|
+
if (input === 's' && stories[selected])
|
|
80
|
+
return setSummaryOpen(true);
|
|
81
|
+
if (input === 'a' && stories[selected])
|
|
82
|
+
return onAskAI(stories[selected]);
|
|
83
|
+
if (input === 'B' && stories[selected]) {
|
|
84
|
+
return flash(toggleBookmark(stories[selected]) ? 'bookmarked ✓' : 'bookmark removed');
|
|
85
|
+
}
|
|
86
|
+
if (key.return && stories[selected])
|
|
87
|
+
return onSelectStory(stories[selected]);
|
|
88
|
+
}
|
|
89
|
+
useInput(handleInput, { isActive: !summaryOpen });
|
|
90
|
+
if (status === 'loading')
|
|
91
|
+
return _jsx(LoadingIndicator, { label: "Loading matches..." });
|
|
92
|
+
if (status === 'error')
|
|
93
|
+
return _jsxs(Text, { color: theme.colors.error, children: [error, " (r to retry)"] });
|
|
94
|
+
const panelHeight = summaryOpen ? Math.max(6, Math.floor(bodyHeight / 2)) : 0;
|
|
95
|
+
const statusLineHeight = flashMessage ? 1 : 0;
|
|
96
|
+
const listHeight = Math.max(1, bodyHeight - statusLineHeight - panelHeight);
|
|
97
|
+
const heights = stories.map(() => STORY_ROW_HEIGHT);
|
|
98
|
+
const topLine = ensureVisibleLines(heights, selected, topLineRef.current, listHeight);
|
|
99
|
+
topLineRef.current = topLine;
|
|
100
|
+
const selectedStory = stories[selected];
|
|
101
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: ["sub: ", subscription.name, " \"", subscription.query, "\""] }), stories.length === 0 ? (_jsx(Text, { dimColor: true, children: "no matches in the last 7 days" })) : (_jsx(StoryListView, { stories: stories, selected: selected, topLine: topLine, height: listHeight, width: columns })), flashMessage && _jsx(Text, { dimColor: true, children: flashMessage }), summaryOpen && selectedStory && (_jsx(SummaryPanel, { config: config, buildPrompt: (signal) => buildListSummaryPrompt(selectedStory, signal), height: panelHeight, width: columns, onClose: () => setSummaryOpen(false) }))] }));
|
|
102
|
+
}
|