This commit is contained in:
Mikhail Kilin
2026-01-30 15:07:13 +03:00
parent 126c7482af
commit 4deb0fbe00
32 changed files with 1049 additions and 697 deletions

View File

@@ -2,9 +2,9 @@ mod state;
pub use state::AppScreen;
use ratatui::widgets::ListState;
use crate::tdlib::client::ChatInfo;
use crate::tdlib::TdClient;
use ratatui::widgets::ListState;
pub struct App {
pub config: crate::config::Config,
@@ -87,7 +87,6 @@ pub struct App {
pub selected_reaction_index: usize,
}
impl App {
pub fn new(config: crate::config::Config) -> App {
let mut state = ListState::default();
@@ -226,13 +225,14 @@ impl App {
self.selected_message_index = Some(
self.selected_message_index
.map(|i| (i + 1).min(total - 1))
.unwrap_or(0)
.unwrap_or(0),
);
}
/// Выбрать следующее сообщение (вниз по списку = уменьшить индекс)
pub fn select_next_message(&mut self) {
self.selected_message_index = self.selected_message_index
self.selected_message_index = self
.selected_message_index
.map(|i| if i > 0 { Some(i - 1) } else { None })
.flatten();
}
@@ -312,7 +312,8 @@ impl App {
pub fn get_filtered_chats(&self) -> Vec<&ChatInfo> {
let folder_filtered: Vec<&ChatInfo> = match self.selected_folder_id {
None => self.chats.iter().collect(), // All - показываем все
Some(folder_id) => self.chats
Some(folder_id) => self
.chats
.iter()
.filter(|c| c.folder_ids.contains(&folder_id))
.collect(),
@@ -410,7 +411,10 @@ impl App {
/// Получить сообщение, на которое отвечаем
pub fn get_replying_to_message(&self) -> Option<&crate::tdlib::client::MessageInfo> {
self.replying_to_message_id.and_then(|id| {
self.td_client.current_chat_messages.iter().find(|m| m.id == id)
self.td_client
.current_chat_messages
.iter()
.find(|m| m.id == id)
})
}
@@ -441,7 +445,10 @@ impl App {
/// Получить сообщение для пересылки
pub fn get_forwarding_message(&self) -> Option<&crate::tdlib::client::MessageInfo> {
self.forwarding_message_id.and_then(|id| {
self.td_client.current_chat_messages.iter().find(|m| m.id == id)
self.td_client
.current_chat_messages
.iter()
.find(|m| m.id == id)
})
}
@@ -470,7 +477,9 @@ impl App {
/// Выбрать предыдущий pinned (вверх = более старый)
pub fn select_previous_pinned(&mut self) {
if !self.pinned_messages.is_empty() && self.selected_pinned_index < self.pinned_messages.len() - 1 {
if !self.pinned_messages.is_empty()
&& self.selected_pinned_index < self.pinned_messages.len() - 1
{
self.selected_pinned_index += 1;
}
}
@@ -539,7 +548,8 @@ impl App {
/// Получить текущий выбранный результат
pub fn get_selected_search_result(&self) -> Option<&crate::tdlib::client::MessageInfo> {
self.message_search_results.get(self.selected_search_result_index)
self.message_search_results
.get(self.selected_search_result_index)
}
/// Получить ID выбранного результата для перехода
@@ -629,7 +639,11 @@ impl App {
self.is_reaction_picker_mode
}
pub fn enter_reaction_picker_mode(&mut self, message_id: i64, available_reactions: Vec<String>) {
pub fn enter_reaction_picker_mode(
&mut self,
message_id: i64,
available_reactions: Vec<String>,
) {
self.is_reaction_picker_mode = true;
self.selected_message_for_reaction = Some(message_id);
self.available_reactions = available_reactions;

View File

@@ -67,9 +67,7 @@ fn default_reaction_other_color() -> String {
impl Default for GeneralConfig {
fn default() -> Self {
Self {
timezone: default_timezone(),
}
Self { timezone: default_timezone() }
}
}
@@ -132,15 +130,13 @@ impl Config {
}
match fs::read_to_string(&config_path) {
Ok(content) => {
match toml::from_str::<Config>(&content) {
Ok(content) => match toml::from_str::<Config>(&content) {
Ok(config) => config,
Err(e) => {
eprintln!("Warning: Could not parse config file: {}", e);
Self::default()
}
}
}
},
Err(e) => {
eprintln!("Warning: Could not read config file: {}", e);
Self::default()
@@ -150,8 +146,8 @@ impl Config {
/// Сохранить конфигурацию в файл
pub fn save(&self) -> Result<(), String> {
let config_dir = Self::config_dir()
.ok_or_else(|| "Could not determine config directory".to_string())?;
let config_dir =
Self::config_dir().ok_or_else(|| "Could not determine config directory".to_string())?;
// Создаём директорию если её нет
fs::create_dir_all(&config_dir)

View File

@@ -1,8 +1,8 @@
use crate::app::App;
use crate::tdlib::client::AuthState;
use crossterm::event::KeyCode;
use std::time::Duration;
use tokio::time::timeout;
use crate::app::App;
use crate::tdlib::client::AuthState;
pub async fn handle(app: &mut App, key_code: KeyCode) {
match &app.td_client.auth_state {
@@ -18,7 +18,12 @@ pub async fn handle(app: &mut App, key_code: KeyCode) {
KeyCode::Enter => {
if !app.phone_input.is_empty() {
app.status_message = Some("Отправка номера...".to_string());
match timeout(Duration::from_secs(10), app.td_client.send_phone_number(app.phone_input.clone())).await {
match timeout(
Duration::from_secs(10),
app.td_client.send_phone_number(app.phone_input.clone()),
)
.await
{
Ok(Ok(_)) => {
app.error_message = None;
app.status_message = None;
@@ -48,7 +53,12 @@ pub async fn handle(app: &mut App, key_code: KeyCode) {
KeyCode::Enter => {
if !app.code_input.is_empty() {
app.status_message = Some("Проверка кода...".to_string());
match timeout(Duration::from_secs(10), app.td_client.send_code(app.code_input.clone())).await {
match timeout(
Duration::from_secs(10),
app.td_client.send_code(app.code_input.clone()),
)
.await
{
Ok(Ok(_)) => {
app.error_message = None;
app.status_message = None;
@@ -78,7 +88,12 @@ pub async fn handle(app: &mut App, key_code: KeyCode) {
KeyCode::Enter => {
if !app.password_input.is_empty() {
app.status_message = Some("Проверка пароля...".to_string());
match timeout(Duration::from_secs(10), app.td_client.send_password(app.password_input.clone())).await {
match timeout(
Duration::from_secs(10),
app.td_client.send_password(app.password_input.clone()),
)
.await
{
Ok(Ok(_)) => {
app.error_message = None;
app.status_message = None;

View File

@@ -1,8 +1,8 @@
use crate::app::App;
use crate::tdlib::ChatAction;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use std::time::{Duration, Instant};
use tokio::time::timeout;
use crate::app::App;
use crate::tdlib::ChatAction;
pub async fn handle(app: &mut App, key: KeyEvent) {
let has_ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
@@ -27,7 +27,12 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
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 {
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());
@@ -51,7 +56,10 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
}
KeyCode::Char('f') if has_ctrl => {
// Ctrl+F - поиск по сообщениям в открытом чате
if app.selected_chat_id.is_some() && !app.is_pinned_mode() && !app.is_message_search_mode() {
if app.selected_chat_id.is_some()
&& !app.is_pinned_mode()
&& !app.is_message_search_mode()
{
app.enter_message_search_mode();
}
return;
@@ -125,13 +133,17 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
if profile.username.is_some() {
if action_index == current_idx {
if let Some(username) = &profile.username {
let url = format!("https://t.me/{}", username.trim_start_matches('@'));
let url = format!(
"https://t.me/{}",
username.trim_start_matches('@')
);
match open::that(&url) {
Ok(_) => {
app.status_message = Some(format!("Открыто: {}", url));
}
Err(e) => {
app.error_message = Some(format!("Ошибка открытия браузера: {}", e));
app.error_message =
Some(format!("Ошибка открытия браузера: {}", e));
}
}
}
@@ -142,7 +154,8 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
// Действие: Скопировать ID
if action_index == current_idx {
app.status_message = Some(format!("ID скопирован: {}", profile.chat_id));
app.status_message =
Some(format!("ID скопирован: {}", profile.chat_id));
return;
}
current_idx += 1;
@@ -174,7 +187,9 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
KeyCode::Enter => {
// Перейти к выбранному сообщению
if let Some(msg_id) = app.get_selected_search_result_id() {
let msg_index = app.td_client.current_chat_messages
let msg_index = app
.td_client
.current_chat_messages
.iter()
.position(|m| m.id == msg_id);
@@ -192,8 +207,11 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
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.td_client
.search_messages(chat_id, &app.message_search_query),
)
.await
{
app.set_search_results(results);
}
} else {
@@ -207,8 +225,11 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
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.td_client
.search_messages(chat_id, &app.message_search_query),
)
.await
{
app.set_search_results(results);
}
}
@@ -234,7 +255,9 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
// Перейти к сообщению в истории
if let Some(msg_id) = app.get_selected_pinned_id() {
// Ищем индекс сообщения в текущей истории
let msg_index = app.td_client.current_chat_messages
let msg_index = app
.td_client
.current_chat_messages
.iter()
.position(|m| m.id == msg_id);
@@ -287,10 +310,14 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
match timeout(
Duration::from_secs(5),
app.td_client.toggle_reaction(chat_id, message_id, emoji.clone())
).await {
app.td_client
.toggle_reaction(chat_id, message_id, emoji.clone()),
)
.await
{
Ok(Ok(_)) => {
app.status_message = Some(format!("Реакция {} добавлена", emoji));
app.status_message =
Some(format!("Реакция {} добавлена", emoji));
app.exit_reaction_picker_mode();
app.needs_redraw = true;
}
@@ -300,7 +327,8 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
app.needs_redraw = true;
}
Err(_) => {
app.error_message = Some("Таймаут отправки реакции".to_string());
app.error_message =
Some("Таймаут отправки реакции".to_string());
app.status_message = None;
app.needs_redraw = true;
}
@@ -326,7 +354,9 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
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
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)
@@ -334,11 +364,19 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
match timeout(
Duration::from_secs(5),
app.td_client.delete_messages(chat_id, vec![msg_id], can_delete_for_all)
).await {
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.td_client
.current_chat_messages
.retain(|m| m.id != msg_id);
app.selected_message_index = None;
}
Ok(Err(e)) => {
@@ -377,10 +415,17 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
if let Some(from_chat_id) = app.get_selected_chat_id() {
match timeout(
Duration::from_secs(5),
app.td_client.forward_messages(to_chat_id, from_chat_id, vec![msg_id])
).await {
app.td_client.forward_messages(
to_chat_id,
from_chat_id,
vec![msg_id],
),
)
.await
{
Ok(Ok(_)) => {
app.status_message = Some("Сообщение переслано".to_string());
app.status_message =
Some("Сообщение переслано".to_string());
}
Ok(Err(e)) => {
app.error_message = Some(e);
@@ -418,12 +463,25 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
if let Some(chat_id) = app.get_selected_chat_id() {
app.status_message = Some("Загрузка сообщений...".to_string());
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(_)) => {
// Загружаем недостающие 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;
let _ = timeout(
Duration::from_secs(2),
app.td_client.load_current_pinned_message(chat_id),
)
.await;
// Загружаем черновик
app.load_draft();
app.status_message = None;
@@ -460,8 +518,6 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
return;
}
// Enter - открыть чат, отправить сообщение или редактировать
if key.code == KeyCode::Enter {
if app.selected_chat_id.is_some() {
@@ -488,10 +544,20 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
app.cursor_position = 0;
app.editing_message_id = None;
match timeout(Duration::from_secs(5), app.td_client.edit_message(chat_id, msg_id, text)).await {
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) {
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;
@@ -521,9 +587,17 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
app.last_typing_sent = None;
// Отменяем typing status
app.td_client.send_chat_action(chat_id, ChatAction::Cancel).await;
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)) => {
// Добавляем отправленное сообщение в список (с лимитом)
app.td_client.push_message(sent_msg);
@@ -549,12 +623,25 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
if let Some(chat_id) = app.get_selected_chat_id() {
app.status_message = Some("Загрузка сообщений...".to_string());
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(_)) => {
// Загружаем недостающие 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;
let _ = timeout(
Duration::from_secs(2),
app.td_client.load_current_pinned_message(chat_id),
)
.await;
// Загружаем черновик
app.load_draft();
app.status_message = None;
@@ -593,7 +680,10 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
let _ = app.td_client.set_draft_message(chat_id, draft_text).await;
} else if app.message_input.is_empty() {
// Очищаем черновик если инпут пустой
let _ = app.td_client.set_draft_message(chat_id, String::new()).await;
let _ = app
.td_client
.set_draft_message(chat_id, String::new())
.await;
}
}
app.close_chat();
@@ -616,7 +706,8 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
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;
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);
}
@@ -656,11 +747,15 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
// Запрашиваем доступные реакции
match timeout(
Duration::from_secs(5),
app.td_client.get_message_available_reactions(chat_id, message_id)
).await {
app.td_client
.get_message_available_reactions(chat_id, message_id),
)
.await
{
Ok(Ok(reactions)) => {
if reactions.is_empty() {
app.error_message = Some("Реакции недоступны для этого сообщения".to_string());
app.error_message =
Some("Реакции недоступны для этого сообщения".to_string());
app.status_message = None;
app.needs_redraw = true;
} else {
@@ -691,7 +786,8 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
if key.code == KeyCode::Char('u') && has_ctrl {
if let Some(chat_id) = app.selected_chat_id {
app.status_message = Some("Загрузка профиля...".to_string());
match timeout(Duration::from_secs(5), app.td_client.get_profile_info(chat_id)).await {
match timeout(Duration::from_secs(5), app.td_client.get_profile_info(chat_id)).await
{
Ok(Ok(profile)) => {
app.profile_info = Some(profile);
app.enter_profile_mode();
@@ -756,12 +852,15 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
app.cursor_position += 1;
// Отправляем typing status с throttling (не чаще 1 раза в 5 сек)
let should_send_typing = app.last_typing_sent
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.td_client
.send_chat_action(chat_id, ChatAction::Typing)
.await;
app.last_typing_sent = Some(Instant::now());
}
}
@@ -804,18 +903,29 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
// Проверяем, нужно ли подгрузить старые сообщения
if !app.td_client.current_chat_messages.is_empty() {
let oldest_msg_id = app.td_client.current_chat_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 app.message_scroll_offset > app.td_client.current_chat_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(
Duration::from_secs(3),
app.td_client.load_older_messages(chat_id, oldest_msg_id, 20)
).await {
app.td_client
.load_older_messages(chat_id, oldest_msg_id, 20),
)
.await
{
if !older.is_empty() {
// Добавляем старые сообщения в начало
let mut new_messages = older;
new_messages.extend(app.td_client.current_chat_messages.drain(..));
new_messages
.extend(app.td_client.current_chat_messages.drain(..));
app.td_client.current_chat_messages = new_messages;
}
}
@@ -848,7 +958,11 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
app.selected_folder_id = Some(folder_id);
// Загружаем чаты папки
app.status_message = Some("Загрузка чатов папки...".to_string());
let _ = timeout(Duration::from_secs(5), app.td_client.load_folder_chats(folder_id, 50)).await;
let _ = timeout(
Duration::from_secs(5),
app.td_client.load_folder_chats(folder_id, 50),
)
.await;
app.status_message = None;
}
}
@@ -880,8 +994,11 @@ fn get_available_actions_count(profile: &crate::tdlib::ProfileInfo) -> usize {
fn copy_to_clipboard(text: &str) -> Result<(), String> {
use arboard::Clipboard;
let mut clipboard = Clipboard::new().map_err(|e| format!("Не удалось инициализировать буфер обмена: {}", e))?;
clipboard.set_text(text).map_err(|e| format!("Не удалось скопировать: {}", e))?;
let mut clipboard =
Clipboard::new().map_err(|e| format!("Не удалось инициализировать буфер обмена: {}", e))?;
clipboard
.set_text(text)
.map_err(|e| format!("Не удалось скопировать: {}", e))?;
Ok(())
}

View File

@@ -46,11 +46,7 @@ async fn main() -> Result<(), io::Error> {
// Restore terminal
disable_raw_mode()?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
execute!(terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture)?;
terminal.show_cursor()?;
if let Err(err) = res {
@@ -156,7 +152,9 @@ async fn run_app<B: ratatui::backend::Backend>(
match event::read()? {
Event::Key(key) => {
// Global quit command
if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
if key.code == KeyCode::Char('c')
&& key.modifiers.contains(KeyModifiers::CONTROL)
{
// Graceful shutdown
should_stop.store(true, Ordering::Relaxed);
@@ -164,10 +162,7 @@ async fn run_app<B: ratatui::backend::Backend>(
let _ = tdlib_rs::functions::close(app.td_client.client_id()).await;
// Ждём завершения polling задачи (с таймаутом)
let _ = tokio::time::timeout(
Duration::from_secs(2),
polling_handle
).await;
let _ = tokio::time::timeout(Duration::from_secs(2), polling_handle).await;
return Ok(());
}

View File

@@ -1,7 +1,10 @@
use std::env;
use std::collections::HashMap;
use std::env;
use std::time::Instant;
use tdlib_rs::enums::{AuthorizationState, ChatAction, ChatList, ChatType, ConnectionState, MessageContent, MessageSender, SearchMessagesFilter, Update, User, UserStatus};
use tdlib_rs::enums::{
AuthorizationState, ChatAction, ChatList, ChatType, ConnectionState, MessageContent,
MessageSender, SearchMessagesFilter, Update, User, UserStatus,
};
use tdlib_rs::types::TextEntity;
/// Максимальный размер кэшей пользователей
@@ -311,7 +314,11 @@ impl TdClient {
/// Если сообщение с таким id уже есть — заменяет его (сохраняя reply_to)
pub fn push_message(&mut self, msg: MessageInfo) {
// Проверяем, есть ли уже сообщение с таким id
if let Some(idx) = self.current_chat_messages.iter().position(|m| m.id == msg.id) {
if let Some(idx) = self
.current_chat_messages
.iter()
.position(|m| m.id == msg.id)
{
// Если новое сообщение имеет reply_to, или старое не имеет — заменяем
if msg.reply_to.is_some() || self.current_chat_messages[idx].reply_to.is_none() {
self.current_chat_messages[idx] = msg;
@@ -350,7 +357,8 @@ impl TdClient {
/// Например: "Вася печатает..."
pub fn get_typing_text(&self) -> Option<String> {
self.typing_status.as_ref().map(|(user_id, action, _)| {
let name = self.user_names
let name = self
.user_names
.peek(user_id)
.cloned()
.unwrap_or_else(|| "Кто-то".to_string());
@@ -457,7 +465,9 @@ impl TdClient {
if update.position.order == 0 {
// Чат больше не в Main (перемещён в архив и т.д.)
self.chats.retain(|c| c.id != update.chat_id);
} else if let Some(chat) = self.chats.iter_mut().find(|c| c.id == update.chat_id) {
} else if let Some(chat) =
self.chats.iter_mut().find(|c| c.id == update.chat_id)
{
// Обновляем позицию существующего чата
chat.order = update.position.order;
chat.is_pinned = update.position.is_pinned;
@@ -493,7 +503,10 @@ impl TdClient {
let is_incoming = !msg_info.is_outgoing;
// Проверяем, есть ли уже сообщение с таким id
let existing_idx = self.current_chat_messages.iter().position(|m| m.id == msg_info.id);
let existing_idx = self
.current_chat_messages
.iter()
.position(|m| m.id == msg_info.id);
match existing_idx {
Some(idx) => {
@@ -505,8 +518,10 @@ impl TdClient {
// но сохраняем reply_to (добавленный при отправке)
let existing = &mut self.current_chat_messages[idx];
existing.can_be_edited = msg_info.can_be_edited;
existing.can_be_deleted_only_for_self = msg_info.can_be_deleted_only_for_self;
existing.can_be_deleted_for_all_users = msg_info.can_be_deleted_for_all_users;
existing.can_be_deleted_only_for_self =
msg_info.can_be_deleted_only_for_self;
existing.can_be_deleted_for_all_users =
msg_info.can_be_deleted_for_all_users;
existing.is_read = msg_info.is_read;
}
}
@@ -529,9 +544,8 @@ impl TdClient {
if user.first_name.is_empty() && user.last_name.is_empty() {
// Удаляем чаты с этим пользователем из списка
let user_id = user.id;
self.chats.retain(|c| {
self.chat_user_ids.get(&c.id) != Some(&user_id)
});
self.chats
.retain(|c| self.chat_user_ids.get(&c.id) != Some(&user_id));
return;
}
@@ -550,7 +564,8 @@ impl TdClient {
// Обновляем username в чатах, связанных с этим пользователем
for (&chat_id, &user_id) in &self.chat_user_ids.clone() {
if user_id == user.id {
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)
{
chat.username = Some(format!("@{}", username));
}
}
@@ -564,10 +579,7 @@ impl TdClient {
self.folders = update
.chat_folders
.into_iter()
.map(|f| FolderInfo {
id: f.id,
name: f.title,
})
.map(|f| FolderInfo { id: f.id, name: f.title })
.collect();
self.main_chat_list_position = update.main_chat_list_position;
}
@@ -607,14 +619,26 @@ impl TdClient {
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::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::UploadingDocument(_) => {
Some("отправляет файл...".to_string())
}
ChatAction::ChoosingSticker => Some("выбирает стикер...".to_string()),
ChatAction::RecordingVideoNote => Some("записывает видеосообщение...".to_string()),
ChatAction::UploadingVideoNote(_) => Some("отправляет видеосообщение...".to_string()),
ChatAction::RecordingVideoNote => {
Some("записывает видеосообщение...".to_string())
}
ChatAction::UploadingVideoNote(_) => {
Some("отправляет видеосообщение...".to_string())
}
ChatAction::Cancel => None, // Отмена — сбрасываем статус
_ => None,
};
@@ -633,7 +657,9 @@ impl TdClient {
if let Some(chat) = self.chats.iter_mut().find(|c| c.id == update.chat_id) {
chat.draft_text = update.draft_message.as_ref().and_then(|draft| {
// Извлекаем текст из InputMessageText
if let tdlib_rs::enums::InputMessageContent::InputMessageText(text_msg) = &draft.input_message_text {
if let tdlib_rs::enums::InputMessageContent::InputMessageText(text_msg) =
&draft.input_message_text
{
Some(text_msg.text.text.clone())
} else {
None
@@ -644,7 +670,11 @@ impl TdClient {
Update::MessageInteractionInfo(update) => {
// Обновляем реакции в текущем открытом чате
if Some(update.chat_id) == self.current_chat_id {
if let Some(msg) = self.current_chat_messages.iter_mut().find(|m| m.id == update.message_id) {
if let Some(msg) = self
.current_chat_messages
.iter_mut()
.find(|m| m.id == update.message_id)
{
// Извлекаем реакции из interaction_info
msg.reactions = update
.interaction_info
@@ -656,8 +686,12 @@ impl TdClient {
.iter()
.filter_map(|reaction| {
let emoji = match &reaction.r#type {
tdlib_rs::enums::ReactionType::Emoji(e) => e.emoji.clone(),
tdlib_rs::enums::ReactionType::CustomEmoji(_) => return None,
tdlib_rs::enums::ReactionType::Emoji(e) => {
e.emoji.clone()
}
tdlib_rs::enums::ReactionType::CustomEmoji(_) => {
return None
}
};
Some(ReactionInfo {
@@ -697,9 +731,10 @@ impl TdClient {
}
// Ищем позицию в Main списке (если есть)
let main_position = td_chat.positions.iter().find(|pos| {
matches!(pos.list, ChatList::Main)
});
let main_position = td_chat
.positions
.iter()
.find(|pos| matches!(pos.list, ChatList::Main));
// Получаем order и is_pinned из позиции, или используем значения по умолчанию
let (order, is_pinned) = main_position
@@ -716,7 +751,9 @@ impl TdClient {
let username = match &td_chat.r#type {
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 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);
@@ -724,7 +761,9 @@ impl TdClient {
}
self.chat_user_ids.insert(td_chat.id, private.user_id);
// Проверяем, есть ли уже username в кэше (peek не обновляет LRU)
self.user_usernames.peek(&private.user_id).map(|u| format!("@{}", u))
self.user_usernames
.peek(&private.user_id)
.map(|u| format!("@{}", u))
}
_ => None,
};
@@ -784,7 +823,13 @@ impl TdClient {
// Ограничиваем количество чатов
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) {
if let Some(min_idx) = self
.chats
.iter()
.enumerate()
.min_by_key(|(_, c)| c.order)
.map(|(i, _)| i)
{
self.chats.remove(min_idx);
}
}
@@ -891,11 +936,7 @@ impl TdClient {
.unwrap_or_default()
};
Some(ReplyInfo {
message_id: reply.message_id,
sender_name,
text,
})
Some(ReplyInfo { message_id: reply.message_id, sender_name, text })
}
_ => None,
}
@@ -905,10 +946,7 @@ impl TdClient {
fn extract_forward_info(&self, message: &TdMessage) -> Option<ForwardInfo> {
message.forward_info.as_ref().map(|info| {
let sender_name = self.get_origin_sender_name(&info.origin);
ForwardInfo {
sender_name,
date: info.date,
}
ForwardInfo { sender_name, date: info.date }
})
}
@@ -944,24 +982,24 @@ impl TdClient {
fn get_origin_sender_name(&self, origin: &tdlib_rs::enums::MessageOrigin) -> String {
use tdlib_rs::enums::MessageOrigin;
match origin {
MessageOrigin::User(u) => {
self.user_names.peek(&u.sender_user_id)
MessageOrigin::User(u) => self
.user_names
.peek(&u.sender_user_id)
.cloned()
.unwrap_or_else(|| format!("User_{}", u.sender_user_id))
}
MessageOrigin::Chat(c) => {
self.chats.iter()
.unwrap_or_else(|| format!("User_{}", u.sender_user_id)),
MessageOrigin::Chat(c) => self
.chats
.iter()
.find(|chat| chat.id == c.sender_chat_id)
.map(|chat| chat.title.clone())
.unwrap_or_else(|| "Чат".to_string())
}
.unwrap_or_else(|| "Чат".to_string()),
MessageOrigin::HiddenUser(h) => h.sender_name.clone(),
MessageOrigin::Channel(c) => {
self.chats.iter()
MessageOrigin::Channel(c) => self
.chats
.iter()
.find(|chat| chat.id == c.chat_id)
.map(|chat| chat.title.clone())
.unwrap_or_else(|| "Канал".to_string())
}
.unwrap_or_else(|| "Канал".to_string()),
}
}
@@ -1032,19 +1070,17 @@ impl TdClient {
functions::get_message(chat_id, msg_id, self.client_id).await
{
let sender_name = match &msg.sender_id {
tdlib_rs::enums::MessageSender::User(user) => {
self.user_names
tdlib_rs::enums::MessageSender::User(user) => self
.user_names
.get(&user.user_id)
.cloned()
.unwrap_or_else(|| format!("User_{}", user.user_id))
}
tdlib_rs::enums::MessageSender::Chat(chat) => {
self.chats
.unwrap_or_else(|| format!("User_{}", user.user_id)),
tdlib_rs::enums::MessageSender::Chat(chat) => self
.chats
.iter()
.find(|c| c.id == chat.chat_id)
.map(|c| c.title.clone())
.unwrap_or_else(|| "Чат".to_string())
}
.unwrap_or_else(|| "Чат".to_string()),
};
let (content, _) = extract_message_text_static(&msg);
reply_cache.insert(msg_id, (sender_name, content));
@@ -1068,12 +1104,7 @@ impl TdClient {
/// Отправка номера телефона
pub async fn send_phone_number(&mut self, phone: String) -> Result<(), String> {
let result = functions::set_authentication_phone_number(
phone,
None,
self.client_id,
)
.await;
let result = functions::set_authentication_phone_number(phone, None, self.client_id).await;
match result {
Ok(_) => Ok(()),
@@ -1103,12 +1134,7 @@ impl TdClient {
/// Загрузка списка чатов
pub async fn load_chats(&mut self, limit: i32) -> Result<(), String> {
let result = functions::load_chats(
Some(ChatList::Main),
limit,
self.client_id,
)
.await;
let result = functions::load_chats(Some(ChatList::Main), limit, self.client_id).await;
match result {
Ok(_) => Ok(()),
@@ -1118,16 +1144,10 @@ impl TdClient {
/// Загрузка чатов для конкретной папки
pub async fn load_folder_chats(&mut self, folder_id: i32, limit: i32) -> Result<(), String> {
let chat_list = ChatList::Folder(tdlib_rs::types::ChatListFolder {
chat_folder_id: folder_id,
});
let chat_list =
ChatList::Folder(tdlib_rs::types::ChatListFolder { chat_folder_id: folder_id });
let result = functions::load_chats(
Some(chat_list),
limit,
self.client_id,
)
.await;
let result = functions::load_chats(Some(chat_list), limit, self.client_id).await;
match result {
Ok(_) => Ok(()),
@@ -1279,7 +1299,11 @@ impl TdClient {
}
/// Поиск сообщений в чате по тексту
pub async fn search_messages(&mut self, chat_id: i64, query: &str) -> Result<Vec<MessageInfo>, String> {
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());
}
@@ -1359,8 +1383,12 @@ impl TdClient {
profile.online_status = Some(match user.status {
tdlib_rs::enums::UserStatus::Online(_) => "Онлайн".to_string(),
tdlib_rs::enums::UserStatus::Recently(_) => "Был(а) недавно".to_string(),
tdlib_rs::enums::UserStatus::LastWeek(_) => "Был(а) на этой неделе".to_string(),
tdlib_rs::enums::UserStatus::LastMonth(_) => "Был(а) в этом месяце".to_string(),
tdlib_rs::enums::UserStatus::LastWeek(_) => {
"Был(а) на этой неделе".to_string()
}
tdlib_rs::enums::UserStatus::LastMonth(_) => {
"Был(а) в этом месяце".to_string()
}
tdlib_rs::enums::UserStatus::Offline(offline) => {
crate::utils::format_was_online(offline.was_online)
}
@@ -1369,8 +1397,10 @@ impl TdClient {
}
// Bio (getUserFullInfo)
let full_info_result = functions::get_user_full_info(private_chat.user_id, self.client_id).await;
if let Ok(tdlib_rs::enums::UserFullInfo::UserFullInfo(full_info)) = full_info_result {
let full_info_result =
functions::get_user_full_info(private_chat.user_id, self.client_id).await;
if let Ok(tdlib_rs::enums::UserFullInfo::UserFullInfo(full_info)) = full_info_result
{
if let Some(bio_obj) = full_info.bio {
profile.bio = Some(bio_obj.text);
}
@@ -1381,14 +1411,21 @@ impl TdClient {
profile.is_group = true;
// Получаем информацию о группе
let group_result = functions::get_basic_group(basic_group.basic_group_id, self.client_id).await;
let group_result =
functions::get_basic_group(basic_group.basic_group_id, self.client_id).await;
if let Ok(tdlib_rs::enums::BasicGroup::BasicGroup(group)) = group_result {
profile.member_count = Some(group.member_count);
}
// Полная информация о группе
let full_info_result = functions::get_basic_group_full_info(basic_group.basic_group_id, self.client_id).await;
if let Ok(tdlib_rs::enums::BasicGroupFullInfo::BasicGroupFullInfo(full_info)) = full_info_result {
let full_info_result = functions::get_basic_group_full_info(
basic_group.basic_group_id,
self.client_id,
)
.await;
if let Ok(tdlib_rs::enums::BasicGroupFullInfo::BasicGroupFullInfo(full_info)) =
full_info_result
{
if !full_info.description.is_empty() {
profile.description = Some(full_info.description);
}
@@ -1399,9 +1436,14 @@ impl TdClient {
}
ChatType::Supergroup(supergroup) => {
// Получаем информацию о супергруппе
let sg_result = functions::get_supergroup(supergroup.supergroup_id, self.client_id).await;
let sg_result =
functions::get_supergroup(supergroup.supergroup_id, self.client_id).await;
if let Ok(tdlib_rs::enums::Supergroup::Supergroup(sg)) = sg_result {
profile.chat_type = if sg.is_channel { "Канал".to_string() } else { "Супергруппа".to_string() };
profile.chat_type = if sg.is_channel {
"Канал".to_string()
} else {
"Супергруппа".to_string()
};
profile.is_group = !sg.is_channel;
profile.member_count = Some(sg.member_count);
@@ -1414,8 +1456,12 @@ impl TdClient {
}
// Полная информация о супергруппе
let full_info_result = functions::get_supergroup_full_info(supergroup.supergroup_id, self.client_id).await;
if let Ok(tdlib_rs::enums::SupergroupFullInfo::SupergroupFullInfo(full_info)) = full_info_result {
let full_info_result =
functions::get_supergroup_full_info(supergroup.supergroup_id, self.client_id)
.await;
if let Ok(tdlib_rs::enums::SupergroupFullInfo::SupergroupFullInfo(full_info)) =
full_info_result
{
if !full_info.description.is_empty() {
profile.description = Some(full_info.description);
}
@@ -1497,11 +1543,9 @@ impl TdClient {
/// Получение моего user_id
pub async fn get_me(&self) -> Result<i64, String> {
match functions::get_me(self.client_id).await {
Ok(user) => {
match user {
Ok(user) => match user {
User::User(u) => Ok(u.id),
}
}
},
Err(e) => Err(format!("Ошибка получения профиля: {:?}", e)),
}
}
@@ -1513,30 +1557,37 @@ impl TdClient {
0, // message_thread_id
Some(action),
self.client_id,
).await;
)
.await;
}
/// Отправка текстового сообщения с поддержкой 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> {
use tdlib_rs::types::{FormattedText, InputMessageText, TextParseModeMarkdown, InputMessageReplyToMessage};
use tdlib_rs::enums::{InputMessageContent, TextParseMode, InputMessageReplyTo};
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::enums::{InputMessageContent, InputMessageReplyTo, TextParseMode};
use tdlib_rs::types::{
FormattedText, InputMessageReplyToMessage, InputMessageText, TextParseModeMarkdown,
};
// Парсим 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,
},
)
.await
{
Ok(tdlib_rs::enums::FormattedText::FormattedText(ft)) => {
FormattedText { text: ft.text, entities: ft.entities }
}
Err(_) => {
// Если парсинг не удался, отправляем как plain text
FormattedText {
text: text.clone(),
entities: vec![],
}
FormattedText { text: text.clone(), entities: vec![] }
}
};
@@ -1592,7 +1643,6 @@ impl TdClient {
}
}
/// Получить доступные реакции для сообщения
pub async fn get_message_available_reactions(
&mut self,
@@ -1653,9 +1703,9 @@ impl TdClient {
message_id: i64,
emoji: String,
) -> Result<(), String> {
use tdlib_rs::enums::ReactionType;
use tdlib_rs::functions;
use tdlib_rs::types::ReactionTypeEmoji;
use tdlib_rs::enums::ReactionType;
let reaction_type = ReactionType::Emoji(ReactionTypeEmoji { emoji });
@@ -1678,8 +1728,8 @@ impl TdClient {
/// Редактирование текстового сообщения с поддержкой Markdown
/// Устанавливает черновик для чата через TDLib API
pub async fn set_draft_message(&self, chat_id: i64, text: String) -> Result<(), String> {
use tdlib_rs::types::{FormattedText, InputMessageText, DraftMessage};
use tdlib_rs::enums::InputMessageContent;
use tdlib_rs::types::{DraftMessage, FormattedText, InputMessageText};
if text.is_empty() {
// Очищаем черновик
@@ -1688,7 +1738,8 @@ impl TdClient {
0, // message_thread_id
None, // draft_message (None = очистить)
self.client_id,
).await;
)
.await;
match result {
Ok(_) => Ok(()),
@@ -1696,10 +1747,7 @@ impl TdClient {
}
} else {
// Создаём черновик
let formatted_text = FormattedText {
text: text.clone(),
entities: vec![],
};
let formatted_text = FormattedText { text: text.clone(), entities: vec![] };
let input_message = InputMessageContent::InputMessageText(InputMessageText {
text: formatted_text,
@@ -1718,7 +1766,8 @@ impl TdClient {
0, // message_thread_id
Some(draft),
self.client_id,
).await;
)
.await;
match result {
Ok(_) => Ok(()),
@@ -1727,26 +1776,29 @@ impl TdClient {
}
}
pub async fn edit_message(&self, chat_id: i64, message_id: i64, text: String) -> Result<MessageInfo, String> {
use tdlib_rs::types::{FormattedText, InputMessageText, TextParseModeMarkdown};
pub async fn edit_message(
&self,
chat_id: i64,
message_id: i64,
text: String,
) -> Result<MessageInfo, String> {
use tdlib_rs::enums::{InputMessageContent, TextParseMode};
use tdlib_rs::types::{FormattedText, InputMessageText, TextParseModeMarkdown};
// Парсим 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,
},
)
.await
{
Ok(tdlib_rs::enums::FormattedText::FormattedText(ft)) => {
FormattedText { text: ft.text, entities: ft.entities }
}
Err(_) => {
// Если парсинг не удался, отправляем как plain text
FormattedText {
text: text.clone(),
entities: vec![],
}
FormattedText { text: text.clone(), entities: vec![] }
}
};
@@ -1756,13 +1808,8 @@ impl TdClient {
clear_draft: true,
});
let result = functions::edit_message_text(
chat_id,
message_id,
content,
self.client_id,
)
.await;
let result =
functions::edit_message_text(chat_id, message_id, content, self.client_id).await;
match result {
Ok(tdlib_rs::enums::Message::Message(msg)) => {
@@ -1790,14 +1837,13 @@ impl TdClient {
/// Удаление сообщений
/// 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;
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(()),
@@ -1806,7 +1852,12 @@ impl TdClient {
}
/// Пересылка сообщений
pub async fn forward_messages(&self, to_chat_id: i64, from_chat_id: i64, message_ids: Vec<i64>) -> Result<(), String> {
pub async fn forward_messages(
&self,
to_chat_id: i64,
from_chat_id: i64,
message_ids: Vec<i64>,
) -> Result<(), String> {
let result = functions::forward_messages(
to_chat_id,
0, // message_thread_id
@@ -1847,7 +1898,8 @@ impl TdClient {
const BATCH_SIZE: usize = 5;
// Убираем дубликаты и уже загруженные
self.pending_user_ids.retain(|id| !self.user_names.contains_key(id));
self.pending_user_ids
.retain(|id| !self.user_names.contains_key(id));
self.pending_user_ids.dedup();
// Берём последние BATCH_SIZE элементов
@@ -1885,16 +1937,17 @@ impl TdClient {
/// Статическая функция для извлечения текста и entities сообщения (без &self)
fn extract_message_text_static(message: &TdMessage) -> (String, Vec<TextEntity>) {
match &message.content {
MessageContent::MessageText(text) => {
(text.text.text.clone(), text.text.entities.clone())
}
MessageContent::MessageText(text) => (text.text.text.clone(), text.text.entities.clone()),
MessageContent::MessagePhoto(photo) => {
if photo.caption.text.is_empty() {
("[Фото]".to_string(), vec![])
} else {
// Добавляем смещение для "[Фото] " к entities
let prefix_len = "[Фото] ".chars().count() as i32;
let adjusted_entities: Vec<TextEntity> = photo.caption.entities.iter()
let adjusted_entities: Vec<TextEntity> = photo
.caption
.entities
.iter()
.map(|e| TextEntity {
offset: e.offset + prefix_len,
length: e.length,
@@ -1909,7 +1962,10 @@ fn extract_message_text_static(message: &TdMessage) -> (String, Vec<TextEntity>)
("[Видео]".to_string(), vec![])
} else {
let prefix_len = "[Видео] ".chars().count() as i32;
let adjusted_entities: Vec<TextEntity> = video.caption.entities.iter()
let adjusted_entities: Vec<TextEntity> = video
.caption
.entities
.iter()
.map(|e| TextEntity {
offset: e.offset + prefix_len,
length: e.length,
@@ -1932,7 +1988,10 @@ fn extract_message_text_static(message: &TdMessage) -> (String, Vec<TextEntity>)
("[GIF]".to_string(), vec![])
} else {
let prefix_len = "[GIF] ".chars().count() as i32;
let adjusted_entities: Vec<TextEntity> = anim.caption.entities.iter()
let adjusted_entities: Vec<TextEntity> = anim
.caption
.entities
.iter()
.map(|e| TextEntity {
offset: e.offset + prefix_len,
length: e.length,
@@ -1942,9 +2001,7 @@ fn extract_message_text_static(message: &TdMessage) -> (String, Vec<TextEntity>)
(format!("[GIF] {}", anim.caption.text), adjusted_entities)
}
}
MessageContent::MessageAudio(audio) => {
(format!("[Аудио: {}]", audio.audio.title), vec![])
}
MessageContent::MessageAudio(audio) => (format!("[Аудио: {}]", audio.audio.title), vec![]),
MessageContent::MessageCall(_) => ("[Звонок]".to_string(), vec![]),
MessageContent::MessagePoll(poll) => {
(format!("[Опрос: {}]", poll.poll.question.text), vec![])

View File

@@ -1,13 +1,13 @@
pub mod client;
pub use client::TdClient;
pub use client::UserOnlineStatus;
pub use client::ChatInfo;
pub use client::FolderInfo;
pub use client::ForwardInfo;
pub use client::MessageInfo;
pub use client::NetworkState;
pub use client::ProfileInfo;
pub use client::ChatInfo;
pub use client::MessageInfo;
pub use client::ReactionInfo;
pub use client::ReplyInfo;
pub use client::ForwardInfo;
pub use client::FolderInfo;
pub use client::TdClient;
pub use client::UserOnlineStatus;
pub use tdlib_rs::enums::ChatAction;

View File

@@ -1,3 +1,5 @@
use crate::app::App;
use crate::tdlib::client::AuthState;
use ratatui::{
layout::{Alignment, Constraint, Direction, Layout},
style::{Color, Modifier, Style},
@@ -5,8 +7,6 @@ use ratatui::{
widgets::{Block, Borders, Paragraph},
Frame,
};
use crate::app::App;
use crate::tdlib::client::AuthState;
pub fn render(f: &mut Frame, app: &App) {
let area = f.area();

View File

@@ -1,11 +1,11 @@
use crate::app::App;
use crate::tdlib::UserOnlineStatus;
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
widgets::{Block, Borders, List, ListItem, Paragraph},
Frame,
};
use crate::app::App;
use crate::tdlib::UserOnlineStatus;
pub fn render(f: &mut Frame, area: Rect, app: &mut App) {
let chat_chunks = Layout::default()
@@ -54,7 +54,9 @@ pub fn render(f: &mut Frame, area: Rect, app: &mut App) {
let prefix = if is_selected { "" } else { " " };
let username_text = chat.username.as_ref()
let username_text = chat
.username
.as_ref()
.map(|u| format!(" {}", u))
.unwrap_or_default();
@@ -78,7 +80,18 @@ pub fn render(f: &mut Frame, area: Rect, app: &mut App) {
String::new()
};
let content = format!("{}{}{}{}{}{}{}{}{}", prefix, status_icon, pin_icon, mute_icon, chat.title, username_text, mention_badge, draft_badge, unread_badge);
let content = format!(
"{}{}{}{}{}{}{}{}{}",
prefix,
status_icon,
pin_icon,
mute_icon,
chat.title,
username_text,
mention_badge,
draft_badge,
unread_badge
);
// Цвет: онлайн — зелёные, остальные — белые
let style = match app.td_client.get_user_status_by_chat_id(chat.id) {
@@ -100,9 +113,7 @@ pub fn render(f: &mut Frame, area: Rect, app: &mut App) {
Block::default().borders(Borders::ALL)
};
let chats_list = List::new(items)
.block(block)
.highlight_style(
let chats_list = List::new(items).block(block).highlight_style(
Style::default()
.add_modifier(Modifier::ITALIC)
.fg(Color::Yellow),
@@ -119,8 +130,12 @@ pub fn render(f: &mut Frame, area: Rect, app: &mut App) {
let formatted = format_was_online(*was_online);
(formatted, Color::Gray)
}
Some(UserOnlineStatus::LastWeek) => ("был(а) на этой неделе".to_string(), Color::DarkGray),
Some(UserOnlineStatus::LastMonth) => ("был(а) в этом месяце".to_string(), Color::DarkGray),
Some(UserOnlineStatus::LastWeek) => {
("был(а) на этой неделе".to_string(), Color::DarkGray)
}
Some(UserOnlineStatus::LastMonth) => {
("был(а) в этом месяце".to_string(), Color::DarkGray)
}
Some(UserOnlineStatus::LongTimeAgo) => ("был(а) давно".to_string(), Color::DarkGray),
None => ("".to_string(), Color::DarkGray), // Для групп/каналов
}
@@ -131,14 +146,22 @@ pub fn render(f: &mut Frame, area: Rect, app: &mut App) {
if let Some(chat) = filtered.get(i) {
match app.td_client.get_user_status_by_chat_id(chat.id) {
Some(UserOnlineStatus::Online) => ("● онлайн".to_string(), Color::Green),
Some(UserOnlineStatus::Recently) => ("был(а) недавно".to_string(), Color::Yellow),
Some(UserOnlineStatus::Recently) => {
("был(а) недавно".to_string(), Color::Yellow)
}
Some(UserOnlineStatus::Offline(was_online)) => {
let formatted = format_was_online(*was_online);
(formatted, Color::Gray)
}
Some(UserOnlineStatus::LastWeek) => ("был(а) на этой неделе".to_string(), Color::DarkGray),
Some(UserOnlineStatus::LastMonth) => ("был(а) в этом месяце".to_string(), Color::DarkGray),
Some(UserOnlineStatus::LongTimeAgo) => ("был(а) давно".to_string(), Color::DarkGray),
Some(UserOnlineStatus::LastWeek) => {
("был(а) на этой неделе".to_string(), Color::DarkGray)
}
Some(UserOnlineStatus::LastMonth) => {
("был(а) в этом месяце".to_string(), Color::DarkGray)
}
Some(UserOnlineStatus::LongTimeAgo) => {
("был(а) давно".to_string(), Color::DarkGray)
}
None => ("".to_string(), Color::DarkGray),
}
} else {

View File

@@ -1,11 +1,11 @@
use crate::app::App;
use crate::tdlib::NetworkState;
use ratatui::{
layout::Rect,
style::{Color, Style},
widgets::Paragraph,
Frame,
};
use crate::app::App;
use crate::tdlib::NetworkState;
pub fn render(f: &mut Frame, area: Rect, app: &App) {
// Индикатор состояния сети
@@ -26,7 +26,10 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) {
} else if app.selected_chat_id.is_some() {
format!(" {}↑/↓: Scroll | Ctrl+U: Profile | Enter: Send | Esc: Close | Ctrl+R: Refresh | Ctrl+C: Quit ", network_indicator)
} else {
format!(" {}↑/↓: Navigate | Enter: Open | Ctrl+S: Search | Ctrl+R: Refresh | Ctrl+C: Quit ", network_indicator)
format!(
" {}↑/↓: Navigate | Enter: Open | Ctrl+S: Search | Ctrl+R: Refresh | Ctrl+C: Quit ",
network_indicator
)
};
let style = if matches!(app.td_client.network_state, NetworkState::WaitingForNetwork) {

View File

@@ -1,10 +1,10 @@
use crate::app::App;
use ratatui::{
layout::{Alignment, Constraint, Direction, Layout},
style::{Color, Modifier, Style},
widgets::{Block, Borders, Paragraph},
Frame,
};
use crate::app::App;
pub fn render(f: &mut Frame, app: &App) {
let area = f.area();
@@ -18,10 +18,7 @@ pub fn render(f: &mut Frame, app: &App) {
])
.split(area);
let message = app
.status_message
.as_deref()
.unwrap_or("Загрузка...");
let message = app.status_message.as_deref().unwrap_or("Загрузка...");
let loading = Paragraph::new(message)
.style(
@@ -30,11 +27,7 @@ pub fn render(f: &mut Frame, app: &App) {
.add_modifier(Modifier::BOLD),
)
.alignment(Alignment::Center)
.block(
Block::default()
.borders(Borders::ALL)
.title(" TTUI "),
);
.block(Block::default().borders(Borders::ALL).title(" TTUI "));
f.render_widget(loading, chunks[1]);
}

View File

@@ -1,3 +1,5 @@
use super::{chat_list, footer, messages};
use crate::app::App;
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
@@ -5,8 +7,6 @@ use ratatui::{
widgets::{Block, Borders, Paragraph},
Frame,
};
use crate::app::App;
use super::{chat_list, messages, footer};
/// Порог ширины для компактного режима (одна панель)
const COMPACT_WIDTH: u16 = 80;
@@ -81,11 +81,8 @@ fn render_folders(f: &mut Frame, area: Rect, app: &App) {
}
let folders_line = Line::from(spans);
let folders_widget = Paragraph::new(folders_line).block(
Block::default()
.title(" TTUI ")
.borders(Borders::ALL),
);
let folders_widget =
Paragraph::new(folders_line).block(Block::default().title(" TTUI ").borders(Borders::ALL));
f.render_widget(folders_widget, area);
}

View File

@@ -1,3 +1,5 @@
use crate::app::App;
use crate::utils::{format_date, format_timestamp_with_tz, get_day};
use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
@@ -5,8 +7,6 @@ use ratatui::{
widgets::{Block, Borders, Paragraph},
Frame,
};
use crate::app::App;
use crate::utils::{format_timestamp_with_tz, format_date, get_day};
use tdlib_rs::enums::TextEntityType;
use tdlib_rs::types::TextEntity;
@@ -58,9 +58,16 @@ impl CharStyle {
}
/// Преобразует текст с entities в вектор стилизованных Span (owned)
fn format_text_with_entities(text: &str, entities: &[TextEntity], base_color: Color) -> Vec<Span<'static>> {
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))];
return vec![Span::styled(
text.to_string(),
Style::default().fg(base_color),
)];
}
// Создаём массив стилей для каждого символа
@@ -82,9 +89,13 @@ fn format_text_with_entities(text: &str, entities: &[TextEntity], base_color: Co
char_styles[i].code = true
}
TextEntityType::Spoiler => char_styles[i].spoiler = true,
TextEntityType::Url | TextEntityType::TextUrl(_) | TextEntityType::EmailAddress
TextEntityType::Url
| TextEntityType::TextUrl(_)
| TextEntityType::EmailAddress
| TextEntityType::PhoneNumber => char_styles[i].url = true,
TextEntityType::Mention | TextEntityType::MentionName(_) => char_styles[i].mention = true,
TextEntityType::Mention | TextEntityType::MentionName(_) => {
char_styles[i].mention = true
}
_ => {}
}
}
@@ -144,7 +155,12 @@ fn styles_equal(a: &CharStyle, b: &CharStyle) -> bool {
}
/// Рендерит текст инпута с блочным курсором
fn render_input_with_cursor(prefix: &str, text: &str, cursor_pos: usize, color: Color) -> Line<'static> {
fn render_input_with_cursor(
prefix: &str,
text: &str,
cursor_pos: usize,
color: Color,
) -> Line<'static> {
let chars: Vec<char> = text.chars().collect();
let mut spans: Vec<Span> = vec![Span::raw(prefix.to_string())];
@@ -186,10 +202,7 @@ struct WrappedLine {
/// Возвращает строки с информацией о позициях для корректного применения entities
fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec<WrappedLine> {
if max_width == 0 {
return vec![WrappedLine {
text: text.to_string(),
start_offset: 0,
}];
return vec![WrappedLine { text: text.to_string(), start_offset: 0 }];
}
let mut result = Vec::new();
@@ -263,10 +276,7 @@ fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec<WrappedLine> {
}
if result.is_empty() {
result.push(WrappedLine {
text: String::new(),
start_offset: 0,
});
result.push(WrappedLine { text: String::new(), start_offset: 0 });
}
result
@@ -368,24 +378,28 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) {
};
// Chat header с typing status
let typing_action = app.td_client.typing_status.as_ref().map(|(_, action, _)| action.clone());
let typing_action = app
.td_client
.typing_status
.as_ref()
.map(|(_, action, _)| action.clone());
let header_line = if let Some(action) = typing_action {
// Показываем typing status: "👤 Имя @username печатает..."
let mut spans = vec![
Span::styled(
let mut spans = vec![Span::styled(
format!("👤 {}", chat.title),
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
),
];
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!(" {}", username), Style::default().fg(Color::Gray)));
}
spans.push(Span::styled(
format!(" {}", action),
Style::default().fg(Color::Yellow).add_modifier(Modifier::ITALIC),
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::ITALIC),
));
Line::from(spans)
} else {
@@ -396,17 +410,22 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) {
};
Line::from(Span::styled(
header_text,
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
))
};
let header = Paragraph::new(header_line)
.block(Block::default().borders(Borders::ALL));
let header = Paragraph::new(header_line).block(Block::default().borders(Borders::ALL));
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 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";
@@ -421,8 +440,8 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) {
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)));
let pinned_bar =
Paragraph::new(pinned_line).style(Style::default().bg(Color::Rgb(40, 20, 40)));
f.render_widget(pinned_bar, message_chunks[1]);
}
@@ -484,9 +503,13 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) {
}
let sender_style = if msg.is_outgoing {
Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD)
};
if msg.is_outgoing {
@@ -540,16 +563,21 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) {
]));
} else {
// Forward слева для входящих
lines.push(Line::from(vec![
Span::styled(forward_line, Style::default().fg(Color::Magenta)),
]));
lines.push(Line::from(vec![Span::styled(
forward_line,
Style::default().fg(Color::Magenta),
)]));
}
}
// Отображаем reply если есть
if let Some(reply) = &msg.reply_to {
let reply_text: String = reply.text.chars().take(40).collect();
let ellipsis = if reply.text.chars().count() > 40 { "..." } else { "" };
let ellipsis = if reply.text.chars().count() > 40 {
"..."
} else {
""
};
let reply_line = format!("{}: {}{}", reply.sender_name, reply_text, ellipsis);
let reply_len = reply_line.chars().count();
@@ -562,9 +590,10 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) {
]));
} else {
// Reply слева для входящих
lines.push(Line::from(vec![
Span::styled(reply_line, Style::default().fg(Color::Cyan)),
]));
lines.push(Line::from(vec![Span::styled(
reply_line,
Style::default().fg(Color::Cyan),
)]));
}
}
@@ -593,11 +622,8 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) {
);
// Форматируем текст с entities
let formatted_spans = format_text_with_entities(
&wrapped.text,
&line_entities,
msg_color,
);
let formatted_spans =
format_text_with_entities(&wrapped.text, &line_entities, msg_color);
if is_last_line {
// Последняя строка — добавляем time_mark
@@ -605,17 +631,30 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) {
let padding = content_width.saturating_sub(full_len + 1);
let mut line_spans = vec![Span::raw(" ".repeat(padding))];
if is_selected {
line_spans.push(Span::styled(selection_marker, Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)));
line_spans.push(Span::styled(
selection_marker,
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
));
}
line_spans.extend(formatted_spans);
line_spans.push(Span::styled(format!(" {}", time_mark), Style::default().fg(Color::Gray)));
line_spans.push(Span::styled(
format!(" {}", time_mark),
Style::default().fg(Color::Gray),
));
lines.push(Line::from(line_spans));
} else {
// Промежуточные строки — просто текст справа
let padding = content_width.saturating_sub(line_len + marker_len + 1);
let mut line_spans = vec![Span::raw(" ".repeat(padding))];
if i == 0 && is_selected {
line_spans.push(Span::styled(selection_marker, Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)));
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));
@@ -643,19 +682,24 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) {
);
// Форматируем текст с entities
let formatted_spans = format_text_with_entities(
&wrapped.text,
&line_entities,
msg_color,
);
let formatted_spans =
format_text_with_entities(&wrapped.text, &line_entities, msg_color);
if i == 0 {
// Первая строка — с временем и маркером выбора
let mut line_spans = vec![];
if is_selected {
line_spans.push(Span::styled(selection_marker, Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)));
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::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));
@@ -694,9 +738,11 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) {
};
let style = if reaction.is_chosen {
Style::default().fg(app.config.parse_color(&app.config.colors.reaction_chosen))
Style::default()
.fg(app.config.parse_color(&app.config.colors.reaction_chosen))
} else {
Style::default().fg(app.config.parse_color(&app.config.colors.reaction_other))
Style::default()
.fg(app.config.parse_color(&app.config.colors.reaction_other))
};
reaction_spans.push(Span::styled(reaction_text, style));
@@ -723,10 +769,7 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) {
}
if lines.is_empty() {
lines.push(Line::from(Span::styled(
"Нет сообщений",
Style::default().fg(Color::Gray),
)));
lines.push(Line::from(Span::styled("Нет сообщений", Style::default().fg(Color::Gray))));
}
// Вычисляем скролл с учётом пользовательского offset
@@ -769,10 +812,15 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) {
// Input box с wrap для длинного текста и блочным курсором
let (input_line, input_title) = if app.is_forwarding() {
// Режим пересылки - показываем превью сообщения
let forward_preview = app.get_forwarding_message()
let forward_preview = app
.get_forwarding_message()
.map(|m| {
let text_preview: String = m.content.chars().take(40).collect();
let ellipsis = if m.content.chars().count() > 40 { "..." } else { "" };
let ellipsis = if m.content.chars().count() > 40 {
"..."
} else {
""
};
format!("{}{}", text_preview, ellipsis)
})
.unwrap_or_else(|| "↪ ...".to_string());
@@ -782,8 +830,12 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) {
} else if app.is_selecting_message() {
// Режим выбора сообщения - подсказка зависит от возможностей
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 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 ред. · r ответ · f перслть · y копир. · d удал. · Esc",
@@ -791,7 +843,10 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) {
(false, true) => "↑↓ · r ответ · f переслать · y копир. · d удалить · Esc",
(false, false) => "↑↓ · r ответить · f переслать · y копировать · Esc",
};
(Line::from(Span::styled(hint, Style::default().fg(Color::Cyan))), " Выбор сообщения ")
(
Line::from(Span::styled(hint, Style::default().fg(Color::Cyan))),
" Выбор сообщения ",
)
} else if app.is_editing() {
// Режим редактирования
if app.message_input.is_empty() {
@@ -804,16 +859,30 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) {
(line, " Редактирование (Esc отмена) ")
} else {
// Текст с курсором
let line = render_input_with_cursor("", &app.message_input, app.cursor_position, Color::Magenta);
let line = render_input_with_cursor(
"",
&app.message_input,
app.cursor_position,
Color::Magenta,
);
(line, " Редактирование (Esc отмена) ")
}
} else if app.is_replying() {
// Режим ответа на сообщение
let reply_preview = app.get_replying_to_message()
let reply_preview = app
.get_replying_to_message()
.map(|m| {
let sender = if m.is_outgoing { "Вы" } else { &m.sender_name };
let sender = if m.is_outgoing {
"Вы"
} else {
&m.sender_name
};
let text_preview: String = m.content.chars().take(30).collect();
let ellipsis = if m.content.chars().count() > 30 { "..." } else { "" };
let ellipsis = if m.content.chars().count() > 30 {
"..."
} else {
""
};
format!("{}: {}{}", sender, text_preview, ellipsis)
})
.unwrap_or_else(|| "...".to_string());
@@ -829,7 +898,12 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) {
} else {
let short_preview: String = reply_preview.chars().take(15).collect();
let prefix = format!("{} > ", short_preview);
let line = render_input_with_cursor(&prefix, &app.message_input, app.cursor_position, Color::Yellow);
let line = render_input_with_cursor(
&prefix,
&app.message_input,
app.cursor_position,
Color::Yellow,
);
(line, " Ответ (Esc отмена) ")
}
} else {
@@ -844,7 +918,12 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) {
(line, "")
} else {
// Текст с курсором
let line = render_input_with_cursor("> ", &app.message_input, app.cursor_position, Color::Yellow);
let line = render_input_with_cursor(
"> ",
&app.message_input,
app.cursor_position,
Color::Yellow,
);
(line, "")
}
};
@@ -860,7 +939,11 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) {
Block::default()
.borders(Borders::ALL)
.title(input_title)
.title_style(Style::default().fg(title_color).add_modifier(Modifier::BOLD))
.title_style(
Style::default()
.fg(title_color)
.add_modifier(Modifier::BOLD),
)
};
let input = Paragraph::new(input_line)
@@ -882,7 +965,12 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) {
// Модалка выбора реакции
if app.is_reaction_picker_mode() {
render_reaction_picker_modal(f, area, &app.available_reactions, app.selected_reaction_index);
render_reaction_picker_modal(
f,
area,
&app.available_reactions,
app.selected_reaction_index,
);
}
}
@@ -899,7 +987,11 @@ fn render_search_mode(f: &mut Frame, area: Rect, app: &App) {
// Search input
let total = app.message_search_results.len();
let current = if total > 0 { app.selected_search_result_index + 1 } else { 0 };
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![
@@ -916,13 +1008,16 @@ fn render_search_mode(f: &mut Frame, area: Rect, app: &App) {
])
};
let search_input = Paragraph::new(input_line)
.block(
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))
.title_style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
);
f.render_widget(search_input, chunks[0]);
@@ -948,14 +1043,29 @@ fn render_search_mode(f: &mut Frame, area: Rect, app: &App) {
// Маркер выбора, имя и дата
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() };
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(
marker,
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!("{} ", sender_name),
Style::default().fg(sender_color).add_modifier(Modifier::BOLD),
Style::default()
.fg(sender_color)
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!("({})", crate::utils::format_datetime(msg.date)),
@@ -964,7 +1074,11 @@ fn render_search_mode(f: &mut Frame, area: Rect, app: &App) {
]));
// Текст сообщения (с переносом)
let msg_color = if is_selected { Color::Yellow } else { Color::White };
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();
@@ -998,20 +1112,35 @@ fn render_search_mode(f: &mut Frame, area: Rect, app: &App) {
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Yellow))
.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::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::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::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)),
@@ -1021,7 +1150,7 @@ fn render_search_mode(f: &mut Frame, area: Rect, app: &App) {
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Yellow))
.border_style(Style::default().fg(Color::Yellow)),
)
.alignment(Alignment::Center);
f.render_widget(help, chunks[2]);
@@ -1046,9 +1175,13 @@ fn render_pinned_mode(f: &mut Frame, area: Rect, app: &App) {
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Magenta))
.border_style(Style::default().fg(Color::Magenta)),
)
.style(Style::default().fg(Color::Magenta).add_modifier(Modifier::BOLD));
.style(
Style::default()
.fg(Color::Magenta)
.add_modifier(Modifier::BOLD),
);
f.render_widget(header, chunks[0]);
// Pinned messages list
@@ -1065,14 +1198,29 @@ fn render_pinned_mode(f: &mut Frame, area: Rect, app: &App) {
// Маркер выбора и имя отправителя
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() };
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(
marker,
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!("{} ", sender_name),
Style::default().fg(sender_color).add_modifier(Modifier::BOLD),
Style::default()
.fg(sender_color)
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!("({})", crate::utils::format_datetime(msg.date)),
@@ -1081,12 +1229,17 @@ fn render_pinned_mode(f: &mut Frame, area: Rect, app: &App) {
]));
// Текст сообщения (с переносом)
let msg_color = if is_selected { Color::Yellow } else { Color::White };
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 строки на сообщение
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)),
@@ -1121,17 +1274,27 @@ fn render_pinned_mode(f: &mut Frame, area: Rect, app: &App) {
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Magenta))
.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::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::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)),
@@ -1141,7 +1304,7 @@ fn render_pinned_mode(f: &mut Frame, area: Rect, app: &App) {
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Magenta))
.border_style(Style::default().fg(Color::Magenta)),
)
.alignment(Alignment::Center);
f.render_widget(help, chunks[2]);
@@ -1169,11 +1332,18 @@ fn render_delete_confirm_modal(f: &mut Frame, area: Rect) {
Line::from(""),
Line::from(Span::styled(
"Удалить сообщение?",
Style::default().fg(Color::White).add_modifier(Modifier::BOLD),
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::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)),
@@ -1194,9 +1364,13 @@ fn render_delete_confirm_modal(f: &mut Frame, area: Rect) {
f.render_widget(modal, modal_area);
}
/// Рендерит модалку выбора реакции
fn render_reaction_picker_modal(f: &mut Frame, area: Rect, available_reactions: &[String], selected_index: usize) {
fn render_reaction_picker_modal(
f: &mut Frame,
area: Rect,
available_reactions: &[String],
selected_index: usize,
) {
use ratatui::widgets::Clear;
// Размеры модалки (зависят от количества реакций)
@@ -1248,9 +1422,19 @@ fn render_reaction_picker_modal(f: &mut Frame, area: Rect, available_reactions:
// Добавляем пустую строку и подсказку
text_lines.push(Line::from(""));
text_lines.push(Line::from(vec![
Span::styled(" [←/→/↑/↓] ", Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)),
Span::styled(
" [←/→/↑/↓] ",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::raw("Выбор "),
Span::styled(" [Enter] ", Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)),
Span::styled(
" [Enter] ",
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
),
Span::raw("Добавить "),
Span::styled(" [Esc] ", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)),
Span::raw("Отмена"),
@@ -1262,7 +1446,11 @@ fn render_reaction_picker_modal(f: &mut Frame, area: Rect, available_reactions:
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Yellow))
.title(" Выбери реакцию ")
.title_style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
.title_style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
)
.alignment(Alignment::Left);

View File

@@ -1,16 +1,16 @@
mod loading;
mod auth;
mod main_screen;
pub mod chat_list;
pub mod messages;
pub mod footer;
mod loading;
mod main_screen;
pub mod messages;
pub mod profile;
use ratatui::Frame;
use crate::app::{App, AppScreen};
use ratatui::layout::Alignment;
use ratatui::style::{Color, Modifier, Style};
use ratatui::widgets::Paragraph;
use crate::app::{App, AppScreen};
use ratatui::Frame;
/// Минимальная высота терминала
const MIN_HEIGHT: u16 = 10;
@@ -34,12 +34,13 @@ pub fn render(f: &mut Frame, app: &mut App) {
}
fn render_size_warning(f: &mut Frame, width: u16, height: u16) {
let message = format!(
"{}x{}\nМинимум: {}x{}",
width, height, MIN_WIDTH, MIN_HEIGHT
);
let message = format!("{}x{}\nМинимум: {}x{}", width, height, MIN_WIDTH, MIN_HEIGHT);
let warning = Paragraph::new(message)
.style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD))
.style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)
.alignment(Alignment::Center);
f.render_widget(warning, f.area());
}

View File

@@ -1,3 +1,5 @@
use crate::app::App;
use crate::tdlib::client::ProfileInfo;
use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
@@ -5,8 +7,6 @@ use ratatui::{
widgets::{Block, Borders, Paragraph},
Frame,
};
use crate::app::App;
use crate::tdlib::client::ProfileInfo;
/// Рендерит режим просмотра профиля
pub fn render(f: &mut Frame, area: Rect, app: &App, profile: &ProfileInfo) {
@@ -32,9 +32,13 @@ pub fn render(f: &mut Frame, area: Rect, app: &App, profile: &ProfileInfo) {
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan))
.border_style(Style::default().fg(Color::Cyan)),
)
.style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD));
.style(
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
);
f.render_widget(header, chunks[0]);
// Profile info
@@ -83,9 +87,7 @@ pub fn render(f: &mut Frame, area: Rect, app: &App, profile: &ProfileInfo) {
// Bio (только для личных чатов)
if let Some(bio) = &profile.bio {
lines.push(Line::from(vec![
Span::styled("О себе: ", Style::default().fg(Color::Gray)),
]));
lines.push(Line::from(vec![Span::styled("О себе: ", Style::default().fg(Color::Gray))]));
// Разбиваем bio на строки если длинное
let bio_lines: Vec<&str> = bio.lines().collect();
for bio_line in bio_lines {
@@ -105,9 +107,7 @@ pub fn render(f: &mut Frame, area: Rect, app: &App, profile: &ProfileInfo) {
// Description (для групп/каналов)
if let Some(desc) = &profile.description {
lines.push(Line::from(vec![
Span::styled("Описание: ", Style::default().fg(Color::Gray)),
]));
lines.push(Line::from(vec![Span::styled("Описание: ", Style::default().fg(Color::Gray))]));
let desc_lines: Vec<&str> = desc.lines().collect();
for desc_line in desc_lines {
lines.push(Line::from(Span::styled(desc_line, Style::default().fg(Color::White))));
@@ -119,7 +119,12 @@ pub fn render(f: &mut Frame, area: Rect, app: &App, profile: &ProfileInfo) {
if let Some(link) = &profile.invite_link {
lines.push(Line::from(vec![
Span::styled("Ссылка: ", Style::default().fg(Color::Gray)),
Span::styled(link, Style::default().fg(Color::Blue).add_modifier(Modifier::UNDERLINED)),
Span::styled(
link,
Style::default()
.fg(Color::Blue)
.add_modifier(Modifier::UNDERLINED),
),
]));
lines.push(Line::from(""));
}
@@ -131,7 +136,9 @@ pub fn render(f: &mut Frame, area: Rect, app: &App, profile: &ProfileInfo) {
// Действия
lines.push(Line::from(Span::styled(
"Действия:",
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD),
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)));
lines.push(Line::from(""));
@@ -140,7 +147,9 @@ pub fn render(f: &mut Frame, area: Rect, app: &App, profile: &ProfileInfo) {
let is_selected = idx == app.selected_profile_action;
let marker = if is_selected { "" } else { " " };
let style = if is_selected {
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::White)
};
@@ -154,17 +163,27 @@ pub fn render(f: &mut Frame, area: Rect, app: &App, profile: &ProfileInfo) {
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan))
.border_style(Style::default().fg(Color::Cyan)),
)
.scroll((0, 0));
f.render_widget(info_widget, chunks[1]);
// Help bar
let help_line = Line::from(vec![
Span::styled(" ↑↓ ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
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::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)),
@@ -174,7 +193,7 @@ pub fn render(f: &mut Frame, area: Rect, app: &App, profile: &ProfileInfo) {
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan))
.border_style(Style::default().fg(Color::Cyan)),
)
.alignment(Alignment::Center);
f.render_widget(help, chunks[2]);
@@ -212,12 +231,19 @@ fn render_leave_confirmation_modal(f: &mut Frame, area: Rect, step: u8) {
Line::from(""),
Line::from(Span::styled(
text,
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD),
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)),
Line::from(""),
Line::from(""),
Line::from(vec![
Span::styled("y/н/Enter", Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)),
Span::styled(
"y/н/Enter",
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
),
Span::raw(" — да "),
Span::styled("n/т/Esc", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)),
Span::raw(" — нет"),
@@ -230,7 +256,7 @@ fn render_leave_confirmation_modal(f: &mut Frame, area: Rect, step: u8) {
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Red))
.title(" ⚠ ВНИМАНИЕ ")
.title_style(Style::default().fg(Color::Red).add_modifier(Modifier::BOLD))
.title_style(Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)),
)
.alignment(Alignment::Center);

View File

@@ -2,9 +2,9 @@
mod helpers;
use helpers::test_data::{TestChatBuilder, create_test_chat};
use helpers::app_builder::TestAppBuilder;
use helpers::snapshot_utils::{render_to_buffer, buffer_to_string};
use helpers::snapshot_utils::{buffer_to_string, render_to_buffer};
use helpers::test_data::{create_test_chat, TestChatBuilder};
use insta::assert_snapshot;
#[test]
@@ -44,9 +44,7 @@ fn snapshot_chat_with_unread_count() {
.last_message("Привет, как дела?")
.build();
let mut app = TestAppBuilder::new()
.with_chat(chat)
.build();
let mut app = TestAppBuilder::new().with_chat(chat).build();
let buffer = render_to_buffer(80, 24, |f| {
tele_tui::ui::chat_list::render(f, f.area(), &mut app);
@@ -63,9 +61,7 @@ fn snapshot_chat_with_pinned() {
.last_message("Pinned message")
.build();
let mut app = TestAppBuilder::new()
.with_chat(chat)
.build();
let mut app = TestAppBuilder::new().with_chat(chat).build();
let buffer = render_to_buffer(80, 24, |f| {
tele_tui::ui::chat_list::render(f, f.area(), &mut app);
@@ -83,9 +79,7 @@ fn snapshot_chat_with_muted() {
.last_message("Too many messages")
.build();
let mut app = TestAppBuilder::new()
.with_chat(chat)
.build();
let mut app = TestAppBuilder::new().with_chat(chat).build();
let buffer = render_to_buffer(80, 24, |f| {
tele_tui::ui::chat_list::render(f, f.area(), &mut app);
@@ -103,9 +97,7 @@ fn snapshot_chat_with_mentions() {
.last_message("@me check this out")
.build();
let mut app = TestAppBuilder::new()
.with_chat(chat)
.build();
let mut app = TestAppBuilder::new().with_chat(chat).build();
let buffer = render_to_buffer(80, 24, |f| {
tele_tui::ui::chat_list::render(f, f.area(), &mut app);
@@ -139,9 +131,7 @@ fn snapshot_chat_long_title() {
.last_message("Test message")
.build();
let mut app = TestAppBuilder::new()
.with_chat(chat)
.build();
let mut app = TestAppBuilder::new().with_chat(chat).build();
let buffer = render_to_buffer(80, 24, |f| {
tele_tui::ui::chat_list::render(f, f.area(), &mut app);

View File

@@ -61,9 +61,7 @@ fn test_can_only_delete_own_messages_for_all() {
let mut client = FakeTdClient::new();
// Наше исходящее сообщение (можно удалить для всех)
let outgoing_msg = TestMessageBuilder::new("My message", 1)
.outgoing()
.build();
let outgoing_msg = TestMessageBuilder::new("My message", 1).outgoing().build();
client = client.with_message(123, outgoing_msg);

View File

@@ -12,9 +12,7 @@ struct DraftManager {
impl DraftManager {
fn new() -> Self {
Self {
drafts: HashMap::new(),
}
Self { drafts: HashMap::new() }
}
/// Сохранить черновик для чата

View File

@@ -55,9 +55,7 @@ fn test_can_only_edit_own_messages() {
let mut client = FakeTdClient::new();
// Наше исходящее сообщение (можно редактировать)
let outgoing_msg = TestMessageBuilder::new("My message", 1)
.outgoing()
.build();
let outgoing_msg = TestMessageBuilder::new("My message", 1).outgoing().build();
client = client.with_message(123, outgoing_msg);

View File

@@ -2,9 +2,9 @@
mod helpers;
use helpers::test_data::create_test_chat;
use helpers::app_builder::TestAppBuilder;
use helpers::snapshot_utils::{render_to_buffer, buffer_to_string};
use helpers::snapshot_utils::{buffer_to_string, render_to_buffer};
use helpers::test_data::create_test_chat;
use insta::assert_snapshot;
use tele_tui::tdlib::NetworkState;
@@ -12,9 +12,7 @@ use tele_tui::tdlib::NetworkState;
fn snapshot_footer_chat_list() {
let chat = create_test_chat("Mom", 123);
let app = TestAppBuilder::new()
.with_chat(chat)
.build();
let app = TestAppBuilder::new().with_chat(chat).build();
let buffer = render_to_buffer(80, 24, |f| {
tele_tui::ui::footer::render(f, f.area(), &app);
@@ -45,9 +43,7 @@ fn snapshot_footer_open_chat() {
fn snapshot_footer_network_waiting() {
let chat = create_test_chat("Mom", 123);
let mut app = TestAppBuilder::new()
.with_chat(chat)
.build();
let mut app = TestAppBuilder::new().with_chat(chat).build();
// Set network state to WaitingForNetwork
app.td_client.network_state = NetworkState::WaitingForNetwork;
@@ -64,9 +60,7 @@ fn snapshot_footer_network_waiting() {
fn snapshot_footer_network_connecting_proxy() {
let chat = create_test_chat("Mom", 123);
let mut app = TestAppBuilder::new()
.with_chat(chat)
.build();
let mut app = TestAppBuilder::new().with_chat(chat).build();
// Set network state to ConnectingToProxy
app.td_client.network_state = NetworkState::ConnectingToProxy;
@@ -83,9 +77,7 @@ fn snapshot_footer_network_connecting_proxy() {
fn snapshot_footer_network_connecting() {
let chat = create_test_chat("Mom", 123);
let mut app = TestAppBuilder::new()
.with_chat(chat)
.build();
let mut app = TestAppBuilder::new().with_chat(chat).build();
// Set network state to Connecting
app.td_client.network_state = NetworkState::Connecting;

View File

@@ -1,11 +1,11 @@
// Test App builder
use tele_tui::app::{App, AppScreen};
use tele_tui::config::Config;
use tele_tui::tdlib::{ChatInfo, MessageInfo};
use tele_tui::tdlib::client::AuthState;
use ratatui::widgets::ListState;
use std::collections::HashMap;
use tele_tui::app::{App, AppScreen};
use tele_tui::config::Config;
use tele_tui::tdlib::client::AuthState;
use tele_tui::tdlib::{ChatInfo, MessageInfo};
/// Builder для создания тестового App
///
@@ -149,13 +149,19 @@ impl TestAppBuilder {
/// Добавить сообщение для чата
pub fn with_message(mut self, chat_id: i64, message: MessageInfo) -> Self {
self.messages.entry(chat_id).or_insert_with(Vec::new).push(message);
self.messages
.entry(chat_id)
.or_insert_with(Vec::new)
.push(message);
self
}
/// Добавить несколько сообщений для чата
pub fn with_messages(mut self, chat_id: i64, messages: Vec<MessageInfo>) -> Self {
self.messages.entry(chat_id).or_insert_with(Vec::new).extend(messages);
self.messages
.entry(chat_id)
.or_insert_with(Vec::new)
.extend(messages);
self
}
@@ -329,9 +335,7 @@ mod tests {
#[test]
fn test_builder_search_mode() {
let app = TestAppBuilder::new()
.searching("test query")
.build();
let app = TestAppBuilder::new().searching("test query").build();
assert!(app.is_searching);
assert_eq!(app.search_query, "test query");

View File

@@ -1,7 +1,7 @@
// Fake TDLib client for testing
use std::collections::HashMap;
use tele_tui::tdlib::{ChatInfo, MessageInfo, FolderInfo, NetworkState};
use tele_tui::tdlib::{ChatInfo, FolderInfo, MessageInfo, NetworkState};
/// Упрощённый mock TDLib клиента для тестов
#[derive(Clone)]
@@ -42,12 +42,7 @@ impl FakeTdClient {
Self {
chats: vec![],
messages: HashMap::new(),
folders: vec![
FolderInfo {
id: 0,
name: "All".to_string(),
},
],
folders: vec![FolderInfo { id: 0, name: "All".to_string() }],
user_names: HashMap::new(),
network_state: NetworkState::Ready,
typing_chat_id: None,
@@ -90,10 +85,7 @@ impl FakeTdClient {
/// Добавить папку
pub fn with_folder(mut self, id: i32, name: &str) -> Self {
self.folders.push(FolderInfo {
id,
name: name.to_string(),
});
self.folders.push(FolderInfo { id, name: name.to_string() });
self
}
@@ -116,10 +108,7 @@ impl FakeTdClient {
/// Получить сообщения для чата
pub fn get_messages(&self, chat_id: i64) -> Vec<MessageInfo> {
self.messages
.get(&chat_id)
.cloned()
.unwrap_or_default()
self.messages.get(&chat_id).cloned().unwrap_or_default()
}
/// Получить папки
@@ -131,11 +120,8 @@ impl FakeTdClient {
pub fn send_message(&mut self, chat_id: i64, text: String, reply_to: Option<i64>) -> i64 {
let message_id = (self.sent_messages.len() as i64) + 1000;
self.sent_messages.push(SentMessage {
chat_id,
text: text.clone(),
reply_to,
});
self.sent_messages
.push(SentMessage { chat_id, text: text.clone(), reply_to });
// Добавляем сообщение в список сообщений чата
let message = MessageInfo {
@@ -165,10 +151,8 @@ impl FakeTdClient {
/// Редактировать сообщение (мок)
pub fn edit_message(&mut self, chat_id: i64, message_id: i64, new_text: String) {
self.edited_messages.push(EditedMessage {
message_id,
new_text: new_text.clone(),
});
self.edited_messages
.push(EditedMessage { message_id, new_text: new_text.clone() });
// Обновляем сообщение в списке
if let Some(messages) = self.messages.get_mut(&chat_id) {

View File

@@ -1,9 +1,9 @@
// Snapshot testing utilities
use ratatui::backend::TestBackend;
use ratatui::Terminal;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::Terminal;
/// Конвертирует Buffer в читаемую строку для snapshot тестов
pub fn buffer_to_string(buffer: &Buffer) -> String {
@@ -33,9 +33,7 @@ where
let backend = TestBackend::new(width, height);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(render_fn)
.unwrap();
terminal.draw(render_fn).unwrap();
terminal.backend().buffer().clone()
}
@@ -44,7 +42,7 @@ where
#[macro_export]
macro_rules! assert_ui_snapshot {
($name:expr, $width:expr, $height:expr, $render_fn:expr) => {{
use $crate::helpers::snapshot_utils::{render_to_buffer, buffer_to_string};
use $crate::helpers::snapshot_utils::{buffer_to_string, render_to_buffer};
let buffer = render_to_buffer($width, $height, $render_fn);
let output = buffer_to_string(&buffer);
insta::assert_snapshot!($name, output);
@@ -59,9 +57,7 @@ mod tests {
#[test]
fn test_buffer_to_string_simple() {
let buffer = render_to_buffer(10, 3, |f| {
let block = Block::default()
.borders(Borders::ALL)
.title("Hi");
let block = Block::default().borders(Borders::ALL).title("Hi");
f.render_widget(block, f.area());
});

View File

@@ -1,6 +1,6 @@
// Test data builders and fixtures
use tele_tui::tdlib::{ChatInfo, MessageInfo, ReactionInfo, ReplyInfo, ForwardInfo, ProfileInfo};
use tele_tui::tdlib::{ChatInfo, ForwardInfo, MessageInfo, ProfileInfo, ReactionInfo, ReplyInfo};
/// Builder для создания тестового чата
pub struct TestChatBuilder {
@@ -181,11 +181,8 @@ impl TestMessageBuilder {
}
pub fn reaction(mut self, emoji: &str, count: i32, chosen: bool) -> Self {
self.reactions.push(ReactionInfo {
emoji: emoji.to_string(),
count,
is_chosen: chosen,
});
self.reactions
.push(ReactionInfo { emoji: emoji.to_string(), count, is_chosen: chosen });
self
}

View File

@@ -2,9 +2,9 @@
mod helpers;
use helpers::test_data::{TestMessageBuilder, create_test_chat};
use helpers::app_builder::TestAppBuilder;
use helpers::snapshot_utils::{render_to_buffer, buffer_to_string};
use helpers::snapshot_utils::{buffer_to_string, render_to_buffer};
use helpers::test_data::{create_test_chat, TestMessageBuilder};
use insta::assert_snapshot;
#[test]

View File

@@ -2,9 +2,9 @@
mod helpers;
use helpers::test_data::{TestChatBuilder, TestMessageBuilder, create_test_chat};
use helpers::app_builder::TestAppBuilder;
use helpers::snapshot_utils::{render_to_buffer, buffer_to_string};
use helpers::snapshot_utils::{buffer_to_string, render_to_buffer};
use helpers::test_data::{create_test_chat, TestChatBuilder, TestMessageBuilder};
use insta::assert_snapshot;
#[test]
@@ -48,9 +48,7 @@ fn snapshot_single_incoming_message() {
#[test]
fn snapshot_single_outgoing_message() {
let chat = create_test_chat("Mom", 123);
let message = TestMessageBuilder::new("Hi mom!", 1)
.outgoing()
.build();
let message = TestMessageBuilder::new("Hi mom!", 1).outgoing().build();
let mut app = TestAppBuilder::new()
.with_chat(chat)
@@ -122,9 +120,7 @@ fn snapshot_sender_grouping() {
#[test]
fn snapshot_outgoing_sent() {
let chat = create_test_chat("Mom", 123);
let message = TestMessageBuilder::new("Just sent", 1)
.outgoing()
.build();
let message = TestMessageBuilder::new("Just sent", 1).outgoing().build();
let mut app = TestAppBuilder::new()
.with_chat(chat)
@@ -173,9 +169,7 @@ fn snapshot_outgoing_read() {
#[test]
fn snapshot_edited_message() {
let chat = create_test_chat("Mom", 123);
let message = TestMessageBuilder::new("Edited text", 1)
.edited()
.build();
let message = TestMessageBuilder::new("Edited text", 1).edited().build();
let mut app = TestAppBuilder::new()
.with_chat(chat)
@@ -195,8 +189,7 @@ fn snapshot_edited_message() {
fn snapshot_long_message_wrap() {
let chat = create_test_chat("Mom", 123);
let long_text = "This is a very long message that should wrap across multiple lines when rendered in the terminal UI. Let's make it even longer to ensure we test the wrapping behavior properly.";
let message = TestMessageBuilder::new(long_text, 1)
.build();
let message = TestMessageBuilder::new(long_text, 1).build();
let mut app = TestAppBuilder::new()
.with_chat(chat)
@@ -215,8 +208,7 @@ fn snapshot_long_message_wrap() {
#[test]
fn snapshot_markdown_bold_italic_code() {
let chat = create_test_chat("Mom", 123);
let message = TestMessageBuilder::new("**bold** *italic* `code`", 1)
.build();
let message = TestMessageBuilder::new("**bold** *italic* `code`", 1).build();
let mut app = TestAppBuilder::new()
.with_chat(chat)
@@ -235,8 +227,8 @@ fn snapshot_markdown_bold_italic_code() {
#[test]
fn snapshot_markdown_link_mention() {
let chat = create_test_chat("Mom", 123);
let message = TestMessageBuilder::new("Check [this](https://example.com) and @username", 1)
.build();
let message =
TestMessageBuilder::new("Check [this](https://example.com) and @username", 1).build();
let mut app = TestAppBuilder::new()
.with_chat(chat)
@@ -255,8 +247,7 @@ fn snapshot_markdown_link_mention() {
#[test]
fn snapshot_markdown_spoiler() {
let chat = create_test_chat("Mom", 123);
let message = TestMessageBuilder::new("Spoiler: ||hidden text||", 1)
.build();
let message = TestMessageBuilder::new("Spoiler: ||hidden text||", 1).build();
let mut app = TestAppBuilder::new()
.with_chat(chat)
@@ -275,8 +266,7 @@ fn snapshot_markdown_spoiler() {
#[test]
fn snapshot_media_placeholder() {
let chat = create_test_chat("Mom", 123);
let message = TestMessageBuilder::new("[Фото]", 1)
.build();
let message = TestMessageBuilder::new("[Фото]", 1).build();
let mut app = TestAppBuilder::new()
.with_chat(chat)
@@ -380,8 +370,7 @@ fn snapshot_multiple_reactions() {
#[test]
fn snapshot_selected_message() {
let chat = create_test_chat("Mom", 123);
let message = TestMessageBuilder::new("Selected message", 1)
.build();
let message = TestMessageBuilder::new("Selected message", 1).build();
let mut app = TestAppBuilder::new()
.with_chat(chat)

View File

@@ -2,17 +2,17 @@
mod helpers;
use helpers::test_data::{TestChatBuilder, TestMessageBuilder, create_test_chat, create_test_profile};
use helpers::app_builder::TestAppBuilder;
use helpers::snapshot_utils::{render_to_buffer, buffer_to_string};
use helpers::snapshot_utils::{buffer_to_string, render_to_buffer};
use helpers::test_data::{
create_test_chat, create_test_profile, TestChatBuilder, TestMessageBuilder,
};
use insta::assert_snapshot;
#[test]
fn snapshot_delete_confirmation_modal() {
let chat = create_test_chat("Mom", 123);
let message = TestMessageBuilder::new("Delete me", 1)
.outgoing()
.build();
let message = TestMessageBuilder::new("Delete me", 1).outgoing().build();
let app = TestAppBuilder::new()
.with_chat(chat)
@@ -32,8 +32,7 @@ fn snapshot_delete_confirmation_modal() {
#[test]
fn snapshot_emoji_picker_default() {
let chat = create_test_chat("Mom", 123);
let message = TestMessageBuilder::new("React to this", 1)
.build();
let message = TestMessageBuilder::new("React to this", 1).build();
let app = TestAppBuilder::new()
.with_chat(chat)
@@ -53,8 +52,7 @@ fn snapshot_emoji_picker_default() {
#[test]
fn snapshot_emoji_picker_with_selection() {
let chat = create_test_chat("Mom", 123);
let message = TestMessageBuilder::new("React to this", 1)
.build();
let message = TestMessageBuilder::new("React to this", 1).build();
let mut app = TestAppBuilder::new()
.with_chat(chat)
@@ -97,8 +95,7 @@ fn snapshot_profile_personal_chat() {
#[test]
fn snapshot_profile_group_chat() {
let chat = TestChatBuilder::new("Work Group", 456)
.build();
let chat = TestChatBuilder::new("Work Group", 456).build();
let mut profile = create_test_profile("Work Group", 456);
profile.is_group = true;
@@ -125,10 +122,8 @@ fn snapshot_profile_group_chat() {
#[test]
fn snapshot_pinned_message() {
let chat = create_test_chat("Mom", 123);
let message1 = TestMessageBuilder::new("Regular message", 1)
.build();
let pinned_msg = TestMessageBuilder::new("Important pinned message!", 2)
.build();
let message1 = TestMessageBuilder::new("Regular message", 1).build();
let pinned_msg = TestMessageBuilder::new("Important pinned message!", 2).build();
let mut app = TestAppBuilder::new()
.with_chat(chat)
@@ -150,12 +145,9 @@ fn snapshot_pinned_message() {
#[test]
fn snapshot_search_in_chat() {
let chat = create_test_chat("Mom", 123);
let msg1 = TestMessageBuilder::new("Hello world", 1)
.build();
let msg2 = TestMessageBuilder::new("World is beautiful", 2)
.build();
let msg3 = TestMessageBuilder::new("Beautiful day", 3)
.build();
let msg1 = TestMessageBuilder::new("Hello world", 1).build();
let msg2 = TestMessageBuilder::new("World is beautiful", 2).build();
let msg3 = TestMessageBuilder::new("Beautiful day", 3).build();
let mut app = TestAppBuilder::new()
.with_chat(chat)
@@ -182,8 +174,7 @@ fn snapshot_forward_mode() {
let chat2 = create_test_chat("Dad", 456);
let chat3 = create_test_chat("Work Group", 789);
let message = TestMessageBuilder::new("Forward this message", 1)
.build();
let message = TestMessageBuilder::new("Forward this message", 1).build();
let mut app = TestAppBuilder::new()
.with_chats(vec![chat1.clone(), chat2, chat3])

View File

@@ -128,9 +128,7 @@ fn test_switch_folders() {
let mut client = FakeTdClient::new();
// Добавляем папки (FakeTdClient уже создаёт "All" с id=0)
client = client
.with_folder(1, "Personal")
.with_folder(2, "Work");
client = client.with_folder(1, "Personal").with_folder(2, "Work");
let folders = client.get_folders();

View File

@@ -3,7 +3,7 @@
mod helpers;
use helpers::app_builder::TestAppBuilder;
use helpers::snapshot_utils::{render_to_buffer, buffer_to_string};
use helpers::snapshot_utils::{buffer_to_string, render_to_buffer};
use helpers::test_data::create_test_chat;
use insta::assert_snapshot;
use tele_tui::app::AppScreen;
@@ -11,9 +11,7 @@ use tele_tui::tdlib::client::AuthState;
#[test]
fn snapshot_loading_screen_default() {
let mut app = TestAppBuilder::new()
.screen(AppScreen::Loading)
.build();
let mut app = TestAppBuilder::new().screen(AppScreen::Loading).build();
let buffer = render_to_buffer(80, 24, |f| {
tele_tui::ui::render(f, &mut app);
@@ -88,9 +86,7 @@ fn snapshot_auth_screen_password() {
#[test]
fn snapshot_main_screen_empty() {
let mut app = TestAppBuilder::new()
.screen(AppScreen::Main)
.build();
let mut app = TestAppBuilder::new().screen(AppScreen::Main).build();
let buffer = render_to_buffer(80, 24, |f| {
tele_tui::ui::render(f, &mut app);

View File

@@ -34,13 +34,9 @@ fn test_search_chats_by_title() {
fn test_search_chats_by_username() {
let mut client = FakeTdClient::new();
let chat1 = TestChatBuilder::new("Alice", 123)
.username("alice")
.build();
let chat1 = TestChatBuilder::new("Alice", 123).username("alice").build();
let chat2 = TestChatBuilder::new("Bob", 456)
.username("bobby")
.build();
let chat2 = TestChatBuilder::new("Bob", 456).username("bobby").build();
let chat3 = TestChatBuilder::new("Charlie", 789).build(); // Без username