gloomberb 0.8.2 → 0.9.1

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 (111) hide show
  1. package/README.md +9 -7
  2. package/package.json +18 -13
  3. package/patches/{@opentui%2Fcore@0.1.90.patch → @opentui%2Fcore@0.3.2.patch} +175 -90
  4. package/patches/electrobun@1.18.1.patch +85 -0
  5. package/src/app/global-shortcuts.test.tsx +66 -4
  6. package/src/app/global-shortcuts.ts +12 -6
  7. package/src/assets/gloomberb-logo-windows.ico +0 -0
  8. package/src/assets/gloomberb-logo-windows.png +0 -0
  9. package/src/assets/gloomberb-logo.ico +0 -0
  10. package/src/components/command-bar/action-runtime.ts +1 -0
  11. package/src/components/command-bar/layout-items/current-layout.test.ts +115 -0
  12. package/src/components/command-bar/layout-items/current-layout.ts +33 -0
  13. package/src/components/command-bar/list/view.tsx +3 -1
  14. package/src/components/command-bar/pane-templates/items.ts +4 -1
  15. package/src/components/command-bar/panel/body.tsx +1 -0
  16. package/src/components/command-bar/routes/root/results.ts +0 -30
  17. package/src/components/command-bar/routes/root/runtime.ts +2 -20
  18. package/src/components/command-bar/routes/ticker-search/actions.ts +42 -4
  19. package/src/components/command-bar/routes/ticker-search/route.ts +4 -1
  20. package/src/components/command-bar/routing/launch-request.ts +14 -7
  21. package/src/components/command-bar/routing/list-state.ts +1 -3
  22. package/src/components/command-bar/surface/index.test.tsx +76 -4
  23. package/src/components/command-bar/surface/index.tsx +0 -2
  24. package/src/components/command-bar/surface/pane.test.tsx +1 -0
  25. package/src/components/command-bar/surface/test-harness.tsx +1 -0
  26. package/src/components/command-bar/view-model.ts +3 -3
  27. package/src/components/layout/detached-pane-shell.tsx +8 -4
  28. package/src/components/layout/header.tsx +13 -6
  29. package/src/components/layout/shell/index.test.tsx +120 -2
  30. package/src/components/layout/shell/index.tsx +2 -0
  31. package/src/components/layout/shell/menu.ts +1 -7
  32. package/src/components/layout/shell/pane/actions.ts +8 -0
  33. package/src/components/layout/shell/pane/management-shortcuts.ts +12 -2
  34. package/src/components/layout/shell/shortcuts.ts +14 -6
  35. package/src/components/layout/shell/window-mode/index.ts +8 -3
  36. package/src/components/layout/titlebar-overlay.ts +16 -0
  37. package/src/components/layout/window-controls.tsx +90 -0
  38. package/src/components/layout/window-edit/presentation.ts +4 -4
  39. package/src/components/onboarding/onboarding-steps.tsx +3 -2
  40. package/src/components/ticker/list-table-view.tsx +6 -0
  41. package/src/components/ui/remote-image.tsx +1 -14
  42. package/src/components/ui/tabs.tsx +95 -80
  43. package/src/components/ui/ui.test.tsx +54 -0
  44. package/src/core/state/app/types.ts +12 -4
  45. package/src/plugins/builtin/chat/content/channel-navigation.ts +44 -11
  46. package/src/plugins/builtin/chat/content/index.tsx +5 -4
  47. package/src/plugins/builtin/chat/content/shortcuts.ts +7 -0
  48. package/src/plugins/builtin/chat/sidebar.test.tsx +71 -0
  49. package/src/plugins/builtin/help/components.tsx +1 -1
  50. package/src/plugins/builtin/help/index.test.tsx +105 -30
  51. package/src/plugins/builtin/help/index.tsx +324 -168
  52. package/src/plugins/builtin/help/shortcut-model.ts +5 -6
  53. package/src/plugins/builtin/portfolio-list/column-values.ts +304 -1
  54. package/src/plugins/builtin/portfolio-list/metrics.test.ts +93 -0
  55. package/src/plugins/builtin/portfolio-list/pane/data.ts +7 -0
  56. package/src/plugins/builtin/portfolio-list/pane/index.tsx +25 -2
  57. package/src/plugins/builtin/portfolio-list/pane/supplemental.ts +281 -0
  58. package/src/plugins/builtin/portfolio-list/settings.ts +27 -0
  59. package/src/plugins/builtin/portfolio-list/table.tsx +4 -2
  60. package/src/plugins/builtin/portfolio-list/use-throttled-cursor-symbol.ts +10 -68
  61. package/src/plugins/builtin/substack/api/auth.ts +185 -0
  62. package/src/plugins/builtin/substack/api/cache.ts +78 -0
  63. package/src/plugins/builtin/substack/api/loaders.ts +146 -0
  64. package/src/plugins/builtin/substack/api/store.ts +186 -0
  65. package/src/plugins/builtin/substack/api/types.ts +40 -0
  66. package/src/plugins/builtin/substack/api.test.ts +298 -0
  67. package/src/plugins/builtin/substack/article-detail.tsx +359 -0
  68. package/src/plugins/builtin/substack/article-stack.tsx +129 -0
  69. package/src/plugins/builtin/substack/content.ts +478 -0
  70. package/src/plugins/builtin/substack/feed-tabs.tsx +41 -0
  71. package/src/plugins/builtin/substack/index.tsx +49 -0
  72. package/src/plugins/builtin/substack/login-view.tsx +126 -0
  73. package/src/plugins/builtin/substack/model.test.ts +155 -0
  74. package/src/plugins/builtin/substack/normalize.ts +343 -0
  75. package/src/plugins/builtin/substack/pane-footer.ts +71 -0
  76. package/src/plugins/builtin/substack/pane-state.ts +170 -0
  77. package/src/plugins/builtin/substack/pane.tsx +500 -0
  78. package/src/plugins/builtin/substack/table.ts +111 -0
  79. package/src/plugins/builtin/substack/types.ts +71 -0
  80. package/src/plugins/builtin/substack/utils.ts +131 -0
  81. package/src/plugins/builtin/ticker-detail/index.test.tsx +16 -2
  82. package/src/plugins/builtin/ticker-detail/index.tsx +1 -0
  83. package/src/plugins/builtin/ticker-detail/pane.tsx +13 -1
  84. package/src/plugins/catalog-ui.ts +2 -0
  85. package/src/plugins/pane-manager/layout-state.ts +11 -0
  86. package/src/plugins/pane-manager.ts +1 -1
  87. package/src/react/use-throttled-commit-value.ts +116 -0
  88. package/src/renderers/electrobun/bun/application-menu/index.test.ts +12 -0
  89. package/src/renderers/electrobun/bun/application-menu/index.ts +4 -0
  90. package/src/renderers/electrobun/bun/desktop/detached-windows.ts +43 -2
  91. package/src/renderers/electrobun/bun/desktop/host-requests.ts +31 -0
  92. package/src/renderers/electrobun/bun/desktop/http-fetch.test.ts +42 -0
  93. package/src/renderers/electrobun/bun/desktop/http-fetch.ts +5 -0
  94. package/src/renderers/electrobun/bun/desktop/window-controls.test.ts +53 -0
  95. package/src/renderers/electrobun/bun/desktop/window-controls.ts +64 -0
  96. package/src/renderers/electrobun/bun/desktop/window-style.ts +5 -0
  97. package/src/renderers/electrobun/bun/desktop/windows-icons.ts +123 -0
  98. package/src/renderers/electrobun/bun/index.ts +28 -6
  99. package/src/renderers/electrobun/bun/window/frame.test.ts +7 -0
  100. package/src/renderers/electrobun/bun/window/frame.ts +5 -0
  101. package/src/renderers/electrobun/view/build-assets.ts +2 -1
  102. package/src/renderers/electrobun/view/host/text.tsx +29 -9
  103. package/src/renderers/electrobun/view/http-fetch.ts +10 -3
  104. package/src/renderers/electrobun/view/styles.css +20 -0
  105. package/src/renderers/electrobun/view/ui-host.tsx +14 -0
  106. package/src/state/app/context/index.test.tsx +16 -0
  107. package/src/tickers/search/index.ts +1 -0
  108. package/src/ui/host.tsx +2 -0
  109. package/src/updater.test.ts +47 -2
  110. package/src/updater.ts +67 -10
  111. package/src/version.ts +1 -1
