gloomberb 0.7.2 → 0.7.3
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 +79 -132
- package/package.json +1 -1
- package/src/app.tsx +73 -67
- package/src/components/chart/chart-data.ts +7 -2
- package/src/components/chart/chart-pane-settings.ts +7 -4
- package/src/components/chart/chart-renderer.ts +49 -15
- package/src/components/chart/chart-types.ts +2 -2
- package/src/components/chart/chart.test.ts +27 -8
- package/src/components/chart/comparison-stock-chart.tsx +84 -121
- package/src/components/chart/native/chart-rasterizer.ts +43 -17
- package/src/components/chart/price-axis-labels.tsx +25 -9
- package/src/components/chart/stock-chart-renderer-switch.test.tsx +1 -1
- package/src/components/chart/stock-chart.tsx +59 -41
- package/src/components/command-bar/command-bar.test.tsx +446 -120
- package/src/components/command-bar/command-bar.tsx +528 -383
- package/src/components/command-bar/command-registry.ts +16 -6
- package/src/components/command-bar/root-shortcuts.ts +4 -5
- package/src/components/command-bar/view-model.test.ts +1 -0
- package/src/components/command-bar/view-model.ts +2 -1
- package/src/components/command-bar/workflow-ops.ts +11 -32
- package/src/components/data-table-view.test.tsx +24 -1
- package/src/components/data-table-view.tsx +3 -2
- package/src/components/feed-data-table-stack-view.tsx +3 -2
- package/src/components/layout/detached-pane-shell.tsx +2 -2
- package/src/components/layout/floating-pane.tsx +9 -2
- package/src/components/layout/pane-footer.tsx +1 -1
- package/src/components/layout/pane-header.tsx +4 -2
- package/src/components/layout/pane-sizing.ts +12 -0
- package/src/components/layout/pane.tsx +24 -2
- package/src/components/layout/shell.tsx +24 -10
- package/src/components/layout/status-bar.test.tsx +4 -1
- package/src/components/layout/status-bar.tsx +4 -24
- package/src/components/markdown-text.tsx +53 -8
- package/src/components/onboarding/onboarding-wizard.tsx +7 -6
- package/src/components/pane-settings-dialog.tsx +9 -6
- package/src/components/pane-template-wizard.tsx +3 -2
- package/src/components/price-selector-dialog.tsx +3 -2
- package/src/components/price-sparkline-view.tsx +463 -0
- package/src/components/price-sparkline.ts +46 -0
- package/src/components/table-view-shared.tsx +21 -3
- package/src/components/ticker-list-table-view.tsx +3 -2
- package/src/components/ticker-list-table.tsx +11 -51
- package/src/components/toggle-list.tsx +3 -3
- package/src/components/ui/choice-dialog.tsx +3 -2
- package/src/components/ui/multi-select-dialog.tsx +3 -2
- package/src/components/ui/native-select.tsx +3 -3
- package/src/core/state/app-state.ts +210 -80
- package/src/data/config-store-node.ts +113 -25
- package/src/data/config-store.test.ts +65 -3
- package/src/data/plugin-state-store.ts +39 -13
- package/src/data/sqlite-cache.test.ts +13 -0
- package/src/market-data/coordinator.ts +8 -1
- package/src/news/aggregator.test.ts +41 -0
- package/src/news/aggregator.ts +61 -9
- package/src/plugins/builtin/ai/ask-ai-detail-tab.tsx +25 -6
- package/src/plugins/builtin/ai/settings.ts +3 -0
- package/src/plugins/builtin/broker-manager/index.tsx +4 -3
- package/src/plugins/builtin/chat-controller.test.ts +398 -17
- package/src/plugins/builtin/chat-controller.ts +258 -44
- package/src/plugins/builtin/chat.test.tsx +257 -6
- package/src/plugins/builtin/chat.tsx +225 -60
- package/src/plugins/builtin/comparison-chart.test.tsx +117 -3
- package/src/plugins/builtin/correlation/colors.test.ts +39 -0
- package/src/plugins/builtin/correlation/colors.ts +87 -0
- package/src/plugins/builtin/correlation/index.test.tsx +151 -0
- package/src/plugins/builtin/correlation/index.tsx +92 -21
- package/src/plugins/builtin/debug.tsx +4 -3
- package/src/plugins/builtin/econ/index.tsx +3 -2
- package/src/plugins/builtin/help.test.tsx +2 -2
- package/src/plugins/builtin/holders/index.tsx +25 -13
- package/src/plugins/builtin/layout-manager.tsx +1 -7
- package/src/plugins/builtin/news-wire/index.tsx +1 -1
- package/src/plugins/builtin/news-wire/news-detail-view.tsx +3 -2
- package/src/plugins/builtin/news-wire/news-preset-pane.tsx +4 -1
- package/src/plugins/builtin/news-wire/persisted-articles.ts +7 -1
- package/src/plugins/builtin/options.tsx +9 -17
- package/src/plugins/builtin/portfolio-list/cli/commands.ts +6 -10
- package/src/plugins/builtin/portfolio-list/metrics.ts +11 -0
- package/src/plugins/builtin/portfolio-list/mutations.ts +17 -0
- package/src/plugins/builtin/portfolio-list/pane.tsx +117 -18
- package/src/plugins/builtin/portfolio-list/quick-add.tsx +422 -0
- package/src/plugins/builtin/portfolio-list/settings.ts +20 -12
- package/src/plugins/builtin/portfolio-list/table.tsx +3 -3
- package/src/plugins/builtin/portfolio-list.test.tsx +282 -2
- package/src/plugins/builtin/quote-monitor.test.tsx +158 -25
- package/src/plugins/builtin/sectors/index.tsx +252 -41
- package/src/plugins/builtin/sectors/sector-data.ts +41 -1
- package/src/plugins/builtin/ticker-detail/chart-tab.tsx +3 -2
- package/src/plugins/builtin/ticker-detail/index.tsx +30 -9
- package/src/plugins/builtin/ticker-detail/overview-tab.tsx +25 -15
- package/src/plugins/builtin/ticker-detail/pane.tsx +0 -6
- package/src/plugins/builtin/ticker-detail/quote-monitor.tsx +442 -63
- package/src/plugins/builtin/ticker-detail/settings.ts +84 -3
- package/src/plugins/builtin/ticker-detail.test.tsx +2 -2
- package/src/plugins/ibkr/trading-pane.tsx +3 -2
- package/src/plugins/plugin-runtime.tsx +2 -2
- package/src/plugins/prediction-markets/settings.ts +4 -0
- package/src/plugins/registry.test.ts +55 -0
- package/src/plugins/registry.ts +11 -4
- package/src/plugins/ticker-navigation.test.ts +35 -5
- package/src/plugins/ticker-navigation.ts +16 -22
- package/src/renderers/electrobun/bun/application-menu-click.test.ts +9 -0
- package/src/renderers/electrobun/bun/application-menu-click.ts +7 -4
- package/src/renderers/electrobun/bun/application-menu.ts +6 -1
- package/src/renderers/electrobun/bun/desktop-workspace.ts +47 -29
- package/src/renderers/electrobun/bun/index.ts +52 -13
- package/src/renderers/electrobun/view/app-services.ts +51 -9
- package/src/renderers/electrobun/view/backend-rpc.ts +5 -8
- package/src/renderers/electrobun/view/desktop-controls.tsx +4 -4
- package/src/renderers/electrobun/view/main.tsx +0 -16
- package/src/renderers/electrobun/view/styles.css +23 -21
- package/src/renderers/electrobun/view/ui-host.tsx +6 -4
- package/src/renderers/opentui/host.tsx +2 -1
- package/src/renderers/opentui/start.tsx +1 -1
- package/src/renderers/opentui/test-utils.tsx +6 -5
- package/src/state/app-bootstrap.ts +3 -3
- package/src/state/app-context.test.ts +113 -0
- package/src/state/app-context.tsx +8 -4
- package/src/theme/colors.ts +13 -14
- package/src/types/config.ts +9 -4
- package/src/types/plugin.ts +8 -1
- package/src/ui/context-menu.tsx +4 -4
- package/src/utils/api-client.test.ts +128 -0
- package/src/utils/api-client.ts +112 -7
- package/src/utils/fuzzy-search.ts +12 -1
- package/src/utils/keyboard.ts +31 -0
- package/src/utils/ticker-search.test.ts +2 -2
- package/src/utils/ticker-search.ts +48 -1
- package/src/version.ts +1 -1
|
@@ -32,6 +32,7 @@ import type { GloomPlugin, PaneProps } from "../../../types/plugin";
|
|
|
32
32
|
import type { BrokerAccount } from "../../../types/trading";
|
|
33
33
|
import { useDialog, type PromptContext } from "../../../ui/dialog";
|
|
34
34
|
import { formatCurrency } from "../../../utils/format";
|
|
35
|
+
import { isPlainKey } from "../../../utils/keyboard";
|
|
35
36
|
import { usePluginAppActions, usePluginBrokerActions } from "../../plugin-runtime";
|
|
36
37
|
import {
|
|
37
38
|
buildBrokerProfileRows,
|
|
@@ -47,7 +48,7 @@ type BrokerColumn = DataTableColumn & { id: BrokerColumnId };
|
|
|
47
48
|
function stateColor(state: BrokerDisplayState): string {
|
|
48
49
|
switch (state) {
|
|
49
50
|
case "connected": return colors.positive;
|
|
50
|
-
case "connecting": return
|
|
51
|
+
case "connecting": return colors.warning;
|
|
51
52
|
case "error": return colors.negative;
|
|
52
53
|
case "disabled": return colors.textMuted;
|
|
53
54
|
case "unavailable": return colors.negative;
|
|
@@ -442,13 +443,13 @@ export function BrokersPane({ focused, width, height }: PaneProps) {
|
|
|
442
443
|
saveEdit().catch(() => {});
|
|
443
444
|
return;
|
|
444
445
|
}
|
|
445
|
-
if (event
|
|
446
|
+
if (isPlainKey(event, "up", "k")) {
|
|
446
447
|
event.stopPropagation();
|
|
447
448
|
const index = editKeys.indexOf(activeEditKey);
|
|
448
449
|
setActiveEditKey(editKeys[Math.max(0, index - 1)] ?? "label");
|
|
449
450
|
return;
|
|
450
451
|
}
|
|
451
|
-
if (event
|
|
452
|
+
if (isPlainKey(event, "down", "j", "tab")) {
|
|
452
453
|
event.stopPropagation();
|
|
453
454
|
const index = editKeys.indexOf(activeEditKey);
|
|
454
455
|
setActiveEditKey(editKeys[Math.min(editKeys.length - 1, index + 1)] ?? "label");
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { afterEach, describe, expect, test } from "bun:test";
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
2
|
import type { AppNotificationRequest, PluginPersistence } from "../../types/plugin";
|
|
3
3
|
import type { PersistedResourceValue } from "../../types/persistence";
|
|
4
|
-
import { apiClient, type ChatChannel, type ChatMessage } from "../../utils/api-client";
|
|
4
|
+
import { apiClient, type ChatChannel, type ChatMessage, type ChatNotification } from "../../utils/api-client";
|
|
5
5
|
import { ChatController } from "./chat-controller";
|
|
6
6
|
|
|
7
7
|
const TRANSCRIPT_KIND = "channel-transcript";
|
|
@@ -12,6 +12,12 @@ const originalConnectChannel = apiClient.connectChannel.bind(apiClient);
|
|
|
12
12
|
const originalGetSession = apiClient.getSession.bind(apiClient);
|
|
13
13
|
const originalGetMessages = apiClient.getMessages.bind(apiClient);
|
|
14
14
|
const originalGetChannels = apiClient.getChannels.bind(apiClient);
|
|
15
|
+
const originalGetChatPresence = apiClient.getChatPresence.bind(apiClient);
|
|
16
|
+
const originalGetChatState = apiClient.getChatState.bind(apiClient);
|
|
17
|
+
const originalUpdateChatChannelState = apiClient.updateChatChannelState.bind(apiClient);
|
|
18
|
+
const originalMarkChatNotificationsDelivered = apiClient.markChatNotificationsDelivered.bind(apiClient);
|
|
19
|
+
const originalSubscribeChatNotifications = apiClient.subscribeChatNotifications.bind(apiClient);
|
|
20
|
+
const originalSubscribeChatPresence = apiClient.subscribeChatPresence.bind(apiClient);
|
|
15
21
|
|
|
16
22
|
const SERVER_CHAT_CHANNELS: ChatChannel[] = [
|
|
17
23
|
{ id: "everyone", name: "everyone", created_at: "2026-03-26T12:10:05.684Z" },
|
|
@@ -100,6 +106,30 @@ class TrackingPersistence extends MemoryPersistence {
|
|
|
100
106
|
}
|
|
101
107
|
}
|
|
102
108
|
|
|
109
|
+
beforeEach(() => {
|
|
110
|
+
apiClient.getChatPresence = async () => ({ onlineCount: 0 });
|
|
111
|
+
apiClient.getChatState = async () => ({
|
|
112
|
+
channels: SERVER_CHAT_CHANNELS,
|
|
113
|
+
onlineCount: 0,
|
|
114
|
+
channelStates: SERVER_CHAT_CHANNELS.map((channel) => ({
|
|
115
|
+
channelId: channel.id,
|
|
116
|
+
notificationsEnabled: false,
|
|
117
|
+
lastReadMessageId: null,
|
|
118
|
+
unreadCount: 0,
|
|
119
|
+
})),
|
|
120
|
+
notifications: [],
|
|
121
|
+
});
|
|
122
|
+
apiClient.updateChatChannelState = async (channelId, body) => ({
|
|
123
|
+
channelId,
|
|
124
|
+
notificationsEnabled: body.notificationsEnabled ?? false,
|
|
125
|
+
lastReadMessageId: body.readThroughMessageId ?? null,
|
|
126
|
+
unreadCount: 0,
|
|
127
|
+
});
|
|
128
|
+
apiClient.markChatNotificationsDelivered = async () => ({ delivered: 1 });
|
|
129
|
+
apiClient.subscribeChatNotifications = () => () => {};
|
|
130
|
+
apiClient.subscribeChatPresence = () => () => {};
|
|
131
|
+
});
|
|
132
|
+
|
|
103
133
|
afterEach(() => {
|
|
104
134
|
apiClient.dispose();
|
|
105
135
|
apiClient.setSessionToken(null);
|
|
@@ -107,6 +137,12 @@ afterEach(() => {
|
|
|
107
137
|
apiClient.getSession = originalGetSession;
|
|
108
138
|
apiClient.getMessages = originalGetMessages;
|
|
109
139
|
apiClient.getChannels = originalGetChannels;
|
|
140
|
+
apiClient.getChatPresence = originalGetChatPresence;
|
|
141
|
+
apiClient.getChatState = originalGetChatState;
|
|
142
|
+
apiClient.updateChatChannelState = originalUpdateChatChannelState;
|
|
143
|
+
apiClient.markChatNotificationsDelivered = originalMarkChatNotificationsDelivered;
|
|
144
|
+
apiClient.subscribeChatNotifications = originalSubscribeChatNotifications;
|
|
145
|
+
apiClient.subscribeChatPresence = originalSubscribeChatPresence;
|
|
110
146
|
});
|
|
111
147
|
|
|
112
148
|
describe("ChatController", () => {
|
|
@@ -280,6 +316,7 @@ describe("ChatController", () => {
|
|
|
280
316
|
const controller = new ChatController();
|
|
281
317
|
|
|
282
318
|
apiClient.setSessionToken("token-123");
|
|
319
|
+
(controller as any).sessionToken = "token-123";
|
|
283
320
|
(controller as any).user = { id: "u1", username: "vince", emailVerified: false };
|
|
284
321
|
|
|
285
322
|
controller.setAppActive(false);
|
|
@@ -297,6 +334,7 @@ describe("ChatController", () => {
|
|
|
297
334
|
let closed = false;
|
|
298
335
|
|
|
299
336
|
apiClient.setSessionToken("token-123");
|
|
337
|
+
(controller as any).sessionToken = "token-123";
|
|
300
338
|
(controller as any).user = { id: "u1", username: "vince", emailVerified: false };
|
|
301
339
|
(controller as any).syncVerificationPolling();
|
|
302
340
|
expect((controller as any).verificationPollTimer).not.toBeNull();
|
|
@@ -548,6 +586,54 @@ describe("ChatController", () => {
|
|
|
548
586
|
});
|
|
549
587
|
});
|
|
550
588
|
|
|
589
|
+
test("hydrates missing transcript cache without marking history unread", async () => {
|
|
590
|
+
const persistence = new MemoryPersistence();
|
|
591
|
+
const controller = new ChatController();
|
|
592
|
+
const notifications: AppNotificationRequest[] = [];
|
|
593
|
+
const history: ChatMessage = {
|
|
594
|
+
id: "m1",
|
|
595
|
+
channelId: "everyone",
|
|
596
|
+
content: "already seen",
|
|
597
|
+
replyToId: null,
|
|
598
|
+
createdAt: "2026-03-28T00:00:00.000Z",
|
|
599
|
+
user: { id: "u2", username: "bob", displayName: "Bob" },
|
|
600
|
+
};
|
|
601
|
+
|
|
602
|
+
persistence.setState("session", {
|
|
603
|
+
sessionToken: "token-123",
|
|
604
|
+
user: { id: "u1", username: "vince", emailVerified: true },
|
|
605
|
+
}, { schemaVersion: 1 });
|
|
606
|
+
persistence.setState("channel:everyone", {
|
|
607
|
+
draft: "",
|
|
608
|
+
replyToId: null,
|
|
609
|
+
lastCursor: "m1",
|
|
610
|
+
lastViewedMessageId: "m1",
|
|
611
|
+
}, { schemaVersion: 1 });
|
|
612
|
+
|
|
613
|
+
controller.setNotifier((notification) => {
|
|
614
|
+
notifications.push(notification);
|
|
615
|
+
});
|
|
616
|
+
controller.attachPersistence(persistence);
|
|
617
|
+
|
|
618
|
+
const calls: Array<{ channelId: string; opts?: { after?: string; before?: string; limit?: number } }> = [];
|
|
619
|
+
apiClient.getMessages = async (channelId, opts) => {
|
|
620
|
+
calls.push({ channelId, opts });
|
|
621
|
+
return opts?.after ? [] : [history];
|
|
622
|
+
};
|
|
623
|
+
|
|
624
|
+
await controller.refreshMessages();
|
|
625
|
+
|
|
626
|
+
expect(calls).toEqual([{
|
|
627
|
+
channelId: "everyone",
|
|
628
|
+
opts: { limit: 50, after: undefined },
|
|
629
|
+
}]);
|
|
630
|
+
expect(controller.getSnapshot().messages).toEqual([history]);
|
|
631
|
+
expect(controller.getSnapshot().channelStates.find((entry) => entry.channelId === "everyone")).toMatchObject({
|
|
632
|
+
unreadCount: 0,
|
|
633
|
+
});
|
|
634
|
+
expect(notifications).toEqual([]);
|
|
635
|
+
});
|
|
636
|
+
|
|
551
637
|
test("backfills from cached transcript when persisted cursor is ahead of the cache", async () => {
|
|
552
638
|
const persistence = new MemoryPersistence();
|
|
553
639
|
const controller = new ChatController();
|
|
@@ -671,6 +757,40 @@ describe("ChatController", () => {
|
|
|
671
757
|
expect(snapshot.messages).toEqual([replyTarget, sentMessage]);
|
|
672
758
|
});
|
|
673
759
|
|
|
760
|
+
test("sends one idempotency key while the same message is pending", () => {
|
|
761
|
+
const persistence = new MemoryPersistence();
|
|
762
|
+
const controller = new ChatController();
|
|
763
|
+
const clientMessageIds: Array<string | undefined> = [];
|
|
764
|
+
|
|
765
|
+
persistence.setState("session", {
|
|
766
|
+
sessionToken: "token-123",
|
|
767
|
+
user: { id: "u1", username: "vince", emailVerified: true },
|
|
768
|
+
}, { schemaVersion: 1 });
|
|
769
|
+
|
|
770
|
+
controller.attachPersistence(persistence);
|
|
771
|
+
apiClient.getMessages = async () => [];
|
|
772
|
+
apiClient.connectChannel = () => ({
|
|
773
|
+
send: (_content, _replyToId, clientMessageId) => {
|
|
774
|
+
clientMessageIds.push(clientMessageId);
|
|
775
|
+
return new Promise<ChatMessage>(() => {});
|
|
776
|
+
},
|
|
777
|
+
close: () => {},
|
|
778
|
+
});
|
|
779
|
+
|
|
780
|
+
const detachFirstView = controller.attachChannelView("everyone");
|
|
781
|
+
const detachSecondView = controller.attachChannelView("everyone");
|
|
782
|
+
|
|
783
|
+
controller.sendToChannel("everyone", " hello ");
|
|
784
|
+
controller.sendToChannel("everyone", "hello");
|
|
785
|
+
|
|
786
|
+
expect(clientMessageIds).toHaveLength(1);
|
|
787
|
+
expect(clientMessageIds[0]).toBeTruthy();
|
|
788
|
+
expect(controller.getSnapshot("everyone").messages).toHaveLength(1);
|
|
789
|
+
|
|
790
|
+
detachFirstView();
|
|
791
|
+
detachSecondView();
|
|
792
|
+
});
|
|
793
|
+
|
|
674
794
|
test("marks a pending message as failed when sending errors", async () => {
|
|
675
795
|
const persistence = new MemoryPersistence();
|
|
676
796
|
const controller = new ChatController();
|
|
@@ -706,7 +826,7 @@ describe("ChatController", () => {
|
|
|
706
826
|
expect(notifications).toEqual([{ body: "server offline", type: "error" }]);
|
|
707
827
|
});
|
|
708
828
|
|
|
709
|
-
test("tracks unread mentions
|
|
829
|
+
test("tracks unread mentions from fetched messages without issuing local notifications", () => {
|
|
710
830
|
const persistence = new MemoryPersistence();
|
|
711
831
|
const controller = new ChatController();
|
|
712
832
|
const notifications: AppNotificationRequest[] = [];
|
|
@@ -732,19 +852,14 @@ describe("ChatController", () => {
|
|
|
732
852
|
(controller as any).mergeMessages([message]);
|
|
733
853
|
|
|
734
854
|
expect(controller.getSnapshot().unreadMentionCount).toBe(1);
|
|
735
|
-
expect(notifications).toEqual([
|
|
736
|
-
title: "Gloomberb chat",
|
|
737
|
-
body: "@bob mentioned you: hey @Vince can you take a look?",
|
|
738
|
-
type: "info",
|
|
739
|
-
desktop: "when-inactive",
|
|
740
|
-
}]);
|
|
855
|
+
expect(notifications).toEqual([]);
|
|
741
856
|
expect(persistence.getState<{ lastCursor: string | null; lastViewedMessageId: string | null }>("channel:everyone", { schemaVersion: 1 })).toMatchObject({
|
|
742
857
|
lastCursor: "m1",
|
|
743
858
|
lastViewedMessageId: null,
|
|
744
859
|
});
|
|
745
860
|
});
|
|
746
861
|
|
|
747
|
-
test("
|
|
862
|
+
test("displays server-issued mention notifications while the app is backgrounded", () => {
|
|
748
863
|
const persistence = new MemoryPersistence();
|
|
749
864
|
const controller = new ChatController();
|
|
750
865
|
const notifications: AppNotificationRequest[] = [];
|
|
@@ -768,10 +883,18 @@ describe("ChatController", () => {
|
|
|
768
883
|
controller.attachPersistence(persistence);
|
|
769
884
|
controller.setAppActive(false);
|
|
770
885
|
|
|
771
|
-
(controller as any).
|
|
886
|
+
(controller as any).handleChatNotification({
|
|
887
|
+
id: "n1",
|
|
888
|
+
type: "mention",
|
|
889
|
+
channelId: "everyone",
|
|
890
|
+
messageId: "m1",
|
|
891
|
+
createdAt: "2026-03-28T00:00:00.000Z",
|
|
892
|
+
message,
|
|
893
|
+
} satisfies ChatNotification);
|
|
772
894
|
|
|
773
895
|
expect(notifications).toEqual([{
|
|
774
896
|
title: "Gloomberb chat",
|
|
897
|
+
subtitle: "#everyone",
|
|
775
898
|
body: "@bob mentioned you: hey @vince",
|
|
776
899
|
type: "info",
|
|
777
900
|
desktop: "when-inactive",
|
|
@@ -808,12 +931,7 @@ describe("ChatController", () => {
|
|
|
808
931
|
const detachView = controller.attachView();
|
|
809
932
|
|
|
810
933
|
expect(controller.getSnapshot().unreadMentionCount).toBe(0);
|
|
811
|
-
expect(notifications).toEqual([
|
|
812
|
-
title: "Gloomberb chat",
|
|
813
|
-
body: "@bob mentioned you: hey @vince",
|
|
814
|
-
type: "info",
|
|
815
|
-
desktop: "when-inactive",
|
|
816
|
-
}]);
|
|
934
|
+
expect(notifications).toEqual([]);
|
|
817
935
|
expect(persistence.getState<{ lastViewedMessageId: string | null }>("channel:everyone", { schemaVersion: 1 })).toMatchObject({
|
|
818
936
|
lastViewedMessageId: "m1",
|
|
819
937
|
});
|
|
@@ -855,6 +973,269 @@ describe("ChatController", () => {
|
|
|
855
973
|
detachView();
|
|
856
974
|
});
|
|
857
975
|
|
|
976
|
+
test("loads server chat state, pending reply notifications, and acks delivery", async () => {
|
|
977
|
+
const persistence = new MemoryPersistence();
|
|
978
|
+
const controller = new ChatController();
|
|
979
|
+
const notifications: AppNotificationRequest[] = [];
|
|
980
|
+
const deliveredIds: string[][] = [];
|
|
981
|
+
|
|
982
|
+
persistence.setState("session", {
|
|
983
|
+
sessionToken: "token-123",
|
|
984
|
+
user: { id: "u1", username: "vince", emailVerified: true },
|
|
985
|
+
}, { schemaVersion: 1 });
|
|
986
|
+
apiClient.getSession = async () => ({
|
|
987
|
+
id: "u1",
|
|
988
|
+
name: "Vince",
|
|
989
|
+
email: "vince@example.com",
|
|
990
|
+
username: "vince",
|
|
991
|
+
emailVerified: true,
|
|
992
|
+
image: null,
|
|
993
|
+
createdAt: "2026-01-01T00:00:00.000Z",
|
|
994
|
+
updatedAt: "2026-01-01T00:00:00.000Z",
|
|
995
|
+
});
|
|
996
|
+
apiClient.getChatState = async () => ({
|
|
997
|
+
channels: SERVER_CHAT_CHANNELS,
|
|
998
|
+
onlineCount: 7,
|
|
999
|
+
channelStates: [{
|
|
1000
|
+
channelId: "options",
|
|
1001
|
+
notificationsEnabled: true,
|
|
1002
|
+
lastReadMessageId: "m1",
|
|
1003
|
+
unreadCount: 3,
|
|
1004
|
+
}],
|
|
1005
|
+
notifications: [{
|
|
1006
|
+
id: "n1",
|
|
1007
|
+
type: "reply",
|
|
1008
|
+
channelId: "options",
|
|
1009
|
+
messageId: "m2",
|
|
1010
|
+
createdAt: "2026-03-28T00:02:00.000Z",
|
|
1011
|
+
message: {
|
|
1012
|
+
id: "m2",
|
|
1013
|
+
channelId: "options",
|
|
1014
|
+
content: "answering you",
|
|
1015
|
+
replyToId: "m1",
|
|
1016
|
+
createdAt: "2026-03-28T00:02:00.000Z",
|
|
1017
|
+
user: { id: "u2", username: "bob", displayName: "Bob" },
|
|
1018
|
+
replyTo: { content: "question", user: { id: "u1", username: "vince" } },
|
|
1019
|
+
},
|
|
1020
|
+
}],
|
|
1021
|
+
});
|
|
1022
|
+
apiClient.markChatNotificationsDelivered = async (ids) => {
|
|
1023
|
+
deliveredIds.push(ids);
|
|
1024
|
+
return { delivered: ids.length };
|
|
1025
|
+
};
|
|
1026
|
+
apiClient.connectChannel = () => ({
|
|
1027
|
+
send: async () => {
|
|
1028
|
+
throw new Error("not implemented");
|
|
1029
|
+
},
|
|
1030
|
+
close: () => {},
|
|
1031
|
+
});
|
|
1032
|
+
|
|
1033
|
+
controller.setNotifier((notification) => {
|
|
1034
|
+
notifications.push(notification);
|
|
1035
|
+
});
|
|
1036
|
+
controller.attachPersistence(persistence);
|
|
1037
|
+
|
|
1038
|
+
await controller.refreshSession();
|
|
1039
|
+
|
|
1040
|
+
expect(controller.getSnapshot("options").onlineCount).toBe(7);
|
|
1041
|
+
expect(controller.getSnapshot("options").channelStates.find((entry) => entry.channelId === "options")).toMatchObject({
|
|
1042
|
+
notificationsEnabled: true,
|
|
1043
|
+
unreadCount: 3,
|
|
1044
|
+
});
|
|
1045
|
+
expect(notifications).toEqual([{
|
|
1046
|
+
title: "Gloomberb chat",
|
|
1047
|
+
subtitle: "#options",
|
|
1048
|
+
body: "@bob replied to you: answering you",
|
|
1049
|
+
type: "info",
|
|
1050
|
+
desktop: "when-inactive",
|
|
1051
|
+
}]);
|
|
1052
|
+
expect(deliveredIds).toEqual([["n1"]]);
|
|
1053
|
+
});
|
|
1054
|
+
|
|
1055
|
+
test("toggles channel notifications optimistically and keeps the channel connected", async () => {
|
|
1056
|
+
const persistence = new MemoryPersistence();
|
|
1057
|
+
const controller = new ChatController();
|
|
1058
|
+
const connectedChannels: string[] = [];
|
|
1059
|
+
|
|
1060
|
+
persistence.setState("session", {
|
|
1061
|
+
sessionToken: "token-123",
|
|
1062
|
+
user: { id: "u1", username: "vince", emailVerified: true },
|
|
1063
|
+
}, { schemaVersion: 1 });
|
|
1064
|
+
apiClient.updateChatChannelState = async (channelId, body) => ({
|
|
1065
|
+
channelId,
|
|
1066
|
+
notificationsEnabled: body.notificationsEnabled === true,
|
|
1067
|
+
lastReadMessageId: null,
|
|
1068
|
+
unreadCount: 0,
|
|
1069
|
+
});
|
|
1070
|
+
apiClient.connectChannel = (channelId) => {
|
|
1071
|
+
connectedChannels.push(channelId);
|
|
1072
|
+
return {
|
|
1073
|
+
send: async () => {
|
|
1074
|
+
throw new Error("not implemented");
|
|
1075
|
+
},
|
|
1076
|
+
close: () => {},
|
|
1077
|
+
};
|
|
1078
|
+
};
|
|
1079
|
+
|
|
1080
|
+
controller.attachPersistence(persistence);
|
|
1081
|
+
controller.setChannelNotificationsEnabled("options", true);
|
|
1082
|
+
await flushMicrotasks();
|
|
1083
|
+
|
|
1084
|
+
expect(controller.getSnapshot("options").channelStates.find((entry) => entry.channelId === "options")).toMatchObject({
|
|
1085
|
+
notificationsEnabled: true,
|
|
1086
|
+
});
|
|
1087
|
+
expect(connectedChannels).toContain("options");
|
|
1088
|
+
});
|
|
1089
|
+
|
|
1090
|
+
test("dedupes reply notifications by message id", () => {
|
|
1091
|
+
const persistence = new MemoryPersistence();
|
|
1092
|
+
const controller = new ChatController();
|
|
1093
|
+
const notifications: AppNotificationRequest[] = [];
|
|
1094
|
+
const notification: ChatNotification = {
|
|
1095
|
+
id: "n1",
|
|
1096
|
+
type: "reply",
|
|
1097
|
+
channelId: "everyone",
|
|
1098
|
+
messageId: "m2",
|
|
1099
|
+
createdAt: "2026-03-28T00:02:00.000Z",
|
|
1100
|
+
message: {
|
|
1101
|
+
id: "m2",
|
|
1102
|
+
channelId: "everyone",
|
|
1103
|
+
content: "same reply",
|
|
1104
|
+
replyToId: "m1",
|
|
1105
|
+
createdAt: "2026-03-28T00:02:00.000Z",
|
|
1106
|
+
user: { id: "u2", username: "bob", displayName: "Bob" },
|
|
1107
|
+
replyTo: { content: "question", user: { id: "u1", username: "vince" } },
|
|
1108
|
+
},
|
|
1109
|
+
};
|
|
1110
|
+
|
|
1111
|
+
persistence.setState("session", {
|
|
1112
|
+
sessionToken: "token-123",
|
|
1113
|
+
user: { id: "u1", username: "vince", emailVerified: true },
|
|
1114
|
+
}, { schemaVersion: 1 });
|
|
1115
|
+
controller.setNotifier((entry) => {
|
|
1116
|
+
notifications.push(entry);
|
|
1117
|
+
});
|
|
1118
|
+
controller.attachPersistence(persistence);
|
|
1119
|
+
|
|
1120
|
+
(controller as any).handleChatNotification(notification);
|
|
1121
|
+
(controller as any).handleChatNotification({ ...notification, id: "n2" });
|
|
1122
|
+
|
|
1123
|
+
expect(notifications).toHaveLength(1);
|
|
1124
|
+
});
|
|
1125
|
+
|
|
1126
|
+
test("displays server-issued channel notifications", () => {
|
|
1127
|
+
const persistence = new MemoryPersistence();
|
|
1128
|
+
const controller = new ChatController();
|
|
1129
|
+
const notifications: AppNotificationRequest[] = [];
|
|
1130
|
+
const message: ChatMessage = {
|
|
1131
|
+
id: "m1",
|
|
1132
|
+
channelId: "options",
|
|
1133
|
+
content: "new option flow",
|
|
1134
|
+
replyToId: null,
|
|
1135
|
+
createdAt: "2026-03-28T00:00:00.000Z",
|
|
1136
|
+
user: { id: "u2", username: "bob", displayName: "Bob" },
|
|
1137
|
+
};
|
|
1138
|
+
|
|
1139
|
+
persistence.setState("session", {
|
|
1140
|
+
sessionToken: "token-123",
|
|
1141
|
+
user: { id: "u1", username: "vince", emailVerified: true },
|
|
1142
|
+
}, { schemaVersion: 1 });
|
|
1143
|
+
controller.setNotifier((entry) => {
|
|
1144
|
+
notifications.push(entry);
|
|
1145
|
+
});
|
|
1146
|
+
controller.attachPersistence(persistence);
|
|
1147
|
+
|
|
1148
|
+
(controller as any).handleChatNotification({
|
|
1149
|
+
id: "n1",
|
|
1150
|
+
type: "channel",
|
|
1151
|
+
channelId: "options",
|
|
1152
|
+
messageId: "m1",
|
|
1153
|
+
createdAt: "2026-03-28T00:00:00.000Z",
|
|
1154
|
+
message,
|
|
1155
|
+
} satisfies ChatNotification);
|
|
1156
|
+
|
|
1157
|
+
expect(notifications).toEqual([{
|
|
1158
|
+
title: "Gloomberb chat",
|
|
1159
|
+
subtitle: "#options",
|
|
1160
|
+
body: "#options @bob: new option flow",
|
|
1161
|
+
type: "info",
|
|
1162
|
+
desktop: "when-inactive",
|
|
1163
|
+
}]);
|
|
1164
|
+
});
|
|
1165
|
+
|
|
1166
|
+
test("tracks unread channel messages and clears them when the channel opens", () => {
|
|
1167
|
+
const persistence = new MemoryPersistence();
|
|
1168
|
+
const controller = new ChatController();
|
|
1169
|
+
const message: ChatMessage = {
|
|
1170
|
+
id: "m1",
|
|
1171
|
+
channelId: "options",
|
|
1172
|
+
content: "new option flow",
|
|
1173
|
+
replyToId: null,
|
|
1174
|
+
createdAt: "2026-03-28T00:00:00.000Z",
|
|
1175
|
+
user: { id: "u2", username: "bob", displayName: "Bob" },
|
|
1176
|
+
};
|
|
1177
|
+
|
|
1178
|
+
persistence.setState("session", {
|
|
1179
|
+
sessionToken: "token-123",
|
|
1180
|
+
user: { id: "u1", username: "vince", emailVerified: true },
|
|
1181
|
+
}, { schemaVersion: 1 });
|
|
1182
|
+
controller.attachPersistence(persistence);
|
|
1183
|
+
|
|
1184
|
+
(controller as any).mergeMessages("options", [message]);
|
|
1185
|
+
|
|
1186
|
+
expect(controller.getSnapshot("options").channelStates.find((entry) => entry.channelId === "options")).toMatchObject({
|
|
1187
|
+
unreadCount: 1,
|
|
1188
|
+
});
|
|
1189
|
+
|
|
1190
|
+
const detachView = controller.attachChannelView("options");
|
|
1191
|
+
|
|
1192
|
+
expect(controller.getSnapshot("options").channelStates.find((entry) => entry.channelId === "options")).toMatchObject({
|
|
1193
|
+
unreadCount: 0,
|
|
1194
|
+
});
|
|
1195
|
+
detachView();
|
|
1196
|
+
});
|
|
1197
|
+
|
|
1198
|
+
test("server reply notifications fire even when channel notifications are disabled", () => {
|
|
1199
|
+
const persistence = new MemoryPersistence();
|
|
1200
|
+
const controller = new ChatController();
|
|
1201
|
+
const notifications: AppNotificationRequest[] = [];
|
|
1202
|
+
const message: ChatMessage = {
|
|
1203
|
+
id: "m2",
|
|
1204
|
+
channelId: "options",
|
|
1205
|
+
content: "reply without channel notify",
|
|
1206
|
+
replyToId: "m1",
|
|
1207
|
+
createdAt: "2026-03-28T00:00:00.000Z",
|
|
1208
|
+
user: { id: "u2", username: "bob", displayName: "Bob" },
|
|
1209
|
+
replyTo: { content: "question", user: { id: "u1", username: "vince" } },
|
|
1210
|
+
};
|
|
1211
|
+
|
|
1212
|
+
persistence.setState("session", {
|
|
1213
|
+
sessionToken: "token-123",
|
|
1214
|
+
user: { id: "u1", username: "vince", emailVerified: true },
|
|
1215
|
+
}, { schemaVersion: 1 });
|
|
1216
|
+
controller.setNotifier((notification) => {
|
|
1217
|
+
notifications.push(notification);
|
|
1218
|
+
});
|
|
1219
|
+
controller.attachPersistence(persistence);
|
|
1220
|
+
|
|
1221
|
+
(controller as any).handleChatNotification({
|
|
1222
|
+
id: "n1",
|
|
1223
|
+
type: "reply",
|
|
1224
|
+
channelId: "options",
|
|
1225
|
+
messageId: "m2",
|
|
1226
|
+
createdAt: "2026-03-28T00:00:00.000Z",
|
|
1227
|
+
message,
|
|
1228
|
+
} satisfies ChatNotification);
|
|
1229
|
+
|
|
1230
|
+
expect(notifications).toEqual([{
|
|
1231
|
+
title: "Gloomberb chat",
|
|
1232
|
+
subtitle: "#options",
|
|
1233
|
+
body: "@bob replied to you: reply without channel notify",
|
|
1234
|
+
type: "info",
|
|
1235
|
+
desktop: "when-inactive",
|
|
1236
|
+
}]);
|
|
1237
|
+
});
|
|
1238
|
+
|
|
858
1239
|
test("recovers from a legacy timestamp cursor by falling back to a full transcript fetch", async () => {
|
|
859
1240
|
const persistence = new MemoryPersistence();
|
|
860
1241
|
const controller = new ChatController();
|