@emaxe/tuigram 1.1.0 → 1.2.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/.env.example CHANGED
@@ -28,6 +28,13 @@ AUTO_SCROLL=true
28
28
  # Показывать уведомления о наборе текста (true/false)
29
29
  SHOW_TYPING=true
30
30
 
31
+ # Отображение цветных превью изображений в чате (true/false)
32
+ SHOW_IMAGES=true
33
+
34
+ # Максимальные размеры превью изображений в символах терминала (ширина x высота)
35
+ IMAGE_MAX_WIDTH=36
36
+ IMAGE_MAX_HEIGHT=14
37
+
31
38
  # ── Proxy Settings ────────────────────────────────────────────────────────────
32
39
  # Поддерживаются HTTP и SOCKS5 (как с авторизацией, так и без).
33
40
  # Формат единого URL:
package/CHANGELOG.md CHANGED
@@ -13,6 +13,13 @@ 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.2.0] — 2026-09-01
17
+
18
+ ### Added
19
+
20
+ - Display color image previews directly in chat messages using high-resolution Unicode half-block characters (`▀` `U+2580`) with instant rendering of Telegram stripped thumbnails (`PhotoStrippedSize`).
21
+ - Configuration options `SHOW_IMAGES`, `IMAGE_MAX_WIDTH`, and `IMAGE_MAX_HEIGHT` to control image preview rendering in `.env` and `config.js`.
22
+
16
23
  ## [1.1.0] — 2026-08-31
17
24
 
18
25
  ### Added
@@ -111,7 +118,8 @@ First public release.
111
118
  - A WCAG contrast test (3:1 threshold) applied to every theme **after** conversion
112
119
  to xterm-256 — the colors are checked exactly as the user sees them.
113
120
 
114
- [Unreleased]: https://github.com/emaxe/tuigram/compare/v1.1.0...HEAD
121
+ [Unreleased]: https://github.com/emaxe/tuigram/compare/v1.2.0...HEAD
122
+ [1.2.0]: https://github.com/emaxe/tuigram/compare/v1.1.0...v1.2.0
115
123
  [1.1.0]: https://github.com/emaxe/tuigram/compare/v1.0.1...v1.1.0
116
124
  [1.0.1]: https://github.com/emaxe/tuigram/compare/v1.0.0...v1.0.1
117
125
  [1.0.0]: https://github.com/emaxe/tuigram/releases/tag/v1.0.0
package/CHANGELOG.ru.md CHANGED
@@ -13,6 +13,13 @@
13
13
 
14
14
  - Английская локализация строк интерфейса TUI и CLI.
15
15
 
16
+ ## [1.2.0] — 2026-09-01
17
+
18
+ ### Добавлено
19
+
20
+ - Отображение цветных превью изображений прямо в ленте сообщений чата с помощью псевдографики высокого разрешения (Unicode Half-Block `▀` `U+2580`) и мгновенного рендеринга миниатюр `PhotoStrippedSize`.
21
+ - Параметры конфигурации `SHOW_IMAGES`, `IMAGE_MAX_WIDTH` и `IMAGE_MAX_HEIGHT` в `.env` и `config.js` для управления отображением превью и их максимальным размером.
22
+
16
23
  ## [1.1.0] — 2026-08-31
17
24
 
18
25
  ### Добавлено
@@ -113,7 +120,8 @@
113
120
  - Тест контраста по WCAG (порог 3:1) для каждой темы — **после** конверсии в xterm-256,
114
121
  то есть ровно в том виде, в каком цвет увидит пользователь.
115
122
 
116
- [Unreleased]: https://github.com/emaxe/tuigram/compare/v1.1.0...HEAD
123
+ [Unreleased]: https://github.com/emaxe/tuigram/compare/v1.2.0...HEAD
124
+ [1.2.0]: https://github.com/emaxe/tuigram/compare/v1.1.0...v1.2.0
117
125
  [1.1.0]: https://github.com/emaxe/tuigram/compare/v1.0.1...v1.1.0
118
126
  [1.0.1]: https://github.com/emaxe/tuigram/compare/v1.0.0...v1.0.1
119
127
  [1.0.0]: https://github.com/emaxe/tuigram/releases/tag/v1.0.0
