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.
- package/README.md +9 -7
- package/package.json +14 -13
- package/patches/{@opentui%2Fcore@0.1.90.patch → @opentui%2Fcore@0.3.2.patch} +175 -90
- package/src/app/global-shortcuts.test.tsx +54 -4
- package/src/app/global-shortcuts.ts +11 -5
- package/src/components/command-bar/action-runtime.ts +1 -0
- package/src/components/command-bar/layout-items/current-layout.test.ts +115 -0
- package/src/components/command-bar/layout-items/current-layout.ts +33 -0
- package/src/components/command-bar/list/view.tsx +3 -1
- package/src/components/command-bar/pane-templates/items.ts +4 -1
- package/src/components/command-bar/panel/body.tsx +1 -0
- package/src/components/command-bar/routes/root/results.ts +0 -30
- package/src/components/command-bar/routes/root/runtime.ts +2 -20
- package/src/components/command-bar/routes/ticker-search/actions.ts +42 -4
- package/src/components/command-bar/routes/ticker-search/route.ts +4 -1
- package/src/components/command-bar/routing/launch-request.ts +14 -7
- package/src/components/command-bar/routing/list-state.ts +1 -3
- package/src/components/command-bar/surface/index.test.tsx +76 -4
- package/src/components/command-bar/surface/index.tsx +0 -2
- package/src/components/command-bar/surface/pane.test.tsx +1 -0
- package/src/components/command-bar/surface/test-harness.tsx +1 -0
- package/src/components/command-bar/view-model.ts +3 -3
- package/src/components/layout/shell/index.test.tsx +120 -2
- package/src/components/layout/shell/index.tsx +2 -0
- package/src/components/layout/shell/menu.ts +1 -7
- package/src/components/layout/shell/pane/actions.ts +8 -0
- package/src/components/layout/shell/pane/management-shortcuts.ts +12 -2
- package/src/components/layout/shell/shortcuts.ts +14 -6
- package/src/components/layout/shell/window-mode/index.ts +8 -3
- package/src/components/layout/window-edit/presentation.ts +4 -4
- package/src/components/onboarding/onboarding-steps.tsx +3 -2
- package/src/components/ticker/list-table-view.tsx +6 -0
- package/src/components/ui/remote-image.tsx +1 -14
- package/src/components/ui/tabs.tsx +95 -80
- package/src/components/ui/ui.test.tsx +54 -0
- package/src/core/state/app/types.ts +12 -4
- package/src/plugins/builtin/chat/content/channel-navigation.ts +44 -11
- package/src/plugins/builtin/chat/content/index.tsx +5 -4
- package/src/plugins/builtin/chat/content/shortcuts.ts +7 -0
- package/src/plugins/builtin/chat/sidebar.test.tsx +71 -0
- package/src/plugins/builtin/help/components.tsx +1 -1
- package/src/plugins/builtin/help/index.test.tsx +105 -30
- package/src/plugins/builtin/help/index.tsx +324 -168
- package/src/plugins/builtin/help/shortcut-model.ts +5 -6
- package/src/plugins/builtin/portfolio-list/column-values.ts +304 -1
- package/src/plugins/builtin/portfolio-list/metrics.test.ts +93 -0
- package/src/plugins/builtin/portfolio-list/pane/data.ts +7 -0
- package/src/plugins/builtin/portfolio-list/pane/index.tsx +25 -2
- package/src/plugins/builtin/portfolio-list/pane/supplemental.ts +281 -0
- package/src/plugins/builtin/portfolio-list/settings.ts +27 -0
- package/src/plugins/builtin/portfolio-list/table.tsx +4 -2
- package/src/plugins/builtin/portfolio-list/use-throttled-cursor-symbol.ts +10 -68
- package/src/plugins/builtin/substack/api/auth.ts +185 -0
- package/src/plugins/builtin/substack/api/cache.ts +78 -0
- package/src/plugins/builtin/substack/api/loaders.ts +146 -0
- package/src/plugins/builtin/substack/api/store.ts +186 -0
- package/src/plugins/builtin/substack/api/types.ts +40 -0
- package/src/plugins/builtin/substack/api.test.ts +298 -0
- package/src/plugins/builtin/substack/article-detail.tsx +359 -0
- package/src/plugins/builtin/substack/article-stack.tsx +129 -0
- package/src/plugins/builtin/substack/content.ts +478 -0
- package/src/plugins/builtin/substack/feed-tabs.tsx +41 -0
- package/src/plugins/builtin/substack/index.tsx +49 -0
- package/src/plugins/builtin/substack/login-view.tsx +126 -0
- package/src/plugins/builtin/substack/model.test.ts +155 -0
- package/src/plugins/builtin/substack/normalize.ts +343 -0
- package/src/plugins/builtin/substack/pane-footer.ts +71 -0
- package/src/plugins/builtin/substack/pane-state.ts +170 -0
- package/src/plugins/builtin/substack/pane.tsx +500 -0
- package/src/plugins/builtin/substack/table.ts +111 -0
- package/src/plugins/builtin/substack/types.ts +71 -0
- package/src/plugins/builtin/substack/utils.ts +131 -0
- package/src/plugins/builtin/ticker-detail/index.test.tsx +16 -2
- package/src/plugins/builtin/ticker-detail/index.tsx +1 -0
- package/src/plugins/builtin/ticker-detail/pane.tsx +13 -1
- package/src/plugins/catalog-ui.ts +2 -0
- package/src/plugins/pane-manager/layout-state.ts +11 -0
- package/src/plugins/pane-manager.ts +1 -1
- package/src/react/use-throttled-commit-value.ts +116 -0
- package/src/renderers/electrobun/bun/desktop/http-fetch.test.ts +42 -0
- package/src/renderers/electrobun/bun/desktop/http-fetch.ts +5 -0
- package/src/renderers/electrobun/view/http-fetch.ts +10 -3
- package/src/state/app/context/index.test.tsx +16 -0
- package/src/tickers/search/index.ts +1 -0
- 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
|
+
}
|