@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.
- package/.env.example +15 -0
- package/CHANGELOG.md +32 -1
- package/CHANGELOG.ru.md +32 -1
- package/README.md +48 -3
- package/README.ru.md +48 -3
- package/package.json +2 -1
- package/src/cli/formatters.js +2 -2
- 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/entities.js +62 -7
- package/src/telegram/formatter.js +1 -1
- package/src/telegram/listener.js +57 -11
- package/src/telegram/messages.js +193 -18
- package/src/ui/app.js +211 -37
- package/src/ui/components/chatList.js +156 -100
- package/src/ui/components/chatView.js +259 -78
- package/src/ui/components/inputBox.js +13 -5
- 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
package/src/ui/app.js
CHANGED
|
@@ -13,17 +13,20 @@ import { createActionModal } from "./components/modals/actionModal.js";
|
|
|
13
13
|
import { createFileModal } from "./components/modals/fileModal.js";
|
|
14
14
|
import { createConfirmModal } from "./components/modals/confirmModal.js";
|
|
15
15
|
import { createImageViewerModal } from "./components/modals/imageViewerModal.js";
|
|
16
|
+
import { createVideoPlayerModal } from "./components/modals/videoPlayerModal.js";
|
|
16
17
|
import { state } from "../state.js";
|
|
17
18
|
import { config } from "../config.js";
|
|
19
|
+
import { entityCache, getEntityDisplayName, resolveEntity } from "../telegram/entities.js";
|
|
18
20
|
import { fetchDialogs } from "../telegram/dialogs.js";
|
|
19
21
|
import { setMessagePalette } from "../telegram/formatter.js";
|
|
20
|
-
import { fetchHistory, sendMessage, editMessage, deleteMessages, sendFiles, downloadMedia, sendReaction, markAsRead, loadMessageImagePreview, downloadImageBuffer, renderMessageThumbnail } from "../telegram/messages.js";
|
|
22
|
+
import { fetchHistory, sendMessage, editMessage, deleteMessages, sendFiles, downloadMedia, sendReaction, markAsRead, loadMessageImagePreview, downloadImageBuffer, renderMessageThumbnail, findFirstUnreadMessage, calculateRemainingUnreadCount } from "../telegram/messages.js";
|
|
21
23
|
import { startTelegramListener } from "../telegram/listener.js";
|
|
22
24
|
import { logout } from "../telegram/auth.js";
|
|
23
25
|
import { ensureDir, inspectLocalFile } from "../utils/storage.js";
|
|
24
26
|
import { formatFileSize } from "../utils/time.js";
|
|
25
27
|
import { parseSendFileArgs } from "../utils/commands.js";
|
|
26
28
|
import { isRightClick } from "../utils/mouse.js";
|
|
29
|
+
import { renderMediaPreloader } from "../utils/image.js";
|
|
27
30
|
|
|
28
31
|
/**
|
|
29
32
|
* Запускает полноэкранный TUI-клиент Telegram.
|
|
@@ -38,7 +41,10 @@ export async function startTui(client, me) {
|
|
|
38
41
|
let listener = null;
|
|
39
42
|
const screen = createScreen({
|
|
40
43
|
theme,
|
|
41
|
-
onExit: () =>
|
|
44
|
+
onExit: () => {
|
|
45
|
+
flushPendingMarkAsRead();
|
|
46
|
+
listener?.stop();
|
|
47
|
+
},
|
|
42
48
|
});
|
|
43
49
|
|
|
44
50
|
state.me = me;
|
|
@@ -112,6 +118,7 @@ export async function startTui(client, me) {
|
|
|
112
118
|
onQuit: () => {
|
|
113
119
|
releaseInputs();
|
|
114
120
|
confirmModal.ask("Выйти из TuiGram?", () => {
|
|
121
|
+
flushPendingMarkAsRead();
|
|
115
122
|
listener?.stop();
|
|
116
123
|
screen.destroy();
|
|
117
124
|
process.exit(0);
|
|
@@ -129,11 +136,62 @@ export async function startTui(client, me) {
|
|
|
129
136
|
|
|
130
137
|
const imageViewerModal = createImageViewerModal(screen, theme, {
|
|
131
138
|
onLoadFullImage: (msg) => downloadImageBuffer(client, msg.rawMessage),
|
|
132
|
-
// Пока качается оригинал, показываем встроенную в сообщение миниатюру
|
|
139
|
+
// Пока качается оригинал, показываем встроенную в сообщение миниатюру или прелоадер
|
|
133
140
|
onRenderPlaceholder: (msg, size) => renderMessageThumbnail(msg.rawMessage, {
|
|
134
141
|
maxWidth: size.maxWidth,
|
|
135
142
|
maxHeight: size.maxHeight,
|
|
136
143
|
useCache: false,
|
|
144
|
+
}) || renderMediaPreloader(msg.rawMessage, {
|
|
145
|
+
maxWidth: size.maxWidth,
|
|
146
|
+
maxHeight: size.maxHeight,
|
|
147
|
+
palette: {
|
|
148
|
+
bg: theme.surface,
|
|
149
|
+
border: theme.borders.fg,
|
|
150
|
+
fg: theme.fg,
|
|
151
|
+
accent: theme.accent,
|
|
152
|
+
dim: theme.dim,
|
|
153
|
+
},
|
|
154
|
+
}),
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
const videoPlayerModal = createVideoPlayerModal(screen, theme, {
|
|
158
|
+
onLoadVideoFile: async (msg, progressCallback) => {
|
|
159
|
+
const downloadsDir = config.downloadsDir;
|
|
160
|
+
ensureDir(downloadsDir);
|
|
161
|
+
let fileName = `video_${msg.id}.mp4`;
|
|
162
|
+
const doc = msg.rawMessage?.media?.document;
|
|
163
|
+
if (doc?.attributes) {
|
|
164
|
+
for (const attr of doc.attributes) {
|
|
165
|
+
if (attr.className === "DocumentAttributeFilename" && attr.fileName) {
|
|
166
|
+
fileName = `msg_${msg.id}_${attr.fileName}`;
|
|
167
|
+
break;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
const targetPath = path.join(downloadsDir, fileName);
|
|
172
|
+
if (fs.existsSync(targetPath) && fs.statSync(targetPath).size > 0) {
|
|
173
|
+
return targetPath;
|
|
174
|
+
}
|
|
175
|
+
const res = await downloadMedia(client, msg.rawMessage, {
|
|
176
|
+
outputFile: targetPath,
|
|
177
|
+
progressCallback,
|
|
178
|
+
});
|
|
179
|
+
return typeof res === "string" ? res : targetPath;
|
|
180
|
+
},
|
|
181
|
+
onRenderPlaceholder: (msg, size) => renderMessageThumbnail(msg.rawMessage, {
|
|
182
|
+
maxWidth: size.maxWidth,
|
|
183
|
+
maxHeight: size.maxHeight,
|
|
184
|
+
useCache: false,
|
|
185
|
+
}) || renderMediaPreloader(msg.rawMessage, {
|
|
186
|
+
maxWidth: size.maxWidth,
|
|
187
|
+
maxHeight: size.maxHeight,
|
|
188
|
+
palette: {
|
|
189
|
+
bg: theme.surface,
|
|
190
|
+
border: theme.borders.fg,
|
|
191
|
+
fg: theme.fg,
|
|
192
|
+
accent: theme.accent,
|
|
193
|
+
dim: theme.dim,
|
|
194
|
+
},
|
|
137
195
|
}),
|
|
138
196
|
});
|
|
139
197
|
|
|
@@ -143,6 +201,10 @@ export async function startTui(client, me) {
|
|
|
143
201
|
const peerId = state.activeChat.peerId;
|
|
144
202
|
|
|
145
203
|
switch (actionId) {
|
|
204
|
+
case "play_video":
|
|
205
|
+
releaseInputs();
|
|
206
|
+
videoPlayerModal.play(msg);
|
|
207
|
+
break;
|
|
146
208
|
case "reply":
|
|
147
209
|
inputBox.setContext("reply", msg);
|
|
148
210
|
break;
|
|
@@ -200,20 +262,62 @@ export async function startTui(client, me) {
|
|
|
200
262
|
},
|
|
201
263
|
});
|
|
202
264
|
|
|
265
|
+
let readDebounceTimer = null;
|
|
266
|
+
let pendingReadMaxId = 0;
|
|
267
|
+
let pendingReadPeerId = null;
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Отправляет подтверждение прочтения на сервер с дебаунсом, чтобы не спамить сеть при скролле.
|
|
271
|
+
* @param {string|number|bigint} peerId
|
|
272
|
+
* @param {number} maxId
|
|
273
|
+
*/
|
|
274
|
+
function debouncedMarkAsRead(peerId, maxId) {
|
|
275
|
+
pendingReadPeerId = peerId;
|
|
276
|
+
if (maxId > pendingReadMaxId) {
|
|
277
|
+
pendingReadMaxId = maxId;
|
|
278
|
+
}
|
|
279
|
+
if (readDebounceTimer) clearTimeout(readDebounceTimer);
|
|
280
|
+
readDebounceTimer = setTimeout(() => {
|
|
281
|
+
flushPendingMarkAsRead();
|
|
282
|
+
}, 300);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Сбрасывает накопленный маркер прочтения на сервер без задержки. */
|
|
286
|
+
function flushPendingMarkAsRead() {
|
|
287
|
+
if (readDebounceTimer) {
|
|
288
|
+
clearTimeout(readDebounceTimer);
|
|
289
|
+
readDebounceTimer = null;
|
|
290
|
+
}
|
|
291
|
+
if (pendingReadPeerId && pendingReadMaxId > 0) {
|
|
292
|
+
const peer = pendingReadPeerId;
|
|
293
|
+
const id = pendingReadMaxId;
|
|
294
|
+
pendingReadPeerId = null;
|
|
295
|
+
pendingReadMaxId = 0;
|
|
296
|
+
markAsRead(client, peer, id).catch(() => {});
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
203
300
|
// 2. Список диалогов (левая панель)
|
|
204
301
|
const chatList = createChatList(screen, theme, {
|
|
205
302
|
onSelectDialog: async (dialog) => {
|
|
303
|
+
flushPendingMarkAsRead();
|
|
206
304
|
state.setActiveChat(dialog);
|
|
305
|
+
chatView.resetReadState(dialog.readInboxMaxId || 0);
|
|
207
306
|
statusBar.showMessage(`Загрузка сообщений: ${dialog.title}...`, "info");
|
|
208
307
|
|
|
209
308
|
try {
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
309
|
+
// Загружаем всю пачку непрочитанных сообщений + запас из 20 прочитанных
|
|
310
|
+
// для контекста и правильного позиционирования разделителя
|
|
311
|
+
const limit = Math.max(50, Math.min(1000, (dialog.unreadCount || 0) + 20));
|
|
312
|
+
const history = await fetchHistory(client, dialog.peerId, { limit });
|
|
313
|
+
if (state.activeChat?.id !== dialog.id) return;
|
|
314
|
+
const firstUnread = findFirstUnreadMessage(history.messages, dialog);
|
|
315
|
+
state.setMessages(dialog.id, history.messages, { firstUnreadId: firstUnread?.id || null });
|
|
213
316
|
statusBar.showMessage(`Чат: ${dialog.title}`, "info");
|
|
214
|
-
fetchMissingImagePreviews(history.messages, dialog.id);
|
|
215
317
|
} catch (err) {
|
|
216
|
-
|
|
318
|
+
if (state.activeChat?.id === dialog.id) {
|
|
319
|
+
statusBar.showMessage(`Ошибка загрузки: ${err.message}`, "error");
|
|
320
|
+
}
|
|
217
321
|
}
|
|
218
322
|
},
|
|
219
323
|
onTabChange: (tab) => {
|
|
@@ -241,7 +345,6 @@ export async function startTui(client, me) {
|
|
|
241
345
|
if (older.messages.length > 0) {
|
|
242
346
|
state.setMessages(state.activeChat.id, older.messages, true);
|
|
243
347
|
statusBar.showMessage(`Загружено ${older.messages.length} предыдущих сообщений`, "info");
|
|
244
|
-
fetchMissingImagePreviews(older.messages, state.activeChat.id);
|
|
245
348
|
}
|
|
246
349
|
} catch (err) {
|
|
247
350
|
statusBar.showMessage(`Ошибка пагинации: ${err.message}`, "error");
|
|
@@ -263,7 +366,24 @@ export async function startTui(client, me) {
|
|
|
263
366
|
releaseInputs();
|
|
264
367
|
imageViewerModal.show(msg);
|
|
265
368
|
},
|
|
369
|
+
onPlayVideo: (msg) => {
|
|
370
|
+
releaseInputs();
|
|
371
|
+
videoPlayerModal.play(msg);
|
|
372
|
+
},
|
|
266
373
|
onFocusRequest: () => releaseInputs(),
|
|
374
|
+
onMessagesRead: (maxVisibleId) => {
|
|
375
|
+
if (!state.activeChat) return;
|
|
376
|
+
const chatId = state.activeChat.id;
|
|
377
|
+
const dialog = state.dialogs.find((d) => d.id === chatId);
|
|
378
|
+
const messages = state.getMessages(chatId);
|
|
379
|
+
const remainingCount = calculateRemainingUnreadCount(messages, maxVisibleId, dialog?.unreadCount);
|
|
380
|
+
state.updateDialogUnread(chatId, remainingCount, maxVisibleId);
|
|
381
|
+
debouncedMarkAsRead(state.activeChat.peerId, maxVisibleId);
|
|
382
|
+
},
|
|
383
|
+
onVisibleMessagesChanged: (visibleMsgs) => {
|
|
384
|
+
if (!state.activeChat) return;
|
|
385
|
+
fetchMissingImagePreviews(visibleMsgs, state.activeChat.id);
|
|
386
|
+
},
|
|
267
387
|
});
|
|
268
388
|
|
|
269
389
|
// 4. Поле ввода (нижняя панель)
|
|
@@ -411,6 +531,7 @@ export async function startTui(client, me) {
|
|
|
411
531
|
});
|
|
412
532
|
|
|
413
533
|
state.on("active_chat_changed", (chat) => {
|
|
534
|
+
activePreviewLoads.clear();
|
|
414
535
|
header.updateInfo({
|
|
415
536
|
me: state.me,
|
|
416
537
|
status: state.connectionStatus,
|
|
@@ -419,16 +540,29 @@ export async function startTui(client, me) {
|
|
|
419
540
|
});
|
|
420
541
|
const msgs = chat ? state.getMessages(chat.id) : [];
|
|
421
542
|
chatView.setSelected(null);
|
|
422
|
-
|
|
543
|
+
if (chat && msgs.length > 0) {
|
|
544
|
+
const firstUnread = findFirstUnreadMessage(msgs, chat);
|
|
545
|
+
if (firstUnread) {
|
|
546
|
+
chatView.setMessages(msgs, { firstUnreadId: firstUnread.id, autoScrollToBottom: false });
|
|
547
|
+
} else {
|
|
548
|
+
chatView.setMessages(msgs, true);
|
|
549
|
+
}
|
|
550
|
+
} else {
|
|
551
|
+
chatView.setMessages([], false);
|
|
552
|
+
}
|
|
423
553
|
});
|
|
424
554
|
|
|
425
|
-
state.on("messages_updated", ({ chatId, messages, isPrepend, isUpdate, isNewMessage }) => {
|
|
555
|
+
state.on("messages_updated", ({ chatId, messages, isPrepend, isUpdate, isNewMessage, firstUnreadId }) => {
|
|
426
556
|
if (state.activeChat?.id === chatId) {
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
557
|
+
if (firstUnreadId) {
|
|
558
|
+
chatView.setMessages(messages, { firstUnreadId, autoScrollToBottom: false });
|
|
559
|
+
} else {
|
|
560
|
+
// Скроллим в самый низ только если это новое сообщение или первая загрузка чата.
|
|
561
|
+
// При подгрузке старых сообщений (isPrepend) или фоновых обновлениях (isUpdate)
|
|
562
|
+
// позиция скролла сохраняется!
|
|
563
|
+
const shouldScrollToBottom = isNewMessage ? config.autoScroll : (!isPrepend && !isUpdate);
|
|
564
|
+
chatView.setMessages(messages, shouldScrollToBottom);
|
|
565
|
+
}
|
|
432
566
|
}
|
|
433
567
|
});
|
|
434
568
|
|
|
@@ -451,30 +585,63 @@ export async function startTui(client, me) {
|
|
|
451
585
|
}
|
|
452
586
|
});
|
|
453
587
|
|
|
588
|
+
let previewBatchTimer = null;
|
|
589
|
+
let pendingBatchChatId = null;
|
|
590
|
+
|
|
454
591
|
/**
|
|
455
|
-
*
|
|
592
|
+
* Планирует пакетное обновление интерфейса после догрузки группы превью.
|
|
593
|
+
* @param {string} chatId
|
|
594
|
+
*/
|
|
595
|
+
function scheduleBatchPreviewUpdate(chatId) {
|
|
596
|
+
if (state.activeChat?.id !== chatId) return;
|
|
597
|
+
pendingBatchChatId = chatId;
|
|
598
|
+
if (previewBatchTimer) return;
|
|
599
|
+
previewBatchTimer = setTimeout(() => {
|
|
600
|
+
previewBatchTimer = null;
|
|
601
|
+
if (state.activeChat?.id === pendingBatchChatId) {
|
|
602
|
+
const current = state.getMessages(pendingBatchChatId);
|
|
603
|
+
state.emit("messages_updated", {
|
|
604
|
+
chatId: pendingBatchChatId,
|
|
605
|
+
messages: current,
|
|
606
|
+
isPrepend: false,
|
|
607
|
+
isUpdate: true,
|
|
608
|
+
});
|
|
609
|
+
}
|
|
610
|
+
pendingBatchChatId = null;
|
|
611
|
+
}, 150);
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/** Множество ID сообщений, для которых прямо сейчас выполняется загрузка превью. */
|
|
615
|
+
const activePreviewLoads = new Set();
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* Фоново догружает превью изображений только для тех сообщений, которые видны на экране (Lazy Loading).
|
|
619
|
+
* Обновления группируются пакетами, предотвращая фризы и блокировку основного потока.
|
|
456
620
|
* @param {Array<object>} messages
|
|
457
621
|
* @param {string} chatId
|
|
458
622
|
*/
|
|
459
623
|
async function fetchMissingImagePreviews(messages, chatId) {
|
|
460
624
|
if (!config.showImages || !messages || messages.length === 0) return;
|
|
461
|
-
|
|
625
|
+
const toFetch = messages.filter((m) => m.isPreviewLoading && m.rawMessage && !activePreviewLoads.has(m.id));
|
|
626
|
+
if (toFetch.length === 0) return;
|
|
627
|
+
|
|
628
|
+
for (const msg of toFetch) {
|
|
462
629
|
if (state.activeChat?.id !== chatId) break;
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
const
|
|
466
|
-
if (
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
msg.imagePreview = preview;
|
|
471
|
-
state.updateMessage(chatId, msg);
|
|
472
|
-
}
|
|
473
|
-
} catch {
|
|
474
|
-
// Игнорируем сетевые ошибки фоновой загрузки превью
|
|
475
|
-
}
|
|
630
|
+
activePreviewLoads.add(msg.id);
|
|
631
|
+
try {
|
|
632
|
+
const preview = await loadMessageImagePreview(client, msg.rawMessage);
|
|
633
|
+
if (preview && state.activeChat?.id === chatId) {
|
|
634
|
+
msg.imagePreview = preview;
|
|
635
|
+
msg.isPreviewLoading = false;
|
|
636
|
+
scheduleBatchPreviewUpdate(chatId);
|
|
476
637
|
}
|
|
638
|
+
} catch {
|
|
639
|
+
// Игнорируем сетевые ошибки фоновой загрузки превью
|
|
640
|
+
} finally {
|
|
641
|
+
activePreviewLoads.delete(msg.id);
|
|
477
642
|
}
|
|
643
|
+
// Даём event loop обработать пользовательский ввод и скролл
|
|
644
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
478
645
|
}
|
|
479
646
|
}
|
|
480
647
|
|
|
@@ -483,13 +650,11 @@ export async function startTui(client, me) {
|
|
|
483
650
|
|
|
484
651
|
listener.on("new_message", ({ peerId, message }) => {
|
|
485
652
|
state.addMessage(peerId, message);
|
|
486
|
-
if (
|
|
653
|
+
if (message.isPreviewLoading) {
|
|
487
654
|
fetchMissingImagePreviews([message], peerId);
|
|
488
655
|
}
|
|
489
656
|
|
|
490
|
-
if (state.activeChat?.id
|
|
491
|
-
markAsRead(client, state.activeChat.peerId).catch(() => {});
|
|
492
|
-
} else if (!message.out) {
|
|
657
|
+
if (state.activeChat?.id !== peerId && !message.out) {
|
|
493
658
|
const sender = message.senderName || "Новое сообщение";
|
|
494
659
|
const preview = (message.text || "Вложение").slice(0, 30);
|
|
495
660
|
statusBar.showMessage(`💬 ${sender}: "${preview}"`, "info", 5000);
|
|
@@ -504,8 +669,16 @@ export async function startTui(client, me) {
|
|
|
504
669
|
state.removeMessages(peerId, deletedIds);
|
|
505
670
|
});
|
|
506
671
|
|
|
507
|
-
listener.on("typing", ({ chatId, userId }) => {
|
|
508
|
-
|
|
672
|
+
listener.on("typing", async ({ chatId, userId }) => {
|
|
673
|
+
let cached = entityCache.get(userId);
|
|
674
|
+
if (!cached && userId) {
|
|
675
|
+
try {
|
|
676
|
+
cached = await resolveEntity(client, userId);
|
|
677
|
+
} catch {
|
|
678
|
+
// Фоллбэк
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
state.setTyping(chatId, cached ? getEntityDisplayName(cached) : "Собеседник");
|
|
509
682
|
});
|
|
510
683
|
|
|
511
684
|
/**
|
|
@@ -730,6 +903,7 @@ export async function startTui(client, me) {
|
|
|
730
903
|
screen.key(["C-q"], () => {
|
|
731
904
|
releaseInputs();
|
|
732
905
|
confirmModal.ask("Выйти из TuiGram?", () => {
|
|
906
|
+
flushPendingMarkAsRead();
|
|
733
907
|
listener?.stop();
|
|
734
908
|
screen.destroy();
|
|
735
909
|
process.exit(0);
|