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/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 ? { name: 'search', query: initialQuery, from: 'cli' } : { name: 'list' });
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
- const ctx = { feed, config, setFeed, setView, exit };
31
- return (_jsxs(Screen, { children: [_jsx(Header, { children: _jsx(TabBar, { active: feed }) }), _jsx(Body, { children: helpOpen ? _jsx(HelpOverlay, { ...helpFor(view) }) : renderBody(view, ctx) }), _jsx(Footer, { children: renderFooter(view, ctx) })] }));
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 }) }));
@@ -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 listViewportLines = Math.max(1, viewportLines - panelHeight);
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
+ }
@@ -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 listHeight = Math.max(1, (loadingMore ? bodyHeight - 1 : bodyHeight) - panelHeight);
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
  }
@@ -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 { clampSelection, mapFeedKey, nextFeed, previousFeed } from '../lib/listNavigation.js';
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 onFeedChange(previousFeed(feed));
103
+ return onTabChange(-1);
101
104
  if (key.rightArrow)
102
- return onFeedChange(nextFeed(feed));
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 listHeight = Math.max(1, (loadingMore ? bodyHeight - 1 : bodyHeight) - panelHeight);
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
  }
@@ -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 meta = metaText(story);
26
- 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), _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: " " })] }));
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
+ }