Merge pull request 'fixes' (#5) from yet-another-changes into main
Reviewed-on: #5
This commit is contained in:
46
CONTEXT.md
46
CONTEXT.md
@@ -1,6 +1,6 @@
|
|||||||
# Текущий контекст проекта
|
# Текущий контекст проекта
|
||||||
|
|
||||||
## Статус: Фаза 6 завершена — Полировка
|
## Статус: Фаза 8 в процессе — Дополнительные фичи
|
||||||
|
|
||||||
### Что сделано
|
### Что сделано
|
||||||
|
|
||||||
@@ -30,11 +30,23 @@
|
|||||||
- **Галочки прочтения** (✓ отправлено, ✓✓ прочитано) — обновляются в реальном времени
|
- **Галочки прочтения** (✓ отправлено, ✓✓ прочитано) — обновляются в реальном времени
|
||||||
- **Отметка сообщений как прочитанных**: при открытии чата счётчик непрочитанных сбрасывается
|
- **Отметка сообщений как прочитанных**: при открытии чата счётчик непрочитанных сбрасывается
|
||||||
- **Отправка текстовых сообщений**
|
- **Отправка текстовых сообщений**
|
||||||
|
- **Редактирование сообщений**: ↑ при пустом инпуте → выбор → Enter → редактирование
|
||||||
|
- **Удаление сообщений**: в режиме выбора нажать `d` / `в` / `Delete` → модалка подтверждения
|
||||||
|
- **Индикатор редактирования**: ✎ рядом с временем для отредактированных сообщений
|
||||||
- **Новые сообщения в реальном времени** при открытом чате
|
- **Новые сообщения в реальном времени** при открытом чате
|
||||||
- **Поиск по чатам** (Ctrl+S): фильтрация по названию и @username
|
- **Поиск по чатам** (Ctrl+S): фильтрация по названию и @username
|
||||||
- **Кеширование имён пользователей**: имена загружаются асинхронно и обновляются в UI
|
- **Кеширование имён пользователей**: имена загружаются асинхронно и обновляются в UI
|
||||||
- **Папки Telegram**: загрузка и переключение между папками (1-9)
|
- **Папки Telegram**: загрузка и переключение между папками (1-9)
|
||||||
- **Медиа-заглушки**: [Фото], [Видео], [Голосовое], [Стикер], [GIF] и др.
|
- **Медиа-заглушки**: [Фото], [Видео], [Голосовое], [Стикер], [GIF] и др.
|
||||||
|
- **Markdown форматирование в сообщениях**:
|
||||||
|
- **Жирный** (bold)
|
||||||
|
- *Курсив* (italic)
|
||||||
|
- __Подчёркнутый__ (underline)
|
||||||
|
- ~~Зачёркнутый~~ (strikethrough)
|
||||||
|
- `Код` (inline code, Pre, PreCode) — cyan на тёмном фоне
|
||||||
|
- Спойлеры — скрытый текст (серый на сером)
|
||||||
|
- Ссылки (URL, TextUrl, Email, Phone) — синий с подчёркиванием
|
||||||
|
- @Упоминания — синий с подчёркиванием
|
||||||
|
|
||||||
#### Состояние сети
|
#### Состояние сети
|
||||||
- **Индикатор в футере**: показывает текущее состояние подключения
|
- **Индикатор в футере**: показывает текущее состояние подключения
|
||||||
@@ -54,6 +66,7 @@
|
|||||||
#### Динамический инпут
|
#### Динамический инпут
|
||||||
- **Автоматическое расширение**: поле ввода увеличивается при длинном тексте (до 10 строк)
|
- **Автоматическое расширение**: поле ввода увеличивается при длинном тексте (до 10 строк)
|
||||||
- **Перенос текста**: длинные сообщения переносятся на новые строки
|
- **Перенос текста**: длинные сообщения переносятся на новые строки
|
||||||
|
- **Блочный курсор**: vim-style курсор █ с возможностью перемещения по тексту
|
||||||
|
|
||||||
#### Управление
|
#### Управление
|
||||||
- `↑/↓` стрелки — навигация по списку чатов
|
- `↑/↓` стрелки — навигация по списку чатов
|
||||||
@@ -63,8 +76,19 @@
|
|||||||
- `Ctrl+R` — обновить список чатов
|
- `Ctrl+R` — обновить список чатов
|
||||||
- `Ctrl+C` — выход (graceful shutdown)
|
- `Ctrl+C` — выход (graceful shutdown)
|
||||||
- `↑/↓` в открытом чате — скролл сообщений (с подгрузкой старых)
|
- `↑/↓` в открытом чате — скролл сообщений (с подгрузкой старых)
|
||||||
|
- `↑` при пустом инпуте — выбор сообщения для редактирования
|
||||||
|
- `Enter` в режиме выбора — начать редактирование
|
||||||
|
- `d` / `в` / `Delete` в режиме выбора — удалить сообщение (с подтверждением)
|
||||||
|
- `y` / `н` / `Enter` — подтвердить удаление в модалке
|
||||||
|
- `n` / `т` / `Esc` — отменить удаление в модалке
|
||||||
|
- `Esc` — отменить выбор/редактирование
|
||||||
- `1-9` — переключение папок (в списке чатов)
|
- `1-9` — переключение папок (в списке чатов)
|
||||||
- Ввод текста в поле сообщения
|
- **Редактирование текста в инпуте:**
|
||||||
|
- `←` / `→` — перемещение курсора
|
||||||
|
- `Home` — курсор в начало
|
||||||
|
- `End` — курсор в конец
|
||||||
|
- `Backspace` — удалить символ слева
|
||||||
|
- `Delete` — удалить символ справа
|
||||||
|
|
||||||
### Структура проекта
|
### Структура проекта
|
||||||
|
|
||||||
@@ -132,14 +156,18 @@ API_ID=your_api_id
|
|||||||
API_HASH=your_api_hash
|
API_HASH=your_api_hash
|
||||||
```
|
```
|
||||||
|
|
||||||
## Что НЕ сделано / TODO (Фаза 7)
|
## Что НЕ сделано / TODO (Фаза 8)
|
||||||
|
|
||||||
- [ ] Удалить дублирование current_messages между App и TdClient
|
- [x] Удалить дублирование current_messages между App и TdClient
|
||||||
- [ ] Использовать единый источник данных для сообщений
|
- [x] Использовать единый источник данных для сообщений (td_client.current_chat_messages)
|
||||||
- [ ] Реализовать LRU-кэш для user_names/user_statuses вместо простого лимита
|
- [x] Реализовать LRU-кэш для user_names/user_statuses/user_usernames
|
||||||
- [ ] Lazy loading для имён пользователей (загружать только видимых)
|
- [x] Lazy loading для имён пользователей (батчевая загрузка последних 5 за цикл, лимит очереди 50)
|
||||||
- [ ] Профилирование памяти и устранение утечек
|
- [x] Лимиты памяти: сообщения (500), чаты (200), chat_user_ids (500)
|
||||||
- [ ] Markdown форматирование в сообщениях
|
- [x] Markdown форматирование в сообщениях
|
||||||
|
- [x] Редактирование сообщений
|
||||||
|
- [x] Удаление сообщений
|
||||||
|
- [ ] Reply на сообщения
|
||||||
|
- [ ] Forward сообщений
|
||||||
|
|
||||||
## Известные проблемы
|
## Известные проблемы
|
||||||
|
|
||||||
|
|||||||
41
ROADMAP.md
41
ROADMAP.md
@@ -67,18 +67,39 @@
|
|||||||
- Автоматический wrap на несколько строк
|
- Автоматический wrap на несколько строк
|
||||||
- Правильное выравнивание для исходящих/входящих
|
- Правильное выравнивание для исходящих/входящих
|
||||||
|
|
||||||
## Фаза 7: Глубокий рефакторинг памяти [TODO]
|
## Фаза 7: Глубокий рефакторинг памяти [DONE]
|
||||||
|
|
||||||
- [ ] Удалить дублирование current_messages между App и TdClient
|
- [x] Удалить дублирование current_messages между App и TdClient
|
||||||
- [ ] Использовать единый источник данных для сообщений
|
- [x] Использовать единый источник данных для сообщений
|
||||||
- [ ] Реализовать LRU-кэш для user_names/user_statuses вместо простого лимита
|
- [x] Реализовать LRU-кэш для user_names/user_statuses вместо простого лимита
|
||||||
- [ ] Lazy loading для имён пользователей (загружать только видимых)
|
- [x] Lazy loading для имён пользователей (батчевая загрузка последних 5 за цикл)
|
||||||
- [ ] Профилирование памяти и устранение утечек
|
- [x] Лимиты памяти:
|
||||||
|
- MAX_MESSAGES_IN_CHAT = 500
|
||||||
|
- MAX_CHATS = 200
|
||||||
|
- MAX_CHAT_USER_IDS = 500
|
||||||
|
- MAX_USER_CACHE_SIZE = 500 (LRU)
|
||||||
|
|
||||||
## Фаза 8: Дополнительные фичи [TODO]
|
## Фаза 8: Дополнительные фичи [IN PROGRESS]
|
||||||
|
|
||||||
- [ ] Markdown форматирование в сообщениях
|
- [x] Markdown форматирование в сообщениях
|
||||||
- [ ] Редактирование сообщений
|
- Bold, Italic, Underline, Strikethrough
|
||||||
- [ ] Удаление сообщений
|
- Code (inline, Pre, PreCode)
|
||||||
|
- Spoiler (скрытый текст)
|
||||||
|
- URLs, упоминания (@)
|
||||||
|
- [x] Редактирование сообщений
|
||||||
|
- ↑ при пустом инпуте → выбор сообщения
|
||||||
|
- Enter для начала редактирования
|
||||||
|
- Подсветка выбранного сообщения (▶)
|
||||||
|
- Esc для отмены
|
||||||
|
- [x] Удаление сообщений
|
||||||
|
- d / в / Delete в режиме выбора
|
||||||
|
- Модалка подтверждения (y/n)
|
||||||
|
- Удаление для всех если возможно
|
||||||
|
- [x] Индикатор редактирования (✎)
|
||||||
|
- Отображается рядом с временем для отредактированных сообщений
|
||||||
|
- [x] Блочный курсор в поле ввода
|
||||||
|
- Vim-style курсор █
|
||||||
|
- Перемещение ←/→, Home/End
|
||||||
|
- Редактирование в любой позиции
|
||||||
- [ ] Reply на сообщения
|
- [ ] Reply на сообщения
|
||||||
- [ ] Forward сообщений
|
- [ ] Forward сообщений
|
||||||
|
|||||||
113
src/app/mod.rs
113
src/app/mod.rs
@@ -3,7 +3,7 @@ mod state;
|
|||||||
pub use state::AppScreen;
|
pub use state::AppScreen;
|
||||||
|
|
||||||
use ratatui::widgets::ListState;
|
use ratatui::widgets::ListState;
|
||||||
use crate::tdlib::client::{ChatInfo, MessageInfo};
|
use crate::tdlib::client::ChatInfo;
|
||||||
use crate::tdlib::TdClient;
|
use crate::tdlib::TdClient;
|
||||||
|
|
||||||
pub struct App {
|
pub struct App {
|
||||||
@@ -19,8 +19,9 @@ pub struct App {
|
|||||||
pub chats: Vec<ChatInfo>,
|
pub chats: Vec<ChatInfo>,
|
||||||
pub chat_list_state: ListState,
|
pub chat_list_state: ListState,
|
||||||
pub selected_chat_id: Option<i64>,
|
pub selected_chat_id: Option<i64>,
|
||||||
pub current_messages: Vec<MessageInfo>,
|
|
||||||
pub message_input: String,
|
pub message_input: String,
|
||||||
|
/// Позиция курсора в message_input (в символах)
|
||||||
|
pub cursor_position: usize,
|
||||||
pub message_scroll_offset: usize,
|
pub message_scroll_offset: usize,
|
||||||
/// None = All (основной список), Some(id) = папка с id
|
/// None = All (основной список), Some(id) = папка с id
|
||||||
pub selected_folder_id: Option<i32>,
|
pub selected_folder_id: Option<i32>,
|
||||||
@@ -30,6 +31,14 @@ pub struct App {
|
|||||||
pub search_query: String,
|
pub search_query: String,
|
||||||
/// Флаг для оптимизации рендеринга - перерисовывать только при изменениях
|
/// Флаг для оптимизации рендеринга - перерисовывать только при изменениях
|
||||||
pub needs_redraw: bool,
|
pub needs_redraw: bool,
|
||||||
|
// Edit message state
|
||||||
|
/// ID сообщения, которое редактируется (None = режим отправки нового)
|
||||||
|
pub editing_message_id: Option<i64>,
|
||||||
|
/// Индекс выбранного сообщения для навигации (снизу вверх, 0 = последнее)
|
||||||
|
pub selected_message_index: Option<usize>,
|
||||||
|
// Delete confirmation
|
||||||
|
/// ID сообщения для подтверждения удаления (показывает модалку)
|
||||||
|
pub confirm_delete_message_id: Option<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl App {
|
impl App {
|
||||||
@@ -48,22 +57,20 @@ impl App {
|
|||||||
chats: Vec::new(),
|
chats: Vec::new(),
|
||||||
chat_list_state: state,
|
chat_list_state: state,
|
||||||
selected_chat_id: None,
|
selected_chat_id: None,
|
||||||
current_messages: Vec::new(),
|
|
||||||
message_input: String::new(),
|
message_input: String::new(),
|
||||||
|
cursor_position: 0,
|
||||||
message_scroll_offset: 0,
|
message_scroll_offset: 0,
|
||||||
selected_folder_id: None, // None = All
|
selected_folder_id: None, // None = All
|
||||||
is_loading: true,
|
is_loading: true,
|
||||||
is_searching: false,
|
is_searching: false,
|
||||||
search_query: String::new(),
|
search_query: String::new(),
|
||||||
needs_redraw: true,
|
needs_redraw: true,
|
||||||
|
editing_message_id: None,
|
||||||
|
selected_message_index: None,
|
||||||
|
confirm_delete_message_id: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Помечает UI как требующий перерисовки
|
|
||||||
pub fn mark_dirty(&mut self) {
|
|
||||||
self.needs_redraw = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn next_chat(&mut self) {
|
pub fn next_chat(&mut self) {
|
||||||
let filtered = self.get_filtered_chats();
|
let filtered = self.get_filtered_chats();
|
||||||
if filtered.is_empty() {
|
if filtered.is_empty() {
|
||||||
@@ -111,18 +118,95 @@ impl App {
|
|||||||
|
|
||||||
pub fn close_chat(&mut self) {
|
pub fn close_chat(&mut self) {
|
||||||
self.selected_chat_id = None;
|
self.selected_chat_id = None;
|
||||||
self.current_messages.clear();
|
|
||||||
self.message_input.clear();
|
self.message_input.clear();
|
||||||
|
self.cursor_position = 0;
|
||||||
self.message_scroll_offset = 0;
|
self.message_scroll_offset = 0;
|
||||||
|
self.editing_message_id = None;
|
||||||
|
self.selected_message_index = None;
|
||||||
// Очищаем данные в TdClient
|
// Очищаем данные в TdClient
|
||||||
self.td_client.current_chat_id = None;
|
self.td_client.current_chat_id = None;
|
||||||
self.td_client.current_chat_messages.clear();
|
self.td_client.current_chat_messages.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn select_first_chat(&mut self) {
|
/// Начать выбор сообщения для редактирования (при стрелке вверх в пустом инпуте)
|
||||||
if !self.chats.is_empty() {
|
pub fn start_message_selection(&mut self) {
|
||||||
self.chat_list_state.select(Some(0));
|
if self.td_client.current_chat_messages.is_empty() {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
// Начинаем с последнего сообщения (индекс 0 = самое новое снизу)
|
||||||
|
self.selected_message_index = Some(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Выбрать предыдущее сообщение (вверх по списку = увеличить индекс)
|
||||||
|
pub fn select_previous_message(&mut self) {
|
||||||
|
let total = self.td_client.current_chat_messages.len();
|
||||||
|
if total == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.selected_message_index = Some(
|
||||||
|
self.selected_message_index
|
||||||
|
.map(|i| (i + 1).min(total - 1))
|
||||||
|
.unwrap_or(0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Выбрать следующее сообщение (вниз по списку = уменьшить индекс)
|
||||||
|
pub fn select_next_message(&mut self) {
|
||||||
|
self.selected_message_index = self.selected_message_index
|
||||||
|
.map(|i| if i > 0 { Some(i - 1) } else { None })
|
||||||
|
.flatten();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Получить выбранное сообщение
|
||||||
|
pub fn get_selected_message(&self) -> Option<&crate::tdlib::client::MessageInfo> {
|
||||||
|
self.selected_message_index.and_then(|idx| {
|
||||||
|
let total = self.td_client.current_chat_messages.len();
|
||||||
|
if total == 0 || idx >= total {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
// idx=0 это последнее сообщение (total-1), idx=1 это предпоследнее (total-2), и т.д.
|
||||||
|
self.td_client.current_chat_messages.get(total - 1 - idx)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Начать редактирование выбранного сообщения
|
||||||
|
pub fn start_editing_selected(&mut self) -> bool {
|
||||||
|
// Сначала извлекаем данные из сообщения
|
||||||
|
let msg_data = self.get_selected_message().and_then(|msg| {
|
||||||
|
if msg.can_be_edited && msg.is_outgoing {
|
||||||
|
Some((msg.id, msg.content.clone()))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Затем присваиваем
|
||||||
|
if let Some((id, content)) = msg_data {
|
||||||
|
self.editing_message_id = Some(id);
|
||||||
|
self.cursor_position = content.chars().count();
|
||||||
|
self.message_input = content;
|
||||||
|
self.selected_message_index = None;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Отменить редактирование
|
||||||
|
pub fn cancel_editing(&mut self) {
|
||||||
|
self.editing_message_id = None;
|
||||||
|
self.selected_message_index = None;
|
||||||
|
self.message_input.clear();
|
||||||
|
self.cursor_position = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Проверить, находимся ли в режиме редактирования
|
||||||
|
pub fn is_editing(&self) -> bool {
|
||||||
|
self.editing_message_id.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Проверить, находимся ли в режиме выбора сообщения
|
||||||
|
pub fn is_selecting_message(&self) -> bool {
|
||||||
|
self.selected_message_index.is_some()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_selected_chat_id(&self) -> Option<i64> {
|
pub fn get_selected_chat_id(&self) -> Option<i64> {
|
||||||
@@ -217,4 +301,9 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Проверить, показывается ли модалка подтверждения удаления
|
||||||
|
pub fn is_confirm_delete_shown(&self) -> bool {
|
||||||
|
self.confirm_delete_message_id.is_some()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,49 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
|
|||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Модалка подтверждения удаления
|
||||||
|
if app.is_confirm_delete_shown() {
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Char('y') | KeyCode::Char('н') | KeyCode::Enter => {
|
||||||
|
// Подтверждение удаления
|
||||||
|
if let Some(msg_id) = app.confirm_delete_message_id {
|
||||||
|
if let Some(chat_id) = app.get_selected_chat_id() {
|
||||||
|
// Находим сообщение для проверки can_be_deleted_for_all_users
|
||||||
|
let can_delete_for_all = app.td_client.current_chat_messages
|
||||||
|
.iter()
|
||||||
|
.find(|m| m.id == msg_id)
|
||||||
|
.map(|m| m.can_be_deleted_for_all_users)
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
match timeout(
|
||||||
|
Duration::from_secs(5),
|
||||||
|
app.td_client.delete_messages(chat_id, vec![msg_id], can_delete_for_all)
|
||||||
|
).await {
|
||||||
|
Ok(Ok(_)) => {
|
||||||
|
// Удаляем из локального списка
|
||||||
|
app.td_client.current_chat_messages.retain(|m| m.id != msg_id);
|
||||||
|
app.selected_message_index = None;
|
||||||
|
}
|
||||||
|
Ok(Err(e)) => {
|
||||||
|
app.error_message = Some(e);
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
app.error_message = Some("Таймаут удаления".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
app.confirm_delete_message_id = None;
|
||||||
|
}
|
||||||
|
KeyCode::Char('n') | KeyCode::Char('т') | KeyCode::Esc => {
|
||||||
|
// Отмена удаления
|
||||||
|
app.confirm_delete_message_id = None;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Режим поиска
|
// Режим поиска
|
||||||
if app.is_searching {
|
if app.is_searching {
|
||||||
match key.code {
|
match key.code {
|
||||||
@@ -37,8 +80,8 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
|
|||||||
app.status_message = Some("Загрузка сообщений...".to_string());
|
app.status_message = Some("Загрузка сообщений...".to_string());
|
||||||
app.message_scroll_offset = 0;
|
app.message_scroll_offset = 0;
|
||||||
match timeout(Duration::from_secs(10), app.td_client.get_chat_history(chat_id, 100)).await {
|
match timeout(Duration::from_secs(10), app.td_client.get_chat_history(chat_id, 100)).await {
|
||||||
Ok(Ok(messages)) => {
|
Ok(Ok(_)) => {
|
||||||
app.current_messages = messages;
|
// Сообщения уже сохранены в td_client.current_chat_messages
|
||||||
app.status_message = None;
|
app.status_message = None;
|
||||||
}
|
}
|
||||||
Ok(Err(e)) => {
|
Ok(Err(e)) => {
|
||||||
@@ -75,27 +118,66 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Enter - открыть чат или отправить сообщение
|
// Enter - открыть чат, отправить сообщение или редактировать
|
||||||
if key.code == KeyCode::Enter {
|
if key.code == KeyCode::Enter {
|
||||||
if app.selected_chat_id.is_some() {
|
if app.selected_chat_id.is_some() {
|
||||||
// Отправка сообщения
|
// Режим выбора сообщения
|
||||||
|
if app.is_selecting_message() {
|
||||||
|
// Начать редактирование выбранного сообщения
|
||||||
|
if app.start_editing_selected() {
|
||||||
|
// Редактирование начато
|
||||||
|
} else {
|
||||||
|
// Нельзя редактировать это сообщение
|
||||||
|
app.selected_message_index = None;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Отправка или редактирование сообщения
|
||||||
if !app.message_input.is_empty() {
|
if !app.message_input.is_empty() {
|
||||||
if let Some(chat_id) = app.get_selected_chat_id() {
|
if let Some(chat_id) = app.get_selected_chat_id() {
|
||||||
let text = app.message_input.clone();
|
let text = app.message_input.clone();
|
||||||
app.message_input.clear();
|
|
||||||
|
|
||||||
match timeout(Duration::from_secs(5), app.td_client.send_message(chat_id, text.clone())).await {
|
if let Some(msg_id) = app.editing_message_id {
|
||||||
Ok(Ok(sent_msg)) => {
|
// Режим редактирования
|
||||||
// Добавляем отправленное сообщение в список
|
app.message_input.clear();
|
||||||
app.current_messages.push(sent_msg);
|
app.cursor_position = 0;
|
||||||
// Сбрасываем скролл чтобы видеть новое сообщение
|
app.editing_message_id = None;
|
||||||
app.message_scroll_offset = 0;
|
|
||||||
|
match timeout(Duration::from_secs(5), app.td_client.edit_message(chat_id, msg_id, text)).await {
|
||||||
|
Ok(Ok(edited_msg)) => {
|
||||||
|
// Обновляем сообщение в списке
|
||||||
|
if let Some(msg) = app.td_client.current_chat_messages.iter_mut().find(|m| m.id == msg_id) {
|
||||||
|
msg.content = edited_msg.content;
|
||||||
|
msg.entities = edited_msg.entities;
|
||||||
|
msg.edit_date = edited_msg.edit_date;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Err(e)) => {
|
||||||
|
app.error_message = Some(e);
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
app.error_message = Some("Таймаут редактирования".to_string());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Ok(Err(e)) => {
|
} else {
|
||||||
app.error_message = Some(e);
|
// Обычная отправка
|
||||||
}
|
app.message_input.clear();
|
||||||
Err(_) => {
|
app.cursor_position = 0;
|
||||||
app.error_message = Some("Таймаут отправки".to_string());
|
|
||||||
|
match timeout(Duration::from_secs(5), app.td_client.send_message(chat_id, text)).await {
|
||||||
|
Ok(Ok(sent_msg)) => {
|
||||||
|
// Добавляем отправленное сообщение в список (с лимитом)
|
||||||
|
app.td_client.push_message(sent_msg);
|
||||||
|
// Сбрасываем скролл чтобы видеть новое сообщение
|
||||||
|
app.message_scroll_offset = 0;
|
||||||
|
}
|
||||||
|
Ok(Err(e)) => {
|
||||||
|
app.error_message = Some(e);
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
app.error_message = Some("Таймаут отправки".to_string());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -110,8 +192,8 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
|
|||||||
app.status_message = Some("Загрузка сообщений...".to_string());
|
app.status_message = Some("Загрузка сообщений...".to_string());
|
||||||
app.message_scroll_offset = 0;
|
app.message_scroll_offset = 0;
|
||||||
match timeout(Duration::from_secs(10), app.td_client.get_chat_history(chat_id, 100)).await {
|
match timeout(Duration::from_secs(10), app.td_client.get_chat_history(chat_id, 100)).await {
|
||||||
Ok(Ok(messages)) => {
|
Ok(Ok(_)) => {
|
||||||
app.current_messages = messages;
|
// Сообщения уже сохранены в td_client.current_chat_messages
|
||||||
app.status_message = None;
|
app.status_message = None;
|
||||||
}
|
}
|
||||||
Ok(Err(e)) => {
|
Ok(Err(e)) => {
|
||||||
@@ -129,9 +211,15 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Esc - закрыть чат
|
// Esc - отменить выбор/редактирование или закрыть чат
|
||||||
if key.code == KeyCode::Esc {
|
if key.code == KeyCode::Esc {
|
||||||
if app.selected_chat_id.is_some() {
|
if app.is_selecting_message() {
|
||||||
|
// Отменить выбор сообщения
|
||||||
|
app.selected_message_index = None;
|
||||||
|
} else if app.is_editing() {
|
||||||
|
// Отменить редактирование
|
||||||
|
app.cancel_editing();
|
||||||
|
} else if app.selected_chat_id.is_some() {
|
||||||
app.close_chat();
|
app.close_chat();
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -139,14 +227,97 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
|
|||||||
|
|
||||||
// Режим открытого чата
|
// Режим открытого чата
|
||||||
if app.selected_chat_id.is_some() {
|
if app.selected_chat_id.is_some() {
|
||||||
|
// Режим выбора сообщения для редактирования/удаления
|
||||||
|
if app.is_selecting_message() {
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Up => {
|
||||||
|
app.select_previous_message();
|
||||||
|
}
|
||||||
|
KeyCode::Down => {
|
||||||
|
app.select_next_message();
|
||||||
|
// Если вышли из режима выбора (индекс стал None), ничего не делаем
|
||||||
|
}
|
||||||
|
KeyCode::Char('d') | KeyCode::Char('в') | KeyCode::Delete => {
|
||||||
|
// Показать модалку подтверждения удаления
|
||||||
|
if let Some(msg) = app.get_selected_message() {
|
||||||
|
let can_delete = msg.can_be_deleted_only_for_self || msg.can_be_deleted_for_all_users;
|
||||||
|
if can_delete {
|
||||||
|
app.confirm_delete_message_id = Some(msg.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
match key.code {
|
match key.code {
|
||||||
KeyCode::Backspace => {
|
KeyCode::Backspace => {
|
||||||
app.message_input.pop();
|
// Удаляем символ слева от курсора
|
||||||
|
if app.cursor_position > 0 {
|
||||||
|
let chars: Vec<char> = app.message_input.chars().collect();
|
||||||
|
let mut new_input = String::new();
|
||||||
|
for (i, ch) in chars.iter().enumerate() {
|
||||||
|
if i != app.cursor_position - 1 {
|
||||||
|
new_input.push(*ch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
app.message_input = new_input;
|
||||||
|
app.cursor_position -= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Delete => {
|
||||||
|
// Удаляем символ справа от курсора
|
||||||
|
let len = app.message_input.chars().count();
|
||||||
|
if app.cursor_position < len {
|
||||||
|
let chars: Vec<char> = app.message_input.chars().collect();
|
||||||
|
let mut new_input = String::new();
|
||||||
|
for (i, ch) in chars.iter().enumerate() {
|
||||||
|
if i != app.cursor_position {
|
||||||
|
new_input.push(*ch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
app.message_input = new_input;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
KeyCode::Char(c) => {
|
KeyCode::Char(c) => {
|
||||||
app.message_input.push(c);
|
// Вставляем символ в позицию курсора
|
||||||
|
let chars: Vec<char> = app.message_input.chars().collect();
|
||||||
|
let mut new_input = String::new();
|
||||||
|
for (i, ch) in chars.iter().enumerate() {
|
||||||
|
if i == app.cursor_position {
|
||||||
|
new_input.push(c);
|
||||||
|
}
|
||||||
|
new_input.push(*ch);
|
||||||
|
}
|
||||||
|
if app.cursor_position >= chars.len() {
|
||||||
|
new_input.push(c);
|
||||||
|
}
|
||||||
|
app.message_input = new_input;
|
||||||
|
app.cursor_position += 1;
|
||||||
}
|
}
|
||||||
// Стрелки - скролл сообщений
|
KeyCode::Left => {
|
||||||
|
// Курсор влево
|
||||||
|
if app.cursor_position > 0 {
|
||||||
|
app.cursor_position -= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Right => {
|
||||||
|
// Курсор вправо
|
||||||
|
let len = app.message_input.chars().count();
|
||||||
|
if app.cursor_position < len {
|
||||||
|
app.cursor_position += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Home => {
|
||||||
|
// Курсор в начало
|
||||||
|
app.cursor_position = 0;
|
||||||
|
}
|
||||||
|
KeyCode::End => {
|
||||||
|
// Курсор в конец
|
||||||
|
app.cursor_position = app.message_input.chars().count();
|
||||||
|
}
|
||||||
|
// Стрелки вверх/вниз - скролл сообщений или начало выбора
|
||||||
KeyCode::Down => {
|
KeyCode::Down => {
|
||||||
// Скролл вниз (к новым сообщениям)
|
// Скролл вниз (к новым сообщениям)
|
||||||
if app.message_scroll_offset > 0 {
|
if app.message_scroll_offset > 0 {
|
||||||
@@ -154,24 +325,29 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
KeyCode::Up => {
|
KeyCode::Up => {
|
||||||
// Скролл вверх (к старым сообщениям)
|
// Если инпут пустой и не в режиме редактирования — начать выбор сообщения
|
||||||
app.message_scroll_offset += 3;
|
if app.message_input.is_empty() && !app.is_editing() {
|
||||||
|
app.start_message_selection();
|
||||||
|
} else {
|
||||||
|
// Скролл вверх (к старым сообщениям)
|
||||||
|
app.message_scroll_offset += 3;
|
||||||
|
|
||||||
// Проверяем, нужно ли подгрузить старые сообщения
|
// Проверяем, нужно ли подгрузить старые сообщения
|
||||||
if !app.current_messages.is_empty() {
|
if !app.td_client.current_chat_messages.is_empty() {
|
||||||
let oldest_msg_id = app.current_messages.first().map(|m| m.id).unwrap_or(0);
|
let oldest_msg_id = app.td_client.current_chat_messages.first().map(|m| m.id).unwrap_or(0);
|
||||||
if let Some(chat_id) = app.get_selected_chat_id() {
|
if let Some(chat_id) = app.get_selected_chat_id() {
|
||||||
// Подгружаем больше сообщений если скролл близко к верху
|
// Подгружаем больше сообщений если скролл близко к верху
|
||||||
if app.message_scroll_offset > app.current_messages.len().saturating_sub(10) {
|
if app.message_scroll_offset > app.td_client.current_chat_messages.len().saturating_sub(10) {
|
||||||
if let Ok(Ok(older)) = timeout(
|
if let Ok(Ok(older)) = timeout(
|
||||||
Duration::from_secs(3),
|
Duration::from_secs(3),
|
||||||
app.td_client.load_older_messages(chat_id, oldest_msg_id, 20)
|
app.td_client.load_older_messages(chat_id, oldest_msg_id, 20)
|
||||||
).await {
|
).await {
|
||||||
if !older.is_empty() {
|
if !older.is_empty() {
|
||||||
// Добавляем старые сообщения в начало
|
// Добавляем старые сообщения в начало
|
||||||
let mut new_messages = older;
|
let mut new_messages = older;
|
||||||
new_messages.extend(app.current_messages.drain(..));
|
new_messages.extend(app.td_client.current_chat_messages.drain(..));
|
||||||
app.current_messages = new_messages;
|
app.td_client.current_chat_messages = new_messages;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
20
src/main.rs
20
src/main.rs
@@ -129,26 +129,6 @@ async fn run_app<B: ratatui::backend::Backend>(
|
|||||||
app.td_client.process_pending_user_ids().await;
|
app.td_client.process_pending_user_ids().await;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Синхронизируем сообщения из td_client в app (для новых сообщений в реальном времени)
|
|
||||||
if app.selected_chat_id.is_some() && !app.td_client.current_chat_messages.is_empty() {
|
|
||||||
let prev_messages_len = app.current_messages.len();
|
|
||||||
// Синхронизируем все сообщения (включая обновлённые имена и is_read)
|
|
||||||
for td_msg in &app.td_client.current_chat_messages {
|
|
||||||
if let Some(app_msg) = app.current_messages.iter_mut().find(|m| m.id == td_msg.id) {
|
|
||||||
// Обновляем существующее сообщение
|
|
||||||
app_msg.sender_name = td_msg.sender_name.clone();
|
|
||||||
app_msg.is_read = td_msg.is_read;
|
|
||||||
} else {
|
|
||||||
// Добавляем новое сообщение
|
|
||||||
app.current_messages.push(td_msg.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Если добавились новые сообщения - нужна перерисовка
|
|
||||||
if app.current_messages.len() != prev_messages_len {
|
|
||||||
app.needs_redraw = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Обновляем состояние экрана на основе auth_state
|
// Обновляем состояние экрана на основе auth_state
|
||||||
let screen_changed = update_screen_state(app).await;
|
let screen_changed = update_screen_state(app).await;
|
||||||
if screen_changed {
|
if screen_changed {
|
||||||
|
|||||||
@@ -1,9 +1,82 @@
|
|||||||
use std::env;
|
use std::env;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use tdlib_rs::enums::{AuthorizationState, ChatList, ChatType, ConnectionState, MessageContent, Update, User, UserStatus};
|
use tdlib_rs::enums::{AuthorizationState, ChatList, ChatType, ConnectionState, MessageContent, Update, User, UserStatus};
|
||||||
|
use tdlib_rs::types::TextEntity;
|
||||||
|
|
||||||
/// Максимальный размер кэшей пользователей
|
/// Максимальный размер кэшей пользователей
|
||||||
const MAX_USER_CACHE_SIZE: usize = 500;
|
const MAX_USER_CACHE_SIZE: usize = 500;
|
||||||
|
/// Максимальное количество сообщений в текущем чате
|
||||||
|
const MAX_MESSAGES_IN_CHAT: usize = 500;
|
||||||
|
/// Максимальное количество чатов
|
||||||
|
const MAX_CHATS: usize = 200;
|
||||||
|
/// Максимальный размер кэша chat_user_ids
|
||||||
|
const MAX_CHAT_USER_IDS: usize = 500;
|
||||||
|
|
||||||
|
/// Простой LRU-кэш на основе HashMap + Vec для отслеживания порядка
|
||||||
|
pub struct LruCache<V> {
|
||||||
|
map: HashMap<i64, V>,
|
||||||
|
/// Порядок доступа: последний элемент — самый недавно использованный
|
||||||
|
order: Vec<i64>,
|
||||||
|
capacity: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<V: Clone> LruCache<V> {
|
||||||
|
pub fn new(capacity: usize) -> Self {
|
||||||
|
Self {
|
||||||
|
map: HashMap::with_capacity(capacity),
|
||||||
|
order: Vec::with_capacity(capacity),
|
||||||
|
capacity,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Получить значение и обновить порядок доступа
|
||||||
|
pub fn get(&mut self, key: &i64) -> Option<&V> {
|
||||||
|
if self.map.contains_key(key) {
|
||||||
|
// Перемещаем ключ в конец (самый недавно использованный)
|
||||||
|
self.order.retain(|k| k != key);
|
||||||
|
self.order.push(*key);
|
||||||
|
self.map.get(key)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Получить значение без обновления порядка (для read-only доступа)
|
||||||
|
pub fn peek(&self, key: &i64) -> Option<&V> {
|
||||||
|
self.map.get(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Вставить значение
|
||||||
|
pub fn insert(&mut self, key: i64, value: V) {
|
||||||
|
if self.map.contains_key(&key) {
|
||||||
|
// Обновляем существующее значение
|
||||||
|
self.map.insert(key, value);
|
||||||
|
self.order.retain(|k| *k != key);
|
||||||
|
self.order.push(key);
|
||||||
|
} else {
|
||||||
|
// Если кэш полон, удаляем самый старый элемент
|
||||||
|
if self.map.len() >= self.capacity {
|
||||||
|
if let Some(oldest) = self.order.first().copied() {
|
||||||
|
self.order.remove(0);
|
||||||
|
self.map.remove(&oldest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.map.insert(key, value);
|
||||||
|
self.order.push(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Проверить наличие ключа
|
||||||
|
pub fn contains_key(&self, key: &i64) -> bool {
|
||||||
|
self.map.contains_key(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Количество элементов
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn len(&self) -> usize {
|
||||||
|
self.map.len()
|
||||||
|
}
|
||||||
|
}
|
||||||
use tdlib_rs::functions;
|
use tdlib_rs::functions;
|
||||||
use tdlib_rs::types::{Chat as TdChat, Message as TdMessage};
|
use tdlib_rs::types::{Chat as TdChat, Message as TdMessage};
|
||||||
|
|
||||||
@@ -46,8 +119,18 @@ pub struct MessageInfo {
|
|||||||
pub sender_name: String,
|
pub sender_name: String,
|
||||||
pub is_outgoing: bool,
|
pub is_outgoing: bool,
|
||||||
pub content: String,
|
pub content: String,
|
||||||
|
/// Сущности форматирования (bold, italic, code и т.д.)
|
||||||
|
pub entities: Vec<TextEntity>,
|
||||||
pub date: i32,
|
pub date: i32,
|
||||||
|
/// Дата редактирования (0 если не редактировалось)
|
||||||
|
pub edit_date: i32,
|
||||||
pub is_read: bool,
|
pub is_read: bool,
|
||||||
|
/// Можно ли редактировать сообщение
|
||||||
|
pub can_be_edited: bool,
|
||||||
|
/// Можно ли удалить только для себя
|
||||||
|
pub can_be_deleted_only_for_self: bool,
|
||||||
|
/// Можно ли удалить для всех
|
||||||
|
pub can_be_deleted_for_all_users: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -97,10 +180,10 @@ pub struct TdClient {
|
|||||||
pub current_chat_messages: Vec<MessageInfo>,
|
pub current_chat_messages: Vec<MessageInfo>,
|
||||||
/// ID текущего открытого чата (для получения новых сообщений)
|
/// ID текущего открытого чата (для получения новых сообщений)
|
||||||
pub current_chat_id: Option<i64>,
|
pub current_chat_id: Option<i64>,
|
||||||
/// Кэш usernames: user_id -> username
|
/// LRU-кэш usernames: user_id -> username
|
||||||
user_usernames: HashMap<i64, String>,
|
user_usernames: LruCache<String>,
|
||||||
/// Кэш имён: user_id -> display_name (first_name + last_name)
|
/// LRU-кэш имён: user_id -> display_name (first_name + last_name)
|
||||||
user_names: HashMap<i64, String>,
|
user_names: LruCache<String>,
|
||||||
/// Связь chat_id -> user_id для приватных чатов
|
/// Связь chat_id -> user_id для приватных чатов
|
||||||
chat_user_ids: HashMap<i64, i64>,
|
chat_user_ids: HashMap<i64, i64>,
|
||||||
/// Очередь сообщений для отметки как прочитанных: (chat_id, message_ids)
|
/// Очередь сообщений для отметки как прочитанных: (chat_id, message_ids)
|
||||||
@@ -111,8 +194,8 @@ pub struct TdClient {
|
|||||||
pub folders: Vec<FolderInfo>,
|
pub folders: Vec<FolderInfo>,
|
||||||
/// Позиция основного списка среди папок
|
/// Позиция основного списка среди папок
|
||||||
pub main_chat_list_position: i32,
|
pub main_chat_list_position: i32,
|
||||||
/// Онлайн-статусы пользователей: user_id -> status
|
/// LRU-кэш онлайн-статусов пользователей: user_id -> status
|
||||||
user_statuses: HashMap<i64, UserOnlineStatus>,
|
user_statuses: LruCache<UserOnlineStatus>,
|
||||||
/// Состояние сетевого соединения
|
/// Состояние сетевого соединения
|
||||||
pub network_state: NetworkState,
|
pub network_state: NetworkState,
|
||||||
}
|
}
|
||||||
@@ -136,14 +219,14 @@ impl TdClient {
|
|||||||
chats: Vec::new(),
|
chats: Vec::new(),
|
||||||
current_chat_messages: Vec::new(),
|
current_chat_messages: Vec::new(),
|
||||||
current_chat_id: None,
|
current_chat_id: None,
|
||||||
user_usernames: HashMap::new(),
|
user_usernames: LruCache::new(MAX_USER_CACHE_SIZE),
|
||||||
user_names: HashMap::new(),
|
user_names: LruCache::new(MAX_USER_CACHE_SIZE),
|
||||||
chat_user_ids: HashMap::new(),
|
chat_user_ids: HashMap::new(),
|
||||||
pending_view_messages: Vec::new(),
|
pending_view_messages: Vec::new(),
|
||||||
pending_user_ids: Vec::new(),
|
pending_user_ids: Vec::new(),
|
||||||
folders: Vec::new(),
|
folders: Vec::new(),
|
||||||
main_chat_list_position: 0,
|
main_chat_list_position: 0,
|
||||||
user_statuses: HashMap::new(),
|
user_statuses: LruCache::new(MAX_USER_CACHE_SIZE),
|
||||||
network_state: NetworkState::Connecting,
|
network_state: NetworkState::Connecting,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -156,23 +239,21 @@ impl TdClient {
|
|||||||
self.client_id
|
self.client_id
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Очистка кэшей если они превышают лимит
|
/// Добавляет сообщение в текущий чат с соблюдением лимита
|
||||||
fn trim_caches(&mut self) {
|
pub fn push_message(&mut self, msg: MessageInfo) {
|
||||||
if self.user_names.len() > MAX_USER_CACHE_SIZE {
|
self.current_chat_messages.push(msg);
|
||||||
// Оставляем только пользователей из текущих чатов
|
// Ограничиваем количество сообщений (удаляем старые)
|
||||||
let active_user_ids: std::collections::HashSet<i64> =
|
if self.current_chat_messages.len() > MAX_MESSAGES_IN_CHAT {
|
||||||
self.chat_user_ids.values().copied().collect();
|
self.current_chat_messages.remove(0);
|
||||||
self.user_names.retain(|id, _| active_user_ids.contains(id));
|
|
||||||
self.user_usernames.retain(|id, _| active_user_ids.contains(id));
|
|
||||||
self.user_statuses.retain(|id, _| active_user_ids.contains(id));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Получение онлайн-статуса пользователя по chat_id (для приватных чатов)
|
/// Получение онлайн-статуса пользователя по chat_id (для приватных чатов)
|
||||||
|
/// Использует peek для read-only доступа (не обновляет LRU порядок)
|
||||||
pub fn get_user_status_by_chat_id(&self, chat_id: i64) -> Option<&UserOnlineStatus> {
|
pub fn get_user_status_by_chat_id(&self, chat_id: i64) -> Option<&UserOnlineStatus> {
|
||||||
self.chat_user_ids
|
self.chat_user_ids
|
||||||
.get(&chat_id)
|
.get(&chat_id)
|
||||||
.and_then(|user_id| self.user_statuses.get(user_id))
|
.and_then(|user_id| self.user_statuses.peek(user_id))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Инициализация TDLib с параметрами
|
/// Инициализация TDLib с параметрами
|
||||||
@@ -216,7 +297,7 @@ impl TdClient {
|
|||||||
let (last_message_text, last_message_date) = update
|
let (last_message_text, last_message_date) = update
|
||||||
.last_message
|
.last_message
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|msg| (extract_message_text_static(msg), msg.date))
|
.map(|msg| (extract_message_text_static(msg).0, msg.date))
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
if let Some(chat) = self.chats.iter_mut().find(|c| c.id == chat_id) {
|
if let Some(chat) = self.chats.iter_mut().find(|c| c.id == chat_id) {
|
||||||
@@ -310,7 +391,7 @@ impl TdClient {
|
|||||||
let is_incoming = !msg_info.is_outgoing;
|
let is_incoming = !msg_info.is_outgoing;
|
||||||
// Проверяем, что сообщение ещё не добавлено (по id)
|
// Проверяем, что сообщение ещё не добавлено (по id)
|
||||||
if !self.current_chat_messages.iter().any(|m| m.id == msg_info.id) {
|
if !self.current_chat_messages.iter().any(|m| m.id == msg_info.id) {
|
||||||
self.current_chat_messages.push(msg_info);
|
self.push_message(msg_info);
|
||||||
// Если это входящее сообщение — добавляем в очередь для отметки как прочитанное
|
// Если это входящее сообщение — добавляем в очередь для отметки как прочитанное
|
||||||
if is_incoming {
|
if is_incoming {
|
||||||
self.pending_view_messages.push((chat_id, vec![msg_id]));
|
self.pending_view_messages.push((chat_id, vec![msg_id]));
|
||||||
@@ -354,9 +435,7 @@ impl TdClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// LRU-кэш автоматически удаляет старые записи при вставке
|
||||||
// Периодически очищаем кэши
|
|
||||||
self.trim_caches();
|
|
||||||
}
|
}
|
||||||
Update::ChatFolders(update) => {
|
Update::ChatFolders(update) => {
|
||||||
// Обновляем список папок
|
// Обновляем список папок
|
||||||
@@ -429,15 +508,22 @@ impl TdClient {
|
|||||||
let (last_message, last_message_date) = td_chat
|
let (last_message, last_message_date) = td_chat
|
||||||
.last_message
|
.last_message
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|m| (extract_message_text_static(m), m.date))
|
.map(|m| (extract_message_text_static(m).0, m.date))
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
// Извлекаем user_id для приватных чатов и сохраняем связь
|
// Извлекаем user_id для приватных чатов и сохраняем связь
|
||||||
let username = match &td_chat.r#type {
|
let username = match &td_chat.r#type {
|
||||||
ChatType::Private(private) => {
|
ChatType::Private(private) => {
|
||||||
|
// Ограничиваем размер chat_user_ids
|
||||||
|
if self.chat_user_ids.len() >= MAX_CHAT_USER_IDS && !self.chat_user_ids.contains_key(&td_chat.id) {
|
||||||
|
// Удаляем случайную запись (первую найденную)
|
||||||
|
if let Some(&key) = self.chat_user_ids.keys().next() {
|
||||||
|
self.chat_user_ids.remove(&key);
|
||||||
|
}
|
||||||
|
}
|
||||||
self.chat_user_ids.insert(td_chat.id, private.user_id);
|
self.chat_user_ids.insert(td_chat.id, private.user_id);
|
||||||
// Проверяем, есть ли уже username в кэше
|
// Проверяем, есть ли уже username в кэше (peek не обновляет LRU)
|
||||||
self.user_usernames.get(&private.user_id).map(|u| format!("@{}", u))
|
self.user_usernames.peek(&private.user_id).map(|u| format!("@{}", u))
|
||||||
}
|
}
|
||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
@@ -493,6 +579,13 @@ impl TdClient {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
self.chats.push(chat_info);
|
self.chats.push(chat_info);
|
||||||
|
// Ограничиваем количество чатов
|
||||||
|
if self.chats.len() > MAX_CHATS {
|
||||||
|
// Удаляем чат с наименьшим order (наименее активный)
|
||||||
|
if let Some(min_idx) = self.chats.iter().enumerate().min_by_key(|(_, c)| c.order).map(|(i, _)| i) {
|
||||||
|
self.chats.remove(min_idx);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Сортируем чаты по order (TDLib order учитывает pinned и время)
|
// Сортируем чаты по order (TDLib order учитывает pinned и время)
|
||||||
@@ -502,9 +595,9 @@ impl TdClient {
|
|||||||
fn convert_message(&mut self, message: &TdMessage, chat_id: i64) -> MessageInfo {
|
fn convert_message(&mut self, message: &TdMessage, chat_id: i64) -> MessageInfo {
|
||||||
let sender_name = match &message.sender_id {
|
let sender_name = match &message.sender_id {
|
||||||
tdlib_rs::enums::MessageSender::User(user) => {
|
tdlib_rs::enums::MessageSender::User(user) => {
|
||||||
// Пробуем получить имя из кеша
|
// Пробуем получить имя из кеша (get обновляет LRU порядок)
|
||||||
if let Some(name) = self.user_names.get(&user.user_id) {
|
if let Some(name) = self.user_names.get(&user.user_id).cloned() {
|
||||||
name.clone()
|
name
|
||||||
} else {
|
} else {
|
||||||
// Добавляем в очередь для загрузки
|
// Добавляем в очередь для загрузки
|
||||||
if !self.pending_user_ids.contains(&user.user_id) {
|
if !self.pending_user_ids.contains(&user.user_id) {
|
||||||
@@ -535,13 +628,20 @@ impl TdClient {
|
|||||||
true // Входящие сообщения не показывают галочки
|
true // Входящие сообщения не показывают галочки
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let (content, entities) = extract_message_text_static(message);
|
||||||
|
|
||||||
MessageInfo {
|
MessageInfo {
|
||||||
id: message.id,
|
id: message.id,
|
||||||
sender_name,
|
sender_name,
|
||||||
is_outgoing: message.is_outgoing,
|
is_outgoing: message.is_outgoing,
|
||||||
content: extract_message_text_static(message),
|
content,
|
||||||
|
entities,
|
||||||
date: message.date,
|
date: message.date,
|
||||||
|
edit_date: message.edit_date,
|
||||||
is_read,
|
is_read,
|
||||||
|
can_be_edited: message.can_be_edited,
|
||||||
|
can_be_deleted_only_for_self: message.can_be_deleted_only_for_self,
|
||||||
|
can_be_deleted_for_all_users: message.can_be_deleted_for_all_users,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -760,16 +860,32 @@ impl TdClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Отправка текстового сообщения
|
/// Отправка текстового сообщения с поддержкой Markdown
|
||||||
pub async fn send_message(&self, chat_id: i64, text: String) -> Result<MessageInfo, String> {
|
pub async fn send_message(&self, chat_id: i64, text: String) -> Result<MessageInfo, String> {
|
||||||
use tdlib_rs::types::{FormattedText, InputMessageText};
|
use tdlib_rs::types::{FormattedText, InputMessageText, TextParseModeMarkdown};
|
||||||
use tdlib_rs::enums::InputMessageContent;
|
use tdlib_rs::enums::{InputMessageContent, TextParseMode};
|
||||||
|
|
||||||
|
// Парсим markdown в тексте
|
||||||
|
let formatted_text = match functions::parse_text_entities(
|
||||||
|
text.clone(),
|
||||||
|
TextParseMode::Markdown(TextParseModeMarkdown { version: 2 }),
|
||||||
|
self.client_id,
|
||||||
|
).await {
|
||||||
|
Ok(tdlib_rs::enums::FormattedText::FormattedText(ft)) => FormattedText {
|
||||||
|
text: ft.text,
|
||||||
|
entities: ft.entities,
|
||||||
|
},
|
||||||
|
Err(_) => {
|
||||||
|
// Если парсинг не удался, отправляем как plain text
|
||||||
|
FormattedText {
|
||||||
|
text: text.clone(),
|
||||||
|
entities: vec![],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let content = InputMessageContent::InputMessageText(InputMessageText {
|
let content = InputMessageContent::InputMessageText(InputMessageText {
|
||||||
text: FormattedText {
|
text: formatted_text,
|
||||||
text: text.clone(),
|
|
||||||
entities: vec![],
|
|
||||||
},
|
|
||||||
link_preview_options: None,
|
link_preview_options: None,
|
||||||
clear_draft: true,
|
clear_draft: true,
|
||||||
});
|
});
|
||||||
@@ -786,20 +902,102 @@ impl TdClient {
|
|||||||
|
|
||||||
match result {
|
match result {
|
||||||
Ok(tdlib_rs::enums::Message::Message(msg)) => {
|
Ok(tdlib_rs::enums::Message::Message(msg)) => {
|
||||||
// Конвертируем отправленное сообщение в MessageInfo
|
// Извлекаем текст и entities из отправленного сообщения
|
||||||
|
let (content, entities) = extract_message_text_static(&msg);
|
||||||
Ok(MessageInfo {
|
Ok(MessageInfo {
|
||||||
id: msg.id,
|
id: msg.id,
|
||||||
sender_name: "You".to_string(),
|
sender_name: "Вы".to_string(),
|
||||||
is_outgoing: true,
|
is_outgoing: true,
|
||||||
content: text,
|
content,
|
||||||
|
entities,
|
||||||
date: msg.date,
|
date: msg.date,
|
||||||
|
edit_date: msg.edit_date,
|
||||||
is_read: false,
|
is_read: false,
|
||||||
|
can_be_edited: msg.can_be_edited,
|
||||||
|
can_be_deleted_only_for_self: msg.can_be_deleted_only_for_self,
|
||||||
|
can_be_deleted_for_all_users: msg.can_be_deleted_for_all_users,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
Err(e) => Err(format!("Ошибка отправки сообщения: {:?}", e)),
|
Err(e) => Err(format!("Ошибка отправки сообщения: {:?}", e)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Редактирование текстового сообщения с поддержкой Markdown
|
||||||
|
pub async fn edit_message(&self, chat_id: i64, message_id: i64, text: String) -> Result<MessageInfo, String> {
|
||||||
|
use tdlib_rs::types::{FormattedText, InputMessageText, TextParseModeMarkdown};
|
||||||
|
use tdlib_rs::enums::{InputMessageContent, TextParseMode};
|
||||||
|
|
||||||
|
// Парсим markdown в тексте
|
||||||
|
let formatted_text = match functions::parse_text_entities(
|
||||||
|
text.clone(),
|
||||||
|
TextParseMode::Markdown(TextParseModeMarkdown { version: 2 }),
|
||||||
|
self.client_id,
|
||||||
|
).await {
|
||||||
|
Ok(tdlib_rs::enums::FormattedText::FormattedText(ft)) => FormattedText {
|
||||||
|
text: ft.text,
|
||||||
|
entities: ft.entities,
|
||||||
|
},
|
||||||
|
Err(_) => {
|
||||||
|
// Если парсинг не удался, отправляем как plain text
|
||||||
|
FormattedText {
|
||||||
|
text: text.clone(),
|
||||||
|
entities: vec![],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let content = InputMessageContent::InputMessageText(InputMessageText {
|
||||||
|
text: formatted_text,
|
||||||
|
link_preview_options: None,
|
||||||
|
clear_draft: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = functions::edit_message_text(
|
||||||
|
chat_id,
|
||||||
|
message_id,
|
||||||
|
content,
|
||||||
|
self.client_id,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(tdlib_rs::enums::Message::Message(msg)) => {
|
||||||
|
let (content, entities) = extract_message_text_static(&msg);
|
||||||
|
Ok(MessageInfo {
|
||||||
|
id: msg.id,
|
||||||
|
sender_name: "Вы".to_string(),
|
||||||
|
is_outgoing: true,
|
||||||
|
content,
|
||||||
|
entities,
|
||||||
|
date: msg.date,
|
||||||
|
edit_date: msg.edit_date,
|
||||||
|
is_read: true,
|
||||||
|
can_be_edited: msg.can_be_edited,
|
||||||
|
can_be_deleted_only_for_self: msg.can_be_deleted_only_for_self,
|
||||||
|
can_be_deleted_for_all_users: msg.can_be_deleted_for_all_users,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Err(e) => Err(format!("Ошибка редактирования сообщения: {:?}", e)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Удаление сообщений
|
||||||
|
/// revoke = true удаляет для всех, false только для себя
|
||||||
|
pub async fn delete_messages(&self, chat_id: i64, message_ids: Vec<i64>, revoke: bool) -> Result<(), String> {
|
||||||
|
let result = functions::delete_messages(
|
||||||
|
chat_id,
|
||||||
|
message_ids,
|
||||||
|
revoke,
|
||||||
|
self.client_id,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(_) => Ok(()),
|
||||||
|
Err(e) => Err(format!("Ошибка удаления сообщения: {:?}", e)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Обработка очереди сообщений для отметки как прочитанных
|
/// Обработка очереди сообщений для отметки как прочитанных
|
||||||
pub async fn process_pending_view_messages(&mut self) {
|
pub async fn process_pending_view_messages(&mut self) {
|
||||||
let pending = std::mem::take(&mut self.pending_view_messages);
|
let pending = std::mem::take(&mut self.pending_view_messages);
|
||||||
@@ -815,14 +1013,21 @@ impl TdClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Обработка очереди user_id для загрузки имён
|
/// Обработка очереди user_id для загрузки имён (lazy loading)
|
||||||
|
/// Загружает только последние 5 запросов за цикл для снижения нагрузки
|
||||||
pub async fn process_pending_user_ids(&mut self) {
|
pub async fn process_pending_user_ids(&mut self) {
|
||||||
let pending = std::mem::take(&mut self.pending_user_ids);
|
// Берём только последние запросы (они актуальнее — от недавних сообщений)
|
||||||
for user_id in pending {
|
const BATCH_SIZE: usize = 5;
|
||||||
// Пропускаем если имя уже есть
|
|
||||||
if self.user_names.contains_key(&user_id) {
|
// Убираем дубликаты и уже загруженные
|
||||||
continue;
|
self.pending_user_ids.retain(|id| !self.user_names.contains_key(id));
|
||||||
}
|
self.pending_user_ids.dedup();
|
||||||
|
|
||||||
|
// Берём последние BATCH_SIZE элементов
|
||||||
|
let start = self.pending_user_ids.len().saturating_sub(BATCH_SIZE);
|
||||||
|
let batch: Vec<i64> = self.pending_user_ids.drain(start..).collect();
|
||||||
|
|
||||||
|
for user_id in batch {
|
||||||
// Загружаем информацию о пользователе
|
// Загружаем информацию о пользователе
|
||||||
if let Ok(User::User(user)) = functions::get_user(user_id, self.client_id).await {
|
if let Ok(User::User(user)) = functions::get_user(user_id, self.client_id).await {
|
||||||
let display_name = if user.last_name.is_empty() {
|
let display_name = if user.last_name.is_empty() {
|
||||||
@@ -840,37 +1045,83 @@ impl TdClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ограничиваем размер очереди (старые запросы отбрасываем)
|
||||||
|
const MAX_QUEUE_SIZE: usize = 50;
|
||||||
|
if self.pending_user_ids.len() > MAX_QUEUE_SIZE {
|
||||||
|
let excess = self.pending_user_ids.len() - MAX_QUEUE_SIZE;
|
||||||
|
self.pending_user_ids.drain(0..excess);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Статическая функция для извлечения текста сообщения (без &self)
|
/// Статическая функция для извлечения текста и entities сообщения (без &self)
|
||||||
fn extract_message_text_static(message: &TdMessage) -> String {
|
fn extract_message_text_static(message: &TdMessage) -> (String, Vec<TextEntity>) {
|
||||||
match &message.content {
|
match &message.content {
|
||||||
MessageContent::MessageText(text) => text.text.text.clone(),
|
MessageContent::MessageText(text) => {
|
||||||
|
(text.text.text.clone(), text.text.entities.clone())
|
||||||
|
}
|
||||||
MessageContent::MessagePhoto(photo) => {
|
MessageContent::MessagePhoto(photo) => {
|
||||||
if photo.caption.text.is_empty() {
|
if photo.caption.text.is_empty() {
|
||||||
"[Фото]".to_string()
|
("[Фото]".to_string(), vec![])
|
||||||
} else {
|
} else {
|
||||||
format!("[Фото] {}", photo.caption.text)
|
// Добавляем смещение для "[Фото] " к entities
|
||||||
|
let prefix_len = "[Фото] ".chars().count() as i32;
|
||||||
|
let adjusted_entities: Vec<TextEntity> = photo.caption.entities.iter()
|
||||||
|
.map(|e| TextEntity {
|
||||||
|
offset: e.offset + prefix_len,
|
||||||
|
length: e.length,
|
||||||
|
r#type: e.r#type.clone(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
(format!("[Фото] {}", photo.caption.text), adjusted_entities)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MessageContent::MessageVideo(video) => {
|
||||||
|
if video.caption.text.is_empty() {
|
||||||
|
("[Видео]".to_string(), vec![])
|
||||||
|
} else {
|
||||||
|
let prefix_len = "[Видео] ".chars().count() as i32;
|
||||||
|
let adjusted_entities: Vec<TextEntity> = video.caption.entities.iter()
|
||||||
|
.map(|e| TextEntity {
|
||||||
|
offset: e.offset + prefix_len,
|
||||||
|
length: e.length,
|
||||||
|
r#type: e.r#type.clone(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
(format!("[Видео] {}", video.caption.text), adjusted_entities)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
MessageContent::MessageVideo(_) => "[Видео]".to_string(),
|
|
||||||
MessageContent::MessageDocument(doc) => {
|
MessageContent::MessageDocument(doc) => {
|
||||||
format!("[Файл: {}]", doc.document.file_name)
|
(format!("[Файл: {}]", doc.document.file_name), vec![])
|
||||||
}
|
}
|
||||||
MessageContent::MessageVoiceNote(_) => "[Голосовое сообщение]".to_string(),
|
MessageContent::MessageVoiceNote(_) => ("[Голосовое сообщение]".to_string(), vec![]),
|
||||||
MessageContent::MessageVideoNote(_) => "[Видеосообщение]".to_string(),
|
MessageContent::MessageVideoNote(_) => ("[Видеосообщение]".to_string(), vec![]),
|
||||||
MessageContent::MessageSticker(sticker) => {
|
MessageContent::MessageSticker(sticker) => {
|
||||||
format!("[Стикер: {}]", sticker.sticker.emoji)
|
(format!("[Стикер: {}]", sticker.sticker.emoji), vec![])
|
||||||
|
}
|
||||||
|
MessageContent::MessageAnimation(anim) => {
|
||||||
|
if anim.caption.text.is_empty() {
|
||||||
|
("[GIF]".to_string(), vec![])
|
||||||
|
} else {
|
||||||
|
let prefix_len = "[GIF] ".chars().count() as i32;
|
||||||
|
let adjusted_entities: Vec<TextEntity> = anim.caption.entities.iter()
|
||||||
|
.map(|e| TextEntity {
|
||||||
|
offset: e.offset + prefix_len,
|
||||||
|
length: e.length,
|
||||||
|
r#type: e.r#type.clone(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
(format!("[GIF] {}", anim.caption.text), adjusted_entities)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
MessageContent::MessageAnimation(_) => "[GIF]".to_string(),
|
|
||||||
MessageContent::MessageAudio(audio) => {
|
MessageContent::MessageAudio(audio) => {
|
||||||
format!("[Аудио: {}]", audio.audio.title)
|
(format!("[Аудио: {}]", audio.audio.title), vec![])
|
||||||
}
|
}
|
||||||
MessageContent::MessageCall(_) => "[Звонок]".to_string(),
|
MessageContent::MessageCall(_) => ("[Звонок]".to_string(), vec![]),
|
||||||
MessageContent::MessagePoll(poll) => {
|
MessageContent::MessagePoll(poll) => {
|
||||||
format!("[Опрос: {}]", poll.poll.question.text)
|
(format!("[Опрос: {}]", poll.poll.question.text), vec![])
|
||||||
}
|
}
|
||||||
_ => "[Сообщение]".to_string(),
|
_ => ("[Сообщение]".to_string(), vec![]),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,13 @@ use ratatui::{
|
|||||||
use crate::app::App;
|
use crate::app::App;
|
||||||
use super::{chat_list, messages, footer};
|
use super::{chat_list, messages, footer};
|
||||||
|
|
||||||
|
/// Порог ширины для компактного режима (одна панель)
|
||||||
|
const COMPACT_WIDTH: u16 = 80;
|
||||||
|
|
||||||
pub fn render(f: &mut Frame, app: &mut App) {
|
pub fn render(f: &mut Frame, app: &mut App) {
|
||||||
|
let area = f.area();
|
||||||
|
let is_compact = area.width < COMPACT_WIDTH;
|
||||||
|
|
||||||
let chunks = Layout::default()
|
let chunks = Layout::default()
|
||||||
.direction(Direction::Vertical)
|
.direction(Direction::Vertical)
|
||||||
.constraints([
|
.constraints([
|
||||||
@@ -16,20 +22,33 @@ pub fn render(f: &mut Frame, app: &mut App) {
|
|||||||
Constraint::Min(0), // Main content
|
Constraint::Min(0), // Main content
|
||||||
Constraint::Length(1), // Commands footer
|
Constraint::Length(1), // Commands footer
|
||||||
])
|
])
|
||||||
.split(f.area());
|
.split(area);
|
||||||
|
|
||||||
render_folders(f, chunks[0], app);
|
render_folders(f, chunks[0], app);
|
||||||
|
|
||||||
let main_chunks = Layout::default()
|
if is_compact {
|
||||||
.direction(Direction::Horizontal)
|
// Компактный режим: показываем либо список чатов, либо открытый чат
|
||||||
.constraints([
|
if app.selected_chat_id.is_some() {
|
||||||
Constraint::Percentage(30), // Chat list
|
// Чат открыт — показываем только сообщения
|
||||||
Constraint::Percentage(70), // Messages area
|
messages::render(f, chunks[1], app);
|
||||||
])
|
} else {
|
||||||
.split(chunks[1]);
|
// Чат не открыт — показываем только список чатов
|
||||||
|
chat_list::render(f, chunks[1], app);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Обычный режим: две панели
|
||||||
|
let main_chunks = Layout::default()
|
||||||
|
.direction(Direction::Horizontal)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Percentage(30), // Chat list
|
||||||
|
Constraint::Percentage(70), // Messages area
|
||||||
|
])
|
||||||
|
.split(chunks[1]);
|
||||||
|
|
||||||
|
chat_list::render(f, main_chunks[0], app);
|
||||||
|
messages::render(f, main_chunks[1], app);
|
||||||
|
}
|
||||||
|
|
||||||
chat_list::render(f, main_chunks[0], app);
|
|
||||||
messages::render(f, main_chunks[1], app);
|
|
||||||
footer::render(f, chunks[2], app);
|
footer::render(f, chunks[2], app);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,48 +7,305 @@ use ratatui::{
|
|||||||
};
|
};
|
||||||
use crate::app::App;
|
use crate::app::App;
|
||||||
use crate::utils::{format_timestamp, format_date, get_day};
|
use crate::utils::{format_timestamp, format_date, get_day};
|
||||||
|
use tdlib_rs::enums::TextEntityType;
|
||||||
|
use tdlib_rs::types::TextEntity;
|
||||||
|
|
||||||
|
/// Структура для хранения стиля символа
|
||||||
|
#[derive(Clone, Default)]
|
||||||
|
struct CharStyle {
|
||||||
|
bold: bool,
|
||||||
|
italic: bool,
|
||||||
|
underline: bool,
|
||||||
|
strikethrough: bool,
|
||||||
|
code: bool,
|
||||||
|
spoiler: bool,
|
||||||
|
url: bool,
|
||||||
|
mention: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CharStyle {
|
||||||
|
fn to_style(&self, base_color: Color) -> Style {
|
||||||
|
let mut style = Style::default();
|
||||||
|
|
||||||
|
if self.code {
|
||||||
|
// Код отображается cyan на тёмном фоне
|
||||||
|
style = style.fg(Color::Cyan).bg(Color::DarkGray);
|
||||||
|
} else if self.spoiler {
|
||||||
|
// Спойлер — серый текст (скрытый)
|
||||||
|
style = style.fg(Color::DarkGray).bg(Color::DarkGray);
|
||||||
|
} else if self.url || self.mention {
|
||||||
|
// Ссылки и упоминания — синий с подчёркиванием
|
||||||
|
style = style.fg(Color::Blue).add_modifier(Modifier::UNDERLINED);
|
||||||
|
} else {
|
||||||
|
style = style.fg(base_color);
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.bold {
|
||||||
|
style = style.add_modifier(Modifier::BOLD);
|
||||||
|
}
|
||||||
|
if self.italic {
|
||||||
|
style = style.add_modifier(Modifier::ITALIC);
|
||||||
|
}
|
||||||
|
if self.underline {
|
||||||
|
style = style.add_modifier(Modifier::UNDERLINED);
|
||||||
|
}
|
||||||
|
if self.strikethrough {
|
||||||
|
style = style.add_modifier(Modifier::CROSSED_OUT);
|
||||||
|
}
|
||||||
|
|
||||||
|
style
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Преобразует текст с entities в вектор стилизованных Span (owned)
|
||||||
|
fn format_text_with_entities(text: &str, entities: &[TextEntity], base_color: Color) -> Vec<Span<'static>> {
|
||||||
|
if entities.is_empty() {
|
||||||
|
return vec![Span::styled(text.to_string(), Style::default().fg(base_color))];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Создаём массив стилей для каждого символа
|
||||||
|
let chars: Vec<char> = text.chars().collect();
|
||||||
|
let mut char_styles: Vec<CharStyle> = vec![CharStyle::default(); chars.len()];
|
||||||
|
|
||||||
|
// Применяем entities к символам
|
||||||
|
for entity in entities {
|
||||||
|
let start = entity.offset as usize;
|
||||||
|
let end = (entity.offset + entity.length) as usize;
|
||||||
|
|
||||||
|
for i in start..end.min(chars.len()) {
|
||||||
|
match &entity.r#type {
|
||||||
|
TextEntityType::Bold => char_styles[i].bold = true,
|
||||||
|
TextEntityType::Italic => char_styles[i].italic = true,
|
||||||
|
TextEntityType::Underline => char_styles[i].underline = true,
|
||||||
|
TextEntityType::Strikethrough => char_styles[i].strikethrough = true,
|
||||||
|
TextEntityType::Code | TextEntityType::Pre | TextEntityType::PreCode(_) => {
|
||||||
|
char_styles[i].code = true
|
||||||
|
}
|
||||||
|
TextEntityType::Spoiler => char_styles[i].spoiler = true,
|
||||||
|
TextEntityType::Url | TextEntityType::TextUrl(_) | TextEntityType::EmailAddress
|
||||||
|
| TextEntityType::PhoneNumber => char_styles[i].url = true,
|
||||||
|
TextEntityType::Mention | TextEntityType::MentionName(_) => char_styles[i].mention = true,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Группируем последовательные символы с одинаковым стилем
|
||||||
|
let mut spans: Vec<Span<'static>> = Vec::new();
|
||||||
|
let mut current_text = String::new();
|
||||||
|
let mut current_style: Option<CharStyle> = None;
|
||||||
|
|
||||||
|
for (i, ch) in chars.iter().enumerate() {
|
||||||
|
let style = &char_styles[i];
|
||||||
|
|
||||||
|
match ¤t_style {
|
||||||
|
Some(prev_style) if styles_equal(prev_style, style) => {
|
||||||
|
current_text.push(*ch);
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
if !current_text.is_empty() {
|
||||||
|
if let Some(prev_style) = ¤t_style {
|
||||||
|
spans.push(Span::styled(
|
||||||
|
current_text.clone(),
|
||||||
|
prev_style.to_style(base_color),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
current_text = ch.to_string();
|
||||||
|
current_style = Some(style.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Добавляем последний span
|
||||||
|
if !current_text.is_empty() {
|
||||||
|
if let Some(style) = current_style {
|
||||||
|
spans.push(Span::styled(current_text, style.to_style(base_color)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if spans.is_empty() {
|
||||||
|
spans.push(Span::styled(text.to_string(), Style::default().fg(base_color)));
|
||||||
|
}
|
||||||
|
|
||||||
|
spans
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Проверяет равенство двух стилей
|
||||||
|
fn styles_equal(a: &CharStyle, b: &CharStyle) -> bool {
|
||||||
|
a.bold == b.bold
|
||||||
|
&& a.italic == b.italic
|
||||||
|
&& a.underline == b.underline
|
||||||
|
&& a.strikethrough == b.strikethrough
|
||||||
|
&& a.code == b.code
|
||||||
|
&& a.spoiler == b.spoiler
|
||||||
|
&& a.url == b.url
|
||||||
|
&& a.mention == b.mention
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Рендерит текст инпута с блочным курсором
|
||||||
|
fn render_input_with_cursor<'a>(prefix: &'a str, text: &str, cursor_pos: usize, color: Color) -> Line<'a> {
|
||||||
|
let chars: Vec<char> = text.chars().collect();
|
||||||
|
let mut spans: Vec<Span> = vec![Span::raw(prefix.to_string())];
|
||||||
|
|
||||||
|
// Текст до курсора
|
||||||
|
if cursor_pos > 0 {
|
||||||
|
let before: String = chars[..cursor_pos].iter().collect();
|
||||||
|
spans.push(Span::styled(before, Style::default().fg(color)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Символ под курсором (или █ если курсор в конце)
|
||||||
|
if cursor_pos < chars.len() {
|
||||||
|
let cursor_char = chars[cursor_pos].to_string();
|
||||||
|
spans.push(Span::styled(cursor_char, Style::default().fg(Color::Black).bg(color)));
|
||||||
|
} else {
|
||||||
|
// Курсор в конце - показываем блок
|
||||||
|
spans.push(Span::styled("█", Style::default().fg(color)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Текст после курсора
|
||||||
|
if cursor_pos + 1 < chars.len() {
|
||||||
|
let after: String = chars[cursor_pos + 1..].iter().collect();
|
||||||
|
spans.push(Span::styled(after, Style::default().fg(color)));
|
||||||
|
}
|
||||||
|
|
||||||
|
Line::from(spans)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Информация о строке после переноса: текст и позиция в оригинале
|
||||||
|
struct WrappedLine {
|
||||||
|
text: String,
|
||||||
|
/// Начальная позиция в символах от начала оригинального текста
|
||||||
|
start_offset: usize,
|
||||||
|
}
|
||||||
|
|
||||||
/// Разбивает текст на строки с учётом максимальной ширины
|
/// Разбивает текст на строки с учётом максимальной ширины
|
||||||
fn wrap_text(text: &str, max_width: usize) -> Vec<String> {
|
/// Возвращает строки с информацией о позициях для корректного применения entities
|
||||||
|
fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec<WrappedLine> {
|
||||||
if max_width == 0 {
|
if max_width == 0 {
|
||||||
return vec![text.to_string()];
|
return vec![WrappedLine {
|
||||||
|
text: text.to_string(),
|
||||||
|
start_offset: 0,
|
||||||
|
}];
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut result = Vec::new();
|
let mut result = Vec::new();
|
||||||
let mut current_line = String::new();
|
let mut current_line = String::new();
|
||||||
let mut current_width = 0;
|
let mut current_width = 0;
|
||||||
|
let mut line_start_offset = 0;
|
||||||
|
|
||||||
for word in text.split_whitespace() {
|
// Разбиваем текст на слова, сохраняя позиции
|
||||||
|
let chars: Vec<char> = text.chars().collect();
|
||||||
|
let mut word_start = 0;
|
||||||
|
let mut in_word = false;
|
||||||
|
|
||||||
|
for (i, ch) in chars.iter().enumerate() {
|
||||||
|
if ch.is_whitespace() {
|
||||||
|
if in_word {
|
||||||
|
// Конец слова
|
||||||
|
let word: String = chars[word_start..i].iter().collect();
|
||||||
|
let word_width = word.chars().count();
|
||||||
|
|
||||||
|
if current_width == 0 {
|
||||||
|
current_line = word;
|
||||||
|
current_width = word_width;
|
||||||
|
line_start_offset = word_start;
|
||||||
|
} else if current_width + 1 + word_width <= max_width {
|
||||||
|
current_line.push(' ');
|
||||||
|
current_line.push_str(&word);
|
||||||
|
current_width += 1 + word_width;
|
||||||
|
} else {
|
||||||
|
result.push(WrappedLine {
|
||||||
|
text: current_line,
|
||||||
|
start_offset: line_start_offset,
|
||||||
|
});
|
||||||
|
current_line = word;
|
||||||
|
current_width = word_width;
|
||||||
|
line_start_offset = word_start;
|
||||||
|
}
|
||||||
|
in_word = false;
|
||||||
|
}
|
||||||
|
} else if !in_word {
|
||||||
|
word_start = i;
|
||||||
|
in_word = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обрабатываем последнее слово
|
||||||
|
if in_word {
|
||||||
|
let word: String = chars[word_start..].iter().collect();
|
||||||
let word_width = word.chars().count();
|
let word_width = word.chars().count();
|
||||||
|
|
||||||
if current_width == 0 {
|
if current_width == 0 {
|
||||||
// Первое слово в строке
|
current_line = word;
|
||||||
current_line = word.to_string();
|
line_start_offset = word_start;
|
||||||
current_width = word_width;
|
|
||||||
} else if current_width + 1 + word_width <= max_width {
|
} else if current_width + 1 + word_width <= max_width {
|
||||||
// Слово помещается
|
|
||||||
current_line.push(' ');
|
current_line.push(' ');
|
||||||
current_line.push_str(word);
|
current_line.push_str(&word);
|
||||||
current_width += 1 + word_width;
|
|
||||||
} else {
|
} else {
|
||||||
// Слово не помещается, начинаем новую строку
|
result.push(WrappedLine {
|
||||||
result.push(current_line);
|
text: current_line,
|
||||||
current_line = word.to_string();
|
start_offset: line_start_offset,
|
||||||
current_width = word_width;
|
});
|
||||||
|
current_line = word;
|
||||||
|
line_start_offset = word_start;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !current_line.is_empty() {
|
if !current_line.is_empty() {
|
||||||
result.push(current_line);
|
result.push(WrappedLine {
|
||||||
|
text: current_line,
|
||||||
|
start_offset: line_start_offset,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if result.is_empty() {
|
if result.is_empty() {
|
||||||
result.push(String::new());
|
result.push(WrappedLine {
|
||||||
|
text: String::new(),
|
||||||
|
start_offset: 0,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Фильтрует и корректирует entities для подстроки
|
||||||
|
fn adjust_entities_for_substring(
|
||||||
|
entities: &[TextEntity],
|
||||||
|
start: usize,
|
||||||
|
length: usize,
|
||||||
|
) -> Vec<TextEntity> {
|
||||||
|
let start = start as i32;
|
||||||
|
let end = start + length as i32;
|
||||||
|
|
||||||
|
entities
|
||||||
|
.iter()
|
||||||
|
.filter_map(|e| {
|
||||||
|
let e_start = e.offset;
|
||||||
|
let e_end = e.offset + e.length;
|
||||||
|
|
||||||
|
// Проверяем пересечение с нашей подстрокой
|
||||||
|
if e_end <= start || e_start >= end {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Вычисляем пересечение
|
||||||
|
let new_start = (e_start - start).max(0);
|
||||||
|
let new_end = (e_end - start).min(length as i32);
|
||||||
|
|
||||||
|
if new_end > new_start {
|
||||||
|
Some(TextEntity {
|
||||||
|
offset: new_start,
|
||||||
|
length: new_end - new_start,
|
||||||
|
r#type: e.r#type.clone(),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn render(f: &mut Frame, area: Rect, app: &App) {
|
pub fn render(f: &mut Frame, area: Rect, app: &App) {
|
||||||
if let Some(chat) = app.get_selected_chat() {
|
if let Some(chat) = app.get_selected_chat() {
|
||||||
// Вычисляем динамическую высоту инпута на основе длины текста
|
// Вычисляем динамическую высоту инпута на основе длины текста
|
||||||
@@ -93,7 +350,12 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) {
|
|||||||
let mut last_day: Option<i64> = None;
|
let mut last_day: Option<i64> = None;
|
||||||
let mut last_sender: Option<(bool, String)> = None; // (is_outgoing, sender_name)
|
let mut last_sender: Option<(bool, String)> = None; // (is_outgoing, sender_name)
|
||||||
|
|
||||||
for msg in &app.current_messages {
|
// ID выбранного сообщения для подсветки
|
||||||
|
let selected_msg_id = app.get_selected_message().map(|m| m.id);
|
||||||
|
|
||||||
|
for msg in &app.td_client.current_chat_messages {
|
||||||
|
// Проверяем, выбрано ли это сообщение
|
||||||
|
let is_selected = selected_msg_id == Some(msg.id);
|
||||||
// Проверяем, нужно ли добавить разделитель даты
|
// Проверяем, нужно ли добавить разделитель даты
|
||||||
let msg_day = get_day(msg.date);
|
let msg_day = get_day(msg.date);
|
||||||
if last_day != Some(msg_day) {
|
if last_day != Some(msg_day) {
|
||||||
@@ -160,64 +422,116 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) {
|
|||||||
// Форматируем время (HH:MM)
|
// Форматируем время (HH:MM)
|
||||||
let time = format_timestamp(msg.date);
|
let time = format_timestamp(msg.date);
|
||||||
|
|
||||||
|
// Цвет сообщения (жёлтый если выбрано)
|
||||||
|
let msg_color = if is_selected {
|
||||||
|
Color::Yellow
|
||||||
|
} else if msg.is_outgoing {
|
||||||
|
Color::Green
|
||||||
|
} else {
|
||||||
|
Color::White
|
||||||
|
};
|
||||||
|
|
||||||
|
// Маркер выбора
|
||||||
|
let selection_marker = if is_selected { "▶ " } else { "" };
|
||||||
|
let marker_len = selection_marker.chars().count();
|
||||||
|
|
||||||
if msg.is_outgoing {
|
if msg.is_outgoing {
|
||||||
// Исходящие: справа, формат "текст (HH:MM ✓✓)"
|
// Исходящие: справа, формат "текст (HH:MM ✎ ✓✓)"
|
||||||
let read_mark = if msg.is_read { "✓✓" } else { "✓" };
|
let read_mark = if msg.is_read { "✓✓" } else { "✓" };
|
||||||
let time_mark = format!("({} {})", time, read_mark);
|
let edit_mark = if msg.edit_date > 0 { "✎ " } else { "" };
|
||||||
|
let time_mark = format!("({} {}{})", time, edit_mark, read_mark);
|
||||||
let time_mark_len = time_mark.chars().count() + 1; // +1 для пробела
|
let time_mark_len = time_mark.chars().count() + 1; // +1 для пробела
|
||||||
|
|
||||||
// Максимальная ширина для текста сообщения (оставляем место для time_mark)
|
// Максимальная ширина для текста сообщения (оставляем место для time_mark и маркера)
|
||||||
let max_msg_width = content_width.saturating_sub(time_mark_len + 2);
|
let max_msg_width = content_width.saturating_sub(time_mark_len + marker_len + 2);
|
||||||
|
|
||||||
let wrapped_lines = wrap_text(&msg.content, max_msg_width);
|
let wrapped_lines = wrap_text_with_offsets(&msg.content, max_msg_width);
|
||||||
let total_wrapped = wrapped_lines.len();
|
let total_wrapped = wrapped_lines.len();
|
||||||
|
|
||||||
for (i, line_text) in wrapped_lines.into_iter().enumerate() {
|
for (i, wrapped) in wrapped_lines.into_iter().enumerate() {
|
||||||
let is_last_line = i == total_wrapped - 1;
|
let is_last_line = i == total_wrapped - 1;
|
||||||
let line_len = line_text.chars().count();
|
let line_len = wrapped.text.chars().count();
|
||||||
|
|
||||||
|
// Получаем entities для этой строки
|
||||||
|
let line_entities = adjust_entities_for_substring(
|
||||||
|
&msg.entities,
|
||||||
|
wrapped.start_offset,
|
||||||
|
line_len,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Форматируем текст с entities
|
||||||
|
let formatted_spans = format_text_with_entities(
|
||||||
|
&wrapped.text,
|
||||||
|
&line_entities,
|
||||||
|
msg_color,
|
||||||
|
);
|
||||||
|
|
||||||
if is_last_line {
|
if is_last_line {
|
||||||
// Последняя строка — добавляем time_mark
|
// Последняя строка — добавляем time_mark
|
||||||
let full_len = line_len + time_mark_len;
|
let full_len = line_len + time_mark_len + marker_len;
|
||||||
let padding = content_width.saturating_sub(full_len + 1);
|
let padding = content_width.saturating_sub(full_len + 1);
|
||||||
lines.push(Line::from(vec![
|
let mut line_spans = vec![Span::raw(" ".repeat(padding))];
|
||||||
Span::raw(" ".repeat(padding)),
|
if is_selected {
|
||||||
Span::styled(line_text, Style::default().fg(Color::Green)),
|
line_spans.push(Span::styled(selection_marker, Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)));
|
||||||
Span::styled(format!(" {}", time_mark), Style::default().fg(Color::Gray)),
|
}
|
||||||
]));
|
line_spans.extend(formatted_spans);
|
||||||
|
line_spans.push(Span::styled(format!(" {}", time_mark), Style::default().fg(Color::Gray)));
|
||||||
|
lines.push(Line::from(line_spans));
|
||||||
} else {
|
} else {
|
||||||
// Промежуточные строки — просто текст справа
|
// Промежуточные строки — просто текст справа
|
||||||
let padding = content_width.saturating_sub(line_len + 1);
|
let padding = content_width.saturating_sub(line_len + marker_len + 1);
|
||||||
lines.push(Line::from(vec![
|
let mut line_spans = vec![Span::raw(" ".repeat(padding))];
|
||||||
Span::raw(" ".repeat(padding)),
|
if i == 0 && is_selected {
|
||||||
Span::styled(line_text, Style::default().fg(Color::Green)),
|
line_spans.push(Span::styled(selection_marker, Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)));
|
||||||
]));
|
}
|
||||||
|
line_spans.extend(formatted_spans);
|
||||||
|
lines.push(Line::from(line_spans));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Входящие: слева, формат "(HH:MM) текст"
|
// Входящие: слева, формат "(HH:MM ✎) текст"
|
||||||
let time_str = format!("({})", time);
|
let edit_mark = if msg.edit_date > 0 { " ✎" } else { "" };
|
||||||
|
let time_str = format!("({}{})", time, edit_mark);
|
||||||
let time_prefix_len = time_str.chars().count() + 2; // " (HH:MM) "
|
let time_prefix_len = time_str.chars().count() + 2; // " (HH:MM) "
|
||||||
|
|
||||||
// Максимальная ширина для текста
|
// Максимальная ширина для текста
|
||||||
let max_msg_width = content_width.saturating_sub(time_prefix_len + 1);
|
let max_msg_width = content_width.saturating_sub(time_prefix_len + 1);
|
||||||
|
|
||||||
let wrapped_lines = wrap_text(&msg.content, max_msg_width);
|
let wrapped_lines = wrap_text_with_offsets(&msg.content, max_msg_width);
|
||||||
|
|
||||||
|
for (i, wrapped) in wrapped_lines.into_iter().enumerate() {
|
||||||
|
let line_len = wrapped.text.chars().count();
|
||||||
|
|
||||||
|
// Получаем entities для этой строки
|
||||||
|
let line_entities = adjust_entities_for_substring(
|
||||||
|
&msg.entities,
|
||||||
|
wrapped.start_offset,
|
||||||
|
line_len,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Форматируем текст с entities
|
||||||
|
let formatted_spans = format_text_with_entities(
|
||||||
|
&wrapped.text,
|
||||||
|
&line_entities,
|
||||||
|
msg_color,
|
||||||
|
);
|
||||||
|
|
||||||
for (i, line_text) in wrapped_lines.into_iter().enumerate() {
|
|
||||||
if i == 0 {
|
if i == 0 {
|
||||||
// Первая строка — с временем
|
// Первая строка — с временем и маркером выбора
|
||||||
lines.push(Line::from(vec![
|
let mut line_spans = vec![];
|
||||||
Span::styled(format!(" {}", time_str), Style::default().fg(Color::Gray)),
|
if is_selected {
|
||||||
Span::raw(format!(" {}", line_text)),
|
line_spans.push(Span::styled(selection_marker, Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)));
|
||||||
]));
|
}
|
||||||
|
line_spans.push(Span::styled(format!(" {}", time_str), Style::default().fg(Color::Gray)));
|
||||||
|
line_spans.push(Span::raw(" "));
|
||||||
|
line_spans.extend(formatted_spans);
|
||||||
|
lines.push(Line::from(line_spans));
|
||||||
} else {
|
} else {
|
||||||
// Последующие строки — с отступом
|
// Последующие строки — с отступом
|
||||||
let indent = " ".repeat(time_prefix_len);
|
let indent = " ".repeat(time_prefix_len + marker_len);
|
||||||
lines.push(Line::from(vec![
|
let mut line_spans = vec![Span::raw(indent)];
|
||||||
Span::raw(indent),
|
line_spans.extend(formatted_spans);
|
||||||
Span::raw(line_text),
|
lines.push(Line::from(line_spans));
|
||||||
]));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -247,20 +561,63 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) {
|
|||||||
.scroll((scroll_offset, 0));
|
.scroll((scroll_offset, 0));
|
||||||
f.render_widget(messages_widget, message_chunks[1]);
|
f.render_widget(messages_widget, message_chunks[1]);
|
||||||
|
|
||||||
// Input box с wrap для длинного текста
|
// Input box с wrap для длинного текста и блочным курсором
|
||||||
let input_text = if app.message_input.is_empty() {
|
let (input_line, input_title) = if app.is_selecting_message() {
|
||||||
"> Введите сообщение...".to_string()
|
// Режим выбора сообщения - подсказка зависит от возможностей
|
||||||
|
let selected_msg = app.get_selected_message();
|
||||||
|
let can_edit = selected_msg.map(|m| m.can_be_edited && m.is_outgoing).unwrap_or(false);
|
||||||
|
let can_delete = selected_msg.map(|m| m.can_be_deleted_only_for_self || m.can_be_deleted_for_all_users).unwrap_or(false);
|
||||||
|
|
||||||
|
let hint = match (can_edit, can_delete) {
|
||||||
|
(true, true) => "↑↓ выбрать · Enter редакт. · d удалить · Esc отмена",
|
||||||
|
(true, false) => "↑↓ выбрать · Enter редакт. · Esc отмена",
|
||||||
|
(false, true) => "↑↓ выбрать · d удалить · Esc отмена",
|
||||||
|
(false, false) => "↑↓ выбрать · Esc отмена",
|
||||||
|
};
|
||||||
|
(Line::from(Span::styled(hint, Style::default().fg(Color::Cyan))), " Выбор сообщения ")
|
||||||
|
} else if app.is_editing() {
|
||||||
|
// Режим редактирования
|
||||||
|
if app.message_input.is_empty() {
|
||||||
|
// Пустой инпут - показываем курсор и placeholder
|
||||||
|
let line = Line::from(vec![
|
||||||
|
Span::raw("✏ "),
|
||||||
|
Span::styled("█", Style::default().fg(Color::Magenta)),
|
||||||
|
Span::styled(" Введите новый текст...", Style::default().fg(Color::Gray)),
|
||||||
|
]);
|
||||||
|
(line, " Редактирование (Esc отмена) ")
|
||||||
|
} else {
|
||||||
|
// Текст с курсором
|
||||||
|
let line = render_input_with_cursor("✏ ", &app.message_input, app.cursor_position, Color::Magenta);
|
||||||
|
(line, " Редактирование (Esc отмена) ")
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
format!("> {}", app.message_input)
|
// Обычный режим
|
||||||
|
if app.message_input.is_empty() {
|
||||||
|
// Пустой инпут - показываем курсор и placeholder
|
||||||
|
let line = Line::from(vec![
|
||||||
|
Span::raw("> "),
|
||||||
|
Span::styled("█", Style::default().fg(Color::Yellow)),
|
||||||
|
Span::styled(" Введите сообщение...", Style::default().fg(Color::Gray)),
|
||||||
|
]);
|
||||||
|
(line, "")
|
||||||
|
} else {
|
||||||
|
// Текст с курсором
|
||||||
|
let line = render_input_with_cursor("> ", &app.message_input, app.cursor_position, Color::Yellow);
|
||||||
|
(line, "")
|
||||||
|
}
|
||||||
};
|
};
|
||||||
let input_style = if app.message_input.is_empty() {
|
|
||||||
Style::default().fg(Color::Gray)
|
let input_block = if input_title.is_empty() {
|
||||||
|
Block::default().borders(Borders::ALL)
|
||||||
} else {
|
} else {
|
||||||
Style::default().fg(Color::Yellow)
|
Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.title(input_title)
|
||||||
|
.title_style(Style::default().fg(Color::Magenta).add_modifier(Modifier::BOLD))
|
||||||
};
|
};
|
||||||
let input = Paragraph::new(input_text)
|
|
||||||
.block(Block::default().borders(Borders::ALL))
|
let input = Paragraph::new(input_line)
|
||||||
.style(input_style)
|
.block(input_block)
|
||||||
.wrap(ratatui::widgets::Wrap { trim: false });
|
.wrap(ratatui::widgets::Wrap { trim: false });
|
||||||
f.render_widget(input, message_chunks[2]);
|
f.render_widget(input, message_chunks[2]);
|
||||||
} else {
|
} else {
|
||||||
@@ -270,4 +627,56 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) {
|
|||||||
.alignment(Alignment::Center);
|
.alignment(Alignment::Center);
|
||||||
f.render_widget(empty, area);
|
f.render_widget(empty, area);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Модалка подтверждения удаления
|
||||||
|
if app.is_confirm_delete_shown() {
|
||||||
|
render_delete_confirm_modal(f, area);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Рендерит модалку подтверждения удаления
|
||||||
|
fn render_delete_confirm_modal(f: &mut Frame, area: Rect) {
|
||||||
|
use ratatui::widgets::Clear;
|
||||||
|
|
||||||
|
// Размеры модалки
|
||||||
|
let modal_width = 40u16;
|
||||||
|
let modal_height = 7u16;
|
||||||
|
|
||||||
|
// Центрируем модалку
|
||||||
|
let x = area.x + (area.width.saturating_sub(modal_width)) / 2;
|
||||||
|
let y = area.y + (area.height.saturating_sub(modal_height)) / 2;
|
||||||
|
|
||||||
|
let modal_area = Rect::new(x, y, modal_width.min(area.width), modal_height.min(area.height));
|
||||||
|
|
||||||
|
// Очищаем область под модалкой
|
||||||
|
f.render_widget(Clear, modal_area);
|
||||||
|
|
||||||
|
// Содержимое модалки
|
||||||
|
let text = vec![
|
||||||
|
Line::from(""),
|
||||||
|
Line::from(Span::styled(
|
||||||
|
"Удалить сообщение?",
|
||||||
|
Style::default().fg(Color::White).add_modifier(Modifier::BOLD),
|
||||||
|
)),
|
||||||
|
Line::from(""),
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled(" [y/Enter] ", Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)),
|
||||||
|
Span::raw("Да"),
|
||||||
|
Span::raw(" "),
|
||||||
|
Span::styled(" [n/Esc] ", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)),
|
||||||
|
Span::raw("Нет"),
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
|
||||||
|
let modal = Paragraph::new(text)
|
||||||
|
.block(
|
||||||
|
Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_style(Style::default().fg(Color::Red))
|
||||||
|
.title(" Подтверждение ")
|
||||||
|
.title_style(Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)),
|
||||||
|
)
|
||||||
|
.alignment(Alignment::Center);
|
||||||
|
|
||||||
|
f.render_widget(modal, modal_area);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,10 +11,10 @@ use ratatui::style::{Color, Modifier, Style};
|
|||||||
use ratatui::widgets::Paragraph;
|
use ratatui::widgets::Paragraph;
|
||||||
use crate::app::{App, AppScreen};
|
use crate::app::{App, AppScreen};
|
||||||
|
|
||||||
/// Минимальная ширина терминала
|
|
||||||
const MIN_WIDTH: u16 = 80;
|
|
||||||
/// Минимальная высота терминала
|
/// Минимальная высота терминала
|
||||||
const MIN_HEIGHT: u16 = 20;
|
const MIN_HEIGHT: u16 = 10;
|
||||||
|
/// Минимальная ширина терминала
|
||||||
|
const MIN_WIDTH: u16 = 40;
|
||||||
|
|
||||||
pub fn render(f: &mut Frame, app: &mut App) {
|
pub fn render(f: &mut Frame, app: &mut App) {
|
||||||
let area = f.area();
|
let area = f.area();
|
||||||
@@ -34,7 +34,7 @@ pub fn render(f: &mut Frame, app: &mut App) {
|
|||||||
|
|
||||||
fn render_size_warning(f: &mut Frame, width: u16, height: u16) {
|
fn render_size_warning(f: &mut Frame, width: u16, height: u16) {
|
||||||
let message = format!(
|
let message = format!(
|
||||||
"Терминал слишком мал: {}x{}\n\nМинимум: {}x{}",
|
"{}x{}\nМинимум: {}x{}",
|
||||||
width, height, MIN_WIDTH, MIN_HEIGHT
|
width, height, MIN_WIDTH, MIN_HEIGHT
|
||||||
);
|
);
|
||||||
let warning = Paragraph::new(message)
|
let warning = Paragraph::new(message)
|
||||||
|
|||||||
Reference in New Issue
Block a user