@emaxe/tuigram 1.4.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 +21 -1
- package/CHANGELOG.ru.md +21 -1
- package/README.md +47 -2
- package/README.ru.md +47 -2
- 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 +90 -6
- package/src/ui/app.js +200 -35
- package/src/ui/components/chatList.js +156 -100
- package/src/ui/components/chatView.js +259 -78
- package/src/ui/components/inputBox.js +12 -4
- package/src/ui/components/modals/actionModal.js +5 -0
- package/src/ui/components/modals/helpModal.js +2 -1
- package/src/ui/components/modals/videoPlayerModal.js +274 -0
- package/src/ui/screen.js +108 -0
- package/src/ui/theme.js +1 -0
- package/src/utils/image.js +310 -6
- package/src/utils/mouse.js +1 -1
- package/src/utils/video.js +495 -0
|
@@ -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) {
|
|
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
|
+
}
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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 (
|
|
179
|
-
const
|
|
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 +
|
|
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
|
|
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
|
-
|
|
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} [
|
|
360
|
+
* @param {boolean|object} [scrollOption=true]
|
|
227
361
|
*/
|
|
228
|
-
function setMessages(messages,
|
|
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 (
|
|
243
|
-
|
|
244
|
-
|
|
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
|
-
|
|
409
|
+
scrollToLine(prevScroll + addedLines);
|
|
251
410
|
} else if (prevScroll > 0) {
|
|
252
|
-
|
|
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 =
|
|
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
|
-
|
|
454
|
+
scrollToLine(top);
|
|
295
455
|
} else if (bottom >= base + visible) {
|
|
296
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
539
|
+
scrollToLine(0);
|
|
376
540
|
onLoadMoreHistory?.();
|
|
377
541
|
screen.render();
|
|
542
|
+
checkVisibleMessages();
|
|
378
543
|
});
|
|
379
544
|
scrollBox.key(["end"], () => {
|
|
380
|
-
|
|
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
|
-
|
|
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
|
-
|
|
619
|
+
scrollToBottom();
|
|
440
620
|
screen.render();
|
|
621
|
+
checkVisibleMessages();
|
|
441
622
|
},
|
|
442
623
|
focus: () => scrollBox.focus(),
|
|
443
624
|
};
|
|
@@ -146,8 +146,10 @@ export function createInputBox(screen, theme, {
|
|
|
146
146
|
});
|
|
147
147
|
|
|
148
148
|
textarea.on("click", () => {
|
|
149
|
-
textarea
|
|
150
|
-
|
|
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
|
|
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: () =>
|
|
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}Клик по
|
|
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} Отдать мышь терминалу, чтобы выделить и скопировать текст
|