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