@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/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} [
|
|
141
|
+
* @param {boolean|object} [options=false] Если true — добавляет старые сообщения в начало
|
|
121
142
|
*/
|
|
122
|
-
setMessages(chatId, newMessages,
|
|
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
|
/**
|
package/src/telegram/dialogs.js
CHANGED
|
@@ -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":
|
package/src/telegram/entities.js
CHANGED
|
@@ -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 (!
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
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 (
|
|
119
|
-
|
|
120
|
-
|
|
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(
|
|
73
|
+
return String(text).replace(/[{}]/g, (ch) => (ch === "{" ? "{open}" : "{close}"));
|
|
74
74
|
}
|
|
75
75
|
|
|
76
76
|
/**
|
package/src/telegram/listener.js
CHANGED
|
@@ -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
|
-
|
|
45
|
-
const
|
|
46
|
-
|
|
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
|
-
|
|
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,
|
package/src/telegram/messages.js
CHANGED
|
@@ -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
|
-
? (
|
|
37
|
-
:
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
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.
|
|
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}`;
|