@emaxe/tuigram 1.2.0 → 1.4.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 +61 -1
- package/CHANGELOG.ru.md +65 -1
- package/README.md +63 -4
- package/README.ru.md +64 -4
- package/package.json +1 -1
- package/src/telegram/messages.js +98 -19
- package/src/ui/app.js +189 -21
- package/src/ui/components/chatList.js +64 -0
- package/src/ui/components/chatView.js +256 -35
- package/src/ui/components/header.js +25 -1
- package/src/ui/components/inputBox.js +41 -1
- package/src/ui/components/modals/actionModal.js +23 -0
- package/src/ui/components/modals/chatInfoModal.js +9 -0
- package/src/ui/components/modals/confirmModal.js +8 -0
- package/src/ui/components/modals/fileModal.js +26 -1
- package/src/ui/components/modals/filePickerModal.js +9 -0
- package/src/ui/components/modals/helpModal.js +23 -4
- package/src/ui/components/modals/imageViewerModal.js +172 -0
- package/src/ui/components/statusBar.js +54 -1
- package/src/ui/modalMouse.js +43 -0
- package/src/ui/screen.js +92 -3
- package/src/utils/image.js +8 -8
- package/src/utils/mouse.js +223 -0
|
@@ -3,21 +3,36 @@ 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 { getMessagePartAtPoint, isRightClick, stringCellWidth } from "../../utils/mouse.js";
|
|
7
|
+
|
|
8
|
+
/** Отступ тела сообщения от левого края ленты, в ячейках. */
|
|
9
|
+
const BODY_INDENT = 2;
|
|
10
|
+
|
|
6
11
|
/**
|
|
7
12
|
* Создаёт компонент просмотра сообщений чата (правая центральная панель).
|
|
8
13
|
* @param {blessed.Widgets.Screen} screen
|
|
9
14
|
* @param {object} theme
|
|
10
15
|
* @param {object} callbacks
|
|
11
16
|
* @param {() => void} [callbacks.onLoadMoreHistory]
|
|
12
|
-
* @param {(msg: object) => void} [callbacks.onActionMenu]
|
|
17
|
+
* @param {(msg: object) => void} [callbacks.onActionMenu] правый клик / Enter / Ctrl+A
|
|
18
|
+
* @param {(msg: object) => void} [callbacks.onSelectMessage] сообщение выделено
|
|
19
|
+
* @param {(msg: object) => void} [callbacks.onOpenImage] клик по превью изображения
|
|
20
|
+
* @param {() => void} [callbacks.onFocusRequest] вызывается перед взятием фокуса мышью
|
|
13
21
|
*/
|
|
14
|
-
export function createChatView(screen, theme, {
|
|
22
|
+
export function createChatView(screen, theme, {
|
|
23
|
+
onLoadMoreHistory,
|
|
24
|
+
onActionMenu,
|
|
25
|
+
onSelectMessage,
|
|
26
|
+
onOpenImage,
|
|
27
|
+
onFocusRequest,
|
|
28
|
+
} = {}) {
|
|
15
29
|
const container = blessed.box({
|
|
16
30
|
parent: screen,
|
|
17
31
|
top: 4,
|
|
18
32
|
left: "35%",
|
|
19
33
|
right: 0,
|
|
20
34
|
bottom: 6,
|
|
35
|
+
mouse: true,
|
|
21
36
|
border: {
|
|
22
37
|
type: "line",
|
|
23
38
|
},
|
|
@@ -38,9 +53,13 @@ export function createChatView(screen, theme, { onLoadMoreHistory, onActionMenu
|
|
|
38
53
|
bottom: 0,
|
|
39
54
|
tags: true,
|
|
40
55
|
scrollable: true,
|
|
56
|
+
// alwaysScroll обязателен: без него blessed копит смещение в childOffset,
|
|
57
|
+
// не двигая содержимое (первые щелчки колеса «проглатываются»), а getScroll()
|
|
58
|
+
// перестаёт совпадать с реальным сдвигом ленты — клики попадали не в то сообщение.
|
|
59
|
+
alwaysScroll: true,
|
|
41
60
|
mouse: true,
|
|
42
|
-
keys
|
|
43
|
-
|
|
61
|
+
// keys/vi намеренно выключены: их встроенные обработчики скроллят ленту на
|
|
62
|
+
// стрелках, а стрелки здесь двигают выделение. Все клавиши навешаны явно ниже.
|
|
44
63
|
scrollbar: {
|
|
45
64
|
ch: "│",
|
|
46
65
|
style: {
|
|
@@ -54,26 +73,49 @@ export function createChatView(screen, theme, { onLoadMoreHistory, onActionMenu
|
|
|
54
73
|
},
|
|
55
74
|
});
|
|
56
75
|
|
|
76
|
+
// blessed сам вешает на scrollable-box с mouse:true прокрутку колесом на пол-экрана.
|
|
77
|
+
// Вместе с нашими обработчиками получалось height/2 + 3 строки за щелчок.
|
|
78
|
+
scrollBox.removeAllListeners("wheelup");
|
|
79
|
+
scrollBox.removeAllListeners("wheeldown");
|
|
80
|
+
|
|
81
|
+
/** Подсвечивает рамку, когда лента сообщений в фокусе. */
|
|
82
|
+
function setFocusHighlight(active) {
|
|
83
|
+
container.style.border.fg = active ? theme.borders.focusFg : theme.borders.fg;
|
|
84
|
+
screen.render();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
scrollBox.on("focus", () => setFocusHighlight(true));
|
|
88
|
+
scrollBox.on("blur", () => setFocusHighlight(false));
|
|
89
|
+
|
|
57
90
|
let currentMessages = [];
|
|
91
|
+
let currentRanges = [];
|
|
92
|
+
let selectedId = null;
|
|
58
93
|
|
|
59
94
|
/**
|
|
60
|
-
* Форматирует список сообщений в единую ленту текста с разметкой Blessed
|
|
95
|
+
* Форматирует список сообщений в единую ленту текста с разметкой Blessed
|
|
96
|
+
* и вычисляет координаты строк каждого сообщения для кликов мыши.
|
|
61
97
|
* @param {Array<object>} messages
|
|
62
|
-
* @returns {string}
|
|
98
|
+
* @returns {{ text: string, ranges: Array<object> }}
|
|
63
99
|
*/
|
|
64
|
-
function
|
|
100
|
+
function renderMessagesWithRanges(messages) {
|
|
65
101
|
if (!messages || messages.length === 0) {
|
|
66
|
-
return
|
|
102
|
+
return {
|
|
103
|
+
text: `\n\n ${fg(theme.muted, "Сообщений пока нет. Напишите первое сообщение ниже!")}`,
|
|
104
|
+
ranges: [],
|
|
105
|
+
};
|
|
67
106
|
}
|
|
68
107
|
|
|
69
108
|
let output = "";
|
|
70
109
|
let lastDateString = "";
|
|
110
|
+
const ranges = [];
|
|
111
|
+
let lineCursor = 0;
|
|
71
112
|
|
|
72
113
|
for (const msg of messages) {
|
|
73
114
|
// Разделитель дат
|
|
74
115
|
const dateStr = formatDateDivider(msg.date);
|
|
75
116
|
if (dateStr && dateStr !== lastDateString) {
|
|
76
117
|
output += `\n ${fg(theme.chatView.dateDivider, `─────── ${escapeBlessed(dateStr)} ───────`)}\n\n`;
|
|
118
|
+
lineCursor += 3;
|
|
77
119
|
lastDateString = dateStr;
|
|
78
120
|
}
|
|
79
121
|
|
|
@@ -90,10 +132,14 @@ export function createChatView(screen, theme, { onLoadMoreHistory, onActionMenu
|
|
|
90
132
|
authorTag = `${fg(theme.chatView.incomingName, `{bold}${name}{/bold}`)} ${timeTag}`;
|
|
91
133
|
}
|
|
92
134
|
|
|
135
|
+
// Метка редактирования
|
|
136
|
+
const editedTag = msg.editDate ? ` ${fg(theme.chatView.time, "(изменено)")}` : "";
|
|
137
|
+
|
|
138
|
+
const lines = [` ${authorTag}${editedTag}`];
|
|
139
|
+
|
|
93
140
|
// Блок ответа (Reply)
|
|
94
|
-
let replyBlock = "";
|
|
95
141
|
if (msg.replyToMsgId) {
|
|
96
|
-
|
|
142
|
+
lines.push(` ${fg(theme.chatView.replyBorder, `┌─ Ответ на сообщение #${msg.replyToMsgId}`)}`);
|
|
97
143
|
}
|
|
98
144
|
|
|
99
145
|
// Текст сообщения и entities
|
|
@@ -113,29 +159,65 @@ export function createChatView(screen, theme, { onLoadMoreHistory, onActionMenu
|
|
|
113
159
|
bodyText = bodyText ? `${mediaBlock}\n${bodyText}` : mediaBlock;
|
|
114
160
|
}
|
|
115
161
|
|
|
116
|
-
//
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
.
|
|
120
|
-
|
|
162
|
+
// Строка, с которой начинается тело — нужна для координат превью
|
|
163
|
+
const bodyStartOffset = lines.length;
|
|
164
|
+
for (const line of bodyText.split("\n")) {
|
|
165
|
+
lines.push(` ${line}`);
|
|
166
|
+
}
|
|
121
167
|
|
|
122
168
|
// Реакции
|
|
123
|
-
let reactionsLine = "";
|
|
124
169
|
if (msg.reactions && msg.reactions.length > 0) {
|
|
125
170
|
const list = msg.reactions.map((r) => `${r.emoticon} ${r.count}`).join(" ");
|
|
126
|
-
|
|
171
|
+
lines.push(` ${fg(theme.chatView.reactionFg, `{bold}${list}{/bold}`)}`);
|
|
127
172
|
}
|
|
128
173
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
174
|
+
const startLine = lineCursor;
|
|
175
|
+
|
|
176
|
+
// Прямоугольник превью изображения внутри сообщения
|
|
177
|
+
let image = null;
|
|
178
|
+
if (msg.imagePreview) {
|
|
179
|
+
const descLines = msg.mediaDescription ? msg.mediaDescription.split("\n").length : 0;
|
|
180
|
+
const previewLines = msg.imagePreview.split("\n");
|
|
181
|
+
const imageStart = startLine + bodyStartOffset + descLines;
|
|
182
|
+
const width = previewLines.reduce((max, line) => Math.max(max, stringCellWidth(line)), 0);
|
|
183
|
+
image = {
|
|
184
|
+
startLine: imageStart,
|
|
185
|
+
endLine: imageStart + previewLines.length - 1,
|
|
186
|
+
left: BODY_INDENT,
|
|
187
|
+
right: BODY_INDENT + width,
|
|
188
|
+
};
|
|
133
189
|
}
|
|
134
190
|
|
|
135
|
-
|
|
191
|
+
// Выделенное сообщение помечается полосой в первой колонке. Первая колонка
|
|
192
|
+
// каждой строки — пробел отступа, поэтому ширина строк не меняется и карта
|
|
193
|
+
// координат остаётся верной.
|
|
194
|
+
const rendered = msg.id === selectedId
|
|
195
|
+
? lines.map((line) => `${fg(theme.accent, "▌")}${line.slice(1)}`)
|
|
196
|
+
: lines;
|
|
197
|
+
|
|
198
|
+
ranges.push({
|
|
199
|
+
message: msg,
|
|
200
|
+
startLine,
|
|
201
|
+
endLine: startLine + lines.length - 1,
|
|
202
|
+
image,
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
// lines.length строк + одна пустая строка-разделитель между сообщениями
|
|
206
|
+
lineCursor += lines.length + 1;
|
|
207
|
+
output += `${rendered.join("\n")}\n\n`;
|
|
136
208
|
}
|
|
137
209
|
|
|
138
|
-
return output;
|
|
210
|
+
return { text: output, ranges };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Перерисовывает ленту, сохраняя позицию прокрутки (например, после смены выделения). */
|
|
214
|
+
function redraw() {
|
|
215
|
+
const prevBase = scrollBox.childBase || 0;
|
|
216
|
+
const rendered = renderMessagesWithRanges(currentMessages);
|
|
217
|
+
currentRanges = rendered.ranges;
|
|
218
|
+
scrollBox.setContent(rendered.text);
|
|
219
|
+
scrollBox.scrollTo(prevBase);
|
|
220
|
+
screen.render();
|
|
139
221
|
}
|
|
140
222
|
|
|
141
223
|
/**
|
|
@@ -145,10 +227,17 @@ export function createChatView(screen, theme, { onLoadMoreHistory, onActionMenu
|
|
|
145
227
|
*/
|
|
146
228
|
function setMessages(messages, autoScrollToBottom = true) {
|
|
147
229
|
currentMessages = messages;
|
|
148
|
-
const prevScroll = scrollBox.
|
|
230
|
+
const prevScroll = scrollBox.childBase || 0;
|
|
149
231
|
const prevHeight = scrollBox.getScrollHeight();
|
|
150
232
|
|
|
151
|
-
|
|
233
|
+
// Выделенное сообщение могло быть удалено или относиться к другому чату
|
|
234
|
+
if (selectedId !== null && !messages.some((m) => m.id === selectedId)) {
|
|
235
|
+
selectedId = null;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const rendered = renderMessagesWithRanges(messages);
|
|
239
|
+
currentRanges = rendered.ranges;
|
|
240
|
+
scrollBox.setContent(rendered.text);
|
|
152
241
|
|
|
153
242
|
if (autoScrollToBottom) {
|
|
154
243
|
scrollBox.setScrollPerc(100);
|
|
@@ -166,10 +255,108 @@ export function createChatView(screen, theme, { onLoadMoreHistory, onActionMenu
|
|
|
166
255
|
screen.render();
|
|
167
256
|
}
|
|
168
257
|
|
|
258
|
+
/**
|
|
259
|
+
* Переводит номер отрисованной строки в номер строки исходного содержимого.
|
|
260
|
+
* blessed переносит длинные строки, поэтому напрямую индексы не совпадают.
|
|
261
|
+
* @param {number} renderedLine
|
|
262
|
+
* @returns {number}
|
|
263
|
+
*/
|
|
264
|
+
function toContentLine(renderedLine) {
|
|
265
|
+
const rtof = scrollBox._clines?.rtof;
|
|
266
|
+
if (Array.isArray(rtof) && renderedLine >= 0 && renderedLine < rtof.length) {
|
|
267
|
+
return rtof[renderedLine];
|
|
268
|
+
}
|
|
269
|
+
return renderedLine;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Обратное преобразование: первая и последняя отрисованные строки для строки содержимого.
|
|
274
|
+
* @param {number} contentLine
|
|
275
|
+
* @param {"first"|"last"} edge
|
|
276
|
+
* @returns {number}
|
|
277
|
+
*/
|
|
278
|
+
function toRenderedLine(contentLine, edge = "first") {
|
|
279
|
+
const ftor = scrollBox._clines?.ftor;
|
|
280
|
+
const mapped = Array.isArray(ftor) ? ftor[contentLine] : null;
|
|
281
|
+
if (!Array.isArray(mapped) || mapped.length === 0) return contentLine;
|
|
282
|
+
return edge === "first" ? mapped[0] : mapped[mapped.length - 1];
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Подкручивает ленту так, чтобы выделенное сообщение было видно целиком. */
|
|
286
|
+
function scrollMessageIntoView(range) {
|
|
287
|
+
if (!range) return;
|
|
288
|
+
const visible = scrollBox.height - scrollBox.iheight;
|
|
289
|
+
const base = scrollBox.childBase || 0;
|
|
290
|
+
const top = toRenderedLine(range.startLine, "first");
|
|
291
|
+
const bottom = toRenderedLine(range.endLine, "last");
|
|
292
|
+
|
|
293
|
+
if (top < base) {
|
|
294
|
+
scrollBox.scrollTo(top);
|
|
295
|
+
} else if (bottom >= base + visible) {
|
|
296
|
+
scrollBox.scrollTo(Math.max(0, bottom - visible + 1));
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Выделяет сообщение по идентификатору.
|
|
302
|
+
* @param {number|null} id
|
|
303
|
+
* @param {object} [options]
|
|
304
|
+
* @param {boolean} [options.scrollIntoView=false]
|
|
305
|
+
* @param {boolean} [options.notify=false] вызвать onSelectMessage
|
|
306
|
+
*/
|
|
307
|
+
function setSelected(id, { scrollIntoView = false, notify = false } = {}) {
|
|
308
|
+
if (selectedId === id && !scrollIntoView) return;
|
|
309
|
+
selectedId = id;
|
|
310
|
+
redraw();
|
|
311
|
+
|
|
312
|
+
const range = currentRanges.find((r) => r.message.id === id);
|
|
313
|
+
if (scrollIntoView && range) {
|
|
314
|
+
scrollMessageIntoView(range);
|
|
315
|
+
screen.render();
|
|
316
|
+
}
|
|
317
|
+
if (notify && range) {
|
|
318
|
+
onSelectMessage?.(range.message);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** @returns {object|null} выделенное сообщение */
|
|
323
|
+
function getSelected() {
|
|
324
|
+
if (selectedId === null) return null;
|
|
325
|
+
return currentMessages.find((m) => m.id === selectedId) || null;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** Сообщение, над которым выполняются действия: выделенное, иначе последнее. */
|
|
329
|
+
function getTargetMessage() {
|
|
330
|
+
return getSelected() || currentMessages[currentMessages.length - 1] || null;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Двигает выделение на step сообщений и подкручивает ленту к нему.
|
|
335
|
+
* @param {number} step
|
|
336
|
+
*/
|
|
337
|
+
function selectByOffset(step) {
|
|
338
|
+
if (currentMessages.length === 0) return;
|
|
339
|
+
|
|
340
|
+
const currentIndex = currentMessages.findIndex((m) => m.id === selectedId);
|
|
341
|
+
let nextIndex;
|
|
342
|
+
if (currentIndex === -1) {
|
|
343
|
+
nextIndex = currentMessages.length - 1;
|
|
344
|
+
} else {
|
|
345
|
+
nextIndex = Math.min(currentMessages.length - 1, Math.max(0, currentIndex + step));
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
setSelected(currentMessages[nextIndex].id, { scrollIntoView: true, notify: true });
|
|
349
|
+
|
|
350
|
+
// Дошли до верха ленты — подтягиваем предыдущую страницу истории
|
|
351
|
+
if (nextIndex === 0 && step < 0) {
|
|
352
|
+
onLoadMoreHistory?.();
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
169
356
|
// Обработка прокрутки вверх для подгрузки истории
|
|
170
357
|
function handleScrollUp(step = 10) {
|
|
171
358
|
scrollBox.scroll(-step);
|
|
172
|
-
if (scrollBox.
|
|
359
|
+
if ((scrollBox.childBase || 0) <= 0) {
|
|
173
360
|
onLoadMoreHistory?.();
|
|
174
361
|
}
|
|
175
362
|
screen.render();
|
|
@@ -182,8 +369,8 @@ export function createChatView(screen, theme, { onLoadMoreHistory, onActionMenu
|
|
|
182
369
|
|
|
183
370
|
scrollBox.key(["pageup", "C-u"], () => handleScrollUp(10));
|
|
184
371
|
scrollBox.key(["pagedown", "C-d"], () => handleScrollDown(10));
|
|
185
|
-
scrollBox.key(["up", "k"], () =>
|
|
186
|
-
scrollBox.key(["down", "j"], () =>
|
|
372
|
+
scrollBox.key(["up", "k"], () => selectByOffset(-1));
|
|
373
|
+
scrollBox.key(["down", "j"], () => selectByOffset(1));
|
|
187
374
|
scrollBox.key(["home"], () => {
|
|
188
375
|
scrollBox.scrollTo(0);
|
|
189
376
|
onLoadMoreHistory?.();
|
|
@@ -194,18 +381,48 @@ export function createChatView(screen, theme, { onLoadMoreHistory, onActionMenu
|
|
|
194
381
|
screen.render();
|
|
195
382
|
});
|
|
196
383
|
|
|
197
|
-
scrollBox.on("wheelup", () =>
|
|
198
|
-
|
|
199
|
-
|
|
384
|
+
scrollBox.on("wheelup", () => handleScrollUp(3));
|
|
385
|
+
scrollBox.on("wheeldown", () => handleScrollDown(3));
|
|
386
|
+
container.on("wheelup", () => handleScrollUp(3));
|
|
387
|
+
container.on("wheeldown", () => handleScrollDown(3));
|
|
388
|
+
|
|
389
|
+
scrollBox.on("click", (data) => {
|
|
390
|
+
const renderedLine = data.y - (scrollBox.atop || 0) - (scrollBox.itop || 0) + (scrollBox.childBase || 0);
|
|
391
|
+
const relX = data.x - (scrollBox.aleft || 0) - (scrollBox.ileft || 0);
|
|
392
|
+
const hit = getMessagePartAtPoint(toContentLine(renderedLine), relX, currentRanges);
|
|
393
|
+
|
|
394
|
+
if (!hit) {
|
|
395
|
+
// Клик по пустому месту ленты — просто передаём ей фокус
|
|
396
|
+
onFocusRequest?.();
|
|
397
|
+
scrollBox.focus();
|
|
398
|
+
screen.render();
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
if (isRightClick(data)) {
|
|
403
|
+
setSelected(hit.message.id);
|
|
404
|
+
onActionMenu?.(hit.message);
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
if (hit.part === "image") {
|
|
409
|
+
setSelected(hit.message.id);
|
|
410
|
+
onOpenImage?.(hit.message);
|
|
411
|
+
return;
|
|
200
412
|
}
|
|
413
|
+
|
|
414
|
+
onFocusRequest?.();
|
|
415
|
+
scrollBox.focus();
|
|
416
|
+
setSelected(hit.message.id, { notify: true });
|
|
201
417
|
});
|
|
202
418
|
|
|
203
419
|
// Ctrl+M терминал шлёт как "\r" (имя клавиши "return"), поэтому меню действий
|
|
204
420
|
// висит на Ctrl+A — иначе оно недостижимо.
|
|
205
|
-
scrollBox.key(["C-a"], () => {
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
421
|
+
scrollBox.key(["C-a", "enter", "return"], () => {
|
|
422
|
+
const msg = getTargetMessage();
|
|
423
|
+
if (msg) {
|
|
424
|
+
setSelected(msg.id);
|
|
425
|
+
onActionMenu?.(msg);
|
|
209
426
|
}
|
|
210
427
|
});
|
|
211
428
|
|
|
@@ -213,6 +430,10 @@ export function createChatView(screen, theme, { onLoadMoreHistory, onActionMenu
|
|
|
213
430
|
container,
|
|
214
431
|
scrollBox,
|
|
215
432
|
setMessages,
|
|
433
|
+
setSelected,
|
|
434
|
+
getSelected,
|
|
435
|
+
getTargetMessage,
|
|
436
|
+
selectByOffset,
|
|
216
437
|
loadMore: () => onLoadMoreHistory?.(),
|
|
217
438
|
scrollToBottom: () => {
|
|
218
439
|
scrollBox.setScrollPerc(100);
|
|
@@ -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, isRightClick } 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,20 @@ export function createHeader(screen, theme) {
|
|
|
29
38
|
},
|
|
30
39
|
});
|
|
31
40
|
|
|
41
|
+
headerBox.on("click", (data) => {
|
|
42
|
+
if (isRightClick(data)) return;
|
|
43
|
+
const relX = data.x - (headerBox.aleft || 0);
|
|
44
|
+
const relY = data.y - (headerBox.atop || 0);
|
|
45
|
+
const action = getHeaderActionAt(relX, relY, { hasActiveChat: Boolean(currentActiveChat) });
|
|
46
|
+
if (action === "help") {
|
|
47
|
+
onHelp?.();
|
|
48
|
+
} else if (action === "info") {
|
|
49
|
+
onChatInfo?.();
|
|
50
|
+
} else if (action === "status") {
|
|
51
|
+
onStatusClick?.();
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
32
55
|
/**
|
|
33
56
|
* Обновляет содержимое шапки.
|
|
34
57
|
* @param {object} data
|
|
@@ -38,6 +61,7 @@ export function createHeader(screen, theme) {
|
|
|
38
61
|
* @param {string|null} [data.typingUser]
|
|
39
62
|
*/
|
|
40
63
|
headerBox.updateInfo = function ({ me, status = "connected", activeChat, typingUser }) {
|
|
64
|
+
currentActiveChat = activeChat;
|
|
41
65
|
let statusBadge = fg(theme.status.online, "● В сети");
|
|
42
66
|
if (status === "connecting") {
|
|
43
67
|
statusBadge = fg(theme.status.connecting, "◌ Подключение...");
|
|
@@ -2,6 +2,8 @@ import blessed from "neo-blessed";
|
|
|
2
2
|
import { escapeBlessed } from "../../telegram/formatter.js";
|
|
3
3
|
import { fg, badge } from "../theme.js";
|
|
4
4
|
|
|
5
|
+
import { getInputContextActionAt, isRightClick } from "../../utils/mouse.js";
|
|
6
|
+
|
|
5
7
|
/**
|
|
6
8
|
* Создаёт компонент поля ввода сообщения (нижняя панель).
|
|
7
9
|
* @param {blessed.Widgets.Screen} screen
|
|
@@ -10,8 +12,16 @@ import { fg, badge } from "../theme.js";
|
|
|
10
12
|
* @param {(text: string, context: { mode: string|null, target: object|null }) => void} callbacks.onSubmit
|
|
11
13
|
* @param {() => void} [callbacks.onCancelContext]
|
|
12
14
|
* @param {(command: string, args: string[]) => void} [callbacks.onSlashCommand]
|
|
15
|
+
* @param {() => void} [callbacks.onReplyLast]
|
|
16
|
+
* @param {() => void} [callbacks.onEditLast]
|
|
13
17
|
*/
|
|
14
|
-
export function createInputBox(screen, theme, {
|
|
18
|
+
export function createInputBox(screen, theme, {
|
|
19
|
+
onSubmit,
|
|
20
|
+
onCancelContext,
|
|
21
|
+
onSlashCommand,
|
|
22
|
+
onReplyLast,
|
|
23
|
+
onEditLast,
|
|
24
|
+
} = {}) {
|
|
15
25
|
// Высота 5 = рамка (2) + контекстная плашка (1) + две строки ввода (2).
|
|
16
26
|
// При autoPadding у blessed рамка съедает по строке сверху и снизу, поэтому
|
|
17
27
|
// меньшая высота оставляет textarea нулевую высоту и вводимый текст не виден.
|
|
@@ -21,6 +31,7 @@ export function createInputBox(screen, theme, { onSubmit, onCancelContext, onSla
|
|
|
21
31
|
left: "35%",
|
|
22
32
|
right: 0,
|
|
23
33
|
height: 5,
|
|
34
|
+
mouse: true,
|
|
24
35
|
border: {
|
|
25
36
|
type: "line",
|
|
26
37
|
},
|
|
@@ -41,6 +52,8 @@ export function createInputBox(screen, theme, { onSubmit, onCancelContext, onSla
|
|
|
41
52
|
right: 0,
|
|
42
53
|
height: 1,
|
|
43
54
|
tags: true,
|
|
55
|
+
mouse: true,
|
|
56
|
+
clickable: true,
|
|
44
57
|
style: {
|
|
45
58
|
bg: theme.input.contextBg,
|
|
46
59
|
fg: theme.input.contextFg,
|
|
@@ -110,6 +123,33 @@ export function createInputBox(screen, theme, { onSubmit, onCancelContext, onSla
|
|
|
110
123
|
screen.render();
|
|
111
124
|
}
|
|
112
125
|
|
|
126
|
+
contextBar.on("click", (data) => {
|
|
127
|
+
if (isRightClick(data)) return;
|
|
128
|
+
const relX = data.x - (contextBar.aleft || 0);
|
|
129
|
+
const action = getInputContextActionAt(relX, currentMode);
|
|
130
|
+
if (action === "cancel") {
|
|
131
|
+
if (currentMode) {
|
|
132
|
+
currentMode = null;
|
|
133
|
+
currentTarget = null;
|
|
134
|
+
renderContext();
|
|
135
|
+
onCancelContext?.();
|
|
136
|
+
}
|
|
137
|
+
} else if (action === "reply") {
|
|
138
|
+
onReplyLast?.();
|
|
139
|
+
} else if (action === "edit") {
|
|
140
|
+
onEditLast?.();
|
|
141
|
+
} else if (action === "commands") {
|
|
142
|
+
textarea.setValue("/");
|
|
143
|
+
textarea.focus();
|
|
144
|
+
screen.render();
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
textarea.on("click", () => {
|
|
149
|
+
textarea.focus();
|
|
150
|
+
screen.render();
|
|
151
|
+
});
|
|
152
|
+
|
|
113
153
|
textarea.key(["enter"], () => {
|
|
114
154
|
const value = textarea.getValue().trim();
|
|
115
155
|
if (!value) {
|
|
@@ -2,6 +2,9 @@ import blessed from "neo-blessed";
|
|
|
2
2
|
import { escapeBlessed } from "../../../telegram/formatter.js";
|
|
3
3
|
import { fg } from "../../theme.js";
|
|
4
4
|
|
|
5
|
+
import { isRightClick } from "../../../utils/mouse.js";
|
|
6
|
+
import { bindOutsideClickClose } from "../../modalMouse.js";
|
|
7
|
+
|
|
5
8
|
/**
|
|
6
9
|
* Создаёт модальное окно контекстных действий над сообщением.
|
|
7
10
|
* @param {blessed.Widgets.Screen} screen
|
|
@@ -19,6 +22,7 @@ export function createActionModal(screen, theme, { onAction } = {}) {
|
|
|
19
22
|
height: "55%",
|
|
20
23
|
hidden: true,
|
|
21
24
|
tags: true,
|
|
25
|
+
mouse: true,
|
|
22
26
|
border: {
|
|
23
27
|
type: "line",
|
|
24
28
|
},
|
|
@@ -66,6 +70,21 @@ export function createActionModal(screen, theme, { onAction } = {}) {
|
|
|
66
70
|
},
|
|
67
71
|
});
|
|
68
72
|
|
|
73
|
+
const baseCreateItem = list.createItem.bind(list);
|
|
74
|
+
list.createItem = (content) => {
|
|
75
|
+
const item = baseCreateItem(content);
|
|
76
|
+
item.on("click", (data) => {
|
|
77
|
+
if (isRightClick(data)) return;
|
|
78
|
+
const index = list.getItemIndex(item);
|
|
79
|
+
const action = currentActions[index];
|
|
80
|
+
if (action && currentMsg) {
|
|
81
|
+
hide();
|
|
82
|
+
onAction?.(action.id, currentMsg);
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
return item;
|
|
86
|
+
};
|
|
87
|
+
|
|
69
88
|
let currentMsg = null;
|
|
70
89
|
let currentActions = [];
|
|
71
90
|
let previousFocus = null;
|
|
@@ -111,6 +130,7 @@ export function createActionModal(screen, theme, { onAction } = {}) {
|
|
|
111
130
|
|
|
112
131
|
list.setItems(currentActions.map((a) => a.label));
|
|
113
132
|
previousFocus = screen.focused;
|
|
133
|
+
armOutsideClose();
|
|
114
134
|
modal.show();
|
|
115
135
|
modal.setFront();
|
|
116
136
|
list.focus();
|
|
@@ -128,6 +148,9 @@ export function createActionModal(screen, theme, { onAction } = {}) {
|
|
|
128
148
|
// Фокус получает список — на нём и живут клавиши закрытия.
|
|
129
149
|
list.key(["escape", "q"], hide);
|
|
130
150
|
|
|
151
|
+
// Закрытие при клике мышью мимо модального окна
|
|
152
|
+
const armOutsideClose = bindOutsideClickClose(screen, modal, hide);
|
|
153
|
+
|
|
131
154
|
return {
|
|
132
155
|
modal,
|
|
133
156
|
show,
|
|
@@ -3,6 +3,8 @@ import { escapeBlessed } from "../../../telegram/formatter.js";
|
|
|
3
3
|
import { idToString } from "../../../telegram/entities.js";
|
|
4
4
|
import { fg } from "../../theme.js";
|
|
5
5
|
|
|
6
|
+
import { bindOutsideClickClose } from "../../modalMouse.js";
|
|
7
|
+
|
|
6
8
|
/**
|
|
7
9
|
* Создаёт модальное окно информации о текущем чате.
|
|
8
10
|
* @param {blessed.Widgets.Screen} screen
|
|
@@ -18,6 +20,7 @@ export function createChatInfoModal(screen, theme) {
|
|
|
18
20
|
height: "60%",
|
|
19
21
|
hidden: true,
|
|
20
22
|
tags: true,
|
|
23
|
+
mouse: true,
|
|
21
24
|
border: {
|
|
22
25
|
type: "line",
|
|
23
26
|
},
|
|
@@ -38,6 +41,7 @@ export function createChatInfoModal(screen, theme) {
|
|
|
38
41
|
right: 2,
|
|
39
42
|
bottom: 3,
|
|
40
43
|
tags: true,
|
|
44
|
+
mouse: true,
|
|
41
45
|
scrollable: true,
|
|
42
46
|
style: {
|
|
43
47
|
bg: theme.modal.bg,
|
|
@@ -107,6 +111,7 @@ export function createChatInfoModal(screen, theme) {
|
|
|
107
111
|
|
|
108
112
|
infoText.setContent(body);
|
|
109
113
|
previousFocus = screen.focused;
|
|
114
|
+
armOutsideClose();
|
|
110
115
|
modal.show();
|
|
111
116
|
modal.setFront();
|
|
112
117
|
closeBtn.focus();
|
|
@@ -114,9 +119,13 @@ export function createChatInfoModal(screen, theme) {
|
|
|
114
119
|
}
|
|
115
120
|
|
|
116
121
|
closeBtn.on("press", hide);
|
|
122
|
+
closeBtn.on("click", hide);
|
|
117
123
|
// Клавиши вешаем на кнопку: blessed отдаёт события только сфокусированному элементу.
|
|
118
124
|
closeBtn.key(["escape", "q"], hide);
|
|
119
125
|
|
|
126
|
+
// Закрытие при клике мышью мимо модального окна
|
|
127
|
+
const armOutsideClose = bindOutsideClickClose(screen, modal, hide);
|
|
128
|
+
|
|
120
129
|
return {
|
|
121
130
|
modal,
|
|
122
131
|
show,
|