gloomberb 0.8.1 → 0.8.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/README.md +102 -52
- package/package.json +1 -1
- package/src/api-client/chat.ts +8 -0
- package/src/api-client/index.test.ts +30 -0
- package/src/api-client/index.ts +4 -0
- package/src/api-client/normalizers.ts +1 -0
- package/src/api-client/types.ts +1 -0
- package/src/capabilities/factories.ts +4 -0
- package/src/components/chart/comparison/data.test.ts +54 -0
- package/src/components/chart/comparison/data.ts +47 -24
- package/src/components/chart/core/projection.ts +51 -27
- package/src/components/data-table/stack-view.test.tsx +14 -11
- package/src/components/data-table/stack-view.tsx +1 -2
- package/src/components/data-table/view.test.tsx +24 -18
- package/src/components/data-table/view.tsx +313 -83
- package/src/components/feed-data-table/stack-view.tsx +6 -8
- package/src/components/layout/pane/footer/index.test.tsx +6 -0
- package/src/components/layout/pane/footer/index.tsx +14 -2
- package/src/components/layout/pane/title.ts +5 -1
- package/src/components/pane-settings-dialog/field-dialogs.tsx +0 -2
- package/src/components/pane-settings-dialog/tui.tsx +1 -1
- package/src/components/ticker/list-table-view.tsx +11 -25
- package/src/components/ui/choice-dialog.tsx +12 -2
- package/src/components/ui/multi-select/dialog.tsx +17 -5
- package/src/components/ui/shortcut-hint.tsx +14 -2
- package/src/components/ui/table-layout.test.ts +2 -2
- package/src/components/ui/table-layout.ts +8 -2
- package/src/components/ui/tabs.tsx +20 -6
- package/src/components/ui/ui.test.tsx +30 -5
- package/src/market-data/coordinator/auxiliary.ts +29 -1
- package/src/market-data/coordinator/index.ts +17 -1
- package/src/market-data/hooks.tsx +17 -1
- package/src/market-data/selectors.ts +4 -0
- package/src/plugins/builtin/account-management/form-components.tsx +0 -4
- package/src/plugins/builtin/account-management/model.test.ts +54 -0
- package/src/plugins/builtin/account-management/model.ts +13 -3
- package/src/plugins/builtin/account-management/pane.tsx +5 -11
- package/src/plugins/builtin/account-management/password-dialog.tsx +1 -1
- package/src/plugins/builtin/alerts/pane.tsx +5 -3
- package/src/plugins/builtin/analytics/index.tsx +0 -3
- package/src/plugins/builtin/analytics/view.tsx +6 -5
- package/src/plugins/builtin/broker-manager/index.tsx +6 -6
- package/src/plugins/builtin/buildout/pane/index.tsx +6 -6
- package/src/plugins/builtin/changelog/index.tsx +40 -27
- package/src/plugins/builtin/changelog/model.test.ts +77 -0
- package/src/plugins/builtin/changelog/model.ts +98 -0
- package/src/plugins/builtin/chat/channel-labels.ts +53 -0
- package/src/plugins/builtin/chat/channels.test.ts +28 -0
- package/src/plugins/builtin/chat/channels.ts +36 -19
- package/src/plugins/builtin/chat/chat.test.tsx +264 -5
- package/src/plugins/builtin/chat/content/channel-navigation.ts +6 -0
- package/src/plugins/builtin/chat/content/composer-runtime.test.tsx +144 -0
- package/src/plugins/builtin/chat/content/composer-runtime.ts +151 -20
- package/src/plugins/builtin/chat/content/composer.tsx +23 -2
- package/src/plugins/builtin/chat/content/index.tsx +61 -0
- package/src/plugins/builtin/chat/content/layout-metrics.ts +3 -1
- package/src/plugins/builtin/chat/content/shortcuts.ts +20 -1
- package/src/plugins/builtin/chat/content/transcript.tsx +8 -0
- package/src/plugins/builtin/chat/content/types.ts +1 -0
- package/src/plugins/builtin/chat/controller/index.ts +41 -0
- package/src/plugins/builtin/chat/controller/messages.ts +18 -0
- package/src/plugins/builtin/chat/controller/notifications.ts +10 -4
- package/src/plugins/builtin/chat/controller/utils.ts +6 -3
- package/src/plugins/builtin/chat/controller/view.ts +1 -1
- package/src/plugins/builtin/chat/controller.test.ts +189 -0
- package/src/plugins/builtin/chat/edit-window.ts +24 -0
- package/src/plugins/builtin/chat/layout.ts +94 -31
- package/src/plugins/builtin/chat/message/action-chip.tsx +1 -0
- package/src/plugins/builtin/chat/message/desktop.tsx +53 -20
- package/src/plugins/builtin/chat/message/inline-tokens.tsx +16 -10
- package/src/plugins/builtin/chat/message/render-state.ts +5 -1
- package/src/plugins/builtin/chat/message/terminal.tsx +49 -20
- package/src/plugins/builtin/chat/message/types.ts +2 -0
- package/src/plugins/builtin/chat/pane.tsx +35 -7
- package/src/plugins/builtin/chat/sidebar.test.tsx +1 -0
- package/src/plugins/builtin/chat/status-widget.tsx +29 -11
- package/src/plugins/builtin/chat/test-harness.tsx +2 -0
- package/src/plugins/builtin/cloud/plugin.tsx +3 -0
- package/src/plugins/builtin/cloud-tweets/table.tsx +7 -11
- package/src/plugins/builtin/congress-trades/pane.tsx +14 -22
- package/src/plugins/builtin/earnings/index.tsx +8 -7
- package/src/plugins/builtin/econ/index.tsx +6 -8
- package/src/plugins/builtin/help/index.tsx +6 -0
- package/src/plugins/builtin/holders/pane.tsx +7 -6
- package/src/plugins/builtin/market-movers/index.tsx +6 -3
- package/src/plugins/builtin/news/wire/news/table.tsx +7 -6
- package/src/plugins/builtin/options/view.tsx +16 -8
- package/src/plugins/builtin/research/analyst-pane.tsx +1 -2
- package/src/plugins/builtin/research/corporate-actions-pane.tsx +358 -7
- package/src/plugins/builtin/research/index.test.ts +30 -1
- package/src/plugins/builtin/research/relative-valuation-pane.tsx +6 -8
- package/src/plugins/builtin/sec/filing-documents.ts +66 -0
- package/src/plugins/builtin/sec/index.tsx +132 -20
- package/src/plugins/builtin/sectors/index.tsx +13 -23
- package/src/plugins/builtin/shared/ticker-request.ts +1 -0
- package/src/plugins/builtin/thirteenf/pane.tsx +26 -25
- package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/content.tsx +5 -4
- package/src/plugins/builtin/ticker-detail/data-panes/historical-prices.tsx +5 -4
- package/src/plugins/builtin/ticker-detail/data-panes/provider-search.tsx +6 -5
- package/src/plugins/builtin/ticker-detail/financials/tab.tsx +10 -8
- package/src/plugins/builtin/world-indices/index.tsx +9 -6
- package/src/plugins/prediction-markets/pane.tsx +10 -9
- package/src/plugins/registry/index.ts +3 -0
- package/src/plugins/runtime/context.tsx +1 -0
- package/src/plugins/runtime/index.tsx +4 -0
- package/src/renderers/electrobun/view/cli-pane-shot-entry.tsx +1 -0
- package/src/renderers/electrobun/view/data-table/index.tsx +3 -2
- package/src/renderers/electrobun/view/data-table/row.tsx +2 -2
- package/src/renderers/electrobun/view/host/input.tsx +23 -2
- package/src/renderers/electrobun/view/remote/asset-data-client.ts +1 -0
- package/src/sources/gloomberb-cloud/index.ts +4 -0
- package/src/sources/provider-router/cache.ts +1 -0
- package/src/sources/provider-router/document-routes.ts +45 -0
- package/src/sources/provider-router/index.ts +4 -0
- package/src/sources/sec-edgar/content.ts +9 -3
- package/src/sources/sec-edgar.test.ts +108 -1
- package/src/sources/sec-edgar.ts +105 -1
- package/src/sources/yahoo-finance.ts +4 -0
- package/src/test-support/plugin-runtime.ts +1 -0
- package/src/types/data-provider.ts +11 -0
- package/src/ui/host.tsx +1 -0
- package/src/version.ts +1 -1
|
@@ -42,6 +42,10 @@ import {
|
|
|
42
42
|
import {
|
|
43
43
|
handleChatNotification as handleChatNotificationEvent,
|
|
44
44
|
} from "./notifications";
|
|
45
|
+
import {
|
|
46
|
+
CHAT_MESSAGE_EDIT_WINDOW_LABEL,
|
|
47
|
+
isWithinChatMessageEditWindow,
|
|
48
|
+
} from "../edit-window";
|
|
45
49
|
import { ChatControllerRealtime } from "./realtime";
|
|
46
50
|
import { ChatControllerChannels } from "./channels";
|
|
47
51
|
import { ChatControllerView } from "./view";
|
|
@@ -367,6 +371,42 @@ export class ChatController {
|
|
|
367
371
|
});
|
|
368
372
|
}
|
|
369
373
|
|
|
374
|
+
async editChannelMessage(channelId: string, messageId: string, content: string): Promise<boolean> {
|
|
375
|
+
const normalizedChannelId = normalizeChannelId(channelId);
|
|
376
|
+
const channel = this.ensureChannelState(normalizedChannelId);
|
|
377
|
+
const messageContent = content.trim();
|
|
378
|
+
if (!messageContent) return false;
|
|
379
|
+
if (!this.session.user?.emailVerified || !this.session.sessionToken) return false;
|
|
380
|
+
|
|
381
|
+
const latestOwnMessage = [...getVisibleMessages(channel)]
|
|
382
|
+
.reverse()
|
|
383
|
+
.find((message) => (
|
|
384
|
+
message.user.id === this.session.user?.id
|
|
385
|
+
&& !message.clientStatus
|
|
386
|
+
));
|
|
387
|
+
if (!latestOwnMessage || latestOwnMessage.id !== messageId) {
|
|
388
|
+
this.notifyFn({ body: "Only your latest sent message can be edited.", type: "error" });
|
|
389
|
+
return false;
|
|
390
|
+
}
|
|
391
|
+
if (!isWithinChatMessageEditWindow(latestOwnMessage)) {
|
|
392
|
+
this.notifyFn({ body: `Messages can only be edited within ${CHAT_MESSAGE_EDIT_WINDOW_LABEL}.`, type: "error" });
|
|
393
|
+
return false;
|
|
394
|
+
}
|
|
395
|
+
if (latestOwnMessage.content === messageContent) return true;
|
|
396
|
+
|
|
397
|
+
try {
|
|
398
|
+
const message = await apiClient.editMessage(normalizedChannelId, messageId, messageContent);
|
|
399
|
+
this.mergeMessages(normalizedChannelId, [message]);
|
|
400
|
+
return true;
|
|
401
|
+
} catch (error) {
|
|
402
|
+
const errorMessage = error instanceof Error && error.message
|
|
403
|
+
? error.message
|
|
404
|
+
: "Failed to edit message.";
|
|
405
|
+
this.notifyFn({ body: errorMessage, type: "error" });
|
|
406
|
+
return false;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
370
410
|
ensureConnection(channelId = DEFAULT_CHAT_CHANNEL_ID): void {
|
|
371
411
|
const normalizedChannelId = normalizeChannelId(channelId);
|
|
372
412
|
const channel = this.ensureChannelState(normalizedChannelId);
|
|
@@ -451,6 +491,7 @@ export class ChatController {
|
|
|
451
491
|
options,
|
|
452
492
|
ensureChannelState: (channelId) => this.ensureChannelState(channelId),
|
|
453
493
|
mergeMessages: (channelId, messages, mergeOptions) => this.mergeMessages(channelId, messages, mergeOptions),
|
|
494
|
+
getChannel: (channelId) => this.channelCatalog.getChannels().find((channel) => channel.id === channelId),
|
|
454
495
|
notifiedMessageIds: this.notifiedMessageIds,
|
|
455
496
|
notify: this.notifyFn,
|
|
456
497
|
});
|
|
@@ -30,6 +30,24 @@ function mergeStoredMessages(channel: ChannelRuntimeState, messages: ChatMessage
|
|
|
30
30
|
}
|
|
31
31
|
merged.set(message.id, message);
|
|
32
32
|
}
|
|
33
|
+
for (const message of messages) {
|
|
34
|
+
for (const [id, current] of merged) {
|
|
35
|
+
if (current.replyToId !== message.id || current.replyTo?.content === message.content) continue;
|
|
36
|
+
merged.set(id, {
|
|
37
|
+
...current,
|
|
38
|
+
replyTo: current.replyTo
|
|
39
|
+
? {
|
|
40
|
+
...current.replyTo,
|
|
41
|
+
content: message.content,
|
|
42
|
+
user: {
|
|
43
|
+
id: message.user.id,
|
|
44
|
+
username: message.user.username ?? current.replyTo.user.username,
|
|
45
|
+
},
|
|
46
|
+
}
|
|
47
|
+
: current.replyTo,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
}
|
|
33
51
|
channel.messages = [...merged.values()]
|
|
34
52
|
.sort(compareMessages);
|
|
35
53
|
channel.lastCursor = channel.messages[channel.messages.length - 1]?.id ?? channel.lastCursor;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { AppNotificationRequest } from "../../../../types/plugin";
|
|
2
|
-
import { apiClient, type ChatMessage, type ChatNotification } from "../../../../api-client";
|
|
2
|
+
import { apiClient, type ChatChannel, type ChatMessage, type ChatNotification } from "../../../../api-client";
|
|
3
3
|
import type { ChannelRuntimeState, MergeMessagesOptions } from "./state";
|
|
4
|
+
import { formatChatPaneTitle } from "../channel-labels";
|
|
4
5
|
import {
|
|
5
6
|
formatChannelToast,
|
|
6
7
|
formatMentionToast,
|
|
@@ -9,23 +10,26 @@ import {
|
|
|
9
10
|
|
|
10
11
|
function notifyChatServerMessage({
|
|
11
12
|
notification,
|
|
13
|
+
channel,
|
|
12
14
|
notifiedMessageIds,
|
|
13
15
|
notify,
|
|
14
16
|
}: {
|
|
15
17
|
notification: ChatNotification;
|
|
18
|
+
channel: ChatChannel | undefined;
|
|
16
19
|
notifiedMessageIds: Set<string>;
|
|
17
20
|
notify: (notification: AppNotificationRequest) => void;
|
|
18
21
|
}): void {
|
|
19
22
|
if (notifiedMessageIds.has(notification.messageId)) return;
|
|
20
23
|
notifiedMessageIds.add(notification.messageId);
|
|
24
|
+
const channelTitle = formatChatPaneTitle(channel, notification.channelId);
|
|
21
25
|
const body = notification.type === "reply"
|
|
22
26
|
? formatReplyToast(notification.message)
|
|
23
27
|
: notification.type === "mention"
|
|
24
28
|
? formatMentionToast(notification.message)
|
|
25
|
-
: formatChannelToast(
|
|
29
|
+
: formatChannelToast(channelTitle, notification.message, channel?.kind);
|
|
26
30
|
notify({
|
|
27
31
|
title: "Gloomberb chat",
|
|
28
|
-
subtitle:
|
|
32
|
+
subtitle: channelTitle,
|
|
29
33
|
body,
|
|
30
34
|
type: "info",
|
|
31
35
|
desktop: "when-inactive",
|
|
@@ -37,6 +41,7 @@ export function handleChatNotification({
|
|
|
37
41
|
options = {},
|
|
38
42
|
ensureChannelState,
|
|
39
43
|
mergeMessages,
|
|
44
|
+
getChannel,
|
|
40
45
|
notifiedMessageIds,
|
|
41
46
|
notify,
|
|
42
47
|
}: {
|
|
@@ -44,13 +49,14 @@ export function handleChatNotification({
|
|
|
44
49
|
options?: { countUnread?: boolean };
|
|
45
50
|
ensureChannelState: (channelId: string) => ChannelRuntimeState;
|
|
46
51
|
mergeMessages: (channelId: string, messages: ChatMessage[], options?: MergeMessagesOptions) => void;
|
|
52
|
+
getChannel: (channelId: string) => ChatChannel | undefined;
|
|
47
53
|
notifiedMessageIds: Set<string>;
|
|
48
54
|
notify: (notification: AppNotificationRequest) => void;
|
|
49
55
|
}): void {
|
|
50
56
|
mergeMessages(notification.channelId, [notification.message], { countUnread: options.countUnread });
|
|
51
57
|
const channel = ensureChannelState(notification.channelId);
|
|
52
58
|
if (channel.openViewCount === 0) {
|
|
53
|
-
notifyChatServerMessage({ notification, notifiedMessageIds, notify });
|
|
59
|
+
notifyChatServerMessage({ notification, channel: getChannel(notification.channelId), notifiedMessageIds, notify });
|
|
54
60
|
}
|
|
55
61
|
void apiClient.markChatNotificationsDelivered([notification.id]).catch(() => {});
|
|
56
62
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ChatMessage } from "../../../../api-client";
|
|
1
|
+
import type { ChatChannel, ChatMessage } from "../../../../api-client";
|
|
2
2
|
|
|
3
3
|
const ISO_TIMESTAMP_CURSOR = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/;
|
|
4
4
|
const USERNAME_MENTION = /(^|[^A-Za-z0-9_])@([A-Za-z][A-Za-z0-9_]{2,29})(?![A-Za-z0-9_])/g;
|
|
@@ -42,10 +42,13 @@ export function formatReplyToast(message: ChatMessage): string {
|
|
|
42
42
|
return `@${author} replied to you: ${snippet}`;
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
export function formatChannelToast(
|
|
45
|
+
export function formatChannelToast(channelTitle: string, message: ChatMessage, channelKind?: ChatChannel["kind"]): string {
|
|
46
46
|
const author = message.user.username || "Someone";
|
|
47
47
|
const snippet = formatMessageSnippet(message.content);
|
|
48
|
-
|
|
48
|
+
if (channelKind === "direct") {
|
|
49
|
+
return snippet ? `@${author}: ${snippet}` : `@${author} sent a message.`;
|
|
50
|
+
}
|
|
51
|
+
return snippet ? `${channelTitle} @${author}: ${snippet}` : `${channelTitle} @${author} sent a message.`;
|
|
49
52
|
}
|
|
50
53
|
|
|
51
54
|
export function isLegacyTimestampCursor(cursor: string | null): boolean {
|
|
@@ -80,7 +80,7 @@ export class ChatControllerView {
|
|
|
80
80
|
|
|
81
81
|
emit(channelId?: string): void {
|
|
82
82
|
for (const entry of this.listeners) {
|
|
83
|
-
if (channelId && entry.channelId !== channelId) continue;
|
|
83
|
+
if (channelId && entry.channelId !== channelId && entry.channelId !== DEFAULT_CHAT_CHANNEL_ID) continue;
|
|
84
84
|
const snapshot = this.options.getListenerSnapshot(entry.channelId);
|
|
85
85
|
try {
|
|
86
86
|
entry.listener(snapshot);
|
|
@@ -18,6 +18,7 @@ const originalUpdateChatChannelState = apiClient.updateChatChannelState.bind(api
|
|
|
18
18
|
const originalMarkChatNotificationsDelivered = apiClient.markChatNotificationsDelivered.bind(apiClient);
|
|
19
19
|
const originalSubscribeChatNotifications = apiClient.subscribeChatNotifications.bind(apiClient);
|
|
20
20
|
const originalSubscribeChatPresence = apiClient.subscribeChatPresence.bind(apiClient);
|
|
21
|
+
const originalEditMessage = apiClient.editMessage.bind(apiClient);
|
|
21
22
|
|
|
22
23
|
const SERVER_CHAT_CHANNELS: ChatChannel[] = [
|
|
23
24
|
{ id: "everyone", name: "everyone", created_at: "2026-03-26T12:10:05.684Z" },
|
|
@@ -31,6 +32,10 @@ async function flushMicrotasks() {
|
|
|
31
32
|
await Promise.resolve();
|
|
32
33
|
}
|
|
33
34
|
|
|
35
|
+
function recentChatTimestamp(offsetMs = 60_000) {
|
|
36
|
+
return new Date(Date.now() - offsetMs).toISOString();
|
|
37
|
+
}
|
|
38
|
+
|
|
34
39
|
class TrackingPersistence extends MemoryPersistence {
|
|
35
40
|
stateWrites = 0;
|
|
36
41
|
|
|
@@ -77,6 +82,7 @@ afterEach(() => {
|
|
|
77
82
|
apiClient.markChatNotificationsDelivered = originalMarkChatNotificationsDelivered;
|
|
78
83
|
apiClient.subscribeChatNotifications = originalSubscribeChatNotifications;
|
|
79
84
|
apiClient.subscribeChatPresence = originalSubscribeChatPresence;
|
|
85
|
+
apiClient.editMessage = originalEditMessage;
|
|
80
86
|
});
|
|
81
87
|
|
|
82
88
|
describe("ChatController", () => {
|
|
@@ -717,6 +723,130 @@ describe("ChatController", () => {
|
|
|
717
723
|
detachSecondView();
|
|
718
724
|
});
|
|
719
725
|
|
|
726
|
+
test("edits the latest message from the current user", async () => {
|
|
727
|
+
const persistence = new MemoryPersistence();
|
|
728
|
+
const controller = new ChatController();
|
|
729
|
+
const original: ChatMessage = {
|
|
730
|
+
id: "m1",
|
|
731
|
+
channelId: "everyone",
|
|
732
|
+
content: "helo",
|
|
733
|
+
replyToId: null,
|
|
734
|
+
createdAt: recentChatTimestamp(),
|
|
735
|
+
user: { id: "u1", username: "vince", displayName: "Vince" },
|
|
736
|
+
};
|
|
737
|
+
|
|
738
|
+
persistence.setState("session", {
|
|
739
|
+
sessionToken: "token-123",
|
|
740
|
+
user: { id: "u1", username: "vince", emailVerified: true },
|
|
741
|
+
}, { schemaVersion: 1 });
|
|
742
|
+
persistence.setResource(TRANSCRIPT_KIND, TRANSCRIPT_KEY, {
|
|
743
|
+
messages: [original],
|
|
744
|
+
}, {
|
|
745
|
+
sourceKey: TRANSCRIPT_SOURCE,
|
|
746
|
+
schemaVersion: TRANSCRIPT_SCHEMA_VERSION,
|
|
747
|
+
cachePolicy: { staleMs: 1_000, expireMs: 2_000 },
|
|
748
|
+
});
|
|
749
|
+
controller.attachPersistence(persistence);
|
|
750
|
+
apiClient.editMessage = async (channelId, messageId, content) => ({
|
|
751
|
+
...original,
|
|
752
|
+
channelId,
|
|
753
|
+
id: messageId,
|
|
754
|
+
content,
|
|
755
|
+
editedAt: "2026-03-28T00:01:00.000Z",
|
|
756
|
+
});
|
|
757
|
+
|
|
758
|
+
await expect(controller.editChannelMessage("everyone", "m1", "hello")).resolves.toBe(true);
|
|
759
|
+
|
|
760
|
+
const [edited] = controller.getSnapshot().messages;
|
|
761
|
+
expect(edited).toMatchObject({
|
|
762
|
+
id: "m1",
|
|
763
|
+
content: "hello",
|
|
764
|
+
editedAt: "2026-03-28T00:01:00.000Z",
|
|
765
|
+
});
|
|
766
|
+
});
|
|
767
|
+
|
|
768
|
+
test("refuses to edit after the edit window expires", async () => {
|
|
769
|
+
const persistence = new MemoryPersistence();
|
|
770
|
+
const controller = new ChatController();
|
|
771
|
+
const notifications: AppNotificationRequest[] = [];
|
|
772
|
+
const original: ChatMessage = {
|
|
773
|
+
id: "m1",
|
|
774
|
+
channelId: "everyone",
|
|
775
|
+
content: "old typo",
|
|
776
|
+
replyToId: null,
|
|
777
|
+
createdAt: recentChatTimestamp(16 * 60_000),
|
|
778
|
+
user: { id: "u1", username: "vince", displayName: "Vince" },
|
|
779
|
+
};
|
|
780
|
+
|
|
781
|
+
persistence.setState("session", {
|
|
782
|
+
sessionToken: "token-123",
|
|
783
|
+
user: { id: "u1", username: "vince", emailVerified: true },
|
|
784
|
+
}, { schemaVersion: 1 });
|
|
785
|
+
persistence.setResource(TRANSCRIPT_KIND, TRANSCRIPT_KEY, {
|
|
786
|
+
messages: [original],
|
|
787
|
+
}, {
|
|
788
|
+
sourceKey: TRANSCRIPT_SOURCE,
|
|
789
|
+
schemaVersion: TRANSCRIPT_SCHEMA_VERSION,
|
|
790
|
+
cachePolicy: { staleMs: 1_000, expireMs: 2_000 },
|
|
791
|
+
});
|
|
792
|
+
controller.setNotifier((notification) => notifications.push(notification));
|
|
793
|
+
controller.attachPersistence(persistence);
|
|
794
|
+
apiClient.editMessage = async () => {
|
|
795
|
+
throw new Error("should not call server");
|
|
796
|
+
};
|
|
797
|
+
|
|
798
|
+
await expect(controller.editChannelMessage("everyone", "m1", "old fix")).resolves.toBe(false);
|
|
799
|
+
|
|
800
|
+
expect(controller.getSnapshot().messages[0]?.content).toBe("old typo");
|
|
801
|
+
expect(notifications).toEqual([{ body: "Messages can only be edited within 15 minutes.", type: "error" }]);
|
|
802
|
+
});
|
|
803
|
+
|
|
804
|
+
test("refuses to edit an older message from the current user", async () => {
|
|
805
|
+
const persistence = new MemoryPersistence();
|
|
806
|
+
const controller = new ChatController();
|
|
807
|
+
const notifications: AppNotificationRequest[] = [];
|
|
808
|
+
const messages: ChatMessage[] = [
|
|
809
|
+
{
|
|
810
|
+
id: "m1",
|
|
811
|
+
channelId: "everyone",
|
|
812
|
+
content: "older",
|
|
813
|
+
replyToId: null,
|
|
814
|
+
createdAt: "2026-03-28T00:00:00.000Z",
|
|
815
|
+
user: { id: "u1", username: "vince", displayName: "Vince" },
|
|
816
|
+
},
|
|
817
|
+
{
|
|
818
|
+
id: "m2",
|
|
819
|
+
channelId: "everyone",
|
|
820
|
+
content: "newer",
|
|
821
|
+
replyToId: null,
|
|
822
|
+
createdAt: "2026-03-28T00:01:00.000Z",
|
|
823
|
+
user: { id: "u1", username: "vince", displayName: "Vince" },
|
|
824
|
+
},
|
|
825
|
+
];
|
|
826
|
+
|
|
827
|
+
persistence.setState("session", {
|
|
828
|
+
sessionToken: "token-123",
|
|
829
|
+
user: { id: "u1", username: "vince", emailVerified: true },
|
|
830
|
+
}, { schemaVersion: 1 });
|
|
831
|
+
persistence.setResource(TRANSCRIPT_KIND, TRANSCRIPT_KEY, {
|
|
832
|
+
messages,
|
|
833
|
+
}, {
|
|
834
|
+
sourceKey: TRANSCRIPT_SOURCE,
|
|
835
|
+
schemaVersion: TRANSCRIPT_SCHEMA_VERSION,
|
|
836
|
+
cachePolicy: { staleMs: 1_000, expireMs: 2_000 },
|
|
837
|
+
});
|
|
838
|
+
controller.setNotifier((notification) => notifications.push(notification));
|
|
839
|
+
controller.attachPersistence(persistence);
|
|
840
|
+
apiClient.editMessage = async () => {
|
|
841
|
+
throw new Error("should not call server");
|
|
842
|
+
};
|
|
843
|
+
|
|
844
|
+
await expect(controller.editChannelMessage("everyone", "m1", "edited")).resolves.toBe(false);
|
|
845
|
+
|
|
846
|
+
expect(controller.getSnapshot().messages.map((message) => message.content)).toEqual(["older", "newer"]);
|
|
847
|
+
expect(notifications).toEqual([{ body: "Only your latest sent message can be edited.", type: "error" }]);
|
|
848
|
+
});
|
|
849
|
+
|
|
720
850
|
test("marks a pending message as failed when sending errors", async () => {
|
|
721
851
|
const persistence = new MemoryPersistence();
|
|
722
852
|
const controller = new ChatController();
|
|
@@ -1089,6 +1219,65 @@ describe("ChatController", () => {
|
|
|
1089
1219
|
}]);
|
|
1090
1220
|
});
|
|
1091
1221
|
|
|
1222
|
+
test("uses direct channel labels in server-issued notification subtitles", async () => {
|
|
1223
|
+
const persistence = new MemoryPersistence();
|
|
1224
|
+
const controller = new ChatController();
|
|
1225
|
+
const notifications: AppNotificationRequest[] = [];
|
|
1226
|
+
const directChannel: ChatChannel = {
|
|
1227
|
+
id: "dm:u2",
|
|
1228
|
+
name: "u2",
|
|
1229
|
+
kind: "direct",
|
|
1230
|
+
created_at: "2026-03-28T00:00:00.000Z",
|
|
1231
|
+
dmUser: { id: "u2", username: "bob", displayName: "Bob" },
|
|
1232
|
+
};
|
|
1233
|
+
const message: ChatMessage = {
|
|
1234
|
+
id: "m1",
|
|
1235
|
+
channelId: directChannel.id,
|
|
1236
|
+
content: "ping",
|
|
1237
|
+
replyToId: null,
|
|
1238
|
+
createdAt: "2026-03-28T00:00:00.000Z",
|
|
1239
|
+
user: { id: "u2", username: "bob", displayName: "Bob" },
|
|
1240
|
+
};
|
|
1241
|
+
|
|
1242
|
+
apiClient.getChatState = async () => ({
|
|
1243
|
+
channels: [...SERVER_CHAT_CHANNELS, directChannel],
|
|
1244
|
+
onlineCount: 0,
|
|
1245
|
+
channelStates: [...SERVER_CHAT_CHANNELS, directChannel].map((channel) => ({
|
|
1246
|
+
channelId: channel.id,
|
|
1247
|
+
notificationsEnabled: false,
|
|
1248
|
+
lastReadMessageId: null,
|
|
1249
|
+
unreadCount: 0,
|
|
1250
|
+
})),
|
|
1251
|
+
notifications: [],
|
|
1252
|
+
});
|
|
1253
|
+
persistence.setState("session", {
|
|
1254
|
+
sessionToken: "token-123",
|
|
1255
|
+
user: { id: "u1", username: "vince", emailVerified: true },
|
|
1256
|
+
}, { schemaVersion: 1 });
|
|
1257
|
+
controller.setNotifier((entry) => {
|
|
1258
|
+
notifications.push(entry);
|
|
1259
|
+
});
|
|
1260
|
+
controller.attachPersistence(persistence);
|
|
1261
|
+
await controller.refreshChatState();
|
|
1262
|
+
|
|
1263
|
+
(controller as any).handleChatNotification({
|
|
1264
|
+
id: "n1",
|
|
1265
|
+
type: "channel",
|
|
1266
|
+
channelId: directChannel.id,
|
|
1267
|
+
messageId: "m1",
|
|
1268
|
+
createdAt: "2026-03-28T00:00:00.000Z",
|
|
1269
|
+
message,
|
|
1270
|
+
} satisfies ChatNotification);
|
|
1271
|
+
|
|
1272
|
+
expect(notifications).toEqual([{
|
|
1273
|
+
title: "Gloomberb chat",
|
|
1274
|
+
subtitle: "@bob",
|
|
1275
|
+
body: "@bob: ping",
|
|
1276
|
+
type: "info",
|
|
1277
|
+
desktop: "when-inactive",
|
|
1278
|
+
}]);
|
|
1279
|
+
});
|
|
1280
|
+
|
|
1092
1281
|
test("tracks unread channel messages and clears them when the channel opens", () => {
|
|
1093
1282
|
const persistence = new MemoryPersistence();
|
|
1094
1283
|
const controller = new ChatController();
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { ChatMessage } from "../../../api-client";
|
|
2
|
+
|
|
3
|
+
export const CHAT_MESSAGE_EDIT_WINDOW_MS = 15 * 60_000;
|
|
4
|
+
export const CHAT_MESSAGE_EDIT_WINDOW_LABEL = "15 minutes";
|
|
5
|
+
|
|
6
|
+
export function isWithinChatMessageEditWindow(message: ChatMessage, nowMs = Date.now()): boolean {
|
|
7
|
+
const createdMs = Date.parse(message.createdAt);
|
|
8
|
+
return Number.isFinite(createdMs) && nowMs - createdMs <= CHAT_MESSAGE_EDIT_WINDOW_MS;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function findLatestEditableChatMessage(
|
|
12
|
+
messages: ChatMessage[],
|
|
13
|
+
userId: string | null | undefined,
|
|
14
|
+
nowMs = Date.now(),
|
|
15
|
+
): ChatMessage | null {
|
|
16
|
+
if (!userId) return null;
|
|
17
|
+
return [...messages]
|
|
18
|
+
.reverse()
|
|
19
|
+
.find((message) => (
|
|
20
|
+
message.user.id === userId
|
|
21
|
+
&& !message.clientStatus
|
|
22
|
+
&& isWithinChatMessageEditWindow(message, nowMs)
|
|
23
|
+
)) ?? null;
|
|
24
|
+
}
|
|
@@ -30,6 +30,10 @@ export function formatInlinePreview(text: string, width: number) {
|
|
|
30
30
|
return truncateWithEllipsis(normalizeInlinePreview(text), width);
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
export type ChatBodyInlineToken = InlineContentToken;
|
|
34
|
+
|
|
35
|
+
export type ChatBodyLine = ChatBodyInlineToken[];
|
|
36
|
+
|
|
33
37
|
function wrapTextLines(text: string, width: number) {
|
|
34
38
|
const safeWidth = Math.max(width, 1);
|
|
35
39
|
const lines: string[] = [];
|
|
@@ -78,52 +82,108 @@ function inlineTokenWidth(
|
|
|
78
82
|
return Math.max(baseWidth, hoverWidth);
|
|
79
83
|
}
|
|
80
84
|
|
|
85
|
+
function tokenWithValue(token: InlineContentToken, value: string): InlineContentToken {
|
|
86
|
+
if (token.kind === "link") return { ...token, value };
|
|
87
|
+
if (token.kind === "username") return { ...token, value };
|
|
88
|
+
if (token.kind === "ticker") return { ...token, value };
|
|
89
|
+
return { kind: "text", value };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function firstDisplayWidthChunk(value: string, width: number): string {
|
|
93
|
+
return splitLongTextSegmentByDisplayWidth(value, Math.max(width, 1))[0] ?? "";
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function trimLineEnd(line: ChatBodyLine): ChatBodyLine {
|
|
97
|
+
const next = [...line];
|
|
98
|
+
while (next.length > 0) {
|
|
99
|
+
const last = next[next.length - 1]!;
|
|
100
|
+
if (last.kind !== "text") break;
|
|
101
|
+
const trimmed = last.value.trimEnd();
|
|
102
|
+
if (trimmed) {
|
|
103
|
+
next[next.length - 1] = { ...last, value: trimmed };
|
|
104
|
+
break;
|
|
105
|
+
}
|
|
106
|
+
next.pop();
|
|
107
|
+
}
|
|
108
|
+
return next;
|
|
109
|
+
}
|
|
110
|
+
|
|
81
111
|
function wrapInlineContentLine(
|
|
82
112
|
text: string,
|
|
83
113
|
width: number,
|
|
84
114
|
catalog: Record<string, InlineTickerCatalogEntry>,
|
|
85
|
-
):
|
|
115
|
+
): ChatBodyLine[] {
|
|
86
116
|
const safeWidth = Math.max(width, 1);
|
|
87
|
-
const lines:
|
|
88
|
-
let current =
|
|
117
|
+
const lines: ChatBodyLine[] = [];
|
|
118
|
+
let current: ChatBodyLine = [];
|
|
89
119
|
let currentWidth = 0;
|
|
90
120
|
|
|
91
121
|
const finishLine = () => {
|
|
92
|
-
lines.push(current
|
|
93
|
-
current =
|
|
122
|
+
lines.push(trimLineEnd(current));
|
|
123
|
+
current = [];
|
|
94
124
|
currentWidth = 0;
|
|
95
125
|
};
|
|
96
126
|
|
|
97
|
-
const
|
|
98
|
-
let
|
|
99
|
-
if (!
|
|
100
|
-
|
|
101
|
-
while (
|
|
102
|
-
const
|
|
103
|
-
|
|
127
|
+
const appendSegment = (token: InlineContentToken, rawValue: string, trimStartOnWrap: boolean) => {
|
|
128
|
+
let value = currentWidth === 0 && trimStartOnWrap ? rawValue.trimStart() : rawValue;
|
|
129
|
+
if (!value) return;
|
|
130
|
+
|
|
131
|
+
while (value) {
|
|
132
|
+
const tokenWidth = token.kind === "ticker"
|
|
133
|
+
? inlineTokenWidth(tokenWithValue(token, value), catalog)
|
|
134
|
+
: displayWidth(value);
|
|
135
|
+
const available = safeWidth - currentWidth;
|
|
136
|
+
|
|
137
|
+
if (currentWidth > 0 && tokenWidth > available) {
|
|
138
|
+
if (available <= 0) {
|
|
139
|
+
finishLine();
|
|
140
|
+
if (trimStartOnWrap) value = value.trimStart();
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (token.kind === "ticker") {
|
|
145
|
+
finishLine();
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const chunk = firstDisplayWidthChunk(value, Math.max(available, 1));
|
|
150
|
+
if (chunk) {
|
|
151
|
+
current.push(tokenWithValue(token, chunk));
|
|
152
|
+
currentWidth += displayWidth(chunk);
|
|
153
|
+
value = value.slice(chunk.length);
|
|
154
|
+
}
|
|
104
155
|
finishLine();
|
|
105
|
-
|
|
106
|
-
if (!part) return;
|
|
156
|
+
if (trimStartOnWrap) value = value.trimStart();
|
|
107
157
|
continue;
|
|
108
158
|
}
|
|
109
159
|
|
|
110
|
-
if (
|
|
111
|
-
current
|
|
112
|
-
currentWidth +=
|
|
160
|
+
if (tokenWidth <= safeWidth - currentWidth || token.kind === "ticker") {
|
|
161
|
+
current.push(tokenWithValue(token, value));
|
|
162
|
+
currentWidth += tokenWidth;
|
|
113
163
|
return;
|
|
114
164
|
}
|
|
115
165
|
|
|
116
|
-
const
|
|
117
|
-
const [chunk = "", ...rest] = splitLongTextSegmentByDisplayWidth(part, available);
|
|
166
|
+
const chunk = firstDisplayWidthChunk(value, Math.max(safeWidth - currentWidth, 1));
|
|
118
167
|
if (chunk) {
|
|
119
|
-
current
|
|
168
|
+
current.push(tokenWithValue(token, chunk));
|
|
120
169
|
currentWidth += displayWidth(chunk);
|
|
170
|
+
value = value.slice(chunk.length);
|
|
121
171
|
}
|
|
122
172
|
finishLine();
|
|
123
|
-
|
|
173
|
+
if (trimStartOnWrap) value = value.trimStart();
|
|
124
174
|
}
|
|
125
175
|
};
|
|
126
176
|
|
|
177
|
+
const appendTextPart = (rawPart: string) => {
|
|
178
|
+
let part = rawPart;
|
|
179
|
+
const partWidth = displayWidth(part);
|
|
180
|
+
if (currentWidth > 0 && partWidth > safeWidth - currentWidth && partWidth <= safeWidth) {
|
|
181
|
+
finishLine();
|
|
182
|
+
part = part.trimStart();
|
|
183
|
+
}
|
|
184
|
+
appendSegment({ kind: "text", value: part }, part, true);
|
|
185
|
+
};
|
|
186
|
+
|
|
127
187
|
const appendText = (value: string) => {
|
|
128
188
|
const parts = value.match(/\S+\s*|\s+/g) ?? [value];
|
|
129
189
|
for (const part of parts) {
|
|
@@ -134,8 +194,7 @@ function wrapInlineContentLine(
|
|
|
134
194
|
const appendInlineToken = (token: InlineContentToken) => {
|
|
135
195
|
const tokenWidth = inlineTokenWidth(token, catalog);
|
|
136
196
|
if (currentWidth > 0 && currentWidth + tokenWidth > safeWidth) finishLine();
|
|
137
|
-
|
|
138
|
-
currentWidth += tokenWidth;
|
|
197
|
+
appendSegment(token, token.value, false);
|
|
139
198
|
};
|
|
140
199
|
|
|
141
200
|
for (const token of tokenizeInlineContent(text)) {
|
|
@@ -146,24 +205,24 @@ function wrapInlineContentLine(
|
|
|
146
205
|
appendInlineToken(token);
|
|
147
206
|
}
|
|
148
207
|
|
|
149
|
-
if (current || lines.length === 0) lines.push(current
|
|
208
|
+
if (current.length > 0 || lines.length === 0) lines.push(trimLineEnd(current));
|
|
150
209
|
return lines;
|
|
151
210
|
}
|
|
152
211
|
|
|
153
|
-
function
|
|
212
|
+
export function getMessageBodyTokenLines(
|
|
154
213
|
text: string,
|
|
155
214
|
width: number,
|
|
156
215
|
catalog: Record<string, InlineTickerCatalogEntry>,
|
|
157
|
-
):
|
|
158
|
-
const lines:
|
|
216
|
+
): ChatBodyLine[] {
|
|
217
|
+
const lines: ChatBodyLine[] = [];
|
|
159
218
|
for (const paragraph of text.split("\n")) {
|
|
160
219
|
if (!paragraph) {
|
|
161
|
-
lines.push(
|
|
220
|
+
lines.push([]);
|
|
162
221
|
continue;
|
|
163
222
|
}
|
|
164
223
|
lines.push(...wrapInlineContentLine(paragraph, width, catalog));
|
|
165
224
|
}
|
|
166
|
-
return lines.length > 0 ? lines : [
|
|
225
|
+
return lines.length > 0 ? lines : [[]];
|
|
167
226
|
}
|
|
168
227
|
|
|
169
228
|
export function getMessageBodyLines(
|
|
@@ -172,7 +231,7 @@ export function getMessageBodyLines(
|
|
|
172
231
|
catalog?: Record<string, InlineTickerCatalogEntry>,
|
|
173
232
|
) {
|
|
174
233
|
const contentLineWidth = Math.max(width - 4, 1);
|
|
175
|
-
if (catalog) return
|
|
234
|
+
if (catalog) return getMessageBodyTokenLines(message.content, contentLineWidth, catalog).map((line) => line.map((token) => token.value).join(""));
|
|
176
235
|
return wrapTextLines(message.content, contentLineWidth);
|
|
177
236
|
}
|
|
178
237
|
|
|
@@ -183,7 +242,11 @@ export function estimateMessageHeight(
|
|
|
183
242
|
catalog?: Record<string, InlineTickerCatalogEntry>,
|
|
184
243
|
) {
|
|
185
244
|
const headerHeight = grouped ? 0 : 1;
|
|
186
|
-
|
|
245
|
+
const contentLineWidth = Math.max(width - 4, 1);
|
|
246
|
+
const bodyLineCount = catalog
|
|
247
|
+
? getMessageBodyTokenLines(message.content, contentLineWidth, catalog).length
|
|
248
|
+
: getMessageBodyLines(message, width, catalog).length;
|
|
249
|
+
return headerHeight + (message.replyTo ? 1 : 0) + bodyLineCount;
|
|
187
250
|
}
|
|
188
251
|
|
|
189
252
|
export function estimateComposerHeight(text: string, width: number) {
|