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.
Files changed (122) hide show
  1. package/README.md +102 -52
  2. package/package.json +1 -1
  3. package/src/api-client/chat.ts +8 -0
  4. package/src/api-client/index.test.ts +30 -0
  5. package/src/api-client/index.ts +4 -0
  6. package/src/api-client/normalizers.ts +1 -0
  7. package/src/api-client/types.ts +1 -0
  8. package/src/capabilities/factories.ts +4 -0
  9. package/src/components/chart/comparison/data.test.ts +54 -0
  10. package/src/components/chart/comparison/data.ts +47 -24
  11. package/src/components/chart/core/projection.ts +51 -27
  12. package/src/components/data-table/stack-view.test.tsx +14 -11
  13. package/src/components/data-table/stack-view.tsx +1 -2
  14. package/src/components/data-table/view.test.tsx +24 -18
  15. package/src/components/data-table/view.tsx +313 -83
  16. package/src/components/feed-data-table/stack-view.tsx +6 -8
  17. package/src/components/layout/pane/footer/index.test.tsx +6 -0
  18. package/src/components/layout/pane/footer/index.tsx +14 -2
  19. package/src/components/layout/pane/title.ts +5 -1
  20. package/src/components/pane-settings-dialog/field-dialogs.tsx +0 -2
  21. package/src/components/pane-settings-dialog/tui.tsx +1 -1
  22. package/src/components/ticker/list-table-view.tsx +11 -25
  23. package/src/components/ui/choice-dialog.tsx +12 -2
  24. package/src/components/ui/multi-select/dialog.tsx +17 -5
  25. package/src/components/ui/shortcut-hint.tsx +14 -2
  26. package/src/components/ui/table-layout.test.ts +2 -2
  27. package/src/components/ui/table-layout.ts +8 -2
  28. package/src/components/ui/tabs.tsx +20 -6
  29. package/src/components/ui/ui.test.tsx +30 -5
  30. package/src/market-data/coordinator/auxiliary.ts +29 -1
  31. package/src/market-data/coordinator/index.ts +17 -1
  32. package/src/market-data/hooks.tsx +17 -1
  33. package/src/market-data/selectors.ts +4 -0
  34. package/src/plugins/builtin/account-management/form-components.tsx +0 -4
  35. package/src/plugins/builtin/account-management/model.test.ts +54 -0
  36. package/src/plugins/builtin/account-management/model.ts +13 -3
  37. package/src/plugins/builtin/account-management/pane.tsx +5 -11
  38. package/src/plugins/builtin/account-management/password-dialog.tsx +1 -1
  39. package/src/plugins/builtin/alerts/pane.tsx +5 -3
  40. package/src/plugins/builtin/analytics/index.tsx +0 -3
  41. package/src/plugins/builtin/analytics/view.tsx +6 -5
  42. package/src/plugins/builtin/broker-manager/index.tsx +6 -6
  43. package/src/plugins/builtin/buildout/pane/index.tsx +6 -6
  44. package/src/plugins/builtin/changelog/index.tsx +40 -27
  45. package/src/plugins/builtin/changelog/model.test.ts +77 -0
  46. package/src/plugins/builtin/changelog/model.ts +98 -0
  47. package/src/plugins/builtin/chat/channel-labels.ts +53 -0
  48. package/src/plugins/builtin/chat/channels.test.ts +28 -0
  49. package/src/plugins/builtin/chat/channels.ts +36 -19
  50. package/src/plugins/builtin/chat/chat.test.tsx +264 -5
  51. package/src/plugins/builtin/chat/content/channel-navigation.ts +6 -0
  52. package/src/plugins/builtin/chat/content/composer-runtime.test.tsx +144 -0
  53. package/src/plugins/builtin/chat/content/composer-runtime.ts +151 -20
  54. package/src/plugins/builtin/chat/content/composer.tsx +23 -2
  55. package/src/plugins/builtin/chat/content/index.tsx +61 -0
  56. package/src/plugins/builtin/chat/content/layout-metrics.ts +3 -1
  57. package/src/plugins/builtin/chat/content/shortcuts.ts +20 -1
  58. package/src/plugins/builtin/chat/content/transcript.tsx +8 -0
  59. package/src/plugins/builtin/chat/content/types.ts +1 -0
  60. package/src/plugins/builtin/chat/controller/index.ts +41 -0
  61. package/src/plugins/builtin/chat/controller/messages.ts +18 -0
  62. package/src/plugins/builtin/chat/controller/notifications.ts +10 -4
  63. package/src/plugins/builtin/chat/controller/utils.ts +6 -3
  64. package/src/plugins/builtin/chat/controller/view.ts +1 -1
  65. package/src/plugins/builtin/chat/controller.test.ts +189 -0
  66. package/src/plugins/builtin/chat/edit-window.ts +24 -0
  67. package/src/plugins/builtin/chat/layout.ts +94 -31
  68. package/src/plugins/builtin/chat/message/action-chip.tsx +1 -0
  69. package/src/plugins/builtin/chat/message/desktop.tsx +53 -20
  70. package/src/plugins/builtin/chat/message/inline-tokens.tsx +16 -10
  71. package/src/plugins/builtin/chat/message/render-state.ts +5 -1
  72. package/src/plugins/builtin/chat/message/terminal.tsx +49 -20
  73. package/src/plugins/builtin/chat/message/types.ts +2 -0
  74. package/src/plugins/builtin/chat/pane.tsx +35 -7
  75. package/src/plugins/builtin/chat/sidebar.test.tsx +1 -0
  76. package/src/plugins/builtin/chat/status-widget.tsx +29 -11
  77. package/src/plugins/builtin/chat/test-harness.tsx +2 -0
  78. package/src/plugins/builtin/cloud/plugin.tsx +3 -0
  79. package/src/plugins/builtin/cloud-tweets/table.tsx +7 -11
  80. package/src/plugins/builtin/congress-trades/pane.tsx +14 -22
  81. package/src/plugins/builtin/earnings/index.tsx +8 -7
  82. package/src/plugins/builtin/econ/index.tsx +6 -8
  83. package/src/plugins/builtin/help/index.tsx +6 -0
  84. package/src/plugins/builtin/holders/pane.tsx +7 -6
  85. package/src/plugins/builtin/market-movers/index.tsx +6 -3
  86. package/src/plugins/builtin/news/wire/news/table.tsx +7 -6
  87. package/src/plugins/builtin/options/view.tsx +16 -8
  88. package/src/plugins/builtin/research/analyst-pane.tsx +1 -2
  89. package/src/plugins/builtin/research/corporate-actions-pane.tsx +358 -7
  90. package/src/plugins/builtin/research/index.test.ts +30 -1
  91. package/src/plugins/builtin/research/relative-valuation-pane.tsx +6 -8
  92. package/src/plugins/builtin/sec/filing-documents.ts +66 -0
  93. package/src/plugins/builtin/sec/index.tsx +132 -20
  94. package/src/plugins/builtin/sectors/index.tsx +13 -23
  95. package/src/plugins/builtin/shared/ticker-request.ts +1 -0
  96. package/src/plugins/builtin/thirteenf/pane.tsx +26 -25
  97. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/content.tsx +5 -4
  98. package/src/plugins/builtin/ticker-detail/data-panes/historical-prices.tsx +5 -4
  99. package/src/plugins/builtin/ticker-detail/data-panes/provider-search.tsx +6 -5
  100. package/src/plugins/builtin/ticker-detail/financials/tab.tsx +10 -8
  101. package/src/plugins/builtin/world-indices/index.tsx +9 -6
  102. package/src/plugins/prediction-markets/pane.tsx +10 -9
  103. package/src/plugins/registry/index.ts +3 -0
  104. package/src/plugins/runtime/context.tsx +1 -0
  105. package/src/plugins/runtime/index.tsx +4 -0
  106. package/src/renderers/electrobun/view/cli-pane-shot-entry.tsx +1 -0
  107. package/src/renderers/electrobun/view/data-table/index.tsx +3 -2
  108. package/src/renderers/electrobun/view/data-table/row.tsx +2 -2
  109. package/src/renderers/electrobun/view/host/input.tsx +23 -2
  110. package/src/renderers/electrobun/view/remote/asset-data-client.ts +1 -0
  111. package/src/sources/gloomberb-cloud/index.ts +4 -0
  112. package/src/sources/provider-router/cache.ts +1 -0
  113. package/src/sources/provider-router/document-routes.ts +45 -0
  114. package/src/sources/provider-router/index.ts +4 -0
  115. package/src/sources/sec-edgar/content.ts +9 -3
  116. package/src/sources/sec-edgar.test.ts +108 -1
  117. package/src/sources/sec-edgar.ts +105 -1
  118. package/src/sources/yahoo-finance.ts +4 -0
  119. package/src/test-support/plugin-runtime.ts +1 -0
  120. package/src/types/data-provider.ts +11 -0
  121. package/src/ui/host.tsx +1 -0
  122. 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(notification.channelId, notification.message);
