Compare commits

..

2 Commits

Author SHA1 Message Date
Mikhail Kilin
f8aab8232a fix: keep selection on last/first message instead of deselecting
Some checks 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
When pressing down on the last message or up on the first message in
chat navigation, stay on the current message instead of exiting
message selection mode.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 14:27:43 +03:00
Mikhail Kilin
3234607bcd feat: add per-account lock file protection via fs2
Prevent running multiple tele-tui instances with the same account by
using advisory file locks (flock). Lock is acquired before raw mode so
errors print to normal terminal. Account switching acquires new lock
before releasing old. Also log set_tdlib_parameters errors via tracing
instead of silently discarding them.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 14:23:30 +03:00
96 changed files with 1605 additions and 1779 deletions

50
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,50 @@
name: CI
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main, develop ]
env:
CARGO_TERM_COLOR: always
jobs:
check:
name: Check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- run: cargo check --all-features
fmt:
name: Format
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt
- run: cargo fmt --all -- --check
clippy:
name: Clippy
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy
- run: cargo clippy --all-features -- -D warnings
build:
name: Build
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- run: cargo build --release --all-features

View File

@@ -1,26 +0,0 @@
when:
- event: pull_request
steps:
- name: fmt
image: rust:latest
commands:
- rustup component add rustfmt
- cargo fmt -- --check
- name: clippy
image: rust:latest
environment:
CARGO_HOME: /tmp/cargo
commands:
- apt-get update -qq && apt-get install -y -qq pkg-config libssl-dev libdbus-1-dev zlib1g-dev > /dev/null 2>&1
- rustup component add clippy
- cargo clippy -- -D warnings
- name: test
image: rust:latest
environment:
CARGO_HOME: /tmp/cargo
commands:
- apt-get update -qq && apt-get install -y -qq pkg-config libssl-dev libdbus-1-dev zlib1g-dev > /dev/null 2>&1
- cargo test

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 tdlib_rs::enums::{TextEntity, TextEntityType};
use tele_tui::formatting::format_text_with_entities; use tele_tui::formatting::format_text_with_entities;
use tdlib_rs::enums::{TextEntity, TextEntityType};
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,7 +41,9 @@ 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(|| format_text_with_entities(black_box(&text), black_box(&entities))); b.iter(|| {
format_text_with_entities(black_box(&text), black_box(&entities))
});
}); });
} }
@@ -49,7 +51,9 @@ 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(|| format_text_with_entities(black_box(&text), black_box(&entities))); b.iter(|| {
format_text_with_entities(black_box(&text), black_box(&entities))
});
}); });
} }
@@ -73,7 +77,9 @@ 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(|| format_text_with_entities(black_box(&text), black_box(&entities))); b.iter(|| {
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_date, format_timestamp_with_tz, get_day}; use tele_tui::utils::formatting::{format_timestamp_with_tz, format_date, 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,5 +34,10 @@ fn benchmark_get_day(c: &mut Criterion) {
}); });
} }
criterion_group!(benches, benchmark_format_timestamp, benchmark_format_date, benchmark_get_day); criterion_group!(
benches,
benchmark_format_timestamp,
benchmark_format_date,
benchmark_get_day
);
criterion_main!(benches); criterion_main!(benches);

View File

@@ -8,10 +8,7 @@ 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!( .text(&format!("Test message number {} with some longer text to make it more realistic", i))
"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 {
@@ -27,7 +24,9 @@ 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(|| group_messages(black_box(&messages))); b.iter(|| {
group_messages(black_box(&messages))
});
}); });
} }
@@ -35,7 +34,9 @@ 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(|| group_messages(black_box(&messages))); b.iter(|| {
group_messages(black_box(&messages))
});
}); });
} }

View File

@@ -6,6 +6,15 @@ 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 = let config_path = accounts_config_path()
accounts_config_path().ok_or_else(|| "Could not determine config directory".to_string())?; .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,10 +111,17 @@ 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!("Migrated ./tdlib_data/ -> {}", target.display()); tracing::info!(
"Migrated ./tdlib_data/ -> {}",
target.display()
);
} }
Err(e) => { Err(e) => {
tracing::error!("Could not migrate ./tdlib_data/ to {}: {}", target.display(), e); tracing::error!(
"Could not migrate ./tdlib_data/ to {}: {}",
target.display(),
e
);
} }
} }
} }

View File

@@ -9,7 +9,5 @@ pub mod manager;
pub mod profile; pub mod profile;
pub use lock::{acquire_lock, release_lock}; pub use lock::{acquire_lock, release_lock};
#[allow(unused_imports)]
pub use manager::{add_account, ensure_account_dir, load_or_create, resolve_account, save}; pub use manager::{add_account, ensure_account_dir, load_or_create, resolve_account, save};
#[allow(unused_imports)]
pub use profile::{account_db_path, validate_account_name, AccountProfile, AccountsConfig}; pub use profile::{account_db_path, validate_account_name, AccountProfile, AccountsConfig};

View File

