@emaxe/tuigram 1.3.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 +63 -1
- package/CHANGELOG.ru.md +67 -1
- package/README.md +96 -15
- package/README.ru.md +97 -15
- 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 +182 -19
- package/src/ui/app.js +293 -73
- package/src/ui/components/chatList.js +159 -101
- package/src/ui/components/chatView.js +454 -109
- package/src/ui/components/header.js +2 -1
- package/src/ui/components/inputBox.js +14 -5
- package/src/ui/components/modals/actionModal.js +11 -14
- package/src/ui/components/modals/chatInfoModal.js +3 -13
- package/src/ui/components/modals/confirmModal.js +3 -13
- package/src/ui/components/modals/fileModal.js +6 -13
- package/src/ui/components/modals/filePickerModal.js +3 -13
- package/src/ui/components/modals/helpModal.js +14 -18
- package/src/ui/components/modals/imageViewerModal.js +172 -0
- package/src/ui/components/modals/videoPlayerModal.js +274 -0
- package/src/ui/components/statusBar.js +2 -1
- package/src/ui/modalMouse.js +43 -0
- package/src/ui/screen.js +168 -20
- package/src/ui/theme.js +1 -0
- package/src/utils/image.js +318 -14
- package/src/utils/mouse.js +62 -75
- package/src/utils/video.js +495 -0
package/src/ui/app.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import fs from "node:fs";
|
|
3
|
-
import { createScreen } from "./screen.js";
|
|
3
|
+
import { createScreen, setMouseCapture } from "./screen.js";
|
|
4
4
|
import { getTheme } from "./theme.js";
|
|
5
5
|
import { createHeader } from "./components/header.js";
|
|
6
6
|
import { createChatList } from "./components/chatList.js";
|
|
@@ -12,16 +12,20 @@ import { createChatInfoModal } from "./components/modals/chatInfoModal.js";
|
|
|
12
12
|
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
|
+
import { createImageViewerModal } from "./components/modals/imageViewerModal.js";
|
|
16
|
+
import { createVideoPlayerModal } from "./components/modals/videoPlayerModal.js";
|
|
15
17
|
import { state } from "../state.js";
|
|
16
18
|
import { config } from "../config.js";
|
|
17
19
|
import { fetchDialogs } from "../telegram/dialogs.js";
|
|
18
20
|
import { setMessagePalette } from "../telegram/formatter.js";
|
|
19
|
-
import { fetchHistory, sendMessage, editMessage, deleteMessages, sendFiles, downloadMedia, sendReaction, markAsRead, loadMessageImagePreview } from "../telegram/messages.js";
|
|
21
|
+
import { fetchHistory, sendMessage, editMessage, deleteMessages, sendFiles, downloadMedia, sendReaction, markAsRead, loadMessageImagePreview, downloadImageBuffer, renderMessageThumbnail, findFirstUnreadMessage, calculateRemainingUnreadCount } from "../telegram/messages.js";
|
|
20
22
|
import { startTelegramListener } from "../telegram/listener.js";
|
|
21
23
|
import { logout } from "../telegram/auth.js";
|
|
22
24
|
import { ensureDir, inspectLocalFile } from "../utils/storage.js";
|
|
23
25
|
import { formatFileSize } from "../utils/time.js";
|
|
24
26
|
import { parseSendFileArgs } from "../utils/commands.js";
|
|
27
|
+
import { isRightClick } from "../utils/mouse.js";
|
|
28
|
+
import { renderMediaPreloader } from "../utils/image.js";
|
|
25
29
|
|
|
26
30
|
/**
|
|
27
31
|
* Запускает полноэкранный TUI-клиент Telegram.
|
|
@@ -36,7 +40,10 @@ export async function startTui(client, me) {
|
|
|
36
40
|
let listener = null;
|
|
37
41
|
const screen = createScreen({
|
|
38
42
|
theme,
|
|
39
|
-
onExit: () =>
|
|
43
|
+
onExit: () => {
|
|
44
|
+
flushPendingMarkAsRead();
|
|
45
|
+
listener?.stop();
|
|
46
|
+
},
|
|
40
47
|
});
|
|
41
48
|
|
|
42
49
|
state.me = me;
|
|
@@ -93,10 +100,10 @@ export async function startTui(client, me) {
|
|
|
93
100
|
statusBar.showMessage("Сначала выберите чат слева!", "warning");
|
|
94
101
|
return;
|
|
95
102
|
}
|
|
96
|
-
const
|
|
97
|
-
if (
|
|
103
|
+
const msg = chatView.getTargetMessage();
|
|
104
|
+
if (msg) {
|
|
98
105
|
releaseInputs();
|
|
99
|
-
actionModal.show(
|
|
106
|
+
actionModal.show(msg);
|
|
100
107
|
}
|
|
101
108
|
},
|
|
102
109
|
onChatInfo: () => {
|
|
@@ -110,6 +117,7 @@ export async function startTui(client, me) {
|
|
|
110
117
|
onQuit: () => {
|
|
111
118
|
releaseInputs();
|
|
112
119
|
confirmModal.ask("Выйти из TuiGram?", () => {
|
|
120
|
+
flushPendingMarkAsRead();
|
|
113
121
|
listener?.stop();
|
|
114
122
|
screen.destroy();
|
|
115
123
|
process.exit(0);
|
|
@@ -125,12 +133,77 @@ export async function startTui(client, me) {
|
|
|
125
133
|
onSendFile: (files, options) => sendFilesToActiveChat(files, options),
|
|
126
134
|
});
|
|
127
135
|
|
|
136
|
+
const imageViewerModal = createImageViewerModal(screen, theme, {
|
|
137
|
+
onLoadFullImage: (msg) => downloadImageBuffer(client, msg.rawMessage),
|
|
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
|
+
},
|
|
180
|
+
onRenderPlaceholder: (msg, size) => renderMessageThumbnail(msg.rawMessage, {
|
|
181
|
+
maxWidth: size.maxWidth,
|
|
182
|
+
maxHeight: size.maxHeight,
|
|
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
|
+
},
|
|
194
|
+
}),
|
|
195
|
+
});
|
|
196
|
+
|
|
128
197
|
const actionModal = createActionModal(screen, theme, {
|
|
129
198
|
onAction: async (actionId, msg) => {
|
|
130
199
|
if (!state.activeChat) return;
|
|
131
200
|
const peerId = state.activeChat.peerId;
|
|
132
201
|
|
|
133
202
|
switch (actionId) {
|
|
203
|
+
case "play_video":
|
|
204
|
+
releaseInputs();
|
|
205
|
+
videoPlayerModal.play(msg);
|
|
206
|
+
break;
|
|
134
207
|
case "reply":
|
|
135
208
|
inputBox.setContext("reply", msg);
|
|
136
209
|
break;
|
|
@@ -188,20 +261,62 @@ export async function startTui(client, me) {
|
|
|
188
261
|
},
|
|
189
262
|
});
|
|
190
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
|
+
|
|
191
299
|
// 2. Список диалогов (левая панель)
|
|
192
300
|
const chatList = createChatList(screen, theme, {
|
|
193
301
|
onSelectDialog: async (dialog) => {
|
|
302
|
+
flushPendingMarkAsRead();
|
|
194
303
|
state.setActiveChat(dialog);
|
|
304
|
+
chatView.resetReadState(dialog.readInboxMaxId || 0);
|
|
195
305
|
statusBar.showMessage(`Загрузка сообщений: ${dialog.title}...`, "info");
|
|
196
306
|
|
|
197
307
|
try {
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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 });
|
|
201
315
|
statusBar.showMessage(`Чат: ${dialog.title}`, "info");
|
|
202
|
-
fetchMissingImagePreviews(history.messages, dialog.id);
|
|
203
316
|
} catch (err) {
|
|
204
|
-
|
|
317
|
+
if (state.activeChat?.id === dialog.id) {
|
|
318
|
+
statusBar.showMessage(`Ошибка загрузки: ${err.message}`, "error");
|
|
319
|
+
}
|
|
205
320
|
}
|
|
206
321
|
},
|
|
207
322
|
onTabChange: (tab) => {
|
|
@@ -229,7 +344,6 @@ export async function startTui(client, me) {
|
|
|
229
344
|
if (older.messages.length > 0) {
|
|
230
345
|
state.setMessages(state.activeChat.id, older.messages, true);
|
|
231
346
|
statusBar.showMessage(`Загружено ${older.messages.length} предыдущих сообщений`, "info");
|
|
232
|
-
fetchMissingImagePreviews(older.messages, state.activeChat.id);
|
|
233
347
|
}
|
|
234
348
|
} catch (err) {
|
|
235
349
|
statusBar.showMessage(`Ошибка пагинации: ${err.message}`, "error");
|
|
@@ -239,6 +353,36 @@ export async function startTui(client, me) {
|
|
|
239
353
|
releaseInputs();
|
|
240
354
|
actionModal.show(msg);
|
|
241
355
|
},
|
|
356
|
+
onSelectMessage: (msg) => {
|
|
357
|
+
const preview = (msg.text || msg.mediaDescription || "вложение").replace(/\s+/g, " ").slice(0, 30);
|
|
358
|
+
statusBar.showMessage(
|
|
359
|
+
`Выделено #${msg.id}: "${preview}" · [Enter] или правый клик — действия`,
|
|
360
|
+
"info",
|
|
361
|
+
3000
|
|
362
|
+
);
|
|
363
|
+
},
|
|
364
|
+
onOpenImage: (msg) => {
|
|
365
|
+
releaseInputs();
|
|
366
|
+
imageViewerModal.show(msg);
|
|
367
|
+
},
|
|
368
|
+
onPlayVideo: (msg) => {
|
|
369
|
+
releaseInputs();
|
|
370
|
+
videoPlayerModal.play(msg);
|
|
371
|
+
},
|
|
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
|
+
},
|
|
242
386
|
});
|
|
243
387
|
|
|
244
388
|
// 4. Поле ввода (нижняя панель)
|
|
@@ -276,21 +420,8 @@ export async function startTui(client, me) {
|
|
|
276
420
|
onCancelContext: () => {
|
|
277
421
|
statusBar.showMessage("Режим ответа/редактирования сброшен", "info", 2000);
|
|
278
422
|
},
|
|
279
|
-
onReplyLast: () =>
|
|
280
|
-
|
|
281
|
-
const msgs = state.getMessages(state.activeChat.id);
|
|
282
|
-
if (msgs.length > 0) {
|
|
283
|
-
inputBox.setContext("reply", msgs[msgs.length - 1]);
|
|
284
|
-
}
|
|
285
|
-
},
|
|
286
|
-
onEditLast: () => {
|
|
287
|
-
if (!state.activeChat) return;
|
|
288
|
-
const msgs = state.getMessages(state.activeChat.id);
|
|
289
|
-
const ownMsgs = msgs.filter((m) => m.out);
|
|
290
|
-
if (ownMsgs.length > 0) {
|
|
291
|
-
inputBox.setContext("edit", ownMsgs[ownMsgs.length - 1]);
|
|
292
|
-
}
|
|
293
|
-
},
|
|
423
|
+
onReplyLast: () => startReply(),
|
|
424
|
+
onEditLast: () => startEdit(),
|
|
294
425
|
onSlashCommand: (cmd, args) => {
|
|
295
426
|
switch (cmd) {
|
|
296
427
|
case "help":
|
|
@@ -356,7 +487,8 @@ export async function startTui(client, me) {
|
|
|
356
487
|
});
|
|
357
488
|
|
|
358
489
|
// Переключение фокуса по клику мышью на любую из трех панелей
|
|
359
|
-
chatList.container.on("click", () => {
|
|
490
|
+
chatList.container.on("click", (data) => {
|
|
491
|
+
if (isRightClick(data)) return;
|
|
360
492
|
if (screen.focused !== chatList.list && screen.focused !== chatList.searchBox) {
|
|
361
493
|
releaseInputs();
|
|
362
494
|
chatList.focus();
|
|
@@ -365,7 +497,8 @@ export async function startTui(client, me) {
|
|
|
365
497
|
}
|
|
366
498
|
});
|
|
367
499
|
|
|
368
|
-
chatView.container.on("click", () => {
|
|
500
|
+
chatView.container.on("click", (data) => {
|
|
501
|
+
if (isRightClick(data)) return;
|
|
369
502
|
if (screen.focused !== chatView.scrollBox) {
|
|
370
503
|
releaseInputs();
|
|
371
504
|
chatView.focus();
|
|
@@ -374,7 +507,8 @@ export async function startTui(client, me) {
|
|
|
374
507
|
}
|
|
375
508
|
});
|
|
376
509
|
|
|
377
|
-
inputBox.container.on("click", () => {
|
|
510
|
+
inputBox.container.on("click", (data) => {
|
|
511
|
+
if (isRightClick(data)) return;
|
|
378
512
|
if (screen.focused !== inputBox.textarea) {
|
|
379
513
|
inputBox.focus();
|
|
380
514
|
statusBar.showMessage("Фокус: поле ввода", "info", 2000);
|
|
@@ -396,6 +530,7 @@ export async function startTui(client, me) {
|
|
|
396
530
|
});
|
|
397
531
|
|
|
398
532
|
state.on("active_chat_changed", (chat) => {
|
|
533
|
+
activePreviewLoads.clear();
|
|
399
534
|
header.updateInfo({
|
|
400
535
|
me: state.me,
|
|
401
536
|
status: state.connectionStatus,
|
|
@@ -403,16 +538,30 @@ export async function startTui(client, me) {
|
|
|
403
538
|
typingUser: state.getTypingUser(chat?.id),
|
|
404
539
|
});
|
|
405
540
|
const msgs = chat ? state.getMessages(chat.id) : [];
|
|
406
|
-
chatView.
|
|
541
|
+
chatView.setSelected(null);
|
|
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
|
+
}
|
|
407
552
|
});
|
|
408
553
|
|
|
409
|
-
state.on("messages_updated", ({ chatId, messages, isPrepend, isUpdate, isNewMessage }) => {
|
|
554
|
+
state.on("messages_updated", ({ chatId, messages, isPrepend, isUpdate, isNewMessage, firstUnreadId }) => {
|
|
410
555
|
if (state.activeChat?.id === chatId) {
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
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
|
+
}
|
|
416
565
|
}
|
|
417
566
|
});
|
|
418
567
|
|
|
@@ -435,30 +584,63 @@ export async function startTui(client, me) {
|
|
|
435
584
|
}
|
|
436
585
|
});
|
|
437
586
|
|
|
587
|
+
let previewBatchTimer = null;
|
|
588
|
+
let pendingBatchChatId = null;
|
|
589
|
+
|
|
438
590
|
/**
|
|
439
|
-
*
|
|
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
|
+
|
|
616
|
+
/**
|
|
617
|
+
* Фоново догружает превью изображений только для тех сообщений, которые видны на экране (Lazy Loading).
|
|
618
|
+
* Обновления группируются пакетами, предотвращая фризы и блокировку основного потока.
|
|
440
619
|
* @param {Array<object>} messages
|
|
441
620
|
* @param {string} chatId
|
|
442
621
|
*/
|
|
443
622
|
async function fetchMissingImagePreviews(messages, chatId) {
|
|
444
623
|
if (!config.showImages || !messages || messages.length === 0) return;
|
|
445
|
-
|
|
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) {
|
|
446
628
|
if (state.activeChat?.id !== chatId) break;
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
const
|
|
450
|
-
if (
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
msg.imagePreview = preview;
|
|
455
|
-
state.updateMessage(chatId, msg);
|
|
456
|
-
}
|
|
457
|
-
} catch {
|
|
458
|
-
// Игнорируем сетевые ошибки фоновой загрузки превью
|
|
459
|
-
}
|
|
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);
|
|
460
636
|
}
|
|
637
|
+
} catch {
|
|
638
|
+
// Игнорируем сетевые ошибки фоновой загрузки превью
|
|
639
|
+
} finally {
|
|
640
|
+
activePreviewLoads.delete(msg.id);
|
|
461
641
|
}
|
|
642
|
+
// Даём event loop обработать пользовательский ввод и скролл
|
|
643
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
462
644
|
}
|
|
463
645
|
}
|
|
464
646
|
|
|
@@ -467,13 +649,11 @@ export async function startTui(client, me) {
|
|
|
467
649
|
|
|
468
650
|
listener.on("new_message", ({ peerId, message }) => {
|
|
469
651
|
state.addMessage(peerId, message);
|
|
470
|
-
if (
|
|
652
|
+
if (message.isPreviewLoading) {
|
|
471
653
|
fetchMissingImagePreviews([message], peerId);
|
|
472
654
|
}
|
|
473
655
|
|
|
474
|
-
if (state.activeChat?.id
|
|
475
|
-
markAsRead(client, state.activeChat.peerId).catch(() => {});
|
|
476
|
-
} else if (!message.out) {
|
|
656
|
+
if (state.activeChat?.id !== peerId && !message.out) {
|
|
477
657
|
const sender = message.senderName || "Новое сообщение";
|
|
478
658
|
const preview = (message.text || "Вложение").slice(0, 30);
|
|
479
659
|
statusBar.showMessage(`💬 ${sender}: "${preview}"`, "info", 5000);
|
|
@@ -572,6 +752,31 @@ export async function startTui(client, me) {
|
|
|
572
752
|
chatList.release?.();
|
|
573
753
|
}
|
|
574
754
|
|
|
755
|
+
/** Включает режим ответа на выделенное сообщение, иначе — на последнее в ленте. */
|
|
756
|
+
function startReply() {
|
|
757
|
+
if (!state.activeChat) return;
|
|
758
|
+
const target = chatView.getTargetMessage();
|
|
759
|
+
if (target) {
|
|
760
|
+
inputBox.setContext("reply", target);
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
/** Включает правку выделенного своего сообщения, иначе — последнего своего. */
|
|
765
|
+
function startEdit() {
|
|
766
|
+
if (!state.activeChat) return;
|
|
767
|
+
const selected = chatView.getSelected();
|
|
768
|
+
if (selected?.out) {
|
|
769
|
+
inputBox.setContext("edit", selected);
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
772
|
+
const ownMsgs = state.getMessages(state.activeChat.id).filter((m) => m.out);
|
|
773
|
+
if (ownMsgs.length > 0) {
|
|
774
|
+
inputBox.setContext("edit", ownMsgs[ownMsgs.length - 1]);
|
|
775
|
+
} else {
|
|
776
|
+
statusBar.showMessage("В этом чате нет ваших сообщений для правки", "warning", 3000);
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
|
|
575
780
|
// 7. Глобальные сочетания клавиш
|
|
576
781
|
// Цикл фокуса: список чатов -> лента сообщений -> поле ввода -> список чатов.
|
|
577
782
|
// Без ленты в цикле были недостижимы прокрутка, подгрузка истории и меню действий.
|
|
@@ -614,17 +819,18 @@ export async function startTui(client, me) {
|
|
|
614
819
|
screen.key(["tab"], () => moveFocus(1));
|
|
615
820
|
screen.key(["S-tab"], () => moveFocus(-1));
|
|
616
821
|
|
|
822
|
+
// Когда лента в фокусе, эти клавиши обрабатывает она сама — иначе прокрутка удваивается
|
|
617
823
|
screen.key(["pageup", "C-u"], () => {
|
|
618
|
-
if (!state.activeChat) return;
|
|
824
|
+
if (!state.activeChat || screen.focused === chatView.scrollBox) return;
|
|
619
825
|
chatView.scrollBox.scroll(-10);
|
|
620
|
-
if (chatView.scrollBox.
|
|
826
|
+
if ((chatView.scrollBox.childBase || 0) <= 0) {
|
|
621
827
|
chatView.loadMore?.();
|
|
622
828
|
}
|
|
623
829
|
screen.render();
|
|
624
830
|
});
|
|
625
831
|
|
|
626
832
|
screen.key(["pagedown", "C-d"], () => {
|
|
627
|
-
if (!state.activeChat) return;
|
|
833
|
+
if (!state.activeChat || screen.focused === chatView.scrollBox) return;
|
|
628
834
|
chatView.scrollBox.scroll(10);
|
|
629
835
|
screen.render();
|
|
630
836
|
});
|
|
@@ -653,28 +859,42 @@ export async function startTui(client, me) {
|
|
|
653
859
|
}
|
|
654
860
|
});
|
|
655
861
|
|
|
656
|
-
screen.key(["C-r"], () =>
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
862
|
+
screen.key(["C-r"], () => startReply());
|
|
863
|
+
|
|
864
|
+
screen.key(["C-e"], () => startEdit());
|
|
865
|
+
|
|
866
|
+
// Меню действий над выделенным сообщением из любой панели.
|
|
867
|
+
// Когда лента в фокусе, Ctrl+A обрабатывает она сама.
|
|
868
|
+
screen.key(["C-a"], () => {
|
|
869
|
+
if (screen.focused === chatView.scrollBox) return;
|
|
870
|
+
if (!state.activeChat) {
|
|
871
|
+
statusBar.showMessage("Сначала выберите чат слева!", "warning");
|
|
872
|
+
return;
|
|
873
|
+
}
|
|
874
|
+
const msg = chatView.getTargetMessage();
|
|
875
|
+
if (msg) {
|
|
876
|
+
releaseInputs();
|
|
877
|
+
actionModal.show(msg);
|
|
662
878
|
}
|
|
663
879
|
});
|
|
664
880
|
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
const
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
881
|
+
// Пока мышь захвачена приложением, терминал не даёт выделять текст для копирования
|
|
882
|
+
screen.key(["f12"], () => {
|
|
883
|
+
const enabled = !screen.mouseCaptured;
|
|
884
|
+
setMouseCapture(screen, enabled);
|
|
885
|
+
statusBar.showMessage(
|
|
886
|
+
enabled
|
|
887
|
+
? "Мышь снова управляет интерфейсом"
|
|
888
|
+
: "Мышь отдана терминалу — можно выделять и копировать текст. [F12] вернуть",
|
|
889
|
+
enabled ? "info" : "warning",
|
|
890
|
+
5000
|
|
891
|
+
);
|
|
673
892
|
});
|
|
674
893
|
|
|
675
894
|
screen.key(["C-q"], () => {
|
|
676
895
|
releaseInputs();
|
|
677
896
|
confirmModal.ask("Выйти из TuiGram?", () => {
|
|
897
|
+
flushPendingMarkAsRead();
|
|
678
898
|
listener?.stop();
|
|
679
899
|
screen.destroy();
|
|
680
900
|
process.exit(0);
|