@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/.env.example +15 -0
- package/CHANGELOG.md +63 -1
- package/CHANGELOG.ru.md +67 -1
- package/README.md +96 -15
- package/README.ru.md +97 -15
- package/package.json +2 -1
- package/src/cli/videoSetup.js +187 -0
- package/src/config.js +6 -0
- package/src/index.js +8 -0
- package/src/state.js +31 -8
- package/src/telegram/dialogs.js +6 -2
- package/src/telegram/formatter.js +1 -1
- package/src/telegram/messages.js +182 -19
- package/src/ui/app.js +293 -73
- package/src/ui/components/chatList.js +159 -101
- package/src/ui/components/chatView.js +454 -109
- package/src/ui/components/header.js +2 -1
- package/src/ui/components/inputBox.js +14 -5
- package/src/ui/components/modals/actionModal.js +11 -14
- package/src/ui/components/modals/chatInfoModal.js +3 -13
- package/src/ui/components/modals/confirmModal.js +3 -13
- package/src/ui/components/modals/fileModal.js +6 -13
- package/src/ui/components/modals/filePickerModal.js +3 -13
- package/src/ui/components/modals/helpModal.js +14 -18
- package/src/ui/components/modals/imageViewerModal.js +172 -0
- package/src/ui/components/modals/videoPlayerModal.js +274 -0
- package/src/ui/components/statusBar.js +2 -1
- package/src/ui/modalMouse.js +43 -0
- package/src/ui/screen.js +168 -20
- package/src/ui/theme.js +1 -0
- package/src/utils/image.js +318 -14
- package/src/utils/mouse.js +62 -75
- package/src/utils/video.js +495 -0
|
@@ -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 {
|
|
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, {
|
|
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
|
|
46
|
-
|
|
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
|
-
* @
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
114
|
-
|
|
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
|
-
//
|
|
122
|
-
let
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
:
|
|
130
|
-
|
|
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
|
-
|
|
135
|
-
|
|
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
|
-
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
165
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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} [
|
|
360
|
+
* @param {boolean|object} [scrollOption=true]
|
|
177
361
|
*/
|
|
178
|
-
function setMessages(messages,
|
|
362
|
+
function setMessages(messages, scrollOption = true) {
|
|
179
363
|
currentMessages = messages;
|
|
180
|
-
const prevScroll = scrollBox.
|
|
364
|
+
const prevScroll = scrollBox.childBase || 0;
|
|
181
365
|
const prevHeight = scrollBox.getScrollHeight();
|
|
182
366
|
|
|
183
|
-
|
|
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 (
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
409
|
+
scrollToLine(prevScroll + addedLines);
|
|
196
410
|
} else if (prevScroll > 0) {
|
|
197
|
-
|
|
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.
|
|
206
|
-
|
|
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.
|
|
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"], () =>
|
|
220
|
-
scrollBox.key(["down", "j"], () =>
|
|
536
|
+
scrollBox.key(["up", "k"], () => selectByOffset(-1));
|
|
537
|
+
scrollBox.key(["down", "j"], () => selectByOffset(1));
|
|
221
538
|
scrollBox.key(["home"], () => {
|
|
222
|
-
|
|
539
|
+
scrollToLine(0);
|
|
223
540
|
onLoadMoreHistory?.();
|
|
224
541
|
screen.render();
|
|
542
|
+
checkVisibleMessages();
|
|
225
543
|
});
|
|
226
544
|
scrollBox.key(["end"], () => {
|
|
227
|
-
|
|
545
|
+
scrollToBottom();
|
|
228
546
|
screen.render();
|
|
547
|
+
checkVisibleMessages();
|
|
229
548
|
});
|
|
230
549
|
|
|
231
|
-
scrollBox.on("wheelup", () =>
|
|
232
|
-
|
|
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
|
|
249
|
-
const
|
|
250
|
-
const
|
|
251
|
-
|
|
252
|
-
if (
|
|
253
|
-
|
|
254
|
-
|
|
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
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
-
|
|
619
|
+
scrollToBottom();
|
|
276
620
|
screen.render();
|
|
621
|
+
checkVisibleMessages();
|
|
277
622
|
},
|
|
278
623
|
focus: () => scrollBox.focus(),
|
|
279
624
|
};
|