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
@@ -12,6 +12,26 @@ interface MutableRef<T> {
12
12
  current: T;
13
13
  }
14
14
 
15
+ function moveComposerCursorToEnd(textarea: TextareaRenderable, draft: string) {
16
+ const offset = draft.length;
17
+ const editBuffer = textarea.editBuffer as typeof textarea.editBuffer & {
18
+ setCursorByOffset?: (offset: number) => void;
19
+ };
20
+ if (typeof editBuffer.setCursorByOffset === "function") {
21
+ editBuffer.setCursorByOffset(offset);
22
+ return;
23
+ }
24
+ if (typeof textarea.setCursorOffset === "function") {
25
+ textarea.setCursorOffset(offset);
26
+ return;
27
+ }
28
+ try {
29
+ textarea.cursorOffset = offset;
30
+ } catch {
31
+ // Some host renderers expose cursorOffset as read-only.
32
+ }
33
+ }
34
+
15
35
  export function useChatComposerRuntime({
16
36
  applyingExternalDraftRef,
17
37
  blurInput,
@@ -27,7 +47,10 @@ export function useChatComposerRuntime({
27
47
  inputValueRef,
28
48
  messages,
29
49
  onChannelChange,
50
+ editingMessage,
51
+ latestEditableMessageId,
30
52
  replyTo,
53
+ setEditingMessage,
31
54
  setDirectExpanded,
32
55
  setFollowMessages,
33
56
  setReplyTo,
@@ -49,7 +72,10 @@ export function useChatComposerRuntime({
49
72
  inputValueRef: MutableRef<string>;
50
73
  messages: ChatMessage[];
51
74
  onChannelChange?: (channelId: string) => void;
75
+ editingMessage: ChatMessage | null;
76
+ latestEditableMessageId: string | null;
52
77
  replyTo: ChatMessage | null;
78
+ setEditingMessage: Dispatch<SetStateAction<ChatMessage | null>>;
53
79
  setDirectExpanded: Dispatch<SetStateAction<boolean>>;
54
80
  setFollowMessages: Dispatch<SetStateAction<boolean>>;
55
81
  setReplyTo: Dispatch<SetStateAction<ChatMessage | null>>;
@@ -59,6 +85,9 @@ export function useChatComposerRuntime({
59
85
  }) {
60
86
  const replyToRef = useRef(replyTo);
61
87
  replyToRef.current = replyTo;
88
+ const editingMessageRef = useRef(editingMessage);
89
+ editingMessageRef.current = editingMessage;
90
+ const editSubmittingRef = useRef(false);
62
91
 
63
92
  const focusComposer = useCallback(() => {
64
93
  setSelectedIdx(-1);
@@ -75,10 +104,40 @@ export function useChatComposerRuntime({
75
104
  }
76
105
  }, [channelId, controller, setReplyTo, useDefaultControllerChannel]);
77
106
 
107
+ const persistDraft = useCallback((draft: string) => {
108
+ if (useDefaultControllerChannel) {
109
+ controller.setDraft(draft);
110
+ } else {
111
+ controller.setChannelDraft(channelId, draft);
112
+ }
113
+ }, [channelId, controller, useDefaultControllerChannel]);
114
+
115
+ const replaceLocalComposer = useCallback((draft: string) => {
116
+ inputValueRef.current = draft;
117
+ updateComposerRows(draft);
118
+ const textarea = inputRef.current;
119
+ if (textarea && textarea.editBuffer.getText() !== draft) {
120
+ applyingExternalDraftRef.current = true;
121
+ try {
122
+ textarea.setText(draft);
123
+ moveComposerCursorToEnd(textarea, draft);
124
+ } finally {
125
+ applyingExternalDraftRef.current = false;
126
+ }
127
+ } else if (textarea) {
128
+ moveComposerCursorToEnd(textarea, draft);
129
+ }
130
+ }, [applyingExternalDraftRef, inputRef, inputValueRef, updateComposerRows]);
131
+
78
132
  const beginReplyTo = useCallback((index: number, options?: { deferFocus?: boolean }) => {
79
133
  if (!canSend || index < 0 || index >= messages.length) return;
80
134
  const nextReplyTo = messages[index] ?? null;
81
135
  if (!nextReplyTo) return;
136
+ if (editingMessageRef.current) {
137
+ replaceLocalComposer("");
138
+ persistDraft("");
139
+ }
140
+ setEditingMessage(null);
82
141
  setSelectedIdx(index);
83
142
  setFollowMessages(index === messages.length - 1);
84
143
  setReplyTo(nextReplyTo);
@@ -98,12 +157,58 @@ export function useChatComposerRuntime({
98
157
  controller,
99
158
  focusInput,
100
159
  messages,
160
+ persistDraft,
161
+ replaceLocalComposer,
162
+ setEditingMessage,
101
163
  setFollowMessages,
102
164
  setReplyTo,
103
165
  setSelectedIdx,
104
166
  useDefaultControllerChannel,
105
167
  ]);
106
168
 
169
+ const beginEditMessage = useCallback((index: number, options?: { deferFocus?: boolean }) => {
170
+ if (!canSend || index < 0 || index >= messages.length) return false;
171
+ const message = messages[index] ?? null;
172
+ if (!message || message.id !== latestEditableMessageId) return false;
173
+ clearReplyTarget();
174
+ setEditingMessage(message);
175
+ setSelectedIdx(index);
176
+ setFollowMessages(index === messages.length - 1);
177
+ replaceLocalComposer(message.content);
178
+ persistDraft(message.content);
179
+ if (options?.deferFocus) {
180
+ queueMicrotask(() => focusInput());
181
+ } else {
182
+ focusInput();
183
+ }
184
+ return true;
185
+ }, [
186
+ canSend,
187
+ clearReplyTarget,
188
+ focusInput,
189
+ latestEditableMessageId,
190
+ messages,
191
+ persistDraft,
192
+ replaceLocalComposer,
193
+ setEditingMessage,
194
+ setFollowMessages,
195
+ setSelectedIdx,
196
+ ]);
197
+
198
+ const beginEditLatestMessage = useCallback((options?: { deferFocus?: boolean }) => {
199
+ if (!latestEditableMessageId) return false;
200
+ const index = messages.findIndex((message) => message.id === latestEditableMessageId);
201
+ return beginEditMessage(index, options);
202
+ }, [beginEditMessage, latestEditableMessageId, messages]);
203
+
204
+ const cancelEditMessage = useCallback(() => {
205
+ if (!editingMessageRef.current) return;
206
+ setEditingMessage(null);
207
+ replaceLocalComposer("");
208
+ persistDraft("");
209
+ focusInput();
210
+ }, [focusInput, persistDraft, replaceLocalComposer, setEditingMessage]);
211
+
107
212
  const returnToComposer = useCallback(() => {
108
213
  setSelectedIdx(-1);
109
214
  setFollowMessages(true);
@@ -113,22 +218,30 @@ export function useChatComposerRuntime({
113
218
  }, [canSend, focusInput, setFollowMessages, setSelectedIdx]);
114
219
 
115
220
  const clearLocalComposer = useCallback(() => {
116
- inputValueRef.current = "";
117
- updateComposerRows("");
118
- const textarea = inputRef.current;
119
- if (textarea && textarea.editBuffer.getText() !== "") {
120
- applyingExternalDraftRef.current = true;
121
- try {
122
- textarea.setText("");
123
- } finally {
124
- applyingExternalDraftRef.current = false;
125
- }
126
- }
127
- }, [applyingExternalDraftRef, inputRef, inputValueRef, updateComposerRows]);
221
+ replaceLocalComposer("");
222
+ }, [replaceLocalComposer]);
128
223
 
129
224
  const sendMessage = useCallback(() => {
130
225
  const content = inputValueRef.current.trim();
131
226
  if (!content) return;
227
+ const editing = editingMessageRef.current;
228
+ if (editing) {
229
+ if (editSubmittingRef.current) return;
230
+ const sendChannelId = useDefaultControllerChannel ? channelId : channelIdRef.current;
231
+ if (editing.channelId !== sendChannelId) return;
232
+ editSubmittingRef.current = true;
233
+ void controller.editChannelMessage(sendChannelId, editing.id, content).then((accepted) => {
234
+ if (!accepted) return;
235
+ setEditingMessage(null);
236
+ clearLocalComposer();
237
+ persistDraft("");
238
+ setSelectedIdx(-1);
239
+ setFollowMessages(true);
240
+ }).finally(() => {
241
+ editSubmittingRef.current = false;
242
+ });
243
+ return;
244
+ }
132
245
  const composerCommand = parseChatComposerCommand(content);
133
246
  if (composerCommand?.kind === "direct") {
134
247
  void controller.openDirectChannel({ username: composerCommand.username }).then((channel) => {
@@ -154,9 +267,13 @@ export function useChatComposerRuntime({
154
267
  }).catch(() => {});
155
268
  return;
156
269
  }
270
+ const sendChannelId = useDefaultControllerChannel ? channelId : channelIdRef.current;
271
+ const replyToId = replyToRef.current?.channelId === sendChannelId
272
+ ? replyToRef.current.id
273
+ : undefined;
157
274
  const accepted = useDefaultControllerChannel
158
- ? controller.send(content, replyToRef.current?.id)
159
- : controller.sendToChannel(channelId, content, replyToRef.current?.id);
275
+ ? controller.send(content, replyToId)
276
+ : controller.sendToChannel(sendChannelId, content, replyToId);
160
277
  if (!accepted) return;
161
278
  clearLocalComposer();
162
279
  setSelectedIdx(-1);
@@ -168,7 +285,9 @@ export function useChatComposerRuntime({
168
285
  controller,
169
286
  inputValueRef,
170
287
  onChannelChange,
288
+ persistDraft,
171
289
  setDirectExpanded,
290
+ setEditingMessage,
172
291
  setFollowMessages,
173
292
  setSelectedIdx,
174
293
  useDefaultControllerChannel,
@@ -178,16 +297,13 @@ export function useChatComposerRuntime({
178
297
  if (applyingExternalDraftRef.current) return;
179
298
  inputValueRef.current = draft;
180
299
  updateComposerRows(draft);
181
- if (useDefaultControllerChannel) {
182
- controller.setDraft(draft);
183
- } else {
184
- controller.setChannelDraft(channelId, draft);
185
- }
300
+ persistDraft(draft);
186
301
  }, [
187
302
  applyingExternalDraftRef,
188
303
  channelId,
189
304
  controller,
190
305
  inputValueRef,
306
+ persistDraft,
191
307
  updateComposerRows,
192
308
  useDefaultControllerChannel,
193
309
  ]);
@@ -230,18 +346,33 @@ export function useChatComposerRuntime({
230
346
  clearReplyTarget();
231
347
  }, [canSend, clearReplyTarget, replyTo]);
232
348
 
349
+ useEffect(() => {
350
+ if (canSend || !editingMessage) return;
351
+ cancelEditMessage();
352
+ }, [canSend, cancelEditMessage, editingMessage]);
353
+
233
354
  const replyPreview = replyTo
234
355
  ? formatInlinePreview(
235
356
  replyTo.content,
236
357
  Math.max(contentWidth - ` replying to @${replyTo.user.username}: `.length - COMPOSER_ACTION_WIDTH - 1, 0),
237
358
  )
238
359
  : "";
239
- const inputPlaceholder = replyTo ? `Reply to @${replyTo.user.username}...` : "Type a message...";
360
+ const editingPreview = editingMessage
361
+ ? formatInlinePreview(
362
+ editingMessage.content,
363
+ Math.max(contentWidth - " editing: ".length - COMPOSER_ACTION_WIDTH - 1, 0),
364
+ )
365
+ : "";
366
+ const inputPlaceholder = editingMessage ? "Edit message..." : replyTo ? `Reply to @${replyTo.user.username}...` : "Type a message...";
240
367
 
241
368
  return {
369
+ beginEditLatestMessage,
370
+ beginEditMessage,
242
371
  beginReplyTo,
372
+ cancelEditMessage,
243
373
  clearReplyTarget,
244
374
  commitLocalDraft,
375
+ editingPreview,
245
376
  focusComposer,
246
377
  inputPlaceholder,
247
378
  replyPreview,
@@ -14,6 +14,7 @@ interface MutableRef<T> {
14
14
 
15
15
  interface ChatComposerAreaProps {
16
16
  canSend: boolean;
17
+ cancelEditMessage: () => void;
17
18
  clearReplyTarget: () => void;
18
19
  commitLocalDraft: (draft: string) => void;
19
20
  composerHeight: number;
@@ -27,6 +28,8 @@ interface ChatComposerAreaProps {
27
28
  inputRef: MutableRef<TextareaRenderable | null>;
28
29
  inputValueRef: MutableRef<string>;
29
30
  nativePaneChrome: boolean | undefined;
31
+ editingMessage: ChatMessage | null;
32
+ editingPreview: string;
30
33
  replyPreview: string;
31
34
  replyTo: ChatMessage | null;
32
35
  sendMessage: () => void;
@@ -35,6 +38,7 @@ interface ChatComposerAreaProps {
35
38
 
36
39
  export function ChatComposerArea({
37
40
  canSend,
41
+ cancelEditMessage,
38
42
  clearReplyTarget,
39
43
  commitLocalDraft,
40
44
  composerHeight,
@@ -48,6 +52,8 @@ export function ChatComposerArea({
48
52
  inputRef,
49
53
  inputValueRef,
50
54
  nativePaneChrome,
55
+ editingMessage,
56
+ editingPreview,
51
57
  replyPreview,
52
58
  replyTo,
53
59
  sendMessage,
@@ -89,7 +95,20 @@ export function ChatComposerArea({
89
95
  />
90
96
  )}
91
97
 
92
- {replyTo && (
98
+ {editingMessage && (
99
+ <Box height={1} width={contentWidth} flexDirection="row">
100
+ <Text fg={colors.textMuted}> editing </Text>
101
+ <Text fg={colors.textDim}>{editingPreview ? `: ${editingPreview}` : ""}</Text>
102
+ <Box flexGrow={1} />
103
+ <ChatActionChip
104
+ label="Cancel"
105
+ width={COMPOSER_ACTION_WIDTH}
106
+ onPress={cancelEditMessage}
107
+ />
108
+ </Box>
109
+ )}
110
+
111
+ {!editingMessage && replyTo && (
93
112
  <Box height={1} width={contentWidth} flexDirection="row">
94
113
  <Text fg={colors.textMuted}> replying to </Text>
95
114
  <Text fg={colors.positive} attributes={TextAttributes.BOLD}>{`@${replyTo.user.username}`}</Text>
@@ -135,14 +154,16 @@ export function ChatComposerArea({
135
154
  export function getChatComposerAreaHeight({
136
155
  canSend,
137
156
  composerHeight,
157
+ editingMessage,
138
158
  nativePaneChrome,
139
159
  replyTo,
140
160
  }: {
141
161
  canSend: boolean;
142
162
  composerHeight: number;
163
+ editingMessage: ChatMessage | null;
143
164
  nativePaneChrome: boolean | undefined;
144
165
  replyTo: ChatMessage | null;
145
166
  }) {
146
167
  if (!canSend) return 2;
147
- return getMessageComposerBlockHeight({ height: composerHeight, nativePaneChrome }) + (replyTo ? 1 : 0);
168
+ return getMessageComposerBlockHeight({ height: composerHeight, nativePaneChrome }) + (editingMessage || replyTo ? 1 : 0);
148
169
  }
@@ -10,6 +10,7 @@ import {
10
10
  } from "../layout";
11
11
  import {
12
12
  DEFAULT_CHAT_CHANNEL_ID,
13
+ formatChatPaneTitle,
13
14
  normalizeChannelId,
14
15
  } from "../channels";
15
16
  import { ChatComposerArea } from "./composer";
@@ -30,6 +31,11 @@ import {
30
31
  import { buildChatUserByUsername } from "./user-map";
31
32
  import { useChatComposerRuntime } from "./composer-runtime";
32
33
  import { useChatMessageSelection } from "./selection-runtime";
34
+ import type { ChatMessage } from "../../../../api-client";
35
+ import {
36
+ CHAT_MESSAGE_EDIT_WINDOW_MS,
37
+ findLatestEditableChatMessage,
38
+ } from "../edit-window";
33
39
 
34
40
  interface ChatContentProps {
35
41
  width: number;
@@ -37,6 +43,7 @@ interface ChatContentProps {
37
43
  focused: boolean;
38
44
  channelId?: string;
39
45
  onChannelChange?: (channelId: string) => void;
46
+ onChannelTitleChange?: (title: string) => void;
40
47
  controller?: ChatContentController;
41
48
  }
42
49
 
@@ -46,6 +53,7 @@ export function ChatContent({
46
53
  focused,
47
54
  channelId: rawChannelId,
48
55
  onChannelChange,
56
+ onChannelTitleChange,
49
57
  controller = chatController,
50
58
  }: ChatContentProps) {
51
59
  const dispatch = useAppDispatch();
@@ -57,12 +65,14 @@ export function ChatContent({
57
65
  const [inputFocused, setInputFocused] = useState(false);
58
66
  const [selectedIdx, setSelectedIdx] = useState(-1);
59
67
  const [hoveredIdx, setHoveredIdx] = useState<number | null>(null);
68
+ const [editingMessage, setEditingMessage] = useState<ChatMessage | null>(null);
60
69
  const [followMessages, setFollowMessages] = useState(true);
61
70
  const inputRef = useRef<TextareaRenderable>(null);
62
71
  const scrollRef = useRef<ScrollBoxRenderable>(null);
63
72
  const messageElementsRef = useRef(new Map<string, unknown>());
64
73
  const applyingExternalDraftRef = useRef(false);
65
74
  const prependAnchorRef = useRef<ChatPrependAnchor | null>(null);
75
+ const previousEditingChannelIdRef = useRef(channelId);
66
76
  const useDefaultControllerChannel = channelId === DEFAULT_CHAT_CHANNEL_ID && !onChannelChange;
67
77
  const initialWidthMetrics = resolveChatContentWidthMetrics({
68
78
  width,
@@ -122,12 +132,36 @@ export function ChatContent({
122
132
  const selectionActive = selectedIdx >= 0 && selectedIdx < messages.length;
123
133
  const stickyTranscript = followMessages && !selectionActive;
124
134
  const latestMessageId = messages[messages.length - 1]?.id ?? null;
135
+ const [editWindowNowMs, setEditWindowNowMs] = useState(() => Date.now());
136
+ const latestOwnMessage = useMemo(() => {
137
+ if (!user?.id) return null;
138
+ return [...messages]
139
+ .reverse()
140
+ .find((message) => message.user.id === user.id && !message.clientStatus) ?? null;
141
+ }, [messages, user?.id]);
142
+
143
+ useEffect(() => {
144
+ if (!latestOwnMessage) return;
145
+ const createdMs = Date.parse(latestOwnMessage.createdAt);
146
+ if (!Number.isFinite(createdMs)) return;
147
+ const expiresInMs = createdMs + CHAT_MESSAGE_EDIT_WINDOW_MS - Date.now();
148
+ if (expiresInMs <= 0) return;
149
+ const timer = setTimeout(() => {
150
+ setEditWindowNowMs(Date.now());
151
+ }, Math.min(expiresInMs + 250, 60_000));
152
+ return () => clearTimeout(timer);
153
+ }, [editWindowNowMs, latestOwnMessage]);
154
+
155
+ const latestEditableMessageId = useMemo(() => {
156
+ return findLatestEditableChatMessage(messages, user?.id, editWindowNowMs)?.id ?? null;
157
+ }, [editWindowNowMs, messages, user?.id]);
125
158
  const {
126
159
  composerHeight,
127
160
  messageAreaHeight,
128
161
  } = resolveChatContentHeightMetrics({
129
162
  canSend,
130
163
  composerRows,
164
+ editingMessage,
131
165
  height,
132
166
  nativePaneChrome,
133
167
  replyTo,
@@ -140,6 +174,8 @@ export function ChatContent({
140
174
  const messageContents = useMemo(() => messages.map((message) => message.content), [messages]);
141
175
  const { catalog, openTicker } = useInlineTickers(messageContents);
142
176
  const userByUsername = useMemo(() => buildChatUserByUsername(channels, messages), [channels, messages]);
177
+ const activeChannel = useMemo(() => channels.find((channel) => channel.id === channelId), [channelId, channels]);
178
+ const activeChannelTitle = useMemo(() => formatChatPaneTitle(activeChannel, channelId), [activeChannel, channelId]);
143
179
  const {
144
180
  cancelProfilePopoverClose,
145
181
  closeProfilePopover,
@@ -153,6 +189,16 @@ export function ChatContent({
153
189
  dispatch({ type: "SET_INPUT_CAPTURED", captured: false });
154
190
  }, [dispatch]);
155
191
 
192
+ useEffect(() => {
193
+ onChannelTitleChange?.(activeChannelTitle);
194
+ }, [activeChannelTitle, onChannelTitleChange]);
195
+
196
+ useEffect(() => {
197
+ if (previousEditingChannelIdRef.current === channelId) return;
198
+ previousEditingChannelIdRef.current = channelId;
199
+ setEditingMessage(null);
200
+ }, [channelId]);
201
+
156
202
  const {
157
203
  moveMessageSelection,
158
204
  resetTranscriptSelection,
@@ -200,9 +246,13 @@ export function ChatContent({
200
246
  }, [dispatch, setSidebarFocused]);
201
247
 
202
248
  const {
249
+ beginEditLatestMessage,
250
+ beginEditMessage,
203
251
  beginReplyTo,
252
+ cancelEditMessage,
204
253
  clearReplyTarget,
205
254
  commitLocalDraft,
255
+ editingPreview,
206
256
  focusComposer,
207
257
  inputPlaceholder,
208
258
  replyPreview,
@@ -223,7 +273,10 @@ export function ChatContent({
223
273
  inputValueRef,
224
274
  messages,
225
275
  onChannelChange,
276
+ editingMessage,
277
+ latestEditableMessageId,
226
278
  replyTo,
279
+ setEditingMessage,
227
280
  setDirectExpanded,
228
281
  setFollowMessages,
229
282
  setReplyTo,
@@ -263,9 +316,11 @@ export function ChatContent({
263
316
  });
264
317
 
265
318
  useChatContentShortcuts({
319
+ beginEditLatestMessage,
266
320
  beginReplyTo,
267
321
  blurInput,
268
322
  canSend,
323
+ cancelEditMessage,
269
324
  clearReplyTarget,
270
325
  cycleChannel,
271
326
  focusChannelSidebar,
@@ -280,6 +335,7 @@ export function ChatContent({
280
335
  moveMessageSelection,
281
336
  moveSidebarChannelSelection,
282
337
  nativePaneChrome,
338
+ editingMessage,
283
339
  replyTo,
284
340
  requestOlderMessages,
285
341
  requestOlderMessagesIfNeeded,
@@ -347,6 +403,7 @@ export function ChatContent({
347
403
 
348
404
  <ChatTranscript
349
405
  beginReplyTo={beginReplyTo}
406
+ beginEditMessage={beginEditMessage}
350
407
  canSend={canSend}
351
408
  catalog={catalog}
352
409
  cancelProfilePopoverClose={cancelProfilePopoverClose}
@@ -361,6 +418,7 @@ export function ChatContent({
361
418
  messageBodyWidth={messageBodyWidth}
362
419
  messages={messages}
363
420
  nativePaneChrome={nativePaneChrome}
421
+ latestEditableMessageId={latestEditableMessageId}
364
422
  openDirectMessage={openDirectMessage}
365
423
  openTicker={openTicker}
366
424
  profilePopoverUser={profilePopoverUser}
@@ -383,6 +441,7 @@ export function ChatContent({
383
441
 
384
442
  <ChatComposerArea
385
443
  canSend={canSend}
444
+ cancelEditMessage={cancelEditMessage}
386
445
  clearReplyTarget={clearReplyTarget}
387
446
  commitLocalDraft={commitLocalDraft}
388
447
  composerHeight={composerHeight}
@@ -396,6 +455,8 @@ export function ChatContent({
396
455
  inputRef={inputRef}
397
456
  inputValueRef={inputValueRef}
398
457
  nativePaneChrome={nativePaneChrome}
458
+ editingMessage={editingMessage}
459
+ editingPreview={editingPreview}
399
460
  replyPreview={replyPreview}
400
461
  replyTo={replyTo}
401
462
  sendMessage={sendMessage}
@@ -42,12 +42,14 @@ export function resolveChatContentWidthMetrics({
42
42
  export function resolveChatContentHeightMetrics({
43
43
  canSend,
44
44
  composerRows,
45
+ editingMessage,
45
46
  height,
46
47
  nativePaneChrome,
47
48
  replyTo,
48
49
  }: {
49
50
  canSend: boolean;
50
51
  composerRows: number;
52
+ editingMessage: ChatMessage | null;
51
53
  height: number;
52
54
  nativePaneChrome: boolean | undefined;
53
55
  replyTo: ChatMessage | null;
@@ -57,7 +59,7 @@ export function resolveChatContentHeightMetrics({
57
59
  ? Math.min(CHAT_COMPOSER_MAX_ROWS + 1, Math.max(2, composerRows + 1))
58
60
  : composerRows
59
61
  : 0;
60
- const inputAreaHeight = getChatComposerAreaHeight({ canSend, composerHeight, nativePaneChrome, replyTo });
62
+ const inputAreaHeight = getChatComposerAreaHeight({ canSend, composerHeight, editingMessage, nativePaneChrome, replyTo });
61
63
  const topSeparatorHeight = nativePaneChrome ? 0 : 1;
62
64
  const footerSeparatorHeight = !nativePaneChrome && !canSend ? 1 : 0;
63
65
  const messageAreaHeight = Math.max(1, height - topSeparatorHeight - footerSeparatorHeight - inputAreaHeight);
@@ -6,9 +6,11 @@ import { isPlainKey } from "../../../../utils/keyboard";
6
6
  import { scrollToBottom } from "../layout";
7
7
 
8
8
  export function useChatContentShortcuts({
9
+ beginEditLatestMessage,
9
10
  beginReplyTo,
10
11
  blurInput,
11
12
  canSend,
13
+ cancelEditMessage,
12
14
  clearReplyTarget,
13
15
  cycleChannel,
14
16
  focusChannelSidebar,
@@ -23,6 +25,7 @@ export function useChatContentShortcuts({
23
25
  moveMessageSelection,
24
26
  moveSidebarChannelSelection,
25
27
  nativePaneChrome,
28
+ editingMessage,
26
29
  replyTo,
27
30
  requestOlderMessages,
28
31
  requestOlderMessagesIfNeeded,
@@ -35,9 +38,11 @@ export function useChatContentShortcuts({
35
38
  showChannelSidebar,
36
39
  sidebarFocusedRef,
37
40
  }: {
41
+ beginEditLatestMessage: (options?: { deferFocus?: boolean }) => boolean;
38
42
  beginReplyTo: (index: number, options?: { deferFocus?: boolean }) => void;
39
43
  blurInput: () => void;
40
44
  canSend: boolean;
45
+ cancelEditMessage: () => void;
41
46
  clearReplyTarget: () => void;
42
47
  cycleChannel: (direction: 1 | -1) => boolean;
43
48
  focusChannelSidebar: () => boolean;
@@ -52,6 +57,7 @@ export function useChatContentShortcuts({
52
57
  moveMessageSelection: (direction: "up" | "down") => boolean;
53
58
  moveSidebarChannelSelection: (direction: "up" | "down") => boolean;
54
59
  nativePaneChrome?: boolean;
60
+ editingMessage: ChatMessage | null;
55
61
  replyTo: ChatMessage | null;
56
62
  requestOlderMessages: () => void;
57
63
  requestOlderMessagesIfNeeded: () => void;
@@ -107,7 +113,9 @@ export function useChatContentShortcuts({
107
113
  if (event.name === "escape") {
108
114
  event.preventDefault?.();
109
115
  event.stopPropagation?.();
110
- if (replyTo) {
116
+ if (editingMessage) {
117
+ cancelEditMessage();
118
+ } else if (replyTo) {
111
119
  clearReplyTarget();
112
120
  } else {
113
121
  blurInput();
@@ -116,6 +124,17 @@ export function useChatContentShortcuts({
116
124
  }
117
125
 
118
126
  const verticalDirection = event.name === "up" || event.name === "down" ? event.name : null;
127
+ if (
128
+ verticalDirection === "up"
129
+ && canSend
130
+ && isPlainKey(event, "up")
131
+ && inputValueRef.current.trim().length === 0
132
+ && beginEditLatestMessage({ deferFocus: true })
133
+ ) {
134
+ event.preventDefault?.();
135
+ event.stopPropagation?.();
136
+ return;
137
+ }
119
138
  if (verticalDirection && isPlainKey(event, "up", "down") && shouldLeaveComposerForSelection(verticalDirection)) {
120
139
  const moved = moveMessageSelection(verticalDirection);
121
140
  if (moved) {
@@ -13,6 +13,7 @@ interface MutableRef<T> {
13
13
 
14
14
  interface ChatTranscriptProps {
15
15
  beginReplyTo: (index: number, options?: { deferFocus?: boolean }) => void;
16
+ beginEditMessage: (index: number, options?: { deferFocus?: boolean }) => boolean;
16
17
  canSend: boolean;
17
18
  catalog: Record<string, InlineTickerCatalogEntry>;
18
19
  cancelProfilePopoverClose: () => void;
@@ -27,6 +28,7 @@ interface ChatTranscriptProps {
27
28
  messageBodyWidth: number;
28
29
  messages: ChatMessage[];
29
30
  nativePaneChrome: boolean | undefined;
31
+ latestEditableMessageId: string | null;
30
32
  openDirectMessage: (target: ChatUserSummary) => void;
31
33
  openTicker: (symbol: string) => void;
32
34
  profilePopoverUser: ChatUserSummary | null;
@@ -43,6 +45,7 @@ interface ChatTranscriptProps {
43
45
 
44
46
  export function ChatTranscript({
45
47
  beginReplyTo,
48
+ beginEditMessage,
46
49
  canSend,
47
50
  catalog,
48
51
  cancelProfilePopoverClose,
@@ -57,6 +60,7 @@ export function ChatTranscript({
57
60
  messageBodyWidth,
58
61
  messages,
59
62
  nativePaneChrome,
63
+ latestEditableMessageId,
60
64
  openDirectMessage,
61
65
  openTicker,
62
66
  profilePopoverUser,
@@ -113,7 +117,9 @@ export function ChatTranscript({
113
117
  onUserHover={showProfilePopover}
114
118
  onUserHoverEnd={scheduleProfilePopoverClose}
115
119
  beginReplyTo={beginReplyTo}
120
+ beginEditMessage={beginEditMessage}
116
121
  jumpToMessage={jumpToMessage}
122
+ latestEditableMessageId={latestEditableMessageId}
117
123
  registerMessageElement={registerMessageElement}
118
124
  />
119
125
  ) : (
@@ -133,7 +139,9 @@ export function ChatTranscript({
133
139
  onUserHover={showProfilePopover}
134
140
  onUserHoverEnd={scheduleProfilePopoverClose}
135
141
  beginReplyTo={beginReplyTo}
142
+ beginEditMessage={beginEditMessage}
136
143
  jumpToMessage={jumpToMessage}
144
+ latestEditableMessageId={latestEditableMessageId}
137
145
  setHoveredIdx={setHoveredIdx}
138
146
  />
139
147
  )
@@ -15,6 +15,7 @@ export type ChatContentController = Pick<
15
15
  | "refreshSession"
16
16
  | "send"
17
17
  | "sendToChannel"
18
+ | "editChannelMessage"
18
19
  | "openDirectChannel"
19
20
  | "openGroupChannel"
20
21
  | "setDraft"