@emaxe/tuigram 1.0.1 → 1.2.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 +24 -0
- package/CHANGELOG.md +18 -1
- package/CHANGELOG.ru.md +18 -1
- package/README.md +51 -0
- package/README.ru.md +51 -0
- package/package.json +3 -1
- package/src/cli/init.js +2 -1
- package/src/config.js +128 -0
- package/src/state.js +4 -4
- package/src/telegram/client.js +15 -7
- package/src/telegram/messages.js +58 -0
- package/src/telegram/socket.js +224 -0
- package/src/ui/app.js +54 -3
- package/src/ui/components/chatView.js +53 -8
- package/src/utils/image.js +343 -0
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Сетевой транспорт для MTProto с поддержкой прокси (HTTP CONNECT, SOCKS5, SOCKS4).
|
|
3
|
+
*/
|
|
4
|
+
import http from "node:http";
|
|
5
|
+
import https from "node:https";
|
|
6
|
+
import net from "node:net";
|
|
7
|
+
import { PromisedNetSockets } from "teleproto/extensions/PromisedNetSockets.js";
|
|
8
|
+
import { SocksClient } from "socks";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Создаёт TCP-туннель через HTTP/HTTPS прокси методом HTTP CONNECT.
|
|
12
|
+
* Поддерживает как прокси с Basic-авторизацией, так и без неё.
|
|
13
|
+
* @param {object} params
|
|
14
|
+
* @param {boolean} [params.isHttps=false] подключение к прокси-серверу по TLS
|
|
15
|
+
* @param {string} params.proxyHost адрес прокси-сервера
|
|
16
|
+
* @param {number} params.proxyPort порт прокси-сервера
|
|
17
|
+
* @param {string} params.targetHost адрес целевого сервера Telegram MTProto
|
|
18
|
+
* @param {number} params.targetPort порт целевого сервера Telegram MTProto
|
|
19
|
+
* @param {string} [params.username] логин для авторизации на прокси
|
|
20
|
+
* @param {string} [params.password] пароль для авторизации на прокси
|
|
21
|
+
* @param {number} [params.timeout=10000] таймаут установки соединения в миллисекундах
|
|
22
|
+
* @returns {Promise<import("node:net").Socket>}
|
|
23
|
+
*/
|
|
24
|
+
export function createHttpConnectSocket({
|
|
25
|
+
isHttps = false,
|
|
26
|
+
proxyHost,
|
|
27
|
+
proxyPort,
|
|
28
|
+
targetHost,
|
|
29
|
+
targetPort,
|
|
30
|
+
username,
|
|
31
|
+
password,
|
|
32
|
+
timeout = 10000,
|
|
33
|
+
}) {
|
|
34
|
+
return new Promise((resolve, reject) => {
|
|
35
|
+
const clientModule = isHttps ? https : http;
|
|
36
|
+
|
|
37
|
+
const headers = {
|
|
38
|
+
Host: `${targetHost}:${targetPort}`,
|
|
39
|
+
"User-Agent": "TuiGram",
|
|
40
|
+
"Proxy-Connection": "Keep-Alive",
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
if (username || password) {
|
|
44
|
+
const auth = Buffer.from(`${username || ""}:${password || ""}`).toString("base64");
|
|
45
|
+
headers["Proxy-Authorization"] = `Basic ${auth}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const req = clientModule.request({
|
|
49
|
+
host: proxyHost,
|
|
50
|
+
port: proxyPort,
|
|
51
|
+
method: "CONNECT",
|
|
52
|
+
path: `${targetHost}:${targetPort}`,
|
|
53
|
+
headers,
|
|
54
|
+
timeout,
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
let finished = false;
|
|
58
|
+
|
|
59
|
+
const cleanup = () => {
|
|
60
|
+
req.removeAllListeners();
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
req.on("connect", (res, socket, head) => {
|
|
64
|
+
if (finished) return;
|
|
65
|
+
finished = true;
|
|
66
|
+
cleanup();
|
|
67
|
+
|
|
68
|
+
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
69
|
+
// Если в заголовке ответа уже были данные следующего протокола, возвращаем их в сокет
|
|
70
|
+
if (head && head.length > 0) {
|
|
71
|
+
socket.unshift(head);
|
|
72
|
+
}
|
|
73
|
+
resolve(socket);
|
|
74
|
+
} else if (res.statusCode === 407) {
|
|
75
|
+
socket.destroy();
|
|
76
|
+
reject(new Error("Ошибка авторизации на HTTP-прокси (407 Proxy Authentication Required)"));
|
|
77
|
+
} else {
|
|
78
|
+
socket.destroy();
|
|
79
|
+
reject(new Error(`HTTP-прокси вернул код ошибки: ${res.statusCode} ${res.statusMessage || ""}`.trim()));
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
req.on("response", (res) => {
|
|
84
|
+
if (finished) return;
|
|
85
|
+
finished = true;
|
|
86
|
+
cleanup();
|
|
87
|
+
if (res.statusCode === 407) {
|
|
88
|
+
reject(new Error("Ошибка авторизации на HTTP-прокси (407 Proxy Authentication Required)"));
|
|
89
|
+
} else {
|
|
90
|
+
reject(new Error(`HTTP-прокси вернул код ошибки: ${res.statusCode} ${res.statusMessage || ""}`.trim()));
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
req.on("timeout", () => {
|
|
95
|
+
if (finished) return;
|
|
96
|
+
finished = true;
|
|
97
|
+
cleanup();
|
|
98
|
+
req.destroy(new Error(`Таймаут подключения к HTTP-прокси (${proxyHost}:${proxyPort})`));
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
req.on("error", (err) => {
|
|
102
|
+
if (finished) return;
|
|
103
|
+
finished = true;
|
|
104
|
+
cleanup();
|
|
105
|
+
reject(new Error(`Не удалось подключиться к HTTP-прокси (${proxyHost}:${proxyPort}): ${err.message}`));
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
req.end();
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Расширенный сокет для teleproto с поддержкой HTTP CONNECT и SOCKS5/4 туннелирования.
|
|
114
|
+
*/
|
|
115
|
+
export class TuiGramNetSockets extends PromisedNetSockets {
|
|
116
|
+
/**
|
|
117
|
+
* @param {object} [proxy]
|
|
118
|
+
* @param {number} [keepAliveInterval]
|
|
119
|
+
*/
|
|
120
|
+
constructor(proxy, keepAliveInterval) {
|
|
121
|
+
// Передаём undefined в базовый класс, чтобы обойти валидацию socksType для HTTP-прокси
|
|
122
|
+
super(undefined, keepAliveInterval);
|
|
123
|
+
this.proxy = proxy;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Устанавливает сетевое соединение с дата-центром Telegram.
|
|
128
|
+
* @param {number} port
|
|
129
|
+
* @param {string} ip
|
|
130
|
+
* @returns {Promise<this>}
|
|
131
|
+
*/
|
|
132
|
+
async connect(port, ip) {
|
|
133
|
+
this.chunks = [];
|
|
134
|
+
this.headOffset = 0;
|
|
135
|
+
this.available = 0;
|
|
136
|
+
let connected = false;
|
|
137
|
+
|
|
138
|
+
if (this.proxy) {
|
|
139
|
+
const proxyType = (this.proxy.type || "").toLowerCase();
|
|
140
|
+
const host = this.proxy.host || this.proxy.ip;
|
|
141
|
+
const proxyPort = Number(this.proxy.port);
|
|
142
|
+
const timeout = (this.proxy.timeout || 10) * 1000;
|
|
143
|
+
|
|
144
|
+
if (proxyType === "socks5" || proxyType === "socks4" || this.proxy.socksType) {
|
|
145
|
+
const socksType = this.proxy.socksType || (proxyType === "socks4" ? 4 : 5);
|
|
146
|
+
const info = await SocksClient.createConnection({
|
|
147
|
+
proxy: {
|
|
148
|
+
host,
|
|
149
|
+
port: proxyPort,
|
|
150
|
+
type: socksType,
|
|
151
|
+
userId: this.proxy.username,
|
|
152
|
+
password: this.proxy.password,
|
|
153
|
+
},
|
|
154
|
+
command: "connect",
|
|
155
|
+
timeout,
|
|
156
|
+
destination: {
|
|
157
|
+
host: ip,
|
|
158
|
+
port: port,
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
this.client = info.socket;
|
|
162
|
+
connected = true;
|
|
163
|
+
} else if (proxyType === "http" || proxyType === "https" || this.proxy.http) {
|
|
164
|
+
this.client = await createHttpConnectSocket({
|
|
165
|
+
isHttps: proxyType === "https",
|
|
166
|
+
proxyHost: host,
|
|
167
|
+
proxyPort: proxyPort,
|
|
168
|
+
targetHost: ip,
|
|
169
|
+
targetPort: port,
|
|
170
|
+
username: this.proxy.username,
|
|
171
|
+
password: this.proxy.password,
|
|
172
|
+
timeout,
|
|
173
|
+
});
|
|
174
|
+
connected = true;
|
|
175
|
+
} else {
|
|
176
|
+
throw new Error(`Неподдерживаемый тип прокси: ${this.proxy.type}`);
|
|
177
|
+
}
|
|
178
|
+
} else {
|
|
179
|
+
this.client = new net.Socket();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
this.canRead = new Promise((resolve) => {
|
|
183
|
+
this.resolveRead = resolve;
|
|
184
|
+
});
|
|
185
|
+
this.closed = false;
|
|
186
|
+
|
|
187
|
+
return new Promise((resolve, reject) => {
|
|
188
|
+
if (!this.client) {
|
|
189
|
+
return reject(new Error("Сетевой сокет не инициализирован"));
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const tune = (socket) => {
|
|
193
|
+
socket.setNoDelay(true);
|
|
194
|
+
socket.setKeepAlive(this.keepAliveInterval > 0, Math.max(0, this.keepAliveInterval));
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
if (connected) {
|
|
198
|
+
tune(this.client);
|
|
199
|
+
this.receive();
|
|
200
|
+
resolve(this);
|
|
201
|
+
} else {
|
|
202
|
+
this.client.connect(port, ip, () => {
|
|
203
|
+
tune(this.client);
|
|
204
|
+
this.receive();
|
|
205
|
+
resolve(this);
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
this.client.on("error", reject);
|
|
210
|
+
this.client.on("close", () => {
|
|
211
|
+
if (this.client && this.client.destroyed) {
|
|
212
|
+
if (this.resolveRead) {
|
|
213
|
+
this.resolveRead(false);
|
|
214
|
+
}
|
|
215
|
+
this.closed = true;
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
toString() {
|
|
222
|
+
return "TuiGramNetSocket";
|
|
223
|
+
}
|
|
224
|
+
}
|
package/src/ui/app.js
CHANGED
|
@@ -16,7 +16,7 @@ import { state } from "../state.js";
|
|
|
16
16
|
import { config } from "../config.js";
|
|
17
17
|
import { fetchDialogs } from "../telegram/dialogs.js";
|
|
18
18
|
import { setMessagePalette } from "../telegram/formatter.js";
|
|
19
|
-
import { fetchHistory, sendMessage, editMessage, deleteMessages, sendFiles, downloadMedia, sendReaction, markAsRead } from "../telegram/messages.js";
|
|
19
|
+
import { fetchHistory, sendMessage, editMessage, deleteMessages, sendFiles, downloadMedia, sendReaction, markAsRead, loadMessageImagePreview } from "../telegram/messages.js";
|
|
20
20
|
import { startTelegramListener } from "../telegram/listener.js";
|
|
21
21
|
import { logout } from "../telegram/auth.js";
|
|
22
22
|
import { ensureDir, inspectLocalFile } from "../utils/storage.js";
|
|
@@ -128,6 +128,7 @@ export async function startTui(client, me) {
|
|
|
128
128
|
state.setMessages(dialog.id, history.messages);
|
|
129
129
|
markAsRead(client, dialog.peerId).catch(() => {});
|
|
130
130
|
statusBar.showMessage(`Чат: ${dialog.title}`, "info");
|
|
131
|
+
fetchMissingImagePreviews(history.messages, dialog.id);
|
|
131
132
|
} catch (err) {
|
|
132
133
|
statusBar.showMessage(`Ошибка загрузки: ${err.message}`, "error");
|
|
133
134
|
}
|
|
@@ -157,6 +158,7 @@ export async function startTui(client, me) {
|
|
|
157
158
|
if (older.messages.length > 0) {
|
|
158
159
|
state.setMessages(state.activeChat.id, older.messages, true);
|
|
159
160
|
statusBar.showMessage(`Загружено ${older.messages.length} предыдущих сообщений`, "info");
|
|
161
|
+
fetchMissingImagePreviews(older.messages, state.activeChat.id);
|
|
160
162
|
}
|
|
161
163
|
} catch (err) {
|
|
162
164
|
statusBar.showMessage(`Ошибка пагинации: ${err.message}`, "error");
|
|
@@ -291,9 +293,13 @@ export async function startTui(client, me) {
|
|
|
291
293
|
chatView.setMessages(msgs);
|
|
292
294
|
});
|
|
293
295
|
|
|
294
|
-
state.on("messages_updated", ({ chatId, messages, isPrepend }) => {
|
|
296
|
+
state.on("messages_updated", ({ chatId, messages, isPrepend, isUpdate, isNewMessage }) => {
|
|
295
297
|
if (state.activeChat?.id === chatId) {
|
|
296
|
-
|
|
298
|
+
// Скроллим в самый низ только если это новое сообщение или первая загрузка чата.
|
|
299
|
+
// При подгрузке старых сообщений (isPrepend) или фоновых обновлениях (isUpdate)
|
|
300
|
+
// позиция скролла сохраняется!
|
|
301
|
+
const shouldScrollToBottom = isNewMessage ? config.autoScroll : (!isPrepend && !isUpdate);
|
|
302
|
+
chatView.setMessages(messages, shouldScrollToBottom);
|
|
297
303
|
}
|
|
298
304
|
});
|
|
299
305
|
|
|
@@ -316,11 +322,41 @@ export async function startTui(client, me) {
|
|
|
316
322
|
}
|
|
317
323
|
});
|
|
318
324
|
|
|
325
|
+
/**
|
|
326
|
+
* Фоново догружает превью изображений для сообщений, не имевших встроенного PhotoStrippedSize.
|
|
327
|
+
* @param {Array<object>} messages
|
|
328
|
+
* @param {string} chatId
|
|
329
|
+
*/
|
|
330
|
+
async function fetchMissingImagePreviews(messages, chatId) {
|
|
331
|
+
if (!config.showImages || !messages || messages.length === 0) return;
|
|
332
|
+
for (const msg of messages) {
|
|
333
|
+
if (state.activeChat?.id !== chatId) break;
|
|
334
|
+
if (msg.media && !msg.imagePreview && msg.rawMessage) {
|
|
335
|
+
const isPhoto = msg.media.className === "MessageMediaPhoto";
|
|
336
|
+
const isDoc = msg.media.className === "MessageMediaDocument";
|
|
337
|
+
if (isPhoto || isDoc) {
|
|
338
|
+
try {
|
|
339
|
+
const preview = await loadMessageImagePreview(client, msg.rawMessage);
|
|
340
|
+
if (preview && state.activeChat?.id === chatId) {
|
|
341
|
+
msg.imagePreview = preview;
|
|
342
|
+
state.updateMessage(chatId, msg);
|
|
343
|
+
}
|
|
344
|
+
} catch {
|
|
345
|
+
// Игнорируем сетевые ошибки фоновой загрузки превью
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
319
352
|
// 6. Подключение фонового слушателя MTProto событий
|
|
320
353
|
listener = startTelegramListener(client);
|
|
321
354
|
|
|
322
355
|
listener.on("new_message", ({ peerId, message }) => {
|
|
323
356
|
state.addMessage(peerId, message);
|
|
357
|
+
if (!message.imagePreview && (message.media?.className === "MessageMediaPhoto" || message.media?.className === "MessageMediaDocument")) {
|
|
358
|
+
fetchMissingImagePreviews([message], peerId);
|
|
359
|
+
}
|
|
324
360
|
|
|
325
361
|
if (state.activeChat?.id === peerId) {
|
|
326
362
|
markAsRead(client, state.activeChat.peerId).catch(() => {});
|
|
@@ -465,6 +501,21 @@ export async function startTui(client, me) {
|
|
|
465
501
|
screen.key(["tab"], () => moveFocus(1));
|
|
466
502
|
screen.key(["S-tab"], () => moveFocus(-1));
|
|
467
503
|
|
|
504
|
+
screen.key(["pageup", "C-u"], () => {
|
|
505
|
+
if (!state.activeChat) return;
|
|
506
|
+
chatView.scrollBox.scroll(-10);
|
|
507
|
+
if (chatView.scrollBox.getScroll() <= 0) {
|
|
508
|
+
chatView.loadMore?.();
|
|
509
|
+
}
|
|
510
|
+
screen.render();
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
screen.key(["pagedown", "C-d"], () => {
|
|
514
|
+
if (!state.activeChat) return;
|
|
515
|
+
chatView.scrollBox.scroll(10);
|
|
516
|
+
screen.render();
|
|
517
|
+
});
|
|
518
|
+
|
|
468
519
|
screen.key(["f1", "?"], () => {
|
|
469
520
|
releaseInputs();
|
|
470
521
|
helpModal.show();
|
|
@@ -38,7 +38,6 @@ export function createChatView(screen, theme, { onLoadMoreHistory, onActionMenu
|
|
|
38
38
|
bottom: 0,
|
|
39
39
|
tags: true,
|
|
40
40
|
scrollable: true,
|
|
41
|
-
alwaysScroll: true,
|
|
42
41
|
mouse: true,
|
|
43
42
|
keys: true,
|
|
44
43
|
vi: true,
|
|
@@ -99,8 +98,19 @@ export function createChatView(screen, theme, { onLoadMoreHistory, onActionMenu
|
|
|
99
98
|
|
|
100
99
|
// Текст сообщения и entities
|
|
101
100
|
let bodyText = formatMessageText(msg.text, msg.entities);
|
|
102
|
-
|
|
103
|
-
|
|
101
|
+
|
|
102
|
+
// Блок медиа-вложения и превью изображения
|
|
103
|
+
let mediaBlock = "";
|
|
104
|
+
if (msg.imagePreview) {
|
|
105
|
+
mediaBlock = msg.mediaDescription
|
|
106
|
+
? `${msg.mediaDescription}\n${msg.imagePreview}`
|
|
107
|
+
: msg.imagePreview;
|
|
108
|
+
} else if (msg.mediaDescription) {
|
|
109
|
+
mediaBlock = msg.mediaDescription;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (mediaBlock) {
|
|
113
|
+
bodyText = bodyText ? `${mediaBlock}\n${bodyText}` : mediaBlock;
|
|
104
114
|
}
|
|
105
115
|
|
|
106
116
|
// Отступ строк текста сообщения
|
|
@@ -135,26 +145,60 @@ export function createChatView(screen, theme, { onLoadMoreHistory, onActionMenu
|
|
|
135
145
|
*/
|
|
136
146
|
function setMessages(messages, autoScrollToBottom = true) {
|
|
137
147
|
currentMessages = messages;
|
|
148
|
+
const prevScroll = scrollBox.getScroll();
|
|
149
|
+
const prevHeight = scrollBox.getScrollHeight();
|
|
150
|
+
|
|
138
151
|
scrollBox.setContent(renderMessages(messages));
|
|
152
|
+
|
|
139
153
|
if (autoScrollToBottom) {
|
|
140
154
|
scrollBox.setScrollPerc(100);
|
|
155
|
+
} else {
|
|
156
|
+
// Сохраняем относительную позицию скролла: если добавились старые сообщения сверху,
|
|
157
|
+
// компенсируем сдвиг высоты ленты
|
|
158
|
+
const newHeight = scrollBox.getScrollHeight();
|
|
159
|
+
const addedLines = newHeight - prevHeight;
|
|
160
|
+
if (addedLines > 0 && prevScroll > 0) {
|
|
161
|
+
scrollBox.scrollTo(prevScroll + addedLines);
|
|
162
|
+
} else if (prevScroll > 0) {
|
|
163
|
+
scrollBox.scrollTo(prevScroll);
|
|
164
|
+
}
|
|
141
165
|
}
|
|
142
166
|
screen.render();
|
|
143
167
|
}
|
|
144
168
|
|
|
145
169
|
// Обработка прокрутки вверх для подгрузки истории
|
|
146
|
-
|
|
147
|
-
scrollBox.scroll(-
|
|
170
|
+
function handleScrollUp(step = 10) {
|
|
171
|
+
scrollBox.scroll(-step);
|
|
148
172
|
if (scrollBox.getScroll() <= 0) {
|
|
149
173
|
onLoadMoreHistory?.();
|
|
150
174
|
}
|
|
151
175
|
screen.render();
|
|
152
|
-
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function handleScrollDown(step = 10) {
|
|
179
|
+
scrollBox.scroll(step);
|
|
180
|
+
screen.render();
|
|
181
|
+
}
|
|
153
182
|
|
|
154
|
-
scrollBox.key(["
|
|
155
|
-
|
|
183
|
+
scrollBox.key(["pageup", "C-u"], () => handleScrollUp(10));
|
|
184
|
+
scrollBox.key(["pagedown", "C-d"], () => handleScrollDown(10));
|
|
185
|
+
scrollBox.key(["up", "k"], () => handleScrollUp(2));
|
|
186
|
+
scrollBox.key(["down", "j"], () => handleScrollDown(2));
|
|
187
|
+
scrollBox.key(["home"], () => {
|
|
188
|
+
scrollBox.scrollTo(0);
|
|
189
|
+
onLoadMoreHistory?.();
|
|
156
190
|
screen.render();
|
|
157
191
|
});
|
|
192
|
+
scrollBox.key(["end"], () => {
|
|
193
|
+
scrollBox.setScrollPerc(100);
|
|
194
|
+
screen.render();
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
scrollBox.on("wheelup", () => {
|
|
198
|
+
if (scrollBox.getScroll() <= 0) {
|
|
199
|
+
onLoadMoreHistory?.();
|
|
200
|
+
}
|
|
201
|
+
});
|
|
158
202
|
|
|
159
203
|
// Ctrl+M терминал шлёт как "\r" (имя клавиши "return"), поэтому меню действий
|
|
160
204
|
// висит на Ctrl+A — иначе оно недостижимо.
|
|
@@ -169,6 +213,7 @@ export function createChatView(screen, theme, { onLoadMoreHistory, onActionMenu
|
|
|
169
213
|
container,
|
|
170
214
|
scrollBox,
|
|
171
215
|
setMessages,
|
|
216
|
+
loadMore: () => onLoadMoreHistory?.(),
|
|
172
217
|
scrollToBottom: () => {
|
|
173
218
|
scrollBox.setScrollPerc(100);
|
|
174
219
|
screen.render();
|