@@ -0,0 +1,126 @@
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
+ import { Box, Text, TextAttributes, type InputRenderable } from "../../../ui";
3
+ import { Button, TextField } from "../../../components";
4
+ import { colors } from "../../../theme/colors";
5
+ import {
6
+ completeSubstackMagicLink,
7
+ completeSubstackOtpLogin,
8
+ requestSubstackMagicLink,
9
+ } from "./api/auth";
10
+ import type {
11
+ SubstackAuthState,
12
+ } from "./api/types";
13
+ import { errorMessage } from "./pane-state";
14
+
15
+ export function SubstackLoginView({
16
+ width,
17
+ height,
18
+ focused,
19
+ onLogin,
20
+ }: {
21
+ width: number;
22
+ height: number;
23
+ focused: boolean;
24
+ onLogin: (auth: SubstackAuthState) => void;
25
+ }) {
26
+ const [email, setEmail] = useState("");
27
+ const [loginToken, setLoginToken] = useState("");
28
+ const [phase, setPhase] = useState<"email" | "link">("email");
29
+ const [busy, setBusy] = useState(false);
30
+ const [error, setError] = useState<string | null>(null);
31
+ const emailRef = useRef<InputRenderable | null>(null);
32
+ const linkRef = useRef<InputRenderable | null>(null);
33
+ const panelWidth = Math.max(32, Math.min(72, width - 4));
34
+
35
+ useEffect(() => {
36
+ if (!focused) return;
37
+ const input = phase === "email" ? emailRef.current : linkRef.current;
38
+ input?.focus?.();
39
+ }, [focused, phase]);
40
+
41
+ const requestLink = useCallback(() => {
42
+ if (busy) return;
43
+ setBusy(true);
44
+ setError(null);
45
+ requestSubstackMagicLink(email)
46
+ .then(() => {
47
+ setPhase("link");
48
+ })
49
+ .catch((requestError) => setError(errorMessage(requestError)))
50
+ .finally(() => setBusy(false));
51
+ }, [busy, email]);
52
+
53
+ const completeLogin = useCallback(() => {
54
+ if (busy) return;
55
+ setBusy(true);
56
+ setError(null);
57
+ const token = loginToken.trim();
58
+ const login = /^\d{6}$/.test(token)
59
+ ? completeSubstackOtpLogin(token, email)
60
+ : completeSubstackMagicLink(token, email);
61
+ login
62
+ .then(onLogin)
63
+ .catch((loginError) => setError(errorMessage(loginError)))
64
+ .finally(() => setBusy(false));
65
+ }, [busy, email, loginToken, onLogin]);
66
+
67
+ return (
68
+ <Box width={width} height={height} justifyContent="center" alignItems="center">
69
+ <Box width={panelWidth} flexDirection="column" gap={1}>
70
+ <Text fg={colors.textBright} attributes={TextAttributes.BOLD}>Substack login</Text>
71
+ {phase === "email" ? (
72
+ <>
73
+ <TextField
74
+ label="Email"
75
+ value={email}
76
+ placeholder="you@example.com"
77
+ width={panelWidth}
78
+ inputRef={emailRef}
79
+ focused={focused}
80
+ onChange={setEmail}
81
+ onSubmit={requestLink}
82
+ />
83
+ <Button
84
+ label={busy ? "Sending..." : "Send magic link"}
85
+ variant="primary"
86
+ disabled={busy || !email.trim()}
87
+ onPress={requestLink}
88
+ />
89
+ </>
90
+ ) : (
91
+ <>
92
+ <Text fg={colors.textDim}>Paste the 6-digit code or full URL from Substack's email.</Text>
93
+ <TextField
94
+ label="Code or magic link"
95
+ value={loginToken}
96
+ placeholder="123456 or https://substack.com/..."
97
+ width={panelWidth}
98
+ inputRef={linkRef}
99
+ focused={focused}
100
+ onChange={setLoginToken}
101
+ onSubmit={completeLogin}
102
+ />
103
+ <Box flexDirection="row" gap={1}>
104
+ <Button
105
+ label={busy ? "Logging in..." : "Log in"}
106
+ variant="primary"
107
+ disabled={busy || !loginToken.trim()}
108
+ onPress={completeLogin}
109
+ />
110
+ <Button
111
+ label="Change email"
112
+ variant="ghost"
113
+ disabled={busy}
114
+ onPress={() => {
115
+ setPhase("email");
116
+ setLoginToken("");
117
+ }}
118
+ />
119
+ </Box>
120
+ </>
121
+ )}
122
+ {error ? <Text fg={colors.negative}>{error}</Text> : null}
123
+ </Box>
124
+ </Box>
125
+ );
126
+ }
@@ -0,0 +1,155 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { extractArticleContent } from "./content";
3
+ import {
4
+ articleMatchesPublication,
5
+ normalizeFeedItems,
6
+ normalizePostDetail,
7
+ normalizeSubscriptions,
8
+ sortSubscriptionsByLatest,
9
+ } from "./normalize";
10
+
11
+ describe("Substack article extraction", () => {
12
+ test("keeps article structure while removing chrome and image-only links", () => {
13
+ const attrs = JSON.stringify({
14
+ url: "https://x.com/jukan05/status/2062809398284812532?s=46",
15
+ full_text: "* Important: SK hynix has no plans to add any new NAND flash capacity.\n\n=&gt; Pure-play NAND manufacturers add capacity.",
16
+ username: "jukan05",
17
+ name: "Jukan @COMPUTEX",
18
+ date: "2026-06-05T08:11:00.000Z",
19
+ photos: [{ img_url: "https://pbs.substack.com/media/HKCSNYaa8AAq-06.jpg" }],
20
+ quoted_tweet: {
21
+ full_text: "[Exclusive] Jensen Huang asked Hynix to make more.",
22
+ username: "jukan05",
23
+ },
24
+ }).replace(/"/g, "&quot;");
25
+ const content = extractArticleContent(`
26
+ <article>
27
+ <script>window.bad = true</script>
28
+ <h1>Ignored detail title</h1>
29
+ <h2>Victory Giant: -11% as US weighs subsidies</h2>
30
+ <p>We bought <a href="/p/company">$NVDA</a> after earnings.</p>
31
+ <div class="twitter-embed" data-attrs="${attrs}" data-component-name="Twitter2ToDOM"></div>
32
+ <blockquote>Seems very knee jerkish - subsidies seem very small.</blockquote>
33
+ <figure>
34
+ <a href="https://substackcdn.com/image/fetch/$s_!abc/f_auto,q_auto:good/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fchart.png">image</a>
35
+ <img src="/company-card.png" alt="CQME chart" />
36
+ </figure>
37
+ <p>See <a href="https://example.com/report">the report</a>.</p>
38
+ </article>
39
+ `, {
40
+ baseUrl: "https://collyerbridge.substack.com",
41
+ title: "Ignored detail title",
42
+ });
43
+
44
+ expect(content.text).toContain("$NVDA");
45
+ expect(content.text).toContain("Seems very knee jerkish");
46
+ expect(content.text).not.toContain("Ignored detail title");
47
+ expect(content.text).not.toContain("window.bad");
48
+ expect(content.text).not.toContain("substackcdn.com/image/fetch");
49
+ expect(content.imageUrls).toEqual(["https://collyerbridge.substack.com/company-card.png"]);
50
+ expect(content.linkUrls).toEqual([
51
+ "https://collyerbridge.substack.com/p/company",
52
+ "https://example.com/report",
53
+ ]);
54
+ expect(content.blocks.map((block) => block.type)).toEqual([
55
+ "heading",
56
+ "paragraph",
57
+ "embed",
58
+ "quote",
59
+ "image",
60
+ "paragraph",
61
+ ]);
62
+ expect(content.blocks[2]).toEqual({
63
+ type: "embed",
64
+ kind: "tweet",
65
+ text: "* Important: SK hynix has no plans to add any new NAND flash capacity.\n\n=> Pure-play NAND manufacturers add capacity.\n\nQuoted: [Exclusive] Jensen Huang asked Hynix to make more.",
66
+ url: "https://x.com/jukan05/status/2062809398284812532?s=46",
67
+ username: "jukan05",
68
+ authorName: "Jukan @COMPUTEX",
69
+ dateLabel: "Jun 5, 2026",
70
+ imageUrls: ["https://pbs.substack.com/media/HKCSNYaa8AAq-06.jpg"],
71
+ });
72
+ });
73
+ });
74
+
75
+ describe("Substack feed normalization", () => {
76
+ test("normalizes reader rows and orders subscriptions by newest matching post", () => {
77
+ const subscriptions = normalizeSubscriptions({
78
+ result: {
79
+ subscriptions: [
80
+ { id: "sa", publication_id: "a" },
81
+ { id: "sb", publication_id: "b" },
82
+ ],
83
+ publicationMap: {
84
+ a: { id: "a", name: "Alpha", base_url: "https://alpha.example" },
85
+ b: { id: "b", name: "Beta", base_url: "https://beta.example" },
86
+ },
87
+ },
88
+ });
89
+ const feed = normalizeFeedItems({
90
+ items: [
91
+ {
92
+ post: {
93
+ id: "older",
94
+ title: "Older",
95
+ post_date: "2026-06-01T10:00:00Z",
96
+ publication: { id: "a", name: "Alpha", base_url: "https://alpha.example" },
97
+ },
98
+ },
99
+ {
100
+ post: {
101
+ id: "newer",
102
+ title: "Semis and $NVDA",
103
+ subtitle: "A quick note",
104
+ post_date: "2026-06-05T10:00:00Z",
105
+ canonical_url: "https://beta.example/p/nvda",
106
+ slug: "nvda",
107
+ wordcount: 560,
108
+ publication: { id: "b", name: "Beta", base_url: "https://beta.example" },
109
+ },
110
+ },
111
+ ],
112
+ });
113
+
114
+ const sorted = sortSubscriptionsByLatest(subscriptions, feed);
115
+ expect(feed[1]).toMatchObject({
116
+ id: "newer",
117
+ title: "Semis and $NVDA",
118
+ publicationName: "Beta",
119
+ publicationBaseUrl: "https://beta.example",
120
+ url: "https://beta.example/p/nvda",
121
+ readMinutes: 3,
122
+ });
123
+ expect(sorted.map((publication) => publication.name)).toEqual(["Beta", "Alpha"]);
124
+ expect(articleMatchesPublication(feed[1]!, sorted[0]!)).toBe(true);
125
+ });
126
+
127
+ test("merges fetched post body into an existing summary", () => {
128
+ const summary = normalizeFeedItems({
129
+ items: [
130
+ {
131
+ post: {
132
+ id: 99,
133
+ title: "Macro Links",
134
+ post_date: "2026-06-05T10:00:00Z",
135
+ publication: { id: "macro", name: "Macro", base_url: "https://macro.example" },
136
+ },
137
+ },
138
+ ],
139
+ })[0]!;
140
+
141
+ const detail = normalizePostDetail({
142
+ id: 99,
143
+ title: "Macro Links",
144
+ body_html: "<p>$SPY broke higher. <a href=\"/p/chart\">Chart</a></p>",
145
+ publication: { id: "macro", name: "Macro", base_url: "https://macro.example" },
146
+ }, summary);
147
+
148
+ expect(detail.contentText).toContain("$SPY");
149
+ expect(detail.linkUrls).toEqual(["https://macro.example/p/chart"]);
150
+ expect(detail.contentBlocks).toEqual([
151
+ { type: "paragraph", text: "$SPY broke higher. Chart" },
152
+ ]);
153
+ expect(detail.id).toBe("99");
154
+ });
155
+ });
@@ -0,0 +1,343 @@
1
+ import {
2
+ estimateReadingMinutes,
3
+ extractArticleContent,
4
+ wordCount,
5
+ } from "./content";
6
+ import type {
7
+ SubstackArticleDetail,
8
+ SubstackArticleSummary,
9
+ SubstackPublication,
10
+ } from "./types";
11
+ import {
12
+ asRecord,
13
+ firstNumber,
14
+ firstRawString,
15
+ firstRecord,
16
+ firstString,
17
+ firstValue,
18
+ maxIso,
19
+ normalizeUrl,
20
+ parseDateIso,
21
+ timestamp,
22
+ uniqueStrings,
23
+ type JsonRecord,
24
+ } from "./utils";
25
+
26
+ function extractPayloadArray(payload: unknown, preferredKeys: string[]): unknown[] {
27
+ if (Array.isArray(payload)) return payload;
28
+ const record = asRecord(payload);
29
+ if (!record) return [];
30
+ for (const key of preferredKeys) {
31
+ const value = record[key];
32
+ if (Array.isArray(value)) return value;
33
+ }
34
+ const data = asRecord(record.data);
35
+ if (data) {
36
+ for (const key of preferredKeys) {
37
+ const value = data[key];
38
+ if (Array.isArray(value)) return value;
39
+ }
40
+ }
41
+ const result = asRecord(record.result);
42
+ if (result) {
43
+ for (const key of preferredKeys) {
44
+ const value = result[key];
45
+ if (Array.isArray(value)) return value;
46
+ }
47
+ }
48
+ return [];
49
+ }
50
+
51
+ export function normalizePublication(raw: unknown): SubstackPublication | null {
52
+ const record = asRecord(raw);
53
+ if (!record) return null;
54
+ const subdomain = firstString(record, ["subdomain", "handle", "slug"]);
55
+ const customDomain = firstString(record, ["custom_domain", "customDomain", "domain"]);
56
+ const baseUrl = normalizeUrl(firstRawString(record, ["base_url", "baseUrl", "url", "web_url", "canonical_url"]))
57
+ ?? (customDomain ? `https://${customDomain}` : null)
58
+ ?? (subdomain ? `https://${subdomain}.substack.com` : null);
59
+ const idValue = firstValue(record, ["id", "publication_id", "publicationId", "subdomain"]);
60
+ const name = firstString(record, ["name", "publication_name", "publicationName", "title"]) ?? subdomain ?? "Substack";
61
+ const id = String(idValue ?? baseUrl ?? name).trim();
62
+ return {
63
+ id,
64
+ name,
65
+ subdomain,
66
+ baseUrl,
67
+ description: firstString(record, ["description", "hero_text", "heroText", "author_bio"]),
68
+ logoUrl: normalizeUrl(firstRawString(record, ["logo_url", "logoUrl", "logo", "cover_image", "coverImage"]), baseUrl),
69
+ latestPublishedAt: parseDateIso(firstValue(record, [
70
+ "latestPublishedAt",
71
+ "last_published_at",
72
+ "last_post_date",
73
+ "lastPostDate",
74
+ "updated_at",
75
+ ])),
76
+ };
77
+ }
78
+
79
+ export function normalizeSubscriptions(payload: unknown): SubstackPublication[] {
80
+ const rows = extractPayloadArray(payload, [
81
+ "subscriptions",
82
+ "publications",
83
+ "results",
84
+ "items",
85
+ ]);
86
+ const rootRecord = asRecord(payload);
87
+ const publicationLookup = buildPublicationLookup(rootRecord);
88
+ const deduped = new Map<string, SubstackPublication>();
89
+ for (const row of rows) {
90
+ const record = asRecord(row);
91
+ const publication = normalizePublication(
92
+ record?.publication
93
+ ?? record?.pub
94
+ ?? (record ? publicationLookup.get(String(record.publication_id ?? record.publicationId ?? "")) : null)
95
+ ?? row,
96
+ );
97
+ if (!publication) continue;
98
+ const key = publicationKey(publication);
99
+ const existing = deduped.get(key);
100
+ if (!existing) {
101
+ deduped.set(key, publication);
102
+ continue;
103
+ }
104
+ deduped.set(key, {
105
+ ...existing,
106
+ latestPublishedAt: maxIso(existing.latestPublishedAt, publication.latestPublishedAt),
107
+ });
108
+ }
109
+ return [...deduped.values()].sort((a, b) => a.name.localeCompare(b.name));
110
+ }
111
+
112
+ function buildPublicationLookup(record: JsonRecord | null): Map<string, unknown> {
113
+ const lookup = new Map<string, unknown>();
114
+ if (!record) return lookup;
115
+ const maps = [
116
+ asRecord(record.publicationMap),
117
+ asRecord(record.publication_map),
118
+ asRecord(asRecord(record.result)?.publicationMap),
119
+ asRecord(asRecord(record.result)?.publication_map),
120
+ ];
121
+ for (const map of maps) {
122
+ if (!map) continue;
123
+ for (const [key, value] of Object.entries(map)) {
124
+ lookup.set(key, value);
125
+ }
126
+ }
127
+ const arrays = [
128
+ record.publications,
129
+ asRecord(record.result)?.publications,
130
+ ];
131
+ for (const array of arrays) {
132
+ if (!Array.isArray(array)) continue;
133
+ for (const publication of array) {
134
+ const normalized = normalizePublication(publication);
135
+ if (!normalized) continue;
136
+ lookup.set(normalized.id, publication);
137
+ }
138
+ }
139
+ return lookup;
140
+ }
141
+
142
+ function articlePostRecord(row: unknown): JsonRecord | null {
143
+ const record = asRecord(row);
144
+ return firstRecord(
145
+ record?.post,
146
+ record?.item,
147
+ record?.entity,
148
+ record?.object,
149
+ record,
150
+ );
151
+ }
152
+
153
+ function articlePublicationRecord(row: JsonRecord | null, post: JsonRecord | null): JsonRecord | null {
154
+ return firstRecord(
155
+ post?.publication,
156
+ post?.pub,
157
+ row?.publication,
158
+ row?.pub,
159
+ );
160
+ }
161
+
162
+ function buildArticleUrl(post: JsonRecord | null, publication: SubstackPublication | null): string | null {
163
+ const direct = normalizeUrl(firstRawString(post, ["canonical_url", "canonicalUrl", "url", "web_url"]), publication?.baseUrl);
164
+ if (direct) return direct;
165
+ const slug = firstString(post, ["slug"]);
166
+ if (!slug || !publication?.baseUrl) return null;
167
+ return normalizeUrl(`/p/${slug}`, publication.baseUrl);
168
+ }
169
+
170
+ function normalizeArticle(row: unknown, sourcePublication?: SubstackPublication | null): SubstackArticleSummary | null {
171
+ const rowRecord = asRecord(row);
172
+ const post = articlePostRecord(row);
173
+ if (!post) return null;
174
+ const publication = normalizePublication(articlePublicationRecord(rowRecord, post)) ?? sourcePublication ?? null;
175
+ const title = firstString(post, ["title", "name", "social_title", "email_subject"]) ?? firstString(rowRecord, ["title"]);
176
+ const subtitle = firstString(post, ["subtitle", "description", "search_engine_description", "social_description"]);
177
+ const bodyHtml = firstRawString(post, ["body_html", "bodyHtml", "body"]);
178
+ const coverImage = normalizeUrl(firstRawString(post, [
179
+ "cover_image",
180
+ "coverImage",
181
+ "cover_image_url",
182
+ "coverImageUrl",
183
+ "thumbnail_url",
184
+ "thumbnailUrl",
185
+ "image",
186
+ ]), publication?.baseUrl);
187
+ const extracted = bodyHtml
188
+ ? extractArticleContent(bodyHtml, { baseUrl: publication?.baseUrl, imageUrls: coverImage ? [coverImage] : [], title })
189
+ : { text: "", blocks: [], imageUrls: uniqueStrings([coverImage]), linkUrls: [], wordCount: 0, readMinutes: 1 };
190
+ const previewText = firstString(post, [
191
+ "truncated_body_text",
192
+ "body_preview",
193
+ "preview",
194
+ "subtitle",
195
+ "description",
196
+ "search_engine_description",
197
+ ]) ?? (extracted.text ? extracted.text.slice(0, 500) : null);
198
+ const rawWordCount = firstNumber(post, ["wordcount", "word_count", "words"]);
199
+ const words = Math.max(0, Math.round(rawWordCount ?? extracted.wordCount ?? wordCount(previewText ?? "")));
200
+ const rawReadMinutes = firstNumber(post, [
201
+ "reading_time_minutes",
202
+ "read_time_minutes",
203
+ "readMinutes",
204
+ "readingTime",
205
+ ]);
206
+ const publishedAt = parseDateIso(firstValue(post, [
207
+ "post_date",
208
+ "published_at",
209
+ "publish_date",
210
+ "date",
211
+ "created_at",
212
+ "updated_at",
213
+ ]) ?? firstValue(rowRecord, ["published_at", "timestamp", "date"]));
214
+ const url = buildArticleUrl(post, publication);
215
+ const slug = firstString(post, ["slug"]);
216
+ const rawId = firstValue(post, ["id", "post_id", "postId", "canonical_slug"]) ?? firstValue(rowRecord, ["id", "post_id"]);
217
+ const id = String(rawId ?? url ?? `${publication?.id ?? "substack"}:${slug ?? title ?? publishedAt ?? Math.random()}`).trim();
218
+
219
+ if (!title && !url && !bodyHtml) return null;
220
+
221
+ return {
222
+ id,
223
+ title: title ?? "Untitled",
224
+ publicationId: publication?.id ?? null,
225
+ publicationName: publication?.name ?? null,
226
+ publicationSubdomain: publication?.subdomain ?? null,
227
+ publicationBaseUrl: publication?.baseUrl ?? null,
228
+ url,
229
+ slug,
230
+ publishedAt,
231
+ subtitle,
232
+ previewText,
233
+ bodyHtml,
234
+ imageUrls: extracted.imageUrls,
235
+ wordCount: words,
236
+ readMinutes: rawReadMinutes != null ? Math.max(1, Math.ceil(rawReadMinutes)) : estimateReadingMinutes(words || extracted.text),
237
+ };
238
+ }
239
+
240
+ export function normalizeFeedItems(payload: unknown, sourcePublication?: SubstackPublication | null): SubstackArticleSummary[] {
241
+ const rows = extractPayloadArray(payload, ["items", "posts", "results", "feed"]);
242
+ const items = rows
243
+ .map((row) => normalizeArticle(row, sourcePublication))
244
+ .filter((row): row is SubstackArticleSummary => !!row);
245
+ const deduped = new Map<string, SubstackArticleSummary>();
246
+ for (const item of items) {
247
+ const existing = deduped.get(item.id);
248
+ if (!existing || timestamp(item.publishedAt) > timestamp(existing.publishedAt)) {
249
+ deduped.set(item.id, item);
250
+ }
251
+ }
252
+ return [...deduped.values()];
253
+ }
254
+
255
+ export function normalizePostDetail(
256
+ payload: unknown,
257
+ fallback: SubstackArticleSummary,
258
+ ): SubstackArticleDetail {
259
+ const post = articlePostRecord(payload) ?? asRecord(payload);
260
+ const normalized = normalizeArticle(post ?? payload, {
261
+ id: fallback.publicationId ?? fallback.publicationBaseUrl ?? fallback.publicationName ?? "substack",
262
+ name: fallback.publicationName ?? "Substack",
263
+ subdomain: fallback.publicationSubdomain,
264
+ baseUrl: fallback.publicationBaseUrl,
265
+ description: null,
266
+ logoUrl: null,
267
+ latestPublishedAt: null,
268
+ }) ?? fallback;
269
+ const bodyHtml = normalized.bodyHtml ?? fallback.bodyHtml;
270
+ const extracted = extractArticleContent(bodyHtml, {
271
+ baseUrl: normalized.publicationBaseUrl ?? fallback.publicationBaseUrl,
272
+ imageUrls: uniqueStrings([...fallback.imageUrls, ...normalized.imageUrls]),
273
+ title: normalized.title || fallback.title,
274
+ });
275
+ const contentText = extracted.text || normalized.previewText || fallback.previewText || "";
276
+ const words = Math.max(normalized.wordCount, extracted.wordCount, wordCount(contentText));
277
+ return {
278
+ ...fallback,
279
+ ...normalized,
280
+ id: fallback.id,
281
+ title: normalized.title || fallback.title,
282
+ url: normalized.url ?? fallback.url,
283
+ bodyHtml,
284
+ imageUrls: uniqueStrings([...normalized.imageUrls, ...fallback.imageUrls, ...extracted.imageUrls]),
285
+ contentText,
286
+ contentBlocks: extracted.blocks,
287
+ linkUrls: extracted.linkUrls,
288
+ wordCount: words,
289
+ readMinutes: estimateReadingMinutes(words || contentText),
290
+ };
291
+ }
292
+
293
+ export function publicationKey(publication: Pick<SubstackPublication, "id" | "name" | "baseUrl" | "subdomain">): string {
294
+ return (
295
+ publication.baseUrl
296
+ ?? publication.subdomain
297
+ ?? publication.id
298
+ ?? publication.name
299
+ ).toLowerCase();
300
+ }
301
+
302
+ function articlePublicationKeys(article: SubstackArticleSummary): string[] {
303
+ return uniqueStrings([
304
+ article.publicationBaseUrl?.toLowerCase(),
305
+ article.publicationSubdomain?.toLowerCase(),
306
+ article.publicationId?.toLowerCase(),
307
+ article.publicationName?.toLowerCase(),
308
+ ]);
309
+ }
310
+
311
+ export function articleMatchesPublication(
312
+ article: SubstackArticleSummary,
313
+ publication: SubstackPublication,
314
+ ): boolean {
315
+ const keys = new Set(articlePublicationKeys(article));
316
+ return [
317
+ publication.baseUrl,
318
+ publication.subdomain,
319
+ publication.id,
320
+ publication.name,
321
+ ].some((value) => !!value && keys.has(value.toLowerCase()));
322
+ }
323
+
324
+ export function sortSubscriptionsByLatest(
325
+ subscriptions: SubstackPublication[],
326
+ feedItems: SubstackArticleSummary[],
327
+ ): SubstackPublication[] {
328
+ return [...subscriptions]
329
+ .map((publication) => {
330
+ const latestFeedAt = feedItems
331
+ .filter((article) => articleMatchesPublication(article, publication))
332
+ .reduce<string | null>((latest, article) => maxIso(latest, article.publishedAt), null);
333
+ return {
334
+ publication,
335
+ latestAt: maxIso(publication.latestPublishedAt, latestFeedAt),
336
+ };
337
+ })
338
+ .sort((a, b) => {
339
+ const byDate = timestamp(b.latestAt) - timestamp(a.latestAt);
340
+ return byDate !== 0 ? byDate : a.publication.name.localeCompare(b.publication.name);
341
+ })
342
+ .map((entry) => ({ ...entry.publication, latestPublishedAt: entry.latestAt }));
343
+ }
@@ -0,0 +1,71 @@
1
+ import { usePaneFooter } from "../../../components";
2
+ import type { SubstackAuthState } from "./api/types";
3
+ import {
4
+ formatReadTime,
5
+ formatWordCount,
6
+ } from "./table";
7
+ import {
8
+ SUBSTACK_PANE_ID,
9
+ type SubstackArticleSummary,
10
+ } from "./types";
11
+ import { cacheStatusLabel, type ActiveFeedState, type DetailState } from "./pane-state";
12
+
13
+ export function useSubstackPaneFooter({
14
+ auth,
15
+ detailOpen,
16
+ activeFeedState,
17
+ activeDetail,
18
+ selectedArticle,
19
+ refreshActive,
20
+ openSelectedArticle,
21
+ }: {
22
+ auth: SubstackAuthState | null;
23
+ detailOpen: boolean;
24
+ activeFeedState: ActiveFeedState;
25
+ activeDetail: DetailState;
26
+ selectedArticle: SubstackArticleSummary | null;
27
+ refreshActive: () => void;
28
+ openSelectedArticle: () => void;
29
+ }) {
30
+ const statusLabel = cacheStatusLabel(activeFeedState.fetchedAt, activeFeedState.stale);
31
+ const articleMetaLabel = detailOpen && selectedArticle
32
+ ? [
33
+ selectedArticle.publicationName,
34
+ formatReadTime(activeDetail.data?.readMinutes ?? selectedArticle.readMinutes),
35
+ formatWordCount(activeDetail.data?.wordCount ?? selectedArticle.wordCount),
36
+ ].filter(Boolean).join(" | ")
37
+ : null;
38
+
39
+ usePaneFooter(SUBSTACK_PANE_ID, () => ({
40
+ info: [
41
+ ...(!auth ? [{ id: "auth", parts: [{ text: "login required", tone: "warning" as const }] }] : []),
42
+ ...(articleMetaLabel ? [{ id: "article-meta", parts: [{ text: articleMetaLabel, tone: "muted" as const }] }] : []),
43
+ ...(activeFeedState.loading || activeFeedState.loadingMore ? [{ id: "loading", parts: [{ text: activeFeedState.loadingMore ? "loading more" : "loading", tone: "muted" as const }] }] : []),
44
+ ...(activeDetail.loading && detailOpen ? [{ id: "detail-loading", parts: [{ text: "loading article", tone: "muted" as const }] }] : []),
45
+ ...(statusLabel && auth ? [{ id: "cache", parts: [{ text: statusLabel, tone: activeFeedState.stale ? "warning" as const : "muted" as const }] }] : []),
46
+ ...(activeFeedState.error ? [{ id: "error", parts: [{ text: activeFeedState.error, tone: "warning" as const }] }] : []),
47
+ ...(activeDetail.error && detailOpen ? [{ id: "detail-error", parts: [{ text: activeDetail.error, tone: "warning" as const }] }] : []),
48
+ ],
49
+ hints: auth ? [
50
+ { id: "refresh", key: "r", label: "efresh", onPress: refreshActive },
51
+ { id: "open", key: "o", label: "pen", onPress: openSelectedArticle, disabled: !selectedArticle?.url },
52
+ ] : [],
53
+ }), [
54
+ activeDetail.error,
55
+ activeDetail.loading,
56
+ activeDetail.data?.readMinutes,
57
+ activeDetail.data?.wordCount,
58
+ activeFeedState.error,
59
+ activeFeedState.fetchedAt,
60
+ activeFeedState.loading,
61
+ activeFeedState.loadingMore,
62
+ activeFeedState.stale,
63
+ articleMetaLabel,
64
+ auth,
65
+ detailOpen,
66
+ openSelectedArticle,
67
+ refreshActive,
68
+ selectedArticle?.url,
69
+ statusLabel,
70
+ ]);
71
+ }