gloomberb 0.3.7 → 0.4.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 +19 -8
- package/package.json +1 -1
- package/src/app.tsx +29 -65
- 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 +359 -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 +1 -1
- package/src/components/layout/pane-header.tsx +26 -2
- package/src/components/layout/shell.tsx +62 -50
- 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 +174 -97
- package/src/components/ui/data-table.tsx +27 -2
- 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 +370 -3
- package/src/plugins/builtin/chat-controller.ts +310 -35
- package/src/plugins/builtin/chat.test.tsx +118 -6
- package/src/plugins/builtin/chat.tsx +175 -116
- package/src/plugins/builtin/comparison-chart.test.tsx +66 -5
- package/src/plugins/builtin/comparison-chart.tsx +21 -1
- 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 +45 -1
- package/src/plugins/ibkr/gateway-service.ts +141 -7
- 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 +11 -3
- package/src/plugins/prediction-markets/controller.ts +141 -121
- package/src/plugins/prediction-markets/index.tsx +2 -0
- 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 +119 -57
- package/src/plugins/prediction-markets/pane.tsx +74 -153
- package/src/plugins/prediction-markets/table.tsx +6 -3
- package/src/plugins/prediction-markets/types.ts +0 -1
- 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/utils/api-client.test.ts +57 -0
- package/src/utils/api-client.ts +35 -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
|
@@ -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 }>();
|
|
@@ -77,7 +85,9 @@ class MemoryPersistence implements PluginPersistence {
|
|
|
77
85
|
|
|
78
86
|
afterEach(() => {
|
|
79
87
|
apiClient.setSessionToken(null);
|
|
88
|
+
apiClient.connectChannel = originalConnectChannel;
|
|
80
89
|
apiClient.getSession = originalGetSession;
|
|
90
|
+
apiClient.getMessages = originalGetMessages;
|
|
81
91
|
});
|
|
82
92
|
|
|
83
93
|
describe("ChatController", () => {
|
|
@@ -101,12 +111,13 @@ describe("ChatController", () => {
|
|
|
101
111
|
draft: "cached draft",
|
|
102
112
|
replyToId: "m1",
|
|
103
113
|
lastCursor: "2026-03-28T00:00:00.000Z",
|
|
114
|
+
lastViewedMessageId: "m1",
|
|
104
115
|
}, { schemaVersion: 1 });
|
|
105
116
|
persistence.setResource(TRANSCRIPT_KIND, TRANSCRIPT_KEY, {
|
|
106
117
|
messages: [message],
|
|
107
118
|
}, {
|
|
108
119
|
sourceKey: TRANSCRIPT_SOURCE,
|
|
109
|
-
schemaVersion:
|
|
120
|
+
schemaVersion: TRANSCRIPT_SCHEMA_VERSION,
|
|
110
121
|
cachePolicy: { staleMs: 1_000, expireMs: 2_000 },
|
|
111
122
|
});
|
|
112
123
|
|
|
@@ -155,12 +166,13 @@ describe("ChatController", () => {
|
|
|
155
166
|
draft: "cached draft",
|
|
156
167
|
replyToId: null,
|
|
157
168
|
lastCursor: null,
|
|
169
|
+
lastViewedMessageId: null,
|
|
158
170
|
}, { schemaVersion: 1 });
|
|
159
171
|
persistence.setResource(TRANSCRIPT_KIND, TRANSCRIPT_KEY, {
|
|
160
172
|
messages: [],
|
|
161
173
|
}, {
|
|
162
174
|
sourceKey: TRANSCRIPT_SOURCE,
|
|
163
|
-
schemaVersion:
|
|
175
|
+
schemaVersion: TRANSCRIPT_SCHEMA_VERSION,
|
|
164
176
|
cachePolicy: { staleMs: 1_000, expireMs: 2_000 },
|
|
165
177
|
});
|
|
166
178
|
|
|
@@ -169,7 +181,10 @@ describe("ChatController", () => {
|
|
|
169
181
|
|
|
170
182
|
expect(apiClient.getSessionToken()).toBeNull();
|
|
171
183
|
expect(persistence.getState("session", { schemaVersion: 1 })).toBeNull();
|
|
172
|
-
expect(persistence.getResource(TRANSCRIPT_KIND, TRANSCRIPT_KEY, {
|
|
184
|
+
expect(persistence.getResource(TRANSCRIPT_KIND, TRANSCRIPT_KEY, {
|
|
185
|
+
sourceKey: TRANSCRIPT_SOURCE,
|
|
186
|
+
schemaVersion: TRANSCRIPT_SCHEMA_VERSION,
|
|
187
|
+
})).toBeNull();
|
|
173
188
|
});
|
|
174
189
|
|
|
175
190
|
test("pauses verification polling while the app is backgrounded", () => {
|
|
@@ -214,4 +229,356 @@ describe("ChatController", () => {
|
|
|
214
229
|
emailVerified: true,
|
|
215
230
|
});
|
|
216
231
|
});
|
|
232
|
+
|
|
233
|
+
test("refreshes the public transcript without requiring a session", async () => {
|
|
234
|
+
const persistence = new MemoryPersistence();
|
|
235
|
+
const controller = new ChatController();
|
|
236
|
+
const message: ChatMessage = {
|
|
237
|
+
id: "m1",
|
|
238
|
+
channelId: "everyone",
|
|
239
|
+
content: "hello from the lobby",
|
|
240
|
+
replyToId: null,
|
|
241
|
+
createdAt: "2026-03-28T00:00:00.000Z",
|
|
242
|
+
user: { id: "u1", username: "vince", displayName: "Vince" },
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
controller.attachPersistence(persistence);
|
|
246
|
+
apiClient.getMessages = async () => [message];
|
|
247
|
+
|
|
248
|
+
await controller.refreshMessages();
|
|
249
|
+
|
|
250
|
+
expect(controller.getSnapshot().messages).toEqual([message]);
|
|
251
|
+
expect(persistence.getResource(TRANSCRIPT_KIND, TRANSCRIPT_KEY, {
|
|
252
|
+
sourceKey: TRANSCRIPT_SOURCE,
|
|
253
|
+
schemaVersion: TRANSCRIPT_SCHEMA_VERSION,
|
|
254
|
+
})?.value).toEqual({
|
|
255
|
+
messages: [message],
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
test("stores the latest message id as the incremental cursor", async () => {
|
|
260
|
+
const persistence = new MemoryPersistence();
|
|
261
|
+
const controller = new ChatController();
|
|
262
|
+
const initial: ChatMessage = {
|
|
263
|
+
id: "m1",
|
|
264
|
+
channelId: "everyone",
|
|
265
|
+
content: "hello",
|
|
266
|
+
replyToId: null,
|
|
267
|
+
createdAt: "2026-03-28T00:00:00.000Z",
|
|
268
|
+
user: { id: "u1", username: "vince", displayName: "Vince" },
|
|
269
|
+
};
|
|
270
|
+
const next: ChatMessage = {
|
|
271
|
+
id: "m2",
|
|
272
|
+
channelId: "everyone",
|
|
273
|
+
content: "new message",
|
|
274
|
+
replyToId: null,
|
|
275
|
+
createdAt: "2026-03-28T00:01:00.000Z",
|
|
276
|
+
user: { id: "u2", username: "bob", displayName: "Bob" },
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
persistence.setState("channel:everyone", {
|
|
280
|
+
draft: "",
|
|
281
|
+
replyToId: null,
|
|
282
|
+
lastCursor: "m1",
|
|
283
|
+
lastViewedMessageId: "m1",
|
|
284
|
+
}, { schemaVersion: 1 });
|
|
285
|
+
persistence.setResource(TRANSCRIPT_KIND, TRANSCRIPT_KEY, {
|
|
286
|
+
messages: [initial],
|
|
287
|
+
}, {
|
|
288
|
+
sourceKey: TRANSCRIPT_SOURCE,
|
|
289
|
+
schemaVersion: TRANSCRIPT_SCHEMA_VERSION,
|
|
290
|
+
cachePolicy: { staleMs: 1_000, expireMs: 2_000 },
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
controller.attachPersistence(persistence);
|
|
294
|
+
|
|
295
|
+
const calls: Array<{ channelId: string; opts?: { after?: string; before?: string; limit?: number } }> = [];
|
|
296
|
+
apiClient.getMessages = async (channelId, opts) => {
|
|
297
|
+
calls.push({ channelId, opts });
|
|
298
|
+
return opts?.after === "m1" ? [next] : [];
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
await controller.refreshMessages();
|
|
302
|
+
|
|
303
|
+
expect(calls).toEqual([{
|
|
304
|
+
channelId: "everyone",
|
|
305
|
+
opts: { limit: 50, after: "m1" },
|
|
306
|
+
}]);
|
|
307
|
+
expect(controller.getSnapshot().messages.map((entry) => entry.id)).toEqual(["m1", "m2"]);
|
|
308
|
+
expect(persistence.getState<{ lastCursor: string }>("channel:everyone", { schemaVersion: 1 })).toMatchObject({
|
|
309
|
+
lastCursor: "m2",
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
test("shows a pending message immediately and replaces it when the send succeeds", async () => {
|
|
314
|
+
const persistence = new MemoryPersistence();
|
|
315
|
+
const controller = new ChatController();
|
|
316
|
+
const replyTarget: ChatMessage = {
|
|
317
|
+
id: "m1",
|
|
318
|
+
channelId: "everyone",
|
|
319
|
+
content: "first",
|
|
320
|
+
replyToId: null,
|
|
321
|
+
createdAt: "2026-03-28T00:00:00.000Z",
|
|
322
|
+
user: { id: "u2", username: "bob", displayName: "Bob" },
|
|
323
|
+
};
|
|
324
|
+
const sentMessage: ChatMessage = {
|
|
325
|
+
id: "m2",
|
|
326
|
+
channelId: "everyone",
|
|
327
|
+
content: "hello",
|
|
328
|
+
replyToId: "m1",
|
|
329
|
+
createdAt: "2026-03-28T00:01:00.000Z",
|
|
330
|
+
user: { id: "u1", username: "vince", displayName: "Vince" },
|
|
331
|
+
replyTo: { content: "first", user: { username: "bob" } },
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
persistence.setState("session", {
|
|
335
|
+
sessionToken: "token-123",
|
|
336
|
+
user: { id: "u1", username: "vince", emailVerified: true },
|
|
337
|
+
}, { schemaVersion: 1 });
|
|
338
|
+
persistence.setState("channel:everyone", {
|
|
339
|
+
draft: "hello",
|
|
340
|
+
replyToId: "m1",
|
|
341
|
+
lastCursor: "m1",
|
|
342
|
+
lastViewedMessageId: "m1",
|
|
343
|
+
}, { schemaVersion: 1 });
|
|
344
|
+
persistence.setResource(TRANSCRIPT_KIND, TRANSCRIPT_KEY, {
|
|
345
|
+
messages: [replyTarget],
|
|
346
|
+
}, {
|
|
347
|
+
sourceKey: TRANSCRIPT_SOURCE,
|
|
348
|
+
schemaVersion: TRANSCRIPT_SCHEMA_VERSION,
|
|
349
|
+
cachePolicy: { staleMs: 1_000, expireMs: 2_000 },
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
controller.attachPersistence(persistence);
|
|
353
|
+
|
|
354
|
+
let resolveSend: ((message: ChatMessage) => void) | null = null;
|
|
355
|
+
apiClient.connectChannel = () => ({
|
|
356
|
+
send: () => new Promise<ChatMessage>((resolve) => {
|
|
357
|
+
resolveSend = resolve;
|
|
358
|
+
}),
|
|
359
|
+
close: () => {},
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
controller.send("hello", "m1");
|
|
363
|
+
|
|
364
|
+
let snapshot = controller.getSnapshot();
|
|
365
|
+
expect(snapshot.draft).toBe("");
|
|
366
|
+
expect(snapshot.replyToId).toBeNull();
|
|
367
|
+
expect(snapshot.messages.map((message) => message.id)).toEqual(["m1", snapshot.messages[1]!.id]);
|
|
368
|
+
expect(snapshot.messages[1]).toMatchObject({
|
|
369
|
+
content: "hello",
|
|
370
|
+
replyToId: "m1",
|
|
371
|
+
clientStatus: "sending",
|
|
372
|
+
replyTo: { content: "first", user: { username: "bob" } },
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
resolveSend?.(sentMessage);
|
|
376
|
+
await flushMicrotasks();
|
|
377
|
+
|
|
378
|
+
snapshot = controller.getSnapshot();
|
|
379
|
+
expect(snapshot.messages).toEqual([replyTarget, sentMessage]);
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
test("marks a pending message as failed when sending errors", async () => {
|
|
383
|
+
const persistence = new MemoryPersistence();
|
|
384
|
+
const controller = new ChatController();
|
|
385
|
+
const toasts: string[] = [];
|
|
386
|
+
|
|
387
|
+
persistence.setState("session", {
|
|
388
|
+
sessionToken: "token-123",
|
|
389
|
+
user: { id: "u1", username: "vince", emailVerified: true },
|
|
390
|
+
}, { schemaVersion: 1 });
|
|
391
|
+
|
|
392
|
+
controller.setToastNotifier((message) => {
|
|
393
|
+
toasts.push(message);
|
|
394
|
+
});
|
|
395
|
+
controller.attachPersistence(persistence);
|
|
396
|
+
|
|
397
|
+
apiClient.connectChannel = () => ({
|
|
398
|
+
send: async () => {
|
|
399
|
+
throw new Error("server offline");
|
|
400
|
+
},
|
|
401
|
+
close: () => {},
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
controller.send("hello");
|
|
405
|
+
await flushMicrotasks();
|
|
406
|
+
|
|
407
|
+
const snapshot = controller.getSnapshot();
|
|
408
|
+
expect(snapshot.messages).toHaveLength(1);
|
|
409
|
+
expect(snapshot.messages[0]).toMatchObject({
|
|
410
|
+
content: "hello",
|
|
411
|
+
clientStatus: "failed",
|
|
412
|
+
clientError: "server offline",
|
|
413
|
+
});
|
|
414
|
+
expect(toasts).toEqual(["server offline"]);
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
test("tracks unread mentions and shows a toast while chat is closed", () => {
|
|
418
|
+
const persistence = new MemoryPersistence();
|
|
419
|
+
const controller = new ChatController();
|
|
420
|
+
const toasts: string[] = [];
|
|
421
|
+
const message: ChatMessage = {
|
|
422
|
+
id: "m1",
|
|
423
|
+
channelId: "everyone",
|
|
424
|
+
content: "hey @Vince can you take a look?",
|
|
425
|
+
replyToId: null,
|
|
426
|
+
createdAt: "2026-03-28T00:00:00.000Z",
|
|
427
|
+
user: { id: "u2", username: "bob", displayName: "Bob" },
|
|
428
|
+
};
|
|
429
|
+
|
|
430
|
+
persistence.setState("session", {
|
|
431
|
+
sessionToken: "token-123",
|
|
432
|
+
user: { id: "u1", username: "vince", emailVerified: true },
|
|
433
|
+
}, { schemaVersion: 1 });
|
|
434
|
+
|
|
435
|
+
controller.setToastNotifier((toast) => {
|
|
436
|
+
toasts.push(toast);
|
|
437
|
+
});
|
|
438
|
+
controller.attachPersistence(persistence);
|
|
439
|
+
|
|
440
|
+
(controller as any).mergeMessages([message]);
|
|
441
|
+
|
|
442
|
+
expect(controller.getSnapshot().unreadMentionCount).toBe(1);
|
|
443
|
+
expect(toasts).toEqual(["@bob mentioned you: hey @Vince can you take a look?"]);
|
|
444
|
+
expect(persistence.getState<{ lastCursor: string | null; lastViewedMessageId: string | null }>("channel:everyone", { schemaVersion: 1 })).toMatchObject({
|
|
445
|
+
lastCursor: "m1",
|
|
446
|
+
lastViewedMessageId: null,
|
|
447
|
+
});
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
test("marks mentions viewed when a chat view opens", () => {
|
|
451
|
+
const persistence = new MemoryPersistence();
|
|
452
|
+
const controller = new ChatController();
|
|
453
|
+
const toasts: string[] = [];
|
|
454
|
+
const message: ChatMessage = {
|
|
455
|
+
id: "m1",
|
|
456
|
+
channelId: "everyone",
|
|
457
|
+
content: "hey @vince",
|
|
458
|
+
replyToId: null,
|
|
459
|
+
createdAt: "2026-03-28T00:00:00.000Z",
|
|
460
|
+
user: { id: "u2", username: "bob", displayName: "Bob" },
|
|
461
|
+
};
|
|
462
|
+
|
|
463
|
+
persistence.setState("session", {
|
|
464
|
+
sessionToken: "token-123",
|
|
465
|
+
user: { id: "u1", username: "vince", emailVerified: true },
|
|
466
|
+
}, { schemaVersion: 1 });
|
|
467
|
+
|
|
468
|
+
controller.setToastNotifier((toast) => {
|
|
469
|
+
toasts.push(toast);
|
|
470
|
+
});
|
|
471
|
+
controller.attachPersistence(persistence);
|
|
472
|
+
|
|
473
|
+
(controller as any).mergeMessages([message]);
|
|
474
|
+
|
|
475
|
+
expect(controller.getSnapshot().unreadMentionCount).toBe(1);
|
|
476
|
+
|
|
477
|
+
const detachView = controller.attachView();
|
|
478
|
+
|
|
479
|
+
expect(controller.getSnapshot().unreadMentionCount).toBe(0);
|
|
480
|
+
expect(toasts).toEqual(["@bob mentioned you: hey @vince"]);
|
|
481
|
+
expect(persistence.getState<{ lastViewedMessageId: string | null }>("channel:everyone", { schemaVersion: 1 })).toMatchObject({
|
|
482
|
+
lastViewedMessageId: "m1",
|
|
483
|
+
});
|
|
484
|
+
detachView();
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
test("does not keep mentions unread while a chat view is already open", () => {
|
|
488
|
+
const persistence = new MemoryPersistence();
|
|
489
|
+
const controller = new ChatController();
|
|
490
|
+
const toasts: string[] = [];
|
|
491
|
+
const message: ChatMessage = {
|
|
492
|
+
id: "m1",
|
|
493
|
+
channelId: "everyone",
|
|
494
|
+
content: "hey @vince",
|
|
495
|
+
replyToId: null,
|
|
496
|
+
createdAt: "2026-03-28T00:00:00.000Z",
|
|
497
|
+
user: { id: "u2", username: "bob", displayName: "Bob" },
|
|
498
|
+
};
|
|
499
|
+
|
|
500
|
+
persistence.setState("session", {
|
|
501
|
+
sessionToken: "token-123",
|
|
502
|
+
user: { id: "u1", username: "vince", emailVerified: true },
|
|
503
|
+
}, { schemaVersion: 1 });
|
|
504
|
+
|
|
505
|
+
controller.setToastNotifier((toast) => {
|
|
506
|
+
toasts.push(toast);
|
|
507
|
+
});
|
|
508
|
+
controller.attachPersistence(persistence);
|
|
509
|
+
const detachView = controller.attachView();
|
|
510
|
+
|
|
511
|
+
(controller as any).mergeMessages([message]);
|
|
512
|
+
|
|
513
|
+
expect(controller.getSnapshot().unreadMentionCount).toBe(0);
|
|
514
|
+
expect(toasts).toEqual([]);
|
|
515
|
+
expect(persistence.getState<{ lastViewedMessageId: string | null }>("channel:everyone", { schemaVersion: 1 })).toMatchObject({
|
|
516
|
+
lastViewedMessageId: "m1",
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
detachView();
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
test("recovers from a legacy timestamp cursor by falling back to a full transcript fetch", async () => {
|
|
523
|
+
const persistence = new MemoryPersistence();
|
|
524
|
+
const controller = new ChatController();
|
|
525
|
+
const cached: ChatMessage = {
|
|
526
|
+
id: "m1",
|
|
527
|
+
channelId: "everyone",
|
|
528
|
+
content: "cached",
|
|
529
|
+
replyToId: null,
|
|
530
|
+
createdAt: "2026-03-28T00:00:00.000Z",
|
|
531
|
+
user: { id: "u1", username: "vince", displayName: "Vince" },
|
|
532
|
+
};
|
|
533
|
+
const fullTranscript: ChatMessage[] = [
|
|
534
|
+
cached,
|
|
535
|
+
{
|
|
536
|
+
id: "m2",
|
|
537
|
+
channelId: "everyone",
|
|
538
|
+
content: "fresh",
|
|
539
|
+
replyToId: null,
|
|
540
|
+
createdAt: "2026-03-28T00:01:00.000Z",
|
|
541
|
+
user: { id: "u2", username: "bob", displayName: "Bob" },
|
|
542
|
+
},
|
|
543
|
+
];
|
|
544
|
+
|
|
545
|
+
persistence.setState("channel:everyone", {
|
|
546
|
+
draft: "",
|
|
547
|
+
replyToId: null,
|
|
548
|
+
lastCursor: "2026-03-28T00:00:00.000Z",
|
|
549
|
+
lastViewedMessageId: "m1",
|
|
550
|
+
}, { schemaVersion: 1 });
|
|
551
|
+
persistence.setResource(TRANSCRIPT_KIND, TRANSCRIPT_KEY, {
|
|
552
|
+
messages: [cached],
|
|
553
|
+
}, {
|
|
554
|
+
sourceKey: TRANSCRIPT_SOURCE,
|
|
555
|
+
schemaVersion: TRANSCRIPT_SCHEMA_VERSION,
|
|
556
|
+
cachePolicy: { staleMs: 1_000, expireMs: 2_000 },
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
controller.attachPersistence(persistence);
|
|
560
|
+
|
|
561
|
+
const calls: Array<{ channelId: string; opts?: { after?: string; before?: string; limit?: number } }> = [];
|
|
562
|
+
apiClient.getMessages = async (channelId, opts) => {
|
|
563
|
+
calls.push({ channelId, opts });
|
|
564
|
+
return opts?.after ? [] : fullTranscript;
|
|
565
|
+
};
|
|
566
|
+
|
|
567
|
+
await controller.refreshMessages();
|
|
568
|
+
|
|
569
|
+
expect(calls).toEqual([
|
|
570
|
+
{
|
|
571
|
+
channelId: "everyone",
|
|
572
|
+
opts: { limit: 50, after: "2026-03-28T00:00:00.000Z" },
|
|
573
|
+
},
|
|
574
|
+
{
|
|
575
|
+
channelId: "everyone",
|
|
576
|
+
opts: { limit: 50 },
|
|
577
|
+
},
|
|
578
|
+
]);
|
|
579
|
+
expect(controller.getSnapshot().messages.map((entry) => entry.id)).toEqual(["m1", "m2"]);
|
|
580
|
+
expect(persistence.getState<{ lastCursor: string }>("channel:everyone", { schemaVersion: 1 })).toMatchObject({
|
|
581
|
+
lastCursor: "m2",
|
|
582
|
+
});
|
|
583
|
+
});
|
|
217
584
|
});
|