@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.
@@ -37,6 +37,9 @@ export function normalizeDialog(dialog) {
37
37
  archived: Boolean(dialog.archived),
38
38
  unreadCount: dialog.unreadCount || 0,
39
39
  unreadMentionsCount: dialog.unreadMentionsCount || 0,
40
+ readInboxMaxId: dialog.dialog?.readInboxMaxId || dialog.readInboxMaxId || 0,
41
+ readOutboxMaxId: dialog.dialog?.readOutboxMaxId || dialog.readOutboxMaxId || 0,
42
+ topMessage: dialog.dialog?.topMessage || message?.id || 0,
40
43
  folderId: dialog.folderId || 0,
41
44
  date: dialog.date ? dialog.date * 1000 : (message?.date ? message.date * 1000 : Date.now()),
42
45
  isMuted: Boolean(dialog.dialog?.notifySettings?.muteUntil > 0),
@@ -86,9 +89,10 @@ export async function fetchDialogs(client, { limit = 100, archived } = {}) {
86
89
  * Фильтрует список диалогов по выбранной категории.
87
90
  * @param {Array<object>} dialogs
88
91
  * @param {"all"|"users"|"groups"|"channels"|"bots"|"unread"|"archived"} filterTab
92
+ * @param {string|null} [activeChatId=null] ID активного чата (не исключается из вкладки непрочитанных)
89
93
  * @returns {Array<object>}
90
94
  */
91
- export function filterDialogsByTab(dialogs, filterTab = "all") {
95
+ export function filterDialogsByTab(dialogs, filterTab = "all", activeChatId = null) {
92
96
  if (!dialogs) return [];
93
97
 
94
98
  switch (filterTab) {
@@ -101,7 +105,7 @@ export function filterDialogsByTab(dialogs, filterTab = "all") {
101
105
  case "bots":
102
106
  return dialogs.filter((d) => !d.archived && d.type === "bot");
103
107
  case "unread":
104
- return dialogs.filter((d) => !d.archived && (d.unreadCount > 0 || d.unreadMentionsCount > 0));
108
+ return dialogs.filter((d) => !d.archived && (d.unreadCount > 0 || d.unreadMentionsCount > 0 || (activeChatId && d.id === activeChatId)));
105
109
  case "archived":
106
110
  return dialogs.filter((d) => d.archived);
107
111
  case "all":
@@ -70,7 +70,7 @@ export function setMessagePalette(theme) {
70
70
  */
71
71
  export function escapeBlessed(text) {
72
72
  if (!text) return "";
73
- return String(text).replace(/\{/g, "\\{").replace(/\}/g, "\\}");
73
+ return String(text).replace(/[{}]/g, (ch) => (ch === "{" ? "{open}" : "{close}"));
74
74
  }
75
75
 
76
76
  /**
@@ -2,7 +2,7 @@ import { Api, errors } from "teleproto";
2
2
  import { idToString, toMarkedId, getEntityDisplayName, entityCache, resolveEntity } from "./entities.js";
3
3
  import { describeMedia } from "./formatter.js";
4
4
  import { config } from "../config.js";
5
- import { renderStrippedThumbnail, renderImageBuffer } from "../utils/image.js";
5
+ import { renderStrippedThumbnail, renderImageBuffer, renderMediaPreloader, getCachedImagePreview, isPreviewableMedia } from "../utils/image.js";
6
6
 
7
7
  const { FloodWaitError } = errors;
8
8
 
@@ -73,10 +73,32 @@ export function normalizeMessage(message) {
73
73
  }
74
74
  }
75
75
 
76
- // Извлечение и рендеринг PhotoStrippedSize в псевдографику
77
- const imagePreview = config.showImages
78
- ? (renderMessageThumbnail(message) || null)
79
- : null;
76
+ // Извлечение и рендеринг PhotoStrippedSize в псевдографику или прелоадера
77
+ let imagePreview = null;
78
+ let isPreviewLoading = false;
79
+
80
+ if (config.showImages && isPreviewableMedia(message)) {
81
+ const cached = getCachedImagePreview(message, {
82
+ maxWidth: config.imageMaxWidth,
83
+ maxHeight: config.imageMaxHeight,
84
+ });
85
+ if (cached) {
86
+ imagePreview = cached;
87
+ isPreviewLoading = false;
88
+ } else {
89
+ const strippedThumb = renderMessageThumbnail(message);
90
+ if (strippedThumb) {
91
+ imagePreview = strippedThumb;
92
+ isPreviewLoading = false;
93
+ } else {
94
+ imagePreview = renderMediaPreloader(message, {
95
+ maxWidth: config.imageMaxWidth,
96
+ maxHeight: config.imageMaxHeight,
97
+ });
98
+ isPreviewLoading = true;
99
+ }
100
+ }
101
+ }
80
102
 
81
103
  return {
82
104
  id: message.id,
@@ -94,6 +116,7 @@ export function normalizeMessage(message) {
94
116
  media: message.media || null,
95
117
  mediaDescription: describeMedia(message.media),
96
118
  imagePreview,
119
+ isPreviewLoading,
97
120
  entities: message.entities || [],
98
121
  reactions,
99
122
  rawMessage: message,
@@ -120,6 +143,9 @@ export async function fetchHistory(client, rawPeer, { limit = 40, offsetId = 0,
120
143
  messages.push(normalizeMessage(msg));
121
144
  }
122
145
  }
146
+ if (!reverse) {
147
+ messages.reverse();
148
+ }
123
149
  };
124
150
 
125
151
  try {
@@ -286,6 +312,61 @@ export async function sendReaction(client, rawPeer, messageId, emoji = "👍") {
286
312
  );
287
313
  }
288
314
 
315
+ /**
316
+ * Находит первое непрочитанное входящее сообщение в хронологическом списке сообщений.
317
+ * @param {Array<object>} messages
318
+ * @param {object} [options]
319
+ * @param {number} [options.readInboxMaxId=0]
320
+ * @param {number} [options.unreadCount=0]
321
+ * @returns {object|null}
322
+ */
323
+ export function findFirstUnreadMessage(messages, { readInboxMaxId = 0, unreadCount = 0 } = {}) {
324
+ if (!Array.isArray(messages) || messages.length === 0 || unreadCount === 0) {
325
+ return null;
326
+ }
327
+
328
+ // Всегда сортируем сообщения по возрастанию ID/даты перед поиском
329
+ const sorted = [...messages].sort((a, b) => (a.date || 0) - (b.date || 0) || (a.id - b.id));
330
+
331
+ if (readInboxMaxId > 0) {
332
+ const first = sorted.find((m) => !m.out && m.id > readInboxMaxId);
333
+ if (first) return first;
334
+ }
335
+
336
+ if (unreadCount > 0) {
337
+ const incoming = sorted.filter((m) => !m.out);
338
+ if (incoming.length > 0) {
339
+ const unreadIncoming = incoming.slice(-unreadCount);
340
+ return unreadIncoming[0] || null;
341
+ }
342
+ }
343
+
344
+ return null;
345
+ }
346
+
347
+ /**
348
+ * Вычисляет количество оставшихся непрочитанных сообщений в чате на основе прочитанного maxId.
349
+ * @param {Array<object>} messages
350
+ * @param {number} maxReadId
351
+ * @param {number} [totalUnreadCount]
352
+ * @returns {number}
353
+ */
354
+ export function calculateRemainingUnreadCount(messages, maxReadId = 0, totalUnreadCount) {
355
+ if (!Array.isArray(messages) || messages.length === 0) return 0;
356
+
357
+ const unreadInLoaded = messages.filter((m) => !m.out && m.id > maxReadId).length;
358
+
359
+ if (typeof totalUnreadCount === "number" && totalUnreadCount > 0) {
360
+ const incomingInLoaded = messages.filter((m) => !m.out).length;
361
+ if (totalUnreadCount > incomingInLoaded) {
362
+ const notLoadedCount = totalUnreadCount - incomingInLoaded;
363
+ return notLoadedCount + unreadInLoaded;
364
+ }
365
+ }
366
+
367
+ return unreadInLoaded;
368
+ }
369
+
289
370
  /**
290
371
  * Отмечает сообщения в чате прочитанными.
291
372
  * @param {import("teleproto").TelegramClient} client
@@ -296,7 +377,7 @@ export async function markAsRead(client, rawPeer, maxId = 0) {
296
377
  const entity = await resolveEntity(client, rawPeer);
297
378
  try {
298
379
  if (maxId > 0) {
299
- await client.sendReadAcknowledge(entity, { maxId });
380
+ await client.markAsRead(entity, maxId);
300
381
  } else {
301
382
  await client.markAsRead(entity);
302
383
  }
@@ -383,6 +464,9 @@ export async function loadMessageImagePreview(client, rawMessage, { maxWidth = c
383
464
  const isDoc = media.className === "MessageMediaDocument";
384
465
  if (!isPhoto && !isDoc) return "";
385
466
 
467
+ const cached = getCachedImagePreview(rawMessage, { maxWidth, maxHeight });
468
+ if (cached) return cached;
469
+
386
470
  const cacheKey = isPhoto
387
471
  ? `photo_full_${media.photo?.id || rawMessage.id}`
388
472
  : `doc_full_${media.document?.id || rawMessage.id}`;
package/src/ui/app.js CHANGED
@@ -13,17 +13,19 @@ 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";
18
19
  import { fetchDialogs } from "../telegram/dialogs.js";
19
20
  import { setMessagePalette } from "../telegram/formatter.js";
20
- import { fetchHistory, sendMessage, editMessage, deleteMessages, sendFiles, downloadMedia, sendReaction, markAsRead, loadMessageImagePreview, downloadImageBuffer, renderMessageThumbnail } from "../telegram/messages.js";
21
+ import { fetchHistory, sendMessage, editMessage, deleteMessages, sendFiles, downloadMedia, sendReaction, markAsRead, loadMessageImagePreview, downloadImageBuffer, renderMessageThumbnail, findFirstUnreadMessage, calculateRemainingUnreadCount } from "../telegram/messages.js";
21
22
  import { startTelegramListener } from "../telegram/listener.js";
22
23
  import { logout } from "../telegram/auth.js";
23
24
  import { ensureDir, inspectLocalFile } from "../utils/storage.js";
24
25
  import { formatFileSize } from "../utils/time.js";
25
26
  import { parseSendFileArgs } from "../utils/commands.js";
26
27
  import { isRightClick } from "../utils/mouse.js";
28
+ import { renderMediaPreloader } from "../utils/image.js";
27
29
 
28
30
  /**
29
31
  * Запускает полноэкранный TUI-клиент Telegram.
@@ -38,7 +40,10 @@ export async function startTui(client, me) {
38
40
  let listener = null;
39
41
  const screen = createScreen({
40
42
  theme,
41
- onExit: () => listener?.stop(),
43
+ onExit: () => {
44
+ flushPendingMarkAsRead();
45
+ listener?.stop();
46
+ },
42
47
  });
43
48
 
44
49
  state.me = me;
@@ -112,6 +117,7 @@ export async function startTui(client, me) {
112
117
  onQuit: () => {
113
118
  releaseInputs();
114
119
  confirmModal.ask("Выйти из TuiGram?", () => {
120
+ flushPendingMarkAsRead();
115
121
  listener?.stop();
116
122
  screen.destroy();
117
123
  process.exit(0);
@@ -129,11 +135,62 @@ export async function startTui(client, me) {
129
135
 
130
136
  const imageViewerModal = createImageViewerModal(screen, theme, {
131
137
  onLoadFullImage: (msg) => downloadImageBuffer(client, msg.rawMessage),
132
- // Пока качается оригинал, показываем встроенную в сообщение миниатюру
138
+ // Пока качается оригинал, показываем встроенную в сообщение миниатюру или прелоадер
139
+ onRenderPlaceholder: (msg, size) => renderMessageThumbnail(msg.rawMessage, {
140
+ maxWidth: size.maxWidth,
141
+ maxHeight: size.maxHeight,
142
+ useCache: false,
143
+ }) || renderMediaPreloader(msg.rawMessage, {
144
+ maxWidth: size.maxWidth,
145
+ maxHeight: size.maxHeight,
146
+ palette: {
147
+ bg: theme.surface,
148
+ border: theme.borders.fg,
149
+ fg: theme.fg,
150
+ accent: theme.accent,
151
+ dim: theme.dim,
152
+ },
153
+ }),
154
+ });
155
+
156
+ const videoPlayerModal = createVideoPlayerModal(screen, theme, {
157
+ onLoadVideoFile: async (msg, progressCallback) => {
158
+ const downloadsDir = config.downloadsDir;
159
+ ensureDir(downloadsDir);
160
+ let fileName = `video_${msg.id}.mp4`;
161
+ const doc = msg.rawMessage?.media?.document;
162
+ if (doc?.attributes) {
163
+ for (const attr of doc.attributes) {
164
+ if (attr.className === "DocumentAttributeFilename" && attr.fileName) {
165
+ fileName = `msg_${msg.id}_${attr.fileName}`;
166
+ break;
167
+ }
168
+ }
169
+ }
170
+ const targetPath = path.join(downloadsDir, fileName);
171
+ if (fs.existsSync(targetPath) && fs.statSync(targetPath).size > 0) {
172
+ return targetPath;
173
+ }
174
+ const res = await downloadMedia(client, msg.rawMessage, {
175
+ outputFile: targetPath,
176
+ progressCallback,
177
+ });
178
+ return typeof res === "string" ? res : targetPath;
179
+ },
133
180
  onRenderPlaceholder: (msg, size) => renderMessageThumbnail(msg.rawMessage, {
134
181
  maxWidth: size.maxWidth,
135
182
  maxHeight: size.maxHeight,
136
183
  useCache: false,
184
+ }) || renderMediaPreloader(msg.rawMessage, {
185
+ maxWidth: size.maxWidth,
186
+ maxHeight: size.maxHeight,
187
+ palette: {
188
+ bg: theme.surface,
189
+ border: theme.borders.fg,
190
+ fg: theme.fg,
191
+ accent: theme.accent,
192
+ dim: theme.dim,
193
+ },
137
194
  }),
138
195
  });
139
196
 
@@ -143,6 +200,10 @@ export async function startTui(client, me) {
143
200
  const peerId = state.activeChat.peerId;
144
201
 
145
202
  switch (actionId) {
203
+ case "play_video":
204
+ releaseInputs();
205
+ videoPlayerModal.play(msg);
206
+ break;
146
207
  case "reply":
147
208
  inputBox.setContext("reply", msg);
148
209
  break;
@@ -200,20 +261,62 @@ export async function startTui(client, me) {
200
261
  },
201
262
  });
202
263
 
264
+ let readDebounceTimer = null;
265
+ let pendingReadMaxId = 0;
266
+ let pendingReadPeerId = null;
267
+
268
+ /**
269
+ * Отправляет подтверждение прочтения на сервер с дебаунсом, чтобы не спамить сеть при скролле.
270
+ * @param {string|number|bigint} peerId
271
+ * @param {number} maxId
272
+ */
273
+ function debouncedMarkAsRead(peerId, maxId) {
274
+ pendingReadPeerId = peerId;
275
+ if (maxId > pendingReadMaxId) {
276
+ pendingReadMaxId = maxId;
277
+ }
278
+ if (readDebounceTimer) clearTimeout(readDebounceTimer);
279
+ readDebounceTimer = setTimeout(() => {
280
+ flushPendingMarkAsRead();
281
+ }, 300);
282
+ }
283
+
284
+ /** Сбрасывает накопленный маркер прочтения на сервер без задержки. */
285
+ function flushPendingMarkAsRead() {
286
+ if (readDebounceTimer) {
287
+ clearTimeout(readDebounceTimer);
288
+ readDebounceTimer = null;
289
+ }
290
+ if (pendingReadPeerId && pendingReadMaxId > 0) {
291
+ const peer = pendingReadPeerId;
292
+ const id = pendingReadMaxId;
293
+ pendingReadPeerId = null;
294
+ pendingReadMaxId = 0;
295
+ markAsRead(client, peer, id).catch(() => {});
296
+ }
297
+ }
298
+
203
299
  // 2. Список диалогов (левая панель)
204
300
  const chatList = createChatList(screen, theme, {
205
301
  onSelectDialog: async (dialog) => {
302
+ flushPendingMarkAsRead();
206
303
  state.setActiveChat(dialog);
304
+ chatView.resetReadState(dialog.readInboxMaxId || 0);
207
305
  statusBar.showMessage(`Загрузка сообщений: ${dialog.title}...`, "info");
208
306
 
209
307
  try {
210
- const history = await fetchHistory(client, dialog.peerId, { limit: 50 });
211
- state.setMessages(dialog.id, history.messages);
212
- markAsRead(client, dialog.peerId).catch(() => {});
308
+ // Загружаем всю пачку непрочитанных сообщений + запас из 20 прочитанных
309
+ // для контекста и правильного позиционирования разделителя
310
+ const limit = Math.max(50, Math.min(1000, (dialog.unreadCount || 0) + 20));
311
+ const history = await fetchHistory(client, dialog.peerId, { limit });
312
+ if (state.activeChat?.id !== dialog.id) return;
313
+ const firstUnread = findFirstUnreadMessage(history.messages, dialog);
314
+ state.setMessages(dialog.id, history.messages, { firstUnreadId: firstUnread?.id || null });
213
315
  statusBar.showMessage(`Чат: ${dialog.title}`, "info");
214
- fetchMissingImagePreviews(history.messages, dialog.id);
215
316
  } catch (err) {
216
- statusBar.showMessage(`Ошибка загрузки: ${err.message}`, "error");
317
+ if (state.activeChat?.id === dialog.id) {
318
+ statusBar.showMessage(`Ошибка загрузки: ${err.message}`, "error");
319
+ }
217
320
  }
218
321
  },
219
322
  onTabChange: (tab) => {
@@ -241,7 +344,6 @@ export async function startTui(client, me) {
241
344
  if (older.messages.length > 0) {
242
345
  state.setMessages(state.activeChat.id, older.messages, true);
243
346
  statusBar.showMessage(`Загружено ${older.messages.length} предыдущих сообщений`, "info");
244
- fetchMissingImagePreviews(older.messages, state.activeChat.id);
245
347
  }
246
348
  } catch (err) {
247
349
  statusBar.showMessage(`Ошибка пагинации: ${err.message}`, "error");
@@ -263,7 +365,24 @@ export async function startTui(client, me) {
263
365
  releaseInputs();
264
366
  imageViewerModal.show(msg);
265
367
  },
368
+ onPlayVideo: (msg) => {
369
+ releaseInputs();
370
+ videoPlayerModal.play(msg);
371
+ },
266
372
  onFocusRequest: () => releaseInputs(),
373
+ onMessagesRead: (maxVisibleId) => {
374
+ if (!state.activeChat) return;
375
+ const chatId = state.activeChat.id;
376
+ const dialog = state.dialogs.find((d) => d.id === chatId);
377
+ const messages = state.getMessages(chatId);
378
+ const remainingCount = calculateRemainingUnreadCount(messages, maxVisibleId, dialog?.unreadCount);
379
+ state.updateDialogUnread(chatId, remainingCount, maxVisibleId);
380
+ debouncedMarkAsRead(state.activeChat.peerId, maxVisibleId);
381
+ },
382
+ onVisibleMessagesChanged: (visibleMsgs) => {
383
+ if (!state.activeChat) return;
384
+ fetchMissingImagePreviews(visibleMsgs, state.activeChat.id);
385
+ },
267
386
  });
268
387
 
269
388
  // 4. Поле ввода (нижняя панель)
@@ -411,6 +530,7 @@ export async function startTui(client, me) {
411
530
  });
412
531
 
413
532
  state.on("active_chat_changed", (chat) => {
533
+ activePreviewLoads.clear();
414
534
  header.updateInfo({
415
535
  me: state.me,
416
536
  status: state.connectionStatus,
@@ -419,16 +539,29 @@ export async function startTui(client, me) {
419
539
  });
420
540
  const msgs = chat ? state.getMessages(chat.id) : [];
421
541
  chatView.setSelected(null);
422
- chatView.setMessages(msgs);
542
+ if (chat && msgs.length > 0) {
543
+ const firstUnread = findFirstUnreadMessage(msgs, chat);
544
+ if (firstUnread) {
545
+ chatView.setMessages(msgs, { firstUnreadId: firstUnread.id, autoScrollToBottom: false });
546
+ } else {
547
+ chatView.setMessages(msgs, true);
548
+ }
549
+ } else {
550
+ chatView.setMessages([], false);
551
+ }
423
552
  });
424
553
 
425
- state.on("messages_updated", ({ chatId, messages, isPrepend, isUpdate, isNewMessage }) => {
554
+ state.on("messages_updated", ({ chatId, messages, isPrepend, isUpdate, isNewMessage, firstUnreadId }) => {
426
555
  if (state.activeChat?.id === chatId) {
427
- // Скроллим в самый низ только если это новое сообщение или первая загрузка чата.
428
- // При подгрузке старых сообщений (isPrepend) или фоновых обновлениях (isUpdate)
429
- // позиция скролла сохраняется!
430
- const shouldScrollToBottom = isNewMessage ? config.autoScroll : (!isPrepend && !isUpdate);
431
- chatView.setMessages(messages, shouldScrollToBottom);
556
+ if (firstUnreadId) {
557
+ chatView.setMessages(messages, { firstUnreadId, autoScrollToBottom: false });
558
+ } else {
559
+ // Скроллим в самый низ только если это новое сообщение или первая загрузка чата.
560
+ // При подгрузке старых сообщений (isPrepend) или фоновых обновлениях (isUpdate)
561
+ // позиция скролла сохраняется!
562
+ const shouldScrollToBottom = isNewMessage ? config.autoScroll : (!isPrepend && !isUpdate);
563
+ chatView.setMessages(messages, shouldScrollToBottom);
564
+ }
432
565
  }
433
566
  });
434
567
 
@@ -451,30 +584,63 @@ export async function startTui(client, me) {
451
584
  }
452
585
  });
453
586
 
587
+ let previewBatchTimer = null;
588
+ let pendingBatchChatId = null;
589
+
590
+ /**
591
+ * Планирует пакетное обновление интерфейса после догрузки группы превью.
592
+ * @param {string} chatId
593
+ */
594
+ function scheduleBatchPreviewUpdate(chatId) {
595
+ if (state.activeChat?.id !== chatId) return;
596
+ pendingBatchChatId = chatId;
597
+ if (previewBatchTimer) return;
598
+ previewBatchTimer = setTimeout(() => {
599
+ previewBatchTimer = null;
600
+ if (state.activeChat?.id === pendingBatchChatId) {
601
+ const current = state.getMessages(pendingBatchChatId);
602
+ state.emit("messages_updated", {
603
+ chatId: pendingBatchChatId,
604
+ messages: current,
605
+ isPrepend: false,
606
+ isUpdate: true,
607
+ });
608
+ }
609
+ pendingBatchChatId = null;
610
+ }, 150);
611
+ }
612
+
613
+ /** Множество ID сообщений, для которых прямо сейчас выполняется загрузка превью. */
614
+ const activePreviewLoads = new Set();
615
+
454
616
  /**
455
- * Фоново догружает превью изображений для сообщений, не имевших встроенного PhotoStrippedSize.
617
+ * Фоново догружает превью изображений только для тех сообщений, которые видны на экране (Lazy Loading).
618
+ * Обновления группируются пакетами, предотвращая фризы и блокировку основного потока.
456
619
  * @param {Array<object>} messages
457
620
  * @param {string} chatId
458
621
  */
459
622
  async function fetchMissingImagePreviews(messages, chatId) {
460
623
  if (!config.showImages || !messages || messages.length === 0) return;
461
- for (const msg of messages) {
624
+ const toFetch = messages.filter((m) => m.isPreviewLoading && m.rawMessage && !activePreviewLoads.has(m.id));
625
+ if (toFetch.length === 0) return;
626
+
627
+ for (const msg of toFetch) {
462
628
  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
- }
629
+ activePreviewLoads.add(msg.id);
630
+ try {
631
+ const preview = await loadMessageImagePreview(client, msg.rawMessage);
632
+ if (preview && state.activeChat?.id === chatId) {
633
+ msg.imagePreview = preview;
634
+ msg.isPreviewLoading = false;
635
+ scheduleBatchPreviewUpdate(chatId);
476
636
  }
637
+ } catch {
638
+ // Игнорируем сетевые ошибки фоновой загрузки превью
639
+ } finally {
640
+ activePreviewLoads.delete(msg.id);
477
641
  }
642
+ // Даём event loop обработать пользовательский ввод и скролл
643
+ await new Promise((resolve) => setImmediate(resolve));
478
644
  }
479
645
  }
480
646
 
@@ -483,13 +649,11 @@ export async function startTui(client, me) {
483
649
 
484
650
  listener.on("new_message", ({ peerId, message }) => {
485
651
  state.addMessage(peerId, message);
486
- if (!message.imagePreview && (message.media?.className === "MessageMediaPhoto" || message.media?.className === "MessageMediaDocument")) {
652
+ if (message.isPreviewLoading) {
487
653
  fetchMissingImagePreviews([message], peerId);
488
654
  }
489
655
 
490
- if (state.activeChat?.id === peerId) {
491
- markAsRead(client, state.activeChat.peerId).catch(() => {});
492
- } else if (!message.out) {
656
+ if (state.activeChat?.id !== peerId && !message.out) {
493
657
  const sender = message.senderName || "Новое сообщение";
494
658
  const preview = (message.text || "Вложение").slice(0, 30);
495
659
  statusBar.showMessage(`💬 ${sender}: "${preview}"`, "info", 5000);
@@ -730,6 +894,7 @@ export async function startTui(client, me) {
730
894
  screen.key(["C-q"], () => {
731
895
  releaseInputs();
732
896
  confirmModal.ask("Выйти из TuiGram?", () => {
897
+ flushPendingMarkAsRead();
733
898
  listener?.stop();
734
899
  screen.destroy();
735
900
  process.exit(0);