gloomberb 0.6.1 → 0.7.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/package.json +1 -1
- package/patches/@opentui%2Fcore@0.1.90.patch +48 -24
- package/src/app.tsx +158 -57
- package/src/capabilities/factories.ts +3 -0
- package/src/cli/registry.test.ts +0 -1
- package/src/components/chart/chart-controller.ts +0 -1
- package/src/components/chart/chart-data.ts +1 -31
- package/src/components/chart/chart-renderer.ts +94 -12
- package/src/components/chart/chart-resolution.test.ts +0 -36
- package/src/components/chart/chart-resolution.ts +0 -27
- package/src/components/chart/chart.test.ts +60 -45
- package/src/components/chart/comparison-chart-data.test.ts +1 -5
- package/src/components/chart/comparison-chart-data.ts +1 -4
- package/src/components/chart/comparison-chart-renderer.test.ts +26 -0
- package/src/components/chart/comparison-chart-renderer.ts +13 -6
- package/src/components/chart/comparison-stock-chart.tsx +63 -39
- package/src/components/chart/price-axis-labels.tsx +114 -0
- package/src/components/chart/static-chart-surface.tsx +30 -4
- package/src/components/chart/stock-chart.test.ts +0 -16
- package/src/components/chart/stock-chart.tsx +69 -41
- package/src/components/chart/time-axis-label.tsx +120 -0
- package/src/components/command-bar/__snapshots__/command-bar.test.tsx.snap +0 -28
- package/src/components/command-bar/command-bar.test.tsx +182 -36
- package/src/components/command-bar/command-bar.tsx +943 -1016
- package/src/components/command-bar/command-registry.ts +0 -10
- package/src/components/command-bar/confirm-body.tsx +51 -0
- package/src/components/command-bar/helpers.ts +2 -5
- package/src/components/command-bar/list-model.ts +76 -0
- package/src/components/command-bar/theme-picker.tsx +254 -0
- package/src/components/command-bar/view-model.test.ts +4 -4
- package/src/components/command-bar/view-model.ts +1 -20
- package/src/components/command-bar/workflow-ops.ts +4 -0
- package/src/components/command-bar/workflow-types.ts +1 -2
- package/src/components/data-table-stack-view.test.tsx +39 -4
- package/src/components/data-table-stack-view.tsx +11 -104
- package/src/components/data-table-view.tsx +43 -79
- package/src/components/feed-data-table-stack-view.tsx +10 -17
- package/src/components/feed-stack-controller.ts +39 -0
- package/src/components/index.ts +5 -1
- package/src/components/layout/detached-pane-shell.tsx +29 -10
- package/src/components/layout/floating-pane.tsx +35 -4
- package/src/components/layout/header.tsx +12 -7
- package/src/components/layout/pane-content.test.tsx +64 -0
- package/src/components/layout/pane-content.tsx +2 -0
- package/src/components/layout/pane-cycle-order.test.ts +30 -0
- package/src/components/layout/pane-cycle-order.ts +32 -0
- package/src/components/layout/pane-footer.tsx +11 -4
- package/src/components/layout/pane-header.tsx +5 -5
- package/src/components/layout/pane-sizing.ts +9 -2
- package/src/components/layout/pane-title.ts +4 -1
- package/src/components/layout/pane.tsx +11 -5
- package/src/components/layout/shell.test.tsx +264 -22
- package/src/components/layout/shell.tsx +46 -8
- package/src/components/layout/status-bar.tsx +2 -0
- package/src/components/onboarding/onboarding-wizard.tsx +1 -2
- package/src/components/pane-settings-dialog.tsx +12 -4
- package/src/components/pane-template-wizard.test.tsx +3 -4
- package/src/components/speedometer-gauge.test.tsx +45 -0
- package/src/components/speedometer-gauge.tsx +562 -0
- package/src/components/table-view-shared.tsx +218 -0
- package/src/components/ticker-list-table-view.tsx +57 -73
- package/src/components/ticker-list-table.test.tsx +82 -1
- package/src/components/ticker-list-table.tsx +22 -9
- package/src/components/ui/confirm-dialog.tsx +66 -0
- package/src/components/ui/data-table.tsx +30 -19
- package/src/components/ui/frame.tsx +2 -0
- package/src/components/ui/index.ts +0 -1
- package/src/components/ui/page-stack-view.tsx +39 -10
- package/src/components/ui/table-layout.test.ts +42 -0
- package/src/components/ui/table-layout.ts +49 -4
- package/src/components/ui/tabs.tsx +34 -1
- package/src/components/ui/ui.test.tsx +168 -21
- package/src/core/state/app-state.ts +10 -17
- package/src/market-data/coordinator.test.ts +76 -1
- package/src/market-data/coordinator.ts +218 -49
- package/src/news/aggregator.test.ts +49 -1
- package/src/news/aggregator.ts +48 -3
- package/src/news/hooks.ts +6 -2
- package/src/plugins/builtin/ai/ask-ai-detail-tab.tsx +46 -16
- package/src/plugins/builtin/ai/providers.ts +0 -17
- package/src/plugins/builtin/ai/screener-pane.test.tsx +3 -4
- package/src/plugins/builtin/ai/screener-pane.tsx +3 -5
- package/src/plugins/builtin/analytics/index.tsx +1 -18
- package/src/plugins/builtin/ask-ai.test.tsx +89 -2
- package/src/plugins/builtin/broker-manager/index.test.tsx +2 -2
- package/src/plugins/builtin/broker-manager/index.tsx +19 -36
- package/src/plugins/builtin/chat-controller.test.ts +188 -1
- package/src/plugins/builtin/chat-controller.ts +537 -220
- package/src/plugins/builtin/chat.test.tsx +431 -13
- package/src/plugins/builtin/chat.tsx +1134 -227
- package/src/plugins/builtin/cloud-tweets.tsx +1 -1
- package/src/plugins/builtin/comparison-chart.test.tsx +53 -2
- package/src/plugins/builtin/comparison-chart.tsx +11 -5
- package/src/plugins/builtin/correlation/index.tsx +1 -6
- package/src/plugins/builtin/debug.tsx +1 -3
- package/src/plugins/builtin/econ/index.tsx +1 -2
- package/src/plugins/builtin/fear-greed/fear-greed-data.test.ts +72 -0
- package/src/plugins/builtin/fear-greed/fear-greed-data.ts +366 -0
- package/src/plugins/builtin/fear-greed/index.tsx +587 -0
- package/src/plugins/builtin/fx-matrix/index.tsx +2 -2
- package/src/plugins/builtin/help.test.tsx +2 -2
- package/src/plugins/builtin/help.tsx +2 -2
- package/src/plugins/builtin/holders/index.test.ts +13 -1
- package/src/plugins/builtin/holders/index.tsx +64 -29
- package/src/plugins/builtin/insider/index.tsx +22 -89
- package/src/plugins/builtin/market-movers/index.tsx +13 -19
- package/src/plugins/builtin/market-movers/screener.test.ts +39 -1
- package/src/plugins/builtin/market-movers/screener.ts +70 -17
- package/src/plugins/builtin/news-wire/breaking-pane.tsx +3 -2
- package/src/plugins/builtin/news-wire/index.tsx +41 -2
- package/src/plugins/builtin/news-wire/industry-pane.tsx +4 -18
- package/src/plugins/builtin/news-wire/news-detail-view.tsx +46 -4
- package/src/plugins/builtin/news-wire/news-preset-pane.tsx +3 -2
- package/src/plugins/builtin/news-wire/news-table.tsx +11 -11
- package/src/plugins/builtin/news.tsx +4 -16
- package/src/plugins/builtin/notes.tsx +19 -55
- package/src/plugins/builtin/options.test.tsx +58 -2
- package/src/plugins/builtin/options.tsx +110 -65
- package/src/plugins/builtin/plugin-groups.ts +2 -0
- package/src/plugins/builtin/portfolio-list/cli/commands.ts +1 -1
- package/src/plugins/builtin/portfolio-list/pane.tsx +16 -39
- package/src/plugins/builtin/research/index.test.ts +94 -0
- package/src/plugins/builtin/research/index.tsx +164 -33
- package/src/plugins/builtin/sec-filing-display.tsx +84 -0
- package/src/plugins/builtin/sec.tsx +13 -66
- package/src/plugins/builtin/sectors/index.tsx +1 -1
- package/src/plugins/builtin/ticker-detail/chart-tab.tsx +0 -1
- package/src/plugins/builtin/ticker-detail/index.tsx +0 -1
- package/src/plugins/builtin/ticker-detail/overview-tab.tsx +0 -1
- package/src/plugins/builtin/ticker-detail/pane.tsx +28 -22
- package/src/plugins/builtin/ticker-detail-chart-tab-switch.test.tsx +3 -4
- package/src/plugins/builtin/ticker-detail.test.tsx +38 -0
- package/src/plugins/builtin/world-indices/index.tsx +1 -1
- package/src/plugins/builtin/yield-curve/index.tsx +1 -1
- package/src/plugins/ibkr/broker-adapter.ts +1 -3
- package/src/plugins/ibkr/gateway-service-native.ts +1 -5
- package/src/plugins/ibkr/gateway-service.ts +1 -1
- package/src/plugins/ibkr/index.test.tsx +0 -2
- package/src/plugins/ibkr/trade-tab.test.tsx +3 -4
- package/src/plugins/pane-manager.ts +0 -13
- package/src/plugins/plugin-runtime.test.tsx +40 -6
- package/src/plugins/plugin-runtime.tsx +20 -10
- package/src/plugins/prediction-markets/cli.ts +0 -1
- package/src/plugins/prediction-markets/controller-data.ts +0 -2
- package/src/plugins/prediction-markets/controller.ts +0 -15
- package/src/plugins/prediction-markets/detail/pane.tsx +1 -0
- package/src/plugins/prediction-markets/pane.test.tsx +3 -11
- package/src/plugins/prediction-markets/test-helpers.tsx +1 -1
- package/src/plugins/registry.ts +13 -42
- package/src/plugins/ticker-navigation.test.ts +66 -0
- package/src/plugins/ticker-navigation.ts +44 -0
- package/src/react/input.ts +2 -1
- package/src/renderers/electrobun/bun/index.ts +32 -11
- package/src/renderers/electrobun/bun/window-focus.ts +37 -0
- package/src/renderers/electrobun/shared/protocol.ts +7 -1
- package/src/renderers/electrobun/view/app-services.ts +88 -31
- package/src/renderers/electrobun/view/backend-rpc.ts +15 -0
- package/src/renderers/electrobun/view/data-table.tsx +138 -101
- package/src/renderers/electrobun/view/desktop-controls.tsx +42 -20
- package/src/renderers/electrobun/view/desktop-window-bridge.ts +12 -1
- package/src/renderers/electrobun/view/dialog-host.tsx +13 -1
- package/src/renderers/electrobun/view/input-host.tsx +74 -10
- package/src/renderers/electrobun/view/key-event.ts +1 -1
- package/src/renderers/electrobun/view/main.tsx +18 -1
- package/src/renderers/electrobun/view/styles.css +329 -0
- package/src/renderers/electrobun/view/toast-host.tsx +29 -3
- package/src/renderers/electrobun/view/ui-host.tsx +42 -0
- package/src/renderers/opentui/chart-surface.tsx +179 -0
- package/src/renderers/opentui/input-host.tsx +1 -1
- package/src/renderers/opentui/start.tsx +48 -30
- package/src/renderers/opentui/test-utils.tsx +17 -0
- package/src/renderers/opentui/ui-host.tsx +5 -2
- package/src/sources/gloomberb-cloud.test.ts +86 -1
- package/src/sources/gloomberb-cloud.ts +117 -21
- package/src/sources/provider-router.test.ts +122 -0
- package/src/sources/provider-router.ts +198 -15
- package/src/sources/yahoo-finance.test.ts +44 -0
- package/src/sources/yahoo-finance.ts +6 -0
- package/src/state/app-bootstrap.ts +20 -7
- package/src/state/app-context-selector.test.tsx +189 -4
- package/src/state/app-context.test.ts +65 -0
- package/src/state/app-context.tsx +48 -16
- package/src/state/use-inline-tickers.test.tsx +73 -0
- package/src/state/use-inline-tickers.ts +19 -10
- package/src/test-support/plugin-runtime.ts +3 -2
- package/src/theme/colors.test.ts +75 -0
- package/src/theme/colors.ts +76 -6
- package/src/theme/theme-context.tsx +26 -0
- package/src/theme/themes.test.ts +23 -1
- package/src/theme/themes.ts +115 -0
- package/src/types/broker.ts +1 -1
- package/src/types/capability-route-source.ts +1 -0
- package/src/types/data-provider.ts +15 -2
- package/src/types/desktop-window.ts +6 -0
- package/src/types/financials.ts +2 -0
- package/src/types/plugin.ts +1 -3
- package/src/ui/host.tsx +7 -0
- package/src/utils/api-client.test.ts +32 -0
- package/src/utils/api-client.ts +119 -18
- package/src/utils/back-navigation.ts +24 -1
- package/src/utils/double-escape-close.test.ts +33 -0
- package/src/utils/double-escape-close.ts +40 -0
- package/src/utils/throttled-fetch.test.ts +22 -0
- package/src/utils/throttled-fetch.ts +3 -1
- package/src/utils/ticker-search.ts +53 -9
- package/src/version.ts +1 -1
- package/src/plugins/builtin/news-wire/feed-pane.tsx +0 -21
- package/src/plugins/builtin/news-wire/top-pane.tsx +0 -21
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import type { AppNotificationRequest, PluginPersistence, PluginResumeState } from "../../types/plugin";
|
|
2
|
-
import { apiClient, type ChatMessage, type PersistedAuthUser } from "../../utils/api-client";
|
|
2
|
+
import { apiClient, type ChatChannel, type ChatMessage, type PersistedAuthUser } from "../../utils/api-client";
|
|
3
3
|
import { debugLog } from "../../utils/debug-log";
|
|
4
4
|
import { toTimestampMillis } from "../../utils/timestamp";
|
|
5
5
|
|
|
6
6
|
const SESSION_STATE_KEY = "session";
|
|
7
|
-
const
|
|
7
|
+
const DEFAULT_CHAT_CHANNEL_ID = "everyone";
|
|
8
8
|
const TRANSCRIPT_KIND = "channel-transcript";
|
|
9
9
|
const TRANSCRIPT_SOURCE = "server";
|
|
10
10
|
const MESSAGE_PAGE_SIZE = 50;
|
|
@@ -16,7 +16,9 @@ const TRANSCRIPT_CACHE_POLICY = {
|
|
|
16
16
|
staleMs: 30 * 24 * 60 * 60_000,
|
|
17
17
|
expireMs: 90 * 24 * 60 * 60_000,
|
|
18
18
|
};
|
|
19
|
+
const DRAFT_SYNC_DEBOUNCE_MS = 250;
|
|
19
20
|
const VERIFICATION_POLL_MS = 5_000;
|
|
21
|
+
const SAFETY_REFRESH_MS = 30_000;
|
|
20
22
|
const ISO_TIMESTAMP_CURSOR = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/;
|
|
21
23
|
const USERNAME_MENTION = /(^|[^A-Za-z0-9_])@([A-Za-z][A-Za-z0-9_]{2,29})(?![A-Za-z0-9_])/g;
|
|
22
24
|
const PENDING_RECONCILE_WINDOW_MS = 2 * 60_000;
|
|
@@ -40,6 +42,9 @@ interface PersistedTranscript {
|
|
|
40
42
|
}
|
|
41
43
|
|
|
42
44
|
export interface ChatControllerSnapshot {
|
|
45
|
+
channelId: string;
|
|
46
|
+
channels: ChatChannel[];
|
|
47
|
+
channelsLoading: boolean;
|
|
43
48
|
loading: boolean;
|
|
44
49
|
loadingOlderMessages: boolean;
|
|
45
50
|
hasOlderMessages: boolean;
|
|
@@ -53,6 +58,74 @@ export interface ChatControllerSnapshot {
|
|
|
53
58
|
|
|
54
59
|
type ChatConnection = { send: (content: string, replyToId?: string) => Promise<ChatMessage>; close: () => void };
|
|
55
60
|
|
|
61
|
+
interface ChannelRuntimeState {
|
|
62
|
+
hydrated: boolean;
|
|
63
|
+
messagesLoading: boolean;
|
|
64
|
+
olderMessagesLoading: boolean;
|
|
65
|
+
refreshMessagesPromise: Promise<void> | null;
|
|
66
|
+
loadOlderMessagesPromise: Promise<void> | null;
|
|
67
|
+
reachedOldestMessage: boolean;
|
|
68
|
+
messages: ChatMessage[];
|
|
69
|
+
pendingMessages: ChatMessage[];
|
|
70
|
+
draft: string;
|
|
71
|
+
replyToId: string | null;
|
|
72
|
+
lastCursor: string | null;
|
|
73
|
+
lastViewedMessageId: string | null;
|
|
74
|
+
wsConnection: ChatConnection | null;
|
|
75
|
+
wsConnected: boolean;
|
|
76
|
+
draftSyncTimer: ReturnType<typeof setTimeout> | null;
|
|
77
|
+
openViewCount: number;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
interface ChannelListenerEntry {
|
|
81
|
+
channelId: string;
|
|
82
|
+
listener: (snapshot: ChatControllerSnapshot) => void;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function channelStateKey(channelId: string): string {
|
|
86
|
+
return `channel:${channelId}`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function normalizeChannelId(channelId: string | null | undefined): string {
|
|
90
|
+
const normalized = channelId?.trim();
|
|
91
|
+
return normalized || DEFAULT_CHAT_CHANNEL_ID;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function createEmptyChannelState(): ChannelRuntimeState {
|
|
95
|
+
return {
|
|
96
|
+
hydrated: false,
|
|
97
|
+
messagesLoading: false,
|
|
98
|
+
olderMessagesLoading: false,
|
|
99
|
+
refreshMessagesPromise: null,
|
|
100
|
+
loadOlderMessagesPromise: null,
|
|
101
|
+
reachedOldestMessage: false,
|
|
102
|
+
messages: [],
|
|
103
|
+
pendingMessages: [],
|
|
104
|
+
draft: "",
|
|
105
|
+
replyToId: null,
|
|
106
|
+
lastCursor: null,
|
|
107
|
+
lastViewedMessageId: null,
|
|
108
|
+
wsConnection: null,
|
|
109
|
+
wsConnected: false,
|
|
110
|
+
draftSyncTimer: null,
|
|
111
|
+
openViewCount: 0,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function normalizeChannels(channels: ChatChannel[]): ChatChannel[] {
|
|
116
|
+
const byId = new Map<string, ChatChannel>();
|
|
117
|
+
for (const channel of channels) {
|
|
118
|
+
const id = channel.id.trim();
|
|
119
|
+
if (!id) continue;
|
|
120
|
+
byId.set(id, {
|
|
121
|
+
...channel,
|
|
122
|
+
id,
|
|
123
|
+
name: channel.name.trim() || id,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
return [...byId.values()];
|
|
127
|
+
}
|
|
128
|
+
|
|
56
129
|
function normalizeUsername(username: string | null | undefined): string | null {
|
|
57
130
|
const trimmed = username?.trim();
|
|
58
131
|
return trimmed ? trimmed.toLowerCase() : null;
|
|
@@ -110,25 +183,20 @@ export class ChatController {
|
|
|
110
183
|
private appActive = true;
|
|
111
184
|
private hydrated = false;
|
|
112
185
|
private sessionChecked = false;
|
|
113
|
-
private messagesLoading = false;
|
|
114
|
-
private olderMessagesLoading = false;
|
|
115
|
-
private refreshMessagesPromise: Promise<void> | null = null;
|
|
116
|
-
private loadOlderMessagesPromise: Promise<void> | null = null;
|
|
117
|
-
private reachedOldestMessage = false;
|
|
118
186
|
private user: { id: string; username: string; emailVerified: boolean } | null = null;
|
|
119
|
-
private
|
|
120
|
-
private
|
|
121
|
-
private
|
|
122
|
-
private
|
|
123
|
-
private lastCursor: string | null = null;
|
|
124
|
-
private lastViewedMessageId: string | null = null;
|
|
125
|
-
private wsConnection: ChatConnection | null = null;
|
|
126
|
-
private wsConnected = false;
|
|
187
|
+
private channels: ChatChannel[] = [];
|
|
188
|
+
private channelsLoading = false;
|
|
189
|
+
private channelsPromise: Promise<void> | null = null;
|
|
190
|
+
private channelStates = new Map<string, ChannelRuntimeState>();
|
|
127
191
|
private verificationPollTimer: ReturnType<typeof setInterval> | null = null;
|
|
128
|
-
private
|
|
192
|
+
private safetyRefreshTimer: ReturnType<typeof setInterval> | null = null;
|
|
129
193
|
private pendingMessageSeq = 0;
|
|
130
194
|
private notifyFn: (notification: AppNotificationRequest) => void = () => {};
|
|
131
|
-
private listeners = new Set<
|
|
195
|
+
private listeners = new Set<ChannelListenerEntry>();
|
|
196
|
+
|
|
197
|
+
private get wsConnection(): ChatConnection | null {
|
|
198
|
+
return this.ensureChannelState(DEFAULT_CHAT_CHANNEL_ID).wsConnection;
|
|
199
|
+
}
|
|
132
200
|
|
|
133
201
|
attachPersistence(persistence: PluginPersistence, resume?: PluginResumeState): void {
|
|
134
202
|
this.persistence = persistence;
|
|
@@ -162,63 +230,154 @@ export class ChatController {
|
|
|
162
230
|
}
|
|
163
231
|
: null;
|
|
164
232
|
this.sessionChecked = true;
|
|
165
|
-
|
|
166
|
-
const channel = this.resume?.getState<PersistedChannelState>(CHANNEL_STATE_KEY, {
|
|
167
|
-
schemaVersion: CHANNEL_SCHEMA_VERSION,
|
|
168
|
-
}) ?? this.persistence.getState<PersistedChannelState>(CHANNEL_STATE_KEY, {
|
|
169
|
-
schemaVersion: CHANNEL_SCHEMA_VERSION,
|
|
170
|
-
});
|
|
171
|
-
this.draft = channel?.draft ?? "";
|
|
172
|
-
this.replyToId = channel?.replyToId ?? null;
|
|
173
|
-
const persistedCursor = channel?.lastCursor ?? null;
|
|
174
|
-
const persistedViewedMessageId = channel?.lastViewedMessageId ?? null;
|
|
175
|
-
|
|
176
|
-
const transcript = this.persistence.getResource<PersistedTranscript>(TRANSCRIPT_KIND, "everyone", {
|
|
177
|
-
sourceKey: TRANSCRIPT_SOURCE,
|
|
178
|
-
schemaVersion: TRANSCRIPT_SCHEMA_VERSION,
|
|
179
|
-
allowExpired: true,
|
|
180
|
-
});
|
|
181
|
-
this.messages = transcript?.value.messages ?? [];
|
|
182
|
-
this.lastCursor = resolveHydratedCursor(this.messages, persistedCursor);
|
|
183
|
-
this.lastViewedMessageId = this.user?.id && apiClient.getSessionToken()
|
|
184
|
-
? persistedViewedMessageId ?? getLatestMessageId(this.messages)
|
|
185
|
-
: null;
|
|
233
|
+
this.ensureChannelState(DEFAULT_CHAT_CHANNEL_ID);
|
|
186
234
|
this.syncVerificationPolling();
|
|
187
235
|
}
|
|
188
236
|
|
|
189
|
-
subscribe(listener: (snapshot: ChatControllerSnapshot) => void): () => void
|
|
190
|
-
|
|
191
|
-
|
|
237
|
+
subscribe(listener: (snapshot: ChatControllerSnapshot) => void): () => void;
|
|
238
|
+
subscribe(channelId: string, listener: (snapshot: ChatControllerSnapshot) => void): () => void;
|
|
239
|
+
subscribe(
|
|
240
|
+
channelIdOrListener: string | ((snapshot: ChatControllerSnapshot) => void),
|
|
241
|
+
maybeListener?: (snapshot: ChatControllerSnapshot) => void,
|
|
242
|
+
): () => void {
|
|
243
|
+
const channelId = typeof channelIdOrListener === "string"
|
|
244
|
+
? normalizeChannelId(channelIdOrListener)
|
|
245
|
+
: DEFAULT_CHAT_CHANNEL_ID;
|
|
246
|
+
const listener = typeof channelIdOrListener === "function" ? channelIdOrListener : maybeListener;
|
|
247
|
+
if (!listener) return () => {};
|
|
248
|
+
const entry = { channelId, listener };
|
|
249
|
+
this.listeners.add(entry);
|
|
250
|
+
listener(this.getSnapshot(channelId));
|
|
192
251
|
return () => {
|
|
193
|
-
this.listeners.delete(
|
|
252
|
+
this.listeners.delete(entry);
|
|
194
253
|
};
|
|
195
254
|
}
|
|
196
255
|
|
|
197
|
-
getSnapshot(): ChatControllerSnapshot {
|
|
256
|
+
getSnapshot(channelId = DEFAULT_CHAT_CHANNEL_ID): ChatControllerSnapshot {
|
|
257
|
+
const normalizedChannelId = normalizeChannelId(channelId);
|
|
258
|
+
const channel = this.ensureChannelState(normalizedChannelId);
|
|
198
259
|
return {
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
260
|
+
channelId: normalizedChannelId,
|
|
261
|
+
channels: this.channels,
|
|
262
|
+
channelsLoading: this.channelsLoading,
|
|
263
|
+
loading: !this.sessionChecked || channel.messagesLoading,
|
|
264
|
+
loadingOlderMessages: channel.olderMessagesLoading,
|
|
265
|
+
hasOlderMessages: channel.messages.length > 0 && !channel.reachedOldestMessage,
|
|
202
266
|
hasSavedSession: !!apiClient.getSessionToken(),
|
|
203
267
|
user: this.user,
|
|
204
|
-
messages: this.getVisibleMessages(),
|
|
205
|
-
draft:
|
|
206
|
-
replyToId:
|
|
207
|
-
unreadMentionCount: this.getUnreadMentionCount(),
|
|
268
|
+
messages: this.getVisibleMessages(normalizedChannelId),
|
|
269
|
+
draft: channel.draft,
|
|
270
|
+
replyToId: channel.replyToId,
|
|
271
|
+
unreadMentionCount: this.getUnreadMentionCount(normalizedChannelId),
|
|
208
272
|
};
|
|
209
273
|
}
|
|
210
274
|
|
|
275
|
+
getChannels(): ChatChannel[] {
|
|
276
|
+
return this.channels;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async refreshChannels(): Promise<void> {
|
|
280
|
+
if (this.channelsPromise) return this.channelsPromise;
|
|
281
|
+
this.channelsLoading = true;
|
|
282
|
+
this.emit();
|
|
283
|
+
|
|
284
|
+
const request = apiClient.getChannels()
|
|
285
|
+
.then((channels) => {
|
|
286
|
+
this.channels = normalizeChannels(channels);
|
|
287
|
+
})
|
|
288
|
+
.catch(() => {
|
|
289
|
+
// Keep the last backend-provided list if the refresh fails.
|
|
290
|
+
})
|
|
291
|
+
.finally(() => {
|
|
292
|
+
this.channelsLoading = false;
|
|
293
|
+
this.channelsPromise = null;
|
|
294
|
+
this.emit();
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
this.channelsPromise = request;
|
|
298
|
+
return request;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async resolveRequiredChannelId(channelId: string): Promise<string> {
|
|
302
|
+
const normalizedChannelId = normalizeChannelId(channelId);
|
|
303
|
+
if (this.isKnownChannelId(normalizedChannelId)) {
|
|
304
|
+
return normalizedChannelId;
|
|
305
|
+
}
|
|
306
|
+
await this.refreshChannels();
|
|
307
|
+
if (this.isKnownChannelId(normalizedChannelId)) {
|
|
308
|
+
return normalizedChannelId;
|
|
309
|
+
}
|
|
310
|
+
throw new Error(`Unknown chat channel "#${normalizedChannelId}".`);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
async resolvePreferredChannelId(channelId: string | null | undefined): Promise<string> {
|
|
314
|
+
const normalizedChannelId = normalizeChannelId(channelId);
|
|
315
|
+
if (this.isKnownChannelId(normalizedChannelId)) {
|
|
316
|
+
return normalizedChannelId;
|
|
317
|
+
}
|
|
318
|
+
await this.refreshChannels();
|
|
319
|
+
if (this.isKnownChannelId(normalizedChannelId)) {
|
|
320
|
+
return normalizedChannelId;
|
|
321
|
+
}
|
|
322
|
+
if (this.isKnownChannelId(DEFAULT_CHAT_CHANNEL_ID)) {
|
|
323
|
+
return DEFAULT_CHAT_CHANNEL_ID;
|
|
324
|
+
}
|
|
325
|
+
return this.channels[0]?.id ?? DEFAULT_CHAT_CHANNEL_ID;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
private isKnownChannelId(channelId: string): boolean {
|
|
329
|
+
return this.channels.some((channel) => channel.id === channelId);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
private ensureChannelState(channelId: string): ChannelRuntimeState {
|
|
333
|
+
const normalizedChannelId = normalizeChannelId(channelId);
|
|
334
|
+
const existing = this.channelStates.get(normalizedChannelId);
|
|
335
|
+
if (existing) return existing;
|
|
336
|
+
|
|
337
|
+
const channel = createEmptyChannelState();
|
|
338
|
+
this.channelStates.set(normalizedChannelId, channel);
|
|
339
|
+
this.hydrateChannelState(normalizedChannelId, channel);
|
|
340
|
+
return channel;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
private hydrateChannelState(channelId: string, channel: ChannelRuntimeState): void {
|
|
344
|
+
if (channel.hydrated || !this.persistence) return;
|
|
345
|
+
channel.hydrated = true;
|
|
346
|
+
|
|
347
|
+
const persistedChannel = this.resume?.getState<PersistedChannelState>(channelStateKey(channelId), {
|
|
348
|
+
schemaVersion: CHANNEL_SCHEMA_VERSION,
|
|
349
|
+
}) ?? this.persistence.getState<PersistedChannelState>(channelStateKey(channelId), {
|
|
350
|
+
schemaVersion: CHANNEL_SCHEMA_VERSION,
|
|
351
|
+
});
|
|
352
|
+
channel.draft = persistedChannel?.draft ?? "";
|
|
353
|
+
channel.replyToId = persistedChannel?.replyToId ?? null;
|
|
354
|
+
const persistedCursor = persistedChannel?.lastCursor ?? null;
|
|
355
|
+
const persistedViewedMessageId = persistedChannel?.lastViewedMessageId ?? null;
|
|
356
|
+
|
|
357
|
+
const transcript = this.persistence.getResource<PersistedTranscript>(TRANSCRIPT_KIND, channelId, {
|
|
358
|
+
sourceKey: TRANSCRIPT_SOURCE,
|
|
359
|
+
schemaVersion: TRANSCRIPT_SCHEMA_VERSION,
|
|
360
|
+
allowExpired: true,
|
|
361
|
+
});
|
|
362
|
+
channel.messages = transcript?.value.messages ?? [];
|
|
363
|
+
channel.lastCursor = resolveHydratedCursor(channel.messages, persistedCursor);
|
|
364
|
+
channel.lastViewedMessageId = this.user?.id && apiClient.getSessionToken()
|
|
365
|
+
? persistedViewedMessageId ?? getLatestMessageId(channel.messages)
|
|
366
|
+
: null;
|
|
367
|
+
}
|
|
368
|
+
|
|
211
369
|
async refreshSession(): Promise<void> {
|
|
212
370
|
const token = apiClient.getSessionToken();
|
|
213
371
|
if (!token) {
|
|
214
372
|
this.stopVerificationPolling();
|
|
215
|
-
this.
|
|
216
|
-
this.
|
|
217
|
-
this.wsConnected = false;
|
|
373
|
+
this.stopSafetyRefresh();
|
|
374
|
+
this.closeAllConnections();
|
|
218
375
|
this.user = null;
|
|
219
376
|
this.sessionChecked = true;
|
|
220
|
-
this.
|
|
221
|
-
|
|
377
|
+
for (const channel of this.channelStates.values()) {
|
|
378
|
+
channel.pendingMessages = [];
|
|
379
|
+
channel.lastViewedMessageId = null;
|
|
380
|
+
}
|
|
222
381
|
this.persistSession();
|
|
223
382
|
this.emit();
|
|
224
383
|
return;
|
|
@@ -227,14 +386,15 @@ export class ChatController {
|
|
|
227
386
|
const session = await apiClient.getSession();
|
|
228
387
|
if (!session) {
|
|
229
388
|
this.stopVerificationPolling();
|
|
230
|
-
this.
|
|
231
|
-
this.
|
|
232
|
-
this.wsConnected = false;
|
|
389
|
+
this.stopSafetyRefresh();
|
|
390
|
+
this.closeAllConnections();
|
|
233
391
|
apiClient.setSessionToken(null);
|
|
234
392
|
this.user = null;
|
|
235
393
|
this.sessionChecked = true;
|
|
236
|
-
this.
|
|
237
|
-
|
|
394
|
+
for (const channel of this.channelStates.values()) {
|
|
395
|
+
channel.pendingMessages = [];
|
|
396
|
+
channel.lastViewedMessageId = null;
|
|
397
|
+
}
|
|
238
398
|
this.persistSession();
|
|
239
399
|
this.emit();
|
|
240
400
|
return;
|
|
@@ -245,8 +405,10 @@ export class ChatController {
|
|
|
245
405
|
this.user = nextUser;
|
|
246
406
|
const nextIdentity = `${nextUser.id}:${normalizeUsername(nextUser.username) ?? ""}`;
|
|
247
407
|
if (previousIdentity && previousIdentity !== nextIdentity) {
|
|
248
|
-
|
|
249
|
-
|
|
408
|
+
for (const [channelId, channel] of this.channelStates) {
|
|
409
|
+
channel.lastViewedMessageId = channel.messages[channel.messages.length - 1]?.id ?? null;
|
|
410
|
+
this.persistChannelState(channelId);
|
|
411
|
+
}
|
|
250
412
|
}
|
|
251
413
|
this.sessionChecked = true;
|
|
252
414
|
this.persistSession();
|
|
@@ -254,90 +416,127 @@ export class ChatController {
|
|
|
254
416
|
|
|
255
417
|
if (nextUser?.emailVerified) {
|
|
256
418
|
this.stopVerificationPolling();
|
|
257
|
-
this.
|
|
419
|
+
this.ensureOpenChannelConnections();
|
|
258
420
|
return;
|
|
259
421
|
}
|
|
260
422
|
|
|
261
423
|
this.syncVerificationPolling();
|
|
262
|
-
this.
|
|
263
|
-
this.
|
|
264
|
-
this.wsConnected = false;
|
|
424
|
+
this.stopSafetyRefresh();
|
|
425
|
+
this.closeAllConnections();
|
|
265
426
|
}
|
|
266
427
|
|
|
267
428
|
async refreshMessages(): Promise<void> {
|
|
268
|
-
|
|
429
|
+
return this.refreshChannelMessages(DEFAULT_CHAT_CHANNEL_ID);
|
|
430
|
+
}
|
|
269
431
|
|
|
270
|
-
|
|
271
|
-
this.
|
|
432
|
+
async refreshChannelMessages(channelId: string): Promise<void> {
|
|
433
|
+
return this.runMessagesRefresh(normalizeChannelId(channelId), { showLoading: true });
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
private async runMessagesRefresh(channelId: string, options: { showLoading: boolean }): Promise<void> {
|
|
437
|
+
const channel = this.ensureChannelState(channelId);
|
|
438
|
+
if (channel.refreshMessagesPromise) return channel.refreshMessagesPromise;
|
|
439
|
+
|
|
440
|
+
if (options.showLoading) {
|
|
441
|
+
channel.messagesLoading = true;
|
|
442
|
+
this.emit(channelId);
|
|
443
|
+
}
|
|
272
444
|
|
|
273
|
-
const request = this.fetchMessages()
|
|
445
|
+
const request = this.fetchMessages(channelId)
|
|
274
446
|
.catch(() => {
|
|
275
|
-
this.persistChannelState();
|
|
447
|
+
this.persistChannelState(channelId);
|
|
276
448
|
})
|
|
277
449
|
.finally(() => {
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
450
|
+
if (options.showLoading) {
|
|
451
|
+
channel.messagesLoading = false;
|
|
452
|
+
}
|
|
453
|
+
channel.refreshMessagesPromise = null;
|
|
454
|
+
this.emit(channelId);
|
|
281
455
|
});
|
|
282
456
|
|
|
283
|
-
|
|
457
|
+
channel.refreshMessagesPromise = request;
|
|
284
458
|
return request;
|
|
285
459
|
}
|
|
286
460
|
|
|
287
461
|
async loadOlderMessages(): Promise<void> {
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
if (!before || this.reachedOldestMessage) return;
|
|
462
|
+
return this.loadOlderChannelMessages(DEFAULT_CHAT_CHANNEL_ID);
|
|
463
|
+
}
|
|
291
464
|
|
|
292
|
-
|
|
293
|
-
|
|
465
|
+
async loadOlderChannelMessages(channelId: string): Promise<void> {
|
|
466
|
+
const normalizedChannelId = normalizeChannelId(channelId);
|
|
467
|
+
const channel = this.ensureChannelState(normalizedChannelId);
|
|
468
|
+
if (channel.loadOlderMessagesPromise) return channel.loadOlderMessagesPromise;
|
|
469
|
+
const before = channel.messages[0]?.id ?? null;
|
|
470
|
+
if (!before || channel.reachedOldestMessage) return;
|
|
471
|
+
|
|
472
|
+
channel.olderMessagesLoading = true;
|
|
473
|
+
this.emit(normalizedChannelId);
|
|
294
474
|
|
|
295
|
-
const request = this.fetchOlderMessages(before)
|
|
475
|
+
const request = this.fetchOlderMessages(normalizedChannelId, before)
|
|
296
476
|
.catch(() => {
|
|
297
|
-
this.persistChannelState();
|
|
477
|
+
this.persistChannelState(normalizedChannelId);
|
|
298
478
|
})
|
|
299
479
|
.finally(() => {
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
this.emit();
|
|
480
|
+
channel.olderMessagesLoading = false;
|
|
481
|
+
channel.loadOlderMessagesPromise = null;
|
|
482
|
+
this.emit(normalizedChannelId);
|
|
303
483
|
});
|
|
304
484
|
|
|
305
|
-
|
|
485
|
+
channel.loadOlderMessagesPromise = request;
|
|
306
486
|
return request;
|
|
307
487
|
}
|
|
308
488
|
|
|
309
489
|
setDraft(draft: string): void {
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
490
|
+
this.setChannelDraft(DEFAULT_CHAT_CHANNEL_ID, draft);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
setChannelDraft(channelId: string, draft: string): void {
|
|
494
|
+
const normalizedChannelId = normalizeChannelId(channelId);
|
|
495
|
+
const channel = this.ensureChannelState(normalizedChannelId);
|
|
496
|
+
if (draft === channel.draft) return;
|
|
497
|
+
channel.draft = draft;
|
|
498
|
+
this.scheduleDraftSync(normalizedChannelId);
|
|
314
499
|
}
|
|
315
500
|
|
|
316
501
|
setReplyToId(replyToId: string | null): void {
|
|
317
|
-
this.
|
|
318
|
-
|
|
319
|
-
|
|
502
|
+
this.setChannelReplyToId(DEFAULT_CHAT_CHANNEL_ID, replyToId);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
setChannelReplyToId(channelId: string, replyToId: string | null): void {
|
|
506
|
+
const normalizedChannelId = normalizeChannelId(channelId);
|
|
507
|
+
const channel = this.ensureChannelState(normalizedChannelId);
|
|
508
|
+
channel.replyToId = replyToId;
|
|
509
|
+
this.persistChannelState(normalizedChannelId);
|
|
510
|
+
this.emit(normalizedChannelId);
|
|
320
511
|
}
|
|
321
512
|
|
|
322
513
|
attachView(): () => void {
|
|
323
|
-
this.
|
|
324
|
-
|
|
325
|
-
|
|
514
|
+
return this.attachChannelView(DEFAULT_CHAT_CHANNEL_ID);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
attachChannelView(channelId: string): () => void {
|
|
518
|
+
const normalizedChannelId = normalizeChannelId(channelId);
|
|
519
|
+
const channel = this.ensureChannelState(normalizedChannelId);
|
|
520
|
+
channel.openViewCount += 1;
|
|
521
|
+
if (this.markViewedThroughLatestMessage(normalizedChannelId)) {
|
|
522
|
+
this.emit(normalizedChannelId);
|
|
326
523
|
}
|
|
327
524
|
return () => {
|
|
328
|
-
|
|
525
|
+
channel.openViewCount = Math.max(0, channel.openViewCount - 1);
|
|
526
|
+
this.flushDraftSync(normalizedChannelId);
|
|
329
527
|
};
|
|
330
528
|
}
|
|
331
529
|
|
|
332
530
|
clearSession(): void {
|
|
333
531
|
this.stopVerificationPolling();
|
|
334
|
-
this.
|
|
335
|
-
this.
|
|
336
|
-
this.wsConnected = false;
|
|
532
|
+
this.stopSafetyRefresh();
|
|
533
|
+
this.closeAllConnections();
|
|
337
534
|
this.user = null;
|
|
338
535
|
this.sessionChecked = false;
|
|
339
|
-
this.
|
|
340
|
-
|
|
536
|
+
for (const channel of this.channelStates.values()) {
|
|
537
|
+
channel.pendingMessages = [];
|
|
538
|
+
channel.lastViewedMessageId = null;
|
|
539
|
+
}
|
|
341
540
|
this.emit();
|
|
342
541
|
}
|
|
343
542
|
|
|
@@ -353,17 +552,21 @@ export class ChatController {
|
|
|
353
552
|
|
|
354
553
|
reset(clearSession = false): void {
|
|
355
554
|
this.stopVerificationPolling();
|
|
356
|
-
this.
|
|
357
|
-
this.
|
|
358
|
-
this.
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
555
|
+
this.stopSafetyRefresh();
|
|
556
|
+
this.closeAllConnections();
|
|
557
|
+
for (const [channelId, channel] of this.channelStates) {
|
|
558
|
+
this.clearDraftSyncTimer(channel);
|
|
559
|
+
channel.messages = [];
|
|
560
|
+
channel.pendingMessages = [];
|
|
561
|
+
channel.draft = "";
|
|
562
|
+
channel.replyToId = null;
|
|
563
|
+
channel.lastCursor = null;
|
|
564
|
+
channel.lastViewedMessageId = null;
|
|
565
|
+
channel.reachedOldestMessage = false;
|
|
566
|
+
channel.openViewCount = 0;
|
|
567
|
+
this.persistence?.deleteResource(TRANSCRIPT_KIND, channelId, { sourceKey: TRANSCRIPT_SOURCE });
|
|
568
|
+
this.persistChannelState(channelId);
|
|
569
|
+
}
|
|
367
570
|
if (clearSession) {
|
|
368
571
|
this.user = null;
|
|
369
572
|
apiClient.setSessionToken(null);
|
|
@@ -371,85 +574,101 @@ export class ChatController {
|
|
|
371
574
|
} else {
|
|
372
575
|
this.persistSession();
|
|
373
576
|
}
|
|
374
|
-
this.persistence?.deleteResource(TRANSCRIPT_KIND, "everyone", { sourceKey: TRANSCRIPT_SOURCE });
|
|
375
|
-
this.persistChannelState();
|
|
376
577
|
this.emit();
|
|
377
578
|
}
|
|
378
579
|
|
|
379
580
|
dispose(): void {
|
|
380
581
|
chatLog.info("dispose controller", {
|
|
381
582
|
listeners: this.listeners.size,
|
|
382
|
-
|
|
583
|
+
connections: [...this.channelStates.values()].filter((channel) => !!channel.wsConnection).length,
|
|
383
584
|
});
|
|
585
|
+
for (const [channelId] of this.channelStates) {
|
|
586
|
+
this.flushDraftSync(channelId);
|
|
587
|
+
}
|
|
384
588
|
this.stopVerificationPolling();
|
|
385
|
-
this.
|
|
386
|
-
this.
|
|
387
|
-
this.
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
589
|
+
this.stopSafetyRefresh();
|
|
590
|
+
this.closeAllConnections();
|
|
591
|
+
for (const channel of this.channelStates.values()) {
|
|
592
|
+
channel.messagesLoading = false;
|
|
593
|
+
channel.olderMessagesLoading = false;
|
|
594
|
+
channel.refreshMessagesPromise = null;
|
|
595
|
+
channel.loadOlderMessagesPromise = null;
|
|
596
|
+
channel.openViewCount = 0;
|
|
597
|
+
}
|
|
393
598
|
this.notifyFn = () => {};
|
|
394
599
|
this.listeners.clear();
|
|
395
600
|
}
|
|
396
601
|
|
|
397
602
|
send(content: string, replyToId?: string): void {
|
|
603
|
+
this.sendToChannel(DEFAULT_CHAT_CHANNEL_ID, content, replyToId);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
sendToChannel(channelId: string, content: string, replyToId?: string): void {
|
|
607
|
+
const normalizedChannelId = normalizeChannelId(channelId);
|
|
608
|
+
const channel = this.ensureChannelState(normalizedChannelId);
|
|
398
609
|
if (!this.user?.emailVerified || !apiClient.getSessionToken()) return;
|
|
399
|
-
if (!
|
|
400
|
-
this.ensureConnection();
|
|
610
|
+
if (!channel.wsConnection && this.user?.emailVerified && apiClient.getSessionToken()) {
|
|
611
|
+
this.ensureConnection(normalizedChannelId);
|
|
401
612
|
}
|
|
402
|
-
const connection =
|
|
613
|
+
const connection = channel.wsConnection;
|
|
403
614
|
if (!connection) {
|
|
404
615
|
this.notifyFn({ body: "Unable to send message right now.", type: "error" });
|
|
405
616
|
return;
|
|
406
617
|
}
|
|
407
618
|
|
|
408
|
-
const pendingMessage = this.createPendingMessage(content, replyToId);
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
this.persistChannelState();
|
|
413
|
-
this.emit();
|
|
619
|
+
const pendingMessage = this.createPendingMessage(normalizedChannelId, content, replyToId);
|
|
620
|
+
channel.pendingMessages = [...channel.pendingMessages, pendingMessage];
|
|
621
|
+
channel.draft = "";
|
|
622
|
+
channel.replyToId = null;
|
|
623
|
+
this.persistChannelState(normalizedChannelId);
|
|
624
|
+
this.emit(normalizedChannelId);
|
|
414
625
|
|
|
415
626
|
void connection.send(content, replyToId).then((message) => {
|
|
416
|
-
|
|
417
|
-
this.mergeMessages([message]);
|
|
627
|
+
channel.pendingMessages = channel.pendingMessages.filter((entry) => entry.id !== pendingMessage.id);
|
|
628
|
+
this.mergeMessages(normalizedChannelId, [message]);
|
|
418
629
|
}).catch((error) => {
|
|
419
630
|
const errorMessage = error instanceof Error && error.message ? error.message : "Failed to send message.";
|
|
420
|
-
|
|
631
|
+
channel.pendingMessages = channel.pendingMessages.map((entry) => (
|
|
421
632
|
entry.id === pendingMessage.id
|
|
422
633
|
? { ...entry, clientStatus: "failed", clientError: errorMessage }
|
|
423
634
|
: entry
|
|
424
635
|
));
|
|
425
|
-
this.emit();
|
|
636
|
+
this.emit(normalizedChannelId);
|
|
426
637
|
this.notifyFn({ body: errorMessage, type: "error" });
|
|
427
638
|
});
|
|
428
639
|
}
|
|
429
640
|
|
|
430
|
-
ensureConnection(): void {
|
|
431
|
-
|
|
432
|
-
|
|
641
|
+
ensureConnection(channelId = DEFAULT_CHAT_CHANNEL_ID): void {
|
|
642
|
+
const normalizedChannelId = normalizeChannelId(channelId);
|
|
643
|
+
const channel = this.ensureChannelState(normalizedChannelId);
|
|
644
|
+
if (!this.user?.emailVerified || !apiClient.getSessionToken()) {
|
|
645
|
+
this.stopSafetyRefresh();
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
this.startSafetyRefresh();
|
|
649
|
+
if (channel.wsConnected) return;
|
|
650
|
+
channel.wsConnected = true;
|
|
433
651
|
|
|
434
|
-
void this.
|
|
652
|
+
void this.refreshChannelMessages(normalizedChannelId).catch(() => {});
|
|
435
653
|
|
|
436
|
-
|
|
437
|
-
|
|
654
|
+
channel.wsConnection = apiClient.connectChannel(
|
|
655
|
+
normalizedChannelId,
|
|
438
656
|
(message) => {
|
|
439
|
-
if (
|
|
440
|
-
this.mergeMessages([message]);
|
|
657
|
+
if (channel.messages.some((entry) => entry.id === message.id)) return;
|
|
658
|
+
this.mergeMessages(normalizedChannelId, [message]);
|
|
441
659
|
},
|
|
442
660
|
() => {
|
|
443
|
-
|
|
661
|
+
channel.wsConnected = false;
|
|
444
662
|
},
|
|
445
663
|
);
|
|
446
664
|
}
|
|
447
665
|
|
|
448
|
-
private emit(): void {
|
|
449
|
-
const
|
|
450
|
-
|
|
666
|
+
private emit(channelId?: string): void {
|
|
667
|
+
for (const entry of this.listeners) {
|
|
668
|
+
if (channelId && entry.channelId !== channelId) continue;
|
|
669
|
+
const snapshot = this.getSnapshot(entry.channelId);
|
|
451
670
|
try {
|
|
452
|
-
listener(snapshot);
|
|
671
|
+
entry.listener(snapshot);
|
|
453
672
|
} catch {
|
|
454
673
|
// ignore subscriber errors
|
|
455
674
|
}
|
|
@@ -473,85 +692,146 @@ export class ChatController {
|
|
|
473
692
|
this.verificationPollTimer = null;
|
|
474
693
|
}
|
|
475
694
|
|
|
476
|
-
private
|
|
477
|
-
|
|
478
|
-
|
|
695
|
+
private startSafetyRefresh(): void {
|
|
696
|
+
if (this.safetyRefreshTimer) return;
|
|
697
|
+
this.safetyRefreshTimer = setInterval(() => {
|
|
698
|
+
if (!this.user?.emailVerified || !apiClient.getSessionToken()) {
|
|
699
|
+
this.stopSafetyRefresh();
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
for (const channelId of this.getSafetyRefreshChannelIds()) {
|
|
703
|
+
void this.runMessagesRefresh(channelId, { showLoading: false }).catch(() => {});
|
|
704
|
+
}
|
|
705
|
+
}, SAFETY_REFRESH_MS);
|
|
706
|
+
this.safetyRefreshTimer.unref?.();
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
private stopSafetyRefresh(): void {
|
|
710
|
+
if (!this.safetyRefreshTimer) return;
|
|
711
|
+
clearInterval(this.safetyRefreshTimer);
|
|
712
|
+
this.safetyRefreshTimer = null;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
private closeAllConnections(): void {
|
|
716
|
+
for (const channel of this.channelStates.values()) {
|
|
717
|
+
channel.wsConnection?.close();
|
|
718
|
+
channel.wsConnection = null;
|
|
719
|
+
channel.wsConnected = false;
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
private ensureOpenChannelConnections(): void {
|
|
724
|
+
const channelIds = new Set<string>([DEFAULT_CHAT_CHANNEL_ID]);
|
|
725
|
+
for (const [channelId, channel] of this.channelStates) {
|
|
726
|
+
if (channel.openViewCount > 0 || channel.wsConnection) {
|
|
727
|
+
channelIds.add(channelId);
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
for (const channelId of channelIds) {
|
|
731
|
+
this.ensureConnection(channelId);
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
private getSafetyRefreshChannelIds(): string[] {
|
|
736
|
+
const active = [...this.channelStates.entries()]
|
|
737
|
+
.filter(([, channel]) => channel.openViewCount > 0 || channel.wsConnection)
|
|
738
|
+
.map(([channelId]) => channelId);
|
|
739
|
+
return active.length > 0 ? active : [DEFAULT_CHAT_CHANNEL_ID];
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
private async fetchMessages(channelId: string): Promise<void> {
|
|
743
|
+
const channel = this.ensureChannelState(channelId);
|
|
744
|
+
const legacyTimestampCursor = isLegacyTimestampCursor(channel.lastCursor);
|
|
745
|
+
const hasIncrementalCursor = !!channel.lastCursor;
|
|
479
746
|
|
|
480
747
|
try {
|
|
481
|
-
const messages = await apiClient.getMessages(
|
|
748
|
+
const messages = await apiClient.getMessages(channelId, {
|
|
482
749
|
limit: MESSAGE_PAGE_SIZE,
|
|
483
|
-
after:
|
|
750
|
+
after: channel.lastCursor ?? undefined,
|
|
484
751
|
});
|
|
485
752
|
if (!hasIncrementalCursor && messages.length < MESSAGE_PAGE_SIZE) {
|
|
486
|
-
|
|
753
|
+
channel.reachedOldestMessage = true;
|
|
487
754
|
}
|
|
488
755
|
if (messages.length > 0) {
|
|
489
|
-
this.mergeMessages(messages);
|
|
756
|
+
this.mergeMessages(channelId, messages);
|
|
490
757
|
return;
|
|
491
758
|
}
|
|
492
759
|
if (legacyTimestampCursor) {
|
|
493
|
-
const fullRefresh = await apiClient.getMessages(
|
|
760
|
+
const fullRefresh = await apiClient.getMessages(channelId, { limit: MESSAGE_PAGE_SIZE });
|
|
494
761
|
if (fullRefresh.length < MESSAGE_PAGE_SIZE) {
|
|
495
|
-
|
|
762
|
+
channel.reachedOldestMessage = true;
|
|
496
763
|
}
|
|
497
764
|
if (fullRefresh.length > 0) {
|
|
498
|
-
this.mergeMessages(fullRefresh);
|
|
765
|
+
this.mergeMessages(channelId, fullRefresh);
|
|
499
766
|
return;
|
|
500
767
|
}
|
|
501
|
-
|
|
768
|
+
channel.lastCursor = null;
|
|
502
769
|
}
|
|
503
|
-
this.persistChannelState();
|
|
770
|
+
this.persistChannelState(channelId);
|
|
504
771
|
return;
|
|
505
772
|
} catch {
|
|
506
|
-
const messages = await apiClient.getMessages(
|
|
773
|
+
const messages = await apiClient.getMessages(channelId, { limit: MESSAGE_PAGE_SIZE });
|
|
507
774
|
if (messages.length < MESSAGE_PAGE_SIZE) {
|
|
508
|
-
|
|
775
|
+
channel.reachedOldestMessage = true;
|
|
509
776
|
}
|
|
510
777
|
if (messages.length > 0) {
|
|
511
|
-
this.mergeMessages(messages);
|
|
778
|
+
this.mergeMessages(channelId, messages);
|
|
512
779
|
return;
|
|
513
780
|
}
|
|
514
|
-
this.persistChannelState();
|
|
781
|
+
this.persistChannelState(channelId);
|
|
515
782
|
}
|
|
516
783
|
}
|
|
517
784
|
|
|
518
|
-
private async fetchOlderMessages(before: string): Promise<void> {
|
|
519
|
-
const
|
|
785
|
+
private async fetchOlderMessages(channelId: string, before: string): Promise<void> {
|
|
786
|
+
const channel = this.ensureChannelState(channelId);
|
|
787
|
+
const messages = await apiClient.getMessages(channelId, {
|
|
520
788
|
limit: MESSAGE_PAGE_SIZE,
|
|
521
789
|
before,
|
|
522
790
|
});
|
|
523
791
|
if (messages.length < MESSAGE_PAGE_SIZE) {
|
|
524
|
-
|
|
792
|
+
channel.reachedOldestMessage = true;
|
|
525
793
|
}
|
|
526
794
|
if (messages.length === 0) {
|
|
527
|
-
this.persistChannelState();
|
|
795
|
+
this.persistChannelState(channelId);
|
|
528
796
|
return;
|
|
529
797
|
}
|
|
530
|
-
this.mergeMessages(messages, { notifyMentions: false });
|
|
798
|
+
this.mergeMessages(channelId, messages, { notifyMentions: false });
|
|
531
799
|
}
|
|
532
800
|
|
|
533
|
-
private mergeMessages(
|
|
534
|
-
|
|
801
|
+
private mergeMessages(
|
|
802
|
+
channelIdOrMessages: string | ChatMessage[],
|
|
803
|
+
maybeMessages?: ChatMessage[] | { notifyMentions?: boolean },
|
|
804
|
+
maybeOptions?: { notifyMentions?: boolean },
|
|
805
|
+
): void {
|
|
806
|
+
const channelId = Array.isArray(channelIdOrMessages) ? DEFAULT_CHAT_CHANNEL_ID : channelIdOrMessages;
|
|
807
|
+
const messages = Array.isArray(channelIdOrMessages)
|
|
808
|
+
? channelIdOrMessages
|
|
809
|
+
: (maybeMessages as ChatMessage[] | undefined) ?? [];
|
|
810
|
+
const options = Array.isArray(channelIdOrMessages)
|
|
811
|
+
? maybeMessages as { notifyMentions?: boolean } | undefined
|
|
812
|
+
: maybeOptions;
|
|
813
|
+
const channel = this.ensureChannelState(channelId);
|
|
814
|
+
this.reconcilePendingMessages(channelId, messages);
|
|
535
815
|
const merged = new Map<string, ChatMessage>();
|
|
536
816
|
const incoming = new Map<string, ChatMessage>();
|
|
537
|
-
for (const message of
|
|
817
|
+
for (const message of channel.messages) merged.set(message.id, message);
|
|
538
818
|
for (const message of messages) {
|
|
539
819
|
if (!merged.has(message.id)) {
|
|
540
820
|
incoming.set(message.id, message);
|
|
541
821
|
}
|
|
542
822
|
merged.set(message.id, message);
|
|
543
823
|
}
|
|
544
|
-
|
|
824
|
+
channel.messages = [...merged.values()]
|
|
545
825
|
.sort(compareMessages);
|
|
546
|
-
|
|
547
|
-
if (
|
|
548
|
-
this.markViewedThroughLatestMessage(false);
|
|
826
|
+
channel.lastCursor = channel.messages[channel.messages.length - 1]?.id ?? channel.lastCursor;
|
|
827
|
+
if (channel.openViewCount > 0) {
|
|
828
|
+
this.markViewedThroughLatestMessage(channelId, false);
|
|
549
829
|
}
|
|
550
830
|
if (options?.notifyMentions !== false) {
|
|
551
|
-
this.trackUnreadMentions([...incoming.values()]);
|
|
831
|
+
this.trackUnreadMentions(channelId, [...incoming.values()]);
|
|
552
832
|
}
|
|
553
|
-
this.persistTranscript();
|
|
554
|
-
this.emit();
|
|
833
|
+
this.persistTranscript(channelId);
|
|
834
|
+
this.emit(channelId);
|
|
555
835
|
}
|
|
556
836
|
|
|
557
837
|
private persistSession(): void {
|
|
@@ -567,40 +847,73 @@ export class ChatController {
|
|
|
567
847
|
});
|
|
568
848
|
}
|
|
569
849
|
|
|
570
|
-
private persistChannelState(): void {
|
|
850
|
+
private persistChannelState(channelId: string): void {
|
|
851
|
+
const channel = this.ensureChannelState(channelId);
|
|
852
|
+
this.clearDraftSyncTimer(channel);
|
|
853
|
+
this.writeChannelState(channelId);
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
private writeChannelState(channelId: string): void {
|
|
857
|
+
const channel = this.ensureChannelState(channelId);
|
|
571
858
|
const value = {
|
|
572
|
-
draft:
|
|
573
|
-
replyToId:
|
|
574
|
-
lastCursor:
|
|
575
|
-
lastViewedMessageId:
|
|
859
|
+
draft: channel.draft,
|
|
860
|
+
replyToId: channel.replyToId,
|
|
861
|
+
lastCursor: channel.lastCursor,
|
|
862
|
+
lastViewedMessageId: channel.lastViewedMessageId,
|
|
576
863
|
} satisfies PersistedChannelState;
|
|
577
|
-
this.resume?.setState(
|
|
864
|
+
this.resume?.setState(channelStateKey(channelId), value, {
|
|
578
865
|
schemaVersion: CHANNEL_SCHEMA_VERSION,
|
|
579
866
|
});
|
|
580
|
-
this.persistence?.setState(
|
|
867
|
+
this.persistence?.setState(channelStateKey(channelId), value, {
|
|
581
868
|
schemaVersion: CHANNEL_SCHEMA_VERSION,
|
|
582
869
|
});
|
|
583
870
|
}
|
|
584
871
|
|
|
585
|
-
private
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
872
|
+
private scheduleDraftSync(channelId: string): void {
|
|
873
|
+
const channel = this.ensureChannelState(channelId);
|
|
874
|
+
this.clearDraftSyncTimer(channel);
|
|
875
|
+
channel.draftSyncTimer = setTimeout(() => {
|
|
876
|
+
channel.draftSyncTimer = null;
|
|
877
|
+
this.writeChannelState(channelId);
|
|
878
|
+
this.emit(channelId);
|
|
879
|
+
}, DRAFT_SYNC_DEBOUNCE_MS);
|
|
880
|
+
channel.draftSyncTimer.unref?.();
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
private clearDraftSyncTimer(channel: ChannelRuntimeState): void {
|
|
884
|
+
if (!channel.draftSyncTimer) return;
|
|
885
|
+
clearTimeout(channel.draftSyncTimer);
|
|
886
|
+
channel.draftSyncTimer = null;
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
private flushDraftSync(channelId: string): void {
|
|
890
|
+
const channel = this.ensureChannelState(channelId);
|
|
891
|
+
if (!channel.draftSyncTimer) return;
|
|
892
|
+
this.clearDraftSyncTimer(channel);
|
|
893
|
+
this.writeChannelState(channelId);
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
private persistTranscript(channelId: string): void {
|
|
897
|
+
const channel = this.ensureChannelState(channelId);
|
|
898
|
+
if (channel.messages.length === 0) {
|
|
899
|
+
this.persistence?.deleteResource(TRANSCRIPT_KIND, channelId, { sourceKey: TRANSCRIPT_SOURCE });
|
|
900
|
+
this.persistChannelState(channelId);
|
|
589
901
|
return;
|
|
590
902
|
}
|
|
591
903
|
|
|
592
|
-
this.persistence?.setResource(TRANSCRIPT_KIND,
|
|
593
|
-
messages:
|
|
904
|
+
this.persistence?.setResource(TRANSCRIPT_KIND, channelId, {
|
|
905
|
+
messages: channel.messages.slice(-MAX_CACHED_MESSAGES),
|
|
594
906
|
} satisfies PersistedTranscript, {
|
|
595
907
|
sourceKey: TRANSCRIPT_SOURCE,
|
|
596
908
|
schemaVersion: TRANSCRIPT_SCHEMA_VERSION,
|
|
597
909
|
cachePolicy: TRANSCRIPT_CACHE_POLICY,
|
|
598
910
|
});
|
|
599
|
-
this.persistChannelState();
|
|
911
|
+
this.persistChannelState(channelId);
|
|
600
912
|
}
|
|
601
913
|
|
|
602
|
-
private trackUnreadMentions(messages: ChatMessage[]): void {
|
|
603
|
-
|
|
914
|
+
private trackUnreadMentions(channelId: string, messages: ChatMessage[]): void {
|
|
915
|
+
const channel = this.ensureChannelState(channelId);
|
|
916
|
+
if (channel.openViewCount > 0) {
|
|
604
917
|
return;
|
|
605
918
|
}
|
|
606
919
|
|
|
@@ -618,25 +931,26 @@ export class ChatController {
|
|
|
618
931
|
}
|
|
619
932
|
this.notifyFn({
|
|
620
933
|
title: "Gloomberb chat",
|
|
621
|
-
body: `${freshMentions.length} new mentions in
|
|
934
|
+
body: `${freshMentions.length} new mentions in #${channelId}.`,
|
|
622
935
|
type: "info",
|
|
623
936
|
desktop: "when-inactive",
|
|
624
937
|
});
|
|
625
938
|
}
|
|
626
939
|
|
|
627
|
-
private getUnreadMentionCount(): number {
|
|
628
|
-
return this.getUnreadMentionMessages().length;
|
|
940
|
+
private getUnreadMentionCount(channelId: string): number {
|
|
941
|
+
return this.getUnreadMentionMessages(channelId).length;
|
|
629
942
|
}
|
|
630
943
|
|
|
631
|
-
private getUnreadMentionMessages(): ChatMessage[] {
|
|
632
|
-
|
|
633
|
-
|
|
944
|
+
private getUnreadMentionMessages(channelId: string): ChatMessage[] {
|
|
945
|
+
const channel = this.ensureChannelState(channelId);
|
|
946
|
+
if (!channel.lastViewedMessageId) {
|
|
947
|
+
return this.getMentionMessages(channel.messages);
|
|
634
948
|
}
|
|
635
949
|
|
|
636
|
-
const viewedIndex =
|
|
950
|
+
const viewedIndex = channel.messages.findIndex((message) => message.id === channel.lastViewedMessageId);
|
|
637
951
|
const unseenMessages = viewedIndex >= 0
|
|
638
|
-
?
|
|
639
|
-
:
|
|
952
|
+
? channel.messages.slice(viewedIndex + 1)
|
|
953
|
+
: channel.messages;
|
|
640
954
|
return this.getMentionMessages(unseenMessages);
|
|
641
955
|
}
|
|
642
956
|
|
|
@@ -649,31 +963,33 @@ export class ChatController {
|
|
|
649
963
|
));
|
|
650
964
|
}
|
|
651
965
|
|
|
652
|
-
private markViewedThroughLatestMessage(persist = true): boolean {
|
|
653
|
-
const
|
|
654
|
-
|
|
966
|
+
private markViewedThroughLatestMessage(channelId: string, persist = true): boolean {
|
|
967
|
+
const channel = this.ensureChannelState(channelId);
|
|
968
|
+
const latestMessageId = channel.messages[channel.messages.length - 1]?.id ?? null;
|
|
969
|
+
if (channel.lastViewedMessageId === latestMessageId) {
|
|
655
970
|
return false;
|
|
656
971
|
}
|
|
657
|
-
|
|
972
|
+
channel.lastViewedMessageId = latestMessageId;
|
|
658
973
|
if (persist) {
|
|
659
|
-
this.persistChannelState();
|
|
974
|
+
this.persistChannelState(channelId);
|
|
660
975
|
}
|
|
661
976
|
return true;
|
|
662
977
|
}
|
|
663
978
|
|
|
664
|
-
private getVisibleMessages(): ChatMessage[] {
|
|
665
|
-
|
|
979
|
+
private getVisibleMessages(channelId: string): ChatMessage[] {
|
|
980
|
+
const channel = this.ensureChannelState(channelId);
|
|
981
|
+
return [...channel.messages, ...channel.pendingMessages]
|
|
666
982
|
.sort(compareMessages);
|
|
667
983
|
}
|
|
668
984
|
|
|
669
|
-
private createPendingMessage(content: string, replyToId?: string): ChatMessage {
|
|
985
|
+
private createPendingMessage(channelId: string, content: string, replyToId?: string): ChatMessage {
|
|
670
986
|
const replyToMessage = replyToId
|
|
671
|
-
? this.getVisibleMessages().find((message) => message.id === replyToId) ?? null
|
|
987
|
+
? this.getVisibleMessages(channelId).find((message) => message.id === replyToId) ?? null
|
|
672
988
|
: null;
|
|
673
989
|
const pendingId = `local:${Date.now()}:${this.pendingMessageSeq += 1}`;
|
|
674
990
|
return {
|
|
675
991
|
id: pendingId,
|
|
676
|
-
channelId
|
|
992
|
+
channelId,
|
|
677
993
|
content,
|
|
678
994
|
replyToId: replyToId ?? null,
|
|
679
995
|
createdAt: new Date().toISOString(),
|
|
@@ -693,10 +1009,11 @@ export class ChatController {
|
|
|
693
1009
|
};
|
|
694
1010
|
}
|
|
695
1011
|
|
|
696
|
-
private reconcilePendingMessages(messages: ChatMessage[]): void {
|
|
697
|
-
|
|
1012
|
+
private reconcilePendingMessages(channelId: string, messages: ChatMessage[]): void {
|
|
1013
|
+
const channel = this.ensureChannelState(channelId);
|
|
1014
|
+
if (channel.pendingMessages.length === 0 || messages.length === 0) return;
|
|
698
1015
|
|
|
699
|
-
const remaining = [...
|
|
1016
|
+
const remaining = [...channel.pendingMessages];
|
|
700
1017
|
for (const incoming of messages) {
|
|
701
1018
|
const incomingMs = toTimestampMillis(incoming.createdAt);
|
|
702
1019
|
const pendingIndex = remaining.findIndex((pending) => (
|
|
@@ -709,7 +1026,7 @@ export class ChatController {
|
|
|
709
1026
|
remaining.splice(pendingIndex, 1);
|
|
710
1027
|
}
|
|
711
1028
|
}
|
|
712
|
-
|
|
1029
|
+
channel.pendingMessages = remaining;
|
|
713
1030
|
}
|
|
714
1031
|
}
|
|
715
1032
|
|