@emaxe/tuigram 1.5.1 → 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,15 @@ 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
+
16
25
  ## [1.5.1] — 2026-09-03
17
26
 
18
27
  ### Fixed
@@ -205,7 +214,8 @@ First public release.
205
214
  - A WCAG contrast test (3:1 threshold) applied to every theme **after** conversion
206
215
  to xterm-256 — the colors are checked exactly as the user sees them.
207
216
 
208
- [Unreleased]: https://github.com/emaxe/tuigram/compare/v1.5.1...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
209
219
  [1.5.1]: https://github.com/emaxe/tuigram/compare/v1.5.0...v1.5.1
210
220
  [1.5.0]: https://github.com/emaxe/tuigram/compare/v1.4.0...v1.5.0
211
221
  [1.4.0]: https://github.com/emaxe/tuigram/compare/v1.3.0...v1.4.0
package/CHANGELOG.ru.md CHANGED
@@ -13,6 +13,15 @@
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
+
16
25
  ## [1.5.1] — 2026-09-03
17
26
 
18
27
  ### Исправлено
@@ -211,7 +220,8 @@
211
220
  - Тест контраста по WCAG (порог 3:1) для каждой темы — **после** конверсии в xterm-256,
212
221
  то есть ровно в том виде, в каком цвет увидит пользователь.
213
222
 
214
- [Unreleased]: https://github.com/emaxe/tuigram/compare/v1.5.1...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
215
225
  [1.5.1]: https://github.com/emaxe/tuigram/compare/v1.5.0...v1.5.1
216
226
  [1.5.0]: https://github.com/emaxe/tuigram/compare/v1.4.0...v1.5.0
217
227
  [1.4.0]: https://github.com/emaxe/tuigram/compare/v1.3.0...v1.4.0
package/README.md CHANGED
@@ -72,8 +72,12 @@ 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
83
  - Live typing indicator with sender display name in real time;
@@ -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,8 +68,12 @@
68
68
 
69
69
  - **Полноценный интерактивный TUI**:
70
70
  - Двухпанельный адаптивный интерфейс (список чатов слева + история и поле ввода справа);
71
+ - Автоматический выбор и открытие первого диалога при запуске;
71
72
  - Поддержка управления клавиатурой и мышью (левый клик — выделение сообщения,
72
73
  правый — меню действий, клик по картинке — просмотр на весь экран, клик по видео — воспроизведение, колесо — прокрутка);
74
+ - Быстрая прокрутка к последним сообщениям диалога (кнопка «↓ Вниз» и клавиши `End` / `G`);
75
+ - Анимированный прелоадер при переходе в диалог до загрузки истории сообщений;
76
+ - Блокировка поля ввода для каналов и чатов без прав на отправку сообщений (с иконкой 🔒 и причиной);
73
77
  - Категории чатов: Все, Личные (ЛС), Группы, Каналы, Боты, Непрочитанные;
74
78
  - Мгновенный поиск и фильтрация чатов по названию и `@username` (`/`);
75
79
  - Отображение статуса набора текста с именем собеседника в реальном времени;
@@ -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.1",
3
+ "version": "1.6.0",
4
4
  "description": "Полнофункциональный TUI & CLI клиент Telegram на Node.js на базе MTProto (teleproto)",
5
5
  "type": "module",
6
6
  "exports": {
@@ -211,3 +211,83 @@ export async function resolveEntity(client, rawPeer) {
211
211
  throw err;
212
212
  }
213
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/ui/app.js CHANGED
@@ -16,7 +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 } from "../telegram/entities.js";
19
+ import { entityCache, getEntityDisplayName, resolveEntity, canSendMessages } from "../telegram/entities.js";
20
20
  import { fetchDialogs } from "../telegram/dialogs.js";
21
21
  import { setMessagePalette } from "../telegram/formatter.js";
22
22
  import { fetchHistory, sendMessage, editMessage, deleteMessages, sendFiles, downloadMedia, sendReaction, markAsRead, loadMessageImagePreview, downloadImageBuffer, renderMessageThumbnail, findFirstUnreadMessage, calculateRemainingUnreadCount } from "../telegram/messages.js";