29
+ : formatChannelToast(channelTitle, notification.message, channel?.kind);
26
30
  notify({
27
31
  title: "Gloomberb chat",
28
- subtitle: `#${notification.channelId}`,
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(channelId: string, message: ChatMessage): string {
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
- return snippet ? `#${channelId} @${author}: ${snippet}` : `#${channelId} @${author} sent a message.`;
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
- ): string[] {
115
+ ): ChatBodyLine[] {
86
116
  const safeWidth = Math.max(width, 1);
87
- const lines: string[] = [];
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.trimEnd());
93
- current = "";
122
+ lines.push(trimLineEnd(current));
123
+ current = [];
94
124
  currentWidth = 0;
95
125
  };
96
126
 
97
- const appendTextPart = (rawPart: string) => {
98
- let part = currentWidth === 0 ? rawPart.trimStart() : rawPart;
99
- if (!part) return;
100
-
101
- while (part) {
102
- const partWidth = displayWidth(part);
103
- if (currentWidth > 0 && currentWidth + partWidth > safeWidth) {
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
- part = part.trimStart();
106
- if (!part) return;
156
+ if (trimStartOnWrap) value = value.trimStart();
107
157
  continue;
108
158
  }
109
159
 
110
- if (partWidth <= safeWidth - currentWidth) {
111
- current += part;
112
- currentWidth += partWidth;
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 available = Math.max(safeWidth - currentWidth, 1);
117
- const [chunk = "", ...rest] = splitLongTextSegmentByDisplayWidth(part, available);
166
+ const chunk = firstDisplayWidthChunk(value, Math.max(safeWidth - currentWidth, 1));
118
167
  if (chunk) {
119
- current += chunk;
168
+ current.push(tokenWithValue(token, chunk));
120
169
  currentWidth += displayWidth(chunk);
170
+ value = value.slice(chunk.length);
121
171
  }
122
172
  finishLine();
123
- part = rest.join("").trimStart();
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
- current += token.value;
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.trimEnd());
208
+ if (current.length > 0 || lines.length === 0) lines.push(trimLineEnd(current));
150
209
  return lines;
151
210
  }
152
211
 
153
- function wrapInlineContentLines(
212
+ export function getMessageBodyTokenLines(
154
213
  text: string,
155
214
  width: number,
156
215
  catalog: Record<string, InlineTickerCatalogEntry>,
157
- ): string[] {
158
- const lines: string[] = [];
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 wrapInlineContentLines(message.content, contentLineWidth, catalog);
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
- return headerHeight + (message.replyTo ? 1 : 0) + getMessageBodyLines(message, width, catalog).length;
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) {
@@ -25,6 +25,7 @@ export function ChatActionChip({
25
25
  height={1}
26
26
  backgroundColor={emphasized ? colors.borderFocused : colors.panel}
27
27
  onMouseDown={handlePress}
28
+ style={{ cursor: "pointer" }}
28
29
  >
29
30
  <Text
30
31
  fg={emphasized ? colors.bg : colors.text}