@emaxe/tuigram 1.3.0 → 1.5.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/src/ui/app.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import path from "node:path";
2
2
  import fs from "node:fs";
3
- import { createScreen } from "./screen.js";
3
+ import { createScreen, setMouseCapture } from "./screen.js";
4
4
  import { getTheme } from "./theme.js";
5
5
  import { createHeader } from "./components/header.js";
6
6
  import { createChatList } from "./components/chatList.js";
@@ -12,16 +12,20 @@ import { createChatInfoModal } from "./components/modals/chatInfoModal.js";
12
12
  import { createActionModal } from "./components/modals/actionModal.js";
13
13
  import { createFileModal } from "./components/modals/fileModal.js";
14
14
  import { createConfirmModal } from "./components/modals/confirmModal.js";
15
+ import { createImageViewerModal } from "./components/modals/imageViewerModal.js";
16
+ import { createVideoPlayerModal } from "./components/modals/videoPlayerModal.js";
15
17
  import { state } from "../state.js";
16
18
  import { config } from "../config.js";
17
19
  import { fetchDialogs } from "../telegram/dialogs.js";
18
20
  import { setMessagePalette } from "../telegram/formatter.js";
19
- import { fetchHistory, sendMessage, editMessage, deleteMessages, sendFiles, downloadMedia, sendReaction, markAsRead, loadMessageImagePreview } from "../telegram/messages.js";
21
+ import { fetchHistory, sendMessage, editMessage, deleteMessages, sendFiles, downloadMedia, sendReaction, markAsRead, loadMessageImagePreview, downloadImageBuffer, renderMessageThumbnail, findFirstUnreadMessage, calculateRemainingUnreadCount } from "../telegram/messages.js";
20
22
  import { startTelegramListener } from "../telegram/listener.js";
21
23
  import { logout } from "../telegram/auth.js";
22
24
  import { ensureDir, inspectLocalFile } from "../utils/storage.js";
23
25
  import { formatFileSize } from "../utils/time.js";
24
26
  import { parseSendFileArgs } from "../utils/commands.js";
27
+ import { isRightClick } from "../utils/mouse.js";
28
+ import { renderMediaPreloader } from "../utils/image.js";
25
29
 
