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 CHANGED
@@ -1,9 +1,45 @@
1
1
  # hn-bits
2
2
 
3
- Terminal-first Hacker News client. Fullscreen TUI built with TypeScript + Ink (React for the terminal), backed by the HN Firebase and Algolia APIs. No database. Optional local-AI features (summaries + Ask AI) read a config file; everything else is fully stateless.
3
+ A fullscreen terminal client for Hacker News, built with TypeScript + Ink. Optional local AI summaries via Ollama, topic subscriptions with Telegram alerts, no cloud required beyond what you opt into.
4
+
5
+ ![npm version](https://img.shields.io/npm/v/hn-bits)
6
+ [![CI](https://img.shields.io/github/actions/workflow/status/navinrc/hn-bits/release.yml)](https://github.com/navinrc/hn-bits/actions)
7
+ [![Release](https://img.shields.io/github/v/release/navinrc/hn-bits)](https://github.com/navinrc/hn-bits/releases)
8
+ [![npm downloads](https://img.shields.io/npm/dt/hn-bits)](https://www.npmjs.com/package/hn-bits)
9
+ ![License](https://img.shields.io/badge/license-MIT-green)
10
+
11
+ ![Streaming article summary, then a multi-turn Ask AI conversation grounded in the same story](docs/ai-demo.gif)
12
+
13
+ ![Subs tab: manage topic subscriptions, browse per-topic matches](docs/subs-demo.gif)
14
+
15
+ ![Saved tab: bookmark a story with B, browse it from hn bookmarks](docs/saved-demo.gif)
16
+
17
+ ## Features
18
+
19
+ - Fullscreen TUI: top/new/best/ask/show/saved/subs tabs, vim-style navigation
20
+ - Threaded comments view with fold/collapse
21
+ - Search across stories
22
+ - 6 built-in themes (`hn`, `mocha`, `dracula`, `tokyo`, `nord`, `gruvbox`)
23
+ - Local AI article/thread summaries via Ollama, no cloud calls
24
+ - Ask AI: multi-turn Q&A grounded in the current story
25
+ - Subscriptions + `hn watch --once` radar with Telegram notifications
26
+ - Bookmarks: `B` to save a story, `hn bookmarks` to browse them
27
+ - SQLite storage (subscriptions, dedup, bookmarks); config file for AI/Telegram settings
4
28
 
5
29
  ## Install
6
30
 
31
+ ```bash
32
+ npm install -g hn-bits
33
+ ```
34
+
35
+ Or run without installing:
36
+
37
+ ```bash
38
+ npx hn-bits
39
+ ```
40
+
41
+ ### From source
42
+
7
43
  ```bash
8
44
  npm install
9
45
  npm run build
@@ -13,8 +49,10 @@ npm link # exposes the `hn` binary
13
49
  ## Usage
14
50
 
15
51
  ```bash
16
- hn # fullscreen shell: top/new/best/ask/show tabs
52
+ hn # fullscreen shell: top/new/best/ask/show/saved/subs tabs
17
53
  hn search <query...> # search results
54
+ hn bookmarks # opens straight into the saved tab
55
+ hn subs # opens straight into the subs tab
18
56
  hn --theme dracula # themes: hn (default), mocha, dracula, tokyo, nord, gruvbox
19
57
  ```
20
58
 
@@ -22,9 +60,7 @@ Press `?` from any view for the full keybinding help overlay.
22
60
 
23
61
  ### Local AI (summaries + Ask AI)
24
62
 
25
- Article/thread summaries (`s`) and interactive Q&A (`a`) run against a local [Ollama](https://ollama.com) instance no cloud calls, no API keys. Optional: the app works fully without it, `s`/`a` just show a setup hint until configured.
26
-
27
- ![Streaming article summary, then a multi-turn Ask AI conversation grounded in the same story](docs/ai-demo.gif)
63
+ Article/thread summaries (`s`) and interactive Q&A (`a`) run against a local [Ollama](https://ollama.com) instance, no cloud calls, no API keys. Optional: the app works fully without it, `s`/`a` just show a setup hint until configured.
28
64
 
29
65
  **Prerequisites**
30
66
 
@@ -34,7 +70,7 @@ ollama serve # if not already running as a service
34
70
  ollama pull llama3.2 # or any other chat-capable model
35
71
  ```
36
72
 
37
- **Setup** one-time:
73
+ **Setup** (one-time):
38
74
 
39
75
  ```bash
40
76
  hn config set ollama.host http://localhost:11434
@@ -43,9 +79,43 @@ hn config set ollama.model llama3.2
43
79
 
44
80
  Missing fields fall back to the defaults above; invalid JSON degrades to "AI disabled" with a warning rather than crashing the reader.
45
81
 
82
+ ### Subscriptions + Telegram notifications
83
+
84
+ `hn sub` tracks topic queries; `hn watch --once` checks each one, dedups against past matches, and sends new ones to Telegram. No daemon: schedule `hn watch --once` with cron or launchd.
85
+
86
+ ```bash
87
+ hn sub add apple "Apple" --min-points 50 # topic name, Algolia query, min score
88
+ hn sub list
89
+ hn sub rm apple
90
+ ```
91
+
92
+ **Telegram setup** (one-time):
93
+
94
+ 1. Message [`@BotFather`](https://t.me/BotFather) on Telegram, run `/newbot`, copy the token it gives you.
95
+ 2. Message your new bot once (anything, e.g. "hi").
96
+ 3. Fetch `https://api.telegram.org/bot<TOKEN>/getUpdates` and read `result[0].message.chat.id`.
97
+
98
+ ```bash
99
+ hn config set telegram.botToken 123456:ABC-...
100
+ hn config set telegram.chatId 987654321
101
+ hn config set telegram.enabled true
102
+ ```
103
+
104
+ All three keys are required, `telegram.enabled` alone is not enough. With nothing enabled, `hn watch --once` exits with code 2.
105
+
106
+ ```bash
107
+ hn watch --once # one pass: query subscriptions, notify new matches, exit
108
+ ```
109
+
110
+ Schedule it, e.g. every 30 minutes via crontab:
111
+
112
+ ```
113
+ */30 * * * * cd /path/to/hn-bits && hn watch --once
114
+ ```
115
+
46
116
  ### Configuration
47
117
 
48
- `hn config` reads/writes `~/.config/hn-bits/config.json` (override the path with `$HN_BITS_CONFIG`) no need to hand-edit JSON:
118
+ `hn config` reads/writes `~/.config/hn-bits/config.json` (override the path with `$HN_BITS_CONFIG`), no need to hand-edit JSON:
49
119
 
50
120
  ```bash
51
121
  hn config list # every known key + current value
@@ -54,11 +124,11 @@ hn config set ollama.model llama3.2 # validates + writes
54
124
  hn config unset ollama.model # removes a key
55
125
  ```
56
126
 
57
- Sensitive values (e.g. a future Telegram bot token) print masked in `list` but raw from `get`, so scripts can still consume them. File format is plain JSON hand-editing still works if you prefer it.
127
+ Sensitive values (e.g. `telegram.botToken`) print masked in `list` but raw from `get`, so scripts can still consume them. File format is plain JSON; hand-editing still works if you prefer it.
58
128
 
59
129
  ### Themes
60
130
 
61
- `hn`, `mocha`, `dracula`, `tokyo`, `nord`, `gruvbox` (default: `hn`, HN-orange). Pick with `--theme <name>` or the `HN_THEME` env var not persisted (no config file yet).
131
+ `hn`, `mocha`, `dracula`, `tokyo`, `nord`, `gruvbox` (default: `hn`, HN-orange). Pick with `--theme <name>` or the `HN_THEME` env var; not persisted (no config file yet).
62
132
 
63
133
  ### Keybindings
64
134
 
@@ -83,6 +153,7 @@ Sensitive values (e.g. a future Telegram bot token) print masked in `list` but r
83
153
  | `/` | search |
84
154
  | `s` | AI summary (article, or thread if no article) |
85
155
  | `a` | Ask AI (chat grounded in the story) |
156
+ | `B` | bookmark |
86
157
 
87
158
  **Comments**
88
159
 
@@ -96,11 +167,31 @@ Sensitive values (e.g. a future Telegram bot token) print masked in `list` but r
96
167
  | `r` | reload |
97
168
  | `s` | AI thread summary |
98
169
  | `a` | Ask AI (chat grounded in the story) |
170
+ | `B` | bookmark |
99
171
  | `esc`/`b` | back |
100
172
 
101
173
  **Search results**
102
174
 
103
- Same as story list, minus the tab/feed keys, plus `/` to start a new search. `esc` returns to the list if you got here via `/` in-TUI, or quits if you entered via `hn search`.
175
+ Same as story list, minus the tab/feed keys, plus `/` to start a new search and `S` to subscribe (prefills a topic from the query). `esc` returns to the list if you got here via `/` in-TUI, or quits if you entered via `hn search`.
176
+
177
+ **Saved** (`hn bookmarks`, or the Saved tab)
178
+
179
+ Same as story list, minus search; `B` removes a bookmark instead of adding one.
180
+
181
+ **Subs** (`hn subs`, or the Subs tab)
182
+
183
+ | Key | Action |
184
+ |-----|--------|
185
+ | `j`/`k` or ↓/↑ | move selection |
186
+ | `←`/`→` | previous/next tab |
187
+ | `enter` | browse matches for the selected topic |
188
+ | `a` | add subscription |
189
+ | `e` | edit subscription |
190
+ | `d` | delete subscription |
191
+
192
+ **Sub matches** (opened from Subs via `enter`)
193
+
194
+ Same as story list, minus search/tab-switch; `r` refetches, `esc` returns to Subs.
104
195
 
105
196
  ## Development
106
197
 
@@ -114,9 +205,10 @@ npm run build # tsc
114
205
 
115
206
  - TypeScript (strict, ESM) + Ink (React for terminals)
116
207
  - Commander CLI, native `fetch`
117
- - HN Firebase + Algolia APIs no backend, no database
208
+ - HN Firebase + Algolia APIs, no backend
209
+ - `better-sqlite3` for subscriptions/dedup/bookmarks
118
210
  - Vitest
119
211
 
120
212
  ## Specs
121
213
 
122
- Implementation specs live in [`specs/`](specs/README.md); phase-by-phase status is tracked in [`PROGRESS.md`](PROGRESS.md). V1, V1.5, V1.6, and V2 (local AI) are feature-complete; V3 (subscriptions/watcher) is spec'd but not started.
214
+ Implementation specs live in [`specs/`](specs/README.md); phase-by-phase status is tracked in [`PROGRESS.md`](PROGRESS.md). V1, V1.5, V1.6, V2 (local AI), and V3 (subscriptions/watcher/notifications/bookmarks) are feature-complete.
@@ -25,6 +25,17 @@ export async function fetchComments(storyId) {
25
25
  const item = await getJson(`${BASE}/items/${storyId}`);
26
26
  return item.children.map(toNode).filter((c) => c != null);
27
27
  }
28
+ function hitToStory(hit) {
29
+ return {
30
+ id: Number(hit.objectID),
31
+ title: hit.title,
32
+ url: hit.url ?? undefined,
33
+ by: hit.author ?? '?',
34
+ score: hit.points ?? 0,
35
+ descendants: hit.num_comments ?? 0,
36
+ time: hit.created_at_i,
37
+ };
38
+ }
28
39
  export async function searchStories(query, page = 0) {
29
40
  const params = new URLSearchParams({
30
41
  query,
@@ -34,18 +45,27 @@ export async function searchStories(query, page = 0) {
34
45
  });
35
46
  const res = await getJson(`${BASE}/search?${params}`);
36
47
  return {
37
- stories: res.hits
38
- .filter((h) => h.title != null)
39
- .map((h) => ({
40
- id: Number(h.objectID),
41
- title: h.title,
42
- url: h.url ?? undefined,
43
- by: h.author ?? '?',
44
- score: h.points ?? 0,
45
- descendants: h.num_comments ?? 0,
46
- time: h.created_at_i,
47
- })),
48
+ stories: res.hits.filter((h) => h.title != null).map(hitToStory),
48
49
  hasMore: page + 1 < res.nbPages,
49
50
  totalHits: res.nbHits,
50
51
  };
51
52
  }
53
+ /**
54
+ * Recency-ordered search (subscription matching + watcher window rescans),
55
+ * as opposed to searchStories' relevance ordering.
56
+ */
57
+ export async function searchRecent(query, options) {
58
+ const numericFilters = [`created_at_i>${options.createdAfter}`];
59
+ if (options.minPoints)
60
+ numericFilters.push(`points>=${options.minPoints}`);
61
+ const params = new URLSearchParams({
62
+ query,
63
+ tags: 'story',
64
+ numericFilters: numericFilters.join(','),
65
+ hitsPerPage: String(options.hitsPerPage ?? 50),
66
+ typoTolerance: 'false',
67
+ queryType: 'prefixNone',
68
+ });
69
+ const res = await getJson(`${BASE}/search_by_date?${params}`);
70
+ return res.hits.filter((h) => h.title != null).map(hitToStory);
71
+ }
@@ -0,0 +1,37 @@
1
+ import { openDb } from './db.js';
2
+ function toStory(row) {
3
+ return {
4
+ id: row.story_id,
5
+ title: row.title,
6
+ url: row.url ?? undefined,
7
+ by: row.by,
8
+ score: row.score,
9
+ descendants: row.descendants,
10
+ time: row.time,
11
+ };
12
+ }
13
+ let lastBookmarkedAt = 0;
14
+ /** Monotonic ms timestamp — Date.now() alone can tie on rapid successive bookmarks. */
15
+ function nextBookmarkedAt() {
16
+ lastBookmarkedAt = Math.max(Date.now(), lastBookmarkedAt + 1);
17
+ return lastBookmarkedAt;
18
+ }
19
+ export function isBookmarked(storyId) {
20
+ const row = openDb().prepare('SELECT 1 FROM bookmarks WHERE story_id = ?').get(storyId);
21
+ return row != null;
22
+ }
23
+ /** Toggles the bookmark for a story; returns the new state (true = now bookmarked). */
24
+ export function toggleBookmark(story) {
25
+ const db = openDb();
26
+ if (isBookmarked(story.id)) {
27
+ db.prepare('DELETE FROM bookmarks WHERE story_id = ?').run(story.id);
28
+ return false;
29
+ }
30
+ db.prepare(`INSERT INTO bookmarks (story_id, title, url, by, score, descendants, time, bookmarked_at)
31
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(story.id, story.title, story.url ?? null, story.by, story.score, story.descendants, story.time, nextBookmarkedAt());
32
+ return true;
33
+ }
34
+ export function listBookmarks() {
35
+ const rows = openDb().prepare('SELECT * FROM bookmarks ORDER BY bookmarked_at DESC').all();
36
+ return rows.map(toStory);
37
+ }
package/dist/db/db.js ADDED
@@ -0,0 +1,64 @@
1
+ import { mkdirSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { dirname, join } from 'node:path';
4
+ import DatabaseConstructor from 'better-sqlite3';
5
+ const MIGRATIONS = [
6
+ `
7
+ CREATE TABLE subscriptions (
8
+ id INTEGER PRIMARY KEY,
9
+ name TEXT NOT NULL UNIQUE,
10
+ query TEXT NOT NULL,
11
+ min_points INTEGER NOT NULL DEFAULT 0,
12
+ created_at INTEGER NOT NULL,
13
+ last_run_at INTEGER
14
+ );
15
+
16
+ CREATE TABLE seen_items (
17
+ story_id INTEGER NOT NULL,
18
+ subscription_id INTEGER NOT NULL REFERENCES subscriptions(id) ON DELETE CASCADE,
19
+ notified_at INTEGER NOT NULL,
20
+ PRIMARY KEY (story_id, subscription_id)
21
+ );
22
+
23
+ CREATE TABLE bookmarks (
24
+ story_id INTEGER PRIMARY KEY,
25
+ title TEXT NOT NULL,
26
+ url TEXT,
27
+ by TEXT NOT NULL,
28
+ score INTEGER NOT NULL,
29
+ descendants INTEGER NOT NULL,
30
+ time INTEGER NOT NULL,
31
+ bookmarked_at INTEGER NOT NULL
32
+ );
33
+ `,
34
+ ];
35
+ export function dbPath() {
36
+ return process.env.HN_BITS_DB || join(homedir(), '.local', 'share', 'hn-bits', 'hn-bits.db');
37
+ }
38
+ let cached = null;
39
+ function migrate(db) {
40
+ const currentVersion = db.pragma('user_version', { simple: true });
41
+ for (let i = currentVersion; i < MIGRATIONS.length; i++) {
42
+ db.transaction(() => {
43
+ db.exec(MIGRATIONS[i]);
44
+ db.pragma(`user_version = ${i + 1}`);
45
+ })();
46
+ }
47
+ }
48
+ /** Opens (or returns the cached handle for) the app database, running pending migrations. */
49
+ export function openDb() {
50
+ const path = dbPath();
51
+ if (cached && cached.path === path)
52
+ return cached.db;
53
+ mkdirSync(dirname(path), { recursive: true });
54
+ const db = new DatabaseConstructor(path);
55
+ db.pragma('journal_mode = WAL');
56
+ migrate(db);
57
+ cached = { path, db };
58
+ return db;
59
+ }
60
+ /** Test-only: drops the cached handle so the next openDb() re-resolves HN_BITS_DB. */
61
+ export function resetDbCache() {
62
+ cached?.db.close();
63
+ cached = null;
64
+ }
@@ -0,0 +1,12 @@
1
+ import { openDb } from './db.js';
2
+ export function isSeen(storyId, subscriptionId) {
3
+ const row = openDb()
4
+ .prepare('SELECT 1 FROM seen_items WHERE story_id = ? AND subscription_id = ?')
5
+ .get(storyId, subscriptionId);
6
+ return row != null;
7
+ }
8
+ export function markSeen(storyId, subscriptionId, at) {
9
+ openDb()
10
+ .prepare('INSERT OR IGNORE INTO seen_items (story_id, subscription_id, notified_at) VALUES (?, ?, ?)')
11
+ .run(storyId, subscriptionId, at);
12
+ }
@@ -0,0 +1,44 @@
1
+ import { openDb } from './db.js';
2
+ function fromRow(row) {
3
+ return {
4
+ id: row.id,
5
+ name: row.name,
6
+ query: row.query,
7
+ minPoints: row.min_points,
8
+ createdAt: row.created_at,
9
+ lastRunAt: row.last_run_at,
10
+ };
11
+ }
12
+ function assertNameFree(name, excludeId) {
13
+ const db = openDb();
14
+ const conflict = excludeId === undefined
15
+ ? db.prepare('SELECT 1 FROM subscriptions WHERE name = ?').get(name)
16
+ : db.prepare('SELECT 1 FROM subscriptions WHERE name = ? AND id != ?').get(name, excludeId);
17
+ if (conflict)
18
+ throw new Error(`subscription '${name}' already exists`);
19
+ }
20
+ export function addSubscription(name, query, minPoints) {
21
+ assertNameFree(name);
22
+ const createdAt = Math.floor(Date.now() / 1000);
23
+ const info = openDb()
24
+ .prepare('INSERT INTO subscriptions (name, query, min_points, created_at) VALUES (?, ?, ?, ?)')
25
+ .run(name, query, minPoints, createdAt);
26
+ return { id: Number(info.lastInsertRowid), name, query, minPoints, createdAt, lastRunAt: null };
27
+ }
28
+ export function listSubscriptions() {
29
+ const rows = openDb().prepare('SELECT * FROM subscriptions ORDER BY created_at ASC').all();
30
+ return rows.map(fromRow);
31
+ }
32
+ export function removeSubscription(name) {
33
+ const info = openDb().prepare('DELETE FROM subscriptions WHERE name = ?').run(name);
34
+ return info.changes > 0;
35
+ }
36
+ export function updateSubscription(id, fields) {
37
+ assertNameFree(fields.name, id);
38
+ const db = openDb();
39
+ db.prepare('UPDATE subscriptions SET name = ?, query = ?, min_points = ? WHERE id = ?').run(fields.name, fields.query, fields.minPoints, id);
40
+ return fromRow(db.prepare('SELECT * FROM subscriptions WHERE id = ?').get(id));
41
+ }
42
+ export function touchLastRun(id, at) {
43
+ openDb().prepare('UPDATE subscriptions SET last_run_at = ? WHERE id = ?').run(at, id);
44
+ }
package/dist/index.js CHANGED
@@ -26,6 +26,22 @@ program
26
26
  const { App } = await import('./ui/App.js');
27
27
  render(_jsx(App, { initialQuery: queryParts.join(' ') }), { alternateScreen });
28
28
  });
29
+ program
30
+ .command('bookmarks')
31
+ .description('Open the TUI on the Saved tab')
32
+ .action(async () => {
33
+ applyTheme(program.opts());
34
+ const { App } = await import('./ui/App.js');
35
+ render(_jsx(App, { initialView: "saved" }), { alternateScreen });
36
+ });
37
+ program
38
+ .command('subs')
39
+ .description('Open the TUI on the Subs tab')
40
+ .action(async () => {
41
+ applyTheme(program.opts());
42
+ const { App } = await import('./ui/App.js');
43
+ render(_jsx(App, { initialView: "subs" }), { alternateScreen });
44
+ });
29
45
  program
30
46
  .command('theme')
31
47
  .description('Show the active color theme and available palettes')
@@ -90,4 +106,63 @@ config
90
106
  process.exit(1);
91
107
  }
92
108
  });
109
+ const sub = program.command('sub').description('Manage topic subscriptions');
110
+ sub
111
+ .command('add <name> <query...>')
112
+ .description('Add a subscription')
113
+ .option('--min-points <n>', 'minimum points threshold', '0')
114
+ .action(async (name, queryParts, options) => {
115
+ const minPoints = Number(options.minPoints);
116
+ if (!Number.isInteger(minPoints) || minPoints < 0) {
117
+ console.error(`--min-points must be a non-negative integer, got '${options.minPoints}'`);
118
+ process.exit(1);
119
+ }
120
+ const { addSubscription } = await import('./db/subscriptions.js');
121
+ try {
122
+ addSubscription(name, queryParts.join(' '), minPoints);
123
+ console.log(`added '${name}'`);
124
+ }
125
+ catch (err) {
126
+ console.error(err.message);
127
+ process.exit(1);
128
+ }
129
+ });
130
+ sub
131
+ .command('list')
132
+ .description('List subscriptions')
133
+ .action(async () => {
134
+ const { listSubscriptions } = await import('./db/subscriptions.js');
135
+ const { formatAge } = await import('./lib/format.js');
136
+ const subs = listSubscriptions();
137
+ if (subs.length === 0) {
138
+ console.log('no subscriptions yet');
139
+ return;
140
+ }
141
+ const nameWidth = Math.max(...subs.map((s) => s.name.length));
142
+ for (const s of subs) {
143
+ const points = s.minPoints === 0 ? 'any' : `>=${s.minPoints}`;
144
+ const lastRun = s.lastRunAt == null ? 'never' : `${formatAge(s.lastRunAt)} ago`;
145
+ console.log(`${s.name.padEnd(nameWidth)} "${s.query}" ${points} ${lastRun}`);
146
+ }
147
+ });
148
+ sub
149
+ .command('rm <name>')
150
+ .description('Remove a subscription')
151
+ .action(async (name) => {
152
+ const { removeSubscription } = await import('./db/subscriptions.js');
153
+ if (!removeSubscription(name)) {
154
+ console.error(`subscription '${name}' not found`);
155
+ process.exit(1);
156
+ }
157
+ console.log(`removed '${name}'`);
158
+ });
159
+ program
160
+ .command('watch')
161
+ .description('Check subscriptions for new matches and notify (one-shot; cron owns scheduling)')
162
+ .requiredOption('--once', 'run a single pass and exit (required; guards against future daemon semantics)')
163
+ .option('--dry-run', 'print would-notify matches without sending or writing seen_items')
164
+ .action(async (options) => {
165
+ const { runWatch } = await import('./watch.js');
166
+ process.exit(await runWatch({ dryRun: Boolean(options.dryRun) }));
167
+ });
93
168
  program.parse();
@@ -7,12 +7,12 @@ const FEED_KEYS = { t: 'top', n: 'new', b: 'best' };
7
7
  export function mapFeedKey(key) {
8
8
  return FEED_KEYS[key];
9
9
  }
10
- const FEED_ORDER = ['top', 'new', 'best', 'ask', 'show'];
11
- export function nextFeed(current) {
12
- const index = FEED_ORDER.indexOf(current);
13
- return FEED_ORDER[(index + 1) % FEED_ORDER.length];
10
+ const TAB_ORDER = ['top', 'new', 'best', 'ask', 'show', 'saved', 'subs'];
11
+ export function nextTab(current) {
12
+ const index = TAB_ORDER.indexOf(current);
13
+ return TAB_ORDER[(index + 1) % TAB_ORDER.length];
14
14
  }
15
- export function previousFeed(current) {
16
- const index = FEED_ORDER.indexOf(current);
17
- return FEED_ORDER[(index - 1 + FEED_ORDER.length) % FEED_ORDER.length];
15
+ export function previousTab(current) {
16
+ const index = TAB_ORDER.indexOf(current);
17
+ return TAB_ORDER[(index - 1 + TAB_ORDER.length) % TAB_ORDER.length];
18
18
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,58 @@
1
+ import { hnItemUrl } from '../api/firebase.js';
2
+ const SEND_TIMEOUT_MS = 10_000;
3
+ export class NotifyError extends Error {
4
+ }
5
+ function escapeHtml(text) {
6
+ return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
7
+ }
8
+ function buildMessage({ subscription, story }) {
9
+ const lines = [`🔔 <b>${escapeHtml(subscription.name)}</b>`];
10
+ if (story.url) {
11
+ lines.push(`<a href="${story.url}">${escapeHtml(story.title)}</a>`);
12
+ lines.push(`${story.score} points · ${story.descendants} comments · by ${escapeHtml(story.by)}`);
13
+ lines.push(`<a href="${hnItemUrl(story.id)}">HN discussion</a>`);
14
+ }
15
+ else {
16
+ lines.push(`<a href="${hnItemUrl(story.id)}">${escapeHtml(story.title)}</a>`);
17
+ lines.push(`${story.score} points · ${story.descendants} comments · by ${escapeHtml(story.by)}`);
18
+ }
19
+ return lines.join('\n');
20
+ }
21
+ async function post(config, text) {
22
+ const controller = new AbortController();
23
+ const timeout = setTimeout(() => controller.abort(), SEND_TIMEOUT_MS);
24
+ try {
25
+ return await fetch(`https://api.telegram.org/bot${config.botToken}/sendMessage`, {
26
+ method: 'POST',
27
+ headers: { 'Content-Type': 'application/json' },
28
+ body: JSON.stringify({
29
+ chat_id: config.chatId,
30
+ text,
31
+ parse_mode: 'HTML',
32
+ disable_web_page_preview: false,
33
+ }),
34
+ signal: controller.signal,
35
+ });
36
+ }
37
+ finally {
38
+ clearTimeout(timeout);
39
+ }
40
+ }
41
+ async function sendMessage(config, text, retried = false) {
42
+ const res = await post(config, text);
43
+ const body = (await res.json().catch(() => null));
44
+ if (res.ok && body?.ok)
45
+ return;
46
+ if (res.status === 429 && !retried) {
47
+ const retryAfter = body?.parameters?.retry_after ?? 1;
48
+ await new Promise((resolve) => setTimeout(resolve, retryAfter * 1000));
49
+ return sendMessage(config, text, true);
50
+ }
51
+ throw new NotifyError(`telegram sendMessage failed: ${res.status}`);
52
+ }
53
+ export function createTelegramNotifier(config) {
54
+ return {
55
+ name: 'telegram',
56
+ send: (match) => sendMessage(config, buildMessage(match)),
57
+ };
58
+ }