@emaxe/tuigram 1.2.0 → 1.3.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 +19 -1
- package/CHANGELOG.ru.md +19 -1
- package/README.md +23 -0
- package/README.ru.md +23 -0
- package/package.json +1 -1
- package/src/ui/app.js +115 -2
- package/src/ui/components/chatList.js +62 -0
- package/src/ui/components/chatView.js +65 -8
- package/src/ui/components/header.js +24 -1
- package/src/ui/components/inputBox.js +40 -1
- package/src/ui/components/modals/actionModal.js +31 -0
- package/src/ui/components/modals/chatInfoModal.js +19 -0
- package/src/ui/components/modals/confirmModal.js +18 -0
- package/src/ui/components/modals/fileModal.js +33 -1
- package/src/ui/components/modals/filePickerModal.js +19 -0
- package/src/ui/components/modals/helpModal.js +24 -0
- package/src/ui/components/statusBar.js +53 -1
- package/src/ui/screen.js +52 -3
- package/src/utils/mouse.js +236 -0
package/CHANGELOG.md
CHANGED
|
@@ -13,6 +13,23 @@ 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.3.0] — 2026-09-01
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
|
|
20
|
+
- Comprehensive mouse support across the entire TUI:
|
|
21
|
+
- Direct panel focus on click (left panel for dialog list, middle panel for message feed, bottom panel for input box).
|
|
22
|
+
- Single-click dialog selection and immediate opening.
|
|
23
|
+
- Smooth mouse wheel scrolling for dialog list and message feed with automatic loading of older history when scrolling up past the top.
|
|
24
|
+
- Clicking on any message opens its contextual action menu (reactions, replies, edits, downloads, deletion).
|
|
25
|
+
- Clickable filter tabs (`1:Все`, `2:ЛС`, `3:Группы`, `4:Каналы`, `5:Боты`, `6:Непроч`) and search input focusing.
|
|
26
|
+
- Interactive status bar buttons (`[Tab]`, `[1-6]`, `[/]`, `[F1]`, `[Ctrl+A]`, `[Ctrl+P]`, `[Ctrl+Q]`).
|
|
27
|
+
- Interactive header elements: click logo for Help, click chat title for Chat Info, click status badge for network state.
|
|
28
|
+
- Interactive context bar in input box: click to cancel reply/edit mode or trigger shortcuts.
|
|
29
|
+
- Full mouse navigation inside modals (button clicks, checkbox toggles, file browsing) and dismissing any modal by clicking outside its boundary.
|
|
30
|
+
- Robust terminal mouse mode initialization (`\x1b[?1000h\x1b[?1002h\x1b[?1006h\x1b[?1015h`) supporting modern terminals (macOS Terminal.app, iTerm2, Alacritty, Kitty, Windows Terminal, xterm).
|
|
31
|
+
- Pure mouse coordinate calculation utilities (`src/utils/mouse.js`) with full test coverage in `test/unit.test.js`.
|
|
32
|
+
|
|
16
33
|
## [1.2.0] — 2026-09-01
|
|
17
34
|
|
|
18
35
|
### Added
|
|
@@ -118,7 +135,8 @@ First public release.
|
|
|
118
135
|
- A WCAG contrast test (3:1 threshold) applied to every theme **after** conversion
|
|
119
136
|
to xterm-256 — the colors are checked exactly as the user sees them.
|
|
120
137
|
|
|
121
|
-
[Unreleased]: https://github.com/emaxe/tuigram/compare/v1.
|
|
138
|
+
[Unreleased]: https://github.com/emaxe/tuigram/compare/v1.3.0...HEAD
|
|
139
|
+
[1.3.0]: https://github.com/emaxe/tuigram/compare/v1.2.0...v1.3.0
|
|
122
140
|
[1.2.0]: https://github.com/emaxe/tuigram/compare/v1.1.0...v1.2.0
|
|
123
141
|
[1.1.0]: https://github.com/emaxe/tuigram/compare/v1.0.1...v1.1.0
|
|
124
142
|
[1.0.1]: https://github.com/emaxe/tuigram/compare/v1.0.0...v1.0.1
|
package/CHANGELOG.ru.md
CHANGED
|
@@ -13,6 +13,23 @@
|
|
|
13
13
|
|
|
14
14
|
- Английская локализация строк интерфейса TUI и CLI.
|
|
15
15
|
|
|
16
|
+
## [1.3.0] — 2026-09-01
|
|
17
|
+
|
|
18
|
+
### Добавлено
|
|
19
|
+
|
|
20
|
+
- Полноценная поддержка управления мышью во всём TUI-интерфейсе:
|
|
21
|
+
- Фокусировка любой панели по клику (список чатов, лента сообщений, поле ввода).
|
|
22
|
+
- Открытие диалога в один клик по элементу списка.
|
|
23
|
+
- Плавная прокрутка списка чатов и ленты сообщений колесом мыши с автоматической подгрузкой старой истории при прокрутке вверх.
|
|
24
|
+
- Открытие меню действий над сообщением (реакции, ответ, редактирование, скачивание, удаление) по прямому клику на сообщение.
|
|
25
|
+
- Интерактивные вкладки фильтрации (`1:Все`, `2:ЛС`, `3:Группы`, `4:Каналы`, `5:Боты`, `6:Непроч`) и строка поиска в списке диалогов.
|
|
26
|
+
- Интерактивная нижняя строка состояния (`[Tab]`, `[1-6]`, `[/]`, `[F1]`, `[Ctrl+A]`, `[Ctrl+P]`, `[Ctrl+Q]`).
|
|
27
|
+
- Интерактивный верхний заголовок: клик по логотипу открывает справку, по названию активного чата — сведения о чате, по статусу — информацию о сети.
|
|
28
|
+
- Контекстная строка поля ввода: клик сбрасывает режим ответа/редактирования либо запускает команды.
|
|
29
|
+
- Поддержка мыши во всех модальных окнах (клики по кнопкам, чекбоксам, файлам в проводнике) и закрытие модалок по клику мимо окна.
|
|
30
|
+
- Надёжная инициализация режимов мыши терминала (`\x1b[?1000h\x1b[?1002h\x1b[?1006h\x1b[?1015h`) с поддержкой современных эмуляторов (macOS Terminal.app, iTerm2, Alacritty, Kitty, Windows Terminal, xterm).
|
|
31
|
+
- Чистые утилиты расчёта координат мыши (`src/utils/mouse.js`) и покрытие юнит-тестами в `test/unit.test.js`.
|
|
32
|
+
|
|
16
33
|
## [1.2.0] — 2026-09-01
|
|
17
34
|
|
|
18
35
|
### Добавлено
|
|
@@ -120,7 +137,8 @@
|
|
|
120
137
|
- Тест контраста по WCAG (порог 3:1) для каждой темы — **после** конверсии в xterm-256,
|
|
121
138
|
то есть ровно в том виде, в каком цвет увидит пользователь.
|
|
122
139
|
|
|
123
|
-
[Unreleased]: https://github.com/emaxe/tuigram/compare/v1.
|
|
140
|
+
[Unreleased]: https://github.com/emaxe/tuigram/compare/v1.3.0...HEAD
|
|
141
|
+
[1.3.0]: https://github.com/emaxe/tuigram/compare/v1.2.0...v1.3.0
|
|
124
142
|
[1.2.0]: https://github.com/emaxe/tuigram/compare/v1.1.0...v1.2.0
|
|
125
143
|
[1.1.0]: https://github.com/emaxe/tuigram/compare/v1.0.1...v1.1.0
|
|
126
144
|
[1.0.1]: https://github.com/emaxe/tuigram/compare/v1.0.0...v1.0.1
|
package/README.md
CHANGED
|
@@ -52,6 +52,7 @@ TuiGram lets you use Telegram entirely from the terminal: browse your dialog lis
|
|
|
52
52
|
- [Where files are stored](#-where-files-are-stored)
|
|
53
53
|
- [Development](#-development)
|
|
54
54
|
- [Keyboard shortcuts](#️-keyboard-shortcuts)
|
|
55
|
+
- [Mouse controls](#-mouse-controls)
|
|
55
56
|
- [Slash commands](#-slash-commands-in-the-input-box)
|
|
56
57
|
- [Sending files and images](#-sending-files-and-images)
|
|
57
58
|
- [Themes](#-themes)
|
|
@@ -452,6 +453,28 @@ node scripts/check-package.js # verifies that .env and the session do not leak
|
|
|
452
453
|
|
|
453
454
|
---
|
|
454
455
|
|
|
456
|
+
## 🖱 Mouse controls
|
|
457
|
+
|
|
458
|
+
TuiGram fully supports mouse interaction in terminals that support mouse reporting (macOS Terminal, iTerm2, Alacritty, Kitty, Windows Terminal, Linux virtual terminals):
|
|
459
|
+
|
|
460
|
+
| Action | Target | Result |
|
|
461
|
+
|---|---|---|
|
|
462
|
+
| Click | Left / Middle / Bottom panel | Focus the clicked panel |
|
|
463
|
+
| Click | Chat item in dialog list | Immediately select and open the chat |
|
|
464
|
+
| Click | Filter tabs `1:Все` .. `6:Непроч` | Switch chat category filter |
|
|
465
|
+
| Click | Search bar `[/]` | Focus search box and clear placeholder |
|
|
466
|
+
| Wheel scroll | Dialog list / Message feed | Smooth scroll (scrolling up to the top loads older messages) |
|
|
467
|
+
| Click | Any message line | Open message action menu (reactions, reply, edit, download, delete) |
|
|
468
|
+
| Click | Header logo 🚀 | Open Help window |
|
|
469
|
+
| Click | Header active chat title | Open Chat Info window |
|
|
470
|
+
| Click | Header connection badge | Show active network status |
|
|
471
|
+
| Click | Input context bar | Cancel reply/edit mode or insert `/` |
|
|
472
|
+
| Click | Status bar items | Trigger corresponding action (`[Tab]`, `[F1]`, `[Ctrl+A]`, `[Ctrl+Q]`, etc.) |
|
|
473
|
+
| Click | Outside any modal window | Dismiss / close modal |
|
|
474
|
+
| Click | Buttons in modals | Click buttons (`[ Отправить ]`, `[ Отмена ]`, `[ Закрыть ]`, toggle checkboxes) |
|
|
475
|
+
|
|
476
|
+
---
|
|
477
|
+
|
|
455
478
|
## 💬 Slash commands in the input box
|
|
456
479
|
|
|
457
480
|
Quick commands are available in the message input box (they start with `/`):
|
package/README.ru.md
CHANGED
|
@@ -48,6 +48,7 @@
|
|
|
48
48
|
- [Где хранятся файлы](#-где-хранятся-файлы)
|
|
49
49
|
- [Разработка](#-разработка)
|
|
50
50
|
- [Горячие клавиши](#️-горячие-клавиши)
|
|
51
|
+
- [Управление мышью](#-управление-мышью)
|
|
51
52
|
- [Слэш-команды](#-слэш-команды-в-поле-ввода)
|
|
52
53
|
- [Отправка файлов и картинок](#-отправка-файлов-и-картинок)
|
|
53
54
|
- [Темы оформления](#-темы-оформления)
|
|
@@ -444,6 +445,28 @@ node scripts/check-package.js # проверка, что в пакет не у
|
|
|
444
445
|
|
|
445
446
|
---
|
|
446
447
|
|
|
448
|
+
## 🖱 Управление мышью
|
|
449
|
+
|
|
450
|
+
TuiGram полноценно поддерживает управление мышью во всех современных терминалах (Terminal.app, iTerm2, Alacritty, Kitty, Windows Terminal, xterm):
|
|
451
|
+
|
|
452
|
+
| Действие | Где | Результат |
|
|
453
|
+
|---|---|---|
|
|
454
|
+
| Клик | Левая / Центральная / Нижняя панель | Переключение фокуса на выбранную панель |
|
|
455
|
+
| Клик | Диалог в списке чатов | Мгновенный выбор и открытие чата |
|
|
456
|
+
| Клик | Вкладки `1:Все` .. `6:Непроч` | Переключение фильтра категории чатов |
|
|
457
|
+
| Клик | Поле поиска `[/]` | Фокус в поиск с очисткой подсказки |
|
|
458
|
+
| Колесо мыши | Список чатов / Лента сообщений | Плавная прокрутка (прокрутка вверх в ленте подгружает историю) |
|
|
459
|
+
| Клик | Любое сообщение | Открытие меню действий (реакции, ответ, редактирование, удаление) |
|
|
460
|
+
| Клик | Логотип 🚀 в шапке | Открытие окна справки |
|
|
461
|
+
| Клик | Название активного чата в шапке | Открытие информации о текущем чате |
|
|
462
|
+
| Клик | Индикатор статуса в шапке | Просмотр статуса MTProto подключения |
|
|
463
|
+
| Клик | Контекстная плашка ввода | Сброс режима ответа/редактирования или вставка `/` |
|
|
464
|
+
| Клик | Кнопки строки состояния | Быстрый вызов действий (`[Tab]`, `[F1]`, `[Ctrl+A]`, `[Ctrl+Q]` и др.) |
|
|
465
|
+
| Клик | Мимо любого модального окна | Закрытие модального окна |
|
|
466
|
+
| Клик | Кнопки в модалках | Нажатие кнопки (`[ Отправить ]`, `[ Отмена ]`, `[ Закрыть ]`, чекбоксы) |
|
|
467
|
+
|
|
468
|
+
---
|
|
469
|
+
|
|
447
470
|
## 💬 Слэш-команды в поле ввода
|
|
448
471
|
|
|
449
472
|
В поле ввода сообщения доступны быстрые команды (начинаются с `/`):
|
package/package.json
CHANGED
package/src/ui/app.js
CHANGED
|
@@ -43,8 +43,79 @@ export async function startTui(client, me) {
|
|
|
43
43
|
state.setConnectionStatus("connected");
|
|
44
44
|
|
|
45
45
|
// 1. Создание визуальных компонентов
|
|
46
|
-
const header = createHeader(screen, theme
|
|
47
|
-
|
|
46
|
+
const header = createHeader(screen, theme, {
|
|
47
|
+
onHelp: () => {
|
|
48
|
+
releaseInputs();
|
|
49
|
+
helpModal.show();
|
|
50
|
+
},
|
|
51
|
+
onChatInfo: () => {
|
|
52
|
+
if (state.activeChat) {
|
|
53
|
+
releaseInputs();
|
|
54
|
+
chatInfoModal.show(state.activeChat);
|
|
55
|
+
} else {
|
|
56
|
+
statusBar.showMessage("Сначала выберите чат слева!", "warning");
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
onStatusClick: () => {
|
|
60
|
+
const statusText = state.connectionStatus === "connected"
|
|
61
|
+
? "Подключение активно: MTProto онлайн"
|
|
62
|
+
: `Статус сети: ${state.connectionStatus}`;
|
|
63
|
+
statusBar.showMessage(statusText, "info");
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const statusBar = createStatusBar(screen, theme, {
|
|
68
|
+
onFocusNext: () => moveFocus(1),
|
|
69
|
+
onSelectOrSubmit: () => {
|
|
70
|
+
const focused = detectFocus();
|
|
71
|
+
if (focused === "input") {
|
|
72
|
+
inputBox.textarea.emit("key enter");
|
|
73
|
+
} else if (focused === "chatList") {
|
|
74
|
+
chatList.list.emit("key enter");
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
onFilterTabs: () => {
|
|
78
|
+
releaseInputs();
|
|
79
|
+
chatList.focus();
|
|
80
|
+
statusBar.showMessage("Фильтры: нажмите 1-6 для выбора вкладки", "info");
|
|
81
|
+
},
|
|
82
|
+
onSearch: () => {
|
|
83
|
+
chatList.searchBox.setValue("");
|
|
84
|
+
chatList.searchBox.focus();
|
|
85
|
+
screen.render();
|
|
86
|
+
},
|
|
87
|
+
onHelp: () => {
|
|
88
|
+
releaseInputs();
|
|
89
|
+
helpModal.show();
|
|
90
|
+
},
|
|
91
|
+
onAction: () => {
|
|
92
|
+
if (!state.activeChat) {
|
|
93
|
+
statusBar.showMessage("Сначала выберите чат слева!", "warning");
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
const msgs = state.getMessages(state.activeChat.id);
|
|
97
|
+
if (msgs.length > 0) {
|
|
98
|
+
releaseInputs();
|
|
99
|
+
actionModal.show(msgs[msgs.length - 1]);
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
onChatInfo: () => {
|
|
103
|
+
if (state.activeChat) {
|
|
104
|
+
releaseInputs();
|
|
105
|
+
chatInfoModal.show(state.activeChat);
|
|
106
|
+
} else {
|
|
107
|
+
statusBar.showMessage("Сначала выберите чат слева!", "warning");
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
onQuit: () => {
|
|
111
|
+
releaseInputs();
|
|
112
|
+
confirmModal.ask("Выйти из TuiGram?", () => {
|
|
113
|
+
listener?.stop();
|
|
114
|
+
screen.destroy();
|
|
115
|
+
process.exit(0);
|
|
116
|
+
});
|
|
117
|
+
},
|
|
118
|
+
});
|
|
48
119
|
|
|
49
120
|
// Модальные окна
|
|
50
121
|
const helpModal = createHelpModal(screen, theme);
|
|
@@ -205,6 +276,21 @@ export async function startTui(client, me) {
|
|
|
205
276
|
onCancelContext: () => {
|
|
206
277
|
statusBar.showMessage("Режим ответа/редактирования сброшен", "info", 2000);
|
|
207
278
|
},
|
|
279
|
+
onReplyLast: () => {
|
|
280
|
+
if (!state.activeChat) return;
|
|
281
|
+
const msgs = state.getMessages(state.activeChat.id);
|
|
282
|
+
if (msgs.length > 0) {
|
|
283
|
+
inputBox.setContext("reply", msgs[msgs.length - 1]);
|
|
284
|
+
}
|
|
285
|
+
},
|
|
286
|
+
onEditLast: () => {
|
|
287
|
+
if (!state.activeChat) return;
|
|
288
|
+
const msgs = state.getMessages(state.activeChat.id);
|
|
289
|
+
const ownMsgs = msgs.filter((m) => m.out);
|
|
290
|
+
if (ownMsgs.length > 0) {
|
|
291
|
+
inputBox.setContext("edit", ownMsgs[ownMsgs.length - 1]);
|
|
292
|
+
}
|
|
293
|
+
},
|
|
208
294
|
onSlashCommand: (cmd, args) => {
|
|
209
295
|
switch (cmd) {
|
|
210
296
|
case "help":
|
|
@@ -269,6 +355,33 @@ export async function startTui(client, me) {
|
|
|
269
355
|
},
|
|
270
356
|
});
|
|
271
357
|
|
|
358
|
+
// Переключение фокуса по клику мышью на любую из трех панелей
|
|
359
|
+
chatList.container.on("click", () => {
|
|
360
|
+
if (screen.focused !== chatList.list && screen.focused !== chatList.searchBox) {
|
|
361
|
+
releaseInputs();
|
|
362
|
+
chatList.focus();
|
|
363
|
+
statusBar.showMessage("Фокус: список чатов", "info", 2000);
|
|
364
|
+
screen.render();
|
|
365
|
+
}
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
chatView.container.on("click", () => {
|
|
369
|
+
if (screen.focused !== chatView.scrollBox) {
|
|
370
|
+
releaseInputs();
|
|
371
|
+
chatView.focus();
|
|
372
|
+
statusBar.showMessage("Фокус: лента сообщений", "info", 2000);
|
|
373
|
+
screen.render();
|
|
374
|
+
}
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
inputBox.container.on("click", () => {
|
|
378
|
+
if (screen.focused !== inputBox.textarea) {
|
|
379
|
+
inputBox.focus();
|
|
380
|
+
statusBar.showMessage("Фокус: поле ввода", "info", 2000);
|
|
381
|
+
screen.render();
|
|
382
|
+
}
|
|
383
|
+
});
|
|
384
|
+
|
|
272
385
|
// 5. Реакция на события состояния
|
|
273
386
|
state.on("dialogs_updated", (dialogs) => {
|
|
274
387
|
chatList.setDialogs(dialogs);
|
|
@@ -4,6 +4,8 @@ import { escapeBlessed } from "../../telegram/formatter.js";
|
|
|
4
4
|
import { fg, badge } from "../theme.js";
|
|
5
5
|
import unicode from "neo-blessed/lib/unicode.js";
|
|
6
6
|
|
|
7
|
+
import { getTabByCoordinate } from "../../utils/mouse.js";
|
|
8
|
+
|
|
7
9
|
/**
|
|
8
10
|
* Создаёт компонент списка диалогов (левая панель).
|
|
9
11
|
* @param {blessed.Widgets.Screen} screen
|
|
@@ -20,6 +22,7 @@ export function createChatList(screen, theme, { onSelectDialog, onTabChange, onS
|
|
|
20
22
|
left: 0,
|
|
21
23
|
width: "35%",
|
|
22
24
|
bottom: 1,
|
|
25
|
+
mouse: true,
|
|
23
26
|
border: {
|
|
24
27
|
type: "line",
|
|
25
28
|
},
|
|
@@ -40,6 +43,8 @@ export function createChatList(screen, theme, { onSelectDialog, onTabChange, onS
|
|
|
40
43
|
right: 0,
|
|
41
44
|
height: 1,
|
|
42
45
|
tags: true,
|
|
46
|
+
mouse: true,
|
|
47
|
+
clickable: true,
|
|
43
48
|
style: {
|
|
44
49
|
bg: theme.tabs.bg,
|
|
45
50
|
fg: theme.tabs.fg,
|
|
@@ -64,6 +69,17 @@ export function createChatList(screen, theme, { onSelectDialog, onTabChange, onS
|
|
|
64
69
|
tabsBox.setContent(rendered);
|
|
65
70
|
}
|
|
66
71
|
|
|
72
|
+
tabsBox.on("click", (data) => {
|
|
73
|
+
const relX = data.x - (tabsBox.aleft || 0);
|
|
74
|
+
const tabKey = getTabByCoordinate(relX, TAB_KEYS, TAB_NAMES);
|
|
75
|
+
if (tabKey) {
|
|
76
|
+
currentTab = tabKey;
|
|
77
|
+
renderTabs();
|
|
78
|
+
onTabChange?.(currentTab);
|
|
79
|
+
screen.render();
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
67
83
|
// 2. Строка поиска / фильтра
|
|
68
84
|
const searchBox = blessed.textbox({
|
|
69
85
|
parent: container,
|
|
@@ -72,6 +88,7 @@ export function createChatList(screen, theme, { onSelectDialog, onTabChange, onS
|
|
|
72
88
|
right: 0,
|
|
73
89
|
height: 1,
|
|
74
90
|
inputOnFocus: true,
|
|
91
|
+
mouse: true,
|
|
75
92
|
style: {
|
|
76
93
|
bg: theme.search.bg,
|
|
77
94
|
fg: theme.search.fg,
|
|
@@ -80,6 +97,14 @@ export function createChatList(screen, theme, { onSelectDialog, onTabChange, onS
|
|
|
80
97
|
const SEARCH_PLACEHOLDER = "[/] Поиск чатов...";
|
|
81
98
|
searchBox.setValue(SEARCH_PLACEHOLDER);
|
|
82
99
|
|
|
100
|
+
searchBox.on("click", () => {
|
|
101
|
+
if (searchBox.getValue() === SEARCH_PLACEHOLDER) {
|
|
102
|
+
searchBox.setValue("");
|
|
103
|
+
}
|
|
104
|
+
searchBox.focus();
|
|
105
|
+
screen.render();
|
|
106
|
+
});
|
|
107
|
+
|
|
83
108
|
// 3. Список диалогов
|
|
84
109
|
const list = blessed.list({
|
|
85
110
|
parent: container,
|
|
@@ -118,13 +143,31 @@ export function createChatList(screen, theme, { onSelectDialog, onTabChange, onS
|
|
|
118
143
|
// не влезающий хвост на вторую строку, которой в элементе высотой 1 просто
|
|
119
144
|
// нет: хвост исчезал, а начатый перед разрывом фон бейджа непрочитанных
|
|
120
145
|
// оставался залитым до края панели. С wrap: false лишнее просто обрезается.
|
|
146
|
+
// По клику мыши сразу выбираем и открываем диалог.
|
|
121
147
|
const baseCreateItem = list.createItem.bind(list);
|
|
122
148
|
list.createItem = (content) => {
|
|
123
149
|
const item = baseCreateItem(content);
|
|
124
150
|
item.wrap = false;
|
|
151
|
+
item.on("click", () => {
|
|
152
|
+
const index = list.getItemIndex(item);
|
|
153
|
+
if (index !== -1 && currentDialogs[index]) {
|
|
154
|
+
list.select(index);
|
|
155
|
+
onSelectDialog?.(currentDialogs[index]);
|
|
156
|
+
}
|
|
157
|
+
});
|
|
125
158
|
return item;
|
|
126
159
|
};
|
|
127
160
|
|
|
161
|
+
container.on("wheelup", () => {
|
|
162
|
+
list.select(list.selected - 2);
|
|
163
|
+
screen.render();
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
container.on("wheeldown", () => {
|
|
167
|
+
list.select(list.selected + 2);
|
|
168
|
+
screen.render();
|
|
169
|
+
});
|
|
170
|
+
|
|
128
171
|
let currentDialogs = [];
|
|
129
172
|
|
|
130
173
|
/**
|
|
@@ -275,6 +318,25 @@ export function createChatList(screen, theme, { onSelectDialog, onTabChange, onS
|
|
|
275
318
|
|
|
276
319
|
renderTabs();
|
|
277
320
|
|
|
321
|
+
/** Подсвечивает рамку, когда список диалогов или строка поиска в фокусе. */
|
|
322
|
+
function setFocusHighlight(active) {
|
|
323
|
+
container.style.border.fg = active ? theme.borders.focusFg : theme.borders.fg;
|
|
324
|
+
screen.render();
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
list.on("focus", () => setFocusHighlight(true));
|
|
328
|
+
list.on("blur", (newTarget) => {
|
|
329
|
+
if (newTarget !== searchBox && screen.focused !== searchBox) {
|
|
330
|
+
setFocusHighlight(false);
|
|
331
|
+
}
|
|
332
|
+
});
|
|
333
|
+
searchBox.on("focus", () => setFocusHighlight(true));
|
|
334
|
+
searchBox.on("blur", (newTarget) => {
|
|
335
|
+
if (newTarget !== list && screen.focused !== list) {
|
|
336
|
+
setFocusHighlight(false);
|
|
337
|
+
}
|
|
338
|
+
});
|
|
339
|
+
|
|
278
340
|
return {
|
|
279
341
|
container,
|
|
280
342
|
list,
|
|
@@ -3,6 +3,8 @@ import { formatMessageTime, formatDateDivider } from "../../utils/time.js";
|
|
|
3
3
|
import { formatMessageText, escapeBlessed } from "../../telegram/formatter.js";
|
|
4
4
|
import { fg } from "../theme.js";
|
|
5
5
|
|
|
6
|
+
import { getMessageAtLine } from "../../utils/mouse.js";
|
|
7
|
+
|
|
6
8
|
/**
|
|
7
9
|
* Создаёт компонент просмотра сообщений чата (правая центральная панель).
|
|
8
10
|
* @param {blessed.Widgets.Screen} screen
|
|
@@ -18,6 +20,7 @@ export function createChatView(screen, theme, { onLoadMoreHistory, onActionMenu
|
|
|
18
20
|
left: "35%",
|
|
19
21
|
right: 0,
|
|
20
22
|
bottom: 6,
|
|
23
|
+
mouse: true,
|
|
21
24
|
border: {
|
|
22
25
|
type: "line",
|
|
23
26
|
},
|
|
@@ -54,26 +57,43 @@ export function createChatView(screen, theme, { onLoadMoreHistory, onActionMenu
|
|
|
54
57
|
},
|
|
55
58
|
});
|
|
56
59
|
|
|
60
|
+
/** Подсвечивает рамку, когда лента сообщений в фокусе. */
|
|
61
|
+
function setFocusHighlight(active) {
|
|
62
|
+
container.style.border.fg = active ? theme.borders.focusFg : theme.borders.fg;
|
|
63
|
+
screen.render();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
scrollBox.on("focus", () => setFocusHighlight(true));
|
|
67
|
+
scrollBox.on("blur", () => setFocusHighlight(false));
|
|
68
|
+
|
|
57
69
|
let currentMessages = [];
|
|
70
|
+
let currentRanges = [];
|
|
58
71
|
|
|
59
72
|
/**
|
|
60
|
-
* Форматирует список сообщений в единую ленту текста с разметкой Blessed
|
|
73
|
+
* Форматирует список сообщений в единую ленту текста с разметкой Blessed
|
|
74
|
+
* и вычисляет координаты строк каждого сообщения для кликов мыши.
|
|
61
75
|
* @param {Array<object>} messages
|
|
62
|
-
* @returns {string}
|
|
76
|
+
* @returns {{ text: string, ranges: Array<{ message: object, startLine: number, endLine: number }> }}
|
|
63
77
|
*/
|
|
64
|
-
function
|
|
78
|
+
function renderMessagesWithRanges(messages) {
|
|
65
79
|
if (!messages || messages.length === 0) {
|
|
66
|
-
return
|
|
80
|
+
return {
|
|
81
|
+
text: `\n\n ${fg(theme.muted, "Сообщений пока нет. Напишите первое сообщение ниже!")}`,
|
|
82
|
+
ranges: [],
|
|
83
|
+
};
|
|
67
84
|
}
|
|
68
85
|
|
|
69
86
|
let output = "";
|
|
70
87
|
let lastDateString = "";
|
|
88
|
+
const ranges = [];
|
|
89
|
+
let lineCursor = 0;
|
|
71
90
|
|
|
72
91
|
for (const msg of messages) {
|
|
73
92
|
// Разделитель дат
|
|
74
93
|
const dateStr = formatDateDivider(msg.date);
|
|
75
94
|
if (dateStr && dateStr !== lastDateString) {
|
|
76
95
|
output += `\n ${fg(theme.chatView.dateDivider, `─────── ${escapeBlessed(dateStr)} ───────`)}\n\n`;
|
|
96
|
+
lineCursor += 3;
|
|
77
97
|
lastDateString = dateStr;
|
|
78
98
|
}
|
|
79
99
|
|
|
@@ -92,8 +112,10 @@ export function createChatView(screen, theme, { onLoadMoreHistory, onActionMenu
|
|
|
92
112
|
|
|
93
113
|
// Блок ответа (Reply)
|
|
94
114
|
let replyBlock = "";
|
|
115
|
+
let replyLines = 0;
|
|
95
116
|
if (msg.replyToMsgId) {
|
|
96
117
|
replyBlock = ` ${fg(theme.chatView.replyBorder, `┌─ Ответ на сообщение #${msg.replyToMsgId}`)}\n`;
|
|
118
|
+
replyLines = 1;
|
|
97
119
|
}
|
|
98
120
|
|
|
99
121
|
// Текст сообщения и entities
|
|
@@ -118,12 +140,15 @@ export function createChatView(screen, theme, { onLoadMoreHistory, onActionMenu
|
|
|
118
140
|
.split("\n")
|
|
119
141
|
.map((line) => ` ${line}`)
|
|
120
142
|
.join("\n");
|
|
143
|
+
const bodyLinesCount = indentedBody.split("\n").length;
|
|
121
144
|
|
|
122
145
|
// Реакции
|
|
123
146
|
let reactionsLine = "";
|
|
147
|
+
let reactionLinesCount = 0;
|
|
124
148
|
if (msg.reactions && msg.reactions.length > 0) {
|
|
125
149
|
const list = msg.reactions.map((r) => `${r.emoticon} ${r.count}`).join(" ");
|
|
126
150
|
reactionsLine = `\n ${fg(theme.chatView.reactionFg, `{bold}${list}{/bold}`)}`;
|
|
151
|
+
reactionLinesCount = 1;
|
|
127
152
|
}
|
|
128
153
|
|
|
129
154
|
// Метка редактирования
|
|
@@ -132,10 +157,17 @@ export function createChatView(screen, theme, { onLoadMoreHistory, onActionMenu
|
|
|
132
157
|
editedTag = ` ${fg(theme.chatView.time, "(изменено)")}`;
|
|
133
158
|
}
|
|
134
159
|
|
|
160
|
+
const startLine = lineCursor;
|
|
161
|
+
const totalMsgLines = 1 + replyLines + bodyLinesCount + reactionLinesCount;
|
|
162
|
+
const endLine = startLine + totalMsgLines - 1;
|
|
163
|
+
|
|
164
|
+
ranges.push({ message: msg, startLine, endLine });
|
|
165
|
+
lineCursor += totalMsgLines + 2;
|
|
166
|
+
|
|
135
167
|
output += ` ${authorTag}${editedTag}\n${replyBlock}${indentedBody}${reactionsLine}\n\n`;
|
|
136
168
|
}
|
|
137
169
|
|
|
138
|
-
return output;
|
|
170
|
+
return { text: output, ranges };
|
|
139
171
|
}
|
|
140
172
|
|
|
141
173
|
/**
|
|
@@ -148,7 +180,9 @@ export function createChatView(screen, theme, { onLoadMoreHistory, onActionMenu
|
|
|
148
180
|
const prevScroll = scrollBox.getScroll();
|
|
149
181
|
const prevHeight = scrollBox.getScrollHeight();
|
|
150
182
|
|
|
151
|
-
|
|
183
|
+
const rendered = renderMessagesWithRanges(messages);
|
|
184
|
+
currentRanges = rendered.ranges;
|
|
185
|
+
scrollBox.setContent(rendered.text);
|
|
152
186
|
|
|
153
187
|
if (autoScrollToBottom) {
|
|
154
188
|
scrollBox.setScrollPerc(100);
|
|
@@ -195,8 +229,31 @@ export function createChatView(screen, theme, { onLoadMoreHistory, onActionMenu
|
|
|
195
229
|
});
|
|
196
230
|
|
|
197
231
|
scrollBox.on("wheelup", () => {
|
|
198
|
-
|
|
199
|
-
|
|
232
|
+
handleScrollUp(3);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
scrollBox.on("wheeldown", () => {
|
|
236
|
+
handleScrollDown(3);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
container.on("wheelup", () => {
|
|
240
|
+
handleScrollUp(3);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
container.on("wheeldown", () => {
|
|
244
|
+
handleScrollDown(3);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
scrollBox.on("click", (data) => {
|
|
248
|
+
const clickY = data.y;
|
|
249
|
+
const itop = scrollBox.itop || 0;
|
|
250
|
+
const lineIndex = clickY - (scrollBox.atop || 0) + scrollBox.getScroll() - itop;
|
|
251
|
+
const clickedMsg = getMessageAtLine(lineIndex, currentRanges);
|
|
252
|
+
if (clickedMsg) {
|
|
253
|
+
onActionMenu?.(clickedMsg);
|
|
254
|
+
} else {
|
|
255
|
+
scrollBox.focus();
|
|
256
|
+
screen.render();
|
|
200
257
|
}
|
|
201
258
|
});
|
|
202
259
|
|
|
@@ -2,13 +2,21 @@ import blessed from "neo-blessed";
|
|
|
2
2
|
import { escapeBlessed } from "../../telegram/formatter.js";
|
|
3
3
|
import { fg } from "../theme.js";
|
|
4
4
|
|
|
5
|
+
import { getHeaderActionAt } from "../../utils/mouse.js";
|
|
6
|
+
|
|
5
7
|
/**
|
|
6
8
|
* Верхняя шапка приложения с информацией о пользователе, активном чате и статусе соединения.
|
|
7
9
|
* @param {blessed.Widgets.Screen} screen
|
|
8
10
|
* @param {object} theme
|
|
11
|
+
* @param {object} [callbacks]
|
|
12
|
+
* @param {() => void} [callbacks.onHelp]
|
|
13
|
+
* @param {() => void} [callbacks.onChatInfo]
|
|
14
|
+
* @param {() => void} [callbacks.onStatusClick]
|
|
9
15
|
* @returns {blessed.Widgets.BoxElement & { updateInfo: (data: object) => void }}
|
|
10
16
|
*/
|
|
11
|
-
export function createHeader(screen, theme) {
|
|
17
|
+
export function createHeader(screen, theme, { onHelp, onChatInfo, onStatusClick } = {}) {
|
|
18
|
+
let currentActiveChat = null;
|
|
19
|
+
|
|
12
20
|
const headerBox = blessed.box({
|
|
13
21
|
parent: screen,
|
|
14
22
|
top: 0,
|
|
@@ -17,6 +25,7 @@ export function createHeader(screen, theme) {
|
|
|
17
25
|
// 4 = рамка (2) + две строки контента (профиль + активный чат)
|
|
18
26
|
height: 4,
|
|
19
27
|
tags: true,
|
|
28
|
+
mouse: true,
|
|
20
29
|
border: {
|
|
21
30
|
type: "line",
|
|
22
31
|
},
|
|
@@ -29,6 +38,19 @@ export function createHeader(screen, theme) {
|
|
|
29
38
|
},
|
|
30
39
|
});
|
|
31
40
|
|
|
41
|
+
headerBox.on("click", (data) => {
|
|
42
|
+
const relX = data.x - (headerBox.aleft || 0);
|
|
43
|
+
const relY = data.y - (headerBox.atop || 0);
|
|
44
|
+
const action = getHeaderActionAt(relX, relY, { hasActiveChat: Boolean(currentActiveChat) });
|
|
45
|
+
if (action === "help") {
|
|
46
|
+
onHelp?.();
|
|
47
|
+
} else if (action === "info") {
|
|
48
|
+
onChatInfo?.();
|
|
49
|
+
} else if (action === "status") {
|
|
50
|
+
onStatusClick?.();
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
|
|
32
54
|
/**
|
|
33
55
|
* Обновляет содержимое шапки.
|
|
34
56
|
* @param {object} data
|
|
@@ -38,6 +60,7 @@ export function createHeader(screen, theme) {
|
|
|
38
60
|
* @param {string|null} [data.typingUser]
|
|
39
61
|
*/
|
|
40
62
|
headerBox.updateInfo = function ({ me, status = "connected", activeChat, typingUser }) {
|
|
63
|
+
currentActiveChat = activeChat;
|
|
41
64
|
let statusBadge = fg(theme.status.online, "● В сети");
|
|
42
65
|
if (status === "connecting") {
|
|
43
66
|
statusBadge = fg(theme.status.connecting, "◌ Подключение...");
|