style: auto-format entire codebase with cargo fmt (stable rustfmt.toml)
Some checks failed
ci/woodpecker/pr/check Pipeline failed
CI / Check (pull_request) Has been cancelled
CI / Format (pull_request) Has been cancelled
CI / Clippy (pull_request) Has been cancelled
CI / Build (macos-latest) (pull_request) Has been cancelled
CI / Build (ubuntu-latest) (pull_request) Has been cancelled
CI / Build (windows-latest) (pull_request) Has been cancelled

This commit is contained in:
Mikhail Kilin
2026-02-22 17:09:51 +03:00
parent 2442a90e23
commit 264f183510
90 changed files with 1632 additions and 1450 deletions

View File

@@ -61,8 +61,8 @@ pub fn load_or_create() -> AccountsConfig {
/// Saves `AccountsConfig` to `accounts.toml`.
pub fn save(config: &AccountsConfig) -> Result<(), String> {
let config_path = accounts_config_path()
.ok_or_else(|| "Could not determine config directory".to_string())?;
let config_path =
accounts_config_path().ok_or_else(|| "Could not determine config directory".to_string())?;
// Ensure parent directory exists
if let Some(parent) = config_path.parent() {
@@ -111,17 +111,10 @@ fn migrate_legacy() {
// Move (rename) the directory
match fs::rename(&legacy_path, &target) {
Ok(()) => {
tracing::info!(
"Migrated ./tdlib_data/ -> {}",
target.display()
);
tracing::info!("Migrated ./tdlib_data/ -> {}", target.display());
}
Err(e) => {
tracing::error!(
"Could not migrate ./tdlib_data/ to {}: {}",
target.display(),
e
);
tracing::error!("Could not migrate ./tdlib_data/ to {}: {}", target.display(), e);
}
}
}

View File

@@ -6,7 +6,6 @@
/// - По статусу (archived, muted, и т.д.)
///
/// Используется как в App, так и в UI слое для консистентной фильтрации.
use crate::tdlib::ChatInfo;
/// Критерии фильтрации чатов
@@ -42,18 +41,12 @@ impl ChatFilterCriteria {
/// Фильтр только по папке
pub fn by_folder(folder_id: Option<i32>) -> Self {
Self {
folder_id,
..Default::default()
}
Self { folder_id, ..Default::default() }
}
/// Фильтр только по поисковому запросу
pub fn by_search(query: String) -> Self {
Self {
search_query: Some(query),
..Default::default()
}
Self { search_query: Some(query), ..Default::default() }
}
/// Builder: установить папку
@@ -176,10 +169,7 @@ impl ChatFilter {
///
/// let filtered = ChatFilter::filter(&all_chats, &criteria);
/// ```
pub fn filter<'a>(
chats: &'a [ChatInfo],
criteria: &ChatFilterCriteria,
) -> Vec<&'a ChatInfo> {
pub fn filter<'a>(chats: &'a [ChatInfo], criteria: &ChatFilterCriteria) -> Vec<&'a ChatInfo> {
chats.iter().filter(|chat| criteria.matches(chat)).collect()
}
@@ -309,8 +299,7 @@ mod tests {
let filtered = ChatFilter::filter(&chats, &criteria);
assert_eq!(filtered.len(), 2); // Chat 1 and Chat 3 have unread
let criteria = ChatFilterCriteria::new()
.pinned_only(true);
let criteria = ChatFilterCriteria::new().pinned_only(true);
let filtered = ChatFilter::filter(&chats, &criteria);
assert_eq!(filtered.len(), 1); // Only Chat 1 is pinned
@@ -330,5 +319,4 @@ mod tests {
assert_eq!(ChatFilter::count_unread(&chats, &criteria), 15); // 5 + 10
assert_eq!(ChatFilter::count_unread_mentions(&chats, &criteria), 3); // 1 + 2
}
}

View File

@@ -2,8 +2,8 @@
//!
//! Handles reply, forward, and draft functionality
use crate::app::{App, ChatState};
use crate::app::methods::messages::MessageMethods;
use crate::app::{App, ChatState};
use crate::tdlib::{MessageInfo, TdClientTrait};
/// Compose methods for reply/forward/draft
@@ -44,9 +44,7 @@ pub trait ComposeMethods<T: TdClientTrait> {
impl<T: TdClientTrait> ComposeMethods<T> for App<T> {
fn start_reply_to_selected(&mut self) -> bool {
if let Some(msg) = self.get_selected_message() {
self.chat_state = ChatState::Reply {
message_id: msg.id(),
};
self.chat_state = ChatState::Reply { message_id: msg.id() };
return true;
}
false
@@ -72,9 +70,7 @@ impl<T: TdClientTrait> ComposeMethods<T> for App<T> {
fn start_forward_selected(&mut self) -> bool {
if let Some(msg) = self.get_selected_message() {
self.chat_state = ChatState::Forward {
message_id: msg.id(),
};
self.chat_state = ChatState::Forward { message_id: msg.id() };
// Сбрасываем выбор чата на первый
self.chat_list_state.select(Some(0));
return true;

View File

@@ -61,8 +61,7 @@ impl<T: TdClientTrait> MessageMethods<T> for App<T> {
// Перескакиваем через все сообщения текущего альбома назад
let mut new_index = *selected_index - 1;
if current_album_id != 0 {
while new_index > 0
&& messages[new_index].media_album_id() == current_album_id
while new_index > 0 && messages[new_index].media_album_id() == current_album_id
{
new_index -= 1;
}
@@ -125,9 +124,9 @@ impl<T: TdClientTrait> MessageMethods<T> for App<T> {
}
fn get_selected_message(&self) -> Option<MessageInfo> {
self.chat_state.selected_message_index().and_then(|idx| {
self.td_client.current_chat_messages().get(idx).cloned()
})
self.chat_state
.selected_message_index()
.and_then(|idx| self.td_client.current_chat_messages().get(idx).cloned())
}
fn start_editing_selected(&mut self) -> bool {
@@ -158,10 +157,7 @@ impl<T: TdClientTrait> MessageMethods<T> for App<T> {
if let Some((id, content, idx)) = msg_data {
self.cursor_position = content.chars().count();
self.message_input = content;
self.chat_state = ChatState::Editing {
message_id: id,
selected_index: idx,
};
self.chat_state = ChatState::Editing { message_id: id, selected_index: idx };
return true;
}
false

View File

@@ -7,14 +7,14 @@
//! - search: Search in chats and messages
//! - modal: Modal dialogs (Profile, Pinned, Reactions, Delete)
pub mod navigation;
pub mod messages;
pub mod compose;
pub mod search;
pub mod messages;
pub mod modal;
pub mod navigation;
pub mod search;
pub use navigation::NavigationMethods;
pub use messages::MessageMethods;
pub use compose::ComposeMethods;
pub use search::SearchMethods;
pub use messages::MessageMethods;
pub use modal::ModalMethods;
pub use navigation::NavigationMethods;
pub use search::SearchMethods;

View File

@@ -106,10 +106,7 @@ impl<T: TdClientTrait> ModalMethods<T> for App<T> {
fn enter_pinned_mode(&mut self, messages: Vec<MessageInfo>) {
if !messages.is_empty() {
self.chat_state = ChatState::PinnedMessages {
messages,
selected_index: 0,
};
self.chat_state = ChatState::PinnedMessages { messages, selected_index: 0 };
}
}
@@ -118,11 +115,7 @@ impl<T: TdClientTrait> ModalMethods<T> for App<T> {
}
fn select_previous_pinned(&mut self) {
if let ChatState::PinnedMessages {
selected_index,
messages,
} = &mut self.chat_state
{
if let ChatState::PinnedMessages { selected_index, messages } = &mut self.chat_state {
if *selected_index + 1 < messages.len() {
*selected_index += 1;
}
@@ -138,11 +131,7 @@ impl<T: TdClientTrait> ModalMethods<T> for App<T> {
}
fn get_selected_pinned(&self) -> Option<&MessageInfo> {
if let ChatState::PinnedMessages {
messages,
selected_index,
} = &self.chat_state
{
if let ChatState::PinnedMessages { messages, selected_index } = &self.chat_state {
messages.get(*selected_index)
} else {
None
@@ -170,10 +159,7 @@ impl<T: TdClientTrait> ModalMethods<T> for App<T> {
}
fn select_previous_profile_action(&mut self) {
if let ChatState::Profile {
selected_action, ..
} = &mut self.chat_state
{
if let ChatState::Profile { selected_action, .. } = &mut self.chat_state {
if *selected_action > 0 {
*selected_action -= 1;
}
@@ -181,10 +167,7 @@ impl<T: TdClientTrait> ModalMethods<T> for App<T> {
}
fn select_next_profile_action(&mut self, max_actions: usize) {
if let ChatState::Profile {
selected_action, ..
} = &mut self.chat_state
{
if let ChatState::Profile { selected_action, .. } = &mut self.chat_state {
if *selected_action < max_actions.saturating_sub(1) {
*selected_action += 1;
}
@@ -192,41 +175,25 @@ impl<T: TdClientTrait> ModalMethods<T> for App<T> {
}
fn show_leave_group_confirmation(&mut self) {
if let ChatState::Profile {
leave_group_confirmation_step,
..
} = &mut self.chat_state
{
if let ChatState::Profile { leave_group_confirmation_step, .. } = &mut self.chat_state {
*leave_group_confirmation_step = 1;
}
}
fn show_leave_group_final_confirmation(&mut self) {
if let ChatState::Profile {
leave_group_confirmation_step,
..
} = &mut self.chat_state
{
if let ChatState::Profile { leave_group_confirmation_step, .. } = &mut self.chat_state {
*leave_group_confirmation_step = 2;
}
}
fn cancel_leave_group(&mut self) {
if let ChatState::Profile {
leave_group_confirmation_step,
..
} = &mut self.chat_state
{
if let ChatState::Profile { leave_group_confirmation_step, .. } = &mut self.chat_state {
*leave_group_confirmation_step = 0;
}
}
fn get_leave_group_confirmation_step(&self) -> u8 {
if let ChatState::Profile {
leave_group_confirmation_step,
..
} = &self.chat_state
{
if let ChatState::Profile { leave_group_confirmation_step, .. } = &self.chat_state {
*leave_group_confirmation_step
} else {
0
@@ -242,10 +209,7 @@ impl<T: TdClientTrait> ModalMethods<T> for App<T> {
}
fn get_selected_profile_action(&self) -> Option<usize> {
if let ChatState::Profile {
selected_action, ..
} = &self.chat_state
{
if let ChatState::Profile { selected_action, .. } = &self.chat_state {
Some(*selected_action)
} else {
None
@@ -277,11 +241,8 @@ impl<T: TdClientTrait> ModalMethods<T> for App<T> {
}
fn select_next_reaction(&mut self) {
if let ChatState::ReactionPicker {
selected_index,
available_reactions,
..
} = &mut self.chat_state
if let ChatState::ReactionPicker { selected_index, available_reactions, .. } =
&mut self.chat_state
{
if *selected_index + 1 < available_reactions.len() {
*selected_index += 1;
@@ -290,11 +251,8 @@ impl<T: TdClientTrait> ModalMethods<T> for App<T> {
}
fn get_selected_reaction(&self) -> Option<&String> {
if let ChatState::ReactionPicker {
available_reactions,
selected_index,
..
} = &self.chat_state
if let ChatState::ReactionPicker { available_reactions, selected_index, .. } =
&self.chat_state
{
available_reactions.get(*selected_index)
} else {

View File

@@ -2,8 +2,8 @@
//!
//! Handles chat list navigation and selection
use crate::app::{App, ChatState, InputMode};
use crate::app::methods::search::SearchMethods;
use crate::app::{App, ChatState, InputMode};
use crate::tdlib::TdClientTrait;
/// Navigation methods for chat list

View File

@@ -71,8 +71,7 @@ impl<T: TdClientTrait> SearchMethods<T> for App<T> {
fn get_filtered_chats(&self) -> Vec<&ChatInfo> {
// Используем ChatFilter для централизованной фильтрации
let mut criteria = ChatFilterCriteria::new()
.with_folder(self.selected_folder_id);
let mut criteria = ChatFilterCriteria::new().with_folder(self.selected_folder_id);
if !self.search_query.is_empty() {
criteria = criteria.with_search(self.search_query.clone());
@@ -113,12 +112,7 @@ impl<T: TdClientTrait> SearchMethods<T> for App<T> {
}
fn select_next_search_result(&mut self) {
if let ChatState::SearchInChat {
selected_index,
results,
..
} = &mut self.chat_state
{
if let ChatState::SearchInChat { selected_index, results, .. } = &mut self.chat_state {
if *selected_index + 1 < results.len() {
*selected_index += 1;
}
@@ -126,12 +120,7 @@ impl<T: TdClientTrait> SearchMethods<T> for App<T> {
}
fn get_selected_search_result(&self) -> Option<&MessageInfo> {
if let ChatState::SearchInChat {
results,
selected_index,
..
} = &self.chat_state
{
if let ChatState::SearchInChat { results, selected_index, .. } = &self.chat_state {
results.get(*selected_index)
} else {
None

View File

@@ -5,13 +5,13 @@
mod chat_filter;
mod chat_state;
mod state;
pub mod methods;
mod state;
pub use chat_filter::{ChatFilter, ChatFilterCriteria};
pub use chat_state::{ChatState, InputMode};
pub use state::AppScreen;
pub use methods::*;
pub use state::AppScreen;
use crate::accounts::AccountProfile;
use crate::tdlib::{ChatInfo, TdClient, TdClientTrait};
@@ -165,9 +165,7 @@ impl<T: TdClientTrait> App<T> {
let audio_cache_size_mb = config.audio.cache_size_mb;
#[cfg(feature = "images")]
let image_cache = Some(crate::media::cache::ImageCache::new(
config.images.cache_size_mb,
));
let image_cache = Some(crate::media::cache::ImageCache::new(config.images.cache_size_mb));
#[cfg(feature = "images")]
let inline_image_renderer = crate::media::image_renderer::ImageRenderer::new_fast();
#[cfg(feature = "images")]
@@ -275,11 +273,8 @@ impl<T: TdClientTrait> App<T> {
/// Navigate to next item in account switcher list.
pub fn account_switcher_select_next(&mut self) {
if let Some(AccountSwitcherState::SelectAccount {
accounts,
selected_index,
..
}) = &mut self.account_switcher
if let Some(AccountSwitcherState::SelectAccount { accounts, selected_index, .. }) =
&mut self.account_switcher
{
// +1 for the "Add account" item at the end
let max_index = accounts.len();
@@ -372,20 +367,6 @@ impl<T: TdClientTrait> App<T> {
.and_then(|id| self.chats.iter().find(|c| c.id == id))
}
// ========== Getter/Setter методы для инкапсуляции ==========
// Config

View File

@@ -97,8 +97,7 @@ impl VoiceCache {
/// Evicts a specific file from cache
fn evict(&mut self, file_id: &str) -> Result<(), String> {
if let Some((path, _, _)) = self.files.remove(file_id) {
fs::remove_file(&path)
.map_err(|e| format!("Failed to remove cached file: {}", e))?;
fs::remove_file(&path).map_err(|e| format!("Failed to remove cached file: {}", e))?;
}
Ok(())
}

View File

@@ -58,7 +58,8 @@ impl AudioPlayer {
let mut cmd = Command::new("ffplay");
cmd.arg("-nodisp")
.arg("-autoexit")
.arg("-loglevel").arg("quiet");
.arg("-loglevel")
.arg("quiet");
if start_secs > 0.0 {
cmd.arg("-ss").arg(format!("{:.1}", start_secs));
@@ -132,9 +133,7 @@ impl AudioPlayer {
.arg("-CONT")
.arg(pid.to_string())
.output();
let _ = Command::new("kill")
.arg(pid.to_string())
.output();
let _ = Command::new("kill").arg(pid.to_string()).output();
}
*self.paused.lock().unwrap() = false;
}

View File

@@ -4,7 +4,6 @@
/// - Загрузку из конфигурационного файла
/// - Множественные binding для одной команды (EN/RU раскладки)
/// - Type-safe команды через enum
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
@@ -49,12 +48,12 @@ pub enum Command {
SelectMessage,
// Media
ViewImage, // v - просмотр фото
ViewImage, // v - просмотр фото
// Voice playback
TogglePlayback, // Space - play/pause
SeekForward, // → - seek +5s
SeekBackward, // ← - seek -5s
TogglePlayback, // Space - play/pause
SeekForward, // → - seek +5s
SeekBackward, // ← - seek -5s
// Input
SubmitMessage,
@@ -83,31 +82,19 @@ pub struct KeyBinding {
impl KeyBinding {
pub fn new(key: KeyCode) -> Self {
Self {
key,
modifiers: KeyModifiers::NONE,
}
Self { key, modifiers: KeyModifiers::NONE }
}
pub fn with_ctrl(key: KeyCode) -> Self {
Self {
key,
modifiers: KeyModifiers::CONTROL,
}
Self { key, modifiers: KeyModifiers::CONTROL }
}
pub fn with_shift(key: KeyCode) -> Self {
Self {
key,
modifiers: KeyModifiers::SHIFT,
}
Self { key, modifiers: KeyModifiers::SHIFT }
}
pub fn with_alt(key: KeyCode) -> Self {
Self {
key,
modifiers: KeyModifiers::ALT,
}
Self { key, modifiers: KeyModifiers::ALT }
}
pub fn matches(&self, event: &KeyEvent) -> bool {
@@ -128,50 +115,65 @@ impl Keybindings {
let mut bindings = HashMap::new();
// Navigation
bindings.insert(Command::MoveUp, vec![
KeyBinding::new(KeyCode::Up),
KeyBinding::new(KeyCode::Char('k')),
KeyBinding::new(KeyCode::Char('р')), // RU (custom mapping, not standard ЙЦУКЕН)
]);
bindings.insert(Command::MoveDown, vec![
KeyBinding::new(KeyCode::Down),
KeyBinding::new(KeyCode::Char('j')),
KeyBinding::new(KeyCode::Char('о')), // RU
]);
bindings.insert(Command::MoveLeft, vec![
KeyBinding::new(KeyCode::Left),
KeyBinding::new(KeyCode::Char('h')),
KeyBinding::new(KeyCode::Char('л')), // RU (custom mapping, not standard ЙЦУКЕН)
]);
bindings.insert(Command::MoveRight, vec![
KeyBinding::new(KeyCode::Right),
KeyBinding::new(KeyCode::Char('l')),
KeyBinding::new(KeyCode::Char('д')), // RU
]);
bindings.insert(Command::PageUp, vec![
KeyBinding::new(KeyCode::PageUp),
KeyBinding::with_ctrl(KeyCode::Char('u')),
]);
bindings.insert(Command::PageDown, vec![
KeyBinding::new(KeyCode::PageDown),
KeyBinding::with_ctrl(KeyCode::Char('d')),
]);
bindings.insert(
Command::MoveUp,
vec![
KeyBinding::new(KeyCode::Up),
KeyBinding::new(KeyCode::Char('k')),
KeyBinding::new(KeyCode::Char('р')), // RU (custom mapping, not standard ЙЦУКЕН)
],
);
bindings.insert(
Command::MoveDown,
vec![
KeyBinding::new(KeyCode::Down),
KeyBinding::new(KeyCode::Char('j')),
KeyBinding::new(KeyCode::Char('о')), // RU
],
);
bindings.insert(
Command::MoveLeft,
vec![
KeyBinding::new(KeyCode::Left),
KeyBinding::new(KeyCode::Char('h')),
KeyBinding::new(KeyCode::Char('л')), // RU (custom mapping, not standard ЙЦУКЕН)
],
);
bindings.insert(
Command::MoveRight,
vec![
KeyBinding::new(KeyCode::Right),
KeyBinding::new(KeyCode::Char('l')),
KeyBinding::new(KeyCode::Char('д')), // RU
],
);
bindings.insert(
Command::PageUp,
vec![
KeyBinding::new(KeyCode::PageUp),
KeyBinding::with_ctrl(KeyCode::Char('u')),
],
);
bindings.insert(
Command::PageDown,
vec![
KeyBinding::new(KeyCode::PageDown),
KeyBinding::with_ctrl(KeyCode::Char('d')),
],
);
// Global
bindings.insert(Command::Quit, vec![
KeyBinding::new(KeyCode::Char('q')),
KeyBinding::new(KeyCode::Char('й')), // RU
KeyBinding::with_ctrl(KeyCode::Char('c')),
]);
bindings.insert(Command::OpenSearch, vec![
KeyBinding::with_ctrl(KeyCode::Char('s')),
]);
bindings.insert(Command::OpenSearchInChat, vec![
KeyBinding::with_ctrl(KeyCode::Char('f')),
]);
bindings.insert(Command::Help, vec![
KeyBinding::new(KeyCode::Char('?')),
]);
bindings.insert(
Command::Quit,
vec![
KeyBinding::new(KeyCode::Char('q')),
KeyBinding::new(KeyCode::Char('й')), // RU
KeyBinding::with_ctrl(KeyCode::Char('c')),
],
);
bindings.insert(Command::OpenSearch, vec![KeyBinding::with_ctrl(KeyCode::Char('s'))]);
bindings.insert(Command::OpenSearchInChat, vec![KeyBinding::with_ctrl(KeyCode::Char('f'))]);
bindings.insert(Command::Help, vec![KeyBinding::new(KeyCode::Char('?'))]);
// Chat list
// Note: Enter обрабатывается через Command::SubmitMessage в handle_enter_key()
@@ -188,90 +190,114 @@ impl Keybindings {
9 => Command::SelectFolder9,
_ => unreachable!(),
};
bindings.insert(cmd, vec![
KeyBinding::new(KeyCode::Char(char::from_digit(i, 10).unwrap())),
]);
bindings.insert(
cmd,
vec![KeyBinding::new(KeyCode::Char(
char::from_digit(i, 10).unwrap(),
))],
);
}
// Message actions
// Note: EditMessage (Up) обрабатывается напрямую в handle_open_chat_keyboard_input
// в зависимости от контекста (пустой инпут). Не привязываем здесь, чтобы не
// конфликтовать с Command::MoveUp в списке чатов.
bindings.insert(Command::DeleteMessage, vec![
KeyBinding::new(KeyCode::Delete),
KeyBinding::new(KeyCode::Char('d')),
KeyBinding::new(KeyCode::Char('в')), // RU
]);
bindings.insert(Command::ReplyMessage, vec![
KeyBinding::new(KeyCode::Char('r')),
KeyBinding::new(KeyCode::Char('к')), // RU
]);
bindings.insert(Command::ForwardMessage, vec![
KeyBinding::new(KeyCode::Char('f')),
KeyBinding::new(KeyCode::Char('а')), // RU
]);
bindings.insert(Command::CopyMessage, vec![
KeyBinding::new(KeyCode::Char('y')),
KeyBinding::new(KeyCode::Char('н')), // RU
]);
bindings.insert(Command::ReactMessage, vec![
KeyBinding::new(KeyCode::Char('e')),
KeyBinding::new(KeyCode::Char('у')), // RU
]);
bindings.insert(
Command::DeleteMessage,
vec![
KeyBinding::new(KeyCode::Delete),
KeyBinding::new(KeyCode::Char('d')),
KeyBinding::new(KeyCode::Char('в')), // RU
],
);
bindings.insert(
Command::ReplyMessage,
vec![
KeyBinding::new(KeyCode::Char('r')),
KeyBinding::new(KeyCode::Char('к')), // RU
],
);
bindings.insert(
Command::ForwardMessage,
vec![
KeyBinding::new(KeyCode::Char('f')),
KeyBinding::new(KeyCode::Char('а')), // RU
],
);
bindings.insert(
Command::CopyMessage,
vec![
KeyBinding::new(KeyCode::Char('y')),
KeyBinding::new(KeyCode::Char('н')), // RU
],
);
bindings.insert(
Command::ReactMessage,
vec![
KeyBinding::new(KeyCode::Char('e')),
KeyBinding::new(KeyCode::Char('у')), // RU
],
);
// Note: SelectMessage обрабатывается через Command::SubmitMessage в handle_enter_key()
// Media
bindings.insert(Command::ViewImage, vec![
KeyBinding::new(KeyCode::Char('v')),
KeyBinding::new(KeyCode::Char('м')), // RU
]);
bindings.insert(
Command::ViewImage,
vec![
KeyBinding::new(KeyCode::Char('v')),
KeyBinding::new(KeyCode::Char('м')), // RU
],
);
// Voice playback
bindings.insert(Command::TogglePlayback, vec![
KeyBinding::new(KeyCode::Char(' ')),
]);
bindings.insert(Command::SeekForward, vec![
KeyBinding::new(KeyCode::Right),
]);
bindings.insert(Command::SeekBackward, vec![
KeyBinding::new(KeyCode::Left),
]);
bindings.insert(Command::TogglePlayback, vec![KeyBinding::new(KeyCode::Char(' '))]);
bindings.insert(Command::SeekForward, vec![KeyBinding::new(KeyCode::Right)]);
bindings.insert(Command::SeekBackward, vec![KeyBinding::new(KeyCode::Left)]);
// Input
bindings.insert(Command::SubmitMessage, vec![
KeyBinding::new(KeyCode::Enter),
]);
bindings.insert(Command::Cancel, vec![
KeyBinding::new(KeyCode::Esc),
]);
bindings.insert(Command::SubmitMessage, vec![KeyBinding::new(KeyCode::Enter)]);
bindings.insert(Command::Cancel, vec![KeyBinding::new(KeyCode::Esc)]);
bindings.insert(Command::NewLine, vec![]);
bindings.insert(Command::DeleteChar, vec![
KeyBinding::new(KeyCode::Backspace),
]);
bindings.insert(Command::DeleteWord, vec![
KeyBinding::with_ctrl(KeyCode::Backspace),
KeyBinding::with_ctrl(KeyCode::Char('w')),
]);
bindings.insert(Command::MoveToStart, vec![
KeyBinding::new(KeyCode::Home),
KeyBinding::with_ctrl(KeyCode::Char('a')),
]);
bindings.insert(Command::MoveToEnd, vec![
KeyBinding::new(KeyCode::End),
KeyBinding::with_ctrl(KeyCode::Char('e')),
]);
bindings.insert(Command::DeleteChar, vec![KeyBinding::new(KeyCode::Backspace)]);
bindings.insert(
Command::DeleteWord,
vec![
KeyBinding::with_ctrl(KeyCode::Backspace),
KeyBinding::with_ctrl(KeyCode::Char('w')),
],
);
bindings.insert(
Command::MoveToStart,
vec![
KeyBinding::new(KeyCode::Home),
KeyBinding::with_ctrl(KeyCode::Char('a')),
],
);
bindings.insert(
Command::MoveToEnd,
vec![
KeyBinding::new(KeyCode::End),
KeyBinding::with_ctrl(KeyCode::Char('e')),
],
);
// Vim mode
bindings.insert(Command::EnterInsertMode, vec![
KeyBinding::new(KeyCode::Char('i')),
KeyBinding::new(KeyCode::Char('ш')), // RU
]);
bindings.insert(
Command::EnterInsertMode,
vec![
KeyBinding::new(KeyCode::Char('i')),
KeyBinding::new(KeyCode::Char('ш')), // RU
],
);
// Profile
bindings.insert(Command::OpenProfile, vec![
KeyBinding::with_ctrl(KeyCode::Char('u')), // Ctrl+U instead of Ctrl+I
KeyBinding::with_ctrl(KeyCode::Char('г')), // RU
]);
bindings.insert(
Command::OpenProfile,
vec![
KeyBinding::with_ctrl(KeyCode::Char('u')), // Ctrl+U instead of Ctrl+I
KeyBinding::with_ctrl(KeyCode::Char('г')), // RU
],
);
Self { bindings }
}
@@ -395,9 +421,10 @@ mod key_code_serde {
let s = String::deserialize(deserializer)?;
if s.starts_with("Char('") && s.ends_with("')") {
let c = s.chars().nth(6).ok_or_else(|| {
serde::de::Error::custom("Invalid Char format")
})?;
let c = s
.chars()
.nth(6)
.ok_or_else(|| serde::de::Error::custom("Invalid Char format"))?;
return Ok(KeyCode::Char(c));
}

View File

@@ -284,10 +284,22 @@ mod tests {
let keybindings = &config.keybindings;
// Test that keybindings exist for common commands
assert!(keybindings.get_command(&KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE)) == Some(Command::ReplyMessage));
assert!(keybindings.get_command(&KeyEvent::new(KeyCode::Char('к'), KeyModifiers::NONE)) == Some(Command::ReplyMessage));
assert!(keybindings.get_command(&KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE)) == Some(Command::ForwardMessage));
assert!(keybindings.get_command(&KeyEvent::new(KeyCode::Char('а'), KeyModifiers::NONE)) == Some(Command::ForwardMessage));
assert!(
keybindings.get_command(&KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE))
== Some(Command::ReplyMessage)
);
assert!(
keybindings.get_command(&KeyEvent::new(KeyCode::Char('к'), KeyModifiers::NONE))
== Some(Command::ReplyMessage)
);
assert!(
keybindings.get_command(&KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE))
== Some(Command::ForwardMessage)
);
assert!(
keybindings.get_command(&KeyEvent::new(KeyCode::Char('а'), KeyModifiers::NONE))
== Some(Command::ForwardMessage)
);
}
#[test]
@@ -355,10 +367,24 @@ mod tests {
#[test]
fn test_config_validate_valid_all_standard_colors() {
let colors = [
"black", "red", "green", "yellow", "blue", "magenta",
"cyan", "gray", "grey", "white", "darkgray", "darkgrey",
"lightred", "lightgreen", "lightyellow", "lightblue",
"lightmagenta", "lightcyan"
"black",
"red",
"green",
"yellow",
"blue",
"magenta",
"cyan",
"gray",
"grey",
"white",
"darkgray",
"darkgrey",
"lightred",
"lightgreen",
"lightyellow",
"lightblue",
"lightmagenta",
"lightcyan",
];
for color in colors {
@@ -369,11 +395,7 @@ mod tests {
config.colors.reaction_chosen = color.to_string();
config.colors.reaction_other = color.to_string();
assert!(
config.validate().is_ok(),
"Color '{}' should be valid",
color
);
assert!(config.validate().is_ok(), "Color '{}' should be valid", color);
}
}

View File

@@ -277,11 +277,7 @@ mod tests {
#[test]
fn test_format_text_with_bold() {
let text = "Hello";
let entities = vec![TextEntity {
offset: 0,
length: 5,
r#type: TextEntityType::Bold,
}];
let entities = vec![TextEntity { offset: 0, length: 5, r#type: TextEntityType::Bold }];
let spans = format_text_with_entities(text, &entities, Color::White);
assert_eq!(spans.len(), 1);

View File

@@ -20,7 +20,8 @@ pub async fn handle<T: TdClientTrait>(app: &mut App<T>, key_code: KeyCode) {
app.status_message = Some("Отправка номера...".to_string());
match with_timeout_msg(
Duration::from_secs(10),
app.td_client.send_phone_number(app.phone_input().to_string()),
app.td_client
.send_phone_number(app.phone_input().to_string()),
"Таймаут отправки номера",
)
.await
@@ -84,7 +85,8 @@ pub async fn handle<T: TdClientTrait>(app: &mut App<T>, key_code: KeyCode) {
app.status_message = Some("Проверка пароля...".to_string());
match with_timeout_msg(
Duration::from_secs(10),
app.td_client.send_password(app.password_input().to_string()),
app.td_client
.send_password(app.password_input().to_string()),
"Таймаут проверки пароля",
)
.await

View File

@@ -6,22 +6,22 @@
//! - Editing and sending messages
//! - Loading older messages
use super::chat_list::open_chat_and_load_data;
use crate::app::methods::{
compose::ComposeMethods, messages::MessageMethods, modal::ModalMethods,
navigation::NavigationMethods,
};
use crate::app::App;
use crate::app::InputMode;
use crate::app::methods::{
compose::ComposeMethods, messages::MessageMethods,
modal::ModalMethods, navigation::NavigationMethods,
};
use crate::tdlib::{TdClientTrait, ChatAction};
use crate::input::handlers::{copy_to_clipboard, format_message_for_clipboard};
use crate::tdlib::{ChatAction, TdClientTrait};
use crate::types::{ChatId, MessageId};
use crate::utils::{is_non_empty, with_timeout, with_timeout_msg};
use crate::input::handlers::{copy_to_clipboard, format_message_for_clipboard};
use super::chat_list::open_chat_and_load_data;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use std::time::{Duration, Instant};
/// Обработка режима выбора сообщения для действий
///
///
/// Обрабатывает:
/// - Навигацию по сообщениям (Up/Down)
/// - Удаление сообщения (d/в/Delete)
@@ -29,7 +29,11 @@ use std::time::{Duration, Instant};
/// - Пересылку сообщения (f/а)
/// - Копирование сообщения (y/н)
/// - Добавление реакции (e/у)
pub async fn handle_message_selection<T: TdClientTrait>(app: &mut App<T>, _key: KeyEvent, command: Option<crate::config::Command>) {
pub async fn handle_message_selection<T: TdClientTrait>(
app: &mut App<T>,
_key: KeyEvent,
command: Option<crate::config::Command>,
) {
match command {
Some(crate::config::Command::MoveUp) => {
app.select_previous_message();
@@ -44,9 +48,7 @@ pub async fn handle_message_selection<T: TdClientTrait>(app: &mut App<T>, _key:
let can_delete =
msg.can_be_deleted_only_for_self() || msg.can_be_deleted_for_all_users();
if can_delete {
app.chat_state = crate::app::ChatState::DeleteConfirmation {
message_id: msg.id(),
};
app.chat_state = crate::app::ChatState::DeleteConfirmation { message_id: msg.id() };
}
}
Some(crate::config::Command::EnterInsertMode) => {
@@ -129,17 +131,22 @@ pub async fn handle_message_selection<T: TdClientTrait>(app: &mut App<T>, _key:
}
/// Редактирование существующего сообщения
pub async fn edit_message<T: TdClientTrait>(app: &mut App<T>, chat_id: i64, msg_id: MessageId, text: String) {
pub async fn edit_message<T: TdClientTrait>(
app: &mut App<T>,
chat_id: i64,
msg_id: MessageId,
text: String,
) {
// Проверяем, что сообщение есть в локальном кэше
let msg_exists = app.td_client.current_chat_messages()
let msg_exists = app
.td_client
.current_chat_messages()
.iter()
.any(|m| m.id() == msg_id);
if !msg_exists {
app.error_message = Some(format!(
"Сообщение {} не найдено в кэше чата {}",
msg_id.as_i64(), chat_id
));
app.error_message =
Some(format!("Сообщение {} не найдено в кэше чата {}", msg_id.as_i64(), chat_id));
app.chat_state = crate::app::ChatState::Normal;
app.message_input.clear();
app.cursor_position = 0;
@@ -148,7 +155,8 @@ pub async fn edit_message<T: TdClientTrait>(app: &mut App<T>, chat_id: i64, msg_
match with_timeout_msg(
Duration::from_secs(5),
app.td_client.edit_message(ChatId::new(chat_id), msg_id, text),
app.td_client
.edit_message(ChatId::new(chat_id), msg_id, text),
"Таймаут редактирования",
)
.await
@@ -160,8 +168,12 @@ pub async fn edit_message<T: TdClientTrait>(app: &mut App<T>, chat_id: i64, msg_
let old_reply_to = messages[pos].interactions.reply_to.clone();
// Если в старом сообщении был reply и в новом он "Unknown" - сохраняем старый
if let Some(old_reply) = old_reply_to {
if edited_msg.interactions.reply_to.as_ref()
.map_or(true, |r| r.sender_name == "Unknown") {
if edited_msg
.interactions
.reply_to
.as_ref()
.map_or(true, |r| r.sender_name == "Unknown")
{
edited_msg.interactions.reply_to = Some(old_reply);
}
}
@@ -189,13 +201,13 @@ pub async fn send_new_message<T: TdClientTrait>(app: &mut App<T>, chat_id: i64,
};
// Создаём ReplyInfo ДО отправки, пока сообщение точно доступно
let reply_info = app.get_replying_to_message().map(|m| {
crate::tdlib::ReplyInfo {
let reply_info = app
.get_replying_to_message()
.map(|m| crate::tdlib::ReplyInfo {
message_id: m.id(),
sender_name: m.sender_name().to_string(),
text: m.text().to_string(),
}
});
});
app.message_input.clear();
app.cursor_position = 0;
@@ -206,11 +218,14 @@ pub async fn send_new_message<T: TdClientTrait>(app: &mut App<T>, chat_id: i64,
app.last_typing_sent = None;
// Отменяем typing status
app.td_client.send_chat_action(ChatId::new(chat_id), ChatAction::Cancel).await;
app.td_client
.send_chat_action(ChatId::new(chat_id), ChatAction::Cancel)
.await;
match with_timeout_msg(
Duration::from_secs(5),
app.td_client.send_message(ChatId::new(chat_id), text, reply_to_id, reply_info),
app.td_client
.send_message(ChatId::new(chat_id), text, reply_to_id, reply_info),
"Таймаут отправки",
)
.await
@@ -228,7 +243,7 @@ pub async fn send_new_message<T: TdClientTrait>(app: &mut App<T>, chat_id: i64,
}
/// Обработка клавиши Enter
///
///
/// Обрабатывает три сценария:
/// 1. В режиме выбора сообщения: начать редактирование
/// 2. В открытом чате: отправить новое или редактировать существующее сообщение
@@ -304,7 +319,8 @@ pub async fn send_reaction<T: TdClientTrait>(app: &mut App<T>) {
// Send reaction with timeout
let result = with_timeout_msg(
Duration::from_secs(5),
app.td_client.toggle_reaction(chat_id, message_id, emoji.clone()),
app.td_client
.toggle_reaction(chat_id, message_id, emoji.clone()),
"Таймаут отправки реакции",
)
.await;
@@ -353,7 +369,8 @@ pub async fn load_older_messages_if_needed<T: TdClientTrait>(app: &mut App<T>) {
// Load older messages with timeout
let Ok(older) = with_timeout(
Duration::from_secs(3),
app.td_client.load_older_messages(ChatId::new(chat_id), oldest_msg_id),
app.td_client
.load_older_messages(ChatId::new(chat_id), oldest_msg_id),
)
.await
else {
@@ -368,7 +385,7 @@ pub async fn load_older_messages_if_needed<T: TdClientTrait>(app: &mut App<T>) {
}
/// Обработка ввода клавиатуры в открытом чате
///
///
/// Обрабатывает:
/// - Backspace/Delete: удаление символов относительно курсора
/// - Char: вставка символов в позицию курсора + typing status
@@ -408,7 +425,8 @@ pub async fn handle_open_chat_keyboard_input<T: TdClientTrait>(app: &mut App<T>,
// Игнорируем символы с Ctrl/Alt модификаторами (кроме Shift)
// Это позволяет обрабатывать хоткеи типа Ctrl+U для профиля
if key.modifiers.contains(KeyModifiers::CONTROL)
|| key.modifiers.contains(KeyModifiers::ALT) {
|| key.modifiers.contains(KeyModifiers::ALT)
{
return;
}
@@ -434,7 +452,9 @@ pub async fn handle_open_chat_keyboard_input<T: TdClientTrait>(app: &mut App<T>,
.unwrap_or(true);
if should_send_typing {
if let Some(chat_id) = app.get_selected_chat_id() {
app.td_client.send_chat_action(ChatId::new(chat_id), ChatAction::Typing).await;
app.td_client
.send_chat_action(ChatId::new(chat_id), ChatAction::Typing)
.await;
app.last_typing_sent = Some(Instant::now());
}
}
@@ -621,8 +641,7 @@ async fn handle_view_image<T: TdClientTrait>(app: &mut App<T>) {
for msg in app.td_client.current_chat_messages_mut() {
if let Some(photo) = msg.photo_info_mut() {
if photo.file_id == file_id {
photo.download_state =
PhotoDownloadState::Downloaded(path.clone());
photo.download_state = PhotoDownloadState::Downloaded(path.clone());
break;
}
}
@@ -640,8 +659,7 @@ async fn handle_view_image<T: TdClientTrait>(app: &mut App<T>) {
for msg in app.td_client.current_chat_messages_mut() {
if let Some(photo) = msg.photo_info_mut() {
if photo.file_id == file_id {
photo.download_state =
PhotoDownloadState::Error(e.clone());
photo.download_state = PhotoDownloadState::Error(e.clone());
break;
}
}
@@ -660,8 +678,7 @@ async fn handle_view_image<T: TdClientTrait>(app: &mut App<T>) {
for msg in app.td_client.current_chat_messages_mut() {
if let Some(photo) = msg.photo_info_mut() {
if photo.file_id == file_id {
photo.download_state =
PhotoDownloadState::Downloaded(path.clone());
photo.download_state = PhotoDownloadState::Downloaded(path.clone());
break;
}
}
@@ -748,13 +765,25 @@ async fn handle_play_voice<T: TdClientTrait>(app: &mut App<T>) {
if let Ok(entries) = std::fs::read_dir(parent) {
for entry in entries.flatten() {
let entry_name = entry.file_name();
if entry_name.to_string_lossy().starts_with(&stem.to_string_lossy().to_string()) {
if entry_name
.to_string_lossy()
.starts_with(&stem.to_string_lossy().to_string())
{
let found_path = entry.path().to_string_lossy().to_string();
// Кэшируем найденный файл
if let Some(ref mut cache) = app.voice_cache {
let _ = cache.store(&file_id.to_string(), Path::new(&found_path));
let _ = cache.store(
&file_id.to_string(),
Path::new(&found_path),
);
}
return handle_play_voice_from_path(app, &found_path, &voice, &msg).await;
return handle_play_voice_from_path(
app,
&found_path,
&voice,
&msg,
)
.await;
}
}
}
@@ -826,4 +855,3 @@ async fn _download_and_expand<T: TdClientTrait>(app: &mut App<T>, msg_id: crate:
// Закомментировано - будет реализовано в Этапе 4
}
*/

View File

@@ -5,9 +5,11 @@
//! - Folder selection
//! - Opening chats
use crate::app::methods::{
compose::ComposeMethods, messages::MessageMethods, navigation::NavigationMethods,
};
use crate::app::App;
use crate::app::InputMode;
use crate::app::methods::{compose::ComposeMethods, messages::MessageMethods, navigation::NavigationMethods};
use crate::tdlib::TdClientTrait;
use crate::types::{ChatId, MessageId};
use crate::utils::{with_timeout, with_timeout_msg};
@@ -15,11 +17,15 @@ use crossterm::event::KeyEvent;
use std::time::Duration;
/// Обработка навигации в списке чатов
///
///
/// Обрабатывает:
/// - Up/Down/j/k: навигация между чатами
/// - Цифры 1-9: переключение папок (1=All, 2-9=папки из TDLib)
pub async fn handle_chat_list_navigation<T: TdClientTrait>(app: &mut App<T>, _key: KeyEvent, command: Option<crate::config::Command>) {
pub async fn handle_chat_list_navigation<T: TdClientTrait>(
app: &mut App<T>,
_key: KeyEvent,
command: Option<crate::config::Command>,
) {
match command {
Some(crate::config::Command::MoveDown) => {
app.next_chat();
@@ -65,11 +71,9 @@ pub async fn select_folder<T: TdClientTrait>(app: &mut App<T>, folder_idx: usize
let folder_id = folder.id;
app.selected_folder_id = Some(folder_id);
app.status_message = Some("Загрузка чатов папки...".to_string());
let _ = with_timeout(
Duration::from_secs(5),
app.td_client.load_folder_chats(folder_id, 50),
)
.await;
let _ =
with_timeout(Duration::from_secs(5), app.td_client.load_folder_chats(folder_id, 50))
.await;
app.status_message = None;
app.chat_list_state.select(Some(0));
}
@@ -114,7 +118,8 @@ pub async fn open_chat_and_load_data<T: TdClientTrait>(app: &mut App<T>, chat_id
// ВАЖНО: Устанавливаем current_chat_id ТОЛЬКО ПОСЛЕ сохранения истории
// Это предотвращает race condition с Update::NewMessage
app.td_client.set_current_chat_id(Some(ChatId::new(chat_id)));
app.td_client
.set_current_chat_id(Some(ChatId::new(chat_id)));
// Загружаем черновик (локальная операция, мгновенно)
app.load_draft();
@@ -132,4 +137,4 @@ pub async fn open_chat_and_load_data<T: TdClientTrait>(app: &mut App<T>, chat_id
app.status_message = None;
}
}
}
}

View File

@@ -6,10 +6,10 @@
//! - Edit mode
//! - Cursor movement and text editing
use crate::app::App;
use crate::app::methods::{
compose::ComposeMethods, navigation::NavigationMethods, search::SearchMethods,
};
use crate::app::App;
use crate::tdlib::TdClientTrait;
use crate::types::ChatId;
use crate::utils::with_timeout_msg;
@@ -17,12 +17,16 @@ use crossterm::event::KeyEvent;
use std::time::Duration;
/// Обработка режима выбора чата для пересылки сообщения
///
///
/// Обрабатывает:
/// - Навигацию по списку чатов (Up/Down)
/// - Пересылку сообщения в выбранный чат (Enter)
/// - Отмену пересылки (Esc)
pub async fn handle_forward_mode<T: TdClientTrait>(app: &mut App<T>, _key: KeyEvent, command: Option<crate::config::Command>) {
pub async fn handle_forward_mode<T: TdClientTrait>(
app: &mut App<T>,
_key: KeyEvent,
command: Option<crate::config::Command>,
) {
match command {
Some(crate::config::Command::Cancel) => {
app.cancel_forward();
@@ -63,11 +67,8 @@ pub async fn forward_selected_message<T: TdClientTrait>(app: &mut App<T>) {
// Forward the message with timeout
let result = with_timeout_msg(
Duration::from_secs(5),
app.td_client.forward_messages(
to_chat_id,
ChatId::new(from_chat_id),
vec![msg_id],
),
app.td_client
.forward_messages(to_chat_id, ChatId::new(from_chat_id), vec![msg_id]),
"Таймаут пересылки",
)
.await;
@@ -81,4 +82,4 @@ pub async fn forward_selected_message<T: TdClientTrait>(app: &mut App<T>) {
app.error_message = Some(e);
}
}
}
}

View File

@@ -6,8 +6,8 @@
//! - Ctrl+P: View pinned messages
//! - Ctrl+F: Search messages in chat
use crate::app::App;
use crate::app::methods::{modal::ModalMethods, search::SearchMethods};
use crate::app::App;
use crate::tdlib::TdClientTrait;
use crate::types::ChatId;
use crate::utils::{with_timeout, with_timeout_msg};
@@ -47,7 +47,8 @@ pub async fn handle_global_commands<T: TdClientTrait>(app: &mut App<T>, key: Key
KeyCode::Char('r') if has_ctrl => {
// Ctrl+R - обновить список чатов
app.status_message = Some("Обновление чатов...".to_string());
let _ = with_timeout(Duration::from_secs(5), app.td_client.load_chats(50)).await;
let _ =
with_timeout(Duration::from_secs(5), app.td_client.load_chats(50)).await;
// Синхронизируем muted чаты после обновления
app.td_client.sync_notification_muted_chats();
app.status_message = None;

View File

@@ -10,13 +10,13 @@
//! - modal: Modal dialogs (delete confirmation, emoji picker, etc.)
//! - search: Search functionality (chat search, message search)
pub mod clipboard;
pub mod global;
pub mod profile;
pub mod chat;
pub mod chat_list;
pub mod clipboard;
pub mod compose;
pub mod global;
pub mod modal;
pub mod profile;
pub mod search;
pub use clipboard::*;

View File

@@ -7,13 +7,13 @@
//! - Pinned messages view
//! - Profile information modal
use crate::app::{AccountSwitcherState, App};
use super::scroll_to_message;
use crate::app::methods::{modal::ModalMethods, navigation::NavigationMethods};
use crate::app::{AccountSwitcherState, App};
use crate::input::handlers::get_available_actions_count;
use crate::tdlib::TdClientTrait;
use crate::types::{ChatId, MessageId};
use crate::utils::{with_timeout_msg, modal_handler::handle_yes_no};
use crate::input::handlers::get_available_actions_count;
use super::scroll_to_message;
use crate::utils::{modal_handler::handle_yes_no, with_timeout_msg};
use crossterm::event::{KeyCode, KeyEvent};
use std::time::Duration;
@@ -65,58 +65,60 @@ pub async fn handle_account_switcher<T: TdClientTrait>(
}
}
}
AccountSwitcherState::AddAccount { .. } => {
match key.code {
KeyCode::Esc => {
app.account_switcher_back();
}
KeyCode::Enter => {
app.account_switcher_confirm_add();
}
KeyCode::Backspace => {
if let Some(AccountSwitcherState::AddAccount {
name_input,
cursor_position,
error,
}) = &mut app.account_switcher
{
if *cursor_position > 0 {
let mut chars: Vec<char> = name_input.chars().collect();
chars.remove(*cursor_position - 1);
*name_input = chars.into_iter().collect();
*cursor_position -= 1;
*error = None;
}
}
}
KeyCode::Char(c) => {
if let Some(AccountSwitcherState::AddAccount {
name_input,
cursor_position,
error,
}) = &mut app.account_switcher
{
AccountSwitcherState::AddAccount { .. } => match key.code {
KeyCode::Esc => {
app.account_switcher_back();
}
KeyCode::Enter => {
app.account_switcher_confirm_add();
}
KeyCode::Backspace => {
if let Some(AccountSwitcherState::AddAccount {
name_input,
cursor_position,
error,
}) = &mut app.account_switcher
{
if *cursor_position > 0 {
let mut chars: Vec<char> = name_input.chars().collect();
chars.insert(*cursor_position, c);
chars.remove(*cursor_position - 1);
*name_input = chars.into_iter().collect();
*cursor_position += 1;
*cursor_position -= 1;
*error = None;
}
}
_ => {}
}
}
KeyCode::Char(c) => {
if let Some(AccountSwitcherState::AddAccount {
name_input,
cursor_position,
error,
}) = &mut app.account_switcher
{
let mut chars: Vec<char> = name_input.chars().collect();
chars.insert(*cursor_position, c);
*name_input = chars.into_iter().collect();
*cursor_position += 1;
*error = None;
}
}
_ => {}
},
}
}
/// Обработка режима профиля пользователя/чата
///
///
/// Обрабатывает:
/// - Модалку подтверждения выхода из группы (двухшаговая)
/// - Навигацию по действиям профиля (Up/Down)
/// - Выполнение выбранного действия (Enter): открыть в браузере, скопировать ID, покинуть группу
/// - Выход из режима профиля (Esc)
pub async fn handle_profile_mode<T: TdClientTrait>(app: &mut App<T>, key: KeyEvent, command: Option<crate::config::Command>) {
pub async fn handle_profile_mode<T: TdClientTrait>(
app: &mut App<T>,
key: KeyEvent,
command: Option<crate::config::Command>,
) {
// Обработка подтверждения выхода из группы
let confirmation_step = app.get_leave_group_confirmation_step();
if confirmation_step > 0 {
@@ -189,10 +191,7 @@ pub async fn handle_profile_mode<T: TdClientTrait>(app: &mut App<T>, key: KeyEve
// Действие: Открыть в браузере
if let Some(username) = &profile.username {
if action_index == current_idx {
let url = format!(
"https://t.me/{}",
username.trim_start_matches('@')
);
let url = format!("https://t.me/{}", username.trim_start_matches('@'));
#[cfg(feature = "url-open")]
{
match open::that(&url) {
@@ -208,7 +207,7 @@ pub async fn handle_profile_mode<T: TdClientTrait>(app: &mut App<T>, key: KeyEve
#[cfg(not(feature = "url-open"))]
{
app.error_message = Some(
"Открытие URL недоступно (требуется feature 'url-open')".to_string()
"Открытие URL недоступно (требуется feature 'url-open')".to_string(),
);
}
return;
@@ -233,7 +232,7 @@ pub async fn handle_profile_mode<T: TdClientTrait>(app: &mut App<T>, key: KeyEve
}
/// Обработка Ctrl+U для открытия профиля чата/пользователя
///
///
/// Загружает информацию о профиле и переключает в режим просмотра профиля
pub async fn handle_profile_open<T: TdClientTrait>(app: &mut App<T>) {
let Some(chat_id) = app.selected_chat_id else {
@@ -319,12 +318,16 @@ pub async fn handle_delete_confirmation<T: TdClientTrait>(app: &mut App<T>, key:
}
/// Обработка режима выбора реакции (emoji picker)
///
///
/// Обрабатывает:
/// - Навигацию по сетке реакций: Left/Right, Up/Down (сетка 8x6)
/// - Добавление/удаление реакции (Enter)
/// - Выход из режима (Esc)
pub async fn handle_reaction_picker_mode<T: TdClientTrait>(app: &mut App<T>, _key: KeyEvent, command: Option<crate::config::Command>) {
pub async fn handle_reaction_picker_mode<T: TdClientTrait>(
app: &mut App<T>,
_key: KeyEvent,
command: Option<crate::config::Command>,
) {
match command {
Some(crate::config::Command::MoveLeft) => {
app.select_previous_reaction();
@@ -335,10 +338,8 @@ pub async fn handle_reaction_picker_mode<T: TdClientTrait>(app: &mut App<T>, _ke
app.needs_redraw = true;
}
Some(crate::config::Command::MoveUp) => {
if let crate::app::ChatState::ReactionPicker {
selected_index,
..
} = &mut app.chat_state
if let crate::app::ChatState::ReactionPicker { selected_index, .. } =
&mut app.chat_state
{
if *selected_index >= 8 {
*selected_index = selected_index.saturating_sub(8);
@@ -372,12 +373,16 @@ pub async fn handle_reaction_picker_mode<T: TdClientTrait>(app: &mut App<T>, _ke
}
/// Обработка режима просмотра закреплённых сообщений
///
///
/// Обрабатывает:
/// - Навигацию по закреплённым сообщениям (Up/Down)
/// - Переход к сообщению в истории (Enter)
/// - Выход из режима (Esc)
pub async fn handle_pinned_mode<T: TdClientTrait>(app: &mut App<T>, _key: KeyEvent, command: Option<crate::config::Command>) {
pub async fn handle_pinned_mode<T: TdClientTrait>(
app: &mut App<T>,
_key: KeyEvent,
command: Option<crate::config::Command>,
) {
match command {
Some(crate::config::Command::Cancel) => {
app.exit_pinned_mode();
@@ -396,4 +401,4 @@ pub async fn handle_pinned_mode<T: TdClientTrait>(app: &mut App<T>, _key: KeyEve
}
_ => {}
}
}
}

View File

@@ -5,8 +5,8 @@
//! - Message search mode
//! - Search query input
use crate::app::App;
use crate::app::methods::{navigation::NavigationMethods, search::SearchMethods};
use crate::app::App;
use crate::tdlib::TdClientTrait;
use crate::types::{ChatId, MessageId};
use crate::utils::with_timeout;
@@ -17,13 +17,17 @@ use super::chat_list::open_chat_and_load_data;
use super::scroll_to_message;
/// Обработка режима поиска по чатам
///
///
/// Обрабатывает:
/// - Редактирование поискового запроса (Backspace, Char)
/// - Навигацию по отфильтрованному списку (Up/Down)
/// - Открытие выбранного чата (Enter)
/// - Отмену поиска (Esc)
pub async fn handle_chat_search_mode<T: TdClientTrait>(app: &mut App<T>, key: KeyEvent, command: Option<crate::config::Command>) {
pub async fn handle_chat_search_mode<T: TdClientTrait>(
app: &mut App<T>,
key: KeyEvent,
command: Option<crate::config::Command>,
) {
match command {
Some(crate::config::Command::Cancel) => {
app.cancel_search();
@@ -40,30 +44,32 @@ pub async fn handle_chat_search_mode<T: TdClientTrait>(app: &mut App<T>, key: Ke
Some(crate::config::Command::MoveUp) => {
app.previous_filtered_chat();
}
_ => {
match key.code {
KeyCode::Backspace => {
app.search_query.pop();
app.chat_list_state.select(Some(0));
}
KeyCode::Char(c) => {
app.search_query.push(c);
app.chat_list_state.select(Some(0));
}
_ => {}
_ => match key.code {
KeyCode::Backspace => {
app.search_query.pop();
app.chat_list_state.select(Some(0));
}
}
KeyCode::Char(c) => {
app.search_query.push(c);
app.chat_list_state.select(Some(0));
}
_ => {}
},
}
}
/// Обработка режима поиска по сообщениям в открытом чате
///
///
/// Обрабатывает:
/// - Навигацию по результатам поиска (Up/Down/N/n)
/// - Переход к выбранному сообщению (Enter)
/// - Редактирование поискового запроса (Backspace, Char)
/// - Выход из режима поиска (Esc)
pub async fn handle_message_search_mode<T: TdClientTrait>(app: &mut App<T>, key: KeyEvent, command: Option<crate::config::Command>) {
pub async fn handle_message_search_mode<T: TdClientTrait>(
app: &mut App<T>,
key: KeyEvent,
command: Option<crate::config::Command>,
) {
match command {
Some(crate::config::Command::Cancel) => {
app.exit_message_search_mode();
@@ -80,33 +86,31 @@ pub async fn handle_message_search_mode<T: TdClientTrait>(app: &mut App<T>, key:
app.exit_message_search_mode();
}
}
_ => {
match key.code {
KeyCode::Char('N') => {
app.select_previous_search_result();
}
KeyCode::Char('n') => {
app.select_next_search_result();
}
KeyCode::Backspace => {
let Some(mut query) = app.get_search_query().map(|s| s.to_string()) else {
return;
};
query.pop();
app.update_search_query(query.clone());
perform_message_search(app, &query).await;
}
KeyCode::Char(c) => {
let Some(mut query) = app.get_search_query().map(|s| s.to_string()) else {
return;
};
query.push(c);
app.update_search_query(query.clone());
perform_message_search(app, &query).await;
}
_ => {}
_ => match key.code {
KeyCode::Char('N') => {
app.select_previous_search_result();
}
}
KeyCode::Char('n') => {
app.select_next_search_result();
}
KeyCode::Backspace => {
let Some(mut query) = app.get_search_query().map(|s| s.to_string()) else {
return;
};
query.pop();
app.update_search_query(query.clone());
perform_message_search(app, &query).await;
}
KeyCode::Char(c) => {
let Some(mut query) = app.get_search_query().map(|s| s.to_string()) else {
return;
};
query.push(c);
app.update_search_query(query.clone());
perform_message_search(app, &query).await;
}
_ => {}
},
}
}
@@ -129,4 +133,4 @@ pub async fn perform_message_search<T: TdClientTrait>(app: &mut App<T>, query: &
{
app.set_search_results(results);
}
}
}

View File

@@ -3,35 +3,26 @@
//! Dispatches keyboard events to specialized handlers based on current app mode.
//! Priority order: modals → search → compose → chat → chat list.
use crate::app::methods::{
compose::ComposeMethods, messages::MessageMethods, modal::ModalMethods,
navigation::NavigationMethods, search::SearchMethods,
};
use crate::app::App;
use crate::app::InputMode;
use crate::app::methods::{
compose::ComposeMethods,
messages::MessageMethods,
modal::ModalMethods,
navigation::NavigationMethods,
search::SearchMethods,
};
use crate::tdlib::TdClientTrait;
use crate::input::handlers::{
chat::{handle_enter_key, handle_message_selection, handle_open_chat_keyboard_input},
chat_list::handle_chat_list_navigation,
compose::handle_forward_mode,
handle_global_commands,
modal::{
handle_account_switcher,
handle_profile_mode, handle_profile_open, handle_delete_confirmation,
handle_reaction_picker_mode, handle_pinned_mode,
handle_account_switcher, handle_delete_confirmation, handle_pinned_mode,
handle_profile_mode, handle_profile_open, handle_reaction_picker_mode,
},
search::{handle_chat_search_mode, handle_message_search_mode},
compose::handle_forward_mode,
chat_list::handle_chat_list_navigation,
chat::{
handle_message_selection, handle_enter_key,
handle_open_chat_keyboard_input,
},
};
use crate::tdlib::TdClientTrait;
use crossterm::event::KeyEvent;
/// Обработка клавиши Esc в Normal mode
///
/// Закрывает чат с сохранением черновика
@@ -55,7 +46,10 @@ async fn handle_escape_normal<T: TdClientTrait>(app: &mut App<T>) {
let _ = app.td_client.set_draft_message(chat_id, draft_text).await;
} else {
// Очищаем черновик если инпут пустой
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();
@@ -252,7 +246,7 @@ pub async fn handle<T: TdClientTrait>(app: &mut App<T>, key: KeyEvent) {
}
/// Обработка модального окна просмотра изображения
///
///
/// Hotkeys:
/// - Esc/q: закрыть модальное окно
/// - ←: предыдущее фото в чате
@@ -331,4 +325,3 @@ async fn navigate_to_adjacent_photo<T: TdClientTrait>(app: &mut App<T>, directio
};
app.status_message = Some(msg.to_string());
}

View File

@@ -57,7 +57,7 @@ async fn main() -> Result<(), io::Error> {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("warn"))
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("warn")),
)
.init();
@@ -70,15 +70,16 @@ async fn main() -> Result<(), io::Error> {
// Резолвим аккаунт из CLI или default
let account_arg = parse_account_arg();
let (account_name, db_path) =
accounts::resolve_account(&accounts_config, account_arg.as_deref())
.unwrap_or_else(|e| {
eprintln!("Error: {}", e);
std::process::exit(1);
});
accounts::resolve_account(&accounts_config, account_arg.as_deref()).unwrap_or_else(|e| {
eprintln!("Error: {}", e);
std::process::exit(1);
});
// Создаём директорию аккаунта если её нет
let db_path = accounts::ensure_account_dir(
account_arg.as_deref().unwrap_or(&accounts_config.default_account),
account_arg
.as_deref()
.unwrap_or(&accounts_config.default_account),
)
.unwrap_or(db_path);
@@ -112,14 +113,14 @@ async fn main() -> Result<(), io::Error> {
tokio::spawn(async move {
let _ = tdlib_rs::functions::set_tdlib_parameters(
false, // use_test_dc
db_path_str, // database_directory
"".to_string(), // files_directory
"".to_string(), // database_encryption_key
true, // use_file_database
true, // use_chat_info_database
true, // use_message_database
false, // use_secret_chats
false, // use_test_dc
db_path_str, // database_directory
"".to_string(), // files_directory
"".to_string(), // database_encryption_key
true, // use_file_database
true, // use_chat_info_database
true, // use_message_database
false, // use_secret_chats
api_id,
api_hash,
"en".to_string(), // system_language_code
@@ -292,7 +293,11 @@ async fn run_app<B: ratatui::backend::Backend, T: tdlib::TdClientTrait>(
let _ = tdlib_rs::functions::close(app.td_client.client_id()).await;
// Ждём завершения polling задачи (с таймаутом)
with_timeout_ignore(Duration::from_secs(SHUTDOWN_TIMEOUT_SECS), polling_handle).await;
with_timeout_ignore(
Duration::from_secs(SHUTDOWN_TIMEOUT_SECS),
polling_handle,
)
.await;
return Ok(());
}
@@ -330,11 +335,8 @@ async fn run_app<B: ratatui::backend::Backend, T: tdlib::TdClientTrait>(
// Process pending chat initialization (reply info, pinned, photos)
if let Some(chat_id) = app.pending_chat_init.take() {
// Загружаем недостающие reply info (игнорируем ошибки)
with_timeout_ignore(
Duration::from_secs(5),
app.td_client.fetch_missing_reply_info(),
)
.await;
with_timeout_ignore(Duration::from_secs(5), app.td_client.fetch_missing_reply_info())
.await;
// Загружаем последнее закреплённое сообщение (игнорируем ошибки)
with_timeout_ignore(
@@ -372,25 +374,22 @@ async fn run_app<B: ratatui::backend::Backend, T: tdlib::TdClientTrait>(
for file_id in photo_file_ids {
let tx = tx.clone();
tokio::spawn(async move {
let result = tokio::time::timeout(
Duration::from_secs(5),
async {
match tdlib_rs::functions::download_file(
file_id, 1, 0, 0, true, client_id,
)
.await
let result = tokio::time::timeout(Duration::from_secs(5), async {
match tdlib_rs::functions::download_file(
file_id, 1, 0, 0, true, client_id,
)
.await
{
Ok(tdlib_rs::enums::File::File(file))
if file.local.is_downloading_completed
&& !file.local.path.is_empty() =>
{
Ok(tdlib_rs::enums::File::File(file))
if file.local.is_downloading_completed
&& !file.local.path.is_empty() =>
{
Ok(file.local.path)
}
Ok(_) => Err("Файл не скачан".to_string()),
Err(e) => Err(format!("{:?}", e)),
Ok(file.local.path)
}
},
)
Ok(_) => Err("Файл не скачан".to_string()),
Err(e) => Err(format!("{:?}", e)),
}
})
.await;
let result = match result {

View File

@@ -33,10 +33,7 @@ impl ImageCache {
let path = self.cache_dir.join(format!("{}.jpg", file_id));
if path.exists() {
// Обновляем mtime для LRU
let _ = filetime::set_file_mtime(
&path,
filetime::FileTime::now(),
);
let _ = filetime::set_file_mtime(&path, filetime::FileTime::now());
Some(path)
} else {
None
@@ -47,8 +44,7 @@ impl ImageCache {
pub fn cache_file(&self, file_id: i32, source_path: &str) -> Result<PathBuf, String> {
let dest = self.cache_dir.join(format!("{}.jpg", file_id));
fs::copy(source_path, &dest)
.map_err(|e| format!("Ошибка кэширования: {}", e))?;
fs::copy(source_path, &dest).map_err(|e| format!("Ошибка кэширования: {}", e))?;
// Evict если превышен лимит
self.evict_if_needed();

View File

@@ -28,7 +28,7 @@ impl ImageRenderer {
/// Создаёт ImageRenderer с автодетектом протокола (высокое качество для modal)
pub fn new() -> Option<Self> {
let picker = Picker::from_query_stdio().ok()?;
Some(Self {
picker,
protocols: HashMap::new(),
@@ -41,7 +41,7 @@ impl ImageRenderer {
pub fn new_fast() -> Option<Self> {
let mut picker = Picker::from_fontsize((8, 12));
picker.set_protocol_type(ProtocolType::Halfblocks);
Some(Self {
picker,
protocols: HashMap::new(),
@@ -51,7 +51,7 @@ impl ImageRenderer {
}
/// Загружает изображение из файла и создаёт протокол рендеринга.
///
///
/// Если протокол уже существует, не загружает повторно (кэширование).
/// Использует LRU eviction при превышении лимита.
pub fn load_image(&mut self, msg_id: MessageId, path: &str) -> Result<(), String> {
@@ -76,7 +76,7 @@ impl ImageRenderer {
let protocol = self.picker.new_resize_protocol(img);
self.protocols.insert(msg_id_i64, protocol);
// Обновляем access order
self.access_counter += 1;
self.access_order.insert(msg_id_i64, self.access_counter);
@@ -93,17 +93,17 @@ impl ImageRenderer {
}
/// Получает мутабельную ссылку на протокол для рендеринга.
///
///
/// Обновляет access time для LRU.
pub fn get_protocol(&mut self, msg_id: &MessageId) -> Option<&mut StatefulProtocol> {
let msg_id_i64 = msg_id.as_i64();
if self.protocols.contains_key(&msg_id_i64) {
// Обновляем access time
self.access_counter += 1;
self.access_order.insert(msg_id_i64, self.access_counter);
}
self.protocols.get_mut(&msg_id_i64)
}

View File

@@ -12,7 +12,10 @@ pub enum MessageGroup {
/// Разделитель даты (день в формате timestamp)
DateSeparator(i32),
/// Заголовок отправителя (is_outgoing, sender_name)
SenderHeader { is_outgoing: bool, sender_name: String },
SenderHeader {
is_outgoing: bool,
sender_name: String,
},
/// Сообщение
Message(MessageInfo),
/// Альбом (группа фото с одинаковым media_album_id)
@@ -106,10 +109,7 @@ pub fn group_messages(messages: &[MessageInfo]) -> Vec<MessageGroup> {
if show_sender_header {
// Flush аккумулятор перед сменой отправителя
flush_album(&mut album_acc, &mut result);
result.push(MessageGroup::SenderHeader {
is_outgoing: msg.is_outgoing(),
sender_name,
});
result.push(MessageGroup::SenderHeader { is_outgoing: msg.is_outgoing(), sender_name });
last_sender = Some(current_sender);
}

View File

@@ -39,11 +39,7 @@ impl NotificationManager {
}
/// Creates a notification manager with custom settings
pub fn with_config(
enabled: bool,
only_mentions: bool,
show_preview: bool,
) -> Self {
pub fn with_config(enabled: bool, only_mentions: bool, show_preview: bool) -> Self {
Self {
enabled,
muted_chats: HashSet::new(),
@@ -311,22 +307,13 @@ mod tests {
#[test]
fn test_beautify_media_labels() {
// Test photo
assert_eq!(
NotificationManager::beautify_media_labels("[Фото]"),
"📷 Фото"
);
assert_eq!(NotificationManager::beautify_media_labels("[Фото]"), "📷 Фото");
// Test video
assert_eq!(
NotificationManager::beautify_media_labels("[Видео]"),
"🎥 Видео"
);
assert_eq!(NotificationManager::beautify_media_labels("[Видео]"), "🎥 Видео");
// Test sticker with emoji
assert_eq!(
NotificationManager::beautify_media_labels("[Стикер: 😊]"),
"🎨 Стикер: 😊]"
);
assert_eq!(NotificationManager::beautify_media_labels("[Стикер: 😊]"), "🎨 Стикер: 😊]");
// Test audio with title
assert_eq!(
@@ -341,10 +328,7 @@ mod tests {
);
// Test regular text (no changes)
assert_eq!(
NotificationManager::beautify_media_labels("Hello, world!"),
"Hello, world!"
);
assert_eq!(NotificationManager::beautify_media_labels("Hello, world!"), "Hello, world!");
// Test mixed content
assert_eq!(

View File

@@ -83,10 +83,7 @@ impl AuthManager {
///
/// Новый экземпляр `AuthManager` в состоянии `WaitTdlibParameters`.
pub fn new(client_id: i32) -> Self {
Self {
state: AuthState::WaitTdlibParameters,
client_id,
}
Self { state: AuthState::WaitTdlibParameters, client_id }
}
/// Проверяет, завершена ли авторизация.

View File

@@ -3,7 +3,7 @@
//! This module contains utility functions for managing chats,
//! including finding, updating, and adding/removing chats.
use crate::constants::{MAX_CHAT_USER_IDS, MAX_CHATS};
use crate::constants::{MAX_CHATS, MAX_CHAT_USER_IDS};
use crate::types::{ChatId, MessageId, UserId};
use tdlib_rs::enums::{Chat as TdChat, ChatList, ChatType};
@@ -33,7 +33,9 @@ pub fn add_or_update_chat(client: &mut TdClient, td_chat_enum: &TdChat) {
// Пропускаем удалённые аккаунты
if td_chat.title == "Deleted Account" || td_chat.title.is_empty() {
// Удаляем из списка если уже был добавлен
client.chats_mut().retain(|c| c.id != ChatId::new(td_chat.id));
client
.chats_mut()
.retain(|c| c.id != ChatId::new(td_chat.id));
return;
}
@@ -70,7 +72,9 @@ pub fn add_or_update_chat(client: &mut TdClient, td_chat_enum: &TdChat) {
let user_id = UserId::new(private.user_id);
client.user_cache.chat_user_ids.insert(chat_id, user_id);
// Проверяем, есть ли уже username в кэше (peek не обновляет LRU)
client.user_cache.user_usernames
client
.user_cache
.user_usernames
.peek(&user_id)
.map(|u| format!("@{}", u))
}

View File

@@ -197,10 +197,7 @@ impl ChatManager {
ChatType::Secret(_) => "Секретный чат",
};
let is_group = matches!(
&chat.r#type,
ChatType::Supergroup(_) | ChatType::BasicGroup(_)
);
let is_group = matches!(&chat.r#type, ChatType::Supergroup(_) | ChatType::BasicGroup(_));
// Для личных чатов получаем информацию о пользователе
let (bio, phone_number, username, online_status) = if let ChatType::Private(private_chat) =
@@ -208,13 +205,15 @@ impl ChatManager {
{
match functions::get_user(private_chat.user_id, self.client_id).await {
Ok(tdlib_rs::enums::User::User(user)) => {
let bio_opt = if let Ok(tdlib_rs::enums::UserFullInfo::UserFullInfo(full_info)) =
functions::get_user_full_info(private_chat.user_id, self.client_id).await
{
full_info.bio.map(|b| b.text)
} else {
None
};
let bio_opt =
if let Ok(tdlib_rs::enums::UserFullInfo::UserFullInfo(full_info)) =
functions::get_user_full_info(private_chat.user_id, self.client_id)
.await
{
full_info.bio.map(|b| b.text)
} else {
None
};
let online_status_str = match user.status {
tdlib_rs::enums::UserStatus::Online(_) => Some("В сети".to_string()),
@@ -234,10 +233,7 @@ impl ChatManager {
_ => None,
};
let username_opt = user
.usernames
.as_ref()
.map(|u| u.editable_username.clone());
let username_opt = user.usernames.as_ref().map(|u| u.editable_username.clone());
(bio_opt, Some(user.phone_number.clone()), username_opt, online_status_str)
}
@@ -257,7 +253,10 @@ impl ChatManager {
} else {
None
};
let link = full_info.invite_link.as_ref().map(|l| l.invite_link.clone());
let link = full_info
.invite_link
.as_ref()
.map(|l| l.invite_link.clone());
(Some(full_info.member_count), desc, link)
}
_ => (None, None, None),
@@ -324,7 +323,8 @@ impl ChatManager {
/// ).await;
/// ```
pub async fn send_chat_action(&self, chat_id: ChatId, action: ChatAction) {
let _ = functions::send_chat_action(chat_id.as_i64(), 0, Some(action), self.client_id).await;
let _ =
functions::send_chat_action(chat_id.as_i64(), 0, Some(action), self.client_id).await;
}
/// Очищает устаревший typing-статус.

View File

@@ -1,20 +1,17 @@
use crate::types::{ChatId, MessageId, UserId};
use std::env;
use std::path::PathBuf;
use tdlib_rs::enums::{
ChatList, ConnectionState, Update, UserStatus,
Chat as TdChat
};
use tdlib_rs::types::Message as TdMessage;
use tdlib_rs::enums::{Chat as TdChat, ChatList, ConnectionState, Update, UserStatus};
use tdlib_rs::functions;
use tdlib_rs::types::Message as TdMessage;
use super::auth::{AuthManager, AuthState};
use super::chats::ChatManager;
use super::messages::MessageManager;
use super::reactions::ReactionManager;
use super::types::{ChatInfo, FolderInfo, MessageInfo, NetworkState, ProfileInfo, UserOnlineStatus};
use super::types::{
ChatInfo, FolderInfo, MessageInfo, NetworkState, ProfileInfo, UserOnlineStatus,
};
use super::users::UserCache;
use crate::notifications::NotificationManager;
@@ -75,16 +72,15 @@ impl TdClient {
/// A new `TdClient` instance ready for authentication.
pub fn new(db_path: PathBuf) -> Self {
// Пробуем загрузить credentials из Config (файл или env)
let (api_id, api_hash) = crate::config::Config::load_credentials()
.unwrap_or_else(|_| {
// Fallback на прямое чтение из env (старое поведение)
let api_id = env::var("API_ID")
.unwrap_or_else(|_| "0".to_string())
.parse()
.unwrap_or(0);
let api_hash = env::var("API_HASH").unwrap_or_default();
(api_id, api_hash)
});
let (api_id, api_hash) = crate::config::Config::load_credentials().unwrap_or_else(|_| {
// Fallback на прямое чтение из env (старое поведение)
let api_id = env::var("API_ID")
.unwrap_or_else(|_| "0".to_string())
.parse()
.unwrap_or(0);
let api_hash = env::var("API_HASH").unwrap_or_default();
(api_id, api_hash)
});
let client_id = tdlib_rs::create_client();
@@ -106,9 +102,11 @@ impl TdClient {
/// Configures notification manager from app config
pub fn configure_notifications(&mut self, config: &crate::config::NotificationsConfig) {
self.notification_manager.set_enabled(config.enabled);
self.notification_manager.set_only_mentions(config.only_mentions);
self.notification_manager
.set_only_mentions(config.only_mentions);
self.notification_manager.set_timeout(config.timeout_ms);
self.notification_manager.set_urgency(config.urgency.clone());
self.notification_manager
.set_urgency(config.urgency.clone());
// Note: show_preview is used when formatting notification body
}
@@ -116,7 +114,8 @@ impl TdClient {
///
/// Should be called after chats are loaded to ensure muted chats don't trigger notifications.
pub fn sync_notification_muted_chats(&mut self) {
self.notification_manager.sync_muted_chats(&self.chat_manager.chats);
self.notification_manager
.sync_muted_chats(&self.chat_manager.chats);
}
// Делегирование к auth
@@ -257,12 +256,17 @@ impl TdClient {
.await
}
pub async fn get_pinned_messages(&mut self, chat_id: ChatId) -> Result<Vec<MessageInfo>, String> {
pub async fn get_pinned_messages(
&mut self,
chat_id: ChatId,
) -> Result<Vec<MessageInfo>, String> {
self.message_manager.get_pinned_messages(chat_id).await
}
pub async fn load_current_pinned_message(&mut self, chat_id: ChatId) {
self.message_manager.load_current_pinned_message(chat_id).await
self.message_manager
.load_current_pinned_message(chat_id)
.await
}
pub async fn search_messages(
@@ -442,7 +446,10 @@ impl TdClient {
self.chat_manager.typing_status.as_ref()
}
pub fn set_typing_status(&mut self, status: Option<(crate::types::UserId, String, std::time::Instant)>) {
pub fn set_typing_status(
&mut self,
status: Option<(crate::types::UserId, String, std::time::Instant)>,
) {
self.chat_manager.typing_status = status;
}
@@ -450,7 +457,9 @@ impl TdClient {
&self.message_manager.pending_view_messages
}
pub fn pending_view_messages_mut(&mut self) -> &mut Vec<(crate::types::ChatId, Vec<crate::types::MessageId>)> {
pub fn pending_view_messages_mut(
&mut self,
) -> &mut Vec<(crate::types::ChatId, Vec<crate::types::MessageId>)> {
&mut self.message_manager.pending_view_messages
}
@@ -519,7 +528,11 @@ impl TdClient {
});
// Обновляем позиции если они пришли
for pos in update.positions.iter().filter(|p| matches!(p.list, ChatList::Main)) {
for pos in update
.positions
.iter()
.filter(|p| matches!(p.list, ChatList::Main))
{
crate::tdlib::chat_helpers::update_chat(self, chat_id, |chat| {
chat.order = pos.order;
chat.is_pinned = pos.is_pinned;
@@ -530,27 +543,43 @@ impl TdClient {
self.chats_mut().sort_by(|a, b| b.order.cmp(&a.order));
}
Update::ChatReadInbox(update) => {
crate::tdlib::chat_helpers::update_chat(self, ChatId::new(update.chat_id), |chat| {
chat.unread_count = update.unread_count;
});
crate::tdlib::chat_helpers::update_chat(
self,
ChatId::new(update.chat_id),
|chat| {
chat.unread_count = update.unread_count;
},
);
}
Update::ChatUnreadMentionCount(update) => {
crate::tdlib::chat_helpers::update_chat(self, ChatId::new(update.chat_id), |chat| {
chat.unread_mention_count = update.unread_mention_count;
});
crate::tdlib::chat_helpers::update_chat(
self,
ChatId::new(update.chat_id),
|chat| {
chat.unread_mention_count = update.unread_mention_count;
},
);
}
Update::ChatNotificationSettings(update) => {
crate::tdlib::chat_helpers::update_chat(self, ChatId::new(update.chat_id), |chat| {
// mute_for > 0 означает что чат замьючен
chat.is_muted = update.notification_settings.mute_for > 0;
});
crate::tdlib::chat_helpers::update_chat(
self,
ChatId::new(update.chat_id),
|chat| {
// mute_for > 0 означает что чат замьючен
chat.is_muted = update.notification_settings.mute_for > 0;
},
);
}
Update::ChatReadOutbox(update) => {
// Обновляем last_read_outbox_message_id когда собеседник прочитал сообщения
let last_read_msg_id = MessageId::new(update.last_read_outbox_message_id);
crate::tdlib::chat_helpers::update_chat(self, ChatId::new(update.chat_id), |chat| {
chat.last_read_outbox_message_id = last_read_msg_id;
});
crate::tdlib::chat_helpers::update_chat(
self,
ChatId::new(update.chat_id),
|chat| {
chat.last_read_outbox_message_id = last_read_msg_id;
},
);
// Если это текущий открытый чат — обновляем is_read у сообщений
if Some(ChatId::new(update.chat_id)) == self.current_chat_id() {
for msg in self.current_chat_messages_mut().iter_mut() {
@@ -588,7 +617,9 @@ impl TdClient {
UserStatus::LastMonth(_) => UserOnlineStatus::LastMonth,
UserStatus::Empty => UserOnlineStatus::LongTimeAgo,
};
self.user_cache.user_statuses.insert(UserId::new(update.user_id), status);
self.user_cache
.user_statuses
.insert(UserId::new(update.user_id), status);
}
Update::ConnectionState(update) => {
// Обновляем состояние сетевого соединения
@@ -616,13 +647,15 @@ impl TdClient {
}
}
// Helper functions
pub fn extract_message_text_static(message: &TdMessage) -> (String, Vec<tdlib_rs::types::TextEntity>) {
pub fn extract_message_text_static(
message: &TdMessage,
) -> (String, Vec<tdlib_rs::types::TextEntity>) {
use tdlib_rs::enums::MessageContent;
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())
}
_ => (String::new(), Vec::new()),
}
}

View File

@@ -4,7 +4,10 @@
use super::client::TdClient;
use super::r#trait::TdClientTrait;
use super::{AuthState, ChatInfo, FolderInfo, MessageInfo, ProfileInfo, ReplyInfo, UserCache, UserOnlineStatus};
use super::{
AuthState, ChatInfo, FolderInfo, MessageInfo, ProfileInfo, ReplyInfo, UserCache,
UserOnlineStatus,
};
use crate::types::{ChatId, MessageId, UserId};
use async_trait::async_trait;
use std::path::PathBuf;
@@ -52,11 +55,19 @@ impl TdClientTrait for TdClient {
}
// ============ Message methods ============
async fn get_chat_history(&mut self, chat_id: ChatId, limit: i32) -> Result<Vec<MessageInfo>, String> {
async fn get_chat_history(
&mut self,
chat_id: ChatId,
limit: i32,
) -> Result<Vec<MessageInfo>, String> {
self.get_chat_history(chat_id, limit).await
}
async fn load_older_messages(&mut self, chat_id: ChatId, from_message_id: MessageId) -> Result<Vec<MessageInfo>, String> {
async fn load_older_messages(
&mut self,
chat_id: ChatId,
from_message_id: MessageId,
) -> Result<Vec<MessageInfo>, String> {
self.load_older_messages(chat_id, from_message_id).await
}
@@ -68,7 +79,11 @@ impl TdClientTrait for TdClient {
self.load_current_pinned_message(chat_id).await
}
async fn search_messages(&self, chat_id: ChatId, query: &str) -> Result<Vec<MessageInfo>, String> {
async fn search_messages(
&self,
chat_id: ChatId,
query: &str,
) -> Result<Vec<MessageInfo>, String> {
self.search_messages(chat_id, query).await
}
@@ -148,7 +163,8 @@ impl TdClientTrait for TdClient {
chat_id: ChatId,
message_id: MessageId,
) -> Result<Vec<String>, String> {
self.get_message_available_reactions(chat_id, message_id).await
self.get_message_available_reactions(chat_id, message_id)
.await
}
async fn toggle_reaction(
@@ -276,7 +292,8 @@ impl TdClientTrait for TdClient {
// ============ Notification methods ============
fn sync_notification_muted_chats(&mut self) {
self.notification_manager.sync_muted_chats(&self.chat_manager.chats);
self.notification_manager
.sync_muted_chats(&self.chat_manager.chats);
}
// ============ Account switching ============

View File

@@ -7,7 +7,10 @@ use crate::types::MessageId;
use tdlib_rs::enums::{MessageContent, MessageSender};
use tdlib_rs::types::Message as TdMessage;
use super::types::{ForwardInfo, MediaInfo, PhotoDownloadState, PhotoInfo, ReactionInfo, ReplyInfo, VoiceDownloadState, VoiceInfo};
use super::types::{
ForwardInfo, MediaInfo, PhotoDownloadState, PhotoInfo, ReactionInfo, ReplyInfo,
VoiceDownloadState, VoiceInfo,
};
/// Извлекает текст контента из TDLib Message
///
@@ -95,9 +98,9 @@ pub async fn extract_sender_name(msg: &TdMessage, client_id: i32) -> String {
match &msg.sender_id {
MessageSender::User(user) => {
match tdlib_rs::functions::get_user(user.user_id, client_id).await {
Ok(tdlib_rs::enums::User::User(u)) => {
format!("{} {}", u.first_name, u.last_name).trim().to_string()
}
Ok(tdlib_rs::enums::User::User(u)) => format!("{} {}", u.first_name, u.last_name)
.trim()
.to_string(),
_ => format!("User {}", user.user_id),
}
}
@@ -155,12 +158,7 @@ pub fn extract_media_info(msg: &TdMessage) -> Option<MediaInfo> {
PhotoDownloadState::NotDownloaded
};
Some(MediaInfo::Photo(PhotoInfo {
file_id,
width,
height,
download_state,
}))
Some(MediaInfo::Photo(PhotoInfo { file_id, width, height, download_state }))
}
MessageContent::MessageVoiceNote(v) => {
let file_id = v.voice_note.voice.id;

View File

@@ -11,11 +11,7 @@ use super::client::TdClient;
use super::types::{ForwardInfo, MessageInfo, ReactionInfo, ReplyInfo};
/// Конвертирует TDLib сообщение в MessageInfo
pub fn convert_message(
client: &mut TdClient,
message: &TdMessage,
chat_id: ChatId,
) -> MessageInfo {
pub fn convert_message(client: &mut TdClient, message: &TdMessage, chat_id: ChatId) -> MessageInfo {
let sender_name = match &message.sender_id {
tdlib_rs::enums::MessageSender::User(user) => {
// Пробуем получить имя из кеша (get обновляет LRU порядок)
@@ -138,12 +134,7 @@ pub fn extract_reply_info(client: &TdClient, message: &TdMessage) -> Option<Repl
.quote
.as_ref()
.map(|q| q.text.text.clone())
.or_else(|| {
reply
.content
.as_ref()
.map(TdClient::extract_content_text)
})
.or_else(|| reply.content.as_ref().map(TdClient::extract_content_text))
.unwrap_or_else(|| {
// Пробуем найти в текущих сообщениях
client
@@ -154,11 +145,7 @@ pub fn extract_reply_info(client: &TdClient, message: &TdMessage) -> Option<Repl
.unwrap_or_default()
});
Some(ReplyInfo {
message_id: reply_msg_id,
sender_name,
text,
})
Some(ReplyInfo { message_id: reply_msg_id, sender_name, text })
}
_ => None,
}
@@ -219,12 +206,7 @@ pub fn update_reply_info_from_loaded_messages(client: &mut TdClient) {
let msg_data: std::collections::HashMap<i64, (String, String)> = client
.current_chat_messages()
.iter()
.map(|m| {
(
m.id().as_i64(),
(m.sender_name().to_string(), m.text().to_string()),
)
})
.map(|m| (m.id().as_i64(), (m.sender_name().to_string(), m.text().to_string())))
.collect();
// Обновляем reply_to для сообщений с неполными данными

View File

@@ -12,8 +12,8 @@ impl MessageManager {
/// Конвертировать TdMessage в MessageInfo
pub(crate) async fn convert_message(&self, msg: &TdMessage) -> Option<MessageInfo> {
use crate::tdlib::message_conversion::{
extract_content_text, extract_entities, extract_forward_info,
extract_media_info, extract_reactions, extract_reply_info, extract_sender_name,
extract_content_text, extract_entities, extract_forward_info, extract_media_info,
extract_reactions, extract_reply_info, extract_sender_name,
};
// Извлекаем все части сообщения используя вспомогательные функции
@@ -122,12 +122,7 @@ impl MessageManager {
};
// Extract text preview (first 50 chars)
let text_preview: String = orig_info
.content
.text
.chars()
.take(50)
.collect();
let text_preview: String = orig_info.content.text.chars().take(50).collect();
// Update reply info in all messages that reference this message
self.current_chat_messages

View File

@@ -95,7 +95,8 @@ impl MessageManager {
// Ограничиваем размер списка (удаляем старые с начала)
if self.current_chat_messages.len() > MAX_MESSAGES_IN_CHAT {
self.current_chat_messages.drain(0..(self.current_chat_messages.len() - MAX_MESSAGES_IN_CHAT));
self.current_chat_messages
.drain(0..(self.current_chat_messages.len() - MAX_MESSAGES_IN_CHAT));
}
}
}

View File

@@ -2,9 +2,13 @@
use crate::constants::TDLIB_MESSAGE_LIMIT;
use crate::types::{ChatId, MessageId};
use tdlib_rs::enums::{InputMessageContent, InputMessageReplyTo, SearchMessagesFilter, TextParseMode};
use tdlib_rs::enums::{
InputMessageContent, InputMessageReplyTo, SearchMessagesFilter, TextParseMode,
};
use tdlib_rs::functions;
use tdlib_rs::types::{FormattedText, InputMessageReplyToMessage, InputMessageText, TextParseModeMarkdown};
use tdlib_rs::types::{
FormattedText, InputMessageReplyToMessage, InputMessageText, TextParseModeMarkdown,
};
use tokio::time::{sleep, Duration};
use crate::tdlib::types::{MessageInfo, ReplyInfo};
@@ -103,9 +107,10 @@ impl MessageManager {
// Если это первая загрузка и получили мало сообщений - продолжаем попытки
// TDLib может подгружать данные с сервера постепенно
if all_messages.is_empty() &&
received_count < (chunk_size as usize) &&
attempt < max_attempts_per_chunk {
if all_messages.is_empty()
&& received_count < (chunk_size as usize)
&& attempt < max_attempts_per_chunk
{
// Даём TDLib время на синхронизацию с сервером
sleep(Duration::from_millis(100)).await;
continue;
@@ -233,17 +238,20 @@ impl MessageManager {
/// let pinned = msg_manager.get_pinned_messages(chat_id).await?;
/// println!("Found {} pinned messages", pinned.len());
/// ```
pub async fn get_pinned_messages(&mut self, chat_id: ChatId) -> Result<Vec<MessageInfo>, String> {
pub async fn get_pinned_messages(
&mut self,
chat_id: ChatId,
) -> Result<Vec<MessageInfo>, String> {
let result = functions::search_chat_messages(
chat_id.as_i64(),
String::new(),
None,
0, // from_message_id
0, // offset
100, // limit
0, // from_message_id
0, // offset
100, // limit
Some(SearchMessagesFilter::Pinned),
0, // message_thread_id
0, // saved_messages_topic_id
0, // message_thread_id
0, // saved_messages_topic_id
self.client_id,
)
.await;
@@ -310,8 +318,8 @@ impl MessageManager {
0, // offset
100, // limit
None,
0, // message_thread_id
0, // saved_messages_topic_id
0, // message_thread_id
0, // saved_messages_topic_id
self.client_id,
)
.await;
@@ -381,15 +389,9 @@ impl MessageManager {
.await
{
Ok(tdlib_rs::enums::FormattedText::FormattedText(ft)) => {
FormattedText {
text: ft.text,
entities: ft.entities,
}
FormattedText { text: ft.text, entities: ft.entities }
}
Err(_) => FormattedText {
text: text.clone(),
entities: vec![],
},
Err(_) => FormattedText { text: text.clone(), entities: vec![] },
};
let content = InputMessageContent::InputMessageText(InputMessageText {
@@ -460,15 +462,9 @@ impl MessageManager {
.await
{
Ok(tdlib_rs::enums::FormattedText::FormattedText(ft)) => {
FormattedText {
text: ft.text,
entities: ft.entities,
}
FormattedText { text: ft.text, entities: ft.entities }
}
Err(_) => FormattedText {
text: text.clone(),
entities: vec![],
},
Err(_) => FormattedText { text: text.clone(), entities: vec![] },
};
let content = InputMessageContent::InputMessageText(InputMessageText {
@@ -477,8 +473,13 @@ impl MessageManager {
clear_draft: true,
});
let result =
functions::edit_message_text(chat_id.as_i64(), message_id.as_i64(), content, self.client_id).await;
let result = functions::edit_message_text(
chat_id.as_i64(),
message_id.as_i64(),
content,
self.client_id,
)
.await;
match result {
Ok(tdlib_rs::enums::Message::Message(msg)) => self
@@ -509,7 +510,8 @@ impl MessageManager {
) -> Result<(), String> {
let message_ids_i64: Vec<i64> = message_ids.into_iter().map(|id| id.as_i64()).collect();
let result =
functions::delete_messages(chat_id.as_i64(), message_ids_i64, revoke, self.client_id).await;
functions::delete_messages(chat_id.as_i64(), message_ids_i64, revoke, self.client_id)
.await;
match result {
Ok(_) => Ok(()),
Err(e) => Err(format!("Ошибка удаления: {:?}", e)),
@@ -577,17 +579,15 @@ impl MessageManager {
reply_to: None,
date: 0,
input_message_text: InputMessageContent::InputMessageText(InputMessageText {
text: FormattedText {
text: text.clone(),
entities: vec![],
},
text: FormattedText { text: text.clone(), entities: vec![] },
link_preview_options: None,
clear_draft: false,
}),
})
};
let result = functions::set_chat_draft_message(chat_id.as_i64(), 0, draft, self.client_id).await;
let result =
functions::set_chat_draft_message(chat_id.as_i64(), 0, draft, self.client_id).await;
match result {
Ok(_) => Ok(()),
@@ -612,7 +612,8 @@ impl MessageManager {
for (chat_id, message_ids) in batch {
let ids: Vec<i64> = message_ids.iter().map(|id| id.as_i64()).collect();
let _ = functions::view_messages(chat_id.as_i64(), ids, None, true, self.client_id).await;
let _ =
functions::view_messages(chat_id.as_i64(), ids, None, true, self.client_id).await;
}
}
}

View File

@@ -4,8 +4,8 @@ mod chat_helpers; // Chat management helpers
pub mod chats;
pub mod client;
mod client_impl; // Private module for trait implementation
mod message_converter; // Message conversion utilities (for client.rs)
mod message_conversion; // Message conversion utilities (for messages.rs)
mod message_converter; // Message conversion utilities (for client.rs)
pub mod messages;
pub mod reactions;
pub mod r#trait;

View File

@@ -69,7 +69,8 @@ impl ReactionManager {
message_id: MessageId,
) -> Result<Vec<String>, String> {
// Получаем сообщение
let msg_result = functions::get_message(chat_id.as_i64(), message_id.as_i64(), self.client_id).await;
let msg_result =
functions::get_message(chat_id.as_i64(), message_id.as_i64(), self.client_id).await;
let _msg = match msg_result {
Ok(m) => m,
Err(e) => return Err(format!("Ошибка получения сообщения: {:?}", e)),

View File

@@ -32,11 +32,23 @@ pub trait TdClientTrait: Send {
fn clear_stale_typing_status(&mut self) -> bool;
// ============ Message methods ============
async fn get_chat_history(&mut self, chat_id: ChatId, limit: i32) -> Result<Vec<MessageInfo>, String>;
async fn load_older_messages(&mut self, chat_id: ChatId, from_message_id: MessageId) -> Result<Vec<MessageInfo>, String>;
async fn get_chat_history(
&mut self,
chat_id: ChatId,
limit: i32,
) -> Result<Vec<MessageInfo>, String>;
async fn load_older_messages(
&mut self,
chat_id: ChatId,
from_message_id: MessageId,
) -> Result<Vec<MessageInfo>, String>;
async fn get_pinned_messages(&mut self, chat_id: ChatId) -> Result<Vec<MessageInfo>, String>;
async fn load_current_pinned_message(&mut self, chat_id: ChatId);
async fn search_messages(&self, chat_id: ChatId, query: &str) -> Result<Vec<MessageInfo>, String>;
async fn search_messages(
&self,
chat_id: ChatId,
query: &str,
) -> Result<Vec<MessageInfo>, String>;
async fn send_message(
&mut self,

View File

@@ -179,11 +179,7 @@ impl MessageInfo {
edit_date,
media_album_id: 0,
},
content: MessageContent {
text: content,
entities,
media: None,
},
content: MessageContent { text: content, entities, media: None },
state: MessageState {
is_outgoing,
is_read,
@@ -191,11 +187,7 @@ impl MessageInfo {
can_be_deleted_only_for_self,
can_be_deleted_for_all_users,
},
interactions: MessageInteractions {
reply_to,
forward_from,
reactions,
},
interactions: MessageInteractions { reply_to, forward_from, reactions },
}
}
@@ -251,10 +243,7 @@ impl MessageInfo {
/// Checks if the message contains a mention (@username or user mention)
pub fn has_mention(&self) -> bool {
self.content.entities.iter().any(|entity| {
matches!(
entity.r#type,
TextEntityType::Mention | TextEntityType::MentionName(_)
)
matches!(entity.r#type, TextEntityType::Mention | TextEntityType::MentionName(_))
})
}
@@ -314,13 +303,13 @@ impl MessageInfo {
}
/// Builder для удобного создания MessageInfo с fluent API
///
///
/// # Примеры
///
///
/// ```
/// use tele_tui::tdlib::MessageBuilder;
/// use tele_tui::types::MessageId;
///
///
/// let message = MessageBuilder::new(MessageId::new(123))
/// .sender_name("Alice")
/// .text("Hello, world!")
@@ -500,7 +489,6 @@ impl MessageBuilder {
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -568,9 +556,7 @@ mod tests {
#[test]
fn test_message_builder_with_reactions() {
let reaction = ReactionInfo {
emoji: "👍".to_string(),
count: 5,
is_chosen: true,
emoji: "👍".to_string(), count: 5, is_chosen: true
};
let message = MessageBuilder::new(MessageId::new(300))
@@ -628,9 +614,9 @@ mod tests {
.entities(vec![TextEntity {
offset: 6,
length: 4,
r#type: TextEntityType::MentionName(
tdlib_rs::types::TextEntityTypeMentionName { user_id: 123 },
),
r#type: TextEntityType::MentionName(tdlib_rs::types::TextEntityTypeMentionName {
user_id: 123,
}),
}])
.build();
assert!(message_with_mention_name.has_mention());

View File

@@ -5,12 +5,10 @@
use crate::types::{ChatId, MessageId, UserId};
use std::time::Instant;
use tdlib_rs::enums::{
AuthorizationState, ChatAction, ChatList, MessageSender,
};
use tdlib_rs::enums::{AuthorizationState, ChatAction, ChatList, MessageSender};
use tdlib_rs::types::{
UpdateChatAction, UpdateChatDraftMessage, UpdateChatPosition,
UpdateMessageInteractionInfo, UpdateMessageSendSucceeded, UpdateNewMessage, UpdateUser,
UpdateChatAction, UpdateChatDraftMessage, UpdateChatPosition, UpdateMessageInteractionInfo,
UpdateMessageSendSucceeded, UpdateNewMessage, UpdateUser,
};
use super::auth::AuthState;
@@ -25,24 +23,24 @@ pub fn handle_new_message_update(client: &mut TdClient, new_msg: UpdateNewMessag
if Some(chat_id) != client.current_chat_id() {
// Find and clone chat info to avoid borrow checker issues
if let Some(chat) = client.chats().iter().find(|c| c.id == chat_id).cloned() {
let msg_info = crate::tdlib::message_converter::convert_message(client, &new_msg.message, chat_id);
let msg_info =
crate::tdlib::message_converter::convert_message(client, &new_msg.message, chat_id);
// Get sender name (from message or user cache)
let sender_name = msg_info.sender_name();
// Send notification
let _ = client.notification_manager.notify_new_message(
&chat,
&msg_info,
sender_name,
);
let _ = client
.notification_manager
.notify_new_message(&chat, &msg_info, sender_name);
}
return;
}
// Добавляем новое сообщение если это текущий открытый чат
let msg_info = crate::tdlib::message_converter::convert_message(client, &new_msg.message, chat_id);
let msg_info =
crate::tdlib::message_converter::convert_message(client, &new_msg.message, chat_id);
let msg_id = msg_info.id();
let is_incoming = !msg_info.is_outgoing();
@@ -74,7 +72,9 @@ pub fn handle_new_message_update(client: &mut TdClient, new_msg: UpdateNewMessag
client.push_message(msg_info.clone());
// Если это входящее сообщение — добавляем в очередь для отметки как прочитанное
if is_incoming {
client.pending_view_messages_mut().push((chat_id, vec![msg_id]));
client
.pending_view_messages_mut()
.push((chat_id, vec![msg_id]));
}
}
}
@@ -181,14 +181,21 @@ pub fn handle_user_update(client: &mut TdClient, update: UpdateUser) {
} else {
format!("{} {}", user.first_name, user.last_name)
};
client.user_cache.user_names.insert(UserId::new(user.id), display_name);
client
.user_cache
.user_names
.insert(UserId::new(user.id), display_name);
// Сохраняем username если есть (с упрощённым извлечением через and_then)
if let Some(username) = user.usernames
if let Some(username) = user
.usernames
.as_ref()
.and_then(|u| u.active_usernames.first())
{
client.user_cache.user_usernames.insert(UserId::new(user.id), username.to_string());
client
.user_cache
.user_usernames
.insert(UserId::new(user.id), username.to_string());
// Обновляем username в чатах, связанных с этим пользователем
for (&chat_id, &user_id) in &client.user_cache.chat_user_ids.clone() {
if user_id == UserId::new(user.id) {
@@ -273,7 +280,8 @@ pub fn handle_message_send_succeeded_update(
};
// Конвертируем новое сообщение
let mut new_msg = crate::tdlib::message_converter::convert_message(client, &update.message, chat_id);
let mut new_msg =
crate::tdlib::message_converter::convert_message(client, &update.message, chat_id);
// Сохраняем reply_info из старого сообщения (если было)
let old_reply = client.current_chat_messages()[idx]

View File

@@ -175,7 +175,9 @@ impl UserCache {
}
// Сохраняем имя
let display_name = format!("{} {}", user.first_name, user.last_name).trim().to_string();
let display_name = format!("{} {}", user.first_name, user.last_name)
.trim()
.to_string();
self.user_names.insert(UserId::new(user_id), display_name);
// Обновляем статус
@@ -220,7 +222,9 @@ impl UserCache {
// Загружаем пользователя
match functions::get_user(user_id.as_i64(), self.client_id).await {
Ok(User::User(user)) => {
let name = format!("{} {}", user.first_name, user.last_name).trim().to_string();
let name = format!("{} {}", user.first_name, user.last_name)
.trim()
.to_string();
name
}
_ => format!("User {}", user_id.as_i64()),
@@ -257,8 +261,7 @@ impl UserCache {
}
Err(_) => {
// Если не удалось загрузить, сохраняем placeholder
self.user_names
.insert(user_id, format!("User {}", user_id));
self.user_names.insert(user_id, format!("User {}", user_id));
}
}
}

View File

@@ -136,7 +136,7 @@ mod tests {
// let chat_id = ChatId::new(1);
// let message_id = MessageId::new(1);
// if chat_id == message_id { } // ERROR: mismatched types
// Runtime values can be the same, but types are different
let chat_id = ChatId::new(1);
let message_id = MessageId::new(1);

View File

@@ -1,6 +1,6 @@
use crate::app::App;
use crate::tdlib::TdClientTrait;
use crate::tdlib::AuthState;
use crate::tdlib::TdClientTrait;
use ratatui::{
layout::{Alignment, Constraint, Direction, Layout},
style::{Color, Modifier, Style},

View File

@@ -1,7 +1,7 @@
//! Chat list panel: search box, chat items, and user online status.
use crate::app::App;
use crate::app::methods::{compose::ComposeMethods, search::SearchMethods};
use crate::app::App;
use crate::tdlib::TdClientTrait;
use crate::tdlib::UserOnlineStatus;
use crate::ui::components;
@@ -76,7 +76,9 @@ pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &mut App<T>) {
app.selected_chat_id
} else {
let filtered = app.get_filtered_chats();
app.chat_list_state.selected().and_then(|i| filtered.get(i).map(|c| c.id))
app.chat_list_state
.selected()
.and_then(|i| filtered.get(i).map(|c| c.id))
};
let (status_text, status_color) = match status_chat_id {
Some(chat_id) => format_user_status(app.td_client.get_user_status_by_chat_id(chat_id)),

View File

@@ -29,12 +29,7 @@ pub fn render_emoji_picker(
let x = area.x + (area.width.saturating_sub(modal_width)) / 2;
let y = area.y + (area.height.saturating_sub(modal_height)) / 2;
let modal_area = Rect::new(
x,
y,
modal_width.min(area.width),
modal_height.min(area.height),
);
let modal_area = Rect::new(x, y, modal_width.min(area.width), modal_height.min(area.height));
// Очищаем область под модалкой
f.render_widget(Clear, modal_area);
@@ -87,10 +82,7 @@ pub fn render_emoji_picker(
.add_modifier(Modifier::BOLD),
),
Span::raw("Добавить "),
Span::styled(
" [Esc] ",
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
),
Span::styled(" [Esc] ", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)),
Span::raw("Отмена"),
]));

View File

@@ -34,10 +34,7 @@ pub fn render_input_field(
// Символ под курсором (или █ если курсор в конце)
if safe_cursor_pos < chars.len() {
let cursor_char = chars[safe_cursor_pos].to_string();
spans.push(Span::styled(
cursor_char,
Style::default().fg(Color::Black).bg(color),
));
spans.push(Span::styled(cursor_char, Style::default().fg(Color::Black).bg(color)));
} else {
// Курсор в конце - показываем блок
spans.push(Span::styled("", Style::default().fg(color)));

View File

@@ -7,9 +7,9 @@
use crate::config::Config;
use crate::formatting;
use crate::tdlib::{MessageInfo, PlaybackState, PlaybackStatus};
#[cfg(feature = "images")]
use crate::tdlib::PhotoDownloadState;
use crate::tdlib::{MessageInfo, PlaybackState, PlaybackStatus};
use crate::types::MessageId;
use crate::utils::{format_date, format_timestamp_with_tz};
use ratatui::{
@@ -36,10 +36,7 @@ fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec<WrappedLine> {
}
if all_lines.is_empty() {
all_lines.push(WrappedLine {
text: String::new(),
start_offset: 0,
});
all_lines.push(WrappedLine { text: String::new(), start_offset: 0 });
}
all_lines
@@ -48,10 +45,7 @@ fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec<WrappedLine> {
/// Разбивает один абзац (без `\n`) на строки по ширине
fn wrap_paragraph(text: &str, max_width: usize, base_offset: usize) -> Vec<WrappedLine> {
if max_width == 0 {
return vec![WrappedLine {
text: text.to_string(),
start_offset: base_offset,
}];
return vec![WrappedLine { text: text.to_string(), start_offset: base_offset }];
}
let mut result = Vec::new();
@@ -122,10 +116,7 @@ fn wrap_paragraph(text: &str, max_width: usize, base_offset: usize) -> Vec<Wrapp
}
if result.is_empty() {
result.push(WrappedLine {
text: String::new(),
start_offset: base_offset,
});
result.push(WrappedLine { text: String::new(), start_offset: base_offset });
}
result
@@ -138,7 +129,11 @@ fn wrap_paragraph(text: &str, max_width: usize, base_offset: usize) -> Vec<Wrapp
/// * `date` - timestamp сообщения
/// * `content_width` - ширина области для центрирования
/// * `is_first` - первый ли это разделитель (если нет, добавляется пустая строка сверху)
pub fn render_date_separator(date: i32, content_width: usize, is_first: bool) -> Vec<Line<'static>> {
pub fn render_date_separator(
date: i32,
content_width: usize,
is_first: bool,
) -> Vec<Line<'static>> {
let mut lines = Vec::new();
if !is_first {
@@ -276,10 +271,8 @@ pub fn render_message_bubble(
Span::styled(reply_line, Style::default().fg(Color::Cyan)),
]));
} else {
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))]));
}
}
@@ -301,9 +294,13 @@ pub fn render_message_bubble(
let is_last_line = i == total_wrapped - 1;
let line_len = wrapped.text.chars().count();
let line_entities =
formatting::adjust_entities_for_substring(msg.entities(), wrapped.start_offset, line_len);
let formatted_spans = formatting::format_text_with_entities(&wrapped.text, &line_entities, msg_color);
let line_entities = formatting::adjust_entities_for_substring(
msg.entities(),
wrapped.start_offset,
line_len,
);
let formatted_spans =
formatting::format_text_with_entities(&wrapped.text, &line_entities, msg_color);
if is_last_line {
let full_len = line_len + time_mark_len + marker_len;
@@ -313,14 +310,19 @@ pub fn render_message_bubble(
// Одна строка — маркер на ней
line_spans.push(Span::styled(
selection_marker,
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD),
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
));
} else if is_selected {
// Последняя строка multi-line — пробелы вместо маркера
line_spans.push(Span::raw(" ".repeat(marker_len)));
}
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);
@@ -328,7 +330,9 @@ pub fn render_message_bubble(
if i == 0 && is_selected {
line_spans.push(Span::styled(
selection_marker,
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD),
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
));
} else if is_selected {
// Средние строки multi-line — пробелы вместо маркера
@@ -350,19 +354,26 @@ pub fn render_message_bubble(
for (i, wrapped) in wrapped_lines.into_iter().enumerate() {
let line_len = wrapped.text.chars().count();
let line_entities =
formatting::adjust_entities_for_substring(msg.entities(), wrapped.start_offset, line_len);
let formatted_spans = formatting::format_text_with_entities(&wrapped.text, &line_entities, msg_color);
let line_entities = formatting::adjust_entities_for_substring(
msg.entities(),
wrapped.start_offset,
line_len,
);
let formatted_spans =
formatting::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),
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));
@@ -439,10 +450,7 @@ pub fn render_message_bubble(
_ => "",
};
let bar = render_progress_bar(ps.position, ps.duration, 20);
format!(
"{} {} {:.0}s/{:.0}s",
icon, bar, ps.position, ps.duration
)
format!("{} {} {:.0}s/{:.0}s", icon, bar, ps.position, ps.duration)
} else {
let waveform = render_waveform(&voice.waveform, 20);
format!(" {} {:.0}s", waveform, voice.duration)
@@ -456,10 +464,7 @@ pub fn render_message_bubble(
Span::styled(status_line, Style::default().fg(Color::Cyan)),
]));
} else {
lines.push(Line::from(Span::styled(
status_line,
Style::default().fg(Color::Cyan),
)));
lines.push(Line::from(Span::styled(status_line, Style::default().fg(Color::Cyan))));
}
}
}
@@ -477,10 +482,8 @@ pub fn render_message_bubble(
Span::styled(status, Style::default().fg(Color::Yellow)),
]));
} else {
lines.push(Line::from(Span::styled(
status,
Style::default().fg(Color::Yellow),
)));
lines
.push(Line::from(Span::styled(status, Style::default().fg(Color::Yellow))));
}
}
PhotoDownloadState::Error(e) => {
@@ -492,10 +495,7 @@ pub fn render_message_bubble(
Span::styled(status, Style::default().fg(Color::Red)),
]));
} else {
lines.push(Line::from(Span::styled(
status,
Style::default().fg(Color::Red),
)));
lines.push(Line::from(Span::styled(status, Style::default().fg(Color::Red))));
}
}
PhotoDownloadState::Downloaded(_) => {
@@ -540,7 +540,9 @@ pub fn render_album_bubble(
content_width: usize,
selected_msg_id: Option<MessageId>,
) -> (Vec<Line<'static>>, Vec<DeferredImageRender>) {
use crate::constants::{ALBUM_GRID_MAX_COLS, ALBUM_PHOTO_GAP, ALBUM_PHOTO_HEIGHT, ALBUM_PHOTO_WIDTH};
use crate::constants::{
ALBUM_GRID_MAX_COLS, ALBUM_PHOTO_GAP, ALBUM_PHOTO_HEIGHT, ALBUM_PHOTO_WIDTH,
};
let mut lines: Vec<Line<'static>> = Vec::new();
let mut deferred: Vec<DeferredImageRender> = Vec::new();
@@ -569,12 +571,12 @@ pub fn render_album_bubble(
// Добавляем маркер выбора на первую строку
if is_selected {
lines.push(Line::from(vec![
Span::styled(
selection_marker,
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD),
),
]));
lines.push(Line::from(vec![Span::styled(
selection_marker,
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)]));
}
let grid_start_line = lines.len();
@@ -608,7 +610,9 @@ pub fn render_album_bubble(
let x_off = if is_outgoing {
let grid_width = cols as u16 * ALBUM_PHOTO_WIDTH
+ (cols as u16).saturating_sub(1) * ALBUM_PHOTO_GAP;
let padding = content_width.saturating_sub(grid_width as usize + 1) as u16;
let padding = content_width
.saturating_sub(grid_width as usize + 1)
as u16;
padding + col as u16 * (ALBUM_PHOTO_WIDTH + ALBUM_PHOTO_GAP)
} else {
col as u16 * (ALBUM_PHOTO_WIDTH + ALBUM_PHOTO_GAP)
@@ -617,7 +621,8 @@ pub fn render_album_bubble(
deferred.push(DeferredImageRender {
message_id: msg.id(),
photo_path: path.clone(),
line_offset: grid_start_line + row * ALBUM_PHOTO_HEIGHT as usize,
line_offset: grid_start_line
+ row * ALBUM_PHOTO_HEIGHT as usize,
x_offset: x_off,
width: ALBUM_PHOTO_WIDTH,
height: ALBUM_PHOTO_HEIGHT,
@@ -644,10 +649,7 @@ pub fn render_album_bubble(
}
PhotoDownloadState::NotDownloaded => {
if line_in_row == ALBUM_PHOTO_HEIGHT / 2 {
spans.push(Span::styled(
"📷",
Style::default().fg(Color::Gray),
));
spans.push(Span::styled("📷", Style::default().fg(Color::Gray)));
}
}
}
@@ -706,9 +708,10 @@ pub fn render_album_bubble(
Span::styled(time_text, Style::default().fg(Color::Gray)),
]));
} else {
lines.push(Line::from(vec![
Span::styled(format!(" {}", time_text), Style::default().fg(Color::Gray)),
]));
lines.push(Line::from(vec![Span::styled(
format!(" {}", time_text),
Style::default().fg(Color::Gray),
)]));
}
}

View File

@@ -91,7 +91,10 @@ pub fn calculate_scroll_offset(
}
/// Renders a help bar with keyboard shortcuts
pub fn render_help_bar(shortcuts: &[(&str, &str, Color)], border_color: Color) -> Paragraph<'static> {
pub fn render_help_bar(
shortcuts: &[(&str, &str, Color)],
border_color: Color,
) -> Paragraph<'static> {
let mut spans: Vec<Span<'static>> = Vec::new();
for (i, (key, label, color)) in shortcuts.iter().enumerate() {
if i > 0 {
@@ -99,9 +102,7 @@ pub fn render_help_bar(shortcuts: &[(&str, &str, Color)], border_color: Color) -
}
spans.push(Span::styled(
format!(" {} ", key),
Style::default()
.fg(*color)
.add_modifier(Modifier::BOLD),
Style::default().fg(*color).add_modifier(Modifier::BOLD),
));
spans.push(Span::raw(label.to_string()));
}

View File

@@ -1,17 +1,17 @@
//! Reusable UI components: message bubbles, input fields, modals, lists.
pub mod modal;
pub mod chat_list_item;
pub mod emoji_picker;
pub mod input_field;
pub mod message_bubble;
pub mod message_list;
pub mod chat_list_item;
pub mod emoji_picker;
pub mod modal;
// Экспорт основных функций
pub use input_field::render_input_field;
pub use chat_list_item::render_chat_list_item;
pub use emoji_picker::render_emoji_picker;
pub use message_bubble::{render_date_separator, render_message_bubble, render_sender_header};
pub use input_field::render_input_field;
#[cfg(feature = "images")]
pub use message_bubble::{DeferredImageRender, calculate_image_height, render_album_bubble};
pub use message_list::{render_message_item, calculate_scroll_offset, render_help_bar};
pub use message_bubble::{calculate_image_height, render_album_bubble, DeferredImageRender};
pub use message_bubble::{render_date_separator, render_message_bubble, render_sender_header};
pub use message_list::{calculate_scroll_offset, render_help_bar, render_message_item};

View File

@@ -74,10 +74,7 @@ pub fn render_delete_confirm_modal(f: &mut Frame, area: Rect) {
),
Span::raw("Да"),
Span::raw(" "),
Span::styled(
" [n/Esc] ",
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
),
Span::styled(" [n/Esc] ", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)),
Span::raw("Нет"),
]),
];

View File

@@ -1,8 +1,8 @@
//! Compose bar / input box rendering
use crate::app::methods::{compose::ComposeMethods, messages::MessageMethods};
use crate::app::App;
use crate::app::InputMode;
use crate::app::methods::{compose::ComposeMethods, messages::MessageMethods};
use crate::tdlib::TdClientTrait;
use crate::ui::components;
use ratatui::{
@@ -124,13 +124,18 @@ pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
} else if app.input_mode == InputMode::Normal {
// Normal mode — dim, no cursor
if app.message_input.is_empty() {
let line = Line::from(vec![
Span::styled("> Press i to type...", Style::default().fg(Color::DarkGray)),
]);
let line = Line::from(vec![Span::styled(
"> Press i to type...",
Style::default().fg(Color::DarkGray),
)]);
(line, "")
} else {
let draft_preview: String = app.message_input.chars().take(60).collect();
let ellipsis = if app.message_input.chars().count() > 60 { "..." } else { "" };
let ellipsis = if app.message_input.chars().count() > 60 {
"..."
} else {
""
};
let line = Line::from(Span::styled(
format!("> {}{}", draft_preview, ellipsis),
Style::default().fg(Color::DarkGray),
@@ -163,7 +168,9 @@ pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
} else {
Style::default().fg(Color::DarkGray)
};
Block::default().borders(Borders::ALL).border_style(border_style)
Block::default()
.borders(Borders::ALL)
.border_style(border_style)
} else {
let title_color = if app.is_replying() || app.is_forwarding() {
Color::Cyan

View File

@@ -1,7 +1,7 @@
use crate::app::App;
use crate::app::InputMode;
use crate::tdlib::TdClientTrait;
use crate::tdlib::NetworkState;
use crate::tdlib::TdClientTrait;
use ratatui::{
layout::Rect,
style::{Color, Style},
@@ -31,7 +31,10 @@ pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
} else if let Some(err) = &app.error_message {
format!(" {}{}Error: {} ", account_indicator, network_indicator, err)
} else if app.is_searching {
format!(" {}{}↑/↓: Navigate | Enter: Select | Esc: Cancel ", account_indicator, network_indicator)
format!(
" {}{}↑/↓: Navigate | Enter: Select | Esc: Cancel ",
account_indicator, network_indicator
)
} else if app.selected_chat_id.is_some() {
let mode_str = match app.input_mode {
InputMode::Normal => "[NORMAL] j/k: Nav | i: Insert | d/r/f/y: Actions | Esc: Close",

View File

@@ -3,10 +3,10 @@
//! Renders message bubbles grouped by date/sender, pinned bar, and delegates
//! to modals (search, pinned, reactions, delete) and compose_bar.
use crate::app::App;
use crate::app::methods::{messages::MessageMethods, modal::ModalMethods, search::SearchMethods};
use crate::tdlib::TdClientTrait;
use crate::app::App;
use crate::message_grouping::{group_messages, MessageGroup};
use crate::tdlib::TdClientTrait;
use crate::ui::components;
use crate::ui::{compose_bar, modals};
use ratatui::{
@@ -18,7 +18,12 @@ use ratatui::{
};
/// Рендерит заголовок чата с typing status
fn render_chat_header<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>, chat: &crate::tdlib::ChatInfo) {
fn render_chat_header<T: TdClientTrait>(
f: &mut Frame,
area: Rect,
app: &App<T>,
chat: &crate::tdlib::ChatInfo,
) {
let typing_action = app
.td_client
.typing_status()
@@ -34,10 +39,7 @@ fn render_chat_header<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>,
.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),
@@ -90,8 +92,7 @@ fn render_pinned_bar<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>)
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, area);
}
@@ -104,9 +105,7 @@ pub(super) struct WrappedLine {
/// (используется только для search/pinned режимов, основной рендеринг через message_bubble)
pub(super) fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec<WrappedLine> {
if max_width == 0 {
return vec![WrappedLine {
text: text.to_string(),
}];
return vec![WrappedLine { text: text.to_string() }];
}
let mut result = Vec::new();
@@ -131,9 +130,7 @@ pub(super) fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec<Wrappe
current_line.push_str(&word);
current_width += 1 + word_width;
} else {
result.push(WrappedLine {
text: current_line,
});
result.push(WrappedLine { text: current_line });
current_line = word;
current_width = word_width;
}
@@ -155,23 +152,17 @@ pub(super) fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec<Wrappe
current_line.push(' ');
current_line.push_str(&word);
} else {
result.push(WrappedLine {
text: current_line,
});
result.push(WrappedLine { text: current_line });
current_line = word;
}
}
if !current_line.is_empty() {
result.push(WrappedLine {
text: current_line,
});
result.push(WrappedLine { text: current_line });
}
if result.is_empty() {
result.push(WrappedLine {
text: String::new(),
});
result.push(WrappedLine { text: String::new() });
}
result
@@ -208,10 +199,7 @@ fn render_message_list<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &mut Ap
is_first_date = false;
is_first_sender = true; // Сбрасываем счётчик заголовков после даты
}
MessageGroup::SenderHeader {
is_outgoing,
sender_name,
} => {
MessageGroup::SenderHeader { is_outgoing, sender_name } => {
// Рендерим заголовок отправителя
lines.extend(components::render_sender_header(
is_outgoing,
@@ -240,9 +228,16 @@ fn render_message_list<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &mut Ap
// Собираем deferred image renders для всех загруженных фото
#[cfg(feature = "images")]
if let Some(photo) = msg.photo_info() {
if let crate::tdlib::PhotoDownloadState::Downloaded(path) = &photo.download_state {
let inline_width = content_width.min(crate::constants::INLINE_IMAGE_MAX_WIDTH);
let img_height = components::calculate_image_height(photo.width, photo.height, inline_width);
if let crate::tdlib::PhotoDownloadState::Downloaded(path) =
&photo.download_state
{
let inline_width =
content_width.min(crate::constants::INLINE_IMAGE_MAX_WIDTH);
let img_height = components::calculate_image_height(
photo.width,
photo.height,
inline_width,
);
let img_width = inline_width as u16;
let bubble_len = bubble_lines.len();
let placeholder_start = lines.len() + bubble_len - img_height as usize;
@@ -352,7 +347,8 @@ fn render_message_list<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &mut Ap
use ratatui_image::StatefulImage;
// THROTTLING: Рендерим изображения максимум 15 FPS (каждые 66ms)
let should_render_images = app.last_image_render_time
let should_render_images = app
.last_image_render_time
.map(|t| t.elapsed() > std::time::Duration::from_millis(66))
.unwrap_or(true);
@@ -384,7 +380,7 @@ fn render_message_list<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &mut Ap
if let Some(renderer) = &mut app.inline_image_renderer {
// Загружаем только если видимо (early return если уже в кеше)
let _ = renderer.load_image(d.message_id, &d.photo_path);
if let Some(protocol) = renderer.get_protocol(&d.message_id) {
f.render_stateful_widget(StatefulImage::default(), img_rect, protocol);
}
@@ -487,14 +483,9 @@ pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &mut App<T>) {
}
// Модалка выбора реакции
if let crate::app::ChatState::ReactionPicker {
available_reactions,
selected_index,
..
} = &app.chat_state
if let crate::app::ChatState::ReactionPicker { available_reactions, selected_index, .. } =
&app.chat_state
{
modals::render_reaction_picker(f, area, available_reactions, *selected_index);
}
}

View File

@@ -4,8 +4,8 @@
mod auth;
pub mod chat_list;
mod compose_bar;
pub mod components;
mod compose_bar;
pub mod footer;
mod loading;
mod main_screen;

View File

@@ -20,18 +20,10 @@ pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
};
match state {
AccountSwitcherState::SelectAccount {
accounts,
selected_index,
current_account,
} => {
AccountSwitcherState::SelectAccount { accounts, selected_index, current_account } => {
render_select_account(f, area, accounts, *selected_index, current_account);
}
AccountSwitcherState::AddAccount {
name_input,
cursor_position,
error,
} => {
AccountSwitcherState::AddAccount { name_input, cursor_position, error } => {
render_add_account(f, area, name_input, *cursor_position, error.as_deref());
}
}
@@ -53,10 +45,7 @@ fn render_select_account(
let marker = if is_current { "" } else { " " };
let suffix = if is_current { " (текущий)" } else { "" };
let display = format!(
"{}{} ({}){}",
marker, account.name, account.display_name, suffix
);
let display = format!("{}{} ({}){}", marker, account.name, account.display_name, suffix);
let style = if is_selected {
Style::default()
@@ -86,10 +75,7 @@ fn render_select_account(
} else {
Style::default().fg(Color::Cyan)
};
lines.push(Line::from(Span::styled(
" + Добавить аккаунт",
add_style,
)));
lines.push(Line::from(Span::styled(" + Добавить аккаунт", add_style)));
lines.push(Line::from(""));
@@ -148,10 +134,7 @@ fn render_add_account(
let input_display = if name_input.is_empty() {
Span::styled("_", Style::default().fg(Color::DarkGray))
} else {
Span::styled(
format!("{}_", name_input),
Style::default().fg(Color::White),
)
Span::styled(format!("{}_", name_input), Style::default().fg(Color::White))
};
lines.push(Line::from(vec![
Span::styled(" Имя: ", Style::default().fg(Color::Cyan)),
@@ -168,10 +151,7 @@ fn render_add_account(
// Error
if let Some(err) = error {
lines.push(Line::from(Span::styled(
format!(" {}", err),
Style::default().fg(Color::Red),
)));
lines.push(Line::from(Span::styled(format!(" {}", err), Style::default().fg(Color::Red))));
lines.push(Line::from(""));
}

View File

@@ -1,6 +1,6 @@
//! Delete confirmation modal
use ratatui::{Frame, layout::Rect};
use ratatui::{layout::Rect, Frame};
/// Renders delete confirmation modal
pub fn render(f: &mut Frame, area: Rect) {

View File

@@ -19,19 +19,12 @@ use ratatui::{
use ratatui_image::StatefulImage;
/// Рендерит модальное окно с полноэкранным изображением
pub fn render<T: TdClientTrait>(
f: &mut Frame,
app: &mut App<T>,
modal_state: &ImageModalState,
) {
pub fn render<T: TdClientTrait>(f: &mut Frame, app: &mut App<T>, modal_state: &ImageModalState) {
let area = f.area();
// Затемняем весь фон
f.render_widget(Clear, area);
f.render_widget(
Block::default().style(Style::default().bg(Color::Black)),
area,
);
f.render_widget(Block::default().style(Style::default().bg(Color::Black)), area);
// Резервируем место для подсказок (2 строки внизу)
let image_area_height = area.height.saturating_sub(2);
@@ -76,7 +69,7 @@ pub fn render<T: TdClientTrait>(
// Загружаем изображение (может занять время для iTerm2/Sixel)
let _ = renderer.load_image(modal_state.message_id, &modal_state.photo_path);
// Триггерим перерисовку для показа загруженного изображения
app.needs_redraw = true;
}

View File

@@ -10,18 +10,18 @@
pub mod account_switcher;
pub mod delete_confirm;
pub mod pinned;
pub mod reaction_picker;
pub mod search;
pub mod pinned;
#[cfg(feature = "images")]
pub mod image_viewer;
pub use account_switcher::render as render_account_switcher;
pub use delete_confirm::render as render_delete_confirm;
pub use pinned::render as render_pinned;
pub use reaction_picker::render as render_reaction_picker;
pub use search::render as render_search;
pub use pinned::render as render_pinned;
#[cfg(feature = "images")]
pub use image_viewer::render as render_image_viewer;

View File

@@ -2,7 +2,7 @@
use crate::app::App;
use crate::tdlib::TdClientTrait;
use crate::ui::components::{render_message_item, calculate_scroll_offset, render_help_bar};
use crate::ui::components::{calculate_scroll_offset, render_help_bar, render_message_item};
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
@@ -14,15 +14,13 @@ use ratatui::{
/// Renders pinned messages mode
pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
// Извлекаем данные из ChatState
let (messages, selected_index) = if let crate::app::ChatState::PinnedMessages {
messages,
selected_index,
} = &app.chat_state
{
(messages.as_slice(), *selected_index)
} else {
return; // Некорректное состояние
};
let (messages, selected_index) =
if let crate::app::ChatState::PinnedMessages { messages, selected_index } = &app.chat_state
{
(messages.as_slice(), *selected_index)
} else {
return; // Некорректное состояние
};
let chunks = Layout::default()
.direction(Direction::Vertical)

View File

@@ -1,13 +1,8 @@
//! Reaction picker modal
use ratatui::{Frame, layout::Rect};
use ratatui::{layout::Rect, Frame};
/// Renders emoji reaction picker modal
pub fn render(
f: &mut Frame,
area: Rect,
available_reactions: &[String],
selected_index: usize,
) {
pub fn render(f: &mut Frame, area: Rect, available_reactions: &[String], selected_index: usize) {
crate::ui::components::render_emoji_picker(f, area, available_reactions, selected_index);
}

View File

@@ -2,7 +2,7 @@
use crate::app::App;
use crate::tdlib::TdClientTrait;
use crate::ui::components::{render_message_item, calculate_scroll_offset, render_help_bar};
use crate::ui::components::{calculate_scroll_offset, render_help_bar, render_message_item};
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
@@ -15,11 +15,8 @@ use ratatui::{
pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
// Извлекаем данные из ChatState
let (query, results, selected_index) =
if let crate::app::ChatState::SearchInChat {
query,
results,
selected_index,
} = &app.chat_state
if let crate::app::ChatState::SearchInChat { query, results, selected_index } =
&app.chat_state
{
(query.as_str(), results.as_slice(), *selected_index)
} else {
@@ -37,11 +34,7 @@ pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
// Search input
let total = results.len();
let current = if total > 0 {
selected_index + 1
} else {
0
};
let current = if total > 0 { selected_index + 1 } else { 0 };
let input_line = if query.is_empty() {
Line::from(vec![

View File

@@ -1,7 +1,7 @@
use crate::app::App;
use crate::app::methods::modal::ModalMethods;
use crate::tdlib::TdClientTrait;
use crate::app::App;
use crate::tdlib::ProfileInfo;
use crate::tdlib::TdClientTrait;
use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},

View File

@@ -6,6 +6,6 @@ pub mod validation;
pub use formatting::*;
// pub use modal_handler::*; // Используется через явный import
pub use retry::{with_timeout, with_timeout_msg, with_timeout_ignore};
pub use retry::{with_timeout, with_timeout_ignore, with_timeout_msg};
pub use tdlib::*;
pub use validation::*;

View File

@@ -105,10 +105,9 @@ mod tests {
#[tokio::test]
async fn test_with_timeout_success() {
let result = with_timeout(Duration::from_secs(1), async {
Ok::<_, String>("success".to_string())
})
.await;
let result =
with_timeout(Duration::from_secs(1), async { Ok::<_, String>("success".to_string()) })
.await;
assert!(result.is_ok());
assert_eq!(result.unwrap(), "success");