26
30
  /**
27
31
  * Запускает полноэкранный TUI-клиент Telegram.
@@ -36,7 +40,10 @@ export async function startTui(client, me) {
36
40
  let listener = null;
37
41
  const screen = createScreen({
38
42
  theme,
39
- onExit: () => listener?.stop(),
43
+ onExit: () => {
44
+ flushPendingMarkAsRead();
45
+ listener?.stop();
46
+ },
40
47
  });
41
48
 
42
49
  state.me = me;
@@ -93,10 +100,10 @@ export async function startTui(client, me) {
93
100
  statusBar.showMessage("Сначала выберите чат слева!", "warning");
94
101
  return;
95
102
  }
96
- const msgs = state.getMessages(state.activeChat.id);
97
- if (msgs.length > 0) {
103
+ const msg = chatView.getTargetMessage();
104
+ if (msg) {
98
105
  releaseInputs();
99
- actionModal.show(msgs[msgs.length - 1]);
106
+ actionModal.show(msg);
100
107
  }
101
108
  },
102
109
  onChatInfo: () => {
@@ -110,6 +117,7 @@ export async function startTui(client, me) {
110
117
  onQuit: () => {
111
118
  releaseInputs();
112
119
  confirmModal.ask("Выйти из TuiGram?", () => {
120
+ flushPendingMarkAsRead();
113
121
  listener?.stop();
114
122
  screen.destroy();
115
123
  process.exit(0);
@@ -125,12 +133,77 @@ export async function startTui(client, me) {
125
133
  onSendFile: (files, options) => sendFilesToActiveChat(files, options),
126
134
  });
127
135
 
136
+ const imageViewerModal = createImageViewerModal(screen, theme, {
137
+ onLoadFullImage: (msg) => downloadImageBuffer(client, msg.rawMessage),
138
+ // Пока качается оригинал, показываем встроенную в сообщение миниатюру или прелоадер
139
+ onRenderPlaceholder: (msg, size) => renderMessageThumbnail(msg.rawMessage, {
140
+ maxWidth: size.maxWidth,
141
+ maxHeight: size.maxHeight,
142
+ useCache: false,
143
+ }) || renderMediaPreloader(msg.rawMessage, {
144
+ maxWidth: size.maxWidth,
145
+ maxHeight: size.maxHeight,
146
+ palette: {
147
+ bg: theme.surface,
148
+ border: theme.borders.fg,
149
+ fg: theme.fg,
150
+ accent: theme.accent,
151
+ dim: theme.dim,
152
+ },
153
+ }),
154
+ });
155
+
156
+ const videoPlayerModal = createVideoPlayerModal(screen, theme, {
157
+ onLoadVideoFile: async (msg, progressCallback) => {
158
+ const downloadsDir = config.downloadsDir;
159
+ ensureDir(downloadsDir);
160
+ let fileName = `video_${msg.id}.mp4`;
161
+ const doc = msg.rawMessage?.media?.document;
162
+ if (doc?.attributes) {
163
+ for (const attr of doc.attributes) {
164
+ if (attr.className === "DocumentAttributeFilename" && attr.fileName) {
165
+ fileName = `msg_${msg.id}_${attr.fileName}`;
166
+ break;
167
+ }
168
+ }
169
+ }
170
+ const targetPath = path.join(downloadsDir, fileName);
171
+ if (fs.existsSync(targetPath) && fs.statSync(targetPath).size > 0) {
172
+ return targetPath;
173
+ }
174
+ const res = await downloadMedia(client, msg.rawMessage, {
175
+ outputFile: targetPath,
176
+ progressCallback,
177
+ });
178
+ return typeof res === "string" ? res : targetPath;
179
+ },
180
+ onRenderPlaceholder: (msg, size) => renderMessageThumbnail(msg.rawMessage, {
181
+ maxWidth: size.maxWidth,
182
+ maxHeight: size.maxHeight,
183
+ useCache: false,
184
+ }) || renderMediaPreloader(msg.rawMessage, {
185
+ maxWidth: size.maxWidth,
186
+ maxHeight: size.maxHeight,
187
+ palette: {
188
+ bg: theme.surface,
189
+ border: theme.borders.fg,
190
+ fg: theme.fg,
191
+ accent: theme.accent,
192
+ dim: theme.dim,
193
+ },
194
+ }),
195
+ });
196
+
128
197
  const actionModal = createActionModal(screen, theme, {
129
198
  onAction: async (actionId, msg) => {
130
199
  if (!state.activeChat) return;
131
200
  const peerId = state.activeChat.peerId;
132
201
 
133
202
  switch (actionId) {
203
+ case "play_video":
204
+ releaseInputs();
205
+ videoPlayerModal.play(msg);
206
+ break;
134
207
  case "reply":
135
208
  inputBox.setContext("reply", msg);
136
209
  break;
@@ -188,20 +261,62 @@ export async function startTui(client, me) {
188
261
  },
189
262
  });
190
263
 
264
+ let readDebounceTimer = null;
265
+ let pendingReadMaxId = 0;
266
+ let pendingReadPeerId = null;
267
+
268
+ /**
269
+ * Отправляет подтверждение прочтения на сервер с дебаунсом, чтобы не спамить сеть при скролле.
270
+ * @param {string|number|bigint} peerId
271
+ * @param {number} maxId
272
+ */
273
+ function debouncedMarkAsRead(peerId, maxId) {
274
+ pendingReadPeerId = peerId;
275
+ if (maxId > pendingReadMaxId) {
276
+ pendingReadMaxId = maxId;
277
+ }
278
+ if (readDebounceTimer) clearTimeout(readDebounceTimer);
279
+ readDebounceTimer = setTimeout(() => {
280
+ flushPendingMarkAsRead();
281
+ }, 300);
282
+ }
283
+
284
+ /** Сбрасывает накопленный маркер прочтения на сервер без задержки. */
285
+ function flushPendingMarkAsRead() {
286
+ if (readDebounceTimer) {
287
+ clearTimeout(readDebounceTimer);
288
+ readDebounceTimer = null;
289
+ }
290
+ if (pendingReadPeerId && pendingReadMaxId > 0) {
291
+ const peer = pendingReadPeerId;
292
+ const id = pendingReadMaxId;
293
+ pendingReadPeerId = null;
294
+ pendingReadMaxId = 0;
295
+ markAsRead(client, peer, id).catch(() => {});
296
+ }
297
+ }
298
+
191
299
  // 2. Список диалогов (левая панель)
