Compare commits
4 Commits
e4dabbe3ac
...
dc76e01f3c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc76e01f3c | ||
|
|
81dc5b9007 | ||
|
|
4d5625f950 | ||
|
|
46720b3584 |
22
CONTEXT.md
22
CONTEXT.md
@@ -1,6 +1,6 @@
|
|||||||
# Текущий контекст проекта
|
# Текущий контекст проекта
|
||||||
|
|
||||||
## Статус: Фаза 8 в процессе — Дополнительные фичи
|
## Статус: Фаза 9 — Расширенные возможности
|
||||||
|
|
||||||
### Что сделано
|
### Что сделано
|
||||||
|
|
||||||
@@ -161,18 +161,16 @@ API_ID=your_api_id
|
|||||||
API_HASH=your_api_hash
|
API_HASH=your_api_hash
|
||||||
```
|
```
|
||||||
|
|
||||||
## Что НЕ сделано / TODO (Фаза 8)
|
## Что НЕ сделано / TODO (Фаза 9)
|
||||||
|
|
||||||
- [x] Удалить дублирование current_messages между App и TdClient
|
- [ ] Typing indicator ("печатает...")
|
||||||
- [x] Использовать единый источник данных для сообщений (td_client.current_chat_messages)
|
- [ ] Закреплённые сообщения (Pinned) — отображение вверху чата
|
||||||
- [x] Реализовать LRU-кэш для user_names/user_statuses/user_usernames
|
- [ ] Поиск по сообщениям в чате (Ctrl+F)
|
||||||
- [x] Lazy loading для имён пользователей (батчевая загрузка последних 5 за цикл, лимит очереди 50)
|
- [ ] Черновики — сохранение текста при переключении чатов
|
||||||
- [x] Лимиты памяти: сообщения (500), чаты (200), chat_user_ids (500)
|
- [ ] Профиль пользователя/чата (хоткей `i`)
|
||||||
- [x] Markdown форматирование в сообщениях
|
- [ ] Копирование сообщений в буфер обмена (`y` в режиме выбора)
|
||||||
- [x] Редактирование сообщений
|
- [ ] Реакции — просмотр и добавление
|
||||||
- [x] Удаление сообщений
|
- [ ] Конфигурационный файл (~/.config/tele-tui/config.toml)
|
||||||
- [x] Reply на сообщения
|
|
||||||
- [x] Forward сообщений
|
|
||||||
|
|
||||||
## Известные проблемы
|
## Известные проблемы
|
||||||
|
|
||||||
|
|||||||
34
ROADMAP.md
34
ROADMAP.md
@@ -79,7 +79,7 @@
|
|||||||
- MAX_CHAT_USER_IDS = 500
|
- MAX_CHAT_USER_IDS = 500
|
||||||
- MAX_USER_CACHE_SIZE = 500 (LRU)
|
- MAX_USER_CACHE_SIZE = 500 (LRU)
|
||||||
|
|
||||||
## Фаза 8: Дополнительные фичи [IN PROGRESS]
|
## Фаза 8: Дополнительные фичи [DONE]
|
||||||
|
|
||||||
- [x] Markdown форматирование в сообщениях
|
- [x] Markdown форматирование в сообщениях
|
||||||
- Bold, Italic, Underline, Strikethrough
|
- Bold, Italic, Underline, Strikethrough
|
||||||
@@ -111,3 +111,35 @@
|
|||||||
- Выбор чата стрелками, Enter для пересылки
|
- Выбор чата стрелками, Enter для пересылки
|
||||||
- Esc для отмены
|
- Esc для отмены
|
||||||
- Отображение "↪ Переслано от" для пересланных сообщений
|
- Отображение "↪ Переслано от" для пересланных сообщений
|
||||||
|
|
||||||
|
## Фаза 9: Расширенные возможности [TODO]
|
||||||
|
|
||||||
|
- [ ] Typing indicator ("печатает...")
|
||||||
|
- Показывать когда собеседник печатает
|
||||||
|
- Отправлять свой статус печати при наборе текста
|
||||||
|
- [ ] Закреплённые сообщения (Pinned)
|
||||||
|
- Отображать pinned message вверху открытого чата
|
||||||
|
- Клик/хоткей для перехода к закреплённому сообщению
|
||||||
|
- [ ] Поиск по сообщениям в чате
|
||||||
|
- `Ctrl+F` — поиск текста внутри открытого чата
|
||||||
|
- Навигация по результатам (n/N или стрелки)
|
||||||
|
- Подсветка найденных совпадений
|
||||||
|
- [ ] Черновики
|
||||||
|
- Сохранять набранный текст при переключении между чатами
|
||||||
|
- Индикатор черновика в списке чатов
|
||||||
|
- Восстановление текста при возврате в чат
|
||||||
|
- [ ] Профиль пользователя/чата
|
||||||
|
- `i` — открыть информацию о чате/собеседнике
|
||||||
|
- Для личных чатов: имя, username, телефон, био
|
||||||
|
- Для групп: название, описание, количество участников
|
||||||
|
- [ ] Копирование сообщений
|
||||||
|
- `y` / `н` в режиме выбора — скопировать текст в системный буфер обмена
|
||||||
|
- Использовать clipboard crate для кроссплатформенности
|
||||||
|
- [ ] Реакции
|
||||||
|
- Отображение реакций под сообщениями
|
||||||
|
- `e` в режиме выбора — добавить реакцию (emoji picker)
|
||||||
|
- Список доступных реакций чата
|
||||||
|
- [ ] Конфигурационный файл
|
||||||
|
- `~/.config/tele-tui/config.toml`
|
||||||
|
- Настройки: цветовая схема, часовой пояс, хоткеи
|
||||||
|
- Загрузка конфига при старте
|
||||||
|
|||||||
141
src/app/mod.rs
141
src/app/mod.rs
@@ -47,6 +47,25 @@ pub struct App {
|
|||||||
pub forwarding_message_id: Option<i64>,
|
pub forwarding_message_id: Option<i64>,
|
||||||
/// Режим выбора чата для пересылки
|
/// Режим выбора чата для пересылки
|
||||||
pub is_selecting_forward_chat: bool,
|
pub is_selecting_forward_chat: bool,
|
||||||
|
// Typing indicator
|
||||||
|
/// Время последней отправки typing status (для throttling)
|
||||||
|
pub last_typing_sent: Option<std::time::Instant>,
|
||||||
|
// Pinned messages mode
|
||||||
|
/// Режим просмотра закреплённых сообщений
|
||||||
|
pub is_pinned_mode: bool,
|
||||||
|
/// Список закреплённых сообщений
|
||||||
|
pub pinned_messages: Vec<crate::tdlib::client::MessageInfo>,
|
||||||
|
/// Индекс выбранного pinned сообщения
|
||||||
|
pub selected_pinned_index: usize,
|
||||||
|
// Message search mode
|
||||||
|
/// Режим поиска по сообщениям
|
||||||
|
pub is_message_search_mode: bool,
|
||||||
|
/// Поисковый запрос
|
||||||
|
pub message_search_query: String,
|
||||||
|
/// Результаты поиска
|
||||||
|
pub message_search_results: Vec<crate::tdlib::client::MessageInfo>,
|
||||||
|
/// Индекс выбранного результата
|
||||||
|
pub selected_search_result_index: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl App {
|
impl App {
|
||||||
@@ -79,6 +98,14 @@ impl App {
|
|||||||
replying_to_message_id: None,
|
replying_to_message_id: None,
|
||||||
forwarding_message_id: None,
|
forwarding_message_id: None,
|
||||||
is_selecting_forward_chat: false,
|
is_selecting_forward_chat: false,
|
||||||
|
last_typing_sent: None,
|
||||||
|
is_pinned_mode: false,
|
||||||
|
pinned_messages: Vec::new(),
|
||||||
|
selected_pinned_index: 0,
|
||||||
|
is_message_search_mode: false,
|
||||||
|
message_search_query: String::new(),
|
||||||
|
message_search_results: Vec::new(),
|
||||||
|
selected_search_result_index: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,9 +162,21 @@ impl App {
|
|||||||
self.editing_message_id = None;
|
self.editing_message_id = None;
|
||||||
self.selected_message_index = None;
|
self.selected_message_index = None;
|
||||||
self.replying_to_message_id = None;
|
self.replying_to_message_id = None;
|
||||||
|
self.last_typing_sent = None;
|
||||||
|
// Сбрасываем pinned режим
|
||||||
|
self.is_pinned_mode = false;
|
||||||
|
self.pinned_messages.clear();
|
||||||
|
self.selected_pinned_index = 0;
|
||||||
// Очищаем данные в 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();
|
||||||
|
self.td_client.typing_status = None;
|
||||||
|
self.td_client.current_pinned_message = None;
|
||||||
|
// Сбрасываем режим поиска
|
||||||
|
self.is_message_search_mode = false;
|
||||||
|
self.message_search_query.clear();
|
||||||
|
self.message_search_results.clear();
|
||||||
|
self.selected_search_result_index = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Начать выбор сообщения для редактирования (при стрелке вверх в пустом инпуте)
|
/// Начать выбор сообщения для редактирования (при стрелке вверх в пустом инпуте)
|
||||||
@@ -376,4 +415,106 @@ impl App {
|
|||||||
self.td_client.current_chat_messages.iter().find(|m| m.id == id)
|
self.td_client.current_chat_messages.iter().find(|m| m.id == id)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// === Pinned messages mode ===
|
||||||
|
|
||||||
|
/// Проверка режима pinned
|
||||||
|
pub fn is_pinned_mode(&self) -> bool {
|
||||||
|
self.is_pinned_mode
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Войти в режим pinned (вызывается после загрузки pinned сообщений)
|
||||||
|
pub fn enter_pinned_mode(&mut self, messages: Vec<crate::tdlib::client::MessageInfo>) {
|
||||||
|
if !messages.is_empty() {
|
||||||
|
self.pinned_messages = messages;
|
||||||
|
self.selected_pinned_index = 0;
|
||||||
|
self.is_pinned_mode = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Выйти из режима pinned
|
||||||
|
pub fn exit_pinned_mode(&mut self) {
|
||||||
|
self.is_pinned_mode = false;
|
||||||
|
self.pinned_messages.clear();
|
||||||
|
self.selected_pinned_index = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Выбрать предыдущий pinned (вверх = более старый)
|
||||||
|
pub fn select_previous_pinned(&mut self) {
|
||||||
|
if !self.pinned_messages.is_empty() && self.selected_pinned_index < self.pinned_messages.len() - 1 {
|
||||||
|
self.selected_pinned_index += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Выбрать следующий pinned (вниз = более новый)
|
||||||
|
pub fn select_next_pinned(&mut self) {
|
||||||
|
if self.selected_pinned_index > 0 {
|
||||||
|
self.selected_pinned_index -= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Получить текущее выбранное pinned сообщение
|
||||||
|
pub fn get_selected_pinned(&self) -> Option<&crate::tdlib::client::MessageInfo> {
|
||||||
|
self.pinned_messages.get(self.selected_pinned_index)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Получить ID текущего pinned для перехода в историю
|
||||||
|
pub fn get_selected_pinned_id(&self) -> Option<i64> {
|
||||||
|
self.get_selected_pinned().map(|m| m.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Message Search Mode ===
|
||||||
|
|
||||||
|
/// Проверить, активен ли режим поиска по сообщениям
|
||||||
|
pub fn is_message_search_mode(&self) -> bool {
|
||||||
|
self.is_message_search_mode
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Войти в режим поиска по сообщениям
|
||||||
|
pub fn enter_message_search_mode(&mut self) {
|
||||||
|
self.is_message_search_mode = true;
|
||||||
|
self.message_search_query.clear();
|
||||||
|
self.message_search_results.clear();
|
||||||
|
self.selected_search_result_index = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Выйти из режима поиска
|
||||||
|
pub fn exit_message_search_mode(&mut self) {
|
||||||
|
self.is_message_search_mode = false;
|
||||||
|
self.message_search_query.clear();
|
||||||
|
self.message_search_results.clear();
|
||||||
|
self.selected_search_result_index = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Установить результаты поиска
|
||||||
|
pub fn set_search_results(&mut self, results: Vec<crate::tdlib::client::MessageInfo>) {
|
||||||
|
self.message_search_results = results;
|
||||||
|
self.selected_search_result_index = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Выбрать предыдущий результат (вверх)
|
||||||
|
pub fn select_previous_search_result(&mut self) {
|
||||||
|
if self.selected_search_result_index > 0 {
|
||||||
|
self.selected_search_result_index -= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Выбрать следующий результат (вниз)
|
||||||
|
pub fn select_next_search_result(&mut self) {
|
||||||
|
if !self.message_search_results.is_empty()
|
||||||
|
&& self.selected_search_result_index < self.message_search_results.len() - 1
|
||||||
|
{
|
||||||
|
self.selected_search_result_index += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Получить текущий выбранный результат
|
||||||
|
pub fn get_selected_search_result(&self) -> Option<&crate::tdlib::client::MessageInfo> {
|
||||||
|
self.message_search_results.get(self.selected_search_result_index)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Получить ID выбранного результата для перехода
|
||||||
|
pub fn get_selected_search_result_id(&self) -> Option<i64> {
|
||||||
|
self.get_selected_search_result().map(|m| m.id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||||
use std::time::Duration;
|
use std::time::{Duration, Instant};
|
||||||
use tokio::time::timeout;
|
use tokio::time::timeout;
|
||||||
use crate::app::App;
|
use crate::app::App;
|
||||||
|
use crate::tdlib::ChatAction;
|
||||||
|
|
||||||
pub async fn handle(app: &mut App, key: KeyEvent) {
|
pub async fn handle(app: &mut App, key: KeyEvent) {
|
||||||
let has_ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
|
let has_ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
|
||||||
@@ -21,9 +22,135 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
KeyCode::Char('p') if has_ctrl => {
|
||||||
|
// Ctrl+P - режим просмотра закреплённых сообщений
|
||||||
|
if app.selected_chat_id.is_some() && !app.is_pinned_mode() {
|
||||||
|
if let Some(chat_id) = app.get_selected_chat_id() {
|
||||||
|
app.status_message = Some("Загрузка закреплённых...".to_string());
|
||||||
|
match timeout(Duration::from_secs(5), app.td_client.get_pinned_messages(chat_id)).await {
|
||||||
|
Ok(Ok(messages)) => {
|
||||||
|
if messages.is_empty() {
|
||||||
|
app.status_message = Some("Нет закреплённых сообщений".to_string());
|
||||||
|
} else {
|
||||||
|
app.enter_pinned_mode(messages);
|
||||||
|
app.status_message = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Err(e)) => {
|
||||||
|
app.error_message = Some(e);
|
||||||
|
app.status_message = None;
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
app.error_message = Some("Таймаут загрузки".to_string());
|
||||||
|
app.status_message = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
KeyCode::Char('f') if has_ctrl => {
|
||||||
|
// Ctrl+F - поиск по сообщениям в открытом чате
|
||||||
|
if app.selected_chat_id.is_some() && !app.is_pinned_mode() && !app.is_message_search_mode() {
|
||||||
|
app.enter_message_search_mode();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Режим поиска по сообщениям
|
||||||
|
if app.is_message_search_mode() {
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Esc => {
|
||||||
|
app.exit_message_search_mode();
|
||||||
|
}
|
||||||
|
KeyCode::Up | KeyCode::Char('N') => {
|
||||||
|
app.select_previous_search_result();
|
||||||
|
}
|
||||||
|
KeyCode::Down | KeyCode::Char('n') => {
|
||||||
|
app.select_next_search_result();
|
||||||
|
}
|
||||||
|
KeyCode::Enter => {
|
||||||
|
// Перейти к выбранному сообщению
|
||||||
|
if let Some(msg_id) = app.get_selected_search_result_id() {
|
||||||
|
let msg_index = app.td_client.current_chat_messages
|
||||||
|
.iter()
|
||||||
|
.position(|m| m.id == msg_id);
|
||||||
|
|
||||||
|
if let Some(idx) = msg_index {
|
||||||
|
let total = app.td_client.current_chat_messages.len();
|
||||||
|
app.message_scroll_offset = total.saturating_sub(idx + 5);
|
||||||
|
}
|
||||||
|
app.exit_message_search_mode();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Backspace => {
|
||||||
|
app.message_search_query.pop();
|
||||||
|
// Выполняем поиск при изменении запроса
|
||||||
|
if let Some(chat_id) = app.get_selected_chat_id() {
|
||||||
|
if !app.message_search_query.is_empty() {
|
||||||
|
if let Ok(Ok(results)) = timeout(
|
||||||
|
Duration::from_secs(3),
|
||||||
|
app.td_client.search_messages(chat_id, &app.message_search_query)
|
||||||
|
).await {
|
||||||
|
app.set_search_results(results);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
app.set_search_results(Vec::new());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Char(c) => {
|
||||||
|
app.message_search_query.push(c);
|
||||||
|
// Выполняем поиск при изменении запроса
|
||||||
|
if let Some(chat_id) = app.get_selected_chat_id() {
|
||||||
|
if let Ok(Ok(results)) = timeout(
|
||||||
|
Duration::from_secs(3),
|
||||||
|
app.td_client.search_messages(chat_id, &app.message_search_query)
|
||||||
|
).await {
|
||||||
|
app.set_search_results(results);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Режим просмотра закреплённых сообщений
|
||||||
|
if app.is_pinned_mode() {
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Esc => {
|
||||||
|
app.exit_pinned_mode();
|
||||||
|
}
|
||||||
|
KeyCode::Up => {
|
||||||
|
app.select_previous_pinned();
|
||||||
|
}
|
||||||
|
KeyCode::Down => {
|
||||||
|
app.select_next_pinned();
|
||||||
|
}
|
||||||
|
KeyCode::Enter => {
|
||||||
|
// Перейти к сообщению в истории
|
||||||
|
if let Some(msg_id) = app.get_selected_pinned_id() {
|
||||||
|
// Ищем индекс сообщения в текущей истории
|
||||||
|
let msg_index = app.td_client.current_chat_messages
|
||||||
|
.iter()
|
||||||
|
.position(|m| m.id == msg_id);
|
||||||
|
|
||||||
|
if let Some(idx) = msg_index {
|
||||||
|
// Вычисляем scroll offset чтобы показать сообщение
|
||||||
|
let total = app.td_client.current_chat_messages.len();
|
||||||
|
app.message_scroll_offset = total.saturating_sub(idx + 5);
|
||||||
|
}
|
||||||
|
app.exit_pinned_mode();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Модалка подтверждения удаления
|
// Модалка подтверждения удаления
|
||||||
if app.is_confirm_delete_shown() {
|
if app.is_confirm_delete_shown() {
|
||||||
match key.code {
|
match key.code {
|
||||||
@@ -128,6 +255,8 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
|
|||||||
Ok(Ok(_)) => {
|
Ok(Ok(_)) => {
|
||||||
// Загружаем недостающие reply info
|
// Загружаем недостающие reply info
|
||||||
let _ = timeout(Duration::from_secs(5), app.td_client.fetch_missing_reply_info()).await;
|
let _ = timeout(Duration::from_secs(5), app.td_client.fetch_missing_reply_info()).await;
|
||||||
|
// Загружаем последнее закреплённое сообщение
|
||||||
|
let _ = timeout(Duration::from_secs(2), app.td_client.load_current_pinned_message(chat_id)).await;
|
||||||
app.status_message = None;
|
app.status_message = None;
|
||||||
}
|
}
|
||||||
Ok(Err(e)) => {
|
Ok(Err(e)) => {
|
||||||
@@ -220,6 +349,10 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
|
|||||||
app.message_input.clear();
|
app.message_input.clear();
|
||||||
app.cursor_position = 0;
|
app.cursor_position = 0;
|
||||||
app.replying_to_message_id = None;
|
app.replying_to_message_id = None;
|
||||||
|
app.last_typing_sent = None;
|
||||||
|
|
||||||
|
// Отменяем typing status
|
||||||
|
app.td_client.send_chat_action(chat_id, ChatAction::Cancel).await;
|
||||||
|
|
||||||
match timeout(Duration::from_secs(5), app.td_client.send_message(chat_id, text, reply_to_id, reply_info)).await {
|
match timeout(Duration::from_secs(5), app.td_client.send_message(chat_id, text, reply_to_id, reply_info)).await {
|
||||||
Ok(Ok(sent_msg)) => {
|
Ok(Ok(sent_msg)) => {
|
||||||
@@ -251,6 +384,8 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
|
|||||||
Ok(Ok(_)) => {
|
Ok(Ok(_)) => {
|
||||||
// Загружаем недостающие reply info
|
// Загружаем недостающие reply info
|
||||||
let _ = timeout(Duration::from_secs(5), app.td_client.fetch_missing_reply_info()).await;
|
let _ = timeout(Duration::from_secs(5), app.td_client.fetch_missing_reply_info()).await;
|
||||||
|
// Загружаем последнее закреплённое сообщение
|
||||||
|
let _ = timeout(Duration::from_secs(2), app.td_client.load_current_pinned_message(chat_id)).await;
|
||||||
app.status_message = None;
|
app.status_message = None;
|
||||||
}
|
}
|
||||||
Ok(Err(e)) => {
|
Ok(Err(e)) => {
|
||||||
@@ -363,6 +498,17 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
|
|||||||
}
|
}
|
||||||
app.message_input = new_input;
|
app.message_input = new_input;
|
||||||
app.cursor_position += 1;
|
app.cursor_position += 1;
|
||||||
|
|
||||||
|
// Отправляем typing status с throttling (не чаще 1 раза в 5 сек)
|
||||||
|
let should_send_typing = app.last_typing_sent
|
||||||
|
.map(|t| t.elapsed().as_secs() >= 5)
|
||||||
|
.unwrap_or(true);
|
||||||
|
if should_send_typing {
|
||||||
|
if let Some(chat_id) = app.get_selected_chat_id() {
|
||||||
|
app.td_client.send_chat_action(chat_id, ChatAction::Typing).await;
|
||||||
|
app.last_typing_sent = Some(Instant::now());
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
KeyCode::Left => {
|
KeyCode::Left => {
|
||||||
// Курсор влево
|
// Курсор влево
|
||||||
|
|||||||
@@ -119,6 +119,11 @@ async fn run_app<B: ratatui::backend::Backend>(
|
|||||||
app.needs_redraw = true;
|
app.needs_redraw = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Очищаем устаревший typing status
|
||||||
|
if app.td_client.clear_stale_typing_status() {
|
||||||
|
app.needs_redraw = true;
|
||||||
|
}
|
||||||
|
|
||||||
// Обрабатываем очередь сообщений для отметки как прочитанных
|
// Обрабатываем очередь сообщений для отметки как прочитанных
|
||||||
if !app.td_client.pending_view_messages.is_empty() {
|
if !app.td_client.pending_view_messages.is_empty() {
|
||||||
app.td_client.process_pending_view_messages().await;
|
app.td_client.process_pending_view_messages().await;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
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 std::time::Instant;
|
||||||
|
use tdlib_rs::enums::{AuthorizationState, ChatAction, ChatList, ChatType, ConnectionState, MessageContent, MessageSender, SearchMessagesFilter, Update, User, UserStatus};
|
||||||
use tdlib_rs::types::TextEntity;
|
use tdlib_rs::types::TextEntity;
|
||||||
|
|
||||||
/// Максимальный размер кэшей пользователей
|
/// Максимальный размер кэшей пользователей
|
||||||
@@ -129,7 +130,8 @@ pub struct ReplyInfo {
|
|||||||
pub struct ForwardInfo {
|
pub struct ForwardInfo {
|
||||||
/// Имя оригинального отправителя
|
/// Имя оригинального отправителя
|
||||||
pub sender_name: String,
|
pub sender_name: String,
|
||||||
/// Дата оригинального сообщения
|
/// Дата оригинального сообщения (для будущего использования)
|
||||||
|
#[allow(dead_code)]
|
||||||
pub date: i32,
|
pub date: i32,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,6 +224,10 @@ pub struct TdClient {
|
|||||||
user_statuses: LruCache<UserOnlineStatus>,
|
user_statuses: LruCache<UserOnlineStatus>,
|
||||||
/// Состояние сетевого соединения
|
/// Состояние сетевого соединения
|
||||||
pub network_state: NetworkState,
|
pub network_state: NetworkState,
|
||||||
|
/// Typing status для текущего чата: (user_id, action_text, timestamp)
|
||||||
|
pub typing_status: Option<(i64, String, Instant)>,
|
||||||
|
/// Последнее закреплённое сообщение текущего чата
|
||||||
|
pub current_pinned_message: Option<MessageInfo>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
@@ -252,6 +258,8 @@ impl TdClient {
|
|||||||
main_chat_list_position: 0,
|
main_chat_list_position: 0,
|
||||||
user_statuses: LruCache::new(MAX_USER_CACHE_SIZE),
|
user_statuses: LruCache::new(MAX_USER_CACHE_SIZE),
|
||||||
network_state: NetworkState::Connecting,
|
network_state: NetworkState::Connecting,
|
||||||
|
typing_status: None,
|
||||||
|
current_pinned_message: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -290,6 +298,30 @@ impl TdClient {
|
|||||||
.and_then(|user_id| self.user_statuses.peek(user_id))
|
.and_then(|user_id| self.user_statuses.peek(user_id))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Очищает typing status если прошло более 6 секунд
|
||||||
|
/// Возвращает true если статус был очищен (нужна перерисовка)
|
||||||
|
pub fn clear_stale_typing_status(&mut self) -> bool {
|
||||||
|
if let Some((_, _, timestamp)) = &self.typing_status {
|
||||||
|
if timestamp.elapsed().as_secs() > 6 {
|
||||||
|
self.typing_status = None;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Возвращает текст typing status с именем пользователя
|
||||||
|
/// Например: "Вася печатает..."
|
||||||
|
pub fn get_typing_text(&self) -> Option<String> {
|
||||||
|
self.typing_status.as_ref().map(|(user_id, action, _)| {
|
||||||
|
let name = self.user_names
|
||||||
|
.peek(user_id)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| "Кто-то".to_string());
|
||||||
|
format!("{} {}", name, action)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Инициализация TDLib с параметрами
|
/// Инициализация TDLib с параметрами
|
||||||
pub async fn init(&mut self) -> Result<(), String> {
|
pub async fn init(&mut self) -> Result<(), String> {
|
||||||
let result = functions::set_tdlib_parameters(
|
let result = functions::set_tdlib_parameters(
|
||||||
@@ -525,6 +557,41 @@ impl TdClient {
|
|||||||
ConnectionState::Ready => NetworkState::Ready,
|
ConnectionState::Ready => NetworkState::Ready,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
Update::ChatAction(update) => {
|
||||||
|
// Обрабатываем только для текущего открытого чата
|
||||||
|
if Some(update.chat_id) == self.current_chat_id {
|
||||||
|
// Извлекаем user_id из sender_id
|
||||||
|
let user_id = match update.sender_id {
|
||||||
|
MessageSender::User(user) => Some(user.user_id),
|
||||||
|
MessageSender::Chat(_) => None, // Игнорируем действия от имени чата
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(user_id) = user_id {
|
||||||
|
// Определяем текст действия
|
||||||
|
let action_text = match update.action {
|
||||||
|
ChatAction::Typing => Some("печатает...".to_string()),
|
||||||
|
ChatAction::RecordingVideo => Some("записывает видео...".to_string()),
|
||||||
|
ChatAction::UploadingVideo(_) => Some("отправляет видео...".to_string()),
|
||||||
|
ChatAction::RecordingVoiceNote => Some("записывает голосовое...".to_string()),
|
||||||
|
ChatAction::UploadingVoiceNote(_) => Some("отправляет голосовое...".to_string()),
|
||||||
|
ChatAction::UploadingPhoto(_) => Some("отправляет фото...".to_string()),
|
||||||
|
ChatAction::UploadingDocument(_) => Some("отправляет файл...".to_string()),
|
||||||
|
ChatAction::ChoosingSticker => Some("выбирает стикер...".to_string()),
|
||||||
|
ChatAction::RecordingVideoNote => Some("записывает видеосообщение...".to_string()),
|
||||||
|
ChatAction::UploadingVideoNote(_) => Some("отправляет видеосообщение...".to_string()),
|
||||||
|
ChatAction::Cancel => None, // Отмена — сбрасываем статус
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(text) = action_text {
|
||||||
|
self.typing_status = Some((user_id, text, Instant::now()));
|
||||||
|
} else {
|
||||||
|
// Cancel или неизвестное действие — сбрасываем
|
||||||
|
self.typing_status = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1039,6 +1106,97 @@ impl TdClient {
|
|||||||
Ok(all_messages)
|
Ok(all_messages)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Загрузка закреплённых сообщений чата
|
||||||
|
pub async fn get_pinned_messages(&mut self, chat_id: i64) -> Result<Vec<MessageInfo>, String> {
|
||||||
|
let result = functions::search_chat_messages(
|
||||||
|
chat_id,
|
||||||
|
"".to_string(), // query
|
||||||
|
None, // sender_id
|
||||||
|
0, // from_message_id
|
||||||
|
0, // offset
|
||||||
|
100, // limit
|
||||||
|
Some(SearchMessagesFilter::Pinned), // filter
|
||||||
|
0, // message_thread_id
|
||||||
|
0, // saved_messages_topic_id
|
||||||
|
self.client_id,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(tdlib_rs::enums::FoundChatMessages::FoundChatMessages(found)) => {
|
||||||
|
let mut messages: Vec<MessageInfo> = Vec::new();
|
||||||
|
for m in found.messages {
|
||||||
|
messages.push(self.convert_message(&m, chat_id));
|
||||||
|
}
|
||||||
|
// Сообщения приходят от новых к старым, оставляем как есть
|
||||||
|
Ok(messages)
|
||||||
|
}
|
||||||
|
Err(e) => Err(format!("Ошибка загрузки закреплённых: {:?}", e)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Загружает последнее закреплённое сообщение для текущего чата
|
||||||
|
pub async fn load_current_pinned_message(&mut self, chat_id: i64) {
|
||||||
|
let result = functions::search_chat_messages(
|
||||||
|
chat_id,
|
||||||
|
"".to_string(),
|
||||||
|
None,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
1, // Только одно сообщение
|
||||||
|
Some(SearchMessagesFilter::Pinned),
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
self.client_id,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(tdlib_rs::enums::FoundChatMessages::FoundChatMessages(found)) => {
|
||||||
|
if let Some(m) = found.messages.first() {
|
||||||
|
self.current_pinned_message = Some(self.convert_message(m, chat_id));
|
||||||
|
} else {
|
||||||
|
self.current_pinned_message = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
self.current_pinned_message = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Поиск сообщений в чате по тексту
|
||||||
|
pub async fn search_messages(&mut self, chat_id: i64, query: &str) -> Result<Vec<MessageInfo>, String> {
|
||||||
|
if query.trim().is_empty() {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = functions::search_chat_messages(
|
||||||
|
chat_id,
|
||||||
|
query.to_string(),
|
||||||
|
None, // sender_id
|
||||||
|
0, // from_message_id
|
||||||
|
0, // offset
|
||||||
|
50, // limit
|
||||||
|
None, // filter (no filter = search by text)
|
||||||
|
0, // message_thread_id
|
||||||
|
0, // saved_messages_topic_id
|
||||||
|
self.client_id,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(tdlib_rs::enums::FoundChatMessages::FoundChatMessages(found)) => {
|
||||||
|
let mut messages: Vec<MessageInfo> = Vec::new();
|
||||||
|
for m in found.messages {
|
||||||
|
messages.push(self.convert_message(&m, chat_id));
|
||||||
|
}
|
||||||
|
Ok(messages)
|
||||||
|
}
|
||||||
|
Err(e) => Err(format!("Ошибка поиска: {:?}", e)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Загрузка старых сообщений (для скролла вверх)
|
/// Загрузка старых сообщений (для скролла вверх)
|
||||||
pub async fn load_older_messages(
|
pub async fn load_older_messages(
|
||||||
&mut self,
|
&mut self,
|
||||||
@@ -1104,6 +1262,16 @@ impl TdClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Отправка статуса действия в чат (typing, cancel и т.д.)
|
||||||
|
pub async fn send_chat_action(&self, chat_id: i64, action: ChatAction) {
|
||||||
|
let _ = functions::send_chat_action(
|
||||||
|
chat_id,
|
||||||
|
0, // message_thread_id
|
||||||
|
Some(action),
|
||||||
|
self.client_id,
|
||||||
|
).await;
|
||||||
|
}
|
||||||
|
|
||||||
/// Отправка текстового сообщения с поддержкой Markdown и reply
|
/// Отправка текстового сообщения с поддержкой Markdown и reply
|
||||||
pub async fn send_message(&self, chat_id: i64, text: String, reply_to_message_id: Option<i64>, reply_info: Option<ReplyInfo>) -> Result<MessageInfo, String> {
|
pub async fn send_message(&self, chat_id: i64, text: String, reply_to_message_id: Option<i64>, reply_info: Option<ReplyInfo>) -> Result<MessageInfo, String> {
|
||||||
use tdlib_rs::types::{FormattedText, InputMessageText, TextParseModeMarkdown, InputMessageReplyToMessage};
|
use tdlib_rs::types::{FormattedText, InputMessageText, TextParseModeMarkdown, InputMessageReplyToMessage};
|
||||||
|
|||||||
@@ -3,3 +3,4 @@ pub mod client;
|
|||||||
pub use client::TdClient;
|
pub use client::TdClient;
|
||||||
pub use client::UserOnlineStatus;
|
pub use client::UserOnlineStatus;
|
||||||
pub use client::NetworkState;
|
pub use client::NetworkState;
|
||||||
|
pub use tdlib_rs::enums::ChatAction;
|
||||||
|
|||||||
@@ -307,6 +307,18 @@ fn adjust_entities_for_substring(
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn render(f: &mut Frame, area: Rect, app: &App) {
|
pub fn render(f: &mut Frame, area: Rect, app: &App) {
|
||||||
|
// Режим поиска по сообщениям
|
||||||
|
if app.is_message_search_mode() {
|
||||||
|
render_search_mode(f, area, app);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Режим просмотра закреплённых сообщений
|
||||||
|
if app.is_pinned_mode() {
|
||||||
|
render_pinned_mode(f, area, app);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(chat) = app.get_selected_chat() {
|
if let Some(chat) = app.get_selected_chat() {
|
||||||
// Вычисляем динамическую высоту инпута на основе длины текста
|
// Вычисляем динамическую высоту инпута на основе длины текста
|
||||||
let input_width = area.width.saturating_sub(4) as usize; // -2 для рамок, -2 для "> "
|
let input_width = area.width.saturating_sub(4) as usize; // -2 для рамок, -2 для "> "
|
||||||
@@ -319,31 +331,92 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) {
|
|||||||
// Минимум 3 строки (1 контент + 2 рамки), максимум 10
|
// Минимум 3 строки (1 контент + 2 рамки), максимум 10
|
||||||
let input_height = (input_lines + 2).min(10).max(3);
|
let input_height = (input_lines + 2).min(10).max(3);
|
||||||
|
|
||||||
let message_chunks = Layout::default()
|
// Проверяем, есть ли закреплённое сообщение
|
||||||
.direction(Direction::Vertical)
|
let has_pinned = app.td_client.current_pinned_message.is_some();
|
||||||
.constraints([
|
|
||||||
Constraint::Length(3), // Chat header
|
|
||||||
Constraint::Min(0), // Messages
|
|
||||||
Constraint::Length(input_height), // Input box (динамическая высота)
|
|
||||||
])
|
|
||||||
.split(area);
|
|
||||||
|
|
||||||
// Chat header
|
let message_chunks = if has_pinned {
|
||||||
let header_text = match &chat.username {
|
Layout::default()
|
||||||
Some(username) => format!("👤 {} {}", chat.title, username),
|
.direction(Direction::Vertical)
|
||||||
None => format!("👤 {}", chat.title),
|
.constraints([
|
||||||
|
Constraint::Length(3), // Chat header
|
||||||
|
Constraint::Length(1), // Pinned bar
|
||||||
|
Constraint::Min(0), // Messages
|
||||||
|
Constraint::Length(input_height), // Input box (динамическая высота)
|
||||||
|
])
|
||||||
|
.split(area)
|
||||||
|
} else {
|
||||||
|
Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Length(3), // Chat header
|
||||||
|
Constraint::Length(0), // Pinned bar (hidden)
|
||||||
|
Constraint::Min(0), // Messages
|
||||||
|
Constraint::Length(input_height), // Input box (динамическая высота)
|
||||||
|
])
|
||||||
|
.split(area)
|
||||||
};
|
};
|
||||||
let header = Paragraph::new(header_text)
|
|
||||||
.block(Block::default().borders(Borders::ALL))
|
// Chat header с typing status
|
||||||
.style(
|
let typing_action = app.td_client.typing_status.as_ref().map(|(_, action, _)| action.clone());
|
||||||
Style::default()
|
let header_line = if let Some(action) = typing_action {
|
||||||
.fg(Color::Cyan)
|
// Показываем typing status: "👤 Имя @username печатает..."
|
||||||
.add_modifier(Modifier::BOLD),
|
let mut spans = vec![
|
||||||
);
|
Span::styled(
|
||||||
|
format!("👤 {}", chat.title),
|
||||||
|
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
if let Some(username) = &chat.username {
|
||||||
|
spans.push(Span::styled(
|
||||||
|
format!(" {}", username),
|
||||||
|
Style::default().fg(Color::Gray),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
spans.push(Span::styled(
|
||||||
|
format!(" {}", action),
|
||||||
|
Style::default().fg(Color::Yellow).add_modifier(Modifier::ITALIC),
|
||||||
|
));
|
||||||
|
Line::from(spans)
|
||||||
|
} else {
|
||||||
|
// Показываем username
|
||||||
|
let header_text = match &chat.username {
|
||||||
|
Some(username) => format!("👤 {} {}", chat.title, username),
|
||||||
|
None => format!("👤 {}", chat.title),
|
||||||
|
};
|
||||||
|
Line::from(Span::styled(
|
||||||
|
header_text,
|
||||||
|
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
|
||||||
|
))
|
||||||
|
};
|
||||||
|
let header = Paragraph::new(header_line)
|
||||||
|
.block(Block::default().borders(Borders::ALL));
|
||||||
f.render_widget(header, message_chunks[0]);
|
f.render_widget(header, message_chunks[0]);
|
||||||
|
|
||||||
|
// Pinned bar (если есть закреплённое сообщение)
|
||||||
|
if let Some(pinned_msg) = &app.td_client.current_pinned_message {
|
||||||
|
let pinned_preview: String = pinned_msg.content.chars().take(40).collect();
|
||||||
|
let ellipsis = if pinned_msg.content.chars().count() > 40 { "..." } else { "" };
|
||||||
|
let pinned_datetime = crate::utils::format_datetime(pinned_msg.date);
|
||||||
|
let pinned_text = format!("📌 {} {}{}", pinned_datetime, pinned_preview, ellipsis);
|
||||||
|
let pinned_hint = "Ctrl+P";
|
||||||
|
|
||||||
|
let pinned_bar_width = message_chunks[1].width as usize;
|
||||||
|
let text_len = pinned_text.chars().count();
|
||||||
|
let hint_len = pinned_hint.chars().count();
|
||||||
|
let padding = pinned_bar_width.saturating_sub(text_len + hint_len + 2);
|
||||||
|
|
||||||
|
let pinned_line = Line::from(vec![
|
||||||
|
Span::styled(pinned_text, Style::default().fg(Color::Magenta)),
|
||||||
|
Span::raw(" ".repeat(padding)),
|
||||||
|
Span::styled(pinned_hint, Style::default().fg(Color::Gray)),
|
||||||
|
]);
|
||||||
|
let pinned_bar = Paragraph::new(pinned_line)
|
||||||
|
.style(Style::default().bg(Color::Rgb(40, 20, 40)));
|
||||||
|
f.render_widget(pinned_bar, message_chunks[1]);
|
||||||
|
}
|
||||||
|
|
||||||
// Ширина области сообщений (без рамок)
|
// Ширина области сообщений (без рамок)
|
||||||
let content_width = message_chunks[1].width.saturating_sub(2) as usize;
|
let content_width = message_chunks[2].width.saturating_sub(2) as usize;
|
||||||
|
|
||||||
// Messages с группировкой по дате и отправителю
|
// Messages с группировкой по дате и отправителю
|
||||||
let mut lines: Vec<Line> = Vec::new();
|
let mut lines: Vec<Line> = Vec::new();
|
||||||
@@ -594,7 +667,7 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Вычисляем скролл с учётом пользовательского offset
|
// Вычисляем скролл с учётом пользовательского offset
|
||||||
let visible_height = message_chunks[1].height.saturating_sub(2) as usize;
|
let visible_height = message_chunks[2].height.saturating_sub(2) as usize;
|
||||||
let total_lines = lines.len();
|
let total_lines = lines.len();
|
||||||
|
|
||||||
// Базовый скролл (показываем последние сообщения)
|
// Базовый скролл (показываем последние сообщения)
|
||||||
@@ -628,7 +701,7 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) {
|
|||||||
let messages_widget = Paragraph::new(lines)
|
let messages_widget = Paragraph::new(lines)
|
||||||
.block(Block::default().borders(Borders::ALL))
|
.block(Block::default().borders(Borders::ALL))
|
||||||
.scroll((scroll_offset, 0));
|
.scroll((scroll_offset, 0));
|
||||||
f.render_widget(messages_widget, message_chunks[1]);
|
f.render_widget(messages_widget, message_chunks[2]);
|
||||||
|
|
||||||
// Input box с wrap для длинного текста и блочным курсором
|
// Input box с wrap для длинного текста и блочным курсором
|
||||||
let (input_line, input_title) = if app.is_forwarding() {
|
let (input_line, input_title) = if app.is_forwarding() {
|
||||||
@@ -730,7 +803,7 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) {
|
|||||||
let input = Paragraph::new(input_line)
|
let input = Paragraph::new(input_line)
|
||||||
.block(input_block)
|
.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[3]);
|
||||||
} else {
|
} else {
|
||||||
let empty = Paragraph::new("Выберите чат")
|
let empty = Paragraph::new("Выберите чат")
|
||||||
.block(Block::default().borders(Borders::ALL))
|
.block(Block::default().borders(Borders::ALL))
|
||||||
@@ -745,6 +818,267 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Рендерит режим поиска по сообщениям
|
||||||
|
fn render_search_mode(f: &mut Frame, area: Rect, app: &App) {
|
||||||
|
let chunks = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Length(3), // Search input
|
||||||
|
Constraint::Min(0), // Search results
|
||||||
|
Constraint::Length(3), // Help bar
|
||||||
|
])
|
||||||
|
.split(area);
|
||||||
|
|
||||||
|
// Search input
|
||||||
|
let total = app.message_search_results.len();
|
||||||
|
let current = if total > 0 { app.selected_search_result_index + 1 } else { 0 };
|
||||||
|
|
||||||
|
let input_line = if app.message_search_query.is_empty() {
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled("🔍 ", Style::default().fg(Color::Yellow)),
|
||||||
|
Span::styled("█", Style::default().fg(Color::Yellow)),
|
||||||
|
Span::styled(" Введите текст для поиска...", Style::default().fg(Color::Gray)),
|
||||||
|
])
|
||||||
|
} else {
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled("🔍 ", Style::default().fg(Color::Yellow)),
|
||||||
|
Span::styled(&app.message_search_query, Style::default().fg(Color::White)),
|
||||||
|
Span::styled("█", Style::default().fg(Color::Yellow)),
|
||||||
|
Span::styled(format!(" ({}/{})", current, total), Style::default().fg(Color::Gray)),
|
||||||
|
])
|
||||||
|
};
|
||||||
|
|
||||||
|
let search_input = Paragraph::new(input_line)
|
||||||
|
.block(
|
||||||
|
Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_style(Style::default().fg(Color::Yellow))
|
||||||
|
.title(" Поиск по сообщениям ")
|
||||||
|
.title_style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD))
|
||||||
|
);
|
||||||
|
f.render_widget(search_input, chunks[0]);
|
||||||
|
|
||||||
|
// Search results
|
||||||
|
let content_width = chunks[1].width.saturating_sub(2) as usize;
|
||||||
|
let mut lines: Vec<Line> = Vec::new();
|
||||||
|
|
||||||
|
if app.message_search_results.is_empty() {
|
||||||
|
if !app.message_search_query.is_empty() {
|
||||||
|
lines.push(Line::from(Span::styled(
|
||||||
|
"Ничего не найдено",
|
||||||
|
Style::default().fg(Color::Gray),
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (idx, msg) in app.message_search_results.iter().enumerate() {
|
||||||
|
let is_selected = idx == app.selected_search_result_index;
|
||||||
|
|
||||||
|
// Пустая строка между результатами
|
||||||
|
if idx > 0 {
|
||||||
|
lines.push(Line::from(""));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Маркер выбора, имя и дата
|
||||||
|
let marker = if is_selected { "▶ " } else { " " };
|
||||||
|
let sender_color = if msg.is_outgoing { Color::Green } else { Color::Cyan };
|
||||||
|
let sender_name = if msg.is_outgoing { "Вы".to_string() } else { msg.sender_name.clone() };
|
||||||
|
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::styled(marker, Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
|
||||||
|
Span::styled(
|
||||||
|
format!("{} ", sender_name),
|
||||||
|
Style::default().fg(sender_color).add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
|
Span::styled(
|
||||||
|
format!("({})", crate::utils::format_datetime(msg.date)),
|
||||||
|
Style::default().fg(Color::Gray),
|
||||||
|
),
|
||||||
|
]));
|
||||||
|
|
||||||
|
// Текст сообщения (с переносом)
|
||||||
|
let msg_color = if is_selected { Color::Yellow } else { Color::White };
|
||||||
|
let max_width = content_width.saturating_sub(4);
|
||||||
|
let wrapped = wrap_text_with_offsets(&msg.content, max_width);
|
||||||
|
let wrapped_count = wrapped.len();
|
||||||
|
|
||||||
|
for wrapped_line in wrapped.into_iter().take(2) {
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::raw(" "),
|
||||||
|
Span::styled(wrapped_line.text, Style::default().fg(msg_color)),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
if wrapped_count > 2 {
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::raw(" "),
|
||||||
|
Span::styled("...", Style::default().fg(Color::Gray)),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Скролл к выбранному результату
|
||||||
|
let visible_height = chunks[1].height.saturating_sub(2) as usize;
|
||||||
|
let lines_per_result = 4;
|
||||||
|
let selected_line = app.selected_search_result_index * lines_per_result;
|
||||||
|
let scroll_offset = if selected_line > visible_height / 2 {
|
||||||
|
(selected_line - visible_height / 2) as u16
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
|
||||||
|
let results_widget = Paragraph::new(lines)
|
||||||
|
.block(
|
||||||
|
Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_style(Style::default().fg(Color::Yellow))
|
||||||
|
)
|
||||||
|
.scroll((scroll_offset, 0));
|
||||||
|
f.render_widget(results_widget, chunks[1]);
|
||||||
|
|
||||||
|
// Help bar
|
||||||
|
let help_line = Line::from(vec![
|
||||||
|
Span::styled(" ↑↓ ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
|
||||||
|
Span::raw("навигация"),
|
||||||
|
Span::raw(" "),
|
||||||
|
Span::styled(" n/N ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
|
||||||
|
Span::raw("след./пред."),
|
||||||
|
Span::raw(" "),
|
||||||
|
Span::styled(" Enter ", Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)),
|
||||||
|
Span::raw("перейти"),
|
||||||
|
Span::raw(" "),
|
||||||
|
Span::styled(" Esc ", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)),
|
||||||
|
Span::raw("выход"),
|
||||||
|
]);
|
||||||
|
let help = Paragraph::new(help_line)
|
||||||
|
.block(
|
||||||
|
Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_style(Style::default().fg(Color::Yellow))
|
||||||
|
)
|
||||||
|
.alignment(Alignment::Center);
|
||||||
|
f.render_widget(help, chunks[2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Рендерит режим просмотра закреплённых сообщений
|
||||||
|
fn render_pinned_mode(f: &mut Frame, area: Rect, app: &App) {
|
||||||
|
let chunks = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Length(3), // Header
|
||||||
|
Constraint::Min(0), // Pinned messages list
|
||||||
|
Constraint::Length(3), // Help bar
|
||||||
|
])
|
||||||
|
.split(area);
|
||||||
|
|
||||||
|
// Header
|
||||||
|
let total = app.pinned_messages.len();
|
||||||
|
let current = app.selected_pinned_index + 1;
|
||||||
|
let header_text = format!("📌 ЗАКРЕПЛЁННЫЕ ({}/{})", current, total);
|
||||||
|
let header = Paragraph::new(header_text)
|
||||||
|
.block(
|
||||||
|
Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_style(Style::default().fg(Color::Magenta))
|
||||||
|
)
|
||||||
|
.style(Style::default().fg(Color::Magenta).add_modifier(Modifier::BOLD));
|
||||||
|
f.render_widget(header, chunks[0]);
|
||||||
|
|
||||||
|
// Pinned messages list
|
||||||
|
let content_width = chunks[1].width.saturating_sub(2) as usize;
|
||||||
|
let mut lines: Vec<Line> = Vec::new();
|
||||||
|
|
||||||
|
for (idx, msg) in app.pinned_messages.iter().enumerate() {
|
||||||
|
let is_selected = idx == app.selected_pinned_index;
|
||||||
|
|
||||||
|
// Пустая строка между сообщениями
|
||||||
|
if idx > 0 {
|
||||||
|
lines.push(Line::from(""));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Маркер выбора и имя отправителя
|
||||||
|
let marker = if is_selected { "▶ " } else { " " };
|
||||||
|
let sender_color = if msg.is_outgoing { Color::Green } else { Color::Cyan };
|
||||||
|
let sender_name = if msg.is_outgoing { "Вы".to_string() } else { msg.sender_name.clone() };
|
||||||
|
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::styled(marker, Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
|
||||||
|
Span::styled(
|
||||||
|
format!("{} ", sender_name),
|
||||||
|
Style::default().fg(sender_color).add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
|
Span::styled(
|
||||||
|
format!("({})", crate::utils::format_datetime(msg.date)),
|
||||||
|
Style::default().fg(Color::Gray),
|
||||||
|
),
|
||||||
|
]));
|
||||||
|
|
||||||
|
// Текст сообщения (с переносом)
|
||||||
|
let msg_color = if is_selected { Color::Yellow } else { Color::White };
|
||||||
|
let max_width = content_width.saturating_sub(4);
|
||||||
|
let wrapped = wrap_text_with_offsets(&msg.content, max_width);
|
||||||
|
let wrapped_count = wrapped.len();
|
||||||
|
|
||||||
|
for wrapped_line in wrapped.into_iter().take(3) { // Максимум 3 строки на сообщение
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::raw(" "), // Отступ
|
||||||
|
Span::styled(wrapped_line.text, Style::default().fg(msg_color)),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
if wrapped_count > 3 {
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::raw(" "),
|
||||||
|
Span::styled("...", Style::default().fg(Color::Gray)),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if lines.is_empty() {
|
||||||
|
lines.push(Line::from(Span::styled(
|
||||||
|
"Нет закреплённых сообщений",
|
||||||
|
Style::default().fg(Color::Gray),
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Скролл к выбранному сообщению
|
||||||
|
let visible_height = chunks[1].height.saturating_sub(2) as usize;
|
||||||
|
let lines_per_msg = 5; // Примерно строк на сообщение
|
||||||
|
let selected_line = app.selected_pinned_index * lines_per_msg;
|
||||||
|
let scroll_offset = if selected_line > visible_height / 2 {
|
||||||
|
(selected_line - visible_height / 2) as u16
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
|
||||||
|
let messages_widget = Paragraph::new(lines)
|
||||||
|
.block(
|
||||||
|
Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_style(Style::default().fg(Color::Magenta))
|
||||||
|
)
|
||||||
|
.scroll((scroll_offset, 0));
|
||||||
|
f.render_widget(messages_widget, chunks[1]);
|
||||||
|
|
||||||
|
// Help bar
|
||||||
|
let help_line = Line::from(vec![
|
||||||
|
Span::styled(" ↑↓ ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
|
||||||
|
Span::raw("навигация"),
|
||||||
|
Span::raw(" "),
|
||||||
|
Span::styled(" Enter ", Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)),
|
||||||
|
Span::raw("перейти"),
|
||||||
|
Span::raw(" "),
|
||||||
|
Span::styled(" Esc ", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)),
|
||||||
|
Span::raw("выход"),
|
||||||
|
]);
|
||||||
|
let help = Paragraph::new(help_line)
|
||||||
|
.block(
|
||||||
|
Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_style(Style::default().fg(Color::Magenta))
|
||||||
|
)
|
||||||
|
.alignment(Alignment::Center);
|
||||||
|
f.render_widget(help, chunks[2]);
|
||||||
|
}
|
||||||
|
|
||||||
/// Рендерит модалку подтверждения удаления
|
/// Рендерит модалку подтверждения удаления
|
||||||
fn render_delete_confirm_modal(f: &mut Frame, area: Rect) {
|
fn render_delete_confirm_modal(f: &mut Frame, area: Rect) {
|
||||||
use ratatui::widgets::Clear;
|
use ratatui::widgets::Clear;
|
||||||
|
|||||||
29
src/utils.rs
29
src/utils.rs
@@ -79,3 +79,32 @@ pub fn format_date(timestamp: i32) -> String {
|
|||||||
pub fn get_day(timestamp: i32) -> i64 {
|
pub fn get_day(timestamp: i32) -> i64 {
|
||||||
timestamp as i64 / 86400
|
timestamp as i64 / 86400
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Форматирование timestamp в полную дату и время (DD.MM.YYYY HH:MM)
|
||||||
|
pub fn format_datetime(timestamp: i32) -> String {
|
||||||
|
let secs = timestamp as i64;
|
||||||
|
|
||||||
|
// Время
|
||||||
|
let hours = ((secs % 86400) / 3600) as u32;
|
||||||
|
let minutes = ((secs % 3600) / 60) as u32;
|
||||||
|
let local_hours = (hours + 3) % 24; // MSK
|
||||||
|
|
||||||
|
// Дата
|
||||||
|
let days_since_epoch = secs / 86400;
|
||||||
|
let year = 1970 + (days_since_epoch / 365) as i32;
|
||||||
|
let day_of_year = days_since_epoch % 365;
|
||||||
|
|
||||||
|
let months = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
||||||
|
let mut month = 1;
|
||||||
|
let mut day = day_of_year as i32;
|
||||||
|
|
||||||
|
for (i, &m) in months.iter().enumerate() {
|
||||||
|
if day < m {
|
||||||
|
month = i + 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
day -= m;
|
||||||
|
}
|
||||||
|
|
||||||
|
format!("{:02}.{:02}.{} {:02}:{:02}", day + 1, month, year, local_hours, minutes)
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user