gloomberb 0.8.1 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +106 -54
- package/package.json +14 -13
- package/patches/{@opentui%2Fcore@0.1.90.patch → @opentui%2Fcore@0.3.2.patch} +175 -90
- 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/app/global-shortcuts.test.tsx +54 -4
- package/src/app/global-shortcuts.ts +11 -5
- 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/command-bar/action-runtime.ts +1 -0
- package/src/components/command-bar/layout-items/current-layout.test.ts +115 -0
- package/src/components/command-bar/layout-items/current-layout.ts +33 -0
- package/src/components/command-bar/list/view.tsx +3 -1
- package/src/components/command-bar/pane-templates/items.ts +4 -1
- package/src/components/command-bar/panel/body.tsx +1 -0
- package/src/components/command-bar/routes/root/results.ts +0 -30
- package/src/components/command-bar/routes/root/runtime.ts +2 -20
- package/src/components/command-bar/routes/ticker-search/actions.ts +42 -4
- package/src/components/command-bar/routes/ticker-search/route.ts +4 -1
- package/src/components/command-bar/routing/launch-request.ts +14 -7
- package/src/components/command-bar/routing/list-state.ts +1 -3
- package/src/components/command-bar/surface/index.test.tsx +76 -4
- package/src/components/command-bar/surface/index.tsx +0 -2
- package/src/components/command-bar/surface/pane.test.tsx +1 -0
- package/src/components/command-bar/surface/test-harness.tsx +1 -0
- package/src/components/command-bar/view-model.ts +3 -3
- 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/layout/shell/index.test.tsx +120 -2
- package/src/components/layout/shell/index.tsx +2 -0
- package/src/components/layout/shell/menu.ts +1 -7
- package/src/components/layout/shell/pane/actions.ts +8 -0
- package/src/components/layout/shell/pane/management-shortcuts.ts +12 -2
- package/src/components/layout/shell/shortcuts.ts +14 -6
- package/src/components/layout/shell/window-mode/index.ts +8 -3
- package/src/components/layout/window-edit/presentation.ts +4 -4
- package/src/components/onboarding/onboarding-steps.tsx +3 -2
- 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 +17 -25
- package/src/components/ui/choice-dialog.tsx +12 -2
- package/src/components/ui/multi-select/dialog.tsx +17 -5
- package/src/components/ui/remote-image.tsx +1 -14
- 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 +115 -86
- package/src/components/ui/ui.test.tsx +83 -4
- package/src/core/state/app/types.ts +12 -4
- 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 +50 -11
- 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 +66 -4
- package/src/plugins/builtin/chat/content/layout-metrics.ts +3 -1
- package/src/plugins/builtin/chat/content/shortcuts.ts +27 -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 +72 -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/components.tsx +1 -1
- package/src/plugins/builtin/help/index.test.tsx +105 -30
- package/src/plugins/builtin/help/index.tsx +326 -164
- package/src/plugins/builtin/help/shortcut-model.ts +5 -6
- 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/portfolio-list/column-values.ts +304 -1
- package/src/plugins/builtin/portfolio-list/metrics.test.ts +93 -0
- package/src/plugins/builtin/portfolio-list/pane/data.ts +7 -0
- package/src/plugins/builtin/portfolio-list/pane/index.tsx +25 -2
- package/src/plugins/builtin/portfolio-list/pane/supplemental.ts +281 -0
- package/src/plugins/builtin/portfolio-list/settings.ts +27 -0
- package/src/plugins/builtin/portfolio-list/table.tsx +4 -2
- package/src/plugins/builtin/portfolio-list/use-throttled-cursor-symbol.ts +10 -68
- 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/substack/api/auth.ts +185 -0
- package/src/plugins/builtin/substack/api/cache.ts +78 -0
- package/src/plugins/builtin/substack/api/loaders.ts +146 -0
- package/src/plugins/builtin/substack/api/store.ts +186 -0
- package/src/plugins/builtin/substack/api/types.ts +40 -0
- package/src/plugins/builtin/substack/api.test.ts +298 -0
- package/src/plugins/builtin/substack/article-detail.tsx +359 -0
- package/src/plugins/builtin/substack/article-stack.tsx +129 -0
- package/src/plugins/builtin/substack/content.ts +478 -0
- package/src/plugins/builtin/substack/feed-tabs.tsx +41 -0
- package/src/plugins/builtin/substack/index.tsx +49 -0
- package/src/plugins/builtin/substack/login-view.tsx +126 -0
- package/src/plugins/builtin/substack/model.test.ts +155 -0
- package/src/plugins/builtin/substack/normalize.ts +343 -0
- package/src/plugins/builtin/substack/pane-footer.ts +71 -0
- package/src/plugins/builtin/substack/pane-state.ts +170 -0
- package/src/plugins/builtin/substack/pane.tsx +500 -0
- package/src/plugins/builtin/substack/table.ts +111 -0
- package/src/plugins/builtin/substack/types.ts +71 -0
- package/src/plugins/builtin/substack/utils.ts +131 -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/ticker-detail/index.test.tsx +16 -2
- package/src/plugins/builtin/ticker-detail/index.tsx +1 -0
- package/src/plugins/builtin/ticker-detail/pane.tsx +13 -1
- package/src/plugins/builtin/world-indices/index.tsx +9 -6
- package/src/plugins/catalog-ui.ts +2 -0
- package/src/plugins/pane-manager/layout-state.ts +11 -0
- package/src/plugins/pane-manager.ts +1 -1
- 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/react/use-throttled-commit-value.ts +116 -0
- package/src/renderers/electrobun/bun/desktop/http-fetch.test.ts +42 -0
- package/src/renderers/electrobun/bun/desktop/http-fetch.ts +5 -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/http-fetch.ts +10 -3
- 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/state/app/context/index.test.tsx +16 -0
- package/src/test-support/plugin-runtime.ts +1 -0
- package/src/tickers/search/index.ts +1 -0
- package/src/types/data-provider.ts +11 -0
- package/src/ui/host.tsx +1 -0
- package/src/version.ts +1 -1
|
@@ -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) {
|
|
@@ -22,14 +22,20 @@ export const DesktopChatMessage = memo(function DesktopChatMessage({
|
|
|
22
22
|
onUserHover,
|
|
23
23
|
onUserHoverEnd,
|
|
24
24
|
beginReplyTo,
|
|
25
|
+
beginEditMessage,
|
|
25
26
|
jumpToMessage,
|
|
27
|
+
latestEditableMessageId,
|
|
26
28
|
registerMessageElement,
|
|
27
29
|
}: ChatMessageBaseProps & {
|
|
28
30
|
registerMessageElement: (messageId: string, node: unknown | null) => void;
|
|
29
31
|
}) {
|
|
30
32
|
const state = getChatMessageRenderState({ msg, index, messages, selectedIdx, hoveredIdx, canSend });
|
|
33
|
+
const canEditMessage = msg.id === latestEditableMessageId;
|
|
31
34
|
const showInlineReplyAction = !state.grouped && canSend;
|
|
35
|
+
const showInlineEditAction = !state.grouped && canSend && canEditMessage;
|
|
32
36
|
const showGroupedReplyAction = state.grouped && canSend;
|
|
37
|
+
const showGroupedEditAction = state.grouped && canSend && canEditMessage;
|
|
38
|
+
const groupedActionWidth = MESSAGE_ACTION_WIDTH * Number(showGroupedReplyAction) + MESSAGE_ACTION_WIDTH * Number(showGroupedEditAction);
|
|
33
39
|
const rowProps = {
|
|
34
40
|
width: "100%",
|
|
35
41
|
paddingRight: DESKTOP_MESSAGE_RIGHT_PADDING,
|
|
@@ -98,21 +104,37 @@ export const DesktopChatMessage = memo(function DesktopChatMessage({
|
|
|
98
104
|
{msg.user.username ?? "anon"}
|
|
99
105
|
</Text>
|
|
100
106
|
<Text fg={state.headerStatusColor}> {state.headerStatus}</Text>
|
|
101
|
-
{showInlineReplyAction && (
|
|
107
|
+
{(showInlineReplyAction || showInlineEditAction) && (
|
|
102
108
|
<>
|
|
103
109
|
<Text fg={state.headerStatusColor}> </Text>
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
110
|
+
{showInlineReplyAction && (
|
|
111
|
+
<Box
|
|
112
|
+
width={MESSAGE_ACTION_WIDTH}
|
|
113
|
+
height={1}
|
|
114
|
+
data-gloom-role="chat-message-reply-action"
|
|
115
|
+
>
|
|
116
|
+
<ChatActionChip
|
|
117
|
+
label="Reply"
|
|
118
|
+
width={MESSAGE_ACTION_WIDTH}
|
|
119
|
+
emphasized={state.isSelected}
|
|
120
|
+
onPress={() => beginReplyTo(index)}
|
|
121
|
+
/>
|
|
122
|
+
</Box>
|
|
123
|
+
)}
|
|
124
|
+
{showInlineEditAction && (
|
|
125
|
+
<Box
|
|
111
126
|
width={MESSAGE_ACTION_WIDTH}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
127
|
+
height={1}
|
|
128
|
+
data-gloom-role="chat-message-reply-action"
|
|
129
|
+
>
|
|
130
|
+
<ChatActionChip
|
|
131
|
+
label="Edit"
|
|
132
|
+
width={MESSAGE_ACTION_WIDTH}
|
|
133
|
+
emphasized={state.isSelected}
|
|
134
|
+
onPress={() => beginEditMessage(index)}
|
|
135
|
+
/>
|
|
136
|
+
</Box>
|
|
137
|
+
)}
|
|
116
138
|
</>
|
|
117
139
|
)}
|
|
118
140
|
</Box>
|
|
@@ -135,21 +157,32 @@ export const DesktopChatMessage = memo(function DesktopChatMessage({
|
|
|
135
157
|
onUserHoverEnd={onUserHoverEnd}
|
|
136
158
|
/>
|
|
137
159
|
</Box>
|
|
138
|
-
{showGroupedReplyAction && (
|
|
160
|
+
{(showGroupedReplyAction || showGroupedEditAction) && (
|
|
139
161
|
<Box
|
|
140
162
|
position="absolute"
|
|
141
163
|
top={0}
|
|
142
164
|
right={0}
|
|
143
|
-
width={
|
|
165
|
+
width={groupedActionWidth}
|
|
144
166
|
height={1}
|
|
167
|
+
flexDirection="row"
|
|
145
168
|
data-gloom-role="chat-message-reply-action"
|
|
146
169
|
>
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
170
|
+
{showGroupedReplyAction && (
|
|
171
|
+
<ChatActionChip
|
|
172
|
+
label="Reply"
|
|
173
|
+
width={MESSAGE_ACTION_WIDTH}
|
|
174
|
+
emphasized={state.isSelected}
|
|
175
|
+
onPress={() => beginReplyTo(index)}
|
|
176
|
+
/>
|
|
177
|
+
)}
|
|
178
|
+
{showGroupedEditAction && (
|
|
179
|
+
<ChatActionChip
|
|
180
|
+
label="Edit"
|
|
181
|
+
width={MESSAGE_ACTION_WIDTH}
|
|
182
|
+
emphasized={state.isSelected}
|
|
183
|
+
onPress={() => beginEditMessage(index)}
|
|
184
|
+
/>
|
|
185
|
+
)}
|
|
153
186
|
</Box>
|
|
154
187
|
)}
|
|
155
188
|
</Box>
|
|
@@ -6,10 +6,12 @@ import { TickerBadge } from "../../../../components/ticker/badge";
|
|
|
6
6
|
import type { InlineTickerCatalogEntry } from "../../../../state/hooks/inline-tickers";
|
|
7
7
|
import { blendHex, colors } from "../../../../theme/colors";
|
|
8
8
|
import type { ChatUserSummary } from "../../../../api-client";
|
|
9
|
-
import { tokenizeInlineContent } from "../../../../utils/inline-content-tokenizer";
|
|
9
|
+
import { tokenizeInlineContent, type InlineContentToken } from "../../../../utils/inline-content-tokenizer";
|
|
10
10
|
|
|
11
11
|
export function ResponsiveTickerBadgeText({
|
|
12
|
-
text,
|
|
12
|
+
text = "",
|
|
13
|
+
tokens: providedTokens,
|
|
14
|
+
prewrapped = false,
|
|
13
15
|
catalog,
|
|
14
16
|
textColor,
|
|
15
17
|
openTicker,
|
|
@@ -17,7 +19,9 @@ export function ResponsiveTickerBadgeText({
|
|
|
17
19
|
onUserHover,
|
|
18
20
|
onUserHoverEnd,
|
|
19
21
|
}: {
|
|
20
|
-
text
|
|
22
|
+
text?: string;
|
|
23
|
+
tokens?: readonly InlineContentToken[];
|
|
24
|
+
prewrapped?: boolean;
|
|
21
25
|
catalog: Record<string, InlineTickerCatalogEntry>;
|
|
22
26
|
textColor: string;
|
|
23
27
|
openTicker: (symbol: string) => void;
|
|
@@ -26,19 +30,21 @@ export function ResponsiveTickerBadgeText({
|
|
|
26
30
|
onUserHoverEnd?: () => void;
|
|
27
31
|
}) {
|
|
28
32
|
const [hoveredSymbol, setHoveredSymbol] = useState<string | null>(null);
|
|
29
|
-
const tokens = useMemo(() => tokenizeInlineContent(text), [text]);
|
|
33
|
+
const tokens = useMemo(() => providedTokens ?? tokenizeInlineContent(text), [providedTokens, text]);
|
|
30
34
|
const renderTextToken = (value: string, tokenIndex: number) => {
|
|
31
35
|
if (!value) return null;
|
|
32
36
|
return (
|
|
33
37
|
<Text
|
|
34
38
|
key={`text:${tokenIndex}`}
|
|
35
39
|
fg={textColor}
|
|
36
|
-
wrapText
|
|
37
|
-
style={
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
40
|
+
wrapText={!prewrapped}
|
|
41
|
+
style={prewrapped
|
|
42
|
+
? undefined
|
|
43
|
+
: {
|
|
44
|
+
minWidth: 0,
|
|
45
|
+
whiteSpace: "pre-wrap",
|
|
46
|
+
overflowWrap: "anywhere",
|
|
47
|
+
}}
|
|
42
48
|
>
|
|
43
49
|
{value}
|
|
44
50
|
</Text>
|
|
@@ -41,7 +41,11 @@ export function getChatMessageRenderState({
|
|
|
41
41
|
const isSending = msg.clientStatus === "sending";
|
|
42
42
|
const hasFailed = msg.clientStatus === "failed";
|
|
43
43
|
const selectedTextColor = hasFailed ? colors.negative : colors.selectedText;
|
|
44
|
-
const headerStatus = isSending
|
|
44
|
+
const headerStatus = isSending
|
|
45
|
+
? "sending..."
|
|
46
|
+
: hasFailed
|
|
47
|
+
? "failed"
|
|
48
|
+
: `${formatTimeAgo(msg.createdAt)}${msg.editedAt ? " edited" : ""}`;
|
|
45
49
|
|
|
46
50
|
return {
|
|
47
51
|
isSelected,
|