@@ -6,10 +6,10 @@
/// - По статусу (archived, muted, и т.д.) /// - По статусу (archived, muted, и т.д.)
/// ///
/// Используется как в App, так и в UI слое для консистентной фильтрации. /// Используется как в App, так и в UI слое для консистентной фильтрации.
use crate::tdlib::ChatInfo; use crate::tdlib::ChatInfo;
/// Критерии фильтрации чатов /// Критерии фильтрации чатов
#[allow(dead_code)]
#[derive(Debug, Clone, Default)] #[derive(Debug, Clone, Default)]
pub struct ChatFilterCriteria { pub struct ChatFilterCriteria {
/// Фильтр по папке (folder_id) /// Фильтр по папке (folder_id)
@@ -34,7 +34,6 @@ pub struct ChatFilterCriteria {
pub hide_archived: bool, pub hide_archived: bool,
} }
#[allow(dead_code)]
impl ChatFilterCriteria { impl ChatFilterCriteria {
/// Создаёт критерии с дефолтными значениями /// Создаёт критерии с дефолтными значениями
pub fn new() -> Self { pub fn new() -> Self {
@@ -43,12 +42,18 @@ impl ChatFilterCriteria {
/// Фильтр только по папке /// Фильтр только по папке
pub fn by_folder(folder_id: Option<i32>) -> Self { pub fn by_folder(folder_id: Option<i32>) -> Self {
Self { folder_id, ..Default::default() } Self {
folder_id,
..Default::default()
}
} }
/// Фильтр только по поисковому запросу /// Фильтр только по поисковому запросу
pub fn by_search(query: String) -> Self { pub fn by_search(query: String) -> Self {
Self { search_query: Some(query), ..Default::default() } Self {
search_query: Some(query),
..Default::default()
}
} }
/// Builder: установить папку /// Builder: установить папку
@@ -149,10 +154,8 @@ impl ChatFilterCriteria {
} }
/// Централизованный фильтр чатов /// Централизованный фильтр чатов
#[allow(dead_code)]
pub struct ChatFilter; pub struct ChatFilter;
#[allow(dead_code)]
impl ChatFilter { impl ChatFilter {
/// Фильтрует список чатов по критериям /// Фильтрует список чатов по критериям
/// ///
@@ -173,7 +176,10 @@ impl ChatFilter {
/// ///
/// let filtered = ChatFilter::filter(&all_chats, &criteria); /// let filtered = ChatFilter::filter(&all_chats, &criteria);
/// ``` /// ```
pub fn filter<'a>(chats: &'a [ChatInfo], criteria: &ChatFilterCriteria) -> Vec<&'a ChatInfo> { pub fn filter<'a>(
chats: &'a [ChatInfo],
criteria: &ChatFilterCriteria,
) -> Vec<&'a ChatInfo> {
chats.iter().filter(|chat| criteria.matches(chat)).collect() chats.iter().filter(|chat| criteria.matches(chat)).collect()
} }
@@ -303,7 +309,8 @@ 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().pinned_only(true); let criteria = ChatFilterCriteria::new()
.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
@@ -323,4 +330,5 @@ 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

@@ -14,10 +14,9 @@ pub enum InputMode {
} }
/// Состояния чата - взаимоисключающие режимы работы с чатом /// Состояния чата - взаимоисключающие режимы работы с чатом
#[derive(Debug, Clone, Default)] #[derive(Debug, Clone)]
pub enum ChatState { pub enum ChatState {
/// Обычный режим - просмотр сообщений, набор текста /// Обычный режим - просмотр сообщений, набор текста
#[default]
Normal, Normal,
/// Выбор сообщения для действия (edit/delete/reply/forward/reaction) /// Выбор сообщения для действия (edit/delete/reply/forward/reaction)
@@ -91,6 +90,12 @@ pub enum ChatState {
}, },
} }
impl Default for ChatState {
fn default() -> Self {
ChatState::Normal
}
}
impl ChatState { impl ChatState {
/// Проверка: находимся в режиме выбора сообщения /// Проверка: находимся в режиме выбора сообщения
pub fn is_message_selection(&self) -> bool { pub fn is_message_selection(&self) -> bool {

View File

@@ -2,8 +2,8 @@
//! //!
//! Handles reply, forward, and draft functionality //! Handles reply, forward, and draft functionality
use crate::app::methods::messages::MessageMethods;
use crate::app::{App, ChatState}; use crate::app::{App, ChatState};
use crate::app::methods::messages::MessageMethods;
use crate::tdlib::{MessageInfo, TdClientTrait}; use crate::tdlib::{MessageInfo, TdClientTrait};
/// Compose methods for reply/forward/draft /// Compose methods for reply/forward/draft
@@ -44,7 +44,9 @@ 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 { message_id: msg.id() }; self.chat_state = ChatState::Reply {
message_id: msg.id(),
};
return true; return true;
} }
false false
@@ -70,7 +72,9 @@ 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 { message_id: msg.id() }; self.chat_state = ChatState::Forward {
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,7 +61,8 @@ 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 && messages[new_index].media_album_id() == current_album_id while new_index > 0
&& messages[new_index].media_album_id() == current_album_id
{ {
new_index -= 1; new_index -= 1;
} }
@@ -120,9 +121,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 self.chat_state.selected_message_index().and_then(|idx| {
.selected_message_index() self.td_client.current_chat_messages().get(idx).cloned()
.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 {
@@ -153,7 +154,10 @@ 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 { message_id: id, selected_index: idx }; self.chat_state = ChatState::Editing {
message_id: id,
selected_index: idx,
};
return true; return true;
} }
false false

View File

@@ -7,19 +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 compose;
pub mod messages;
pub mod modal;
pub mod navigation; pub mod navigation;
pub mod messages;
pub mod compose;
pub mod search; pub mod search;
pub mod modal;
#[allow(unused_imports)]
pub use compose::ComposeMethods;
#[allow(unused_imports)]
pub use messages::MessageMethods;
#[allow(unused_imports)]
pub use modal::ModalMethods;
#[allow(unused_imports)]
pub use navigation::NavigationMethods; pub use navigation::NavigationMethods;
#[allow(unused_imports)] pub use messages::MessageMethods;
pub use compose::ComposeMethods;
pub use search::SearchMethods; pub use search::SearchMethods;
pub use modal::ModalMethods;

View File

@@ -106,7 +106,10 @@ 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 { messages, selected_index: 0 }; self.chat_state = ChatState::PinnedMessages {
messages,
selected_index: 0,
};
} }
} }
@@ -115,7 +118,11 @@ impl<T: TdClientTrait> ModalMethods<T> for App<T> {
} }
fn select_previous_pinned(&mut self) { fn select_previous_pinned(&mut self) {
if let ChatState::PinnedMessages { selected_index, messages } = &mut self.chat_state { if let ChatState::PinnedMessages {
selected_index,
messages,
} = &mut self.chat_state
{
if *selected_index + 1 < messages.len() { if *selected_index + 1 < messages.len() {
*selected_index += 1; *selected_index += 1;
} }
@@ -131,7 +138,11 @@ 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 { messages, selected_index } = &self.chat_state { if let ChatState::PinnedMessages {
messages,
selected_index,
} = &self.chat_state
{
messages.get(*selected_index) messages.get(*selected_index)
} else { } else {
None None
@@ -159,7 +170,10 @@ 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 { selected_action, .. } = &mut self.chat_state { if let ChatState::Profile {
selected_action, ..
} = &mut self.chat_state
{
if *selected_action > 0 { if *selected_action > 0 {
*selected_action -= 1; *selected_action -= 1;
} }
@@ -167,7 +181,10 @@ 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 { selected_action, .. } = &mut self.chat_state { if let ChatState::Profile {
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;
} }
@@ -175,25 +192,41 @@ 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 { leave_group_confirmation_step, .. } = &mut self.chat_state { if let ChatState::Profile {
leave_group_confirmation_step,
..
} = &mut self.chat_state
{
*leave_group_confirmation_step = 1; *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 { leave_group_confirmation_step, .. } = &mut self.chat_state { if let ChatState::Profile {
leave_group_confirmation_step,
..
} = &mut self.chat_state
{
*leave_group_confirmation_step = 2; *leave_group_confirmation_step = 2;
} }
} }
fn cancel_leave_group(&mut self) { fn cancel_leave_group(&mut self) {
if let ChatState::Profile { leave_group_confirmation_step, .. } = &mut self.chat_state { if let ChatState::Profile {
leave_group_confirmation_step,
..
} = &mut self.chat_state
{
*leave_group_confirmation_step = 0; *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 { leave_group_confirmation_step, .. } = &self.chat_state { if let ChatState::Profile {
leave_group_confirmation_step,
..
} = &self.chat_state
{
*leave_group_confirmation_step *leave_group_confirmation_step
} else { } else {
0 0
@@ -209,7 +242,10 @@ 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 { selected_action, .. } = &self.chat_state { if let ChatState::Profile {
selected_action, ..
} = &self.chat_state
{
Some(*selected_action) Some(*selected_action)
} else { } else {
None None
@@ -241,8 +277,11 @@ impl<T: TdClientTrait> ModalMethods<T> for App<T> {
} }
fn select_next_reaction(&mut self) { fn select_next_reaction(&mut self) {
if let ChatState::ReactionPicker { selected_index, available_reactions, .. } = if let ChatState::ReactionPicker {
&mut self.chat_state selected_index,
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;
@@ -251,8 +290,11 @@ 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 { available_reactions, selected_index, .. } = if let ChatState::ReactionPicker {
&self.chat_state available_reactions,
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::methods::search::SearchMethods;
use crate::app::{App, ChatState, InputMode}; use crate::app::{App, ChatState, InputMode};
use crate::app::methods::search::SearchMethods;
use crate::tdlib::TdClientTrait; use crate::tdlib::TdClientTrait;
/// Navigation methods for chat list /// Navigation methods for chat list

View File

@@ -51,11 +51,9 @@ pub trait SearchMethods<T: TdClientTrait> {
fn update_search_query(&mut self, new_query: String); fn update_search_query(&mut self, new_query: String);
/// Get index of selected search result /// Get index of selected search result
#[allow(dead_code)]
fn get_search_selected_index(&self) -> Option<usize>; fn get_search_selected_index(&self) -> Option<usize>;
/// Get all search results /// Get all search results
#[allow(dead_code)]
fn get_search_results(&self) -> Option<&[MessageInfo]>; fn get_search_results(&self) -> Option<&[MessageInfo]>;
} }
@@ -73,7 +71,8 @@ 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().with_folder(self.selected_folder_id); let mut criteria = ChatFilterCriteria::new()
.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());
@@ -114,7 +113,12 @@ 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 { selected_index, results, .. } = &mut self.chat_state { if let ChatState::SearchInChat {
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;
} }
@@ -122,7 +126,12 @@ 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 { results, selected_index, .. } = &self.chat_state { if let ChatState::SearchInChat {
results,
selected_index,
..
} = &self.chat_state
{
results.get(*selected_index) results.get(*selected_index)
} else { } else {
None None

View File

@@ -5,14 +5,13 @@
mod chat_filter; mod chat_filter;
mod chat_state; mod chat_state;
pub mod methods;
mod state; mod state;
pub mod methods;
pub use chat_filter::{ChatFilter, ChatFilterCriteria}; pub use chat_filter::{ChatFilter, ChatFilterCriteria};
pub use chat_state::{ChatState, InputMode}; pub use chat_state::{ChatState, InputMode};
#[allow(unused_imports)]
pub use methods::*;
pub use state::AppScreen; pub use state::AppScreen;
pub use methods::*;
use crate::accounts::AccountProfile; use crate::accounts::AccountProfile;
use crate::tdlib::{ChatInfo, TdClient, TdClientTrait}; use crate::tdlib::{ChatInfo, TdClient, TdClientTrait};
@@ -108,7 +107,6 @@ pub struct App<T: TdClientTrait = TdClient> {
/// Время последней отправки typing status (для throttling) /// Время последней отправки typing status (для throttling)
pub last_typing_sent: Option<std::time::Instant>, pub last_typing_sent: Option<std::time::Instant>,
// Image support // Image support
#[allow(dead_code)]
#[cfg(feature = "images")] #[cfg(feature = "images")]
pub image_cache: Option<crate::media::cache::ImageCache>, pub image_cache: Option<crate::media::cache::ImageCache>,
/// Renderer для inline preview в чате (Halfblocks - быстро) /// Renderer для inline preview в чате (Halfblocks - быстро)
@@ -150,7 +148,6 @@ pub struct App<T: TdClientTrait = TdClient> {
pub last_playback_tick: Option<std::time::Instant>, pub last_playback_tick: Option<std::time::Instant>,
} }
#[allow(dead_code)]
impl<T: TdClientTrait> App<T> { impl<T: TdClientTrait> App<T> {
/// Creates a new App instance with the given configuration and client. /// Creates a new App instance with the given configuration and client.
/// ///
@@ -171,7 +168,9 @@ 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(config.images.cache_size_mb)); let image_cache = Some(crate::media::cache::ImageCache::new(
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")]
@@ -281,8 +280,11 @@ 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 { accounts, selected_index, .. }) = if let Some(AccountSwitcherState::SelectAccount {
&mut self.account_switcher accounts,
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();
@@ -375,6 +377,20 @@ 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,13 +97,13 @@ 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).map_err(|e| format!("Failed to remove cached file: {}", e))?; fs::remove_file(&path)
.map_err(|e| format!("Failed to remove cached file: {}", e))?;
} }
Ok(()) Ok(())
} }
/// Clears all cached files /// Clears all cached files
#[allow(dead_code)]
pub fn clear(&mut self) -> Result<(), String> { pub fn clear(&mut self) -> Result<(), String> {
for (path, _, _) in self.files.values() { for (path, _, _) in self.files.values() {
let _ = fs::remove_file(path); // Ignore errors let _ = fs::remove_file(path); // Ignore errors

View File

@@ -58,8 +58,7 @@ 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("-loglevel").arg("quiet");
.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));
@@ -133,19 +132,19 @@ impl AudioPlayer {
.arg("-CONT") .arg("-CONT")
.arg(pid.to_string()) .arg(pid.to_string())
.output(); .output();
let _ = Command::new("kill").arg(pid.to_string()).output(); let _ = Command::new("kill")
.arg(pid.to_string())
.output();
} }
*self.paused.lock().unwrap() = false; *self.paused.lock().unwrap() = false;
} }
/// Returns true if a process is active (playing or paused) /// Returns true if a process is active (playing or paused)
#[allow(dead_code)]
pub fn is_playing(&self) -> bool { pub fn is_playing(&self) -> bool {
self.current_pid.lock().unwrap().is_some() && !*self.paused.lock().unwrap() self.current_pid.lock().unwrap().is_some() && !*self.paused.lock().unwrap()
} }
/// Returns true if paused /// Returns true if paused
#[allow(dead_code)]
pub fn is_paused(&self) -> bool { pub fn is_paused(&self) -> bool {
self.current_pid.lock().unwrap().is_some() && *self.paused.lock().unwrap() self.current_pid.lock().unwrap().is_some() && *self.paused.lock().unwrap()
} }
@@ -155,16 +154,13 @@ impl AudioPlayer {
self.current_pid.lock().unwrap().is_none() && !*self.starting.lock().unwrap() self.current_pid.lock().unwrap().is_none() && !*self.starting.lock().unwrap()
} }
#[allow(dead_code)]
pub fn set_volume(&self, _volume: f32) {} pub fn set_volume(&self, _volume: f32) {}
#[allow(dead_code)]
pub fn adjust_volume(&self, _delta: f32) {} pub fn adjust_volume(&self, _delta: f32) {}
pub fn volume(&self) -> f32 { pub fn volume(&self) -> f32 {
1.0 1.0
} }
#[allow(dead_code)]
pub fn seek(&self, _delta: Duration) -> Result<(), String> { pub fn seek(&self, _delta: Duration) -> Result<(), String> {
Err("Seeking not supported".to_string()) Err("Seeking not supported".to_string())
} }

View File

@@ -4,6 +4,7 @@
/// - Загрузку из конфигурационного файла /// - Загрузку из конфигурационного файла
/// - Множественные 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;
@@ -48,12 +49,12 @@ pub enum Command {
SelectMessage, SelectMessage,
// Media // Media
ViewImage, // v - просмотр фото ViewImage, // v - просмотр фото
// Voice playback // Voice playback
TogglePlayback, // Space - play/pause TogglePlayback, // Space - play/pause
SeekForward, // → - seek +5s SeekForward, // → - seek +5s
SeekBackward, // ← - seek -5s SeekBackward, // ← - seek -5s
// Input // Input
SubmitMessage, SubmitMessage,
@@ -82,21 +83,31 @@ pub struct KeyBinding {
impl KeyBinding { impl KeyBinding {
pub fn new(key: KeyCode) -> Self { pub fn new(key: KeyCode) -> Self {
Self { key, modifiers: KeyModifiers::NONE } Self {
key,
modifiers: KeyModifiers::NONE,
}
} }
pub fn with_ctrl(key: KeyCode) -> Self { pub fn with_ctrl(key: KeyCode) -> Self {
Self { key, modifiers: KeyModifiers::CONTROL } Self {
key,
modifiers: KeyModifiers::CONTROL,
}
} }
#[allow(dead_code)]
pub fn with_shift(key: KeyCode) -> Self { pub fn with_shift(key: KeyCode) -> Self {
Self { key, modifiers: KeyModifiers::SHIFT } Self {
key,
modifiers: KeyModifiers::SHIFT,
}
} }
#[allow(dead_code)]
pub fn with_alt(key: KeyCode) -> Self { pub fn with_alt(key: KeyCode) -> Self {
Self { key, modifiers: KeyModifiers::ALT } Self {
key,
modifiers: KeyModifiers::ALT,
}
} }
pub fn matches(&self, event: &KeyEvent) -> bool { pub fn matches(&self, event: &KeyEvent) -> bool {
@@ -112,81 +123,55 @@ pub struct Keybindings {
} }
impl Keybindings { impl Keybindings {
/// Ищет команду по клавише /// Создаёт дефолтную конфигурацию
pub fn get_command(&self, event: &KeyEvent) -> Option<Command> { pub fn default() -> Self {
for (command, bindings) in &self.bindings {
if bindings.iter().any(|binding| binding.matches(event)) {
return Some(*command);
}
}
None
}
}
impl Default for Keybindings {
fn default() -> Self {
let mut bindings = HashMap::new(); let mut bindings = HashMap::new();
// Navigation // Navigation
bindings.insert( bindings.insert(Command::MoveUp, vec![
Command::MoveUp, KeyBinding::new(KeyCode::Up),
vec![ KeyBinding::new(KeyCode::Char('k')),
KeyBinding::new(KeyCode::Up), KeyBinding::new(KeyCode::Char('р')), // RU (custom mapping, not standard ЙЦУКЕН)
KeyBinding::new(KeyCode::Char('k')), ]);
KeyBinding::new(KeyCode::Char('р')), // RU (custom mapping, not standard ЙЦУКЕН) bindings.insert(Command::MoveDown, vec![
], KeyBinding::new(KeyCode::Down),
); KeyBinding::new(KeyCode::Char('j')),
bindings.insert( KeyBinding::new(KeyCode::Char('о')), // RU
Command::MoveDown, ]);
vec![ bindings.insert(Command::MoveLeft, vec![
KeyBinding::new(KeyCode::Down), KeyBinding::new(KeyCode::Left),
KeyBinding::new(KeyCode::Char('j')), KeyBinding::new(KeyCode::Char('h')),
KeyBinding::new(KeyCode::Char('о')), // RU KeyBinding::new(KeyCode::Char('л')), // RU (custom mapping, not standard ЙЦУКЕН)
], ]);
); bindings.insert(Command::MoveRight, vec![
bindings.insert( KeyBinding::new(KeyCode::Right),
Command::MoveLeft, KeyBinding::new(KeyCode::Char('l')),
vec![ KeyBinding::new(KeyCode::Char('д')), // RU
KeyBinding::new(KeyCode::Left), ]);
KeyBinding::new(KeyCode::Char('h')), bindings.insert(Command::PageUp, vec![
KeyBinding::new(KeyCode::Char('л')), // RU (custom mapping, not standard ЙЦУКЕН) KeyBinding::new(KeyCode::PageUp),
], KeyBinding::with_ctrl(KeyCode::Char('u')),
); ]);
bindings.insert( bindings.insert(Command::PageDown, vec![
Command::MoveRight, KeyBinding::new(KeyCode::PageDown),
vec![ KeyBinding::with_ctrl(KeyCode::Char('d')),
KeyBinding::new(KeyCode::Right), ]);
KeyBinding::new(KeyCode::Char('l')),
KeyBinding::new(KeyCode::Char('д')), // RU
],
);
bindings.insert(
Command::PageUp,
vec![
KeyBinding::new(KeyCode::PageUp),
KeyBinding::with_ctrl(KeyCode::Char('u')),
],
);
bindings.insert(
Command::PageDown,
vec![
KeyBinding::new(KeyCode::PageDown),
KeyBinding::with_ctrl(KeyCode::Char('d')),
],
);
// Global // Global
bindings.insert( bindings.insert(Command::Quit, vec![
Command::Quit, KeyBinding::new(KeyCode::Char('q')),
vec![ KeyBinding::new(KeyCode::Char('й')), // RU
KeyBinding::new(KeyCode::Char('q')), KeyBinding::with_ctrl(KeyCode::Char('c')),
KeyBinding::new(KeyCode::Char('й')), // RU ]);
KeyBinding::with_ctrl(KeyCode::Char('c')), bindings.insert(Command::OpenSearch, vec![
], KeyBinding::with_ctrl(KeyCode::Char('s')),
); ]);
bindings.insert(Command::OpenSearch, vec![KeyBinding::with_ctrl(KeyCode::Char('s'))]); bindings.insert(Command::OpenSearchInChat, vec![
bindings.insert(Command::OpenSearchInChat, vec![KeyBinding::with_ctrl(KeyCode::Char('f'))]); KeyBinding::with_ctrl(KeyCode::Char('f')),
bindings.insert(Command::Help, vec![KeyBinding::new(KeyCode::Char('?'))]); ]);
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()
@@ -203,117 +188,109 @@ impl Default for Keybindings {
9 => Command::SelectFolder9, 9 => Command::SelectFolder9,
_ => unreachable!(), _ => unreachable!(),
}; };
bindings.insert( bindings.insert(cmd, vec![
cmd, KeyBinding::new(KeyCode::Char(char::from_digit(i, 10).unwrap())),
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( bindings.insert(Command::DeleteMessage, vec![
Command::DeleteMessage, KeyBinding::new(KeyCode::Delete),
vec![ KeyBinding::new(KeyCode::Char('d')),
KeyBinding::new(KeyCode::Delete), KeyBinding::new(KeyCode::Char('в')), // RU
KeyBinding::new(KeyCode::Char('d')), ]);
KeyBinding::new(KeyCode::Char('в')), // RU bindings.insert(Command::ReplyMessage, vec![
], KeyBinding::new(KeyCode::Char('r')),
); KeyBinding::new(KeyCode::Char('к')), // RU
bindings.insert( ]);
Command::ReplyMessage, bindings.insert(Command::ForwardMessage, vec![
vec![ KeyBinding::new(KeyCode::Char('f')),
KeyBinding::new(KeyCode::Char('r')), KeyBinding::new(KeyCode::Char('а')), // RU
KeyBinding::new(KeyCode::Char('к')), // RU ]);
], bindings.insert(Command::CopyMessage, vec![
); KeyBinding::new(KeyCode::Char('y')),
bindings.insert( KeyBinding::new(KeyCode::Char('н')), // RU
Command::ForwardMessage, ]);
vec![ bindings.insert(Command::ReactMessage, vec![
KeyBinding::new(KeyCode::Char('f')), KeyBinding::new(KeyCode::Char('e')),
KeyBinding::new(KeyCode::Char('а')), // RU KeyBinding::new(KeyCode::Char('у')), // RU
], ]);
);
bindings.insert(
Command::CopyMessage,
vec![
KeyBinding::new(KeyCode::Char('y')),
KeyBinding::new(KeyCode::Char('н')), // RU
],
);
bindings.insert(
Command::ReactMessage,
vec![
KeyBinding::new(KeyCode::Char('e')),
KeyBinding::new(KeyCode::Char('у')), // RU
],
);
// Note: SelectMessage обрабатывается через Command::SubmitMessage в handle_enter_key() // Note: SelectMessage обрабатывается через Command::SubmitMessage в handle_enter_key()
// Media // Media
bindings.insert( bindings.insert(Command::ViewImage, vec![
Command::ViewImage, KeyBinding::new(KeyCode::Char('v')),
vec![ KeyBinding::new(KeyCode::Char('м')), // RU
KeyBinding::new(KeyCode::Char('v')), ]);
KeyBinding::new(KeyCode::Char('м')), // RU
],
);
// Voice playback // Voice playback
bindings.insert(Command::TogglePlayback, vec![KeyBinding::new(KeyCode::Char(' '))]); bindings.insert(Command::TogglePlayback, vec![
bindings.insert(Command::SeekForward, vec![KeyBinding::new(KeyCode::Right)]); KeyBinding::new(KeyCode::Char(' ')),
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![KeyBinding::new(KeyCode::Enter)]); bindings.insert(Command::SubmitMessage, vec![
bindings.insert(Command::Cancel, vec![KeyBinding::new(KeyCode::Esc)]); KeyBinding::new(KeyCode::Enter),
]);
bindings.insert(Command::Cancel, vec![
KeyBinding::new(KeyCode::Esc),
]);
bindings.insert(Command::NewLine, vec![]); bindings.insert(Command::NewLine, vec![]);
bindings.insert(Command::DeleteChar, vec![KeyBinding::new(KeyCode::Backspace)]); bindings.insert(Command::DeleteChar, vec![
bindings.insert( KeyBinding::new(KeyCode::Backspace),
Command::DeleteWord, ]);
vec![ bindings.insert(Command::DeleteWord, 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( KeyBinding::new(KeyCode::Home),
Command::MoveToStart, KeyBinding::with_ctrl(KeyCode::Char('a')),
vec![ ]);
KeyBinding::new(KeyCode::Home), bindings.insert(Command::MoveToEnd, vec![
KeyBinding::with_ctrl(KeyCode::Char('a')), KeyBinding::new(KeyCode::End),
], KeyBinding::with_ctrl(KeyCode::Char('e')),
); ]);
bindings.insert(
Command::MoveToEnd,
vec![
KeyBinding::new(KeyCode::End),
KeyBinding::with_ctrl(KeyCode::Char('e')),
],
);
// Vim mode // Vim mode
bindings.insert( bindings.insert(Command::EnterInsertMode, vec![
Command::EnterInsertMode, KeyBinding::new(KeyCode::Char('i')),
vec![ KeyBinding::new(KeyCode::Char('ш')), // RU
KeyBinding::new(KeyCode::Char('i')), ]);
KeyBinding::new(KeyCode::Char('ш')), // RU
],
);
// Profile // Profile
bindings.insert( bindings.insert(Command::OpenProfile, vec![
Command::OpenProfile, KeyBinding::with_ctrl(KeyCode::Char('u')), // Ctrl+U instead of Ctrl+I
vec![ KeyBinding::with_ctrl(KeyCode::Char('г')), // RU
KeyBinding::with_ctrl(KeyCode::Char('u')), // Ctrl+U instead of Ctrl+I ]);
KeyBinding::with_ctrl(KeyCode::Char('г')), // RU
],
);
Self { bindings } Self { bindings }
} }
/// Ищет команду по клавише
pub fn get_command(&self, event: &KeyEvent) -> Option<Command> {
for (command, bindings) in &self.bindings {
if bindings.iter().any(|binding| binding.matches(event)) {
return Some(*command);
}
}
None
}
}
impl Default for Keybindings {
fn default() -> Self {
Self::default()
}
} }
/// Сериализация KeyModifiers /// Сериализация KeyModifiers
@@ -418,15 +395,14 @@ 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 let c = s.chars().nth(6).ok_or_else(|| {
.chars() serde::de::Error::custom("Invalid Char format")
.nth(6) })?;
.ok_or_else(|| serde::de::Error::custom("Invalid Char format"))?;
return Ok(KeyCode::Char(c)); return Ok(KeyCode::Char(c));
} }
if let Some(suffix) = s.strip_prefix("F") { if s.starts_with("F") {
let n = suffix.parse().map_err(serde::de::Error::custom)?; let n = s[1..].parse().map_err(serde::de::Error::custom)?;
return Ok(KeyCode::F(n)); return Ok(KeyCode::F(n));
} }

View File

@@ -26,7 +26,7 @@ pub use keybindings::{Command, Keybindings};
/// println!("Timezone: {}", config.general.timezone); /// println!("Timezone: {}", config.general.timezone);
/// println!("Incoming color: {}", config.colors.incoming_message); /// println!("Incoming color: {}", config.colors.incoming_message);
/// ``` /// ```
#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config { pub struct Config {
/// Общие настройки (timezone и т.д.). /// Общие настройки (timezone и т.д.).
#[serde(default)] #[serde(default)]
@@ -260,6 +260,19 @@ impl Default for NotificationsConfig {
} }
} }
impl Default for Config {
fn default() -> Self {
Self {
general: GeneralConfig::default(),
colors: ColorsConfig::default(),
keybindings: Keybindings::default(),
notifications: NotificationsConfig::default(),
images: ImagesConfig::default(),
audio: AudioConfig::default(),
}
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -271,22 +284,10 @@ 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!( assert!(keybindings.get_command(&KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE)) == Some(Command::ReplyMessage));
keybindings.get_command(&KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE)) assert!(keybindings.get_command(&KeyEvent::new(KeyCode::Char('к'), KeyModifiers::NONE)) == Some(Command::ReplyMessage));
== Some(Command::ReplyMessage) assert!(keybindings.get_command(&KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE)) == Some(Command::ForwardMessage));
); assert!(keybindings.get_command(&KeyEvent::new(KeyCode::Char('а'), KeyModifiers::NONE)) == Some(Command::ForwardMessage));
assert!(
keybindings.get_command(&KeyEvent::new(KeyCode::Char('к'), 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]
@@ -354,24 +355,10 @@ 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", "black", "red", "green", "yellow", "blue", "magenta",
"red", "cyan", "gray", "grey", "white", "darkgray", "darkgrey",
"green", "lightred", "lightgreen", "lightyellow", "lightblue",
"yellow", "lightmagenta", "lightcyan"
"blue",
"magenta",
"cyan",
"gray",
"grey",
"white",
"darkgray",
"darkgrey",
"lightred",
"lightgreen",
"lightyellow",
"lightblue",
"lightmagenta",
"lightcyan",
]; ];
for color in colors { for color in colors {
@@ -382,7 +369,11 @@ 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!(config.validate().is_ok(), "Color '{}' should be valid", color); assert!(
config.validate().is_ok(),
"Color '{}' should be valid",
color
);
} }
} }

View File

@@ -50,7 +50,6 @@ pub const MAX_IMAGE_HEIGHT: u16 = 15;
pub const MIN_IMAGE_HEIGHT: u16 = 3; pub const MIN_IMAGE_HEIGHT: u16 = 3;
/// Таймаут скачивания файла (в секундах) /// Таймаут скачивания файла (в секундах)
#[allow(dead_code)]
pub const FILE_DOWNLOAD_TIMEOUT_SECS: u64 = 30; pub const FILE_DOWNLOAD_TIMEOUT_SECS: u64 = 30;
/// Размер кэша изображений по умолчанию (в МБ) /// Размер кэша изображений по умолчанию (в МБ)

View File

@@ -126,25 +126,23 @@ pub fn format_text_with_entities(
let start = entity.offset as usize; let start = entity.offset as usize;
let end = (entity.offset + entity.length) as usize; let end = (entity.offset + entity.length) as usize;
for item in char_styles for i in start..end.min(chars.len()) {
.iter_mut()
.take(end.min(chars.len()))
.skip(start)
{
match &entity.r#type { match &entity.r#type {
TextEntityType::Bold => item.bold = true, TextEntityType::Bold => char_styles[i].bold = true,
TextEntityType::Italic => item.italic = true, TextEntityType::Italic => char_styles[i].italic = true,
TextEntityType::Underline => item.underline = true, TextEntityType::Underline => char_styles[i].underline = true,
TextEntityType::Strikethrough => item.strikethrough = true, TextEntityType::Strikethrough => char_styles[i].strikethrough = true,
TextEntityType::Code | TextEntityType::Pre | TextEntityType::PreCode(_) => { TextEntityType::Code | TextEntityType::Pre | TextEntityType::PreCode(_) => {
item.code = true char_styles[i].code = true
} }
TextEntityType::Spoiler => item.spoiler = true, TextEntityType::Spoiler => char_styles[i].spoiler = true,
TextEntityType::Url TextEntityType::Url
| TextEntityType::TextUrl(_) | TextEntityType::TextUrl(_)
| TextEntityType::EmailAddress | TextEntityType::EmailAddress
| TextEntityType::PhoneNumber => item.url = true, | TextEntityType::PhoneNumber => char_styles[i].url = true,
TextEntityType::Mention | TextEntityType::MentionName(_) => item.mention = true, TextEntityType::Mention | TextEntityType::MentionName(_) => {
char_styles[i].mention = true
}
_ => {} _ => {}
} }
} }
@@ -279,7 +277,11 @@ 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 { offset: 0, length: 5, r#type: TextEntityType::Bold }]; let entities = vec![TextEntity {
offset: 0,
length: 5,
r#type: TextEntityType::Bold,
}];
let spans = format_text_with_entities(text, &entities, Color::White); let spans = format_text_with_entities(text, &entities, Color::White);
assert_eq!(spans.len(), 1); assert_eq!(spans.len(), 1);

View File

@@ -20,8 +20,7 @@ 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 app.td_client.send_phone_number(app.phone_input().to_string()),
.send_phone_number(app.phone_input().to_string()),
"Таймаут отправки номера", "Таймаут отправки номера",
) )
.await .await
@@ -85,8 +84,7 @@ 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 app.td_client.send_password(app.password_input().to_string()),
.send_password(app.password_input().to_string()),
"Таймаут проверки пароля", "Таймаут проверки пароля",
) )
.await .await

View File

@@ -6,22 +6,22 @@
//! - 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::input::handlers::{copy_to_clipboard, format_message_for_clipboard}; use crate::app::methods::{
use crate::tdlib::{ChatAction, TdClientTrait}; compose::ComposeMethods, messages::MessageMethods,
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};
/// Обработка режима выбора сообщения для действий /// Обработка режима выбора сообщения для действий
/// ///
/// Обрабатывает: /// Обрабатывает:
/// - Навигацию по сообщениям (Up/Down) /// - Навигацию по сообщениям (Up/Down)
/// - Удаление сообщения (d/в/Delete) /// - Удаление сообщения (d/в/Delete)
@@ -29,11 +29,7 @@ use std::time::{Duration, Instant};
/// - Пересылку сообщения (f/а) /// - Пересылку сообщения (f/а)
/// - Копирование сообщения (y/н) /// - Копирование сообщения (y/н)
/// - Добавление реакции (e/у) /// - Добавление реакции (e/у)
pub async fn handle_message_selection<T: TdClientTrait>( pub async fn handle_message_selection<T: TdClientTrait>(app: &mut App<T>, _key: KeyEvent, command: Option<crate::config::Command>) {
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();
@@ -48,7 +44,9 @@ pub async fn handle_message_selection<T: TdClientTrait>(
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 { message_id: msg.id() }; app.chat_state = crate::app::ChatState::DeleteConfirmation {
message_id: msg.id(),
};
} }
} }
Some(crate::config::Command::EnterInsertMode) => { Some(crate::config::Command::EnterInsertMode) => {
@@ -131,22 +129,17 @@ pub async fn handle_message_selection<T: TdClientTrait>(
} }
/// Редактирование существующего сообщения /// Редактирование существующего сообщения
pub async fn edit_message<T: TdClientTrait>( pub async fn edit_message<T: TdClientTrait>(app: &mut App<T>, chat_id: i64, msg_id: MessageId, text: String) {
app: &mut App<T>,
chat_id: i64,
msg_id: MessageId,
text: String,
) {
// Проверяем, что сообщение есть в локальном кэше // Проверяем, что сообщение есть в локальном кэше
let msg_exists = app let msg_exists = app.td_client.current_chat_messages()
.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 = app.error_message = Some(format!(
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;
@@ -155,8 +148,7 @@ pub async fn edit_message<T: TdClientTrait>(
match with_timeout_msg( match with_timeout_msg(
Duration::from_secs(5), Duration::from_secs(5),
app.td_client app.td_client.edit_message(ChatId::new(chat_id), msg_id, text),
.edit_message(ChatId::new(chat_id), msg_id, text),
"Таймаут редактирования", "Таймаут редактирования",
) )
.await .await
@@ -168,12 +160,8 @@ pub async fn edit_message<T: TdClientTrait>(
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 if edited_msg.interactions.reply_to.as_ref()
.interactions .map_or(true, |r| r.sender_name == "Unknown") {
.reply_to
.as_ref()
.is_none_or(|r| r.sender_name == "Unknown")
{
edited_msg.interactions.reply_to = Some(old_reply); edited_msg.interactions.reply_to = Some(old_reply);
} }
} }
@@ -201,13 +189,13 @@ pub async fn send_new_message<T: TdClientTrait>(app: &mut App<T>, chat_id: i64,
}; };
// Создаём ReplyInfo ДО отправки, пока сообщение точно доступно // Создаём ReplyInfo ДО отправки, пока сообщение точно доступно
let reply_info = app let reply_info = app.get_replying_to_message().map(|m| {
.get_replying_to_message() crate::tdlib::ReplyInfo {
.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();
app.cursor_position = 0; app.cursor_position = 0;
@@ -218,14 +206,11 @@ 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 app.td_client.send_chat_action(ChatId::new(chat_id), ChatAction::Cancel).await;
.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 app.td_client.send_message(ChatId::new(chat_id), text, reply_to_id, reply_info),
.send_message(ChatId::new(chat_id), text, reply_to_id, reply_info),
"Таймаут отправки", "Таймаут отправки",
) )
.await .await
@@ -243,7 +228,7 @@ pub async fn send_new_message<T: TdClientTrait>(app: &mut App<T>, chat_id: i64,
} }
/// Обработка клавиши Enter /// Обработка клавиши Enter
/// ///
/// Обрабатывает три сценария: /// Обрабатывает три сценария:
/// 1. В режиме выбора сообщения: начать редактирование /// 1. В режиме выбора сообщения: начать редактирование
/// 2. В открытом чате: отправить новое или редактировать существующее сообщение /// 2. В открытом чате: отправить новое или редактировать существующее сообщение
@@ -319,8 +304,7 @@ 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 app.td_client.toggle_reaction(chat_id, message_id, emoji.clone()),
.toggle_reaction(chat_id, message_id, emoji.clone()),
"Таймаут отправки реакции", "Таймаут отправки реакции",
) )
.await; .await;
@@ -369,8 +353,7 @@ 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 app.td_client.load_older_messages(ChatId::new(chat_id), oldest_msg_id),
.load_older_messages(ChatId::new(chat_id), oldest_msg_id),
) )
.await .await
else { else {
@@ -385,7 +368,7 @@ pub async fn load_older_messages_if_needed<T: TdClientTrait>(app: &mut App<T>) {
} }
/// Обработка ввода клавиатуры в открытом чате /// Обработка ввода клавиатуры в открытом чате
/// ///
/// Обрабатывает: /// Обрабатывает:
/// - Backspace/Delete: удаление символов относительно курсора /// - Backspace/Delete: удаление символов относительно курсора
/// - Char: вставка символов в позицию курсора + typing status /// - Char: вставка символов в позицию курсора + typing status
@@ -425,8 +408,7 @@ 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;
} }
@@ -452,9 +434,7 @@ 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 app.td_client.send_chat_action(ChatId::new(chat_id), ChatAction::Typing).await;
.send_chat_action(ChatId::new(chat_id), ChatAction::Typing)
.await;
app.last_typing_sent = Some(Instant::now()); app.last_typing_sent = Some(Instant::now());
} }
} }
@@ -641,7 +621,8 @@ 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 = PhotoDownloadState::Downloaded(path.clone()); photo.download_state =
PhotoDownloadState::Downloaded(path.clone());
break; break;
} }
} }
@@ -659,7 +640,8 @@ 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 = PhotoDownloadState::Error(e.clone()); photo.download_state =
PhotoDownloadState::Error(e.clone());
break; break;
} }
} }
@@ -678,7 +660,8 @@ 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 = PhotoDownloadState::Downloaded(path.clone()); photo.download_state =
PhotoDownloadState::Downloaded(path.clone());
break; break;
} }
} }
@@ -765,25 +748,13 @@ 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 if entry_name.to_string_lossy().starts_with(&stem.to_string_lossy().to_string()) {
.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( let _ = cache.store(&file_id.to_string(), Path::new(&found_path));
&file_id.to_string(),
Path::new(&found_path),
);
} }
return handle_play_voice_from_path( return handle_play_voice_from_path(app, &found_path, &voice, &msg).await;
app,
&found_path,
voice,
&msg,
)
.await;
} }
} }
} }
@@ -799,7 +770,7 @@ async fn handle_play_voice<T: TdClientTrait>(app: &mut App<T>) {
let _ = cache.store(&file_id.to_string(), Path::new(&audio_path)); let _ = cache.store(&file_id.to_string(), Path::new(&audio_path));
} }
handle_play_voice_from_path(app, &audio_path, voice, &msg).await; handle_play_voice_from_path(app, &audio_path, &voice, &msg).await;
} }
VoiceDownloadState::Downloading => { VoiceDownloadState::Downloading => {
app.status_message = Some("Загрузка голосового...".to_string()); app.status_message = Some("Загрузка голосового...".to_string());
@@ -809,7 +780,7 @@ async fn handle_play_voice<T: TdClientTrait>(app: &mut App<T>) {
let cache_key = file_id.to_string(); let cache_key = file_id.to_string();
if let Some(cached_path) = app.voice_cache.as_mut().and_then(|c| c.get(&cache_key)) { if let Some(cached_path) = app.voice_cache.as_mut().and_then(|c| c.get(&cache_key)) {
let path_str = cached_path.to_string_lossy().to_string(); let path_str = cached_path.to_string_lossy().to_string();
handle_play_voice_from_path(app, &path_str, voice, &msg).await; handle_play_voice_from_path(app, &path_str, &voice, &msg).await;
return; return;
} }
@@ -822,7 +793,7 @@ async fn handle_play_voice<T: TdClientTrait>(app: &mut App<T>) {
let _ = cache.store(&cache_key, std::path::Path::new(&path)); let _ = cache.store(&cache_key, std::path::Path::new(&path));
} }
handle_play_voice_from_path(app, &path, voice, &msg).await; handle_play_voice_from_path(app, &path, &voice, &msg).await;
} }
Err(e) => { Err(e) => {
app.error_message = Some(format!("Ошибка загрузки: {}", e)); app.error_message = Some(format!("Ошибка загрузки: {}", e));
@@ -855,3 +826,4 @@ async fn _download_and_expand<T: TdClientTrait>(app: &mut App<T>, msg_id: crate:
// Закомментировано - будет реализовано в Этапе 4 // Закомментировано - будет реализовано в Этапе 4
} }
*/ */

View File

@@ -5,11 +5,9 @@
//! - 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};
@@ -17,15 +15,11 @@ use crossterm::event::KeyEvent;
use std::time::Duration; 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>( pub async fn handle_chat_list_navigation<T: TdClientTrait>(app: &mut App<T>, _key: KeyEvent, command: Option<crate::config::Command>) {
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();
@@ -71,9 +65,11 @@ 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 _ = let _ = with_timeout(
with_timeout(Duration::from_secs(5), app.td_client.load_folder_chats(folder_id, 50)) Duration::from_secs(5),
.await; app.td_client.load_folder_chats(folder_id, 50),
)
.await;
app.status_message = None; app.status_message = None;
app.chat_list_state.select(Some(0)); app.chat_list_state.select(Some(0));
} }
@@ -118,8 +114,7 @@ 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 app.td_client.set_current_chat_id(Some(ChatId::new(chat_id)));
.set_current_chat_id(Some(ChatId::new(chat_id)));
// Загружаем черновик (локальная операция, мгновенно) // Загружаем черновик (локальная операция, мгновенно)
app.load_draft(); app.load_draft();
@@ -137,4 +132,4 @@ pub async fn open_chat_and_load_data<T: TdClientTrait>(app: &mut App<T>, chat_id
app.status_message = None; app.status_message = None;
} }
} }
} }

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;
@@ -17,16 +17,12 @@ use crossterm::event::KeyEvent;
use std::time::Duration; use std::time::Duration;
/// Обработка режима выбора чата для пересылки сообщения /// Обработка режима выбора чата для пересылки сообщения
/// ///
/// Обрабатывает: /// Обрабатывает:
/// - Навигацию по списку чатов (Up/Down) /// - Навигацию по списку чатов (Up/Down)
/// - Пересылку сообщения в выбранный чат (Enter) /// - Пересылку сообщения в выбранный чат (Enter)
/// - Отмену пересылки (Esc) /// - Отмену пересылки (Esc)
pub async fn handle_forward_mode<T: TdClientTrait>( pub async fn handle_forward_mode<T: TdClientTrait>(app: &mut App<T>, _key: KeyEvent, command: Option<crate::config::Command>) {
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();
@@ -67,8 +63,11 @@ 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 app.td_client.forward_messages(
.forward_messages(to_chat_id, ChatId::new(from_chat_id), vec![msg_id]), to_chat_id,
ChatId::new(from_chat_id),
vec![msg_id],
),
"Таймаут пересылки", "Таймаут пересылки",
) )
.await; .await;
@@ -82,4 +81,4 @@ pub async fn forward_selected_message<T: TdClientTrait>(app: &mut App<T>) {
app.error_message = Some(e); app.error_message = Some(e);
} }
} }
} }

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

