@ahmd-sh/hntui 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +171 -0
- package/package.json +59 -0
- package/src/App.tsx +629 -0
- package/src/api/hn.test.ts +84 -0
- package/src/api/hn.ts +200 -0
- package/src/api/hnDemo.ts +70 -0
- package/src/api/types.ts +40 -0
- package/src/components/CommentNode.tsx +81 -0
- package/src/components/ContextMenu.tsx +78 -0
- package/src/components/Header.tsx +89 -0
- package/src/components/HelpOverlay.tsx +133 -0
- package/src/components/LinksPopup.tsx +101 -0
- package/src/components/Loader.tsx +30 -0
- package/src/components/StatusBar.tsx +40 -0
- package/src/components/StoryRow.tsx +76 -0
- package/src/hooks/useCommentTree.ts +95 -0
- package/src/hooks/useHistory.ts +40 -0
- package/src/hooks/useItems.ts +34 -0
- package/src/hooks/useSaved.ts +37 -0
- package/src/hooks/useStoryIds.ts +38 -0
- package/src/index.tsx +8 -0
- package/src/runtime.ts +12 -0
- package/src/spinner.ts +243 -0
- package/src/theme.ts +131 -0
- package/src/utils/configDir.ts +22 -0
- package/src/utils/errors.test.ts +21 -0
- package/src/utils/errors.ts +13 -0
- package/src/utils/format.test.ts +56 -0
- package/src/utils/format.ts +97 -0
- package/src/utils/historyStore.ts +38 -0
- package/src/utils/openUrl.ts +10 -0
- package/src/utils/savedStore.ts +34 -0
- package/src/views/MessageView.tsx +34 -0
- package/src/views/StoryDetailView.tsx +116 -0
- package/src/views/StoryListView.tsx +83 -0
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test"
|
|
2
|
+
import { Effect, Layer } from "effect"
|
|
3
|
+
import { HnApiLive, HnTransport, fetchItem, resolveStory } from "./hn"
|
|
4
|
+
|
|
5
|
+
// A transport made of canned JSON. The REAL HnApiLive layer — schema
|
|
6
|
+
// decoding, the item cache, resolveStory's parent walk — runs on top of it,
|
|
7
|
+
// hermetically. Before Layers this required monkeypatching globalThis.fetch.
|
|
8
|
+
const apiWith = (db: Record<string, unknown>, log?: string[]) =>
|
|
9
|
+
HnApiLive.pipe(
|
|
10
|
+
Layer.provide(
|
|
11
|
+
Layer.succeed(HnTransport, {
|
|
12
|
+
getJson: (path) =>
|
|
13
|
+
Effect.sync(() => {
|
|
14
|
+
log?.push(path)
|
|
15
|
+
return db[path] ?? null
|
|
16
|
+
}),
|
|
17
|
+
}),
|
|
18
|
+
),
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
const DB = {
|
|
22
|
+
"item/1.json": { id: 1, type: "story", title: "Root story", kids: [2, 4] },
|
|
23
|
+
"item/2.json": { id: 2, type: "comment", parent: 1, text: "child" },
|
|
24
|
+
"item/3.json": { id: 3, type: "comment", parent: 2, text: "grandchild" },
|
|
25
|
+
"item/4.json": { id: 4, type: "comment", parent: 1, deleted: true },
|
|
26
|
+
"item/5.json": { id: 5, type: "story", title: "Deleted story", deleted: true },
|
|
27
|
+
"item/9.json": { id: "not-a-number", type: "story" },
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe("resolveStory (hermetic, via test transport layer)", () => {
|
|
31
|
+
test("a grandchild comment walks up to the root story and focuses itself", async () => {
|
|
32
|
+
const r = await Effect.runPromise(Effect.provide(resolveStory({ id: 3 }), apiWith(DB)))
|
|
33
|
+
expect(r.story.id).toBe(1)
|
|
34
|
+
expect(r.focusId).toBe(3)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test("an explicit #anchor wins over the walked-from comment", async () => {
|
|
38
|
+
const r = await Effect.runPromise(
|
|
39
|
+
Effect.provide(resolveStory({ id: 1, anchorId: 2 }), apiWith(DB)),
|
|
40
|
+
)
|
|
41
|
+
expect(r.story.id).toBe(1)
|
|
42
|
+
expect(r.focusId).toBe(2)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
test("an unknown id fails with HnItemGone", async () => {
|
|
46
|
+
const e = await Effect.runPromise(
|
|
47
|
+
Effect.provide(Effect.flip(resolveStory({ id: 404 })), apiWith(DB)),
|
|
48
|
+
)
|
|
49
|
+
expect(e._tag).toBe("HnItemGone")
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
test("a deleted story fails with HnItemGone", async () => {
|
|
53
|
+
const e = await Effect.runPromise(
|
|
54
|
+
Effect.provide(Effect.flip(resolveStory({ id: 5 })), apiWith(DB)),
|
|
55
|
+
)
|
|
56
|
+
expect(e._tag).toBe("HnItemGone")
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
test("a deleted comment still walks through to its living root", async () => {
|
|
60
|
+
const r = await Effect.runPromise(Effect.provide(resolveStory({ id: 4 }), apiWith(DB)))
|
|
61
|
+
expect(r.story.id).toBe(1)
|
|
62
|
+
expect(r.focusId).toBe(4)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test("a payload that violates the schema fails with HnDecodeError", async () => {
|
|
66
|
+
const e = await Effect.runPromise(
|
|
67
|
+
Effect.provide(Effect.flip(fetchItem(9)), apiWith(DB)),
|
|
68
|
+
)
|
|
69
|
+
expect(e._tag).toBe("HnDecodeError")
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
describe("item cache (hermetic)", () => {
|
|
74
|
+
test("repeated fetches of one id hit the transport once", async () => {
|
|
75
|
+
const log: string[] = []
|
|
76
|
+
const program = Effect.gen(function* () {
|
|
77
|
+
yield* fetchItem(1)
|
|
78
|
+
yield* fetchItem(1)
|
|
79
|
+
yield* fetchItem(1)
|
|
80
|
+
})
|
|
81
|
+
await Effect.runPromise(Effect.provide(program, apiWith(DB, log)))
|
|
82
|
+
expect(log.filter((p) => p === "item/1.json")).toHaveLength(1)
|
|
83
|
+
})
|
|
84
|
+
})
|
package/src/api/hn.ts
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { Array as Arr, Cache, Context, Data, Duration, Effect, Layer, Schedule, Schema } from "effect"
|
|
2
|
+
import { ItemSchema } from "./types"
|
|
3
|
+
import type { FeedCategory, Item } from "./types"
|
|
4
|
+
import type { HnItemRef } from "../utils/format"
|
|
5
|
+
|
|
6
|
+
const BASE = "https://hacker-news.firebaseio.com/v0"
|
|
7
|
+
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// Errors — every way a call can fail, as its own named type.
|
|
10
|
+
// These show up in the *type* of each effect below.
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
export class HnRequestError extends Data.TaggedError("HnRequestError")<{
|
|
14
|
+
readonly path: string
|
|
15
|
+
readonly cause: unknown
|
|
16
|
+
}> {}
|
|
17
|
+
|
|
18
|
+
export class HnStatusError extends Data.TaggedError("HnStatusError")<{
|
|
19
|
+
readonly path: string
|
|
20
|
+
readonly status: number
|
|
21
|
+
}> {}
|
|
22
|
+
|
|
23
|
+
export class HnTimeoutError extends Data.TaggedError("HnTimeoutError")<{
|
|
24
|
+
readonly path: string
|
|
25
|
+
}> {}
|
|
26
|
+
|
|
27
|
+
export class HnDecodeError extends Data.TaggedError("HnDecodeError")<{
|
|
28
|
+
readonly path: string
|
|
29
|
+
readonly issue: string
|
|
30
|
+
}> {}
|
|
31
|
+
|
|
32
|
+
export type HnError = HnRequestError | HnStatusError | HnTimeoutError | HnDecodeError
|
|
33
|
+
|
|
34
|
+
// Not an API failure — a link resolved to nothing viewable.
|
|
35
|
+
export class HnItemGone extends Data.TaggedError("HnItemGone")<{
|
|
36
|
+
readonly id: number
|
|
37
|
+
}> {}
|
|
38
|
+
|
|
39
|
+
export interface ResolvedLink {
|
|
40
|
+
story: Item
|
|
41
|
+
// the comment the link pointed at, to focus after opening (best effort)
|
|
42
|
+
focusId?: number
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// HnTransport — the service that produces raw JSON. This is the app's only
|
|
47
|
+
// contact with the outside world; everything above it is policy and domain
|
|
48
|
+
// logic. Swapping this layer swaps the data source (live / demo / test).
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
export interface HnTransportShape {
|
|
52
|
+
readonly getJson: (path: string) => Effect.Effect<unknown, HnError>
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export class HnTransport extends Context.Tag("HnTransport")<HnTransport, HnTransportShape>() {}
|
|
56
|
+
|
|
57
|
+
// The live transport: fetch against Firebase. Timeout aborts the underlying
|
|
58
|
+
// request (via the injected AbortSignal); transient failures retry with
|
|
59
|
+
// exponential backoff, client errors don't.
|
|
60
|
+
export const makeLiveGetJson =
|
|
61
|
+
(): HnTransportShape["getJson"] =>
|
|
62
|
+
(path) =>
|
|
63
|
+
Effect.gen(function* () {
|
|
64
|
+
const res = yield* Effect.tryPromise({
|
|
65
|
+
try: (signal) => fetch(`${BASE}/${path}`, { signal }),
|
|
66
|
+
catch: (cause) => new HnRequestError({ path, cause }),
|
|
67
|
+
})
|
|
68
|
+
if (!res.ok) {
|
|
69
|
+
return yield* new HnStatusError({ path, status: res.status })
|
|
70
|
+
}
|
|
71
|
+
return yield* Effect.tryPromise({
|
|
72
|
+
try: () => res.json() as Promise<unknown>,
|
|
73
|
+
catch: (cause) => new HnRequestError({ path, cause }),
|
|
74
|
+
})
|
|
75
|
+
}).pipe(
|
|
76
|
+
Effect.timeoutFail({
|
|
77
|
+
duration: "5 seconds",
|
|
78
|
+
onTimeout: () => new HnTimeoutError({ path }),
|
|
79
|
+
}),
|
|
80
|
+
Effect.retry({
|
|
81
|
+
schedule: Schedule.exponential("250 millis"),
|
|
82
|
+
times: 2,
|
|
83
|
+
while: (e) => e._tag !== "HnStatusError" || e.status >= 500,
|
|
84
|
+
}),
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
export const HnTransportLive = Layer.succeed(HnTransport, { getJson: makeLiveGetJson() })
|
|
88
|
+
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
// HnApi — the domain service: feeds, items, link resolution. Its layer asks
|
|
91
|
+
// for a transport and builds the item cache, so the cache lives exactly as
|
|
92
|
+
// long as the runtime that built the layer — not "forever, from module
|
|
93
|
+
// import" like the old module-global Effect.runSync version.
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
export interface HnApiShape {
|
|
97
|
+
readonly fetchIds: (category: FeedCategory) => Effect.Effect<number[], HnError>
|
|
98
|
+
readonly fetchItem: (id: number) => Effect.Effect<Item | null, HnError>
|
|
99
|
+
readonly fetchItems: (ids: ReadonlyArray<number>, concurrency?: number) => Effect.Effect<Item[]>
|
|
100
|
+
readonly resolveStory: (ref: HnItemRef) => Effect.Effect<ResolvedLink, HnError | HnItemGone>
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export class HnApi extends Context.Tag("HnApi")<HnApi, HnApiShape>() {}
|
|
104
|
+
|
|
105
|
+
// HN returns `null` (with a 200) for deleted/nonexistent ids — the schemas
|
|
106
|
+
// say so explicitly instead of a cast papering over it.
|
|
107
|
+
const IdsPayload = Schema.NullOr(Schema.mutable(Schema.Array(Schema.Number)))
|
|
108
|
+
const ItemPayload = Schema.NullOr(ItemSchema)
|
|
109
|
+
|
|
110
|
+
const isComment = (i: Item) => i.type === "comment" || i.type === "pollopt"
|
|
111
|
+
|
|
112
|
+
export const HnApiLive = Layer.effect(
|
|
113
|
+
HnApi,
|
|
114
|
+
Effect.gen(function* () {
|
|
115
|
+
const transport = yield* HnTransport
|
|
116
|
+
|
|
117
|
+
// GET + validate: the `unknown` from the wire only becomes an A by
|
|
118
|
+
// passing through the schema.
|
|
119
|
+
const getDecoded = <A, I>(
|
|
120
|
+
path: string,
|
|
121
|
+
schema: Schema.Schema<A, I, never>,
|
|
122
|
+
): Effect.Effect<A, HnError> =>
|
|
123
|
+
transport.getJson(path).pipe(
|
|
124
|
+
Effect.flatMap(Schema.decodeUnknown(schema)),
|
|
125
|
+
Effect.catchTag("ParseError", (e) =>
|
|
126
|
+
Effect.fail(new HnDecodeError({ path, issue: e.message })),
|
|
127
|
+
),
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
// Concurrent lookups of the same id share one request, successes are
|
|
131
|
+
// memoized, failures are evicted so the next attempt refetches.
|
|
132
|
+
const cache = yield* Cache.make({
|
|
133
|
+
capacity: 50_000,
|
|
134
|
+
timeToLive: Duration.infinity,
|
|
135
|
+
lookup: (id: number) => getDecoded(`item/${id}.json`, ItemPayload),
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
const fetchIds: HnApiShape["fetchIds"] = (category) =>
|
|
139
|
+
getDecoded(`${category}stories.json`, IdsPayload).pipe(Effect.map((ids) => ids ?? []))
|
|
140
|
+
|
|
141
|
+
const fetchItem: HnApiShape["fetchItem"] = (id) =>
|
|
142
|
+
cache.get(id).pipe(Effect.tapError(() => cache.invalidate(id)))
|
|
143
|
+
|
|
144
|
+
// Fetch many items, at most `concurrency` in flight, failures and null
|
|
145
|
+
// items dropped, input order preserved.
|
|
146
|
+
const fetchItems: HnApiShape["fetchItems"] = (ids, concurrency = 10) =>
|
|
147
|
+
Effect.forEach(ids, (id) => fetchItem(id).pipe(Effect.option), {
|
|
148
|
+
concurrency,
|
|
149
|
+
}).pipe(
|
|
150
|
+
Effect.map((opts) => Arr.getSomes(opts).filter((x): x is Item => x !== null)),
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
// An /item?id=N link may point at a comment: walk `parent` upward until
|
|
154
|
+
// the root story. Every hop inherits retry/timeout/caching from
|
|
155
|
+
// fetchItem, and interruption aborts mid-chain.
|
|
156
|
+
const resolveStory: HnApiShape["resolveStory"] = (ref) =>
|
|
157
|
+
Effect.gen(function* () {
|
|
158
|
+
const gone = () => new HnItemGone({ id: ref.id })
|
|
159
|
+
const first = yield* fetchItem(ref.id)
|
|
160
|
+
if (!first) return yield* gone()
|
|
161
|
+
// deleted comments still carry `parent`, so we can walk through them
|
|
162
|
+
const fromComment = isComment(first) ? first.id : undefined
|
|
163
|
+
let cur: Item = first
|
|
164
|
+
let hops = 0
|
|
165
|
+
while (isComment(cur)) {
|
|
166
|
+
if (cur.parent == null || ++hops > 64) return yield* gone()
|
|
167
|
+
const parent: Item | null = yield* fetchItem(cur.parent)
|
|
168
|
+
if (!parent) return yield* gone()
|
|
169
|
+
cur = parent
|
|
170
|
+
}
|
|
171
|
+
if (cur.deleted || cur.dead) return yield* gone()
|
|
172
|
+
return { story: cur, focusId: ref.anchorId ?? fromComment }
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
return HnApi.of({ fetchIds, fetchItem, fetchItems, resolveStory })
|
|
176
|
+
}),
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
// Accessors — call sites keep the exact shape they had. The only change is
|
|
181
|
+
// in the type: `HnApi` in the R channel names the dependency, and the
|
|
182
|
+
// compiler refuses to run these until something provides it.
|
|
183
|
+
// ---------------------------------------------------------------------------
|
|
184
|
+
|
|
185
|
+
export const fetchIds = (category: FeedCategory): Effect.Effect<number[], HnError, HnApi> =>
|
|
186
|
+
Effect.flatMap(HnApi, (api) => api.fetchIds(category))
|
|
187
|
+
|
|
188
|
+
export const fetchItem = (id: number): Effect.Effect<Item | null, HnError, HnApi> =>
|
|
189
|
+
Effect.flatMap(HnApi, (api) => api.fetchItem(id))
|
|
190
|
+
|
|
191
|
+
export const fetchItems = (
|
|
192
|
+
ids: ReadonlyArray<number>,
|
|
193
|
+
concurrency = 10,
|
|
194
|
+
): Effect.Effect<Item[], never, HnApi> =>
|
|
195
|
+
Effect.flatMap(HnApi, (api) => api.fetchItems(ids, concurrency))
|
|
196
|
+
|
|
197
|
+
export const resolveStory = (
|
|
198
|
+
ref: HnItemRef,
|
|
199
|
+
): Effect.Effect<ResolvedLink, HnError | HnItemGone, HnApi> =>
|
|
200
|
+
Effect.flatMap(HnApi, (api) => api.resolveStory(ref))
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { Effect, Layer } from "effect"
|
|
2
|
+
import { HnTransport, makeLiveGetJson } from "./hn"
|
|
3
|
+
import type { HnTransportShape } from "./hn"
|
|
4
|
+
|
|
5
|
+
// Demo transport: the live data source, plus one synthetic comment spliced
|
|
6
|
+
// into the first story of the first feed the app loads. This is the typed
|
|
7
|
+
// replacement for the old globalThis.fetch monkeypatch preload — select it
|
|
8
|
+
// with HN_DEMO=1 (see src/runtime.ts). Real HN is never touched.
|
|
9
|
+
//
|
|
10
|
+
// HN_DEMO=1 bun src/index.tsx
|
|
11
|
+
//
|
|
12
|
+
// The comment carries four links:
|
|
13
|
+
// 1. a real HN story link → opens in the TUI (item 1, pg's first post)
|
|
14
|
+
// 2. a real HN comment link → walks to its story, cursor lands on the comment
|
|
15
|
+
// 3. a broken HN link → Not Found state
|
|
16
|
+
// 4. an external link → opens in the browser
|
|
17
|
+
|
|
18
|
+
const REAL_STORY_LINK = "https://news.ycombinator.com/item?id=1"
|
|
19
|
+
const REAL_COMMENT_LINK = "https://news.ycombinator.com/item?id=15"
|
|
20
|
+
const BROKEN_LINK = "https://news.ycombinator.com/item?id=999999999999"
|
|
21
|
+
const FAKE_ID = 999_999_001
|
|
22
|
+
|
|
23
|
+
export const HnTransportLinkDemo = Layer.sync(HnTransport, () => {
|
|
24
|
+
const live = makeLiveGetJson()
|
|
25
|
+
let hookedStoryId: number | null = null
|
|
26
|
+
|
|
27
|
+
const getJson: HnTransportShape["getJson"] = (path) => {
|
|
28
|
+
// hook the first story of the first feed the app loads
|
|
29
|
+
if (/^(top|new|best|ask|show|job)stories\.json$/.test(path)) {
|
|
30
|
+
return live(path).pipe(
|
|
31
|
+
Effect.tap((ids) =>
|
|
32
|
+
Effect.sync(() => {
|
|
33
|
+
if (Array.isArray(ids) && ids.length > 0) hookedStoryId ??= ids[0] as number
|
|
34
|
+
}),
|
|
35
|
+
),
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
if (hookedStoryId !== null && path === `item/${hookedStoryId}.json`) {
|
|
39
|
+
return live(path).pipe(
|
|
40
|
+
Effect.map((raw) => {
|
|
41
|
+
const item = raw as { kids?: number[]; descendants?: number } | null
|
|
42
|
+
if (!item) return raw
|
|
43
|
+
return {
|
|
44
|
+
...item,
|
|
45
|
+
kids: [FAKE_ID, ...(item.kids ?? [])],
|
|
46
|
+
descendants: (item.descendants ?? 0) + 1,
|
|
47
|
+
}
|
|
48
|
+
}),
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
if (path === `item/${FAKE_ID}.json`) {
|
|
52
|
+
return Effect.succeed({
|
|
53
|
+
id: FAKE_ID,
|
|
54
|
+
type: "comment",
|
|
55
|
+
by: "link-demo",
|
|
56
|
+
time: Math.floor(Date.now() / 1000),
|
|
57
|
+
parent: hookedStoryId ?? undefined,
|
|
58
|
+
text:
|
|
59
|
+
`[SIMULATED COMMENT] Press ⏎ on me to list my links: ` +
|
|
60
|
+
`<a href="${REAL_STORY_LINK}">the first HN post ever</a>, ` +
|
|
61
|
+
`<a href="${REAL_COMMENT_LINK}">a comment under it</a>, ` +
|
|
62
|
+
`<a href="${BROKEN_LINK}">a broken HN link</a>, ` +
|
|
63
|
+
`and <a href="https://example.com/">an external link</a>.`,
|
|
64
|
+
})
|
|
65
|
+
}
|
|
66
|
+
return live(path)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return { getJson }
|
|
70
|
+
})
|
package/src/api/types.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { Schema } from "effect"
|
|
2
|
+
|
|
3
|
+
export type FeedCategory = "top" | "new" | "best" | "ask" | "show" | "job"
|
|
4
|
+
export type Category = FeedCategory | "saved" | "history"
|
|
5
|
+
|
|
6
|
+
export const FEED_CATEGORIES: { key: FeedCategory; label: string }[] = [
|
|
7
|
+
{ key: "top", label: "Top" },
|
|
8
|
+
{ key: "new", label: "New" },
|
|
9
|
+
{ key: "best", label: "Best" },
|
|
10
|
+
{ key: "ask", label: "Ask" },
|
|
11
|
+
{ key: "show", label: "Show" },
|
|
12
|
+
{ key: "job", label: "Jobs" },
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
export const ALL_CATEGORIES: Category[] = [
|
|
16
|
+
...FEED_CATEGORIES.map((c) => c.key),
|
|
17
|
+
"history",
|
|
18
|
+
"saved",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
// Backwards-compat alias used in older imports
|
|
22
|
+
export const CATEGORIES = FEED_CATEGORIES
|
|
23
|
+
|
|
24
|
+
export const ItemSchema = Schema.Struct({
|
|
25
|
+
id: Schema.Number,
|
|
26
|
+
type: Schema.Literal("story", "comment", "job", "poll", "pollopt"),
|
|
27
|
+
by: Schema.optional(Schema.String),
|
|
28
|
+
time: Schema.optional(Schema.Number),
|
|
29
|
+
text: Schema.optional(Schema.String),
|
|
30
|
+
dead: Schema.optional(Schema.Boolean),
|
|
31
|
+
deleted: Schema.optional(Schema.Boolean),
|
|
32
|
+
parent: Schema.optional(Schema.Number),
|
|
33
|
+
kids: Schema.optional(Schema.mutable(Schema.Array(Schema.Number))),
|
|
34
|
+
url: Schema.optional(Schema.String),
|
|
35
|
+
score: Schema.optional(Schema.Number),
|
|
36
|
+
title: Schema.optional(Schema.String),
|
|
37
|
+
descendants: Schema.optional(Schema.Number),
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
export type Item = typeof ItemSchema.Type
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { useRef } from "react"
|
|
2
|
+
import type { CommentNode as CN } from "../hooks/useCommentTree"
|
|
3
|
+
import { htmlToText, relativeTime } from "../utils/format"
|
|
4
|
+
import { selectionColors, useTheme } from "../theme"
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
node: CN
|
|
8
|
+
depth: number
|
|
9
|
+
collapsed: boolean
|
|
10
|
+
selected: boolean
|
|
11
|
+
hiddenChildren: number
|
|
12
|
+
onSelect: () => void
|
|
13
|
+
onToggle: () => void
|
|
14
|
+
onOpenLinks: () => void
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function CommentNode({
|
|
18
|
+
node,
|
|
19
|
+
depth,
|
|
20
|
+
collapsed,
|
|
21
|
+
selected,
|
|
22
|
+
hiddenChildren,
|
|
23
|
+
onSelect,
|
|
24
|
+
onToggle,
|
|
25
|
+
onOpenLinks,
|
|
26
|
+
}: Props) {
|
|
27
|
+
const t = useTheme()
|
|
28
|
+
const author = node.item.by ?? "?"
|
|
29
|
+
const age = relativeTime(node.item.time)
|
|
30
|
+
const body = htmlToText(node.item.text)
|
|
31
|
+
const accent = t.commentDepth[depth % t.commentDepth.length]
|
|
32
|
+
const indent = depth * 2
|
|
33
|
+
const bg = selected ? t.rowHighlight : undefined
|
|
34
|
+
|
|
35
|
+
const lastClick = useRef(0)
|
|
36
|
+
const checkDouble = () => {
|
|
37
|
+
const now = Date.now()
|
|
38
|
+
const isDouble = now - lastClick.current < 400
|
|
39
|
+
lastClick.current = now
|
|
40
|
+
return isDouble
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const handleHeaderClick = () => {
|
|
44
|
+
onSelect()
|
|
45
|
+
onToggle()
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const handleBodyClick = () => {
|
|
49
|
+
if (checkDouble()) {
|
|
50
|
+
onOpenLinks()
|
|
51
|
+
} else {
|
|
52
|
+
onSelect()
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<box
|
|
58
|
+
id={`comment-${node.item.id}`}
|
|
59
|
+
flexDirection="column"
|
|
60
|
+
marginLeft={indent}
|
|
61
|
+
marginTop={1}
|
|
62
|
+
backgroundColor={bg}
|
|
63
|
+
>
|
|
64
|
+
<text {...selectionColors(t)} onMouseDown={handleHeaderClick}>
|
|
65
|
+
<span fg={accent}>{selected ? "▶ " : "│ "}</span>
|
|
66
|
+
<span fg={t.accent}>{author}</span>
|
|
67
|
+
<span fg={t.textDim}>{` · ${age}`}</span>
|
|
68
|
+
{hiddenChildren > 0 || collapsed ? (
|
|
69
|
+
<span fg={t.textMuted}>
|
|
70
|
+
{` · ${collapsed ? "[+]" : "[-]"} ${hiddenChildren} repl${hiddenChildren === 1 ? "y" : "ies"}`}
|
|
71
|
+
</span>
|
|
72
|
+
) : null}
|
|
73
|
+
</text>
|
|
74
|
+
{!collapsed && body ? (
|
|
75
|
+
<text fg={t.textBody} {...selectionColors(t)} wrapMode="word" onMouseDown={handleBodyClick}>
|
|
76
|
+
{body}
|
|
77
|
+
</text>
|
|
78
|
+
) : null}
|
|
79
|
+
</box>
|
|
80
|
+
)
|
|
81
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { useTerminalDimensions } from "@opentui/react"
|
|
2
|
+
import { selectionColors, useTheme } from "../theme"
|
|
3
|
+
|
|
4
|
+
export interface MenuItem {
|
|
5
|
+
label: string
|
|
6
|
+
action: () => void
|
|
7
|
+
disabled?: boolean
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface Props {
|
|
11
|
+
x: number
|
|
12
|
+
y: number
|
|
13
|
+
items: MenuItem[]
|
|
14
|
+
cursor: number
|
|
15
|
+
onSelect: (idx: number) => void
|
|
16
|
+
onActivate: (idx: number) => void
|
|
17
|
+
onClose: () => void
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function ContextMenu({ x, y, items, cursor, onSelect, onActivate, onClose }: Props) {
|
|
21
|
+
const t = useTheme()
|
|
22
|
+
const { width: termW, height: termH } = useTerminalDimensions()
|
|
23
|
+
const longest = items.reduce((n, it) => Math.max(n, it.label.length), 0)
|
|
24
|
+
const menuW = Math.min(longest + 4, 40)
|
|
25
|
+
const menuH = items.length + 2
|
|
26
|
+
const left = Math.min(Math.max(0, x), termW - menuW)
|
|
27
|
+
const top = Math.min(Math.max(0, y), termH - menuH - 1)
|
|
28
|
+
|
|
29
|
+
const hoverFg = t.name === "dark" ? "#000000" : "#ffffff"
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<box
|
|
33
|
+
position="absolute"
|
|
34
|
+
top={0}
|
|
35
|
+
left={0}
|
|
36
|
+
width="100%"
|
|
37
|
+
height="100%"
|
|
38
|
+
zIndex={200}
|
|
39
|
+
onMouseDown={onClose}
|
|
40
|
+
>
|
|
41
|
+
<box
|
|
42
|
+
position="absolute"
|
|
43
|
+
top={top}
|
|
44
|
+
left={left}
|
|
45
|
+
width={menuW}
|
|
46
|
+
flexDirection="column"
|
|
47
|
+
backgroundColor={t.menuBg}
|
|
48
|
+
border={true}
|
|
49
|
+
borderStyle="single"
|
|
50
|
+
borderColor={t.textMuted}
|
|
51
|
+
onMouseDown={(ev) => ev.stopPropagation()}
|
|
52
|
+
>
|
|
53
|
+
{items.map((item, idx) => {
|
|
54
|
+
const selected = idx === cursor
|
|
55
|
+
const fg = item.disabled ? t.textDim : selected ? hoverFg : t.text
|
|
56
|
+
const bg = selected && !item.disabled ? t.textMuted : undefined
|
|
57
|
+
return (
|
|
58
|
+
<box
|
|
59
|
+
key={idx}
|
|
60
|
+
backgroundColor={bg}
|
|
61
|
+
paddingLeft={1}
|
|
62
|
+
paddingRight={1}
|
|
63
|
+
onMouseDown={() => {
|
|
64
|
+
if (item.disabled) return
|
|
65
|
+
onActivate(idx)
|
|
66
|
+
}}
|
|
67
|
+
onMouseOver={() => {
|
|
68
|
+
if (!item.disabled) onSelect(idx)
|
|
69
|
+
}}
|
|
70
|
+
>
|
|
71
|
+
<text fg={fg} {...selectionColors(t)}>{item.label}</text>
|
|
72
|
+
</box>
|
|
73
|
+
)
|
|
74
|
+
})}
|
|
75
|
+
</box>
|
|
76
|
+
</box>
|
|
77
|
+
)
|
|
78
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { TextAttributes } from "@opentui/core"
|
|
2
|
+
import type { Category } from "../api/types"
|
|
3
|
+
import { FEED_CATEGORIES } from "../api/types"
|
|
4
|
+
import { selectionColors, useTheme } from "../theme"
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
category: Category
|
|
8
|
+
onSelect: (c: Category) => void
|
|
9
|
+
onHome: () => void
|
|
10
|
+
showTabs?: boolean
|
|
11
|
+
// how many views are stacked beneath the current one (internal-link navigation)
|
|
12
|
+
depth?: number
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function Header({ category, onSelect, onHome, showTabs = true, depth = 0 }: Props) {
|
|
16
|
+
const t = useTheme()
|
|
17
|
+
return (
|
|
18
|
+
<box
|
|
19
|
+
flexDirection="column"
|
|
20
|
+
flexShrink={0}
|
|
21
|
+
height={showTabs ? 5 : 3}
|
|
22
|
+
paddingTop={1}
|
|
23
|
+
paddingLeft={1}
|
|
24
|
+
paddingRight={1}
|
|
25
|
+
backgroundColor={t.strip}
|
|
26
|
+
border={["bottom"]}
|
|
27
|
+
borderStyle="single"
|
|
28
|
+
borderColor={t.border}
|
|
29
|
+
>
|
|
30
|
+
<box flexDirection="row" flexShrink={0} height={1} alignItems="center" gap={2}>
|
|
31
|
+
<text
|
|
32
|
+
bg={t.brandTileBg}
|
|
33
|
+
fg={t.brandTileFg}
|
|
34
|
+
{...selectionColors(t)}
|
|
35
|
+
attributes={TextAttributes.BOLD}
|
|
36
|
+
onMouseDown={onHome}
|
|
37
|
+
>
|
|
38
|
+
{" Y "}
|
|
39
|
+
</text>
|
|
40
|
+
<text fg={t.brandText} {...selectionColors(t)} attributes={TextAttributes.BOLD}>
|
|
41
|
+
HackerNews
|
|
42
|
+
</text>
|
|
43
|
+
<text fg={t.brandSubtle} {...selectionColors(t)}>· TUI</text>
|
|
44
|
+
<box flexGrow={1} />
|
|
45
|
+
{depth > 0 ? (
|
|
46
|
+
<text fg={t.brandSubtle} {...selectionColors(t)}>{`↩ ${depth}`}</text>
|
|
47
|
+
) : null}
|
|
48
|
+
</box>
|
|
49
|
+
{showTabs ? (
|
|
50
|
+
<box flexDirection="row" flexShrink={0} height={1} marginTop={1} gap={1}>
|
|
51
|
+
{FEED_CATEGORIES.map((c, i) => {
|
|
52
|
+
const active = c.key === category
|
|
53
|
+
return (
|
|
54
|
+
<box
|
|
55
|
+
key={c.key}
|
|
56
|
+
flexShrink={0}
|
|
57
|
+
onMouseDown={() => onSelect(c.key)}
|
|
58
|
+
backgroundColor={active ? t.tabActiveBg : undefined}
|
|
59
|
+
>
|
|
60
|
+
<text fg={active ? t.tabActiveFg : t.tabInactiveFg} {...selectionColors(t)}>
|
|
61
|
+
{` ${i + 1} ${c.label} `}
|
|
62
|
+
</text>
|
|
63
|
+
</box>
|
|
64
|
+
)
|
|
65
|
+
})}
|
|
66
|
+
<box flexGrow={1} />
|
|
67
|
+
<box
|
|
68
|
+
flexShrink={0}
|
|
69
|
+
onMouseDown={() => onSelect("history")}
|
|
70
|
+
backgroundColor={category === "history" ? t.tabActiveBg : undefined}
|
|
71
|
+
>
|
|
72
|
+
<text fg={category === "history" ? t.tabActiveFg : t.tabInactiveFg} {...selectionColors(t)}>
|
|
73
|
+
{" [H]istory "}
|
|
74
|
+
</text>
|
|
75
|
+
</box>
|
|
76
|
+
<box
|
|
77
|
+
flexShrink={0}
|
|
78
|
+
onMouseDown={() => onSelect("saved")}
|
|
79
|
+
backgroundColor={category === "saved" ? t.tabActiveBg : undefined}
|
|
80
|
+
>
|
|
81
|
+
<text fg={category === "saved" ? t.tabActiveFg : t.tabInactiveFg} {...selectionColors(t)}>
|
|
82
|
+
{" [S]aved "}
|
|
83
|
+
</text>
|
|
84
|
+
</box>
|
|
85
|
+
</box>
|
|
86
|
+
) : null}
|
|
87
|
+
</box>
|
|
88
|
+
)
|
|
89
|
+
}
|