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 tdlib_rs::enums::{TextEntity, TextEntityType};
use tele_tui::formatting::format_text_with_entities;
use tdlib_rs::enums::{TextEntity, TextEntityType};
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();
@@ -41,7 +41,9 @@ fn benchmark_format_simple_text(c: &mut Criterion) {
let entities = vec![];
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();
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| {
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 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) {
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);

View File

@@ -8,10 +8,7 @@ fn create_test_messages(count: usize) -> Vec<tele_tui::tdlib::MessageInfo> {
.map(|i| {
let builder = MessageBuilder::new(MessageId::new(i as i64))
.sender_name(&format!("User{}", i % 10))
.text(&format!(
"Test message number {} with some longer text to make it more realistic",
i
))
.text(&format!("Test message number {} with some longer text to make it more realistic", i))
.date(1640000000 + (i as i32 * 60));
if i % 2 == 0 {
@@ -27,7 +24,9 @@ fn benchmark_group_100_messages(c: &mut Criterion) {
let messages = create_test_messages(100);
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);
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
newline_style = "Unix"
# Imports
imports_granularity = "Crate"
group_imports = "StdExternalCrate"
# Comments
wrap_comments = true
comment_width = 80
normalize_comments = true
# Formatting
use_small_heuristics = "Default"
fn_call_width = 80

View File

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

View File

@@ -9,7 +9,5 @@ pub mod manager;
pub mod profile;
pub use lock::{acquire_lock, release_lock};
#[allow(unused_imports)]
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};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,14 +5,13 @@
mod chat_filter;
mod chat_state;
pub mod methods;
mod state;
pub mod methods;
pub use chat_filter::{ChatFilter, ChatFilterCriteria};
pub use chat_state::{ChatState, InputMode};
#[allow(unused_imports)]
pub use methods::*;
pub use state::AppScreen;
pub use methods::*;
use crate::accounts::AccountProfile;
use crate::tdlib::{ChatInfo, TdClient, TdClientTrait};
@@ -108,7 +107,6 @@ pub struct App<T: TdClientTrait = TdClient> {
/// Время последней отправки typing status (для throttling)
pub last_typing_sent: Option<std::time::Instant>,
// Image support
#[allow(dead_code)]
#[cfg(feature = "images")]
pub image_cache: Option<crate::media::cache::ImageCache>,
/// Renderer для inline preview в чате (Halfblocks - быстро)
@@ -150,7 +148,6 @@ pub struct App<T: TdClientTrait = TdClient> {
pub last_playback_tick: Option<std::time::Instant>,
}
#[allow(dead_code)]
impl<T: TdClientTrait> App<T> {
/// 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;
#[cfg(feature = "images")]
let image_cache = Some(crate::media::cache::ImageCache::new(config.images.cache_size_mb));
let image_cache = Some(crate::media::cache::ImageCache::new(
config.images.cache_size_mb,
));
#[cfg(feature = "images")]
let inline_image_renderer = crate::media::image_renderer::ImageRenderer::new_fast();
#[cfg(feature = "images")]
@@ -281,8 +280,11 @@ impl<T: TdClientTrait> App<T> {
/// Navigate to next item in account switcher list.
pub fn account_switcher_select_next(&mut self) {
if let Some(AccountSwitcherState::SelectAccount { accounts, selected_index, .. }) =
&mut self.account_switcher
if let Some(AccountSwitcherState::SelectAccount {
accounts,
selected_index,
..
}) = &mut self.account_switcher
{
// +1 for the "Add account" item at the end
let max_index = accounts.len();
@@ -375,6 +377,20 @@ impl<T: TdClientTrait> App<T> {
.and_then(|id| self.chats.iter().find(|c| c.id == id))
}
// ========== Getter/Setter методы для инкапсуляции ==========
// Config

View File

@@ -97,13 +97,13 @@ impl VoiceCache {
/// Evicts a specific file from cache
fn evict(&mut self, file_id: &str) -> Result<(), String> {
if let Some((path, _, _)) = self.files.remove(file_id) {
fs::remove_file(&path).map_err(|e| format!("Failed to remove cached file: {}", e))?;
fs::remove_file(&path)
.map_err(|e| format!("Failed to remove cached file: {}", e))?;
}
Ok(())
}
/// Clears all cached files
#[allow(dead_code)]
pub fn clear(&mut self) -> Result<(), String> {
for (path, _, _) in self.files.values() {
let _ = fs::remove_file(path); // Ignore errors

View File

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

View File

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

View File

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

View File

@@ -50,7 +50,6 @@ pub const MAX_IMAGE_HEIGHT: u16 = 15;
pub const MIN_IMAGE_HEIGHT: u16 = 3;
/// Таймаут скачивания файла (в секундах)
#[allow(dead_code)]
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 end = (entity.offset + entity.length) as usize;
for item in char_styles
.iter_mut()
.take(end.min(chars.len()))
.skip(start)
{
for i in start..end.min(chars.len()) {
match &entity.r#type {
TextEntityType::Bold => item.bold = true,
TextEntityType::Italic => item.italic = true,
TextEntityType::Underline => item.underline = true,
TextEntityType::Strikethrough => item.strikethrough = true,
TextEntityType::Bold => char_styles[i].bold = true,
TextEntityType::Italic => char_styles[i].italic = true,
TextEntityType::Underline => char_styles[i].underline = true,
TextEntityType::Strikethrough => char_styles[i].strikethrough = true,
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::TextUrl(_)
| TextEntityType::EmailAddress
| TextEntityType::PhoneNumber => item.url = true,
TextEntityType::Mention | TextEntityType::MentionName(_) => item.mention = true,
| TextEntityType::PhoneNumber => char_styles[i].url = true,
TextEntityType::Mention | TextEntityType::MentionName(_) => {
char_styles[i].mention = true
}
_ => {}
}
}
@@ -279,7 +277,11 @@ mod tests {
#[test]
fn test_format_text_with_bold() {
let text = "Hello";
let entities = vec![TextEntity { offset: 0, length: 5, r#type: TextEntityType::Bold }];
let entities = vec![TextEntity {
offset: 0,
length: 5,
r#type: TextEntityType::Bold,
}];
let spans = format_text_with_entities(text, &entities, Color::White);
assert_eq!(spans.len(), 1);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,26 +3,35 @@
//! Dispatches keyboard events to specialized handlers based on current app mode.
//! Priority order: modals → search → compose → chat → chat list.
use crate::app::methods::{
compose::ComposeMethods, messages::MessageMethods, modal::ModalMethods,
navigation::NavigationMethods, search::SearchMethods,
};
use crate::app::App;
use crate::app::InputMode;
use crate::input::handlers::{
chat::{handle_enter_key, handle_message_selection, handle_open_chat_keyboard_input},
chat_list::handle_chat_list_navigation,
compose::handle_forward_mode,
handle_global_commands,
modal::{
handle_account_switcher, handle_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::app::methods::{
compose::ComposeMethods,
messages::MessageMethods,
modal::ModalMethods,
navigation::NavigationMethods,
search::SearchMethods,
};
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;
/// Обработка клавиши 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;
} else {
// Очищаем черновик если инпут пустой
let _ = app
.td_client
.set_draft_message(chat_id, String::new())
.await;
let _ = app.td_client.set_draft_message(chat_id, String::new()).await;
}
app.close_chat();
@@ -325,3 +331,4 @@ async fn navigate_to_adjacent_photo<T: TdClientTrait>(app: &mut App<T>, directio
};
app.status_message = Some(msg.to_string());
}

View File

@@ -37,9 +37,11 @@ fn parse_account_arg() -> Option<String> {
let args: Vec<String> = std::env::args().collect();
let mut i = 1;
while i < args.len() {
if args[i] == "--account" && i + 1 < args.len() {
if args[i] == "--account" {
if i + 1 < args.len() {
return Some(args[i + 1].clone());
}
}
i += 1;
}
None
@@ -55,7 +57,7 @@ async fn main() -> Result<(), io::Error> {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("warn")),
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("warn"))
)
.init();
@@ -68,16 +70,15 @@ async fn main() -> Result<(), io::Error> {
// Резолвим аккаунт из CLI или default
let account_arg = parse_account_arg();
let (account_name, db_path) =
accounts::resolve_account(&accounts_config, account_arg.as_deref()).unwrap_or_else(|e| {
accounts::resolve_account(&accounts_config, account_arg.as_deref())
.unwrap_or_else(|e| {
eprintln!("Error: {}", e);
std::process::exit(1);
});
// Создаём директорию аккаунта если её нет
let db_path = accounts::ensure_account_dir(
account_arg
.as_deref()
.unwrap_or(&accounts_config.default_account),
account_arg.as_deref().unwrap_or(&accounts_config.default_account),
)
.unwrap_or(db_path);
@@ -172,7 +173,7 @@ async fn run_app<B: ratatui::backend::Backend, T: tdlib::TdClientTrait>(
let polling_handle = tokio::spawn(async move {
while !should_stop_clone.load(Ordering::Relaxed) {
// 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 update_tx.send(update).is_err() {
break; // Канал закрыт, выходим
@@ -259,7 +260,7 @@ async fn run_app<B: ratatui::backend::Backend, T: tdlib::TdClientTrait>(
// Проверяем завершение воспроизведения
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;
}
@@ -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;
// Ждём завершения polling задачи (с таймаутом)
with_timeout_ignore(
Duration::from_secs(SHUTDOWN_TIMEOUT_SECS),
polling_handle,
)
.await;
with_timeout_ignore(Duration::from_secs(SHUTDOWN_TIMEOUT_SECS), polling_handle).await;
return Ok(());
}
@@ -346,7 +343,10 @@ async fn run_app<B: ratatui::backend::Backend, T: tdlib::TdClientTrait>(
// Process pending chat initialization (reply info, pinned, photos)
if let Some(chat_id) = app.pending_chat_init.take() {
// Загружаем недостающие reply info (игнорируем ошибки)
with_timeout_ignore(Duration::from_secs(5), app.td_client.fetch_missing_reply_info())
with_timeout_ignore(
Duration::from_secs(5),
app.td_client.fetch_missing_reply_info(),
)
.await;
// Загружаем последнее закреплённое сообщение (игнорируем ошибки)
@@ -385,7 +385,9 @@ async fn run_app<B: ratatui::backend::Backend, T: tdlib::TdClientTrait>(
for file_id in photo_file_ids {
let tx = tx.clone();
tokio::spawn(async move {
let result = tokio::time::timeout(Duration::from_secs(5), async {
let result = tokio::time::timeout(
Duration::from_secs(5),
async {
match tdlib_rs::functions::download_file(
file_id, 1, 0, 0, true, client_id,
)
@@ -400,7 +402,8 @@ async fn run_app<B: ratatui::backend::Backend, T: tdlib::TdClientTrait>(
Ok(_) => Err("Файл не скачан".to_string()),
Err(e) => Err(format!("{:?}", e)),
}
})
},
)
.await;
let result = match result {

View File

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

View File

@@ -108,7 +108,6 @@ impl ImageRenderer {
}
/// Удаляет протокол для сообщения
#[allow(dead_code)]
pub fn remove(&mut self, msg_id: &MessageId) {
let msg_id_i64 = msg_id.as_i64();
self.protocols.remove(&msg_id_i64);
@@ -116,7 +115,6 @@ impl ImageRenderer {
}
/// Очищает все протоколы
#[allow(dead_code)]
pub fn clear(&mut self) {
self.protocols.clear();
self.access_order.clear();

View File

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

View File

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

View File

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

View File

@@ -3,7 +3,7 @@
//! This module contains utility functions for managing chats,
//! including finding, updating, and adding/removing chats.
use crate::constants::{MAX_CHATS, MAX_CHAT_USER_IDS};
use crate::constants::{MAX_CHAT_USER_IDS, MAX_CHATS};
use crate::types::{ChatId, MessageId, UserId};
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() {
// Удаляем из списка если уже был добавлен
client
.chats_mut()
.retain(|c| c.id != ChatId::new(td_chat.id));
client.chats_mut().retain(|c| c.id != ChatId::new(td_chat.id));
return;
}
@@ -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);
client.user_cache.chat_user_ids.insert(chat_id, user_id);
// Проверяем, есть ли уже username в кэше (peek не обновляет LRU)
client
.user_cache
.user_usernames
client.user_cache.user_usernames
.peek(&user_id)
.map(|u| format!("@{}", u))
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,8 +4,8 @@ mod chat_helpers; // Chat management helpers
pub mod chats;
pub mod client;
mod client_impl; // Private module for trait implementation
mod message_conversion; // Message conversion utilities (for messages.rs)
mod message_converter; // Message conversion utilities (for client.rs)
mod message_conversion; // Message conversion utilities (for messages.rs)
pub mod messages;
pub mod reactions;
pub mod r#trait;
@@ -17,7 +17,6 @@ pub mod users;
pub use auth::AuthState;
pub use client::TdClient;
pub use r#trait::TdClientTrait;
#[allow(unused_imports)]
pub use types::{
ChatInfo, FolderInfo, MediaInfo, MessageBuilder, MessageInfo, NetworkState, PhotoDownloadState,
PhotoInfo, PlaybackState, PlaybackStatus, ProfileInfo, ReplyInfo, UserOnlineStatus,

View File

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

View File

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

View File

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

View File

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

View File

@@ -175,9 +175,7 @@ impl UserCache {
}
// Сохраняем имя
let display_name = format!("{} {}", user.first_name, user.last_name)
.trim()
.to_string();
let display_name = format!("{} {}", user.first_name, user.last_name).trim().to_string();
self.user_names.insert(UserId::new(user_id), display_name);
// Обновляем статус
@@ -213,7 +211,6 @@ impl UserCache {
/// # Returns
///
/// Имя пользователя (first_name + last_name) или "User {id}" если не найден.
#[allow(dead_code)]
pub async fn get_user_name(&self, user_id: UserId) -> String {
// Сначала пытаемся получить из кэша
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 {
Ok(User::User(user)) => {
let name = format!("{} {}", user.first_name, user.last_name)
.trim()
.to_string();
let name = format!("{} {}", user.first_name, user.last_name).trim().to_string();
name
}
_ => format!("User {}", user_id.as_i64()),
@@ -262,7 +257,8 @@ impl UserCache {
}
Err(_) => {
// Если не удалось загрузить, сохраняем placeholder
self.user_names.insert(user_id, format!("User {}", user_id));
self.user_names
.insert(user_id, format!("User {}", user_id));
}
}
}

View File

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

View File

@@ -1,7 +1,7 @@
//! Chat list panel: search box, chat items, and user online status.
use crate::app::methods::{compose::ComposeMethods, search::SearchMethods};
use crate::app::App;
use crate::app::methods::{compose::ComposeMethods, search::SearchMethods};
use crate::tdlib::TdClientTrait;
use crate::tdlib::UserOnlineStatus;
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
} else {
let filtered = app.get_filtered_chats();
app.chat_list_state
.selected()
.and_then(|i| filtered.get(i).map(|c| c.id))
app.chat_list_state.selected().and_then(|i| filtered.get(i).map(|c| c.id))
};
let (status_text, status_color) = match status_chat_id {
Some(chat_id) => format_user_status(app.td_client.get_user_status_by_chat_id(chat_id)),

View File

@@ -21,7 +21,7 @@ pub fn render_emoji_picker(
) {
// Размеры модалки (зависят от количества реакций)
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_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 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);
@@ -82,7 +87,10 @@ pub fn render_emoji_picker(
.add_modifier(Modifier::BOLD),
),
Span::raw("Добавить "),
Span::styled(" [Esc] ", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)),
Span::styled(
" [Esc] ",
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
),
Span::raw("Отмена"),
]));

View File

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

View File

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

View File

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

View File

@@ -1,17 +1,17 @@
//! Reusable UI components: message bubbles, input fields, modals, lists.
pub mod chat_list_item;
pub mod emoji_picker;
pub mod modal;
pub mod input_field;
pub mod message_bubble;
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 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_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::styled(" [n/Esc] ", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)),
Span::styled(
" [n/Esc] ",
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
),
Span::raw("Нет"),
]),
];

View File

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

View File

@@ -1,7 +1,7 @@
use crate::app::App;
use crate::app::InputMode;
use crate::tdlib::NetworkState;
use crate::tdlib::TdClientTrait;
use crate::tdlib::NetworkState;
use ratatui::{
layout::Rect,
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 {
format!(" {}{}Error: {} ", account_indicator, network_indicator, err)
} else if app.is_searching {
format!(
" {}{}↑/↓: Navigate | Enter: Select | Esc: Cancel ",
account_indicator, network_indicator
)
format!(" {}{}↑/↓: Navigate | Enter: Select | Esc: Cancel ", account_indicator, network_indicator)
} else if app.selected_chat_id.is_some() {
let mode_str = match app.input_mode {
InputMode::Normal => "[NORMAL] j/k: Nav | i: Insert | d/r/f/y: Actions | Esc: Close",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -17,7 +17,11 @@ fn test_open_account_switcher() {
assert!(app.account_switcher.is_some());
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_eq!(*selected_index, 0);
assert_eq!(current_account, "default");
@@ -54,7 +58,11 @@ fn test_account_switcher_navigate_down() {
}
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())
assert_eq!(*selected_index, accounts.len());
}
@@ -129,7 +137,11 @@ fn test_confirm_add_account_transitions_to_add_state() {
app.account_switcher_confirm();
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_eq!(*cursor_position, 0);
assert!(error.is_none());

View File

@@ -1,6 +1,8 @@
// 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]
fn test_default_single_config() {

View File

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

View File

@@ -1,9 +1,6 @@
// Integration tests for config flow
use tele_tui::config::{
AudioConfig, ColorsConfig, Config, GeneralConfig, ImagesConfig, Keybindings,
NotificationsConfig,
};
use tele_tui::config::{AudioConfig, Config, ColorsConfig, GeneralConfig, ImagesConfig, Keybindings, NotificationsConfig};
/// Test: Дефолтные значения конфигурации
#[test]
@@ -25,7 +22,9 @@ fn test_config_default_values() {
#[test]
fn test_config_custom_values() {
let config = Config {
general: GeneralConfig { timezone: "+05:00".to_string() },
general: GeneralConfig {
timezone: "+05:00".to_string(),
},
colors: ColorsConfig {
incoming_message: "cyan".to_string(),
outgoing_message: "blue".to_string(),
@@ -109,7 +108,9 @@ fn test_parse_color_case_insensitive() {
#[test]
fn test_config_toml_serialization() {
let original_config = Config {
general: GeneralConfig { timezone: "-05:00".to_string() },
general: GeneralConfig {
timezone: "-05:00".to_string(),
},
colors: ColorsConfig {
incoming_message: "cyan".to_string(),
outgoing_message: "blue".to_string(),
@@ -163,19 +164,25 @@ mod timezone_tests {
#[test]
fn test_timezone_formats() {
let positive = Config {
general: GeneralConfig { timezone: "+03:00".to_string() },
general: GeneralConfig {
timezone: "+03:00".to_string(),
},
..Default::default()
};
assert_eq!(positive.general.timezone, "+03:00");
let negative = Config {
general: GeneralConfig { timezone: "-05:00".to_string() },
general: GeneralConfig {
timezone: "-05:00".to_string(),
},
..Default::default()
};
assert_eq!(negative.general.timezone, "-05:00");
let zero = Config {
general: GeneralConfig { timezone: "+00:00".to_string() },
general: GeneralConfig {
timezone: "+00:00".to_string(),
},
..Default::default()
};
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 msg = client
.send_message(ChatId::new(123), "Delete me".to_string(), None, None)
.await
.unwrap();
let msg = client.send_message(ChatId::new(123), "Delete me".to_string(), None, None).await.unwrap();
// Проверяем что сообщение есть
assert_eq!(client.get_messages(123).len(), 1);
// Удаляем сообщение
client
.delete_messages(ChatId::new(123), vec![msg.id()], false)
.await
.unwrap();
client.delete_messages(ChatId::new(123), vec![msg.id()], false).await.unwrap();
// Проверяем что удаление записалось
assert_eq!(client.get_deleted_messages().len(), 1);
@@ -40,30 +34,15 @@ async fn test_delete_multiple_messages() {
let client = FakeTdClient::new();
// Отправляем 3 сообщения
let msg1 = client
.send_message(ChatId::new(123), "Message 1".to_string(), None, None)
.await
.unwrap();
let msg2 = client
.send_message(ChatId::new(123), "Message 2".to_string(), None, None)
.await
.unwrap();
let msg3 = client
.send_message(ChatId::new(123), "Message 3".to_string(), None, None)
.await
.unwrap();
let msg1 = client.send_message(ChatId::new(123), "Message 1".to_string(), None, None).await.unwrap();
let msg2 = client.send_message(ChatId::new(123), "Message 2".to_string(), None, None).await.unwrap();
let msg3 = client.send_message(ChatId::new(123), "Message 3".to_string(), None, None).await.unwrap();
assert_eq!(client.get_messages(123).len(), 3);
// Удаляем первое и третье
client
.delete_messages(ChatId::new(123), vec![msg1.id()], false)
.await
.unwrap();
client
.delete_messages(ChatId::new(123), vec![msg3.id()], false)
.await
.unwrap();
client.delete_messages(ChatId::new(123), vec![msg1.id()], false).await.unwrap();
client.delete_messages(ChatId::new(123), vec![msg3.id()], false).await.unwrap();
// Проверяем историю удалений
assert_eq!(client.get_deleted_messages().len(), 2);
@@ -110,18 +89,12 @@ async fn test_delete_nonexistent_message() {
let client = FakeTdClient::new();
// Отправляем одно сообщение
let msg = client
.send_message(ChatId::new(123), "Exists".to_string(), None, None)
.await
.unwrap();
let msg = client.send_message(ChatId::new(123), "Exists".to_string(), None, None).await.unwrap();
assert_eq!(client.get_messages(123).len(), 1);
// Пытаемся удалить несуществующее
client
.delete_messages(ChatId::new(123), vec![MessageId::new(999)], false)
.await
.unwrap();
client.delete_messages(ChatId::new(123), vec![MessageId::new(999)], false).await.unwrap();
// Удаление записалось в историю
assert_eq!(client.get_deleted_messages().len(), 1);
@@ -139,10 +112,7 @@ async fn test_delete_nonexistent_message() {
async fn test_delete_with_confirmation_flow() {
let client = FakeTdClient::new();
let msg = client
.send_message(ChatId::new(123), "To delete".to_string(), None, None)
.await
.unwrap();
let msg = client.send_message(ChatId::new(123), "To delete".to_string(), None, None).await.unwrap();
// Шаг 1: Пользователь нажал 'd' -> показывается модалка (в App)
// В FakeTdClient просто проверяем что сообщение ещё есть
@@ -150,10 +120,7 @@ async fn test_delete_with_confirmation_flow() {
assert_eq!(client.get_deleted_messages().len(), 0);
// Шаг 2: Пользователь подтвердил 'y' -> удаляем
client
.delete_messages(ChatId::new(123), vec![msg.id()], false)
.await
.unwrap();
client.delete_messages(ChatId::new(123), vec![msg.id()], false).await.unwrap();
// Проверяем что удалено
assert_eq!(client.get_messages(123).len(), 0);
@@ -165,10 +132,7 @@ async fn test_delete_with_confirmation_flow() {
async fn test_cancel_delete_keeps_message() {
let client = FakeTdClient::new();
let msg = client
.send_message(ChatId::new(123), "Keep me".to_string(), None, None)
.await
.unwrap();
let msg = client.send_message(ChatId::new(123), "Keep me".to_string(), None, None).await.unwrap();
// Шаг 1: Пользователь нажал 'd' -> показалась модалка
assert_eq!(client.get_messages(123).len(), 1);

View File

@@ -3,8 +3,8 @@
mod helpers;
use helpers::test_data::{create_test_chat, TestChatBuilder};
use std::collections::HashMap;
use tele_tui::types::{ChatId, MessageId};
use std::collections::HashMap;
/// Простая структура для хранения черновиков (как в реальном App)
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 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
let loaded_chats = client.load_chats(50).await.unwrap();
@@ -55,7 +58,9 @@ async fn test_user_journey_open_chat_send_message() {
.outgoing()
.build();
let client = client.with_message(123, msg1).with_message(123, msg2);
let client = client
.with_message(123, msg1)
.with_message(123, msg2);
// 3. Открываем чат
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!");
// 7. Отправляем новое сообщение
let _new_msg = client
.send_message(ChatId::new(123), "What's for dinner?".to_string(), None, None)
.await
.unwrap();
let _new_msg = client.send_message(
ChatId::new(123),
"What's for dinner?".to_string(),
None,
None
).await.unwrap();
// 8. Проверяем что сообщение отправлено
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);
// 4. Входящее сообщение от Alice
client.simulate_incoming_message(
ChatId::new(789),
"How's the project going?".to_string(),
"Alice",
);
client.simulate_incoming_message(ChatId::new(789), "How's the project going?".to_string(), "Alice");
// Проверяем update
let update = rx.try_recv().ok();
assert!(matches!(update, Some(TdUpdate::NewMessage { .. })));
// 5. Отвечаем
client
.send_message(
client.send_message(
ChatId::new(789),
"Almost done! Just need to finish tests.".to_string(),
None,
None,
)
.await
.unwrap();
None
).await.unwrap();
// 6. Проверяем историю после первого обмена
let history1 = client.get_chat_history(ChatId::new(789), 50).await.unwrap();
assert_eq!(history1.len(), 2);
// 7. Еще одно входящее сообщение
client.simulate_incoming_message(
ChatId::new(789),
"Great! Let me know if you need help.".to_string(),
"Alice",
);
client.simulate_incoming_message(ChatId::new(789), "Great! Let me know if you need help.".to_string(), "Alice");
// 8. Снова отвечаем
client
.send_message(ChatId::new(789), "Will do, thanks!".to_string(), None, None)
.await
.unwrap();
client.send_message(
ChatId::new(789),
"Will do, thanks!".to_string(),
None,
None
).await.unwrap();
// 9. Финальная проверка истории
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));
// 3. Отправляем сообщение в первом чате
client
.send_message(ChatId::new(111), "Message in chat 1".to_string(), None, None)
.await
.unwrap();
client.send_message(
ChatId::new(111),
"Message in chat 1".to_string(),
None,
None
).await.unwrap();
// 4. Переключаемся на второй чат
client.open_chat(ChatId::new(222)).await.unwrap();
assert_eq!(client.get_current_chat_id(), Some(222));
// 5. Отправляем сообщение во втором чате
client
.send_message(ChatId::new(222), "Message in chat 2".to_string(), None, None)
.await
.unwrap();
client.send_message(
ChatId::new(222),
"Message in chat 2".to_string(),
None,
None
).await.unwrap();
// 6. Переключаемся на третий чат
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();
// 2. Отправляем сообщение с опечаткой
let msg = client
.send_message(ChatId::new(555), "I'll be there at 5pm tomorow".to_string(), None, None)
.await
.unwrap();
let msg = client.send_message(
ChatId::new(555),
"I'll be there at 5pm tomorow".to_string(),
None,
None
).await.unwrap();
// 3. Проверяем что сообщение отправлено
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");
// 4. Исправляем опечатку
client
.edit_message(ChatId::new(555), msg.id(), "I'll be there at 5pm tomorrow".to_string())
.await
.unwrap();
client.edit_message(
ChatId::new(555),
msg.id(),
"I'll be there at 5pm tomorrow".to_string()
).await.unwrap();
// 5. Проверяем что сообщение отредактировано
let edited_history = client.get_chat_history(ChatId::new(555), 50).await.unwrap();
assert_eq!(edited_history.len(), 1);
assert_eq!(edited_history[0].text(), "I'll be there at 5pm tomorrow");
assert!(
edited_history[0].metadata.edit_date > 0,
"Должна быть установлена дата редактирования"
);
assert!(edited_history[0].metadata.edit_date > 0, "Должна быть установлена дата редактирования");
// 6. Проверяем историю редактирований
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);
// 3. Входящее сообщение с вопросом
client.simulate_incoming_message(
ChatId::new(666),
"Can you send me the report?".to_string(),
"Charlie",
);
client.simulate_incoming_message(ChatId::new(666), "Can you send me the report?".to_string(), "Charlie");
let update = rx.try_recv().ok();
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();
// 4. Отправляем другое сообщение (не связанное)
client
.send_message(ChatId::new(666), "Working on it now".to_string(), None, None)
.await
.unwrap();
client.send_message(
ChatId::new(666),
"Working on it now".to_string(),
None,
None
).await.unwrap();
// 5. Отвечаем на конкретный вопрос (reply)
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(),
});
client
.send_message(
client.send_message(
ChatId::new(666),
"Sure, sending now!".to_string(),
Some(question_msg_id),
reply_info,
)
.await
.unwrap();
reply_info
).await.unwrap();
// 6. Проверяем что reply сохранён
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. Открываем чат и отправляем сообщение
client.open_chat(ChatId::new(888)).await.unwrap();
client
.send_message(ChatId::new(888), "Test message".to_string(), None, None)
.await
.unwrap();
client.send_message(
ChatId::new(888),
"Test message".to_string(),
None,
None
).await.unwrap();
// Очищаем канал от update NewMessage
let _ = rx.try_recv();
@@ -392,14 +391,8 @@ async fn test_user_journey_network_state_changes() {
// Проверяем update
let update = rx.try_recv().ok();
assert!(
matches!(
update,
Some(TdUpdate::ConnectionState { state: NetworkState::WaitingForNetwork })
),
"Expected ConnectionState update, got: {:?}",
update
);
assert!(matches!(update, Some(TdUpdate::ConnectionState { state: NetworkState::WaitingForNetwork })),
"Expected ConnectionState update, got: {:?}", update);
// 6. Проверяем что состояние изменилось
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);
// 8. Отправляем сообщение после восстановления
client
.send_message(ChatId::new(888), "Connection restored!".to_string(), None, None)
.await
.unwrap();
client.send_message(
ChatId::new(888),
"Connection restored!".to_string(),
None,
None
).await.unwrap();
// 9. Проверяем что оба сообщения в истории
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 msg = client
.send_message(ChatId::new(123), "Original text".to_string(), None, None)
.await
.unwrap();
let msg = client.send_message(ChatId::new(123), "Original text".to_string(), None, None).await.unwrap();
// Редактируем сообщение
client
.edit_message(ChatId::new(123), msg.id(), "Edited text".to_string())
.await
.unwrap();
client.edit_message(ChatId::new(123), msg.id(), "Edited text".to_string()).await.unwrap();
// Проверяем что редактирование записалось
assert_eq!(client.get_edited_messages().len(), 1);
@@ -40,10 +34,7 @@ async fn test_edit_message_sets_edit_date() {
let client = FakeTdClient::new();
// Отправляем сообщение
let msg = client
.send_message(ChatId::new(123), "Original".to_string(), None, None)
.await
.unwrap();
let msg = client.send_message(ChatId::new(123), "Original".to_string(), None, None).await.unwrap();
// Получаем дату до редактирования
let messages_before = client.get_messages(123);
@@ -51,10 +42,7 @@ async fn test_edit_message_sets_edit_date() {
assert_eq!(messages_before[0].metadata.edit_date, 0); // Не редактировалось
// Редактируем сообщение
client
.edit_message(ChatId::new(123), msg.id(), "Edited".to_string())
.await
.unwrap();
client.edit_message(ChatId::new(123), msg.id(), "Edited".to_string()).await.unwrap();
// Проверяем что edit_date установлена
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() {
let client = FakeTdClient::new();
let msg = client
.send_message(ChatId::new(123), "Version 1".to_string(), None, None)
.await
.unwrap();
let msg = client.send_message(ChatId::new(123), "Version 1".to_string(), None, None).await.unwrap();
// Первое редактирование
client
.edit_message(ChatId::new(123), msg.id(), "Version 2".to_string())
.await
.unwrap();
client.edit_message(ChatId::new(123), msg.id(), "Version 2".to_string()).await.unwrap();
// Второе редактирование
client
.edit_message(ChatId::new(123), msg.id(), "Version 3".to_string())
.await
.unwrap();
client.edit_message(ChatId::new(123), msg.id(), "Version 3".to_string()).await.unwrap();
// Третье редактирование
client
.edit_message(ChatId::new(123), msg.id(), "Final version".to_string())
.await
.unwrap();
client.edit_message(ChatId::new(123), msg.id(), "Final version".to_string()).await.unwrap();
// Проверяем что все 3 редактирования записаны
assert_eq!(client.get_edited_messages().len(), 3);
@@ -131,9 +107,7 @@ async fn test_edit_nonexistent_message() {
let client = FakeTdClient::new();
// Пытаемся отредактировать несуществующее сообщение
let result = client
.edit_message(ChatId::new(123), MessageId::new(999), "New text".to_string())
.await;
let result = client.edit_message(ChatId::new(123), MessageId::new(999), "New text".to_string()).await;
// Должна вернуться ошибка
assert!(result.is_err());
@@ -150,10 +124,7 @@ async fn test_edit_nonexistent_message() {
async fn test_edit_history_tracking() {
let client = FakeTdClient::new();
let msg = client
.send_message(ChatId::new(123), "Original".to_string(), None, None)
.await
.unwrap();
let msg = client.send_message(ChatId::new(123), "Original".to_string(), None, None).await.unwrap();
// Симулируем начало редактирования -> изменение -> отмена
// Отменять на уровне FakeTdClient нельзя, но можно проверить что original сохранён
@@ -163,20 +134,14 @@ async fn test_edit_history_tracking() {
let original = messages_before[0].text().to_string();
// Редактируем
client
.edit_message(ChatId::new(123), msg.id(), "Edited".to_string())
.await
.unwrap();
client.edit_message(ChatId::new(123), msg.id(), "Edited".to_string()).await.unwrap();
// Проверяем что изменилось
let messages_edited = client.get_messages(123);
assert_eq!(messages_edited[0].text(), "Edited");
// Можем "отменить" редактирование вернув original
client
.edit_message(ChatId::new(123), msg.id(), original)
.await
.unwrap();
client.edit_message(ChatId::new(123), msg.id(), original).await.unwrap();
// Проверяем что вернулось
let messages_restored = client.get_messages(123);

View File

@@ -1,8 +1,8 @@
// Test App builder
use super::FakeTdClient;
use ratatui::widgets::ListState;
use std::collections::HashMap;
use super::FakeTdClient;
use tele_tui::app::{App, AppScreen, ChatState, InputMode};
use tele_tui::config::Config;
use tele_tui::tdlib::AuthState;
@@ -10,7 +10,6 @@ use tele_tui::tdlib::{ChatInfo, MessageInfo};
use tele_tui::types::{ChatId, MessageId};
/// Builder для создания тестового App с FakeTdClient\n///\n/// Использует trait-based DI для подмены TdClient на FakeTdClient в тестах.
#[allow(dead_code)]
pub struct TestAppBuilder {
config: Config,
screen: AppScreen,
@@ -35,7 +34,6 @@ impl Default for TestAppBuilder {
}
}
#[allow(dead_code)]
impl TestAppBuilder {
pub fn new() -> Self {
Self {
@@ -137,8 +135,7 @@ impl TestAppBuilder {
/// Подтверждение удаления
pub fn delete_confirmation(mut self, message_id: i64) -> Self {
self.chat_state =
Some(ChatState::DeleteConfirmation { message_id: MessageId::new(message_id) });
self.chat_state = Some(ChatState::DeleteConfirmation { message_id: MessageId::new(message_id) });
self
}
@@ -184,7 +181,9 @@ impl TestAppBuilder {
/// Режим пересылки сообщения
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
}

View File

@@ -2,53 +2,25 @@
use std::collections::HashMap;
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::types::{FolderInfo, ReactionInfo};
use tele_tui::types::{ChatId, MessageId, UserId};
use tokio::sync::mpsc;
/// Update события от TDLib (упрощённая версия)
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub enum TdUpdate {
NewMessage {
chat_id: ChatId,
message: MessageInfo,
},
MessageContent {
chat_id: ChatId,
message_id: MessageId,
new_text: String,
},
DeleteMessages {
chat_id: ChatId,
message_ids: Vec<MessageId>,
},
ChatAction {
chat_id: ChatId,
user_id: UserId,
action: String,
},
MessageInteractionInfo {
chat_id: ChatId,
message_id: MessageId,
reactions: Vec<ReactionInfo>,
},
ConnectionState {
state: NetworkState,
},
ChatReadOutbox {
chat_id: ChatId,
last_read_outbox_message_id: MessageId,
},
ChatDraftMessage {
chat_id: ChatId,
draft_text: Option<String>,
},
NewMessage { chat_id: ChatId, message: MessageInfo },
MessageContent { chat_id: ChatId, message_id: MessageId, new_text: String },
DeleteMessages { chat_id: ChatId, message_ids: Vec<MessageId> },
ChatAction { chat_id: ChatId, user_id: UserId, action: String },
MessageInteractionInfo { chat_id: ChatId, message_id: MessageId, reactions: Vec<ReactionInfo> },
ConnectionState { state: NetworkState },
ChatReadOutbox { chat_id: ChatId, last_read_outbox_message_id: MessageId },
ChatDraftMessage { chat_id: ChatId, draft_text: Option<String> },
}
/// Упрощённый mock TDLib клиента для тестов
#[allow(dead_code)]
pub struct FakeTdClient {
// Данные
pub chats: Arc<Mutex<Vec<ChatInfo>>>,
@@ -88,7 +60,6 @@ pub struct FakeTdClient {
}
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct SentMessage {
pub chat_id: i64,
pub text: String,
@@ -97,7 +68,6 @@ pub struct SentMessage {
}
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct EditedMessage {
pub chat_id: i64,
pub message_id: MessageId,
@@ -105,7 +75,6 @@ pub struct EditedMessage {
}
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct DeletedMessages {
pub chat_id: i64,
pub message_ids: Vec<MessageId>,
@@ -113,7 +82,6 @@ pub struct DeletedMessages {
}
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct ForwardedMessages {
pub from_chat_id: i64,
pub to_chat_id: i64,
@@ -121,7 +89,6 @@ pub struct ForwardedMessages {
}
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct SearchQuery {
pub chat_id: i64,
pub query: String,
@@ -165,7 +132,6 @@ impl Clone for FakeTdClient {
}
}
#[allow(dead_code)]
impl FakeTdClient {
pub fn new() -> Self {
Self {
@@ -176,14 +142,8 @@ impl FakeTdClient {
profiles: Arc::new(Mutex::new(HashMap::new())),
drafts: Arc::new(Mutex::new(HashMap::new())),
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)),
typing_chat_id: Arc::new(Mutex::new(None)),
@@ -245,16 +205,16 @@ impl FakeTdClient {
/// Добавить несколько сообщений в чат
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
}
/// Добавить папку
pub fn with_folder(self, id: i32, name: &str) -> Self {
self.folders
.lock()
.unwrap()
.push(FolderInfo { id, name: name.to_string() });
self.folders.lock().unwrap().push(FolderInfo { id, name: name.to_string() });
self
}
@@ -284,10 +244,7 @@ impl FakeTdClient {
/// Добавить скачанный файл (для mock download_file)
pub fn with_downloaded_file(self, file_id: i32, path: &str) -> Self {
self.downloaded_files
.lock()
.unwrap()
.insert(file_id, path.to_string());
self.downloaded_files.lock().unwrap().insert(file_id, path.to_string());
self
}
@@ -309,14 +266,7 @@ impl FakeTdClient {
tokio::time::sleep(tokio::time::Duration::from_millis(50)).await;
}
let chats = self
.chats
.lock()
.unwrap()
.iter()
.take(limit)
.cloned()
.collect();
let chats = self.chats.lock().unwrap().iter().take(limit).cloned().collect();
Ok(chats)
}
@@ -331,11 +281,7 @@ impl FakeTdClient {
}
/// Получить историю чата
pub async fn get_chat_history(
&self,
chat_id: ChatId,
limit: i32,
) -> Result<Vec<MessageInfo>, String> {
pub async fn get_chat_history(&self, chat_id: ChatId, limit: i32) -> Result<Vec<MessageInfo>, String> {
if self.should_fail() {
return Err("Failed to load history".to_string());
}
@@ -344,8 +290,7 @@ impl FakeTdClient {
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
}
let messages = self
.messages
let messages = self.messages
.lock()
.unwrap()
.get(&chat_id.as_i64())
@@ -356,11 +301,7 @@ impl FakeTdClient {
}
/// Загрузить старые сообщения
pub async fn load_older_messages(
&self,
chat_id: ChatId,
from_message_id: MessageId,
) -> Result<Vec<MessageInfo>, String> {
pub async fn load_older_messages(&self, chat_id: ChatId, from_message_id: MessageId) -> Result<Vec<MessageInfo>, String> {
if self.should_fail() {
return Err("Failed to load older messages".to_string());
}
@@ -428,7 +369,10 @@ impl FakeTdClient {
.push(message.clone());
// Отправляем Update::NewMessage
self.send_update(TdUpdate::NewMessage { chat_id, message: message.clone() });
self.send_update(TdUpdate::NewMessage {
chat_id,
message: message.clone(),
});
Ok(message)
}
@@ -465,7 +409,11 @@ impl FakeTdClient {
drop(messages); // Освобождаем lock перед отправкой 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);
}
@@ -503,7 +451,10 @@ impl FakeTdClient {
drop(messages);
// Отправляем Update
self.send_update(TdUpdate::DeleteMessages { chat_id, message_ids });
self.send_update(TdUpdate::DeleteMessages {
chat_id,
message_ids,
});
Ok(())
}
@@ -523,10 +474,7 @@ impl FakeTdClient {
tokio::time::sleep(tokio::time::Duration::from_millis(150)).await;
}
self.forwarded_messages
.lock()
.unwrap()
.push(ForwardedMessages {
self.forwarded_messages.lock().unwrap().push(ForwardedMessages {
from_chat_id: from_chat_id.as_i64(),
to_chat_id: to_chat_id.as_i64(),
message_ids,
@@ -536,11 +484,7 @@ impl FakeTdClient {
}
/// Поиск сообщений в чате
pub async fn search_messages(
&self,
chat_id: ChatId,
query: &str,
) -> Result<Vec<MessageInfo>, String> {
pub async fn search_messages(&self, chat_id: ChatId, query: &str) -> Result<Vec<MessageInfo>, String> {
if self.should_fail() {
return Err("Failed to search messages".to_string());
}
@@ -570,10 +514,7 @@ impl FakeTdClient {
if text.is_empty() {
self.drafts.lock().unwrap().remove(&chat_id.as_i64());
} else {
self.drafts
.lock()
.unwrap()
.insert(chat_id.as_i64(), text.clone());
self.drafts.lock().unwrap().insert(chat_id.as_i64(), text.clone());
}
self.send_update(TdUpdate::ChatDraftMessage {
@@ -586,10 +527,7 @@ impl FakeTdClient {
/// Отправить действие в чате (typing, etc.)
pub async fn send_chat_action(&self, chat_id: ChatId, action: String) {
self.chat_actions
.lock()
.unwrap()
.push((chat_id.as_i64(), action.clone()));
self.chat_actions.lock().unwrap().push((chat_id.as_i64(), action.clone()));
if action == "Typing" {
*self.typing_chat_id.lock().unwrap() = Some(chat_id.as_i64());
@@ -629,10 +567,7 @@ impl FakeTdClient {
let reactions = &mut msg.interactions.reactions;
// Toggle logic
if let Some(pos) = reactions
.iter()
.position(|r| r.emoji == emoji && r.is_chosen)
{
if let Some(pos) = reactions.iter().position(|r| r.emoji == emoji && r.is_chosen) {
// Удаляем свою реакцию
reactions.remove(pos);
} else if let Some(reaction) = reactions.iter_mut().find(|r| r.emoji == emoji) {
@@ -768,7 +703,11 @@ impl FakeTdClient {
/// Симулировать typing от собеседника
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(),
});
}
/// Симулировать изменение состояния сети
@@ -897,9 +836,7 @@ mod tests {
let client = FakeTdClient::new();
let chat_id = ChatId::new(123);
let result = client
.send_message(chat_id, "Hello".to_string(), None, None)
.await;
let result = client.send_message(chat_id, "Hello".to_string(), None, None).await;
assert!(result.is_ok());
let sent = client.get_sent_messages();
@@ -913,15 +850,10 @@ mod tests {
let client = FakeTdClient::new();
let chat_id = ChatId::new(123);
let msg = client
.send_message(chat_id, "Hello".to_string(), None, None)
.await
.unwrap();
let msg = client.send_message(chat_id, "Hello".to_string(), None, None).await.unwrap();
let msg_id = msg.id();
let _ = client
.edit_message(chat_id, msg_id, "Hello World".to_string())
.await;
let _ = client.edit_message(chat_id, msg_id, "Hello World".to_string()).await;
let edited = client.get_edited_messages();
assert_eq!(edited.len(), 1);
@@ -934,10 +866,7 @@ mod tests {
let client = FakeTdClient::new();
let chat_id = ChatId::new(123);
let msg = client
.send_message(chat_id, "Hello".to_string(), None, None)
.await
.unwrap();
let msg = client.send_message(chat_id, "Hello".to_string(), None, None).await.unwrap();
let msg_id = msg.id();
let _ = client.delete_messages(chat_id, vec![msg_id], false).await;
@@ -953,9 +882,7 @@ mod tests {
let chat_id = ChatId::new(123);
// Отправляем сообщение
let _ = client
.send_message(chat_id, "Test".to_string(), None, None)
.await;
let _ = client.send_message(chat_id, "Test".to_string(), None, None).await;
// Проверяем что получили Update
if let Some(update) = rx.recv().await {
@@ -997,15 +924,11 @@ mod tests {
client.fail_next();
// Следующая операция должна упасть
let result = client
.send_message(chat_id, "Test".to_string(), None, None)
.await;
let result = client.send_message(chat_id, "Test".to_string(), None, None).await;
assert!(result.is_err());
// Но следующая должна пройти
let result2 = client
.send_message(chat_id, "Test2".to_string(), None, None)
.await;
let result2 = client.send_message(chat_id, "Test2".to_string(), None, None).await;
assert!(result2.is_ok());
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -97,9 +97,7 @@ async fn test_typing_indicator_on() {
// Alice начала печатать в чате 123
// Симулируем через send_chat_action
client
.send_chat_action(ChatId::new(123), "Typing".to_string())
.await;
client.send_chat_action(ChatId::new(123), "Typing".to_string()).await;
assert_eq!(*client.typing_chat_id.lock().unwrap(), Some(123));
@@ -112,15 +110,11 @@ async fn test_typing_indicator_off() {
let client = FakeTdClient::new();
// Изначально Alice печатала
client
.send_chat_action(ChatId::new(123), "Typing".to_string())
.await;
client.send_chat_action(ChatId::new(123), "Typing".to_string()).await;
assert_eq!(*client.typing_chat_id.lock().unwrap(), Some(123));
// Alice перестала печатать
client
.send_chat_action(ChatId::new(123), "Cancel".to_string())
.await;
client.send_chat_action(ChatId::new(123), "Cancel".to_string()).await;
assert_eq!(*client.typing_chat_id.lock().unwrap(), None);
@@ -130,7 +124,7 @@ async fn test_typing_indicator_off() {
/// Test: Отправка своего typing status
#[tokio::test]
async fn test_send_own_typing_status() {
let _client = FakeTdClient::new();
let client = FakeTdClient::new();
// Пользователь начал печатать в чате 456
// В реальном 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 msg = client
.send_message(ChatId::new(123), "React to this!".to_string(), None, None)
.await
.unwrap();
let msg = client.send_message(ChatId::new(123), "React to this!".to_string(), None, None).await.unwrap();
// Добавляем реакцию
client
.toggle_reaction(ChatId::new(123), msg.id(), "👍".to_string())
.await
.unwrap();
client.toggle_reaction(ChatId::new(123), msg.id(), "👍".to_string()).await.unwrap();
// Проверяем что реакция записалась
let messages = client.get_messages(123);
@@ -52,10 +46,7 @@ async fn test_toggle_reaction_removes_it() {
let msg_id = messages_before[0].id();
// Toggle - удаляем свою реакцию
client
.toggle_reaction(ChatId::new(123), msg_id, "👍".to_string())
.await
.unwrap();
client.toggle_reaction(ChatId::new(123), msg_id, "👍".to_string()).await.unwrap();
let messages_after = client.get_messages(123);
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() {
let client = FakeTdClient::new();
let msg = client
.send_message(ChatId::new(123), "Many reactions".to_string(), None, None)
.await
.unwrap();
let msg = client.send_message(ChatId::new(123), "Many reactions".to_string(), None, None).await.unwrap();
// Добавляем несколько разных реакций
client
.toggle_reaction(ChatId::new(123), msg.id(), "👍".to_string())
.await
.unwrap();
client
.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();
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 реакции записались
let messages = client.get_messages(123);
@@ -175,10 +151,7 @@ async fn test_reaction_counter_increases() {
let msg_id = messages_before[0].id();
// Мы добавляем свою реакцию - счётчик должен увеличиться
client
.toggle_reaction(ChatId::new(123), msg_id, "👍".to_string())
.await
.unwrap();
client.toggle_reaction(ChatId::new(123), msg_id, "👍".to_string()).await.unwrap();
let messages = client.get_messages(123);
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();
// Добавляем нашу реакцию
client
.toggle_reaction(ChatId::new(123), msg_id, "🔥".to_string())
.await
.unwrap();
client.toggle_reaction(ChatId::new(123), msg_id, "🔥".to_string()).await.unwrap();
let messages = client.get_messages(123);
let reaction = &messages[0].reactions()[0];

View File

@@ -4,8 +4,8 @@ mod helpers;
use helpers::fake_tdclient::FakeTdClient;
use helpers::test_data::TestMessageBuilder;
use tele_tui::tdlib::types::ForwardInfo;
use tele_tui::tdlib::ReplyInfo;
use tele_tui::tdlib::types::ForwardInfo;
use tele_tui::types::{ChatId, MessageId};
/// Test: Reply создаёт сообщение с reply_to
@@ -28,15 +28,7 @@ async fn test_reply_creates_message_with_reply_to() {
};
// Отвечаем на него
let reply_msg = client
.send_message(
ChatId::new(123),
"Answer!".to_string(),
Some(MessageId::new(100)),
Some(reply_info),
)
.await
.unwrap();
let reply_msg = client.send_message(ChatId::new(123), "Answer!".to_string(), Some(MessageId::new(100)), Some(reply_info)).await.unwrap();
// Проверяем что ответ отправлен с reply_to
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_to
client
.send_message(ChatId::new(123), "Regular message".to_string(), None, None)
.await
.unwrap();
client.send_message(ChatId::new(123), "Regular message".to_string(), None, None).await.unwrap();
// Проверяем что отправилось без reply_to
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
.send_message(
ChatId::new(123),
"Thanks for sharing!".to_string(),
Some(MessageId::new(100)),
Some(reply_info),
)
.await
.unwrap();
let reply_msg = client.send_message(ChatId::new(123), "Thanks for sharing!".to_string(), Some(MessageId::new(100)), Some(reply_info)).await.unwrap();
// Проверяем что reply содержит reply_to
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 msg = client
.send_message(ChatId::new(123), "Hello, Mom!".to_string(), None, None)
.await
.unwrap();
let msg = client.send_message(ChatId::new(123), "Hello, Mom!".to_string(), None, None).await.unwrap();
// Проверяем что сообщение было отправлено
assert_eq!(client.get_sent_messages().len(), 1);
@@ -39,22 +36,13 @@ async fn test_send_multiple_messages_updates_list() {
let client = FakeTdClient::new();
// Отправляем первое сообщение
let msg1 = client
.send_message(ChatId::new(123), "Message 1".to_string(), None, None)
.await
.unwrap();
let msg1 = client.send_message(ChatId::new(123), "Message 1".to_string(), None, None).await.unwrap();
// Отправляем второе сообщение
let msg2 = client
.send_message(ChatId::new(123), "Message 2".to_string(), None, None)
.await
.unwrap();
let msg2 = client.send_message(ChatId::new(123), "Message 2".to_string(), None, None).await.unwrap();
// Отправляем третье сообщение
let msg3 = client
.send_message(ChatId::new(123), "Message 3".to_string(), None, None)
.await
.unwrap();
let msg3 = client.send_message(ChatId::new(123), "Message 3".to_string(), None, None).await.unwrap();
// Проверяем что все 3 сообщения отслеживаются
assert_eq!(client.get_sent_messages().len(), 3);
@@ -78,10 +66,7 @@ async fn test_send_empty_message_technical() {
let client = FakeTdClient::new();
// FakeTdClient технически может отправить пустое сообщение
let msg = client
.send_message(ChatId::new(123), "".to_string(), None, None)
.await
.unwrap();
let msg = client.send_message(ChatId::new(123), "".to_string(), None, None).await.unwrap();
// Проверяем что оно отправилось (в реальном App это должно фильтроваться)
assert_eq!(client.get_sent_messages().len(), 1);
@@ -100,10 +85,7 @@ async fn test_send_message_with_markdown() {
let client = FakeTdClient::new();
let text = "**Bold** *italic* `code`";
client
.send_message(ChatId::new(123), text.to_string(), None, None)
.await
.unwrap();
client.send_message(ChatId::new(123), text.to_string(), None, None).await.unwrap();
// Проверяем что текст сохранился как есть (парсинг markdown - отдельная логика)
let messages = client.get_messages(123);
@@ -117,22 +99,13 @@ async fn test_send_messages_to_different_chats() {
let client = FakeTdClient::new();
// Отправляем в чат 123
client
.send_message(ChatId::new(123), "Hello Mom".to_string(), None, None)
.await
.unwrap();
client.send_message(ChatId::new(123), "Hello Mom".to_string(), None, None).await.unwrap();
// Отправляем в чат 456
client
.send_message(ChatId::new(456), "Hello Boss".to_string(), None, None)
.await
.unwrap();
client.send_message(ChatId::new(456), "Hello Boss".to_string(), None, None).await.unwrap();
// Отправляем ещё одно в чат 123
client
.send_message(ChatId::new(123), "How are you?".to_string(), None, None)
.await
.unwrap();
client.send_message(ChatId::new(123), "How are you?".to_string(), None, None).await.unwrap();
// Проверяем общее количество отправленных
assert_eq!(client.get_sent_messages().len(), 3);
@@ -155,10 +128,7 @@ async fn test_receive_incoming_message() {
let client = FakeTdClient::new();
// Добавляем существующее сообщение
client
.send_message(ChatId::new(123), "My outgoing".to_string(), None, None)
.await
.unwrap();
client.send_message(ChatId::new(123), "My outgoing".to_string(), None, None).await.unwrap();
// Симулируем входящее сообщение от собеседника
let incoming_msg = TestMessageBuilder::new("Hey there!", 2000)

View File

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