@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.
@@ -3,7 +3,11 @@ import { formatMessageTime, formatDateDivider } from "../../utils/time.js";
3
3
  import { formatMessageText, escapeBlessed } from "../../telegram/formatter.js";
4
4
  import { fg } from "../theme.js";
5
5
 
6
- import { getMessageAtLine } from "../../utils/mouse.js";
6
+ import { getMessagePartAtPoint, isRightClick, stringCellWidth } from "../../utils/mouse.js";
7
+ import { isMessageVideo } from "../../utils/video.js";
8
+
9
+ /** Отступ тела сообщения от левого края ленты, в ячейках. */
10
+ const BODY_INDENT = 2;
7
11
 
8
12
  /**
9
13
  * Создаёт компонент просмотра сообщений чата (правая центральная панель).
@@ -11,9 +15,24 @@ import { getMessageAtLine } from "../../utils/mouse.js";
11
15
  * @param {object} theme
12
16
  * @param {object} callbacks
13
17
  * @param {() => void} [callbacks.onLoadMoreHistory]
14
- * @param {(msg: object) => void} [callbacks.onActionMenu]
18
+ * @param {(msg: object) => void} [callbacks.onActionMenu] правый клик / Enter / Ctrl+A
19
+ * @param {(msg: object) => void} [callbacks.onSelectMessage] сообщение выделено
20
+ * @param {(msg: object) => void} [callbacks.onOpenImage] клик по превью изображения
21
+ * @param {(msg: object) => void} [callbacks.onPlayVideo] клик по превью видео
22
+ * @param {() => void} [callbacks.onFocusRequest] вызывается перед взятием фокуса мышью
23
+ * @param {(maxReadId: number) => void} [callbacks.onMessagesRead] вызывается при прокрутке и прочтении сообщений
24
+ * @param {(visibleMessages: Array<object>) => void} [callbacks.onVisibleMessagesChanged] вызывается при смене видимой области для Lazy Loading превью
15
25
  */