View File

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

View File

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

@@ -37,8 +37,10 @@ fn parse_account_arg() -> Option<String> {
let args: Vec<String> = std::env::args().collect(); let args: Vec<String> = std::env::args().collect();
let mut i = 1; let mut i = 1;
while i < args.len() { while i < args.len() {
if args[i] == "--account" && i + 1 < args.len() { if args[i] == "--account" {
return Some(args[i + 1].clone()); if i + 1 < args.len() {
return Some(args[i + 1].clone());
}
} }
i += 1; i += 1;
} }
@@ -55,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();
@@ -68,16 +70,15 @@ 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()).unwrap_or_else(|e| { accounts::resolve_account(&accounts_config, account_arg.as_deref())
eprintln!("Error: {}", e); .unwrap_or_else(|e| {
std::process::exit(1); eprintln!("Error: {}", e);
}); std::process::exit(1);
});
// Создаём директорию аккаунта если её нет // Создаём директорию аккаунта если её нет
let db_path = accounts::ensure_account_dir( let db_path = accounts::ensure_account_dir(
account_arg account_arg.as_deref().unwrap_or(&accounts_config.default_account),
.as_deref()
.unwrap_or(&accounts_config.default_account),
) )
.unwrap_or(db_path); .unwrap_or(db_path);
@@ -121,14 +122,14 @@ async fn main() -> Result<(), io::Error> {
tokio::spawn(async move { tokio::spawn(async move {
if let Err(e) = tdlib_rs::functions::set_tdlib_parameters( if let Err(e) = tdlib_rs::functions::set_tdlib_parameters(
false, // use_test_dc false, // use_test_dc
db_path_str, // database_directory db_path_str, // database_directory
"".to_string(), // files_directory "".to_string(), // files_directory
"".to_string(), // database_encryption_key "".to_string(), // database_encryption_key
true, // use_file_database true, // use_file_database
true, // use_chat_info_database true, // use_chat_info_database
true, // use_message_database true, // use_message_database
false, // use_secret_chats false, // use_secret_chats
api_id, api_id,
api_hash, api_hash,
"en".to_string(), // system_language_code "en".to_string(), // system_language_code
@@ -172,7 +173,7 @@ async fn run_app<B: ratatui::backend::Backend, T: tdlib::TdClientTrait>(
let polling_handle = tokio::spawn(async move { let polling_handle = tokio::spawn(async move {
while !should_stop_clone.load(Ordering::Relaxed) { while !should_stop_clone.load(Ordering::Relaxed) {
// receive() с таймаутом 0.1 сек чтобы периодически проверять флаг // receive() с таймаутом 0.1 сек чтобы периодически проверять флаг
let result = tokio::task::spawn_blocking(tdlib_rs::receive).await; let result = tokio::task::spawn_blocking(|| tdlib_rs::receive()).await;
if let Ok(Some((update, _client_id))) = result { if let Ok(Some((update, _client_id))) = result {
if update_tx.send(update).is_err() { if update_tx.send(update).is_err() {
break; // Канал закрыт, выходим break; // Канал закрыт, выходим
@@ -259,7 +260,7 @@ async fn run_app<B: ratatui::backend::Backend, T: tdlib::TdClientTrait>(
// Проверяем завершение воспроизведения // Проверяем завершение воспроизведения
if playback.position >= playback.duration if playback.position >= playback.duration
|| app.audio_player.as_ref().is_some_and(|p| p.is_stopped()) || app.audio_player.as_ref().map_or(false, |p| p.is_stopped())
{ {
stop_playback = true; stop_playback = true;
} }
@@ -304,11 +305,7 @@ 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( with_timeout_ignore(Duration::from_secs(SHUTDOWN_TIMEOUT_SECS), polling_handle).await;
Duration::from_secs(SHUTDOWN_TIMEOUT_SECS),
polling_handle,
)
.await;
return Ok(()); return Ok(());
} }
@@ -346,8 +343,11 @@ 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(Duration::from_secs(5), app.td_client.fetch_missing_reply_info()) with_timeout_ignore(
.await; Duration::from_secs(5),
app.td_client.fetch_missing_reply_info(),
)
.await;
// Загружаем последнее закреплённое сообщение (игнорируем ошибки) // Загружаем последнее закреплённое сообщение (игнорируем ошибки)
with_timeout_ignore( with_timeout_ignore(
@@ -385,22 +385,25 @@ 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(Duration::from_secs(5), async { let result = tokio::time::timeout(
match tdlib_rs::functions::download_file( Duration::from_secs(5),
file_id, 1, 0, 0, true, client_id, async {
) match tdlib_rs::functions::download_file(
.await file_id, 1, 0, 0, true, client_id,
{ )
Ok(tdlib_rs::enums::File::File(file)) .await
if file.local.is_downloading_completed
&& !file.local.path.is_empty() =>
{ {
Ok(file.local.path) Ok(tdlib_rs::enums::File::File(file))
if file.local.is_downloading_completed
&& !file.local.path.is_empty() =>
{
Ok(file.local.path)
}
Ok(_) => Err("Файл не скачан".to_string()),
Err(e) => Err(format!("{:?}", e)),
} }
Ok(_) => Err("Файл не скачан".to_string()), },
Err(e) => Err(format!("{:?}", e)), )
}
})
.await; .await;
let result = match result { let result = match result {

View File

@@ -6,13 +6,11 @@ use std::fs;
use std::path::PathBuf; use std::path::PathBuf;
/// Кэш изображений с LRU eviction по mtime /// Кэш изображений с LRU eviction по mtime
#[allow(dead_code)]
pub struct ImageCache { pub struct ImageCache {
cache_dir: PathBuf, cache_dir: PathBuf,
max_size_bytes: u64, max_size_bytes: u64,
} }
#[allow(dead_code)]
impl ImageCache { impl ImageCache {
/// Создаёт новый кэш с указанным лимитом в МБ /// Создаёт новый кэш с указанным лимитом в МБ
pub fn new(cache_size_mb: u64) -> Self { pub fn new(cache_size_mb: u64) -> Self {
@@ -35,7 +33,10 @@ 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(&path, filetime::FileTime::now()); let _ = filetime::set_file_mtime(
&path,
filetime::FileTime::now(),
);
Some(path) Some(path)
} else { } else {
None None
@@ -46,7 +47,8 @@ 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).map_err(|e| format!("Ошибка кэширования: {}", e))?; fs::copy(source_path, &dest)
.map_err(|e| format!("Ошибка кэширования: {}", e))?;
// Evict если превышен лимит // Evict если превышен лимит
self.evict_if_needed(); self.evict_if_needed();
@@ -91,7 +93,6 @@ impl ImageCache {
} }
/// Обёртка для установки mtime без внешней зависимости /// Обёртка для установки mtime без внешней зависимости
#[allow(dead_code)]
mod filetime { mod filetime {
use std::path::Path; use std::path::Path;

View File

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

View File

@@ -12,12 +12,9 @@ pub enum MessageGroup {
/// Разделитель даты (день в формате timestamp) /// Разделитель даты (день в формате timestamp)
DateSeparator(i32), DateSeparator(i32),
/// Заголовок отправителя (is_outgoing, sender_name) /// Заголовок отправителя (is_outgoing, sender_name)
SenderHeader { SenderHeader { is_outgoing: bool, sender_name: String },
is_outgoing: bool,
sender_name: String,
},
/// Сообщение /// Сообщение
Message(Box<MessageInfo>), Message(MessageInfo),
/// Альбом (группа фото с одинаковым media_album_id) /// Альбом (группа фото с одинаковым media_album_id)
Album(Vec<MessageInfo>), Album(Vec<MessageInfo>),
} }
@@ -78,7 +75,7 @@ pub fn group_messages(messages: &[MessageInfo]) -> Vec<MessageGroup> {
result.push(MessageGroup::Album(std::mem::take(acc))); result.push(MessageGroup::Album(std::mem::take(acc)));
} else { } else {
// Одно сообщение — не альбом // Одно сообщение — не альбом
result.push(MessageGroup::Message(Box::new(acc.remove(0)))); result.push(MessageGroup::Message(acc.remove(0)));
} }
} }
@@ -109,7 +106,10 @@ 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 { is_outgoing: msg.is_outgoing(), sender_name }); result.push(MessageGroup::SenderHeader {
is_outgoing: msg.is_outgoing(),
sender_name,
});
last_sender = Some(current_sender); last_sender = Some(current_sender);
} }
@@ -137,7 +137,7 @@ pub fn group_messages(messages: &[MessageInfo]) -> Vec<MessageGroup> {
// Обычное сообщение (не альбом) — flush аккумулятор // Обычное сообщение (не альбом) — flush аккумулятор
flush_album(&mut album_acc, &mut result); flush_album(&mut album_acc, &mut result);
result.push(MessageGroup::Message(Box::new(msg.clone()))); result.push(MessageGroup::Message(msg.clone()));
} }
// Flush оставшийся аккумулятор // Flush оставшийся аккумулятор

View File

@@ -10,7 +10,6 @@ use std::collections::HashSet;
use notify_rust::{Notification, Timeout}; use notify_rust::{Notification, Timeout};
/// Manages desktop notifications /// Manages desktop notifications
#[allow(dead_code)]
pub struct NotificationManager { pub struct NotificationManager {
/// Whether notifications are enabled /// Whether notifications are enabled
enabled: bool, enabled: bool,
@@ -26,7 +25,6 @@ pub struct NotificationManager {
urgency: String, urgency: String,
} }
#[allow(dead_code)]
impl NotificationManager { impl NotificationManager {
/// Creates a new notification manager with default settings /// Creates a new notification manager with default settings
pub fn new() -> Self { pub fn new() -> Self {
@@ -41,7 +39,11 @@ impl NotificationManager {
} }
/// Creates a notification manager with custom settings /// Creates a notification manager with custom settings
pub fn with_config(enabled: bool, only_mentions: bool, show_preview: bool) -> Self { pub fn with_config(
enabled: bool,
only_mentions: bool,
show_preview: bool,
) -> Self {
Self { Self {
enabled, enabled,
muted_chats: HashSet::new(), muted_chats: HashSet::new(),
@@ -309,13 +311,22 @@ mod tests {
#[test] #[test]
fn test_beautify_media_labels() { fn test_beautify_media_labels() {
// Test photo // Test photo
assert_eq!(NotificationManager::beautify_media_labels("[Фото]"), "📷 Фото"); assert_eq!(
NotificationManager::beautify_media_labels("[Фото]"),
"📷 Фото"
);
// Test video // Test video
assert_eq!(NotificationManager::beautify_media_labels("[Видео]"), "🎥 Видео"); assert_eq!(
NotificationManager::beautify_media_labels("[Видео]"),
"🎥 Видео"
);
// Test sticker with emoji // Test sticker with emoji
assert_eq!(NotificationManager::beautify_media_labels("[Стикер: 😊]"), "🎨 Стикер: 😊]"); assert_eq!(
NotificationManager::beautify_media_labels("[Стикер: 😊]"),
"🎨 Стикер: 😊]"
);
// Test audio with title // Test audio with title
assert_eq!( assert_eq!(
@@ -330,7 +341,10 @@ mod tests {
); );
// Test regular text (no changes) // Test regular text (no changes)
assert_eq!(NotificationManager::beautify_media_labels("Hello, world!"), "Hello, world!"); assert_eq!(
NotificationManager::beautify_media_labels("Hello, world!"),
"Hello, world!"
);
// Test mixed content // Test mixed content
assert_eq!( assert_eq!(

View File

@@ -5,7 +5,6 @@ use tdlib_rs::functions;
/// ///
/// Отслеживает текущий этап аутентификации пользователя, /// Отслеживает текущий этап аутентификации пользователя,
/// от инициализации TDLib до полной авторизации. /// от инициализации TDLib до полной авторизации.
#[allow(dead_code)]
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub enum AuthState { pub enum AuthState {
/// Ожидание параметров TDLib (начальное состояние). /// Ожидание параметров TDLib (начальное состояние).
@@ -73,7 +72,6 @@ pub struct AuthManager {
client_id: i32, client_id: i32,
} }
#[allow(dead_code)]
impl AuthManager { impl AuthManager {
/// Создает новый менеджер авторизации. /// Создает новый менеджер авторизации.
/// ///
@@ -85,7 +83,10 @@ impl AuthManager {
/// ///
/// Новый экземпляр `AuthManager` в состоянии `WaitTdlibParameters`. /// Новый экземпляр `AuthManager` в состоянии `WaitTdlibParameters`.
pub fn new(client_id: i32) -> Self { pub fn new(client_id: i32) -> Self {
Self { state: AuthState::WaitTdlibParameters, client_id } Self {
state: AuthState::WaitTdlibParameters,
client_id,
}
} }
/// Проверяет, завершена ли авторизация. /// Проверяет, завершена ли авторизация.

View File

@@ -3,7 +3,7 @@
//! This module contains utility functions for managing chats, //! 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_CHATS, MAX_CHAT_USER_IDS}; use crate::constants::{MAX_CHAT_USER_IDS, MAX_CHATS};
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,9 +33,7 @@ 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 client.chats_mut().retain(|c| c.id != ChatId::new(td_chat.id));
.chats_mut()
.retain(|c| c.id != ChatId::new(td_chat.id));
return; return;
} }
@@ -72,9 +70,7 @@ 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 client.user_cache.user_usernames
.user_cache
.user_usernames
.peek(&user_id) .peek(&user_id)
.map(|u| format!("@{}", u)) .map(|u| format!("@{}", u))
} }

View File

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

View File

@@ -1,17 +1,20 @@
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::{Chat as TdChat, ChatList, ConnectionState, Update, UserStatus}; use tdlib_rs::enums::{
use tdlib_rs::functions; ChatList, ConnectionState, Update, UserStatus,
Chat as TdChat
};
use tdlib_rs::types::Message as TdMessage; use tdlib_rs::types::Message as TdMessage;
use tdlib_rs::functions;
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::{ use super::types::{ChatInfo, FolderInfo, MessageInfo, NetworkState, ProfileInfo, UserOnlineStatus};
ChatInfo, FolderInfo, MessageInfo, NetworkState, ProfileInfo, UserOnlineStatus,
};
use super::users::UserCache; use super::users::UserCache;
use crate::notifications::NotificationManager; use crate::notifications::NotificationManager;
@@ -58,7 +61,6 @@ pub struct TdClient {
pub network_state: NetworkState, pub network_state: NetworkState,
} }
#[allow(dead_code)]
impl TdClient { impl TdClient {
/// Creates a new TDLib client instance. /// Creates a new TDLib client instance.
/// ///
@@ -73,15 +75,16 @@ 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().unwrap_or_else(|_| { let (api_id, api_hash) = crate::config::Config::load_credentials()
// Fallback на прямое чтение из env (старое поведение) .unwrap_or_else(|_| {
let api_id = env::var("API_ID") // Fallback на прямое чтение из env (старое поведение)
.unwrap_or_else(|_| "0".to_string()) let api_id = env::var("API_ID")
.parse() .unwrap_or_else(|_| "0".to_string())
.unwrap_or(0); .parse()
let api_hash = env::var("API_HASH").unwrap_or_default(); .unwrap_or(0);
(api_id, api_hash) let api_hash = env::var("API_HASH").unwrap_or_default();
}); (api_id, api_hash)
});
let client_id = tdlib_rs::create_client(); let client_id = tdlib_rs::create_client();
@@ -103,11 +106,9 @@ 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 self.notification_manager.set_only_mentions(config.only_mentions);
.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 self.notification_manager.set_urgency(config.urgency.clone());
.set_urgency(config.urgency.clone());
// Note: show_preview is used when formatting notification body // Note: show_preview is used when formatting notification body
} }
@@ -115,8 +116,7 @@ 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 self.notification_manager.sync_muted_chats(&self.chat_manager.chats);
.sync_muted_chats(&self.chat_manager.chats);
} }
// Делегирование к auth // Делегирование к auth
@@ -257,17 +257,12 @@ impl TdClient {
.await .await
} }
pub async fn get_pinned_messages( pub async fn get_pinned_messages(&mut self, chat_id: ChatId) -> Result<Vec<MessageInfo>, String> {
&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 self.message_manager.load_current_pinned_message(chat_id).await
.load_current_pinned_message(chat_id)
.await
} }
pub async fn search_messages( pub async fn search_messages(
@@ -447,10 +442,7 @@ impl TdClient {
self.chat_manager.typing_status.as_ref() self.chat_manager.typing_status.as_ref()
} }
pub fn set_typing_status( pub fn set_typing_status(&mut self, status: Option<(crate::types::UserId, String, std::time::Instant)>) {
&mut self,
status: Option<(crate::types::UserId, String, std::time::Instant)>,
) {
self.chat_manager.typing_status = status; self.chat_manager.typing_status = status;
} }
@@ -458,9 +450,7 @@ impl TdClient {
&self.message_manager.pending_view_messages &self.message_manager.pending_view_messages
} }
pub fn pending_view_messages_mut( pub fn pending_view_messages_mut(&mut self) -> &mut Vec<(crate::types::ChatId, Vec<crate::types::MessageId>)> {
&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
} }
@@ -491,6 +481,19 @@ impl TdClient {
// ==================== Helper методы для упрощения обработки updates ==================== // ==================== Helper методы для упрощения обработки updates ====================
/// Находит мутабельную ссылку на чат по ID.
///
/// Упрощает повторяющийся паттерн `self.chats_mut().iter_mut().find(...)`.
///
/// # Arguments
///
/// * `chat_id` - ID чата для поиска
///
/// # Returns
///
/// * `Some(&mut ChatInfo)` - если чат найден
/// * `None` - если чат не найден
/// Обрабатываем одно обновление от TDLib /// Обрабатываем одно обновление от TDLib
pub fn handle_update(&mut self, update: Update) { pub fn handle_update(&mut self, update: Update) {
match update { match update {
@@ -516,11 +519,7 @@ impl TdClient {
}); });
// Обновляем позиции если они пришли // Обновляем позиции если они пришли
for pos in update for pos in update.positions.iter().filter(|p| matches!(p.list, ChatList::Main)) {
.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;
@@ -531,43 +530,27 @@ 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( crate::tdlib::chat_helpers::update_chat(self, ChatId::new(update.chat_id), |chat| {
self, chat.unread_count = update.unread_count;
ChatId::new(update.chat_id), });
|chat| {
chat.unread_count = update.unread_count;
},
);
} }
Update::ChatUnreadMentionCount(update) => { Update::ChatUnreadMentionCount(update) => {
crate::tdlib::chat_helpers::update_chat( crate::tdlib::chat_helpers::update_chat(self, ChatId::new(update.chat_id), |chat| {
self, chat.unread_mention_count = update.unread_mention_count;
ChatId::new(update.chat_id), });
|chat| {
chat.unread_mention_count = update.unread_mention_count;
},
);
} }
Update::ChatNotificationSettings(update) => { Update::ChatNotificationSettings(update) => {
crate::tdlib::chat_helpers::update_chat( crate::tdlib::chat_helpers::update_chat(self, ChatId::new(update.chat_id), |chat| {
self, // mute_for > 0 означает что чат замьючен
ChatId::new(update.chat_id), chat.is_muted = update.notification_settings.mute_for > 0;
|chat| { });
// 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( crate::tdlib::chat_helpers::update_chat(self, ChatId::new(update.chat_id), |chat| {
self, chat.last_read_outbox_message_id = last_read_msg_id;
ChatId::new(update.chat_id), });
|chat| {
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() {
@@ -605,9 +588,7 @@ impl TdClient {
UserStatus::LastMonth(_) => UserOnlineStatus::LastMonth, UserStatus::LastMonth(_) => UserOnlineStatus::LastMonth,
UserStatus::Empty => UserOnlineStatus::LongTimeAgo, UserStatus::Empty => UserOnlineStatus::LongTimeAgo,
}; };
self.user_cache self.user_cache.user_statuses.insert(UserId::new(update.user_id), status);
.user_statuses
.insert(UserId::new(update.user_id), status);
} }
Update::ConnectionState(update) => { Update::ConnectionState(update) => {
// Обновляем состояние сетевого соединения // Обновляем состояние сетевого соединения
@@ -635,15 +616,13 @@ impl TdClient {
} }
} }
// Helper functions // Helper functions
pub fn extract_message_text_static( pub fn extract_message_text_static(message: &TdMessage) -> (String, Vec<tdlib_rs::types::TextEntity>) {
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) => { MessageContent::MessageText(text) => (text.text.text.clone(), text.text.entities.clone()),
(text.text.text.clone(), text.text.entities.clone())
}
_ => (String::new(), Vec::new()), _ => (String::new(), Vec::new()),
} }
} }

View File

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

View File

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

View File

@@ -11,7 +11,11 @@ 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(client: &mut TdClient, message: &TdMessage, chat_id: ChatId) -> MessageInfo { pub fn convert_message(
client: &mut TdClient,
message: &TdMessage,
chat_id: ChatId,
) -> MessageInfo {
let sender_name = match &message.sender_id { let sender_name = match &message.sender_id {
tdlib_rs::enums::MessageSender::User(user) => { tdlib_rs::enums::MessageSender::User(user) => {
// Пробуем получить имя из кеша (get обновляет LRU порядок) // Пробуем получить имя из кеша (get обновляет LRU порядок)
@@ -116,7 +120,7 @@ pub fn extract_reply_info(client: &TdClient, message: &TdMessage) -> Option<Repl
let sender_name = reply let sender_name = reply
.origin .origin
.as_ref() .as_ref()
.map(get_origin_sender_name) .map(|origin| get_origin_sender_name(origin))
.unwrap_or_else(|| { .unwrap_or_else(|| {
// Пробуем найти оригинальное сообщение в текущем списке // Пробуем найти оригинальное сообщение в текущем списке
let reply_msg_id = MessageId::new(reply.message_id); let reply_msg_id = MessageId::new(reply.message_id);
@@ -134,7 +138,12 @@ 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(|| reply.content.as_ref().map(TdClient::extract_content_text)) .or_else(|| {
reply
.content
.as_ref()
.map(TdClient::extract_content_text)
})
.unwrap_or_else(|| { .unwrap_or_else(|| {
// Пробуем найти в текущих сообщениях // Пробуем найти в текущих сообщениях
client client
@@ -145,7 +154,11 @@ pub fn extract_reply_info(client: &TdClient, message: &TdMessage) -> Option<Repl
.unwrap_or_default() .unwrap_or_default()
}); });
Some(ReplyInfo { message_id: reply_msg_id, sender_name, text }) Some(ReplyInfo {
message_id: reply_msg_id,
sender_name,
text,
})
} }
_ => None, _ => None,
} }
@@ -206,7 +219,12 @@ 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| (m.id().as_i64(), (m.sender_name().to_string(), m.text().to_string()))) .map(|m| {
(
m.id().as_i64(),
(m.sender_name().to_string(), m.text().to_string()),
)
})
.collect(); .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_media_info, extract_content_text, extract_entities, extract_forward_info,
extract_reactions, extract_reply_info, extract_sender_name, extract_media_info, extract_reactions, extract_reply_info, extract_sender_name,
}; };
// Извлекаем все части сообщения используя вспомогательные функции // Извлекаем все части сообщения используя вспомогательные функции
@@ -122,7 +122,12 @@ impl MessageManager {
}; };
// Extract text preview (first 50 chars) // Extract text preview (first 50 chars)
let text_preview: String = orig_info.content.text.chars().take(50).collect(); let text_preview: String = orig_info
.content
.text
.chars()
.take(50)
.collect();
// Update reply info in all messages that reference this message // Update reply info in all messages that reference this message
self.current_chat_messages self.current_chat_messages

View File

@@ -95,8 +95,7 @@ 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 self.current_chat_messages.drain(0..(self.current_chat_messages.len() - MAX_MESSAGES_IN_CHAT));
.drain(0..(self.current_chat_messages.len() - MAX_MESSAGES_IN_CHAT));
} }
} }
} }

View File

@@ -2,13 +2,9 @@
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::{ use tdlib_rs::enums::{InputMessageContent, InputMessageReplyTo, SearchMessagesFilter, TextParseMode};
InputMessageContent, InputMessageReplyTo, SearchMessagesFilter, TextParseMode,
};
use tdlib_rs::functions; use tdlib_rs::functions;
use tdlib_rs::types::{ use tdlib_rs::types::{FormattedText, InputMessageReplyToMessage, InputMessageText, TextParseModeMarkdown};
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};
@@ -107,10 +103,9 @@ 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;
@@ -206,9 +201,11 @@ impl MessageManager {
match result { match result {
Ok(tdlib_rs::enums::Messages::Messages(messages_obj)) => { Ok(tdlib_rs::enums::Messages::Messages(messages_obj)) => {
let mut messages = Vec::new(); let mut messages = Vec::new();
for msg in messages_obj.messages.iter().rev().flatten() { for msg_opt in messages_obj.messages.iter().rev() {
if let Some(info) = self.convert_message(msg).await { if let Some(msg) = msg_opt {
messages.push(info); if let Some(info) = self.convert_message(msg).await {
messages.push(info);
}
} }
} }
Ok(messages) Ok(messages)
@@ -236,20 +233,17 @@ 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( pub async fn get_pinned_messages(&mut self, chat_id: ChatId) -> Result<Vec<MessageInfo>, String> {
&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(),
None, None,
0, // from_message_id 0, // from_message_id
0, // offset 0, // offset
100, // limit 100, // limit
Some(SearchMessagesFilter::Pinned), Some(SearchMessagesFilter::Pinned),
0, // message_thread_id 0, // message_thread_id
0, // saved_messages_topic_id 0, // saved_messages_topic_id
self.client_id, self.client_id,
) )
.await; .await;
@@ -316,8 +310,8 @@ impl MessageManager {
0, // offset 0, // offset
100, // limit 100, // limit
None, None,
0, // message_thread_id 0, // message_thread_id
0, // saved_messages_topic_id 0, // saved_messages_topic_id
self.client_id, self.client_id,
) )
.await; .await;
@@ -387,9 +381,15 @@ impl MessageManager {
.await .await
{ {
Ok(tdlib_rs::enums::FormattedText::FormattedText(ft)) => { Ok(tdlib_rs::enums::FormattedText::FormattedText(ft)) => {
FormattedText { text: ft.text, entities: ft.entities } FormattedText {
text: ft.text,
entities: ft.entities,
}
} }
Err(_) => FormattedText { text: text.clone(), entities: vec![] }, Err(_) => FormattedText {
text: text.clone(),
entities: vec![],
},
}; };
let content = InputMessageContent::InputMessageText(InputMessageText { let content = InputMessageContent::InputMessageText(InputMessageText {
@@ -460,9 +460,15 @@ impl MessageManager {
.await .await
{ {
Ok(tdlib_rs::enums::FormattedText::FormattedText(ft)) => { Ok(tdlib_rs::enums::FormattedText::FormattedText(ft)) => {
FormattedText { text: ft.text, entities: ft.entities } FormattedText {
text: ft.text,
entities: ft.entities,
}
} }
Err(_) => FormattedText { text: text.clone(), entities: vec![] }, Err(_) => FormattedText {
text: text.clone(),
entities: vec![],
},
}; };
let content = InputMessageContent::InputMessageText(InputMessageText { let content = InputMessageContent::InputMessageText(InputMessageText {
@@ -471,13 +477,8 @@ impl MessageManager {
clear_draft: true, clear_draft: true,
}); });
let result = functions::edit_message_text( let result =
chat_id.as_i64(), functions::edit_message_text(chat_id.as_i64(), message_id.as_i64(), content, self.client_id).await;
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
@@ -508,8 +509,7 @@ 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) functions::delete_messages(chat_id.as_i64(), message_ids_i64, revoke, self.client_id).await;
.await;
match result { match result {
Ok(_) => Ok(()), Ok(_) => Ok(()),
Err(e) => Err(format!("Ошибка удаления: {:?}", e)), Err(e) => Err(format!("Ошибка удаления: {:?}", e)),
@@ -577,15 +577,17 @@ 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: text.clone(), entities: vec![] }, text: FormattedText {
text: text.clone(),
entities: vec![],
},
link_preview_options: None, link_preview_options: None,
clear_draft: false, clear_draft: false,
}), }),
}) })
}; };
let result = let result = functions::set_chat_draft_message(chat_id.as_i64(), 0, draft, self.client_id).await;
functions::set_chat_draft_message(chat_id.as_i64(), 0, draft, self.client_id).await;
match result { match result {
Ok(_) => Ok(()), Ok(_) => Ok(()),
@@ -610,8 +612,7 @@ 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 _ = let _ = functions::view_messages(chat_id.as_i64(), ids, None, true, self.client_id).await;
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_conversion; // Message conversion utilities (for messages.rs)
mod message_converter; // Message conversion utilities (for client.rs) mod message_converter; // Message conversion utilities (for client.rs)
mod message_conversion; // Message conversion utilities (for messages.rs)
pub mod messages; pub mod messages;
pub mod reactions; pub mod reactions;
pub mod r#trait; pub mod r#trait;
@@ -17,7 +17,6 @@ pub mod users;
pub use auth::AuthState; pub use auth::AuthState;
pub use client::TdClient; pub use client::TdClient;
pub use r#trait::TdClientTrait; pub use r#trait::TdClientTrait;
#[allow(unused_imports)]
pub use types::{ pub use types::{
ChatInfo, FolderInfo, MediaInfo, MessageBuilder, MessageInfo, NetworkState, PhotoDownloadState, ChatInfo, FolderInfo, MediaInfo, MessageBuilder, MessageInfo, NetworkState, PhotoDownloadState,
PhotoInfo, PlaybackState, PlaybackStatus, ProfileInfo, ReplyInfo, UserOnlineStatus, PhotoInfo, PlaybackState, PlaybackStatus, ProfileInfo, ReplyInfo, UserOnlineStatus,

View File

@@ -69,8 +69,7 @@ impl ReactionManager {
message_id: MessageId, message_id: MessageId,
) -> Result<Vec<String>, String> { ) -> Result<Vec<String>, String> {
// Получаем сообщение // Получаем сообщение
let msg_result = let msg_result = functions::get_message(chat_id.as_i64(), message_id.as_i64(), self.client_id).await;
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

@@ -14,7 +14,6 @@ use super::ChatInfo;
/// ///
/// This trait defines the interface for both real and fake TDLib clients, /// This trait defines the interface for both real and fake TDLib clients,
/// enabling dependency injection and easier testing. /// enabling dependency injection and easier testing.
#[allow(dead_code)]
#[async_trait] #[async_trait]
pub trait TdClientTrait: Send { pub trait TdClientTrait: Send {
// ============ Auth methods ============ // ============ Auth methods ============
@@ -33,23 +32,11 @@ 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( async fn get_chat_history(&mut self, chat_id: ChatId, limit: i32) -> Result<Vec<MessageInfo>, String>;
&mut self, async fn load_older_messages(&mut self, chat_id: ChatId, from_message_id: MessageId) -> Result<Vec<MessageInfo>, String>;
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( async fn search_messages(&self, chat_id: ChatId, query: &str) -> Result<Vec<MessageInfo>, String>;
&self,
chat_id: ChatId,
query: &str,
) -> Result<Vec<MessageInfo>, String>;
async fn send_message( async fn send_message(
&mut self, &mut self,

View File

@@ -71,7 +71,6 @@ pub struct PhotoInfo {
} }
/// Состояние загрузки фотографии /// Состояние загрузки фотографии
#[allow(dead_code)]
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum PhotoDownloadState { pub enum PhotoDownloadState {
NotDownloaded, NotDownloaded,
@@ -81,7 +80,6 @@ pub enum PhotoDownloadState {
} }
/// Информация о голосовом сообщении /// Информация о голосовом сообщении
#[allow(dead_code)]
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct VoiceInfo { pub struct VoiceInfo {
pub file_id: i32, pub file_id: i32,
@@ -93,7 +91,6 @@ pub struct VoiceInfo {
} }
/// Состояние загрузки голосового сообщения /// Состояние загрузки голосового сообщения
#[allow(dead_code)]
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum VoiceDownloadState { pub enum VoiceDownloadState {
NotDownloaded, NotDownloaded,
@@ -158,7 +155,6 @@ pub struct MessageInfo {
impl MessageInfo { impl MessageInfo {
/// Создать новое сообщение /// Создать новое сообщение
#[allow(clippy::too_many_arguments)]
pub fn new( pub fn new(
id: MessageId, id: MessageId,
sender_name: String, sender_name: String,
@@ -183,7 +179,11 @@ impl MessageInfo {
edit_date, edit_date,
media_album_id: 0, media_album_id: 0,
}, },
content: MessageContent { text: content, entities, media: None }, content: MessageContent {
text: content,
entities,
media: None,
},
state: MessageState { state: MessageState {
is_outgoing, is_outgoing,
is_read, is_read,
@@ -191,7 +191,11 @@ 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 { reply_to, forward_from, reactions }, interactions: MessageInteractions {
reply_to,
forward_from,
reactions,
},
} }
} }
@@ -247,7 +251,10 @@ 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!(entity.r#type, TextEntityType::Mention | TextEntityType::MentionName(_)) matches!(
entity.r#type,
TextEntityType::Mention | TextEntityType::MentionName(_)
)
}) })
} }
@@ -286,7 +293,6 @@ impl MessageInfo {
} }
/// Возвращает мутабельную ссылку на VoiceInfo (если есть) /// Возвращает мутабельную ссылку на VoiceInfo (если есть)
#[allow(dead_code)]
pub fn voice_info_mut(&mut self) -> Option<&mut VoiceInfo> { pub fn voice_info_mut(&mut self) -> Option<&mut VoiceInfo> {
match &mut self.content.media { match &mut self.content.media {
Some(MediaInfo::Voice(info)) => Some(info), Some(MediaInfo::Voice(info)) => Some(info),
@@ -308,13 +314,13 @@ impl MessageInfo {
} }
/// Builder для удобного создания MessageInfo с fluent API /// Builder для удобного создания MessageInfo с fluent API
/// ///
/// # Примеры /// # Примеры
/// ///
/// ``` /// ```
/// use tele_tui::tdlib::MessageBuilder; /// use tele_tui::tdlib::MessageBuilder;
/// use tele_tui::types::MessageId; /// use tele_tui::types::MessageId;
/// ///
/// let message = MessageBuilder::new(MessageId::new(123)) /// let message = MessageBuilder::new(MessageId::new(123))
/// .sender_name("Alice") /// .sender_name("Alice")
/// .text("Hello, world!") /// .text("Hello, world!")
@@ -494,6 +500,7 @@ impl MessageBuilder {
} }
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -561,7 +568,9 @@ 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(), count: 5, is_chosen: true emoji: "👍".to_string(),
count: 5,
is_chosen: true,
}; };
let message = MessageBuilder::new(MessageId::new(300)) let message = MessageBuilder::new(MessageId::new(300))
@@ -619,9 +628,9 @@ mod tests {
.entities(vec![TextEntity { .entities(vec![TextEntity {
offset: 6, offset: 6,
length: 4, length: 4,
r#type: TextEntityType::MentionName(tdlib_rs::types::TextEntityTypeMentionName { r#type: TextEntityType::MentionName(
user_id: 123, tdlib_rs::types::TextEntityTypeMentionName { user_id: 123 },
}), ),
}]) }])
.build(); .build();
assert!(message_with_mention_name.has_mention()); assert!(message_with_mention_name.has_mention());
@@ -697,7 +706,6 @@ pub struct ImageModalState {
} }
/// Состояние воспроизведения голосового сообщения /// Состояние воспроизведения голосового сообщения
#[allow(dead_code)]
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct PlaybackState { pub struct PlaybackState {
/// ID сообщения, которое воспроизводится /// ID сообщения, которое воспроизводится
@@ -713,7 +721,6 @@ pub struct PlaybackState {
} }
/// Статус воспроизведения /// Статус воспроизведения
#[allow(dead_code)]
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub enum PlaybackStatus { pub enum PlaybackStatus {
Playing, Playing,

View File

@@ -5,10 +5,12 @@
use crate::types::{ChatId, MessageId, UserId}; use crate::types::{ChatId, MessageId, UserId};
use std::time::Instant; use std::time::Instant;
use tdlib_rs::enums::{AuthorizationState, ChatAction, ChatList, MessageSender}; use tdlib_rs::enums::{
AuthorizationState, ChatAction, ChatList, MessageSender,
};
use tdlib_rs::types::{ use tdlib_rs::types::{
UpdateChatAction, UpdateChatDraftMessage, UpdateChatPosition, UpdateMessageInteractionInfo, UpdateChatAction, UpdateChatDraftMessage, UpdateChatPosition,
UpdateMessageSendSucceeded, UpdateNewMessage, UpdateUser, UpdateMessageInteractionInfo, UpdateMessageSendSucceeded, UpdateNewMessage, UpdateUser,
}; };
use super::auth::AuthState; use super::auth::AuthState;
@@ -23,24 +25,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 = let msg_info = crate::tdlib::message_converter::convert_message(client, &new_msg.message, chat_id);
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 let _ = client.notification_manager.notify_new_message(
.notification_manager &chat,
.notify_new_message(&chat, &msg_info, sender_name); &msg_info,
sender_name,
);
} }
return; return;
} }
// Добавляем новое сообщение если это текущий открытый чат // Добавляем новое сообщение если это текущий открытый чат
let msg_info = let msg_info = crate::tdlib::message_converter::convert_message(client, &new_msg.message, chat_id);
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();
@@ -72,9 +74,7 @@ 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 client.pending_view_messages_mut().push((chat_id, vec![msg_id]));
.pending_view_messages_mut()
.push((chat_id, vec![msg_id]));
} }
} }
} }
@@ -105,7 +105,7 @@ pub fn handle_chat_action_update(client: &mut TdClient, update: UpdateChatAction
ChatAction::ChoosingSticker => Some("выбирает стикер...".to_string()), ChatAction::ChoosingSticker => Some("выбирает стикер...".to_string()),
ChatAction::RecordingVideoNote => Some("записывает видеосообщение...".to_string()), ChatAction::RecordingVideoNote => Some("записывает видеосообщение...".to_string()),
ChatAction::UploadingVideoNote(_) => Some("отправляет видеосообщение...".to_string()), ChatAction::UploadingVideoNote(_) => Some("отправляет видеосообщение...".to_string()),
_ => None, // Отмена или неизвестное действие ChatAction::Cancel | _ => None, // Отмена или неизвестное действие
}; };
match action_text { match action_text {
@@ -181,21 +181,14 @@ 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 client.user_cache.user_names.insert(UserId::new(user.id), display_name);
.user_cache
.user_names
.insert(UserId::new(user.id), display_name);
// Сохраняем username если есть (с упрощённым извлечением через and_then) // Сохраняем username если есть (с упрощённым извлечением через and_then)
if let Some(username) = user if let Some(username) = user.usernames
.usernames
.as_ref() .as_ref()
.and_then(|u| u.active_usernames.first()) .and_then(|u| u.active_usernames.first())
{ {
client client.user_cache.user_usernames.insert(UserId::new(user.id), username.to_string());
.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) {
@@ -280,8 +273,7 @@ pub fn handle_message_send_succeeded_update(
}; };
// Конвертируем новое сообщение // Конвертируем новое сообщение
let mut new_msg = let mut new_msg = crate::tdlib::message_converter::convert_message(client, &update.message, chat_id);
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,9 +175,7 @@ impl UserCache {
} }
// Сохраняем имя // Сохраняем имя
let display_name = format!("{} {}", user.first_name, user.last_name) let display_name = format!("{} {}", user.first_name, user.last_name).trim().to_string();
.trim()
.to_string();
self.user_names.insert(UserId::new(user_id), display_name); self.user_names.insert(UserId::new(user_id), display_name);
// Обновляем статус // Обновляем статус
@@ -213,7 +211,6 @@ impl UserCache {
/// # Returns /// # Returns
/// ///
/// Имя пользователя (first_name + last_name) или "User {id}" если не найден. /// Имя пользователя (first_name + last_name) или "User {id}" если не найден.
#[allow(dead_code)]
pub async fn get_user_name(&self, user_id: UserId) -> String { pub async fn get_user_name(&self, user_id: UserId) -> String {
// Сначала пытаемся получить из кэша // Сначала пытаемся получить из кэша
if let Some(name) = self.user_names.peek(&user_id) { if let Some(name) = self.user_names.peek(&user_id) {
@@ -223,9 +220,7 @@ 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) let name = format!("{} {}", user.first_name, user.last_name).trim().to_string();
.trim()
.to_string();
name name
} }
_ => format!("User {}", user_id.as_i64()), _ => format!("User {}", user_id.as_i64()),
@@ -262,7 +257,8 @@ impl UserCache {
} }
Err(_) => { Err(_) => {
// Если не удалось загрузить, сохраняем placeholder // Если не удалось загрузить, сохраняем placeholder
self.user_names.insert(user_id, format!("User {}", user_id)); self.user_names
.insert(user_id, format!("User {}", user_id));
} }
} }
} }

View File

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

View File

@@ -1,6 +1,6 @@
use crate::app::App; use crate::app::App;
use crate::tdlib::AuthState;
use crate::tdlib::TdClientTrait; use crate::tdlib::TdClientTrait;
use crate::tdlib::AuthState;
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::methods::{compose::ComposeMethods, search::SearchMethods};
use crate::app::App; use crate::app::App;
use crate::app::methods::{compose::ComposeMethods, search::SearchMethods};
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,9 +76,7 @@ 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 app.chat_list_state.selected().and_then(|i| filtered.get(i).map(|c| c.id))
.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

@@ -21,7 +21,7 @@ pub fn render_emoji_picker(
) { ) {
// Размеры модалки (зависят от количества реакций) // Размеры модалки (зависят от количества реакций)
let emojis_per_row = 8; let emojis_per_row = 8;
let rows = available_reactions.len().div_ceil(emojis_per_row); let rows = (available_reactions.len() + emojis_per_row - 1) / emojis_per_row;
let modal_width = 50u16; let modal_width = 50u16;
let modal_height = (rows + 4) as u16; // +4 для заголовка, отступов и подсказки let modal_height = (rows + 4) as u16; // +4 для заголовка, отступов и подсказки
@@ -29,7 +29,12 @@ 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(x, y, modal_width.min(area.width), modal_height.min(area.height)); let modal_area = Rect::new(
x,
y,
modal_width.min(area.width),
modal_height.min(area.height),
);
// Очищаем область под модалкой // Очищаем область под модалкой
f.render_widget(Clear, modal_area); f.render_widget(Clear, modal_area);
@@ -82,7 +87,10 @@ pub fn render_emoji_picker(
.add_modifier(Modifier::BOLD), .add_modifier(Modifier::BOLD),
), ),
Span::raw("Добавить "), Span::raw("Добавить "),
Span::styled(" [Esc] ", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)), Span::styled(
" [Esc] ",
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
),
Span::raw("Отмена"), Span::raw("Отмена"),
])); ]));

View File

@@ -34,7 +34,10 @@ 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(cursor_char, Style::default().fg(Color::Black).bg(color))); spans.push(Span::styled(
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,7 +36,10 @@ 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 { text: String::new(), start_offset: 0 }); all_lines.push(WrappedLine {
text: String::new(),
start_offset: 0,
});
} }
all_lines all_lines
@@ -45,7 +48,10 @@ 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 { text: text.to_string(), start_offset: base_offset }]; return vec![WrappedLine {
text: text.to_string(),
start_offset: base_offset,
}];
} }
let mut result = Vec::new(); let mut result = Vec::new();
@@ -116,7 +122,10 @@ fn wrap_paragraph(text: &str, max_width: usize, base_offset: usize) -> Vec<Wrapp
} }
if result.is_empty() { if result.is_empty() {
result.push(WrappedLine { text: String::new(), start_offset: base_offset }); result.push(WrappedLine {
text: String::new(),
start_offset: base_offset,
});
} }
result result
@@ -129,11 +138,7 @@ 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( pub fn render_date_separator(date: i32, content_width: usize, is_first: bool) -> Vec<Line<'static>> {
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 {
@@ -271,8 +276,10 @@ 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 lines.push(Line::from(vec![Span::styled(
.push(Line::from(vec![Span::styled(reply_line, Style::default().fg(Color::Cyan))])); reply_line,
Style::default().fg(Color::Cyan),
)]));
} }
} }
@@ -294,13 +301,9 @@ 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 = formatting::adjust_entities_for_substring( let line_entities =
msg.entities(), formatting::adjust_entities_for_substring(msg.entities(), wrapped.start_offset, line_len);
wrapped.start_offset, let formatted_spans = formatting::format_text_with_entities(&wrapped.text, &line_entities, msg_color);
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;
@@ -310,19 +313,14 @@ pub fn render_message_bubble(
// Одна строка — маркер на ней // Одна строка — маркер на ней
line_spans.push(Span::styled( line_spans.push(Span::styled(
selection_marker, selection_marker,
Style::default() Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD),
.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( line_spans.push(Span::styled(format!(" {}", time_mark), Style::default().fg(Color::Gray)));
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);
@@ -330,9 +328,7 @@ 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() Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD),
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)); ));
} else if is_selected { } else if is_selected {
// Средние строки multi-line — пробелы вместо маркера // Средние строки multi-line — пробелы вместо маркера
@@ -354,26 +350,19 @@ 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 = formatting::adjust_entities_for_substring( let line_entities =
msg.entities(), formatting::adjust_entities_for_substring(msg.entities(), wrapped.start_offset, line_len);
wrapped.start_offset, let formatted_spans = formatting::format_text_with_entities(&wrapped.text, &line_entities, msg_color);
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() Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD),
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)); ));
} }
line_spans line_spans.push(Span::styled(format!(" {}", time_str), Style::default().fg(Color::Gray)));
.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));
@@ -401,10 +390,12 @@ pub fn render_message_bubble(
} else { } else {
format!("[{}]", reaction.emoji) format!("[{}]", reaction.emoji)
} }
} else if reaction.count > 1 {
format!("{} {}", reaction.emoji, reaction.count)
} else { } else {
reaction.emoji.clone() if reaction.count > 1 {
format!("{} {}", reaction.emoji, reaction.count)
} else {
reaction.emoji.clone()
}
}; };
let style = if reaction.is_chosen { let style = if reaction.is_chosen {
@@ -448,7 +439,10 @@ 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!("{} {} {:.0}s/{:.0}s", icon, bar, ps.position, ps.duration) format!(
"{} {} {:.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)
@@ -462,7 +456,10 @@ 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(status_line, Style::default().fg(Color::Cyan)))); lines.push(Line::from(Span::styled(
status_line,
Style::default().fg(Color::Cyan),
)));
} }
} }
} }
@@ -480,8 +477,10 @@ pub fn render_message_bubble(
Span::styled(status, Style::default().fg(Color::Yellow)), Span::styled(status, Style::default().fg(Color::Yellow)),
])); ]));
} else { } else {
lines lines.push(Line::from(Span::styled(
.push(Line::from(Span::styled(status, Style::default().fg(Color::Yellow)))); status,
Style::default().fg(Color::Yellow),
)));
} }
} }
PhotoDownloadState::Error(e) => { PhotoDownloadState::Error(e) => {
@@ -493,7 +492,10 @@ 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(status, Style::default().fg(Color::Red)))); lines.push(Line::from(Span::styled(
status,
Style::default().fg(Color::Red),
)));
} }
} }
PhotoDownloadState::Downloaded(_) => { PhotoDownloadState::Downloaded(_) => {
@@ -538,15 +540,13 @@ 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::{ use crate::constants::{ALBUM_GRID_MAX_COLS, ALBUM_PHOTO_GAP, ALBUM_PHOTO_HEIGHT, ALBUM_PHOTO_WIDTH};
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();
let is_selected = messages.iter().any(|m| selected_msg_id == Some(m.id())); let is_selected = messages.iter().any(|m| selected_msg_id == Some(m.id()));
let is_outgoing = messages.first().is_some_and(|m| m.is_outgoing()); let is_outgoing = messages.first().map_or(false, |m| m.is_outgoing());
// Selection marker // Selection marker
let selection_marker = if is_selected { "" } else { "" }; let selection_marker = if is_selected { "" } else { "" };
@@ -565,16 +565,16 @@ pub fn render_album_bubble(
// Grid layout // Grid layout
let cols = photo_count.min(ALBUM_GRID_MAX_COLS); let cols = photo_count.min(ALBUM_GRID_MAX_COLS);
let rows = photo_count.div_ceil(cols); let rows = (photo_count + cols - 1) / cols;
// Добавляем маркер выбора на первую строку // Добавляем маркер выбора на первую строку
if is_selected { if is_selected {
lines.push(Line::from(vec![Span::styled( lines.push(Line::from(vec![
selection_marker, Span::styled(
Style::default() selection_marker,
.fg(Color::Yellow) Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD),
.add_modifier(Modifier::BOLD), ),
)])); ]));
} }
let grid_start_line = lines.len(); let grid_start_line = lines.len();
@@ -608,9 +608,7 @@ 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 let padding = content_width.saturating_sub(grid_width as usize + 1) as u16;
.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)
@@ -619,8 +617,7 @@ 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 line_offset: grid_start_line + row * ALBUM_PHOTO_HEIGHT as usize,
+ 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,
@@ -647,7 +644,10 @@ 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("📷", Style::default().fg(Color::Gray))); spans.push(Span::styled(
"📷",
Style::default().fg(Color::Gray),
));
} }
} }
} }
@@ -706,10 +706,9 @@ 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![Span::styled( lines.push(Line::from(vec![
format!(" {}", time_text), Span::styled(format!(" {}", time_text), Style::default().fg(Color::Gray)),
Style::default().fg(Color::Gray), ]));
)]));
} }
} }

