@emaxe/tuigram 1.3.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 +43 -1
- package/CHANGELOG.ru.md +47 -1
- package/README.md +50 -14
- package/README.ru.md +51 -14
- package/package.json +1 -1
- package/src/telegram/messages.js +98 -19
- package/src/ui/app.js +95 -40
- package/src/ui/components/chatList.js +4 -2
- package/src/ui/components/chatView.js +225 -61
- package/src/ui/components/header.js +2 -1
- package/src/ui/components/inputBox.js +2 -1
- package/src/ui/components/modals/actionModal.js +6 -14
- package/src/ui/components/modals/chatInfoModal.js +3 -13
- package/src/ui/components/modals/confirmModal.js +3 -13
- package/src/ui/components/modals/fileModal.js +6 -13
- package/src/ui/components/modals/filePickerModal.js +3 -13
- package/src/ui/components/modals/helpModal.js +13 -18
- package/src/ui/components/modals/imageViewerModal.js +172 -0
- package/src/ui/components/statusBar.js +2 -1
- package/src/ui/modalMouse.js +43 -0
- package/src/ui/screen.js +60 -20
- package/src/utils/image.js +8 -8
- package/src/utils/mouse.js +61 -74
package/src/ui/app.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import fs from "node:fs";
|
|
3
|
-
import { createScreen } from "./screen.js";
|
|
3
|
+
import { createScreen, setMouseCapture } from "./screen.js";
|
|
4
4
|
import { getTheme } from "./theme.js";
|
|
5
5
|
import { createHeader } from "./components/header.js";
|
|
6
6
|
import { createChatList } from "./components/chatList.js";
|
|
@@ -12,16 +12,18 @@ import { createChatInfoModal } from "./components/modals/chatInfoModal.js";
|
|
|
12
12
|
import { createActionModal } from "./components/modals/actionModal.js";
|
|
13
13
|
import { createFileModal } from "./components/modals/fileModal.js";
|
|
14
14
|
import { createConfirmModal } from "./components/modals/confirmModal.js";
|
|
15
|
+
import { createImageViewerModal } from "./components/modals/imageViewerModal.js";
|
|
15
16
|
import { state } from "../state.js";
|
|
16
17
|
import { config } from "../config.js";
|
|
17
18
|
import { fetchDialogs } from "../telegram/dialogs.js";
|
|
18
19
|
import { setMessagePalette } from "../telegram/formatter.js";
|
|
19
|
-
import { fetchHistory, sendMessage, editMessage, deleteMessages, sendFiles, downloadMedia, sendReaction, markAsRead, loadMessageImagePreview } from "../telegram/messages.js";
|
|
20
|
+
import { fetchHistory, sendMessage, editMessage, deleteMessages, sendFiles, downloadMedia, sendReaction, markAsRead, loadMessageImagePreview, downloadImageBuffer, renderMessageThumbnail } from "../telegram/messages.js";
|
|
20
21
|
import { startTelegramListener } from "../telegram/listener.js";
|
|
21
22
|
import { logout } from "../telegram/auth.js";
|
|
22
23
|
import { ensureDir, inspectLocalFile } from "../utils/storage.js";
|
|
23
24
|
import { formatFileSize } from "../utils/time.js";
|
|
24
25
|
import { parseSendFileArgs } from "../utils/commands.js";
|
|
26
|
+
import { isRightClick } from "../utils/mouse.js";
|
|
25
27
|
|
|
26
28
|
/**
|
|
27
29
|
* Запускает полноэкранный TUI-клиент Telegram.
|
|
@@ -93,10 +95,10 @@ export async function startTui(client, me) {
|
|
|
93
95
|
statusBar.showMessage("Сначала выберите чат слева!", "warning");
|
|
94
96
|
return;
|
|
95
97
|
}
|
|
96
|
-
const
|
|
97
|
-
if (
|
|
98
|
+
const msg = chatView.getTargetMessage();
|
|
99
|
+
if (msg) {
|
|
98
100
|
releaseInputs();
|
|
99
|
-
actionModal.show(
|
|
101
|
+
actionModal.show(msg);
|
|
100
102
|
}
|
|
101
103
|
},
|
|
102
104
|
onChatInfo: () => {
|
|
@@ -125,6 +127,16 @@ export async function startTui(client, me) {
|
|
|
125
127
|
onSendFile: (files, options) => sendFilesToActiveChat(files, options),
|
|
126
128
|
});
|
|
127
129
|
|
|
130
|
+
const imageViewerModal = createImageViewerModal(screen, theme, {
|
|
131
|
+
onLoadFullImage: (msg) => downloadImageBuffer(client, msg.rawMessage),
|
|
132
|
+
// Пока качается оригинал, показываем встроенную в сообщение миниатюру
|
|
133
|
+
onRenderPlaceholder: (msg, size) => renderMessageThumbnail(msg.rawMessage, {
|
|
134
|
+
maxWidth: size.maxWidth,
|
|
135
|
+
maxHeight: size.maxHeight,
|
|
136
|
+
useCache: false,
|
|
137
|
+
}),
|
|
138
|
+
});
|
|
139
|
+
|
|
128
140
|
const actionModal = createActionModal(screen, theme, {
|
|
129
141
|
onAction: async (actionId, msg) => {
|
|
130
142
|
if (!state.activeChat) return;
|
|
@@ -239,6 +251,19 @@ export async function startTui(client, me) {
|
|
|
239
251
|
releaseInputs();
|
|
240
252
|
actionModal.show(msg);
|
|
241
253
|
},
|
|
254
|
+
onSelectMessage: (msg) => {
|
|
255
|
+
const preview = (msg.text || msg.mediaDescription || "вложение").replace(/\s+/g, " ").slice(0, 30);
|
|
256
|
+
statusBar.showMessage(
|
|
257
|
+
`Выделено #${msg.id}: "${preview}" · [Enter] или правый клик — действия`,
|
|
258
|
+
"info",
|
|
259
|
+
3000
|
|
260
|
+
);
|
|
261
|
+
},
|
|
262
|
+
onOpenImage: (msg) => {
|
|
263
|
+
releaseInputs();
|
|
264
|
+
imageViewerModal.show(msg);
|
|
265
|
+
},
|
|
266
|
+
onFocusRequest: () => releaseInputs(),
|
|
242
267
|
});
|
|
243
268
|
|
|
244
269
|
// 4. Поле ввода (нижняя панель)
|
|
@@ -276,21 +301,8 @@ export async function startTui(client, me) {
|
|
|
276
301
|
onCancelContext: () => {
|
|
277
302
|
statusBar.showMessage("Режим ответа/редактирования сброшен", "info", 2000);
|
|
278
303
|
},
|
|
279
|
-
onReplyLast: () =>
|
|
280
|
-
|
|
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
|
-
},
|
|
304
|
+
onReplyLast: () => startReply(),
|
|
305
|
+
onEditLast: () => startEdit(),
|
|
294
306
|
onSlashCommand: (cmd, args) => {
|
|
295
307
|
switch (cmd) {
|
|
296
308
|
case "help":
|
|
@@ -356,7 +368,8 @@ export async function startTui(client, me) {
|
|
|
356
368
|
});
|
|
357
369
|
|
|
358
370
|
// Переключение фокуса по клику мышью на любую из трех панелей
|
|
359
|
-
chatList.container.on("click", () => {
|
|
371
|
+
chatList.container.on("click", (data) => {
|
|
372
|
+
if (isRightClick(data)) return;
|
|
360
373
|
if (screen.focused !== chatList.list && screen.focused !== chatList.searchBox) {
|
|
361
374
|
releaseInputs();
|
|
362
375
|
chatList.focus();
|
|
@@ -365,7 +378,8 @@ export async function startTui(client, me) {
|
|
|
365
378
|
}
|
|
366
379
|
});
|
|
367
380
|
|
|
368
|
-
chatView.container.on("click", () => {
|
|
381
|
+
chatView.container.on("click", (data) => {
|
|
382
|
+
if (isRightClick(data)) return;
|
|
369
383
|
if (screen.focused !== chatView.scrollBox) {
|
|
370
384
|
releaseInputs();
|
|
371
385
|
chatView.focus();
|
|
@@ -374,7 +388,8 @@ export async function startTui(client, me) {
|
|
|
374
388
|
}
|
|
375
389
|
});
|
|
376
390
|
|
|
377
|
-
inputBox.container.on("click", () => {
|
|
391
|
+
inputBox.container.on("click", (data) => {
|
|
392
|
+
if (isRightClick(data)) return;
|
|
378
393
|
if (screen.focused !== inputBox.textarea) {
|
|
379
394
|
inputBox.focus();
|
|
380
395
|
statusBar.showMessage("Фокус: поле ввода", "info", 2000);
|
|
@@ -403,6 +418,7 @@ export async function startTui(client, me) {
|
|
|
403
418
|
typingUser: state.getTypingUser(chat?.id),
|
|
404
419
|
});
|
|
405
420
|
const msgs = chat ? state.getMessages(chat.id) : [];
|
|
421
|
+
chatView.setSelected(null);
|
|
406
422
|
chatView.setMessages(msgs);
|
|
407
423
|
});
|
|
408
424
|
|
|
@@ -572,6 +588,31 @@ export async function startTui(client, me) {
|
|
|
572
588
|
chatList.release?.();
|
|
573
589
|
}
|
|
574
590
|
|
|
591
|
+
/** Включает режим ответа на выделенное сообщение, иначе — на последнее в ленте. */
|
|
592
|
+
function startReply() {
|
|
593
|
+
if (!state.activeChat) return;
|
|
594
|
+
const target = chatView.getTargetMessage();
|
|
595
|
+
if (target) {
|
|
596
|
+
inputBox.setContext("reply", target);
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/** Включает правку выделенного своего сообщения, иначе — последнего своего. */
|
|
601
|
+
function startEdit() {
|
|
602
|
+
if (!state.activeChat) return;
|
|
603
|
+
const selected = chatView.getSelected();
|
|
604
|
+
if (selected?.out) {
|
|
605
|
+
inputBox.setContext("edit", selected);
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
const ownMsgs = state.getMessages(state.activeChat.id).filter((m) => m.out);
|
|
609
|
+
if (ownMsgs.length > 0) {
|
|
610
|
+
inputBox.setContext("edit", ownMsgs[ownMsgs.length - 1]);
|
|
611
|
+
} else {
|
|
612
|
+
statusBar.showMessage("В этом чате нет ваших сообщений для правки", "warning", 3000);
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
575
616
|
// 7. Глобальные сочетания клавиш
|
|
576
617
|
// Цикл фокуса: список чатов -> лента сообщений -> поле ввода -> список чатов.
|
|
577
618
|
// Без ленты в цикле были недостижимы прокрутка, подгрузка истории и меню действий.
|
|
@@ -614,17 +655,18 @@ export async function startTui(client, me) {
|
|
|
614
655
|
screen.key(["tab"], () => moveFocus(1));
|
|
615
656
|
screen.key(["S-tab"], () => moveFocus(-1));
|
|
616
657
|
|
|
658
|
+
// Когда лента в фокусе, эти клавиши обрабатывает она сама — иначе прокрутка удваивается
|
|
617
659
|
screen.key(["pageup", "C-u"], () => {
|
|
618
|
-
if (!state.activeChat) return;
|
|
660
|
+
if (!state.activeChat || screen.focused === chatView.scrollBox) return;
|
|
619
661
|
chatView.scrollBox.scroll(-10);
|
|
620
|
-
if (chatView.scrollBox.
|
|
662
|
+
if ((chatView.scrollBox.childBase || 0) <= 0) {
|
|
621
663
|
chatView.loadMore?.();
|
|
622
664
|
}
|
|
623
665
|
screen.render();
|
|
624
666
|
});
|
|
625
667
|
|
|
626
668
|
screen.key(["pagedown", "C-d"], () => {
|
|
627
|
-
if (!state.activeChat) return;
|
|
669
|
+
if (!state.activeChat || screen.focused === chatView.scrollBox) return;
|
|
628
670
|
chatView.scrollBox.scroll(10);
|
|
629
671
|
screen.render();
|
|
630
672
|
});
|
|
@@ -653,23 +695,36 @@ export async function startTui(client, me) {
|
|
|
653
695
|
}
|
|
654
696
|
});
|
|
655
697
|
|
|
656
|
-
screen.key(["C-r"], () =>
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
698
|
+
screen.key(["C-r"], () => startReply());
|
|
699
|
+
|
|
700
|
+
screen.key(["C-e"], () => startEdit());
|
|
701
|
+
|
|
702
|
+
// Меню действий над выделенным сообщением из любой панели.
|
|
703
|
+
// Когда лента в фокусе, Ctrl+A обрабатывает она сама.
|
|
704
|
+
screen.key(["C-a"], () => {
|
|
705
|
+
if (screen.focused === chatView.scrollBox) return;
|
|
706
|
+
if (!state.activeChat) {
|
|
707
|
+
statusBar.showMessage("Сначала выберите чат слева!", "warning");
|
|
708
|
+
return;
|
|
709
|
+
}
|
|
710
|
+
const msg = chatView.getTargetMessage();
|
|
711
|
+
if (msg) {
|
|
712
|
+
releaseInputs();
|
|
713
|
+
actionModal.show(msg);
|
|
662
714
|
}
|
|
663
715
|
});
|
|
664
716
|
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
const
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
717
|
+
// Пока мышь захвачена приложением, терминал не даёт выделять текст для копирования
|
|
718
|
+
screen.key(["f12"], () => {
|
|
719
|
+
const enabled = !screen.mouseCaptured;
|
|
720
|
+
setMouseCapture(screen, enabled);
|
|
721
|
+
statusBar.showMessage(
|
|
722
|
+
enabled
|
|
723
|
+
? "Мышь снова управляет интерфейсом"
|
|
724
|
+
: "Мышь отдана терминалу — можно выделять и копировать текст. [F12] вернуть",
|
|
725
|
+
enabled ? "info" : "warning",
|
|
726
|
+
5000
|
|
727
|
+
);
|
|
673
728
|
});
|
|
674
729
|
|
|
675
730
|
screen.key(["C-q"], () => {
|
|
@@ -4,7 +4,7 @@ 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";
|
|
7
|
+
import { getTabByCoordinate, isRightClick } from "../../utils/mouse.js";
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* Создаёт компонент списка диалогов (левая панель).
|
|
@@ -70,6 +70,7 @@ export function createChatList(screen, theme, { onSelectDialog, onTabChange, onS
|
|
|
70
70
|
}
|
|
71
71
|
|
|
72
72
|
tabsBox.on("click", (data) => {
|
|
73
|
+
if (isRightClick(data)) return;
|
|
73
74
|
const relX = data.x - (tabsBox.aleft || 0);
|
|
74
75
|
const tabKey = getTabByCoordinate(relX, TAB_KEYS, TAB_NAMES);
|
|
75
76
|
if (tabKey) {
|
|
@@ -148,7 +149,8 @@ export function createChatList(screen, theme, { onSelectDialog, onTabChange, onS
|
|
|
148
149
|
list.createItem = (content) => {
|
|
149
150
|
const item = baseCreateItem(content);
|
|
150
151
|
item.wrap = false;
|
|
151
|
-
item.on("click", () => {
|
|
152
|
+
item.on("click", (data) => {
|
|
153
|
+
if (isRightClick(data)) return;
|
|
152
154
|
const index = list.getItemIndex(item);
|
|
153
155
|
if (index !== -1 && currentDialogs[index]) {
|
|
154
156
|
list.select(index);
|
|
@@ -3,7 +3,10 @@ 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 {
|
|
6
|
+
import { getMessagePartAtPoint, isRightClick, stringCellWidth } from "../../utils/mouse.js";
|
|
7
|
+
|
|
8
|
+
/** Отступ тела сообщения от левого края ленты, в ячейках. */
|
|
9
|
+
const BODY_INDENT = 2;
|
|
7
10
|
|
|
8
11
|
/**
|
|
9
12
|
* Создаёт компонент просмотра сообщений чата (правая центральная панель).
|
|
@@ -11,9 +14,18 @@ import { getMessageAtLine } from "../../utils/mouse.js";
|
|
|
11
14
|
* @param {object} theme
|
|
12
15
|
* @param {object} callbacks
|
|
13
16
|
* @param {() => void} [callbacks.onLoadMoreHistory]
|
|
14
|
-
* @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] вызывается перед взятием фокуса мышью
|
|
15
21
|
*/
|
|
16
|
-
export function createChatView(screen, theme, {
|
|
22
|
+
export function createChatView(screen, theme, {
|
|
23
|
+
onLoadMoreHistory,
|
|
24
|
+
onActionMenu,
|
|
25
|
+
onSelectMessage,
|
|
26
|
+
onOpenImage,
|
|
27
|
+
onFocusRequest,
|
|
28
|
+
} = {}) {
|
|
17
29
|
const container = blessed.box({
|
|
18
30
|
parent: screen,
|
|
19
31
|
top: 4,
|
|
@@ -41,9 +53,13 @@ export function createChatView(screen, theme, { onLoadMoreHistory, onActionMenu
|
|
|
41
53
|
bottom: 0,
|
|
42
54
|
tags: true,
|
|
43
55
|
scrollable: true,
|
|
56
|
+
// alwaysScroll обязателен: без него blessed копит смещение в childOffset,
|
|
57
|
+
// не двигая содержимое (первые щелчки колеса «проглатываются»), а getScroll()
|
|
58
|
+
// перестаёт совпадать с реальным сдвигом ленты — клики попадали не в то сообщение.
|
|
59
|
+
alwaysScroll: true,
|
|
44
60
|
mouse: true,
|
|
45
|
-
keys
|
|
46
|
-
|
|
61
|
+
// keys/vi намеренно выключены: их встроенные обработчики скроллят ленту на
|
|
62
|
+
// стрелках, а стрелки здесь двигают выделение. Все клавиши навешаны явно ниже.
|
|
47
63
|
scrollbar: {
|
|
48
64
|
ch: "│",
|
|
49
65
|
style: {
|
|
@@ -57,6 +73,11 @@ export function createChatView(screen, theme, { onLoadMoreHistory, onActionMenu
|
|
|
57
73
|
},
|
|
58
74
|
});
|
|
59
75
|
|
|
76
|
+
// blessed сам вешает на scrollable-box с mouse:true прокрутку колесом на пол-экрана.
|
|
77
|
+
// Вместе с нашими обработчиками получалось height/2 + 3 строки за щелчок.
|
|
78
|
+
scrollBox.removeAllListeners("wheelup");
|
|
79
|
+
scrollBox.removeAllListeners("wheeldown");
|
|
80
|
+
|
|
60
81
|
/** Подсвечивает рамку, когда лента сообщений в фокусе. */
|
|
61
82
|
function setFocusHighlight(active) {
|
|
62
83
|
container.style.border.fg = active ? theme.borders.focusFg : theme.borders.fg;
|
|
@@ -68,12 +89,13 @@ export function createChatView(screen, theme, { onLoadMoreHistory, onActionMenu
|
|
|
68
89
|
|
|
69
90
|
let currentMessages = [];
|
|
70
91
|
let currentRanges = [];
|
|
92
|
+
let selectedId = null;
|
|
71
93
|
|
|
72
94
|
/**
|
|
73
95
|
* Форматирует список сообщений в единую ленту текста с разметкой Blessed
|
|
74
96
|
* и вычисляет координаты строк каждого сообщения для кликов мыши.
|
|
75
97
|
* @param {Array<object>} messages
|
|
76
|
-
* @returns {{ text: string, ranges: Array<
|
|
98
|
+
* @returns {{ text: string, ranges: Array<object> }}
|
|
77
99
|
*/
|
|
78
100
|
function renderMessagesWithRanges(messages) {
|
|
79
101
|
if (!messages || messages.length === 0) {
|
|
@@ -110,12 +132,14 @@ export function createChatView(screen, theme, { onLoadMoreHistory, onActionMenu
|
|
|
110
132
|
authorTag = `${fg(theme.chatView.incomingName, `{bold}${name}{/bold}`)} ${timeTag}`;
|
|
111
133
|
}
|
|
112
134
|
|
|
135
|
+
// Метка редактирования
|
|
136
|
+
const editedTag = msg.editDate ? ` ${fg(theme.chatView.time, "(изменено)")}` : "";
|
|
137
|
+
|
|
138
|
+
const lines = [` ${authorTag}${editedTag}`];
|
|
139
|
+
|
|
113
140
|
// Блок ответа (Reply)
|
|
114
|
-
let replyBlock = "";
|
|
115
|
-
let replyLines = 0;
|
|
116
141
|
if (msg.replyToMsgId) {
|
|
117
|
-
|
|
118
|
-
replyLines = 1;
|
|
142
|
+
lines.push(` ${fg(theme.chatView.replyBorder, `┌─ Ответ на сообщение #${msg.replyToMsgId}`)}`);
|
|
119
143
|
}
|
|
120
144
|
|
|
121
145
|
// Текст сообщения и entities
|
|
@@ -135,41 +159,67 @@ export function createChatView(screen, theme, { onLoadMoreHistory, onActionMenu
|
|
|
135
159
|
bodyText = bodyText ? `${mediaBlock}\n${bodyText}` : mediaBlock;
|
|
136
160
|
}
|
|
137
161
|
|
|
138
|
-
//
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
.
|
|
142
|
-
|
|
143
|
-
const bodyLinesCount = indentedBody.split("\n").length;
|
|
162
|
+
// Строка, с которой начинается тело — нужна для координат превью
|
|
163
|
+
const bodyStartOffset = lines.length;
|
|
164
|
+
for (const line of bodyText.split("\n")) {
|
|
165
|
+
lines.push(` ${line}`);
|
|
166
|
+
}
|
|
144
167
|
|
|
145
168
|
// Реакции
|
|
146
|
-
let reactionsLine = "";
|
|
147
|
-
let reactionLinesCount = 0;
|
|
148
169
|
if (msg.reactions && msg.reactions.length > 0) {
|
|
149
170
|
const list = msg.reactions.map((r) => `${r.emoticon} ${r.count}`).join(" ");
|
|
150
|
-
|
|
151
|
-
reactionLinesCount = 1;
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
// Метка редактирования
|
|
155
|
-
let editedTag = "";
|
|
156
|
-
if (msg.editDate) {
|
|
157
|
-
editedTag = ` ${fg(theme.chatView.time, "(изменено)")}`;
|
|
171
|
+
lines.push(` ${fg(theme.chatView.reactionFg, `{bold}${list}{/bold}`)}`);
|
|
158
172
|
}
|
|
159
173
|
|
|
160
174
|
const startLine = lineCursor;
|
|
161
|
-
const totalMsgLines = 1 + replyLines + bodyLinesCount + reactionLinesCount;
|
|
162
|
-
const endLine = startLine + totalMsgLines - 1;
|
|
163
175
|
|
|
164
|
-
|
|
165
|
-
|
|
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
|
+
};
|
|
189
|
+
}
|
|
166
190
|
|
|
167
|
-
|
|
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`;
|
|
168
208
|
}
|
|
169
209
|
|
|
170
210
|
return { text: output, ranges };
|
|
171
211
|
}
|
|
172
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();
|
|
221
|
+
}
|
|
222
|
+
|
|
173
223
|
/**
|
|
174
224
|
* Устанавливает сообщения в ленту.
|
|
175
225
|
* @param {Array<object>} messages
|
|
@@ -177,9 +227,14 @@ export function createChatView(screen, theme, { onLoadMoreHistory, onActionMenu
|
|
|
177
227
|
*/
|
|
178
228
|
function setMessages(messages, autoScrollToBottom = true) {
|
|
179
229
|
currentMessages = messages;
|
|
180
|
-
const prevScroll = scrollBox.
|
|
230
|
+
const prevScroll = scrollBox.childBase || 0;
|
|
181
231
|
const prevHeight = scrollBox.getScrollHeight();
|
|
182
232
|
|
|
233
|
+
// Выделенное сообщение могло быть удалено или относиться к другому чату
|
|
234
|
+
if (selectedId !== null && !messages.some((m) => m.id === selectedId)) {
|
|
235
|
+
selectedId = null;
|
|
236
|
+
}
|
|
237
|
+
|
|
183
238
|
const rendered = renderMessagesWithRanges(messages);
|
|
184
239
|
currentRanges = rendered.ranges;
|
|
185
240
|
scrollBox.setContent(rendered.text);
|
|
@@ -200,10 +255,108 @@ export function createChatView(screen, theme, { onLoadMoreHistory, onActionMenu
|
|
|
200
255
|
screen.render();
|
|
201
256
|
}
|
|
202
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
|
+
|
|
203
356
|
// Обработка прокрутки вверх для подгрузки истории
|
|
204
357
|
function handleScrollUp(step = 10) {
|
|
205
358
|
scrollBox.scroll(-step);
|
|
206
|
-
if (scrollBox.
|
|
359
|
+
if ((scrollBox.childBase || 0) <= 0) {
|
|
207
360
|
onLoadMoreHistory?.();
|
|
208
361
|
}
|
|
209
362
|
screen.render();
|
|
@@ -216,8 +369,8 @@ export function createChatView(screen, theme, { onLoadMoreHistory, onActionMenu
|
|
|
216
369
|
|
|
217
370
|
scrollBox.key(["pageup", "C-u"], () => handleScrollUp(10));
|
|
218
371
|
scrollBox.key(["pagedown", "C-d"], () => handleScrollDown(10));
|
|
219
|
-
scrollBox.key(["up", "k"], () =>
|
|
220
|
-
scrollBox.key(["down", "j"], () =>
|
|
372
|
+
scrollBox.key(["up", "k"], () => selectByOffset(-1));
|
|
373
|
+
scrollBox.key(["down", "j"], () => selectByOffset(1));
|
|
221
374
|
scrollBox.key(["home"], () => {
|
|
222
375
|
scrollBox.scrollTo(0);
|
|
223
376
|
onLoadMoreHistory?.();
|
|
@@ -228,41 +381,48 @@ export function createChatView(screen, theme, { onLoadMoreHistory, onActionMenu
|
|
|
228
381
|
screen.render();
|
|
229
382
|
});
|
|
230
383
|
|
|
231
|
-
scrollBox.on("wheelup", () =>
|
|
232
|
-
|
|
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
|
-
});
|
|
384
|
+
scrollBox.on("wheelup", () => handleScrollUp(3));
|
|
385
|
+
scrollBox.on("wheeldown", () => handleScrollDown(3));
|
|
386
|
+
container.on("wheelup", () => handleScrollUp(3));
|
|
387
|
+
container.on("wheeldown", () => handleScrollDown(3));
|
|
246
388
|
|
|
247
389
|
scrollBox.on("click", (data) => {
|
|
248
|
-
const
|
|
249
|
-
const
|
|
250
|
-
const
|
|
251
|
-
|
|
252
|
-
if (
|
|
253
|
-
|
|
254
|
-
|
|
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?.();
|
|
255
397
|
scrollBox.focus();
|
|
256
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;
|
|
257
412
|
}
|
|
413
|
+
|
|
414
|
+
onFocusRequest?.();
|
|
415
|
+
scrollBox.focus();
|
|
416
|
+
setSelected(hit.message.id, { notify: true });
|
|
258
417
|
});
|
|
259
418
|
|
|
260
419
|
// Ctrl+M терминал шлёт как "\r" (имя клавиши "return"), поэтому меню действий
|
|
261
420
|
// висит на Ctrl+A — иначе оно недостижимо.
|
|
262
|
-
scrollBox.key(["C-a"], () => {
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
421
|
+
scrollBox.key(["C-a", "enter", "return"], () => {
|
|
422
|
+
const msg = getTargetMessage();
|
|
423
|
+
if (msg) {
|
|
424
|
+
setSelected(msg.id);
|
|
425
|
+
onActionMenu?.(msg);
|
|
266
426
|
}
|
|
267
427
|
});
|
|
268
428
|
|
|
@@ -270,6 +430,10 @@ export function createChatView(screen, theme, { onLoadMoreHistory, onActionMenu
|
|
|
270
430
|
container,
|
|
271
431
|
scrollBox,
|
|
272
432
|
setMessages,
|
|
433
|
+
setSelected,
|
|
434
|
+
getSelected,
|
|
435
|
+
getTargetMessage,
|
|
436
|
+
selectByOffset,
|
|
273
437
|
loadMore: () => onLoadMoreHistory?.(),
|
|
274
438
|
scrollToBottom: () => {
|
|
275
439
|
scrollBox.setScrollPerc(100);
|