@emaxe/tuigram 1.4.0 → 1.5.1

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.
@@ -4,6 +4,7 @@ import { formatMessageText, escapeBlessed } from "../../telegram/formatter.js";
4
4
  import { fg } from "../theme.js";
5
5
 
6
6
  import { getMessagePartAtPoint, isRightClick, stringCellWidth } from "../../utils/mouse.js";
7
+ import { isMessageVideo } from "../../utils/video.js";
7
8
 
8
9
  /** Отступ тела сообщения от левого края ленты, в ячейках. */
9
10
  const BODY_INDENT = 2;
@@ -17,14 +18,20 @@ const BODY_INDENT = 2;
17
18
  * @param {(msg: object) => void} [callbacks.onActionMenu] правый клик / Enter / Ctrl+A
18
19
  * @param {(msg: object) => void} [callbacks.onSelectMessage] сообщение выделено
19
20
  * @param {(msg: object) => void} [callbacks.onOpenImage] клик по превью изображения
21
+ * @param {(msg: object) => void} [callbacks.onPlayVideo] клик по превью видео
20
22
  * @param {() => void} [callbacks.onFocusRequest] вызывается перед взятием фокуса мышью
23
+ * @param {(maxReadId: number) => void} [callbacks.onMessagesRead] вызывается при прокрутке и прочтении сообщений
24
+ * @param {(visibleMessages: Array<object>) => void} [callbacks.onVisibleMessagesChanged] вызывается при смене видимой области для Lazy Loading превью
21
25
  */