192
300
  const chatList = createChatList(screen, theme, {
193
301
  onSelectDialog: async (dialog) => {
302
+ flushPendingMarkAsRead();
194
303
  state.setActiveChat(dialog);
304
+ chatView.resetReadState(dialog.readInboxMaxId || 0);
195
305
  statusBar.showMessage(`Загрузка сообщений: ${dialog.title}...`, "info");
196
306
 
197
307
  try {
198
- const history = await fetchHistory(client, dialog.peerId, { limit: 50 });
199
- state.setMessages(dialog.id, history.messages);
200
- markAsRead(client, dialog.peerId).catch(() => {});
308
+ // Загружаем всю пачку непрочитанных сообщений + запас из 20 прочитанных
309
+ // для контекста и правильного позиционирования разделителя
310
+ const limit = Math.max(50, Math.min(1000, (dialog.unreadCount || 0) + 20));
311
+ const history = await fetchHistory(client, dialog.peerId, { limit });
312
+ if (state.activeChat?.id !== dialog.id) return;
313
+ const firstUnread = findFirstUnreadMessage(history.messages, dialog);
314
+ state.setMessages(dialog.id, history.messages, { firstUnreadId: firstUnread?.id || null });
201
315
  statusBar.showMessage(`Чат: ${dialog.title}`, "info");
202
- fetchMissingImagePreviews(history.messages, dialog.id);
203
316
  } catch (err) {
204
- statusBar.showMessage(`Ошибка загрузки: ${err.message}`, "error");
317
+ if (state.activeChat?.id === dialog.id) {
318
+ statusBar.showMessage(`Ошибка загрузки: ${err.message}`, "error");
319
+ }
205
320
  }
206
321
  },
207
322
  onTabChange: (tab) => {
@@ -229,7 +344,6 @@ export async function startTui(client, me) {
229
344
  if (older.messages.length > 0) {
230
345
  state.setMessages(state.activeChat.id, older.messages, true);
231
346
  statusBar.showMessage(`Загружено ${older.messages.length} предыдущих сообщений`, "info");
232
- fetchMissingImagePreviews(older.messages, state.activeChat.id);
233
347
  }
234
348
  } catch (err) {
235
349
  statusBar.showMessage(`Ошибка пагинации: ${err.message}`, "error");
@@ -239,6 +353,36 @@ export async function startTui(client, me) {
239
353
  releaseInputs();
240
354
  actionModal.show(msg);
241
355
  },
356
+ onSelectMessage: (msg) => {
357
+ const preview = (msg.text || msg.mediaDescription || "вложение").replace(/\s+/g, " ").slice(0, 30);
358
+ statusBar.showMessage(
359
+ `Выделено #${msg.id}: "${preview}" · [Enter] или правый клик — действия`,
360
+ "info",
361
+ 3000
362
+ );
363
+ },
364
+ onOpenImage: (msg) => {
365
+ releaseInputs();
366
+ imageViewerModal.show(msg);
367
+ },
368
+ onPlayVideo: (msg) => {
369
+ releaseInputs();
370
+ videoPlayerModal.play(msg);
371
+ },
372
+ onFocusRequest: () => releaseInputs(),
373
+ onMessagesRead: (maxVisibleId) => {
374
+ if (!state.activeChat) return;
375
+ const chatId = state.activeChat.id;
376
+ const dialog = state.dialogs.find((d) => d.id === chatId);
377
+ const messages = state.getMessages(chatId);
378
+ const remainingCount = calculateRemainingUnreadCount(messages, maxVisibleId, dialog?.unreadCount);
379
+ state.updateDialogUnread(chatId, remainingCount, maxVisibleId);
380
+ debouncedMarkAsRead(state.activeChat.peerId, maxVisibleId);
381
+ },
382
+ onVisibleMessagesChanged: (visibleMsgs) => {
383
+ if (!state.activeChat) return;
384
+ fetchMissingImagePreviews(visibleMsgs, state.activeChat.id);
385
+ },
242
386
  });
243
387
 
244
388
  // 4. Поле ввода (нижняя панель)
@@ -276,21 +420,8 @@ export async function startTui(client, me) {
276
420
  onCancelContext: () => {
277
421
  statusBar.showMessage("Режим ответа/редактирования сброшен", "info", 2000);
278
422
  },
279
- onReplyLast: () => {
280
- if (!state.activeChat) return;
281
- const msgs = state.getMessages(state.activeChat.id);
282
- if (msgs.length > 0) {
283
- inputBox.setContext("reply", msgs[msgs.length - 1]);
284
- }
285
- },
286
- onEditLast: () => {
287
- if (!state.activeChat) return;
288
- const msgs = state.getMessages(state.activeChat.id);
289
- const ownMsgs = msgs.filter((m) => m.out);
290
- if (ownMsgs.length > 0) {
291
- inputBox.setContext("edit", ownMsgs[ownMsgs.length - 1]);
292
- }
293
- },
423
+ onReplyLast: () => startReply(),
424
+ onEditLast: () => startEdit(),
294
425
  onSlashCommand: (cmd, args) => {
295
426
  switch (cmd) {
296
427
  case "help":
@@ -356,7 +487,8 @@ export async function startTui(client, me) {
356
487
  });
357
488
 
358
489
  // Переключение фокуса по клику мышью на любую из трех панелей
359
- chatList.container.on("click", () => {
490
+ chatList.container.on("click", (data) => {
491
+ if (isRightClick(data)) return;
360
492
  if (screen.focused !== chatList.list && screen.focused !== chatList.searchBox) {
361
493
  releaseInputs();
362
494
  chatList.focus();
@@ -365,7 +497,8 @@ export async function startTui(client, me) {
365
497
  }
366
498
  });
367
499
 
368
- chatView.container.on("click", () => {
500
+ chatView.container.on("click", (data) => {
501
+ if (isRightClick(data)) return;
369
502
  if (screen.focused !== chatView.scrollBox) {
370
503
  releaseInputs();
371
504
  chatView.focus();
@@ -374,7 +507,8 @@ export async function startTui(client, me) {
374
507
  }
375
508
  });
376
509
 
377
- inputBox.container.on("click", () => {
510
+ inputBox.container.on("click", (data) => {
511
+ if (isRightClick(data)) return;
378
512
  if (screen.focused !== inputBox.textarea) {
379
513
  inputBox.focus();
380
514
  statusBar.showMessage("Фокус: поле ввода", "info", 2000);
@@ -396,6 +530,7 @@ export async function startTui(client, me) {
396
530
  });
397
531
 
398
532
  state.on("active_chat_changed", (chat) => {
533
+ activePreviewLoads.clear();
399
534
  header.updateInfo({
400
535
  me: state.me,
401
536
  status: state.connectionStatus,
@@ -403,16 +538,30 @@ export async function startTui(client, me) {
403
538
  typingUser: state.getTypingUser(chat?.id),
404
539
  });
405
540
  const msgs = chat ? state.getMessages(chat.id) : [];
406
- chatView.setMessages(msgs);
541
+ chatView.setSelected(null);
542
+ if (chat && msgs.length > 0) {
543
+ const firstUnread = findFirstUnreadMessage(msgs, chat);
544
+ if (firstUnread) {
545
+ chatView.setMessages(msgs, { firstUnreadId: firstUnread.id, autoScrollToBottom: false });
546
+ } else {
547
+ chatView.setMessages(msgs, true);
548
+ }
549
+ } else {
550
+ chatView.setMessages([], false);
551
+ }
407
552
  });
408
553
 
409
- state.on("messages_updated", ({ chatId, messages, isPrepend, isUpdate, isNewMessage }) => {
554
+ state.on("messages_updated", ({ chatId, messages, isPrepend, isUpdate, isNewMessage, firstUnreadId }) => {
410
555
  if (state.activeChat?.id === chatId) {
411
- // Скроллим в самый низ только если это новое сообщение или первая загрузка чата.
412
- // При подгрузке старых сообщений (isPrepend) или фоновых обновлениях (isUpdate)
413
- // позиция скролла сохраняется!
414
- const shouldScrollToBottom = isNewMessage ? config.autoScroll : (!isPrepend && !isUpdate);
415
- chatView.setMessages(messages, shouldScrollToBottom);
556
+ if (firstUnreadId) {
557
+ chatView.setMessages(messages, { firstUnreadId, autoScrollToBottom: false });
558
+ } else {
559
+ // Скроллим в самый низ только если это новое сообщение или первая загрузка чата.
560
+ // При подгрузке старых сообщений (isPrepend) или фоновых обновлениях (isUpdate)
561
+ // позиция скролла сохраняется!
562
+ const shouldScrollToBottom = isNewMessage ? config.autoScroll : (!isPrepend && !isUpdate);
563
+ chatView.setMessages(messages, shouldScrollToBottom);
564
+ }
416
565
  }
417
566
  });
418
567
 
@@ -435,30 +584,63 @@ export async function startTui(client, me) {
435
584
  }
436
585
  });
437
586
 
587
+ let previewBatchTimer = null;
588
+ let pendingBatchChatId = null;
589
+
438
590
  /**
439
- * Фоново догружает превью изображений для сообщений, не имевших встроенного PhotoStrippedSize.
591
+ * Планирует пакетное обновление интерфейса после догрузки группы превью.
592
+ * @param {string} chatId
593
+ */
594
+ function scheduleBatchPreviewUpdate(chatId) {
595
+ if (state.activeChat?.id !== chatId) return;
596
+ pendingBatchChatId = chatId;
597
+ if (previewBatchTimer) return;
598
+ previewBatchTimer = setTimeout(() => {
599
+ previewBatchTimer = null;
600
+ if (state.activeChat?.id === pendingBatchChatId) {
601
+ const current = state.getMessages(pendingBatchChatId);
602
+ state.emit("messages_updated", {
603
+ chatId: pendingBatchChatId,
604
+ messages: current,
605
+ isPrepend: false,
606
+ isUpdate: true,
607
+ });
608
+ }
609
+ pendingBatchChatId = null;
610
+ }, 150);
611
+ }
612
+
613
+ /** Множество ID сообщений, для которых прямо сейчас выполняется загрузка превью. */
614
+ const activePreviewLoads = new Set();
615
+
616
+ /**
617
+ * Фоново догружает превью изображений только для тех сообщений, которые видны на экране (Lazy Loading).
618
+ * Обновления группируются пакетами, предотвращая фризы и блокировку основного потока.
440
619
  * @param {Array<object>} messages
441
620
  * @param {string} chatId
442
621
  */
443
622
  async function fetchMissingImagePreviews(messages, chatId) {
444
623
  if (!config.showImages || !messages || messages.length === 0) return;
445
- for (const msg of messages) {
624
+ const toFetch = messages.filter((m) => m.isPreviewLoading && m.rawMessage && !activePreviewLoads.has(m.id));
625
+ if (toFetch.length === 0) return;
626
+
627
+ for (const msg of toFetch) {
446
628
  if (state.activeChat?.id !== chatId) break;
447
- if (msg.media && !msg.imagePreview && msg.rawMessage) {
448
- const isPhoto = msg.media.className === "MessageMediaPhoto";
449
- const isDoc = msg.media.className === "MessageMediaDocument";
450
- if (isPhoto || isDoc) {
451
- try {
452
- const preview = await loadMessageImagePreview(client, msg.rawMessage);
453
- if (preview && state.activeChat?.id === chatId) {
454
- msg.imagePreview = preview;
455
- state.updateMessage(chatId, msg);
456
- }
457
- } catch {
458
- // Игнорируем сетевые ошибки фоновой загрузки превью
459
- }
629
+ activePreviewLoads.add(msg.id);
630
+ try {
631
+ const preview = await loadMessageImagePreview(client, msg.rawMessage);
632
+ if (preview && state.activeChat?.id === chatId) {
633
+ msg.imagePreview = preview;
634
+ msg.isPreviewLoading = false;
635
+ scheduleBatchPreviewUpdate(chatId);
460
636
  }
637
+ } catch {
638
+ // Игнорируем сетевые ошибки фоновой загрузки превью
639
+ } finally {
640
+ activePreviewLoads.delete(msg.id);
461
641
  }
642
+ // Даём event loop обработать пользовательский ввод и скролл
643
+ await new Promise((resolve) => setImmediate(resolve));
462
644
  }
463
645
  }
464
646
 
@@ -467,13 +649,11 @@ export async function startTui(client, me) {
467
649
 
468
650
  listener.on("new_message", ({ peerId, message }) => {
469
651
  state.addMessage(peerId, message);
470
- if (!message.imagePreview && (message.media?.className === "MessageMediaPhoto" || message.media?.className === "MessageMediaDocument")) {
652
+ if (message.isPreviewLoading) {
471
653
  fetchMissingImagePreviews([message], peerId);
472
654
  }
473
655
 
474
- if (state.activeChat?.id === peerId) {
475
- markAsRead(client, state.activeChat.peerId).catch(() => {});
476
- } else if (!message.out) {
656
+ if (state.activeChat?.id !== peerId && !message.out) {
477
657
  const sender = message.senderName || "Новое сообщение";
478
658
  const preview = (message.text || "Вложение").slice(0, 30);
479
659
  statusBar.showMessage(`💬 ${sender}: "${preview}"`, "info", 5000);
@@ -572,6 +752,31 @@ export async function startTui(client, me) {
572
752
  chatList.release?.();
573
753
  }
574
754
 
755
+ /** Включает режим ответа на выделенное сообщение, иначе — на последнее в ленте. */
756
+ function startReply() {
757
+ if (!state.activeChat) return;
758
+ const target = chatView.getTargetMessage();
759
+ if (target) {
760
+ inputBox.setContext("reply", target);
761
+ }
762
+ }
763
+
764
+ /** Включает правку выделенного своего сообщения, иначе — последнего своего. */
765
+ function startEdit() {
766
+ if (!state.activeChat) return;
767
+ const selected = chatView.getSelected();
768
+ if (selected?.out) {
769
+ inputBox.setContext("edit", selected);
770
+ return;
771
+ }
772
+ const ownMsgs = state.getMessages(state.activeChat.id).filter((m) => m.out);
773
+ if (ownMsgs.length > 0) {
774
+ inputBox.setContext("edit", ownMsgs[ownMsgs.length - 1]);
775
+ } else {
776
+ statusBar.showMessage("В этом чате нет ваших сообщений для правки", "warning", 3000);
777
+ }
778
+ }
779
+
575
780
  // 7. Глобальные сочетания клавиш
576
781
  // Цикл фокуса: список чатов -> лента сообщений -> поле ввода -> список чатов.
577
782
  // Без ленты в цикле были недостижимы прокрутка, подгрузка истории и меню действий.
@@ -614,17 +819,18 @@ export async function startTui(client, me) {
614
819
  screen.key(["tab"], () => moveFocus(1));
615
820
  screen.key(["S-tab"], () => moveFocus(-1));
616
821
 
822
+ // Когда лента в фокусе, эти клавиши обрабатывает она сама — иначе прокрутка удваивается
617
823
  screen.key(["pageup", "C-u"], () => {
618
- if (!state.activeChat) return;
824
+ if (!state.activeChat || screen.focused === chatView.scrollBox) return;
619
825
  chatView.scrollBox.scroll(-10);
620
- if (chatView.scrollBox.getScroll() <= 0) {
826
+ if ((chatView.scrollBox.childBase || 0) <= 0) {
621
827
  chatView.loadMore?.();
622
828
  }
623
829
  screen.render();
624
830
  });
625
831
 
626
832
  screen.key(["pagedown", "C-d"], () => {
627
- if (!state.activeChat) return;
833
+ if (!state.activeChat || screen.focused === chatView.scrollBox) return;
628
834
  chatView.scrollBox.scroll(10);
629
835
  screen.render();
630
836
  });
@@ -653,28 +859,42 @@ export async function startTui(client, me) {
653
859
  }
654
860
  });
655
861
 
656
- screen.key(["C-r"], () => {
657
- if (!state.activeChat) return;
658
- const msgs = state.getMessages(state.activeChat.id);
659
- if (msgs.length > 0) {
660
- const lastMsg = msgs[msgs.length - 1];
661
- inputBox.setContext("reply", lastMsg);
862
+ screen.key(["C-r"], () => startReply());
863
+
864
+ screen.key(["C-e"], () => startEdit());
865
+
866
+ // Меню действий над выделенным сообщением из любой панели.
867
+ // Когда лента в фокусе, Ctrl+A обрабатывает она сама.
868
+ screen.key(["C-a"], () => {
869
+ if (screen.focused === chatView.scrollBox) return;
870
+ if (!state.activeChat) {
871
+ statusBar.showMessage("Сначала выберите чат слева!", "warning");
872
+ return;
873
+ }
874
+ const msg = chatView.getTargetMessage();
875
+ if (msg) {
876
+ releaseInputs();
877
+ actionModal.show(msg);
662
878
  }
663
879
  });
664
880
 
665
- screen.key(["C-e"], () => {
666
- if (!state.activeChat) return;
667
- const msgs = state.getMessages(state.activeChat.id);
668
- const ownMsgs = msgs.filter((m) => m.out);
669
- if (ownMsgs.length > 0) {
670
- const lastOwn = ownMsgs[ownMsgs.length - 1];
671
- inputBox.setContext("edit", lastOwn);
672
- }
881
+ // Пока мышь захвачена приложением, терминал не даёт выделять текст для копирования
882
+ screen.key(["f12"], () => {
883
+ const enabled = !screen.mouseCaptured;
884
+ setMouseCapture(screen, enabled);
885
+ statusBar.showMessage(
886
+ enabled
887
+ ? "Мышь снова управляет интерфейсом"
888
+ : "Мышь отдана терминалу — можно выделять и копировать текст. [F12] вернуть",
889
+ enabled ? "info" : "warning",
890
+ 5000
891
+ );
673
892
  });
674
893
 
675
894
  screen.key(["C-q"], () => {
676
895
  releaseInputs();
677
896
  confirmModal.ask("Выйти из TuiGram?", () => {
897
+ flushPendingMarkAsRead();
678
898
  listener?.stop();
679
899
  screen.destroy();
680
900
  process.exit(0);