@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 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.5.0...HEAD
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.5.0...HEAD
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 ("… is typing");
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@emaxe/tuigram",
3
- "version": "1.5.0",
3
+ "version": "1.6.0",
4
4
  "description": "Полнофункциональный TUI & CLI клиент Telegram на Node.js на базе MTProto (teleproto)",
5
5
  "type": "module",
6
6
  "exports": {
@@ -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
  }
@@ -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 (!id || !entity) return;
110
- const key = idToString(id);
111
- this.cache.set(key, entity);
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
- this.cache.set(`@${entity.username.toLowerCase()}`, entity);
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 (!idOrUsername) return null;
119
- const key = String(idOrUsername).toLowerCase();
120
- return this.cache.get(key) || this.cache.get(idToString(idOrUsername)) || null;
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
+ }
@@ -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
- try {
45
- const sender = await event.getSender?.();
46
- if (sender) {
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
- const normalized = normalizeMessage(msg);
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,
@@ -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
- ? (photo?.id ? `photo_${photo.id}` : (doc?.id ? `doc_${doc.id}` : `msg_${rawMessage.id}`))
37
- : undefined;
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
- let senderName = message.out ? "Вы" : "Собеседник";
58
- const cachedSender = entityCache.get(fromId);
59
- if (cachedSender) {
60
- senderName = getEntityDisplayName(cachedSender);
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
- messages.push(normalizeMessage(msg));
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.setContext("reply", msg);
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.setContext("edit", msg);
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
- // 2. Список диалогов (левая панель)
300
- const chatList = createChatList(screen, theme, {
301
- onSelectDialog: async (dialog) => {
302
- flushPendingMarkAsRead();
303
- state.setActiveChat(dialog);
304
- chatView.resetReadState(dialog.readInboxMaxId || 0);
305
- statusBar.showMessage(`Загрузка сообщений: ${dialog.title}...`, "info");
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
- try {
308
- // Загружаем всю пачку непрочитанных сообщений + запас из 20 прочитанных
309
- // для контекста и правильного позиционирования разделителя
310
- const limit = Math.max(50, Math.min(1000, (dialog.unreadCount || 0) + 20));
311
- const history = await fetchHistory(client, dialog.peerId, { limit });
312
- if (state.activeChat?.id !== dialog.id) return;
313
- const firstUnread = findFirstUnreadMessage(history.messages, dialog);
314
- state.setMessages(dialog.id, history.messages, { firstUnreadId: firstUnread?.id || null });
315
- statusBar.showMessage(`Чат: ${dialog.title}`, "info");
316
- } catch (err) {
317
- if (state.activeChat?.id === dialog.id) {
318
- statusBar.showMessage(`Ошибка загрузки: ${err.message}`, "error");
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
- state.setTyping(chatId, "Собеседник");
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
- const current = FOCUS_ORDER.indexOf(detectFocus());
804
- const next = FOCUS_ORDER[(current + step + FOCUS_ORDER.length) % FOCUS_ORDER.length];
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
- statusBar.showMessage(`Загружено ${dialogs.length} диалогов. Выберите чат и нажмите Enter.`, "success");
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} Отдать мышь терминалу, чтобы выделить и скопировать текст