@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/state.js CHANGED
@@ -45,7 +45,7 @@ class AppState extends EventEmitter {
45
45
  * @returns {Array<object>}
46
46
  */
47
47
  getVisibleDialogs() {
48
- const tabFiltered = filterDialogsByTab(this.dialogs, this.currentFilterTab);
48
+ const tabFiltered = filterDialogsByTab(this.dialogs, this.currentFilterTab, this.activeChat?.id);
49
49
  if (!this.searchQuery) return tabFiltered;
50
50
  return searchDialogs(tabFiltered, this.searchQuery);
51
51
  }
@@ -97,13 +97,34 @@ class AppState extends EventEmitter {
97
97
  this.replyTarget = null;
98
98
  this.editTarget = null;
99
99
  this.selectedMessageIndex = -1;
100
- if (dialog) {
101
- dialog.unreadCount = 0;
102
- dialog.unreadMentionsCount = 0;
103
- }
104
100
  this.emit("active_chat_changed", dialog);
105
101
  }
106
102
 
103
+ /**
104
+ * Обновляет счётчик непрочитанных сообщений и максимальный прочитанный ID диалога.
105
+ * @param {string} chatId
106
+ * @param {number} unreadCount
107
+ * @param {number} [readInboxMaxId]
108
+ */
109
+ updateDialogUnread(chatId, unreadCount, readInboxMaxId) {
110
+ const dialog = this.dialogs.find((d) => d.id === chatId);
111
+ if (!dialog) return;
112
+
113
+ const countChanged = dialog.unreadCount !== unreadCount;
114
+ const idChanged = typeof readInboxMaxId === "number" && readInboxMaxId > (dialog.readInboxMaxId || 0);
115
+
116
+ if (countChanged || idChanged) {
117
+ dialog.unreadCount = Math.max(0, unreadCount);
118
+ if (dialog.unreadCount === 0) {
119
+ dialog.unreadMentionsCount = 0;
120
+ }
121
+ if (idChanged) {
122
+ dialog.readInboxMaxId = readInboxMaxId;
123
+ }
124
+ this.emit("dialogs_updated", this.getVisibleDialogs());
125
+ }
126
+ }
127
+
107
128
  /**
108
129
  * Получает список сообщений для указанного чата.
109
130
  * @param {string} chatId
@@ -117,9 +138,11 @@ class AppState extends EventEmitter {
117
138
  * Устанавливает или дополняет список сообщений для чата.
118
139
  * @param {string} chatId
119
140
  * @param {Array<object>} newMessages
120
- * @param {boolean} [prepend=false] Если true — добавляет старые сообщения в начало
141
+ * @param {boolean|object} [options=false] Если true — добавляет старые сообщения в начало
121
142
  */
122
- setMessages(chatId, newMessages, prepend = false) {
143
+ setMessages(chatId, newMessages, options = false) {
144
+ const prepend = typeof options === "boolean" ? options : Boolean(options?.prepend);
145
+ const firstUnreadId = typeof options === "object" ? (options?.firstUnreadId || null) : null;
123
146
  const existing = this.messagesByChat.get(chatId) || [];
124
147
  let combined = [];
125
148
 
@@ -140,7 +163,7 @@ class AppState extends EventEmitter {
140
163
  combined.sort((a, b) => (a.date || 0) - (b.date || 0) || (a.id - b.id));
141
164
 
142
165
  this.messagesByChat.set(chatId, combined);
143
- this.emit("messages_updated", { chatId, messages: combined, isPrepend: prepend, isUpdate: false });
166
+ this.emit("messages_updated", { chatId, messages: combined, isPrepend: prepend, isUpdate: false, firstUnreadId });
144
167
  }
145
168
 
146
169
  /**
@@ -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":
@@ -49,6 +49,12 @@ export function toMarkedId(peer) {
49
49
  * @returns {string|bigint}
50
50
  */
51
51
  export function parsePeer(raw) {
52
+ if (raw && typeof raw === "object") {
53
+ const marked = toMarkedId(raw);
54
+ if (marked && marked !== "[object Object]") {
55
+ return parsePeer(marked);
56
+ }
57
+ }
52
58
  const value = String(raw || "").trim();
53
59
  if (!value) throw new Error("Не указан идентификатор чата (peer)");
54
60
  if (value === "me" || value === "self") return "me";
@@ -106,18 +112,67 @@ class EntityCache {
106
112
  }
107
113
 
108
114
  set(id, entity) {
109
- if (!id || !entity) return;
110
- const key = idToString(id);
111
- this.cache.set(key, entity);
115
+ if (!entity) return;
116
+ if (id !== null && id !== undefined) {
117
+ const key = idToString(id).trim().toLowerCase();
118
+ if (key) this.cache.set(key, entity);
119
+ }
120
+ if (entity.id !== null && entity.id !== undefined) {
121
+ const rawId = idToString(entity.id).trim().toLowerCase();
122
+ if (rawId) {
123
+ this.cache.set(rawId, entity);
124
+ if (entity.className === "Channel" || entity.broadcast || entity.megagroup) {
125
+ this.cache.set(`-100${rawId}`, entity);
126
+ } else if (entity.className === "Chat") {
127
+ this.cache.set(`-${rawId}`, entity);
128
+ }
129
+ }
130
+ }
131
+ const marked = toMarkedId(entity);
132
+ if (marked && marked !== "[object Object]") {
133
+ this.cache.set(marked.toLowerCase(), entity);
134
+ }
112
135
  if (entity.username) {
113
- this.cache.set(`@${entity.username.toLowerCase()}`, entity);
136
+ const u = entity.username.toLowerCase();
137
+ this.cache.set(`@${u}`, entity);
138
+ this.cache.set(u, entity);
114
139
  }
115
140
  }
116
141
 
117
142
  get(idOrUsername) {
118
- if (!idOrUsername) return null;
119
- const key = String(idOrUsername).toLowerCase();
120
- return this.cache.get(key) || this.cache.get(idToString(idOrUsername)) || null;
143
+ if (idOrUsername === null || idOrUsername === undefined) return null;
144
+ let key = "";
145
+ if (typeof idOrUsername === "object") {
146
+ const marked = toMarkedId(idOrUsername);
147
+ key = (marked && marked !== "[object Object]" ? marked : idToString(idOrUsername)).trim().toLowerCase();
148
+ } else {
149
+ key = String(idOrUsername).trim().toLowerCase();
150
+ }
151
+ if (!key) return null;
152
+
153
+ let found = this.cache.get(key);
154
+ if (found) return found;
155
+
156
+ if (key.startsWith("-100")) {
157
+ found = this.cache.get(key.slice(4));
158
+ if (found) return found;
159
+ } else if (key.startsWith("-")) {
160
+ found = this.cache.get(key.slice(1));
161
+ if (found) return found;
162
+ } else if (/^\d+$/.test(key)) {
163
+ found = this.cache.get(`-100${key}`) || this.cache.get(`-${key}`);
164
+ if (found) return found;
165
+ }
166
+
167
+ if (key.startsWith("@")) {
168
+ found = this.cache.get(key.slice(1));
169
+ if (found) return found;
170
+ } else {
171
+ found = this.cache.get(`@${key}`);
172
+ if (found) return found;
173
+ }
174
+
175
+ return null;
121
176
  }
122
177
 
123
178
  has(idOrUsername) {
@@ -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 { EventEmitter } from "node:events";
2
2
  import { Api } from "teleproto";
3
3
  import { NewMessage, EditedMessage, DeletedMessage, Raw } from "teleproto/events/index.js";
4
4
  import { normalizeMessage } from "./messages.js";
5
- import { idToString, toMarkedId, entityCache } from "./entities.js";
5
+ import { idToString, toMarkedId, entityCache, getEntityDisplayName } from "./entities.js";
6
6
 
7
7
  /**
8
8
  * Приводит идентификатор чата из сырого апдейта к маркированному виду (как dialog.id).
@@ -36,21 +36,39 @@ export function startTelegramListener(client) {
36
36
  try {
37
37
  const msg = event.message;
38
38
  if (!msg) return;
39
- const normalized = normalizeMessage(msg);
40
- const peerId = normalized.peerId;
41
- const fromId = normalized.fromId;
42
39
 
43
- // Кэшируем информацию об авторе сообщения, если доступна
44
- try {
45
- const sender = await event.getSender?.();
46
- if (sender) {
47
- entityCache.set(sender.id, sender);
48
- normalized.senderName = sender.title || [sender.firstName, sender.lastName].filter(Boolean).join(" ") || normalized.senderName;
40
+ // Кэшируем сущности события, если они пришли в апдейте
41
+ if (event.originalUpdate?._entities && typeof event.originalUpdate._entities.values === "function") {
42
+ for (const ent of event.originalUpdate._entities.values()) {
43
+ if (ent?.id) entityCache.set(ent.id, ent);
49
44
  }
45
+ }
46
+
47
+ let sender = null;
48
+ try {
49
+ sender = await event.getSender?.();
50
+ if (sender) entityCache.set(sender.id, sender);
51
+ } catch {
52
+ // Игнорируем
53
+ }
54
+
55
+ let chat = null;
56
+ try {
57
+ chat = await event.getChat?.();
58
+ if (chat) entityCache.set(chat.id, chat);
50
59
  } catch {
51
60
  // Игнорируем
52
61
  }
53
62
 
63
+ const normalized = normalizeMessage(msg, chat || sender);
64
+ if (sender && (!normalized.senderName || normalized.senderName === "Собеседник")) {
65
+ const baseName = getEntityDisplayName(sender);
66
+ normalized.senderName = msg.postAuthor ? `${baseName} (${msg.postAuthor})` : baseName;
67
+ }
68
+
69
+ const peerId = normalized.peerId;
70
+ const fromId = normalized.fromId;
71
+
54
72
  bus.emit("new_message", {
55
73
  peerId,
56
74
  fromId,
@@ -67,7 +85,35 @@ export function startTelegramListener(client) {
67
85
  try {
68
86
  const msg = event.message;
69
87
  if (!msg) return;
70
- const normalized = normalizeMessage(msg);
88
+
89
+ if (event.originalUpdate?._entities && typeof event.originalUpdate._entities.values === "function") {
90
+ for (const ent of event.originalUpdate._entities.values()) {
91
+ if (ent?.id) entityCache.set(ent.id, ent);
92
+ }
93
+ }
94
+
95
+ let sender = null;
96
+ try {
97
+ sender = await event.getSender?.();
98
+ if (sender) entityCache.set(sender.id, sender);
99
+ } catch {
100
+ // Игнорируем
101
+ }
102
+
103
+ let chat = null;
104
+ try {
105
+ chat = await event.getChat?.();
106
+ if (chat) entityCache.set(chat.id, chat);
107
+ } catch {
108
+ // Игнорируем
109
+ }
110
+
111
+ const normalized = normalizeMessage(msg, chat || sender);
112
+ if (sender && (!normalized.senderName || normalized.senderName === "Собеседник")) {
113
+ const baseName = getEntityDisplayName(sender);
114
+ normalized.senderName = msg.postAuthor ? `${baseName} (${msg.postAuthor})` : baseName;
115
+ }
116
+
71
117
  bus.emit("edited_message", {
72
118
  peerId: normalized.peerId,
73
119
  message: normalized,
@@ -1,8 +1,8 @@
1
1
  import { Api, errors } from "teleproto";
2
- import { idToString, toMarkedId, getEntityDisplayName, entityCache, resolveEntity } from "./entities.js";
2
+ import { idToString, toMarkedId, detectChatType, 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
 
@@ -33,31 +33,77 @@ export function renderMessageThumbnail(rawMessage, {
33
33
 
34
34
  // Полноэкранные рендеры не кэшируем: одна такая строка весит сотни килобайт
35
35
  const cacheKey = useCache
36
- ? (photo?.id ? `photo_${photo.id}` : (doc?.id ? `doc_${doc.id}` : `msg_${rawMessage.id}`))
37
- : undefined;
36
+ ? (rawMessage.id ? `thumb_${rawMessage.id}@${maxWidth}x${maxHeight}` : null)
37
+ : null;
38
38
 
39
- return renderStrippedThumbnail(stripped.bytes, { maxWidth, maxHeight, cacheKey }) || "";
39
+ return renderStrippedThumbnail(stripped.bytes, { maxWidth, maxHeight, cacheKey });
40
40
  }
41
41
 
42
42
  /**
43
43
  * Преобразует объект Message из MTProto в нормализованный объект для TUI.
44
44
  * @param {object} message
45
+ * @param {object|null} [chatEntity=null] сущность чата (канал, пользователь, группа)
45
46
  * @returns {object}
46
47
  */
47
- export function normalizeMessage(message) {
48
+ export function normalizeMessage(message, chatEntity = null) {
48
49
  if (!message) return null;
49
50
 
50
51
  // fromId остаётся немаркированным: по нему ищется сущность в entityCache,
51
52
  // куда объекты кладутся по entity.id (тоже без маркера).
52
- const fromId = idToString(message.fromId?.userId || message.fromId?.channelId || message.fromId?.chatId);
53
+ const fromId = idToString(message.fromId?.userId || message.fromId?.channelId || message.fromId?.chatId || message.senderId);
53
54
  // peerId маркируется, чтобы совпадать с dialog.id (см. toMarkedId).
54
55
  const peerId = toMarkedId(message.peerId);
55
56
 
56
- // Извлекаем имя отправителя из кэша сущностей, если доступно
57
- let senderName = message.out ? "Вы" : "Собеседник";
58
- const cachedSender = entityCache.get(fromId);
59
- if (cachedSender) {
60
- senderName = getEntityDisplayName(cachedSender);
57
+ const postAuthor = message.postAuthor ? String(message.postAuthor).trim() : null;
58
+ const isPost = Boolean(
59
+ message.post ||
60
+ chatEntity?.broadcast ||
61
+ (chatEntity && detectChatType({ entity: chatEntity }) === "channel")
62
+ );
63
+
64
+ // Ищем сущность отправителя:
65
+ // 1. Уже прикреплённый teleproto sender
66
+ // 2. Вложенный кэш сущностей сообщения message._entities
67
+ // 3. Глобальный entityCache
68
+ let senderEntity = message.sender || null;
69
+ if (!senderEntity && message._entities && typeof message._entities.get === "function") {
70
+ senderEntity = (fromId && message._entities.get(fromId)) ||
71
+ (message.senderId && message._entities.get(idToString(message.senderId))) ||
72
+ (message.fromId && message._entities.get(toMarkedId(message.fromId))) ||
73
+ null;
74
+ }
75
+ if (!senderEntity) {
76
+ senderEntity = (fromId && entityCache.get(fromId)) ||
77
+ (message.senderId && entityCache.get(message.senderId)) ||
78
+ (message.fromId && entityCache.get(message.fromId)) ||
79
+ null;
80
+ }
81
+ if (senderEntity && senderEntity.id) {
82
+ entityCache.set(senderEntity.id, senderEntity);
83
+ }
84
+
85
+ let senderName = "";
86
+ if (isPost) {
87
+ // В вещательных каналах автор — сам канал (или подпись автора)
88
+ const channelEntity = senderEntity || chatEntity || message.chat || entityCache.get(peerId) || null;
89
+ const channelTitle = channelEntity ? getEntityDisplayName(channelEntity) : (chatEntity?.title || "");
90
+ if (postAuthor) {
91
+ senderName = channelTitle ? `${channelTitle} (${postAuthor})` : postAuthor;
92
+ } else {
93
+ senderName = channelTitle || "Канал";
94
+ }
95
+ } else if (message.out) {
96
+ senderName = "Вы";
97
+ } else if (senderEntity) {
98
+ const baseName = getEntityDisplayName(senderEntity);
99
+ senderName = postAuthor ? `${baseName} (${postAuthor})` : baseName;
100
+ } else if (chatEntity && (chatEntity.className === "User" || detectChatType({ entity: chatEntity }) === "user")) {
101
+ // В личном диалоге (1-на-1) входящее сообщение всегда от собеседника чата
102
+ senderName = getEntityDisplayName(chatEntity);
103
+ } else if (postAuthor) {
104
+ senderName = postAuthor;
105
+ } else {
106
+ senderName = "Собеседник";
61
107
  }
62
108
 
63
109
  // Реакции
@@ -73,16 +119,40 @@ export function normalizeMessage(message) {
73
119
  }
74
120
  }
75
121
 
76
- // Извлечение и рендеринг PhotoStrippedSize в псевдографику
77
- const imagePreview = config.showImages
78
- ? (renderMessageThumbnail(message) || null)
79
- : null;
122
+ // Извлечение и рендеринг PhotoStrippedSize в псевдографику или прелоадера
123
+ let imagePreview = null;
124
+ let isPreviewLoading = false;
125
+
126
+ if (config.showImages && isPreviewableMedia(message)) {
127
+ const cached = getCachedImagePreview(message, {
128
+ maxWidth: config.imageMaxWidth,
129
+ maxHeight: config.imageMaxHeight,
130
+ });
131
+ if (cached) {
132
+ imagePreview = cached;
133
+ isPreviewLoading = false;
134
+ } else {
135
+ const strippedThumb = renderMessageThumbnail(message);
136
+ if (strippedThumb) {
137
+ imagePreview = strippedThumb;
138
+ isPreviewLoading = false;
139
+ } else {
140
+ imagePreview = renderMediaPreloader(message, {
141
+ maxWidth: config.imageMaxWidth,
142
+ maxHeight: config.imageMaxHeight,
143
+ });
144
+ isPreviewLoading = true;
145
+ }
146
+ }
147
+ }
80
148
 
81
149
  return {
82
150
  id: message.id,
83
151
  date: message.date ? message.date * 1000 : Date.now(),
84
152
  editDate: message.editDate ? message.editDate * 1000 : null,
85
153
  out: Boolean(message.out),
154
+ post: isPost,
155
+ postAuthor,
86
156
  text: message.message || "",
87
157
  fromId,
88
158
  senderName,
@@ -94,6 +164,7 @@ export function normalizeMessage(message) {
94
164
  media: message.media || null,
95
165
  mediaDescription: describeMedia(message.media),
96
166
  imagePreview,
167
+ isPreviewLoading,
97
168
  entities: message.entities || [],
98
169
  reactions,
99
170
  rawMessage: message,
@@ -117,9 +188,20 @@ export async function fetchHistory(client, rawPeer, { limit = 40, offsetId = 0,
117
188
  const load = async () => {
118
189
  for await (const msg of client.iterMessages(entity, { limit, offsetId, reverse })) {
119
190
  if (msg && msg.className !== "MessageEmpty") {
120
- messages.push(normalizeMessage(msg));
191
+ // Сохраняем сущности, которые Telegram прислал вместе с сообщениями
192
+ if (msg._entities && typeof msg._entities.values === "function") {
193
+ for (const ent of msg._entities.values()) {
194
+ if (ent?.id) {
195
+ entityCache.set(ent.id, ent);
196
+ }
197
+ }
198
+ }
199
+ messages.push(normalizeMessage(msg, entity));
121
200
  }
122
201
  }
202
+ if (!reverse) {
203
+ messages.reverse();
204
+ }
123
205
  };
124
206
 
125
207
  try {
@@ -135,6 +217,41 @@ export async function fetchHistory(client, rawPeer, { limit = 40, offsetId = 0,
135
217
  }
136
218
  }
137
219
 
220
+ // Резолвим отправителей, которых ещё нет в кэше сущностей,
221
+ // чтобы в диалоге отображались реальные имена, а не «Собеседник».
222
+ for (const msg of messages) {
223
+ if (!msg.out && (!msg.senderName || msg.senderName === "Собеседник")) {
224
+ // 1. Попытка через rawMessage.getSender()
225
+ if (msg.rawMessage?.getSender) {
226
+ try {
227
+ const sender = await msg.rawMessage.getSender();
228
+ if (sender) {
229
+ entityCache.set(sender.id, sender);
230
+ const baseName = getEntityDisplayName(sender);
231
+ msg.senderName = msg.postAuthor ? `${baseName} (${msg.postAuthor})` : baseName;
232
+ continue;
233
+ }
234
+ } catch {
235
+ // Игнорируем
236
+ }
237
+ }
238
+
239
+ // 2. Попытка через resolveEntity по targetPeer
240
+ const targetPeer = msg.rawMessage?.fromId || msg.fromId || msg.rawMessage?.peerId || msg.peerId;
241
+ if (targetPeer) {
242
+ try {
243
+ const sender = await resolveEntity(client, targetPeer);
244
+ if (sender) {
245
+ const baseName = getEntityDisplayName(sender);
246
+ msg.senderName = msg.postAuthor ? `${baseName} (${msg.postAuthor})` : baseName;
247
+ }
248
+ } catch {
249
+ // Имя не резолвится — останется fallback
250
+ }
251
+ }
252
+ }
253
+ }
254
+
138
255
  return {
139
256
  peer: entity,
140
257
  messages,
@@ -286,6 +403,61 @@ export async function sendReaction(client, rawPeer, messageId, emoji = "👍") {
286
403
  );
287
404
  }
288
405
 
406
+ /**
407
+ * Находит первое непрочитанное входящее сообщение в хронологическом списке сообщений.
408
+ * @param {Array<object>} messages
409
+ * @param {object} [options]
410
+ * @param {number} [options.readInboxMaxId=0]
411
+ * @param {number} [options.unreadCount=0]
412
+ * @returns {object|null}
413
+ */
414
+ export function findFirstUnreadMessage(messages, { readInboxMaxId = 0, unreadCount = 0 } = {}) {
415
+ if (!Array.isArray(messages) || messages.length === 0 || unreadCount === 0) {
416
+ return null;
417
+ }
418
+
419
+ // Всегда сортируем сообщения по возрастанию ID/даты перед поиском
420
+ const sorted = [...messages].sort((a, b) => (a.date || 0) - (b.date || 0) || (a.id - b.id));
421
+
422
+ if (readInboxMaxId > 0) {
423
+ const first = sorted.find((m) => !m.out && m.id > readInboxMaxId);
424
+ if (first) return first;
425
+ }
426
+
427
+ if (unreadCount > 0) {
428
+ const incoming = sorted.filter((m) => !m.out);
429
+ if (incoming.length > 0) {
430
+ const unreadIncoming = incoming.slice(-unreadCount);
431
+ return unreadIncoming[0] || null;
432
+ }
433
+ }
434
+
435
+ return null;
436
+ }
437
+
438
+ /**
439
+ * Вычисляет количество оставшихся непрочитанных сообщений в чате на основе прочитанного maxId.
440
+ * @param {Array<object>} messages
441
+ * @param {number} maxReadId
442
+ * @param {number} [totalUnreadCount]
443
+ * @returns {number}
444
+ */
445
+ export function calculateRemainingUnreadCount(messages, maxReadId = 0, totalUnreadCount) {
446
+ if (!Array.isArray(messages) || messages.length === 0) return 0;
447
+
448
+ const unreadInLoaded = messages.filter((m) => !m.out && m.id > maxReadId).length;
449
+
450
+ if (typeof totalUnreadCount === "number" && totalUnreadCount > 0) {
451
+ const incomingInLoaded = messages.filter((m) => !m.out).length;
452
+ if (totalUnreadCount > incomingInLoaded) {
453
+ const notLoadedCount = totalUnreadCount - incomingInLoaded;
454
+ return notLoadedCount + unreadInLoaded;
455
+ }
456
+ }
457
+
458
+ return unreadInLoaded;
459
+ }
460
+
289
461
  /**
290
462
  * Отмечает сообщения в чате прочитанными.
291
463
  * @param {import("teleproto").TelegramClient} client
@@ -296,7 +468,7 @@ export async function markAsRead(client, rawPeer, maxId = 0) {
296
468
  const entity = await resolveEntity(client, rawPeer);
297
469
  try {
298
470
  if (maxId > 0) {
299
- await client.sendReadAcknowledge(entity, { maxId });
471
+ await client.markAsRead(entity, maxId);
300
472
  } else {
301
473
  await client.markAsRead(entity);
302
474
  }
@@ -383,6 +555,9 @@ export async function loadMessageImagePreview(client, rawMessage, { maxWidth = c
383
555
  const isDoc = media.className === "MessageMediaDocument";
384
556
  if (!isPhoto && !isDoc) return "";
385
557
 
558
+ const cached = getCachedImagePreview(rawMessage, { maxWidth, maxHeight });
559
+ if (cached) return cached;
560
+
386
561
  const cacheKey = isPhoto
387
562
  ? `photo_full_${media.photo?.id || rawMessage.id}`
388
563
  : `doc_full_${media.document?.id || rawMessage.id}`;