gloomberb 0.3.7 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -8
- package/package.json +1 -1
- package/src/app.tsx +29 -65
- package/src/cli/base-converter.ts +31 -0
- package/src/cli/commands/search.ts +11 -3
- package/src/cli/commands/ticker.ts +22 -11
- package/src/cli/context.ts +27 -32
- package/src/cli/helpers.ts +1 -4
- package/src/cli/index.ts +140 -123
- package/src/cli/registry.test.ts +226 -0
- package/src/cli/registry.ts +201 -0
- package/src/cli.test.ts +219 -0
- package/src/components/chart/chart-controller.ts +309 -0
- package/src/components/chart/chart-data.ts +5 -6
- package/src/components/chart/chart-pane-settings.ts +27 -0
- package/src/components/chart/chart-resolution.test.ts +56 -0
- package/src/components/chart/chart-resolution.ts +362 -0
- package/src/components/chart/chart-types.ts +12 -4
- package/src/components/chart/chart-viewport.test.ts +25 -0
- package/src/components/chart/chart-viewport.ts +28 -0
- package/src/components/chart/comparison-chart-data.ts +20 -27
- package/src/components/chart/comparison-stock-chart.tsx +374 -99
- package/src/components/chart/stock-chart-renderer-switch.test.tsx +124 -0
- package/src/components/chart/stock-chart.tsx +455 -106
- package/src/components/command-bar/__snapshots__/command-bar.test.tsx.snap +4 -4
- package/src/components/command-bar/command-bar.test.tsx +650 -5
- package/src/components/command-bar/command-bar.tsx +359 -63
- package/src/components/command-bar/command-registry.ts +8 -0
- package/src/components/command-bar/helpers.test.ts +23 -1
- package/src/components/command-bar/helpers.ts +14 -5
- package/src/components/command-bar/pane-template-display.ts +12 -0
- package/src/components/command-bar/root-shortcuts.ts +2 -1
- package/src/components/command-bar/workflow-ops.ts +4 -2
- package/src/components/detail-feed-view.test.tsx +124 -49
- package/src/components/detail-feed-view.tsx +396 -160
- package/src/components/layout/floating-pane.tsx +1 -1
- package/src/components/layout/pane-header.tsx +26 -2
- package/src/components/layout/shell.tsx +62 -50
- package/src/components/layout/status-bar.test.tsx +66 -0
- package/src/components/layout/status-bar.tsx +26 -5
- package/src/components/onboarding/onboarding-wizard.test.tsx +65 -0
- package/src/components/onboarding/onboarding-wizard.tsx +5 -6
- package/src/components/ticker-list-table.test.tsx +83 -0
- package/src/components/ticker-list-table.tsx +174 -97
- package/src/components/ui/data-table.tsx +27 -2
- package/src/components/ui/index.ts +1 -0
- package/src/components/ui/page-stack-view.tsx +60 -0
- package/src/components/use-double-click-activation.ts +31 -0
- package/src/index.tsx +10 -10
- package/src/market-data/coordinator.test.ts +92 -4
- package/src/market-data/coordinator.ts +32 -5
- package/src/market-data/hooks.test.tsx +120 -0
- package/src/market-data/hooks.tsx +53 -16
- package/src/market-data/request-types.ts +4 -2
- package/src/market-data/selectors.ts +24 -58
- package/src/plugins/builtin/chat-controller.test.ts +370 -3
- package/src/plugins/builtin/chat-controller.ts +310 -35
- package/src/plugins/builtin/chat.test.tsx +118 -6
- package/src/plugins/builtin/chat.tsx +175 -116
- package/src/plugins/builtin/comparison-chart.test.tsx +66 -5
- package/src/plugins/builtin/comparison-chart.tsx +21 -1
- package/src/plugins/builtin/news.tsx +1 -11
- package/src/plugins/builtin/portfolio-list/cli/commands.ts +656 -0
- package/src/plugins/builtin/portfolio-list/command-bar.ts +122 -0
- package/src/plugins/builtin/portfolio-list/header.tsx +16 -34
- package/src/plugins/builtin/portfolio-list/index.tsx +5 -0
- package/src/plugins/builtin/portfolio-list/metrics.test.ts +61 -0
- package/src/plugins/builtin/portfolio-list/metrics.ts +2 -2
- package/src/plugins/builtin/portfolio-list/mutations.test.ts +187 -0
- package/src/plugins/builtin/portfolio-list/mutations.ts +226 -0
- package/src/plugins/builtin/portfolio-list/pane.tsx +39 -17
- package/src/plugins/builtin/portfolio-list/table.tsx +11 -2
- package/src/plugins/builtin/portfolio-list/use-throttled-cursor-symbol.test.tsx +163 -0
- package/src/plugins/builtin/portfolio-list/use-throttled-cursor-symbol.ts +81 -0
- package/src/plugins/builtin/sec.tsx +1 -11
- package/src/plugins/builtin/ticker-detail/chart-tab.tsx +13 -2
- package/src/plugins/builtin/ticker-detail/financials-tab.tsx +21 -0
- package/src/plugins/builtin/ticker-detail/overview-tab.tsx +45 -10
- package/src/plugins/builtin/ticker-detail/pane.tsx +35 -17
- package/src/plugins/builtin/ticker-detail/settings.ts +19 -1
- package/src/plugins/builtin/ticker-detail.test.tsx +47 -0
- package/src/plugins/catalog.ts +65 -0
- package/src/plugins/ibkr/broker-adapter.ts +28 -0
- package/src/plugins/ibkr/gateway-service.test.ts +45 -1
- package/src/plugins/ibkr/gateway-service.ts +141 -7
- package/src/plugins/pane-manager.test.ts +1 -1
- package/src/plugins/plugin-runtime.tsx +27 -7
- package/src/plugins/prediction-markets/cli.ts +28 -0
- package/src/plugins/prediction-markets/controller-data.ts +11 -3
- package/src/plugins/prediction-markets/controller.ts +141 -121
- package/src/plugins/prediction-markets/index.tsx +2 -0
- package/src/plugins/prediction-markets/launch.ts +51 -16
- package/src/plugins/prediction-markets/metrics.ts +9 -5
- package/src/plugins/prediction-markets/pane.test.tsx +119 -57
- package/src/plugins/prediction-markets/pane.tsx +74 -153
- package/src/plugins/prediction-markets/table.tsx +6 -3
- package/src/plugins/prediction-markets/types.ts +0 -1
- package/src/sources/gloomberb-cloud.test.ts +29 -0
- package/src/sources/gloomberb-cloud.ts +89 -1
- package/src/sources/provider-router.test.ts +265 -3
- package/src/sources/provider-router.ts +239 -11
- package/src/sources/yahoo-finance.test.ts +26 -0
- package/src/sources/yahoo-finance.ts +54 -3
- package/src/state/app-context-selector.test.tsx +99 -0
- package/src/state/app-context.test.ts +18 -0
- package/src/state/app-context.tsx +170 -65
- package/src/state/selectors.ts +25 -0
- package/src/theme/colors.ts +8 -8
- package/src/theme/themes.test.ts +2 -0
- package/src/theme/themes.ts +22 -10
- package/src/types/broker.ts +21 -0
- package/src/types/config.ts +17 -1
- package/src/types/data-provider.ts +18 -0
- package/src/types/financials.ts +31 -1
- package/src/types/plugin.ts +70 -0
- package/src/utils/api-client.test.ts +57 -0
- package/src/utils/api-client.ts +35 -12
- package/src/utils/back-navigation.ts +27 -0
- package/src/utils/format.test.ts +14 -0
- package/src/utils/format.ts +3 -3
- package/src/utils/market-status.ts +46 -1
- package/src/utils/quote-freshness.ts +127 -0
- package/src/utils/quote-resolution.test.ts +277 -0
- package/src/utils/quote-resolution.ts +641 -0
- package/src/utils/slugify.ts +3 -0
- package/src/utils/timestamp.ts +25 -0
- package/src/version.ts +1 -1
- package/src/cli/commands/portfolio.ts +0 -210
- package/src/cli/commands/watchlist.ts +0 -210
- package/src/plugins/builtin/data-providers.ts +0 -16
- package/src/sources/exchange-rates.ts +0 -18
- package/src/utils/ascii-chart.ts +0 -172
- package/src/utils/ticker-tokenizer.test.ts +0 -48
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { PluginPersistence, PluginResumeState } from "../../types/plugin";
|
|
2
2
|
import { apiClient, type ChatMessage, type PersistedAuthUser } from "../../utils/api-client";
|
|
3
|
+
import { toTimestampMillis } from "../../utils/timestamp";
|
|
3
4
|
|
|
4
5
|
const SESSION_STATE_KEY = "session";
|
|
5
6
|
const CHANNEL_STATE_KEY = "channel:everyone";
|
|
@@ -8,12 +9,15 @@ const TRANSCRIPT_SOURCE = "server";
|
|
|
8
9
|
const MAX_CACHED_MESSAGES = 50;
|
|
9
10
|
const SESSION_SCHEMA_VERSION = 1;
|
|
10
11
|
const CHANNEL_SCHEMA_VERSION = 1;
|
|
11
|
-
const TRANSCRIPT_SCHEMA_VERSION =
|
|
12
|
+
const TRANSCRIPT_SCHEMA_VERSION = 2;
|
|
12
13
|
const TRANSCRIPT_CACHE_POLICY = {
|
|
13
14
|
staleMs: 30 * 24 * 60 * 60_000,
|
|
14
15
|
expireMs: 90 * 24 * 60 * 60_000,
|
|
15
16
|
};
|
|
16
17
|
const VERIFICATION_POLL_MS = 5_000;
|
|
18
|
+
const ISO_TIMESTAMP_CURSOR = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/;
|
|
19
|
+
const USERNAME_MENTION = /(^|[^A-Za-z0-9_])@([A-Za-z][A-Za-z0-9_]{2,29})(?![A-Za-z0-9_])/g;
|
|
20
|
+
const PENDING_RECONCILE_WINDOW_MS = 2 * 60_000;
|
|
17
21
|
|
|
18
22
|
interface PersistedSessionState {
|
|
19
23
|
sessionToken: string | null;
|
|
@@ -25,6 +29,7 @@ interface PersistedChannelState {
|
|
|
25
29
|
draft: string;
|
|
26
30
|
replyToId: string | null;
|
|
27
31
|
lastCursor: string | null;
|
|
32
|
+
lastViewedMessageId?: string | null;
|
|
28
33
|
}
|
|
29
34
|
|
|
30
35
|
interface PersistedTranscript {
|
|
@@ -38,9 +43,45 @@ export interface ChatControllerSnapshot {
|
|
|
38
43
|
messages: ChatMessage[];
|
|
39
44
|
draft: string;
|
|
40
45
|
replyToId: string | null;
|
|
46
|
+
unreadMentionCount: number;
|
|
41
47
|
}
|
|
42
48
|
|
|
43
|
-
type ChatConnection = { send: (content: string, replyToId?: string) =>
|
|
49
|
+
type ChatConnection = { send: (content: string, replyToId?: string) => Promise<ChatMessage>; close: () => void };
|
|
50
|
+
type ChatToastOptions = { duration?: number; type?: "info" | "success" | "error" };
|
|
51
|
+
|
|
52
|
+
function normalizeUsername(username: string | null | undefined): string | null {
|
|
53
|
+
const trimmed = username?.trim();
|
|
54
|
+
return trimmed ? trimmed.toLowerCase() : null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function messageMentionsUsername(content: string, username: string): boolean {
|
|
58
|
+
USERNAME_MENTION.lastIndex = 0;
|
|
59
|
+
let match: RegExpExecArray | null;
|
|
60
|
+
while ((match = USERNAME_MENTION.exec(content)) !== null) {
|
|
61
|
+
if ((match[2] ?? "").toLowerCase() === username) {
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function formatMentionToast(message: ChatMessage): string {
|
|
69
|
+
const author = message.user.username || "Someone";
|
|
70
|
+
const normalized = message.content.replace(/\s+/g, " ").trim();
|
|
71
|
+
if (!normalized) {
|
|
72
|
+
return `@${author} mentioned you in chat.`;
|
|
73
|
+
}
|
|
74
|
+
const snippet = normalized.length > 72 ? `${normalized.slice(0, 69)}...` : normalized;
|
|
75
|
+
return `@${author} mentioned you: ${snippet}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function isLegacyTimestampCursor(cursor: string | null): boolean {
|
|
79
|
+
return !!cursor && ISO_TIMESTAMP_CURSOR.test(cursor);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function compareMessages(a: ChatMessage, b: ChatMessage): number {
|
|
83
|
+
return a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id);
|
|
84
|
+
}
|
|
44
85
|
|
|
45
86
|
export class ChatController {
|
|
46
87
|
private persistence: PluginPersistence | null = null;
|
|
@@ -48,14 +89,21 @@ export class ChatController {
|
|
|
48
89
|
private appActive = true;
|
|
49
90
|
private hydrated = false;
|
|
50
91
|
private sessionChecked = false;
|
|
92
|
+
private messagesLoading = false;
|
|
93
|
+
private refreshMessagesPromise: Promise<void> | null = null;
|
|
51
94
|
private user: { id: string; username: string; emailVerified: boolean } | null = null;
|
|
52
95
|
private messages: ChatMessage[] = [];
|
|
96
|
+
private pendingMessages: ChatMessage[] = [];
|
|
53
97
|
private draft = "";
|
|
54
98
|
private replyToId: string | null = null;
|
|
55
99
|
private lastCursor: string | null = null;
|
|
100
|
+
private lastViewedMessageId: string | null = null;
|
|
56
101
|
private wsConnection: ChatConnection | null = null;
|
|
57
102
|
private wsConnected = false;
|
|
58
103
|
private verificationPollTimer: ReturnType<typeof setInterval> | null = null;
|
|
104
|
+
private openViewCount = 0;
|
|
105
|
+
private pendingMessageSeq = 0;
|
|
106
|
+
private toastFn: (message: string, options?: ChatToastOptions) => void = () => {};
|
|
59
107
|
private listeners = new Set<(snapshot: ChatControllerSnapshot) => void>();
|
|
60
108
|
|
|
61
109
|
attachPersistence(persistence: PluginPersistence, resume?: PluginResumeState): void {
|
|
@@ -64,6 +112,10 @@ export class ChatController {
|
|
|
64
112
|
this.hydrate();
|
|
65
113
|
}
|
|
66
114
|
|
|
115
|
+
setToastNotifier(notify: (message: string, options?: ChatToastOptions) => void): void {
|
|
116
|
+
this.toastFn = notify;
|
|
117
|
+
}
|
|
118
|
+
|
|
67
119
|
hydrate(): void {
|
|
68
120
|
if (this.hydrated || !this.persistence) return;
|
|
69
121
|
this.hydrated = true;
|
|
@@ -92,7 +144,8 @@ export class ChatController {
|
|
|
92
144
|
});
|
|
93
145
|
this.draft = channel?.draft ?? "";
|
|
94
146
|
this.replyToId = channel?.replyToId ?? null;
|
|
95
|
-
|
|
147
|
+
const persistedCursor = channel?.lastCursor ?? null;
|
|
148
|
+
const persistedViewedMessageId = channel?.lastViewedMessageId ?? null;
|
|
96
149
|
|
|
97
150
|
const transcript = this.persistence.getResource<PersistedTranscript>(TRANSCRIPT_KIND, "everyone", {
|
|
98
151
|
sourceKey: TRANSCRIPT_SOURCE,
|
|
@@ -100,6 +153,12 @@ export class ChatController {
|
|
|
100
153
|
allowExpired: true,
|
|
101
154
|
});
|
|
102
155
|
this.messages = transcript?.value.messages ?? [];
|
|
156
|
+
this.lastCursor = this.messages.length > 0
|
|
157
|
+
? persistedCursor ?? this.messages[this.messages.length - 1]?.id ?? null
|
|
158
|
+
: null;
|
|
159
|
+
this.lastViewedMessageId = this.user?.id && apiClient.getSessionToken()
|
|
160
|
+
? persistedViewedMessageId ?? this.messages[this.messages.length - 1]?.id ?? null
|
|
161
|
+
: null;
|
|
103
162
|
this.syncVerificationPolling();
|
|
104
163
|
}
|
|
105
164
|
|
|
@@ -113,12 +172,13 @@ export class ChatController {
|
|
|
113
172
|
|
|
114
173
|
getSnapshot(): ChatControllerSnapshot {
|
|
115
174
|
return {
|
|
116
|
-
loading: !this.sessionChecked,
|
|
175
|
+
loading: !this.sessionChecked || this.messagesLoading,
|
|
117
176
|
hasSavedSession: !!apiClient.getSessionToken(),
|
|
118
177
|
user: this.user,
|
|
119
|
-
messages:
|
|
178
|
+
messages: this.getVisibleMessages(),
|
|
120
179
|
draft: this.draft,
|
|
121
180
|
replyToId: this.replyToId,
|
|
181
|
+
unreadMentionCount: this.getUnreadMentionCount(),
|
|
122
182
|
};
|
|
123
183
|
}
|
|
124
184
|
|
|
@@ -126,18 +186,42 @@ export class ChatController {
|
|
|
126
186
|
const token = apiClient.getSessionToken();
|
|
127
187
|
if (!token) {
|
|
128
188
|
this.stopVerificationPolling();
|
|
189
|
+
this.wsConnection?.close();
|
|
190
|
+
this.wsConnection = null;
|
|
191
|
+
this.wsConnected = false;
|
|
129
192
|
this.user = null;
|
|
130
193
|
this.sessionChecked = true;
|
|
194
|
+
this.pendingMessages = [];
|
|
195
|
+
this.lastViewedMessageId = null;
|
|
131
196
|
this.persistSession();
|
|
132
197
|
this.emit();
|
|
133
198
|
return;
|
|
134
199
|
}
|
|
135
200
|
|
|
136
201
|
const session = await apiClient.getSession();
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
202
|
+
if (!session) {
|
|
203
|
+
this.stopVerificationPolling();
|
|
204
|
+
this.wsConnection?.close();
|
|
205
|
+
this.wsConnection = null;
|
|
206
|
+
this.wsConnected = false;
|
|
207
|
+
apiClient.setSessionToken(null);
|
|
208
|
+
this.user = null;
|
|
209
|
+
this.sessionChecked = true;
|
|
210
|
+
this.pendingMessages = [];
|
|
211
|
+
this.lastViewedMessageId = null;
|
|
212
|
+
this.persistSession();
|
|
213
|
+
this.emit();
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const previousIdentity = `${this.user?.id ?? ""}:${normalizeUsername(this.user?.username) ?? ""}`;
|
|
218
|
+
const nextUser = { id: session.id, username: session.username ?? session.name, emailVerified: !!session.emailVerified };
|
|
140
219
|
this.user = nextUser;
|
|
220
|
+
const nextIdentity = `${nextUser.id}:${normalizeUsername(nextUser.username) ?? ""}`;
|
|
221
|
+
if (previousIdentity && previousIdentity !== nextIdentity) {
|
|
222
|
+
this.lastViewedMessageId = this.messages[this.messages.length - 1]?.id ?? null;
|
|
223
|
+
this.persistChannelState();
|
|
224
|
+
}
|
|
141
225
|
this.sessionChecked = true;
|
|
142
226
|
this.persistSession();
|
|
143
227
|
this.emit();
|
|
@@ -152,10 +236,26 @@ export class ChatController {
|
|
|
152
236
|
this.wsConnection?.close();
|
|
153
237
|
this.wsConnection = null;
|
|
154
238
|
this.wsConnected = false;
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async refreshMessages(): Promise<void> {
|
|
242
|
+
if (this.refreshMessagesPromise) return this.refreshMessagesPromise;
|
|
243
|
+
|
|
244
|
+
this.messagesLoading = true;
|
|
245
|
+
this.emit();
|
|
246
|
+
|
|
247
|
+
const request = this.fetchMessages()
|
|
248
|
+
.catch(() => {
|
|
249
|
+
this.persistChannelState();
|
|
250
|
+
})
|
|
251
|
+
.finally(() => {
|
|
252
|
+
this.messagesLoading = false;
|
|
253
|
+
this.refreshMessagesPromise = null;
|
|
254
|
+
this.emit();
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
this.refreshMessagesPromise = request;
|
|
258
|
+
return request;
|
|
159
259
|
}
|
|
160
260
|
|
|
161
261
|
setDraft(draft: string): void {
|
|
@@ -170,6 +270,16 @@ export class ChatController {
|
|
|
170
270
|
this.emit();
|
|
171
271
|
}
|
|
172
272
|
|
|
273
|
+
attachView(): () => void {
|
|
274
|
+
this.openViewCount += 1;
|
|
275
|
+
if (this.markViewedThroughLatestMessage()) {
|
|
276
|
+
this.emit();
|
|
277
|
+
}
|
|
278
|
+
return () => {
|
|
279
|
+
this.openViewCount = Math.max(0, this.openViewCount - 1);
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
173
283
|
clearSession(): void {
|
|
174
284
|
this.stopVerificationPolling();
|
|
175
285
|
this.wsConnection?.close();
|
|
@@ -177,6 +287,8 @@ export class ChatController {
|
|
|
177
287
|
this.wsConnected = false;
|
|
178
288
|
this.user = null;
|
|
179
289
|
this.sessionChecked = false;
|
|
290
|
+
this.pendingMessages = [];
|
|
291
|
+
this.lastViewedMessageId = null;
|
|
180
292
|
this.emit();
|
|
181
293
|
}
|
|
182
294
|
|
|
@@ -196,9 +308,12 @@ export class ChatController {
|
|
|
196
308
|
this.wsConnection = null;
|
|
197
309
|
this.wsConnected = false;
|
|
198
310
|
this.messages = [];
|
|
311
|
+
this.pendingMessages = [];
|
|
199
312
|
this.draft = "";
|
|
200
313
|
this.replyToId = null;
|
|
201
314
|
this.lastCursor = null;
|
|
315
|
+
this.lastViewedMessageId = null;
|
|
316
|
+
this.openViewCount = 0;
|
|
202
317
|
if (clearSession) {
|
|
203
318
|
this.user = null;
|
|
204
319
|
apiClient.setSessionToken(null);
|
|
@@ -212,32 +327,43 @@ export class ChatController {
|
|
|
212
327
|
}
|
|
213
328
|
|
|
214
329
|
send(content: string, replyToId?: string): void {
|
|
215
|
-
this.
|
|
216
|
-
this.
|
|
217
|
-
|
|
330
|
+
if (!this.user?.emailVerified || !apiClient.getSessionToken()) return;
|
|
331
|
+
if (!this.wsConnection && this.user?.emailVerified && apiClient.getSessionToken()) {
|
|
332
|
+
this.ensureConnection();
|
|
333
|
+
}
|
|
334
|
+
const connection = this.wsConnection;
|
|
335
|
+
if (!connection) {
|
|
336
|
+
this.toastFn("Unable to send message right now.", { type: "error" });
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const pendingMessage = this.createPendingMessage(content, replyToId);
|
|
341
|
+
this.pendingMessages = [...this.pendingMessages, pendingMessage];
|
|
342
|
+
this.draft = "";
|
|
343
|
+
this.replyToId = null;
|
|
344
|
+
this.persistChannelState();
|
|
345
|
+
this.emit();
|
|
346
|
+
|
|
347
|
+
void connection.send(content, replyToId).then((message) => {
|
|
348
|
+
this.pendingMessages = this.pendingMessages.filter((entry) => entry.id !== pendingMessage.id);
|
|
349
|
+
this.mergeMessages([message]);
|
|
350
|
+
}).catch((error) => {
|
|
351
|
+
const errorMessage = error instanceof Error && error.message ? error.message : "Failed to send message.";
|
|
352
|
+
this.pendingMessages = this.pendingMessages.map((entry) => (
|
|
353
|
+
entry.id === pendingMessage.id
|
|
354
|
+
? { ...entry, clientStatus: "failed", clientError: errorMessage }
|
|
355
|
+
: entry
|
|
356
|
+
));
|
|
357
|
+
this.emit();
|
|
358
|
+
this.toastFn(errorMessage, { type: "error" });
|
|
359
|
+
});
|
|
218
360
|
}
|
|
219
361
|
|
|
220
362
|
ensureConnection(): void {
|
|
221
363
|
if (this.wsConnected || !this.user?.emailVerified || !apiClient.getSessionToken()) return;
|
|
222
364
|
this.wsConnected = true;
|
|
223
365
|
|
|
224
|
-
|
|
225
|
-
limit: MAX_CACHED_MESSAGES,
|
|
226
|
-
after: this.lastCursor ?? undefined,
|
|
227
|
-
}).then((messages) => {
|
|
228
|
-
if (messages.length > 0) {
|
|
229
|
-
this.mergeMessages(messages);
|
|
230
|
-
return;
|
|
231
|
-
}
|
|
232
|
-
this.persistChannelState();
|
|
233
|
-
}).catch(async () => {
|
|
234
|
-
try {
|
|
235
|
-
const messages = await apiClient.getMessages("everyone", { limit: MAX_CACHED_MESSAGES });
|
|
236
|
-
this.mergeMessages(messages);
|
|
237
|
-
} catch {
|
|
238
|
-
// keep local transcript
|
|
239
|
-
}
|
|
240
|
-
});
|
|
366
|
+
void this.refreshMessages().catch(() => {});
|
|
241
367
|
|
|
242
368
|
this.wsConnection = apiClient.connectChannel(
|
|
243
369
|
"everyone",
|
|
@@ -279,14 +405,57 @@ export class ChatController {
|
|
|
279
405
|
this.verificationPollTimer = null;
|
|
280
406
|
}
|
|
281
407
|
|
|
408
|
+
private async fetchMessages(): Promise<void> {
|
|
409
|
+
const legacyTimestampCursor = isLegacyTimestampCursor(this.lastCursor);
|
|
410
|
+
|
|
411
|
+
try {
|
|
412
|
+
const messages = await apiClient.getMessages("everyone", {
|
|
413
|
+
limit: MAX_CACHED_MESSAGES,
|
|
414
|
+
after: this.lastCursor ?? undefined,
|
|
415
|
+
});
|
|
416
|
+
if (messages.length > 0) {
|
|
417
|
+
this.mergeMessages(messages);
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
if (legacyTimestampCursor) {
|
|
421
|
+
const fullRefresh = await apiClient.getMessages("everyone", { limit: MAX_CACHED_MESSAGES });
|
|
422
|
+
if (fullRefresh.length > 0) {
|
|
423
|
+
this.mergeMessages(fullRefresh);
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
this.lastCursor = null;
|
|
427
|
+
}
|
|
428
|
+
this.persistChannelState();
|
|
429
|
+
return;
|
|
430
|
+
} catch {
|
|
431
|
+
const messages = await apiClient.getMessages("everyone", { limit: MAX_CACHED_MESSAGES });
|
|
432
|
+
if (messages.length > 0) {
|
|
433
|
+
this.mergeMessages(messages);
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
this.persistChannelState();
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
282
440
|
private mergeMessages(messages: ChatMessage[]): void {
|
|
441
|
+
this.reconcilePendingMessages(messages);
|
|
283
442
|
const merged = new Map<string, ChatMessage>();
|
|
443
|
+
const incoming = new Map<string, ChatMessage>();
|
|
284
444
|
for (const message of this.messages) merged.set(message.id, message);
|
|
285
|
-
for (const message of messages)
|
|
445
|
+
for (const message of messages) {
|
|
446
|
+
if (!merged.has(message.id)) {
|
|
447
|
+
incoming.set(message.id, message);
|
|
448
|
+
}
|
|
449
|
+
merged.set(message.id, message);
|
|
450
|
+
}
|
|
286
451
|
this.messages = [...merged.values()]
|
|
287
|
-
.sort(
|
|
452
|
+
.sort(compareMessages)
|
|
288
453
|
.slice(-MAX_CACHED_MESSAGES);
|
|
289
|
-
this.lastCursor = this.messages[this.messages.length - 1]?.
|
|
454
|
+
this.lastCursor = this.messages[this.messages.length - 1]?.id ?? this.lastCursor;
|
|
455
|
+
if (this.openViewCount > 0) {
|
|
456
|
+
this.markViewedThroughLatestMessage(false);
|
|
457
|
+
}
|
|
458
|
+
this.trackUnreadMentions([...incoming.values()]);
|
|
290
459
|
this.persistTranscript();
|
|
291
460
|
this.emit();
|
|
292
461
|
}
|
|
@@ -310,6 +479,7 @@ export class ChatController {
|
|
|
310
479
|
draft: this.draft,
|
|
311
480
|
replyToId: this.replyToId,
|
|
312
481
|
lastCursor: this.lastCursor,
|
|
482
|
+
lastViewedMessageId: this.lastViewedMessageId,
|
|
313
483
|
} satisfies PersistedChannelState;
|
|
314
484
|
this.resume?.setState(CHANNEL_STATE_KEY, value, {
|
|
315
485
|
schemaVersion: CHANNEL_SCHEMA_VERSION,
|
|
@@ -335,6 +505,111 @@ export class ChatController {
|
|
|
335
505
|
});
|
|
336
506
|
this.persistChannelState();
|
|
337
507
|
}
|
|
508
|
+
|
|
509
|
+
private trackUnreadMentions(messages: ChatMessage[]): void {
|
|
510
|
+
if (this.openViewCount > 0) {
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
const freshMentions = this.getMentionMessages(messages);
|
|
515
|
+
if (freshMentions.length === 0) return;
|
|
516
|
+
if (!this.appActive) return;
|
|
517
|
+
|
|
518
|
+
if (freshMentions.length === 1) {
|
|
519
|
+
this.toastFn(formatMentionToast(freshMentions[0]!), { type: "info" });
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
this.toastFn(`${freshMentions.length} new mentions in #everyone.`, { type: "info" });
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
private getUnreadMentionCount(): number {
|
|
526
|
+
return this.getUnreadMentionMessages().length;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
private getUnreadMentionMessages(): ChatMessage[] {
|
|
530
|
+
if (!this.lastViewedMessageId) {
|
|
531
|
+
return this.getMentionMessages(this.messages);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
const viewedIndex = this.messages.findIndex((message) => message.id === this.lastViewedMessageId);
|
|
535
|
+
const unseenMessages = viewedIndex >= 0
|
|
536
|
+
? this.messages.slice(viewedIndex + 1)
|
|
537
|
+
: this.messages;
|
|
538
|
+
return this.getMentionMessages(unseenMessages);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
private getMentionMessages(messages: ChatMessage[]): ChatMessage[] {
|
|
542
|
+
const normalizedUsername = normalizeUsername(this.user?.username);
|
|
543
|
+
if (!normalizedUsername || messages.length === 0) return [];
|
|
544
|
+
|
|
545
|
+
return messages.filter((message) => (
|
|
546
|
+
message.user.id !== this.user?.id && messageMentionsUsername(message.content, normalizedUsername)
|
|
547
|
+
));
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
private markViewedThroughLatestMessage(persist = true): boolean {
|
|
551
|
+
const latestMessageId = this.messages[this.messages.length - 1]?.id ?? null;
|
|
552
|
+
if (this.lastViewedMessageId === latestMessageId) {
|
|
553
|
+
return false;
|
|
554
|
+
}
|
|
555
|
+
this.lastViewedMessageId = latestMessageId;
|
|
556
|
+
if (persist) {
|
|
557
|
+
this.persistChannelState();
|
|
558
|
+
}
|
|
559
|
+
return true;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
private getVisibleMessages(): ChatMessage[] {
|
|
563
|
+
return [...this.messages, ...this.pendingMessages]
|
|
564
|
+
.sort(compareMessages)
|
|
565
|
+
.slice(-MAX_CACHED_MESSAGES);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
private createPendingMessage(content: string, replyToId?: string): ChatMessage {
|
|
569
|
+
const replyToMessage = replyToId
|
|
570
|
+
? this.getVisibleMessages().find((message) => message.id === replyToId) ?? null
|
|
571
|
+
: null;
|
|
572
|
+
const pendingId = `local:${Date.now()}:${this.pendingMessageSeq += 1}`;
|
|
573
|
+
return {
|
|
574
|
+
id: pendingId,
|
|
575
|
+
channelId: "everyone",
|
|
576
|
+
content,
|
|
577
|
+
replyToId: replyToId ?? null,
|
|
578
|
+
createdAt: new Date().toISOString(),
|
|
579
|
+
user: {
|
|
580
|
+
id: this.user?.id ?? "local",
|
|
581
|
+
username: this.user?.username ?? "you",
|
|
582
|
+
displayName: this.user?.username ?? "You",
|
|
583
|
+
},
|
|
584
|
+
replyTo: replyToMessage
|
|
585
|
+
? {
|
|
586
|
+
content: replyToMessage.content,
|
|
587
|
+
user: { username: replyToMessage.user.username },
|
|
588
|
+
}
|
|
589
|
+
: null,
|
|
590
|
+
clientStatus: "sending",
|
|
591
|
+
clientError: null,
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
private reconcilePendingMessages(messages: ChatMessage[]): void {
|
|
596
|
+
if (this.pendingMessages.length === 0 || messages.length === 0) return;
|
|
597
|
+
|
|
598
|
+
const remaining = [...this.pendingMessages];
|
|
599
|
+
for (const incoming of messages) {
|
|
600
|
+
const incomingMs = toTimestampMillis(incoming.createdAt);
|
|
601
|
+
const pendingIndex = remaining.findIndex((pending) => (
|
|
602
|
+
pending.user.id === incoming.user.id
|
|
603
|
+
&& pending.content === incoming.content
|
|
604
|
+
&& pending.replyToId === incoming.replyToId
|
|
605
|
+
&& Math.abs(toTimestampMillis(pending.createdAt) - incomingMs) <= PENDING_RECONCILE_WINDOW_MS
|
|
606
|
+
));
|
|
607
|
+
if (pendingIndex >= 0) {
|
|
608
|
+
remaining.splice(pendingIndex, 1);
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
this.pendingMessages = remaining;
|
|
612
|
+
}
|
|
338
613
|
}
|
|
339
614
|
|
|
340
615
|
export const chatController = new ChatController();
|
|
@@ -13,6 +13,8 @@ import { ChatController } from "./chat-controller";
|
|
|
13
13
|
const TRANSCRIPT_KIND = "channel-transcript";
|
|
14
14
|
const TRANSCRIPT_KEY = "everyone";
|
|
15
15
|
const TRANSCRIPT_SOURCE = "server";
|
|
16
|
+
const TRANSCRIPT_SCHEMA_VERSION = 2;
|
|
17
|
+
const originalConnectChannel = apiClient.connectChannel.bind(apiClient);
|
|
16
18
|
|
|
17
19
|
let testSetup: Awaited<ReturnType<typeof testRender>> | undefined;
|
|
18
20
|
|
|
@@ -115,17 +117,18 @@ function createController(options: {
|
|
|
115
117
|
persistence.setState("channel:everyone", {
|
|
116
118
|
draft: "",
|
|
117
119
|
replyToId: null,
|
|
118
|
-
lastCursor: messages[messages.length - 1]?.
|
|
120
|
+
lastCursor: messages[messages.length - 1]?.id ?? null,
|
|
119
121
|
}, { schemaVersion: 1 });
|
|
120
122
|
if (messages.length > 0) {
|
|
121
123
|
persistence.setResource(TRANSCRIPT_KIND, TRANSCRIPT_KEY, { messages }, {
|
|
122
124
|
sourceKey: TRANSCRIPT_SOURCE,
|
|
123
|
-
schemaVersion:
|
|
125
|
+
schemaVersion: TRANSCRIPT_SCHEMA_VERSION,
|
|
124
126
|
cachePolicy: { staleMs: 1_000, expireMs: 2_000 },
|
|
125
127
|
});
|
|
126
128
|
}
|
|
127
129
|
controller.attachPersistence(persistence);
|
|
128
130
|
controller.refreshSession = async () => {};
|
|
131
|
+
controller.refreshMessages = async () => {};
|
|
129
132
|
return controller;
|
|
130
133
|
}
|
|
131
134
|
|
|
@@ -164,6 +167,7 @@ async function flushFrame() {
|
|
|
164
167
|
afterEach(async () => {
|
|
165
168
|
setSharedRegistryForTests(undefined);
|
|
166
169
|
setSharedDataProviderForTests(undefined);
|
|
170
|
+
apiClient.connectChannel = originalConnectChannel;
|
|
167
171
|
apiClient.setSessionToken(null);
|
|
168
172
|
|
|
169
173
|
if (testSetup) {
|
|
@@ -211,6 +215,34 @@ describe("ChatContent", () => {
|
|
|
211
215
|
expect(frameAfterType).not.toContain("> FCD");
|
|
212
216
|
});
|
|
213
217
|
|
|
218
|
+
test("renders optimistic sends with a sending status", async () => {
|
|
219
|
+
const controller = createController({
|
|
220
|
+
messages: [{
|
|
221
|
+
id: "local:1",
|
|
222
|
+
channelId: "everyone",
|
|
223
|
+
content: "hello",
|
|
224
|
+
replyToId: null,
|
|
225
|
+
createdAt: "2026-03-28T00:00:00.000Z",
|
|
226
|
+
user: { id: "u0", username: "vince", displayName: "Vince" },
|
|
227
|
+
clientStatus: "sending",
|
|
228
|
+
clientError: null,
|
|
229
|
+
}],
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
await act(async () => {
|
|
233
|
+
testSetup = await testRender(createHarness(controller), {
|
|
234
|
+
width: 60,
|
|
235
|
+
height: 12,
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
await flushFrame();
|
|
240
|
+
|
|
241
|
+
const frameAfterSubmit = testSetup.captureCharFrame();
|
|
242
|
+
expect(frameAfterSubmit).toContain("hello");
|
|
243
|
+
expect(frameAfterSubmit).toContain("sending...");
|
|
244
|
+
});
|
|
245
|
+
|
|
214
246
|
test("auto-scrolls to newly appended messages while following the latest transcript", async () => {
|
|
215
247
|
const controller = createController({
|
|
216
248
|
messages: [
|
|
@@ -323,7 +355,7 @@ describe("ChatContent", () => {
|
|
|
323
355
|
expect(opened).toEqual(["TSLA"]);
|
|
324
356
|
});
|
|
325
357
|
|
|
326
|
-
test("shows a saved-login
|
|
358
|
+
test("shows a saved-login read-only footer when a session token is cached", async () => {
|
|
327
359
|
const controller = createController({
|
|
328
360
|
sessionToken: "token-123",
|
|
329
361
|
user: null,
|
|
@@ -340,8 +372,33 @@ describe("ChatContent", () => {
|
|
|
340
372
|
|
|
341
373
|
const frame = testSetup.captureCharFrame();
|
|
342
374
|
expect(frame).toContain("Saved login found.");
|
|
343
|
-
expect(frame).toContain("
|
|
344
|
-
expect(frame).
|
|
375
|
+
expect(frame).toContain("Log in again to send.");
|
|
376
|
+
expect(frame).toContain("No messages yet.");
|
|
377
|
+
expect(frame).not.toContain("Type a message...");
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
test("keeps the transcript visible for logged-out users and blocks the composer", async () => {
|
|
381
|
+
const controller = createController({
|
|
382
|
+
sessionToken: null,
|
|
383
|
+
user: null,
|
|
384
|
+
messages: [makeMessage(1)],
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
await act(async () => {
|
|
388
|
+
testSetup = await testRender(createHarness(controller), {
|
|
389
|
+
width: 60,
|
|
390
|
+
height: 12,
|
|
391
|
+
});
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
await flushFrame();
|
|
395
|
+
|
|
396
|
+
const frame = testSetup.captureCharFrame();
|
|
397
|
+
expect(frame).toContain("message 1");
|
|
398
|
+
expect(frame).toContain("Read-only chat.");
|
|
399
|
+
expect(frame).toContain("Login");
|
|
400
|
+
expect(frame).toContain("Sign Up");
|
|
401
|
+
expect(frame).not.toContain("Type a message...");
|
|
345
402
|
});
|
|
346
403
|
|
|
347
404
|
test("shows a logged-in icon in the cloud status widget for cached sessions", async () => {
|
|
@@ -365,7 +422,7 @@ describe("ChatContent", () => {
|
|
|
365
422
|
|
|
366
423
|
const frame = testSetup.captureCharFrame();
|
|
367
424
|
expect(frame).toContain("@");
|
|
368
|
-
expect(frame).toContain("Shift+C");
|
|
425
|
+
expect(frame).not.toContain("Shift+C");
|
|
369
426
|
expect(frame).not.toContain("vince");
|
|
370
427
|
});
|
|
371
428
|
|
|
@@ -415,6 +472,61 @@ describe("ChatContent", () => {
|
|
|
415
472
|
expect(openedQueries).toEqual(["Sign Up"]);
|
|
416
473
|
});
|
|
417
474
|
|
|
475
|
+
test("shows an unread mention badge and opens chat from the status widget", async () => {
|
|
476
|
+
const controller = createController({
|
|
477
|
+
sessionToken: "token-123",
|
|
478
|
+
user: { id: "u1", username: "vince", emailVerified: true },
|
|
479
|
+
});
|
|
480
|
+
const openedWidgets: string[] = [];
|
|
481
|
+
const state = createInitialState(createDefaultConfig("/tmp/gloomberb-chat"));
|
|
482
|
+
state.config.disabledPlugins = [];
|
|
483
|
+
|
|
484
|
+
setSharedRegistryForTests({
|
|
485
|
+
showWidget(paneId: string) {
|
|
486
|
+
openedWidgets.push(paneId);
|
|
487
|
+
},
|
|
488
|
+
} as any);
|
|
489
|
+
|
|
490
|
+
await act(async () => {
|
|
491
|
+
(controller as any).mergeMessages([{
|
|
492
|
+
id: "m1",
|
|
493
|
+
channelId: "everyone",
|
|
494
|
+
content: "pinging @vince before the bell",
|
|
495
|
+
replyToId: null,
|
|
496
|
+
createdAt: "2026-03-28T00:00:00.000Z",
|
|
497
|
+
user: { id: "u2", username: "bob", displayName: "Bob" },
|
|
498
|
+
} satisfies ChatMessage]);
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
await act(async () => {
|
|
502
|
+
testSetup = await testRender(
|
|
503
|
+
<AppContext value={{ state, dispatch: () => {} }}>
|
|
504
|
+
<ChatStatusWidget controller={controller} />
|
|
505
|
+
</AppContext>,
|
|
506
|
+
{ width: 40, height: 1 },
|
|
507
|
+
);
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
await flushFrame();
|
|
511
|
+
|
|
512
|
+
const frame = testSetup.captureCharFrame();
|
|
513
|
+
expect(frame).toContain("vince");
|
|
514
|
+
expect(frame).toContain("[1]");
|
|
515
|
+
|
|
516
|
+
const line = frame.split("\n")[0] ?? "";
|
|
517
|
+
const badgeCol = line.indexOf("[1]");
|
|
518
|
+
|
|
519
|
+
expect(badgeCol).toBeGreaterThanOrEqual(0);
|
|
520
|
+
|
|
521
|
+
await act(async () => {
|
|
522
|
+
await testSetup!.mockMouse.click(badgeCol + 1, 0);
|
|
523
|
+
await testSetup!.renderOnce();
|
|
524
|
+
await testSetup!.renderOnce();
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
expect(openedWidgets).toEqual(["chat"]);
|
|
528
|
+
});
|
|
529
|
+
|
|
418
530
|
test("auth commands use a single-form layout and signup no longer prompts for display name", () => {
|
|
419
531
|
const registeredCommands: Array<{ id: string; wizardLayout?: string; wizard?: Array<{ key: string }> }> = [];
|
|
420
532
|
|