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

@@ -1,6 +1,6 @@
use criterion::{black_box, criterion_group, criterion_main, Criterion}; use criterion::{black_box, criterion_group, criterion_main, Criterion};
use tele_tui::formatting::format_text_with_entities;
use tdlib_rs::enums::{TextEntity, TextEntityType}; use tdlib_rs::enums::{TextEntity, TextEntityType};
use tele_tui::formatting::format_text_with_entities;
fn create_text_with_entities() -> (String, Vec<TextEntity>) { fn create_text_with_entities() -> (String, Vec<TextEntity>) {
let text = "This is bold and italic text with code and a link and mention".to_string(); let text = "This is bold and italic text with code and a link and mention".to_string();
@@ -41,9 +41,7 @@ fn benchmark_format_simple_text(c: &mut Criterion) {
let entities = vec![]; let entities = vec![];
c.bench_function("format_simple_text", |b| { c.bench_function("format_simple_text", |b| {
b.iter(|| { b.iter(|| format_text_with_entities(black_box(&text), black_box(&entities)));
format_text_with_entities(black_box(&text), black_box(&entities))
});
}); });
} }
@@ -51,9 +49,7 @@ fn benchmark_format_markdown_text(c: &mut Criterion) {
let (text, entities) = create_text_with_entities(); let (text, entities) = create_text_with_entities();
c.bench_function("format_markdown_text", |b| { c.bench_function("format_markdown_text", |b| {
b.iter(|| { b.iter(|| format_text_with_entities(black_box(&text), black_box(&entities)));
format_text_with_entities(black_box(&text), black_box(&entities))
});
}); });
} }
@@ -77,9 +73,7 @@ fn benchmark_format_long_text(c: &mut Criterion) {
} }
c.bench_function("format_long_text_with_100_entities", |b| { c.bench_function("format_long_text_with_100_entities", |b| {
b.iter(|| { b.iter(|| format_text_with_entities(black_box(&text), black_box(&entities)));
format_text_with_entities(black_box(&text), black_box(&entities))
});
}); });
} }

View File

