gloomberb 0.3.7 → 0.4.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.
- package/README.md +19 -8
- package/package.json +1 -1
- package/src/app.tsx +45 -69
- package/src/cli/base-converter.ts +31 -0
- package/src/cli/commands/search.ts +11 -3
- package/src/cli/commands/ticker.ts +22 -11
- package/src/cli/context.ts +27 -32
- package/src/cli/helpers.ts +1 -4
- package/src/cli/index.ts +140 -123
- package/src/cli/registry.test.ts +226 -0
- package/src/cli/registry.ts +201 -0
- package/src/cli.test.ts +219 -0
- package/src/components/chart/chart-controller.ts +309 -0
- package/src/components/chart/chart-data.ts +5 -6
- package/src/components/chart/chart-pane-settings.ts +27 -0
- package/src/components/chart/chart-resolution.test.ts +56 -0
- package/src/components/chart/chart-resolution.ts +362 -0
- package/src/components/chart/chart-types.ts +12 -4
- package/src/components/chart/chart-viewport.test.ts +25 -0
- package/src/components/chart/chart-viewport.ts +28 -0
- package/src/components/chart/comparison-chart-data.ts +20 -27
- package/src/components/chart/comparison-stock-chart.tsx +374 -99
- package/src/components/chart/stock-chart-renderer-switch.test.tsx +124 -0
- package/src/components/chart/stock-chart.tsx +455 -106
- package/src/components/command-bar/__snapshots__/command-bar.test.tsx.snap +4 -4
- package/src/components/command-bar/command-bar.test.tsx +650 -5
- package/src/components/command-bar/command-bar.tsx +363 -63
- package/src/components/command-bar/command-registry.ts +8 -0
- package/src/components/command-bar/helpers.test.ts +23 -1
- package/src/components/command-bar/helpers.ts +14 -5
- package/src/components/command-bar/pane-template-display.ts +12 -0
- package/src/components/command-bar/root-shortcuts.ts +2 -1
- package/src/components/command-bar/workflow-ops.ts +4 -2
- package/src/components/detail-feed-view.test.tsx +124 -49
- package/src/components/detail-feed-view.tsx +396 -160
- package/src/components/layout/floating-pane.tsx +4 -2
- package/src/components/layout/header.tsx +3 -3
- package/src/components/layout/pane-header.tsx +26 -2
- package/src/components/layout/pane-sizing.ts +6 -0
- package/src/components/layout/pane.tsx +5 -1
- package/src/components/layout/shell.test.tsx +77 -5
- package/src/components/layout/shell.tsx +67 -52
- package/src/components/layout/status-bar.test.tsx +66 -0
- package/src/components/layout/status-bar.tsx +26 -5
- package/src/components/onboarding/onboarding-wizard.test.tsx +65 -0
- package/src/components/onboarding/onboarding-wizard.tsx +5 -6
- package/src/components/ticker-list-table.test.tsx +83 -0
- package/src/components/ticker-list-table.tsx +181 -97
- package/src/components/ui/data-table.tsx +50 -5
- package/src/components/ui/index.ts +1 -0
- package/src/components/ui/page-stack-view.tsx +60 -0
- package/src/components/use-double-click-activation.ts +31 -0
- package/src/index.tsx +10 -10
- package/src/market-data/coordinator.test.ts +92 -4
- package/src/market-data/coordinator.ts +32 -5
- package/src/market-data/hooks.test.tsx +120 -0
- package/src/market-data/hooks.tsx +53 -16
- package/src/market-data/request-types.ts +4 -2
- package/src/market-data/selectors.ts +24 -58
- package/src/plugins/builtin/chat-controller.test.ts +400 -3
- package/src/plugins/builtin/chat-controller.ts +328 -35
- package/src/plugins/builtin/chat.test.tsx +118 -6
- package/src/plugins/builtin/chat.tsx +180 -116
- package/src/plugins/builtin/comparison-chart.test.tsx +66 -5
- package/src/plugins/builtin/comparison-chart.tsx +21 -1
- package/src/plugins/builtin/layout-manager.tsx +9 -0
- package/src/plugins/builtin/news.tsx +1 -11
- package/src/plugins/builtin/portfolio-list/cli/commands.ts +656 -0
- package/src/plugins/builtin/portfolio-list/command-bar.ts +122 -0
- package/src/plugins/builtin/portfolio-list/header.tsx +16 -34
- package/src/plugins/builtin/portfolio-list/index.tsx +5 -0
- package/src/plugins/builtin/portfolio-list/metrics.test.ts +61 -0
- package/src/plugins/builtin/portfolio-list/metrics.ts +2 -2
- package/src/plugins/builtin/portfolio-list/mutations.test.ts +187 -0
- package/src/plugins/builtin/portfolio-list/mutations.ts +226 -0
- package/src/plugins/builtin/portfolio-list/pane.tsx +39 -17
- package/src/plugins/builtin/portfolio-list/table.tsx +11 -2
- package/src/plugins/builtin/portfolio-list/use-throttled-cursor-symbol.test.tsx +163 -0
- package/src/plugins/builtin/portfolio-list/use-throttled-cursor-symbol.ts +81 -0
- package/src/plugins/builtin/sec.tsx +1 -11
- package/src/plugins/builtin/ticker-detail/chart-tab.tsx +13 -2
- package/src/plugins/builtin/ticker-detail/financials-tab.tsx +21 -0
- package/src/plugins/builtin/ticker-detail/overview-tab.tsx +45 -10
- package/src/plugins/builtin/ticker-detail/pane.tsx +35 -17
- package/src/plugins/builtin/ticker-detail/settings.ts +19 -1
- package/src/plugins/builtin/ticker-detail.test.tsx +47 -0
- package/src/plugins/catalog.ts +65 -0
- package/src/plugins/ibkr/broker-adapter.ts +28 -0
- package/src/plugins/ibkr/gateway-service.test.ts +67 -1
- package/src/plugins/ibkr/gateway-service.ts +147 -7
- package/src/plugins/ibkr/index.tsx +7 -1
- package/src/plugins/pane-manager.test.ts +1 -1
- package/src/plugins/plugin-runtime.tsx +27 -7
- package/src/plugins/prediction-markets/cli.ts +28 -0
- package/src/plugins/prediction-markets/controller-data.ts +132 -17
- package/src/plugins/prediction-markets/controller.ts +142 -122
- package/src/plugins/prediction-markets/index.tsx +7 -1
- package/src/plugins/prediction-markets/launch.ts +51 -16
- package/src/plugins/prediction-markets/metrics.ts +9 -5
- package/src/plugins/prediction-markets/pane.test.tsx +197 -57
- package/src/plugins/prediction-markets/pane.tsx +80 -155
- package/src/plugins/prediction-markets/services/fetch.ts +4 -0
- package/src/plugins/prediction-markets/table.tsx +6 -3
- package/src/plugins/prediction-markets/test-helpers.tsx +20 -5
- package/src/plugins/prediction-markets/types.ts +0 -1
- package/src/plugins/registry.ts +23 -0
- package/src/sources/gloomberb-cloud.test.ts +29 -0
- package/src/sources/gloomberb-cloud.ts +89 -1
- package/src/sources/provider-router.test.ts +265 -3
- package/src/sources/provider-router.ts +239 -11
- package/src/sources/yahoo-finance.test.ts +26 -0
- package/src/sources/yahoo-finance.ts +54 -3
- package/src/state/app-context-selector.test.tsx +99 -0
- package/src/state/app-context.test.ts +18 -0
- package/src/state/app-context.tsx +170 -65
- package/src/state/selectors.ts +25 -0
- package/src/theme/colors.ts +8 -8
- package/src/theme/themes.test.ts +2 -0
- package/src/theme/themes.ts +22 -10
- package/src/types/broker.ts +21 -0
- package/src/types/config.ts +17 -1
- package/src/types/data-provider.ts +18 -0
- package/src/types/financials.ts +31 -1
- package/src/types/plugin.ts +70 -0
- package/src/updater.test.ts +2 -0
- package/src/utils/api-client.test.ts +57 -0
- package/src/utils/api-client.ts +47 -12
- package/src/utils/back-navigation.ts +27 -0
- package/src/utils/format.test.ts +14 -0
- package/src/utils/format.ts +3 -3
- package/src/utils/market-status.ts +46 -1
- package/src/utils/quote-freshness.ts +127 -0
- package/src/utils/quote-resolution.test.ts +277 -0
- package/src/utils/quote-resolution.ts +641 -0
- package/src/utils/slugify.ts +3 -0
- package/src/utils/timestamp.ts +25 -0
- package/src/version.ts +1 -1
- package/src/cli/commands/portfolio.ts +0 -210
- package/src/cli/commands/watchlist.ts +0 -210
- package/src/plugins/builtin/data-providers.ts +0 -16
- package/src/sources/exchange-rates.ts +0 -18
- package/src/utils/ascii-chart.ts +0 -172
- package/src/utils/ticker-tokenizer.test.ts +0 -48
|
@@ -4,6 +4,7 @@ import type { InstrumentRef, NewsRequest, OptionsRequest, SecFilingsRequest, Cha
|
|
|
4
4
|
import type { QueryEntry } from "./result-types";
|
|
5
5
|
import { hasLikelyQuoteUnitMismatch } from "../utils/currency-units";
|
|
6
6
|
import { normalizePriceHistory } from "../utils/price-history";
|
|
7
|
+
import { resolveTickerFinancialsQuoteState } from "../utils/quote-resolution";
|
|
7
8
|
|
|
8
9
|
export function buildInstrumentKey(instrument: InstrumentRef): string {
|
|
9
10
|
const contractKey = instrument.instrument?.conId
|
|
@@ -51,8 +52,9 @@ export function buildChartKey(request: ChartRequest): string {
|
|
|
51
52
|
return [
|
|
52
53
|
"chart",
|
|
53
54
|
buildInstrumentKey(request.instrument),
|
|
54
|
-
request.
|
|
55
|
-
request.granularity ?? "
|
|
55
|
+
request.bufferRange,
|
|
56
|
+
request.granularity ?? "range",
|
|
57
|
+
request.resolution ?? "",
|
|
56
58
|
request.startDate ? request.startDate.toISOString() : "",
|
|
57
59
|
request.endDate ? request.endDate.toISOString() : "",
|
|
58
60
|
request.barSize ?? "",
|
|
@@ -95,54 +97,6 @@ export function hasLikelyPriceUnitMismatch(
|
|
|
95
97
|
return hasLikelyQuoteUnitMismatch(left, right);
|
|
96
98
|
}
|
|
97
99
|
|
|
98
|
-
function mergeDefinedQuoteFields(
|
|
99
|
-
preferred: Quote | null | undefined,
|
|
100
|
-
fallback: Quote | null | undefined,
|
|
101
|
-
): Quote | undefined {
|
|
102
|
-
const mergedEntries: Array<[string, unknown]> = [];
|
|
103
|
-
for (const source of [fallback, preferred]) {
|
|
104
|
-
if (!source) continue;
|
|
105
|
-
for (const [key, value] of Object.entries(source)) {
|
|
106
|
-
if (value !== undefined) {
|
|
107
|
-
mergedEntries.push([key, value]);
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
if (mergedEntries.length === 0) return undefined;
|
|
112
|
-
return Object.fromEntries(mergedEntries) as Quote;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
function projectExtendedHoursQuote(
|
|
116
|
-
snapshotQuote: Quote | null | undefined,
|
|
117
|
-
liveQuote: Quote | null | undefined,
|
|
118
|
-
): Quote | undefined {
|
|
119
|
-
const merged = mergeDefinedQuoteFields(liveQuote, snapshotQuote);
|
|
120
|
-
if (!merged) return undefined;
|
|
121
|
-
if (!snapshotQuote || !liveQuote) return merged;
|
|
122
|
-
|
|
123
|
-
const snapshotState = snapshotQuote.marketState;
|
|
124
|
-
if (snapshotState !== "PRE" && snapshotState !== "POST") return merged;
|
|
125
|
-
if (liveQuote.marketState === "PRE" || liveQuote.marketState === "POST") return merged;
|
|
126
|
-
|
|
127
|
-
if (snapshotState === "PRE") {
|
|
128
|
-
return {
|
|
129
|
-
...merged,
|
|
130
|
-
marketState: "PRE",
|
|
131
|
-
preMarketPrice: liveQuote.price,
|
|
132
|
-
preMarketChange: liveQuote.change,
|
|
133
|
-
preMarketChangePercent: liveQuote.changePercent,
|
|
134
|
-
};
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
return {
|
|
138
|
-
...merged,
|
|
139
|
-
marketState: "POST",
|
|
140
|
-
postMarketPrice: liveQuote.price,
|
|
141
|
-
postMarketChange: liveQuote.change,
|
|
142
|
-
postMarketChangePercent: liveQuote.changePercent,
|
|
143
|
-
};
|
|
144
|
-
}
|
|
145
|
-
|
|
146
100
|
export function buildTickerFinancialsSnapshot(
|
|
147
101
|
snapshotEntry: QueryEntry<TickerFinancials>,
|
|
148
102
|
quoteEntry?: QueryEntry<TickerFinancials["quote"]>,
|
|
@@ -152,15 +106,27 @@ export function buildTickerFinancialsSnapshot(
|
|
|
152
106
|
const quote = resolveEntryData(quoteEntry);
|
|
153
107
|
const priceHistory = resolveEntryData(chartEntry);
|
|
154
108
|
if (!snapshot && !quote && !priceHistory) return null;
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
109
|
+
if (snapshot?.quote && quote && hasLikelyPriceUnitMismatch(snapshot.quote, quote)) {
|
|
110
|
+
return {
|
|
111
|
+
...(resolveTickerFinancialsQuoteState(snapshot) ?? {
|
|
112
|
+
annualStatements: snapshot?.annualStatements ?? [],
|
|
113
|
+
quarterlyStatements: snapshot?.quarterlyStatements ?? [],
|
|
114
|
+
priceHistory: snapshot?.priceHistory ?? [],
|
|
115
|
+
}),
|
|
116
|
+
priceHistory: normalizePriceHistory(priceHistory ?? snapshot?.priceHistory ?? []),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const resolved = resolveTickerFinancialsQuoteState(snapshot ?? {
|
|
121
|
+
annualStatements: [],
|
|
122
|
+
quarterlyStatements: [],
|
|
123
|
+
priceHistory: [],
|
|
124
|
+
}, quote);
|
|
125
|
+
|
|
126
|
+
if (!resolved) return null;
|
|
127
|
+
|
|
158
128
|
return {
|
|
159
|
-
|
|
160
|
-
fundamentals: snapshot?.fundamentals,
|
|
161
|
-
profile: snapshot?.profile,
|
|
162
|
-
annualStatements: snapshot?.annualStatements ?? [],
|
|
163
|
-
quarterlyStatements: snapshot?.quarterlyStatements ?? [],
|
|
129
|
+
...resolved,
|
|
164
130
|
priceHistory: normalizePriceHistory(priceHistory ?? snapshot?.priceHistory ?? []),
|
|
165
131
|
};
|
|
166
132
|
}
|
|
@@ -7,7 +7,15 @@ import { ChatController } from "./chat-controller";
|
|
|
7
7
|
const TRANSCRIPT_KIND = "channel-transcript";
|
|
8
8
|
const TRANSCRIPT_KEY = "everyone";
|
|
9
9
|
const TRANSCRIPT_SOURCE = "server";
|
|
10
|
+
const TRANSCRIPT_SCHEMA_VERSION = 2;
|
|
11
|
+
const originalConnectChannel = apiClient.connectChannel.bind(apiClient);
|
|
10
12
|
const originalGetSession = apiClient.getSession.bind(apiClient);
|
|
13
|
+
const originalGetMessages = apiClient.getMessages.bind(apiClient);
|
|
14
|
+
|
|
15
|
+
async function flushMicrotasks() {
|
|
16
|
+
await Promise.resolve();
|
|
17
|
+
await Promise.resolve();
|
|
18
|
+
}
|
|
11
19
|
|
|
12
20
|
class MemoryPersistence implements PluginPersistence {
|
|
13
21
|
private readonly state = new Map<string, { schemaVersion: number; value: unknown }>();
|
|
@@ -76,8 +84,11 @@ class MemoryPersistence implements PluginPersistence {
|
|
|
76
84
|
}
|
|
77
85
|
|
|
78
86
|
afterEach(() => {
|
|
87
|
+
apiClient.dispose();
|
|
79
88
|
apiClient.setSessionToken(null);
|
|
89
|
+
apiClient.connectChannel = originalConnectChannel;
|
|
80
90
|
apiClient.getSession = originalGetSession;
|
|
91
|
+
apiClient.getMessages = originalGetMessages;
|
|
81
92
|
});
|
|
82
93
|
|
|
83
94
|
describe("ChatController", () => {
|
|
@@ -101,12 +112,13 @@ describe("ChatController", () => {
|
|
|
101
112
|
draft: "cached draft",
|
|
102
113
|
replyToId: "m1",
|
|
103
114
|
lastCursor: "2026-03-28T00:00:00.000Z",
|
|
115
|
+
lastViewedMessageId: "m1",
|
|
104
116
|
}, { schemaVersion: 1 });
|
|
105
117
|
persistence.setResource(TRANSCRIPT_KIND, TRANSCRIPT_KEY, {
|
|
106
118
|
messages: [message],
|
|
107
119
|
}, {
|
|
108
120
|
sourceKey: TRANSCRIPT_SOURCE,
|
|
109
|
-
schemaVersion:
|
|
121
|
+
schemaVersion: TRANSCRIPT_SCHEMA_VERSION,
|
|
110
122
|
cachePolicy: { staleMs: 1_000, expireMs: 2_000 },
|
|
111
123
|
});
|
|
112
124
|
|
|
@@ -155,12 +167,13 @@ describe("ChatController", () => {
|
|
|
155
167
|
draft: "cached draft",
|
|
156
168
|
replyToId: null,
|
|
157
169
|
lastCursor: null,
|
|
170
|
+
lastViewedMessageId: null,
|
|
158
171
|
}, { schemaVersion: 1 });
|
|
159
172
|
persistence.setResource(TRANSCRIPT_KIND, TRANSCRIPT_KEY, {
|
|
160
173
|
messages: [],
|
|
161
174
|
}, {
|
|
162
175
|
sourceKey: TRANSCRIPT_SOURCE,
|
|
163
|
-
schemaVersion:
|
|
176
|
+
schemaVersion: TRANSCRIPT_SCHEMA_VERSION,
|
|
164
177
|
cachePolicy: { staleMs: 1_000, expireMs: 2_000 },
|
|
165
178
|
});
|
|
166
179
|
|
|
@@ -169,7 +182,10 @@ describe("ChatController", () => {
|
|
|
169
182
|
|
|
170
183
|
expect(apiClient.getSessionToken()).toBeNull();
|
|
171
184
|
expect(persistence.getState("session", { schemaVersion: 1 })).toBeNull();
|
|
172
|
-
expect(persistence.getResource(TRANSCRIPT_KIND, TRANSCRIPT_KEY, {
|
|
185
|
+
expect(persistence.getResource(TRANSCRIPT_KIND, TRANSCRIPT_KEY, {
|
|
186
|
+
sourceKey: TRANSCRIPT_SOURCE,
|
|
187
|
+
schemaVersion: TRANSCRIPT_SCHEMA_VERSION,
|
|
188
|
+
})).toBeNull();
|
|
173
189
|
});
|
|
174
190
|
|
|
175
191
|
test("pauses verification polling while the app is backgrounded", () => {
|
|
@@ -188,6 +204,35 @@ describe("ChatController", () => {
|
|
|
188
204
|
controller.reset(true);
|
|
189
205
|
});
|
|
190
206
|
|
|
207
|
+
test("dispose stops verification polling and closes the live connection", () => {
|
|
208
|
+
const controller = new ChatController();
|
|
209
|
+
let closed = false;
|
|
210
|
+
|
|
211
|
+
apiClient.setSessionToken("token-123");
|
|
212
|
+
(controller as any).user = { id: "u1", username: "vince", emailVerified: false };
|
|
213
|
+
(controller as any).syncVerificationPolling();
|
|
214
|
+
expect((controller as any).verificationPollTimer).not.toBeNull();
|
|
215
|
+
|
|
216
|
+
(controller as any).user = { id: "u1", username: "vince", emailVerified: true };
|
|
217
|
+
apiClient.connectChannel = () => ({
|
|
218
|
+
send: async () => {
|
|
219
|
+
throw new Error("not implemented");
|
|
220
|
+
},
|
|
221
|
+
close: () => {
|
|
222
|
+
closed = true;
|
|
223
|
+
},
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
controller.ensureConnection();
|
|
227
|
+
expect((controller as any).wsConnection).not.toBeNull();
|
|
228
|
+
|
|
229
|
+
controller.dispose();
|
|
230
|
+
|
|
231
|
+
expect(closed).toBe(true);
|
|
232
|
+
expect((controller as any).verificationPollTimer).toBeNull();
|
|
233
|
+
expect((controller as any).wsConnection).toBeNull();
|
|
234
|
+
});
|
|
235
|
+
|
|
191
236
|
test("keeps the cached session when session refresh fails transiently", async () => {
|
|
192
237
|
const persistence = new MemoryPersistence();
|
|
193
238
|
const controller = new ChatController();
|
|
@@ -214,4 +259,356 @@ describe("ChatController", () => {
|
|
|
214
259
|
emailVerified: true,
|
|
215
260
|
});
|
|
216
261
|
});
|
|
262
|
+
|
|
263
|
+
test("refreshes the public transcript without requiring a session", async () => {
|
|
264
|
+
const persistence = new MemoryPersistence();
|
|
265
|
+
const controller = new ChatController();
|
|
266
|
+
const message: ChatMessage = {
|
|
267
|
+
id: "m1",
|
|
268
|
+
channelId: "everyone",
|
|
269
|
+
content: "hello from the lobby",
|
|
270
|
+
replyToId: null,
|
|
271
|
+
createdAt: "2026-03-28T00:00:00.000Z",
|
|
272
|
+
user: { id: "u1", username: "vince", displayName: "Vince" },
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
controller.attachPersistence(persistence);
|
|
276
|
+
apiClient.getMessages = async () => [message];
|
|
277
|
+
|
|
278
|
+
await controller.refreshMessages();
|
|
279
|
+
|
|
280
|
+
expect(controller.getSnapshot().messages).toEqual([message]);
|
|
281
|
+
expect(persistence.getResource(TRANSCRIPT_KIND, TRANSCRIPT_KEY, {
|
|
282
|
+
sourceKey: TRANSCRIPT_SOURCE,
|
|
283
|
+
schemaVersion: TRANSCRIPT_SCHEMA_VERSION,
|
|
284
|
+
})?.value).toEqual({
|
|
285
|
+
messages: [message],
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
test("stores the latest message id as the incremental cursor", async () => {
|
|
290
|
+
const persistence = new MemoryPersistence();
|
|
291
|
+
const controller = new ChatController();
|
|
292
|
+
const initial: ChatMessage = {
|
|
293
|
+
id: "m1",
|
|
294
|
+
channelId: "everyone",
|
|
295
|
+
content: "hello",
|
|
296
|
+
replyToId: null,
|
|
297
|
+
createdAt: "2026-03-28T00:00:00.000Z",
|
|
298
|
+
user: { id: "u1", username: "vince", displayName: "Vince" },
|
|
299
|
+
};
|
|
300
|
+
const next: ChatMessage = {
|
|
301
|
+
id: "m2",
|
|
302
|
+
channelId: "everyone",
|
|
303
|
+
content: "new message",
|
|
304
|
+
replyToId: null,
|
|
305
|
+
createdAt: "2026-03-28T00:01:00.000Z",
|
|
306
|
+
user: { id: "u2", username: "bob", displayName: "Bob" },
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
persistence.setState("channel:everyone", {
|
|
310
|
+
draft: "",
|
|
311
|
+
replyToId: null,
|
|
312
|
+
lastCursor: "m1",
|
|
313
|
+
lastViewedMessageId: "m1",
|
|
314
|
+
}, { schemaVersion: 1 });
|
|
315
|
+
persistence.setResource(TRANSCRIPT_KIND, TRANSCRIPT_KEY, {
|
|
316
|
+
messages: [initial],
|
|
317
|
+
}, {
|
|
318
|
+
sourceKey: TRANSCRIPT_SOURCE,
|
|
319
|
+
schemaVersion: TRANSCRIPT_SCHEMA_VERSION,
|
|
320
|
+
cachePolicy: { staleMs: 1_000, expireMs: 2_000 },
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
controller.attachPersistence(persistence);
|
|
324
|
+
|
|
325
|
+
const calls: Array<{ channelId: string; opts?: { after?: string; before?: string; limit?: number } }> = [];
|
|
326
|
+
apiClient.getMessages = async (channelId, opts) => {
|
|
327
|
+
calls.push({ channelId, opts });
|
|
328
|
+
return opts?.after === "m1" ? [next] : [];
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
await controller.refreshMessages();
|
|
332
|
+
|
|
333
|
+
expect(calls).toEqual([{
|
|
334
|
+
channelId: "everyone",
|
|
335
|
+
opts: { limit: 50, after: "m1" },
|
|
336
|
+
}]);
|
|
337
|
+
expect(controller.getSnapshot().messages.map((entry) => entry.id)).toEqual(["m1", "m2"]);
|
|
338
|
+
expect(persistence.getState<{ lastCursor: string }>("channel:everyone", { schemaVersion: 1 })).toMatchObject({
|
|
339
|
+
lastCursor: "m2",
|
|
340
|
+
});
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
test("shows a pending message immediately and replaces it when the send succeeds", async () => {
|
|
344
|
+
const persistence = new MemoryPersistence();
|
|
345
|
+
const controller = new ChatController();
|
|
346
|
+
const replyTarget: ChatMessage = {
|
|
347
|
+
id: "m1",
|
|
348
|
+
channelId: "everyone",
|
|
349
|
+
content: "first",
|
|
350
|
+
replyToId: null,
|
|
351
|
+
createdAt: "2026-03-28T00:00:00.000Z",
|
|
352
|
+
user: { id: "u2", username: "bob", displayName: "Bob" },
|
|
353
|
+
};
|
|
354
|
+
const sentMessage: ChatMessage = {
|
|
355
|
+
id: "m2",
|
|
356
|
+
channelId: "everyone",
|
|
357
|
+
content: "hello",
|
|
358
|
+
replyToId: "m1",
|
|
359
|
+
createdAt: "2026-03-28T00:01:00.000Z",
|
|
360
|
+
user: { id: "u1", username: "vince", displayName: "Vince" },
|
|
361
|
+
replyTo: { content: "first", user: { username: "bob" } },
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
persistence.setState("session", {
|
|
365
|
+
sessionToken: "token-123",
|
|
366
|
+
user: { id: "u1", username: "vince", emailVerified: true },
|
|
367
|
+
}, { schemaVersion: 1 });
|
|
368
|
+
persistence.setState("channel:everyone", {
|
|
369
|
+
draft: "hello",
|
|
370
|
+
replyToId: "m1",
|
|
371
|
+
lastCursor: "m1",
|
|
372
|
+
lastViewedMessageId: "m1",
|
|
373
|
+
}, { schemaVersion: 1 });
|
|
374
|
+
persistence.setResource(TRANSCRIPT_KIND, TRANSCRIPT_KEY, {
|
|
375
|
+
messages: [replyTarget],
|
|
376
|
+
}, {
|
|
377
|
+
sourceKey: TRANSCRIPT_SOURCE,
|
|
378
|
+
schemaVersion: TRANSCRIPT_SCHEMA_VERSION,
|
|
379
|
+
cachePolicy: { staleMs: 1_000, expireMs: 2_000 },
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
controller.attachPersistence(persistence);
|
|
383
|
+
|
|
384
|
+
let resolveSend: ((message: ChatMessage) => void) | null = null;
|
|
385
|
+
apiClient.connectChannel = () => ({
|
|
386
|
+
send: () => new Promise<ChatMessage>((resolve) => {
|
|
387
|
+
resolveSend = resolve;
|
|
388
|
+
}),
|
|
389
|
+
close: () => {},
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
controller.send("hello", "m1");
|
|
393
|
+
|
|
394
|
+
let snapshot = controller.getSnapshot();
|
|
395
|
+
expect(snapshot.draft).toBe("");
|
|
396
|
+
expect(snapshot.replyToId).toBeNull();
|
|
397
|
+
expect(snapshot.messages.map((message) => message.id)).toEqual(["m1", snapshot.messages[1]!.id]);
|
|
398
|
+
expect(snapshot.messages[1]).toMatchObject({
|
|
399
|
+
content: "hello",
|
|
400
|
+
replyToId: "m1",
|
|
401
|
+
clientStatus: "sending",
|
|
402
|
+
replyTo: { content: "first", user: { username: "bob" } },
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
resolveSend?.(sentMessage);
|
|
406
|
+
await flushMicrotasks();
|
|
407
|
+
|
|
408
|
+
snapshot = controller.getSnapshot();
|
|
409
|
+
expect(snapshot.messages).toEqual([replyTarget, sentMessage]);
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
test("marks a pending message as failed when sending errors", async () => {
|
|
413
|
+
const persistence = new MemoryPersistence();
|
|
414
|
+
const controller = new ChatController();
|
|
415
|
+
const toasts: string[] = [];
|
|
416
|
+
|
|
417
|
+
persistence.setState("session", {
|
|
418
|
+
sessionToken: "token-123",
|
|
419
|
+
user: { id: "u1", username: "vince", emailVerified: true },
|
|
420
|
+
}, { schemaVersion: 1 });
|
|
421
|
+
|
|
422
|
+
controller.setToastNotifier((message) => {
|
|
423
|
+
toasts.push(message);
|
|
424
|
+
});
|
|
425
|
+
controller.attachPersistence(persistence);
|
|
426
|
+
|
|
427
|
+
apiClient.connectChannel = () => ({
|
|
428
|
+
send: async () => {
|
|
429
|
+
throw new Error("server offline");
|
|
430
|
+
},
|
|
431
|
+
close: () => {},
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
controller.send("hello");
|
|
435
|
+
await flushMicrotasks();
|
|
436
|
+
|
|
437
|
+
const snapshot = controller.getSnapshot();
|
|
438
|
+
expect(snapshot.messages).toHaveLength(1);
|
|
439
|
+
expect(snapshot.messages[0]).toMatchObject({
|
|
440
|
+
content: "hello",
|
|
441
|
+
clientStatus: "failed",
|
|
442
|
+
clientError: "server offline",
|
|
443
|
+
});
|
|
444
|
+
expect(toasts).toEqual(["server offline"]);
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
test("tracks unread mentions and shows a toast while chat is closed", () => {
|
|
448
|
+
const persistence = new MemoryPersistence();
|
|
449
|
+
const controller = new ChatController();
|
|
450
|
+
const toasts: string[] = [];
|
|
451
|
+
const message: ChatMessage = {
|
|
452
|
+
id: "m1",
|
|
453
|
+
channelId: "everyone",
|
|
454
|
+
content: "hey @Vince can you take a look?",
|
|
455
|
+
replyToId: null,
|
|
456
|
+
createdAt: "2026-03-28T00:00:00.000Z",
|
|
457
|
+
user: { id: "u2", username: "bob", displayName: "Bob" },
|
|
458
|
+
};
|
|
459
|
+
|
|
460
|
+
persistence.setState("session", {
|
|
461
|
+
sessionToken: "token-123",
|
|
462
|
+
user: { id: "u1", username: "vince", emailVerified: true },
|
|
463
|
+
}, { schemaVersion: 1 });
|
|
464
|
+
|
|
465
|
+
controller.setToastNotifier((toast) => {
|
|
466
|
+
toasts.push(toast);
|
|
467
|
+
});
|
|
468
|
+
controller.attachPersistence(persistence);
|
|
469
|
+
|
|
470
|
+
(controller as any).mergeMessages([message]);
|
|
471
|
+
|
|
472
|
+
expect(controller.getSnapshot().unreadMentionCount).toBe(1);
|
|
473
|
+
expect(toasts).toEqual(["@bob mentioned you: hey @Vince can you take a look?"]);
|
|
474
|
+
expect(persistence.getState<{ lastCursor: string | null; lastViewedMessageId: string | null }>("channel:everyone", { schemaVersion: 1 })).toMatchObject({
|
|
475
|
+
lastCursor: "m1",
|
|
476
|
+
lastViewedMessageId: null,
|
|
477
|
+
});
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
test("marks mentions viewed when a chat view opens", () => {
|
|
481
|
+
const persistence = new MemoryPersistence();
|
|
482
|
+
const controller = new ChatController();
|
|
483
|
+
const toasts: string[] = [];
|
|
484
|
+
const message: ChatMessage = {
|
|
485
|
+
id: "m1",
|
|
486
|
+
channelId: "everyone",
|
|
487
|
+
content: "hey @vince",
|
|
488
|
+
replyToId: null,
|
|
489
|
+
createdAt: "2026-03-28T00:00:00.000Z",
|
|
490
|
+
user: { id: "u2", username: "bob", displayName: "Bob" },
|
|
491
|
+
};
|
|
492
|
+
|
|
493
|
+
persistence.setState("session", {
|
|
494
|
+
sessionToken: "token-123",
|
|
495
|
+
user: { id: "u1", username: "vince", emailVerified: true },
|
|
496
|
+
}, { schemaVersion: 1 });
|
|
497
|
+
|
|
498
|
+
controller.setToastNotifier((toast) => {
|
|
499
|
+
toasts.push(toast);
|
|
500
|
+
});
|
|
501
|
+
controller.attachPersistence(persistence);
|
|
502
|
+
|
|
503
|
+
(controller as any).mergeMessages([message]);
|
|
504
|
+
|
|
505
|
+
expect(controller.getSnapshot().unreadMentionCount).toBe(1);
|
|
506
|
+
|
|
507
|
+
const detachView = controller.attachView();
|
|
508
|
+
|
|
509
|
+
expect(controller.getSnapshot().unreadMentionCount).toBe(0);
|
|
510
|
+
expect(toasts).toEqual(["@bob mentioned you: hey @vince"]);
|
|
511
|
+
expect(persistence.getState<{ lastViewedMessageId: string | null }>("channel:everyone", { schemaVersion: 1 })).toMatchObject({
|
|
512
|
+
lastViewedMessageId: "m1",
|
|
513
|
+
});
|
|
514
|
+
detachView();
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
test("does not keep mentions unread while a chat view is already open", () => {
|
|
518
|
+
const persistence = new MemoryPersistence();
|
|
519
|
+
const controller = new ChatController();
|
|
520
|
+
const toasts: string[] = [];
|
|
521
|
+
const message: ChatMessage = {
|
|
522
|
+
id: "m1",
|
|
523
|
+
channelId: "everyone",
|
|
524
|
+
content: "hey @vince",
|
|
525
|
+
replyToId: null,
|
|
526
|
+
createdAt: "2026-03-28T00:00:00.000Z",
|
|
527
|
+
user: { id: "u2", username: "bob", displayName: "Bob" },
|
|
528
|
+
};
|
|
529
|
+
|
|
530
|
+
persistence.setState("session", {
|
|
531
|
+
sessionToken: "token-123",
|
|
532
|
+
user: { id: "u1", username: "vince", emailVerified: true },
|
|
533
|
+
}, { schemaVersion: 1 });
|
|
534
|
+
|
|
535
|
+
controller.setToastNotifier((toast) => {
|
|
536
|
+
toasts.push(toast);
|
|
537
|
+
});
|
|
538
|
+
controller.attachPersistence(persistence);
|
|
539
|
+
const detachView = controller.attachView();
|
|
540
|
+
|
|
541
|
+
(controller as any).mergeMessages([message]);
|
|
542
|
+
|
|
543
|
+
expect(controller.getSnapshot().unreadMentionCount).toBe(0);
|
|
544
|
+
expect(toasts).toEqual([]);
|
|
545
|
+
expect(persistence.getState<{ lastViewedMessageId: string | null }>("channel:everyone", { schemaVersion: 1 })).toMatchObject({
|
|
546
|
+
lastViewedMessageId: "m1",
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
detachView();
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
test("recovers from a legacy timestamp cursor by falling back to a full transcript fetch", async () => {
|
|
553
|
+
const persistence = new MemoryPersistence();
|
|
554
|
+
const controller = new ChatController();
|
|
555
|
+
const cached: ChatMessage = {
|
|
556
|
+
id: "m1",
|
|
557
|
+
channelId: "everyone",
|
|
558
|
+
content: "cached",
|
|
559
|
+
replyToId: null,
|
|
560
|
+
createdAt: "2026-03-28T00:00:00.000Z",
|
|
561
|
+
user: { id: "u1", username: "vince", displayName: "Vince" },
|
|
562
|
+
};
|
|
563
|
+
const fullTranscript: ChatMessage[] = [
|
|
564
|
+
cached,
|
|
565
|
+
{
|
|
566
|
+
id: "m2",
|
|
567
|
+
channelId: "everyone",
|
|
568
|
+
content: "fresh",
|
|
569
|
+
replyToId: null,
|
|
570
|
+
createdAt: "2026-03-28T00:01:00.000Z",
|
|
571
|
+
user: { id: "u2", username: "bob", displayName: "Bob" },
|
|
572
|
+
},
|
|
573
|
+
];
|
|
574
|
+
|
|
575
|
+
persistence.setState("channel:everyone", {
|
|
576
|
+
draft: "",
|
|
577
|
+
replyToId: null,
|
|
578
|
+
lastCursor: "2026-03-28T00:00:00.000Z",
|
|
579
|
+
lastViewedMessageId: "m1",
|
|
580
|
+
}, { schemaVersion: 1 });
|
|
581
|
+
persistence.setResource(TRANSCRIPT_KIND, TRANSCRIPT_KEY, {
|
|
582
|
+
messages: [cached],
|
|
583
|
+
}, {
|
|
584
|
+
sourceKey: TRANSCRIPT_SOURCE,
|
|
585
|
+
schemaVersion: TRANSCRIPT_SCHEMA_VERSION,
|
|
586
|
+
cachePolicy: { staleMs: 1_000, expireMs: 2_000 },
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
controller.attachPersistence(persistence);
|
|
590
|
+
|
|
591
|
+
const calls: Array<{ channelId: string; opts?: { after?: string; before?: string; limit?: number } }> = [];
|
|
592
|
+
apiClient.getMessages = async (channelId, opts) => {
|
|
593
|
+
calls.push({ channelId, opts });
|
|
594
|
+
return opts?.after ? [] : fullTranscript;
|
|
595
|
+
};
|
|
596
|
+
|
|
597
|
+
await controller.refreshMessages();
|
|
598
|
+
|
|
599
|
+
expect(calls).toEqual([
|
|
600
|
+
{
|
|
601
|
+
channelId: "everyone",
|
|
602
|
+
opts: { limit: 50, after: "2026-03-28T00:00:00.000Z" },
|
|
603
|
+
},
|
|
604
|
+
{
|
|
605
|
+
channelId: "everyone",
|
|
606
|
+
opts: { limit: 50 },
|
|
607
|
+
},
|
|
608
|
+
]);
|
|
609
|
+
expect(controller.getSnapshot().messages.map((entry) => entry.id)).toEqual(["m1", "m2"]);
|
|
610
|
+
expect(persistence.getState<{ lastCursor: string }>("channel:everyone", { schemaVersion: 1 })).toMatchObject({
|
|
611
|
+
lastCursor: "m2",
|
|
612
|
+
});
|
|
613
|
+
});
|
|
217
614
|
});
|