@emaxe/tuigram 1.0.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 +29 -0
- package/LICENSE +21 -0
- package/README.md +364 -0
- package/bin/tuigram.js +2 -0
- package/package.json +73 -0
- package/src/cli/cliCommands.js +164 -0
- package/src/cli/formatters.js +72 -0
- package/src/cli/init.js +197 -0
- package/src/config.js +158 -0
- package/src/index.js +172 -0
- package/src/state.js +243 -0
- package/src/telegram/auth.js +146 -0
- package/src/telegram/client.js +96 -0
- package/src/telegram/dialogs.js +130 -0
- package/src/telegram/entities.js +158 -0
- package/src/telegram/formatter.js +248 -0
- package/src/telegram/listener.js +141 -0
- package/src/telegram/messages.js +265 -0
- package/src/ui/app.js +534 -0
- package/src/ui/components/chatList.js +295 -0
- package/src/ui/components/chatView.js +178 -0
- package/src/ui/components/header.js +82 -0
- package/src/ui/components/inputBox.js +221 -0
- package/src/ui/components/modals/actionModal.js +136 -0
- package/src/ui/components/modals/chatInfoModal.js +125 -0
- package/src/ui/components/modals/confirmModal.js +141 -0
- package/src/ui/components/modals/fileModal.js +355 -0
- package/src/ui/components/modals/filePickerModal.js +214 -0
- package/src/ui/components/modals/helpModal.js +131 -0
- package/src/ui/components/statusBar.js +64 -0
- package/src/ui/screen.js +60 -0
- package/src/ui/theme.js +223 -0
- package/src/utils/commands.js +40 -0
- package/src/utils/storage.js +177 -0
- package/src/utils/time.js +132 -0
package/src/ui/app.js
ADDED
|
@@ -0,0 +1,534 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import { createScreen } from "./screen.js";
|
|
4
|
+
import { getTheme } from "./theme.js";
|
|
5
|
+
import { createHeader } from "./components/header.js";
|
|
6
|
+
import { createChatList } from "./components/chatList.js";
|
|
7
|
+
import { createChatView } from "./components/chatView.js";
|
|
8
|
+
import { createInputBox } from "./components/inputBox.js";
|
|
9
|
+
import { createStatusBar } from "./components/statusBar.js";
|
|
10
|
+
import { createHelpModal } from "./components/modals/helpModal.js";
|
|
11
|
+
import { createChatInfoModal } from "./components/modals/chatInfoModal.js";
|
|
12
|
+
import { createActionModal } from "./components/modals/actionModal.js";
|
|
13
|
+
import { createFileModal } from "./components/modals/fileModal.js";
|
|
14
|
+
import { createConfirmModal } from "./components/modals/confirmModal.js";
|
|
15
|
+
import { state } from "../state.js";
|
|
16
|
+
import { config } from "../config.js";
|
|
17
|
+
import { fetchDialogs } from "../telegram/dialogs.js";
|
|
18
|
+
import { setMessagePalette } from "../telegram/formatter.js";
|
|
19
|
+
import { fetchHistory, sendMessage, editMessage, deleteMessages, sendFiles, downloadMedia, sendReaction, markAsRead } from "../telegram/messages.js";
|
|
20
|
+
import { startTelegramListener } from "../telegram/listener.js";
|
|
21
|
+
import { logout } from "../telegram/auth.js";
|
|
22
|
+
import { ensureDir, inspectLocalFile } from "../utils/storage.js";
|
|
23
|
+
import { formatFileSize } from "../utils/time.js";
|
|
24
|
+
import { parseSendFileArgs } from "../utils/commands.js";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Запускает полноэкранный TUI-клиент Telegram.
|
|
28
|
+
* @param {import("teleproto").TelegramClient} client
|
|
29
|
+
* @param {object} me
|
|
30
|
+
*/
|
|
31
|
+
export async function startTui(client, me) {
|
|
32
|
+
const theme = getTheme(config.theme);
|
|
33
|
+
// Разметка сообщений и медиа-плашки красятся той же темой
|
|
34
|
+
setMessagePalette(theme);
|
|
35
|
+
/** @type {{ stop: () => void }|null} */
|
|
36
|
+
let listener = null;
|
|
37
|
+
const screen = createScreen({
|
|
38
|
+
theme,
|
|
39
|
+
onExit: () => listener?.stop(),
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
state.me = me;
|
|
43
|
+
state.setConnectionStatus("connected");
|
|
44
|
+
|
|
45
|
+
// 1. Создание визуальных компонентов
|
|
46
|
+
const header = createHeader(screen, theme);
|
|
47
|
+
const statusBar = createStatusBar(screen, theme);
|
|
48
|
+
|
|
49
|
+
// Модальные окна
|
|
50
|
+
const helpModal = createHelpModal(screen, theme);
|
|
51
|
+
const chatInfoModal = createChatInfoModal(screen, theme);
|
|
52
|
+
const confirmModal = createConfirmModal(screen, theme);
|
|
53
|
+
const fileModal = createFileModal(screen, theme, {
|
|
54
|
+
onSendFile: (files, options) => sendFilesToActiveChat(files, options),
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const actionModal = createActionModal(screen, theme, {
|
|
58
|
+
onAction: async (actionId, msg) => {
|
|
59
|
+
if (!state.activeChat) return;
|
|
60
|
+
const peerId = state.activeChat.peerId;
|
|
61
|
+
|
|
62
|
+
switch (actionId) {
|
|
63
|
+
case "reply":
|
|
64
|
+
inputBox.setContext("reply", msg);
|
|
65
|
+
break;
|
|
66
|
+
case "edit":
|
|
67
|
+
inputBox.setContext("edit", msg);
|
|
68
|
+
break;
|
|
69
|
+
case "delete":
|
|
70
|
+
releaseInputs();
|
|
71
|
+
confirmModal.ask(`Удалить сообщение #${msg.id} для всех?`, async () => {
|
|
72
|
+
try {
|
|
73
|
+
await deleteMessages(client, peerId, [msg.id], { revoke: true });
|
|
74
|
+
state.removeMessages(state.activeChat.id, [msg.id]);
|
|
75
|
+
statusBar.showMessage(`Сообщение #${msg.id} удалено`, "success");
|
|
76
|
+
} catch (err) {
|
|
77
|
+
statusBar.showMessage(`Ошибка удаления: ${err.message}`, "error");
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
break;
|
|
81
|
+
case "react_like":
|
|
82
|
+
case "react_fire":
|
|
83
|
+
case "react_heart": {
|
|
84
|
+
const emojiMap = { react_like: "👍", react_fire: "🔥", react_heart: "❤️" };
|
|
85
|
+
const emoji = emojiMap[actionId] || "👍";
|
|
86
|
+
try {
|
|
87
|
+
await sendReaction(client, peerId, msg.id, emoji);
|
|
88
|
+
statusBar.showMessage(`Поставлена реакция ${emoji}`, "success");
|
|
89
|
+
} catch (err) {
|
|
90
|
+
statusBar.showMessage(`Ошибка реакции: ${err.message}`, "error");
|
|
91
|
+
}
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
case "download": {
|
|
95
|
+
try {
|
|
96
|
+
const downloadsDir = config.downloadsDir;
|
|
97
|
+
ensureDir(downloadsDir);
|
|
98
|
+
const fileName = `media_${msg.id}_${Date.now()}`;
|
|
99
|
+
const targetPath = path.join(downloadsDir, fileName);
|
|
100
|
+
statusBar.showMessage("Скачивание медиа-вложения...", "info");
|
|
101
|
+
const res = await downloadMedia(client, msg.rawMessage, { outputFile: targetPath });
|
|
102
|
+
const finalPath = typeof res === "string" ? res : targetPath;
|
|
103
|
+
statusBar.showMessage(`✓ Файл сохранён: ${finalPath}`, "success");
|
|
104
|
+
} catch (err) {
|
|
105
|
+
statusBar.showMessage(`Ошибка скачивания: ${err.message}`, "error");
|
|
106
|
+
}
|
|
107
|
+
break;
|
|
108
|
+
}
|
|
109
|
+
case "copy":
|
|
110
|
+
if (msg.text) {
|
|
111
|
+
inputBox.textarea.setValue(msg.text);
|
|
112
|
+
inputBox.focus();
|
|
113
|
+
statusBar.showMessage("Текст скопирован в поле ввода", "info");
|
|
114
|
+
}
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// 2. Список диалогов (левая панель)
|
|
121
|
+
const chatList = createChatList(screen, theme, {
|
|
122
|
+
onSelectDialog: async (dialog) => {
|
|
123
|
+
state.setActiveChat(dialog);
|
|
124
|
+
statusBar.showMessage(`Загрузка сообщений: ${dialog.title}...`, "info");
|
|
125
|
+
|
|
126
|
+
try {
|
|
127
|
+
const history = await fetchHistory(client, dialog.peerId, { limit: 50 });
|
|
128
|
+
state.setMessages(dialog.id, history.messages);
|
|
129
|
+
markAsRead(client, dialog.peerId).catch(() => {});
|
|
130
|
+
statusBar.showMessage(`Чат: ${dialog.title}`, "info");
|
|
131
|
+
} catch (err) {
|
|
132
|
+
statusBar.showMessage(`Ошибка загрузки: ${err.message}`, "error");
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
onTabChange: (tab) => {
|
|
136
|
+
state.setFilterTab(tab);
|
|
137
|
+
},
|
|
138
|
+
onSearchChange: (query) => {
|
|
139
|
+
state.setSearchQuery(query);
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
// 3. Просмотр сообщений (правая центральная панель)
|
|
144
|
+
const chatView = createChatView(screen, theme, {
|
|
145
|
+
onLoadMoreHistory: async () => {
|
|
146
|
+
if (!state.activeChat) return;
|
|
147
|
+
const current = state.getMessages(state.activeChat.id);
|
|
148
|
+
if (current.length === 0) return;
|
|
149
|
+
|
|
150
|
+
const oldestId = current[0].id;
|
|
151
|
+
try {
|
|
152
|
+
statusBar.showMessage("Подгрузка более старых сообщений...", "info");
|
|
153
|
+
const older = await fetchHistory(client, state.activeChat.peerId, {
|
|
154
|
+
limit: 30,
|
|
155
|
+
offsetId: oldestId,
|
|
156
|
+
});
|
|
157
|
+
if (older.messages.length > 0) {
|
|
158
|
+
state.setMessages(state.activeChat.id, older.messages, true);
|
|
159
|
+
statusBar.showMessage(`Загружено ${older.messages.length} предыдущих сообщений`, "info");
|
|
160
|
+
}
|
|
161
|
+
} catch (err) {
|
|
162
|
+
statusBar.showMessage(`Ошибка пагинации: ${err.message}`, "error");
|
|
163
|
+
}
|
|
164
|
+
},
|
|
165
|
+
onActionMenu: (msg) => {
|
|
166
|
+
releaseInputs();
|
|
167
|
+
actionModal.show(msg);
|
|
168
|
+
},
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// 4. Поле ввода (нижняя панель)
|
|
172
|
+
const inputBox = createInputBox(screen, theme, {
|
|
173
|
+
onSubmit: async (text, context) => {
|
|
174
|
+
if (!state.activeChat) {
|
|
175
|
+
statusBar.showMessage("Сначала выберите чат слева!", "warning");
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const peerId = state.activeChat.peerId;
|
|
180
|
+
|
|
181
|
+
if (context.mode === "edit" && context.target) {
|
|
182
|
+
// Редактирование
|
|
183
|
+
try {
|
|
184
|
+
statusBar.showMessage("Редактирование...", "info");
|
|
185
|
+
const edited = await editMessage(client, peerId, context.target.id, text);
|
|
186
|
+
state.updateMessage(state.activeChat.id, edited);
|
|
187
|
+
statusBar.showMessage("✓ Сообщение изменено", "success");
|
|
188
|
+
} catch (err) {
|
|
189
|
+
statusBar.showMessage(`Ошибка редактирования: ${err.message}`, "error");
|
|
190
|
+
}
|
|
191
|
+
} else {
|
|
192
|
+
// Отправка нового сообщения или ответа
|
|
193
|
+
try {
|
|
194
|
+
const replyTo = context.mode === "reply" && context.target ? context.target.id : undefined;
|
|
195
|
+
const sent = await sendMessage(client, peerId, text, { replyTo });
|
|
196
|
+
state.addMessage(state.activeChat.id, sent);
|
|
197
|
+
statusBar.showMessage("✓ Отправлено", "success", 2000);
|
|
198
|
+
} catch (err) {
|
|
199
|
+
statusBar.showMessage(`Ошибка отправки: ${err.message}`, "error");
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
},
|
|
203
|
+
onCancelContext: () => {
|
|
204
|
+
statusBar.showMessage("Режим ответа/редактирования сброшен", "info", 2000);
|
|
205
|
+
},
|
|
206
|
+
onSlashCommand: (cmd, args) => {
|
|
207
|
+
switch (cmd) {
|
|
208
|
+
case "help":
|
|
209
|
+
releaseInputs();
|
|
210
|
+
helpModal.show();
|
|
211
|
+
break;
|
|
212
|
+
case "info":
|
|
213
|
+
releaseInputs();
|
|
214
|
+
if (state.activeChat) chatInfoModal.show(state.activeChat);
|
|
215
|
+
else statusBar.showMessage("Сначала выберите чат!", "warning");
|
|
216
|
+
break;
|
|
217
|
+
case "sendfile": {
|
|
218
|
+
// Формат: /sendfile путь [| путь ...] [-- подпись]
|
|
219
|
+
const { paths, caption } = parseSendFileArgs(args);
|
|
220
|
+
if (paths.length === 0) {
|
|
221
|
+
releaseInputs();
|
|
222
|
+
fileModal.show();
|
|
223
|
+
break;
|
|
224
|
+
}
|
|
225
|
+
if (!state.activeChat) {
|
|
226
|
+
statusBar.showMessage("Сначала выберите чат для отправки файла!", "warning");
|
|
227
|
+
break;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const files = [];
|
|
231
|
+
let invalid = null;
|
|
232
|
+
for (const entry of paths) {
|
|
233
|
+
const info = inspectLocalFile(entry);
|
|
234
|
+
if (!info.ok) {
|
|
235
|
+
invalid = info.error;
|
|
236
|
+
break;
|
|
237
|
+
}
|
|
238
|
+
files.push(info);
|
|
239
|
+
}
|
|
240
|
+
if (invalid) {
|
|
241
|
+
statusBar.showMessage(invalid, "error");
|
|
242
|
+
break;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
sendFilesToActiveChat(files, { caption });
|
|
246
|
+
break;
|
|
247
|
+
}
|
|
248
|
+
case "clear":
|
|
249
|
+
if (state.activeChat) {
|
|
250
|
+
state.messagesByChat.set(state.activeChat.id, []);
|
|
251
|
+
chatView.setMessages([]);
|
|
252
|
+
statusBar.showMessage("Лента очищена на экране", "info");
|
|
253
|
+
}
|
|
254
|
+
break;
|
|
255
|
+
case "logout":
|
|
256
|
+
releaseInputs();
|
|
257
|
+
confirmModal.ask("Вы действительно хотите выйти из аккаунта?", async () => {
|
|
258
|
+
await logout(client);
|
|
259
|
+
screen.destroy();
|
|
260
|
+
console.log("\nВы успешно вышли из аккаунта.");
|
|
261
|
+
process.exit(0);
|
|
262
|
+
});
|
|
263
|
+
break;
|
|
264
|
+
default:
|
|
265
|
+
statusBar.showMessage(`Неизвестная команда: /${cmd}. Введите /help для справки.`, "warning");
|
|
266
|
+
}
|
|
267
|
+
},
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
// 5. Реакция на события состояния
|
|
271
|
+
state.on("dialogs_updated", (dialogs) => {
|
|
272
|
+
chatList.setDialogs(dialogs);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
state.on("filter_changed", ({ dialogs }) => {
|
|
276
|
+
chatList.setDialogs(dialogs);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
state.on("search_changed", ({ dialogs }) => {
|
|
280
|
+
chatList.setDialogs(dialogs);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
state.on("active_chat_changed", (chat) => {
|
|
284
|
+
header.updateInfo({
|
|
285
|
+
me: state.me,
|
|
286
|
+
status: state.connectionStatus,
|
|
287
|
+
activeChat: chat,
|
|
288
|
+
typingUser: state.getTypingUser(chat?.id),
|
|
289
|
+
});
|
|
290
|
+
const msgs = chat ? state.getMessages(chat.id) : [];
|
|
291
|
+
chatView.setMessages(msgs);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
state.on("messages_updated", ({ chatId, messages, isPrepend }) => {
|
|
295
|
+
if (state.activeChat?.id === chatId) {
|
|
296
|
+
chatView.setMessages(messages, !isPrepend);
|
|
297
|
+
}
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
state.on("typing_changed", ({ chatId, userName }) => {
|
|
301
|
+
if (state.activeChat?.id === chatId) {
|
|
302
|
+
header.updateInfo({
|
|
303
|
+
me: state.me,
|
|
304
|
+
status: state.connectionStatus,
|
|
305
|
+
activeChat: state.activeChat,
|
|
306
|
+
typingUser: userName,
|
|
307
|
+
});
|
|
308
|
+
setTimeout(() => {
|
|
309
|
+
header.updateInfo({
|
|
310
|
+
me: state.me,
|
|
311
|
+
status: state.connectionStatus,
|
|
312
|
+
activeChat: state.activeChat,
|
|
313
|
+
typingUser: state.getTypingUser(chatId),
|
|
314
|
+
});
|
|
315
|
+
}, 5000);
|
|
316
|
+
}
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
// 6. Подключение фонового слушателя MTProto событий
|
|
320
|
+
listener = startTelegramListener(client);
|
|
321
|
+
|
|
322
|
+
listener.on("new_message", ({ peerId, message }) => {
|
|
323
|
+
state.addMessage(peerId, message);
|
|
324
|
+
|
|
325
|
+
if (state.activeChat?.id === peerId) {
|
|
326
|
+
markAsRead(client, state.activeChat.peerId).catch(() => {});
|
|
327
|
+
} else if (!message.out) {
|
|
328
|
+
const sender = message.senderName || "Новое сообщение";
|
|
329
|
+
const preview = (message.text || "Вложение").slice(0, 30);
|
|
330
|
+
statusBar.showMessage(`💬 ${sender}: "${preview}"`, "info", 5000);
|
|
331
|
+
}
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
listener.on("edited_message", ({ peerId, message }) => {
|
|
335
|
+
state.updateMessage(peerId, message);
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
listener.on("deleted_messages", ({ peerId, deletedIds }) => {
|
|
339
|
+
state.removeMessages(peerId, deletedIds);
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
listener.on("typing", ({ chatId, userId }) => {
|
|
343
|
+
state.setTyping(chatId, "Собеседник");
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Единая точка отправки файлов: прогресс, отмена по Esc, режим «Ответ».
|
|
348
|
+
* @param {Array<{filePath: string, name: string, size: number}>} files
|
|
349
|
+
* @param {object} [options]
|
|
350
|
+
* @param {string} [options.caption]
|
|
351
|
+
* @param {boolean} [options.asDocument]
|
|
352
|
+
*/
|
|
353
|
+
async function sendFilesToActiveChat(files, { caption = "", asDocument = false } = {}) {
|
|
354
|
+
if (!state.activeChat) {
|
|
355
|
+
statusBar.showMessage("Сначала выберите чат для отправки файла!", "warning");
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
if (!files || files.length === 0) return;
|
|
359
|
+
|
|
360
|
+
const chat = state.activeChat;
|
|
361
|
+
const label = files.length === 1
|
|
362
|
+
? files[0].name
|
|
363
|
+
: `${files.length} файлов`;
|
|
364
|
+
const totalSize = files.reduce((sum, f) => sum + (f.size || 0), 0);
|
|
365
|
+
|
|
366
|
+
// Файл отправляется ответом, если активен режим Reply
|
|
367
|
+
const ctx = inputBox.getContext?.() ?? { mode: null, target: null };
|
|
368
|
+
const replyTo = ctx.mode === "reply" && ctx.target ? ctx.target.id : undefined;
|
|
369
|
+
|
|
370
|
+
// Прогресс 0..1 от teleproto; троттлим по целым процентам и по времени
|
|
371
|
+
let lastPercent = -1;
|
|
372
|
+
let lastTick = 0;
|
|
373
|
+
const progressCallback = (progress) => {
|
|
374
|
+
const percent = Math.min(99, Math.round((progress || 0) * 100));
|
|
375
|
+
const now = Date.now();
|
|
376
|
+
if (percent === lastPercent || now - lastTick < 200) return;
|
|
377
|
+
lastPercent = percent;
|
|
378
|
+
lastTick = now;
|
|
379
|
+
statusBar.showMessage(
|
|
380
|
+
`Отправка ${label}: ${percent}% (${formatFileSize(totalSize)}) · [Esc] отменить`,
|
|
381
|
+
"info",
|
|
382
|
+
60000
|
|
383
|
+
);
|
|
384
|
+
};
|
|
385
|
+
|
|
386
|
+
// teleproto проверяет isCanceled между чанками и бросает USER_CANCELED
|
|
387
|
+
const cancel = () => {
|
|
388
|
+
progressCallback.isCanceled = true;
|
|
389
|
+
statusBar.showMessage("Отмена отправки...", "warning", 3000);
|
|
390
|
+
};
|
|
391
|
+
screen.key(["escape"], cancel);
|
|
392
|
+
|
|
393
|
+
try {
|
|
394
|
+
statusBar.showMessage(`Отправка ${label} (${formatFileSize(totalSize)})...`, "info", 60000);
|
|
395
|
+
const sent = await sendFiles(
|
|
396
|
+
client,
|
|
397
|
+
chat.peerId,
|
|
398
|
+
files.map((f) => f.filePath),
|
|
399
|
+
{ caption, replyTo, forceDocument: asDocument, progressCallback }
|
|
400
|
+
);
|
|
401
|
+
for (const msg of sent) {
|
|
402
|
+
state.addMessage(chat.id, msg);
|
|
403
|
+
}
|
|
404
|
+
if (replyTo) inputBox.clearContext();
|
|
405
|
+
statusBar.showMessage(`✓ Отправлено: ${label}`, "success");
|
|
406
|
+
} catch (err) {
|
|
407
|
+
const message = /USER_CANCELED/i.test(err?.message || "")
|
|
408
|
+
? `Отправка ${label} отменена`
|
|
409
|
+
: `Ошибка отправки файла: ${err.message}`;
|
|
410
|
+
statusBar.showMessage(message, /USER_CANCELED/i.test(err?.message || "") ? "warning" : "error");
|
|
411
|
+
} finally {
|
|
412
|
+
screen.unkey(["escape"], cancel);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Освобождает клавиатуру перед открытием модального окна.
|
|
418
|
+
* blessed-поля ввода (textarea/textbox) держат screen.grabKeys и по blur
|
|
419
|
+
* возвращают себе фокус — без этого модалка открывается «мёртвой».
|
|
420
|
+
*/
|
|
421
|
+
function releaseInputs() {
|
|
422
|
+
inputBox.release?.();
|
|
423
|
+
chatList.release?.();
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// 7. Глобальные сочетания клавиш
|
|
427
|
+
// Цикл фокуса: список чатов -> лента сообщений -> поле ввода -> список чатов.
|
|
428
|
+
// Без ленты в цикле были недостижимы прокрутка, подгрузка истории и меню действий.
|
|
429
|
+
const FOCUS_ORDER = ["chatList", "chatView", "input"];
|
|
430
|
+
const FOCUS_TARGETS = {
|
|
431
|
+
chatList: chatList,
|
|
432
|
+
chatView: chatView,
|
|
433
|
+
input: inputBox,
|
|
434
|
+
};
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Определяет текущую панель по реальному фокусу blessed.
|
|
438
|
+
* Нужно потому, что textarea по Escape сама вызывает screen.rewindFocus().
|
|
439
|
+
* @returns {"chatList"|"chatView"|"input"}
|
|
440
|
+
*/
|
|
441
|
+
function detectFocus() {
|
|
442
|
+
const el = screen.focused;
|
|
443
|
+
if (el === inputBox.textarea) return "input";
|
|
444
|
+
if (el === chatView.scrollBox) return "chatView";
|
|
445
|
+
return "chatList";
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function moveFocus(step) {
|
|
449
|
+
const current = FOCUS_ORDER.indexOf(detectFocus());
|
|
450
|
+
const next = FOCUS_ORDER[(current + step + FOCUS_ORDER.length) % FOCUS_ORDER.length];
|
|
451
|
+
// Сначала выпускаем клавиатуру из активного поля ввода, иначе оно
|
|
452
|
+
// заберёт фокус обратно своим обработчиком blur.
|
|
453
|
+
releaseInputs();
|
|
454
|
+
FOCUS_TARGETS[next].focus();
|
|
455
|
+
statusBar.showMessage(
|
|
456
|
+
next === "chatList" ? "Фокус: список чатов" :
|
|
457
|
+
next === "chatView" ? "Фокус: лента сообщений (PageUp/PageDown, Ctrl+A — действия)" :
|
|
458
|
+
"Фокус: поле ввода",
|
|
459
|
+
"info",
|
|
460
|
+
2000
|
|
461
|
+
);
|
|
462
|
+
screen.render();
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
screen.key(["tab"], () => moveFocus(1));
|
|
466
|
+
screen.key(["S-tab"], () => moveFocus(-1));
|
|
467
|
+
|
|
468
|
+
screen.key(["f1", "?"], () => {
|
|
469
|
+
releaseInputs();
|
|
470
|
+
helpModal.show();
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
// Ctrl+I терминал шлёт как таб ("\t"), поэтому информация о чате — на Ctrl+P.
|
|
474
|
+
screen.key(["C-p"], () => {
|
|
475
|
+
if (state.activeChat) {
|
|
476
|
+
releaseInputs();
|
|
477
|
+
chatInfoModal.show(state.activeChat);
|
|
478
|
+
} else {
|
|
479
|
+
statusBar.showMessage("Сначала выберите чат слева!", "warning");
|
|
480
|
+
}
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
screen.key(["C-o"], () => {
|
|
484
|
+
if (state.activeChat) {
|
|
485
|
+
releaseInputs();
|
|
486
|
+
fileModal.show();
|
|
487
|
+
} else {
|
|
488
|
+
statusBar.showMessage("Сначала выберите чат слева!", "warning");
|
|
489
|
+
}
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
screen.key(["C-r"], () => {
|
|
493
|
+
if (!state.activeChat) return;
|
|
494
|
+
const msgs = state.getMessages(state.activeChat.id);
|
|
495
|
+
if (msgs.length > 0) {
|
|
496
|
+
const lastMsg = msgs[msgs.length - 1];
|
|
497
|
+
inputBox.setContext("reply", lastMsg);
|
|
498
|
+
}
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
screen.key(["C-e"], () => {
|
|
502
|
+
if (!state.activeChat) return;
|
|
503
|
+
const msgs = state.getMessages(state.activeChat.id);
|
|
504
|
+
const ownMsgs = msgs.filter((m) => m.out);
|
|
505
|
+
if (ownMsgs.length > 0) {
|
|
506
|
+
const lastOwn = ownMsgs[ownMsgs.length - 1];
|
|
507
|
+
inputBox.setContext("edit", lastOwn);
|
|
508
|
+
}
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
screen.key(["C-q"], () => {
|
|
512
|
+
releaseInputs();
|
|
513
|
+
confirmModal.ask("Выйти из TuiGram?", () => {
|
|
514
|
+
listener?.stop();
|
|
515
|
+
screen.destroy();
|
|
516
|
+
process.exit(0);
|
|
517
|
+
});
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
// 8. Первоначальная загрузка диалогов
|
|
521
|
+
header.updateInfo({ me, status: "connected", activeChat: null });
|
|
522
|
+
statusBar.showMessage("Загрузка списка диалогов...", "info");
|
|
523
|
+
chatList.focus();
|
|
524
|
+
|
|
525
|
+
try {
|
|
526
|
+
const dialogs = await fetchDialogs(client, { limit: 100 });
|
|
527
|
+
state.setDialogs(dialogs);
|
|
528
|
+
statusBar.showMessage(`Загружено ${dialogs.length} диалогов. Выберите чат и нажмите Enter.`, "success");
|
|
529
|
+
} catch (err) {
|
|
530
|
+
statusBar.showMessage(`Ошибка загрузки диалогов: ${err.message}`, "error");
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
screen.render();
|
|
534
|
+
}
|