@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.
- package/.env.example +15 -0
- package/CHANGELOG.md +21 -1
- package/CHANGELOG.ru.md +21 -1
- package/README.md +47 -2
- package/README.ru.md +47 -2
- package/package.json +2 -1
- 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/formatter.js +1 -1
- package/src/telegram/messages.js +90 -6
- package/src/ui/app.js +200 -35
- package/src/ui/components/chatList.js +156 -100
- package/src/ui/components/chatView.js +259 -78
- package/src/ui/components/inputBox.js +12 -4
- 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/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":
|
|
@@ -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/messages.js
CHANGED
|
@@ -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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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.
|
|
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: () =>
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
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
|
-
* Фоново догружает превью изображений для сообщений,
|
|
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
|
-
|
|
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
|
-
|
|
464
|
-
|
|
465
|
-
const
|
|
466
|
-
if (
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
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 (
|
|
652
|
+
if (message.isPreviewLoading) {
|
|
487
653
|
fetchMissingImagePreviews([message], peerId);
|
|
488
654
|
}
|
|
489
655
|
|
|
490
|
-
if (state.activeChat?.id
|
|
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);
|