gloomberb 0.3.7 → 0.4.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/README.md +19 -8
- package/package.json +1 -1
- package/src/app.tsx +45 -69
- 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 +363 -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 +4 -2
- package/src/components/layout/header.tsx +3 -3
- package/src/components/layout/pane-header.tsx +26 -2
- package/src/components/layout/pane-sizing.ts +6 -0
- package/src/components/layout/pane.tsx +5 -1
- package/src/components/layout/shell.test.tsx +77 -5
- package/src/components/layout/shell.tsx +67 -52
- 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 +181 -97
- package/src/components/ui/data-table.tsx +50 -5
- 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 +400 -3
- package/src/plugins/builtin/chat-controller.ts +328 -35
- package/src/plugins/builtin/chat.test.tsx +118 -6
- package/src/plugins/builtin/chat.tsx +180 -116
- package/src/plugins/builtin/comparison-chart.test.tsx +66 -5
- package/src/plugins/builtin/comparison-chart.tsx +21 -1
- package/src/plugins/builtin/layout-manager.tsx +9 -0
- 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 +67 -1
- package/src/plugins/ibkr/gateway-service.ts +147 -7
- package/src/plugins/ibkr/index.tsx +7 -1
- 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 +132 -17
- package/src/plugins/prediction-markets/controller.ts +142 -122
- package/src/plugins/prediction-markets/index.tsx +7 -1
- 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 +197 -57
- package/src/plugins/prediction-markets/pane.tsx +80 -155
- package/src/plugins/prediction-markets/services/fetch.ts +4 -0
- package/src/plugins/prediction-markets/table.tsx +6 -3
- package/src/plugins/prediction-markets/test-helpers.tsx +20 -5
- package/src/plugins/prediction-markets/types.ts +0 -1
- package/src/plugins/registry.ts +23 -0
- 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/updater.test.ts +2 -0
- package/src/utils/api-client.test.ts +57 -0
- package/src/utils/api-client.ts +47 -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,7 @@
|
|
|
1
1
|
import type { PluginPersistence, PluginResumeState } from "../../types/plugin";
|
|
2
2
|
import { apiClient, type ChatMessage, type PersistedAuthUser } from "../../utils/api-client";
|
|
3
|
+
import { debugLog } from "../../utils/debug-log";
|
|
4
|
+
import { toTimestampMillis } from "../../utils/timestamp";
|
|
3
5
|
|
|
4
6
|
const SESSION_STATE_KEY = "session";
|
|
5
7
|
const CHANNEL_STATE_KEY = "channel:everyone";
|
|
@@ -8,12 +10,16 @@ const TRANSCRIPT_SOURCE = "server";
|
|
|
8
10
|
const MAX_CACHED_MESSAGES = 50;
|
|
9
11
|
const SESSION_SCHEMA_VERSION = 1;
|
|
10
12
|
const CHANNEL_SCHEMA_VERSION = 1;
|
|
11
|
-
const TRANSCRIPT_SCHEMA_VERSION =
|
|
13
|
+
const TRANSCRIPT_SCHEMA_VERSION = 2;
|
|
12
14
|
const TRANSCRIPT_CACHE_POLICY = {
|
|
13
15
|
staleMs: 30 * 24 * 60 * 60_000,
|
|
14
16
|
expireMs: 90 * 24 * 60 * 60_000,
|
|
15
17
|
};
|
|
16
18
|
const VERIFICATION_POLL_MS = 5_000;
|
|
19
|
+
const ISO_TIMESTAMP_CURSOR = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/;
|
|
20
|
+
const USERNAME_MENTION = /(^|[^A-Za-z0-9_])@([A-Za-z][A-Za-z0-9_]{2,29})(?![A-Za-z0-9_])/g;
|
|
21
|
+
const PENDING_RECONCILE_WINDOW_MS = 2 * 60_000;
|
|
22
|
+
const chatLog = debugLog.createLogger("chat-controller");
|
|
17
23
|
|
|
18
24
|
interface PersistedSessionState {
|
|
19
25
|
sessionToken: string | null;
|
|
@@ -25,6 +31,7 @@ interface PersistedChannelState {
|
|
|
25
31
|
draft: string;
|
|
26
32
|
replyToId: string | null;
|
|
27
33
|
lastCursor: string | null;
|
|
34
|
+
lastViewedMessageId?: string | null;
|
|
28
35
|
}
|
|
29
36
|
|
|
30
37
|
interface PersistedTranscript {
|
|
@@ -38,9 +45,45 @@ export interface ChatControllerSnapshot {
|
|
|
38
45
|
messages: ChatMessage[];
|
|
39
46
|
draft: string;
|
|
40
47
|
replyToId: string | null;
|
|
48
|
+
unreadMentionCount: number;
|
|
41
49
|
}
|
|
42
50
|
|
|
43
|
-
type ChatConnection = { send: (content: string, replyToId?: string) =>
|
|
51
|
+
type ChatConnection = { send: (content: string, replyToId?: string) => Promise<ChatMessage>; close: () => void };
|
|
52
|
+
type ChatToastOptions = { duration?: number; type?: "info" | "success" | "error" };
|
|
53
|
+
|
|
54
|
+
function normalizeUsername(username: string | null | undefined): string | null {
|
|
55
|
+
const trimmed = username?.trim();
|
|
56
|
+
return trimmed ? trimmed.toLowerCase() : null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function messageMentionsUsername(content: string, username: string): boolean {
|
|
60
|
+
USERNAME_MENTION.lastIndex = 0;
|
|
61
|
+
let match: RegExpExecArray | null;
|
|
62
|
+
while ((match = USERNAME_MENTION.exec(content)) !== null) {
|
|
63
|
+
if ((match[2] ?? "").toLowerCase() === username) {
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function formatMentionToast(message: ChatMessage): string {
|
|
71
|
+
const author = message.user.username || "Someone";
|
|
72
|
+
const normalized = message.content.replace(/\s+/g, " ").trim();
|
|
73
|
+
if (!normalized) {
|
|
74
|
+
return `@${author} mentioned you in chat.`;
|
|
75
|
+
}
|
|
76
|
+
const snippet = normalized.length > 72 ? `${normalized.slice(0, 69)}...` : normalized;
|
|
77
|
+
return `@${author} mentioned you: ${snippet}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function isLegacyTimestampCursor(cursor: string | null): boolean {
|
|
81
|
+
return !!cursor && ISO_TIMESTAMP_CURSOR.test(cursor);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function compareMessages(a: ChatMessage, b: ChatMessage): number {
|
|
85
|
+
return a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id);
|
|
86
|
+
}
|
|
44
87
|
|
|
45
88
|
export class ChatController {
|
|
46
89
|
private persistence: PluginPersistence | null = null;
|
|
@@ -48,14 +91,21 @@ export class ChatController {
|
|
|
48
91
|
private appActive = true;
|
|
49
92
|
private hydrated = false;
|
|
50
93
|
private sessionChecked = false;
|
|
94
|
+
private messagesLoading = false;
|
|
95
|
+
private refreshMessagesPromise: Promise<void> | null = null;
|
|
51
96
|
private user: { id: string; username: string; emailVerified: boolean } | null = null;
|
|
52
97
|
private messages: ChatMessage[] = [];
|
|
98
|
+
private pendingMessages: ChatMessage[] = [];
|
|
53
99
|
private draft = "";
|
|
54
100
|
private replyToId: string | null = null;
|
|
55
101
|
private lastCursor: string | null = null;
|
|
102
|
+
private lastViewedMessageId: string | null = null;
|
|
56
103
|
private wsConnection: ChatConnection | null = null;
|
|
57
104
|
private wsConnected = false;
|
|
58
105
|
private verificationPollTimer: ReturnType<typeof setInterval> | null = null;
|
|
106
|
+
private openViewCount = 0;
|
|
107
|
+
private pendingMessageSeq = 0;
|
|
108
|
+
private toastFn: (message: string, options?: ChatToastOptions) => void = () => {};
|
|
59
109
|
private listeners = new Set<(snapshot: ChatControllerSnapshot) => void>();
|
|
60
110
|
|
|
61
111
|
attachPersistence(persistence: PluginPersistence, resume?: PluginResumeState): void {
|
|
@@ -64,6 +114,10 @@ export class ChatController {
|
|
|
64
114
|
this.hydrate();
|
|
65
115
|
}
|
|
66
116
|
|
|
117
|
+
setToastNotifier(notify: (message: string, options?: ChatToastOptions) => void): void {
|
|
118
|
+
this.toastFn = notify;
|
|
119
|
+
}
|
|
120
|
+
|
|
67
121
|
hydrate(): void {
|
|
68
122
|
if (this.hydrated || !this.persistence) return;
|
|
69
123
|
this.hydrated = true;
|
|
@@ -92,7 +146,8 @@ export class ChatController {
|
|
|
92
146
|
});
|
|
93
147
|
this.draft = channel?.draft ?? "";
|
|
94
148
|
this.replyToId = channel?.replyToId ?? null;
|
|
95
|
-
|
|
149
|
+
const persistedCursor = channel?.lastCursor ?? null;
|
|
150
|
+
const persistedViewedMessageId = channel?.lastViewedMessageId ?? null;
|
|
96
151
|
|
|
97
152
|
const transcript = this.persistence.getResource<PersistedTranscript>(TRANSCRIPT_KIND, "everyone", {
|
|
98
153
|
sourceKey: TRANSCRIPT_SOURCE,
|
|
@@ -100,6 +155,12 @@ export class ChatController {
|
|
|
100
155
|
allowExpired: true,
|
|
101
156
|
});
|
|
102
157
|
this.messages = transcript?.value.messages ?? [];
|
|
158
|
+
this.lastCursor = this.messages.length > 0
|
|
159
|
+
? persistedCursor ?? this.messages[this.messages.length - 1]?.id ?? null
|
|
160
|
+
: null;
|
|
161
|
+
this.lastViewedMessageId = this.user?.id && apiClient.getSessionToken()
|
|
162
|
+
? persistedViewedMessageId ?? this.messages[this.messages.length - 1]?.id ?? null
|
|
163
|
+
: null;
|
|
103
164
|
this.syncVerificationPolling();
|
|
104
165
|
}
|
|
105
166
|
|
|
@@ -113,12 +174,13 @@ export class ChatController {
|
|
|
113
174
|
|
|
114
175
|
getSnapshot(): ChatControllerSnapshot {
|
|
115
176
|
return {
|
|
116
|
-
loading: !this.sessionChecked,
|
|
177
|
+
loading: !this.sessionChecked || this.messagesLoading,
|
|
117
178
|
hasSavedSession: !!apiClient.getSessionToken(),
|
|
118
179
|
user: this.user,
|
|
119
|
-
messages:
|
|
180
|
+
messages: this.getVisibleMessages(),
|
|
120
181
|
draft: this.draft,
|
|
121
182
|
replyToId: this.replyToId,
|
|
183
|
+
unreadMentionCount: this.getUnreadMentionCount(),
|
|
122
184
|
};
|
|
123
185
|
}
|
|
124
186
|
|
|
@@ -126,18 +188,42 @@ export class ChatController {
|
|
|
126
188
|
const token = apiClient.getSessionToken();
|
|
127
189
|
if (!token) {
|
|
128
190
|
this.stopVerificationPolling();
|
|
191
|
+
this.wsConnection?.close();
|
|
192
|
+
this.wsConnection = null;
|
|
193
|
+
this.wsConnected = false;
|
|
129
194
|
this.user = null;
|
|
130
195
|
this.sessionChecked = true;
|
|
196
|
+
this.pendingMessages = [];
|
|
197
|
+
this.lastViewedMessageId = null;
|
|
131
198
|
this.persistSession();
|
|
132
199
|
this.emit();
|
|
133
200
|
return;
|
|
134
201
|
}
|
|
135
202
|
|
|
136
203
|
const session = await apiClient.getSession();
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
204
|
+
if (!session) {
|
|
205
|
+
this.stopVerificationPolling();
|
|
206
|
+
this.wsConnection?.close();
|
|
207
|
+
this.wsConnection = null;
|
|
208
|
+
this.wsConnected = false;
|
|
209
|
+
apiClient.setSessionToken(null);
|
|
210
|
+
this.user = null;
|
|
211
|
+
this.sessionChecked = true;
|
|
212
|
+
this.pendingMessages = [];
|
|
213
|
+
this.lastViewedMessageId = null;
|
|
214
|
+
this.persistSession();
|
|
215
|
+
this.emit();
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const previousIdentity = `${this.user?.id ?? ""}:${normalizeUsername(this.user?.username) ?? ""}`;
|
|
220
|
+
const nextUser = { id: session.id, username: session.username ?? session.name, emailVerified: !!session.emailVerified };
|
|
140
221
|
this.user = nextUser;
|
|
222
|
+
const nextIdentity = `${nextUser.id}:${normalizeUsername(nextUser.username) ?? ""}`;
|
|
223
|
+
if (previousIdentity && previousIdentity !== nextIdentity) {
|
|
224
|
+
this.lastViewedMessageId = this.messages[this.messages.length - 1]?.id ?? null;
|
|
225
|
+
this.persistChannelState();
|
|
226
|
+
}
|
|
141
227
|
this.sessionChecked = true;
|
|
142
228
|
this.persistSession();
|
|
143
229
|
this.emit();
|
|
@@ -152,10 +238,26 @@ export class ChatController {
|
|
|
152
238
|
this.wsConnection?.close();
|
|
153
239
|
this.wsConnection = null;
|
|
154
240
|
this.wsConnected = false;
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async refreshMessages(): Promise<void> {
|
|
244
|
+
if (this.refreshMessagesPromise) return this.refreshMessagesPromise;
|
|
245
|
+
|
|
246
|
+
this.messagesLoading = true;
|
|
247
|
+
this.emit();
|
|
248
|
+
|
|
249
|
+
const request = this.fetchMessages()
|
|
250
|
+
.catch(() => {
|
|
251
|
+
this.persistChannelState();
|
|
252
|
+
})
|
|
253
|
+
.finally(() => {
|
|
254
|
+
this.messagesLoading = false;
|
|
255
|
+
this.refreshMessagesPromise = null;
|
|
256
|
+
this.emit();
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
this.refreshMessagesPromise = request;
|
|
260
|
+
return request;
|
|
159
261
|
}
|
|
160
262
|
|
|
161
263
|
setDraft(draft: string): void {
|
|
@@ -170,6 +272,16 @@ export class ChatController {
|
|
|
170
272
|
this.emit();
|
|
171
273
|
}
|
|
172
274
|
|
|
275
|
+
attachView(): () => void {
|
|
276
|
+
this.openViewCount += 1;
|
|
277
|
+
if (this.markViewedThroughLatestMessage()) {
|
|
278
|
+
this.emit();
|
|
279
|
+
}
|
|
280
|
+
return () => {
|
|
281
|
+
this.openViewCount = Math.max(0, this.openViewCount - 1);
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
173
285
|
clearSession(): void {
|
|
174
286
|
this.stopVerificationPolling();
|
|
175
287
|
this.wsConnection?.close();
|
|
@@ -177,6 +289,8 @@ export class ChatController {
|
|
|
177
289
|
this.wsConnected = false;
|
|
178
290
|
this.user = null;
|
|
179
291
|
this.sessionChecked = false;
|
|
292
|
+
this.pendingMessages = [];
|
|
293
|
+
this.lastViewedMessageId = null;
|
|
180
294
|
this.emit();
|
|
181
295
|
}
|
|
182
296
|
|
|
@@ -196,9 +310,12 @@ export class ChatController {
|
|
|
196
310
|
this.wsConnection = null;
|
|
197
311
|
this.wsConnected = false;
|
|
198
312
|
this.messages = [];
|
|
313
|
+
this.pendingMessages = [];
|
|
199
314
|
this.draft = "";
|
|
200
315
|
this.replyToId = null;
|
|
201
316
|
this.lastCursor = null;
|
|
317
|
+
this.lastViewedMessageId = null;
|
|
318
|
+
this.openViewCount = 0;
|
|
202
319
|
if (clearSession) {
|
|
203
320
|
this.user = null;
|
|
204
321
|
apiClient.setSessionToken(null);
|
|
@@ -211,33 +328,60 @@ export class ChatController {
|
|
|
211
328
|
this.emit();
|
|
212
329
|
}
|
|
213
330
|
|
|
331
|
+
dispose(): void {
|
|
332
|
+
chatLog.info("dispose controller", {
|
|
333
|
+
listeners: this.listeners.size,
|
|
334
|
+
hasConnection: !!this.wsConnection,
|
|
335
|
+
});
|
|
336
|
+
this.stopVerificationPolling();
|
|
337
|
+
this.wsConnection?.close();
|
|
338
|
+
this.wsConnection = null;
|
|
339
|
+
this.wsConnected = false;
|
|
340
|
+
this.messagesLoading = false;
|
|
341
|
+
this.refreshMessagesPromise = null;
|
|
342
|
+
this.openViewCount = 0;
|
|
343
|
+
this.toastFn = () => {};
|
|
344
|
+
this.listeners.clear();
|
|
345
|
+
}
|
|
346
|
+
|
|
214
347
|
send(content: string, replyToId?: string): void {
|
|
215
|
-
this.
|
|
216
|
-
this.
|
|
217
|
-
|
|
348
|
+
if (!this.user?.emailVerified || !apiClient.getSessionToken()) return;
|
|
349
|
+
if (!this.wsConnection && this.user?.emailVerified && apiClient.getSessionToken()) {
|
|
350
|
+
this.ensureConnection();
|
|
351
|
+
}
|
|
352
|
+
const connection = this.wsConnection;
|
|
353
|
+
if (!connection) {
|
|
354
|
+
this.toastFn("Unable to send message right now.", { type: "error" });
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const pendingMessage = this.createPendingMessage(content, replyToId);
|
|
359
|
+
this.pendingMessages = [...this.pendingMessages, pendingMessage];
|
|
360
|
+
this.draft = "";
|
|
361
|
+
this.replyToId = null;
|
|
362
|
+
this.persistChannelState();
|
|
363
|
+
this.emit();
|
|
364
|
+
|
|
365
|
+
void connection.send(content, replyToId).then((message) => {
|
|
366
|
+
this.pendingMessages = this.pendingMessages.filter((entry) => entry.id !== pendingMessage.id);
|
|
367
|
+
this.mergeMessages([message]);
|
|
368
|
+
}).catch((error) => {
|
|
369
|
+
const errorMessage = error instanceof Error && error.message ? error.message : "Failed to send message.";
|
|
370
|
+
this.pendingMessages = this.pendingMessages.map((entry) => (
|
|
371
|
+
entry.id === pendingMessage.id
|
|
372
|
+
? { ...entry, clientStatus: "failed", clientError: errorMessage }
|
|
373
|
+
: entry
|
|
374
|
+
));
|
|
375
|
+
this.emit();
|
|
376
|
+
this.toastFn(errorMessage, { type: "error" });
|
|
377
|
+
});
|
|
218
378
|
}
|
|
219
379
|
|
|
220
380
|
ensureConnection(): void {
|
|
221
381
|
if (this.wsConnected || !this.user?.emailVerified || !apiClient.getSessionToken()) return;
|
|
222
382
|
this.wsConnected = true;
|
|
223
383
|
|
|
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
|
-
});
|
|
384
|
+
void this.refreshMessages().catch(() => {});
|
|
241
385
|
|
|
242
386
|
this.wsConnection = apiClient.connectChannel(
|
|
243
387
|
"everyone",
|
|
@@ -279,14 +423,57 @@ export class ChatController {
|
|
|
279
423
|
this.verificationPollTimer = null;
|
|
280
424
|
}
|
|
281
425
|
|
|
426
|
+
private async fetchMessages(): Promise<void> {
|
|
427
|
+
const legacyTimestampCursor = isLegacyTimestampCursor(this.lastCursor);
|
|
428
|
+
|
|
429
|
+
try {
|
|
430
|
+
const messages = await apiClient.getMessages("everyone", {
|
|
431
|
+
limit: MAX_CACHED_MESSAGES,
|
|
432
|
+
after: this.lastCursor ?? undefined,
|
|
433
|
+
});
|
|
434
|
+
if (messages.length > 0) {
|
|
435
|
+
this.mergeMessages(messages);
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
if (legacyTimestampCursor) {
|
|
439
|
+
const fullRefresh = await apiClient.getMessages("everyone", { limit: MAX_CACHED_MESSAGES });
|
|
440
|
+
if (fullRefresh.length > 0) {
|
|
441
|
+
this.mergeMessages(fullRefresh);
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
this.lastCursor = null;
|
|
445
|
+
}
|
|
446
|
+
this.persistChannelState();
|
|
447
|
+
return;
|
|
448
|
+
} catch {
|
|
449
|
+
const messages = await apiClient.getMessages("everyone", { limit: MAX_CACHED_MESSAGES });
|
|
450
|
+
if (messages.length > 0) {
|
|
451
|
+
this.mergeMessages(messages);
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
this.persistChannelState();
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
282
458
|
private mergeMessages(messages: ChatMessage[]): void {
|
|
459
|
+
this.reconcilePendingMessages(messages);
|
|
283
460
|
const merged = new Map<string, ChatMessage>();
|
|
461
|
+
const incoming = new Map<string, ChatMessage>();
|
|
284
462
|
for (const message of this.messages) merged.set(message.id, message);
|
|
285
|
-
for (const message of messages)
|
|
463
|
+
for (const message of messages) {
|
|
464
|
+
if (!merged.has(message.id)) {
|
|
465
|
+
incoming.set(message.id, message);
|
|
466
|
+
}
|
|
467
|
+
merged.set(message.id, message);
|
|
468
|
+
}
|
|
286
469
|
this.messages = [...merged.values()]
|
|
287
|
-
.sort(
|
|
470
|
+
.sort(compareMessages)
|
|
288
471
|
.slice(-MAX_CACHED_MESSAGES);
|
|
289
|
-
this.lastCursor = this.messages[this.messages.length - 1]?.
|
|
472
|
+
this.lastCursor = this.messages[this.messages.length - 1]?.id ?? this.lastCursor;
|
|
473
|
+
if (this.openViewCount > 0) {
|
|
474
|
+
this.markViewedThroughLatestMessage(false);
|
|
475
|
+
}
|
|
476
|
+
this.trackUnreadMentions([...incoming.values()]);
|
|
290
477
|
this.persistTranscript();
|
|
291
478
|
this.emit();
|
|
292
479
|
}
|
|
@@ -310,6 +497,7 @@ export class ChatController {
|
|
|
310
497
|
draft: this.draft,
|
|
311
498
|
replyToId: this.replyToId,
|
|
312
499
|
lastCursor: this.lastCursor,
|
|
500
|
+
lastViewedMessageId: this.lastViewedMessageId,
|
|
313
501
|
} satisfies PersistedChannelState;
|
|
314
502
|
this.resume?.setState(CHANNEL_STATE_KEY, value, {
|
|
315
503
|
schemaVersion: CHANNEL_SCHEMA_VERSION,
|
|
@@ -335,6 +523,111 @@ export class ChatController {
|
|
|
335
523
|
});
|
|
336
524
|
this.persistChannelState();
|
|
337
525
|
}
|
|
526
|
+
|
|
527
|
+
private trackUnreadMentions(messages: ChatMessage[]): void {
|
|
528
|
+
if (this.openViewCount > 0) {
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
const freshMentions = this.getMentionMessages(messages);
|
|
533
|
+
if (freshMentions.length === 0) return;
|
|
534
|
+
if (!this.appActive) return;
|
|
535
|
+
|
|
536
|
+
if (freshMentions.length === 1) {
|
|
537
|
+
this.toastFn(formatMentionToast(freshMentions[0]!), { type: "info" });
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
this.toastFn(`${freshMentions.length} new mentions in #everyone.`, { type: "info" });
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
private getUnreadMentionCount(): number {
|
|
544
|
+
return this.getUnreadMentionMessages().length;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
private getUnreadMentionMessages(): ChatMessage[] {
|
|
548
|
+
if (!this.lastViewedMessageId) {
|
|
549
|
+
return this.getMentionMessages(this.messages);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
const viewedIndex = this.messages.findIndex((message) => message.id === this.lastViewedMessageId);
|
|
553
|
+
const unseenMessages = viewedIndex >= 0
|
|
554
|
+
? this.messages.slice(viewedIndex + 1)
|
|
555
|
+
: this.messages;
|
|
556
|
+
return this.getMentionMessages(unseenMessages);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
private getMentionMessages(messages: ChatMessage[]): ChatMessage[] {
|
|
560
|
+
const normalizedUsername = normalizeUsername(this.user?.username);
|
|
561
|
+
if (!normalizedUsername || messages.length === 0) return [];
|
|
562
|
+
|
|
563
|
+
return messages.filter((message) => (
|
|
564
|
+
message.user.id !== this.user?.id && messageMentionsUsername(message.content, normalizedUsername)
|
|
565
|
+
));
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
private markViewedThroughLatestMessage(persist = true): boolean {
|
|
569
|
+
const latestMessageId = this.messages[this.messages.length - 1]?.id ?? null;
|
|
570
|
+
if (this.lastViewedMessageId === latestMessageId) {
|
|
571
|
+
return false;
|
|
572
|
+
}
|
|
573
|
+
this.lastViewedMessageId = latestMessageId;
|
|
574
|
+
if (persist) {
|
|
575
|
+
this.persistChannelState();
|
|
576
|
+
}
|
|
577
|
+
return true;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
private getVisibleMessages(): ChatMessage[] {
|
|
581
|
+
return [...this.messages, ...this.pendingMessages]
|
|
582
|
+
.sort(compareMessages)
|
|
583
|
+
.slice(-MAX_CACHED_MESSAGES);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
private createPendingMessage(content: string, replyToId?: string): ChatMessage {
|
|
587
|
+
const replyToMessage = replyToId
|
|
588
|
+
? this.getVisibleMessages().find((message) => message.id === replyToId) ?? null
|
|
589
|
+
: null;
|
|
590
|
+
const pendingId = `local:${Date.now()}:${this.pendingMessageSeq += 1}`;
|
|
591
|
+
return {
|
|
592
|
+
id: pendingId,
|
|
593
|
+
channelId: "everyone",
|
|
594
|
+
content,
|
|
595
|
+
replyToId: replyToId ?? null,
|
|
596
|
+
createdAt: new Date().toISOString(),
|
|
597
|
+
user: {
|
|
598
|
+
id: this.user?.id ?? "local",
|
|
599
|
+
username: this.user?.username ?? "you",
|
|
600
|
+
displayName: this.user?.username ?? "You",
|
|
601
|
+
},
|
|
602
|
+
replyTo: replyToMessage
|
|
603
|
+
? {
|
|
604
|
+
content: replyToMessage.content,
|
|
605
|
+
user: { username: replyToMessage.user.username },
|
|
606
|
+
}
|
|
607
|
+
: null,
|
|
608
|
+
clientStatus: "sending",
|
|
609
|
+
clientError: null,
|
|
610
|
+
};
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
private reconcilePendingMessages(messages: ChatMessage[]): void {
|
|
614
|
+
if (this.pendingMessages.length === 0 || messages.length === 0) return;
|
|
615
|
+
|
|
616
|
+
const remaining = [...this.pendingMessages];
|
|
617
|
+
for (const incoming of messages) {
|
|
618
|
+
const incomingMs = toTimestampMillis(incoming.createdAt);
|
|
619
|
+
const pendingIndex = remaining.findIndex((pending) => (
|
|
620
|
+
pending.user.id === incoming.user.id
|
|
621
|
+
&& pending.content === incoming.content
|
|
622
|
+
&& pending.replyToId === incoming.replyToId
|
|
623
|
+
&& Math.abs(toTimestampMillis(pending.createdAt) - incomingMs) <= PENDING_RECONCILE_WINDOW_MS
|
|
624
|
+
));
|
|
625
|
+
if (pendingIndex >= 0) {
|
|
626
|
+
remaining.splice(pendingIndex, 1);
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
this.pendingMessages = remaining;
|
|
630
|
+
}
|
|
338
631
|
}
|
|
339
632
|
|
|
340
633
|
export const chatController = new ChatController();
|