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,170 @@
1
+ import { formatTimeAgo } from "../../../utils/format";
2
+ import {
3
+ getCachedSubstackArticleDetail,
4
+ getCachedSubstackHome,
5
+ getCachedSubstackPublicationFeed,
6
+ } from "./api/cache";
7
+ import type {
8
+ SubstackHomeData,
9
+ SubstackPublicationFeedPage,
10
+ } from "./api/types";
11
+ import { articleMatchesPublication } from "./normalize";
12
+ import type {
13
+ SubstackArticleDetail,
14
+ SubstackArticleSummary,
15
+ SubstackPublication,
16
+ } from "./types";
17
+
18
+ export interface LoadState<T> {
19
+ data: T | null;
20
+ loading: boolean;
21
+ error: string | null;
22
+ fetchedAt: number | null;
23
+ stale: boolean;
24
+ }
25
+
26
+ export type PublicationFeedState = LoadState<SubstackPublicationFeedPage> & {
27
+ loadingMore: boolean;
28
+ };
29
+
30
+ export type DetailState = LoadState<SubstackArticleDetail>;
31
+
32
+ export type ActiveFeedState = LoadState<SubstackArticleSummary[]> & {
33
+ loadingMore?: boolean;
34
+ hasMore?: boolean;
35
+ nextOffset?: number | null;
36
+ };
37
+
38
+ export function emptyLoadState<T>(): LoadState<T> {
39
+ return {
40
+ data: null,
41
+ loading: false,
42
+ error: null,
43
+ fetchedAt: null,
44
+ stale: false,
45
+ };
46
+ }
47
+
48
+ export function emptyPublicationFeedState(): PublicationFeedState {
49
+ return {
50
+ ...emptyLoadState<SubstackPublicationFeedPage>(),
51
+ loadingMore: false,
52
+ };
53
+ }
54
+
55
+ export function mergePublicationFeedPages(
56
+ current: SubstackPublicationFeedPage | null,
57
+ next: SubstackPublicationFeedPage,
58
+ ): SubstackPublicationFeedPage {
59
+ if (!current) return next;
60
+ const articlesById = new Map<string, SubstackArticleSummary>();
61
+ for (const article of current.items) articlesById.set(article.id, article);
62
+ for (const article of next.items) articlesById.set(article.id, article);
63
+ return {
64
+ items: [...articlesById.values()],
65
+ nextOffset: next.nextOffset,
66
+ hasMore: next.hasMore,
67
+ };
68
+ }
69
+
70
+ export function homeLoadStateFromCache(): LoadState<SubstackHomeData> {
71
+ const cached = getCachedSubstackHome();
72
+ if (!cached) return emptyLoadState<SubstackHomeData>();
73
+ return {
74
+ data: cached,
75
+ loading: false,
76
+ error: null,
77
+ fetchedAt: cached.fetchedAt,
78
+ stale: cached.stale,
79
+ };
80
+ }
81
+
82
+ export function publicationLoadStateFromCache(publication: SubstackPublication): PublicationFeedState | null {
83
+ const cached = getCachedSubstackPublicationFeed(publication);
84
+ if (!cached) return null;
85
+ return {
86
+ data: cached.data,
87
+ loading: false,
88
+ loadingMore: false,
89
+ error: null,
90
+ fetchedAt: cached.fetchedAt,
91
+ stale: cached.stale,
92
+ };
93
+ }
94
+
95
+ export function detailLoadStateFromCache(article: SubstackArticleSummary): DetailState | null {
96
+ const cached = getCachedSubstackArticleDetail(article);
97
+ if (!cached) return null;
98
+ return {
99
+ data: cached.data,
100
+ loading: false,
101
+ error: null,
102
+ fetchedAt: cached.fetchedAt,
103
+ stale: cached.stale,
104
+ };
105
+ }
106
+
107
+ export function errorMessage(error: unknown): string {
108
+ return error instanceof Error ? error.message : String(error);
109
+ }
110
+
111
+ export function tabLabel(label: string): string {
112
+ const compact = label.replace(/\s+/g, " ").trim();
113
+ return compact.length <= 24 ? compact : `${compact.slice(0, 21)}...`;
114
+ }
115
+
116
+ export function cacheStatusLabel(fetchedAt: number | null, stale: boolean): string | null {
117
+ if (!fetchedAt) return null;
118
+ return `${stale ? "stale " : "updated "}${formatTimeAgo(new Date(fetchedAt))}`;
119
+ }
120
+
121
+ export function activeFeedStateFromSources({
122
+ activePublication,
123
+ activePublicationTabId,
124
+ publicationFeeds,
125
+ home,
126
+ }: {
127
+ activePublication: SubstackPublication | null;
128
+ activePublicationTabId: string | null;
129
+ publicationFeeds: Record<string, PublicationFeedState>;
130
+ home: LoadState<SubstackHomeData>;
131
+ }): ActiveFeedState {
132
+ if (!activePublication || !activePublicationTabId) {
133
+ return {
134
+ data: home.data?.feed ?? null,
135
+ loading: home.loading,
136
+ error: home.error,
137
+ fetchedAt: home.fetchedAt,
138
+ stale: home.stale,
139
+ loadingMore: false,
140
+ hasMore: false,
141
+ nextOffset: null,
142
+ };
143
+ }
144
+
145
+ const publicationEntry = publicationFeeds[activePublicationTabId];
146
+ if (publicationEntry?.data) {
147
+ return {
148
+ data: publicationEntry.data.items,
149
+ loading: publicationEntry.loading,
150
+ loadingMore: publicationEntry.loadingMore,
151
+ hasMore: publicationEntry.data.hasMore,
152
+ nextOffset: publicationEntry.data.nextOffset,
153
+ error: publicationEntry.error,
154
+ fetchedAt: publicationEntry.fetchedAt,
155
+ stale: publicationEntry.stale,
156
+ };
157
+ }
158
+
159
+ const publicationFallbackRows = home.data?.feed.filter((article) => articleMatchesPublication(article, activePublication)) ?? [];
160
+ return {
161
+ data: publicationFallbackRows.length > 0 ? publicationFallbackRows : null,
162
+ loading: publicationEntry?.loading ?? false,
163
+ loadingMore: publicationEntry?.loadingMore ?? false,
164
+ hasMore: false,
165
+ nextOffset: null,
166
+ error: publicationEntry?.error ?? null,
167
+ fetchedAt: publicationEntry?.fetchedAt ?? home.fetchedAt,
168
+ stale: publicationEntry?.stale ?? home.stale,
169
+ };
170
+ }
@@ -0,0 +1,500 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
+ import { Box, useRendererHost, type ScrollBoxRenderable } from "../../../ui";
3
+ import {
4
+ EmptyState,
5
+ Spinner,
6
+ type DataTableKeyEvent,
7
+ } from "../../../components";
8
+ import type { PaneProps } from "../../../types/plugin";
9
+ import { isPlainKey } from "../../../utils/keyboard";
10
+ import { useDebouncedPluginPaneState, usePluginPaneState } from "../../runtime";
11
+ import {
12
+ clearSubstackAuth,
13
+ getStoredSubstackAuth,
14
+ } from "./api/store";
15
+ import {
16
+ loadSubstackArticleDetail,
17
+ loadSubstackHome,
18
+ loadSubstackPublicationFeed,
19
+ } from "./api/loaders";
20
+ import {
21
+ SubstackAuthError,
22
+ type SubstackAuthState,
23
+ type SubstackCachedData,
24
+ type SubstackHomeData,
25
+ type SubstackPublicationFeedPage,
26
+ } from "./api/types";
27
+ import { ArticleDetail } from "./article-detail";
28
+ import { SubstackArticleStack } from "./article-stack";
29
+ import { SubstackFeedTabs } from "./feed-tabs";
30
+ import { SubstackLoginView } from "./login-view";
31
+ import {
32
+ buildSubstackColumns,
33
+ isSubstackSortColumnId,
34
+ nextSubstackSort,
35
+ publicationFromTabId,
36
+ sortedSubstackArticles,
37
+ tabIdForPublication,
38
+ } from "./table";
39
+ import {
40
+ SUBSTACK_FEED_TAB_ID,
41
+ type SubstackArticleDetail,
42
+ type SubstackArticleSummary,
43
+ type SubstackPublication,
44
+ type SubstackSortColumnId,
45
+ type SubstackSortDirection,
46
+ } from "./types";
47
+ import { useSubstackPaneFooter } from "./pane-footer";
48
+ import {
49
+ activeFeedStateFromSources,
50
+ detailLoadStateFromCache,
51
+ emptyLoadState,
52
+ emptyPublicationFeedState,
53
+ errorMessage,
54
+ homeLoadStateFromCache,
55
+ mergePublicationFeedPages,
56
+ publicationLoadStateFromCache,
57
+ type DetailState,
58
+ type LoadState,
59
+ type PublicationFeedState,
60
+ } from "./pane-state";
61
+
62
+ const PUBLICATION_LOAD_MORE_THRESHOLD_ROWS = 8;
63
+
64
+ export function SubstackPane({ focused, width, height }: PaneProps) {
65
+ const rendererHost = useRendererHost();
66
+ const [auth, setAuth] = useState<SubstackAuthState | null>(() => getStoredSubstackAuth());
67
+ const [home, setHome] = useState<LoadState<SubstackHomeData>>(homeLoadStateFromCache);
68
+ const [publicationFeeds, setPublicationFeeds] = useState<Record<string, PublicationFeedState>>({});
69
+ const [details, setDetails] = useState<Record<string, DetailState>>({});
70
+ const [activeTab, setActiveTab] = usePluginPaneState<string>("activeTab", SUBSTACK_FEED_TAB_ID);
71
+ const [selectedArticleId, setSelectedArticleId] = useDebouncedPluginPaneState<string | null>("selectedArticleId", null);
72
+ const [detailOpen, setDetailOpen] = usePluginPaneState<boolean>("detailOpen", false);
73
+ const [sort, setSort] = useState<{ columnId: SubstackSortColumnId; direction: SubstackSortDirection }>({
74
+ columnId: "published",
75
+ direction: "desc",
76
+ });
77
+ const homeFetchGenRef = useRef(0);
78
+ const publicationFetchGenRef = useRef<Record<string, number>>({});
79
+ const detailFetchGenRef = useRef(0);
80
+ const tableScrollRef = useRef<ScrollBoxRenderable | null>(null);
81
+ const detailScrollRef = useRef<ScrollBoxRenderable | null>(null);
82
+
83
+ const handleAuthFailure = useCallback((error: unknown) => {
84
+ if (!(error instanceof SubstackAuthError)) return false;
85
+ clearSubstackAuth();
86
+ setAuth(null);
87
+ setDetailOpen(false);
88
+ setHome((current) => ({
89
+ ...current,
90
+ loading: false,
91
+ error: error.message,
92
+ }));
93
+ return true;
94
+ }, []);
95
+
96
+ const loadHome = useCallback((force = false) => {
97
+ if (!auth) return;
98
+ homeFetchGenRef.current += 1;
99
+ const gen = homeFetchGenRef.current;
100
+ setHome((current) => ({
101
+ ...current,
102
+ loading: !current.data || force,
103
+ error: null,
104
+ }));
105
+ loadSubstackHome(force)
106
+ .then((data) => {
107
+ if (homeFetchGenRef.current !== gen) return;
108
+ setHome({
109
+ data,
110
+ loading: false,
111
+ error: null,
112
+ fetchedAt: data.fetchedAt,
113
+ stale: data.stale,
114
+ });
115
+ })
116
+ .catch((loadError) => {
117
+ if (homeFetchGenRef.current !== gen) return;
118
+ if (handleAuthFailure(loadError)) return;
119
+ setHome((current) => ({
120
+ ...current,
121
+ loading: false,
122
+ error: errorMessage(loadError),
123
+ }));
124
+ });
125
+ }, [auth, handleAuthFailure]);
126
+
127
+ useEffect(() => {
128
+ if (!auth) return;
129
+ loadHome(false);
130
+ }, [auth, loadHome]);
131
+
132
+ const subscriptions = home.data?.subscriptions ?? [];
133
+ const activePublication = useMemo(
134
+ () => publicationFromTabId(activeTab, subscriptions),
135
+ [activeTab, subscriptions],
136
+ );
137
+ const activePublicationTabId = activePublication ? tabIdForPublication(activePublication) : null;
138
+
139
+ useEffect(() => {
140
+ if (!home.data || activeTab === SUBSTACK_FEED_TAB_ID) return;
141
+ if (!publicationFromTabId(activeTab, subscriptions)) {
142
+ setActiveTab(SUBSTACK_FEED_TAB_ID);
143
+ setDetailOpen(false);
144
+ }
145
+ }, [activeTab, home.data, setActiveTab, setDetailOpen, subscriptions]);
146
+
147
+ const loadPublication = useCallback((publication: SubstackPublication, force = false, offset = 0) => {
148
+ const tabId = tabIdForPublication(publication);
149
+ const isLoadMore = offset > 0 && !force;
150
+ const gen = (publicationFetchGenRef.current[tabId] ?? 0) + 1;
151
+ publicationFetchGenRef.current[tabId] = gen;
152
+ setPublicationFeeds((current) => ({
153
+ ...current,
154
+ [tabId]: {
155
+ ...(current[tabId] ?? emptyPublicationFeedState()),
156
+ loading: !isLoadMore,
157
+ loadingMore: isLoadMore,
158
+ error: null,
159
+ },
160
+ }));
161
+ loadSubstackPublicationFeed(publication, force, offset)
162
+ .then((entry: SubstackCachedData<SubstackPublicationFeedPage>) => {
163
+ if (publicationFetchGenRef.current[tabId] !== gen) return;
164
+ setPublicationFeeds((current) => ({
165
+ ...current,
166
+ [tabId]: (() => {
167
+ const previous = current[tabId] ?? emptyPublicationFeedState();
168
+ return {
169
+ data: isLoadMore ? mergePublicationFeedPages(previous.data, entry.data) : entry.data,
170
+ loading: false,
171
+ loadingMore: false,
172
+ error: null,
173
+ fetchedAt: entry.fetchedAt,
174
+ stale: entry.stale,
175
+ };
176
+ })(),
177
+ }));
178
+ })
179
+ .catch((loadError) => {
180
+ if (publicationFetchGenRef.current[tabId] !== gen) return;
181
+ if (handleAuthFailure(loadError)) return;
182
+ setPublicationFeeds((current) => ({
183
+ ...current,
184
+ [tabId]: {
185
+ ...(current[tabId] ?? emptyPublicationFeedState()),
186
+ loading: false,
187
+ loadingMore: false,
188
+ error: errorMessage(loadError),
189
+ },
190
+ }));
191
+ });
192
+ }, [handleAuthFailure]);
193
+
194
+ useEffect(() => {
195
+ if (!activePublication || !activePublicationTabId) return;
196
+ const entry = publicationFeeds[activePublicationTabId];
197
+ if (entry?.data || entry?.loading || entry?.loadingMore || entry?.error) return;
198
+ const cached = publicationLoadStateFromCache(activePublication);
199
+ if (cached) {
200
+ setPublicationFeeds((current) => ({
201
+ ...current,
202
+ [activePublicationTabId]: cached,
203
+ }));
204
+ return;
205
+ }
206
+ loadPublication(activePublication, false);
207
+ }, [activePublication, activePublicationTabId, loadPublication, publicationFeeds]);
208
+
209
+ const activeFeedState = activeFeedStateFromSources({
210
+ activePublication,
211
+ activePublicationTabId,
212
+ publicationFeeds,
213
+ home,
214
+ });
215
+
216
+ const loadMoreActivePublicationRows = useCallback(() => {
217
+ if (!activePublication || !activePublicationTabId || detailOpen) return;
218
+ const entry = publicationFeeds[activePublicationTabId];
219
+ const page = entry?.data;
220
+ if (!page || entry.loading || entry.loadingMore || entry.error || !page.hasMore || page.nextOffset == null) return;
221
+ const scrollBox = tableScrollRef.current;
222
+ if (!scrollBox?.viewport) return;
223
+ const visibleBottom = scrollBox.scrollTop + scrollBox.viewport.height;
224
+ const remainingRows = page.items.length - visibleBottom;
225
+ if (remainingRows > PUBLICATION_LOAD_MORE_THRESHOLD_ROWS) return;
226
+ loadPublication(activePublication, false, page.nextOffset);
227
+ }, [activePublication, activePublicationTabId, detailOpen, loadPublication, publicationFeeds]);
228
+
229
+ const sortedRows = useMemo(() => (
230
+ sortedSubstackArticles(activeFeedState.data ?? [], sort)
231
+ ), [activeFeedState.data, sort]);
232
+
233
+ useEffect(() => {
234
+ loadMoreActivePublicationRows();
235
+ }, [loadMoreActivePublicationRows, sortedRows.length]);
236
+
237
+ const selectedIndex = selectedArticleId
238
+ ? sortedRows.findIndex((article) => article.id === selectedArticleId)
239
+ : -1;
240
+ const selectedArticle = sortedRows[selectedIndex >= 0 ? selectedIndex : 0] ?? null;
241
+
242
+ useEffect(() => {
243
+ if (sortedRows.length === 0) {
244
+ if (!home.loading && !activeFeedState.loading && !activeFeedState.loadingMore) {
245
+ if (selectedArticleId !== null) setSelectedArticleId(null);
246
+ setDetailOpen(false);
247
+ }
248
+ return;
249
+ }
250
+ if (!selectedArticleId || selectedIndex < 0) {
251
+ setSelectedArticleId(sortedRows[0]!.id);
252
+ }
253
+ }, [
254
+ activeFeedState.loading,
255
+ activeFeedState.loadingMore,
256
+ home.loading,
257
+ selectedArticleId,
258
+ selectedIndex,
259
+ setDetailOpen,
260
+ setSelectedArticleId,
261
+ sortedRows,
262
+ ]);
263
+
264
+ const loadSelectedDetail = useCallback((article: SubstackArticleSummary, force = false) => {
265
+ detailFetchGenRef.current += 1;
266
+ const gen = detailFetchGenRef.current;
267
+ setDetails((current) => ({
268
+ ...current,
269
+ [article.id]: {
270
+ ...(current[article.id] ?? emptyLoadState<SubstackArticleDetail>()),
271
+ loading: true,
272
+ error: null,
273
+ },
274
+ }));
275
+ loadSubstackArticleDetail(article, force)
276
+ .then((entry) => {
277
+ if (detailFetchGenRef.current !== gen) return;
278
+ setDetails((current) => ({
279
+ ...current,
280
+ [article.id]: {
281
+ data: entry.data,
282
+ loading: false,
283
+ error: null,
284
+ fetchedAt: entry.fetchedAt,
285
+ stale: entry.stale,
286
+ },
287
+ }));
288
+ })
289
+ .catch((loadError) => {
290
+ if (detailFetchGenRef.current !== gen) return;
291
+ if (handleAuthFailure(loadError)) return;
292
+ setDetails((current) => ({
293
+ ...current,
294
+ [article.id]: {
295
+ ...(current[article.id] ?? emptyLoadState<SubstackArticleDetail>()),
296
+ loading: false,
297
+ error: errorMessage(loadError),
298
+ },
299
+ }));
300
+ });
301
+ }, [handleAuthFailure]);
302
+
303
+ useEffect(() => {
304
+ if (!detailOpen || !selectedArticle) return;
305
+ const existing = details[selectedArticle.id];
306
+ if (existing?.data || existing?.loading) return;
307
+ const cached = detailLoadStateFromCache(selectedArticle);
308
+ if (cached) {
309
+ setDetails((current) => ({
310
+ ...current,
311
+ [selectedArticle.id]: cached,
312
+ }));
313
+ if (!cached.stale) return;
314
+ }
315
+ loadSelectedDetail(selectedArticle, false);
316
+ }, [detailOpen, details, loadSelectedDetail, selectedArticle]);
317
+
318
+ useEffect(() => {
319
+ if (!detailOpen) return;
320
+ if (detailScrollRef.current) detailScrollRef.current.scrollTop = 0;
321
+ }, [detailOpen, selectedArticle?.id]);
322
+
323
+ const selectTab = useCallback((tabId: string) => {
324
+ setActiveTab(tabId);
325
+ setDetailOpen(false);
326
+ }, [setActiveTab, setDetailOpen]);
327
+
328
+ const refreshActive = useCallback(() => {
329
+ if (activePublication) {
330
+ loadPublication(activePublication, true);
331
+ return;
332
+ }
333
+ loadHome(true);
334
+ }, [activePublication, loadHome, loadPublication]);
335
+
336
+ const openSelectedArticle = useCallback(() => {
337
+ if (!selectedArticle?.url) return;
338
+ void rendererHost.openExternal(selectedArticle.url);
339
+ }, [rendererHost, selectedArticle]);
340
+
341
+ const handleLogin = useCallback((nextAuth: SubstackAuthState) => {
342
+ setAuth(nextAuth);
343
+ setActiveTab(SUBSTACK_FEED_TAB_ID);
344
+ }, [setActiveTab]);
345
+
346
+ const handleHeaderClick = useCallback((columnId: string) => {
347
+ if (!isSubstackSortColumnId(columnId)) return;
348
+ setSort((current) => nextSubstackSort(current, columnId));
349
+ }, []);
350
+
351
+ const scrollDetailBy = useCallback((delta: number) => {
352
+ const scrollBox = detailScrollRef.current;
353
+ if (!scrollBox?.viewport) return;
354
+ const maxScrollTop = Math.max(0, scrollBox.scrollHeight - scrollBox.viewport.height);
355
+ scrollBox.scrollTop = Math.max(0, Math.min(maxScrollTop, scrollBox.scrollTop + delta));
356
+ }, []);
357
+
358
+ const handleRootKeyDown = useCallback((event: DataTableKeyEvent) => {
359
+ if (isPlainKey(event, "r")) {
360
+ event.preventDefault?.();
361
+ event.stopPropagation?.();
362
+ refreshActive();
363
+ return true;
364
+ }
365
+ if (isPlainKey(event, "o")) {
366
+ event.preventDefault?.();
367
+ event.stopPropagation?.();
368
+ openSelectedArticle();
369
+ return true;
370
+ }
371
+ return false;
372
+ }, [openSelectedArticle, refreshActive]);
373
+
374
+ const handleDetailKeyDown = useCallback((event: DataTableKeyEvent) => {
375
+ if (isPlainKey(event, "j", "down")) {
376
+ event.preventDefault?.();
377
+ event.stopPropagation?.();
378
+ scrollDetailBy(1);
379
+ return true;
380
+ }
381
+ if (isPlainKey(event, "k", "up")) {
382
+ event.preventDefault?.();
383
+ event.stopPropagation?.();
384
+ scrollDetailBy(-1);
385
+ return true;
386
+ }
387
+ if (isPlainKey(event, "o")) {
388
+ event.preventDefault?.();
389
+ event.stopPropagation?.();
390
+ openSelectedArticle();
391
+ return true;
392
+ }
393
+ if (isPlainKey(event, "r") && selectedArticle) {
394
+ event.preventDefault?.();
395
+ event.stopPropagation?.();
396
+ loadSelectedDetail(selectedArticle, true);
397
+ return true;
398
+ }
399
+ return false;
400
+ }, [loadSelectedDetail, openSelectedArticle, scrollDetailBy, selectedArticle]);
401
+
402
+ const includePublication = !activePublication;
403
+ const columns = useMemo(() => buildSubstackColumns(width, includePublication), [includePublication, width]);
404
+ const activeDetail = selectedArticle ? details[selectedArticle.id] ?? emptyLoadState<SubstackArticleDetail>() : emptyLoadState<SubstackArticleDetail>();
405
+ useSubstackPaneFooter({
406
+ auth,
407
+ detailOpen,
408
+ activeFeedState,
409
+ activeDetail,
410
+ selectedArticle,
411
+ refreshActive,
412
+ openSelectedArticle,
413
+ });
414
+
415
+ if (!auth) {
416
+ return (
417
+ <SubstackLoginView
418
+ width={width}
419
+ height={height}
420
+ focused={focused}
421
+ onLogin={handleLogin}
422
+ />
423
+ );
424
+ }
425
+
426
+ const tabs = (
427
+ <SubstackFeedTabs
428
+ subscriptions={subscriptions}
429
+ activeTab={activeTab}
430
+ focused={focused}
431
+ detailOpen={detailOpen}
432
+ onSelect={selectTab}
433
+ />
434
+ );
435
+
436
+ if (home.loading && !home.data) {
437
+ return (
438
+ <Box flexDirection="column" width={width} height={height}>
439
+ {tabs}
440
+ <Box flexGrow={1} justifyContent="center" alignItems="center">
441
+ <Spinner label="Loading Substack..." />
442
+ </Box>
443
+ </Box>
444
+ );
445
+ }
446
+
447
+ if (home.error && !home.data) {
448
+ return (
449
+ <Box flexDirection="column" width={width} height={height}>
450
+ {tabs}
451
+ <Box padding={1}>
452
+ <EmptyState title="Substack unavailable." message={home.error} hint="Press r to retry." />
453
+ </Box>
454
+ </Box>
455
+ );
456
+ }
457
+
458
+ const detailContent = selectedArticle ? (
459
+ <ArticleDetail
460
+ article={selectedArticle}
461
+ detail={activeDetail.data}
462
+ width={width}
463
+ loading={activeDetail.loading}
464
+ error={activeDetail.error}
465
+ scrollRef={detailScrollRef}
466
+ onOpenArticle={openSelectedArticle}
467
+ />
468
+ ) : null;
469
+
470
+ return (
471
+ <Box flexDirection="column" width={width} height={height}>
472
+ {tabs}
473
+ <SubstackArticleStack
474
+ focused={focused}
475
+ detailOpen={detailOpen}
476
+ onBack={() => setDetailOpen(false)}
477
+ selectedArticle={selectedArticle}
478
+ detailContent={detailContent}
479
+ selectedArticleId={selectedArticleId}
480
+ onActivate={(article) => {
481
+ setSelectedArticleId(article.id, { immediate: true });
482
+ setDetailOpen(true);
483
+ }}
484
+ onSelectionChange={(id) => setSelectedArticleId(id)}
485
+ onRootKeyDown={handleRootKeyDown}
486
+ onDetailKeyDown={handleDetailKeyDown}
487
+ onBodyScrollActivity={loadMoreActivePublicationRows}
488
+ tableScrollRef={tableScrollRef}
489
+ width={width}
490
+ height={height}
491
+ columns={columns}
492
+ sortedRows={sortedRows}
493
+ activePublication={activePublication}
494
+ activeFeedState={activeFeedState}
495
+ sort={sort}
496
+ onHeaderClick={handleHeaderClick}
497
+ />
498
+ </Box>
499
+ );
500
+ }