package/README.md CHANGED
@@ -55,6 +55,7 @@ TuiGram lets you use Telegram entirely from the terminal: browse your dialog lis
55
55
  - [Slash commands](#-slash-commands-in-the-input-box)
56
56
  - [Sending files and images](#-sending-files-and-images)
57
57
  - [Themes](#-themes)
58
+ - [Inline image previews](#️-inline-image-previews)
58
59
  - [Proxy configuration](#-proxy-configuration)
59
60
  - [Command line usage (CLI)](#️-command-line-usage-cli)
60
61
  - [Project structure](#-project-structure)
@@ -74,6 +75,7 @@ TuiGram lets you use Telegram entirely from the terminal: browse your dialog lis
74
75
  - Instant search and filtering of chats by title and `@username` (`/`);
75
76
  - Live typing indicator ("… is typing");
76
77
  - Colored rendering of Telegram entities (bold, italic, monospace code, URLs, mentions, spoilers);
78
+ - High-resolution inline image previews (Unicode Half-Block `▀` pixel art);
77
79
  - Media attachment indicators (photo, video, document, voice, sticker, poll);
78
80
  - Message reactions (👍, 🔥, ❤️);
79
81
  - Infinite upward pagination of message history (`PageUp` / `Ctrl+U`);
@@ -512,6 +514,23 @@ The contrast of every "text on background" pair is verified by an automated WCAG
512
514
 
513
515
  ---
514
516
 
517
+ ## 🖼️ Inline image previews
518
+
519
+ TuiGram displays color image previews directly in the chat history using high-resolution Unicode half-block characters (`▀` `U+2580`). Each character cell renders 2 vertical subpixels with 24-bit RGB colors, producing crisp, proportional pixel art that scrolls naturally with messages and works across all terminals.
520
+
521
+ Previews are enabled by default and can be configured in `.env`:
522
+
523
+ ```env
524
+ # Enable/disable image previews in chat (true/false)
525
+ SHOW_IMAGES=true
526
+
527
+ # Maximum preview size in terminal characters (width x height)
528
+ IMAGE_MAX_WIDTH=36
529
+ IMAGE_MAX_HEIGHT=14
530
+ ```
531
+
532
+ ---
533
+
515
534
  ## 🌐 Proxy configuration
516
535
 
517
536
  TuiGram supports routing MTProto connections through HTTP (including HTTPS CONNECT) and SOCKS5/SOCKS4 proxies — both with and without username/password authentication.
@@ -612,6 +631,7 @@ TuiGram/
612
631
  │ │ ├── init.js # tuigram init / paths — setup and diagnostics
613
632
  │ │ └── formatters.js # Console table and log formatters
614
633
  │ └── utils/
634
+ │ ├── image.js # Image decoding, resizing and Half-Block ANSI rendering
615
635
  │ ├── storage.js # File operations and session persistence
616
636
  │ └── time.js # Time and date formatting
617
637
  ├── scripts/
package/README.ru.md CHANGED
@@ -51,6 +51,7 @@
51
51
  - [Слэш-команды](#-слэш-команды-в-поле-ввода)
52
52
  - [Отправка файлов и картинок](#-отправка-файлов-и-картинок)
53
53
  - [Темы оформления](#-темы-оформления)
54
+ - [Превью изображений в сообщениях](#️-превью-изображений-в-сообщениях)
54
55
  - [Прокси-сервер](#-прокси-сервер)
55
56
  - [Использование через консоль (CLI)](#️-использование-через-консоль-cli)
56
57
  - [Структура проекта](#-структура-проекта)
@@ -70,6 +71,7 @@
70
71
  - Мгновенный поиск и фильтрация чатов по названию и `@username` (`/`);
71
72
  - Отображение статуса набора текста («Собеседник печатает...») в реальном времени;
72
73
  - Цветовое форматирование Telegram Entities (Bold, Italic, Monospace Code, URLs, Mentions, Spoilers);
74
+ - Цветные превью изображений высокого разрешения прямо в чате (Unicode Half-Block `▀` pixel art);
73
75
  - Индикаторы медиа-вложений (фото, видео, документы, голосовые, стикеры, опросы);
74
76
  - Реакции на сообщения (👍, 🔥, ❤️);
75
77
  - Бесконечная пагинация истории сообщений вверх (`PageUp` / `Ctrl+U`);
@@ -506,6 +508,23 @@ TUI_THEME=light # светлая
506
508
 
507
509
  ---
508
510
 
511
+ ## 🖼️ Превью изображений в сообщениях
512
+
513
+ TuiGram отображает цветные превью фотографий прямо в ленте сообщений чата с помощью псевдографики высокого разрешения (Unicode Half-Block `▀` `U+2580`). Каждое знакоместо терминала вмещает 2 вертикальных субпикселя с 24-битными цветами, обеспечивая чёткое пропорциональное изображение 1:1, которое плавно прокручивается вместе с сообщениями и поддерживается всеми терминалами.
514
+
515
+ Отображение превью включено по умолчанию и настраивается в `.env`:
516
+
517
+ ```env
518
+ # Включение/отключение превью картинок в чате (true/false)
519
+ SHOW_IMAGES=true
520
+
521
+ # Максимальный размер превью в символах терминала (ширина x высота)
522
+ IMAGE_MAX_WIDTH=36
523
+ IMAGE_MAX_HEIGHT=14
524
+ ```
525
+
526
+ ---
527
+
509
528
  ## 🌐 Прокси-сервер
510
529
 
511
530
  TuiGram поддерживает маршрутизацию MTProto-соединений через HTTP (включая HTTPS CONNECT) и SOCKS5/SOCKS4 прокси — как с авторизацией по логину и паролю, так и без неё.
@@ -606,6 +625,7 @@ TuiGram/
606
625
  │ │ ├── init.js # tuigram init / paths — настройка и диагностика
607
626
  │ │ └── formatters.js # Консольные форматтеры таблиц и логов
608
627
  │ └── utils/
628
+ │ ├── image.js # Декодирование, масштабирование и Half-Block ANSI рендеринг изображений
609
629
  │ ├── storage.js # Файловые операции и сохранение сессий
610
630
  │ └── time.js # Форматирование времени и дат
611
631
  ├── scripts/
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@emaxe/tuigram",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "Полнофункциональный TUI & CLI клиент Telegram на Node.js на базе MTProto (teleproto)",
5
5
  "type": "module",
6
6
  "exports": {
@@ -70,7 +70,9 @@
70
70
  "colorette": "^2.0.20",
71
71
  "dotenv": "^16.4.7",
72
72
  "input": "^1.0.1",
73
+ "jpeg-js": "^0.4.4",
73
74
  "neo-blessed": "^0.2.0",
75
+ "pngjs": "^7.0.0",
74
76
  "teleproto": "^1.229.0"
75
77
  }
76
78
  }
package/src/config.js CHANGED
@@ -251,6 +251,9 @@ export const config = {
251
251
  theme: process.env.TUI_THEME || "default",
252
252
  autoScroll: String(process.env.AUTO_SCROLL || "true").toLowerCase() === "true",
253
253
  showTyping: String(process.env.SHOW_TYPING || "true").toLowerCase() === "true",
254
+ showImages: String(process.env.SHOW_IMAGES || "true").toLowerCase() !== "false",
255
+ imageMaxWidth: parseInt(process.env.IMAGE_MAX_WIDTH || "36", 10) || 36,
256
+ imageMaxHeight: parseInt(process.env.IMAGE_MAX_HEIGHT || "14", 10) || 14,
254
257
 
255
258
  proxy: parseProxyConfig(),
256
259
 
package/src/state.js CHANGED
@@ -140,7 +140,7 @@ class AppState extends EventEmitter {
140
140
  combined.sort((a, b) => (a.date || 0) - (b.date || 0) || (a.id - b.id));
141
141
 
142
142
  this.messagesByChat.set(chatId, combined);
143
- this.emit("messages_updated", { chatId, messages: combined, isPrepend: prepend });
143
+ this.emit("messages_updated", { chatId, messages: combined, isPrepend: prepend, isUpdate: false });
144
144
  }
145
145
 
146
146
  /**
@@ -181,7 +181,7 @@ class AppState extends EventEmitter {
181
181
  this.emit("dialogs_updated", this.getVisibleDialogs());
182
182
  }
183
183
 
184
- this.emit("messages_updated", { chatId, messages: list, isPrepend: false });
184
+ this.emit("messages_updated", { chatId, messages: list, isPrepend: false, isNewMessage: true });
185
185
  }
186
186
 
187
187
  /**
@@ -194,7 +194,7 @@ class AppState extends EventEmitter {
194
194
  const index = list.findIndex((m) => m.id === updatedMessage.id);
195
195
  if (index !== -1) {
196
196
  list[index] = { ...list[index], ...updatedMessage };
197
- this.emit("messages_updated", { chatId, messages: list, isPrepend: false });
197
+ this.emit("messages_updated", { chatId, messages: list, isPrepend: false, isUpdate: true });
198
198
  }
199
199
  }
200
200
 
@@ -208,7 +208,7 @@ class AppState extends EventEmitter {
208
208
  const idSet = new Set(deletedIds);
209
209
  const filtered = list.filter((m) => !idSet.has(m.id));
210
210
  this.messagesByChat.set(chatId, filtered);
211
- this.emit("messages_updated", { chatId, messages: filtered, isPrepend: false });
211
+ this.emit("messages_updated", { chatId, messages: filtered, isPrepend: false, isUpdate: true });
212
212
  }
213
213
 
214
214
  /**
@@ -1,6 +1,8 @@
1
1
  import { Api, errors } from "teleproto";
2
2
  import { idToString, toMarkedId, getEntityDisplayName, entityCache, resolveEntity } from "./entities.js";
3
3
  import { describeMedia } from "./formatter.js";
4
+ import { config } from "../config.js";
5
+ import { renderStrippedThumbnail, renderImageBuffer } from "../utils/image.js";
4
6
 
5
7
  const { FloodWaitError } = errors;
6
8
 
@@ -38,6 +40,27 @@ export function normalizeMessage(message) {
38
40
  }
39
41
  }
40
42
 
43
+ // Извлечение и рендеринг PhotoStrippedSize в псевдографику
44
+ let imagePreview = null;
45
+ if (config.showImages && message.media) {
46
+ const media = message.media;
47
+ const photo = media.photo;
48
+ const doc = media.document;
49
+ const sizes = photo?.sizes || doc?.thumbs || [];
50
+ const stripped = sizes.find((s) => s?.className === "PhotoStrippedSize" || s?.type === "i" || (s?.bytes && s.bytes.length > 0));
51
+
52
+ if (stripped?.bytes) {
53
+ const cacheKey = photo?.id
54
+ ? `photo_${photo.id}`
55
+ : (doc?.id ? `doc_${doc.id}` : `msg_${message.id}`);
56
+ imagePreview = renderStrippedThumbnail(stripped.bytes, {
57
+ maxWidth: config.imageMaxWidth,
58
+ maxHeight: config.imageMaxHeight,
59
+ cacheKey,
60
+ }) || null;
61
+ }
62
+ }
63
+
41
64
  return {
42
65
  id: message.id,
43
66
  date: message.date ? message.date * 1000 : Date.now(),
@@ -53,6 +76,7 @@ export function normalizeMessage(message) {
53
76
  forwards: message.forwards || null,
54
77
  media: message.media || null,
55
78
  mediaDescription: describeMedia(message.media),
79
+ imagePreview,
56
80
  entities: message.entities || [],
57
81
  reactions,
58
82
  rawMessage: message,
@@ -263,3 +287,37 @@ export async function markAsRead(client, rawPeer, maxId = 0) {
263
287
  // Игнорируем незначительные сетевые ошибки прочтения
264
288
  }
265
289
  }
290
+
291
+ /**
292
+ * Асинхронно загружает и декодирует превью изображения сообщения через MTProto.
293
+ * @param {import("teleproto").TelegramClient} client
294
+ * @param {object} rawMessage
295
+ * @param {object} [options]
296
+ * @param {number} [options.maxWidth]
297
+ * @param {number} [options.maxHeight]
298
+ * @returns {Promise<string>}
299
+ */
300
+ export async function loadMessageImagePreview(client, rawMessage, { maxWidth = config.imageMaxWidth, maxHeight = config.imageMaxHeight } = {}) {
301
+ if (!rawMessage?.media) return "";
302
+ const media = rawMessage.media;
303
+ const isPhoto = media.className === "MessageMediaPhoto";
304
+ const isDoc = media.className === "MessageMediaDocument";
305
+ if (!isPhoto && !isDoc) return "";
306
+
307
+ const cacheKey = isPhoto
308
+ ? `photo_full_${media.photo?.id || rawMessage.id}`
309
+ : `doc_full_${media.document?.id || rawMessage.id}`;
310
+
311
+ try {
312
+ const thumbBuf = await client.downloadMedia(media, { thumb: 1 });
313
+ if (!thumbBuf || thumbBuf.length === 0) return "";
314
+ return renderImageBuffer(thumbBuf, {
315
+ maxWidth,
316
+ maxHeight,
317
+ cacheKey,
318
+ });
319
+ } catch {
320
+ return "";
321
+ }
322
+ }
323
+
package/src/ui/app.js CHANGED
@@ -16,7 +16,7 @@ import { state } from "../state.js";
16
16
  import { config } from "../config.js";
17
17
  import { fetchDialogs } from "../telegram/dialogs.js";
18
18
  import { setMessagePalette } from "../telegram/formatter.js";
19
- import { fetchHistory, sendMessage, editMessage, deleteMessages, sendFiles, downloadMedia, sendReaction, markAsRead } from "../telegram/messages.js";
19
+ import { fetchHistory, sendMessage, editMessage, deleteMessages, sendFiles, downloadMedia, sendReaction, markAsRead, loadMessageImagePreview } from "../telegram/messages.js";
20
20
  import { startTelegramListener } from "../telegram/listener.js";
21
21
  import { logout } from "../telegram/auth.js";
22
22
  import { ensureDir, inspectLocalFile } from "../utils/storage.js";
@@ -128,6 +128,7 @@ export async function startTui(client, me) {
128
128
  state.setMessages(dialog.id, history.messages);
129
129
  markAsRead(client, dialog.peerId).catch(() => {});
130
130
  statusBar.showMessage(`Чат: ${dialog.title}`, "info");
131
+ fetchMissingImagePreviews(history.messages, dialog.id);
131
132
  } catch (err) {
132
133
  statusBar.showMessage(`Ошибка загрузки: ${err.message}`, "error");
133
134
  }
@@ -157,6 +158,7 @@ export async function startTui(client, me) {
157
158
  if (older.messages.length > 0) {
158
159
  state.setMessages(state.activeChat.id, older.messages, true);
159
160
  statusBar.showMessage(`Загружено ${older.messages.length} предыдущих сообщений`, "info");
161
+ fetchMissingImagePreviews(older.messages, state.activeChat.id);
160
162
  }
161
163
  } catch (err) {
162
164
  statusBar.showMessage(`Ошибка пагинации: ${err.message}`, "error");
@@ -291,9 +293,13 @@ export async function startTui(client, me) {
291
293
  chatView.setMessages(msgs);
292
294
  });
293
295
 
294
- state.on("messages_updated", ({ chatId, messages, isPrepend }) => {
296
+ state.on("messages_updated", ({ chatId, messages, isPrepend, isUpdate, isNewMessage }) => {
295
297
  if (state.activeChat?.id === chatId) {
296
- chatView.setMessages(messages, !isPrepend);
298
+ // Скроллим в самый низ только если это новое сообщение или первая загрузка чата.
299
+ // При подгрузке старых сообщений (isPrepend) или фоновых обновлениях (isUpdate)
300
+ // позиция скролла сохраняется!
301
+ const shouldScrollToBottom = isNewMessage ? config.autoScroll : (!isPrepend && !isUpdate);
302
+ chatView.setMessages(messages, shouldScrollToBottom);
297
303
  }
298
304
  });
299
305
 
@@ -316,11 +322,41 @@ export async function startTui(client, me) {
316
322
  }
317
323
  });
318
324
 
325
+ /**
326
+ * Фоново догружает превью изображений для сообщений, не имевших встроенного PhotoStrippedSize.
327
+ * @param {Array<object>} messages
328
+ * @param {string} chatId
329
+ */
330
+ async function fetchMissingImagePreviews(messages, chatId) {
331
+ if (!config.showImages || !messages || messages.length === 0) return;
332
+ for (const msg of messages) {
333
+ if (state.activeChat?.id !== chatId) break;
334
+ if (msg.media && !msg.imagePreview && msg.rawMessage) {
335
+ const isPhoto = msg.media.className === "MessageMediaPhoto";
336
+ const isDoc = msg.media.className === "MessageMediaDocument";
337
+ if (isPhoto || isDoc) {
338
+ try {
339
+ const preview = await loadMessageImagePreview(client, msg.rawMessage);
340
+ if (preview && state.activeChat?.id === chatId) {
341
+ msg.imagePreview = preview;
342
+ state.updateMessage(chatId, msg);
343
+ }
344
+ } catch {
345
+ // Игнорируем сетевые ошибки фоновой загрузки превью
346
+ }
347
+ }
348
+ }
349
+ }
350
+ }
351
+
319
352
  // 6. Подключение фонового слушателя MTProto событий
320
353
  listener = startTelegramListener(client);
321
354
 
322
355
  listener.on("new_message", ({ peerId, message }) => {
323
356
  state.addMessage(peerId, message);
357
+ if (!message.imagePreview && (message.media?.className === "MessageMediaPhoto" || message.media?.className === "MessageMediaDocument")) {
358
+ fetchMissingImagePreviews([message], peerId);
359
+ }
324
360
 
325
361
  if (state.activeChat?.id === peerId) {
326
362
  markAsRead(client, state.activeChat.peerId).catch(() => {});
@@ -465,6 +501,21 @@ export async function startTui(client, me) {
465
501
  screen.key(["tab"], () => moveFocus(1));
466
502
  screen.key(["S-tab"], () => moveFocus(-1));
467
503
 
504
+ screen.key(["pageup", "C-u"], () => {
505
+ if (!state.activeChat) return;
506
+ chatView.scrollBox.scroll(-10);
507
+ if (chatView.scrollBox.getScroll() <= 0) {
508
+ chatView.loadMore?.();
509
+ }
510
+ screen.render();
511
+ });
512
+
513
+ screen.key(["pagedown", "C-d"], () => {
514
+ if (!state.activeChat) return;
515
+ chatView.scrollBox.scroll(10);
516
+ screen.render();
517
+ });
518
+
468
519
  screen.key(["f1", "?"], () => {
469
520
  releaseInputs();
470
521
  helpModal.show();
@@ -38,7 +38,6 @@ export function createChatView(screen, theme, { onLoadMoreHistory, onActionMenu
38
38
  bottom: 0,
39
39
  tags: true,
40
40
  scrollable: true,
41
- alwaysScroll: true,
42
41
  mouse: true,
43
42
  keys: true,
44
43
  vi: true,
@@ -99,8 +98,19 @@ export function createChatView(screen, theme, { onLoadMoreHistory, onActionMenu
99
98
 
100
99
  // Текст сообщения и entities
101
100
  let bodyText = formatMessageText(msg.text, msg.entities);
102
- if (msg.mediaDescription) {
103
- bodyText = bodyText ? `${msg.mediaDescription}\n ${bodyText}` : msg.mediaDescription;
101
+
102
+ // Блок медиа-вложения и превью изображения
103
+ let mediaBlock = "";
104
+ if (msg.imagePreview) {
105
+ mediaBlock = msg.mediaDescription
106
+ ? `${msg.mediaDescription}\n${msg.imagePreview}`
107
+ : msg.imagePreview;
108
+ } else if (msg.mediaDescription) {
109
+ mediaBlock = msg.mediaDescription;
110
+ }
111
+
112
+ if (mediaBlock) {
113
+ bodyText = bodyText ? `${mediaBlock}\n${bodyText}` : mediaBlock;
104
114
  }
105
115
 
106
116
  // Отступ строк текста сообщения
@@ -135,26 +145,60 @@ export function createChatView(screen, theme, { onLoadMoreHistory, onActionMenu
135
145
  */
136
146
  function setMessages(messages, autoScrollToBottom = true) {
137
147
  currentMessages = messages;
148
+ const prevScroll = scrollBox.getScroll();
149
+ const prevHeight = scrollBox.getScrollHeight();
150
+
138
151
  scrollBox.setContent(renderMessages(messages));
152
+
139
153
  if (autoScrollToBottom) {
140
154
  scrollBox.setScrollPerc(100);
155
+ } else {
156
+ // Сохраняем относительную позицию скролла: если добавились старые сообщения сверху,
157
+ // компенсируем сдвиг высоты ленты
158
+ const newHeight = scrollBox.getScrollHeight();
159
+ const addedLines = newHeight - prevHeight;
160
+ if (addedLines > 0 && prevScroll > 0) {
161
+ scrollBox.scrollTo(prevScroll + addedLines);
162
+ } else if (prevScroll > 0) {
163
+ scrollBox.scrollTo(prevScroll);
164
+ }
141
165
  }
142
166
  screen.render();
143
167
  }
144
168
 
145
169
  // Обработка прокрутки вверх для подгрузки истории
146
- scrollBox.key(["pageup", "C-u"], () => {
147
- scrollBox.scroll(-10);
170
+ function handleScrollUp(step = 10) {
171
+ scrollBox.scroll(-step);
148
172
  if (scrollBox.getScroll() <= 0) {
149
173
  onLoadMoreHistory?.();
150
174
  }
151
175
  screen.render();
152
- });
176
+ }
177
+
178
+ function handleScrollDown(step = 10) {
179
+ scrollBox.scroll(step);
180
+ screen.render();
181
+ }
153
182
 
154
- scrollBox.key(["pagedown", "C-d"], () => {
155
- scrollBox.scroll(10);
183
+ scrollBox.key(["pageup", "C-u"], () => handleScrollUp(10));
184
+ scrollBox.key(["pagedown", "C-d"], () => handleScrollDown(10));
185
+ scrollBox.key(["up", "k"], () => handleScrollUp(2));
186
+ scrollBox.key(["down", "j"], () => handleScrollDown(2));
187
+ scrollBox.key(["home"], () => {
188
+ scrollBox.scrollTo(0);
189
+ onLoadMoreHistory?.();
156
190
  screen.render();
157
191
  });
192
+ scrollBox.key(["end"], () => {
193
+ scrollBox.setScrollPerc(100);
194
+ screen.render();
195
+ });
196
+
197
+ scrollBox.on("wheelup", () => {
198
+ if (scrollBox.getScroll() <= 0) {
199
+ onLoadMoreHistory?.();
200
+ }
201
+ });
158
202
 
159
203
  // Ctrl+M терминал шлёт как "\r" (имя клавиши "return"), поэтому меню действий
160
204
  // висит на Ctrl+A — иначе оно недостижимо.
@@ -169,6 +213,7 @@ export function createChatView(screen, theme, { onLoadMoreHistory, onActionMenu
169
213
  container,
170
214
  scrollBox,
171
215
  setMessages,
216
+ loadMore: () => onLoadMoreHistory?.(),
172
217
  scrollToBottom: () => {
173
218
  scrollBox.setScrollPerc(100);
174
219
  screen.render();
@@ -0,0 +1,343 @@
1
+ /**
2
+ * Модуль обработки и рендеринга изображений в терминале (Unicode Half-Block).
3
+ * Преобразует растровые изображения (JPEG, PNG, PhotoStrippedSize) в псевдографику
4
+ * высокого разрешения с разметкой blessed и 24-битными hex-цветами.
5
+ */
6
+
7
+ import jpegJs from "jpeg-js";
8
+ import { PNG } from "pngjs";
9
+
10
+ /** Заголовок стандартного JPEG для распаковки Telegram PhotoStrippedSize. */
11
+ const JPEG_HEADER = Buffer.from([
12
+ 0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01,
13
+ 0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0xff, 0xdb, 0x00, 0x43,
14
+ 0x00, 0x28, 0x1c, 0x1e, 0x23, 0x1e, 0x19, 0x28, 0x23, 0x21, 0x23, 0x2d,
15
+ 0x2b, 0x28, 0x30, 0x3c, 0x64, 0x41, 0x3c, 0x37, 0x37, 0x3c, 0x7b, 0x58,
16
+ 0x5d, 0x49, 0x64, 0x91, 0x80, 0x99, 0x96, 0x8f, 0x80, 0x8c, 0x8a, 0xa0,
17
+ 0xb4, 0xe6, 0xc3, 0xa0, 0xaa, 0xda, 0xad, 0x8a, 0x8c, 0xc8, 0xff, 0xcb,
18
+ 0xda, 0xee, 0xf5, 0xff, 0xff, 0xff, 0x9b, 0xc1, 0xff, 0xff, 0xf7, 0xfa,
19
+ 0xff, 0xe6, 0xfd, 0xff, 0xf8, 0xff, 0xdb, 0x00, 0x43, 0x01, 0x2b, 0x2d,
20
+ 0x2d, 0x3c, 0x35, 0x3c, 0x75, 0x41, 0x41, 0x75, 0xf8, 0xa5, 0x8c, 0xa5,
21
+ 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8,
22
+ 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8,
23
+ 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8,
24
+ 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xff, 0xc0, 0x00,
25
+ 0x11, 0x08, 0x00, 0x00, 0x00, 0x00, 0x03, 0x01, 0x22, 0x00, 0x02, 0x11,
26
+ 0x01, 0x03, 0x11, 0x01, 0xff, 0xc4, 0x00, 0x1f, 0x00, 0x00, 0x01, 0x05,
27
+ 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
28
+ 0x00, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a,
29
+ 0x0b, 0xff, 0xc4, 0x00, 0xb5, 0x10, 0x00, 0x02, 0x01, 0x03, 0x03, 0x02,
30
+ 0x04, 0x03, 0x05, 0x05, 0x04, 0x04, 0x00, 0x00, 0x01, 0x7d, 0x01, 0x02,
31
+ 0x03, 0x00, 0x04, 0x11, 0x05, 0x12, 0x21, 0x31, 0x41, 0x06, 0x13, 0x51,
32
+ 0x61, 0x07, 0x22, 0x71, 0x14, 0x32, 0x81, 0x91, 0xa1, 0x08, 0x23, 0x42,
33
+ 0xb1, 0xc1, 0x15, 0x52, 0xd1, 0xf0, 0x24, 0x33, 0x62, 0x72, 0x82, 0x09,
34
+ 0x0a, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2a,
35
+ 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3a, 0x43, 0x44, 0x45, 0x46, 0x47,
36
+ 0x48, 0x49, 0x4a, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5a, 0x63,
37
+ 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6a, 0x73, 0x74, 0x75, 0x76, 0x77,
38
+ 0x78, 0x79, 0x7a, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, 0x8a, 0x92,
39
+ 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9a, 0xa2, 0xa3, 0xa4, 0xa5,
40
+ 0xa6, 0xa7, 0xa8, 0xa9, 0xaa, 0xb2, 0xb3, 0xb4, 0xb5, 0xb6, 0xb7, 0xb8,
41
+ 0xb9, 0xba, 0xc2, 0xc3, 0xc4, 0xc5, 0xc6, 0xc7, 0xc8, 0xc9, 0xca, 0xd2,
42
+ 0xd3, 0xd4, 0xd5, 0xd6, 0xd7, 0xd8, 0xd9, 0xda, 0xe1, 0xe2, 0xe3, 0xe4,
43
+ 0xe5, 0xe6, 0xe7, 0xe8, 0xe9, 0xea, 0xf1, 0xf2, 0xf3, 0xf4, 0xf5, 0xf6,
44
+ 0xf7, 0xf8, 0xf9, 0xfa, 0xff, 0xc4, 0x00, 0x1f, 0x01, 0x00, 0x03, 0x01,
45
+ 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00,
46
+ 0x00, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a,
47
+ 0x0b, 0xff, 0xc4, 0x00, 0xb5, 0x11, 0x00, 0x02, 0x01, 0x02, 0x04, 0x04,
48
+ 0x03, 0x04, 0x07, 0x05, 0x04, 0x04, 0x00, 0x01, 0x02, 0x77, 0x00, 0x01,
49
+ 0x02, 0x03, 0x11, 0x04, 0x05, 0x21, 0x31, 0x06, 0x12, 0x41, 0x51, 0x07,
50
+ 0x61, 0x71, 0x13, 0x22, 0x32, 0x81, 0x08, 0x14, 0x42, 0x91, 0xa1, 0xb1,
51
+ 0xc1, 0x09, 0x23, 0x33, 0x52, 0xf0, 0x15, 0x62, 0x72, 0xd1, 0x0a, 0x16,
52
+ 0x24, 0x34, 0xe1, 0x25, 0xf1, 0x17, 0x18, 0x19, 0x1a, 0x26, 0x27, 0x28,
53
+ 0x29, 0x2a, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3a, 0x43, 0x44, 0x45, 0x46,
54
+ 0x47, 0x48, 0x49, 0x4a, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5a,
55
+ 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6a, 0x73, 0x74, 0x75, 0x76,
56
+ 0x77, 0x78, 0x79, 0x7a, 0x82, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89,
57
+ 0x8a, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9a, 0xa2, 0xa3,
58
+ 0xa4, 0xa5, 0xa6, 0xa7, 0xa8, 0xa9, 0xaa, 0xb2, 0xb3, 0xb4, 0xb5, 0xb6,
59
+ 0xb7, 0xb8, 0xb9, 0xba, 0xc2, 0xc3, 0xc4, 0xc5, 0xc6, 0xc7, 0xc8, 0xc9,
60
+ 0xca, 0xd2, 0xd3, 0xd4, 0xd5, 0xd6, 0xd7, 0xd8, 0xd9, 0xda, 0xe2, 0xe3,
61
+ 0xe4, 0xe5, 0xe6, 0xe7, 0xe8, 0xe9, 0xea, 0xf2, 0xf3, 0xf4, 0xf5, 0xf6,
62
+ 0xf7, 0xf8, 0xf9, 0xfa, 0xff, 0xda, 0x00, 0x0c, 0x03, 0x01, 0x00, 0x02,
63
+ 0x11, 0x03, 0x11, 0x00, 0x3f, 0x00
64
+ ]);
65
+
66
+ /** Завершающий маркер JPEG (End Of Image). */
67
+ const JPEG_FOOTER = Buffer.from([0xff, 0xd9]);
68
+
69
+ /**
70
+ * Память кэша для готовых строк псевдографики.
71
+ * @type {Map<string, string>}
72
+ */
73
+ export const imagePreviewCache = new Map();
74
+ const MAX_CACHE_SIZE = 500;
75
+
76
+ /**
77
+ * Распаковывает PhotoStrippedSize Telegram в валидный JPEG буфер.
78
+ * @param {Buffer|Uint8Array} stripped
79
+ * @returns {Buffer}
80
+ */
81
+ export function strippedPhotoToJpg(stripped) {
82
+ if (!stripped || stripped.length < 3) {
83
+ return Buffer.isBuffer(stripped) ? stripped : Buffer.from(stripped || []);
84
+ }
85
+ const buf = Buffer.isBuffer(stripped) ? stripped : Buffer.from(stripped);
86
+ if (buf[0] !== 1) {
87
+ return buf;
88
+ }
89
+ const header = Buffer.from(JPEG_HEADER);
90
+ header[164] = buf[1];
91
+ header[166] = buf[2];
92
+ return Buffer.concat([header, buf.subarray(3), JPEG_FOOTER]);
93
+ }
94
+
95
+ /**
96
+ * Декодирует буфер изображения (JPEG или PNG) в сырой RGBA буфер.
97
+ * @param {Buffer|Uint8Array} buffer
98
+ * @param {string} [mimeType]
99
+ * @returns {{ width: number, height: number, data: Uint8Array }}
100
+ */
101
+ export function decodeImageBuffer(buffer, mimeType = "") {
102
+ if (!buffer || buffer.length === 0) {
103
+ throw new Error("Пустой буфер изображения");
104
+ }
105
+
106
+ const buf = Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer);
107
+
108
+ // Определение формата по magic bytes или mimeType
109
+ const isPng = (buf.length >= 8 && buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47)
110
+ || mimeType.includes("png");
111
+
112
+ if (isPng) {
113
+ const png = PNG.sync.read(buf);
114
+ return {
115
+ width: png.width,
116
+ height: png.height,
117
+ data: png.data,
118
+ };
119
+ }
120
+
121
+ // По умолчанию считаем JPEG
122
+ const decoded = jpegJs.decode(buf, { useTArray: true, formatAsRGBA: true });
123
+ return {
124
+ width: decoded.width,
125
+ height: decoded.height,
126
+ data: decoded.data,
127
+ };
128
+ }
129
+
130
+ /**
131
+ * Рассчитывает целевые размеры изображения в терминале с сохранением пропорций.
132
+ * Каждый терминальный ряд Half-Block вмещает 2 вертикальных субпикселя.
133
+ * @param {number} srcW Исходная ширина в пикселях
134
+ * @param {number} srcH Исходная высота в пикселях
135
+ * @param {number} [maxWidth=36] Максимальная ширина в колонках символов
136
+ * @param {number} [maxHeight=14] Максимальная высота в строках терминала
137
+ * @returns {{ dstW: number, dstH: number, rows: number }}
138
+ */
139
+ export function calculateTargetDimensions(srcW, srcH, maxWidth = 36, maxHeight = 14) {
140
+ const safeW = Math.max(1, srcW || 1);
141
+ const safeH = Math.max(1, srcH || 1);
142
+ const maxPixelH = Math.max(2, maxHeight * 2);
143
+
144
+ const aspect = safeW / safeH;
145
+ const maxAspect = maxWidth / maxPixelH;
146
+
147
+ let dstW = maxWidth;
148
+ let dstPixelH = maxPixelH;
149
+
150
+ if (aspect >= maxAspect) {
151
+ dstW = Math.min(maxWidth, safeW);
152
+ dstPixelH = Math.max(2, Math.round(dstW / aspect));
153
+ } else {
154
+ dstPixelH = Math.min(maxPixelH, safeH * 2);
155
+ dstW = Math.max(1, Math.round(dstPixelH * aspect));
156
+ }
157
+
158
+ // Округляем пиксельную высоту до четного числа (для парных субпикселей ▀)
159
+ const rows = Math.max(1, Math.ceil(dstPixelH / 2));
160
+ dstPixelH = rows * 2;
161
+
162
+ return { dstW, dstH: dstPixelH, rows };
163
+ }
164
+
165
+ /**
166
+ * Масштабирует RGBA буфер методом билинейной интерполяции.
167
+ * @param {Uint8Array} src RGBA буфер исходного изображения
168
+ * @param {number} srcW Исходная ширина
169
+ * @param {number} srcH Исходная высота
170
+ * @param {number} dstW Целевая ширина
171
+ * @param {number} dstH Целевая высота
172
+ * @returns {Uint8Array} Результирующий RGBA буфер длины dstW * dstH * 4
173
+ */
174
+ export function resizeRgba(src, srcW, srcH, dstW, dstH) {
175
+ const dst = new Uint8Array(dstW * dstH * 4);
176
+ if (srcW === dstW && srcH === dstH) {
177
+ dst.set(src);
178
+ return dst;
179
+ }
180
+
181
+ const xRatio = srcW / dstW;
182
+ const yRatio = srcH / dstH;
183
+
184
+ for (let dy = 0; dy < dstH; dy++) {
185
+ const srcY = (dy + 0.5) * yRatio - 0.5;
186
+ const y0 = Math.max(0, Math.min(srcH - 1, Math.floor(srcY)));
187
+ const y1 = Math.max(0, Math.min(srcH - 1, y0 + 1));
188
+ const yWeight = Math.max(0, Math.min(1, srcY - y0));
189
+
190
+ const y0Offset = y0 * srcW * 4;
191
+ const y1Offset = y1 * srcW * 4;
192
+ const dstRowOffset = dy * dstW * 4;
193
+
194
+ for (let dx = 0; dx < dstW; dx++) {
195
+ const srcX = (dx + 0.5) * xRatio - 0.5;
196
+ const x0 = Math.max(0, Math.min(srcW - 1, Math.floor(srcX)));
197
+ const x1 = Math.max(0, Math.min(srcW - 1, x0 + 1));
198
+ const xWeight = Math.max(0, Math.min(1, srcX - x0));
199
+
200
+ const p00 = y0Offset + x0 * 4;
201
+ const p10 = y0Offset + x1 * 4;
202
+ const p01 = y1Offset + x0 * 4;
203
+ const p11 = y1Offset + x1 * 4;
204
+
205
+ const dstOffset = dstRowOffset + dx * 4;
206
+
207
+ // Интерполяция 4 каналов: R, G, B, A
208
+ for (let c = 0; c < 4; c++) {
209
+ const top = (1 - xWeight) * src[p00 + c] + xWeight * src[p10 + c];
210
+ const bot = (1 - xWeight) * src[p01 + c] + xWeight * src[p11 + c];
211
+ dst[dstOffset + c] = Math.round((1 - yWeight) * top + yWeight * bot);
212
+ }
213
+ }
214
+ }
215
+
216
+ return dst;
217
+ }
218
+
219
+ /**
220
+ * Преобразует компоненты цвета R, G, B в hex-строку вида "#rrggbb".
221
+ * @param {number} r
222
+ * @param {number} g
223
+ * @param {number} b
224
+ * @returns {string}
225
+ */
226
+ export function rgbaToHex(r, g, b) {
227
+ const hexR = (r < 16 ? "0" : "") + r.toString(16);
228
+ const hexG = (g < 16 ? "0" : "") + g.toString(16);
229
+ const hexB = (b < 16 ? "0" : "") + b.toString(16);
230
+ return `#${hexR}${hexG}${hexB}`;
231
+ }
232
+
233
+ /**
234
+ * Преобразует масштабированный RGBA буфер в многострочный текст Blessed
235
+ * с использованием символов верхнего полублока (▀ U+2580).
236
+ *
237
+ * Верхний пиксель ячейки задаётся через тег цвета текста (`{ #rrggbb-fg }`),
238
+ * нижний пиксель — через тег цвета фона (`{ #rrggbb-bg }`).
239
+ *
240
+ * @param {Uint8Array} data RGBA буфер
241
+ * @param {number} width Ширина в символах/пикселях
242
+ * @param {number} height Высота в пикселях (должна быть четной, = rows * 2)
243
+ * @returns {string}
244
+ */
245
+ export function rgbaToHalfBlockBlessed(data, width, height) {
246
+ const rows = Math.floor(height / 2);
247
+ const lines = [];
248
+
249
+ for (let r = 0; r < rows; r++) {
250
+ let line = "";
251
+ let currentFg = null;
252
+ let currentBg = null;
253
+
254
+ const topRowOffset = (r * 2) * width * 4;
255
+ const botRowOffset = (r * 2 + 1) * width * 4;
256
+
257
+ for (let x = 0; x < width; x++) {
258
+ const topOffset = topRowOffset + x * 4;
259
+ const botOffset = botRowOffset + x * 4;
260
+
261
+ const topHex = rgbaToHex(data[topOffset], data[topOffset + 1], data[topOffset + 2]);
262
+ const botHex = rgbaToHex(data[botOffset], data[botOffset + 1], data[botOffset + 2]);
263
+
264
+ if (topHex !== currentFg) {
265
+ line += `{${topHex}-fg}`;
266
+ currentFg = topHex;
267
+ }
268
+ if (botHex !== currentBg) {
269
+ line += `{${botHex}-bg}`;
270
+ currentBg = botHex;
271
+ }
272
+
273
+ line += "▀";
274
+ }
275
+
276
+ if (currentFg || currentBg) {
277
+ line += "{/}";
278
+ }
279
+
280
+ lines.push(line);
281
+ }
282
+
283
+ return lines.join("\n");
284
+ }
285
+
286
+ /**
287
+ * Выполняет полный цикл декодирования, масштабирования и рендеринга изображения в Blessed-строку.
288
+ * @param {Buffer|Uint8Array} buffer Исходный буфер изображения (JPEG или PNG)
289
+ * @param {object} [options]
290
+ * @param {string} [options.mimeType=""] MIME-тип изображения
291
+ * @param {number} [options.maxWidth=36] Максимальная ширина
292
+ * @param {number} [options.maxHeight=14] Максимальная высота
293
+ * @param {string} [options.cacheKey] Ключ для сохранения в кэш
294
+ * @returns {string}
295
+ */
296
+ export function renderImageBuffer(buffer, { mimeType = "", maxWidth = 36, maxHeight = 14, cacheKey } = {}) {
297
+ if (cacheKey && imagePreviewCache.has(cacheKey)) {
298
+ return imagePreviewCache.get(cacheKey);
299
+ }
300
+
301
+ try {
302
+ const decoded = decodeImageBuffer(buffer, mimeType);
303
+ const { dstW, dstH } = calculateTargetDimensions(decoded.width, decoded.height, maxWidth, maxHeight);
304
+ const resized = resizeRgba(decoded.data, decoded.width, decoded.height, dstW, dstH);
305
+ const blessedText = rgbaToHalfBlockBlessed(resized, dstW, dstH);
306
+
307
+ if (cacheKey) {
308
+ if (imagePreviewCache.size >= MAX_CACHE_SIZE) {
309
+ const firstKey = imagePreviewCache.keys().next().value;
310
+ imagePreviewCache.delete(firstKey);
311
+ }
312
+ imagePreviewCache.set(cacheKey, blessedText);
313
+ }
314
+
315
+ return blessedText;
316
+ } catch {
317
+ return "";
318
+ }
319
+ }
320
+
321
+ /**
322
+ * Синхронно распаковывает и рендерит PhotoStrippedSize Telegram в Blessed Half-Block строку.
323
+ * @param {Buffer|Uint8Array} strippedBytes Байты PhotoStrippedSize
324
+ * @param {object} [options]
325
+ * @param {number} [options.maxWidth=36]
326
+ * @param {number} [options.maxHeight=14]
327
+ * @param {string} [options.cacheKey]
328
+ * @returns {string}
329
+ */
330
+ export function renderStrippedThumbnail(strippedBytes, { maxWidth = 36, maxHeight = 14, cacheKey } = {}) {
331
+ if (!strippedBytes || strippedBytes.length < 3) return "";
332
+
333
+ if (cacheKey && imagePreviewCache.has(cacheKey)) {
334
+ return imagePreviewCache.get(cacheKey);
335
+ }
336
+
337
+ try {
338
+ const jpgBuf = strippedPhotoToJpg(strippedBytes);
339
+ return renderImageBuffer(jpgBuf, { mimeType: "image/jpeg", maxWidth, maxHeight, cacheKey });
340
+ } catch {
341
+ return "";
342
+ }
343
+ }