@@ -206,10 +206,18 @@ export async function startTui(client, me) {
206
206
  videoPlayerModal.play(msg);
207
207
  break;
208
208
  case "reply":
209
- inputBox.setContext("reply", msg);
209
+ if (inputBox.isDisabled()) {
210
+ statusBar.showMessage(inputBox.getDisabledReason() || "Отправка сообщений в этот чат недоступна", "warning");
211
+ } else {
212
+ inputBox.setContext("reply", msg);
213
+ }
210
214
  break;
211
215
  case "edit":
212
- inputBox.setContext("edit", msg);
216
+ if (inputBox.isDisabled()) {
217
+ statusBar.showMessage(inputBox.getDisabledReason() || "Редактирование в этом чате недоступно", "warning");
218
+ } else {
219
+ inputBox.setContext("edit", msg);
220
+ }
213
221
  break;
214
222
  case "delete":
215
223
  releaseInputs();
@@ -297,29 +305,43 @@ export async function startTui(client, me) {
297
305
  }
298
306
  }
299
307
 
300
- // 2. Список диалогов (левая панель)
301
- const chatList = createChatList(screen, theme, {
302
- onSelectDialog: async (dialog) => {
303
- flushPendingMarkAsRead();
304
- state.setActiveChat(dialog);
305
- chatView.resetReadState(dialog.readInboxMaxId || 0);
306
- 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");
307
324
 
308
- try {
309
- // Загружаем всю пачку непрочитанных сообщений + запас из 20 прочитанных
310
- // для контекста и правильного позиционирования разделителя
311
- const limit = Math.max(50, Math.min(1000, (dialog.unreadCount || 0) + 20));
312
- const history = await fetchHistory(client, dialog.peerId, { limit });
313
- if (state.activeChat?.id !== dialog.id) return;
314
- const firstUnread = findFirstUnreadMessage(history.messages, dialog);
315
- state.setMessages(dialog.id, history.messages, { firstUnreadId: firstUnread?.id || null });
316
- statusBar.showMessage(`Чат: ${dialog.title}`, "info");
317
- } catch (err) {
318
- if (state.activeChat?.id === dialog.id) {
319
- statusBar.showMessage(`Ошибка загрузки: ${err.message}`, "error");
320
- }
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");
321
338
  }
322
- },
339
+ }
340
+ }
341
+
342
+ // 2. Список диалогов (левая панель)
343
+ const chatList = createChatList(screen, theme, {
344
+ onSelectDialog: (dialog) => openDialog(dialog),
323
345
  onTabChange: (tab) => {
324
346
  state.setFilterTab(tab);
325
347
  },
@@ -510,6 +532,10 @@ export async function startTui(client, me) {
510
532
 
511
533
  inputBox.container.on("click", (data) => {
512
534
  if (isRightClick(data)) return;
535
+ if (inputBox.isDisabled()) {
536
+ statusBar.showMessage(inputBox.getDisabledReason() || "Отправка сообщений в этот чат недоступна", "warning", 3000);
537
+ return;
538
+ }
513
539
  if (screen.focused !== inputBox.textarea) {
514
540
  inputBox.focus();
515
541
  statusBar.showMessage("Фокус: поле ввода", "info", 2000);
@@ -538,6 +564,11 @@ export async function startTui(client, me) {
538
564
  activeChat: chat,
539
565
  typingUser: state.getTypingUser(chat?.id),
540
566
  });
567
+
568
+ // Проверяем возможность отправки сообщений в выбранный чат
569
+ const sendCheck = canSendMessages(chat);
570
+ inputBox.setDisabled(!sendCheck.canSend, sendCheck.reason);
571
+
541
572
  const msgs = chat ? state.getMessages(chat.id) : [];
542
573
  chatView.setSelected(null);
543
574
  if (chat && msgs.length > 0) {
@@ -547,6 +578,8 @@ export async function startTui(client, me) {
547
578
  } else {
548
579
  chatView.setMessages(msgs, true);
549
580
  }
581
+ } else if (chat) {
582
+ chatView.showLoading();
550
583
  } else {
551
584
  chatView.setMessages([], false);
552
585
  }
@@ -693,6 +726,10 @@ export async function startTui(client, me) {
693
726
  statusBar.showMessage("Сначала выберите чат для отправки файла!", "warning");
694
727
  return;
695
728
  }
729
+ if (inputBox.isDisabled()) {
730
+ statusBar.showMessage(inputBox.getDisabledReason() || "Отправка файлов в этот чат недоступна", "warning");
731
+ return;
732
+ }
696
733
  if (!files || files.length === 0) return;
697
734
 
698
735
  const chat = state.activeChat;
@@ -764,6 +801,10 @@ export async function startTui(client, me) {
764
801
  /** Включает режим ответа на выделенное сообщение, иначе — на последнее в ленте. */
765
802
  function startReply() {
766
803
  if (!state.activeChat) return;
804
+ if (inputBox.isDisabled()) {
805
+ statusBar.showMessage(inputBox.getDisabledReason() || "Отправка сообщений в этот чат недоступна", "warning");
806
+ return;
807
+ }
767
808
  const target = chatView.getTargetMessage();
768
809
  if (target) {
769
810
  inputBox.setContext("reply", target);
@@ -773,6 +814,10 @@ export async function startTui(client, me) {
773
814
  /** Включает правку выделенного своего сообщения, иначе — последнего своего. */
774
815
  function startEdit() {
775
816
  if (!state.activeChat) return;
817
+ if (inputBox.isDisabled()) {
818
+ statusBar.showMessage(inputBox.getDisabledReason() || "Редактирование в этом чате недоступно", "warning");
819
+ return;
820
+ }
776
821
  const selected = chatView.getSelected();
777
822
  if (selected?.out) {
778
823
  inputBox.setContext("edit", selected);
@@ -809,8 +854,13 @@ export async function startTui(client, me) {
809
854
  }
810
855
 
811
856
  function moveFocus(step) {
812
- const current = FOCUS_ORDER.indexOf(detectFocus());
813
- 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
+ }
814
864
  // Сначала выпускаем клавиатуру из активного поля ввода, иначе оно
815
865
  // заберёт фокус обратно своим обработчиком blur.
816
866
  releaseInputs();
@@ -844,6 +894,11 @@ export async function startTui(client, me) {
844
894
  screen.render();
845
895
  });
846
896
 
897
+ screen.key(["end", "C-end"], () => {
898
+ if (!state.activeChat || screen.focused === inputBox.textarea) return;
899
+ chatView.scrollToBottom();
900
+ });
901
+
847
902
  screen.key(["f1", "?"], () => {
848
903
  releaseInputs();
849
904
  helpModal.show();
@@ -861,6 +916,10 @@ export async function startTui(client, me) {
861
916
 
862
917
  screen.key(["C-o"], () => {
863
918
  if (state.activeChat) {
919
+ if (inputBox.isDisabled()) {
920
+ statusBar.showMessage(inputBox.getDisabledReason() || "Отправка файлов в этот чат недоступна", "warning");
921
+ return;
922
+ }
864
923
  releaseInputs();
865
924
  fileModal.show();
866
925
  } else {
@@ -918,7 +977,12 @@ export async function startTui(client, me) {
918
977
  try {
919
978
  const dialogs = await fetchDialogs(client, { limit: 100 });
920
979
  state.setDialogs(dialogs);
921
- 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
+ }
922
986
  } catch (err) {
923
987
  statusBar.showMessage(`Ошибка загрузки диалогов: ${err.message}`, "error");
924
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;
@@ -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,11 +91,21 @@ 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
110
  const author = escapeBlessed(currentTarget.senderName || (currentTarget.post ? "Канал" : "Собеседник"));
101
111
  const preview = escapeBlessed((currentTarget.text || "").slice(0, 30));
@@ -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} Отдать мышь терминалу, чтобы выделить и скопировать текст