@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/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: () => listener?.stop(),
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
- const history = await fetchHistory(client, dialog.peerId, { limit: 50 });
211
- state.setMessages(dialog.id, history.messages);
212
- markAsRead(client, dialog.peerId).catch(() => {});
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
- statusBar.showMessage(`Ошибка загрузки: ${err.message}`, "error");
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
- chatView.setMessages(msgs);
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
- // При подгрузке старых сообщений (isPrepend) или фоновых обновлениях (isUpdate)
429
- // позиция скролла сохраняется!
430
- const shouldScrollToBottom = isNewMessage ? config.autoScroll : (!isPrepend && !isUpdate);
431
- chatView.setMessages(messages, shouldScrollToBottom);
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
- * Фоново догружает превью изображений для сообщений, не имевших встроенного PhotoStrippedSize.
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
- for (const msg of messages) {
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
- if (msg.media && !msg.imagePreview && msg.rawMessage) {
464
- const isPhoto = msg.media.className === "MessageMediaPhoto";
465
- const isDoc = msg.media.className === "MessageMediaDocument";
466
- if (isPhoto || isDoc) {
467
- try {
468
- const preview = await loadMessageImagePreview(client, msg.rawMessage);
469
- if (preview && state.activeChat?.id === chatId) {
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 (!message.imagePreview && (message.media?.className === "MessageMediaPhoto" || message.media?.className === "MessageMediaDocument")) {
653
+ if (message.isPreviewLoading) {
487
654
  fetchMissingImagePreviews([message], peerId);
488
655
  }
489
656
 
490
- if (state.activeChat?.id === peerId) {
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
- state.setTyping(chatId, "Собеседник");
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);