@@ -1,5 +1,5 @@
use criterion::{black_box, criterion_group, criterion_main, Criterion}; use criterion::{black_box, criterion_group, criterion_main, Criterion};
use tele_tui::utils::formatting::{format_timestamp_with_tz, format_date, get_day}; use tele_tui::utils::formatting::{format_date, format_timestamp_with_tz, get_day};
fn benchmark_format_timestamp(c: &mut Criterion) { fn benchmark_format_timestamp(c: &mut Criterion) {
c.bench_function("format_timestamp_50_times", |b| { c.bench_function("format_timestamp_50_times", |b| {
@@ -34,10 +34,5 @@ fn benchmark_get_day(c: &mut Criterion) {
}); });
} }
criterion_group!( criterion_group!(benches, benchmark_format_timestamp, benchmark_format_date, benchmark_get_day);
benches,
benchmark_format_timestamp,
benchmark_format_date,
benchmark_get_day
);
criterion_main!(benches); criterion_main!(benches);

View File

@@ -8,7 +8,10 @@ fn create_test_messages(count: usize) -> Vec<tele_tui::tdlib::MessageInfo> {
.map(|i| { .map(|i| {
let builder = MessageBuilder::new(MessageId::new(i as i64)) let builder = MessageBuilder::new(MessageId::new(i as i64))
.sender_name(&format!("User{}", i % 10)) .sender_name(&format!("User{}", i % 10))
.text(&format!("Test message number {} with some longer text to make it more realistic", i)) .text(&format!(
"Test message number {} with some longer text to make it more realistic",
i
))
.date(1640000000 + (i as i32 * 60)); .date(1640000000 + (i as i32 * 60));
if i % 2 == 0 { if i % 2 == 0 {
@@ -24,9 +27,7 @@ fn benchmark_group_100_messages(c: &mut Criterion) {
let messages = create_test_messages(100); let messages = create_test_messages(100);
c.bench_function("group_100_messages", |b| { c.bench_function("group_100_messages", |b| {
b.iter(|| { b.iter(|| group_messages(black_box(&messages)));
group_messages(black_box(&messages))
});
}); });
} }
@@ -34,9 +35,7 @@ fn benchmark_group_500_messages(c: &mut Criterion) {
let messages = create_test_messages(500); let messages = create_test_messages(500);
c.bench_function("group_500_messages", |b| { c.bench_function("group_500_messages", |b| {
b.iter(|| { b.iter(|| group_messages(black_box(&messages)));
group_messages(black_box(&messages))
});
}); });
} }

View File

@@ -6,15 +6,6 @@ max_width = 100
tab_spaces = 4 tab_spaces = 4
newline_style = "Unix" newline_style = "Unix"
# Imports
imports_granularity = "Crate"
group_imports = "StdExternalCrate"
# Comments
wrap_comments = true
comment_width = 80
normalize_comments = true
# Formatting # Formatting
use_small_heuristics = "Default" use_small_heuristics = "Default"
fn_call_width = 80 fn_call_width = 80

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,14 +7,14 @@
//! - search: Search in chats and messages //! - search: Search in chats and messages
//! - modal: Modal dialogs (Profile, Pinned, Reactions, Delete) //! - modal: Modal dialogs (Profile, Pinned, Reactions, Delete)
pub mod navigation;
pub mod messages;
pub mod compose; pub mod compose;
pub mod search; pub mod messages;
pub mod modal; pub mod modal;
pub mod navigation;
pub mod search;
pub use navigation::NavigationMethods;
pub use messages::MessageMethods;
pub use compose::ComposeMethods; pub use compose::ComposeMethods;
pub use search::SearchMethods; pub use messages::MessageMethods;
pub use modal::ModalMethods; 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>) { fn enter_pinned_mode(&mut self, messages: Vec<MessageInfo>) {
if !messages.is_empty() { if !messages.is_empty() {
self.chat_state = ChatState::PinnedMessages { self.chat_state = ChatState::PinnedMessages { messages, selected_index: 0 };
messages,
selected_index: 0,
};
} }
} }
@@ -118,11 +115,7 @@ impl<T: TdClientTrait> ModalMethods<T> for App<T> {
} }
fn select_previous_pinned(&mut self) { fn select_previous_pinned(&mut self) {
if let ChatState::PinnedMessages { if let ChatState::PinnedMessages { selected_index, messages } = &mut self.chat_state {
selected_index,
messages,
} = &mut self.chat_state
{
if *selected_index + 1 < messages.len() { if *selected_index + 1 < messages.len() {
*selected_index += 1; *selected_index += 1;
} }
@@ -138,11 +131,7 @@ impl<T: TdClientTrait> ModalMethods<T> for App<T> {
} }
fn get_selected_pinned(&self) -> Option<&MessageInfo> { fn get_selected_pinned(&self) -> Option<&MessageInfo> {
if let ChatState::PinnedMessages { if let ChatState::PinnedMessages { messages, selected_index } = &self.chat_state {
messages,
selected_index,
} = &self.chat_state
{
messages.get(*selected_index) messages.get(*selected_index)
} else { } else {
None None
@@ -170,10 +159,7 @@ impl<T: TdClientTrait> ModalMethods<T> for App<T> {
} }
fn select_previous_profile_action(&mut self) { fn select_previous_profile_action(&mut self) {
if let ChatState::Profile { if let ChatState::Profile { selected_action, .. } = &mut self.chat_state {
selected_action, ..
} = &mut self.chat_state
{
if *selected_action > 0 { if *selected_action > 0 {
*selected_action -= 1; *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) { fn select_next_profile_action(&mut self, max_actions: usize) {
if let ChatState::Profile { if let ChatState::Profile { selected_action, .. } = &mut self.chat_state {
selected_action, ..
} = &mut self.chat_state
{
if *selected_action < max_actions.saturating_sub(1) { if *selected_action < max_actions.saturating_sub(1) {
*selected_action += 1; *selected_action += 1;
} }
@@ -192,41 +175,25 @@ impl<T: TdClientTrait> ModalMethods<T> for App<T> {
} }
fn show_leave_group_confirmation(&mut self) { fn show_leave_group_confirmation(&mut self) {
if let ChatState::Profile { if let ChatState::Profile { leave_group_confirmation_step, .. } = &mut self.chat_state {
leave_group_confirmation_step,
..
} = &mut self.chat_state
{
*leave_group_confirmation_step = 1; *leave_group_confirmation_step = 1;
} }
} }
fn show_leave_group_final_confirmation(&mut self) { fn show_leave_group_final_confirmation(&mut self) {
if let ChatState::Profile { if let ChatState::Profile { leave_group_confirmation_step, .. } = &mut self.chat_state {
leave_group_confirmation_step,
..
} = &mut self.chat_state
{
*leave_group_confirmation_step = 2; *leave_group_confirmation_step = 2;
} }
} }
fn cancel_leave_group(&mut self) { fn cancel_leave_group(&mut self) {
if let ChatState::Profile { if let ChatState::Profile { leave_group_confirmation_step, .. } = &mut self.chat_state {
leave_group_confirmation_step,
..
} = &mut self.chat_state
{
*leave_group_confirmation_step = 0; *leave_group_confirmation_step = 0;
} }
} }
fn get_leave_group_confirmation_step(&self) -> u8 { fn get_leave_group_confirmation_step(&self) -> u8 {
if let ChatState::Profile { if let ChatState::Profile { leave_group_confirmation_step, .. } = &self.chat_state {
leave_group_confirmation_step,
..
} = &self.chat_state
{
*leave_group_confirmation_step *leave_group_confirmation_step
} else { } else {
0 0
@@ -242,10 +209,7 @@ impl<T: TdClientTrait> ModalMethods<T> for App<T> {
} }
fn get_selected_profile_action(&self) -> Option<usize> { fn get_selected_profile_action(&self) -> Option<usize> {
if let ChatState::Profile { if let ChatState::Profile { selected_action, .. } = &self.chat_state {
selected_action, ..
} = &self.chat_state
{
Some(*selected_action) Some(*selected_action)
} else { } else {
None None
@@ -277,11 +241,8 @@ impl<T: TdClientTrait> ModalMethods<T> for App<T> {
} }
fn select_next_reaction(&mut self) { fn select_next_reaction(&mut self) {
if let ChatState::ReactionPicker { if let ChatState::ReactionPicker { selected_index, available_reactions, .. } =
selected_index, &mut self.chat_state
available_reactions,
..
} = &mut self.chat_state
{ {
if *selected_index + 1 < available_reactions.len() { if *selected_index + 1 < available_reactions.len() {
*selected_index += 1; *selected_index += 1;
@@ -290,11 +251,8 @@ impl<T: TdClientTrait> ModalMethods<T> for App<T> {
} }
fn get_selected_reaction(&self) -> Option<&String> { fn get_selected_reaction(&self) -> Option<&String> {
if let ChatState::ReactionPicker { if let ChatState::ReactionPicker { available_reactions, selected_index, .. } =
available_reactions, &self.chat_state
selected_index,
..
} = &self.chat_state
{ {
available_reactions.get(*selected_index) available_reactions.get(*selected_index)
} else { } else {

View File

@@ -2,8 +2,8 @@
//! //!
//! Handles chat list navigation and selection //! Handles chat list navigation and selection
use crate::app::{App, ChatState, InputMode};
use crate::app::methods::search::SearchMethods; use crate::app::methods::search::SearchMethods;
use crate::app::{App, ChatState, InputMode};
use crate::tdlib::TdClientTrait; use crate::tdlib::TdClientTrait;
/// Navigation methods for chat list /// 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> { fn get_filtered_chats(&self) -> Vec<&ChatInfo> {
// Используем ChatFilter для централизованной фильтрации // Используем ChatFilter для централизованной фильтрации
let mut criteria = ChatFilterCriteria::new() let mut criteria = ChatFilterCriteria::new().with_folder(self.selected_folder_id);
.with_folder(self.selected_folder_id);
if !self.search_query.is_empty() { if !self.search_query.is_empty() {
criteria = criteria.with_search(self.search_query.clone()); 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) { fn select_next_search_result(&mut self) {
if let ChatState::SearchInChat { if let ChatState::SearchInChat { selected_index, results, .. } = &mut self.chat_state {
selected_index,
results,
..
} = &mut self.chat_state
{
if *selected_index + 1 < results.len() { if *selected_index + 1 < results.len() {
*selected_index += 1; *selected_index += 1;
} }
@@ -126,12 +120,7 @@ impl<T: TdClientTrait> SearchMethods<T> for App<T> {
} }
fn get_selected_search_result(&self) -> Option<&MessageInfo> { fn get_selected_search_result(&self) -> Option<&MessageInfo> {
if let ChatState::SearchInChat { if let ChatState::SearchInChat { results, selected_index, .. } = &self.chat_state {
results,
selected_index,
..
} = &self.chat_state
{
results.get(*selected_index) results.get(*selected_index)
} else { } else {
None None

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -277,11 +277,7 @@ mod tests {
#[test] #[test]
fn test_format_text_with_bold() { fn test_format_text_with_bold() {
let text = "Hello"; let text = "Hello";
let entities = vec![TextEntity { let entities = vec![TextEntity { offset: 0, length: 5, r#type: TextEntityType::Bold }];
offset: 0,
length: 5,
r#type: TextEntityType::Bold,
}];
let spans = format_text_with_entities(text, &entities, Color::White); let spans = format_text_with_entities(text, &entities, Color::White);
assert_eq!(spans.len(), 1); 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()); app.status_message = Some("Отправка номера...".to_string());
match with_timeout_msg( match with_timeout_msg(
Duration::from_secs(10), 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 .await
@@ -84,7 +85,8 @@ pub async fn handle<T: TdClientTrait>(app: &mut App<T>, key_code: KeyCode) {
app.status_message = Some("Проверка пароля...".to_string()); app.status_message = Some("Проверка пароля...".to_string());
match with_timeout_msg( match with_timeout_msg(
Duration::from_secs(10), 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 .await

View File

@@ -6,17 +6,17 @@
//! - Editing and sending messages //! - Editing and sending messages
//! - Loading older 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::App;
use crate::app::InputMode; use crate::app::InputMode;
use crate::app::methods::{ use crate::input::handlers::{copy_to_clipboard, format_message_for_clipboard};
compose::ComposeMethods, messages::MessageMethods, use crate::tdlib::{ChatAction, TdClientTrait};
modal::ModalMethods, navigation::NavigationMethods,
};
use crate::tdlib::{TdClientTrait, ChatAction};
use crate::types::{ChatId, MessageId}; use crate::types::{ChatId, MessageId};
use crate::utils::{is_non_empty, with_timeout, with_timeout_msg}; 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 crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
@@ -29,7 +29,11 @@ use std::time::{Duration, Instant};
/// - Пересылку сообщения (f/а) /// - Пересылку сообщения (f/а)
/// - Копирование сообщения (y/н) /// - Копирование сообщения (y/н)
/// - Добавление реакции (e/у) /// - Добавление реакции (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 { match command {
Some(crate::config::Command::MoveUp) => { Some(crate::config::Command::MoveUp) => {
app.select_previous_message(); app.select_previous_message();
@@ -44,9 +48,7 @@ pub async fn handle_message_selection<T: TdClientTrait>(app: &mut App<T>, _key:
let can_delete = let can_delete =
msg.can_be_deleted_only_for_self() || msg.can_be_deleted_for_all_users(); msg.can_be_deleted_only_for_self() || msg.can_be_deleted_for_all_users();
if can_delete { if can_delete {
app.chat_state = crate::app::ChatState::DeleteConfirmation { app.chat_state = crate::app::ChatState::DeleteConfirmation { message_id: msg.id() };
message_id: msg.id(),
};
} }
} }
Some(crate::config::Command::EnterInsertMode) => { 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() .iter()
.any(|m| m.id() == msg_id); .any(|m| m.id() == msg_id);
if !msg_exists { if !msg_exists {
app.error_message = Some(format!( app.error_message =
"Сообщение {} не найдено в кэше чата {}", Some(format!("Сообщение {} не найдено в кэше чата {}", msg_id.as_i64(), chat_id));
msg_id.as_i64(), chat_id
));
app.chat_state = crate::app::ChatState::Normal; app.chat_state = crate::app::ChatState::Normal;
app.message_input.clear(); app.message_input.clear();
app.cursor_position = 0; 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( match with_timeout_msg(
Duration::from_secs(5), 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 .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(); let old_reply_to = messages[pos].interactions.reply_to.clone();
// Если в старом сообщении был reply и в новом он "Unknown" - сохраняем старый // Если в старом сообщении был reply и в новом он "Unknown" - сохраняем старый
if let Some(old_reply) = old_reply_to { if let Some(old_reply) = old_reply_to {
if edited_msg.interactions.reply_to.as_ref() if edited_msg
.map_or(true, |r| r.sender_name == "Unknown") { .interactions
.reply_to
.as_ref()
.map_or(true, |r| r.sender_name == "Unknown")
{
edited_msg.interactions.reply_to = Some(old_reply); edited_msg.interactions.reply_to = Some(old_reply);
} }
} }
@@ -189,12 +201,12 @@ pub async fn send_new_message<T: TdClientTrait>(app: &mut App<T>, chat_id: i64,
}; };
// Создаём ReplyInfo ДО отправки, пока сообщение точно доступно // Создаём ReplyInfo ДО отправки, пока сообщение точно доступно
let reply_info = app.get_replying_to_message().map(|m| { let reply_info = app
crate::tdlib::ReplyInfo { .get_replying_to_message()
.map(|m| crate::tdlib::ReplyInfo {
message_id: m.id(), message_id: m.id(),
sender_name: m.sender_name().to_string(), sender_name: m.sender_name().to_string(),
text: m.text().to_string(), text: m.text().to_string(),
}
}); });
app.message_input.clear(); app.message_input.clear();
@@ -206,11 +218,14 @@ pub async fn send_new_message<T: TdClientTrait>(app: &mut App<T>, chat_id: i64,
app.last_typing_sent = None; app.last_typing_sent = None;
// Отменяем typing status // Отменяем 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( match with_timeout_msg(
Duration::from_secs(5), 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 .await
@@ -304,7 +319,8 @@ pub async fn send_reaction<T: TdClientTrait>(app: &mut App<T>) {
// Send reaction with timeout // Send reaction with timeout
let result = with_timeout_msg( let result = with_timeout_msg(
Duration::from_secs(5), 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; .await;
@@ -353,7 +369,8 @@ pub async fn load_older_messages_if_needed<T: TdClientTrait>(app: &mut App<T>) {
// Load older messages with timeout // Load older messages with timeout
let Ok(older) = with_timeout( let Ok(older) = with_timeout(
Duration::from_secs(3), 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 .await
else { else {
@@ -408,7 +425,8 @@ pub async fn handle_open_chat_keyboard_input<T: TdClientTrait>(app: &mut App<T>,
// Игнорируем символы с Ctrl/Alt модификаторами (кроме Shift) // Игнорируем символы с Ctrl/Alt модификаторами (кроме Shift)
// Это позволяет обрабатывать хоткеи типа Ctrl+U для профиля // Это позволяет обрабатывать хоткеи типа Ctrl+U для профиля
if key.modifiers.contains(KeyModifiers::CONTROL) if key.modifiers.contains(KeyModifiers::CONTROL)
|| key.modifiers.contains(KeyModifiers::ALT) { || key.modifiers.contains(KeyModifiers::ALT)
{
return; return;
} }
@@ -434,7 +452,9 @@ pub async fn handle_open_chat_keyboard_input<T: TdClientTrait>(app: &mut App<T>,
.unwrap_or(true); .unwrap_or(true);
if should_send_typing { if should_send_typing {
if let Some(chat_id) = app.get_selected_chat_id() { 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()); 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() { for msg in app.td_client.current_chat_messages_mut() {
if let Some(photo) = msg.photo_info_mut() { if let Some(photo) = msg.photo_info_mut() {
if photo.file_id == file_id { if photo.file_id == file_id {
photo.download_state = photo.download_state = PhotoDownloadState::Downloaded(path.clone());
PhotoDownloadState::Downloaded(path.clone());
break; 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() { for msg in app.td_client.current_chat_messages_mut() {
if let Some(photo) = msg.photo_info_mut() { if let Some(photo) = msg.photo_info_mut() {
if photo.file_id == file_id { if photo.file_id == file_id {
photo.download_state = photo.download_state = PhotoDownloadState::Error(e.clone());
PhotoDownloadState::Error(e.clone());
break; 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() { for msg in app.td_client.current_chat_messages_mut() {
if let Some(photo) = msg.photo_info_mut() { if let Some(photo) = msg.photo_info_mut() {
if photo.file_id == file_id { if photo.file_id == file_id {
photo.download_state = photo.download_state = PhotoDownloadState::Downloaded(path.clone());
PhotoDownloadState::Downloaded(path.clone());
break; 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) { if let Ok(entries) = std::fs::read_dir(parent) {
for entry in entries.flatten() { for entry in entries.flatten() {
let entry_name = entry.file_name(); 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(); let found_path = entry.path().to_string_lossy().to_string();
// Кэшируем найденный файл // Кэшируем найденный файл
if let Some(ref mut cache) = app.voice_cache { 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 // Закомментировано - будет реализовано в Этапе 4
} }
*/ */

View File

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

View File

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

View File

@@ -6,8 +6,8 @@
//! - Ctrl+P: View pinned messages //! - Ctrl+P: View pinned messages
//! - Ctrl+F: Search messages in chat //! - Ctrl+F: Search messages in chat
use crate::app::App;
use crate::app::methods::{modal::ModalMethods, search::SearchMethods}; use crate::app::methods::{modal::ModalMethods, search::SearchMethods};
use crate::app::App;
use crate::tdlib::TdClientTrait; use crate::tdlib::TdClientTrait;
use crate::types::ChatId; use crate::types::ChatId;
use crate::utils::{with_timeout, with_timeout_msg}; 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 => { KeyCode::Char('r') if has_ctrl => {
// Ctrl+R - обновить список чатов // Ctrl+R - обновить список чатов
app.status_message = Some("Обновление чатов...".to_string()); 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 чаты после обновления // Синхронизируем muted чаты после обновления
app.td_client.sync_notification_muted_chats(); app.td_client.sync_notification_muted_chats();
app.status_message = None; app.status_message = None;

View File

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

View File

@@ -7,13 +7,13 @@
//! - Pinned messages view //! - Pinned messages view
//! - Profile information modal //! - Profile information modal
use crate::app::{AccountSwitcherState, App}; use super::scroll_to_message;
use crate::app::methods::{modal::ModalMethods, navigation::NavigationMethods}; 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::tdlib::TdClientTrait;
use crate::types::{ChatId, MessageId}; use crate::types::{ChatId, MessageId};
use crate::utils::{with_timeout_msg, modal_handler::handle_yes_no}; use crate::utils::{modal_handler::handle_yes_no, with_timeout_msg};
use crate::input::handlers::get_available_actions_count;
use super::scroll_to_message;
use crossterm::event::{KeyCode, KeyEvent}; use crossterm::event::{KeyCode, KeyEvent};
use std::time::Duration; use std::time::Duration;
@@ -65,8 +65,7 @@ pub async fn handle_account_switcher<T: TdClientTrait>(
} }
} }
} }
AccountSwitcherState::AddAccount { .. } => { AccountSwitcherState::AddAccount { .. } => match key.code {
match key.code {
KeyCode::Esc => { KeyCode::Esc => {
app.account_switcher_back(); app.account_switcher_back();
} }
@@ -104,8 +103,7 @@ pub async fn handle_account_switcher<T: TdClientTrait>(
} }
} }
_ => {} _ => {}
} },
}
} }
} }
@@ -116,7 +114,11 @@ pub async fn handle_account_switcher<T: TdClientTrait>(
/// - Навигацию по действиям профиля (Up/Down) /// - Навигацию по действиям профиля (Up/Down)
/// - Выполнение выбранного действия (Enter): открыть в браузере, скопировать ID, покинуть группу /// - Выполнение выбранного действия (Enter): открыть в браузере, скопировать ID, покинуть группу
/// - Выход из режима профиля (Esc) /// - Выход из режима профиля (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(); let confirmation_step = app.get_leave_group_confirmation_step();
if confirmation_step > 0 { 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 let Some(username) = &profile.username {
if action_index == current_idx { if action_index == current_idx {
let url = format!( let url = format!("https://t.me/{}", username.trim_start_matches('@'));
"https://t.me/{}",
username.trim_start_matches('@')
);
#[cfg(feature = "url-open")] #[cfg(feature = "url-open")]
{ {
match open::that(&url) { 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"))] #[cfg(not(feature = "url-open"))]
{ {
app.error_message = Some( app.error_message = Some(
"Открытие URL недоступно (требуется feature 'url-open')".to_string() "Открытие URL недоступно (требуется feature 'url-open')".to_string(),
); );
} }
return; return;
@@ -324,7 +323,11 @@ pub async fn handle_delete_confirmation<T: TdClientTrait>(app: &mut App<T>, key:
/// - Навигацию по сетке реакций: Left/Right, Up/Down (сетка 8x6) /// - Навигацию по сетке реакций: Left/Right, Up/Down (сетка 8x6)
/// - Добавление/удаление реакции (Enter) /// - Добавление/удаление реакции (Enter)
/// - Выход из режима (Esc) /// - Выход из режима (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 { match command {
Some(crate::config::Command::MoveLeft) => { Some(crate::config::Command::MoveLeft) => {
app.select_previous_reaction(); 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; app.needs_redraw = true;
} }
Some(crate::config::Command::MoveUp) => { Some(crate::config::Command::MoveUp) => {
if let crate::app::ChatState::ReactionPicker { if let crate::app::ChatState::ReactionPicker { selected_index, .. } =
selected_index, &mut app.chat_state
..
} = &mut app.chat_state
{ {
if *selected_index >= 8 { if *selected_index >= 8 {
*selected_index = selected_index.saturating_sub(8); *selected_index = selected_index.saturating_sub(8);
@@ -377,7 +378,11 @@ pub async fn handle_reaction_picker_mode<T: TdClientTrait>(app: &mut App<T>, _ke
/// - Навигацию по закреплённым сообщениям (Up/Down) /// - Навигацию по закреплённым сообщениям (Up/Down)
/// - Переход к сообщению в истории (Enter) /// - Переход к сообщению в истории (Enter)
/// - Выход из режима (Esc) /// - Выход из режима (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 { match command {
Some(crate::config::Command::Cancel) => { Some(crate::config::Command::Cancel) => {
app.exit_pinned_mode(); app.exit_pinned_mode();

View File

@@ -5,8 +5,8 @@
//! - Message search mode //! - Message search mode
//! - Search query input //! - Search query input
use crate::app::App;
use crate::app::methods::{navigation::NavigationMethods, search::SearchMethods}; use crate::app::methods::{navigation::NavigationMethods, search::SearchMethods};
use crate::app::App;
use crate::tdlib::TdClientTrait; use crate::tdlib::TdClientTrait;
use crate::types::{ChatId, MessageId}; use crate::types::{ChatId, MessageId};
use crate::utils::with_timeout; use crate::utils::with_timeout;
@@ -23,7 +23,11 @@ use super::scroll_to_message;
/// - Навигацию по отфильтрованному списку (Up/Down) /// - Навигацию по отфильтрованному списку (Up/Down)
/// - Открытие выбранного чата (Enter) /// - Открытие выбранного чата (Enter)
/// - Отмену поиска (Esc) /// - Отмену поиска (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 { match command {
Some(crate::config::Command::Cancel) => { Some(crate::config::Command::Cancel) => {
app.cancel_search(); app.cancel_search();
@@ -40,8 +44,7 @@ pub async fn handle_chat_search_mode<T: TdClientTrait>(app: &mut App<T>, key: Ke
Some(crate::config::Command::MoveUp) => { Some(crate::config::Command::MoveUp) => {
app.previous_filtered_chat(); app.previous_filtered_chat();
} }
_ => { _ => match key.code {
match key.code {
KeyCode::Backspace => { KeyCode::Backspace => {
app.search_query.pop(); app.search_query.pop();
app.chat_list_state.select(Some(0)); app.chat_list_state.select(Some(0));
@@ -51,8 +54,7 @@ pub async fn handle_chat_search_mode<T: TdClientTrait>(app: &mut App<T>, key: Ke
app.chat_list_state.select(Some(0)); app.chat_list_state.select(Some(0));
} }
_ => {} _ => {}
} },
}
} }
} }
@@ -63,7 +65,11 @@ pub async fn handle_chat_search_mode<T: TdClientTrait>(app: &mut App<T>, key: Ke
/// - Переход к выбранному сообщению (Enter) /// - Переход к выбранному сообщению (Enter)
/// - Редактирование поискового запроса (Backspace, Char) /// - Редактирование поискового запроса (Backspace, Char)
/// - Выход из режима поиска (Esc) /// - Выход из режима поиска (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 { match command {
Some(crate::config::Command::Cancel) => { Some(crate::config::Command::Cancel) => {
app.exit_message_search_mode(); app.exit_message_search_mode();
@@ -80,8 +86,7 @@ pub async fn handle_message_search_mode<T: TdClientTrait>(app: &mut App<T>, key:
app.exit_message_search_mode(); app.exit_message_search_mode();
} }
} }
_ => { _ => match key.code {
match key.code {
KeyCode::Char('N') => { KeyCode::Char('N') => {
app.select_previous_search_result(); app.select_previous_search_result();
} }
@@ -105,8 +110,7 @@ pub async fn handle_message_search_mode<T: TdClientTrait>(app: &mut App<T>, key:
perform_message_search(app, &query).await; perform_message_search(app, &query).await;
} }
_ => {} _ => {}
} },
}
} }
} }

View File

@@ -3,35 +3,26 @@
//! Dispatches keyboard events to specialized handlers based on current app mode. //! Dispatches keyboard events to specialized handlers based on current app mode.
//! Priority order: modals → search → compose → chat → chat list. //! 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::App;
use crate::app::InputMode; 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::{ 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, handle_global_commands,
modal::{ modal::{
handle_account_switcher, handle_account_switcher, handle_delete_confirmation, handle_pinned_mode,
handle_profile_mode, handle_profile_open, handle_delete_confirmation, handle_profile_mode, handle_profile_open, handle_reaction_picker_mode,
handle_reaction_picker_mode, handle_pinned_mode,
}, },
search::{handle_chat_search_mode, handle_message_search_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; use crossterm::event::KeyEvent;
/// Обработка клавиши Esc в Normal mode /// Обработка клавиши 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; let _ = app.td_client.set_draft_message(chat_id, draft_text).await;
} else { } 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(); app.close_chat();
@@ -331,4 +325,3 @@ async fn navigate_to_adjacent_photo<T: TdClientTrait>(app: &mut App<T>, directio
}; };
app.status_message = Some(msg.to_string()); app.status_message = Some(msg.to_string());
} }

View File

@@ -57,7 +57,7 @@ async fn main() -> Result<(), io::Error> {
tracing_subscriber::fmt() tracing_subscriber::fmt()
.with_env_filter( .with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env() tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("warn")) .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("warn")),
) )
.init(); .init();
@@ -70,15 +70,16 @@ async fn main() -> Result<(), io::Error> {
// Резолвим аккаунт из CLI или default // Резолвим аккаунт из CLI или default
let account_arg = parse_account_arg(); let account_arg = parse_account_arg();
let (account_name, db_path) = let (account_name, db_path) =
accounts::resolve_account(&accounts_config, account_arg.as_deref()) accounts::resolve_account(&accounts_config, account_arg.as_deref()).unwrap_or_else(|e| {
.unwrap_or_else(|e| {
eprintln!("Error: {}", e); eprintln!("Error: {}", e);
std::process::exit(1); std::process::exit(1);
}); });
// Создаём директорию аккаунта если её нет // Создаём директорию аккаунта если её нет
let db_path = accounts::ensure_account_dir( 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); .unwrap_or(db_path);
@@ -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; let _ = tdlib_rs::functions::close(app.td_client.client_id()).await;
// Ждём завершения polling задачи (с таймаутом) // Ждём завершения 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(()); return Ok(());
} }
@@ -330,10 +335,7 @@ async fn run_app<B: ratatui::backend::Backend, T: tdlib::TdClientTrait>(
// Process pending chat initialization (reply info, pinned, photos) // Process pending chat initialization (reply info, pinned, photos)
if let Some(chat_id) = app.pending_chat_init.take() { if let Some(chat_id) = app.pending_chat_init.take() {
// Загружаем недостающие reply info (игнорируем ошибки) // Загружаем недостающие reply info (игнорируем ошибки)
with_timeout_ignore( with_timeout_ignore(Duration::from_secs(5), app.td_client.fetch_missing_reply_info())
Duration::from_secs(5),
app.td_client.fetch_missing_reply_info(),
)
.await; .await;
// Загружаем последнее закреплённое сообщение (игнорируем ошибки) // Загружаем последнее закреплённое сообщение (игнорируем ошибки)
@@ -372,9 +374,7 @@ async fn run_app<B: ratatui::backend::Backend, T: tdlib::TdClientTrait>(
for file_id in photo_file_ids { for file_id in photo_file_ids {
let tx = tx.clone(); let tx = tx.clone();
tokio::spawn(async move { tokio::spawn(async move {
let result = tokio::time::timeout( let result = tokio::time::timeout(Duration::from_secs(5), async {
Duration::from_secs(5),
async {
match tdlib_rs::functions::download_file( match tdlib_rs::functions::download_file(
file_id, 1, 0, 0, true, client_id, file_id, 1, 0, 0, true, client_id,
) )
@@ -389,8 +389,7 @@ async fn run_app<B: ratatui::backend::Backend, T: tdlib::TdClientTrait>(
Ok(_) => Err("Файл не скачан".to_string()), Ok(_) => Err("Файл не скачан".to_string()),
Err(e) => Err(format!("{:?}", e)), Err(e) => Err(format!("{:?}", e)),
} }
}, })
)
.await; .await;
let result = match result { let result = match result {

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,7 +3,7 @@
//! This module contains utility functions for managing chats, //! This module contains utility functions for managing chats,
//! including finding, updating, and adding/removing 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 crate::types::{ChatId, MessageId, UserId};
use tdlib_rs::enums::{Chat as TdChat, ChatList, ChatType}; 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() { 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; 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); let user_id = UserId::new(private.user_id);
client.user_cache.chat_user_ids.insert(chat_id, user_id); client.user_cache.chat_user_ids.insert(chat_id, user_id);
// Проверяем, есть ли уже username в кэше (peek не обновляет LRU) // Проверяем, есть ли уже username в кэше (peek не обновляет LRU)
client.user_cache.user_usernames client
.user_cache
.user_usernames
.peek(&user_id) .peek(&user_id)
.map(|u| format!("@{}", u)) .map(|u| format!("@{}", u))
} }

View File

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

View File

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

View File

@@ -4,7 +4,10 @@
use super::client::TdClient; use super::client::TdClient;
use super::r#trait::TdClientTrait; 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 crate::types::{ChatId, MessageId, UserId};
use async_trait::async_trait; use async_trait::async_trait;
use std::path::PathBuf; use std::path::PathBuf;
@@ -52,11 +55,19 @@ impl TdClientTrait for TdClient {
} }
// ============ Message methods ============ // ============ 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 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 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 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 self.search_messages(chat_id, query).await
} }
@@ -148,7 +163,8 @@ impl TdClientTrait for TdClient {
chat_id: ChatId, chat_id: ChatId,
message_id: MessageId, message_id: MessageId,
) -> Result<Vec<String>, String> { ) -> 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( async fn toggle_reaction(
@@ -276,7 +292,8 @@ impl TdClientTrait for TdClient {
// ============ Notification methods ============ // ============ Notification methods ============
fn sync_notification_muted_chats(&mut self) { 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 ============ // ============ Account switching ============

View File

@@ -7,7 +7,10 @@ use crate::types::MessageId;
use tdlib_rs::enums::{MessageContent, MessageSender}; use tdlib_rs::enums::{MessageContent, MessageSender};
use tdlib_rs::types::Message as TdMessage; 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 /// Извлекает текст контента из TDLib Message
/// ///
@@ -95,9 +98,9 @@ pub async fn extract_sender_name(msg: &TdMessage, client_id: i32) -> String {
match &msg.sender_id { match &msg.sender_id {
MessageSender::User(user) => { MessageSender::User(user) => {
match tdlib_rs::functions::get_user(user.user_id, client_id).await { match tdlib_rs::functions::get_user(user.user_id, client_id).await {
Ok(tdlib_rs::enums::User::User(u)) => { Ok(tdlib_rs::enums::User::User(u)) => format!("{} {}", u.first_name, u.last_name)
format!("{} {}", u.first_name, u.last_name).trim().to_string() .trim()
} .to_string(),
_ => format!("User {}", user.user_id), _ => format!("User {}", user.user_id),
} }
} }
@@ -155,12 +158,7 @@ pub fn extract_media_info(msg: &TdMessage) -> Option<MediaInfo> {
PhotoDownloadState::NotDownloaded PhotoDownloadState::NotDownloaded
}; };
Some(MediaInfo::Photo(PhotoInfo { Some(MediaInfo::Photo(PhotoInfo { file_id, width, height, download_state }))
file_id,
width,
height,
download_state,
}))
} }
MessageContent::MessageVoiceNote(v) => { MessageContent::MessageVoiceNote(v) => {
let file_id = v.voice_note.voice.id; 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}; use super::types::{ForwardInfo, MessageInfo, ReactionInfo, ReplyInfo};
/// Конвертирует TDLib сообщение в MessageInfo /// Конвертирует TDLib сообщение в MessageInfo
pub fn convert_message( pub fn convert_message(client: &mut TdClient, message: &TdMessage, chat_id: ChatId) -> MessageInfo {
client: &mut TdClient,
message: &TdMessage,
chat_id: ChatId,
) -> MessageInfo {
let sender_name = match &message.sender_id { let sender_name = match &message.sender_id {
tdlib_rs::enums::MessageSender::User(user) => { tdlib_rs::enums::MessageSender::User(user) => {
// Пробуем получить имя из кеша (get обновляет LRU порядок) // Пробуем получить имя из кеша (get обновляет LRU порядок)
@@ -138,12 +134,7 @@ pub fn extract_reply_info(client: &TdClient, message: &TdMessage) -> Option<Repl
.quote .quote
.as_ref() .as_ref()
.map(|q| q.text.text.clone()) .map(|q| q.text.text.clone())
.or_else(|| { .or_else(|| reply.content.as_ref().map(TdClient::extract_content_text))
reply
.content
.as_ref()
.map(TdClient::extract_content_text)
})
.unwrap_or_else(|| { .unwrap_or_else(|| {
// Пробуем найти в текущих сообщениях // Пробуем найти в текущих сообщениях
client client
@@ -154,11 +145,7 @@ pub fn extract_reply_info(client: &TdClient, message: &TdMessage) -> Option<Repl
.unwrap_or_default() .unwrap_or_default()
}); });
Some(ReplyInfo { Some(ReplyInfo { message_id: reply_msg_id, sender_name, text })
message_id: reply_msg_id,
sender_name,
text,
})
} }
_ => None, _ => 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 let msg_data: std::collections::HashMap<i64, (String, String)> = client
.current_chat_messages() .current_chat_messages()
.iter() .iter()
.map(|m| { .map(|m| (m.id().as_i64(), (m.sender_name().to_string(), m.text().to_string())))
(
m.id().as_i64(),
(m.sender_name().to_string(), m.text().to_string()),
)
})
.collect(); .collect();
// Обновляем reply_to для сообщений с неполными данными // Обновляем reply_to для сообщений с неполными данными

View File

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

View File

@@ -95,7 +95,8 @@ impl MessageManager {
// Ограничиваем размер списка (удаляем старые с начала) // Ограничиваем размер списка (удаляем старые с начала)
if self.current_chat_messages.len() > MAX_MESSAGES_IN_CHAT { 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::constants::TDLIB_MESSAGE_LIMIT;
use crate::types::{ChatId, MessageId}; 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::functions;
use tdlib_rs::types::{FormattedText, InputMessageReplyToMessage, InputMessageText, TextParseModeMarkdown}; use tdlib_rs::types::{
FormattedText, InputMessageReplyToMessage, InputMessageText, TextParseModeMarkdown,
};
use tokio::time::{sleep, Duration}; use tokio::time::{sleep, Duration};
use crate::tdlib::types::{MessageInfo, ReplyInfo}; use crate::tdlib::types::{MessageInfo, ReplyInfo};
@@ -103,9 +107,10 @@ impl MessageManager {
// Если это первая загрузка и получили мало сообщений - продолжаем попытки // Если это первая загрузка и получили мало сообщений - продолжаем попытки
// TDLib может подгружать данные с сервера постепенно // TDLib может подгружать данные с сервера постепенно
if all_messages.is_empty() && if all_messages.is_empty()
received_count < (chunk_size as usize) && && received_count < (chunk_size as usize)
attempt < max_attempts_per_chunk { && attempt < max_attempts_per_chunk
{
// Даём TDLib время на синхронизацию с сервером // Даём TDLib время на синхронизацию с сервером
sleep(Duration::from_millis(100)).await; sleep(Duration::from_millis(100)).await;
continue; continue;
@@ -233,7 +238,10 @@ impl MessageManager {
/// let pinned = msg_manager.get_pinned_messages(chat_id).await?; /// let pinned = msg_manager.get_pinned_messages(chat_id).await?;
/// println!("Found {} pinned messages", pinned.len()); /// 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( let result = functions::search_chat_messages(
chat_id.as_i64(), chat_id.as_i64(),
String::new(), String::new(),
@@ -381,15 +389,9 @@ impl MessageManager {
.await .await
{ {
Ok(tdlib_rs::enums::FormattedText::FormattedText(ft)) => { Ok(tdlib_rs::enums::FormattedText::FormattedText(ft)) => {
FormattedText { FormattedText { text: ft.text, entities: ft.entities }
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 { let content = InputMessageContent::InputMessageText(InputMessageText {
@@ -460,15 +462,9 @@ impl MessageManager {
.await .await
{ {
Ok(tdlib_rs::enums::FormattedText::FormattedText(ft)) => { Ok(tdlib_rs::enums::FormattedText::FormattedText(ft)) => {
FormattedText { FormattedText { text: ft.text, entities: ft.entities }
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 { let content = InputMessageContent::InputMessageText(InputMessageText {
@@ -477,8 +473,13 @@ impl MessageManager {
clear_draft: true, clear_draft: true,
}); });
let result = let result = functions::edit_message_text(
functions::edit_message_text(chat_id.as_i64(), message_id.as_i64(), content, self.client_id).await; chat_id.as_i64(),
message_id.as_i64(),
content,
self.client_id,
)
.await;
match result { match result {
Ok(tdlib_rs::enums::Message::Message(msg)) => self Ok(tdlib_rs::enums::Message::Message(msg)) => self
@@ -509,7 +510,8 @@ impl MessageManager {
) -> Result<(), String> { ) -> Result<(), String> {
let message_ids_i64: Vec<i64> = message_ids.into_iter().map(|id| id.as_i64()).collect(); let message_ids_i64: Vec<i64> = message_ids.into_iter().map(|id| id.as_i64()).collect();
let result = 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 { match result {
Ok(_) => Ok(()), Ok(_) => Ok(()),
Err(e) => Err(format!("Ошибка удаления: {:?}", e)), Err(e) => Err(format!("Ошибка удаления: {:?}", e)),
@@ -577,17 +579,15 @@ impl MessageManager {
reply_to: None, reply_to: None,
date: 0, date: 0,
input_message_text: InputMessageContent::InputMessageText(InputMessageText { input_message_text: InputMessageContent::InputMessageText(InputMessageText {
text: FormattedText { text: FormattedText { text: text.clone(), entities: vec![] },
text: text.clone(),
entities: vec![],
},
link_preview_options: None, link_preview_options: None,
clear_draft: false, 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 { match result {
Ok(_) => Ok(()), Ok(_) => Ok(()),
@@ -612,7 +612,8 @@ impl MessageManager {
for (chat_id, message_ids) in batch { for (chat_id, message_ids) in batch {
let ids: Vec<i64> = message_ids.iter().map(|id| id.as_i64()).collect(); 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 chats;
pub mod client; pub mod client;
mod client_impl; // Private module for trait implementation 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_conversion; // Message conversion utilities (for messages.rs)
mod message_converter; // Message conversion utilities (for client.rs)
pub mod messages; pub mod messages;
pub mod reactions; pub mod reactions;
pub mod r#trait; pub mod r#trait;

View File

@@ -69,7 +69,8 @@ impl ReactionManager {
message_id: MessageId, message_id: MessageId,
) -> Result<Vec<String>, String> { ) -> 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 { let _msg = match msg_result {
Ok(m) => m, Ok(m) => m,
Err(e) => return Err(format!("Ошибка получения сообщения: {:?}", e)), Err(e) => return Err(format!("Ошибка получения сообщения: {:?}", e)),

View File

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

View File

@@ -179,11 +179,7 @@ impl MessageInfo {
edit_date, edit_date,
media_album_id: 0, media_album_id: 0,
}, },
content: MessageContent { content: MessageContent { text: content, entities, media: None },
text: content,
entities,
media: None,
},
state: MessageState { state: MessageState {
is_outgoing, is_outgoing,
is_read, is_read,
@@ -191,11 +187,7 @@ impl MessageInfo {
can_be_deleted_only_for_self, can_be_deleted_only_for_self,
can_be_deleted_for_all_users, can_be_deleted_for_all_users,
}, },
interactions: MessageInteractions { interactions: MessageInteractions { reply_to, forward_from, reactions },
reply_to,
forward_from,
reactions,
},
} }
} }
@@ -251,10 +243,7 @@ impl MessageInfo {
/// Checks if the message contains a mention (@username or user mention) /// Checks if the message contains a mention (@username or user mention)
pub fn has_mention(&self) -> bool { pub fn has_mention(&self) -> bool {
self.content.entities.iter().any(|entity| { self.content.entities.iter().any(|entity| {
matches!( matches!(entity.r#type, TextEntityType::Mention | TextEntityType::MentionName(_))
entity.r#type,
TextEntityType::Mention | TextEntityType::MentionName(_)
)
}) })
} }
@@ -500,7 +489,6 @@ impl MessageBuilder {
} }
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -568,9 +556,7 @@ mod tests {
#[test] #[test]
fn test_message_builder_with_reactions() { fn test_message_builder_with_reactions() {
let reaction = ReactionInfo { let reaction = ReactionInfo {
emoji: "👍".to_string(), emoji: "👍".to_string(), count: 5, is_chosen: true
count: 5,
is_chosen: true,
}; };
let message = MessageBuilder::new(MessageId::new(300)) let message = MessageBuilder::new(MessageId::new(300))
@@ -628,9 +614,9 @@ mod tests {
.entities(vec![TextEntity { .entities(vec![TextEntity {
offset: 6, offset: 6,
length: 4, length: 4,
r#type: TextEntityType::MentionName( r#type: TextEntityType::MentionName(tdlib_rs::types::TextEntityTypeMentionName {
tdlib_rs::types::TextEntityTypeMentionName { user_id: 123 }, user_id: 123,
), }),
}]) }])
.build(); .build();
assert!(message_with_mention_name.has_mention()); assert!(message_with_mention_name.has_mention());

View File

@@ -5,12 +5,10 @@
use crate::types::{ChatId, MessageId, UserId}; use crate::types::{ChatId, MessageId, UserId};
use std::time::Instant; use std::time::Instant;
use tdlib_rs::enums::{ use tdlib_rs::enums::{AuthorizationState, ChatAction, ChatList, MessageSender};
AuthorizationState, ChatAction, ChatList, MessageSender,
};
use tdlib_rs::types::{ use tdlib_rs::types::{
UpdateChatAction, UpdateChatDraftMessage, UpdateChatPosition, UpdateChatAction, UpdateChatDraftMessage, UpdateChatPosition, UpdateMessageInteractionInfo,
UpdateMessageInteractionInfo, UpdateMessageSendSucceeded, UpdateNewMessage, UpdateUser, UpdateMessageSendSucceeded, UpdateNewMessage, UpdateUser,
}; };
use super::auth::AuthState; 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() { if Some(chat_id) != client.current_chat_id() {
// Find and clone chat info to avoid borrow checker issues // Find and clone chat info to avoid borrow checker issues
if let Some(chat) = client.chats().iter().find(|c| c.id == chat_id).cloned() { 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) // Get sender name (from message or user cache)
let sender_name = msg_info.sender_name(); let sender_name = msg_info.sender_name();
// Send notification // Send notification
let _ = client.notification_manager.notify_new_message( let _ = client
&chat, .notification_manager
&msg_info, .notify_new_message(&chat, &msg_info, sender_name);
sender_name,
);
} }
return; 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 msg_id = msg_info.id();
let is_incoming = !msg_info.is_outgoing(); 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()); client.push_message(msg_info.clone());
// Если это входящее сообщение — добавляем в очередь для отметки как прочитанное // Если это входящее сообщение — добавляем в очередь для отметки как прочитанное
if is_incoming { 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 { } else {
format!("{} {}", user.first_name, user.last_name) 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) // Сохраняем username если есть (с упрощённым извлечением через and_then)
if let Some(username) = user.usernames if let Some(username) = user
.usernames
.as_ref() .as_ref()
.and_then(|u| u.active_usernames.first()) .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 в чатах, связанных с этим пользователем // Обновляем username в чатах, связанных с этим пользователем
for (&chat_id, &user_id) in &client.user_cache.chat_user_ids.clone() { for (&chat_id, &user_id) in &client.user_cache.chat_user_ids.clone() {
if user_id == UserId::new(user.id) { 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 из старого сообщения (если было) // Сохраняем reply_info из старого сообщения (если было)
let old_reply = client.current_chat_messages()[idx] 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); 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 { match functions::get_user(user_id.as_i64(), self.client_id).await {
Ok(User::User(user)) => { 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 name
} }
_ => format!("User {}", user_id.as_i64()), _ => format!("User {}", user_id.as_i64()),
@@ -257,8 +261,7 @@ impl UserCache {
} }
Err(_) => { Err(_) => {
// Если не удалось загрузить, сохраняем placeholder // Если не удалось загрузить, сохраняем placeholder
self.user_names self.user_names.insert(user_id, format!("User {}", user_id));
.insert(user_id, format!("User {}", user_id));
} }
} }
} }

View File

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

View File

@@ -1,7 +1,7 @@
//! Chat list panel: search box, chat items, and user online status. //! 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::methods::{compose::ComposeMethods, search::SearchMethods};
use crate::app::App;
use crate::tdlib::TdClientTrait; use crate::tdlib::TdClientTrait;
use crate::tdlib::UserOnlineStatus; use crate::tdlib::UserOnlineStatus;
use crate::ui::components; 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 app.selected_chat_id
} else { } else {
let filtered = app.get_filtered_chats(); 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 { 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)), 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 x = area.x + (area.width.saturating_sub(modal_width)) / 2;
let y = area.y + (area.height.saturating_sub(modal_height)) / 2; let y = area.y + (area.height.saturating_sub(modal_height)) / 2;
let modal_area = Rect::new( let modal_area = Rect::new(x, y, modal_width.min(area.width), modal_height.min(area.height));
x,
y,
modal_width.min(area.width),
modal_height.min(area.height),
);
// Очищаем область под модалкой // Очищаем область под модалкой
f.render_widget(Clear, modal_area); f.render_widget(Clear, modal_area);
@@ -87,10 +82,7 @@ pub fn render_emoji_picker(
.add_modifier(Modifier::BOLD), .add_modifier(Modifier::BOLD),
), ),
Span::raw("Добавить "), Span::raw("Добавить "),
Span::styled( Span::styled(" [Esc] ", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)),
" [Esc] ",
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
),
Span::raw("Отмена"), Span::raw("Отмена"),
])); ]));

View File

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

View File

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

View File

@@ -1,17 +1,17 @@
//! Reusable UI components: message bubbles, input fields, modals, lists. //! 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 input_field;
pub mod message_bubble; pub mod message_bubble;
pub mod message_list; pub mod message_list;
pub mod chat_list_item; pub mod modal;
pub mod emoji_picker;
// Экспорт основных функций // Экспорт основных функций
pub use input_field::render_input_field;
pub use chat_list_item::render_chat_list_item; pub use chat_list_item::render_chat_list_item;
pub use emoji_picker::render_emoji_picker; 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")] #[cfg(feature = "images")]
pub use message_bubble::{DeferredImageRender, calculate_image_height, render_album_bubble}; pub use message_bubble::{calculate_image_height, render_album_bubble, DeferredImageRender};
pub use message_list::{render_message_item, calculate_scroll_offset, render_help_bar}; 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::raw(" "), Span::raw(" "),
Span::styled( Span::styled(" [n/Esc] ", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)),
" [n/Esc] ",
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
),
Span::raw("Нет"), Span::raw("Нет"),
]), ]),
]; ];

View File

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

View File

@@ -1,7 +1,7 @@
use crate::app::App; use crate::app::App;
use crate::app::InputMode; use crate::app::InputMode;
use crate::tdlib::TdClientTrait;
use crate::tdlib::NetworkState; use crate::tdlib::NetworkState;
use crate::tdlib::TdClientTrait;
use ratatui::{ use ratatui::{
layout::Rect, layout::Rect,
style::{Color, Style}, 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 { } else if let Some(err) = &app.error_message {
format!(" {}{}Error: {} ", account_indicator, network_indicator, err) format!(" {}{}Error: {} ", account_indicator, network_indicator, err)
} else if app.is_searching { } 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() { } else if app.selected_chat_id.is_some() {
let mode_str = match app.input_mode { let mode_str = match app.input_mode {
InputMode::Normal => "[NORMAL] j/k: Nav | i: Insert | d/r/f/y: Actions | Esc: Close", 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 //! Renders message bubbles grouped by date/sender, pinned bar, and delegates
//! to modals (search, pinned, reactions, delete) and compose_bar. //! 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::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::message_grouping::{group_messages, MessageGroup};
use crate::tdlib::TdClientTrait;
use crate::ui::components; use crate::ui::components;
use crate::ui::{compose_bar, modals}; use crate::ui::{compose_bar, modals};
use ratatui::{ use ratatui::{
@@ -18,7 +18,12 @@ use ratatui::{
}; };
/// Рендерит заголовок чата с typing status /// Рендерит заголовок чата с 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 let typing_action = app
.td_client .td_client
.typing_status() .typing_status()
@@ -34,10 +39,7 @@ fn render_chat_header<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>,
.add_modifier(Modifier::BOLD), .add_modifier(Modifier::BOLD),
)]; )];
if let Some(username) = &chat.username { if let Some(username) = &chat.username {
spans.push(Span::styled( spans.push(Span::styled(format!(" {}", username), Style::default().fg(Color::Gray)));
format!(" {}", username),
Style::default().fg(Color::Gray),
));
} }
spans.push(Span::styled( spans.push(Span::styled(
format!(" {}", action), 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::raw(" ".repeat(padding)),
Span::styled(pinned_hint, Style::default().fg(Color::Gray)), Span::styled(pinned_hint, Style::default().fg(Color::Gray)),
]); ]);
let pinned_bar = let pinned_bar = Paragraph::new(pinned_line).style(Style::default().bg(Color::Rgb(40, 20, 40)));
Paragraph::new(pinned_line).style(Style::default().bg(Color::Rgb(40, 20, 40)));
f.render_widget(pinned_bar, area); f.render_widget(pinned_bar, area);
} }
@@ -104,9 +105,7 @@ pub(super) struct WrappedLine {
/// (используется только для search/pinned режимов, основной рендеринг через message_bubble) /// (используется только для search/pinned режимов, основной рендеринг через message_bubble)
pub(super) fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec<WrappedLine> { pub(super) fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec<WrappedLine> {
if max_width == 0 { if max_width == 0 {
return vec![WrappedLine { return vec![WrappedLine { text: text.to_string() }];
text: text.to_string(),
}];
} }
let mut result = Vec::new(); 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_line.push_str(&word);
current_width += 1 + word_width; current_width += 1 + word_width;
} else { } else {
result.push(WrappedLine { result.push(WrappedLine { text: current_line });
text: current_line,
});
current_line = word; current_line = word;
current_width = word_width; 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(' ');
current_line.push_str(&word); current_line.push_str(&word);
} else { } else {
result.push(WrappedLine { result.push(WrappedLine { text: current_line });
text: current_line,
});
current_line = word; current_line = word;
} }
} }
if !current_line.is_empty() { if !current_line.is_empty() {
result.push(WrappedLine { result.push(WrappedLine { text: current_line });
text: current_line,
});
} }
if result.is_empty() { if result.is_empty() {
result.push(WrappedLine { result.push(WrappedLine { text: String::new() });
text: String::new(),
});
} }
result 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_date = false;
is_first_sender = true; // Сбрасываем счётчик заголовков после даты is_first_sender = true; // Сбрасываем счётчик заголовков после даты
} }
MessageGroup::SenderHeader { MessageGroup::SenderHeader { is_outgoing, sender_name } => {
is_outgoing,
sender_name,
} => {
// Рендерим заголовок отправителя // Рендерим заголовок отправителя
lines.extend(components::render_sender_header( lines.extend(components::render_sender_header(
is_outgoing, is_outgoing,
@@ -240,9 +228,16 @@ fn render_message_list<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &mut Ap
// Собираем deferred image renders для всех загруженных фото // Собираем deferred image renders для всех загруженных фото
#[cfg(feature = "images")] #[cfg(feature = "images")]
if let Some(photo) = msg.photo_info() { if let Some(photo) = msg.photo_info() {
if let crate::tdlib::PhotoDownloadState::Downloaded(path) = &photo.download_state { if let crate::tdlib::PhotoDownloadState::Downloaded(path) =
let inline_width = content_width.min(crate::constants::INLINE_IMAGE_MAX_WIDTH); &photo.download_state
let img_height = components::calculate_image_height(photo.width, photo.height, inline_width); {
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 img_width = inline_width as u16;
let bubble_len = bubble_lines.len(); let bubble_len = bubble_lines.len();
let placeholder_start = lines.len() + bubble_len - img_height as usize; 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; use ratatui_image::StatefulImage;
// THROTTLING: Рендерим изображения максимум 15 FPS (каждые 66ms) // 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)) .map(|t| t.elapsed() > std::time::Duration::from_millis(66))
.unwrap_or(true); .unwrap_or(true);
@@ -487,14 +483,9 @@ pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &mut App<T>) {
} }
// Модалка выбора реакции // Модалка выбора реакции
if let crate::app::ChatState::ReactionPicker { if let crate::app::ChatState::ReactionPicker { available_reactions, selected_index, .. } =
available_reactions, &app.chat_state
selected_index,
..
} = &app.chat_state
{ {
modals::render_reaction_picker(f, area, available_reactions, *selected_index); modals::render_reaction_picker(f, area, available_reactions, *selected_index);
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,7 +2,7 @@
use crate::app::App; use crate::app::App;
use crate::tdlib::TdClientTrait; 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::{ use ratatui::{
layout::{Constraint, Direction, Layout, Rect}, layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style}, style::{Color, Modifier, Style},
@@ -14,10 +14,8 @@ use ratatui::{
/// Renders pinned messages mode /// Renders pinned messages mode
pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) { pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
// Извлекаем данные из ChatState // Извлекаем данные из ChatState
let (messages, selected_index) = if let crate::app::ChatState::PinnedMessages { let (messages, selected_index) =
messages, if let crate::app::ChatState::PinnedMessages { messages, selected_index } = &app.chat_state
selected_index,
} = &app.chat_state
{ {
(messages.as_slice(), *selected_index) (messages.as_slice(), *selected_index)
} else { } else {

View File

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

View File

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

View File

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

View File

@@ -6,6 +6,6 @@ pub mod validation;
pub use formatting::*; pub use formatting::*;
// pub use modal_handler::*; // Используется через явный import // 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 tdlib::*;
pub use validation::*; pub use validation::*;

View File

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

View File

@@ -17,11 +17,7 @@ fn test_open_account_switcher() {
assert!(app.account_switcher.is_some()); assert!(app.account_switcher.is_some());
match &app.account_switcher { match &app.account_switcher {
Some(AccountSwitcherState::SelectAccount { Some(AccountSwitcherState::SelectAccount { accounts, selected_index, current_account }) => {
accounts,
selected_index,
current_account,
}) => {
assert!(!accounts.is_empty()); assert!(!accounts.is_empty());
assert_eq!(*selected_index, 0); assert_eq!(*selected_index, 0);
assert_eq!(current_account, "default"); assert_eq!(current_account, "default");
@@ -58,11 +54,7 @@ fn test_account_switcher_navigate_down() {
} }
match &app.account_switcher { match &app.account_switcher {
Some(AccountSwitcherState::SelectAccount { Some(AccountSwitcherState::SelectAccount { selected_index, accounts, .. }) => {
selected_index,
accounts,
..
}) => {
// Should be at the "Add account" item (index == accounts.len()) // Should be at the "Add account" item (index == accounts.len())
assert_eq!(*selected_index, accounts.len()); assert_eq!(*selected_index, accounts.len());
} }
@@ -137,11 +129,7 @@ fn test_confirm_add_account_transitions_to_add_state() {
app.account_switcher_confirm(); app.account_switcher_confirm();
match &app.account_switcher { match &app.account_switcher {
Some(AccountSwitcherState::AddAccount { Some(AccountSwitcherState::AddAccount { name_input, cursor_position, error }) => {
name_input,
cursor_position,
error,
}) => {
assert!(name_input.is_empty()); assert!(name_input.is_empty());
assert_eq!(*cursor_position, 0); assert_eq!(*cursor_position, 0);
assert!(error.is_none()); assert!(error.is_none());

View File

@@ -1,8 +1,6 @@
// Integration tests for accounts module // Integration tests for accounts module
use tele_tui::accounts::{ use tele_tui::accounts::{account_db_path, validate_account_name, AccountProfile, AccountsConfig};
account_db_path, validate_account_name, AccountProfile, AccountsConfig,
};
#[test] #[test]
fn test_default_single_config() { fn test_default_single_config() {

View File

@@ -65,9 +65,7 @@ fn test_incoming_message_shows_unread_badge() {
.last_message("Как дела?") .last_message("Как дела?")
.build(); .build();
let mut app = TestAppBuilder::new() let mut app = TestAppBuilder::new().with_chat(chat).build();
.with_chat(chat)
.build();
// Рендерим UI - должно быть без "(1)" // Рендерим UI - должно быть без "(1)"
let buffer_before = render_to_buffer(80, 24, |f| { let buffer_before = render_to_buffer(80, 24, |f| {
@@ -89,7 +87,11 @@ fn test_incoming_message_shows_unread_badge() {
let output_after = buffer_to_string(&buffer_after); let output_after = buffer_to_string(&buffer_after);
// Проверяем что появилось "(1)" в первой строке чата // Проверяем что появилось "(1)" в первой строке чата
assert!(output_after.contains("(1)"), "After: should contain (1)\nActual output:\n{}", output_after); assert!(
output_after.contains("(1)"),
"After: should contain (1)\nActual output:\n{}",
output_after
);
} }
#[tokio::test] #[tokio::test]
@@ -129,7 +131,11 @@ async fn test_opening_chat_clears_unread_badge() {
let output_before = buffer_to_string(&buffer_before); let output_before = buffer_to_string(&buffer_before);
// Проверяем что есть "(3)" в списке чатов // Проверяем что есть "(3)" в списке чатов
assert!(output_before.contains("(3)"), "Before opening: should contain (3)\nActual output:\n{}", output_before); assert!(
output_before.contains("(3)"),
"Before opening: should contain (3)\nActual output:\n{}",
output_before
);
// Симулируем открытие чата - загружаем историю // Симулируем открытие чата - загружаем историю
let chat_id = ChatId::new(999); let chat_id = ChatId::new(999);
@@ -146,7 +152,8 @@ async fn test_opening_chat_clears_unread_badge() {
assert_eq!(incoming_message_ids.len(), 3, "Should have 3 incoming messages"); assert_eq!(incoming_message_ids.len(), 3, "Should have 3 incoming messages");
// Добавляем в очередь для отметки как прочитанные (напрямую через Mutex) // Добавляем в очередь для отметки как прочитанные (напрямую через Mutex)
app.td_client.pending_view_messages app.td_client
.pending_view_messages
.lock() .lock()
.unwrap() .unwrap()
.push((chat_id, incoming_message_ids)); .push((chat_id, incoming_message_ids));
@@ -171,7 +178,11 @@ async fn test_opening_chat_clears_unread_badge() {
let output_after = buffer_to_string(&buffer_after); let output_after = buffer_to_string(&buffer_after);
// Проверяем что "(3)" больше нет // Проверяем что "(3)" больше нет
assert!(!output_after.contains("(3)"), "After opening: should not contain (3)\nActual output:\n{}", output_after); assert!(
!output_after.contains("(3)"),
"After opening: should not contain (3)\nActual output:\n{}",
output_after
);
} }
#[tokio::test] #[tokio::test]
@@ -307,7 +318,11 @@ async fn test_chat_history_loads_all_without_limit() {
// Загружаем без лимита (i32::MAX) // Загружаем без лимита (i32::MAX)
let chat_id = ChatId::new(1001); let chat_id = ChatId::new(1001);
let all = app.td_client.get_chat_history(chat_id, i32::MAX).await.unwrap(); let all = app
.td_client
.get_chat_history(chat_id, i32::MAX)
.await
.unwrap();
assert_eq!(all.len(), 200, "Should load all 200 messages without limit"); assert_eq!(all.len(), 200, "Should load all 200 messages without limit");
assert_eq!(all[0].text(), "Msg 1", "First message should be oldest"); assert_eq!(all[0].text(), "Msg 1", "First message should be oldest");
@@ -355,7 +370,11 @@ async fn test_load_older_messages_pagination() {
let msg_101_id = all_messages[100].id(); // index 100 = Msg 101 let msg_101_id = all_messages[100].id(); // index 100 = Msg 101
// Загружаем сообщения старше 101 // Загружаем сообщения старше 101
let older_batch = app.td_client.load_older_messages(chat_id, msg_101_id).await.unwrap(); let older_batch = app
.td_client
.load_older_messages(chat_id, msg_101_id)
.await
.unwrap();
// Должны получить сообщения 1-100 (все что старше 101) // Должны получить сообщения 1-100 (все что старше 101)
assert_eq!(older_batch.len(), 100, "Should load 100 older messages"); assert_eq!(older_batch.len(), 100, "Should load 100 older messages");
@@ -493,4 +512,3 @@ fn snapshot_chat_with_online_status() {
let output = buffer_to_string(&buffer); let output = buffer_to_string(&buffer);
assert_snapshot!("chat_with_online_status", output); assert_snapshot!("chat_with_online_status", output);
} }

View File

@@ -1,6 +1,9 @@
// Integration tests for config flow // Integration tests for config flow
use tele_tui::config::{AudioConfig, Config, ColorsConfig, GeneralConfig, ImagesConfig, Keybindings, NotificationsConfig}; use tele_tui::config::{
AudioConfig, ColorsConfig, Config, GeneralConfig, ImagesConfig, Keybindings,
NotificationsConfig,
};
/// Test: Дефолтные значения конфигурации /// Test: Дефолтные значения конфигурации
#[test] #[test]
@@ -22,9 +25,7 @@ fn test_config_default_values() {
#[test] #[test]
fn test_config_custom_values() { fn test_config_custom_values() {
let config = Config { let config = Config {
general: GeneralConfig { general: GeneralConfig { timezone: "+05:00".to_string() },
timezone: "+05:00".to_string(),
},
colors: ColorsConfig { colors: ColorsConfig {
incoming_message: "cyan".to_string(), incoming_message: "cyan".to_string(),
outgoing_message: "blue".to_string(), outgoing_message: "blue".to_string(),
@@ -108,9 +109,7 @@ fn test_parse_color_case_insensitive() {
#[test] #[test]
fn test_config_toml_serialization() { fn test_config_toml_serialization() {
let original_config = Config { let original_config = Config {
general: GeneralConfig { general: GeneralConfig { timezone: "-05:00".to_string() },
timezone: "-05:00".to_string(),
},
colors: ColorsConfig { colors: ColorsConfig {
incoming_message: "cyan".to_string(), incoming_message: "cyan".to_string(),
outgoing_message: "blue".to_string(), outgoing_message: "blue".to_string(),
@@ -164,25 +163,19 @@ mod timezone_tests {
#[test] #[test]
fn test_timezone_formats() { fn test_timezone_formats() {
let positive = Config { let positive = Config {
general: GeneralConfig { general: GeneralConfig { timezone: "+03:00".to_string() },
timezone: "+03:00".to_string(),
},
..Default::default() ..Default::default()
}; };
assert_eq!(positive.general.timezone, "+03:00"); assert_eq!(positive.general.timezone, "+03:00");
let negative = Config { let negative = Config {
general: GeneralConfig { general: GeneralConfig { timezone: "-05:00".to_string() },
timezone: "-05:00".to_string(),
},
..Default::default() ..Default::default()
}; };
assert_eq!(negative.general.timezone, "-05:00"); assert_eq!(negative.general.timezone, "-05:00");
let zero = Config { let zero = Config {
general: GeneralConfig { general: GeneralConfig { timezone: "+00:00".to_string() },
timezone: "+00:00".to_string(),
},
..Default::default() ..Default::default()
}; };
assert_eq!(zero.general.timezone, "+00:00"); assert_eq!(zero.general.timezone, "+00:00");

View File

@@ -12,13 +12,19 @@ async fn test_delete_message_removes_from_list() {
let client = FakeTdClient::new(); let client = FakeTdClient::new();
// Отправляем сообщение // Отправляем сообщение
let msg = client.send_message(ChatId::new(123), "Delete me".to_string(), None, None).await.unwrap(); let msg = client
.send_message(ChatId::new(123), "Delete me".to_string(), None, None)
.await
.unwrap();
// Проверяем что сообщение есть // Проверяем что сообщение есть
assert_eq!(client.get_messages(123).len(), 1); assert_eq!(client.get_messages(123).len(), 1);
// Удаляем сообщение // Удаляем сообщение
client.delete_messages(ChatId::new(123), vec![msg.id()], false).await.unwrap(); client
.delete_messages(ChatId::new(123), vec![msg.id()], false)
.await
.unwrap();
// Проверяем что удаление записалось // Проверяем что удаление записалось
assert_eq!(client.get_deleted_messages().len(), 1); assert_eq!(client.get_deleted_messages().len(), 1);
@@ -34,15 +40,30 @@ async fn test_delete_multiple_messages() {
let client = FakeTdClient::new(); let client = FakeTdClient::new();
// Отправляем 3 сообщения // Отправляем 3 сообщения
let msg1 = client.send_message(ChatId::new(123), "Message 1".to_string(), None, None).await.unwrap(); let msg1 = client
let msg2 = client.send_message(ChatId::new(123), "Message 2".to_string(), None, None).await.unwrap(); .send_message(ChatId::new(123), "Message 1".to_string(), None, None)
let msg3 = client.send_message(ChatId::new(123), "Message 3".to_string(), None, None).await.unwrap(); .await
.unwrap();
let msg2 = client
.send_message(ChatId::new(123), "Message 2".to_string(), None, None)
.await
.unwrap();
let msg3 = client
.send_message(ChatId::new(123), "Message 3".to_string(), None, None)
.await
.unwrap();
assert_eq!(client.get_messages(123).len(), 3); assert_eq!(client.get_messages(123).len(), 3);
// Удаляем первое и третье // Удаляем первое и третье
client.delete_messages(ChatId::new(123), vec![msg1.id()], false).await.unwrap(); client
client.delete_messages(ChatId::new(123), vec![msg3.id()], false).await.unwrap(); .delete_messages(ChatId::new(123), vec![msg1.id()], false)
.await
.unwrap();
client
.delete_messages(ChatId::new(123), vec![msg3.id()], false)
.await
.unwrap();
// Проверяем историю удалений // Проверяем историю удалений
assert_eq!(client.get_deleted_messages().len(), 2); assert_eq!(client.get_deleted_messages().len(), 2);
@@ -89,12 +110,18 @@ async fn test_delete_nonexistent_message() {
let client = FakeTdClient::new(); let client = FakeTdClient::new();
// Отправляем одно сообщение // Отправляем одно сообщение
let msg = client.send_message(ChatId::new(123), "Exists".to_string(), None, None).await.unwrap(); let msg = client
.send_message(ChatId::new(123), "Exists".to_string(), None, None)
.await
.unwrap();
assert_eq!(client.get_messages(123).len(), 1); assert_eq!(client.get_messages(123).len(), 1);
// Пытаемся удалить несуществующее // Пытаемся удалить несуществующее
client.delete_messages(ChatId::new(123), vec![MessageId::new(999)], false).await.unwrap(); client
.delete_messages(ChatId::new(123), vec![MessageId::new(999)], false)
.await
.unwrap();
// Удаление записалось в историю // Удаление записалось в историю
assert_eq!(client.get_deleted_messages().len(), 1); assert_eq!(client.get_deleted_messages().len(), 1);
@@ -112,7 +139,10 @@ async fn test_delete_nonexistent_message() {
async fn test_delete_with_confirmation_flow() { async fn test_delete_with_confirmation_flow() {
let client = FakeTdClient::new(); let client = FakeTdClient::new();
let msg = client.send_message(ChatId::new(123), "To delete".to_string(), None, None).await.unwrap(); let msg = client
.send_message(ChatId::new(123), "To delete".to_string(), None, None)
.await
.unwrap();
// Шаг 1: Пользователь нажал 'd' -> показывается модалка (в App) // Шаг 1: Пользователь нажал 'd' -> показывается модалка (в App)
// В FakeTdClient просто проверяем что сообщение ещё есть // В FakeTdClient просто проверяем что сообщение ещё есть
@@ -120,7 +150,10 @@ async fn test_delete_with_confirmation_flow() {
assert_eq!(client.get_deleted_messages().len(), 0); assert_eq!(client.get_deleted_messages().len(), 0);
// Шаг 2: Пользователь подтвердил 'y' -> удаляем // Шаг 2: Пользователь подтвердил 'y' -> удаляем
client.delete_messages(ChatId::new(123), vec![msg.id()], false).await.unwrap(); client
.delete_messages(ChatId::new(123), vec![msg.id()], false)
.await
.unwrap();
// Проверяем что удалено // Проверяем что удалено
assert_eq!(client.get_messages(123).len(), 0); assert_eq!(client.get_messages(123).len(), 0);
@@ -132,7 +165,10 @@ async fn test_delete_with_confirmation_flow() {
async fn test_cancel_delete_keeps_message() { async fn test_cancel_delete_keeps_message() {
let client = FakeTdClient::new(); let client = FakeTdClient::new();
let msg = client.send_message(ChatId::new(123), "Keep me".to_string(), None, None).await.unwrap(); let msg = client
.send_message(ChatId::new(123), "Keep me".to_string(), None, None)
.await
.unwrap();
// Шаг 1: Пользователь нажал 'd' -> показалась модалка // Шаг 1: Пользователь нажал 'd' -> показалась модалка
assert_eq!(client.get_messages(123).len(), 1); assert_eq!(client.get_messages(123).len(), 1);

View File

@@ -3,8 +3,8 @@
mod helpers; mod helpers;
use helpers::test_data::{create_test_chat, TestChatBuilder}; use helpers::test_data::{create_test_chat, TestChatBuilder};
use tele_tui::types::{ChatId, MessageId};
use std::collections::HashMap; use std::collections::HashMap;
use tele_tui::types::{ChatId, MessageId};
/// Простая структура для хранения черновиков (как в реальном App) /// Простая структура для хранения черновиков (как в реальном App)
struct DraftManager { struct DraftManager {

View File

@@ -23,10 +23,7 @@ async fn test_user_journey_app_launch_to_chat_list() {
let chat2 = TestChatBuilder::new("Work Group", 102).build(); let chat2 = TestChatBuilder::new("Work Group", 102).build();
let chat3 = TestChatBuilder::new("Boss", 103).build(); let chat3 = TestChatBuilder::new("Boss", 103).build();
let client = client let client = client.with_chat(chat1).with_chat(chat2).with_chat(chat3);
.with_chat(chat1)
.with_chat(chat2)
.with_chat(chat3);
// 4. Симулируем загрузку чатов через load_chats // 4. Симулируем загрузку чатов через load_chats
let loaded_chats = client.load_chats(50).await.unwrap(); let loaded_chats = client.load_chats(50).await.unwrap();
@@ -58,9 +55,7 @@ async fn test_user_journey_open_chat_send_message() {
.outgoing() .outgoing()
.build(); .build();
let client = client let client = client.with_message(123, msg1).with_message(123, msg2);
.with_message(123, msg1)
.with_message(123, msg2);
// 3. Открываем чат // 3. Открываем чат
client.open_chat(ChatId::new(123)).await.unwrap(); client.open_chat(ChatId::new(123)).await.unwrap();
@@ -77,12 +72,10 @@ async fn test_user_journey_open_chat_send_message() {
assert_eq!(history[1].text(), "I'm good, thanks!"); assert_eq!(history[1].text(), "I'm good, thanks!");
// 7. Отправляем новое сообщение // 7. Отправляем новое сообщение
let _new_msg = client.send_message( let _new_msg = client
ChatId::new(123), .send_message(ChatId::new(123), "What's for dinner?".to_string(), None, None)
"What's for dinner?".to_string(), .await
None, .unwrap();
None
).await.unwrap();
// 8. Проверяем что сообщение отправлено // 8. Проверяем что сообщение отправлено
assert_eq!(client.get_sent_messages().len(), 1); assert_eq!(client.get_sent_messages().len(), 1);
@@ -153,34 +146,43 @@ async fn test_user_journey_multi_step_conversation() {
client.set_update_channel(tx); client.set_update_channel(tx);
// 4. Входящее сообщение от Alice // 4. Входящее сообщение от Alice
client.simulate_incoming_message(ChatId::new(789), "How's the project going?".to_string(), "Alice"); client.simulate_incoming_message(
ChatId::new(789),
"How's the project going?".to_string(),
"Alice",
);
// Проверяем update // Проверяем update
let update = rx.try_recv().ok(); let update = rx.try_recv().ok();
assert!(matches!(update, Some(TdUpdate::NewMessage { .. }))); assert!(matches!(update, Some(TdUpdate::NewMessage { .. })));
// 5. Отвечаем // 5. Отвечаем
client.send_message( client
.send_message(
ChatId::new(789), ChatId::new(789),
"Almost done! Just need to finish tests.".to_string(), "Almost done! Just need to finish tests.".to_string(),
None, None,
None None,
).await.unwrap(); )
.await
.unwrap();
// 6. Проверяем историю после первого обмена // 6. Проверяем историю после первого обмена
let history1 = client.get_chat_history(ChatId::new(789), 50).await.unwrap(); let history1 = client.get_chat_history(ChatId::new(789), 50).await.unwrap();
assert_eq!(history1.len(), 2); assert_eq!(history1.len(), 2);
// 7. Еще одно входящее сообщение // 7. Еще одно входящее сообщение
client.simulate_incoming_message(ChatId::new(789), "Great! Let me know if you need help.".to_string(), "Alice"); client.simulate_incoming_message(
ChatId::new(789),
"Great! Let me know if you need help.".to_string(),
"Alice",
);
// 8. Снова отвечаем // 8. Снова отвечаем
client.send_message( client
ChatId::new(789), .send_message(ChatId::new(789), "Will do, thanks!".to_string(), None, None)
"Will do, thanks!".to_string(), .await
None, .unwrap();
None
).await.unwrap();
// 9. Финальная проверка истории // 9. Финальная проверка истории
let final_history = client.get_chat_history(ChatId::new(789), 50).await.unwrap(); let final_history = client.get_chat_history(ChatId::new(789), 50).await.unwrap();
@@ -219,24 +221,20 @@ async fn test_user_journey_switch_chats() {
assert_eq!(client.get_current_chat_id(), Some(111)); assert_eq!(client.get_current_chat_id(), Some(111));
// 3. Отправляем сообщение в первом чате // 3. Отправляем сообщение в первом чате
client.send_message( client
ChatId::new(111), .send_message(ChatId::new(111), "Message in chat 1".to_string(), None, None)
"Message in chat 1".to_string(), .await
None, .unwrap();
None
).await.unwrap();
// 4. Переключаемся на второй чат // 4. Переключаемся на второй чат
client.open_chat(ChatId::new(222)).await.unwrap(); client.open_chat(ChatId::new(222)).await.unwrap();
assert_eq!(client.get_current_chat_id(), Some(222)); assert_eq!(client.get_current_chat_id(), Some(222));
// 5. Отправляем сообщение во втором чате // 5. Отправляем сообщение во втором чате
client.send_message( client
ChatId::new(222), .send_message(ChatId::new(222), "Message in chat 2".to_string(), None, None)
"Message in chat 2".to_string(), .await
None, .unwrap();
None
).await.unwrap();
// 6. Переключаемся на третий чат // 6. Переключаемся на третий чат
client.open_chat(ChatId::new(333)).await.unwrap(); client.open_chat(ChatId::new(333)).await.unwrap();
@@ -270,12 +268,10 @@ async fn test_user_journey_edit_during_conversation() {
client.open_chat(ChatId::new(555)).await.unwrap(); client.open_chat(ChatId::new(555)).await.unwrap();
// 2. Отправляем сообщение с опечаткой // 2. Отправляем сообщение с опечаткой
let msg = client.send_message( let msg = client
ChatId::new(555), .send_message(ChatId::new(555), "I'll be there at 5pm tomorow".to_string(), None, None)
"I'll be there at 5pm tomorow".to_string(), .await
None, .unwrap();
None
).await.unwrap();
// 3. Проверяем что сообщение отправлено // 3. Проверяем что сообщение отправлено
let history = client.get_chat_history(ChatId::new(555), 50).await.unwrap(); let history = client.get_chat_history(ChatId::new(555), 50).await.unwrap();
@@ -283,17 +279,19 @@ async fn test_user_journey_edit_during_conversation() {
assert_eq!(history[0].text(), "I'll be there at 5pm tomorow"); assert_eq!(history[0].text(), "I'll be there at 5pm tomorow");
// 4. Исправляем опечатку // 4. Исправляем опечатку
client.edit_message( client
ChatId::new(555), .edit_message(ChatId::new(555), msg.id(), "I'll be there at 5pm tomorrow".to_string())
msg.id(), .await
"I'll be there at 5pm tomorrow".to_string() .unwrap();
).await.unwrap();
// 5. Проверяем что сообщение отредактировано // 5. Проверяем что сообщение отредактировано
let edited_history = client.get_chat_history(ChatId::new(555), 50).await.unwrap(); let edited_history = client.get_chat_history(ChatId::new(555), 50).await.unwrap();
assert_eq!(edited_history.len(), 1); assert_eq!(edited_history.len(), 1);
assert_eq!(edited_history[0].text(), "I'll be there at 5pm tomorrow"); assert_eq!(edited_history[0].text(), "I'll be there at 5pm tomorrow");
assert!(edited_history[0].metadata.edit_date > 0, "Должна быть установлена дата редактирования"); assert!(
edited_history[0].metadata.edit_date > 0,
"Должна быть установлена дата редактирования"
);
// 6. Проверяем историю редактирований // 6. Проверяем историю редактирований
assert_eq!(client.get_edited_messages().len(), 1); assert_eq!(client.get_edited_messages().len(), 1);
@@ -315,7 +313,11 @@ async fn test_user_journey_reply_in_conversation() {
client.set_update_channel(tx); client.set_update_channel(tx);
// 3. Входящее сообщение с вопросом // 3. Входящее сообщение с вопросом
client.simulate_incoming_message(ChatId::new(666), "Can you send me the report?".to_string(), "Charlie"); client.simulate_incoming_message(
ChatId::new(666),
"Can you send me the report?".to_string(),
"Charlie",
);
let update = rx.try_recv().ok(); let update = rx.try_recv().ok();
assert!(matches!(update, Some(TdUpdate::NewMessage { .. }))); assert!(matches!(update, Some(TdUpdate::NewMessage { .. })));
@@ -324,12 +326,10 @@ async fn test_user_journey_reply_in_conversation() {
let question_msg_id = history[0].id(); let question_msg_id = history[0].id();
// 4. Отправляем другое сообщение (не связанное) // 4. Отправляем другое сообщение (не связанное)
client.send_message( client
ChatId::new(666), .send_message(ChatId::new(666), "Working on it now".to_string(), None, None)
"Working on it now".to_string(), .await
None, .unwrap();
None
).await.unwrap();
// 5. Отвечаем на конкретный вопрос (reply) // 5. Отвечаем на конкретный вопрос (reply)
let reply_info = Some(tele_tui::tdlib::ReplyInfo { let reply_info = Some(tele_tui::tdlib::ReplyInfo {
@@ -338,12 +338,15 @@ async fn test_user_journey_reply_in_conversation() {
text: "Can you send me the report?".to_string(), text: "Can you send me the report?".to_string(),
}); });
client.send_message( client
.send_message(
ChatId::new(666), ChatId::new(666),
"Sure, sending now!".to_string(), "Sure, sending now!".to_string(),
Some(question_msg_id), Some(question_msg_id),
reply_info reply_info,
).await.unwrap(); )
.await
.unwrap();
// 6. Проверяем что reply сохранён // 6. Проверяем что reply сохранён
let final_history = client.get_chat_history(ChatId::new(666), 50).await.unwrap(); let final_history = client.get_chat_history(ChatId::new(666), 50).await.unwrap();
@@ -376,12 +379,10 @@ async fn test_user_journey_network_state_changes() {
// 4. Открываем чат и отправляем сообщение // 4. Открываем чат и отправляем сообщение
client.open_chat(ChatId::new(888)).await.unwrap(); client.open_chat(ChatId::new(888)).await.unwrap();
client.send_message( client
ChatId::new(888), .send_message(ChatId::new(888), "Test message".to_string(), None, None)
"Test message".to_string(), .await
None, .unwrap();
None
).await.unwrap();
// Очищаем канал от update NewMessage // Очищаем канал от update NewMessage
let _ = rx.try_recv(); let _ = rx.try_recv();
@@ -391,8 +392,14 @@ async fn test_user_journey_network_state_changes() {
// Проверяем update // Проверяем update
let update = rx.try_recv().ok(); let update = rx.try_recv().ok();
assert!(matches!(update, Some(TdUpdate::ConnectionState { state: NetworkState::WaitingForNetwork })), assert!(
"Expected ConnectionState update, got: {:?}", update); matches!(
update,
Some(TdUpdate::ConnectionState { state: NetworkState::WaitingForNetwork })
),
"Expected ConnectionState update, got: {:?}",
update
);
// 6. Проверяем что состояние изменилось // 6. Проверяем что состояние изменилось
assert_eq!(client.get_network_state(), NetworkState::WaitingForNetwork); assert_eq!(client.get_network_state(), NetworkState::WaitingForNetwork);
@@ -405,12 +412,10 @@ async fn test_user_journey_network_state_changes() {
assert_eq!(client.get_network_state(), NetworkState::Ready); assert_eq!(client.get_network_state(), NetworkState::Ready);
// 8. Отправляем сообщение после восстановления // 8. Отправляем сообщение после восстановления
client.send_message( client
ChatId::new(888), .send_message(ChatId::new(888), "Connection restored!".to_string(), None, None)
"Connection restored!".to_string(), .await
None, .unwrap();
None
).await.unwrap();
// 9. Проверяем что оба сообщения в истории // 9. Проверяем что оба сообщения в истории
let history = client.get_chat_history(ChatId::new(888), 50).await.unwrap(); let history = client.get_chat_history(ChatId::new(888), 50).await.unwrap();

View File

@@ -12,10 +12,16 @@ async fn test_edit_message_changes_text() {
let client = FakeTdClient::new(); let client = FakeTdClient::new();
// Отправляем сообщение // Отправляем сообщение
let msg = client.send_message(ChatId::new(123), "Original text".to_string(), None, None).await.unwrap(); let msg = client
.send_message(ChatId::new(123), "Original text".to_string(), None, None)
.await
.unwrap();
// Редактируем сообщение // Редактируем сообщение
client.edit_message(ChatId::new(123), msg.id(), "Edited text".to_string()).await.unwrap(); client
.edit_message(ChatId::new(123), msg.id(), "Edited text".to_string())
.await
.unwrap();
// Проверяем что редактирование записалось // Проверяем что редактирование записалось
assert_eq!(client.get_edited_messages().len(), 1); assert_eq!(client.get_edited_messages().len(), 1);
@@ -34,7 +40,10 @@ async fn test_edit_message_sets_edit_date() {
let client = FakeTdClient::new(); let client = FakeTdClient::new();
// Отправляем сообщение // Отправляем сообщение
let msg = client.send_message(ChatId::new(123), "Original".to_string(), None, None).await.unwrap(); let msg = client
.send_message(ChatId::new(123), "Original".to_string(), None, None)
.await
.unwrap();
// Получаем дату до редактирования // Получаем дату до редактирования
let messages_before = client.get_messages(123); let messages_before = client.get_messages(123);
@@ -42,7 +51,10 @@ async fn test_edit_message_sets_edit_date() {
assert_eq!(messages_before[0].metadata.edit_date, 0); // Не редактировалось assert_eq!(messages_before[0].metadata.edit_date, 0); // Не редактировалось
// Редактируем сообщение // Редактируем сообщение
client.edit_message(ChatId::new(123), msg.id(), "Edited".to_string()).await.unwrap(); client
.edit_message(ChatId::new(123), msg.id(), "Edited".to_string())
.await
.unwrap();
// Проверяем что edit_date установлена // Проверяем что edit_date установлена
let messages_after = client.get_messages(123); let messages_after = client.get_messages(123);
@@ -78,16 +90,28 @@ async fn test_can_only_edit_own_messages() {
async fn test_multiple_edits_of_same_message() { async fn test_multiple_edits_of_same_message() {
let client = FakeTdClient::new(); let client = FakeTdClient::new();
let msg = client.send_message(ChatId::new(123), "Version 1".to_string(), None, None).await.unwrap(); let msg = client
.send_message(ChatId::new(123), "Version 1".to_string(), None, None)
.await
.unwrap();
// Первое редактирование // Первое редактирование
client.edit_message(ChatId::new(123), msg.id(), "Version 2".to_string()).await.unwrap(); client
.edit_message(ChatId::new(123), msg.id(), "Version 2".to_string())
.await
.unwrap();
// Второе редактирование // Второе редактирование
client.edit_message(ChatId::new(123), msg.id(), "Version 3".to_string()).await.unwrap(); client
.edit_message(ChatId::new(123), msg.id(), "Version 3".to_string())
.await
.unwrap();
// Третье редактирование // Третье редактирование
client.edit_message(ChatId::new(123), msg.id(), "Final version".to_string()).await.unwrap(); client
.edit_message(ChatId::new(123), msg.id(), "Final version".to_string())
.await
.unwrap();
// Проверяем что все 3 редактирования записаны // Проверяем что все 3 редактирования записаны
assert_eq!(client.get_edited_messages().len(), 3); assert_eq!(client.get_edited_messages().len(), 3);
@@ -107,7 +131,9 @@ async fn test_edit_nonexistent_message() {
let client = FakeTdClient::new(); let client = FakeTdClient::new();
// Пытаемся отредактировать несуществующее сообщение // Пытаемся отредактировать несуществующее сообщение
let result = client.edit_message(ChatId::new(123), MessageId::new(999), "New text".to_string()).await; let result = client
.edit_message(ChatId::new(123), MessageId::new(999), "New text".to_string())
.await;
// Должна вернуться ошибка // Должна вернуться ошибка
assert!(result.is_err()); assert!(result.is_err());
@@ -124,7 +150,10 @@ async fn test_edit_nonexistent_message() {
async fn test_edit_history_tracking() { async fn test_edit_history_tracking() {
let client = FakeTdClient::new(); let client = FakeTdClient::new();
let msg = client.send_message(ChatId::new(123), "Original".to_string(), None, None).await.unwrap(); let msg = client
.send_message(ChatId::new(123), "Original".to_string(), None, None)
.await
.unwrap();
// Симулируем начало редактирования -> изменение -> отмена // Симулируем начало редактирования -> изменение -> отмена
// Отменять на уровне FakeTdClient нельзя, но можно проверить что original сохранён // Отменять на уровне FakeTdClient нельзя, но можно проверить что original сохранён
@@ -134,14 +163,20 @@ async fn test_edit_history_tracking() {
let original = messages_before[0].text().to_string(); let original = messages_before[0].text().to_string();
// Редактируем // Редактируем
client.edit_message(ChatId::new(123), msg.id(), "Edited".to_string()).await.unwrap(); client
.edit_message(ChatId::new(123), msg.id(), "Edited".to_string())
.await
.unwrap();
// Проверяем что изменилось // Проверяем что изменилось
let messages_edited = client.get_messages(123); let messages_edited = client.get_messages(123);
assert_eq!(messages_edited[0].text(), "Edited"); assert_eq!(messages_edited[0].text(), "Edited");
// Можем "отменить" редактирование вернув original // Можем "отменить" редактирование вернув original
client.edit_message(ChatId::new(123), msg.id(), original).await.unwrap(); client
.edit_message(ChatId::new(123), msg.id(), original)
.await
.unwrap();
// Проверяем что вернулось // Проверяем что вернулось
let messages_restored = client.get_messages(123); let messages_restored = client.get_messages(123);

View File

@@ -1,8 +1,8 @@
// Test App builder // Test App builder
use super::FakeTdClient;
use ratatui::widgets::ListState; use ratatui::widgets::ListState;
use std::collections::HashMap; use std::collections::HashMap;
use super::FakeTdClient;
use tele_tui::app::{App, AppScreen, ChatState, InputMode}; use tele_tui::app::{App, AppScreen, ChatState, InputMode};
use tele_tui::config::Config; use tele_tui::config::Config;
use tele_tui::tdlib::AuthState; use tele_tui::tdlib::AuthState;
@@ -135,7 +135,8 @@ impl TestAppBuilder {
/// Подтверждение удаления /// Подтверждение удаления
pub fn delete_confirmation(mut self, message_id: i64) -> Self { pub fn delete_confirmation(mut self, message_id: i64) -> Self {
self.chat_state = Some(ChatState::DeleteConfirmation { message_id: MessageId::new(message_id) }); self.chat_state =
Some(ChatState::DeleteConfirmation { message_id: MessageId::new(message_id) });
self self
} }
@@ -181,9 +182,7 @@ impl TestAppBuilder {
/// Режим пересылки сообщения /// Режим пересылки сообщения
pub fn forward_mode(mut self, message_id: i64) -> Self { pub fn forward_mode(mut self, message_id: i64) -> Self {
self.chat_state = Some(ChatState::Forward { self.chat_state = Some(ChatState::Forward { message_id: MessageId::new(message_id) });
message_id: MessageId::new(message_id),
});
self self
} }

View File

@@ -2,22 +2,48 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use tele_tui::tdlib::{AuthState, ChatInfo, MessageInfo, NetworkState, ProfileInfo, ReplyInfo};
use tele_tui::tdlib::types::{FolderInfo, ReactionInfo}; use tele_tui::tdlib::types::{FolderInfo, ReactionInfo};
use tele_tui::tdlib::{AuthState, ChatInfo, MessageInfo, NetworkState, ProfileInfo, ReplyInfo};
use tele_tui::types::{ChatId, MessageId, UserId}; use tele_tui::types::{ChatId, MessageId, UserId};
use tokio::sync::mpsc; use tokio::sync::mpsc;
/// Update события от TDLib (упрощённая версия) /// Update события от TDLib (упрощённая версия)
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum TdUpdate { pub enum TdUpdate {
NewMessage { chat_id: ChatId, message: MessageInfo }, NewMessage {
MessageContent { chat_id: ChatId, message_id: MessageId, new_text: String }, chat_id: ChatId,
DeleteMessages { chat_id: ChatId, message_ids: Vec<MessageId> }, message: MessageInfo,
ChatAction { chat_id: ChatId, user_id: UserId, action: String }, },
MessageInteractionInfo { chat_id: ChatId, message_id: MessageId, reactions: Vec<ReactionInfo> }, MessageContent {
ConnectionState { state: NetworkState }, chat_id: ChatId,
ChatReadOutbox { chat_id: ChatId, last_read_outbox_message_id: MessageId }, message_id: MessageId,
ChatDraftMessage { chat_id: ChatId, draft_text: Option<String> }, new_text: String,
},
DeleteMessages {
chat_id: ChatId,
message_ids: Vec<MessageId>,
},
ChatAction {
chat_id: ChatId,
user_id: UserId,
action: String,
},
MessageInteractionInfo {
chat_id: ChatId,
message_id: MessageId,
reactions: Vec<ReactionInfo>,
},
ConnectionState {
state: NetworkState,
},
ChatReadOutbox {
chat_id: ChatId,
last_read_outbox_message_id: MessageId,
},
ChatDraftMessage {
chat_id: ChatId,
draft_text: Option<String>,
},
} }
/// Упрощённый mock TDLib клиента для тестов /// Упрощённый mock TDLib клиента для тестов
@@ -142,8 +168,14 @@ impl FakeTdClient {
profiles: Arc::new(Mutex::new(HashMap::new())), profiles: Arc::new(Mutex::new(HashMap::new())),
drafts: Arc::new(Mutex::new(HashMap::new())), drafts: Arc::new(Mutex::new(HashMap::new())),
available_reactions: Arc::new(Mutex::new(vec![ available_reactions: Arc::new(Mutex::new(vec![
"👍".to_string(), "❤️".to_string(), "😂".to_string(), "😮".to_string(), "👍".to_string(),
"😢".to_string(), "🙏".to_string(), "👏".to_string(), "🔥".to_string(), "❤️".to_string(),
"😂".to_string(),
"😮".to_string(),
"😢".to_string(),
"🙏".to_string(),
"👏".to_string(),
"🔥".to_string(),
])), ])),
network_state: Arc::new(Mutex::new(NetworkState::Ready)), network_state: Arc::new(Mutex::new(NetworkState::Ready)),
typing_chat_id: Arc::new(Mutex::new(None)), typing_chat_id: Arc::new(Mutex::new(None)),
@@ -205,16 +237,16 @@ impl FakeTdClient {
/// Добавить несколько сообщений в чат /// Добавить несколько сообщений в чат
pub fn with_messages(self, chat_id: i64, messages: Vec<MessageInfo>) -> Self { pub fn with_messages(self, chat_id: i64, messages: Vec<MessageInfo>) -> Self {
self.messages self.messages.lock().unwrap().insert(chat_id, messages);
.lock()
.unwrap()
.insert(chat_id, messages);
self self
} }
/// Добавить папку /// Добавить папку
pub fn with_folder(self, id: i32, name: &str) -> Self { pub fn with_folder(self, id: i32, name: &str) -> Self {
self.folders.lock().unwrap().push(FolderInfo { id, name: name.to_string() }); self.folders
.lock()
.unwrap()
.push(FolderInfo { id, name: name.to_string() });
self self
} }
@@ -244,7 +276,10 @@ impl FakeTdClient {
/// Добавить скачанный файл (для mock download_file) /// Добавить скачанный файл (для mock download_file)
pub fn with_downloaded_file(self, file_id: i32, path: &str) -> Self { pub fn with_downloaded_file(self, file_id: i32, path: &str) -> Self {
self.downloaded_files.lock().unwrap().insert(file_id, path.to_string()); self.downloaded_files
.lock()
.unwrap()
.insert(file_id, path.to_string());
self self
} }
@@ -266,7 +301,14 @@ impl FakeTdClient {
tokio::time::sleep(tokio::time::Duration::from_millis(50)).await; tokio::time::sleep(tokio::time::Duration::from_millis(50)).await;
} }
let chats = self.chats.lock().unwrap().iter().take(limit).cloned().collect(); let chats = self
.chats
.lock()
.unwrap()
.iter()
.take(limit)
.cloned()
.collect();
Ok(chats) Ok(chats)
} }
@@ -281,7 +323,11 @@ impl FakeTdClient {
} }
/// Получить историю чата /// Получить историю чата
pub async fn get_chat_history(&self, chat_id: ChatId, limit: i32) -> Result<Vec<MessageInfo>, String> { pub async fn get_chat_history(
&self,
chat_id: ChatId,
limit: i32,
) -> Result<Vec<MessageInfo>, String> {
if self.should_fail() { if self.should_fail() {
return Err("Failed to load history".to_string()); return Err("Failed to load history".to_string());
} }
@@ -290,7 +336,8 @@ impl FakeTdClient {
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
} }
let messages = self.messages let messages = self
.messages
.lock() .lock()
.unwrap() .unwrap()
.get(&chat_id.as_i64()) .get(&chat_id.as_i64())
@@ -301,7 +348,11 @@ impl FakeTdClient {
} }
/// Загрузить старые сообщения /// Загрузить старые сообщения
pub async fn load_older_messages(&self, chat_id: ChatId, from_message_id: MessageId) -> Result<Vec<MessageInfo>, String> { pub async fn load_older_messages(
&self,
chat_id: ChatId,
from_message_id: MessageId,
) -> Result<Vec<MessageInfo>, String> {
if self.should_fail() { if self.should_fail() {
return Err("Failed to load older messages".to_string()); return Err("Failed to load older messages".to_string());
} }
@@ -369,10 +420,7 @@ impl FakeTdClient {
.push(message.clone()); .push(message.clone());
// Отправляем Update::NewMessage // Отправляем Update::NewMessage
self.send_update(TdUpdate::NewMessage { self.send_update(TdUpdate::NewMessage { chat_id, message: message.clone() });
chat_id,
message: message.clone(),
});
Ok(message) Ok(message)
} }
@@ -409,11 +457,7 @@ impl FakeTdClient {
drop(messages); // Освобождаем lock перед отправкой update drop(messages); // Освобождаем lock перед отправкой update
// Отправляем Update // Отправляем Update
self.send_update(TdUpdate::MessageContent { self.send_update(TdUpdate::MessageContent { chat_id, message_id, new_text });
chat_id,
message_id,
new_text,
});
return Ok(updated); return Ok(updated);
} }
@@ -451,10 +495,7 @@ impl FakeTdClient {
drop(messages); drop(messages);
// Отправляем Update // Отправляем Update
self.send_update(TdUpdate::DeleteMessages { self.send_update(TdUpdate::DeleteMessages { chat_id, message_ids });
chat_id,
message_ids,
});
Ok(()) Ok(())
} }
@@ -474,7 +515,10 @@ impl FakeTdClient {
tokio::time::sleep(tokio::time::Duration::from_millis(150)).await; tokio::time::sleep(tokio::time::Duration::from_millis(150)).await;
} }
self.forwarded_messages.lock().unwrap().push(ForwardedMessages { self.forwarded_messages
.lock()
.unwrap()
.push(ForwardedMessages {
from_chat_id: from_chat_id.as_i64(), from_chat_id: from_chat_id.as_i64(),
to_chat_id: to_chat_id.as_i64(), to_chat_id: to_chat_id.as_i64(),
message_ids, message_ids,
@@ -484,7 +528,11 @@ impl FakeTdClient {
} }
/// Поиск сообщений в чате /// Поиск сообщений в чате
pub async fn search_messages(&self, chat_id: ChatId, query: &str) -> Result<Vec<MessageInfo>, String> { pub async fn search_messages(
&self,
chat_id: ChatId,
query: &str,
) -> Result<Vec<MessageInfo>, String> {
if self.should_fail() { if self.should_fail() {
return Err("Failed to search messages".to_string()); return Err("Failed to search messages".to_string());
} }
@@ -514,7 +562,10 @@ impl FakeTdClient {
if text.is_empty() { if text.is_empty() {
self.drafts.lock().unwrap().remove(&chat_id.as_i64()); self.drafts.lock().unwrap().remove(&chat_id.as_i64());
} else { } else {
self.drafts.lock().unwrap().insert(chat_id.as_i64(), text.clone()); self.drafts
.lock()
.unwrap()
.insert(chat_id.as_i64(), text.clone());
} }
self.send_update(TdUpdate::ChatDraftMessage { self.send_update(TdUpdate::ChatDraftMessage {
@@ -527,7 +578,10 @@ impl FakeTdClient {
/// Отправить действие в чате (typing, etc.) /// Отправить действие в чате (typing, etc.)
pub async fn send_chat_action(&self, chat_id: ChatId, action: String) { pub async fn send_chat_action(&self, chat_id: ChatId, action: String) {
self.chat_actions.lock().unwrap().push((chat_id.as_i64(), action.clone())); self.chat_actions
.lock()
.unwrap()
.push((chat_id.as_i64(), action.clone()));
if action == "Typing" { if action == "Typing" {
*self.typing_chat_id.lock().unwrap() = Some(chat_id.as_i64()); *self.typing_chat_id.lock().unwrap() = Some(chat_id.as_i64());
@@ -567,7 +621,10 @@ impl FakeTdClient {
let reactions = &mut msg.interactions.reactions; let reactions = &mut msg.interactions.reactions;
// Toggle logic // Toggle logic
if let Some(pos) = reactions.iter().position(|r| r.emoji == emoji && r.is_chosen) { if let Some(pos) = reactions
.iter()
.position(|r| r.emoji == emoji && r.is_chosen)
{
// Удаляем свою реакцию // Удаляем свою реакцию
reactions.remove(pos); reactions.remove(pos);
} else if let Some(reaction) = reactions.iter_mut().find(|r| r.emoji == emoji) { } else if let Some(reaction) = reactions.iter_mut().find(|r| r.emoji == emoji) {
@@ -703,11 +760,7 @@ impl FakeTdClient {
/// Симулировать typing от собеседника /// Симулировать typing от собеседника
pub fn simulate_typing(&self, chat_id: ChatId, user_id: UserId) { pub fn simulate_typing(&self, chat_id: ChatId, user_id: UserId) {
self.send_update(TdUpdate::ChatAction { self.send_update(TdUpdate::ChatAction { chat_id, user_id, action: "Typing".to_string() });
chat_id,
user_id,
action: "Typing".to_string(),
});
} }
/// Симулировать изменение состояния сети /// Симулировать изменение состояния сети
@@ -836,7 +889,9 @@ mod tests {
let client = FakeTdClient::new(); let client = FakeTdClient::new();
let chat_id = ChatId::new(123); let chat_id = ChatId::new(123);
let result = client.send_message(chat_id, "Hello".to_string(), None, None).await; let result = client
.send_message(chat_id, "Hello".to_string(), None, None)
.await;
assert!(result.is_ok()); assert!(result.is_ok());
let sent = client.get_sent_messages(); let sent = client.get_sent_messages();
@@ -850,10 +905,15 @@ mod tests {
let client = FakeTdClient::new(); let client = FakeTdClient::new();
let chat_id = ChatId::new(123); let chat_id = ChatId::new(123);
let msg = client.send_message(chat_id, "Hello".to_string(), None, None).await.unwrap(); let msg = client
.send_message(chat_id, "Hello".to_string(), None, None)
.await
.unwrap();
let msg_id = msg.id(); let msg_id = msg.id();
let _ = client.edit_message(chat_id, msg_id, "Hello World".to_string()).await; let _ = client
.edit_message(chat_id, msg_id, "Hello World".to_string())
.await;
let edited = client.get_edited_messages(); let edited = client.get_edited_messages();
assert_eq!(edited.len(), 1); assert_eq!(edited.len(), 1);
@@ -866,7 +926,10 @@ mod tests {
let client = FakeTdClient::new(); let client = FakeTdClient::new();
let chat_id = ChatId::new(123); let chat_id = ChatId::new(123);
let msg = client.send_message(chat_id, "Hello".to_string(), None, None).await.unwrap(); let msg = client
.send_message(chat_id, "Hello".to_string(), None, None)
.await
.unwrap();
let msg_id = msg.id(); let msg_id = msg.id();
let _ = client.delete_messages(chat_id, vec![msg_id], false).await; let _ = client.delete_messages(chat_id, vec![msg_id], false).await;
@@ -882,7 +945,9 @@ mod tests {
let chat_id = ChatId::new(123); let chat_id = ChatId::new(123);
// Отправляем сообщение // Отправляем сообщение
let _ = client.send_message(chat_id, "Test".to_string(), None, None).await; let _ = client
.send_message(chat_id, "Test".to_string(), None, None)
.await;
// Проверяем что получили Update // Проверяем что получили Update
if let Some(update) = rx.recv().await { if let Some(update) = rx.recv().await {
@@ -924,11 +989,15 @@ mod tests {
client.fail_next(); client.fail_next();
// Следующая операция должна упасть // Следующая операция должна упасть
let result = client.send_message(chat_id, "Test".to_string(), None, None).await; let result = client
.send_message(chat_id, "Test".to_string(), None, None)
.await;
assert!(result.is_err()); assert!(result.is_err());
// Но следующая должна пройти // Но следующая должна пройти
let result2 = client.send_message(chat_id, "Test2".to_string(), None, None).await; let result2 = client
.send_message(chat_id, "Test2".to_string(), None, None)
.await;
assert!(result2.is_ok()); assert!(result2.is_ok());
} }
} }

View File

@@ -4,8 +4,11 @@ use super::fake_tdclient::FakeTdClient;
use async_trait::async_trait; use async_trait::async_trait;
use std::path::PathBuf; use std::path::PathBuf;
use tdlib_rs::enums::{ChatAction, Update}; use tdlib_rs::enums::{ChatAction, Update};
use tele_tui::tdlib::{AuthState, ChatInfo, FolderInfo, MessageInfo, ProfileInfo, ReplyInfo, UserCache, UserOnlineStatus};
use tele_tui::tdlib::TdClientTrait; use tele_tui::tdlib::TdClientTrait;
use tele_tui::tdlib::{
AuthState, ChatInfo, FolderInfo, MessageInfo, ProfileInfo, ReplyInfo, UserCache,
UserOnlineStatus,
};
use tele_tui::types::{ChatId, MessageId, UserId}; use tele_tui::types::{ChatId, MessageId, UserId};
#[async_trait] #[async_trait]
@@ -55,11 +58,19 @@ impl TdClientTrait for FakeTdClient {
} }
// ============ Message methods ============ // ============ 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> {
FakeTdClient::get_chat_history(self, chat_id, limit).await FakeTdClient::get_chat_history(self, 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> {
FakeTdClient::load_older_messages(self, chat_id, from_message_id).await FakeTdClient::load_older_messages(self, chat_id, from_message_id).await
} }
@@ -72,7 +83,11 @@ impl TdClientTrait for FakeTdClient {
// Not implemented for fake // Not implemented for fake
} }
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> {
FakeTdClient::search_messages(self, chat_id, query).await FakeTdClient::search_messages(self, chat_id, query).await
} }
@@ -130,7 +145,10 @@ impl TdClientTrait for FakeTdClient {
let mut pending = self.pending_view_messages.lock().unwrap(); let mut pending = self.pending_view_messages.lock().unwrap();
for (chat_id, message_ids) in pending.drain(..) { for (chat_id, message_ids) in pending.drain(..) {
let ids: Vec<i64> = message_ids.iter().map(|id| id.as_i64()).collect(); let ids: Vec<i64> = message_ids.iter().map(|id| id.as_i64()).collect();
self.viewed_messages.lock().unwrap().push((chat_id.as_i64(), ids)); self.viewed_messages
.lock()
.unwrap()
.push((chat_id.as_i64(), ids));
} }
} }
@@ -193,9 +211,13 @@ impl TdClientTrait for FakeTdClient {
let current = self.auth_state.lock().unwrap(); let current = self.auth_state.lock().unwrap();
match *current { match *current {
AuthState::Ready => &AUTH_STATE_READY, AuthState::Ready => &AUTH_STATE_READY,
AuthState::WaitPhoneNumber => AUTH_STATE_WAIT_PHONE.get_or_init(|| AuthState::WaitPhoneNumber), AuthState::WaitPhoneNumber => {
AUTH_STATE_WAIT_PHONE.get_or_init(|| AuthState::WaitPhoneNumber)
}
AuthState::WaitCode => AUTH_STATE_WAIT_CODE.get_or_init(|| AuthState::WaitCode), AuthState::WaitCode => AUTH_STATE_WAIT_CODE.get_or_init(|| AuthState::WaitCode),
AuthState::WaitPassword => AUTH_STATE_WAIT_PASSWORD.get_or_init(|| AuthState::WaitPassword), AuthState::WaitPassword => {
AUTH_STATE_WAIT_PASSWORD.get_or_init(|| AuthState::WaitPassword)
}
_ => &AUTH_STATE_READY, _ => &AUTH_STATE_READY,
} }
} }

View File

@@ -1,7 +1,7 @@
// Test data builders and fixtures // Test data builders and fixtures
use tele_tui::tdlib::{ChatInfo, MessageInfo, ProfileInfo, ReplyInfo};
use tele_tui::tdlib::types::{ForwardInfo, ReactionInfo}; use tele_tui::tdlib::types::{ForwardInfo, ReactionInfo};
use tele_tui::tdlib::{ChatInfo, MessageInfo, ProfileInfo, ReplyInfo};
use tele_tui::types::{ChatId, MessageId}; use tele_tui::types::{ChatId, MessageId};
/// Builder для создания тестового чата /// Builder для создания тестового чата
@@ -177,9 +177,7 @@ impl TestMessageBuilder {
} }
pub fn forwarded_from(mut self, sender: &str) -> Self { pub fn forwarded_from(mut self, sender: &str) -> Self {
self.forward_from = Some(ForwardInfo { self.forward_from = Some(ForwardInfo { sender_name: sender.to_string() });
sender_name: sender.to_string(),
});
self self
} }

View File

@@ -292,7 +292,9 @@ async fn test_normal_mode_auto_enters_message_selection() {
#[tokio::test] #[tokio::test]
async fn test_album_navigation_skips_grouped_messages() { async fn test_album_navigation_skips_grouped_messages() {
let messages = vec![ let messages = vec![
TestMessageBuilder::new("Before album", 1).sender("Alice").build(), TestMessageBuilder::new("Before album", 1)
.sender("Alice")
.build(),
TestMessageBuilder::new("Photo 1", 2) TestMessageBuilder::new("Photo 1", 2)
.sender("Alice") .sender("Alice")
.media_album_id(100) .media_album_id(100)
@@ -305,7 +307,9 @@ async fn test_album_navigation_skips_grouped_messages() {
.sender("Alice") .sender("Alice")
.media_album_id(100) .media_album_id(100)
.build(), .build(),
TestMessageBuilder::new("After album", 5).sender("Alice").build(), TestMessageBuilder::new("After album", 5)
.sender("Alice")
.build(),
]; ];
let mut app = TestAppBuilder::new() let mut app = TestAppBuilder::new()
@@ -347,7 +351,9 @@ async fn test_album_navigation_skips_grouped_messages() {
#[tokio::test] #[tokio::test]
async fn test_album_navigation_start_at_album_end() { async fn test_album_navigation_start_at_album_end() {
let messages = vec![ let messages = vec![
TestMessageBuilder::new("Regular", 1).sender("Alice").build(), TestMessageBuilder::new("Regular", 1)
.sender("Alice")
.build(),
TestMessageBuilder::new("Album Photo 1", 2) TestMessageBuilder::new("Album Photo 1", 2)
.sender("Alice") .sender("Alice")
.media_album_id(200) .media_album_id(200)

View File

@@ -3,12 +3,12 @@
mod helpers; mod helpers;
use helpers::app_builder::TestAppBuilder; use helpers::app_builder::TestAppBuilder;
use tele_tui::tdlib::TdClientTrait;
use helpers::snapshot_utils::{buffer_to_string, render_to_buffer}; use helpers::snapshot_utils::{buffer_to_string, render_to_buffer};
use helpers::test_data::{ use helpers::test_data::{
create_test_chat, create_test_profile, TestChatBuilder, TestMessageBuilder, create_test_chat, create_test_profile, TestChatBuilder, TestMessageBuilder,
}; };
use insta::assert_snapshot; use insta::assert_snapshot;
use tele_tui::tdlib::TdClientTrait;
#[test] #[test]
fn snapshot_delete_confirmation_modal() { fn snapshot_delete_confirmation_modal() {
@@ -35,7 +35,16 @@ fn snapshot_emoji_picker_default() {
let chat = create_test_chat("Mom", 123); let chat = create_test_chat("Mom", 123);
let message = TestMessageBuilder::new("React to this", 1).build(); let message = TestMessageBuilder::new("React to this", 1).build();
let reactions = vec!["👍".to_string(), "👎".to_string(), "❤️".to_string(), "🔥".to_string(), "😊".to_string(), "😢".to_string(), "😮".to_string(), "🎉".to_string()]; let reactions = vec![
"👍".to_string(),
"👎".to_string(),
"❤️".to_string(),
"🔥".to_string(),
"😊".to_string(),
"😢".to_string(),
"😮".to_string(),
"🎉".to_string(),
];
let mut app = TestAppBuilder::new() let mut app = TestAppBuilder::new()
.with_chat(chat) .with_chat(chat)
@@ -57,7 +66,16 @@ fn snapshot_emoji_picker_with_selection() {
let chat = create_test_chat("Mom", 123); let chat = create_test_chat("Mom", 123);
let message = TestMessageBuilder::new("React to this", 1).build(); let message = TestMessageBuilder::new("React to this", 1).build();
let reactions = vec!["👍".to_string(), "👎".to_string(), "❤️".to_string(), "🔥".to_string(), "😊".to_string(), "😢".to_string(), "😮".to_string(), "🎉".to_string()]; let reactions = vec![
"👍".to_string(),
"👎".to_string(),
"❤️".to_string(),
"🔥".to_string(),
"😊".to_string(),
"😢".to_string(),
"😮".to_string(),
"🎉".to_string(),
];
let mut app = TestAppBuilder::new() let mut app = TestAppBuilder::new()
.with_chat(chat) .with_chat(chat)
@@ -160,7 +178,9 @@ fn snapshot_search_in_chat() {
.build(); .build();
// Устанавливаем результаты поиска // Устанавливаем результаты поиска
if let tele_tui::app::ChatState::SearchInChat { results, selected_index, .. } = &mut app.chat_state { if let tele_tui::app::ChatState::SearchInChat { results, selected_index, .. } =
&mut app.chat_state
{
*results = vec![msg1, msg2]; *results = vec![msg1, msg2];
*selected_index = 0; *selected_index = 0;
} }

View File

@@ -97,7 +97,9 @@ async fn test_typing_indicator_on() {
// Alice начала печатать в чате 123 // Alice начала печатать в чате 123
// Симулируем через send_chat_action // Симулируем через send_chat_action
client.send_chat_action(ChatId::new(123), "Typing".to_string()).await; client
.send_chat_action(ChatId::new(123), "Typing".to_string())
.await;
assert_eq!(*client.typing_chat_id.lock().unwrap(), Some(123)); assert_eq!(*client.typing_chat_id.lock().unwrap(), Some(123));
@@ -110,11 +112,15 @@ async fn test_typing_indicator_off() {
let client = FakeTdClient::new(); let client = FakeTdClient::new();
// Изначально Alice печатала // Изначально Alice печатала
client.send_chat_action(ChatId::new(123), "Typing".to_string()).await; client
.send_chat_action(ChatId::new(123), "Typing".to_string())
.await;
assert_eq!(*client.typing_chat_id.lock().unwrap(), Some(123)); assert_eq!(*client.typing_chat_id.lock().unwrap(), Some(123));
// Alice перестала печатать // Alice перестала печатать
client.send_chat_action(ChatId::new(123), "Cancel".to_string()).await; client
.send_chat_action(ChatId::new(123), "Cancel".to_string())
.await;
assert_eq!(*client.typing_chat_id.lock().unwrap(), None); assert_eq!(*client.typing_chat_id.lock().unwrap(), None);

View File

@@ -12,10 +12,16 @@ async fn test_add_reaction_to_message() {
let client = FakeTdClient::new(); let client = FakeTdClient::new();
// Отправляем сообщение // Отправляем сообщение
let msg = client.send_message(ChatId::new(123), "React to this!".to_string(), None, None).await.unwrap(); let msg = client
.send_message(ChatId::new(123), "React to this!".to_string(), None, None)
.await
.unwrap();
// Добавляем реакцию // Добавляем реакцию
client.toggle_reaction(ChatId::new(123), msg.id(), "👍".to_string()).await.unwrap(); client
.toggle_reaction(ChatId::new(123), msg.id(), "👍".to_string())
.await
.unwrap();
// Проверяем что реакция записалась // Проверяем что реакция записалась
let messages = client.get_messages(123); let messages = client.get_messages(123);
@@ -46,7 +52,10 @@ async fn test_toggle_reaction_removes_it() {
let msg_id = messages_before[0].id(); let msg_id = messages_before[0].id();
// Toggle - удаляем свою реакцию // Toggle - удаляем свою реакцию
client.toggle_reaction(ChatId::new(123), msg_id, "👍".to_string()).await.unwrap(); client
.toggle_reaction(ChatId::new(123), msg_id, "👍".to_string())
.await
.unwrap();
let messages_after = client.get_messages(123); let messages_after = client.get_messages(123);
assert_eq!(messages_after[0].reactions().len(), 0); assert_eq!(messages_after[0].reactions().len(), 0);
@@ -57,13 +66,28 @@ async fn test_toggle_reaction_removes_it() {
async fn test_multiple_reactions_on_one_message() { async fn test_multiple_reactions_on_one_message() {
let client = FakeTdClient::new(); let client = FakeTdClient::new();
let msg = client.send_message(ChatId::new(123), "Many reactions".to_string(), None, None).await.unwrap(); let msg = client
.send_message(ChatId::new(123), "Many reactions".to_string(), None, None)
.await
.unwrap();
// Добавляем несколько разных реакций // Добавляем несколько разных реакций
client.toggle_reaction(ChatId::new(123), msg.id(), "👍".to_string()).await.unwrap(); client
client.toggle_reaction(ChatId::new(123), msg.id(), "❤️".to_string()).await.unwrap(); .toggle_reaction(ChatId::new(123), msg.id(), "👍".to_string())
client.toggle_reaction(ChatId::new(123), msg.id(), "😂".to_string()).await.unwrap(); .await
client.toggle_reaction(ChatId::new(123), msg.id(), "🔥".to_string()).await.unwrap(); .unwrap();
client
.toggle_reaction(ChatId::new(123), msg.id(), "❤️".to_string())
.await
.unwrap();
client
.toggle_reaction(ChatId::new(123), msg.id(), "😂".to_string())
.await
.unwrap();
client
.toggle_reaction(ChatId::new(123), msg.id(), "🔥".to_string())
.await
.unwrap();
// Проверяем что все 4 реакции записались // Проверяем что все 4 реакции записались
let messages = client.get_messages(123); let messages = client.get_messages(123);
@@ -151,7 +175,10 @@ async fn test_reaction_counter_increases() {
let msg_id = messages_before[0].id(); let msg_id = messages_before[0].id();
// Мы добавляем свою реакцию - счётчик должен увеличиться // Мы добавляем свою реакцию - счётчик должен увеличиться
client.toggle_reaction(ChatId::new(123), msg_id, "👍".to_string()).await.unwrap(); client
.toggle_reaction(ChatId::new(123), msg_id, "👍".to_string())
.await
.unwrap();
let messages = client.get_messages(123); let messages = client.get_messages(123);
assert_eq!(messages[0].reactions()[0].count, 2); assert_eq!(messages[0].reactions()[0].count, 2);
@@ -177,7 +204,10 @@ async fn test_update_reaction_we_add_ours() {
let msg_id = messages_before[0].id(); let msg_id = messages_before[0].id();
// Добавляем нашу реакцию // Добавляем нашу реакцию
client.toggle_reaction(ChatId::new(123), msg_id, "🔥".to_string()).await.unwrap(); client
.toggle_reaction(ChatId::new(123), msg_id, "🔥".to_string())
.await
.unwrap();
let messages = client.get_messages(123); let messages = client.get_messages(123);
let reaction = &messages[0].reactions()[0]; let reaction = &messages[0].reactions()[0];

View File

@@ -4,8 +4,8 @@ mod helpers;
use helpers::fake_tdclient::FakeTdClient; use helpers::fake_tdclient::FakeTdClient;
use helpers::test_data::TestMessageBuilder; use helpers::test_data::TestMessageBuilder;
use tele_tui::tdlib::ReplyInfo;
use tele_tui::tdlib::types::ForwardInfo; use tele_tui::tdlib::types::ForwardInfo;
use tele_tui::tdlib::ReplyInfo;
use tele_tui::types::{ChatId, MessageId}; use tele_tui::types::{ChatId, MessageId};
/// Test: Reply создаёт сообщение с reply_to /// Test: Reply создаёт сообщение с reply_to
@@ -28,7 +28,15 @@ async fn test_reply_creates_message_with_reply_to() {
}; };
// Отвечаем на него // Отвечаем на него
let reply_msg = client.send_message(ChatId::new(123), "Answer!".to_string(), Some(MessageId::new(100)), Some(reply_info)).await.unwrap(); let reply_msg = client
.send_message(
ChatId::new(123),
"Answer!".to_string(),
Some(MessageId::new(100)),
Some(reply_info),
)
.await
.unwrap();
// Проверяем что ответ отправлен с reply_to // Проверяем что ответ отправлен с reply_to
assert_eq!(client.get_sent_messages().len(), 1); assert_eq!(client.get_sent_messages().len(), 1);
@@ -79,7 +87,10 @@ async fn test_cancel_reply_sends_without_reply_to() {
// Пользователь начал reply (r), потом отменил (Esc), затем отправил // Пользователь начал reply (r), потом отменил (Esc), затем отправил
// Это эмулируется отправкой без reply_to // Это эмулируется отправкой без reply_to
client.send_message(ChatId::new(123), "Regular message".to_string(), None, None).await.unwrap(); client
.send_message(ChatId::new(123), "Regular message".to_string(), None, None)
.await
.unwrap();
// Проверяем что отправилось без reply_to // Проверяем что отправилось без reply_to
assert_eq!(client.get_sent_messages()[0].reply_to, None); assert_eq!(client.get_sent_messages()[0].reply_to, None);
@@ -175,7 +186,15 @@ async fn test_reply_to_forwarded_message() {
}; };
// Отвечаем на пересланное сообщение // Отвечаем на пересланное сообщение
let reply_msg = client.send_message(ChatId::new(123), "Thanks for sharing!".to_string(), Some(MessageId::new(100)), Some(reply_info)).await.unwrap(); let reply_msg = client
.send_message(
ChatId::new(123),
"Thanks for sharing!".to_string(),
Some(MessageId::new(100)),
Some(reply_info),
)
.await
.unwrap();
// Проверяем что reply содержит reply_to // Проверяем что reply содержит reply_to
assert_eq!(client.get_sent_messages()[0].reply_to, Some(MessageId::new(100))); assert_eq!(client.get_sent_messages()[0].reply_to, Some(MessageId::new(100)));

View File

@@ -14,7 +14,10 @@ async fn test_send_text_message() {
let client = client.with_chat(chat); let client = client.with_chat(chat);
// Отправляем сообщение // Отправляем сообщение
let msg = client.send_message(ChatId::new(123), "Hello, Mom!".to_string(), None, None).await.unwrap(); let msg = client
.send_message(ChatId::new(123), "Hello, Mom!".to_string(), None, None)
.await
.unwrap();
// Проверяем что сообщение было отправлено // Проверяем что сообщение было отправлено
assert_eq!(client.get_sent_messages().len(), 1); assert_eq!(client.get_sent_messages().len(), 1);
@@ -36,13 +39,22 @@ async fn test_send_multiple_messages_updates_list() {
let client = FakeTdClient::new(); let client = FakeTdClient::new();
// Отправляем первое сообщение // Отправляем первое сообщение
let msg1 = client.send_message(ChatId::new(123), "Message 1".to_string(), None, None).await.unwrap(); let msg1 = client
.send_message(ChatId::new(123), "Message 1".to_string(), None, None)
.await
.unwrap();
// Отправляем второе сообщение // Отправляем второе сообщение
let msg2 = client.send_message(ChatId::new(123), "Message 2".to_string(), None, None).await.unwrap(); let msg2 = client
.send_message(ChatId::new(123), "Message 2".to_string(), None, None)
.await
.unwrap();
// Отправляем третье сообщение // Отправляем третье сообщение
let msg3 = client.send_message(ChatId::new(123), "Message 3".to_string(), None, None).await.unwrap(); let msg3 = client
.send_message(ChatId::new(123), "Message 3".to_string(), None, None)
.await
.unwrap();
// Проверяем что все 3 сообщения отслеживаются // Проверяем что все 3 сообщения отслеживаются
assert_eq!(client.get_sent_messages().len(), 3); assert_eq!(client.get_sent_messages().len(), 3);
@@ -66,7 +78,10 @@ async fn test_send_empty_message_technical() {
let client = FakeTdClient::new(); let client = FakeTdClient::new();
// FakeTdClient технически может отправить пустое сообщение // FakeTdClient технически может отправить пустое сообщение
let msg = client.send_message(ChatId::new(123), "".to_string(), None, None).await.unwrap(); let msg = client
.send_message(ChatId::new(123), "".to_string(), None, None)
.await
.unwrap();
// Проверяем что оно отправилось (в реальном App это должно фильтроваться) // Проверяем что оно отправилось (в реальном App это должно фильтроваться)
assert_eq!(client.get_sent_messages().len(), 1); assert_eq!(client.get_sent_messages().len(), 1);
@@ -85,7 +100,10 @@ async fn test_send_message_with_markdown() {
let client = FakeTdClient::new(); let client = FakeTdClient::new();
let text = "**Bold** *italic* `code`"; let text = "**Bold** *italic* `code`";
client.send_message(ChatId::new(123), text.to_string(), None, None).await.unwrap(); client
.send_message(ChatId::new(123), text.to_string(), None, None)
.await
.unwrap();
// Проверяем что текст сохранился как есть (парсинг markdown - отдельная логика) // Проверяем что текст сохранился как есть (парсинг markdown - отдельная логика)
let messages = client.get_messages(123); let messages = client.get_messages(123);
@@ -99,13 +117,22 @@ async fn test_send_messages_to_different_chats() {
let client = FakeTdClient::new(); let client = FakeTdClient::new();
// Отправляем в чат 123 // Отправляем в чат 123
client.send_message(ChatId::new(123), "Hello Mom".to_string(), None, None).await.unwrap(); client
.send_message(ChatId::new(123), "Hello Mom".to_string(), None, None)
.await
.unwrap();
// Отправляем в чат 456 // Отправляем в чат 456
client.send_message(ChatId::new(456), "Hello Boss".to_string(), None, None).await.unwrap(); client
.send_message(ChatId::new(456), "Hello Boss".to_string(), None, None)
.await
.unwrap();
// Отправляем ещё одно в чат 123 // Отправляем ещё одно в чат 123
client.send_message(ChatId::new(123), "How are you?".to_string(), None, None).await.unwrap(); client
.send_message(ChatId::new(123), "How are you?".to_string(), None, None)
.await
.unwrap();
// Проверяем общее количество отправленных // Проверяем общее количество отправленных
assert_eq!(client.get_sent_messages().len(), 3); assert_eq!(client.get_sent_messages().len(), 3);
@@ -128,7 +155,10 @@ async fn test_receive_incoming_message() {
let client = FakeTdClient::new(); let client = FakeTdClient::new();
// Добавляем существующее сообщение // Добавляем существующее сообщение
client.send_message(ChatId::new(123), "My outgoing".to_string(), None, None).await.unwrap(); client
.send_message(ChatId::new(123), "My outgoing".to_string(), None, None)
.await
.unwrap();
// Симулируем входящее сообщение от собеседника // Симулируем входящее сообщение от собеседника
let incoming_msg = TestMessageBuilder::new("Hey there!", 2000) let incoming_msg = TestMessageBuilder::new("Hey there!", 2000)

View File

@@ -12,9 +12,9 @@ mod helpers;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use helpers::app_builder::TestAppBuilder; use helpers::app_builder::TestAppBuilder;
use helpers::test_data::{create_test_chat, TestMessageBuilder}; use helpers::test_data::{create_test_chat, TestMessageBuilder};
use tele_tui::app::InputMode;
use tele_tui::app::methods::compose::ComposeMethods; use tele_tui::app::methods::compose::ComposeMethods;
use tele_tui::app::methods::messages::MessageMethods; use tele_tui::app::methods::messages::MessageMethods;
use tele_tui::app::InputMode;
use tele_tui::input::handle_main_input; use tele_tui::input::handle_main_input;
fn key(code: KeyCode) -> KeyEvent { fn key(code: KeyCode) -> KeyEvent {
@@ -32,9 +32,7 @@ fn ctrl_key(c: char) -> KeyEvent {
/// `i` в Normal mode → переход в Insert mode /// `i` в Normal mode → переход в Insert mode
#[tokio::test] #[tokio::test]
async fn test_i_enters_insert_mode() { async fn test_i_enters_insert_mode() {
let messages = vec![ let messages = vec![TestMessageBuilder::new("Hello", 1).outgoing().build()];
TestMessageBuilder::new("Hello", 1).outgoing().build(),
];
let mut app = TestAppBuilder::new() let mut app = TestAppBuilder::new()
.with_chats(vec![create_test_chat("Chat", 101)]) .with_chats(vec![create_test_chat("Chat", 101)])
.selected_chat(101) .selected_chat(101)
@@ -54,9 +52,7 @@ async fn test_i_enters_insert_mode() {
/// `ш` (русская i) в Normal mode → переход в Insert mode /// `ш` (русская i) в Normal mode → переход в Insert mode
#[tokio::test] #[tokio::test]
async fn test_russian_i_enters_insert_mode() { async fn test_russian_i_enters_insert_mode() {
let messages = vec![ let messages = vec![TestMessageBuilder::new("Hello", 1).outgoing().build()];
TestMessageBuilder::new("Hello", 1).outgoing().build(),
];
let mut app = TestAppBuilder::new() let mut app = TestAppBuilder::new()
.with_chats(vec![create_test_chat("Chat", 101)]) .with_chats(vec![create_test_chat("Chat", 101)])
.selected_chat(101) .selected_chat(101)
@@ -72,9 +68,7 @@ async fn test_russian_i_enters_insert_mode() {
/// Esc в Insert mode → Normal mode + MessageSelection /// Esc в Insert mode → Normal mode + MessageSelection
#[tokio::test] #[tokio::test]
async fn test_esc_exits_insert_mode() { async fn test_esc_exits_insert_mode() {
let messages = vec![ let messages = vec![TestMessageBuilder::new("Hello", 1).outgoing().build()];
TestMessageBuilder::new("Hello", 1).outgoing().build(),
];
let mut app = TestAppBuilder::new() let mut app = TestAppBuilder::new()
.with_chats(vec![create_test_chat("Chat", 101)]) .with_chats(vec![create_test_chat("Chat", 101)])
.selected_chat(101) .selected_chat(101)
@@ -127,9 +121,9 @@ async fn test_close_chat_resets_input_mode() {
/// Auto-Insert при Reply (`r` в MessageSelection) /// Auto-Insert при Reply (`r` в MessageSelection)
#[tokio::test] #[tokio::test]
async fn test_reply_auto_enters_insert_mode() { async fn test_reply_auto_enters_insert_mode() {
let messages = vec![ let messages = vec![TestMessageBuilder::new("Hello from friend", 1)
TestMessageBuilder::new("Hello from friend", 1).sender("Friend").build(), .sender("Friend")
]; .build()];
let mut app = TestAppBuilder::new() let mut app = TestAppBuilder::new()
.with_chats(vec![create_test_chat("Chat", 101)]) .with_chats(vec![create_test_chat("Chat", 101)])
.selected_chat(101) .selected_chat(101)
@@ -149,9 +143,7 @@ async fn test_reply_auto_enters_insert_mode() {
/// Auto-Insert при Edit (Enter в MessageSelection) /// Auto-Insert при Edit (Enter в MessageSelection)
#[tokio::test] #[tokio::test]
async fn test_edit_auto_enters_insert_mode() { async fn test_edit_auto_enters_insert_mode() {
let messages = vec![ let messages = vec![TestMessageBuilder::new("My message", 1).outgoing().build()];
TestMessageBuilder::new("My message", 1).outgoing().build(),
];
let mut app = TestAppBuilder::new() let mut app = TestAppBuilder::new()
.with_chats(vec![create_test_chat("Chat", 101)]) .with_chats(vec![create_test_chat("Chat", 101)])
.selected_chat(101) .selected_chat(101)
@@ -248,9 +240,7 @@ async fn test_k_types_in_insert_mode() {
/// `d` в Insert mode → набирает "d", НЕ удаляет сообщение /// `d` в Insert mode → набирает "d", НЕ удаляет сообщение
#[tokio::test] #[tokio::test]
async fn test_d_types_in_insert_mode() { async fn test_d_types_in_insert_mode() {
let messages = vec![ let messages = vec![TestMessageBuilder::new("Hello", 1).outgoing().build()];
TestMessageBuilder::new("Hello", 1).outgoing().build(),
];
let mut app = TestAppBuilder::new() let mut app = TestAppBuilder::new()
.with_chats(vec![create_test_chat("Chat", 101)]) .with_chats(vec![create_test_chat("Chat", 101)])
.selected_chat(101) .selected_chat(101)
@@ -268,9 +258,7 @@ async fn test_d_types_in_insert_mode() {
/// `r` в Insert mode → набирает "r", НЕ reply /// `r` в Insert mode → набирает "r", НЕ reply
#[tokio::test] #[tokio::test]
async fn test_r_types_in_insert_mode() { async fn test_r_types_in_insert_mode() {
let messages = vec![ let messages = vec![TestMessageBuilder::new("Hello", 1).build()];
TestMessageBuilder::new("Hello", 1).build(),
];
let mut app = TestAppBuilder::new() let mut app = TestAppBuilder::new()
.with_chats(vec![create_test_chat("Chat", 101)]) .with_chats(vec![create_test_chat("Chat", 101)])
.selected_chat(101) .selected_chat(101)
@@ -395,9 +383,7 @@ async fn test_k_navigates_in_normal_mode() {
/// `d` в Normal mode → показывает подтверждение удаления /// `d` в Normal mode → показывает подтверждение удаления
#[tokio::test] #[tokio::test]
async fn test_d_deletes_in_normal_mode() { async fn test_d_deletes_in_normal_mode() {
let messages = vec![ let messages = vec![TestMessageBuilder::new("My message", 1).outgoing().build()];
TestMessageBuilder::new("My message", 1).outgoing().build(),
];
let mut app = TestAppBuilder::new() let mut app = TestAppBuilder::new()
.with_chats(vec![create_test_chat("Chat", 101)]) .with_chats(vec![create_test_chat("Chat", 101)])
.selected_chat(101) .selected_chat(101)
@@ -488,9 +474,7 @@ async fn test_ctrl_e_moves_to_end_in_insert() {
/// Esc из Insert при активном Reply → отменяет reply + Normal + MessageSelection /// Esc из Insert при активном Reply → отменяет reply + Normal + MessageSelection
#[tokio::test] #[tokio::test]
async fn test_esc_from_insert_cancels_reply() { async fn test_esc_from_insert_cancels_reply() {
let messages = vec![ let messages = vec![TestMessageBuilder::new("Hello", 1).sender("Friend").build()];
TestMessageBuilder::new("Hello", 1).sender("Friend").build(),
];
let mut app = TestAppBuilder::new() let mut app = TestAppBuilder::new()
.with_chats(vec![create_test_chat("Chat", 101)]) .with_chats(vec![create_test_chat("Chat", 101)])
.selected_chat(101) .selected_chat(101)
@@ -512,9 +496,7 @@ async fn test_esc_from_insert_cancels_reply() {
/// Esc из Insert при активном Editing → отменяет editing + Normal + MessageSelection /// Esc из Insert при активном Editing → отменяет editing + Normal + MessageSelection
#[tokio::test] #[tokio::test]
async fn test_esc_from_insert_cancels_editing() { async fn test_esc_from_insert_cancels_editing() {
let messages = vec![ let messages = vec![TestMessageBuilder::new("My message", 1).outgoing().build()];
TestMessageBuilder::new("My message", 1).outgoing().build(),
];
let mut app = TestAppBuilder::new() let mut app = TestAppBuilder::new()
.with_chats(vec![create_test_chat("Chat", 101)]) .with_chats(vec![create_test_chat("Chat", 101)])
.selected_chat(101) .selected_chat(101)
@@ -564,9 +546,7 @@ async fn test_normal_mode_auto_enters_selection_on_any_key() {
/// Полный цикл: Normal → i → набор текста → Esc → Normal /// Полный цикл: Normal → i → набор текста → Esc → Normal
#[tokio::test] #[tokio::test]
async fn test_full_mode_cycle() { async fn test_full_mode_cycle() {
let messages = vec![ let messages = vec![TestMessageBuilder::new("Msg", 1).build()];
TestMessageBuilder::new("Msg", 1).build(),
];
let mut app = TestAppBuilder::new() let mut app = TestAppBuilder::new()
.with_chats(vec![create_test_chat("Chat", 101)]) .with_chats(vec![create_test_chat("Chat", 101)])
.selected_chat(101) .selected_chat(101)
@@ -599,9 +579,9 @@ async fn test_full_mode_cycle() {
/// Полный цикл: Normal → r (reply) → набор → Enter (отправка) → остаёмся в Insert /// Полный цикл: Normal → r (reply) → набор → Enter (отправка) → остаёмся в Insert
#[tokio::test] #[tokio::test]
async fn test_reply_send_stays_insert() { async fn test_reply_send_stays_insert() {
let messages = vec![ let messages = vec![TestMessageBuilder::new("Question?", 1)
TestMessageBuilder::new("Question?", 1).sender("Friend").build(), .sender("Friend")
]; .build()];
let mut app = TestAppBuilder::new() let mut app = TestAppBuilder::new()
.with_chats(vec![create_test_chat("Chat", 101)]) .with_chats(vec![create_test_chat("Chat", 101)])
.selected_chat(101) .selected_chat(101)