gloomberb 0.8.2 → 0.9.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.
Files changed (85) hide show
  1. package/README.md +9 -7
  2. package/package.json +14 -13
  3. package/patches/{@opentui%2Fcore@0.1.90.patch → @opentui%2Fcore@0.3.2.patch} +175 -90
  4. package/src/app/global-shortcuts.test.tsx +54 -4
  5. package/src/app/global-shortcuts.ts +11 -5
  6. package/src/components/command-bar/action-runtime.ts +1 -0
  7. package/src/components/command-bar/layout-items/current-layout.test.ts +115 -0
  8. package/src/components/command-bar/layout-items/current-layout.ts +33 -0
  9. package/src/components/command-bar/list/view.tsx +3 -1
  10. package/src/components/command-bar/pane-templates/items.ts +4 -1
  11. package/src/components/command-bar/panel/body.tsx +1 -0
  12. package/src/components/command-bar/routes/root/results.ts +0 -30
  13. package/src/components/command-bar/routes/root/runtime.ts +2 -20
  14. package/src/components/command-bar/routes/ticker-search/actions.ts +42 -4
  15. package/src/components/command-bar/routes/ticker-search/route.ts +4 -1
  16. package/src/components/command-bar/routing/launch-request.ts +14 -7
  17. package/src/components/command-bar/routing/list-state.ts +1 -3
  18. package/src/components/command-bar/surface/index.test.tsx +76 -4
  19. package/src/components/command-bar/surface/index.tsx +0 -2
  20. package/src/components/command-bar/surface/pane.test.tsx +1 -0
  21. package/src/components/command-bar/surface/test-harness.tsx +1 -0
  22. package/src/components/command-bar/view-model.ts +3 -3
  23. package/src/components/layout/shell/index.test.tsx +120 -2
  24. package/src/components/layout/shell/index.tsx +2 -0
  25. package/src/components/layout/shell/menu.ts +1 -7
  26. package/src/components/layout/shell/pane/actions.ts +8 -0
  27. package/src/components/layout/shell/pane/management-shortcuts.ts +12 -2
  28. package/src/components/layout/shell/shortcuts.ts +14 -6
  29. package/src/components/layout/shell/window-mode/index.ts +8 -3
  30. package/src/components/layout/window-edit/presentation.ts +4 -4
  31. package/src/components/onboarding/onboarding-steps.tsx +3 -2
  32. package/src/components/ticker/list-table-view.tsx +6 -0
  33. package/src/components/ui/remote-image.tsx +1 -14
  34. package/src/components/ui/tabs.tsx +95 -80
  35. package/src/components/ui/ui.test.tsx +54 -0
  36. package/src/core/state/app/types.ts +12 -4
  37. package/src/plugins/builtin/chat/content/channel-navigation.ts +44 -11
  38. package/src/plugins/builtin/chat/content/index.tsx +5 -4
  39. package/src/plugins/builtin/chat/content/shortcuts.ts +7 -0
  40. package/src/plugins/builtin/chat/sidebar.test.tsx +71 -0
  41. package/src/plugins/builtin/help/components.tsx +1 -1
  42. package/src/plugins/builtin/help/index.test.tsx +105 -30
  43. package/src/plugins/builtin/help/index.tsx +324 -168
  44. package/src/plugins/builtin/help/shortcut-model.ts +5 -6
  45. package/src/plugins/builtin/portfolio-list/column-values.ts +304 -1
  46. package/src/plugins/builtin/portfolio-list/metrics.test.ts +93 -0
  47. package/src/plugins/builtin/portfolio-list/pane/data.ts +7 -0
  48. package/src/plugins/builtin/portfolio-list/pane/index.tsx +25 -2
  49. package/src/plugins/builtin/portfolio-list/pane/supplemental.ts +281 -0
  50. package/src/plugins/builtin/portfolio-list/settings.ts +27 -0
  51. package/src/plugins/builtin/portfolio-list/table.tsx +4 -2
  52. package/src/plugins/builtin/portfolio-list/use-throttled-cursor-symbol.ts +10 -68
  53. package/src/plugins/builtin/substack/api/auth.ts +185 -0
  54. package/src/plugins/builtin/substack/api/cache.ts +78 -0
  55. package/src/plugins/builtin/substack/api/loaders.ts +146 -0
  56. package/src/plugins/builtin/substack/api/store.ts +186 -0
  57. package/src/plugins/builtin/substack/api/types.ts +40 -0
  58. package/src/plugins/builtin/substack/api.test.ts +298 -0
  59. package/src/plugins/builtin/substack/article-detail.tsx +359 -0
  60. package/src/plugins/builtin/substack/article-stack.tsx +129 -0
  61. package/src/plugins/builtin/substack/content.ts +478 -0
  62. package/src/plugins/builtin/substack/feed-tabs.tsx +41 -0
  63. package/src/plugins/builtin/substack/index.tsx +49 -0
  64. package/src/plugins/builtin/substack/login-view.tsx +126 -0
  65. package/src/plugins/builtin/substack/model.test.ts +155 -0
  66. package/src/plugins/builtin/substack/normalize.ts +343 -0
  67. package/src/plugins/builtin/substack/pane-footer.ts +71 -0
  68. package/src/plugins/builtin/substack/pane-state.ts +170 -0
  69. package/src/plugins/builtin/substack/pane.tsx +500 -0
  70. package/src/plugins/builtin/substack/table.ts +111 -0
  71. package/src/plugins/builtin/substack/types.ts +71 -0
  72. package/src/plugins/builtin/substack/utils.ts +131 -0
  73. package/src/plugins/builtin/ticker-detail/index.test.tsx +16 -2
  74. package/src/plugins/builtin/ticker-detail/index.tsx +1 -0
  75. package/src/plugins/builtin/ticker-detail/pane.tsx +13 -1
  76. package/src/plugins/catalog-ui.ts +2 -0
  77. package/src/plugins/pane-manager/layout-state.ts +11 -0
  78. package/src/plugins/pane-manager.ts +1 -1
  79. package/src/react/use-throttled-commit-value.ts +116 -0
  80. package/src/renderers/electrobun/bun/desktop/http-fetch.test.ts +42 -0
  81. package/src/renderers/electrobun/bun/desktop/http-fetch.ts +5 -0
  82. package/src/renderers/electrobun/view/http-fetch.ts +10 -3
  83. package/src/state/app/context/index.test.tsx +16 -0
  84. package/src/tickers/search/index.ts +1 -0
  85. package/src/version.ts +1 -1