16
- export function createChatView(screen, theme, { onLoadMoreHistory, onActionMenu } = {}) {
26
+ export function createChatView(screen, theme, {
27
+ onLoadMoreHistory,
28
+ onActionMenu,
29
+ onSelectMessage,
30
+ onOpenImage,
31
+ onPlayVideo,
32
+ onFocusRequest,
33
+ onMessagesRead,
34
+ onVisibleMessagesChanged,
35
+ } = {}) {
17
36
  const container = blessed.box({
18
37
  parent: screen,
19
38
  top: 4,
@@ -41,9 +60,13 @@ export function createChatView(screen, theme, { onLoadMoreHistory, onActionMenu
41
60
  bottom: 0,
42
61
  tags: true,
43
62
  scrollable: true,
63
+ // alwaysScroll обязателен: без него blessed копит смещение в childOffset,
64
+ // не двигая содержимое (первые щелчки колеса «проглатываются»), а getScroll()
65
+ // перестаёт совпадать с реальным сдвигом ленты — клики попадали не в то сообщение.
66
+ alwaysScroll: true,
44
67
  mouse: true,
45
- keys: true,
46
- vi: true,
68
+ // keys/vi намеренно выключены: их встроенные обработчики скроллят ленту на
69
+ // стрелках, а стрелки здесь двигают выделение. Все клавиши навешаны явно ниже.
47
70
  scrollbar: {
48
71
  ch: "│",
49
72
  style: {
@@ -57,6 +80,11 @@ export function createChatView(screen, theme, { onLoadMoreHistory, onActionMenu
57
80
  },
58
81
  });
59
82
 
83
+ // blessed сам вешает на scrollable-box с mouse:true прокрутку колесом на пол-экрана.
84
+ // Вместе с нашими обработчиками получалось height/2 + 3 строки за щелчок.
85
+ scrollBox.removeAllListeners("wheelup");
86
+ scrollBox.removeAllListeners("wheeldown");
87
+
60
88
  /** Подсвечивает рамку, когда лента сообщений в фокусе. */
61
89
  function setFocusHighlight(active) {
62
90
  container.style.border.fg = active ? theme.borders.focusFg : theme.borders.fg;
@@ -68,14 +96,108 @@ export function createChatView(screen, theme, { onLoadMoreHistory, onActionMenu
68
96
 
69
97
  let currentMessages = [];
70
98
  let currentRanges = [];
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) {
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 || "Собеседник");
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
+ }
71
192
 
72
193
  /**
73
194
  * Форматирует список сообщений в единую ленту текста с разметкой Blessed
74
195
  * и вычисляет координаты строк каждого сообщения для кликов мыши.
75
196
  * @param {Array<object>} messages
76
- * @returns {{ text: string, ranges: Array<{ message: object, startLine: number, endLine: number }> }}
197
+ * @param {number|null} [firstUnreadId=null]
198
+ * @returns {{ text: string, ranges: Array<object> }}
77
199
  */
78
- function renderMessagesWithRanges(messages) {
200
+ function renderMessagesWithRanges(messages, firstUnreadId = null) {
79
201
  if (!messages || messages.length === 0) {
80
202
  return {
81
203
  text: `\n\n ${fg(theme.muted, "Сообщений пока нет. Напишите первое сообщение ниже!")}`,
@@ -97,183 +219,406 @@ export function createChatView(screen, theme, { onLoadMoreHistory, onActionMenu
97
219
  lastDateString = dateStr;
98
220
  }
99
221
 
100
- const time = formatMessageTime(msg.date);
101
- const timeTag = fg(theme.chatView.time, `[${time}]`);
102
-
103
- // Отправитель
104
- let authorTag = "";
105
- if (msg.out) {
106
- const readCheck = fg(theme.chatView.outgoingName, "✓✓");
107
- authorTag = `${fg(theme.chatView.outgoingName, "{bold}Вы{/bold}")} ${timeTag} ${readCheck}`;
108
- } else {
109
- const name = escapeBlessed(msg.senderName || "Собеседник");
110
- authorTag = `${fg(theme.chatView.incomingName, `{bold}${name}{/bold}`)} ${timeTag}`;
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;
111
227
  }
112
228
 
113
- // Блок ответа (Reply)
114
- let replyBlock = "";
115
- let replyLines = 0;
116
- if (msg.replyToMsgId) {
117
- replyBlock = ` ${fg(theme.chatView.replyBorder, `┌─ Ответ на сообщение #${msg.replyToMsgId}`)}\n`;
118
- replyLines = 1;
119
- }
229
+ const { lines, bodyStartOffset, imageMeta } = getFormattedMessage(msg);
230
+ const startLine = lineCursor;
120
231
 
121
- // Текст сообщения и entities
122
- let bodyText = formatMessageText(msg.text, msg.entities);
123
-
124
- // Блок медиа-вложения и превью изображения
125
- let mediaBlock = "";
126
- if (msg.imagePreview) {
127
- mediaBlock = msg.mediaDescription
128
- ? `${msg.mediaDescription}\n${msg.imagePreview}`
129
- : msg.imagePreview;
130
- } else if (msg.mediaDescription) {
131
- mediaBlock = msg.mediaDescription;
232
+ // Прямоугольник превью изображения внутри сообщения
233
+ let image = null;
234
+ if (imageMeta) {
235
+ const imageStart = startLine + bodyStartOffset + imageMeta.descLines;
236
+ image = {
237
+ startLine: imageStart,
238
+ endLine: imageStart + imageMeta.lineCount - 1,
239
+ left: BODY_INDENT,
240
+ right: BODY_INDENT + imageMeta.width,
241
+ };
132
242
  }
133
243
 
134
- if (mediaBlock) {
135
- bodyText = bodyText ? `${mediaBlock}\n${bodyText}` : mediaBlock;
136
- }
244
+ // Выделенное сообщение помечается полосой в первой колонке. Первая колонка
245
+ // каждой строки пробел отступа, поэтому ширина строк не меняется и карта
246
+ // координат остаётся верной.
247
+ const isSelected = msg.id === selectedId;
248
+ const rendered = isSelected
249
+ ? lines.map((line) => `${fg(theme.accent, "▌")}${line.slice(1)}`)
250
+ : lines;
251
+
252
+ ranges.push({
253
+ message: msg,
254
+ startLine,
255
+ endLine: startLine + lines.length - 1,
256
+ image,
257
+ });
258
+
259
+ // lines.length строк + одна пустая строка-разделитель между сообщениями
260
+ lineCursor += lines.length + 1;
261
+ output += `${rendered.join("\n")}\n\n`;
262
+ }
137
263
 
138
- // Отступ строк текста сообщения
139
- const indentedBody = bodyText
140
- .split("\n")
141
- .map((line) => ` ${line}`)
142
- .join("\n");
143
- const bodyLinesCount = indentedBody.split("\n").length;
144
-
145
- // Реакции
146
- let reactionsLine = "";
147
- let reactionLinesCount = 0;
148
- if (msg.reactions && msg.reactions.length > 0) {
149
- const list = msg.reactions.map((r) => `${r.emoticon} ${r.count}`).join(" ");
150
- reactionsLine = `\n ${fg(theme.chatView.reactionFg, `{bold}${list}{/bold}`)}`;
151
- reactionLinesCount = 1;
152
- }
264
+ return { text: output, ranges };
265
+ }
153
266
 
154
- // Метка редактирования
155
- let editedTag = "";
156
- if (msg.editDate) {
157
- editedTag = ` ${fg(theme.chatView.time, "(изменено)")}`;
158
- }
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
+ }
159
278
 
160
- const startLine = lineCursor;
161
- const totalMsgLines = 1 + replyLines + bodyLinesCount + reactionLinesCount;
162
- const endLine = startLine + totalMsgLines - 1;
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
+ }
163
291
 
164
- ranges.push({ message: msg, startLine, endLine });
165
- lineCursor += totalMsgLines + 2;
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
+
302
+ /** Перерисовывает ленту, сохраняя позицию прокрутки (например, после смены выделения). */
303
+ function redraw() {
304
+ const prevBase = scrollBox.childBase || 0;
305
+ const rendered = renderMessagesWithRanges(currentMessages, currentFirstUnreadId);
306
+ currentRanges = rendered.ranges;
307
+ scrollBox.setContent(rendered.text);
308
+ scrollToLine(prevBase);
309
+ screen.render();
310
+ }
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 = [];
166
329
 
167
- output += ` ${authorTag}${editedTag}\n${replyBlock}${indentedBody}${reactionsLine}\n\n`;
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
+ }
168
345
  }
169
346
 
170
- return { text: output, ranges };
347
+ if (maxVisibleId > lastReportedMaxReadId) {
348
+ lastReportedMaxReadId = maxVisibleId;
349
+ onMessagesRead?.(maxVisibleId);
350
+ }
351
+
352
+ if (visibleMessages.length > 0) {
353
+ onVisibleMessagesChanged?.(visibleMessages);
354
+ }
171
355
  }
172
356
 
173
357
  /**
174
358
  * Устанавливает сообщения в ленту.
175
359
  * @param {Array<object>} messages
176
- * @param {boolean} [autoScrollToBottom=true]
360
+ * @param {boolean|object} [scrollOption=true]
177
361
  */
178
- function setMessages(messages, autoScrollToBottom = true) {
362
+ function setMessages(messages, scrollOption = true) {
179
363
  currentMessages = messages;
180
- const prevScroll = scrollBox.getScroll();
364
+ const prevScroll = scrollBox.childBase || 0;
181
365
  const prevHeight = scrollBox.getScrollHeight();
182
366
 
183
- const rendered = renderMessagesWithRanges(messages);
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
+
384
+ // Выделенное сообщение могло быть удалено или относиться к другому чату
385
+ if (selectedId !== null && !messages.some((m) => m.id === selectedId)) {
386
+ selectedId = null;
387
+ }
388
+
389
+ const rendered = renderMessagesWithRanges(messages, currentFirstUnreadId);
184
390
  currentRanges = rendered.ranges;
185
391
  scrollBox.setContent(rendered.text);
186
392
 
187
- if (autoScrollToBottom) {
188
- scrollBox.setScrollPerc(100);
189
- } 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) {
190
404
  // Сохраняем относительную позицию скролла: если добавились старые сообщения сверху,
191
405
  // компенсируем сдвиг высоты ленты
192
406
  const newHeight = scrollBox.getScrollHeight();
193
407
  const addedLines = newHeight - prevHeight;
194
408
  if (addedLines > 0 && prevScroll > 0) {
195
- scrollBox.scrollTo(prevScroll + addedLines);
409
+ scrollToLine(prevScroll + addedLines);
196
410
  } else if (prevScroll > 0) {
197
- scrollBox.scrollTo(prevScroll);
411
+ scrollToLine(prevScroll);
198
412
  }
199
413
  }
200
414
  screen.render();
415
+ checkVisibleMessages();
416
+ }
417
+
418
+ /**
419
+ * Переводит номер отрисованной строки в номер строки исходного содержимого.
420
+ * blessed переносит длинные строки, поэтому напрямую индексы не совпадают.
421
+ * @param {number} renderedLine
422
+ * @returns {number}
423
+ */
424
+ function toContentLine(renderedLine) {
425
+ const rtof = scrollBox._clines?.rtof;
426
+ if (Array.isArray(rtof) && renderedLine >= 0 && renderedLine < rtof.length) {
427
+ return rtof[renderedLine];
428
+ }
429
+ return renderedLine;
430
+ }
431
+
432
+ /**
433
+ * Обратное преобразование: первая и последняя отрисованные строки для строки содержимого.
434
+ * @param {number} contentLine
435
+ * @param {"first"|"last"} edge
436
+ * @returns {number}
437
+ */
438
+ function toRenderedLine(contentLine, edge = "first") {
439
+ const ftor = scrollBox._clines?.ftor;
440
+ const mapped = Array.isArray(ftor) ? ftor[contentLine] : null;
441
+ if (!Array.isArray(mapped) || mapped.length === 0) return contentLine;
442
+ return edge === "first" ? mapped[0] : mapped[mapped.length - 1];
443
+ }
444
+
445
+ /** Подкручивает ленту так, чтобы выделенное сообщение было видно целиком. */
446
+ function scrollMessageIntoView(range) {
447
+ if (!range) return;
448
+ const visible = getVisibleHeight();
449
+ const base = scrollBox.childBase || 0;
450
+ const top = toRenderedLine(range.startLine, "first");
451
+ const bottom = toRenderedLine(range.endLine, "last");
452
+
453
+ if (top < base) {
454
+ scrollToLine(top);
455
+ } else if (bottom >= base + visible) {
456
+ scrollToLine(Math.max(0, bottom - visible + 1));
457
+ }
458
+ }
459
+
460
+ /**
461
+ * Выделяет сообщение по идентификатору.
462
+ * @param {number|null} id
463
+ * @param {object} [options]
464
+ * @param {boolean} [options.scrollIntoView=false]
465
+ * @param {boolean} [options.notify=false] вызвать onSelectMessage
466
+ */
467
+ function setSelected(id, { scrollIntoView = false, notify = false } = {}) {
468
+ if (selectedId === id && !scrollIntoView) return;
469
+ selectedId = id;
470
+ redraw();
471
+
472
+ const range = currentRanges.find((r) => r.message.id === id);
473
+ if (scrollIntoView && range) {
474
+ scrollMessageIntoView(range);
475
+ screen.render();
476
+ }
477
+ if (notify && range) {
478
+ onSelectMessage?.(range.message);
479
+ }
480
+ }
481
+
482
+ /** @returns {object|null} выделенное сообщение */
483
+ function getSelected() {
484
+ if (selectedId === null) return null;
485
+ return currentMessages.find((m) => m.id === selectedId) || null;
486
+ }
487
+
488
+ /** Сообщение, над которым выполняются действия: выделенное, иначе последнее. */
489
+ function getTargetMessage() {
490
+ return getSelected() || currentMessages[currentMessages.length - 1] || null;
491
+ }
492
+
493
+ /**
494
+ * Двигает выделение на step сообщений и подкручивает ленту к нему.
495
+ * @param {number} step
496
+ */
497
+ function selectByOffset(step) {
498
+ if (currentMessages.length === 0) return;
499
+
500
+ const currentIndex = currentMessages.findIndex((m) => m.id === selectedId);
501
+ let nextIndex;
502
+ if (currentIndex === -1) {
503
+ nextIndex = currentMessages.length - 1;
504
+ } else {
505
+ nextIndex = Math.min(currentMessages.length - 1, Math.max(0, currentIndex + step));
506
+ }
507
+
508
+ setSelected(currentMessages[nextIndex].id, { scrollIntoView: true, notify: true });
509
+
510
+ // Дошли до верха ленты — подтягиваем предыдущую страницу истории
511
+ if (nextIndex === 0 && step < 0) {
512
+ onLoadMoreHistory?.();
513
+ }
201
514
  }
202
515
 
203
516
  // Обработка прокрутки вверх для подгрузки истории
204
517
  function handleScrollUp(step = 10) {
205
- scrollBox.scroll(-step);
206
- if (scrollBox.getScroll() <= 0) {
518
+ const current = scrollBox.childBase || 0;
519
+ scrollToLine(Math.max(0, current - step));
520
+ if ((scrollBox.childBase || 0) <= 0) {
207
521
  onLoadMoreHistory?.();
208
522
  }
209
523
  screen.render();
524
+ checkVisibleMessages();
210
525
  }
211
526
 
212
527
  function handleScrollDown(step = 10) {
213
- scrollBox.scroll(step);
528
+ const current = scrollBox.childBase || 0;
529
+ scrollToLine(current + step);
214
530
  screen.render();
531
+ checkVisibleMessages();
215
532
  }
216
533
 
217
534
  scrollBox.key(["pageup", "C-u"], () => handleScrollUp(10));
218
535
  scrollBox.key(["pagedown", "C-d"], () => handleScrollDown(10));
219
- scrollBox.key(["up", "k"], () => handleScrollUp(2));
220
- scrollBox.key(["down", "j"], () => handleScrollDown(2));
536
+ scrollBox.key(["up", "k"], () => selectByOffset(-1));
537
+ scrollBox.key(["down", "j"], () => selectByOffset(1));
221
538
  scrollBox.key(["home"], () => {
222
- scrollBox.scrollTo(0);
539
+ scrollToLine(0);
223
540
  onLoadMoreHistory?.();
224
541
  screen.render();
542
+ checkVisibleMessages();
225
543
  });
226
544
  scrollBox.key(["end"], () => {
227
- scrollBox.setScrollPerc(100);
545
+ scrollToBottom();
228
546
  screen.render();
547
+ checkVisibleMessages();
229
548
  });
230
549
 
231
- scrollBox.on("wheelup", () => {
232
- handleScrollUp(3);
233
- });
234
-
235
- scrollBox.on("wheeldown", () => {
236
- handleScrollDown(3);
237
- });
238
-
239
- container.on("wheelup", () => {
240
- handleScrollUp(3);
241
- });
242
-
243
- container.on("wheeldown", () => {
244
- handleScrollDown(3);
245
- });
550
+ scrollBox.on("wheelup", () => handleScrollUp(3));
551
+ scrollBox.on("wheeldown", () => handleScrollDown(3));
552
+ container.on("wheelup", () => handleScrollUp(3));
553
+ container.on("wheeldown", () => handleScrollDown(3));
246
554
 
247
555
  scrollBox.on("click", (data) => {
248
- const clickY = data.y;
249
- const itop = scrollBox.itop || 0;
250
- const lineIndex = clickY - (scrollBox.atop || 0) + scrollBox.getScroll() - itop;
251
- const clickedMsg = getMessageAtLine(lineIndex, currentRanges);
252
- if (clickedMsg) {
253
- onActionMenu?.(clickedMsg);
254
- } else {
556
+ const renderedLine = data.y - (scrollBox.atop || 0) - (scrollBox.itop || 0) + (scrollBox.childBase || 0);
557
+ const relX = data.x - (scrollBox.aleft || 0) - (scrollBox.ileft || 0);
558
+ const hit = getMessagePartAtPoint(toContentLine(renderedLine), relX, currentRanges);
559
+
560
+ if (!hit) {
561
+ // Клик по пустому месту ленты — просто передаём ей фокус
562
+ onFocusRequest?.();
255
563
  scrollBox.focus();
256
564
  screen.render();
565
+ return;
566
+ }
567
+
568
+ if (isRightClick(data)) {
569
+ setSelected(hit.message.id);
570
+ onActionMenu?.(hit.message);
571
+ return;
257
572
  }
573
+
574
+ if (hit.part === "image") {
575
+ setSelected(hit.message.id);
576
+ if (isMessageVideo(hit.message) && onPlayVideo) {
577
+ onPlayVideo(hit.message);
578
+ } else {
579
+ onOpenImage?.(hit.message);
580
+ }
581
+ return;
582
+ }
583
+
584
+ onFocusRequest?.();
585
+ scrollBox.focus();
586
+ setSelected(hit.message.id, { notify: true });
258
587
  });
259
588
 
260
589
  // Ctrl+M терминал шлёт как "\r" (имя клавиши "return"), поэтому меню действий
261
590
  // висит на Ctrl+A — иначе оно недостижимо.
262
- scrollBox.key(["C-a"], () => {
263
- if (currentMessages.length > 0) {
264
- const lastMsg = currentMessages[currentMessages.length - 1];
265
- onActionMenu?.(lastMsg);
591
+ scrollBox.key(["C-a", "enter", "return"], () => {
592
+ const msg = getTargetMessage();
593
+ if (msg) {
594
+ setSelected(msg.id);
595
+ onActionMenu?.(msg);
266
596
  }
267
597
  });
268
598
 
599
+ // Отслеживание прокрутки для обновления прочитанных сообщений
600
+ scrollBox.on("scroll", () => {
601
+ checkVisibleMessages();
602
+ });
603
+
269
604
  return {
270
605
  container,
271
606
  scrollBox,
272
607
  setMessages,
608
+ setSelected,
609
+ getSelected,
610
+ getTargetMessage,
611
+ selectByOffset,
612
+ resetReadState: (initialReadMaxId = 0) => {
613
+ lastReportedMaxReadId = initialReadMaxId;
614
+ currentFirstUnreadId = null;
615
+ },
616
+ checkVisibleMessages,
273
617
  loadMore: () => onLoadMoreHistory?.(),
274
618
  scrollToBottom: () => {
275
- scrollBox.setScrollPerc(100);
619
+ scrollToBottom();
276
620
  screen.render();
621
+ checkVisibleMessages();
277
622
  },
278
623
  focus: () => scrollBox.focus(),
279
624
  };