22
26
  export function createChatView(screen, theme, {
23
27
  onLoadMoreHistory,
24
28
  onActionMenu,
25
29
  onSelectMessage,
26
30
  onOpenImage,
31
+ onPlayVideo,
27
32
  onFocusRequest,
33
+ onMessagesRead,
34
+ onVisibleMessagesChanged,
28
35
  } = {}) {
29
36
  const container = blessed.box({
30
37
  parent: screen,
@@ -90,14 +97,107 @@ export function createChatView(screen, theme, {
90
97
  let currentMessages = [];
91
98
  let currentRanges = [];
92
99
  let selectedId = null;
100
+ let currentFirstUnreadId = null;
101
+ let lastReportedMaxReadId = 0;
102
+
103
+ // Кэш отформатированных строк сообщений для мгновенной перерисовки и прокрутки ленты
104
+ const messageLinesCache = new Map();
105
+ const MAX_LINES_CACHE = 1000;
106
+
107
+ /**
108
+ * Форматирует одиночное сообщение или возвращает готовые строки из кэша.
109
+ * @param {object} msg
110
+ * @returns {{ lines: Array<string>, bodyStartOffset: number, imageMeta: object|null }}
111
+ */
112
+ function getFormattedMessage(msg) {
113
+ const previewLen = msg.imagePreview ? msg.imagePreview.length : 0;
114
+ const reactionsCount = msg.reactions?.length || 0;
115
+ const cacheKey = `${msg.id}_${msg.editDate || 0}_${previewLen}_${reactionsCount}_${msg.senderName || ""}_${msg.text || ""}`;
116
+
117
+ if (messageLinesCache.has(cacheKey)) {
118
+ return messageLinesCache.get(cacheKey);
119
+ }
120
+
121
+ const time = formatMessageTime(msg.date);
122
+ const timeTag = fg(theme.chatView.time, `[${time}]`);
123
+
124
+ // Отправитель
125
+ let authorTag = "";
126
+ if (msg.out && !msg.post) {
127
+ const readCheck = fg(theme.chatView.outgoingName, "✓✓");
128
+ authorTag = `${fg(theme.chatView.outgoingName, "{bold}Вы{/bold}")} ${timeTag} ${readCheck}`;
129
+ } else {
130
+ const name = escapeBlessed(msg.senderName || (msg.post ? "Канал" : "Собеседник"));
131
+ authorTag = `${fg(theme.chatView.incomingName, `{bold}${name}{/bold}`)} ${timeTag}`;
132
+ }
133
+
134
+ // Метка редактирования
135
+ const editedTag = msg.editDate ? ` ${fg(theme.chatView.time, "(изменено)")}` : "";
136
+ const lines = [` ${authorTag}${editedTag}`];
137
+
138
+ // Блок ответа (Reply)
139
+ if (msg.replyToMsgId) {
140
+ lines.push(` ${fg(theme.chatView.replyBorder, `┌─ Ответ на сообщение #${msg.replyToMsgId}`)}`);
141
+ }
142
+
143
+ // Текст сообщения и entities
144
+ let bodyText = formatMessageText(msg.text, msg.entities);
145
+
146
+ // Блок медиа-вложения и превью изображения
147
+ let mediaBlock = "";
148
+ if (msg.imagePreview) {
149
+ mediaBlock = msg.mediaDescription
150
+ ? `${msg.mediaDescription}\n${msg.imagePreview}`
151
+ : msg.imagePreview;
152
+ } else if (msg.mediaDescription) {
153
+ mediaBlock = msg.mediaDescription;
154
+ }
155
+
156
+ if (mediaBlock) {
157
+ bodyText = bodyText ? `${mediaBlock}\n${bodyText}` : mediaBlock;
158
+ }
159
+
160
+ // Строка, с которой начинается тело — нужна для координат превью
161
+ const bodyStartOffset = lines.length;
162
+ for (const line of bodyText.split("\n")) {
163
+ lines.push(` ${line}`);
164
+ }
165
+
166
+ // Реакции
167
+ if (msg.reactions && msg.reactions.length > 0) {
168
+ const list = msg.reactions.map((r) => `${r.emoticon} ${r.count}`).join(" ");
169
+ lines.push(` ${fg(theme.chatView.reactionFg, `{bold}${list}{/bold}`)}`);
170
+ }
171
+
172
+ let imageMeta = null;
173
+ if (msg.imagePreview) {
174
+ const descLines = msg.mediaDescription ? msg.mediaDescription.split("\n").length : 0;
175
+ const previewLines = msg.imagePreview.split("\n");
176
+ const width = previewLines.reduce((max, line) => Math.max(max, stringCellWidth(line)), 0);
177
+ imageMeta = {
178
+ descLines,
179
+ lineCount: previewLines.length,
180
+ width,
181
+ };
182
+ }
183
+
184
+ const result = { lines, bodyStartOffset, imageMeta };
185
+ if (messageLinesCache.size >= MAX_LINES_CACHE) {
186
+ const firstKey = messageLinesCache.keys().next().value;
187
+ messageLinesCache.delete(firstKey);
188
+ }
189
+ messageLinesCache.set(cacheKey, result);
190
+ return result;
191
+ }
93
192
 
94
193
  /**
95
194
  * Форматирует список сообщений в единую ленту текста с разметкой Blessed
96
195
  * и вычисляет координаты строк каждого сообщения для кликов мыши.
97
196
  * @param {Array<object>} messages
197
+ * @param {number|null} [firstUnreadId=null]
98
198
  * @returns {{ text: string, ranges: Array<object> }}
99
199
  */
100
- function renderMessagesWithRanges(messages) {
200
+ function renderMessagesWithRanges(messages, firstUnreadId = null) {
101
201
  if (!messages || messages.length === 0) {
102
202
  return {
103
203
  text: `\n\n ${fg(theme.muted, "Сообщений пока нет. Напишите первое сообщение ниже!")}`,
@@ -119,79 +219,33 @@ export function createChatView(screen, theme, {
119
219
  lastDateString = dateStr;
120
220
  }
121
221
 
122
- const time = formatMessageTime(msg.date);
123
- const timeTag = fg(theme.chatView.time, `[${time}]`);
124
-
125
- // Отправитель
126
- let authorTag = "";
127
- if (msg.out) {
128
- const readCheck = fg(theme.chatView.outgoingName, "✓✓");
129
- authorTag = `${fg(theme.chatView.outgoingName, "{bold}Вы{/bold}")} ${timeTag} ${readCheck}`;
130
- } else {
131
- const name = escapeBlessed(msg.senderName || "Собеседник");
132
- authorTag = `${fg(theme.chatView.incomingName, `{bold}${name}{/bold}`)} ${timeTag}`;
133
- }
134
-
135
- // Метка редактирования
136
- const editedTag = msg.editDate ? ` ${fg(theme.chatView.time, "(изменено)")}` : "";
137
-
138
- const lines = [` ${authorTag}${editedTag}`];
139
-
140
- // Блок ответа (Reply)
141
- if (msg.replyToMsgId) {
142
- lines.push(` ${fg(theme.chatView.replyBorder, `┌─ Ответ на сообщение #${msg.replyToMsgId}`)}`);
143
- }
144
-
145
- // Текст сообщения и entities
146
- let bodyText = formatMessageText(msg.text, msg.entities);
147
-
148
- // Блок медиа-вложения и превью изображения
149
- let mediaBlock = "";
150
- if (msg.imagePreview) {
151
- mediaBlock = msg.mediaDescription
152
- ? `${msg.mediaDescription}\n${msg.imagePreview}`
153
- : msg.imagePreview;
154
- } else if (msg.mediaDescription) {
155
- mediaBlock = msg.mediaDescription;
156
- }
157
-
158
- if (mediaBlock) {
159
- bodyText = bodyText ? `${mediaBlock}\n${bodyText}` : mediaBlock;
160
- }
161
-
162
- // Строка, с которой начинается тело — нужна для координат превью
163
- const bodyStartOffset = lines.length;
164
- for (const line of bodyText.split("\n")) {
165
- lines.push(` ${line}`);
166
- }
167
-
168
- // Реакции
169
- if (msg.reactions && msg.reactions.length > 0) {
170
- const list = msg.reactions.map((r) => `${r.emoticon} ${r.count}`).join(" ");
171
- lines.push(` ${fg(theme.chatView.reactionFg, `{bold}${list}{/bold}`)}`);
222
+ // Разделитель непрочитанных сообщений
223
+ if (firstUnreadId && msg.id === firstUnreadId) {
224
+ const unreadColor = theme.chatView.unreadDivider || theme.accent;
225
+ output += `\n ${fg(unreadColor, "─────── Непрочитанные сообщения ───────")}\n\n`;
226
+ lineCursor += 3;
172
227
  }
173
228
 
229
+ const { lines, bodyStartOffset, imageMeta } = getFormattedMessage(msg);
174
230
  const startLine = lineCursor;
175
231
 
176
232
  // Прямоугольник превью изображения внутри сообщения
177
233
  let image = null;
178
- if (msg.imagePreview) {
179
- const descLines = msg.mediaDescription ? msg.mediaDescription.split("\n").length : 0;
180
- const previewLines = msg.imagePreview.split("\n");
181
- const imageStart = startLine + bodyStartOffset + descLines;
182
- const width = previewLines.reduce((max, line) => Math.max(max, stringCellWidth(line)), 0);
234
+ if (imageMeta) {
235
+ const imageStart = startLine + bodyStartOffset + imageMeta.descLines;
183
236
  image = {
184
237
  startLine: imageStart,
185
- endLine: imageStart + previewLines.length - 1,
238
+ endLine: imageStart + imageMeta.lineCount - 1,
186
239
  left: BODY_INDENT,
187
- right: BODY_INDENT + width,
240
+ right: BODY_INDENT + imageMeta.width,
188
241
  };
189
242
  }
190
243
 
191
244
  // Выделенное сообщение помечается полосой в первой колонке. Первая колонка
192
245
  // каждой строки — пробел отступа, поэтому ширина строк не меняется и карта
193
246
  // координат остаётся верной.
194
- const rendered = msg.id === selectedId
247
+ const isSelected = msg.id === selectedId;
248
+ const rendered = isSelected
195
249
  ? lines.map((line) => `${fg(theme.accent, "▌")}${line.slice(1)}`)
196
250
  : lines;
197
251
 
@@ -210,49 +264,155 @@ export function createChatView(screen, theme, {
210
264
  return { text: output, ranges };
211
265
  }
212
266
 
267
+ /**
268
+ * Вычисляет фактическую видимую высоту окна ленты сообщений в строках терминала.
269
+ * @returns {number}
270
+ */
271
+ function getVisibleHeight() {
272
+ const lpos = scrollBox.lpos || scrollBox._getCoords();
273
+ if (lpos && lpos.yl > lpos.yi) {
274
+ return Math.max(1, lpos.yl - lpos.yi - (scrollBox.iheight || 0));
275
+ }
276
+ return Math.max(1, (screen.height || 24) - 10);
277
+ }
278
+
279
+ /**
280
+ * Точно прокручивает ленту к указанной строке содержимого, избегая багов blessed с относительными высотами.
281
+ * @param {number} targetLine
282
+ */
283
+ function scrollToLine(targetLine) {
284
+ const totalLines = scrollBox._clines?.length || scrollBox.getScrollHeight() || 0;
285
+ const visible = getVisibleHeight();
286
+ const maxBase = Math.max(0, totalLines - visible);
287
+ const clamped = Math.max(0, Math.min(targetLine, maxBase));
288
+ scrollBox.childBase = clamped;
289
+ scrollBox.childOffset = 0;
290
+ }
291
+
292
+ /**
293
+ * Прокручивает ленту в самый низ (к последнему сообщению).
294
+ */
295
+ function scrollToBottom() {
296
+ const totalLines = scrollBox._clines?.length || scrollBox.getScrollHeight() || 0;
297
+ const visible = getVisibleHeight();
298
+ scrollBox.childBase = Math.max(0, totalLines - visible);
299
+ scrollBox.childOffset = 0;
300
+ }
301
+
213
302
  /** Перерисовывает ленту, сохраняя позицию прокрутки (например, после смены выделения). */
214
303
  function redraw() {
215
304
  const prevBase = scrollBox.childBase || 0;
216
- const rendered = renderMessagesWithRanges(currentMessages);
305
+ const rendered = renderMessagesWithRanges(currentMessages, currentFirstUnreadId);
217
306
  currentRanges = rendered.ranges;
218
307
  scrollBox.setContent(rendered.text);
219
- scrollBox.scrollTo(prevBase);
308
+ scrollToLine(prevBase);
220
309
  screen.render();
221
310
  }
222
311
 
312
+ /**
313
+ * Вычисляет видимые в данный момент сообщения, уведомляет о прочитанных
314
+ * и передаёт видимые сообщения для ленивой подгрузки превью (Lazy Loading).
315
+ */
316
+ function checkVisibleMessages() {
317
+ if (!currentRanges || currentRanges.length === 0) return;
318
+
319
+ const visibleHeight = getVisibleHeight();
320
+ const viewportTop = scrollBox.childBase || 0;
321
+ const viewportBottom = viewportTop + visibleHeight - 1;
322
+
323
+ // Буфер в 5 строк сверху и снизу для плавной подгрузки перед появлением на экране
324
+ const bufferTop = Math.max(0, viewportTop - 5);
325
+ const bufferBottom = viewportBottom + 5;
326
+
327
+ let maxVisibleId = 0;
328
+ const visibleMessages = [];
329
+
330
+ for (const range of currentRanges) {
331
+ const startRendered = toRenderedLine(range.startLine, "first");
332
+ const endRendered = toRenderedLine(range.endLine, "last");
333
+
334
+ // Проверка для отметки прочитанных (сообщение началось до низа экрана)
335
+ if (startRendered <= viewportBottom) {
336
+ if (range.message.id > maxVisibleId) {
337
+ maxVisibleId = range.message.id;
338
+ }
339
+ }
340
+
341
+ // Проверка попадания в видимый диапазон (для Lazy Loading)
342
+ if (endRendered >= bufferTop && startRendered <= bufferBottom) {
343
+ visibleMessages.push(range.message);
344
+ }
345
+ }
346
+
347
+ if (maxVisibleId > lastReportedMaxReadId) {
348
+ lastReportedMaxReadId = maxVisibleId;
349
+ onMessagesRead?.(maxVisibleId);
350
+ }
351
+
352
+ if (visibleMessages.length > 0) {
353
+ onVisibleMessagesChanged?.(visibleMessages);
354
+ }
355
+ }
356
+
223
357
  /**
224
358
  * Устанавливает сообщения в ленту.
225
359
  * @param {Array<object>} messages
226
- * @param {boolean} [autoScrollToBottom=true]
360
+ * @param {boolean|object} [scrollOption=true]
227
361
  */
228
- function setMessages(messages, autoScrollToBottom = true) {
362
+ function setMessages(messages, scrollOption = true) {
229
363
  currentMessages = messages;
230
364
  const prevScroll = scrollBox.childBase || 0;
231
365
  const prevHeight = scrollBox.getScrollHeight();
232
366
 
367
+ let autoScrollToBottom = true;
368
+ let firstUnreadId = null;
369
+ let preserveScroll = false;
370
+
371
+ if (typeof scrollOption === "boolean") {
372
+ autoScrollToBottom = scrollOption;
373
+ preserveScroll = !scrollOption;
374
+ } else if (scrollOption && typeof scrollOption === "object") {
375
+ autoScrollToBottom = Boolean(scrollOption.autoScrollToBottom);
376
+ firstUnreadId = scrollOption.firstUnreadId !== undefined ? scrollOption.firstUnreadId : null;
377
+ preserveScroll = Boolean(scrollOption.preserveScroll);
378
+ }
379
+
380
+ if (firstUnreadId !== undefined) {
381
+ currentFirstUnreadId = firstUnreadId;
382
+ }
383
+
233
384
  // Выделенное сообщение могло быть удалено или относиться к другому чату
234
385
  if (selectedId !== null && !messages.some((m) => m.id === selectedId)) {
235
386
  selectedId = null;
236
387
  }
237
388
 
238
- const rendered = renderMessagesWithRanges(messages);
389
+ const rendered = renderMessagesWithRanges(messages, currentFirstUnreadId);
239
390
  currentRanges = rendered.ranges;
240
391
  scrollBox.setContent(rendered.text);
241
392
 
242
- if (autoScrollToBottom) {
243
- scrollBox.setScrollPerc(100);
244
- } else {
393
+ if (currentFirstUnreadId) {
394
+ const range = currentRanges.find((r) => r.message.id === currentFirstUnreadId);
395
+ if (range) {
396
+ const targetLine = toRenderedLine(range.startLine, "first");
397
+ scrollToLine(Math.max(0, targetLine - 2));
398
+ } else if (autoScrollToBottom) {
399
+ scrollToBottom();
400
+ }
401
+ } else if (autoScrollToBottom) {
402
+ scrollToBottom();
403
+ } else if (preserveScroll) {
245
404
  // Сохраняем относительную позицию скролла: если добавились старые сообщения сверху,
246
405
  // компенсируем сдвиг высоты ленты
247
406
  const newHeight = scrollBox.getScrollHeight();
248
407
  const addedLines = newHeight - prevHeight;
249
408
  if (addedLines > 0 && prevScroll > 0) {
250
- scrollBox.scrollTo(prevScroll + addedLines);
409
+ scrollToLine(prevScroll + addedLines);
251
410
  } else if (prevScroll > 0) {
252
- scrollBox.scrollTo(prevScroll);
411
+ scrollToLine(prevScroll);
253
412
  }
254
413
  }
255
414
  screen.render();
415
+ checkVisibleMessages();
256
416
  }
257
417
 
258
418
  /**
@@ -285,15 +445,15 @@ export function createChatView(screen, theme, {
285
445
  /** Подкручивает ленту так, чтобы выделенное сообщение было видно целиком. */
286
446
  function scrollMessageIntoView(range) {
287
447
  if (!range) return;
288
- const visible = scrollBox.height - scrollBox.iheight;
448
+ const visible = getVisibleHeight();
289
449
  const base = scrollBox.childBase || 0;
290
450
  const top = toRenderedLine(range.startLine, "first");
291
451
  const bottom = toRenderedLine(range.endLine, "last");
292
452
 
293
453
  if (top < base) {
294
- scrollBox.scrollTo(top);
454
+ scrollToLine(top);
295
455
  } else if (bottom >= base + visible) {
296
- scrollBox.scrollTo(Math.max(0, bottom - visible + 1));
456
+ scrollToLine(Math.max(0, bottom - visible + 1));
297
457
  }
298
458
  }
299
459
 
@@ -355,16 +515,20 @@ export function createChatView(screen, theme, {
355
515
 
356
516
  // Обработка прокрутки вверх для подгрузки истории
357
517
  function handleScrollUp(step = 10) {
358
- scrollBox.scroll(-step);
518
+ const current = scrollBox.childBase || 0;
519
+ scrollToLine(Math.max(0, current - step));
359
520
  if ((scrollBox.childBase || 0) <= 0) {
360
521
  onLoadMoreHistory?.();
361
522
  }
362
523
  screen.render();
524
+ checkVisibleMessages();
363
525
  }
364
526
 
365
527
  function handleScrollDown(step = 10) {
366
- scrollBox.scroll(step);
528
+ const current = scrollBox.childBase || 0;
529
+ scrollToLine(current + step);
367
530
  screen.render();
531
+ checkVisibleMessages();
368
532
  }
369
533
 
370
534
  scrollBox.key(["pageup", "C-u"], () => handleScrollUp(10));
@@ -372,13 +536,15 @@ export function createChatView(screen, theme, {
372
536
  scrollBox.key(["up", "k"], () => selectByOffset(-1));
373
537
  scrollBox.key(["down", "j"], () => selectByOffset(1));
374
538
  scrollBox.key(["home"], () => {
375
- scrollBox.scrollTo(0);
539
+ scrollToLine(0);
376
540
  onLoadMoreHistory?.();
377
541
  screen.render();
542
+ checkVisibleMessages();
378
543
  });
379
544
  scrollBox.key(["end"], () => {
380
- scrollBox.setScrollPerc(100);
545
+ scrollToBottom();
381
546
  screen.render();
547
+ checkVisibleMessages();
382
548
  });
383
549
 
384
550
  scrollBox.on("wheelup", () => handleScrollUp(3));
@@ -407,7 +573,11 @@ export function createChatView(screen, theme, {
407
573
 
408
574
  if (hit.part === "image") {
409
575
  setSelected(hit.message.id);
410
- onOpenImage?.(hit.message);
576
+ if (isMessageVideo(hit.message) && onPlayVideo) {
577
+ onPlayVideo(hit.message);
578
+ } else {
579
+ onOpenImage?.(hit.message);
580
+ }
411
581
  return;
412
582
  }
413
583
 
@@ -426,6 +596,11 @@ export function createChatView(screen, theme, {
426
596
  }
427
597
  });
428
598
 
599
+ // Отслеживание прокрутки для обновления прочитанных сообщений
600
+ scrollBox.on("scroll", () => {
601
+ checkVisibleMessages();
602
+ });
603
+
429
604
  return {
430
605
  container,
431
606
  scrollBox,
@@ -434,10 +609,16 @@ export function createChatView(screen, theme, {
434
609
  getSelected,
435
610
  getTargetMessage,
436
611
  selectByOffset,
612
+ resetReadState: (initialReadMaxId = 0) => {
613
+ lastReportedMaxReadId = initialReadMaxId;
614
+ currentFirstUnreadId = null;
615
+ },
616
+ checkVisibleMessages,
437
617
  loadMore: () => onLoadMoreHistory?.(),
438
618
  scrollToBottom: () => {
439
- scrollBox.setScrollPerc(100);
619
+ scrollToBottom();
440
620
  screen.render();
621
+ checkVisibleMessages();
441
622
  },
442
623
  focus: () => scrollBox.focus(),
443
624
  };
@@ -97,7 +97,7 @@ export function createInputBox(screen, theme, {
97
97
 
98
98
  function renderContext() {
99
99
  if (currentMode === "reply" && currentTarget) {
100
- const author = escapeBlessed(currentTarget.senderName || "Собеседник");
100
+ const author = escapeBlessed(currentTarget.senderName || (currentTarget.post ? "Канал" : "Собеседник"));
101
101
  const preview = escapeBlessed((currentTarget.text || "").slice(0, 30));
102
102
  contextBar.setContent(
103
103
  badge(theme.input.replyBg, theme.input.replyFg,
@@ -146,8 +146,10 @@ export function createInputBox(screen, theme, {
146
146
  });
147
147
 
148
148
  textarea.on("click", () => {
149
- textarea.focus();
150
- screen.render();
149
+ if (screen.focused !== textarea) {
150
+ textarea.focus();
151
+ screen.render();
152
+ }
151
153
  });
152
154
 
153
155
  textarea.key(["enter"], () => {
@@ -230,7 +232,9 @@ export function createInputBox(screen, theme, {
230
232
  textarea.setValue(target.text);
231
233
  }
232
234
  renderContext();
233
- textarea.focus();
235
+ if (screen.focused !== textarea) {
236
+ textarea.focus();
237
+ }
234
238
  },
235
239
  /**
236
240
  * Текущий режим ввода — нужен, чтобы отправить файл ответом.
@@ -246,7 +250,11 @@ export function createInputBox(screen, theme, {
246
250
  textarea.setValue("");
247
251
  screen.render();
248
252
  },
249
- focus: () => textarea.focus(),
253
+ focus: () => {
254
+ if (screen.focused !== textarea) {
255
+ textarea.focus();
256
+ }
257
+ },
250
258
  /**
251
259
  * Завершает режим ввода, отдавая фокус предыдущей панели.
252
260
  * Нужно вызывать перед открытием модального окна: иначе textarea по blur
@@ -1,6 +1,7 @@
1
1
  import blessed from "neo-blessed";
2
2
  import { escapeBlessed } from "../../../telegram/formatter.js";
3
3
  import { fg } from "../../theme.js";
4
+ import { isMessageVideo } from "../../../utils/video.js";
4
5
 
5
6
  import { isRightClick } from "../../../utils/mouse.js";
6
7
  import { bindOutsideClickClose } from "../../modalMouse.js";
@@ -111,6 +112,10 @@ export function createActionModal(screen, theme, { onAction } = {}) {
111
112
  { id: "reply", label: "↩️ Ответить (Reply)" },
112
113
  ];
113
114
 
115
+ if (isMessageVideo(msg)) {
116
+ currentActions.push({ id: "play_video", label: "▶️ Воспроизвести видео" });
117
+ }
118
+
114
119
  if (msg.out) {
115
120
  currentActions.push({ id: "edit", label: "✏️ Редактировать текст" });
116
121
  }
@@ -55,7 +55,8 @@ export function createHelpModal(screen, theme) {
55
55
  ${K_CYAN}Колесо мыши${K_END} Прокрутка списка чатов и сообщений (вверх — подгрузка истории)
56
56
  ${K_CYAN}Левый клик по сообщению${K_END} Выделить сообщение (помечается полосой ▌ слева)
57
57
  ${K_CYAN}Правый клик по сообщению${K_END} Меню действий над сообщением
58
- ${K_CYAN}Клик по картинке${K_END} Открыть изображение на весь экран (${K_CYAN}[Esc]${K_END} — закрыть)
58
+ ${K_CYAN}Клик по превью фото/видео${K_END}Открыть изображение или воспроизвести видео
59
+ ${K_CYAN} [Space] / [r]${K_END} — в видеоплеере: пауза / перезапуск
59
60
  ${K_CYAN}Клик по вкладкам/кнопкам${K_END} Переключение фильтров и вызов действий
60
61
  ${K_GRAY}macOS Terminal.app перехватывает правый клик — там пользуйтесь [Enter] или [Ctrl+A]${K_END}
61
62
  ${K_CYAN}[F12]${K_END} Отдать мышь терминалу, чтобы выделить и скопировать текст