@@ -0,0 +1,298 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { MemoryPluginPersistence } from "../../../test-support/plugin-persistence";
3
+ import {
4
+ attachSubstackPersistence,
5
+ resetSubstackPersistence,
6
+ setSubstackFetchTransportForTests,
7
+ } from "./api/store";
8
+ import {
9
+ completeSubstackMagicLink,
10
+ completeSubstackOtpLogin,
11
+ parseCookiesFromSetCookie,
12
+ splitSetCookieHeader,
13
+ } from "./api/auth";
14
+ import {
15
+ getCachedSubstackArticleDetail,
16
+ getCachedSubstackHome,
17
+ getCachedSubstackPublicationFeed,
18
+ } from "./api/cache";
19
+ import {
20
+ loadSubstackArticleDetail,
21
+ loadSubstackHome,
22
+ loadSubstackPublicationFeed,
23
+ } from "./api/loaders";
24
+ import type { SubstackArticleSummary, SubstackPublication } from "./types";
25
+
26
+ const EMAIL = "reader@example.com";
27
+ const LOGIN_URL = "https://substack.com/login-token";
28
+
29
+ afterEach(() => {
30
+ setSubstackFetchTransportForTests(null);
31
+ resetSubstackPersistence();
32
+ });
33
+
34
+ function publication(overrides: Partial<SubstackPublication> = {}): SubstackPublication {
35
+ return {
36
+ id: "alpha",
37
+ name: "Alpha Research",
38
+ subdomain: null,
39
+ baseUrl: "https://alpha.example",
40
+ description: null,
41
+ logoUrl: null,
42
+ latestPublishedAt: null,
43
+ ...overrides,
44
+ };
45
+ }
46
+
47
+ function article(overrides: Partial<SubstackArticleSummary> = {}): SubstackArticleSummary {
48
+ const pub = publication();
49
+ return {
50
+ id: "1",
51
+ title: "Markets and $SPY",
52
+ publicationId: pub.id,
53
+ publicationName: pub.name,
54
+ publicationSubdomain: pub.subdomain,
55
+ publicationBaseUrl: pub.baseUrl,
56
+ url: `${pub.baseUrl}/p/markets`,
57
+ slug: "markets",
58
+ publishedAt: "2026-06-05T10:00:00Z",
59
+ subtitle: null,
60
+ previewText: null,
61
+ bodyHtml: null,
62
+ imageUrls: [],
63
+ wordCount: 220,
64
+ readMinutes: 1,
65
+ ...overrides,
66
+ };
67
+ }
68
+
69
+ function installAuthenticatedTransport(
70
+ handler: (url: string, init?: RequestInit) => Response | undefined | Promise<Response | undefined>,
71
+ ): string[] {
72
+ const requests: string[] = [];
73
+ setSubstackFetchTransportForTests(async (url, init) => {
74
+ requests.push(url);
75
+ if (url === LOGIN_URL) {
76
+ return new Response(null, {
77
+ status: 302,
78
+ headers: {
79
+ location: "/reader",
80
+ "set-cookie": "substack.sid=sid123; Path=/; HttpOnly",
81
+ },
82
+ });
83
+ }
84
+ if (url === "https://substack.com/reader") {
85
+ return new Response("ok", { status: 200 });
86
+ }
87
+ return await handler(url, init) ?? new Response(`unexpected ${url}`, { status: 500 });
88
+ });
89
+ return requests;
90
+ }
91
+
92
+ describe("Substack auth", () => {
93
+ test("parses combined cookie headers and follows OTP redirects for the session cookie", async () => {
94
+ const headers = splitSetCookieHeader(
95
+ "foo=bar; Expires=Wed, 21 Oct 2026 07:28:00 GMT; Path=/, substack.sid=sid123; Path=/; HttpOnly, substack.lli=1; Path=/",
96
+ );
97
+ expect(parseCookiesFromSetCookie(headers).get("substack.sid")).toBe("sid123");
98
+
99
+ attachSubstackPersistence(new MemoryPluginPersistence());
100
+ const requests: string[] = [];
101
+ setSubstackFetchTransportForTests(async (url, init) => {
102
+ requests.push(url);
103
+ if (url === "https://substack.com/api/v1/email-otp-login/complete") {
104
+ expect(init?.method).toBe("POST");
105
+ expect(JSON.parse(String(init?.body))).toMatchObject({ code: "123456", email: EMAIL });
106
+ return Response.json({ redirect: "/reader" });
107
+ }
108
+ if (url === "https://substack.com/reader") {
109
+ return new Response(null, {
110
+ status: 302,
111
+ headers: {
112
+ location: "/home",
113
+ "set-cookie": "substack.sid=sid789; Path=/; HttpOnly",
114
+ },
115
+ });
116
+ }
117
+ if (url === "https://substack.com/home") {
118
+ return new Response("ok", {
119
+ headers: { "set-cookie": "substack.lli=1; Path=/" },
120
+ });
121
+ }
122
+ return new Response(`unexpected ${url}`, { status: 500 });
123
+ });
124
+
125
+ const auth = await completeSubstackOtpLogin("123456", EMAIL);
126
+
127
+ expect(auth).toMatchObject({ sid: "sid789", lli: "1", email: EMAIL });
128
+ expect(requests).toEqual([
129
+ "https://substack.com/api/v1/email-otp-login/complete",
130
+ "https://substack.com/reader",
131
+ "https://substack.com/home",
132
+ ]);
133
+ });
134
+ });
135
+
136
+ describe("Substack cached data loading", () => {
137
+ test("loads subscriptions plus paginated feed and reuses the cached result", async () => {
138
+ attachSubstackPersistence(new MemoryPluginPersistence());
139
+ const feedUrls: string[] = [];
140
+ const requests = installAuthenticatedTransport((url) => {
141
+ if (url === "https://substack.com/api/v1/subscriptions/page_v2") {
142
+ return Response.json({
143
+ result: {
144
+ subscriptions: [{ id: "sub-alpha", publication_id: "alpha" }],
145
+ publicationMap: {
146
+ alpha: { id: "alpha", name: "Alpha Research", base_url: "https://alpha.example" },
147
+ },
148
+ },
149
+ });
150
+ }
151
+ if (url.startsWith("https://substack.com/api/v1/reader/feed?")) {
152
+ feedUrls.push(url);
153
+ const parsed = new URL(url);
154
+ return Response.json(parsed.searchParams.has("cursor")
155
+ ? { items: [{ post: { id: "second", title: "Second", post_date: "2026-06-05T11:00:00Z", publication: { id: "alpha", name: "Alpha Research", base_url: "https://alpha.example" } } }] }
156
+ : {
157
+ items: [
158
+ { type: "comment", comment: { id: 1, body: "not a post" } },
159
+ { post: { id: "first", title: "First", post_date: "2026-06-05T10:00:00Z", publication: { id: "alpha", name: "Alpha Research", base_url: "https://alpha.example" } } },
160
+ ],
161
+ nextCursor: "cursor-2",
162
+ });
163
+ }
164
+ });
165
+
166
+ await completeSubstackMagicLink(LOGIN_URL, EMAIL);
167
+ const first = await loadSubstackHome(false);
168
+ const requestCountAfterFirstLoad = requests.length;
169
+ const second = await loadSubstackHome(false);
170
+
171
+ expect(first.subscriptions.map((item) => item.name)).toEqual(["Alpha Research"]);
172
+ expect(first.feed.map((item) => item.id)).toEqual(["first", "second"]);
173
+ expect(second.feed.map((item) => item.id)).toEqual(["first", "second"]);
174
+ expect(feedUrls).toHaveLength(2);
175
+ expect(new URL(feedUrls[1]!).searchParams.get("cursor")).toBe("cursor-2");
176
+ expect(requests).toHaveLength(requestCountAfterFirstLoad);
177
+ });
178
+
179
+ test("hydrates cached home, merged publication pages, and article detail synchronously", () => {
180
+ const persistence = new MemoryPluginPersistence();
181
+ attachSubstackPersistence(persistence);
182
+ const pub = publication({ name: "Alpha" });
183
+ const firstArticle = article({ id: "1", title: "Archive one", publicationName: "Alpha" });
184
+ const secondArticle = article({
185
+ id: "2",
186
+ title: "Archive two",
187
+ publicationName: "Alpha",
188
+ url: "https://alpha.example/p/archive-two",
189
+ slug: "archive-two",
190
+ publishedAt: "2026-06-04T10:00:00Z",
191
+ readMinutes: 2,
192
+ wordCount: 440,
193
+ });
194
+ const cacheOptions = { sourceKey: "substack", schemaVersion: 3 };
195
+
196
+ persistence.seedResource("subscriptions", "me", [pub], cacheOptions);
197
+ persistence.seedResource("feed", "subscribed", [firstArticle], cacheOptions);
198
+ persistence.seedResource("publication", "https://alpha.example:offset:0", {
199
+ items: [firstArticle],
200
+ nextOffset: 12,
201
+ hasMore: true,
202
+ }, cacheOptions);
203
+ persistence.seedResource("publication", "https://alpha.example:offset:12", {
204
+ items: [secondArticle],
205
+ nextOffset: null,
206
+ hasMore: false,
207
+ }, cacheOptions);
208
+ persistence.seedResource("post", "1", {
209
+ ...firstArticle,
210
+ bodyHtml: "<p>Cached body</p>",
211
+ contentText: "Cached body",
212
+ contentBlocks: [{ type: "paragraph", text: "Cached body" }],
213
+ linkUrls: [],
214
+ }, cacheOptions);
215
+
216
+ expect(getCachedSubstackHome()?.feed.map((item) => item.title)).toEqual(["Archive one"]);
217
+ expect(getCachedSubstackPublicationFeed(pub)?.data.items.map((item) => item.title)).toEqual([
218
+ "Archive one",
219
+ "Archive two",
220
+ ]);
221
+ expect(getCachedSubstackArticleDetail({ id: "1" })?.data.contentText).toBe("Cached body");
222
+ });
223
+ });
224
+
225
+ describe("Substack publication archive loading", () => {
226
+ test("uses archive page limits and offset pagination", async () => {
227
+ attachSubstackPersistence(new MemoryPluginPersistence());
228
+ const requests = installAuthenticatedTransport((url) => {
229
+ if (url === "https://alpha.example/api/v1/archive?sort=new&limit=12") {
230
+ return Response.json(Array.from({ length: 12 }, (_, index) => ({
231
+ id: index + 1,
232
+ title: `Archive ${index + 1}`,
233
+ post_date: "2026-06-05T10:00:00Z",
234
+ slug: `archive-${index + 1}`,
235
+ })));
236
+ }
237
+ if (url === "https://alpha.example/api/v1/archive?sort=new&limit=12&offset=12") {
238
+ return Response.json(Array.from({ length: 3 }, (_, index) => ({
239
+ id: index + 13,
240
+ title: `Archive ${index + 13}`,
241
+ post_date: "2026-06-05T10:00:00Z",
242
+ slug: `archive-${index + 13}`,
243
+ })));
244
+ }
245
+ });
246
+
247
+ await completeSubstackMagicLink(LOGIN_URL, EMAIL);
248
+ const first = await loadSubstackPublicationFeed(publication(), false);
249
+ const second = await loadSubstackPublicationFeed(publication(), false, first.data.nextOffset ?? 0);
250
+
251
+ expect(first.data).toMatchObject({ hasMore: true, nextOffset: 12 });
252
+ expect(first.data.items).toHaveLength(12);
253
+ expect(second.data.items.map((item) => item.id)).toEqual(["13", "14", "15"]);
254
+ expect(second.data.hasMore).toBe(false);
255
+ expect(requests).toContain("https://alpha.example/api/v1/archive?sort=new&limit=12");
256
+ expect(requests).toContain("https://alpha.example/api/v1/archive?sort=new&limit=12&offset=12");
257
+ });
258
+ });
259
+
260
+ describe("Substack article detail loading", () => {
261
+ test("prefers the Substack origin post endpoint over custom-domain teaser content", async () => {
262
+ attachSubstackPersistence(new MemoryPluginPersistence());
263
+ const requests = installAuthenticatedTransport((url) => {
264
+ if (url === "https://substack.com/api/v1/posts/by-id/42") {
265
+ return Response.json({
266
+ id: 42,
267
+ title: "Full post",
268
+ body_html: "<p>Preview.</p><p>Paid section with $NVDA and more detail.</p>",
269
+ publication: { id: "alpha", name: "Alpha", base_url: "https://alpha.example" },
270
+ });
271
+ }
272
+ if (url === "https://alpha.example/api/v1/posts/by-id/42") {
273
+ return Response.json({
274
+ id: 42,
275
+ title: "Full post",
276
+ body_html: "<p>Preview.</p>",
277
+ publication: { id: "alpha", name: "Alpha", base_url: "https://alpha.example" },
278
+ });
279
+ }
280
+ });
281
+
282
+ await completeSubstackMagicLink(LOGIN_URL, EMAIL);
283
+ const detail = await loadSubstackArticleDetail(article({
284
+ id: "42",
285
+ title: "Full post",
286
+ url: "https://alpha.example/p/full-post",
287
+ slug: "full-post",
288
+ previewText: "Preview.",
289
+ bodyHtml: "<p>Preview.</p>",
290
+ wordCount: 1,
291
+ }));
292
+
293
+ expect(detail.data.contentText).toContain("Paid section");
294
+ expect(detail.data.wordCount).toBeGreaterThan(1);
295
+ expect(requests).toContain("https://substack.com/api/v1/posts/by-id/42");
296
+ expect(requests).not.toContain("https://alpha.example/api/v1/posts/by-id/42");
297
+ });
298
+ });
@@ -0,0 +1,359 @@
1
+ import { useCallback, type RefObject } from "react";
2
+ import { Box, ScrollBox, Text, TextAttributes, useRendererHost, type ScrollBoxRenderable } from "../../../ui";
3
+ import { Spinner } from "../../../components";
4
+ import { RemoteImage } from "../../../components/ui";
5
+ import { TickerBadgeText } from "../../../components/ticker/badge/text";
6
+ import { useInlineTickers } from "../../../state/hooks/inline-tickers";
7
+ import { colors } from "../../../theme/colors";
8
+ import type {
9
+ SubstackArticleDetail,
10
+ SubstackArticleSummary,
11
+ SubstackContentBlock,
12
+ } from "./types";
13
+
14
+ function articleBlockText(block: SubstackContentBlock): string {
15
+ switch (block.type) {
16
+ case "heading":
17
+ case "paragraph":
18
+ case "quote":
19
+ case "listItem":
20
+ return block.text;
21
+ case "embed":
22
+ return [block.text, block.url].filter(Boolean).join("\n");
23
+ default:
24
+ return "";
25
+ }
26
+ }
27
+
28
+ function wrappedTextProps(width: number) {
29
+ return {
30
+ width,
31
+ wrapText: true,
32
+ wrapMode: "word",
33
+ style: {
34
+ minWidth: 0,
35
+ whiteSpace: "pre-wrap",
36
+ overflowWrap: "anywhere",
37
+ },
38
+ } as const;
39
+ }
40
+
41
+ function normalizedTwitterUsername(username: string | null | undefined): string | null {
42
+ const normalized = username?.trim().replace(/^@/, "") ?? "";
43
+ return /^[A-Za-z0-9_]{1,15}$/.test(normalized) ? normalized : null;
44
+ }
45
+
46
+ function TweetEmbedView({
47
+ block,
48
+ lineWidth,
49
+ imageWidth,
50
+ imageHeight,
51
+ catalog,
52
+ openTicker,
53
+ openLink,
54
+ openUsername,
55
+ }: {
56
+ block: Extract<SubstackContentBlock, { type: "embed"; kind: "tweet" }>;
57
+ lineWidth: number;
58
+ imageWidth: number;
59
+ imageHeight: number;
60
+ catalog: ReturnType<typeof useInlineTickers>["catalog"];
61
+ openTicker: ReturnType<typeof useInlineTickers>["openTicker"];
62
+ openLink: (url: string) => void;
63
+ openUsername: (username: string) => void;
64
+ }) {
65
+ const username = normalizedTwitterUsername(block.username);
66
+ const contentWidth = Math.max(1, lineWidth - 3);
67
+
68
+ return (
69
+ <Box flexDirection="column" width={lineWidth} paddingX={1}>
70
+ <Box flexDirection="row" width={Math.max(1, lineWidth - 2)}>
71
+ <Text fg={colors.textDim} attributes={TextAttributes.BOLD}>Tweet</Text>
72
+ {username ? (
73
+ <Text
74
+ fg={colors.borderFocused}
75
+ onMouseDown={() => openUsername(username)}
76
+ >
77
+ {` @${username}`}
78
+ </Text>
79
+ ) : null}
80
+ {block.dateLabel ? <Text fg={colors.textDim}>{` | ${block.dateLabel}`}</Text> : null}
81
+ </Box>
82
+ <Box flexDirection="row" width={Math.max(1, lineWidth - 2)}>
83
+ <Text fg={colors.borderFocused}>| </Text>
84
+ <Box width={contentWidth}>
85
+ <TickerBadgeText
86
+ text={block.text}
87
+ lineWidth={contentWidth}
88
+ catalog={catalog}
89
+ textColor={colors.text}
90
+ openTicker={openTicker}
91
+ openLink={openLink}
92
+ openUsername={openUsername}
93
+ />
94
+ </Box>
95
+ </Box>
96
+ {block.imageUrls.length > 0 ? (
97
+ <Box flexDirection="column" paddingLeft={2}>
98
+ {block.imageUrls.slice(0, 2).map((url, index) => (
99
+ <RemoteImage
100
+ key={url}
101
+ src={url}
102
+ alt={`Tweet image ${index + 1}`}
103
+ width={Math.max(1, imageWidth - 2)}
104
+ height={Math.max(4, Math.min(imageHeight, 10))}
105
+ label={block.imageUrls.length > 1 ? `tweet image ${index + 1}` : "tweet image"}
106
+ />
107
+ ))}
108
+ </Box>
109
+ ) : null}
110
+ {block.url ? (
111
+ <Text
112
+ fg={colors.borderFocused}
113
+ onMouseDown={() => openLink(block.url!)}
114
+ >
115
+ open tweet
116
+ </Text>
117
+ ) : null}
118
+ </Box>
119
+ );
120
+ }
121
+
122
+ function ArticleBlockView({
123
+ block,
124
+ lineWidth,
125
+ imageWidth,
126
+ imageHeight,
127
+ catalog,
128
+ openTicker,
129
+ openLink,
130
+ openUsername,
131
+ }: {
132
+ block: SubstackContentBlock;
133
+ lineWidth: number;
134
+ imageWidth: number;
135
+ imageHeight: number;
136
+ catalog: ReturnType<typeof useInlineTickers>["catalog"];
137
+ openTicker: ReturnType<typeof useInlineTickers>["openTicker"];
138
+ openLink: (url: string) => void;
139
+ openUsername: (username: string) => void;
140
+ }) {
141
+ switch (block.type) {
142
+ case "heading":
143
+ return (
144
+ <Text
145
+ fg={colors.textBright}
146
+ attributes={TextAttributes.BOLD}
147
+ {...wrappedTextProps(lineWidth)}
148
+ >
149
+ {block.text}
150
+ </Text>
151
+ );
152
+ case "quote":
153
+ return (
154
+ <Box flexDirection="row" width={lineWidth}>
155
+ <Text fg={colors.warning}>| </Text>
156
+ <Box width={Math.max(1, lineWidth - 2)}>
157
+ <TickerBadgeText
158
+ text={block.text}
159
+ lineWidth={Math.max(1, lineWidth - 2)}
160
+ catalog={catalog}
161
+ textColor={colors.textDim}
162
+ openTicker={openTicker}
163
+ openLink={openLink}
164
+ openUsername={openUsername}
165
+ />
166
+ </Box>
167
+ </Box>
168
+ );
169
+ case "listItem":
170
+ return (
171
+ <Box flexDirection="row" width={lineWidth}>
172
+ <Text fg={colors.textDim}>- </Text>
173
+ <Box width={Math.max(1, lineWidth - 2)}>
174
+ <TickerBadgeText
175
+ text={block.text}
176
+ lineWidth={Math.max(1, lineWidth - 2)}
177
+ catalog={catalog}
178
+ textColor={colors.text}
179
+ openTicker={openTicker}
180
+ openLink={openLink}
181
+ openUsername={openUsername}
182
+ />
183
+ </Box>
184
+ </Box>
185
+ );
186
+ case "image":
187
+ return (
188
+ <RemoteImage
189
+ src={block.url}
190
+ alt={block.alt ?? "Substack image"}
191
+ width={imageWidth}
192
+ height={imageHeight}
193
+ label={block.alt ?? "image"}
194
+ />
195
+ );
196
+ case "embed":
197
+ if (block.kind === "tweet") {
198
+ return (
199
+ <TweetEmbedView
200
+ block={block}
201
+ lineWidth={lineWidth}
202
+ imageWidth={imageWidth}
203
+ imageHeight={imageHeight}
204
+ catalog={catalog}
205
+ openTicker={openTicker}
206
+ openLink={openLink}
207
+ openUsername={openUsername}
208
+ />
209
+ );
210
+ }
211
+ return (
212
+ <Box flexDirection="column" width={lineWidth} paddingX={1}>
213
+ <Text fg={colors.textDim} attributes={TextAttributes.BOLD}>
214
+ {block.kind === "media" ? "Media" : "Link"}
215
+ </Text>
216
+ <TickerBadgeText
217
+ text={block.text}
218
+ lineWidth={Math.max(1, lineWidth - 2)}
219
+ catalog={catalog}
220
+ textColor={colors.text}
221
+ openTicker={openTicker}
222
+ openLink={openLink}
223
+ openUsername={openUsername}
224
+ />
225
+ {block.url ? (
226
+ <Text
227
+ fg={colors.borderFocused}
228
+ onMouseDown={() => openLink(block.url!)}
229
+ {...wrappedTextProps(Math.max(1, lineWidth - 2))}
230
+ >
231
+ {block.url}
232
+ </Text>
233
+ ) : null}
234
+ </Box>
235
+ );
236
+ case "divider":
237
+ return <Text fg={colors.border}>{"-".repeat(Math.max(1, Math.min(lineWidth, 96)))}</Text>;
238
+ case "paragraph":
239
+ default:
240
+ return (
241
+ <TickerBadgeText
242
+ text={block.text}
243
+ lineWidth={lineWidth}
244
+ catalog={catalog}
245
+ textColor={colors.text}
246
+ openTicker={openTicker}
247
+ openLink={openLink}
248
+ openUsername={openUsername}
249
+ />
250
+ );
251
+ }
252
+ }
253
+
254
+ function ArticleRichContent({
255
+ blocks,
256
+ fallbackText,
257
+ fallbackImageUrls,
258
+ articleTitle,
259
+ lineWidth,
260
+ imageWidth,
261
+ imageHeight,
262
+ }: {
263
+ blocks: SubstackContentBlock[];
264
+ fallbackText: string;
265
+ fallbackImageUrls: string[];
266
+ articleTitle: string;
267
+ lineWidth: number;
268
+ imageWidth: number;
269
+ imageHeight: number;
270
+ }) {
271
+ const rendererHost = useRendererHost();
272
+ const resolvedBlocks = blocks.length > 0
273
+ ? blocks
274
+ : [
275
+ ...(fallbackText ? [{ type: "paragraph" as const, text: fallbackText }] : []),
276
+ ...fallbackImageUrls.map((url): SubstackContentBlock => ({ type: "image", url, alt: articleTitle })),
277
+ ];
278
+ const tickerText = resolvedBlocks.map(articleBlockText).filter(Boolean).join("\n");
279
+ const { catalog, openTicker } = useInlineTickers([tickerText], { liveQuotes: false });
280
+ const openLink = useCallback((url: string) => {
281
+ void rendererHost.openExternal(url);
282
+ }, [rendererHost]);
283
+ const openUsername = useCallback((username: string) => {
284
+ const normalized = normalizedTwitterUsername(username);
285
+ if (!normalized) return;
286
+ void rendererHost.openExternal(`https://x.com/${normalized}`);
287
+ }, [rendererHost]);
288
+
289
+ if (resolvedBlocks.length === 0) {
290
+ return <Text fg={colors.textDim}>No article text returned.</Text>;
291
+ }
292
+
293
+ return (
294
+ <Box flexDirection="column" width={lineWidth} gap={1}>
295
+ {resolvedBlocks.map((block, index) => (
296
+ <ArticleBlockView
297
+ key={`${block.type}:${index}:${articleBlockText(block).slice(0, 32)}`}
298
+ block={block}
299
+ lineWidth={lineWidth}
300
+ imageWidth={imageWidth}
301
+ imageHeight={imageHeight}
302
+ catalog={catalog}
303
+ openTicker={openTicker}
304
+ openLink={openLink}
305
+ openUsername={openUsername}
306
+ />
307
+ ))}
308
+ </Box>
309
+ );
310
+ }
311
+
312
+ export function ArticleDetail({
313
+ article,
314
+ detail,
315
+ width,
316
+ loading,
317
+ error,
318
+ scrollRef,
319
+ onOpenArticle,
320
+ }: {
321
+ article: SubstackArticleSummary;
322
+ detail: SubstackArticleDetail | null;
323
+ width: number;
324
+ loading: boolean;
325
+ error: string | null;
326
+ scrollRef: RefObject<ScrollBoxRenderable | null>;
327
+ onOpenArticle: () => void;
328
+ }) {
329
+ const resolved = detail ?? null;
330
+ const text = resolved?.contentText || article.previewText || article.subtitle || "";
331
+ const blocks = resolved?.contentBlocks ?? [];
332
+ const fallbackImageUrls = resolved?.imageUrls.length ? resolved.imageUrls : article.imageUrls;
333
+ const lineWidth = Math.max(1, width - 2);
334
+ const imageWidth = Math.min(lineWidth, 86);
335
+ const imageHeight = Math.max(6, Math.min(14, Math.floor(imageWidth * 0.32)));
336
+
337
+ return (
338
+ <ScrollBox ref={scrollRef} scrollY focusable={false} flexGrow={1} paddingX={1}>
339
+ <Box flexDirection="column" width={lineWidth} gap={1}>
340
+ {loading && !resolved ? <Spinner label="Loading article..." /> : null}
341
+ {error ? <Text fg={colors.negative}>{error}</Text> : null}
342
+ <ArticleRichContent
343
+ blocks={blocks}
344
+ fallbackText={text}
345
+ fallbackImageUrls={fallbackImageUrls}
346
+ articleTitle={article.title}
347
+ lineWidth={lineWidth}
348
+ imageWidth={imageWidth}
349
+ imageHeight={imageHeight}
350
+ />
351
+ {article.url ? (
352
+ <Box height={1}>
353
+ <Text fg={colors.textDim} onMouseDown={onOpenArticle}>Open source: O</Text>
354
+ </Box>
355
+ ) : null}
356
+ </Box>
357
+ </ScrollBox>
358
+ );
359
+ }