@emaxe/tuigram 1.5.0 → 1.6.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/CHANGELOG.md +22 -1
- package/CHANGELOG.ru.md +22 -1
- package/README.md +6 -1
- package/README.ru.md +6 -1
- package/package.json +1 -1
- package/src/cli/formatters.js +2 -2
- package/src/telegram/entities.js +142 -7
- package/src/telegram/listener.js +57 -11
- package/src/telegram/messages.js +103 -12
- package/src/ui/app.js +101 -28
- package/src/ui/components/chatList.js +6 -0
- package/src/ui/components/chatView.js +134 -5
- package/src/ui/components/inputBox.js +52 -2
- package/src/ui/components/modals/helpModal.js +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -13,6 +13,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
13
13
|
|
|
14
14
|
- English localization of the TUI and CLI interface strings.
|
|
15
15
|
|
|
16
|
+
## [1.6.0] — 2026-09-05
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
|
|
20
|
+
- Message loading preloader: switching to a dialog now displays an animated loading indicator until message history is fetched, preventing premature "No messages yet" placeholder.
|
|
21
|
+
- Scroll-to-bottom button ("↓ Вниз") and `End` / `G` keyboard shortcuts to quickly jump to the latest messages in the active chat; button appears when scrolling up and hides automatically at the bottom.
|
|
22
|
+
- Automatic selection and opening of the first dialog upon application startup.
|
|
23
|
+
- Message input field locking for channels and groups where the user lacks posting permissions (showing lock icon 🔒 and restriction reason).
|
|
24
|
+
|
|
25
|
+
## [1.5.1] — 2026-09-03
|
|
26
|
+
|
|
27
|
+
### Fixed
|
|
28
|
+
|
|
29
|
+
- Message sender attribution across TUI and CLI: channel posts now display the channel title (along with author signature if present) instead of generic "Собеседник" or marking owner posts as "Вы ✓✓".
|
|
30
|
+
- Sender names in direct 1-on-1 chats and groups are now properly resolved and cached from Telegram entities instead of falling back to "Собеседник".
|
|
31
|
+
- Live typing indicator in the active chat now displays the typing user's display name instead of generic "Собеседник".
|
|
32
|
+
- Contextual reply preview bar in the input box displays the channel name when replying to channel posts.
|
|
33
|
+
- Resolution of Telegram Peer objects and channel IDs in `entityCache` and `parsePeer`.
|
|
34
|
+
|
|
16
35
|
## [1.5.0] — 2026-09-02
|
|
17
36
|
|
|
18
37
|
### Added
|
|
@@ -195,7 +214,9 @@ First public release.
|
|
|
195
214
|
- A WCAG contrast test (3:1 threshold) applied to every theme **after** conversion
|
|
196
215
|
to xterm-256 — the colors are checked exactly as the user sees them.
|
|
197
216
|
|
|
198
|
-
[Unreleased]: https://github.com/emaxe/tuigram/compare/v1.
|
|
217
|
+
[Unreleased]: https://github.com/emaxe/tuigram/compare/v1.6.0...HEAD
|
|
218
|
+
[1.6.0]: https://github.com/emaxe/tuigram/compare/v1.5.1...v1.6.0
|
|
219
|
+
[1.5.1]: https://github.com/emaxe/tuigram/compare/v1.5.0...v1.5.1
|
|
199
220
|
[1.5.0]: https://github.com/emaxe/tuigram/compare/v1.4.0...v1.5.0
|
|
200
221
|
[1.4.0]: https://github.com/emaxe/tuigram/compare/v1.3.0...v1.4.0
|
|
201
222
|
[1.3.0]: https://github.com/emaxe/tuigram/compare/v1.2.0...v1.3.0
|
package/CHANGELOG.ru.md
CHANGED
|
@@ -13,6 +13,25 @@
|
|
|
13
13
|
|
|
14
14
|
- Английская локализация строк интерфейса TUI и CLI.
|
|
15
15
|
|
|
16
|
+
## [1.6.0] — 2026-09-05
|
|
17
|
+
|
|
18
|
+
### Добавлено
|
|
19
|
+
|
|
20
|
+
- Прелоадер при переходе в диалог: во время сетевой подгрузки сообщений вместо текста «Сообщений пока нет..» отображается анимированный индикатор загрузки сообщений.
|
|
21
|
+
- Плавающая кнопка «↓ Вниз» и горячие клавиши `End` / `G` для быстрой прокрутки к последним сообщениям диалога; кнопка появляется при скролле вверх и автоматически скрывается внизу ленты.
|
|
22
|
+
- Автоматический выбор и открытие первого диалога в списке при запуске приложения.
|
|
23
|
+
- Автоматическая блокировка поля ввода сообщений в каналах и группах, где у пользователя нет прав на отправку сообщений (с отображением причины и иконки 🔒).
|
|
24
|
+
|
|
25
|
+
## [1.5.1] — 2026-09-03
|
|
26
|
+
|
|
27
|
+
### Исправлено
|
|
28
|
+
|
|
29
|
+
- Отображение авторов сообщений в TUI и CLI: посты в каналах теперь выводятся от имени канала (с подписью автора, если она включена в канале) вместо подстановки «Собеседник» или ошибочной отметки «Вы ✓✓» для постов владельца канала.
|
|
30
|
+
- Определение и кэширование имён отправителей в личных диалогах и группах: при входящих сообщениях и загрузке истории имена резолвятся из сущностей Telegram, предотвращая фоллбэк на «Собеседник».
|
|
31
|
+
- Индикатор набора текста в активном чате теперь отображает имя конкретного пользователя (например, «Sam печатает...») вместо обобщённого «Собеседник».
|
|
32
|
+
- В строке контекста ответа (Reply) в поле ввода для сообщений каналов отображается название канала вместо «Собеседник».
|
|
33
|
+
- Улучшена работа кэша сущностей (`entityCache`) с поддержкой маркированных и числовых ID, юзернеймов с префиксом `@` и без него, а также объектов Peer в `parsePeer`.
|
|
34
|
+
|
|
16
35
|
## [1.5.0] — 2026-09-02
|
|
17
36
|
|
|
18
37
|
### Добавлено
|
|
@@ -201,7 +220,9 @@
|
|
|
201
220
|
- Тест контраста по WCAG (порог 3:1) для каждой темы — **после** конверсии в xterm-256,
|
|
202
221
|
то есть ровно в том виде, в каком цвет увидит пользователь.
|
|
203
222
|
|
|
204
|
-
[Unreleased]: https://github.com/emaxe/tuigram/compare/v1.
|
|
223
|
+
[Unreleased]: https://github.com/emaxe/tuigram/compare/v1.6.0...HEAD
|
|
224
|
+
[1.6.0]: https://github.com/emaxe/tuigram/compare/v1.5.1...v1.6.0
|
|
225
|
+
[1.5.1]: https://github.com/emaxe/tuigram/compare/v1.5.0...v1.5.1
|
|
205
226
|
[1.5.0]: https://github.com/emaxe/tuigram/compare/v1.4.0...v1.5.0
|
|
206
227
|
[1.4.0]: https://github.com/emaxe/tuigram/compare/v1.3.0...v1.4.0
|
|
207
228
|
[1.3.0]: https://github.com/emaxe/tuigram/compare/v1.2.0...v1.3.0
|
package/README.md
CHANGED
|
@@ -72,11 +72,15 @@ TuiGram lets you use Telegram entirely from the terminal: browse your dialog lis
|
|
|
72
72
|
|
|
73
73
|
- **A complete interactive TUI**:
|
|
74
74
|
- Two-pane adaptive layout (dialog list on the left, history and input box on the right);
|
|
75
|
+
- Automatic selection and opening of the first dialog upon application startup;
|
|
75
76
|
- Keyboard and mouse control (left click selects a message, right click opens the action
|
|
76
77
|
menu, clicking an image opens it full screen, clicking a video plays it, wheel scrolling);
|
|
78
|
+
- Fast scroll-to-bottom ("↓ Вниз" button and `End` / `G` shortcuts);
|
|
79
|
+
- Animated preloader when switching dialogs until message history is loaded;
|
|
80
|
+
- Message input field locking for channels and groups without posting permissions (showing 🔒 and reason);
|
|
77
81
|
- Chat categories: All, Direct messages, Groups, Channels, Bots, Unread;
|
|
78
82
|
- Instant search and filtering of chats by title and `@username` (`/`);
|
|
79
|
-
- Live typing indicator
|
|
83
|
+
- Live typing indicator with sender display name in real time;
|
|
80
84
|
- Colored rendering of Telegram entities (bold, italic, monospace code, URLs, mentions, spoilers);
|
|
81
85
|
- High-resolution inline image previews (Unicode Half-Block `▀` pixel art);
|
|
82
86
|
- Terminal video and video note playback (Unicode Half-Block + synchronized audio);
|
|
@@ -475,6 +479,7 @@ TuiGram fully supports mouse interaction in terminals that support mouse reporti
|
|
|
475
479
|
| **Right click** | **Message in the feed** | **Open the action menu (reactions, reply, edit, download, delete)** |
|
|
476
480
|
| **Left click** | **Image preview inside a message** | **Open the image full screen** |
|
|
477
481
|
| **Left click** | **Video preview inside a message** | **Play video in modal player** |
|
|
482
|
+
| **Left click** | **"↓ Вниз" button in feed** | **Scroll down to latest messages (visible when scrolled up)** |
|
|
478
483
|
| Left click | Empty area of the feed | Focus the message feed |
|
|
479
484
|
| Left click | Header logo 🚀 | Open Help window |
|
|
480
485
|
| Left click | Header active chat title | Open Chat Info window |
|
package/README.ru.md
CHANGED
|
@@ -68,11 +68,15 @@
|
|
|
68
68
|
|
|
69
69
|
- **Полноценный интерактивный TUI**:
|
|
70
70
|
- Двухпанельный адаптивный интерфейс (список чатов слева + история и поле ввода справа);
|
|
71
|
+
- Автоматический выбор и открытие первого диалога при запуске;
|
|
71
72
|
- Поддержка управления клавиатурой и мышью (левый клик — выделение сообщения,
|
|
72
73
|
правый — меню действий, клик по картинке — просмотр на весь экран, клик по видео — воспроизведение, колесо — прокрутка);
|
|
74
|
+
- Быстрая прокрутка к последним сообщениям диалога (кнопка «↓ Вниз» и клавиши `End` / `G`);
|
|
75
|
+
- Анимированный прелоадер при переходе в диалог до загрузки истории сообщений;
|
|
76
|
+
- Блокировка поля ввода для каналов и чатов без прав на отправку сообщений (с иконкой 🔒 и причиной);
|
|
73
77
|
- Категории чатов: Все, Личные (ЛС), Группы, Каналы, Боты, Непрочитанные;
|
|
74
78
|
- Мгновенный поиск и фильтрация чатов по названию и `@username` (`/`);
|
|
75
|
-
- Отображение статуса набора текста
|
|
79
|
+
- Отображение статуса набора текста с именем собеседника в реальном времени;
|
|
76
80
|
- Цветовое форматирование Telegram Entities (Bold, Italic, Monospace Code, URLs, Mentions, Spoilers);
|
|
77
81
|
- Цветные превью изображений высокого разрешения прямо в чате (Unicode Half-Block `▀` pixel art);
|
|
78
82
|
- Воспроизведение видео и видеозаметок в псевдографике (Unicode Half-Block + синхронный звук);
|
|
@@ -467,6 +471,7 @@ TuiGram полноценно поддерживает управление мы
|
|
|
467
471
|
| **Правый клик** | **Сообщение в ленте** | **Меню действий (реакции, ответ, редактирование, скачивание, удаление)** |
|
|
468
472
|
| **Левый клик** | **Превью изображения в сообщении** | **Открытие картинки на весь экран** |
|
|
469
473
|
| **Левый клик** | **Превью видео в сообщении** | **Воспроизведение видео в модальном плеере** |
|
|
474
|
+
| **Левый клик** | **Кнопка «↓ Вниз» в ленте** | **Прокрутка к последним сообщениям диалога (видна при прокрутке вверх)** |
|
|
470
475
|
| Левый клик | Пустое место в ленте | Переключение фокуса на ленту сообщений |
|
|
471
476
|
| Левый клик | Логотип 🚀 в шапке | Открытие окна справки |
|
|
472
477
|
| Левый клик | Название активного чата в шапке | Открытие информации о текущем чате |
|
package/package.json
CHANGED
package/src/cli/formatters.js
CHANGED
|
@@ -30,7 +30,7 @@ export function formatDialogRow(d) {
|
|
|
30
30
|
export function formatHistoryMessage(m) {
|
|
31
31
|
const time = gray(`[${formatFullDateTime(m.date)}]`);
|
|
32
32
|
const id = gray(`#${m.id}`);
|
|
33
|
-
const author = m.out ? green(bold("Вы:")) : cyan(bold(`${m.senderName || "Собеседник"}:`));
|
|
33
|
+
const author = (m.out && !m.post) ? green(bold("Вы:")) : cyan(bold(`${m.senderName || (m.post ? "Канал" : "Собеседник")}:`));
|
|
34
34
|
const text = m.text || (m.mediaDescription ? m.mediaDescription.replace(/\{[a-z0-9\-]+\}/gi, "") : "");
|
|
35
35
|
const reply = m.replyToMsgId ? gray(` (в ответ на #${m.replyToMsgId})`) : "";
|
|
36
36
|
const edited = m.editDate ? gray(" (изменено)") : "";
|
|
@@ -50,7 +50,7 @@ export function formatStreamEvent(kind, data) {
|
|
|
50
50
|
case "new_message": {
|
|
51
51
|
const m = data.message;
|
|
52
52
|
const peer = yellow(`[${data.peerId}]`);
|
|
53
|
-
const author = m.out ? green("Вы") : cyan(m.senderName || "Собеседник");
|
|
53
|
+
const author = (m.out && !m.post) ? green("Вы") : cyan(m.senderName || (m.post ? "Канал" : "Собеседник"));
|
|
54
54
|
const text = m.text || "[медиа]";
|
|
55
55
|
return `${time} ${green("+ НОВОЕ")} ${peer} ${author}: ${text}`;
|
|
56
56
|
}
|
package/src/telegram/entities.js
CHANGED
|
@@ -49,6 +49,12 @@ export function toMarkedId(peer) {
|
|
|
49
49
|
* @returns {string|bigint}
|
|
50
50
|
*/
|
|
51
51
|
export function parsePeer(raw) {
|
|
52
|
+
if (raw && typeof raw === "object") {
|
|
53
|
+
const marked = toMarkedId(raw);
|
|
54
|
+
if (marked && marked !== "[object Object]") {
|
|
55
|
+
return parsePeer(marked);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
52
58
|
const value = String(raw || "").trim();
|
|
53
59
|
if (!value) throw new Error("Не указан идентификатор чата (peer)");
|
|
54
60
|
if (value === "me" || value === "self") return "me";
|
|
@@ -106,18 +112,67 @@ class EntityCache {
|
|
|
106
112
|
}
|
|
107
113
|
|
|
108
114
|
set(id, entity) {
|
|
109
|
-
if (!
|
|
110
|
-
|
|
111
|
-
|
|
115
|
+
if (!entity) return;
|
|
116
|
+
if (id !== null && id !== undefined) {
|
|
117
|
+
const key = idToString(id).trim().toLowerCase();
|
|
118
|
+
if (key) this.cache.set(key, entity);
|
|
119
|
+
}
|
|
120
|
+
if (entity.id !== null && entity.id !== undefined) {
|
|
121
|
+
const rawId = idToString(entity.id).trim().toLowerCase();
|
|
122
|
+
if (rawId) {
|
|
123
|
+
this.cache.set(rawId, entity);
|
|
124
|
+
if (entity.className === "Channel" || entity.broadcast || entity.megagroup) {
|
|
125
|
+
this.cache.set(`-100${rawId}`, entity);
|
|
126
|
+
} else if (entity.className === "Chat") {
|
|
127
|
+
this.cache.set(`-${rawId}`, entity);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
const marked = toMarkedId(entity);
|
|
132
|
+
if (marked && marked !== "[object Object]") {
|
|
133
|
+
this.cache.set(marked.toLowerCase(), entity);
|
|
134
|
+
}
|
|
112
135
|
if (entity.username) {
|
|
113
|
-
|
|
136
|
+
const u = entity.username.toLowerCase();
|
|
137
|
+
this.cache.set(`@${u}`, entity);
|
|
138
|
+
this.cache.set(u, entity);
|
|
114
139
|
}
|
|
115
140
|
}
|
|
116
141
|
|
|
117
142
|
get(idOrUsername) {
|
|
118
|
-
if (
|
|
119
|
-
|
|
120
|
-
|
|
143
|
+
if (idOrUsername === null || idOrUsername === undefined) return null;
|
|
144
|
+
let key = "";
|
|
145
|
+
if (typeof idOrUsername === "object") {
|
|
146
|
+
const marked = toMarkedId(idOrUsername);
|
|
147
|
+
key = (marked && marked !== "[object Object]" ? marked : idToString(idOrUsername)).trim().toLowerCase();
|
|
148
|
+
} else {
|
|
149
|
+
key = String(idOrUsername).trim().toLowerCase();
|
|
150
|
+
}
|
|
151
|
+
if (!key) return null;
|
|
152
|
+
|
|
153
|
+
let found = this.cache.get(key);
|
|
154
|
+
if (found) return found;
|
|
155
|
+
|
|
156
|
+
if (key.startsWith("-100")) {
|
|
157
|
+
found = this.cache.get(key.slice(4));
|
|
158
|
+
if (found) return found;
|
|
159
|
+
} else if (key.startsWith("-")) {
|
|
160
|
+
found = this.cache.get(key.slice(1));
|
|
161
|
+
if (found) return found;
|
|
162
|
+
} else if (/^\d+$/.test(key)) {
|
|
163
|
+
found = this.cache.get(`-100${key}`) || this.cache.get(`-${key}`);
|
|
164
|
+
if (found) return found;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (key.startsWith("@")) {
|
|
168
|
+
found = this.cache.get(key.slice(1));
|
|
169
|
+
if (found) return found;
|
|
170
|
+
} else {
|
|
171
|
+
found = this.cache.get(`@${key}`);
|
|
172
|
+
if (found) return found;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return null;
|
|
121
176
|
}
|
|
122
177
|
|
|
123
178
|
has(idOrUsername) {
|
|
@@ -156,3 +211,83 @@ export async function resolveEntity(client, rawPeer) {
|
|
|
156
211
|
throw err;
|
|
157
212
|
}
|
|
158
213
|
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Проверяет, разрешена ли отправка сообщений в указанный диалог.
|
|
217
|
+
* Учитывает тип чата, удалённые аккаунты, права администратора в каналах,
|
|
218
|
+
* общие и персональные ограничения (defaultBannedRights / bannedRights).
|
|
219
|
+
* @param {object} dialog нормализованный объект диалога
|
|
220
|
+
* @returns {{ canSend: boolean, reason?: string }}
|
|
221
|
+
*/
|
|
222
|
+
export function canSendMessages(dialog) {
|
|
223
|
+
if (!dialog) {
|
|
224
|
+
return { canSend: false, reason: "Чат не выбран" };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const entity = dialog.entity || dialog.rawDialog?.entity || {};
|
|
228
|
+
const className = entity.className || "";
|
|
229
|
+
|
|
230
|
+
// 1. Запрещённые или недоступные чаты
|
|
231
|
+
if (className === "ChatForbidden" || className === "ChannelForbidden") {
|
|
232
|
+
return { canSend: false, reason: "Доступ к чату запрещён" };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// 2. Личные чаты и пользователи
|
|
236
|
+
if (dialog.type === "saved" || entity.isSelf) {
|
|
237
|
+
return { canSend: true };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (dialog.type === "user" || className === "User") {
|
|
241
|
+
if (entity.deleted) {
|
|
242
|
+
return { canSend: false, reason: "Этот аккаунт удалён" };
|
|
243
|
+
}
|
|
244
|
+
return { canSend: true };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (dialog.type === "bot") {
|
|
248
|
+
return { canSend: true };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// 3. Каналы (Broadcast)
|
|
252
|
+
const isBroadcast = dialog.type === "channel" || Boolean(entity.broadcast);
|
|
253
|
+
if (isBroadcast) {
|
|
254
|
+
const isCreator = Boolean(entity.creator);
|
|
255
|
+
const hasPostRights = Boolean(entity.adminRights?.postMessages);
|
|
256
|
+
if (!isCreator && !hasPostRights) {
|
|
257
|
+
return { canSend: false, reason: "Отправка сообщений в этот канал недоступна" };
|
|
258
|
+
}
|
|
259
|
+
return { canSend: true };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// 4. Группы и супергруппы
|
|
263
|
+
if (entity.left) {
|
|
264
|
+
return { canSend: false, reason: "Вы покинули эту группу" };
|
|
265
|
+
}
|
|
266
|
+
if (entity.kicked) {
|
|
267
|
+
return { canSend: false, reason: "Вы исключены из этой группы" };
|
|
268
|
+
}
|
|
269
|
+
if (entity.deactivated) {
|
|
270
|
+
return { canSend: false, reason: "Эта группа деактивирована" };
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Создатель или админ всегда может писать в группе
|
|
274
|
+
if (entity.creator || entity.adminRights) {
|
|
275
|
+
return { canSend: true };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Персональные ограничения пользователя (bannedRights)
|
|
279
|
+
if (entity.bannedRights?.sendMessages) {
|
|
280
|
+
const until = entity.bannedRights.untilDate;
|
|
281
|
+
const isExpired = typeof until === "number" && until > 0 && until * 1000 <= Date.now();
|
|
282
|
+
if (!isExpired) {
|
|
283
|
+
return { canSend: false, reason: "Вам запрещено отправлять сообщения в этой группе" };
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Общие ограничения группы для участников (defaultBannedRights)
|
|
288
|
+
if (entity.defaultBannedRights?.sendMessages) {
|
|
289
|
+
return { canSend: false, reason: "Отправка сообщений в этой группе ограничена" };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
return { canSend: true };
|
|
293
|
+
}
|
package/src/telegram/listener.js
CHANGED
|
@@ -2,7 +2,7 @@ import { EventEmitter } from "node:events";
|
|
|
2
2
|
import { Api } from "teleproto";
|
|
3
3
|
import { NewMessage, EditedMessage, DeletedMessage, Raw } from "teleproto/events/index.js";
|
|
4
4
|
import { normalizeMessage } from "./messages.js";
|
|
5
|
-
import { idToString, toMarkedId, entityCache } from "./entities.js";
|
|
5
|
+
import { idToString, toMarkedId, entityCache, getEntityDisplayName } from "./entities.js";
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* Приводит идентификатор чата из сырого апдейта к маркированному виду (как dialog.id).
|
|
@@ -36,21 +36,39 @@ export function startTelegramListener(client) {
|
|
|
36
36
|
try {
|
|
37
37
|
const msg = event.message;
|
|
38
38
|
if (!msg) return;
|
|
39
|
-
const normalized = normalizeMessage(msg);
|
|
40
|
-
const peerId = normalized.peerId;
|
|
41
|
-
const fromId = normalized.fromId;
|
|
42
39
|
|
|
43
|
-
// Кэшируем
|
|
44
|
-
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
entityCache.set(sender.id, sender);
|
|
48
|
-
normalized.senderName = sender.title || [sender.firstName, sender.lastName].filter(Boolean).join(" ") || normalized.senderName;
|
|
40
|
+
// Кэшируем сущности события, если они пришли в апдейте
|
|
41
|
+
if (event.originalUpdate?._entities && typeof event.originalUpdate._entities.values === "function") {
|
|
42
|
+
for (const ent of event.originalUpdate._entities.values()) {
|
|
43
|
+
if (ent?.id) entityCache.set(ent.id, ent);
|
|
49
44
|
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
let sender = null;
|
|
48
|
+
try {
|
|
49
|
+
sender = await event.getSender?.();
|
|
50
|
+
if (sender) entityCache.set(sender.id, sender);
|
|
51
|
+
} catch {
|
|
52
|
+
// Игнорируем
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
let chat = null;
|
|
56
|
+
try {
|
|
57
|
+
chat = await event.getChat?.();
|
|
58
|
+
if (chat) entityCache.set(chat.id, chat);
|
|
50
59
|
} catch {
|
|
51
60
|
// Игнорируем
|
|
52
61
|
}
|
|
53
62
|
|
|
63
|
+
const normalized = normalizeMessage(msg, chat || sender);
|
|
64
|
+
if (sender && (!normalized.senderName || normalized.senderName === "Собеседник")) {
|
|
65
|
+
const baseName = getEntityDisplayName(sender);
|
|
66
|
+
normalized.senderName = msg.postAuthor ? `${baseName} (${msg.postAuthor})` : baseName;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const peerId = normalized.peerId;
|
|
70
|
+
const fromId = normalized.fromId;
|
|
71
|
+
|
|
54
72
|
bus.emit("new_message", {
|
|
55
73
|
peerId,
|
|
56
74
|
fromId,
|
|
@@ -67,7 +85,35 @@ export function startTelegramListener(client) {
|
|
|
67
85
|
try {
|
|
68
86
|
const msg = event.message;
|
|
69
87
|
if (!msg) return;
|
|
70
|
-
|
|
88
|
+
|
|
89
|
+
if (event.originalUpdate?._entities && typeof event.originalUpdate._entities.values === "function") {
|
|
90
|
+
for (const ent of event.originalUpdate._entities.values()) {
|
|
91
|
+
if (ent?.id) entityCache.set(ent.id, ent);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
let sender = null;
|
|
96
|
+
try {
|
|
97
|
+
sender = await event.getSender?.();
|
|
98
|
+
if (sender) entityCache.set(sender.id, sender);
|
|
99
|
+
} catch {
|
|
100
|
+
// Игнорируем
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
let chat = null;
|
|
104
|
+
try {
|
|
105
|
+
chat = await event.getChat?.();
|
|
106
|
+
if (chat) entityCache.set(chat.id, chat);
|
|
107
|
+
} catch {
|
|
108
|
+
// Игнорируем
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const normalized = normalizeMessage(msg, chat || sender);
|
|
112
|
+
if (sender && (!normalized.senderName || normalized.senderName === "Собеседник")) {
|
|
113
|
+
const baseName = getEntityDisplayName(sender);
|
|
114
|
+
normalized.senderName = msg.postAuthor ? `${baseName} (${msg.postAuthor})` : baseName;
|
|
115
|
+
}
|
|
116
|
+
|
|
71
117
|
bus.emit("edited_message", {
|
|
72
118
|
peerId: normalized.peerId,
|
|
73
119
|
message: normalized,
|
package/src/telegram/messages.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Api, errors } from "teleproto";
|
|
2
|
-
import { idToString, toMarkedId, getEntityDisplayName, entityCache, resolveEntity } from "./entities.js";
|
|
2
|
+
import { idToString, toMarkedId, detectChatType, getEntityDisplayName, entityCache, resolveEntity } from "./entities.js";
|
|
3
3
|
import { describeMedia } from "./formatter.js";
|
|
4
4
|
import { config } from "../config.js";
|
|
5
5
|
import { renderStrippedThumbnail, renderImageBuffer, renderMediaPreloader, getCachedImagePreview, isPreviewableMedia } from "../utils/image.js";
|
|
@@ -33,31 +33,77 @@ export function renderMessageThumbnail(rawMessage, {
|
|
|
33
33
|
|
|
34
34
|
// Полноэкранные рендеры не кэшируем: одна такая строка весит сотни килобайт
|
|
35
35
|
const cacheKey = useCache
|
|
36
|
-
? (
|
|
37
|
-
:
|
|
36
|
+
? (rawMessage.id ? `thumb_${rawMessage.id}@${maxWidth}x${maxHeight}` : null)
|
|
37
|
+
: null;
|
|
38
38
|
|
|
39
|
-
return renderStrippedThumbnail(stripped.bytes, { maxWidth, maxHeight, cacheKey })
|
|
39
|
+
return renderStrippedThumbnail(stripped.bytes, { maxWidth, maxHeight, cacheKey });
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
/**
|
|
43
43
|
* Преобразует объект Message из MTProto в нормализованный объект для TUI.
|
|
44
44
|
* @param {object} message
|
|
45
|
+
* @param {object|null} [chatEntity=null] сущность чата (канал, пользователь, группа)
|
|
45
46
|
* @returns {object}
|
|
46
47
|
*/
|
|
47
|
-
export function normalizeMessage(message) {
|
|
48
|
+
export function normalizeMessage(message, chatEntity = null) {
|
|
48
49
|
if (!message) return null;
|
|
49
50
|
|
|
50
51
|
// fromId остаётся немаркированным: по нему ищется сущность в entityCache,
|
|
51
52
|
// куда объекты кладутся по entity.id (тоже без маркера).
|
|
52
|
-
const fromId = idToString(message.fromId?.userId || message.fromId?.channelId || message.fromId?.chatId);
|
|
53
|
+
const fromId = idToString(message.fromId?.userId || message.fromId?.channelId || message.fromId?.chatId || message.senderId);
|
|
53
54
|
// peerId маркируется, чтобы совпадать с dialog.id (см. toMarkedId).
|
|
54
55
|
const peerId = toMarkedId(message.peerId);
|
|
55
56
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
57
|
+
const postAuthor = message.postAuthor ? String(message.postAuthor).trim() : null;
|
|
58
|
+
const isPost = Boolean(
|
|
59
|
+
message.post ||
|
|
60
|
+
chatEntity?.broadcast ||
|
|
61
|
+
(chatEntity && detectChatType({ entity: chatEntity }) === "channel")
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
// Ищем сущность отправителя:
|
|
65
|
+
// 1. Уже прикреплённый teleproto sender
|
|
66
|
+
// 2. Вложенный кэш сущностей сообщения message._entities
|
|
67
|
+
// 3. Глобальный entityCache
|
|
68
|
+
let senderEntity = message.sender || null;
|
|
69
|
+
if (!senderEntity && message._entities && typeof message._entities.get === "function") {
|
|
70
|
+
senderEntity = (fromId && message._entities.get(fromId)) ||
|
|
71
|
+
(message.senderId && message._entities.get(idToString(message.senderId))) ||
|
|
72
|
+
(message.fromId && message._entities.get(toMarkedId(message.fromId))) ||
|
|
73
|
+
null;
|
|
74
|
+
}
|
|
75
|
+
if (!senderEntity) {
|
|
76
|
+
senderEntity = (fromId && entityCache.get(fromId)) ||
|
|
77
|
+
(message.senderId && entityCache.get(message.senderId)) ||
|
|
78
|
+
(message.fromId && entityCache.get(message.fromId)) ||
|
|
79
|
+
null;
|
|
80
|
+
}
|
|
81
|
+
if (senderEntity && senderEntity.id) {
|
|
82
|
+
entityCache.set(senderEntity.id, senderEntity);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
let senderName = "";
|
|
86
|
+
if (isPost) {
|
|
87
|
+
// В вещательных каналах автор — сам канал (или подпись автора)
|
|
88
|
+
const channelEntity = senderEntity || chatEntity || message.chat || entityCache.get(peerId) || null;
|
|
89
|
+
const channelTitle = channelEntity ? getEntityDisplayName(channelEntity) : (chatEntity?.title || "");
|
|
90
|
+
if (postAuthor) {
|
|
91
|
+
senderName = channelTitle ? `${channelTitle} (${postAuthor})` : postAuthor;
|
|
92
|
+
} else {
|
|
93
|
+
senderName = channelTitle || "Канал";
|
|
94
|
+
}
|
|
95
|
+
} else if (message.out) {
|
|
96
|
+
senderName = "Вы";
|
|
97
|
+
} else if (senderEntity) {
|
|
98
|
+
const baseName = getEntityDisplayName(senderEntity);
|
|
99
|
+
senderName = postAuthor ? `${baseName} (${postAuthor})` : baseName;
|
|
100
|
+
} else if (chatEntity && (chatEntity.className === "User" || detectChatType({ entity: chatEntity }) === "user")) {
|
|
101
|
+
// В личном диалоге (1-на-1) входящее сообщение всегда от собеседника чата
|
|
102
|
+
senderName = getEntityDisplayName(chatEntity);
|
|
103
|
+
} else if (postAuthor) {
|
|
104
|
+
senderName = postAuthor;
|
|
105
|
+
} else {
|
|
106
|
+
senderName = "Собеседник";
|
|
61
107
|
}
|
|
62
108
|
|
|
63
109
|
// Реакции
|
|
@@ -105,6 +151,8 @@ export function normalizeMessage(message) {
|
|
|
105
151
|
date: message.date ? message.date * 1000 : Date.now(),
|
|
106
152
|
editDate: message.editDate ? message.editDate * 1000 : null,
|
|
107
153
|
out: Boolean(message.out),
|
|
154
|
+
post: isPost,
|
|
155
|
+
postAuthor,
|
|
108
156
|
text: message.message || "",
|
|
109
157
|
fromId,
|
|
110
158
|
senderName,
|
|
@@ -140,7 +188,15 @@ export async function fetchHistory(client, rawPeer, { limit = 40, offsetId = 0,
|
|
|
140
188
|
const load = async () => {
|
|
141
189
|
for await (const msg of client.iterMessages(entity, { limit, offsetId, reverse })) {
|
|
142
190
|
if (msg && msg.className !== "MessageEmpty") {
|
|
143
|
-
|
|
191
|
+
// Сохраняем сущности, которые Telegram прислал вместе с сообщениями
|
|
192
|
+
if (msg._entities && typeof msg._entities.values === "function") {
|
|
193
|
+
for (const ent of msg._entities.values()) {
|
|
194
|
+
if (ent?.id) {
|
|
195
|
+
entityCache.set(ent.id, ent);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
messages.push(normalizeMessage(msg, entity));
|
|
144
200
|
}
|
|
145
201
|
}
|
|
146
202
|
if (!reverse) {
|
|
@@ -161,6 +217,41 @@ export async function fetchHistory(client, rawPeer, { limit = 40, offsetId = 0,
|
|
|
161
217
|
}
|
|
162
218
|
}
|
|
163
219
|
|
|
220
|
+
// Резолвим отправителей, которых ещё нет в кэше сущностей,
|
|
221
|
+
// чтобы в диалоге отображались реальные имена, а не «Собеседник».
|
|
222
|
+
for (const msg of messages) {
|
|
223
|
+
if (!msg.out && (!msg.senderName || msg.senderName === "Собеседник")) {
|
|
224
|
+
// 1. Попытка через rawMessage.getSender()
|
|
225
|
+
if (msg.rawMessage?.getSender) {
|
|
226
|
+
try {
|
|
227
|
+
const sender = await msg.rawMessage.getSender();
|
|
228
|
+
if (sender) {
|
|
229
|
+
entityCache.set(sender.id, sender);
|
|
230
|
+
const baseName = getEntityDisplayName(sender);
|
|
231
|
+
msg.senderName = msg.postAuthor ? `${baseName} (${msg.postAuthor})` : baseName;
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
} catch {
|
|
235
|
+
// Игнорируем
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// 2. Попытка через resolveEntity по targetPeer
|
|
240
|
+
const targetPeer = msg.rawMessage?.fromId || msg.fromId || msg.rawMessage?.peerId || msg.peerId;
|
|
241
|
+
if (targetPeer) {
|
|
242
|
+
try {
|
|
243
|
+
const sender = await resolveEntity(client, targetPeer);
|
|
244
|
+
if (sender) {
|
|
245
|
+
const baseName = getEntityDisplayName(sender);
|
|
246
|
+
msg.senderName = msg.postAuthor ? `${baseName} (${msg.postAuthor})` : baseName;
|
|
247
|
+
}
|
|
248
|
+
} catch {
|
|
249
|
+
// Имя не резолвится — останется fallback
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
164
255
|
return {
|
|
165
256
|
peer: entity,
|
|
166
257
|
messages,
|
package/src/ui/app.js
CHANGED
|
@@ -16,6 +16,7 @@ import { createImageViewerModal } from "./components/modals/imageViewerModal.js"
|
|
|
16
16
|
import { createVideoPlayerModal } from "./components/modals/videoPlayerModal.js";
|
|
17
17
|
import { state } from "../state.js";
|
|
18
18
|
import { config } from "../config.js";
|
|
19
|
+
import { entityCache, getEntityDisplayName, resolveEntity, canSendMessages } from "../telegram/entities.js";
|
|
19
20
|
import { fetchDialogs } from "../telegram/dialogs.js";
|
|
20
21
|
import { setMessagePalette } from "../telegram/formatter.js";
|
|
21
22
|
import { fetchHistory, sendMessage, editMessage, deleteMessages, sendFiles, downloadMedia, sendReaction, markAsRead, loadMessageImagePreview, downloadImageBuffer, renderMessageThumbnail, findFirstUnreadMessage, calculateRemainingUnreadCount } from "../telegram/messages.js";
|
|
@@ -205,10 +206,18 @@ export async function startTui(client, me) {
|
|
|
205
206
|
videoPlayerModal.play(msg);
|
|
206
207
|
break;
|
|
207
208
|
case "reply":
|
|
208
|
-
inputBox.
|
|
209
|
+
if (inputBox.isDisabled()) {
|
|
210
|
+
statusBar.showMessage(inputBox.getDisabledReason() || "Отправка сообщений в этот чат недоступна", "warning");
|
|
211
|
+
} else {
|
|
212
|
+
inputBox.setContext("reply", msg);
|
|
213
|
+
}
|
|
209
214
|
break;
|
|
210
215
|
case "edit":
|
|
211
|
-
inputBox.
|
|
216
|
+
if (inputBox.isDisabled()) {
|
|
217
|
+
statusBar.showMessage(inputBox.getDisabledReason() || "Редактирование в этом чате недоступно", "warning");
|
|
218
|
+
} else {
|
|
219
|
+
inputBox.setContext("edit", msg);
|
|
220
|
+
}
|
|
212
221
|
break;
|
|
213
222
|
case "delete":
|
|
214
223
|
releaseInputs();
|
|
@@ -296,29 +305,43 @@ export async function startTui(client, me) {
|
|
|
296
305
|
}
|
|
297
306
|
}
|
|
298
307
|
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
308
|
+
/**
|
|
309
|
+
* Открывает диалог, загружает его историю и отображает сообщения.
|
|
310
|
+
* @param {object} dialog
|
|
311
|
+
*/
|
|
312
|
+
async function openDialog(dialog) {
|
|
313
|
+
if (!dialog) return;
|
|
314
|
+
flushPendingMarkAsRead();
|
|
315
|
+
state.setActiveChat(dialog);
|
|
316
|
+
chatView.resetReadState(dialog.readInboxMaxId || 0);
|
|
317
|
+
|
|
318
|
+
// Если сообщения диалога ещё не подгружены в память, показываем прелоадер
|
|
319
|
+
const cached = state.getMessages(dialog.id);
|
|
320
|
+
if (!cached || cached.length === 0) {
|
|
321
|
+
chatView.showLoading();
|
|
322
|
+
}
|
|
323
|
+
statusBar.showMessage(`Загрузка сообщений: ${dialog.title}...`, "info");
|
|
306
324
|
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
}
|
|
325
|
+
try {
|
|
326
|
+
// Загружаем всю пачку непрочитанных сообщений + запас из 20 прочитанных
|
|
327
|
+
// для контекста и правильного позиционирования разделителя
|
|
328
|
+
const limit = Math.max(50, Math.min(1000, (dialog.unreadCount || 0) + 20));
|
|
329
|
+
const history = await fetchHistory(client, dialog.peerId, { limit });
|
|
330
|
+
if (state.activeChat?.id !== dialog.id) return;
|
|
331
|
+
const firstUnread = findFirstUnreadMessage(history.messages, dialog);
|
|
332
|
+
state.setMessages(dialog.id, history.messages, { firstUnreadId: firstUnread?.id || null });
|
|
333
|
+
statusBar.showMessage(`Чат: ${dialog.title}`, "info");
|
|
334
|
+
} catch (err) {
|
|
335
|
+
if (state.activeChat?.id === dialog.id) {
|
|
336
|
+
chatView.setMessages([], false);
|
|
337
|
+
statusBar.showMessage(`Ошибка загрузки: ${err.message}`, "error");
|
|
320
338
|
}
|
|
321
|
-
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// 2. Список диалогов (левая панель)
|
|
343
|
+
const chatList = createChatList(screen, theme, {
|
|
344
|
+
onSelectDialog: (dialog) => openDialog(dialog),
|
|
322
345
|
onTabChange: (tab) => {
|
|
323
346
|
state.setFilterTab(tab);
|
|
324
347
|
},
|
|
@@ -509,6 +532,10 @@ export async function startTui(client, me) {
|
|
|
509
532
|
|
|
510
533
|
inputBox.container.on("click", (data) => {
|
|
511
534
|
if (isRightClick(data)) return;
|
|
535
|
+
if (inputBox.isDisabled()) {
|
|
536
|
+
statusBar.showMessage(inputBox.getDisabledReason() || "Отправка сообщений в этот чат недоступна", "warning", 3000);
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
512
539
|
if (screen.focused !== inputBox.textarea) {
|
|
513
540
|
inputBox.focus();
|
|
514
541
|
statusBar.showMessage("Фокус: поле ввода", "info", 2000);
|
|
@@ -537,6 +564,11 @@ export async function startTui(client, me) {
|
|
|
537
564
|
activeChat: chat,
|
|
538
565
|
typingUser: state.getTypingUser(chat?.id),
|
|
539
566
|
});
|
|
567
|
+
|
|
568
|
+
// Проверяем возможность отправки сообщений в выбранный чат
|
|
569
|
+
const sendCheck = canSendMessages(chat);
|
|
570
|
+
inputBox.setDisabled(!sendCheck.canSend, sendCheck.reason);
|
|
571
|
+
|
|
540
572
|
const msgs = chat ? state.getMessages(chat.id) : [];
|
|
541
573
|
chatView.setSelected(null);
|
|
542
574
|
if (chat && msgs.length > 0) {
|
|
@@ -546,6 +578,8 @@ export async function startTui(client, me) {
|
|
|
546
578
|
} else {
|
|
547
579
|
chatView.setMessages(msgs, true);
|
|
548
580
|
}
|
|
581
|
+
} else if (chat) {
|
|
582
|
+
chatView.showLoading();
|
|
549
583
|
} else {
|
|
550
584
|
chatView.setMessages([], false);
|
|
551
585
|
}
|
|
@@ -668,8 +702,16 @@ export async function startTui(client, me) {
|
|
|
668
702
|
state.removeMessages(peerId, deletedIds);
|
|
669
703
|
});
|
|
670
704
|
|
|
671
|
-
listener.on("typing", ({ chatId, userId }) => {
|
|
672
|
-
|
|
705
|
+
listener.on("typing", async ({ chatId, userId }) => {
|
|
706
|
+
let cached = entityCache.get(userId);
|
|
707
|
+
if (!cached && userId) {
|
|
708
|
+
try {
|
|
709
|
+
cached = await resolveEntity(client, userId);
|
|
710
|
+
} catch {
|
|
711
|
+
// Фоллбэк
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
state.setTyping(chatId, cached ? getEntityDisplayName(cached) : "Собеседник");
|
|
673
715
|
});
|
|
674
716
|
|
|
675
717
|
/**
|
|
@@ -684,6 +726,10 @@ export async function startTui(client, me) {
|
|
|
684
726
|
statusBar.showMessage("Сначала выберите чат для отправки файла!", "warning");
|
|
685
727
|
return;
|
|
686
728
|
}
|
|
729
|
+
if (inputBox.isDisabled()) {
|
|
730
|
+
statusBar.showMessage(inputBox.getDisabledReason() || "Отправка файлов в этот чат недоступна", "warning");
|
|
731
|
+
return;
|
|
732
|
+
}
|
|
687
733
|
if (!files || files.length === 0) return;
|
|
688
734
|
|
|
689
735
|
const chat = state.activeChat;
|
|
@@ -755,6 +801,10 @@ export async function startTui(client, me) {
|
|
|
755
801
|
/** Включает режим ответа на выделенное сообщение, иначе — на последнее в ленте. */
|
|
756
802
|
function startReply() {
|
|
757
803
|
if (!state.activeChat) return;
|
|
804
|
+
if (inputBox.isDisabled()) {
|
|
805
|
+
statusBar.showMessage(inputBox.getDisabledReason() || "Отправка сообщений в этот чат недоступна", "warning");
|
|
806
|
+
return;
|
|
807
|
+
}
|
|
758
808
|
const target = chatView.getTargetMessage();
|
|
759
809
|
if (target) {
|
|
760
810
|
inputBox.setContext("reply", target);
|
|
@@ -764,6 +814,10 @@ export async function startTui(client, me) {
|
|
|
764
814
|
/** Включает правку выделенного своего сообщения, иначе — последнего своего. */
|
|
765
815
|
function startEdit() {
|
|
766
816
|
if (!state.activeChat) return;
|
|
817
|
+
if (inputBox.isDisabled()) {
|
|
818
|
+
statusBar.showMessage(inputBox.getDisabledReason() || "Редактирование в этом чате недоступно", "warning");
|
|
819
|
+
return;
|
|
820
|
+
}
|
|
767
821
|
const selected = chatView.getSelected();
|
|
768
822
|
if (selected?.out) {
|
|
769
823
|
inputBox.setContext("edit", selected);
|
|
@@ -800,8 +854,13 @@ export async function startTui(client, me) {
|
|
|
800
854
|
}
|
|
801
855
|
|
|
802
856
|
function moveFocus(step) {
|
|
803
|
-
|
|
804
|
-
|
|
857
|
+
let current = FOCUS_ORDER.indexOf(detectFocus());
|
|
858
|
+
let nextIndex = (current + step + FOCUS_ORDER.length) % FOCUS_ORDER.length;
|
|
859
|
+
let next = FOCUS_ORDER[nextIndex];
|
|
860
|
+
if (next === "input" && inputBox.isDisabled()) {
|
|
861
|
+
nextIndex = (nextIndex + step + FOCUS_ORDER.length) % FOCUS_ORDER.length;
|
|
862
|
+
next = FOCUS_ORDER[nextIndex];
|
|
863
|
+
}
|
|
805
864
|
// Сначала выпускаем клавиатуру из активного поля ввода, иначе оно
|
|
806
865
|
// заберёт фокус обратно своим обработчиком blur.
|
|
807
866
|
releaseInputs();
|
|
@@ -835,6 +894,11 @@ export async function startTui(client, me) {
|
|
|
835
894
|
screen.render();
|
|
836
895
|
});
|
|
837
896
|
|
|
897
|
+
screen.key(["end", "C-end"], () => {
|
|
898
|
+
if (!state.activeChat || screen.focused === inputBox.textarea) return;
|
|
899
|
+
chatView.scrollToBottom();
|
|
900
|
+
});
|
|
901
|
+
|
|
838
902
|
screen.key(["f1", "?"], () => {
|
|
839
903
|
releaseInputs();
|
|
840
904
|
helpModal.show();
|
|
@@ -852,6 +916,10 @@ export async function startTui(client, me) {
|
|
|
852
916
|
|
|
853
917
|
screen.key(["C-o"], () => {
|
|
854
918
|
if (state.activeChat) {
|
|
919
|
+
if (inputBox.isDisabled()) {
|
|
920
|
+
statusBar.showMessage(inputBox.getDisabledReason() || "Отправка файлов в этот чат недоступна", "warning");
|
|
921
|
+
return;
|
|
922
|
+
}
|
|
855
923
|
releaseInputs();
|
|
856
924
|
fileModal.show();
|
|
857
925
|
} else {
|
|
@@ -909,7 +977,12 @@ export async function startTui(client, me) {
|
|
|
909
977
|
try {
|
|
910
978
|
const dialogs = await fetchDialogs(client, { limit: 100 });
|
|
911
979
|
state.setDialogs(dialogs);
|
|
912
|
-
|
|
980
|
+
if (dialogs.length > 0) {
|
|
981
|
+
chatList.selectIndex(0);
|
|
982
|
+
await openDialog(dialogs[0]);
|
|
983
|
+
} else {
|
|
984
|
+
statusBar.showMessage("Список диалогов пуст", "info");
|
|
985
|
+
}
|
|
913
986
|
} catch (err) {
|
|
914
987
|
statusBar.showMessage(`Ошибка загрузки диалогов: ${err.message}`, "error");
|
|
915
988
|
}
|
|
@@ -404,6 +404,12 @@ export function createChatList(screen, theme, { onSelectDialog, onTabChange, onS
|
|
|
404
404
|
currentTab = tab;
|
|
405
405
|
renderTabs();
|
|
406
406
|
},
|
|
407
|
+
selectIndex: (index) => {
|
|
408
|
+
if (index >= 0 && index < currentDialogs.length) {
|
|
409
|
+
list.select(index);
|
|
410
|
+
screen.render();
|
|
411
|
+
}
|
|
412
|
+
},
|
|
407
413
|
focus: () => list.focus(),
|
|
408
414
|
/** Завершает режим ввода в строке поиска (см. inputBox.release). */
|
|
409
415
|
release: () => {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import blessed from "neo-blessed";
|
|
2
2
|
import { formatMessageTime, formatDateDivider } from "../../utils/time.js";
|
|
3
3
|
import { formatMessageText, escapeBlessed } from "../../telegram/formatter.js";
|
|
4
|
-
import { fg } from "../theme.js";
|
|
4
|
+
import { fg, badge } from "../theme.js";
|
|
5
5
|
|
|
6
6
|
import { getMessagePartAtPoint, isRightClick, stringCellWidth } from "../../utils/mouse.js";
|
|
7
7
|
import { isMessageVideo } from "../../utils/video.js";
|
|
@@ -85,6 +85,23 @@ export function createChatView(screen, theme, {
|
|
|
85
85
|
scrollBox.removeAllListeners("wheelup");
|
|
86
86
|
scrollBox.removeAllListeners("wheeldown");
|
|
87
87
|
|
|
88
|
+
// Кнопка быстрой прокрутки к последним сообщениям
|
|
89
|
+
const scrollBottomBtn = blessed.box({
|
|
90
|
+
parent: container,
|
|
91
|
+
bottom: 0,
|
|
92
|
+
right: 2,
|
|
93
|
+
height: 1,
|
|
94
|
+
width: 10,
|
|
95
|
+
tags: true,
|
|
96
|
+
mouse: true,
|
|
97
|
+
hidden: true,
|
|
98
|
+
content: badge(theme.accent, theme.onAccent, "{bold} ↓ Вниз {/bold}"),
|
|
99
|
+
style: {
|
|
100
|
+
bg: theme.accent,
|
|
101
|
+
fg: theme.onAccent,
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
|
|
88
105
|
/** Подсвечивает рамку, когда лента сообщений в фокусе. */
|
|
89
106
|
function setFocusHighlight(active) {
|
|
90
107
|
container.style.border.fg = active ? theme.borders.focusFg : theme.borders.fg;
|
|
@@ -100,6 +117,90 @@ export function createChatView(screen, theme, {
|
|
|
100
117
|
let currentFirstUnreadId = null;
|
|
101
118
|
let lastReportedMaxReadId = 0;
|
|
102
119
|
|
|
120
|
+
let isLoading = false;
|
|
121
|
+
let spinnerTimer = null;
|
|
122
|
+
let spinnerIndex = 0;
|
|
123
|
+
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
124
|
+
|
|
125
|
+
function getLoadingContent() {
|
|
126
|
+
const frame = SPINNER_FRAMES[spinnerIndex % SPINNER_FRAMES.length];
|
|
127
|
+
return `\n\n ${fg(theme.accent, `${frame} Загрузка сообщений...`)}`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function showLoading() {
|
|
131
|
+
isLoading = true;
|
|
132
|
+
currentMessages = [];
|
|
133
|
+
currentRanges = [];
|
|
134
|
+
selectedId = null;
|
|
135
|
+
updateScrollBottomBtn();
|
|
136
|
+
if (spinnerTimer) {
|
|
137
|
+
clearInterval(spinnerTimer);
|
|
138
|
+
spinnerTimer = null;
|
|
139
|
+
}
|
|
140
|
+
scrollBox.setContent(getLoadingContent());
|
|
141
|
+
screen.render();
|
|
142
|
+
spinnerTimer = setInterval(() => {
|
|
143
|
+
if (!isLoading) {
|
|
144
|
+
if (spinnerTimer) {
|
|
145
|
+
clearInterval(spinnerTimer);
|
|
146
|
+
spinnerTimer = null;
|
|
147
|
+
}
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
spinnerIndex++;
|
|
151
|
+
scrollBox.setContent(getLoadingContent());
|
|
152
|
+
screen.render();
|
|
153
|
+
}, 80);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function stopLoading() {
|
|
157
|
+
isLoading = false;
|
|
158
|
+
if (spinnerTimer) {
|
|
159
|
+
clearInterval(spinnerTimer);
|
|
160
|
+
spinnerTimer = null;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function isAtBottom() {
|
|
165
|
+
const totalLines = scrollBox._clines?.length || scrollBox.getScrollHeight() || 0;
|
|
166
|
+
const visible = getVisibleHeight();
|
|
167
|
+
if (totalLines <= visible) return true;
|
|
168
|
+
const maxBase = Math.max(0, totalLines - visible);
|
|
169
|
+
const current = scrollBox.childBase || 0;
|
|
170
|
+
return current >= maxBase - 1;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function updateScrollBottomBtn() {
|
|
174
|
+
if (isLoading || currentMessages.length === 0 || isAtBottom()) {
|
|
175
|
+
if (!scrollBottomBtn.hidden) {
|
|
176
|
+
scrollBottomBtn.hide();
|
|
177
|
+
screen.render();
|
|
178
|
+
}
|
|
179
|
+
} else {
|
|
180
|
+
if (scrollBottomBtn.hidden) {
|
|
181
|
+
scrollBottomBtn.show();
|
|
182
|
+
scrollBottomBtn.setFront();
|
|
183
|
+
screen.render();
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function handleScrollToBottomAction() {
|
|
189
|
+
scrollToBottom();
|
|
190
|
+
screen.render();
|
|
191
|
+
checkVisibleMessages();
|
|
192
|
+
updateScrollBottomBtn();
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
scrollBottomBtn.on("click", (data) => {
|
|
196
|
+
if (isRightClick(data)) return;
|
|
197
|
+
handleScrollToBottomAction();
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
scrollBottomBtn.on("press", () => {
|
|
201
|
+
handleScrollToBottomAction();
|
|
202
|
+
});
|
|
203
|
+
|
|
103
204
|
// Кэш отформатированных строк сообщений для мгновенной перерисовки и прокрутки ленты
|
|
104
205
|
const messageLinesCache = new Map();
|
|
105
206
|
const MAX_LINES_CACHE = 1000;
|
|
@@ -123,11 +224,11 @@ export function createChatView(screen, theme, {
|
|
|
123
224
|
|
|
124
225
|
// Отправитель
|
|
125
226
|
let authorTag = "";
|
|
126
|
-
if (msg.out) {
|
|
227
|
+
if (msg.out && !msg.post) {
|
|
127
228
|
const readCheck = fg(theme.chatView.outgoingName, "✓✓");
|
|
128
229
|
authorTag = `${fg(theme.chatView.outgoingName, "{bold}Вы{/bold}")} ${timeTag} ${readCheck}`;
|
|
129
230
|
} else {
|
|
130
|
-
const name = escapeBlessed(msg.senderName || "Собеседник");
|
|
231
|
+
const name = escapeBlessed(msg.senderName || (msg.post ? "Канал" : "Собеседник"));
|
|
131
232
|
authorTag = `${fg(theme.chatView.incomingName, `{bold}${name}{/bold}`)} ${timeTag}`;
|
|
132
233
|
}
|
|
133
234
|
|
|
@@ -198,6 +299,13 @@ export function createChatView(screen, theme, {
|
|
|
198
299
|
* @returns {{ text: string, ranges: Array<object> }}
|
|
199
300
|
*/
|
|
200
301
|
function renderMessagesWithRanges(messages, firstUnreadId = null) {
|
|
302
|
+
if (isLoading) {
|
|
303
|
+
return {
|
|
304
|
+
text: getLoadingContent(),
|
|
305
|
+
ranges: [],
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
|
|
201
309
|
if (!messages || messages.length === 0) {
|
|
202
310
|
return {
|
|
203
311
|
text: `\n\n ${fg(theme.muted, "Сообщений пока нет. Напишите первое сообщение ниже!")}`,
|
|
@@ -287,6 +395,7 @@ export function createChatView(screen, theme, {
|
|
|
287
395
|
const clamped = Math.max(0, Math.min(targetLine, maxBase));
|
|
288
396
|
scrollBox.childBase = clamped;
|
|
289
397
|
scrollBox.childOffset = 0;
|
|
398
|
+
updateScrollBottomBtn();
|
|
290
399
|
}
|
|
291
400
|
|
|
292
401
|
/**
|
|
@@ -297,6 +406,7 @@ export function createChatView(screen, theme, {
|
|
|
297
406
|
const visible = getVisibleHeight();
|
|
298
407
|
scrollBox.childBase = Math.max(0, totalLines - visible);
|
|
299
408
|
scrollBox.childOffset = 0;
|
|
409
|
+
updateScrollBottomBtn();
|
|
300
410
|
}
|
|
301
411
|
|
|
302
412
|
/** Перерисовывает ленту, сохраняя позицию прокрутки (например, после смены выделения). */
|
|
@@ -306,6 +416,7 @@ export function createChatView(screen, theme, {
|
|
|
306
416
|
currentRanges = rendered.ranges;
|
|
307
417
|
scrollBox.setContent(rendered.text);
|
|
308
418
|
scrollToLine(prevBase);
|
|
419
|
+
updateScrollBottomBtn();
|
|
309
420
|
screen.render();
|
|
310
421
|
}
|
|
311
422
|
|
|
@@ -360,6 +471,7 @@ export function createChatView(screen, theme, {
|
|
|
360
471
|
* @param {boolean|object} [scrollOption=true]
|
|
361
472
|
*/
|
|
362
473
|
function setMessages(messages, scrollOption = true) {
|
|
474
|
+
stopLoading();
|
|
363
475
|
currentMessages = messages;
|
|
364
476
|
const prevScroll = scrollBox.childBase || 0;
|
|
365
477
|
const prevHeight = scrollBox.getScrollHeight();
|
|
@@ -413,6 +525,7 @@ export function createChatView(screen, theme, {
|
|
|
413
525
|
}
|
|
414
526
|
screen.render();
|
|
415
527
|
checkVisibleMessages();
|
|
528
|
+
updateScrollBottomBtn();
|
|
416
529
|
}
|
|
417
530
|
|
|
418
531
|
/**
|
|
@@ -522,6 +635,7 @@ export function createChatView(screen, theme, {
|
|
|
522
635
|
}
|
|
523
636
|
screen.render();
|
|
524
637
|
checkVisibleMessages();
|
|
638
|
+
updateScrollBottomBtn();
|
|
525
639
|
}
|
|
526
640
|
|
|
527
641
|
function handleScrollDown(step = 10) {
|
|
@@ -529,6 +643,7 @@ export function createChatView(screen, theme, {
|
|
|
529
643
|
scrollToLine(current + step);
|
|
530
644
|
screen.render();
|
|
531
645
|
checkVisibleMessages();
|
|
646
|
+
updateScrollBottomBtn();
|
|
532
647
|
}
|
|
533
648
|
|
|
534
649
|
scrollBox.key(["pageup", "C-u"], () => handleScrollUp(10));
|
|
@@ -540,11 +655,13 @@ export function createChatView(screen, theme, {
|
|
|
540
655
|
onLoadMoreHistory?.();
|
|
541
656
|
screen.render();
|
|
542
657
|
checkVisibleMessages();
|
|
658
|
+
updateScrollBottomBtn();
|
|
543
659
|
});
|
|
544
|
-
scrollBox.key(["end"], () => {
|
|
660
|
+
scrollBox.key(["end", "G", "C-end"], () => {
|
|
545
661
|
scrollToBottom();
|
|
546
662
|
screen.render();
|
|
547
663
|
checkVisibleMessages();
|
|
664
|
+
updateScrollBottomBtn();
|
|
548
665
|
});
|
|
549
666
|
|
|
550
667
|
scrollBox.on("wheelup", () => handleScrollUp(3));
|
|
@@ -596,14 +713,20 @@ export function createChatView(screen, theme, {
|
|
|
596
713
|
}
|
|
597
714
|
});
|
|
598
715
|
|
|
599
|
-
// Отслеживание прокрутки для обновления прочитанных сообщений
|
|
716
|
+
// Отслеживание прокрутки для обновления прочитанных сообщений и видимости кнопки [Вниз]
|
|
600
717
|
scrollBox.on("scroll", () => {
|
|
601
718
|
checkVisibleMessages();
|
|
719
|
+
updateScrollBottomBtn();
|
|
720
|
+
});
|
|
721
|
+
|
|
722
|
+
screen.on("resize", () => {
|
|
723
|
+
updateScrollBottomBtn();
|
|
602
724
|
});
|
|
603
725
|
|
|
604
726
|
return {
|
|
605
727
|
container,
|
|
606
728
|
scrollBox,
|
|
729
|
+
scrollBottomBtn,
|
|
607
730
|
setMessages,
|
|
608
731
|
setSelected,
|
|
609
732
|
getSelected,
|
|
@@ -612,6 +735,7 @@ export function createChatView(screen, theme, {
|
|
|
612
735
|
resetReadState: (initialReadMaxId = 0) => {
|
|
613
736
|
lastReportedMaxReadId = initialReadMaxId;
|
|
614
737
|
currentFirstUnreadId = null;
|
|
738
|
+
updateScrollBottomBtn();
|
|
615
739
|
},
|
|
616
740
|
checkVisibleMessages,
|
|
617
741
|
loadMore: () => onLoadMoreHistory?.(),
|
|
@@ -619,7 +743,12 @@ export function createChatView(screen, theme, {
|
|
|
619
743
|
scrollToBottom();
|
|
620
744
|
screen.render();
|
|
621
745
|
checkVisibleMessages();
|
|
746
|
+
updateScrollBottomBtn();
|
|
622
747
|
},
|
|
623
748
|
focus: () => scrollBox.focus(),
|
|
749
|
+
showLoading,
|
|
750
|
+
stopLoading,
|
|
751
|
+
isLoading: () => isLoading,
|
|
752
|
+
isScrollBottomVisible: () => !scrollBottomBtn.hidden,
|
|
624
753
|
};
|
|
625
754
|
}
|
|
@@ -91,13 +91,23 @@ export function createInputBox(screen, theme, {
|
|
|
91
91
|
|
|
92
92
|
let currentMode = null; // null | "reply" | "edit"
|
|
93
93
|
let currentTarget = null; // object message
|
|
94
|
+
let isDisabled = false;
|
|
95
|
+
let disabledReason = "";
|
|
94
96
|
|
|
95
97
|
const history = [];
|
|
96
98
|
let historyIndex = -1;
|
|
97
99
|
|
|
98
100
|
function renderContext() {
|
|
101
|
+
if (isDisabled) {
|
|
102
|
+
contextBar.setContent(
|
|
103
|
+
` ${fg(theme.warning, "🔒")} ${fg(theme.dim, disabledReason || "Отправка сообщений недоступна в этом чате")}`
|
|
104
|
+
);
|
|
105
|
+
screen.render();
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
99
109
|
if (currentMode === "reply" && currentTarget) {
|
|
100
|
-
const author = escapeBlessed(currentTarget.senderName || "Собеседник");
|
|
110
|
+
const author = escapeBlessed(currentTarget.senderName || (currentTarget.post ? "Канал" : "Собеседник"));
|
|
101
111
|
const preview = escapeBlessed((currentTarget.text || "").slice(0, 30));
|
|
102
112
|
contextBar.setContent(
|
|
103
113
|
badge(theme.input.replyBg, theme.input.replyFg,
|
|
@@ -124,7 +134,7 @@ export function createInputBox(screen, theme, {
|
|
|
124
134
|
}
|
|
125
135
|
|
|
126
136
|
contextBar.on("click", (data) => {
|
|
127
|
-
if (isRightClick(data)) return;
|
|
137
|
+
if (isDisabled || isRightClick(data)) return;
|
|
128
138
|
const relX = data.x - (contextBar.aleft || 0);
|
|
129
139
|
const action = getInputContextActionAt(relX, currentMode);
|
|
130
140
|
if (action === "cancel") {
|
|
@@ -146,13 +156,31 @@ export function createInputBox(screen, theme, {
|
|
|
146
156
|
});
|
|
147
157
|
|
|
148
158
|
textarea.on("click", () => {
|
|
159
|
+
if (isDisabled) return;
|
|
149
160
|
if (screen.focused !== textarea) {
|
|
150
161
|
textarea.focus();
|
|
151
162
|
screen.render();
|
|
152
163
|
}
|
|
153
164
|
});
|
|
154
165
|
|
|
166
|
+
const origReadInput = textarea.readInput.bind(textarea);
|
|
167
|
+
textarea.readInput = function(callback) {
|
|
168
|
+
if (isDisabled) {
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
return origReadInput(callback);
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
const origSetValue = textarea.setValue.bind(textarea);
|
|
175
|
+
textarea.setValue = function(val) {
|
|
176
|
+
if (isDisabled && val) {
|
|
177
|
+
return origSetValue("");
|
|
178
|
+
}
|
|
179
|
+
return origSetValue(val);
|
|
180
|
+
};
|
|
181
|
+
|
|
155
182
|
textarea.key(["enter"], () => {
|
|
183
|
+
if (isDisabled) return;
|
|
156
184
|
const value = textarea.getValue().trim();
|
|
157
185
|
if (!value) {
|
|
158
186
|
// Textarea уже успел дописать перевод строки в своём обработчике — убираем его.
|
|
@@ -251,10 +279,32 @@ export function createInputBox(screen, theme, {
|
|
|
251
279
|
screen.render();
|
|
252
280
|
},
|
|
253
281
|
focus: () => {
|
|
282
|
+
if (isDisabled) return;
|
|
254
283
|
if (screen.focused !== textarea) {
|
|
255
284
|
textarea.focus();
|
|
256
285
|
}
|
|
257
286
|
},
|
|
287
|
+
/**
|
|
288
|
+
* Блокирует или разблокирует поле ввода сообщений.
|
|
289
|
+
* @param {boolean} disabled
|
|
290
|
+
* @param {string} [reason=""]
|
|
291
|
+
*/
|
|
292
|
+
setDisabled: (disabled, reason = "") => {
|
|
293
|
+
isDisabled = Boolean(disabled);
|
|
294
|
+
disabledReason = reason;
|
|
295
|
+
if (isDisabled) {
|
|
296
|
+
currentMode = null;
|
|
297
|
+
currentTarget = null;
|
|
298
|
+
textarea.setValue("");
|
|
299
|
+
if (textarea._reading && typeof textarea._done === "function") {
|
|
300
|
+
textarea._done("stop");
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
renderContext();
|
|
304
|
+
screen.render();
|
|
305
|
+
},
|
|
306
|
+
isDisabled: () => isDisabled,
|
|
307
|
+
getDisabledReason: () => disabledReason,
|
|
258
308
|
/**
|
|
259
309
|
* Завершает режим ввода, отдавая фокус предыдущей панели.
|
|
260
310
|
* Нужно вызывать перед открытием модального окна: иначе textarea по blur
|
|
@@ -57,6 +57,7 @@ export function createHelpModal(screen, theme) {
|
|
|
57
57
|
${K_CYAN}Правый клик по сообщению${K_END} Меню действий над сообщением
|
|
58
58
|
${K_CYAN}Клик по превью фото/видео${K_END}Открыть изображение или воспроизвести видео
|
|
59
59
|
${K_CYAN} [Space] / [r]${K_END} — в видеоплеере: пауза / перезапуск
|
|
60
|
+
${K_CYAN}Кнопка [↓ Вниз]${K_END} Прокрутить ленту к последним сообщениям
|
|
60
61
|
${K_CYAN}Клик по вкладкам/кнопкам${K_END} Переключение фильтров и вызов действий
|
|
61
62
|
${K_GRAY}macOS Terminal.app перехватывает правый клик — там пользуйтесь [Enter] или [Ctrl+A]${K_END}
|
|
62
63
|
${K_CYAN}[F12]${K_END} Отдать мышь терминалу, чтобы выделить и скопировать текст
|