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,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=> 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, """);
|
|
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
|
+
}
|