View File

@@ -91,10 +91,7 @@ pub fn calculate_scroll_offset(
} }
/// Renders a help bar with keyboard shortcuts /// Renders a help bar with keyboard shortcuts
pub fn render_help_bar( pub fn render_help_bar(shortcuts: &[(&str, &str, Color)], border_color: Color) -> Paragraph<'static> {
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 {
@@ -102,7 +99,9 @@ pub fn render_help_bar(
} }
spans.push(Span::styled( spans.push(Span::styled(
format!(" {} ", key), format!(" {} ", key),
Style::default().fg(*color).add_modifier(Modifier::BOLD), Style::default()
.fg(*color)
.add_modifier(Modifier::BOLD),
)); ));
spans.push(Span::raw(label.to_string())); 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 chat_list_item; pub mod modal;
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 modal; pub mod chat_list_item;
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 input_field::render_input_field;
#[cfg(feature = "images")]
pub use message_bubble::{calculate_image_height, render_album_bubble, DeferredImageRender};
pub use message_bubble::{render_date_separator, render_message_bubble, render_sender_header}; pub use message_bubble::{render_date_separator, render_message_bubble, render_sender_header};
pub use message_list::{calculate_scroll_offset, render_help_bar, render_message_item}; #[cfg(feature = "images")]
pub use message_bubble::{DeferredImageRender, calculate_image_height, render_album_bubble};
pub use message_list::{render_message_item, calculate_scroll_offset, render_help_bar};

View File

@@ -74,7 +74,10 @@ pub fn render_delete_confirm_modal(f: &mut Frame, area: Rect) {
), ),
Span::raw("Да"), Span::raw("Да"),
Span::raw(" "), Span::raw(" "),
Span::styled(" [n/Esc] ", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)), Span::styled(
" [n/Esc] ",
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
),
Span::raw("Нет"), 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,18 +124,13 @@ 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![Span::styled( let line = Line::from(vec![
"> Press i to type...", Span::styled("> Press i to type...", Style::default().fg(Color::DarkGray)),
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 { let ellipsis = if app.message_input.chars().count() > 60 { "..." } else { "" };
"..."
} 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),
@@ -168,9 +163,7 @@ 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() Block::default().borders(Borders::ALL).border_style(border_style)
.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::NetworkState;
use crate::tdlib::TdClientTrait; use crate::tdlib::TdClientTrait;
use crate::tdlib::NetworkState;
use ratatui::{ use ratatui::{
layout::Rect, layout::Rect,
style::{Color, Style}, style::{Color, Style},
@@ -31,10 +31,7 @@ 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!( format!(" {}{}↑/↓: Navigate | Enter: Select | Esc: Cancel ", account_indicator, network_indicator)
" {}{}↑/↓: 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::methods::{messages::MessageMethods, modal::ModalMethods, search::SearchMethods};
use crate::app::App; use crate::app::App;
use crate::message_grouping::{group_messages, MessageGroup}; use crate::app::methods::{messages::MessageMethods, modal::ModalMethods, search::SearchMethods};
use crate::tdlib::TdClientTrait; use crate::tdlib::TdClientTrait;
use crate::message_grouping::{group_messages, MessageGroup};
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,12 +18,7 @@ use ratatui::{
}; };
/// Рендерит заголовок чата с typing status /// Рендерит заголовок чата с typing status
fn render_chat_header<T: TdClientTrait>( fn render_chat_header<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>, chat: &crate::tdlib::ChatInfo) {
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()
@@ -39,7 +34,10 @@ fn render_chat_header<T: TdClientTrait>(
.add_modifier(Modifier::BOLD), .add_modifier(Modifier::BOLD),
)]; )];
if let Some(username) = &chat.username { if let Some(username) = &chat.username {
spans.push(Span::styled(format!(" {}", username), Style::default().fg(Color::Gray))); spans.push(Span::styled(
format!(" {}", username),
Style::default().fg(Color::Gray),
));
} }
spans.push(Span::styled( spans.push(Span::styled(
format!(" {}", action), format!(" {}", action),
@@ -92,7 +90,8 @@ 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 = Paragraph::new(pinned_line).style(Style::default().bg(Color::Rgb(40, 20, 40))); let pinned_bar =
Paragraph::new(pinned_line).style(Style::default().bg(Color::Rgb(40, 20, 40)));
f.render_widget(pinned_bar, area); f.render_widget(pinned_bar, area);
} }
@@ -105,7 +104,9 @@ 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 { text: text.to_string() }]; return vec![WrappedLine {
text: text.to_string(),
}];
} }
let mut result = Vec::new(); let mut result = Vec::new();
@@ -130,7 +131,9 @@ 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 { text: current_line }); result.push(WrappedLine {
text: current_line,
});
current_line = word; current_line = word;
current_width = word_width; current_width = word_width;
} }
@@ -152,17 +155,23 @@ 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 { text: current_line }); result.push(WrappedLine {
text: current_line,
});
current_line = word; current_line = word;
} }
} }
if !current_line.is_empty() { if !current_line.is_empty() {
result.push(WrappedLine { text: current_line }); result.push(WrappedLine {
text: current_line,
});
} }
if result.is_empty() { if result.is_empty() {
result.push(WrappedLine { text: String::new() }); result.push(WrappedLine {
text: String::new(),
});
} }
result result
@@ -199,7 +208,10 @@ 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 { is_outgoing, sender_name } => { MessageGroup::SenderHeader {
is_outgoing,
sender_name,
} => {
// Рендерим заголовок отправителя // Рендерим заголовок отправителя
lines.extend(components::render_sender_header( lines.extend(components::render_sender_header(
is_outgoing, is_outgoing,
@@ -228,16 +240,9 @@ 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) = if let crate::tdlib::PhotoDownloadState::Downloaded(path) = &photo.download_state {
&photo.download_state let inline_width = content_width.min(crate::constants::INLINE_IMAGE_MAX_WIDTH);
{ let img_height = components::calculate_image_height(photo.width, photo.height, inline_width);
let 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;
@@ -309,7 +314,11 @@ fn render_message_list<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &mut Ap
let total_lines = lines.len(); let total_lines = lines.len();
// Базовый скролл (показываем последние сообщения) // Базовый скролл (показываем последние сообщения)
let base_scroll = total_lines.saturating_sub(visible_height); let base_scroll = if total_lines > visible_height {
total_lines - visible_height
} else {
0
};
// Если выбрано сообщение, автоскроллим к нему // Если выбрано сообщение, автоскроллим к нему
let scroll_offset = if app.is_selecting_message() { let scroll_offset = if app.is_selecting_message() {
@@ -343,8 +352,7 @@ 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 let should_render_images = app.last_image_render_time
.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);
@@ -376,7 +384,7 @@ fn render_message_list<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &mut Ap
if let Some(renderer) = &mut app.inline_image_renderer { if let Some(renderer) = &mut app.inline_image_renderer {
// Загружаем только если видимо (early return если уже в кеше) // Загружаем только если видимо (early return если уже в кеше)
let _ = renderer.load_image(d.message_id, &d.photo_path); let _ = renderer.load_image(d.message_id, &d.photo_path);
if let Some(protocol) = renderer.get_protocol(&d.message_id) { if let Some(protocol) = renderer.get_protocol(&d.message_id) {
f.render_stateful_widget(StatefulImage::default(), img_rect, protocol); f.render_stateful_widget(StatefulImage::default(), img_rect, protocol);
} }
@@ -427,7 +435,7 @@ pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &mut App<T>) {
1 1
}; };
// Минимум 3 строки (1 контент + 2 рамки), максимум 10 // Минимум 3 строки (1 контент + 2 рамки), максимум 10
let input_height = (input_lines + 2).clamp(3, 10); let input_height = (input_lines + 2).min(10).max(3);
// Проверяем, есть ли закреплённое сообщение // Проверяем, есть ли закреплённое сообщение
let has_pinned = app.td_client.current_pinned_message().is_some(); let has_pinned = app.td_client.current_pinned_message().is_some();
@@ -479,9 +487,14 @@ pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &mut App<T>) {
} }
// Модалка выбора реакции // Модалка выбора реакции
if let crate::app::ChatState::ReactionPicker { available_reactions, selected_index, .. } = if let crate::app::ChatState::ReactionPicker {
&app.chat_state available_reactions,
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;
pub mod components;
mod compose_bar; mod compose_bar;
pub mod components;
pub mod footer; pub mod footer;
mod loading; mod loading;
mod main_screen; mod main_screen;

View File

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

View File

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

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::{calculate_scroll_offset, render_help_bar, render_message_item}; use crate::ui::components::{render_message_item, calculate_scroll_offset, render_help_bar};
use ratatui::{ use ratatui::{
layout::{Constraint, Direction, Layout, Rect}, layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style}, style::{Color, Modifier, Style},
@@ -14,13 +14,15 @@ 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) = let (messages, selected_index) = if let crate::app::ChatState::PinnedMessages {
if let crate::app::ChatState::PinnedMessages { messages, selected_index } = &app.chat_state messages,
{ selected_index,
(messages.as_slice(), *selected_index) } = &app.chat_state
} else { {
return; // Некорректное состояние (messages.as_slice(), *selected_index)
}; } else {
return; // Некорректное состояние
};
let chunks = Layout::default() let chunks = Layout::default()
.direction(Direction::Vertical) .direction(Direction::Vertical)

View File

@@ -1,8 +1,13 @@
//! Reaction picker modal //! Reaction picker modal
use ratatui::{layout::Rect, Frame}; use ratatui::{Frame, layout::Rect};
/// Renders emoji reaction picker modal /// Renders emoji reaction picker modal
pub fn render(f: &mut Frame, area: Rect, available_reactions: &[String], selected_index: usize) { pub fn render(
f: &mut Frame,
area: Rect,
available_reactions: &[String],
selected_index: usize,
) {
crate::ui::components::render_emoji_picker(f, area, available_reactions, selected_index); 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::{calculate_scroll_offset, render_help_bar, render_message_item}; use crate::ui::components::{render_message_item, calculate_scroll_offset, render_help_bar};
use ratatui::{ use ratatui::{
layout::{Constraint, Direction, Layout, Rect}, layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style}, style::{Color, Modifier, Style},
@@ -15,8 +15,11 @@ 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 { query, results, selected_index } = if let crate::app::ChatState::SearchInChat {
&app.chat_state query,
results,
selected_index,
} = &app.chat_state
{ {
(query.as_str(), results.as_slice(), *selected_index) (query.as_str(), results.as_slice(), *selected_index)
} else { } else {
@@ -34,7 +37,11 @@ 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 { selected_index + 1 } else { 0 }; let current = if total > 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::methods::modal::ModalMethods;
use crate::app::App; use crate::app::App;
use crate::tdlib::ProfileInfo; use crate::app::methods::modal::ModalMethods;
use crate::tdlib::TdClientTrait; use crate::tdlib::TdClientTrait;
use crate::tdlib::ProfileInfo;
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_ignore, with_timeout_msg}; pub use retry::{with_timeout, with_timeout_msg, with_timeout_ignore};
pub use tdlib::*; pub use tdlib::*;
pub use validation::*; pub use validation::*;

View File

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

View File

@@ -17,7 +17,11 @@ 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 { accounts, selected_index, current_account }) => { Some(AccountSwitcherState::SelectAccount {
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");
@@ -54,7 +58,11 @@ fn test_account_switcher_navigate_down() {
} }
match &app.account_switcher { match &app.account_switcher {
Some(AccountSwitcherState::SelectAccount { selected_index, accounts, .. }) => { Some(AccountSwitcherState::SelectAccount {
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());
} }
@@ -129,7 +137,11 @@ 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 { name_input, cursor_position, error }) => { Some(AccountSwitcherState::AddAccount {
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,6 +1,8 @@
// Integration tests for accounts module // Integration tests for accounts module
use tele_tui::accounts::{account_db_path, validate_account_name, AccountProfile, AccountsConfig}; use tele_tui::accounts::{
account_db_path, validate_account_name, AccountProfile, AccountsConfig,
};
#[test] #[test]
fn test_default_single_config() { fn test_default_single_config() {

View File

@@ -65,14 +65,16 @@ fn test_incoming_message_shows_unread_badge() {
.last_message("Как дела?") .last_message("Как дела?")
.build(); .build();
let mut app = TestAppBuilder::new().with_chat(chat).build(); let mut app = TestAppBuilder::new()
.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| {
tele_tui::ui::chat_list::render(f, f.area(), &mut app); tele_tui::ui::chat_list::render(f, f.area(), &mut app);
}); });
let output_before = buffer_to_string(&buffer_before); let output_before = buffer_to_string(&buffer_before);
// Проверяем что нет "(1)" в первой строке чата // Проверяем что нет "(1)" в первой строке чата
assert!(!output_before.contains("(1)"), "Before: should not contain (1)"); assert!(!output_before.contains("(1)"), "Before: should not contain (1)");
@@ -85,13 +87,9 @@ fn test_incoming_message_shows_unread_badge() {
tele_tui::ui::chat_list::render(f, f.area(), &mut app); tele_tui::ui::chat_list::render(f, f.area(), &mut app);
}); });
let output_after = buffer_to_string(&buffer_after); let output_after = buffer_to_string(&buffer_after);
// Проверяем что появилось "(1)" в первой строке чата // Проверяем что появилось "(1)" в первой строке чата
assert!( assert!(output_after.contains("(1)"), "After: should contain (1)\nActual output:\n{}", output_after);
output_after.contains("(1)"),
"After: should contain (1)\nActual output:\n{}",
output_after
);
} }
#[tokio::test] #[tokio::test]
@@ -129,44 +127,39 @@ async fn test_opening_chat_clears_unread_badge() {
tele_tui::ui::chat_list::render(f, f.area(), &mut app); tele_tui::ui::chat_list::render(f, f.area(), &mut app);
}); });
let output_before = buffer_to_string(&buffer_before); let output_before = buffer_to_string(&buffer_before);
// Проверяем что есть "(3)" в списке чатов // Проверяем что есть "(3)" в списке чатов
assert!( assert!(output_before.contains("(3)"), "Before opening: should contain (3)\nActual output:\n{}", output_before);
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);
let loaded_messages = app.td_client.get_chat_history(chat_id, 100).await.unwrap(); let loaded_messages = app.td_client.get_chat_history(chat_id, 100).await.unwrap();
// Собираем ID входящих сообщений (как в реальном коде) // Собираем ID входящих сообщений (как в реальном коде)
let incoming_message_ids: Vec<MessageId> = loaded_messages let incoming_message_ids: Vec<MessageId> = loaded_messages
.iter() .iter()
.filter(|msg| !msg.is_outgoing()) .filter(|msg| !msg.is_outgoing())
.map(|msg| msg.id()) .map(|msg| msg.id())
.collect(); .collect();
// Проверяем что нашли 3 входящих сообщения // Проверяем что нашли 3 входящих сообщения
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 app.td_client.pending_view_messages
.pending_view_messages
.lock() .lock()
.unwrap() .unwrap()
.push((chat_id, incoming_message_ids)); .push((chat_id, incoming_message_ids));
// Обрабатываем очередь (как в main loop) // Обрабатываем очередь (как в main loop)
app.td_client.process_pending_view_messages().await; app.td_client.process_pending_view_messages().await;
// В FakeTdClient это должно записаться в viewed_messages // В FakeTdClient это должно записаться в viewed_messages
let viewed = app.td_client.get_viewed_messages(); let viewed = app.td_client.get_viewed_messages();
assert_eq!(viewed.len(), 1, "Should have one batch of viewed messages"); assert_eq!(viewed.len(), 1, "Should have one batch of viewed messages");
assert_eq!(viewed[0].0, 999, "Should be for chat 999"); assert_eq!(viewed[0].0, 999, "Should be for chat 999");
assert_eq!(viewed[0].1.len(), 3, "Should have viewed 3 messages"); assert_eq!(viewed[0].1.len(), 3, "Should have viewed 3 messages");
// В реальном приложении TDLib отправит Update::ChatReadInbox // В реальном приложении TDLib отправит Update::ChatReadInbox
// который обновит unread_count в чате. Симулируем это: // который обновит unread_count в чате. Симулируем это:
app.chats[0].unread_count = 0; app.chats[0].unread_count = 0;
@@ -176,13 +169,9 @@ async fn test_opening_chat_clears_unread_badge() {
tele_tui::ui::chat_list::render(f, f.area(), &mut app); tele_tui::ui::chat_list::render(f, f.area(), &mut app);
}); });
let output_after = buffer_to_string(&buffer_after); let output_after = buffer_to_string(&buffer_after);
// Проверяем что "(3)" больше нет // Проверяем что "(3)" больше нет
assert!( assert!(!output_after.contains("(3)"), "After opening: should not contain (3)\nActual output:\n{}", output_after);
!output_after.contains("(3)"),
"After opening: should not contain (3)\nActual output:\n{}",
output_after
);
} }
#[tokio::test] #[tokio::test]
@@ -213,7 +202,7 @@ async fn test_opening_chat_loads_many_messages() {
// Открываем чат - загружаем историю (запрашиваем 100 сообщений) // Открываем чат - загружаем историю (запрашиваем 100 сообщений)
let chat_id = ChatId::new(888); let chat_id = ChatId::new(888);
let loaded_messages = app.td_client.get_chat_history(chat_id, 100).await.unwrap(); let loaded_messages = app.td_client.get_chat_history(chat_id, 100).await.unwrap();
// Проверяем что загрузились ВСЕ 50 сообщений, а не только последние 2-3 // Проверяем что загрузились ВСЕ 50 сообщений, а не только последние 2-3
assert_eq!( assert_eq!(
loaded_messages.len(), loaded_messages.len(),
@@ -255,7 +244,7 @@ async fn test_chat_history_chunked_loading() {
// Тест 1: Загружаем 100 сообщений (больше чем 50, меньше чем 120) // Тест 1: Загружаем 100 сообщений (больше чем 50, меньше чем 120)
let chat_id = ChatId::new(999); let chat_id = ChatId::new(999);
let loaded_messages = app.td_client.get_chat_history(chat_id, 100).await.unwrap(); let loaded_messages = app.td_client.get_chat_history(chat_id, 100).await.unwrap();
assert_eq!( assert_eq!(
loaded_messages.len(), loaded_messages.len(),
100, 100,
@@ -265,13 +254,13 @@ async fn test_chat_history_chunked_loading() {
// Проверяем что сообщения в правильном порядке (от старых к новым) // Проверяем что сообщения в правильном порядке (от старых к новым)
assert_eq!(loaded_messages[0].text(), "Message 1"); assert_eq!(loaded_messages[0].text(), "Message 1");
assert_eq!(loaded_messages[49].text(), "Message 50"); // Граница первого чанка assert_eq!(loaded_messages[49].text(), "Message 50"); // Граница первого чанка
assert_eq!(loaded_messages[50].text(), "Message 51"); // Начало второго чанка assert_eq!(loaded_messages[50].text(), "Message 51"); // Начало второго чанка
assert_eq!(loaded_messages[99].text(), "Message 100"); assert_eq!(loaded_messages[99].text(), "Message 100");
// Тест 2: Загружаем все 120 сообщений // Тест 2: Загружаем все 120 сообщений
let all_messages = app.td_client.get_chat_history(chat_id, 120).await.unwrap(); let all_messages = app.td_client.get_chat_history(chat_id, 120).await.unwrap();
assert_eq!( assert_eq!(
all_messages.len(), all_messages.len(),
120, 120,
@@ -284,7 +273,7 @@ async fn test_chat_history_chunked_loading() {
// Тест 3: Запрашиваем 200 сообщений, но есть только 120 // Тест 3: Запрашиваем 200 сообщений, но есть только 120
let limited_messages = app.td_client.get_chat_history(chat_id, 200).await.unwrap(); let limited_messages = app.td_client.get_chat_history(chat_id, 200).await.unwrap();
assert_eq!( assert_eq!(
limited_messages.len(), limited_messages.len(),
120, 120,
@@ -318,12 +307,8 @@ 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 let all = app.td_client.get_chat_history(chat_id, i32::MAX).await.unwrap();
.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");
assert_eq!(all[199].text(), "Msg 200", "Last message should be newest"); assert_eq!(all[199].text(), "Msg 200", "Last message should be newest");
@@ -353,29 +338,25 @@ async fn test_load_older_messages_pagination() {
.build(); .build();
let chat_id = ChatId::new(1002); let chat_id = ChatId::new(1002);
// Шаг 1: Загружаем только последние 30 сообщений // Шаг 1: Загружаем только последние 30 сообщений
// get_chat_history загружает от конца, поэтому получим сообщения 1-30 // get_chat_history загружает от конца, поэтому получим сообщения 1-30
let initial_batch = app.td_client.get_chat_history(chat_id, 30).await.unwrap(); let initial_batch = app.td_client.get_chat_history(chat_id, 30).await.unwrap();
assert_eq!(initial_batch.len(), 30, "Should load 30 messages initially"); assert_eq!(initial_batch.len(), 30, "Should load 30 messages initially");
assert_eq!(initial_batch[0].text(), "Msg 1", "First message should be Msg 1"); assert_eq!(initial_batch[0].text(), "Msg 1", "First message should be Msg 1");
assert_eq!(initial_batch[29].text(), "Msg 30", "Last should be Msg 30"); assert_eq!(initial_batch[29].text(), "Msg 30", "Last should be Msg 30");
// Шаг 2: Загружаем все 150 сообщений для проверки load_older // Шаг 2: Загружаем все 150 сообщений для проверки load_older
let all_messages = app.td_client.get_chat_history(chat_id, 150).await.unwrap(); let all_messages = app.td_client.get_chat_history(chat_id, 150).await.unwrap();
assert_eq!(all_messages.len(), 150); assert_eq!(all_messages.len(), 150);
// Имитируем ситуацию: у нас есть сообщения 101-150, хотим загрузить 51-100 // Имитируем ситуацию: у нас есть сообщения 101-150, хотим загрузить 51-100
// Берем ID сообщения 101 (первое в нашем "окне") // Берем ID сообщения 101 (первое в нашем "окне")
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 let older_batch = app.td_client.load_older_messages(chat_id, msg_101_id).await.unwrap();
.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");
assert_eq!(older_batch[0].text(), "Msg 1", "Oldest should be Msg 1"); assert_eq!(older_batch[0].text(), "Msg 1", "Oldest should be Msg 1");
@@ -492,7 +473,7 @@ fn snapshot_chat_search_mode() {
fn snapshot_chat_with_online_status() { fn snapshot_chat_with_online_status() {
use tele_tui::tdlib::UserOnlineStatus; use tele_tui::tdlib::UserOnlineStatus;
use tele_tui::types::ChatId; use tele_tui::types::ChatId;
let chat = TestChatBuilder::new("Alice", 123) let chat = TestChatBuilder::new("Alice", 123)
.last_message("Hey there!") .last_message("Hey there!")
.build(); .build();
@@ -512,3 +493,4 @@ 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,9 +1,6 @@
// Integration tests for config flow // Integration tests for config flow
use tele_tui::config::{ use tele_tui::config::{AudioConfig, Config, ColorsConfig, GeneralConfig, ImagesConfig, Keybindings, NotificationsConfig};
AudioConfig, ColorsConfig, Config, GeneralConfig, ImagesConfig, Keybindings,
NotificationsConfig,
};
/// Test: Дефолтные значения конфигурации /// Test: Дефолтные значения конфигурации
#[test] #[test]
@@ -25,7 +22,9 @@ 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 { timezone: "+05:00".to_string() }, general: GeneralConfig {
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(),
@@ -109,7 +108,9 @@ 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 { timezone: "-05:00".to_string() }, general: GeneralConfig {
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(),
@@ -163,19 +164,25 @@ mod timezone_tests {
#[test] #[test]
fn test_timezone_formats() { fn test_timezone_formats() {
let positive = Config { let positive = Config {
general: GeneralConfig { timezone: "+03:00".to_string() }, general: GeneralConfig {
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 { timezone: "-05:00".to_string() }, general: GeneralConfig {
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 { timezone: "+00:00".to_string() }, general: GeneralConfig {
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,19 +12,13 @@ async fn test_delete_message_removes_from_list() {
let client = FakeTdClient::new(); let client = FakeTdClient::new();
// Отправляем сообщение // Отправляем сообщение
let msg = client let msg = client.send_message(ChatId::new(123), "Delete me".to_string(), None, None).await.unwrap();
.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 client.delete_messages(ChatId::new(123), vec![msg.id()], false).await.unwrap();
.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);
@@ -40,30 +34,15 @@ async fn test_delete_multiple_messages() {
let client = FakeTdClient::new(); let client = FakeTdClient::new();
// Отправляем 3 сообщения // Отправляем 3 сообщения
let msg1 = client let msg1 = client.send_message(ChatId::new(123), "Message 1".to_string(), None, None).await.unwrap();
.send_message(ChatId::new(123), "Message 1".to_string(), None, None) let msg2 = client.send_message(ChatId::new(123), "Message 2".to_string(), None, None).await.unwrap();
.await let msg3 = client.send_message(ChatId::new(123), "Message 3".to_string(), None, None).await.unwrap();
.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 client.delete_messages(ChatId::new(123), vec![msg1.id()], false).await.unwrap();
.delete_messages(ChatId::new(123), vec![msg1.id()], false) client.delete_messages(ChatId::new(123), vec![msg3.id()], false).await.unwrap();
.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);
@@ -110,18 +89,12 @@ async fn test_delete_nonexistent_message() {
let client = FakeTdClient::new(); let client = FakeTdClient::new();
// Отправляем одно сообщение // Отправляем одно сообщение
let msg = client let msg = client.send_message(ChatId::new(123), "Exists".to_string(), None, None).await.unwrap();
.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 client.delete_messages(ChatId::new(123), vec![MessageId::new(999)], false).await.unwrap();
.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);
@@ -139,10 +112,7 @@ 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 let msg = client.send_message(ChatId::new(123), "To delete".to_string(), None, None).await.unwrap();
.send_message(ChatId::new(123), "To delete".to_string(), None, None)
.await
.unwrap();
// Шаг 1: Пользователь нажал 'd' -> показывается модалка (в App) // Шаг 1: Пользователь нажал 'd' -> показывается модалка (в App)
// В FakeTdClient просто проверяем что сообщение ещё есть // В FakeTdClient просто проверяем что сообщение ещё есть
@@ -150,10 +120,7 @@ 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 client.delete_messages(ChatId::new(123), vec![msg.id()], false).await.unwrap();
.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);
@@ -165,10 +132,7 @@ 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 let msg = client.send_message(ChatId::new(123), "Keep me".to_string(), None, None).await.unwrap();
.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 std::collections::HashMap;
use tele_tui::types::{ChatId, MessageId}; use tele_tui::types::{ChatId, MessageId};
use std::collections::HashMap;
/// Простая структура для хранения черновиков (как в реальном App) /// Простая структура для хранения черновиков (как в реальном App)
struct DraftManager { struct DraftManager {

View File

@@ -23,7 +23,10 @@ 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.with_chat(chat1).with_chat(chat2).with_chat(chat3); let client = client
.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();
@@ -55,7 +58,9 @@ async fn test_user_journey_open_chat_send_message() {
.outgoing() .outgoing()
.build(); .build();
let client = client.with_message(123, msg1).with_message(123, msg2); let client = client
.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();
@@ -72,10 +77,12 @@ 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 let _new_msg = client.send_message(
.send_message(ChatId::new(123), "What's for dinner?".to_string(), None, None) ChatId::new(123),
.await "What's for dinner?".to_string(),
.unwrap(); None,
None
).await.unwrap();
// 8. Проверяем что сообщение отправлено // 8. Проверяем что сообщение отправлено
assert_eq!(client.get_sent_messages().len(), 1); assert_eq!(client.get_sent_messages().len(), 1);
@@ -146,43 +153,34 @@ 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( client.simulate_incoming_message(ChatId::new(789), "How's the project going?".to_string(), "Alice");
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 client.send_message(
.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( client.simulate_incoming_message(ChatId::new(789), "Great! Let me know if you need help.".to_string(), "Alice");
ChatId::new(789),
"Great! Let me know if you need help.".to_string(),
"Alice",
);
// 8. Снова отвечаем // 8. Снова отвечаем
client client.send_message(
.send_message(ChatId::new(789), "Will do, thanks!".to_string(), None, None) ChatId::new(789),
.await "Will do, thanks!".to_string(),
.unwrap(); None,
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();
@@ -221,20 +219,24 @@ 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 client.send_message(
.send_message(ChatId::new(111), "Message in chat 1".to_string(), None, None) ChatId::new(111),
.await "Message in chat 1".to_string(),
.unwrap(); None,
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 client.send_message(
.send_message(ChatId::new(222), "Message in chat 2".to_string(), None, None) ChatId::new(222),
.await "Message in chat 2".to_string(),
.unwrap(); None,
None
).await.unwrap();
// 6. Переключаемся на третий чат // 6. Переключаемся на третий чат
client.open_chat(ChatId::new(333)).await.unwrap(); client.open_chat(ChatId::new(333)).await.unwrap();
@@ -268,10 +270,12 @@ 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 let msg = client.send_message(
.send_message(ChatId::new(555), "I'll be there at 5pm tomorow".to_string(), None, None) ChatId::new(555),
.await "I'll be there at 5pm tomorow".to_string(),
.unwrap(); None,
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();
@@ -279,19 +283,17 @@ 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 client.edit_message(
.edit_message(ChatId::new(555), msg.id(), "I'll be there at 5pm tomorrow".to_string()) ChatId::new(555),
.await msg.id(),
.unwrap(); "I'll be there at 5pm tomorrow".to_string()
).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!( assert!(edited_history[0].metadata.edit_date > 0, "Должна быть установлена дата редактирования");
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);
@@ -313,11 +315,7 @@ async fn test_user_journey_reply_in_conversation() {
client.set_update_channel(tx); client.set_update_channel(tx);
// 3. Входящее сообщение с вопросом // 3. Входящее сообщение с вопросом
client.simulate_incoming_message( client.simulate_incoming_message(ChatId::new(666), "Can you send me the report?".to_string(), "Charlie");
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 { .. })));
@@ -326,10 +324,12 @@ 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 client.send_message(
.send_message(ChatId::new(666), "Working on it now".to_string(), None, None) ChatId::new(666),
.await "Working on it now".to_string(),
.unwrap(); None,
None
).await.unwrap();
// 5. Отвечаем на конкретный вопрос (reply) // 5. Отвечаем на конкретный вопрос (reply)
let reply_info = Some(tele_tui::tdlib::ReplyInfo { let reply_info = Some(tele_tui::tdlib::ReplyInfo {
@@ -338,15 +338,12 @@ 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 client.send_message(
.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();
@@ -379,10 +376,12 @@ 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 client.send_message(
.send_message(ChatId::new(888), "Test message".to_string(), None, None) ChatId::new(888),
.await "Test message".to_string(),
.unwrap(); None,
None
).await.unwrap();
// Очищаем канал от update NewMessage // Очищаем канал от update NewMessage
let _ = rx.try_recv(); let _ = rx.try_recv();
@@ -392,14 +391,8 @@ async fn test_user_journey_network_state_changes() {
// Проверяем update // Проверяем update
let update = rx.try_recv().ok(); let update = rx.try_recv().ok();
assert!( assert!(matches!(update, Some(TdUpdate::ConnectionState { state: NetworkState::WaitingForNetwork })),
matches!( "Expected ConnectionState update, got: {:?}", update);
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);
@@ -412,10 +405,12 @@ 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 client.send_message(
.send_message(ChatId::new(888), "Connection restored!".to_string(), None, None) ChatId::new(888),
.await "Connection restored!".to_string(),
.unwrap(); None,
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,16 +12,10 @@ async fn test_edit_message_changes_text() {
let client = FakeTdClient::new(); let client = FakeTdClient::new();
// Отправляем сообщение // Отправляем сообщение
let msg = client let msg = client.send_message(ChatId::new(123), "Original text".to_string(), None, None).await.unwrap();
.send_message(ChatId::new(123), "Original text".to_string(), None, None)
.await
.unwrap();
// Редактируем сообщение // Редактируем сообщение
client client.edit_message(ChatId::new(123), msg.id(), "Edited text".to_string()).await.unwrap();
.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);
@@ -40,10 +34,7 @@ async fn test_edit_message_sets_edit_date() {
let client = FakeTdClient::new(); let client = FakeTdClient::new();
// Отправляем сообщение // Отправляем сообщение
let msg = client let msg = client.send_message(ChatId::new(123), "Original".to_string(), None, None).await.unwrap();
.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);
@@ -51,10 +42,7 @@ 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 client.edit_message(ChatId::new(123), msg.id(), "Edited".to_string()).await.unwrap();
.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);
@@ -90,28 +78,16 @@ 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 let msg = client.send_message(ChatId::new(123), "Version 1".to_string(), None, None).await.unwrap();
.send_message(ChatId::new(123), "Version 1".to_string(), None, None)
.await
.unwrap();
// Первое редактирование // Первое редактирование
client client.edit_message(ChatId::new(123), msg.id(), "Version 2".to_string()).await.unwrap();
.edit_message(ChatId::new(123), msg.id(), "Version 2".to_string())
.await
.unwrap();
// Второе редактирование // Второе редактирование
client client.edit_message(ChatId::new(123), msg.id(), "Version 3".to_string()).await.unwrap();
.edit_message(ChatId::new(123), msg.id(), "Version 3".to_string())
.await
.unwrap();
// Третье редактирование // Третье редактирование
client client.edit_message(ChatId::new(123), msg.id(), "Final version".to_string()).await.unwrap();
.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);
@@ -131,9 +107,7 @@ async fn test_edit_nonexistent_message() {
let client = FakeTdClient::new(); let client = FakeTdClient::new();
// Пытаемся отредактировать несуществующее сообщение // Пытаемся отредактировать несуществующее сообщение
let result = client let result = client.edit_message(ChatId::new(123), MessageId::new(999), "New text".to_string()).await;
.edit_message(ChatId::new(123), MessageId::new(999), "New text".to_string())
.await;
// Должна вернуться ошибка // Должна вернуться ошибка
assert!(result.is_err()); assert!(result.is_err());
@@ -150,10 +124,7 @@ 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 let msg = client.send_message(ChatId::new(123), "Original".to_string(), None, None).await.unwrap();
.send_message(ChatId::new(123), "Original".to_string(), None, None)
.await
.unwrap();
// Симулируем начало редактирования -> изменение -> отмена // Симулируем начало редактирования -> изменение -> отмена
// Отменять на уровне FakeTdClient нельзя, но можно проверить что original сохранён // Отменять на уровне FakeTdClient нельзя, но можно проверить что original сохранён
@@ -163,20 +134,14 @@ async fn test_edit_history_tracking() {
let original = messages_before[0].text().to_string(); let original = messages_before[0].text().to_string();
// Редактируем // Редактируем
client client.edit_message(ChatId::new(123), msg.id(), "Edited".to_string()).await.unwrap();
.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 client.edit_message(ChatId::new(123), msg.id(), original).await.unwrap();
.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;
@@ -10,7 +10,6 @@ use tele_tui::tdlib::{ChatInfo, MessageInfo};
use tele_tui::types::{ChatId, MessageId}; use tele_tui::types::{ChatId, MessageId};
/// Builder для создания тестового App с FakeTdClient\n///\n/// Использует trait-based DI для подмены TdClient на FakeTdClient в тестах. /// Builder для создания тестового App с FakeTdClient\n///\n/// Использует trait-based DI для подмены TdClient на FakeTdClient в тестах.
#[allow(dead_code)]
pub struct TestAppBuilder { pub struct TestAppBuilder {
config: Config, config: Config,
screen: AppScreen, screen: AppScreen,
@@ -35,7 +34,6 @@ impl Default for TestAppBuilder {
} }
} }
#[allow(dead_code)]
impl TestAppBuilder { impl TestAppBuilder {
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {
@@ -137,8 +135,7 @@ 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 = self.chat_state = Some(ChatState::DeleteConfirmation { message_id: MessageId::new(message_id) });
Some(ChatState::DeleteConfirmation { message_id: MessageId::new(message_id) });
self self
} }
@@ -184,7 +181,9 @@ 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 { message_id: MessageId::new(message_id) }); self.chat_state = Some(ChatState::Forward {
message_id: MessageId::new(message_id),
});
self self
} }
@@ -225,17 +224,17 @@ impl TestAppBuilder {
pub fn build(self) -> App<FakeTdClient> { pub fn build(self) -> App<FakeTdClient> {
// Создаём FakeTdClient с чатами и сообщениями // Создаём FakeTdClient с чатами и сообщениями
let mut fake_client = FakeTdClient::new(); let mut fake_client = FakeTdClient::new();
// Добавляем чаты // Добавляем чаты
for chat in &self.chats { for chat in &self.chats {
fake_client = fake_client.with_chat(chat.clone()); fake_client = fake_client.with_chat(chat.clone());
} }
// Добавляем сообщения // Добавляем сообщения
for (chat_id, messages) in self.messages { for (chat_id, messages) in self.messages {
fake_client = fake_client.with_messages(chat_id, messages); fake_client = fake_client.with_messages(chat_id, messages);
} }
// Устанавливаем текущий чат если нужно // Устанавливаем текущий чат если нужно
if let Some(chat_id) = self.selected_chat_id { if let Some(chat_id) = self.selected_chat_id {
*fake_client.current_chat_id.lock().unwrap() = Some(chat_id); *fake_client.current_chat_id.lock().unwrap() = Some(chat_id);
@@ -245,7 +244,7 @@ impl TestAppBuilder {
if let Some(auth_state) = self.auth_state { if let Some(auth_state) = self.auth_state {
fake_client = fake_client.with_auth_state(auth_state); fake_client = fake_client.with_auth_state(auth_state);
} }
// Создаём App с FakeTdClient // Создаём App с FakeTdClient
let mut app = App::with_client(self.config, fake_client); let mut app = App::with_client(self.config, fake_client);
@@ -255,7 +254,7 @@ impl TestAppBuilder {
app.message_input = self.message_input; app.message_input = self.message_input;
app.is_searching = self.is_searching; app.is_searching = self.is_searching;
app.search_query = self.search_query; app.search_query = self.search_query;
// Применяем chat_state если он установлен // Применяем chat_state если он установлен
if let Some(chat_state) = self.chat_state { if let Some(chat_state) = self.chat_state {
app.chat_state = chat_state; app.chat_state = chat_state;

View File

@@ -2,53 +2,25 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use tele_tui::tdlib::types::{FolderInfo, ReactionInfo};
use tele_tui::tdlib::{AuthState, ChatInfo, MessageInfo, NetworkState, ProfileInfo, ReplyInfo}; use tele_tui::tdlib::{AuthState, ChatInfo, MessageInfo, NetworkState, ProfileInfo, ReplyInfo};
use tele_tui::tdlib::types::{FolderInfo, ReactionInfo};
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)]
#[allow(dead_code)]
pub enum TdUpdate { pub enum TdUpdate {
NewMessage { NewMessage { chat_id: ChatId, message: MessageInfo },
chat_id: ChatId, MessageContent { chat_id: ChatId, message_id: MessageId, new_text: String },
message: MessageInfo, DeleteMessages { chat_id: ChatId, message_ids: Vec<MessageId> },
}, ChatAction { chat_id: ChatId, user_id: UserId, action: String },
MessageContent { MessageInteractionInfo { chat_id: ChatId, message_id: MessageId, reactions: Vec<ReactionInfo> },
chat_id: ChatId, ConnectionState { state: NetworkState },
message_id: MessageId, ChatReadOutbox { chat_id: ChatId, last_read_outbox_message_id: MessageId },
new_text: String, ChatDraftMessage { chat_id: ChatId, draft_text: Option<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 клиента для тестов
#[allow(dead_code)]
pub struct FakeTdClient { pub struct FakeTdClient {
// Данные // Данные
pub chats: Arc<Mutex<Vec<ChatInfo>>>, pub chats: Arc<Mutex<Vec<ChatInfo>>>,
@@ -58,14 +30,14 @@ pub struct FakeTdClient {
pub profiles: Arc<Mutex<HashMap<i64, ProfileInfo>>>, pub profiles: Arc<Mutex<HashMap<i64, ProfileInfo>>>,
pub drafts: Arc<Mutex<HashMap<i64, String>>>, pub drafts: Arc<Mutex<HashMap<i64, String>>>,
pub available_reactions: Arc<Mutex<Vec<String>>>, pub available_reactions: Arc<Mutex<Vec<String>>>,
// Состояние // Состояние
pub network_state: Arc<Mutex<NetworkState>>, pub network_state: Arc<Mutex<NetworkState>>,
pub typing_chat_id: Arc<Mutex<Option<i64>>>, pub typing_chat_id: Arc<Mutex<Option<i64>>>,
pub current_chat_id: Arc<Mutex<Option<i64>>>, pub current_chat_id: Arc<Mutex<Option<i64>>>,
pub current_pinned_message: Arc<Mutex<Option<MessageInfo>>>, pub current_pinned_message: Arc<Mutex<Option<MessageInfo>>>,
pub auth_state: Arc<Mutex<AuthState>>, pub auth_state: Arc<Mutex<AuthState>>,
// История действий (для проверки в тестах) // История действий (для проверки в тестах)
pub sent_messages: Arc<Mutex<Vec<SentMessage>>>, pub sent_messages: Arc<Mutex<Vec<SentMessage>>>,
pub edited_messages: Arc<Mutex<Vec<EditedMessage>>>, pub edited_messages: Arc<Mutex<Vec<EditedMessage>>>,
@@ -73,12 +45,12 @@ pub struct FakeTdClient {
pub forwarded_messages: Arc<Mutex<Vec<ForwardedMessages>>>, pub forwarded_messages: Arc<Mutex<Vec<ForwardedMessages>>>,
pub searched_queries: Arc<Mutex<Vec<SearchQuery>>>, pub searched_queries: Arc<Mutex<Vec<SearchQuery>>>,
pub viewed_messages: Arc<Mutex<Vec<(i64, Vec<i64>)>>>, // (chat_id, message_ids) pub viewed_messages: Arc<Mutex<Vec<(i64, Vec<i64>)>>>, // (chat_id, message_ids)
pub chat_actions: Arc<Mutex<Vec<(i64, String)>>>, // (chat_id, action) pub chat_actions: Arc<Mutex<Vec<(i64, String)>>>, // (chat_id, action)
pub pending_view_messages: Arc<Mutex<Vec<(ChatId, Vec<MessageId>)>>>, // Очередь для отметки как прочитанные pub pending_view_messages: Arc<Mutex<Vec<(ChatId, Vec<MessageId>)>>>, // Очередь для отметки как прочитанные
// Update channel для симуляции событий // Update channel для симуляции событий
pub update_tx: Arc<Mutex<Option<mpsc::UnboundedSender<TdUpdate>>>>, pub update_tx: Arc<Mutex<Option<mpsc::UnboundedSender<TdUpdate>>>>,
// Скачанные файлы (file_id -> local_path) // Скачанные файлы (file_id -> local_path)
pub downloaded_files: Arc<Mutex<HashMap<i32, String>>>, pub downloaded_files: Arc<Mutex<HashMap<i32, String>>>,
@@ -88,7 +60,6 @@ pub struct FakeTdClient {
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct SentMessage { pub struct SentMessage {
pub chat_id: i64, pub chat_id: i64,
pub text: String, pub text: String,
@@ -97,7 +68,6 @@ pub struct SentMessage {
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct EditedMessage { pub struct EditedMessage {
pub chat_id: i64, pub chat_id: i64,
pub message_id: MessageId, pub message_id: MessageId,
@@ -105,7 +75,6 @@ pub struct EditedMessage {
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct DeletedMessages { pub struct DeletedMessages {
pub chat_id: i64, pub chat_id: i64,
pub message_ids: Vec<MessageId>, pub message_ids: Vec<MessageId>,
@@ -113,7 +82,6 @@ pub struct DeletedMessages {
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct ForwardedMessages { pub struct ForwardedMessages {
pub from_chat_id: i64, pub from_chat_id: i64,
pub to_chat_id: i64, pub to_chat_id: i64,
@@ -121,7 +89,6 @@ pub struct ForwardedMessages {
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct SearchQuery { pub struct SearchQuery {
pub chat_id: i64, pub chat_id: i64,
pub query: String, pub query: String,
@@ -165,7 +132,6 @@ impl Clone for FakeTdClient {
} }
} }
#[allow(dead_code)]
impl FakeTdClient { impl FakeTdClient {
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {
@@ -176,14 +142,8 @@ 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)),
@@ -204,14 +164,14 @@ impl FakeTdClient {
fail_next_operation: Arc::new(Mutex::new(false)), fail_next_operation: Arc::new(Mutex::new(false)),
} }
} }
/// Создать update channel для получения событий /// Создать update channel для получения событий
pub fn with_update_channel(self) -> (Self, mpsc::UnboundedReceiver<TdUpdate>) { pub fn with_update_channel(self) -> (Self, mpsc::UnboundedReceiver<TdUpdate>) {
let (tx, rx) = mpsc::unbounded_channel(); let (tx, rx) = mpsc::unbounded_channel();
*self.update_tx.lock().unwrap() = Some(tx); *self.update_tx.lock().unwrap() = Some(tx);
(self, rx) (self, rx)
} }
/// Включить симуляцию задержек (как в реальном TDLib) /// Включить симуляцию задержек (как в реальном TDLib)
pub fn with_delays(mut self) -> Self { pub fn with_delays(mut self) -> Self {
self.simulate_delays = true; self.simulate_delays = true;
@@ -219,7 +179,7 @@ impl FakeTdClient {
} }
// ==================== Builder Methods ==================== // ==================== Builder Methods ====================
/// Добавить чат /// Добавить чат
pub fn with_chat(self, chat: ChatInfo) -> Self { pub fn with_chat(self, chat: ChatInfo) -> Self {
self.chats.lock().unwrap().push(chat); self.chats.lock().unwrap().push(chat);
@@ -245,16 +205,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.lock().unwrap().insert(chat_id, messages); self.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 self.folders.lock().unwrap().push(FolderInfo { id, name: name.to_string() });
.lock()
.unwrap()
.push(FolderInfo { id, name: name.to_string() });
self self
} }
@@ -281,13 +241,10 @@ impl FakeTdClient {
*self.auth_state.lock().unwrap() = state; *self.auth_state.lock().unwrap() = state;
self self
} }
/// Добавить скачанный файл (для 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 self.downloaded_files.lock().unwrap().insert(file_id, path.to_string());
.lock()
.unwrap()
.insert(file_id, path.to_string());
self self
} }
@@ -298,76 +255,60 @@ impl FakeTdClient {
} }
// ==================== Async TDLib Operations ==================== // ==================== Async TDLib Operations ====================
/// Загрузить список чатов /// Загрузить список чатов
pub async fn load_chats(&self, limit: usize) -> Result<Vec<ChatInfo>, String> { pub async fn load_chats(&self, limit: usize) -> Result<Vec<ChatInfo>, String> {
if self.should_fail() { if self.should_fail() {
return Err("Failed to load chats".to_string()); return Err("Failed to load chats".to_string());
} }
if self.simulate_delays { if self.simulate_delays {
tokio::time::sleep(tokio::time::Duration::from_millis(50)).await; tokio::time::sleep(tokio::time::Duration::from_millis(50)).await;
} }
let chats = self let chats = self.chats.lock().unwrap().iter().take(limit).cloned().collect();
.chats
.lock()
.unwrap()
.iter()
.take(limit)
.cloned()
.collect();
Ok(chats) Ok(chats)
} }
/// Открыть чат /// Открыть чат
pub async fn open_chat(&self, chat_id: ChatId) -> Result<(), String> { pub async fn open_chat(&self, chat_id: ChatId) -> Result<(), String> {
if self.should_fail() { if self.should_fail() {
return Err("Failed to open chat".to_string()); return Err("Failed to open chat".to_string());
} }
*self.current_chat_id.lock().unwrap() = Some(chat_id.as_i64()); *self.current_chat_id.lock().unwrap() = Some(chat_id.as_i64());
Ok(()) Ok(())
} }
/// Получить историю чата /// Получить историю чата
pub async fn get_chat_history( pub async fn get_chat_history(&self, chat_id: ChatId, limit: i32) -> Result<Vec<MessageInfo>, String> {
&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());
} }
if self.simulate_delays { if self.simulate_delays {
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
} }
let messages = self let messages = self.messages
.messages
.lock() .lock()
.unwrap() .unwrap()
.get(&chat_id.as_i64()) .get(&chat_id.as_i64())
.map(|msgs| msgs.iter().take(limit as usize).cloned().collect()) .map(|msgs| msgs.iter().take(limit as usize).cloned().collect())
.unwrap_or_default(); .unwrap_or_default();
Ok(messages) Ok(messages)
} }
/// Загрузить старые сообщения /// Загрузить старые сообщения
pub async fn load_older_messages( pub async fn load_older_messages(&self, chat_id: ChatId, from_message_id: MessageId) -> Result<Vec<MessageInfo>, String> {
&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());
} }
let messages = self.messages.lock().unwrap(); let messages = self.messages.lock().unwrap();
let chat_messages = messages.get(&chat_id.as_i64()).ok_or("Chat not found")?; let chat_messages = messages.get(&chat_id.as_i64()).ok_or("Chat not found")?;
// Найти индекс сообщения и вернуть предыдущие // Найти индекс сообщения и вернуть предыдущие
if let Some(idx) = chat_messages.iter().position(|m| m.id() == from_message_id) { if let Some(idx) = chat_messages.iter().position(|m| m.id() == from_message_id) {
let older: Vec<_> = chat_messages.iter().take(idx).cloned().collect(); let older: Vec<_> = chat_messages.iter().take(idx).cloned().collect();
@@ -388,24 +329,24 @@ impl FakeTdClient {
if self.should_fail() { if self.should_fail() {
return Err("Failed to send message".to_string()); return Err("Failed to send message".to_string());
} }
if self.simulate_delays { if self.simulate_delays {
tokio::time::sleep(tokio::time::Duration::from_millis(200)).await; tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
} }
let message_id = MessageId::new((self.sent_messages.lock().unwrap().len() as i64) + 1000); let message_id = MessageId::new((self.sent_messages.lock().unwrap().len() as i64) + 1000);
self.sent_messages.lock().unwrap().push(SentMessage { self.sent_messages.lock().unwrap().push(SentMessage {
chat_id: chat_id.as_i64(), chat_id: chat_id.as_i64(),
text: text.clone(), text: text.clone(),
reply_to, reply_to,
reply_info: reply_info.clone(), reply_info: reply_info.clone(),
}); });
let message = MessageInfo::new( let message = MessageInfo::new(
message_id, message_id,
"You".to_string(), "You".to_string(),
true, // is_outgoing true, // is_outgoing
text.clone(), text.clone(),
vec![], // entities vec![], // entities
chrono::Utc::now().timestamp() as i32, chrono::Utc::now().timestamp() as i32,
@@ -415,10 +356,10 @@ impl FakeTdClient {
true, // can_be_deleted_only_for_self true, // can_be_deleted_only_for_self
true, // can_be_deleted_for_all_users true, // can_be_deleted_for_all_users
reply_info, reply_info,
None, // forward_from None, // forward_from
vec![], // reactions vec![], // reactions
); );
// Добавляем в историю // Добавляем в историю
self.messages self.messages
.lock() .lock()
@@ -426,13 +367,16 @@ impl FakeTdClient {
.entry(chat_id.as_i64()) .entry(chat_id.as_i64())
.or_insert_with(Vec::new) .or_insert_with(Vec::new)
.push(message.clone()); .push(message.clone());
// Отправляем Update::NewMessage // Отправляем Update::NewMessage
self.send_update(TdUpdate::NewMessage { chat_id, message: message.clone() }); self.send_update(TdUpdate::NewMessage {
chat_id,
message: message.clone(),
});
Ok(message) Ok(message)
} }
/// Редактировать сообщение /// Редактировать сообщение
pub async fn edit_message( pub async fn edit_message(
&self, &self,
@@ -443,37 +387,41 @@ impl FakeTdClient {
if self.should_fail() { if self.should_fail() {
return Err("Failed to edit message".to_string()); return Err("Failed to edit message".to_string());
} }
if self.simulate_delays { if self.simulate_delays {
tokio::time::sleep(tokio::time::Duration::from_millis(150)).await; tokio::time::sleep(tokio::time::Duration::from_millis(150)).await;
} }
self.edited_messages.lock().unwrap().push(EditedMessage { self.edited_messages.lock().unwrap().push(EditedMessage {
chat_id: chat_id.as_i64(), chat_id: chat_id.as_i64(),
message_id, message_id,
new_text: new_text.clone(), new_text: new_text.clone(),
}); });
// Обновляем сообщение // Обновляем сообщение
let mut messages = self.messages.lock().unwrap(); let mut messages = self.messages.lock().unwrap();
if let Some(chat_msgs) = messages.get_mut(&chat_id.as_i64()) { if let Some(chat_msgs) = messages.get_mut(&chat_id.as_i64()) {
if let Some(msg) = chat_msgs.iter_mut().find(|m| m.id() == message_id) { if let Some(msg) = chat_msgs.iter_mut().find(|m| m.id() == message_id) {
msg.content.text = new_text.clone(); msg.content.text = new_text.clone();
msg.metadata.edit_date = msg.metadata.date + 60; msg.metadata.edit_date = msg.metadata.date + 60;
let updated = msg.clone(); let updated = msg.clone();
drop(messages); // Освобождаем lock перед отправкой update drop(messages); // Освобождаем lock перед отправкой update
// Отправляем Update // Отправляем Update
self.send_update(TdUpdate::MessageContent { chat_id, message_id, new_text }); self.send_update(TdUpdate::MessageContent {
chat_id,
message_id,
new_text,
});
return Ok(updated); return Ok(updated);
} }
} }
Err("Message not found".to_string()) Err("Message not found".to_string())
} }
/// Удалить сообщения /// Удалить сообщения
pub async fn delete_messages( pub async fn delete_messages(
&self, &self,
@@ -484,30 +432,33 @@ impl FakeTdClient {
if self.should_fail() { if self.should_fail() {
return Err("Failed to delete messages".to_string()); return Err("Failed to delete messages".to_string());
} }
if self.simulate_delays { if self.simulate_delays {
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
} }
self.deleted_messages.lock().unwrap().push(DeletedMessages { self.deleted_messages.lock().unwrap().push(DeletedMessages {
chat_id: chat_id.as_i64(), chat_id: chat_id.as_i64(),
message_ids: message_ids.clone(), message_ids: message_ids.clone(),
revoke, revoke,
}); });
// Удаляем из истории // Удаляем из истории
let mut messages = self.messages.lock().unwrap(); let mut messages = self.messages.lock().unwrap();
if let Some(chat_msgs) = messages.get_mut(&chat_id.as_i64()) { if let Some(chat_msgs) = messages.get_mut(&chat_id.as_i64()) {
chat_msgs.retain(|m| !message_ids.contains(&m.id())); chat_msgs.retain(|m| !message_ids.contains(&m.id()));
} }
drop(messages); drop(messages);
// Отправляем Update // Отправляем Update
self.send_update(TdUpdate::DeleteMessages { chat_id, message_ids }); self.send_update(TdUpdate::DeleteMessages {
chat_id,
message_ids,
});
Ok(()) Ok(())
} }
/// Переслать сообщения /// Переслать сообщения
pub async fn forward_messages( pub async fn forward_messages(
&self, &self,
@@ -518,33 +469,26 @@ impl FakeTdClient {
if self.should_fail() { if self.should_fail() {
return Err("Failed to forward messages".to_string()); return Err("Failed to forward messages".to_string());
} }
if self.simulate_delays { if self.simulate_delays {
tokio::time::sleep(tokio::time::Duration::from_millis(150)).await; tokio::time::sleep(tokio::time::Duration::from_millis(150)).await;
} }
self.forwarded_messages self.forwarded_messages.lock().unwrap().push(ForwardedMessages {
.lock() from_chat_id: from_chat_id.as_i64(),
.unwrap() to_chat_id: to_chat_id.as_i64(),
.push(ForwardedMessages { message_ids,
from_chat_id: from_chat_id.as_i64(), });
to_chat_id: to_chat_id.as_i64(),
message_ids,
});
Ok(()) Ok(())
} }
/// Поиск сообщений в чате /// Поиск сообщений в чате
pub async fn search_messages( pub async fn search_messages(&self, chat_id: ChatId, query: &str) -> Result<Vec<MessageInfo>, String> {
&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());
} }
let messages = self.messages.lock().unwrap(); let messages = self.messages.lock().unwrap();
let results: Vec<_> = messages let results: Vec<_> = messages
.get(&chat_id.as_i64()) .get(&chat_id.as_i64())
@@ -555,49 +499,43 @@ impl FakeTdClient {
.collect() .collect()
}) })
.unwrap_or_default(); .unwrap_or_default();
self.searched_queries.lock().unwrap().push(SearchQuery { self.searched_queries.lock().unwrap().push(SearchQuery {
chat_id: chat_id.as_i64(), chat_id: chat_id.as_i64(),
query: query.to_string(), query: query.to_string(),
results_count: results.len(), results_count: results.len(),
}); });
Ok(results) Ok(results)
} }
/// Установить черновик /// Установить черновик
pub async fn set_draft_message(&self, chat_id: ChatId, text: String) -> Result<(), String> { pub async fn set_draft_message(&self, chat_id: ChatId, text: String) -> Result<(), String> {
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 self.drafts.lock().unwrap().insert(chat_id.as_i64(), text.clone());
.lock()
.unwrap()
.insert(chat_id.as_i64(), text.clone());
} }
self.send_update(TdUpdate::ChatDraftMessage { self.send_update(TdUpdate::ChatDraftMessage {
chat_id, chat_id,
draft_text: if text.is_empty() { None } else { Some(text) }, draft_text: if text.is_empty() { None } else { Some(text) },
}); });
Ok(()) Ok(())
} }
/// Отправить действие в чате (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 self.chat_actions.lock().unwrap().push((chat_id.as_i64(), action.clone()));
.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());
} else if action == "Cancel" { } else if action == "Cancel" {
*self.typing_chat_id.lock().unwrap() = None; *self.typing_chat_id.lock().unwrap() = None;
} }
} }
/// Получить доступные реакции для сообщения /// Получить доступные реакции для сообщения
pub async fn get_message_available_reactions( pub async fn get_message_available_reactions(
&self, &self,
@@ -607,10 +545,10 @@ impl FakeTdClient {
if self.should_fail() { if self.should_fail() {
return Err("Failed to get available reactions".to_string()); return Err("Failed to get available reactions".to_string());
} }
Ok(self.available_reactions.lock().unwrap().clone()) Ok(self.available_reactions.lock().unwrap().clone())
} }
/// Установить/удалить реакцию /// Установить/удалить реакцию
pub async fn toggle_reaction( pub async fn toggle_reaction(
&self, &self,
@@ -621,18 +559,15 @@ impl FakeTdClient {
if self.should_fail() { if self.should_fail() {
return Err("Failed to toggle reaction".to_string()); return Err("Failed to toggle reaction".to_string());
} }
// Обновляем реакции на сообщении // Обновляем реакции на сообщении
let mut messages = self.messages.lock().unwrap(); let mut messages = self.messages.lock().unwrap();
if let Some(chat_msgs) = messages.get_mut(&chat_id.as_i64()) { if let Some(chat_msgs) = messages.get_mut(&chat_id.as_i64()) {
if let Some(msg) = chat_msgs.iter_mut().find(|m| m.id() == message_id) { if let Some(msg) = chat_msgs.iter_mut().find(|m| m.id() == message_id) {
let reactions = &mut msg.interactions.reactions; let reactions = &mut msg.interactions.reactions;
// Toggle logic // Toggle logic
if let Some(pos) = reactions if let Some(pos) = reactions.iter().position(|r| r.emoji == emoji && r.is_chosen) {
.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) {
@@ -647,10 +582,10 @@ impl FakeTdClient {
is_chosen: true, is_chosen: true,
}); });
} }
let updated_reactions = reactions.clone(); let updated_reactions = reactions.clone();
drop(messages); drop(messages);
// Отправляем Update // Отправляем Update
self.send_update(TdUpdate::MessageInteractionInfo { self.send_update(TdUpdate::MessageInteractionInfo {
chat_id, chat_id,
@@ -659,10 +594,10 @@ impl FakeTdClient {
}); });
} }
} }
Ok(()) Ok(())
} }
/// Скачать файл (mock) /// Скачать файл (mock)
pub async fn download_file(&self, file_id: i32) -> Result<String, String> { pub async fn download_file(&self, file_id: i32) -> Result<String, String> {
if self.should_fail() { if self.should_fail() {
@@ -682,7 +617,7 @@ impl FakeTdClient {
if self.should_fail() { if self.should_fail() {
return Err("Failed to get profile info".to_string()); return Err("Failed to get profile info".to_string());
} }
self.profiles self.profiles
.lock() .lock()
.unwrap() .unwrap()
@@ -690,7 +625,7 @@ impl FakeTdClient {
.cloned() .cloned()
.ok_or_else(|| "Profile not found".to_string()) .ok_or_else(|| "Profile not found".to_string())
} }
/// Отметить сообщения как просмотренные /// Отметить сообщения как просмотренные
pub async fn view_messages(&self, chat_id: ChatId, message_ids: Vec<MessageId>) { pub async fn view_messages(&self, chat_id: ChatId, message_ids: Vec<MessageId>) {
self.viewed_messages self.viewed_messages
@@ -698,25 +633,25 @@ impl FakeTdClient {
.unwrap() .unwrap()
.push((chat_id.as_i64(), message_ids.iter().map(|id| id.as_i64()).collect())); .push((chat_id.as_i64(), message_ids.iter().map(|id| id.as_i64()).collect()));
} }
/// Загрузить чаты папки /// Загрузить чаты папки
pub async fn load_folder_chats(&self, _folder_id: i32, _limit: usize) -> Result<(), String> { pub async fn load_folder_chats(&self, _folder_id: i32, _limit: usize) -> Result<(), String> {
if self.should_fail() { if self.should_fail() {
return Err("Failed to load folder chats".to_string()); return Err("Failed to load folder chats".to_string());
} }
Ok(()) Ok(())
} }
// ==================== Helper Methods ==================== // ==================== Helper Methods ====================
/// Отправить update в канал (если он установлен) /// Отправить update в канал (если он установлен)
fn send_update(&self, update: TdUpdate) { fn send_update(&self, update: TdUpdate) {
if let Some(tx) = self.update_tx.lock().unwrap().as_ref() { if let Some(tx) = self.update_tx.lock().unwrap().as_ref() {
let _ = tx.send(update); let _ = tx.send(update);
} }
} }
/// Проверить нужно ли симулировать ошибку /// Проверить нужно ли симулировать ошибку
fn should_fail(&self) -> bool { fn should_fail(&self) -> bool {
let mut fail = self.fail_next_operation.lock().unwrap(); let mut fail = self.fail_next_operation.lock().unwrap();
@@ -727,16 +662,16 @@ impl FakeTdClient {
false false
} }
} }
/// Симулировать ошибку в следующей операции /// Симулировать ошибку в следующей операции
pub fn fail_next(&self) { pub fn fail_next(&self) {
*self.fail_next_operation.lock().unwrap() = true; *self.fail_next_operation.lock().unwrap() = true;
} }
/// Симулировать входящее сообщение /// Симулировать входящее сообщение
pub fn simulate_incoming_message(&self, chat_id: ChatId, text: String, sender_name: &str) { pub fn simulate_incoming_message(&self, chat_id: ChatId, text: String, sender_name: &str) {
let message_id = MessageId::new(9000 + chrono::Utc::now().timestamp()); let message_id = MessageId::new(9000 + chrono::Utc::now().timestamp());
let message = MessageInfo::new( let message = MessageInfo::new(
message_id, message_id,
sender_name.to_string(), sender_name.to_string(),
@@ -753,7 +688,7 @@ impl FakeTdClient {
None, None,
vec![], vec![],
); );
// Добавляем в историю // Добавляем в историю
self.messages self.messages
.lock() .lock()
@@ -761,22 +696,26 @@ impl FakeTdClient {
.entry(chat_id.as_i64()) .entry(chat_id.as_i64())
.or_insert_with(Vec::new) .or_insert_with(Vec::new)
.push(message.clone()); .push(message.clone());
// Отправляем Update // Отправляем Update
self.send_update(TdUpdate::NewMessage { chat_id, message }); self.send_update(TdUpdate::NewMessage { chat_id, message });
} }
/// Симулировать 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 { chat_id, user_id, action: "Typing".to_string() }); self.send_update(TdUpdate::ChatAction {
chat_id,
user_id,
action: "Typing".to_string(),
});
} }
/// Симулировать изменение состояния сети /// Симулировать изменение состояния сети
pub fn simulate_network_change(&self, state: NetworkState) { pub fn simulate_network_change(&self, state: NetworkState) {
*self.network_state.lock().unwrap() = state.clone(); *self.network_state.lock().unwrap() = state.clone();
self.send_update(TdUpdate::ConnectionState { state }); self.send_update(TdUpdate::ConnectionState { state });
} }
/// Симулировать прочтение сообщений /// Симулировать прочтение сообщений
pub fn simulate_read_outbox(&self, chat_id: ChatId, last_read_message_id: MessageId) { pub fn simulate_read_outbox(&self, chat_id: ChatId, last_read_message_id: MessageId) {
self.send_update(TdUpdate::ChatReadOutbox { self.send_update(TdUpdate::ChatReadOutbox {
@@ -784,9 +723,9 @@ impl FakeTdClient {
last_read_outbox_message_id: last_read_message_id, last_read_outbox_message_id: last_read_message_id,
}); });
} }
// ==================== Getters for Test Assertions ==================== // ==================== Getters for Test Assertions ====================
/// Получить все чаты /// Получить все чаты
pub fn get_chats(&self) -> Vec<ChatInfo> { pub fn get_chats(&self) -> Vec<ChatInfo> {
self.chats.lock().unwrap().clone() self.chats.lock().unwrap().clone()
@@ -806,57 +745,57 @@ impl FakeTdClient {
.cloned() .cloned()
.unwrap_or_default() .unwrap_or_default()
} }
/// Получить отправленные сообщения /// Получить отправленные сообщения
pub fn get_sent_messages(&self) -> Vec<SentMessage> { pub fn get_sent_messages(&self) -> Vec<SentMessage> {
self.sent_messages.lock().unwrap().clone() self.sent_messages.lock().unwrap().clone()
} }
/// Получить отредактированные сообщения /// Получить отредактированные сообщения
pub fn get_edited_messages(&self) -> Vec<EditedMessage> { pub fn get_edited_messages(&self) -> Vec<EditedMessage> {
self.edited_messages.lock().unwrap().clone() self.edited_messages.lock().unwrap().clone()
} }
/// Получить удалённые сообщения /// Получить удалённые сообщения
pub fn get_deleted_messages(&self) -> Vec<DeletedMessages> { pub fn get_deleted_messages(&self) -> Vec<DeletedMessages> {
self.deleted_messages.lock().unwrap().clone() self.deleted_messages.lock().unwrap().clone()
} }
/// Получить пересланные сообщения /// Получить пересланные сообщения
pub fn get_forwarded_messages(&self) -> Vec<ForwardedMessages> { pub fn get_forwarded_messages(&self) -> Vec<ForwardedMessages> {
self.forwarded_messages.lock().unwrap().clone() self.forwarded_messages.lock().unwrap().clone()
} }
/// Получить поисковые запросы /// Получить поисковые запросы
pub fn get_search_queries(&self) -> Vec<SearchQuery> { pub fn get_search_queries(&self) -> Vec<SearchQuery> {
self.searched_queries.lock().unwrap().clone() self.searched_queries.lock().unwrap().clone()
} }
/// Получить просмотренные сообщения /// Получить просмотренные сообщения
pub fn get_viewed_messages(&self) -> Vec<(i64, Vec<i64>)> { pub fn get_viewed_messages(&self) -> Vec<(i64, Vec<i64>)> {
self.viewed_messages.lock().unwrap().clone() self.viewed_messages.lock().unwrap().clone()
} }
/// Получить действия в чатах /// Получить действия в чатах
pub fn get_chat_actions(&self) -> Vec<(i64, String)> { pub fn get_chat_actions(&self) -> Vec<(i64, String)> {
self.chat_actions.lock().unwrap().clone() self.chat_actions.lock().unwrap().clone()
} }
/// Получить текущее состояние сети /// Получить текущее состояние сети
pub fn get_network_state(&self) -> NetworkState { pub fn get_network_state(&self) -> NetworkState {
self.network_state.lock().unwrap().clone() self.network_state.lock().unwrap().clone()
} }
/// Получить ID текущего открытого чата /// Получить ID текущего открытого чата
pub fn get_current_chat_id(&self) -> Option<i64> { pub fn get_current_chat_id(&self) -> Option<i64> {
*self.current_chat_id.lock().unwrap() *self.current_chat_id.lock().unwrap()
} }
/// Установить update channel для получения событий /// Установить update channel для получения событий
pub fn set_update_channel(&self, tx: mpsc::UnboundedSender<TdUpdate>) { pub fn set_update_channel(&self, tx: mpsc::UnboundedSender<TdUpdate>) {
*self.update_tx.lock().unwrap() = Some(tx); *self.update_tx.lock().unwrap() = Some(tx);
} }
/// Очистить всю историю действий /// Очистить всю историю действий
pub fn clear_all_history(&self) { pub fn clear_all_history(&self) {
self.sent_messages.lock().unwrap().clear(); self.sent_messages.lock().unwrap().clear();
@@ -896,12 +835,10 @@ mod tests {
async fn test_send_message() { async fn test_send_message() {
let client = FakeTdClient::new(); let client = FakeTdClient::new();
let chat_id = ChatId::new(123); let chat_id = ChatId::new(123);
let result = client let result = client.send_message(chat_id, "Hello".to_string(), None, None).await;
.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();
assert_eq!(sent.len(), 1); assert_eq!(sent.len(), 1);
assert_eq!(sent[0].text, "Hello"); assert_eq!(sent[0].text, "Hello");
@@ -912,17 +849,12 @@ mod tests {
async fn test_edit_message() { async fn test_edit_message() {
let client = FakeTdClient::new(); let client = FakeTdClient::new();
let chat_id = ChatId::new(123); let chat_id = ChatId::new(123);
let msg = client let msg = client.send_message(chat_id, "Hello".to_string(), None, None).await.unwrap();
.send_message(chat_id, "Hello".to_string(), None, None)
.await
.unwrap();
let msg_id = msg.id(); let msg_id = msg.id();
let _ = client let _ = client.edit_message(chat_id, msg_id, "Hello World".to_string()).await;
.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);
assert_eq!(client.get_messages(123)[0].text(), "Hello World"); assert_eq!(client.get_messages(123)[0].text(), "Hello World");
@@ -933,30 +865,25 @@ mod tests {
async fn test_delete_message() { async fn test_delete_message() {
let client = FakeTdClient::new(); let client = FakeTdClient::new();
let chat_id = ChatId::new(123); let chat_id = ChatId::new(123);
let msg = client let msg = client.send_message(chat_id, "Hello".to_string(), None, None).await.unwrap();
.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;
let deleted = client.get_deleted_messages(); let deleted = client.get_deleted_messages();
assert_eq!(deleted.len(), 1); assert_eq!(deleted.len(), 1);
assert_eq!(client.get_messages(123).len(), 0); assert_eq!(client.get_messages(123).len(), 0);
} }
#[tokio::test] #[tokio::test]
async fn test_update_channel() { async fn test_update_channel() {
let (client, mut rx) = FakeTdClient::new().with_update_channel(); let (client, mut rx) = FakeTdClient::new().with_update_channel();
let chat_id = ChatId::new(123); let chat_id = ChatId::new(123);
// Отправляем сообщение // Отправляем сообщение
let _ = client let _ = client.send_message(chat_id, "Test".to_string(), None, None).await;
.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 {
match update { match update {
@@ -969,43 +896,39 @@ mod tests {
panic!("No update received"); panic!("No update received");
} }
} }
#[tokio::test] #[tokio::test]
async fn test_simulate_incoming_message() { async fn test_simulate_incoming_message() {
let (client, mut rx) = FakeTdClient::new().with_update_channel(); let (client, mut rx) = FakeTdClient::new().with_update_channel();
let chat_id = ChatId::new(123); let chat_id = ChatId::new(123);
client.simulate_incoming_message(chat_id, "Hello from Bob".to_string(), "Bob"); client.simulate_incoming_message(chat_id, "Hello from Bob".to_string(), "Bob");
// Проверяем Update // Проверяем Update
if let Some(TdUpdate::NewMessage { message, .. }) = rx.recv().await { if let Some(TdUpdate::NewMessage { message, .. }) = rx.recv().await {
assert_eq!(message.text(), "Hello from Bob"); assert_eq!(message.text(), "Hello from Bob");
assert_eq!(message.sender_name(), "Bob"); assert_eq!(message.sender_name(), "Bob");
assert!(!message.is_outgoing()); assert!(!message.is_outgoing());
} }
// Проверяем что сообщение добавилось // Проверяем что сообщение добавилось
assert_eq!(client.get_messages(123).len(), 1); assert_eq!(client.get_messages(123).len(), 1);
} }
#[tokio::test] #[tokio::test]
async fn test_fail_next_operation() { async fn test_fail_next_operation() {
let client = FakeTdClient::new(); let client = FakeTdClient::new();
let chat_id = ChatId::new(123); let chat_id = ChatId::new(123);
// Устанавливаем флаг ошибки // Устанавливаем флаг ошибки
client.fail_next(); client.fail_next();
// Следующая операция должна упасть // Следующая операция должна упасть
let result = client let result = client.send_message(chat_id, "Test".to_string(), None, None).await;
.send_message(chat_id, "Test".to_string(), None, None)
.await;
assert!(result.is_err()); assert!(result.is_err());
// Но следующая должна пройти // Но следующая должна пройти
let result2 = client let result2 = client.send_message(chat_id, "Test2".to_string(), None, None).await;
.send_message(chat_id, "Test2".to_string(), None, None)
.await;
assert!(result2.is_ok()); assert!(result2.is_ok());
} }
} }

View File

@@ -4,11 +4,8 @@ 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]
@@ -58,19 +55,11 @@ impl TdClientTrait for FakeTdClient {
} }
// ============ Message methods ============ // ============ Message methods ============
async fn get_chat_history( async fn get_chat_history(&mut self, chat_id: ChatId, limit: i32) -> Result<Vec<MessageInfo>, String> {
&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( async fn load_older_messages(&mut self, chat_id: ChatId, from_message_id: MessageId) -> Result<Vec<MessageInfo>, String> {
&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
} }
@@ -83,11 +72,7 @@ impl TdClientTrait for FakeTdClient {
// Not implemented for fake // Not implemented for fake
} }
async fn search_messages( async fn search_messages(&self, chat_id: ChatId, query: &str) -> Result<Vec<MessageInfo>, String> {
&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
} }
@@ -145,10 +130,7 @@ 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 self.viewed_messages.lock().unwrap().push((chat_id.as_i64(), ids));
.lock()
.unwrap()
.push((chat_id.as_i64(), ids));
} }
} }
@@ -207,17 +189,13 @@ impl TdClientTrait for FakeTdClient {
static AUTH_STATE_WAIT_PHONE: OnceLock<AuthState> = OnceLock::new(); static AUTH_STATE_WAIT_PHONE: OnceLock<AuthState> = OnceLock::new();
static AUTH_STATE_WAIT_CODE: OnceLock<AuthState> = OnceLock::new(); static AUTH_STATE_WAIT_CODE: OnceLock<AuthState> = OnceLock::new();
static AUTH_STATE_WAIT_PASSWORD: OnceLock<AuthState> = OnceLock::new(); static AUTH_STATE_WAIT_PASSWORD: OnceLock<AuthState> = OnceLock::new();
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 => { AuthState::WaitPhoneNumber => AUTH_STATE_WAIT_PHONE.get_or_init(|| 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 => { AuthState::WaitPassword => AUTH_STATE_WAIT_PASSWORD.get_or_init(|| AuthState::WaitPassword),
AUTH_STATE_WAIT_PASSWORD.get_or_init(|| AuthState::WaitPassword)
}
_ => &AUTH_STATE_READY, _ => &AUTH_STATE_READY,
} }
} }

View File

@@ -1,11 +1,10 @@
// Test data builders and fixtures // Test data builders and fixtures
use tele_tui::tdlib::types::{ForwardInfo, ReactionInfo};
use tele_tui::tdlib::{ChatInfo, MessageInfo, ProfileInfo, ReplyInfo}; use tele_tui::tdlib::{ChatInfo, MessageInfo, ProfileInfo, ReplyInfo};
use tele_tui::tdlib::types::{ForwardInfo, ReactionInfo};
use tele_tui::types::{ChatId, MessageId}; use tele_tui::types::{ChatId, MessageId};
/// Builder для создания тестового чата /// Builder для создания тестового чата
#[allow(dead_code)]
pub struct TestChatBuilder { pub struct TestChatBuilder {
id: i64, id: i64,
title: String, title: String,
@@ -22,7 +21,6 @@ pub struct TestChatBuilder {
draft_text: Option<String>, draft_text: Option<String>,
} }
#[allow(dead_code)]
impl TestChatBuilder { impl TestChatBuilder {
pub fn new(title: &str, id: i64) -> Self { pub fn new(title: &str, id: i64) -> Self {
Self { Self {
@@ -102,7 +100,6 @@ impl TestChatBuilder {
} }
/// Builder для создания тестового сообщения /// Builder для создания тестового сообщения
#[allow(dead_code)]
pub struct TestMessageBuilder { pub struct TestMessageBuilder {
id: i64, id: i64,
sender_name: String, sender_name: String,
@@ -121,7 +118,6 @@ pub struct TestMessageBuilder {
media_album_id: i64, media_album_id: i64,
} }
#[allow(dead_code)]
impl TestMessageBuilder { impl TestMessageBuilder {
pub fn new(content: &str, id: i64) -> Self { pub fn new(content: &str, id: i64) -> Self {
Self { Self {
@@ -181,7 +177,9 @@ 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 { sender_name: sender.to_string() }); self.forward_from = Some(ForwardInfo {
sender_name: sender.to_string(),
});
self self
} }

View File

@@ -292,9 +292,7 @@ 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) TestMessageBuilder::new("Before album", 1).sender("Alice").build(),
.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)
@@ -307,9 +305,7 @@ 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) TestMessageBuilder::new("After album", 5).sender("Alice").build(),
.sender("Alice")
.build(),
]; ];
let mut app = TestAppBuilder::new() let mut app = TestAppBuilder::new()
@@ -351,9 +347,7 @@ 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) TestMessageBuilder::new("Regular", 1).sender("Alice").build(),
.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,16 +35,7 @@ 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![ let reactions = 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(),
];
let mut app = TestAppBuilder::new() let mut app = TestAppBuilder::new()
.with_chat(chat) .with_chat(chat)
@@ -66,16 +57,7 @@ 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![ let reactions = 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(),
];
let mut app = TestAppBuilder::new() let mut app = TestAppBuilder::new()
.with_chat(chat) .with_chat(chat)
@@ -178,9 +160,7 @@ fn snapshot_search_in_chat() {
.build(); .build();
// Устанавливаем результаты поиска // Устанавливаем результаты поиска
if let tele_tui::app::ChatState::SearchInChat { results, selected_index, .. } = if let tele_tui::app::ChatState::SearchInChat { results, selected_index, .. } = &mut app.chat_state {
&mut app.chat_state
{
*results = vec![msg1, msg2]; *results = vec![msg1, msg2];
*selected_index = 0; *selected_index = 0;
} }

View File

@@ -74,7 +74,7 @@ async fn test_enter_opens_chat() {
#[tokio::test] #[tokio::test]
async fn test_esc_closes_chat() { async fn test_esc_closes_chat() {
// Состояние: открыт чат 123 // Состояние: открыт чат 123
let _selected_chat_id = Some(123); let selected_chat_id = Some(123);
// Пользователь нажал Esc // Пользователь нажал Esc
let selected_chat_id: Option<i64> = None; let selected_chat_id: Option<i64> = None;
@@ -97,7 +97,7 @@ async fn test_scroll_messages_in_chat() {
let client = client.with_messages(123, messages); let client = client.with_messages(123, messages);
let _msgs = client.get_messages(123); let msgs = client.get_messages(123);
// Скролл начинается снизу (последнее сообщение видно) // Скролл начинается снизу (последнее сообщение видно)
let mut scroll_offset: usize = 0; let mut scroll_offset: usize = 0;

View File

@@ -97,9 +97,7 @@ async fn test_typing_indicator_on() {
// Alice начала печатать в чате 123 // Alice начала печатать в чате 123
// Симулируем через send_chat_action // Симулируем через send_chat_action
client client.send_chat_action(ChatId::new(123), "Typing".to_string()).await;
.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));
@@ -112,15 +110,11 @@ async fn test_typing_indicator_off() {
let client = FakeTdClient::new(); let client = FakeTdClient::new();
// Изначально Alice печатала // Изначально Alice печатала
client client.send_chat_action(ChatId::new(123), "Typing".to_string()).await;
.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 client.send_chat_action(ChatId::new(123), "Cancel".to_string()).await;
.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);
@@ -130,7 +124,7 @@ async fn test_typing_indicator_off() {
/// Test: Отправка своего typing status /// Test: Отправка своего typing status
#[tokio::test] #[tokio::test]
async fn test_send_own_typing_status() { async fn test_send_own_typing_status() {
let _client = FakeTdClient::new(); let client = FakeTdClient::new();
// Пользователь начал печатать в чате 456 // Пользователь начал печатать в чате 456
// В реальном App вызывается client.send_chat_action(chat_id, ChatAction::Typing) // В реальном App вызывается client.send_chat_action(chat_id, ChatAction::Typing)

View File

@@ -12,16 +12,10 @@ async fn test_add_reaction_to_message() {
let client = FakeTdClient::new(); let client = FakeTdClient::new();
// Отправляем сообщение // Отправляем сообщение
let msg = client let msg = client.send_message(ChatId::new(123), "React to this!".to_string(), None, None).await.unwrap();
.send_message(ChatId::new(123), "React to this!".to_string(), None, None)
.await
.unwrap();
// Добавляем реакцию // Добавляем реакцию
client client.toggle_reaction(ChatId::new(123), msg.id(), "👍".to_string()).await.unwrap();
.toggle_reaction(ChatId::new(123), msg.id(), "👍".to_string())
.await
.unwrap();
// Проверяем что реакция записалась // Проверяем что реакция записалась
let messages = client.get_messages(123); let messages = client.get_messages(123);
@@ -52,10 +46,7 @@ async fn test_toggle_reaction_removes_it() {
let msg_id = messages_before[0].id(); let msg_id = messages_before[0].id();
// Toggle - удаляем свою реакцию // Toggle - удаляем свою реакцию
client client.toggle_reaction(ChatId::new(123), msg_id, "👍".to_string()).await.unwrap();
.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);
@@ -66,28 +57,13 @@ 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 let msg = client.send_message(ChatId::new(123), "Many reactions".to_string(), None, None).await.unwrap();
.send_message(ChatId::new(123), "Many reactions".to_string(), None, None)
.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();
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);
@@ -175,10 +151,7 @@ async fn test_reaction_counter_increases() {
let msg_id = messages_before[0].id(); let msg_id = messages_before[0].id();
// Мы добавляем свою реакцию - счётчик должен увеличиться // Мы добавляем свою реакцию - счётчик должен увеличиться
client client.toggle_reaction(ChatId::new(123), msg_id, "👍".to_string()).await.unwrap();
.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);
@@ -204,10 +177,7 @@ async fn test_update_reaction_we_add_ours() {
let msg_id = messages_before[0].id(); let msg_id = messages_before[0].id();
// Добавляем нашу реакцию // Добавляем нашу реакцию
client client.toggle_reaction(ChatId::new(123), msg_id, "🔥".to_string()).await.unwrap();
.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::types::ForwardInfo;
use tele_tui::tdlib::ReplyInfo; use tele_tui::tdlib::ReplyInfo;
use tele_tui::tdlib::types::ForwardInfo;
use tele_tui::types::{ChatId, MessageId}; use tele_tui::types::{ChatId, MessageId};
/// Test: Reply создаёт сообщение с reply_to /// Test: Reply создаёт сообщение с reply_to
@@ -28,15 +28,7 @@ async fn test_reply_creates_message_with_reply_to() {
}; };
// Отвечаем на него // Отвечаем на него
let reply_msg = client let reply_msg = client.send_message(ChatId::new(123), "Answer!".to_string(), Some(MessageId::new(100)), Some(reply_info)).await.unwrap();
.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);
@@ -87,10 +79,7 @@ async fn test_cancel_reply_sends_without_reply_to() {
// Пользователь начал reply (r), потом отменил (Esc), затем отправил // Пользователь начал reply (r), потом отменил (Esc), затем отправил
// Это эмулируется отправкой без reply_to // Это эмулируется отправкой без reply_to
client client.send_message(ChatId::new(123), "Regular message".to_string(), None, None).await.unwrap();
.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);
@@ -186,15 +175,7 @@ async fn test_reply_to_forwarded_message() {
}; };
// Отвечаем на пересланное сообщение // Отвечаем на пересланное сообщение
let reply_msg = client let reply_msg = client.send_message(ChatId::new(123), "Thanks for sharing!".to_string(), Some(MessageId::new(100)), Some(reply_info)).await.unwrap();
.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,10 +14,7 @@ async fn test_send_text_message() {
let client = client.with_chat(chat); let client = client.with_chat(chat);
// Отправляем сообщение // Отправляем сообщение
let msg = client let msg = client.send_message(ChatId::new(123), "Hello, Mom!".to_string(), None, None).await.unwrap();
.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);
@@ -39,22 +36,13 @@ async fn test_send_multiple_messages_updates_list() {
let client = FakeTdClient::new(); let client = FakeTdClient::new();
// Отправляем первое сообщение // Отправляем первое сообщение
let msg1 = client let msg1 = client.send_message(ChatId::new(123), "Message 1".to_string(), None, None).await.unwrap();
.send_message(ChatId::new(123), "Message 1".to_string(), None, None)
.await
.unwrap();
// Отправляем второе сообщение // Отправляем второе сообщение
let msg2 = client let msg2 = client.send_message(ChatId::new(123), "Message 2".to_string(), None, None).await.unwrap();
.send_message(ChatId::new(123), "Message 2".to_string(), None, None)
.await
.unwrap();
// Отправляем третье сообщение // Отправляем третье сообщение
let msg3 = client let msg3 = client.send_message(ChatId::new(123), "Message 3".to_string(), None, None).await.unwrap();
.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);
@@ -78,10 +66,7 @@ async fn test_send_empty_message_technical() {
let client = FakeTdClient::new(); let client = FakeTdClient::new();
// FakeTdClient технически может отправить пустое сообщение // FakeTdClient технически может отправить пустое сообщение
let msg = client let msg = client.send_message(ChatId::new(123), "".to_string(), None, None).await.unwrap();
.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);
@@ -100,10 +85,7 @@ 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 client.send_message(ChatId::new(123), text.to_string(), None, None).await.unwrap();
.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);
@@ -117,22 +99,13 @@ async fn test_send_messages_to_different_chats() {
let client = FakeTdClient::new(); let client = FakeTdClient::new();
// Отправляем в чат 123 // Отправляем в чат 123
client client.send_message(ChatId::new(123), "Hello Mom".to_string(), None, None).await.unwrap();
.send_message(ChatId::new(123), "Hello Mom".to_string(), None, None)
.await
.unwrap();
// Отправляем в чат 456 // Отправляем в чат 456
client client.send_message(ChatId::new(456), "Hello Boss".to_string(), None, None).await.unwrap();
.send_message(ChatId::new(456), "Hello Boss".to_string(), None, None)
.await
.unwrap();
// Отправляем ещё одно в чат 123 // Отправляем ещё одно в чат 123
client client.send_message(ChatId::new(123), "How are you?".to_string(), None, None).await.unwrap();
.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);
@@ -155,10 +128,7 @@ async fn test_receive_incoming_message() {
let client = FakeTdClient::new(); let client = FakeTdClient::new();
// Добавляем существующее сообщение // Добавляем существующее сообщение
client client.send_message(ChatId::new(123), "My outgoing".to_string(), None, None).await.unwrap();
.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,7 +32,9 @@ 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![TestMessageBuilder::new("Hello", 1).outgoing().build()]; let messages = vec![
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)
@@ -52,7 +54,9 @@ 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![TestMessageBuilder::new("Hello", 1).outgoing().build()]; let messages = vec![
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)
@@ -68,7 +72,9 @@ 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![TestMessageBuilder::new("Hello", 1).outgoing().build()]; let messages = vec![
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)
@@ -121,9 +127,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![TestMessageBuilder::new("Hello from friend", 1) let messages = vec![
.sender("Friend") TestMessageBuilder::new("Hello from friend", 1).sender("Friend").build(),
.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)
@@ -143,7 +149,9 @@ 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![TestMessageBuilder::new("My message", 1).outgoing().build()]; let messages = vec![
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)
@@ -240,7 +248,9 @@ 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![TestMessageBuilder::new("Hello", 1).outgoing().build()]; let messages = vec![
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)
@@ -258,7 +268,9 @@ 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![TestMessageBuilder::new("Hello", 1).build()]; let messages = vec![
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)
@@ -383,7 +395,9 @@ 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![TestMessageBuilder::new("My message", 1).outgoing().build()]; let messages = vec![
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)
@@ -474,7 +488,9 @@ 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![TestMessageBuilder::new("Hello", 1).sender("Friend").build()]; let messages = vec![
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)
@@ -496,7 +512,9 @@ 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![TestMessageBuilder::new("My message", 1).outgoing().build()]; let messages = vec![
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)
@@ -546,7 +564,9 @@ 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![TestMessageBuilder::new("Msg", 1).build()]; let messages = vec![
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)
@@ -579,9 +599,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![TestMessageBuilder::new("Question?", 1) let messages = vec![
.sender("Friend") TestMessageBuilder::new("Question?", 1).sender("Friend").build(),
.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)