diff --git a/.woodpecker/check.yml b/.woodpecker/check.yml index 8b0a786..6fe7818 100644 --- a/.woodpecker/check.yml +++ b/.woodpecker/check.yml @@ -8,6 +8,14 @@ steps: - rustup component add rustfmt - cargo fmt -- --check + - name: check + 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 check --all-targets --all-features + - name: clippy image: rust:latest environment: @@ -15,7 +23,7 @@ steps: 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 + - cargo clippy --all-targets --all-features -- -D warnings - name: test image: rust:latest @@ -23,4 +31,4 @@ steps: 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 + - cargo test --all-features diff --git a/AGENT.md b/AGENT.md index 302c087..1e80d71 100644 --- a/AGENT.md +++ b/AGENT.md @@ -13,5 +13,6 @@ - Не запускай `cargo run`, `cargo build`, `cargo test`, `cargo check` без прямой команды пользователя. - Не коммить изменения, пока пользователь не попросит. +- Если пользователь попросил тесты/коммит/план до конца, используй quality gate из [DEVELOPMENT.md](DEVELOPMENT.md). - После функциональной правки дай короткий ручной сценарий проверки. - Обновляй [CONTEXT.md](CONTEXT.md), только если изменились статус, риск, архитектурное решение или следующий шаг. diff --git a/CONTEXT.md b/CONTEXT.md index b3e2d49..5437459 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -20,6 +20,8 @@ - Keybindings стали детерминированными; русская vim-раскладка: `h/j/k/l` -> `р/о/л/д`. - `AudioPlayer` проверяет наличие `ffplay`. - `message_grouping` группирует альбомы без клонирования сообщений. +- TDLib facade split на scoped traits; generic код больше не получает raw `*_mut` доступ к сообщениям. +- Локальный `build.rs` удалён: линковкой TDLib управляет зависимость `tdlib-rs`, `cargo check --all-targets --all-features` снова воспроизводим. ## Осталось @@ -40,6 +42,8 @@ ## Ключевые решения - Главный state хранится в `App`, чтобы тесты могли использовать `FakeTdClient`. +- `TdClientTrait` теперь facade поверх scoped traits; чтение текущих сообщений идёт через `Cow`, mutation - через явные update-операции. +- Пользовательская timezone не хранится в config: runtime использует системную timezone, тесты форматирования используют deterministic time source. - Методы `App` разбиты на traits: navigation, messages, compose, search, modal. - UI рендерится только при `needs_redraw`; текстовый интерфейс целится в 60 FPS. - Фото под feature `images`: inline Halfblocks + modal iTerm2/Sixel. diff --git a/Cargo.toml b/Cargo.toml index 5d7dcf6..947b5b8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -44,9 +44,6 @@ insta = "1.34" tokio-test = "0.4" criterion = "0.5" -[build-dependencies] -tdlib-rs = { version = "1.2.0", features = ["download-tdlib"] } - [[bench]] name = "group_messages" harness = false diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 8aa3e35..e4f3158 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -21,6 +21,20 @@ cargo check В финальном ответе после изменения укажи, какие cargo-команды не запускались, и дай минимальную ручную проверку. +## Quality Gate + +Если пользователь прямо попросил проверить, закоммитить или выполнить план с тестами, используй тот же набор проверок, что и CI: + +```bash +cargo fmt -- --check +cargo check --all-targets --all-features +cargo clippy --all-targets --all-features -- -D warnings +cargo test --all-features +git diff --check +``` + +Перед коммитом не оставляй `*.snap.new` файлы. + ## Scope - Делай одну логическую правку за раз. diff --git a/benches/format_markdown.rs b/benches/format_markdown.rs index e722f17..8f37cc6 100644 --- a/benches/format_markdown.rs +++ b/benches/format_markdown.rs @@ -1,5 +1,7 @@ use criterion::{black_box, criterion_group, criterion_main, Criterion}; -use tdlib_rs::enums::{TextEntity, TextEntityType}; +use ratatui::style::Color; +use tdlib_rs::enums::TextEntityType; +use tdlib_rs::types::TextEntity; use tele_tui::formatting::format_text_with_entities; fn create_text_with_entities() -> (String, Vec) { @@ -9,27 +11,27 @@ fn create_text_with_entities() -> (String, Vec) { TextEntity { offset: 8, length: 4, // bold - type_: TextEntityType::Bold, + r#type: TextEntityType::Bold, }, TextEntity { offset: 17, length: 6, // italic - type_: TextEntityType::Italic, + r#type: TextEntityType::Italic, }, TextEntity { offset: 34, length: 4, // code - type_: TextEntityType::Code, + r#type: TextEntityType::Code, }, TextEntity { offset: 45, length: 4, // link - type_: TextEntityType::Url, + r#type: TextEntityType::Url, }, TextEntity { offset: 54, length: 7, // mention - type_: TextEntityType::Mention, + r#type: TextEntityType::Mention, }, ]; @@ -41,7 +43,7 @@ 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), Color::White)); }); } @@ -49,7 +51,7 @@ 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), Color::White)); }); } @@ -67,13 +69,13 @@ fn benchmark_format_long_text(c: &mut Criterion) { entities.push(TextEntity { offset: start as i32, length: format!("Word{}", i).len() as i32, - type_: TextEntityType::Bold, + r#type: TextEntityType::Bold, }); } } 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), Color::White)); }); } diff --git a/benches/group_messages.rs b/benches/group_messages.rs index d4c604c..60fbc99 100644 --- a/benches/group_messages.rs +++ b/benches/group_messages.rs @@ -7,8 +7,8 @@ fn create_test_messages(count: usize) -> Vec { (0..count) .map(|i| { let builder = MessageBuilder::new(MessageId::new(i as i64)) - .sender_name(&format!("User{}", i % 10)) - .text(&format!( + .sender_name(format!("User{}", i % 10)) + .text(format!( "Test message number {} with some longer text to make it more realistic", i )) diff --git a/build.rs b/build.rs deleted file mode 100644 index cc8ba10..0000000 --- a/build.rs +++ /dev/null @@ -1,3 +0,0 @@ -fn main() { - tdlib_rs::build::build(None); -} diff --git a/docs/REFACTOR_PLAN.md b/docs/REFACTOR_PLAN.md new file mode 100644 index 0000000..8f768a4 --- /dev/null +++ b/docs/REFACTOR_PLAN.md @@ -0,0 +1,250 @@ +# tele-tui Refactor Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` (recommended) or `superpowers:executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Finish the next review/refactor layer after the TDLib facade split, keeping behavior stable while making the code easier to test, review, and change. + +**Architecture:** The current working tree already introduces scoped TDLib traits, removes the local `build.rs`, switches message formatting to the system local timezone, moves media chat handlers into a submodule, and makes fake TDLib state more explicit. The remaining work should continue in small vertical slices with focused tests after each slice. + +**Tech Stack:** Rust 2021, Tokio, tdlib-rs, ratatui, crossterm, insta, criterion, Woodpecker CI. + +--- + +## Current Baseline + +The current uncommitted layer should be treated as the baseline before starting the next refactor tasks. + +- TDLib facade is split into scoped traits in `src/tdlib/trait.rs`. +- `src/tdlib/client_impl.rs` implements the scoped traits for `TdClient`. +- `current_chat_messages()` returns `Cow<'_, [MessageInfo]>`; mutation goes through `update_current_chat_messages`. +- Runtime date formatting uses the system local timezone; tests can inject deterministic time through `FixedLocalTime`. +- Media/image/voice chat handling is moved from `src/input/handlers/chat.rs` into `src/input/handlers/chat/media.rs`. +- The repository no longer uses the local `build.rs` that tried to link `tdlib-rs` during build-script execution. + +Verification already used for this baseline: + +```bash +cargo check --all-targets --all-features +cargo clippy --all-targets --all-features -- -D warnings +cargo test --all-features +git diff --check +``` + +## Task 0: Commit Current Layer + +Goal: preserve the completed facade/timezone/media/test-cleanup work before deeper refactors. + +Files to review before commit: + +- `CONTEXT.md` +- `Cargo.toml` +- `src/tdlib/trait.rs` +- `src/tdlib/mod.rs` +- `src/tdlib/client_impl.rs` +- `src/utils/formatting.rs` +- `src/input/handlers/chat.rs` +- `src/input/handlers/chat/media.rs` +- `tests/helpers/fake_tdclient.rs` +- `tests/helpers/fake_tdclient_impl.rs` +- touched tests and benches + +Steps: + +- [x] Review `git diff --stat` and `git diff --check`. +- [x] Run the full verification commands from the baseline section. +- [x] Commit this layer separately from the follow-up refactors. + +## Task 1: Split `FakeTdClient` + +Goal: reduce `tests/helpers/fake_tdclient.rs` from one large mixed helper into smaller modules with clear responsibilities. + +Target files: + +- `tests/helpers/fake_tdclient.rs` +- `tests/helpers/fake_tdclient_impl.rs` +- `tests/helpers/mod.rs` +- new `tests/helpers/fake_tdclient/state.rs` +- new `tests/helpers/fake_tdclient/builders.rs` +- new `tests/helpers/fake_tdclient/operations.rs` +- new `tests/helpers/fake_tdclient/inspect.rs` + +Steps: + +- [x] Move state aliases and shared storage fields into `state.rs`. +- [x] Move fixture construction helpers such as `with_chat`, `with_messages`, and account setup helpers into `builders.rs`. +- [x] Move behavior helpers such as send/edit/delete/reaction operations into `operations.rs`. +- [x] Move read/assertion helpers such as sent-message inspection and viewed-message inspection into `inspect.rs`. +- [x] Keep the public test API stable unless a call site becomes simpler and safer. +- [x] Remove direct test access to internal `Arc>` fields where helper methods are clearer. +- [x] Run `cargo test --all-features`. + +Acceptance criteria: + +- `FakeTdClient` remains easy to construct in integration tests. +- No test loses behavior coverage. +- `tests/helpers/fake_tdclient.rs` becomes a small module entry point instead of the main implementation body. + +## Task 2: Tighten Internal TDLib Mutation API + +Goal: limit raw mutable access to TDLib client internals and replace cross-module state poking with domain-specific methods. + +Target files: + +- `src/tdlib/client.rs` +- `src/tdlib/chat_helpers.rs` +- `src/tdlib/update_handlers.rs` +- `src/tdlib/message_converter.rs` +- `src/tdlib/client_impl.rs` + +Search command: + +```bash +rg -n "current_chat_messages_mut|chats_mut|folders_mut|pending_user_ids_mut|user_cache_mut" src/tdlib +``` + +Steps: + +- [x] Add focused methods on `TdClient` for common mutations: update chat, update message by id, queue pending user, update user cache, update folders. +- [x] Replace raw `*_mut()` usage in helper/update modules with those methods. +- [x] Keep raw mutable access private to `TdClient` implementation where it is still needed. +- [x] Add or update tests around message updates, user-cache updates, and chat-list updates. +- [x] Run `cargo test --all-features`. + +Acceptance criteria: + +- External and helper modules express intent through domain methods. +- Raw state access is either gone or contained in a small internal area. + +## Task 3: Split Remaining Large Input and UI Files + +Goal: make modal, message rendering, and app/input code easier to review independently. + +Target files: + +- `src/input/handlers/modal.rs` +- `src/input/handlers/chat.rs` +- `src/app/mod.rs` +- `src/ui/messages.rs` +- new `src/input/handlers/modal/account.rs` +- new `src/input/handlers/modal/delete.rs` +- new `src/input/handlers/modal/profile.rs` +- new `src/input/handlers/modal/reactions.rs` +- new `src/input/handlers/modal/pinned.rs` +- new `src/ui/messages/header.rs` +- new `src/ui/messages/list.rs` +- new `src/ui/messages/pinned.rs` + +Steps: + +- [x] Split modal handlers by modal type and keep `modal.rs` as the dispatcher/module entry point. +- [x] Split message UI rendering into header, pinned-message, and list rendering modules. +- [x] Keep public function names stable until each split is covered by tests. +- [x] Avoid mixing behavior changes with file movement. +- [x] Run focused modal/navigation/message tests after each split. +- [x] Run `cargo test --all-features` after the full split. + +Acceptance criteria: + +- Large files are reduced to dispatch/orchestration roles. +- The split does not change key handling or rendering behavior. +- Module names match user-facing concepts instead of implementation accidents. + +## Task 4: Remove Production `unwrap()` Risk + +Goal: keep test unwraps where useful, but remove production unwraps where runtime data can be absent. + +Target files: + +- `src/input/handlers/chat/media.rs` +- `src/input/handlers/chat.rs` +- `src/ui/components/message_bubble.rs` +- `src/utils/tdlib.rs` +- `src/audio/player.rs` + +Search command: + +```bash +rg -n "unwrap\\(|expect\\(|panic!\\(" src +``` + +Steps: + +- [x] Replace `photo_info().unwrap()` and `voice_info().unwrap()` with `let Some(...) else { ... }`. +- [x] Replace `selected_chat_id.unwrap()` with an early return or status message. +- [x] Review playback/message unwraps in `message_bubble.rs` and convert absent data into graceful UI fallback. +- [x] Audit mutex unwraps separately; leave only cases where poisoning should be fatal and documented by context. +- [x] Add tests for missing media metadata and absent selected chat. +- [x] Run `cargo clippy --all-targets --all-features -- -D warnings`. + +Acceptance criteria: + +- Malformed or partial TDLib data does not panic in normal UI paths. +- Error handling stays local and does not add noisy user-facing text. + +## Task 5: Resolve TODO and Compatibility Paths + +Goal: make unfinished behavior explicit: either implement it, test it, or remove stale comments. + +Target files: + +- `src/input/key_handler.rs` +- `src/tdlib/reactions.rs` +- `src/tdlib/messages/operations.rs` + +Steps: + +- [x] Review every TODO in `src/`. +- [x] Convert active TODOs into tests or tracked plan items. +- [x] Remove stale TODOs whose behavior is already implemented. +- [x] For pinned-message compatibility in `messages/operations.rs`, decide whether the fallback is still needed and document the decision in code or tests. +- [x] Run `cargo test --all-features`. + +Acceptance criteria: + +- Remaining TODOs point to real unresolved behavior. +- No stale TODO describes behavior that no longer exists. + +## Task 6: Add CI Quality Gate + +Goal: make local quality checks reproducible in CI. + +Target files: + +- `.woodpecker/check.yml` +- `DEVELOPMENT.md` +- `AGENT.md` + +Steps: + +- [x] Add CI steps for `cargo check --all-targets --all-features`. +- [x] Add CI steps for `cargo clippy --all-targets --all-features -- -D warnings`. +- [x] Add CI steps for `cargo test --all-features`. +- [x] Document the same commands in `DEVELOPMENT.md` or `AGENT.md`. +- [x] Keep CI commands aligned with the commands used by agents and humans locally. + +Acceptance criteria: + +- CI catches compile, lint, and test failures before merge. +- Local documentation and CI use the same command set. + +## Global Acceptance Criteria + +Before considering the refactor layer complete: + +- [x] `cargo check --all-targets --all-features` passes. +- [x] `cargo clippy --all-targets --all-features -- -D warnings` passes. +- [x] `cargo test --all-features` passes. +- [x] `git diff --check` passes. +- [x] No unexpected `*.snap.new` files remain. +- [x] `rg -n "current_chat_messages_mut|chats_mut|folders_mut|pending_user_ids_mut|user_cache_mut" src/tdlib` shows only intentionally contained internal access. +- [x] `rg -n "unwrap\\(|expect\\(|panic!\\(" src` has no risky production UI or TDLib data-path panics left. + +## Recommended Commit Order + +1. Baseline commit for already completed facade/timezone/media/test cleanup. +2. `FakeTdClient` split. +3. TDLib internal mutation API cleanup. +4. Modal and message UI file splits. +5. Production unwrap cleanup. +6. TODO cleanup. +7. CI quality gate. diff --git a/src/accounts/lock.rs b/src/accounts/lock.rs index db135b9..ab36166 100644 --- a/src/accounts/lock.rs +++ b/src/accounts/lock.rs @@ -33,17 +33,12 @@ pub fn acquire_lock(account_name: &str) -> Result { // Ensure parent directory exists if let Some(parent) = lock_path.parent() { - fs::create_dir_all(parent).map_err(|e| { - format!( - "Не удалось создать директорию для lock-файла: {}", - e - ) - })?; + fs::create_dir_all(parent) + .map_err(|e| format!("Не удалось создать директорию для lock-файла: {}", e))?; } - let file = File::create(&lock_path).map_err(|e| { - format!("Не удалось создать lock-файл {}: {}", lock_path.display(), e) - })?; + let file = File::create(&lock_path) + .map_err(|e| format!("Не удалось создать lock-файл {}: {}", lock_path.display(), e))?; file.try_lock_exclusive().map_err(|_| { format!( diff --git a/src/app/chat_filter.rs b/src/app/chat_filter.rs index 32615fa..6d196f8 100644 --- a/src/app/chat_filter.rs +++ b/src/app/chat_filter.rs @@ -226,6 +226,7 @@ mod tests { use super::*; use crate::types::ChatId; + #[allow(clippy::too_many_arguments)] fn create_test_chat( id: i64, title: &str, diff --git a/src/app/methods/messages.rs b/src/app/methods/messages.rs index 2a7ba41..8e302f8 100644 --- a/src/app/methods/messages.rs +++ b/src/app/methods/messages.rs @@ -132,9 +132,9 @@ impl MessageMethods for App { _ => None, }; - if selected_idx.is_none() { + let Some(selected_idx) = selected_idx else { return false; - } + }; // Сначала извлекаем данные из сообщения let msg_data = self.get_selected_message().and_then(|msg| { @@ -143,7 +143,7 @@ impl MessageMethods for App { // 2. Это исходящее сообщение // 3. ID не временный (временные ID в TDLib отрицательные) if msg.can_be_edited() && msg.is_outgoing() && msg.id().as_i64() > 0 { - Some((msg.id(), msg.text().to_string(), selected_idx.unwrap())) + Some((msg.id(), msg.text().to_string(), selected_idx)) } else { None } diff --git a/src/input/handlers/chat.rs b/src/input/handlers/chat.rs index 39e1c3c..c7c8194 100644 --- a/src/input/handlers/chat.rs +++ b/src/input/handlers/chat.rs @@ -6,6 +6,8 @@ //! - Editing and sending messages //! - Loading older messages +mod media; + use super::chat_loader::{load_older_messages_if_needed, open_chat_and_load_data}; use crate::app::methods::{ compose::ComposeMethods, messages::MessageMethods, modal::ModalMethods, @@ -77,22 +79,25 @@ pub async fn handle_message_selection( } } Some(crate::config::Command::ViewImage) => { - handle_view_or_play_media(app).await; + media::handle_view_or_play_media(app).await; } Some(crate::config::Command::TogglePlayback) => { - handle_toggle_voice_playback(app).await; + media::handle_toggle_voice_playback(app).await; } Some(crate::config::Command::SeekForward | crate::config::Command::MoveRight) => { - handle_voice_seek(app, 5.0); + media::handle_voice_seek(app, 5.0); } Some(crate::config::Command::SeekBackward | crate::config::Command::MoveLeft) => { - handle_voice_seek(app, -5.0); + media::handle_voice_seek(app, -5.0); } Some(crate::config::Command::ReactMessage) => { + let Some(chat_id) = app.selected_chat_id else { + app.error_message = Some("Чат не выбран".to_string()); + return; + }; let Some(msg) = app.get_selected_message() else { return; }; - let chat_id = app.selected_chat_id.unwrap(); let message_id = msg.id(); app.status_message = Some("Загрузка реакций...".to_string()); @@ -163,23 +168,24 @@ pub async fn edit_message( { Ok(mut edited_msg) => { // Сохраняем reply_to из старого сообщения (если есть) - let messages = app.td_client.current_chat_messages_mut(); - if let Some(pos) = messages.iter().position(|m| m.id() == msg_id) { - 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") - { - edited_msg.interactions.reply_to = Some(old_reply); + app.td_client.update_current_chat_messages(|messages| { + if let Some(pos) = messages.iter().position(|m| m.id() == msg_id) { + 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") + { + edited_msg.interactions.reply_to = Some(old_reply); + } } + // Заменяем сообщение + messages[pos] = edited_msg; } - // Заменяем сообщение - messages[pos] = edited_msg; - } + }); // Очищаем инпут и сбрасываем состояние ПОСЛЕ успешного редактирования app.message_input.clear(); app.cursor_position = 0; @@ -450,359 +456,3 @@ pub async fn handle_open_chat_keyboard_input(app: &mut App, _ => {} } } - -/// Обработка команды ViewImage — только фото -async fn handle_view_or_play_media(app: &mut App) { - let Some(msg) = app.get_selected_message() else { - return; - }; - - if msg.has_photo() { - #[cfg(feature = "images")] - handle_view_image(app).await; - #[cfg(not(feature = "images"))] - { - app.status_message = Some("Просмотр изображений отключён".to_string()); - } - } else { - app.status_message = Some("Сообщение не содержит фото".to_string()); - } -} - -/// Space: play/pause toggle для голосовых сообщений -async fn handle_toggle_voice_playback(app: &mut App) { - use crate::tdlib::PlaybackStatus; - - // Если уже есть активное воспроизведение — toggle pause/resume - if let Some(ref mut playback) = app.playback_state { - if let Some(ref player) = app.audio_player { - match playback.status { - PlaybackStatus::Playing => { - player.pause(); - playback.status = PlaybackStatus::Paused; - app.last_playback_tick = None; - app.status_message = Some("⏸ Пауза".to_string()); - } - PlaybackStatus::Paused => { - // Откатываем на 1 секунду для контекста - let resume_pos = (playback.position - 1.0).max(0.0); - // Перезапускаем ffplay с нужной позиции (-ss) - if player.resume_from(resume_pos).is_ok() { - playback.position = resume_pos; - } else { - // Fallback: простой SIGCONT без перемотки - player.resume(); - } - playback.status = PlaybackStatus::Playing; - app.last_playback_tick = Some(Instant::now()); - app.status_message = Some("▶ Воспроизведение".to_string()); - } - _ => {} - } - app.needs_redraw = true; - } - return; - } - - // Нет активного воспроизведения — пробуем запустить текущее голосовое - let Some(msg) = app.get_selected_message() else { - return; - }; - if msg.has_voice() { - handle_play_voice(app).await; - } -} - -/// Seek голосового сообщения на delta секунд -fn handle_voice_seek(app: &mut App, delta: f32) { - use crate::tdlib::PlaybackStatus; - - let Some(ref mut playback) = app.playback_state else { - return; - }; - let Some(ref player) = app.audio_player else { - return; - }; - - let was_playing = matches!(playback.status, PlaybackStatus::Playing); - let was_paused = matches!(playback.status, PlaybackStatus::Paused); - - if was_playing || was_paused { - let new_position = (playback.position + delta).clamp(0.0, playback.duration); - - if was_playing { - // Перезапускаем ffplay с новой позиции - if player.resume_from(new_position).is_ok() { - playback.position = new_position; - app.last_playback_tick = Some(std::time::Instant::now()); - } - } else { - // На паузе — только двигаем позицию, воспроизведение начнётся при resume - player.stop(); - playback.position = new_position; - } - - let arrow = if delta > 0.0 { "→" } else { "←" }; - app.status_message = Some(format!("{} {:.0}s", arrow, new_position)); - app.needs_redraw = true; - } -} - -/// Обработка команды ViewImage — открыть модальное окно с фото -#[cfg(feature = "images")] -async fn handle_view_image(app: &mut App) { - use crate::tdlib::{ImageModalState, PhotoDownloadState}; - - if !app.config().images.show_images { - return; - } - - let Some(msg) = app.get_selected_message() else { - return; - }; - - if !msg.has_photo() { - app.status_message = Some("Сообщение не содержит фото".to_string()); - return; - } - - let photo = msg.photo_info().unwrap(); - let msg_id = msg.id(); - let file_id = photo.file_id; - let photo_width = photo.width; - let photo_height = photo.height; - let download_state = photo.download_state.clone(); - - match download_state { - PhotoDownloadState::Downloaded(path) => { - // Открываем модальное окно - app.image_modal = Some(ImageModalState { - message_id: msg_id, - photo_path: path, - photo_width, - photo_height, - }); - app.needs_redraw = true; - } - PhotoDownloadState::NotDownloaded | PhotoDownloadState::Downloading => { - // Запоминаем намерение открыть модалку — откроется когда загрузится - app.pending_image_open = Some(crate::app::PendingImageOpen { - file_id, - message_id: msg_id, - photo_width, - photo_height, - }); - app.status_message = Some("Загрузка фото...".to_string()); - app.needs_redraw = true; - - // Если нет активной фоновой загрузки — запускаем свою - if app.photo_download_rx.is_none() { - let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); - app.photo_download_rx = Some(rx); - let client_id = app.td_client.client_id(); - tokio::spawn(async move { - let result = tokio::time::timeout(Duration::from_secs(30), async { - match tdlib_rs::functions::download_file(file_id, 1, 0, 0, true, client_id) - .await - { - Ok(tdlib_rs::enums::File::File(f)) - if f.local.is_downloading_completed && !f.local.path.is_empty() => - { - Ok(f.local.path) - } - Ok(_) => Err("Файл не скачан".to_string()), - Err(e) => Err(format!("{:?}", e)), - } - }) - .await; - let result = result.unwrap_or_else(|_| Err("Таймаут загрузки".to_string())); - let _ = tx.send((file_id, result)); - }); - } - } - PhotoDownloadState::Error(_) => { - // Повторная попытка загрузки - app.status_message = Some("Повторная загрузка фото...".to_string()); - app.needs_redraw = true; - match app.td_client.download_file(file_id).await { - Ok(path) => { - 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()); - break; - } - } - } - app.image_modal = Some(ImageModalState { - message_id: msg_id, - photo_path: path, - photo_width, - photo_height, - }); - app.status_message = None; - } - Err(e) => { - app.error_message = Some(format!("Ошибка загрузки фото: {}", e)); - app.status_message = None; - } - } - } - } -} - -/// Вспомогательная функция для воспроизведения из конкретного пути -async fn handle_play_voice_from_path( - app: &mut App, - path: &str, - voice: &crate::tdlib::VoiceInfo, - msg: &crate::tdlib::MessageInfo, -) { - use crate::tdlib::{PlaybackState, PlaybackStatus}; - - if let Some(ref player) = app.audio_player { - match player.play(path) { - Ok(_) => { - app.playback_state = Some(PlaybackState { - message_id: msg.id(), - status: PlaybackStatus::Playing, - position: 0.0, - duration: voice.duration as f32, - volume: player.volume(), - }); - app.last_playback_tick = Some(Instant::now()); - app.status_message = Some(format!("▶ Воспроизведение ({:.0}s)", voice.duration)); - app.needs_redraw = true; - } - Err(e) => { - app.error_message = Some(format!("Ошибка воспроизведения: {}", e)); - } - } - } else { - app.error_message = Some("Аудиоплеер не инициализирован".to_string()); - } -} - -/// Воспроизведение голосового сообщения -async fn handle_play_voice(app: &mut App) { - use crate::tdlib::VoiceDownloadState; - - let Some(msg) = app.get_selected_message() else { - return; - }; - - if !msg.has_voice() { - return; - } - - let voice = msg.voice_info().unwrap(); - let file_id = voice.file_id; - - match &voice.download_state { - VoiceDownloadState::Downloaded(path) => { - // TDLib может вернуть путь без расширения — ищем файл с .oga - use std::path::Path; - let audio_path = if Path::new(path).exists() { - path.clone() - } else { - // Пробуем добавить .oga - let with_oga = format!("{}.oga", path); - if Path::new(&with_oga).exists() { - with_oga - } else { - // Пробуем найти файл с похожим именем в той же папке - if let Some(parent) = Path::new(path).parent() { - if let Some(stem) = Path::new(path).file_name() { - 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()) - { - 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), - ); - } - return handle_play_voice_from_path( - app, - &found_path, - voice, - &msg, - ) - .await; - } - } - } - } - } - app.error_message = Some(format!("Файл не найден: {}", path)); - return; - } - }; - - // Кэшируем файл если ещё не в кэше - if let Some(ref mut cache) = app.voice_cache { - let _ = cache.store(&file_id.to_string(), Path::new(&audio_path)); - } - - handle_play_voice_from_path(app, &audio_path, voice, &msg).await; - } - VoiceDownloadState::Downloading => { - app.status_message = Some("Загрузка голосового...".to_string()); - } - VoiceDownloadState::NotDownloaded => { - // Проверяем кэш перед загрузкой - 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; - return; - } - - // Начинаем загрузку - app.status_message = Some("Загрузка голосового...".to_string()); - match app.td_client.download_voice_note(file_id).await { - Ok(path) => { - // Кэшируем загруженный файл - if let Some(ref mut cache) = app.voice_cache { - let _ = cache.store(&cache_key, std::path::Path::new(&path)); - } - - handle_play_voice_from_path(app, &path, voice, &msg).await; - } - Err(e) => { - app.error_message = Some(format!("Ошибка загрузки: {}", e)); - } - } - } - VoiceDownloadState::Error(e) => { - app.error_message = Some(format!("Ошибка загрузки: {}", e)); - } - } -} - -// TODO (Этап 4): Эти функции будут переписаны для модального просмотрщика -/* -#[cfg(feature = "images")] -fn collapse_photo(app: &mut App, msg_id: crate::types::MessageId) { - // Закомментировано - будет реализовано в Этапе 4 -} - -#[cfg(feature = "images")] -fn expand_photo(app: &mut App, msg_id: crate::types::MessageId, path: &str) { - // Закомментировано - будет реализовано в Этапе 4 -} -*/ - -// TODO (Этап 4): Функция _download_and_expand будет переписана -/* -#[cfg(feature = "images")] -async fn _download_and_expand(app: &mut App, msg_id: crate::types::MessageId, file_id: i32) { - // Закомментировано - будет реализовано в Этапе 4 -} -*/ diff --git a/src/input/handlers/chat/media.rs b/src/input/handlers/chat/media.rs new file mode 100644 index 0000000..6e052b3 --- /dev/null +++ b/src/input/handlers/chat/media.rs @@ -0,0 +1,328 @@ +//! Media actions for the open chat input handler. + +use crate::app::methods::messages::MessageMethods; +use crate::app::App; +use crate::tdlib::TdClientTrait; +use std::time::{Duration, Instant}; + +/// Обработка команды ViewImage — только фото. +pub(super) async fn handle_view_or_play_media(app: &mut App) { + let Some(msg) = app.get_selected_message() else { + return; + }; + + if msg.has_photo() { + #[cfg(feature = "images")] + handle_view_image(app).await; + #[cfg(not(feature = "images"))] + { + app.status_message = Some("Просмотр изображений отключён".to_string()); + } + } else { + app.status_message = Some("Сообщение не содержит фото".to_string()); + } +} + +/// Space: play/pause toggle для голосовых сообщений. +pub(super) async fn handle_toggle_voice_playback(app: &mut App) { + use crate::tdlib::PlaybackStatus; + + if let Some(ref mut playback) = app.playback_state { + if let Some(ref player) = app.audio_player { + match playback.status { + PlaybackStatus::Playing => { + player.pause(); + playback.status = PlaybackStatus::Paused; + app.last_playback_tick = None; + app.status_message = Some("⏸ Пауза".to_string()); + } + PlaybackStatus::Paused => { + let resume_pos = (playback.position - 1.0).max(0.0); + if player.resume_from(resume_pos).is_ok() { + playback.position = resume_pos; + } else { + player.resume(); + } + playback.status = PlaybackStatus::Playing; + app.last_playback_tick = Some(Instant::now()); + app.status_message = Some("▶ Воспроизведение".to_string()); + } + _ => {} + } + app.needs_redraw = true; + } + return; + } + + let Some(msg) = app.get_selected_message() else { + return; + }; + if msg.has_voice() { + handle_play_voice(app).await; + } +} + +/// Seek голосового сообщения на delta секунд. +pub(super) fn handle_voice_seek(app: &mut App, delta: f32) { + use crate::tdlib::PlaybackStatus; + + let Some(ref mut playback) = app.playback_state else { + return; + }; + let Some(ref player) = app.audio_player else { + return; + }; + + let was_playing = matches!(playback.status, PlaybackStatus::Playing); + let was_paused = matches!(playback.status, PlaybackStatus::Paused); + + if was_playing || was_paused { + let new_position = (playback.position + delta).clamp(0.0, playback.duration); + + if was_playing { + if player.resume_from(new_position).is_ok() { + playback.position = new_position; + app.last_playback_tick = Some(Instant::now()); + } + } else { + player.stop(); + playback.position = new_position; + } + + let arrow = if delta > 0.0 { "→" } else { "←" }; + app.status_message = Some(format!("{} {:.0}s", arrow, new_position)); + app.needs_redraw = true; + } +} + +#[cfg(feature = "images")] +async fn handle_view_image(app: &mut App) { + use crate::tdlib::{ImageModalState, PhotoDownloadState}; + + if !app.config().images.show_images { + return; + } + + let Some(msg) = app.get_selected_message() else { + return; + }; + + if !msg.has_photo() { + app.status_message = Some("Сообщение не содержит фото".to_string()); + return; + } + + let Some(photo) = msg.photo_info() else { + app.status_message = Some("Сообщение не содержит фото".to_string()); + return; + }; + let msg_id = msg.id(); + let file_id = photo.file_id; + let photo_width = photo.width; + let photo_height = photo.height; + let download_state = photo.download_state.clone(); + + match download_state { + PhotoDownloadState::Downloaded(path) => { + app.image_modal = Some(ImageModalState { + message_id: msg_id, + photo_path: path, + photo_width, + photo_height, + }); + app.needs_redraw = true; + } + PhotoDownloadState::NotDownloaded | PhotoDownloadState::Downloading => { + app.pending_image_open = Some(crate::app::PendingImageOpen { + file_id, + message_id: msg_id, + photo_width, + photo_height, + }); + app.status_message = Some("Загрузка фото...".to_string()); + app.needs_redraw = true; + + if app.photo_download_rx.is_none() { + let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); + app.photo_download_rx = Some(rx); + let client_id = app.td_client.client_id(); + tokio::spawn(async move { + let result = tokio::time::timeout(Duration::from_secs(30), async { + match tdlib_rs::functions::download_file(file_id, 1, 0, 0, true, client_id) + .await + { + Ok(tdlib_rs::enums::File::File(f)) + if f.local.is_downloading_completed && !f.local.path.is_empty() => + { + Ok(f.local.path) + } + Ok(_) => Err("Файл не скачан".to_string()), + Err(e) => Err(format!("{:?}", e)), + } + }) + .await; + let result = result.unwrap_or_else(|_| Err("Таймаут загрузки".to_string())); + let _ = tx.send((file_id, result)); + }); + } + } + PhotoDownloadState::Error(_) => { + app.status_message = Some("Повторная загрузка фото...".to_string()); + app.needs_redraw = true; + match app.td_client.download_file(file_id).await { + Ok(path) => { + app.td_client.update_current_chat_messages(|messages| { + for msg in messages { + if let Some(photo) = msg.photo_info_mut() { + if photo.file_id == file_id { + photo.download_state = + PhotoDownloadState::Downloaded(path.clone()); + break; + } + } + } + }); + app.image_modal = Some(ImageModalState { + message_id: msg_id, + photo_path: path, + photo_width, + photo_height, + }); + app.status_message = None; + } + Err(e) => { + app.error_message = Some(format!("Ошибка загрузки фото: {}", e)); + app.status_message = None; + } + } + } + } +} + +async fn handle_play_voice_from_path( + app: &mut App, + path: &str, + voice: &crate::tdlib::VoiceInfo, + msg: &crate::tdlib::MessageInfo, +) { + use crate::tdlib::{PlaybackState, PlaybackStatus}; + + if let Some(ref player) = app.audio_player { + match player.play(path) { + Ok(_) => { + app.playback_state = Some(PlaybackState { + message_id: msg.id(), + status: PlaybackStatus::Playing, + position: 0.0, + duration: voice.duration as f32, + volume: player.volume(), + }); + app.last_playback_tick = Some(Instant::now()); + app.status_message = Some(format!("▶ Воспроизведение ({:.0}s)", voice.duration)); + app.needs_redraw = true; + } + Err(e) => { + app.error_message = Some(format!("Ошибка воспроизведения: {}", e)); + } + } + } else { + app.error_message = Some("Аудиоплеер не инициализирован".to_string()); + } +} + +async fn handle_play_voice(app: &mut App) { + use crate::tdlib::VoiceDownloadState; + + let Some(msg) = app.get_selected_message() else { + return; + }; + + if !msg.has_voice() { + return; + } + + let Some(voice) = msg.voice_info() else { + app.status_message = Some("Сообщение не содержит голосовое".to_string()); + return; + }; + let file_id = voice.file_id; + + match &voice.download_state { + VoiceDownloadState::Downloaded(path) => { + use std::path::Path; + let audio_path = if Path::new(path).exists() { + path.clone() + } else { + let with_oga = format!("{}.oga", path); + if Path::new(&with_oga).exists() { + with_oga + } else { + if let Some(parent) = Path::new(path).parent() { + if let Some(stem) = Path::new(path).file_name() { + 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()) + { + 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), + ); + } + return handle_play_voice_from_path( + app, + &found_path, + voice, + &msg, + ) + .await; + } + } + } + } + } + app.error_message = Some(format!("Файл не найден: {}", path)); + return; + } + }; + + if let Some(ref mut cache) = app.voice_cache { + let _ = cache.store(&file_id.to_string(), Path::new(&audio_path)); + } + + handle_play_voice_from_path(app, &audio_path, voice, &msg).await; + } + VoiceDownloadState::Downloading => { + app.status_message = Some("Загрузка голосового...".to_string()); + } + VoiceDownloadState::NotDownloaded => { + 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; + return; + } + + app.status_message = Some("Загрузка голосового...".to_string()); + match app.td_client.download_voice_note(file_id).await { + Ok(path) => { + if let Some(ref mut cache) = app.voice_cache { + let _ = cache.store(&cache_key, std::path::Path::new(&path)); + } + + handle_play_voice_from_path(app, &path, voice, &msg).await; + } + Err(e) => { + app.error_message = Some(format!("Ошибка загрузки: {}", e)); + } + } + } + VoiceDownloadState::Error(e) => { + app.error_message = Some(format!("Ошибка загрузки: {}", e)); + } + } +} diff --git a/src/input/handlers/chat_list.rs b/src/input/handlers/chat_list.rs index 49b6aba..69c1f97 100644 --- a/src/input/handlers/chat_list.rs +++ b/src/input/handlers/chat_list.rs @@ -74,4 +74,3 @@ pub async fn select_folder(app: &mut App, folder_idx: usize app.chat_list_state.select(Some(0)); } } - diff --git a/src/input/handlers/chat_loader.rs b/src/input/handlers/chat_loader.rs index 051fd5e..d2b929e 100644 --- a/src/input/handlers/chat_loader.rs +++ b/src/input/handlers/chat_loader.rs @@ -228,16 +228,18 @@ pub fn process_chat_init_events(app: &mut App) { } let mut changed = false; - for msg in app.td_client.current_chat_messages_mut() { - let Some(reply) = msg.interactions.reply_to.as_mut() else { - continue; - }; - if reply.message_id == message_id { - reply.sender_name = sender_name.clone(); - reply.text = text.clone(); - changed = true; + app.td_client.update_current_chat_messages(|messages| { + for msg in messages { + let Some(reply) = msg.interactions.reply_to.as_mut() else { + continue; + }; + if reply.message_id == message_id { + reply.sender_name = sender_name.clone(); + reply.text = text.clone(); + changed = true; + } } - } + }); if changed { app.needs_redraw = true; @@ -286,7 +288,8 @@ pub async fn load_older_messages_if_needed(app: &mut App) { // Add older messages to the beginning if any were loaded if !older.is_empty() { - let msgs = app.td_client.current_chat_messages_mut(); - msgs.splice(0..0, older); + app.td_client.update_current_chat_messages(|messages| { + messages.splice(0..0, older); + }); } } diff --git a/src/input/handlers/mod.rs b/src/input/handlers/mod.rs index cdaa9e0..11bdd4b 100644 --- a/src/input/handlers/mod.rs +++ b/src/input/handlers/mod.rs @@ -21,10 +21,7 @@ pub mod modal; pub mod profile; pub mod search; -pub use chat_loader::{ - load_older_messages_if_needed, open_chat_and_load_data, process_chat_init_events, - process_pending_chat_init, -}; +pub use chat_loader::{process_chat_init_events, process_pending_chat_init}; pub use clipboard::*; pub use global::*; pub use profile::get_available_actions_count; diff --git a/src/input/handlers/modal.rs b/src/input/handlers/modal.rs index e40a697..a8601f6 100644 --- a/src/input/handlers/modal.rs +++ b/src/input/handlers/modal.rs @@ -1,404 +1,13 @@ -//! Modal dialog handlers -//! -//! Handles keyboard input for modal dialogs, including: -//! - Account switcher (global overlay) -//! - Delete confirmation -//! - Reaction picker (emoji selector) -//! - Pinned messages view -//! - Profile information modal +//! Modal dialog handlers. -use super::scroll_to_message; -use crate::app::methods::{modal::ModalMethods, navigation::NavigationMethods}; -use crate::app::{AccountSwitcherState, App}; -use crate::input::handlers::get_available_actions_count; -use crate::tdlib::TdClientTrait; -use crate::types::{ChatId, MessageId}; -use crate::utils::{modal_handler::handle_yes_no, with_timeout_msg}; -use crossterm::event::{KeyCode, KeyEvent}; -use std::time::Duration; +mod account; +mod delete; +mod pinned; +mod profile; +mod reactions; -/// Обработка ввода в модалке переключения аккаунтов -/// -/// **SelectAccount mode:** -/// - j/k (MoveUp/MoveDown) — навигация по списку -/// - Enter — выбор аккаунта или переход к добавлению -/// - a/ф — быстрое добавление аккаунта -/// - Esc — закрыть модалку -/// -/// **AddAccount mode:** -/// - Char input → ввод имени -/// - Backspace → удалить символ -/// - Enter → создать аккаунт -/// - Esc → назад к списку -pub async fn handle_account_switcher( - app: &mut App, - key: KeyEvent, - command: Option, -) { - let Some(state) = &app.account_switcher else { - return; - }; - - match state { - AccountSwitcherState::SelectAccount { .. } => { - match command { - Some(crate::config::Command::MoveUp) => { - app.account_switcher_select_prev(); - } - Some(crate::config::Command::MoveDown) => { - app.account_switcher_select_next(); - } - Some(crate::config::Command::SubmitMessage) => { - app.account_switcher_confirm(); - } - Some(crate::config::Command::Cancel) => { - app.close_account_switcher(); - } - _ => { - // Raw key check for 'a'/'ф' shortcut - match key.code { - KeyCode::Char('a') | KeyCode::Char('ф') => { - app.account_switcher_start_add(); - } - _ => {} - } - } - } - } - AccountSwitcherState::AddAccount { .. } => match key.code { - KeyCode::Esc => { - app.account_switcher_back(); - } - KeyCode::Enter => { - app.account_switcher_confirm_add(); - } - KeyCode::Backspace => { - if let Some(AccountSwitcherState::AddAccount { - name_input, - cursor_position, - error, - }) = &mut app.account_switcher - { - if *cursor_position > 0 { - let mut chars: Vec = name_input.chars().collect(); - chars.remove(*cursor_position - 1); - *name_input = chars.into_iter().collect(); - *cursor_position -= 1; - *error = None; - } - } - } - KeyCode::Char(c) => { - if let Some(AccountSwitcherState::AddAccount { - name_input, - cursor_position, - error, - }) = &mut app.account_switcher - { - let mut chars: Vec = name_input.chars().collect(); - chars.insert(*cursor_position, c); - *name_input = chars.into_iter().collect(); - *cursor_position += 1; - *error = None; - } - } - _ => {} - }, - } -} - -/// Обработка режима профиля пользователя/чата -/// -/// Обрабатывает: -/// - Модалку подтверждения выхода из группы (двухшаговая) -/// - Навигацию по действиям профиля (Up/Down) -/// - Выполнение выбранного действия (Enter): открыть в браузере, скопировать ID, покинуть группу -/// - Выход из режима профиля (Esc) -pub async fn handle_profile_mode( - app: &mut App, - key: KeyEvent, - command: Option, -) { - // Обработка подтверждения выхода из группы - let confirmation_step = app.get_leave_group_confirmation_step(); - if confirmation_step > 0 { - match handle_yes_no(key.code) { - Some(true) => { - // Подтверждение - if confirmation_step == 1 { - // Первое подтверждение - показываем второе - app.show_leave_group_final_confirmation(); - } else if confirmation_step == 2 { - // Второе подтверждение - выходим из группы - if let Some(chat_id) = app.selected_chat_id { - let leave_result = app.td_client.leave_chat(chat_id).await; - match leave_result { - Ok(_) => { - app.status_message = Some("Вы вышли из группы".to_string()); - app.exit_profile_mode(); - app.close_chat(); - } - Err(e) => { - app.error_message = Some(e); - app.cancel_leave_group(); - } - } - } - } - } - Some(false) => { - // Отмена - app.cancel_leave_group(); - } - None => { - // Другая клавиша - игнорируем - } - } - return; - } - - // Обычная навигация по профилю - match command { - Some(crate::config::Command::Cancel) => { - app.exit_profile_mode(); - } - Some(crate::config::Command::MoveUp) => { - app.select_previous_profile_action(); - } - Some(crate::config::Command::MoveDown) => { - if let Some(profile) = app.get_profile_info() { - let max_actions = get_available_actions_count(profile); - app.select_next_profile_action(max_actions); - } - } - Some(crate::config::Command::SubmitMessage) => { - // Выполнить выбранное действие - let Some(profile) = app.get_profile_info() else { - return; - }; - - let actions = get_available_actions_count(profile); - let action_index = app.get_selected_profile_action().unwrap_or(0); - - // Guard: проверяем, что индекс действия валидный - if action_index >= actions { - return; - } - - // Определяем какое действие выбрано - let mut current_idx = 0; - - // Действие: Открыть в браузере - if let Some(username) = &profile.username { - if action_index == current_idx { - let url = format!("https://t.me/{}", username.trim_start_matches('@')); - #[cfg(feature = "url-open")] - { - match open::that(&url) { - Ok(_) => { - app.status_message = Some(format!("Открыто: {}", url)); - } - Err(e) => { - app.error_message = - Some(format!("Ошибка открытия браузера: {}", e)); - } - } - } - #[cfg(not(feature = "url-open"))] - { - app.error_message = Some( - "Открытие URL недоступно (требуется feature 'url-open')".to_string(), - ); - } - return; - } - current_idx += 1; - } - - // Действие: Скопировать ID - if action_index == current_idx { - app.status_message = Some(format!("ID скопирован: {}", profile.chat_id)); - return; - } - current_idx += 1; - - // Действие: Покинуть группу - if profile.is_group && action_index == current_idx { - app.show_leave_group_confirmation(); - } - } - _ => {} - } -} - -/// Обработка Ctrl+I для открытия профиля чата/пользователя -/// -/// Загружает информацию о профиле и переключает в режим просмотра профиля -pub async fn handle_profile_open(app: &mut App) { - let Some(chat_id) = app.selected_chat_id else { - return; - }; - - app.status_message = Some("Загрузка профиля...".to_string()); - match with_timeout_msg( - Duration::from_secs(5), - app.td_client.get_profile_info(chat_id), - "Таймаут загрузки профиля", - ) - .await - { - Ok(profile) => { - app.enter_profile_mode(profile); - app.status_message = None; - } - Err(e) => { - app.error_message = Some(e); - app.status_message = None; - } - } -} - -/// Обработка модалки подтверждения удаления сообщения -/// -/// Обрабатывает: -/// - Подтверждение удаления (Y/y/Д/д) -/// - Отмена удаления (N/n/Т/т) -/// - Удаление для себя или для всех (зависит от can_be_deleted_for_all_users) -pub async fn handle_delete_confirmation(app: &mut App, key: KeyEvent) { - match handle_yes_no(key.code) { - Some(true) => { - // Подтверждение удаления - if let Some(msg_id) = app.chat_state.selected_message_id() { - if let Some(chat_id) = app.get_selected_chat_id() { - // Находим сообщение для проверки can_be_deleted_for_all_users - let can_delete_for_all = app - .td_client - .current_chat_messages() - .iter() - .find(|m| m.id() == msg_id) - .map(|m| m.can_be_deleted_for_all_users()) - .unwrap_or(false); - - match with_timeout_msg( - Duration::from_secs(5), - app.td_client.delete_messages( - ChatId::new(chat_id), - vec![msg_id], - can_delete_for_all, - ), - "Таймаут удаления", - ) - .await - { - Ok(_) => { - // Удаляем из локального списка - app.td_client - .current_chat_messages_mut() - .retain(|m| m.id() != msg_id); - // Сбрасываем состояние - app.chat_state = crate::app::ChatState::Normal; - } - Err(e) => { - app.error_message = Some(e); - } - } - } - } - // Закрываем модалку - app.chat_state = crate::app::ChatState::Normal; - } - Some(false) => { - // Отмена удаления - app.chat_state = crate::app::ChatState::Normal; - } - None => { - // Другая клавиша - игнорируем - } - } -} - -/// Обработка режима выбора реакции (emoji picker) -/// -/// Обрабатывает: -/// - Навигацию по сетке реакций: Left/Right, Up/Down (сетка 8x6) -/// - Добавление/удаление реакции (Enter) -/// - Выход из режима (Esc) -pub async fn handle_reaction_picker_mode( - app: &mut App, - _key: KeyEvent, - command: Option, -) { - match command { - Some(crate::config::Command::MoveLeft) => { - app.select_previous_reaction(); - app.needs_redraw = true; - } - Some(crate::config::Command::MoveRight) => { - app.select_next_reaction(); - app.needs_redraw = true; - } - Some(crate::config::Command::MoveUp) => { - if let crate::app::ChatState::ReactionPicker { selected_index, .. } = - &mut app.chat_state - { - if *selected_index >= 8 { - *selected_index = selected_index.saturating_sub(8); - app.needs_redraw = true; - } - } - } - Some(crate::config::Command::MoveDown) => { - if let crate::app::ChatState::ReactionPicker { - selected_index, - available_reactions, - .. - } = &mut app.chat_state - { - let new_index = *selected_index + 8; - if new_index < available_reactions.len() { - *selected_index = new_index; - app.needs_redraw = true; - } - } - } - Some(crate::config::Command::SubmitMessage) => { - super::chat::send_reaction(app).await; - } - Some(crate::config::Command::Cancel) => { - app.exit_reaction_picker_mode(); - app.needs_redraw = true; - } - _ => {} - } -} - -/// Обработка режима просмотра закреплённых сообщений -/// -/// Обрабатывает: -/// - Навигацию по закреплённым сообщениям (Up/Down) -/// - Переход к сообщению в истории (Enter) -/// - Выход из режима (Esc) -pub async fn handle_pinned_mode( - app: &mut App, - _key: KeyEvent, - command: Option, -) { - match command { - Some(crate::config::Command::Cancel) => { - app.exit_pinned_mode(); - } - Some(crate::config::Command::MoveUp) => { - app.select_previous_pinned(); - } - Some(crate::config::Command::MoveDown) => { - app.select_next_pinned(); - } - Some(crate::config::Command::SubmitMessage) => { - if let Some(msg_id) = app.get_selected_pinned_id() { - scroll_to_message(app, MessageId::new(msg_id)); - app.exit_pinned_mode(); - } - } - _ => {} - } -} +pub use account::handle_account_switcher; +pub use delete::handle_delete_confirmation; +pub use pinned::handle_pinned_mode; +pub use profile::{handle_profile_mode, handle_profile_open}; +pub use reactions::handle_reaction_picker_mode; diff --git a/src/input/handlers/modal/account.rs b/src/input/handlers/modal/account.rs new file mode 100644 index 0000000..0a0519a --- /dev/null +++ b/src/input/handlers/modal/account.rs @@ -0,0 +1,76 @@ +use crate::app::{AccountSwitcherState, App}; +use crate::tdlib::TdClientTrait; +use crossterm::event::{KeyCode, KeyEvent}; + +/// Обработка ввода в модалке переключения аккаунтов. +pub async fn handle_account_switcher( + app: &mut App, + key: KeyEvent, + command: Option, +) { + let Some(state) = &app.account_switcher else { + return; + }; + + match state { + AccountSwitcherState::SelectAccount { .. } => match command { + Some(crate::config::Command::MoveUp) => { + app.account_switcher_select_prev(); + } + Some(crate::config::Command::MoveDown) => { + app.account_switcher_select_next(); + } + Some(crate::config::Command::SubmitMessage) => { + app.account_switcher_confirm(); + } + Some(crate::config::Command::Cancel) => { + app.close_account_switcher(); + } + _ => match key.code { + KeyCode::Char('a') | KeyCode::Char('ф') => { + app.account_switcher_start_add(); + } + _ => {} + }, + }, + AccountSwitcherState::AddAccount { .. } => match key.code { + KeyCode::Esc => { + app.account_switcher_back(); + } + KeyCode::Enter => { + app.account_switcher_confirm_add(); + } + KeyCode::Backspace => { + if let Some(AccountSwitcherState::AddAccount { + name_input, + cursor_position, + error, + }) = &mut app.account_switcher + { + if *cursor_position > 0 { + let mut chars: Vec = name_input.chars().collect(); + chars.remove(*cursor_position - 1); + *name_input = chars.into_iter().collect(); + *cursor_position -= 1; + *error = None; + } + } + } + KeyCode::Char(c) => { + if let Some(AccountSwitcherState::AddAccount { + name_input, + cursor_position, + error, + }) = &mut app.account_switcher + { + let mut chars: Vec = name_input.chars().collect(); + chars.insert(*cursor_position, c); + *name_input = chars.into_iter().collect(); + *cursor_position += 1; + *error = None; + } + } + _ => {} + }, + } +} diff --git a/src/input/handlers/modal/delete.rs b/src/input/handlers/modal/delete.rs new file mode 100644 index 0000000..8162de0 --- /dev/null +++ b/src/input/handlers/modal/delete.rs @@ -0,0 +1,52 @@ +use crate::app::{App, ChatState}; +use crate::tdlib::TdClientTrait; +use crate::types::ChatId; +use crate::utils::{modal_handler::handle_yes_no, with_timeout_msg}; +use crossterm::event::KeyEvent; +use std::time::Duration; + +/// Обработка модалки подтверждения удаления сообщения. +pub async fn handle_delete_confirmation(app: &mut App, key: KeyEvent) { + match handle_yes_no(key.code) { + Some(true) => { + if let Some(msg_id) = app.chat_state.selected_message_id() { + if let Some(chat_id) = app.get_selected_chat_id() { + let can_delete_for_all = app + .td_client + .current_chat_messages() + .iter() + .find(|m| m.id() == msg_id) + .map(|m| m.can_be_deleted_for_all_users()) + .unwrap_or(false); + + match with_timeout_msg( + Duration::from_secs(5), + app.td_client.delete_messages( + ChatId::new(chat_id), + vec![msg_id], + can_delete_for_all, + ), + "Таймаут удаления", + ) + .await + { + Ok(_) => { + app.td_client.update_current_chat_messages(|messages| { + messages.retain(|m| m.id() != msg_id); + }); + app.chat_state = ChatState::Normal; + } + Err(e) => { + app.error_message = Some(e); + } + } + } + } + app.chat_state = ChatState::Normal; + } + Some(false) => { + app.chat_state = ChatState::Normal; + } + None => {} + } +} diff --git a/src/input/handlers/modal/pinned.rs b/src/input/handlers/modal/pinned.rs new file mode 100644 index 0000000..0bbc461 --- /dev/null +++ b/src/input/handlers/modal/pinned.rs @@ -0,0 +1,32 @@ +use crate::app::methods::modal::ModalMethods; +use crate::app::App; +use crate::input::handlers::scroll_to_message; +use crate::tdlib::TdClientTrait; +use crate::types::MessageId; +use crossterm::event::KeyEvent; + +/// Обработка режима просмотра закреплённых сообщений. +pub async fn handle_pinned_mode( + app: &mut App, + _key: KeyEvent, + command: Option, +) { + match command { + Some(crate::config::Command::Cancel) => { + app.exit_pinned_mode(); + } + Some(crate::config::Command::MoveUp) => { + app.select_previous_pinned(); + } + Some(crate::config::Command::MoveDown) => { + app.select_next_pinned(); + } + Some(crate::config::Command::SubmitMessage) => { + if let Some(msg_id) = app.get_selected_pinned_id() { + scroll_to_message(app, MessageId::new(msg_id)); + app.exit_pinned_mode(); + } + } + _ => {} + } +} diff --git a/src/input/handlers/modal/profile.rs b/src/input/handlers/modal/profile.rs new file mode 100644 index 0000000..901c634 --- /dev/null +++ b/src/input/handlers/modal/profile.rs @@ -0,0 +1,136 @@ +use crate::app::methods::modal::ModalMethods; +use crate::app::methods::navigation::NavigationMethods; +use crate::app::App; +use crate::input::handlers::get_available_actions_count; +use crate::tdlib::TdClientTrait; +use crate::utils::{modal_handler::handle_yes_no, with_timeout_msg}; +use crossterm::event::KeyEvent; +use std::time::Duration; + +/// Обработка режима профиля пользователя/чата. +pub async fn handle_profile_mode( + app: &mut App, + key: KeyEvent, + command: Option, +) { + let confirmation_step = app.get_leave_group_confirmation_step(); + if confirmation_step > 0 { + match handle_yes_no(key.code) { + Some(true) => { + if confirmation_step == 1 { + app.show_leave_group_final_confirmation(); + } else if confirmation_step == 2 { + if let Some(chat_id) = app.selected_chat_id { + let leave_result = app.td_client.leave_chat(chat_id).await; + match leave_result { + Ok(_) => { + app.status_message = Some("Вы вышли из группы".to_string()); + app.exit_profile_mode(); + app.close_chat(); + } + Err(e) => { + app.error_message = Some(e); + app.cancel_leave_group(); + } + } + } + } + } + Some(false) => { + app.cancel_leave_group(); + } + None => {} + } + return; + } + + match command { + Some(crate::config::Command::Cancel) => { + app.exit_profile_mode(); + } + Some(crate::config::Command::MoveUp) => { + app.select_previous_profile_action(); + } + Some(crate::config::Command::MoveDown) => { + if let Some(profile) = app.get_profile_info() { + let max_actions = get_available_actions_count(profile); + app.select_next_profile_action(max_actions); + } + } + Some(crate::config::Command::SubmitMessage) => { + let Some(profile) = app.get_profile_info() else { + return; + }; + + let actions = get_available_actions_count(profile); + let action_index = app.get_selected_profile_action().unwrap_or(0); + if action_index >= actions { + return; + } + + let mut current_idx = 0; + + if let Some(username) = &profile.username { + if action_index == current_idx { + let url = format!("https://t.me/{}", username.trim_start_matches('@')); + #[cfg(feature = "url-open")] + { + match open::that(&url) { + Ok(_) => { + app.status_message = Some(format!("Открыто: {}", url)); + } + Err(e) => { + app.error_message = + Some(format!("Ошибка открытия браузера: {}", e)); + } + } + } + #[cfg(not(feature = "url-open"))] + { + app.error_message = Some( + "Открытие URL недоступно (требуется feature 'url-open')".to_string(), + ); + } + return; + } + current_idx += 1; + } + + if action_index == current_idx { + app.status_message = Some(format!("ID скопирован: {}", profile.chat_id)); + return; + } + current_idx += 1; + + if profile.is_group && action_index == current_idx { + app.show_leave_group_confirmation(); + } + } + _ => {} + } +} + +/// Обработка Ctrl+I для открытия профиля чата/пользователя. +pub async fn handle_profile_open(app: &mut App) { + let Some(chat_id) = app.selected_chat_id else { + return; + }; + + app.status_message = Some("Загрузка профиля...".to_string()); + match with_timeout_msg( + Duration::from_secs(5), + app.td_client.get_profile_info(chat_id), + "Таймаут загрузки профиля", + ) + .await + { + Ok(profile) => { + app.enter_profile_mode(profile); + app.status_message = None; + } + Err(e) => { + app.error_message = Some(e); + app.status_message = None; + } + } +} diff --git a/src/input/handlers/modal/reactions.rs b/src/input/handlers/modal/reactions.rs new file mode 100644 index 0000000..95cc5cc --- /dev/null +++ b/src/input/handlers/modal/reactions.rs @@ -0,0 +1,54 @@ +use crate::app::methods::modal::ModalMethods; +use crate::app::App; +use crate::tdlib::TdClientTrait; +use crossterm::event::KeyEvent; + +/// Обработка режима выбора реакции (emoji picker). +pub async fn handle_reaction_picker_mode( + app: &mut App, + _key: KeyEvent, + command: Option, +) { + match command { + Some(crate::config::Command::MoveLeft) => { + app.select_previous_reaction(); + app.needs_redraw = true; + } + Some(crate::config::Command::MoveRight) => { + app.select_next_reaction(); + app.needs_redraw = true; + } + Some(crate::config::Command::MoveUp) => { + if let crate::app::ChatState::ReactionPicker { selected_index, .. } = + &mut app.chat_state + { + if *selected_index >= 8 { + *selected_index = selected_index.saturating_sub(8); + app.needs_redraw = true; + } + } + } + Some(crate::config::Command::MoveDown) => { + if let crate::app::ChatState::ReactionPicker { + selected_index, + available_reactions, + .. + } = &mut app.chat_state + { + let new_index = *selected_index + 8; + if new_index < available_reactions.len() { + *selected_index = new_index; + app.needs_redraw = true; + } + } + } + Some(crate::config::Command::SubmitMessage) => { + crate::input::handlers::chat::send_reaction(app).await; + } + Some(crate::config::Command::Cancel) => { + app.exit_reaction_picker_mode(); + app.needs_redraw = true; + } + _ => {} + } +} diff --git a/src/input/key_handler.rs b/src/input/key_handler.rs deleted file mode 100644 index a6d1395..0000000 --- a/src/input/key_handler.rs +++ /dev/null @@ -1,450 +0,0 @@ -/// Модуль для обработки клавиш с использованием trait-based подхода -/// -/// Позволяет каждому экрану/режиму определить свою логику обработки клавиш, -/// избегая огромных match блоков в одном месте. - -use crate::app::App; -use crate::config::Command; -use crate::tdlib::{TdClient, TdClientTrait}; -use crossterm::event::KeyEvent; - -/// Результат обработки клавиши -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum KeyResult { - /// Клавиша обработана, продолжить работу - Handled, - - /// Клавиша обработана, нужна перерисовка UI - HandledNeedsRedraw, - - /// Клавиша не обработана (fallback на глобальные команды) - NotHandled, - - /// Выход из приложения - Quit, -} - -impl KeyResult { - /// Проверяет нужна ли перерисовка - pub fn needs_redraw(&self) -> bool { - matches!(self, KeyResult::HandledNeedsRedraw) - } - - /// Проверяет был ли запрос выхода - pub fn should_quit(&self) -> bool { - matches!(self, KeyResult::Quit) - } -} - -/// Trait для обработки клавиш на конкретном экране/в режиме -/// -/// # Examples -/// -/// ```ignore -/// struct ChatListHandler; -/// -/// impl KeyHandler for ChatListHandler { -/// fn handle_key( -/// &self, -/// app: &mut App, -/// key: KeyEvent, -/// command: Option, -/// ) -> KeyResult { -/// match command { -/// Some(Command::MoveUp) => { -/// app.move_chat_selection_up(); -/// KeyResult::HandledNeedsRedraw -/// } -/// Some(Command::OpenChat) => { -/// // Open selected chat -/// KeyResult::HandledNeedsRedraw -/// } -/// _ => KeyResult::NotHandled, -/// } -/// } -/// } -/// ``` -pub trait KeyHandler { - /// Обрабатывает нажатие клавиши - /// - /// # Arguments - /// - /// * `app` - Mutable reference на состояние приложения - /// * `key` - Событие клавиши от crossterm - /// * `command` - Опциональная команда из keybindings (если привязана) - /// - /// # Returns - /// - /// `KeyResult` - результат обработки (обработана/не обработана/выход) - fn handle_key( - &self, - app: &mut App, - key: KeyEvent, - command: Option, - ) -> KeyResult; - - /// Приоритет обработчика (для цепочки обработчиков) - /// - /// Обработчики с более высоким приоритетом вызываются первыми. - /// По умолчанию 0. - fn priority(&self) -> i32 { - 0 - } -} - -/// Глобальный обработчик клавиш (работает на всех экранах) -pub struct GlobalKeyHandler; - -impl KeyHandler for GlobalKeyHandler { - fn handle_key( - &self, - app: &mut App, - _key: KeyEvent, - command: Option, - ) -> KeyResult { - match command { - Some(Command::Quit) => KeyResult::Quit, - - Some(Command::OpenSearch) if !app.is_searching() => { - // TODO: implement enter_search_mode or use existing method - KeyResult::HandledNeedsRedraw - } - - Some(Command::Cancel) => { - // Cancel различных режимов - if app.is_searching() { - // TODO: implement exit_search_mode or use existing method - KeyResult::HandledNeedsRedraw - } else { - KeyResult::NotHandled - } - } - - _ => KeyResult::NotHandled, - } - } - - fn priority(&self) -> i32 { - -100 // Низкий приоритет - fallback для всех экранов - } -} - -/// Обработчик для списка чатов -pub struct ChatListKeyHandler; - -impl KeyHandler for ChatListKeyHandler { - fn handle_key( - &self, - app: &mut App, - _key: KeyEvent, - command: Option, - ) -> KeyResult { - match command { - Some(Command::MoveUp) => { - // TODO: implement chat selection navigation - // app.chat_list_state is ListState, use .select() - KeyResult::HandledNeedsRedraw - } - - Some(Command::MoveDown) => { - // TODO: implement chat selection navigation - KeyResult::HandledNeedsRedraw - } - - Some(Command::OpenChat) => { - // Обработка открытия чата будет в async контексте - // Здесь только возвращаем что команда распознана - KeyResult::HandledNeedsRedraw - } - - // Папки 1-9 - Some(Command::SelectFolder1) => { - app.set_selected_folder_id(Some(1)); - KeyResult::HandledNeedsRedraw - } - Some(Command::SelectFolder2) => { - app.set_selected_folder_id(Some(2)); - KeyResult::HandledNeedsRedraw - } - Some(Command::SelectFolder3) => { - app.set_selected_folder_id(Some(3)); - KeyResult::HandledNeedsRedraw - } - Some(Command::SelectFolder4) => { - app.set_selected_folder_id(Some(4)); - KeyResult::HandledNeedsRedraw - } - Some(Command::SelectFolder5) => { - app.set_selected_folder_id(Some(5)); - KeyResult::HandledNeedsRedraw - } - Some(Command::SelectFolder6) => { - app.set_selected_folder_id(Some(6)); - KeyResult::HandledNeedsRedraw - } - Some(Command::SelectFolder7) => { - app.set_selected_folder_id(Some(7)); - KeyResult::HandledNeedsRedraw - } - Some(Command::SelectFolder8) => { - app.set_selected_folder_id(Some(8)); - KeyResult::HandledNeedsRedraw - } - Some(Command::SelectFolder9) => { - app.set_selected_folder_id(Some(9)); - KeyResult::HandledNeedsRedraw - } - - _ => KeyResult::NotHandled, - } - } - - fn priority(&self) -> i32 { - 10 // Средний приоритет - } -} - -/// Обработчик для просмотра сообщений -pub struct MessageViewKeyHandler; - -impl KeyHandler for MessageViewKeyHandler { - fn handle_key( - &self, - app: &mut App, - _key: KeyEvent, - command: Option, - ) -> KeyResult { - match command { - Some(Command::MoveUp) => { - if app.message_view_state().message_scroll_offset > 0 { - app.message_view_state().message_scroll_offset -= 1; - KeyResult::HandledNeedsRedraw - } else { - KeyResult::Handled - } - } - - Some(Command::MoveDown) => { - app.message_view_state().message_scroll_offset += 1; - KeyResult::HandledNeedsRedraw - } - - Some(Command::PageUp) => { - app.message_view_state().message_scroll_offset = app.message_view_state().message_scroll_offset.saturating_sub(10); - KeyResult::HandledNeedsRedraw - } - - Some(Command::PageDown) => { - app.message_view_state().message_scroll_offset += 10; - KeyResult::HandledNeedsRedraw - } - - Some(Command::OpenSearchInChat) => { - // Открыть поиск в чате - KeyResult::HandledNeedsRedraw - } - - Some(Command::OpenProfile) => { - // Открыть профиль - KeyResult::HandledNeedsRedraw - } - - _ => KeyResult::NotHandled, - } - } - - fn priority(&self) -> i32 { - 10 // Средний приоритет - } -} - -/// Обработчик для режима выбора сообщения -pub struct MessageSelectionKeyHandler; - -impl KeyHandler for MessageSelectionKeyHandler { - fn handle_key( - &self, - _app: &mut App, - _key: KeyEvent, - command: Option, - ) -> KeyResult { - match command { - Some(Command::DeleteMessage) => { - // Показать модалку подтверждения удаления - KeyResult::HandledNeedsRedraw - } - - Some(Command::ReplyMessage) => { - // Войти в режим ответа - KeyResult::HandledNeedsRedraw - } - - Some(Command::ForwardMessage) => { - // Войти в режим пересылки - KeyResult::HandledNeedsRedraw - } - - Some(Command::CopyMessage) => { - // Скопировать текст в буфер - KeyResult::HandledNeedsRedraw - } - - Some(Command::ReactMessage) => { - // Открыть emoji picker - KeyResult::HandledNeedsRedraw - } - - Some(Command::Cancel) => { - // Выйти из режима выбора - KeyResult::HandledNeedsRedraw - } - - _ => KeyResult::NotHandled, - } - } - - fn priority(&self) -> i32 { - 20 // Высокий приоритет - режимы должны обрабатываться первыми - } -} - -/// Цепочка обработчиков клавиш -/// -/// Позволяет комбинировать несколько обработчиков в порядке приоритета. -pub struct KeyHandlerChain { - handlers: Vec<(i32, Box)>, -} - -impl KeyHandlerChain { - /// Создаёт новую цепочку - pub fn new() -> Self { - Self { - handlers: Vec::new(), - } - } - - /// Добавляет обработчик в цепочку - pub fn add(mut self, handler: H) -> Self { - let priority = handler.priority(); - self.handlers.push((priority, Box::new(handler))); - // Сортируем по убыванию приоритета - self.handlers.sort_by(|a, b| b.0.cmp(&a.0)); - self - } - - /// Обрабатывает клавишу, вызывая обработчики по порядку - /// - /// Останавливается на первом обработчике, который вернул Handled/HandledNeedsRedraw/Quit - pub fn handle( - &self, - app: &mut App, - key: KeyEvent, - command: Option, - ) -> KeyResult { - for (_priority, handler) in &self.handlers { - let result = handler.handle_key(app, key, command); - if result != KeyResult::NotHandled { - return result; - } - } - KeyResult::NotHandled - } -} - -impl Default for KeyHandlerChain { - fn default() -> Self { - Self::new() - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crossterm::event::KeyCode; - - #[test] - fn test_key_result_needs_redraw() { - assert!(!KeyResult::Handled.needs_redraw()); - assert!(KeyResult::HandledNeedsRedraw.needs_redraw()); - assert!(!KeyResult::NotHandled.needs_redraw()); - assert!(!KeyResult::Quit.needs_redraw()); - } - - #[test] - fn test_key_result_should_quit() { - assert!(!KeyResult::Handled.should_quit()); - assert!(!KeyResult::HandledNeedsRedraw.should_quit()); - assert!(!KeyResult::NotHandled.should_quit()); - assert!(KeyResult::Quit.should_quit()); - } - - // TODO: Enable these tests after App trait integration - // #[test] - // fn test_global_handler_quit() { - // let handler = GlobalKeyHandler; - // let mut app = App::new_for_test(); - // - // let result = handler.handle_key( - // &mut app, - // KeyEvent::from(KeyCode::Char('q')), - // Some(Command::Quit), - // ); - // - // assert_eq!(result, KeyResult::Quit); - // } - - // #[test] - // fn test_chat_list_handler_navigation() { - // let handler = ChatListKeyHandler; - // let mut app = App::new_for_test(); - // - // // Test move up (should be handled even at top) - // let result = handler.handle_key( - // &mut app, - // KeyEvent::from(KeyCode::Up), - // Some(Command::MoveUp), - // ); - // - // assert_eq!(result, KeyResult::Handled); - // } - - // #[test] - // fn test_handler_chain() { - // let chain = KeyHandlerChain::new() - // .add(ChatListKeyHandler) - // .add(GlobalKeyHandler); - // - // let mut app = App::new_for_test(); - // - // // ChatListHandler should handle MoveUp first - // let result = chain.handle( - // &mut app, - // KeyEvent::from(KeyCode::Up), - // Some(Command::MoveUp), - // ); - // - // assert_eq!(result, KeyResult::Handled); - // - // // GlobalHandler should handle Quit - // let result = chain.handle( - // &mut app, - // KeyEvent::from(KeyCode::Char('q')), - // Some(Command::Quit), - // ); - // - // assert_eq!(result, KeyResult::Quit); - // } - - #[test] - fn test_handler_priority() { - let handler1 = ChatListKeyHandler; - let handler2 = MessageSelectionKeyHandler; - let handler3 = GlobalKeyHandler; - - assert_eq!(handler1.priority(), 10); - assert_eq!(handler2.priority(), 20); - assert_eq!(handler3.priority(), -100); - - // В цепочке должны быть отсортированы: MessageSelection > ChatList > Global - } -} diff --git a/src/main.rs b/src/main.rs index f31486d..36f917c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -222,15 +222,17 @@ async fn run_app( Ok(path) => PhotoDownloadState::Downloaded(path), Err(_) => PhotoDownloadState::Error("Ошибка загрузки".to_string()), }; - 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 = new_state; - got_photos = true; - break; + app.td_client.update_current_chat_messages(|messages| { + for msg in messages { + if let Some(photo) = msg.photo_info_mut() { + if photo.file_id == file_id { + photo.download_state = new_state; + got_photos = true; + break; + } } } - } + }); // Если это фото ждёт открытия в модалке — открываем let pending_matches = app .pending_image_open diff --git a/src/tdlib/chat_helpers.rs b/src/tdlib/chat_helpers.rs index b022ee7..09fbe55 100644 --- a/src/tdlib/chat_helpers.rs +++ b/src/tdlib/chat_helpers.rs @@ -10,19 +10,12 @@ use tdlib_rs::enums::{Chat as TdChat, ChatList, ChatType}; use super::client::TdClient; use super::types::ChatInfo; -/// Находит мутабельную ссылку на чат по ID. -pub fn find_chat_mut(client: &mut TdClient, chat_id: ChatId) -> Option<&mut ChatInfo> { - client.chats_mut().iter_mut().find(|c| c.id == chat_id) -} - /// Обновляет поле чата, если чат найден. pub fn update_chat(client: &mut TdClient, chat_id: ChatId, updater: F) where F: FnOnce(&mut ChatInfo), { - if let Some(chat) = find_chat_mut(client, chat_id) { - updater(chat); - } + client.update_chat(chat_id, updater); } /// Добавляет новый чат или обновляет существующий @@ -33,9 +26,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.remove_chat(ChatId::new(td_chat.id)); return; } @@ -61,22 +52,23 @@ pub fn add_or_update_chat(client: &mut TdClient, td_chat_enum: &TdChat) { ChatType::Private(private) => { // Ограничиваем размер chat_user_ids let chat_id = ChatId::new(td_chat.id); - if client.user_cache.chat_user_ids.len() >= MAX_CHAT_USER_IDS - && !client.user_cache.chat_user_ids.contains_key(&chat_id) - { - // Удаляем случайную запись (первую найденную) - if let Some(&key) = client.user_cache.chat_user_ids.keys().next() { - client.user_cache.chat_user_ids.remove(&key); - } - } 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 - .peek(&user_id) - .map(|u| format!("@{}", u)) + client.update_user_cache(|cache| { + if cache.chat_user_ids.len() >= MAX_CHAT_USER_IDS + && !cache.chat_user_ids.contains_key(&chat_id) + { + // Удаляем случайную запись (первую найденную) + if let Some(&key) = cache.chat_user_ids.keys().next() { + cache.chat_user_ids.remove(&key); + } + } + cache.chat_user_ids.insert(chat_id, user_id); + // Проверяем, есть ли уже username в кэше (peek не обновляет LRU) + cache + .user_usernames + .peek(&user_id) + .map(|u| format!("@{}", u)) + }) } _ => None, }; @@ -110,44 +102,35 @@ pub fn add_or_update_chat(client: &mut TdClient, td_chat_enum: &TdChat) { draft_text: None, }; - if let Some(existing) = find_chat_mut(client, ChatId::new(td_chat.id)) { - existing.title = chat_info.title; - existing.last_message = chat_info.last_message; - existing.last_message_date = chat_info.last_message_date; - existing.unread_count = chat_info.unread_count; - existing.unread_mention_count = chat_info.unread_mention_count; - existing.last_read_outbox_message_id = chat_info.last_read_outbox_message_id; - existing.folder_ids = chat_info.folder_ids; - existing.is_muted = chat_info.is_muted; + let chat_info_for_update = chat_info.clone(); + let updated_existing = client.update_chat(ChatId::new(td_chat.id), |existing| { + existing.title = chat_info_for_update.title; + existing.last_message = chat_info_for_update.last_message; + existing.last_message_date = chat_info_for_update.last_message_date; + existing.unread_count = chat_info_for_update.unread_count; + existing.unread_mention_count = chat_info_for_update.unread_mention_count; + existing.last_read_outbox_message_id = chat_info_for_update.last_read_outbox_message_id; + existing.folder_ids = chat_info_for_update.folder_ids; + existing.is_muted = chat_info_for_update.is_muted; // Обновляем username если он появился - if let Some(username) = chat_info.username { + if let Some(username) = chat_info_for_update.username { existing.username = Some(username); } // Обновляем позицию только если она пришла if main_position.is_some() { - existing.is_pinned = chat_info.is_pinned; - existing.order = chat_info.order; + existing.is_pinned = chat_info_for_update.is_pinned; + existing.order = chat_info_for_update.order; } - } else { - client.chats_mut().push(chat_info); + }); + + if !updated_existing { + client.push_chat(chat_info); // Ограничиваем количество чатов - if client.chats_mut().len() > MAX_CHATS { - // Удаляем чат с наименьшим order (наименее активный) - let Some(min_idx) = client - .chats() - .iter() - .enumerate() - .min_by_key(|(_, c)| c.order) - .map(|(i, _)| i) - else { - return; // Нет чатов для удаления (не должно произойти) - }; - client.chats_mut().remove(min_idx); - } + client.trim_chats_to_max_by_order(MAX_CHATS); } // Сортируем чаты по order (TDLib order учитывает pinned и время) - client.chats_mut().sort_by(|a, b| b.order.cmp(&a.order)); + client.sort_chats_by_order(); } diff --git a/src/tdlib/client.rs b/src/tdlib/client.rs index 98a39fa..94d48f0 100644 --- a/src/tdlib/client.rs +++ b/src/tdlib/client.rs @@ -105,7 +105,8 @@ impl TdClient { self.notification_manager.set_enabled(config.enabled); self.notification_manager .set_only_mentions(config.only_mentions); - self.notification_manager.set_show_preview(config.show_preview); + self.notification_manager + .set_show_preview(config.show_preview); self.notification_manager.set_timeout(config.timeout_ms); self.notification_manager .set_urgency(config.urgency.clone()); @@ -433,24 +434,117 @@ impl TdClient { &self.chat_manager.chats } - pub fn chats_mut(&mut self) -> &mut Vec { - &mut self.chat_manager.chats + pub fn update_chats(&mut self, updater: F) -> R + where + F: FnOnce(&mut Vec) -> R, + { + updater(&mut self.chat_manager.chats) + } + + pub fn update_chat(&mut self, chat_id: ChatId, updater: F) -> bool + where + F: FnOnce(&mut ChatInfo), + { + let Some(chat) = self.chat_manager.chats.iter_mut().find(|c| c.id == chat_id) else { + return false; + }; + + updater(chat); + true + } + + pub fn remove_chat(&mut self, chat_id: ChatId) { + self.chat_manager.chats.retain(|c| c.id != chat_id); + } + + pub fn push_chat(&mut self, chat: ChatInfo) { + self.chat_manager.chats.push(chat); + } + + pub fn trim_chats_to_max_by_order(&mut self, max_chats: usize) { + if self.chat_manager.chats.len() <= max_chats { + return; + } + + let Some(min_idx) = self + .chat_manager + .chats + .iter() + .enumerate() + .min_by_key(|(_, chat)| chat.order) + .map(|(idx, _)| idx) + else { + return; + }; + + self.chat_manager.chats.remove(min_idx); + } + + pub fn sort_chats_by_order(&mut self) { + self.chat_manager + .chats + .sort_by(|a, b| b.order.cmp(&a.order)); } pub fn folders(&self) -> &[FolderInfo] { &self.chat_manager.folders } - pub fn folders_mut(&mut self) -> &mut Vec { - &mut self.chat_manager.folders + pub fn update_folders(&mut self, updater: F) -> R + where + F: FnOnce(&mut Vec) -> R, + { + updater(&mut self.chat_manager.folders) + } + + pub fn set_folders(&mut self, folders: Vec) { + self.chat_manager.folders = folders; } pub fn current_chat_messages(&self) -> &[MessageInfo] { &self.message_manager.current_chat_messages } - pub fn current_chat_messages_mut(&mut self) -> &mut Vec { - &mut self.message_manager.current_chat_messages + pub fn clear_current_chat_messages(&mut self) { + self.message_manager.current_chat_messages.clear(); + } + + pub fn set_current_chat_messages(&mut self, messages: Vec) { + self.message_manager.current_chat_messages = messages; + } + + pub fn update_current_chat_messages(&mut self, updater: F) -> R + where + F: FnOnce(&mut Vec) -> R, + { + updater(&mut self.message_manager.current_chat_messages) + } + + pub fn update_current_chat_message(&mut self, message_id: MessageId, updater: F) -> bool + where + F: FnOnce(&mut MessageInfo), + { + let Some(message) = self + .message_manager + .current_chat_messages + .iter_mut() + .find(|message| message.id() == message_id) + else { + return false; + }; + + updater(message); + true + } + + pub fn replace_current_chat_message( + &mut self, + message_id: MessageId, + new_message: MessageInfo, + ) -> bool { + self.update_current_chat_message(message_id, |message| { + *message = new_message; + }) } pub fn current_chat_id(&self) -> Option { @@ -498,8 +592,10 @@ impl TdClient { &self.user_cache.pending_user_ids } - pub fn pending_user_ids_mut(&mut self) -> &mut Vec { - &mut self.user_cache.pending_user_ids + pub fn queue_pending_user_id(&mut self, user_id: crate::types::UserId) { + if !self.user_cache.pending_user_ids.contains(&user_id) { + self.user_cache.pending_user_ids.push(user_id); + } } pub fn main_chat_list_position(&self) -> i32 { @@ -515,8 +611,11 @@ impl TdClient { &self.user_cache } - pub fn user_cache_mut(&mut self) -> &mut UserCache { - &mut self.user_cache + pub fn update_user_cache(&mut self, updater: F) -> R + where + F: FnOnce(&mut UserCache) -> R, + { + updater(&mut self.user_cache) } // ==================== Helper методы для упрощения обработки updates ==================== @@ -558,7 +657,7 @@ impl TdClient { } // Пересортируем по order - self.chats_mut().sort_by(|a, b| b.order.cmp(&a.order)); + self.sort_chats_by_order(); } Update::ChatReadInbox(update) => { crate::tdlib::chat_helpers::update_chat( @@ -600,11 +699,13 @@ impl TdClient { ); // Если это текущий открытый чат — обновляем is_read у сообщений if Some(ChatId::new(update.chat_id)) == self.current_chat_id() { - for msg in self.current_chat_messages_mut().iter_mut() { - if msg.is_outgoing() && msg.id() <= last_read_msg_id { - msg.state.is_read = true; + self.update_current_chat_messages(|messages| { + for msg in messages { + if msg.is_outgoing() && msg.id() <= last_read_msg_id { + msg.state.is_read = true; + } } - } + }); } } Update::ChatPosition(update) => { @@ -618,11 +719,13 @@ impl TdClient { } Update::ChatFolders(update) => { // Обновляем список папок - *self.folders_mut() = update - .chat_folders - .into_iter() - .map(|f| FolderInfo { id: f.id, name: f.title }) - .collect(); + self.set_folders( + update + .chat_folders + .into_iter() + .map(|f| FolderInfo { id: f.id, name: f.title }) + .collect(), + ); self.set_main_chat_list_position(update.main_chat_list_position); } Update::UserStatus(update) => { @@ -635,9 +738,11 @@ impl TdClient { UserStatus::LastMonth(_) => UserOnlineStatus::LastMonth, UserStatus::Empty => UserOnlineStatus::LongTimeAgo, }; - self.user_cache - .user_statuses - .insert(UserId::new(update.user_id), status); + self.update_user_cache(|cache| { + cache + .user_statuses + .insert(UserId::new(update.user_id), status); + }); } Update::ConnectionState(update) => { // Обновляем состояние сетевого соединения diff --git a/src/tdlib/client_impl.rs b/src/tdlib/client_impl.rs index 96b4cab..5a70043 100644 --- a/src/tdlib/client_impl.rs +++ b/src/tdlib/client_impl.rs @@ -3,19 +3,22 @@ //! This file contains the trait implementation that delegates to existing TdClient methods. use super::client::TdClient; -use super::r#trait::TdClientTrait; +use super::r#trait::{ + AccountClient, AuthClient, ChatActionClient, ChatClient, ClientState, FileClient, + MessageClient, NotificationClient, ReactionClient, UpdateClient, UserClient, +}; use super::{ AuthState, ChatInfo, FolderInfo, MessageInfo, ProfileInfo, ReplyInfo, UserCache, UserOnlineStatus, }; use crate::types::{ChatId, MessageId, UserId}; use async_trait::async_trait; +use std::borrow::Cow; use std::path::PathBuf; use tdlib_rs::enums::{ChatAction, Update}; #[async_trait] -impl TdClientTrait for TdClient { - // ============ Auth methods ============ +impl AuthClient for TdClient { async fn send_phone_number(&self, phone: String) -> Result<(), String> { self.send_phone_number(phone).await } @@ -27,8 +30,10 @@ impl TdClientTrait for TdClient { async fn send_password(&self, password: String) -> Result<(), String> { self.send_password(password).await } +} - // ============ Chat methods ============ +#[async_trait] +impl ChatClient for TdClient { async fn load_chats(&mut self, limit: i32) -> Result<(), String> { self.load_chats(limit).await } @@ -45,7 +50,39 @@ impl TdClientTrait for TdClient { self.get_profile_info(chat_id).await } - // ============ Chat actions ============ + fn chats(&self) -> &[ChatInfo] { + self.chats() + } + + fn folders(&self) -> &[FolderInfo] { + self.folders() + } + + fn main_chat_list_position(&self) -> i32 { + self.main_chat_list_position() + } + + fn set_main_chat_list_position(&mut self, position: i32) { + self.set_main_chat_list_position(position) + } + + fn update_chats(&mut self, updater: F) + where + F: FnOnce(&mut Vec), + { + TdClient::update_chats(self, updater); + } + + fn update_folders(&mut self, updater: F) + where + F: FnOnce(&mut Vec), + { + TdClient::update_folders(self, updater); + } +} + +#[async_trait] +impl ChatActionClient for TdClient { async fn send_chat_action(&self, chat_id: ChatId, action: ChatAction) { self.send_chat_action(chat_id, action).await } @@ -54,7 +91,17 @@ impl TdClientTrait for TdClient { self.clear_stale_typing_status() } - // ============ Message methods ============ + fn typing_status(&self) -> Option<&(UserId, String, std::time::Instant)> { + self.typing_status() + } + + fn set_typing_status(&mut self, status: Option<(UserId, String, std::time::Instant)>) { + self.set_typing_status(status) + } +} + +#[async_trait] +impl MessageClient for TdClient { async fn get_chat_history( &mut self, chat_id: ChatId, @@ -132,6 +179,18 @@ impl TdClientTrait for TdClient { self.set_draft_message(chat_id, text).await } + fn current_chat_messages(&self) -> Cow<'_, [MessageInfo]> { + Cow::Borrowed(self.current_chat_messages()) + } + + fn current_chat_id(&self) -> Option { + self.current_chat_id() + } + + fn current_pinned_message(&self) -> Option { + self.current_pinned_message().cloned() + } + fn push_message(&mut self, msg: MessageInfo) { self.push_message(msg) } @@ -144,16 +203,66 @@ impl TdClientTrait for TdClient { self.process_pending_view_messages().await } - // ============ User methods ============ + fn clear_current_chat_messages(&mut self) { + TdClient::clear_current_chat_messages(self) + } + + fn set_current_chat_messages(&mut self, messages: Vec) { + TdClient::set_current_chat_messages(self, messages); + } + + fn update_current_chat_messages(&mut self, updater: F) + where + F: FnOnce(&mut Vec), + { + TdClient::update_current_chat_messages(self, updater); + } + + fn set_current_chat_id(&mut self, chat_id: Option) { + self.set_current_chat_id(chat_id) + } + + fn set_current_pinned_message(&mut self, msg: Option) { + self.set_current_pinned_message(msg) + } + + fn pending_view_messages(&self) -> &[(ChatId, Vec)] { + self.pending_view_messages() + } + + fn enqueue_pending_view_messages(&mut self, chat_id: ChatId, message_ids: Vec) { + self.enqueue_pending_view_messages(chat_id, message_ids); + } +} + +#[async_trait] +impl UserClient for TdClient { fn get_user_status_by_chat_id(&self, chat_id: ChatId) -> Option<&UserOnlineStatus> { self.get_user_status_by_chat_id(chat_id) } + fn pending_user_ids(&self) -> &[UserId] { + self.pending_user_ids() + } + + fn user_cache(&self) -> &UserCache { + self.user_cache() + } + + fn update_user_cache(&mut self, updater: F) + where + F: FnOnce(&mut UserCache), + { + TdClient::update_user_cache(self, updater); + } + async fn process_pending_user_ids(&mut self) { self.process_pending_user_ids().await } +} - // ============ Reaction methods ============ +#[async_trait] +impl ReactionClient for TdClient { async fn get_message_available_reactions( &self, chat_id: ChatId, @@ -171,8 +280,10 @@ impl TdClientTrait for TdClient { ) -> Result<(), String> { self.toggle_reaction(chat_id, message_id, reaction).await } +} - // ============ File methods ============ +#[async_trait] +impl FileClient for TdClient { async fn download_file(&self, file_id: i32) -> Result { self.download_file(file_id).await } @@ -181,7 +292,10 @@ impl TdClientTrait for TdClient { // Voice notes use the same download mechanism as photos self.download_file(file_id).await } +} +#[async_trait] +impl ClientState for TdClient { fn client_id(&self) -> i32 { self.client_id() } @@ -194,99 +308,12 @@ impl TdClientTrait for TdClient { self.auth_state() } - fn chats(&self) -> &[ChatInfo] { - self.chats() - } - - fn folders(&self) -> &[FolderInfo] { - self.folders() - } - - fn current_chat_messages(&self) -> Vec { - self.message_manager.current_chat_messages.to_vec() - } - - fn current_chat_id(&self) -> Option { - self.current_chat_id() - } - - fn current_pinned_message(&self) -> Option { - self.message_manager.current_pinned_message.clone() - } - - fn typing_status(&self) -> Option<&(UserId, String, std::time::Instant)> { - self.typing_status() - } - - fn pending_view_messages(&self) -> &[(ChatId, Vec)] { - self.pending_view_messages() - } - - fn pending_user_ids(&self) -> &[UserId] { - self.pending_user_ids() - } - - fn main_chat_list_position(&self) -> i32 { - self.main_chat_list_position() - } - - fn user_cache(&self) -> &UserCache { - self.user_cache() - } - fn network_state(&self) -> super::types::NetworkState { self.network_state.clone() } +} - fn chats_mut(&mut self) -> &mut Vec { - self.chats_mut() - } - - fn folders_mut(&mut self) -> &mut Vec { - self.folders_mut() - } - - fn current_chat_messages_mut(&mut self) -> &mut Vec { - self.current_chat_messages_mut() - } - - fn clear_current_chat_messages(&mut self) { - self.current_chat_messages_mut().clear() - } - - fn set_current_chat_messages(&mut self, messages: Vec) { - *self.current_chat_messages_mut() = messages; - } - - fn set_current_chat_id(&mut self, chat_id: Option) { - self.set_current_chat_id(chat_id) - } - - fn set_current_pinned_message(&mut self, msg: Option) { - self.set_current_pinned_message(msg) - } - - fn set_typing_status(&mut self, status: Option<(UserId, String, std::time::Instant)>) { - self.set_typing_status(status) - } - - fn enqueue_pending_view_messages(&mut self, chat_id: ChatId, message_ids: Vec) { - self.enqueue_pending_view_messages(chat_id, message_ids); - } - - fn pending_user_ids_mut(&mut self) -> &mut Vec { - self.pending_user_ids_mut() - } - - fn set_main_chat_list_position(&mut self, position: i32) { - self.set_main_chat_list_position(position) - } - - fn user_cache_mut(&mut self) -> &mut UserCache { - &mut self.user_cache - } - - // ============ Notification methods ============ +impl NotificationClient for TdClient { fn configure_notifications(&mut self, config: &crate::config::NotificationsConfig) { self.configure_notifications(config); } @@ -295,13 +322,16 @@ impl TdClientTrait for TdClient { self.notification_manager .sync_muted_chats(&self.chat_manager.chats); } +} - // ============ Account switching ============ +#[async_trait] +impl AccountClient for TdClient { async fn recreate_client(&mut self, db_path: PathBuf) -> Result<(), String> { TdClient::recreate_client(self, db_path).await } +} - // ============ Update handling ============ +impl UpdateClient for TdClient { fn handle_update(&mut self, update: Update) { // Delegate to the real implementation TdClient::handle_update(self, update) diff --git a/src/tdlib/message_converter.rs b/src/tdlib/message_converter.rs index 5cdf92a..05d897a 100644 --- a/src/tdlib/message_converter.rs +++ b/src/tdlib/message_converter.rs @@ -23,9 +23,7 @@ pub fn convert_message(client: &mut TdClient, message: &TdMessage, chat_id: Chat .cloned() .unwrap_or_else(|| { // Добавляем в очередь для загрузки - if !client.pending_user_ids().contains(&user_id) { - client.pending_user_ids_mut().push(user_id); - } + client.queue_pending_user_id(user_id); format!("User_{}", user_id.as_i64()) }) } @@ -210,25 +208,27 @@ pub fn update_reply_info_from_loaded_messages(client: &mut TdClient) { .collect(); // Обновляем reply_to для сообщений с неполными данными - for msg in client.current_chat_messages_mut().iter_mut() { - let Some(ref mut reply) = msg.interactions.reply_to else { - continue; - }; + client.update_current_chat_messages(|messages| { + for msg in messages { + let Some(ref mut reply) = msg.interactions.reply_to else { + continue; + }; - // Если sender_name = "..." или text пустой — пробуем заполнить - if reply.sender_name != "..." && !reply.text.is_empty() { - continue; - } + // Если sender_name = "..." или text пустой — пробуем заполнить + if reply.sender_name != "..." && !reply.text.is_empty() { + continue; + } - let Some((sender, content)) = msg_data.get(&reply.message_id.as_i64()) else { - continue; - }; + let Some((sender, content)) = msg_data.get(&reply.message_id.as_i64()) else { + continue; + }; - if reply.sender_name == "..." { - reply.sender_name = sender.clone(); + if reply.sender_name == "..." { + reply.sender_name = sender.clone(); + } + if reply.text.is_empty() { + reply.text = content.clone(); + } } - if reply.text.is_empty() { - reply.text = content.clone(); - } - } + }); } diff --git a/src/tdlib/messages/operations.rs b/src/tdlib/messages/operations.rs index 7dd2635..fb1ea93 100644 --- a/src/tdlib/messages/operations.rs +++ b/src/tdlib/messages/operations.rs @@ -280,15 +280,13 @@ impl MessageManager { /// /// * `chat_id` - ID чата /// - /// # Note + /// # Compatibility /// - /// TODO: В tdlib-rs 1.8.29 поле `pinned_message_id` было удалено из `Chat`. - /// Нужно использовать `getChatPinnedMessage` или альтернативный способ. - /// Временно отключено, возвращает `None`. + /// The current `tdlib-rs` schema no longer exposes `Chat.pinned_message_id`, and the + /// generated wrapper does not provide `getChatPinnedMessage`. The pinned-message modal + /// uses `get_pinned_messages` with `SearchMessagesFilter::Pinned`; this method keeps the + /// legacy single-header state empty until TDLib exposes a direct top-pinned-message API. pub async fn load_current_pinned_message(&mut self, _chat_id: ChatId) { - // TODO: В tdlib-rs 1.8.29 поле pinned_message_id было удалено из Chat. - // Нужно использовать getChatPinnedMessage или альтернативный способ. - // Временно отключено. self.current_pinned_message = None; } diff --git a/src/tdlib/mod.rs b/src/tdlib/mod.rs index 7d32549..3bfe8b1 100644 --- a/src/tdlib/mod.rs +++ b/src/tdlib/mod.rs @@ -16,7 +16,11 @@ pub mod users; // Экспорт основных типов pub use auth::AuthState; pub use client::TdClient; -pub use r#trait::TdClientTrait; +#[allow(unused_imports)] +pub use r#trait::{ + AccountClient, AuthClient, ChatActionClient, ChatClient, ClientState, FileClient, + MessageClient, NotificationClient, ReactionClient, TdClientTrait, UpdateClient, UserClient, +}; #[allow(unused_imports)] pub use types::{ ChatInfo, FolderInfo, MediaInfo, MessageBuilder, MessageInfo, NetworkState, PhotoDownloadState, diff --git a/src/tdlib/reactions.rs b/src/tdlib/reactions.rs index 9682aa4..39afe86 100644 --- a/src/tdlib/reactions.rs +++ b/src/tdlib/reactions.rs @@ -1,7 +1,7 @@ use crate::types::{ChatId, MessageId}; -use tdlib_rs::enums::ReactionType; +use tdlib_rs::enums::{AvailableReactions, ReactionType}; use tdlib_rs::functions; -use tdlib_rs::types::ReactionTypeEmoji; +use tdlib_rs::types::{AvailableReaction, ReactionTypeEmoji}; /// Менеджер реакций на сообщения. /// @@ -49,11 +49,6 @@ impl ReactionManager { /// * `Ok(Vec)` - Список доступных emoji реакций /// * `Err(String)` - Ошибка получения /// - /// # Note - /// - /// В tdlib-rs 1.8.29 структура AvailableReactions изменилась. - /// Временно возвращается стандартный набор из 12 популярных реакций. - /// /// # Examples /// /// ```ignore @@ -86,54 +81,15 @@ impl ReactionManager { .await; match reactions_result { - Ok(_available) => { - // TODO: В tdlib-rs 1.8.29 структура AvailableReactions изменилась - // Временно используем fallback на стандартные реакции - let emojis: Vec = Vec::new(); - - // let emojis: Vec = if let tdlib_rs::enums::AvailableReactions::AvailableReactions(ar) = available { - // ar.top_reactions.iter().filter_map(...).collect() - // } else { - // Vec::new() - // }; - + Ok(available) => { + let emojis = available_reaction_emojis(&available); if emojis.is_empty() { - // Фолбек на стандартные реакции - Ok(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(), - ]) + Ok(default_reaction_emojis()) } else { Ok(emojis) } } - Err(_) => { - // В случае ошибки возвращаем стандартный набор - Ok(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(), - ]) - } + Err(_) => Ok(default_reaction_emojis()), } } @@ -196,3 +152,79 @@ impl ReactionManager { } } } + +fn default_reaction_emojis() -> Vec { + 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(), + ] +} + +fn available_reaction_emojis(available: &AvailableReactions) -> Vec { + let AvailableReactions::AvailableReactions(available) = available; + + available + .top_reactions + .iter() + .chain(available.recent_reactions.iter()) + .chain(available.popular_reactions.iter()) + .filter_map(reaction_emoji) + .fold(Vec::new(), |mut emojis, emoji| { + if !emojis.contains(&emoji) { + emojis.push(emoji); + } + emojis + }) +} + +fn reaction_emoji(reaction: &AvailableReaction) -> Option { + match &reaction.r#type { + ReactionType::Emoji(emoji) => Some(emoji.emoji.clone()), + ReactionType::CustomEmoji(_) => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tdlib_rs::types::{AvailableReaction, AvailableReactions as AvailableReactionsData}; + + fn emoji_reaction(emoji: &str) -> AvailableReaction { + AvailableReaction { + r#type: ReactionType::Emoji(ReactionTypeEmoji { emoji: emoji.to_string() }), + needs_premium: false, + } + } + + #[test] + fn extracts_unique_emoji_reactions_in_display_order() { + let available = AvailableReactions::AvailableReactions(AvailableReactionsData { + top_reactions: vec![emoji_reaction("👍"), emoji_reaction("🔥")], + recent_reactions: vec![emoji_reaction("🔥"), emoji_reaction("❤️")], + popular_reactions: vec![emoji_reaction("🎉")], + allow_custom_emoji: false, + are_tags: false, + unavailability_reason: None, + }); + + assert_eq!( + available_reaction_emojis(&available), + vec![ + "👍".to_string(), + "🔥".to_string(), + "❤️".to_string(), + "🎉".to_string(), + ] + ); + } +} diff --git a/src/tdlib/trait.rs b/src/tdlib/trait.rs index 96d5287..deb3e58 100644 --- a/src/tdlib/trait.rs +++ b/src/tdlib/trait.rs @@ -1,38 +1,57 @@ //! Trait definition for TdClient to enable dependency injection //! //! This trait allows tests to use FakeTdClient instead of real TDLib client. +#![allow(dead_code)] use crate::tdlib::{AuthState, FolderInfo, MessageInfo, ProfileInfo, UserCache, UserOnlineStatus}; use crate::types::{ChatId, MessageId, UserId}; use async_trait::async_trait; +use std::borrow::Cow; use std::path::PathBuf; use tdlib_rs::enums::{ChatAction, Update}; use super::ChatInfo; -/// Trait for TDLib client operations -/// -/// This trait defines the interface for both real and fake TDLib clients, -/// enabling dependency injection and easier testing. -#[allow(dead_code)] +/// Auth operations. #[async_trait] -pub trait TdClientTrait: Send { - // ============ Auth methods ============ +pub trait AuthClient: Send { async fn send_phone_number(&self, phone: String) -> Result<(), String>; async fn send_code(&self, code: String) -> Result<(), String>; async fn send_password(&self, password: String) -> Result<(), String>; +} - // ============ Chat methods ============ +/// Chat list and profile operations. +#[async_trait] +pub trait ChatClient: Send { async fn load_chats(&mut self, limit: i32) -> Result<(), String>; async fn load_folder_chats(&mut self, folder_id: i32, limit: i32) -> Result<(), String>; async fn leave_chat(&self, chat_id: ChatId) -> Result<(), String>; async fn get_profile_info(&self, chat_id: ChatId) -> Result; - // ============ Chat actions ============ + fn chats(&self) -> &[ChatInfo]; + fn folders(&self) -> &[FolderInfo]; + fn main_chat_list_position(&self) -> i32; + fn set_main_chat_list_position(&mut self, position: i32); + fn update_chats(&mut self, updater: F) + where + F: FnOnce(&mut Vec); + fn update_folders(&mut self, updater: F) + where + F: FnOnce(&mut Vec); +} + +/// Ephemeral chat actions such as typing status. +#[async_trait] +pub trait ChatActionClient: Send { async fn send_chat_action(&self, chat_id: ChatId, action: ChatAction); fn clear_stale_typing_status(&mut self) -> bool; + fn typing_status(&self) -> Option<&(UserId, String, std::time::Instant)>; + fn set_typing_status(&mut self, status: Option<(UserId, String, std::time::Instant)>); +} - // ============ Message methods ============ +/// Message history, search, and mutation operations. +#[async_trait] +pub trait MessageClient: Send { async fn get_chat_history( &mut self, chat_id: ChatId, @@ -82,15 +101,38 @@ pub trait TdClientTrait: Send { async fn set_draft_message(&self, chat_id: ChatId, text: String) -> Result<(), String>; + fn current_chat_messages(&self) -> Cow<'_, [MessageInfo]>; + fn current_chat_id(&self) -> Option; + fn current_pinned_message(&self) -> Option; fn push_message(&mut self, msg: MessageInfo); + fn clear_current_chat_messages(&mut self); + fn set_current_chat_messages(&mut self, messages: Vec); + fn update_current_chat_messages(&mut self, updater: F) + where + F: FnOnce(&mut Vec); + fn set_current_chat_id(&mut self, chat_id: Option); + fn set_current_pinned_message(&mut self, msg: Option); + fn pending_view_messages(&self) -> &[(ChatId, Vec)]; + fn enqueue_pending_view_messages(&mut self, chat_id: ChatId, message_ids: Vec); async fn fetch_missing_reply_info(&mut self); async fn process_pending_view_messages(&mut self); +} - // ============ User methods ============ +/// User cache and user-status operations. +#[async_trait] +pub trait UserClient: Send { fn get_user_status_by_chat_id(&self, chat_id: ChatId) -> Option<&UserOnlineStatus>; + fn pending_user_ids(&self) -> &[UserId]; + fn user_cache(&self) -> &UserCache; + fn update_user_cache(&mut self, updater: F) + where + F: FnOnce(&mut UserCache); async fn process_pending_user_ids(&mut self); +} - // ============ Reaction methods ============ +/// Message reaction operations. +#[async_trait] +pub trait ReactionClient: Send { async fn get_message_available_reactions( &self, chat_id: ChatId, @@ -103,52 +145,78 @@ pub trait TdClientTrait: Send { message_id: MessageId, reaction: String, ) -> Result<(), String>; +} - // ============ File methods ============ +/// File download operations. +#[async_trait] +pub trait FileClient: Send { async fn download_file(&self, file_id: i32) -> Result; async fn download_voice_note(&self, file_id: i32) -> Result; +} - // ============ Getters (immutable) ============ +/// Shared client state that does not belong to one feature area. +#[async_trait] +pub trait ClientState: Send { fn client_id(&self) -> i32; async fn get_me(&self) -> Result; fn auth_state(&self) -> &AuthState; - fn chats(&self) -> &[ChatInfo]; - fn folders(&self) -> &[FolderInfo]; - fn current_chat_messages(&self) -> Vec; - fn current_chat_id(&self) -> Option; - fn current_pinned_message(&self) -> Option; - fn typing_status(&self) -> Option<&(UserId, String, std::time::Instant)>; - fn pending_view_messages(&self) -> &[(ChatId, Vec)]; - fn pending_user_ids(&self) -> &[UserId]; - fn main_chat_list_position(&self) -> i32; - fn user_cache(&self) -> &UserCache; fn network_state(&self) -> super::types::NetworkState; +} - // ============ Setters (mutable) ============ - fn chats_mut(&mut self) -> &mut Vec; - fn folders_mut(&mut self) -> &mut Vec; - fn current_chat_messages_mut(&mut self) -> &mut Vec; - fn clear_current_chat_messages(&mut self); - fn set_current_chat_messages(&mut self, messages: Vec); - fn set_current_chat_id(&mut self, chat_id: Option); - fn set_current_pinned_message(&mut self, msg: Option); - fn set_typing_status(&mut self, status: Option<(UserId, String, std::time::Instant)>); - fn enqueue_pending_view_messages(&mut self, chat_id: ChatId, message_ids: Vec); - fn pending_user_ids_mut(&mut self) -> &mut Vec; - fn set_main_chat_list_position(&mut self, position: i32); - fn user_cache_mut(&mut self) -> &mut UserCache; - - // ============ Notification methods ============ +/// Notification configuration operations. +pub trait NotificationClient: Send { fn configure_notifications(&mut self, config: &crate::config::NotificationsConfig); fn sync_notification_muted_chats(&mut self); +} - // ============ Account switching ============ +/// Account switching operations. +#[async_trait] +pub trait AccountClient: Send { /// Recreates the client with a new database path (for account switching). /// /// For real TdClient: closes old client, creates new one, inits TDLib parameters. /// For FakeTdClient: no-op. async fn recreate_client(&mut self, db_path: PathBuf) -> Result<(), String>; +} - // ============ Update handling ============ +/// TDLib update routing. +pub trait UpdateClient: Send { fn handle_update(&mut self, update: Update); } + +/// Facade trait for TDLib client operations +/// +/// This trait defines the interface for both real and fake TDLib clients, +/// enabling dependency injection and easier testing. +#[allow(dead_code)] +pub trait TdClientTrait: + AuthClient + + ChatClient + + ChatActionClient + + MessageClient + + UserClient + + ReactionClient + + FileClient + + ClientState + + NotificationClient + + AccountClient + + UpdateClient + + Send +{ +} + +impl TdClientTrait for T where + T: AuthClient + + ChatClient + + ChatActionClient + + MessageClient + + UserClient + + ReactionClient + + FileClient + + ClientState + + NotificationClient + + AccountClient + + UpdateClient + + Send +{ +} diff --git a/src/tdlib/update_handlers.rs b/src/tdlib/update_handlers.rs index 1c65e29..5b6ec3c 100644 --- a/src/tdlib/update_handlers.rs +++ b/src/tdlib/update_handlers.rs @@ -54,17 +54,19 @@ pub fn handle_new_message_update(client: &mut TdClient, new_msg: UpdateNewMessag Some(idx) => { // Сообщение уже есть - обновляем if is_incoming { - client.current_chat_messages_mut()[idx] = msg_info; + client.replace_current_chat_message(msg_id, msg_info); } else { // Для исходящих: обновляем can_be_edited и другие поля, // но сохраняем reply_to (добавленный при отправке) - let existing = &mut client.current_chat_messages_mut()[idx]; - existing.state.can_be_edited = msg_info.state.can_be_edited; - existing.state.can_be_deleted_only_for_self = - msg_info.state.can_be_deleted_only_for_self; - existing.state.can_be_deleted_for_all_users = - msg_info.state.can_be_deleted_for_all_users; - existing.state.is_read = msg_info.state.is_read; + client.update_current_chat_messages(|messages| { + let existing = &mut messages[idx]; + existing.state.can_be_edited = msg_info.state.can_be_edited; + existing.state.can_be_deleted_only_for_self = + msg_info.state.can_be_deleted_only_for_self; + existing.state.can_be_deleted_for_all_users = + msg_info.state.can_be_deleted_for_all_users; + existing.state.is_read = msg_info.state.is_read; + }); } } None => { @@ -122,7 +124,7 @@ pub fn handle_chat_position_update(client: &mut TdClient, update: UpdateChatPosi ChatList::Main => { if update.position.order == 0 { // Чат больше не в Main (перемещён в архив и т.д.) - client.chats_mut().retain(|c| c.id != chat_id); + client.remove_chat(chat_id); } else { // Обновляем позицию существующего чата crate::tdlib::chat_helpers::update_chat(client, chat_id, |chat| { @@ -131,7 +133,7 @@ pub fn handle_chat_position_update(client: &mut TdClient, update: UpdateChatPosi }); } // Пересортируем по order - client.chats_mut().sort_by(|a, b| b.order.cmp(&a.order)); + client.sort_chats_by_order(); } ChatList::Folder(folder) => { // Обновляем folder_ids для чата @@ -166,10 +168,10 @@ pub fn handle_user_update(client: &mut TdClient, update: UpdateUser) { // Удаляем чаты с этим пользователем из списка let user_id = user.id; // Clone chat_user_ids to avoid borrow conflict - let chat_user_ids = client.user_cache.chat_user_ids.clone(); - client - .chats_mut() - .retain(|c| chat_user_ids.get(&c.id) != Some(&UserId::new(user_id))); + let chat_user_ids = client.user_cache().chat_user_ids.clone(); + client.update_chats(|chats| { + chats.retain(|c| chat_user_ids.get(&c.id) != Some(&UserId::new(user_id))); + }); return; } @@ -179,10 +181,9 @@ 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.update_user_cache(|cache| { + cache.user_names.insert(UserId::new(user.id), display_name); + }); // Сохраняем username если есть (с упрощённым извлечением через and_then) if let Some(username) = user @@ -190,17 +191,23 @@ pub fn handle_user_update(client: &mut TdClient, update: UpdateUser) { .as_ref() .and_then(|u| u.active_usernames.first()) { - client - .user_cache - .user_usernames - .insert(UserId::new(user.id), username.to_string()); + let affected_chat_ids = client.update_user_cache(|cache| { + cache + .user_usernames + .insert(UserId::new(user.id), username.to_string()); + cache + .chat_user_ids + .iter() + .filter_map(|(&chat_id, &user_id)| { + (user_id == UserId::new(user.id)).then_some(chat_id) + }) + .collect::>() + }); // Обновляем username в чатах, связанных с этим пользователем - for (&chat_id, &user_id) in &client.user_cache.chat_user_ids.clone() { - if user_id == UserId::new(user.id) { - crate::tdlib::chat_helpers::update_chat(client, chat_id, |chat| { - chat.username = Some(format!("@{}", username)); - }); - } + for chat_id in affected_chat_ids { + crate::tdlib::chat_helpers::update_chat(client, chat_id, |chat| { + chat.username = Some(format!("@{}", username)); + }); } } // LRU-кэш автоматически удаляет старые записи при вставке @@ -218,16 +225,8 @@ pub fn handle_message_interaction_info_update( return; } - let Some(msg) = client - .current_chat_messages_mut() - .iter_mut() - .find(|m| m.id() == MessageId::new(update.message_id)) - else { - return; - }; - // Извлекаем реакции из interaction_info - msg.interactions.reactions = update + let reactions = update .interaction_info .as_ref() .and_then(|info| info.reactions.as_ref()) @@ -250,6 +249,9 @@ pub fn handle_message_interaction_info_update( .collect() }) .unwrap_or_default(); + client.update_current_chat_message(MessageId::new(update.message_id), |msg| { + msg.interactions.reactions = reactions; + }); } /// Обрабатывает Update::MessageSendSucceeded - успешная отправка сообщения. @@ -291,7 +293,7 @@ pub fn handle_message_send_succeeded_update( } // Заменяем старое сообщение на новое - client.current_chat_messages_mut()[idx] = new_msg; + client.replace_current_chat_message(old_id, new_msg); } /// Обрабатывает Update::ChatDraftMessage - обновление черновика сообщения в чате. diff --git a/src/ui/components/message_bubble.rs b/src/ui/components/message_bubble.rs index 5e20bfc..251a533 100644 --- a/src/ui/components/message_bubble.rs +++ b/src/ui/components/message_bubble.rs @@ -433,24 +433,20 @@ pub fn render_message_bubble( // Отображаем индикатор воспроизведения голосового if msg.has_voice() { if let Some(voice) = msg.voice_info() { - let is_this_playing = playback_state - .map(|ps| ps.message_id == msg.id()) - .unwrap_or(false); - - let status_line = if is_this_playing { - let ps = playback_state.unwrap(); - let icon = match ps.status { - PlaybackStatus::Playing => "▶", - PlaybackStatus::Paused => "⏸", - PlaybackStatus::Loading => "⏳", - _ => "⏹", + let status_line = + if let Some(ps) = playback_state.filter(|ps| ps.message_id == msg.id()) { + let icon = match ps.status { + PlaybackStatus::Playing => "▶", + PlaybackStatus::Paused => "⏸", + PlaybackStatus::Loading => "⏳", + _ => "⏹", + }; + let bar = render_progress_bar(ps.position, ps.duration, 20); + 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) }; - let bar = render_progress_bar(ps.position, ps.duration, 20); - 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) - }; let status_len = status_line.chars().count(); if msg.is_outgoing() { @@ -670,7 +666,9 @@ pub fn render_album_bubble( }; // Timestamp из последнего сообщения - let last_msg = messages.last().unwrap(); + let Some(last_msg) = messages.last() else { + return (lines, deferred); + }; let time = format_timestamp(last_msg.date()); if !captions.is_empty() { diff --git a/src/ui/messages.rs b/src/ui/messages.rs index 0d094bc..fc27588 100644 --- a/src/ui/messages.rs +++ b/src/ui/messages.rs @@ -1,408 +1,33 @@ //! Chat message area rendering. -//! -//! 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}; +mod header; +mod list; +mod pinned; + +use crate::app::methods::{modal::ModalMethods, search::SearchMethods}; use crate::app::App; -use crate::message_grouping::{group_messages, MessageGroup}; use crate::tdlib::TdClientTrait; -use crate::ui::components; use crate::ui::{compose_bar, modals}; use ratatui::{ layout::{Alignment, Constraint, Direction, Layout, Rect}, - style::{Color, Modifier, Style}, - text::{Line, Span}, + style::{Color, Style}, widgets::{Block, Borders, Paragraph}, Frame, }; -/// Рендерит заголовок чата с typing status -fn render_chat_header( - f: &mut Frame, - area: Rect, - app: &App, - chat: &crate::tdlib::ChatInfo, -) { - let typing_action = app - .td_client - .typing_status() - .as_ref() - .map(|(_, action, _)| action.clone()); +use header::render_chat_header; +use list::render_message_list; +use pinned::render_pinned_bar; - let header_line = if let Some(action) = typing_action { - // Показываем typing status: "👤 Имя @username печатает..." - let mut spans = vec![Span::styled( - format!("👤 {}", chat.title), - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD), - )]; - if let Some(username) = &chat.username { - spans.push(Span::styled(format!(" {}", username), Style::default().fg(Color::Gray))); - } - spans.push(Span::styled( - format!(" {}", action), - Style::default() - .fg(Color::Yellow) - .add_modifier(Modifier::ITALIC), - )); - Line::from(spans) - } else { - // Показываем username - let header_text = match &chat.username { - Some(username) => format!("👤 {} {}", chat.title, username), - None => format!("👤 {}", chat.title), - }; - Line::from(Span::styled( - header_text, - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD), - )) - }; - - let header = Paragraph::new(header_line).block(Block::default().borders(Borders::ALL)); - f.render_widget(header, area); -} - -/// Рендерит pinned bar с закреплённым сообщением -fn render_pinned_bar(f: &mut Frame, area: Rect, app: &App) { - let Some(pinned_msg) = app.td_client.current_pinned_message() else { - return; - }; - - let pinned_preview: String = pinned_msg.text().chars().take(40).collect(); - let ellipsis = if pinned_msg.text().chars().count() > 40 { - "..." - } else { - "" - }; - let pinned_datetime = crate::utils::format_datetime(pinned_msg.date()); - let pinned_text = format!("📌 {} {}{}", pinned_datetime, pinned_preview, ellipsis); - let pinned_hint = "Ctrl+P"; - - let pinned_bar_width = area.width as usize; - let text_len = pinned_text.chars().count(); - let hint_len = pinned_hint.chars().count(); - let padding = pinned_bar_width.saturating_sub(text_len + hint_len + 2); - - let pinned_line = Line::from(vec![ - Span::styled(pinned_text, Style::default().fg(Color::Magenta)), - 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))); - f.render_widget(pinned_bar, area); -} - -/// Информация о строке после переноса: текст и позиция в оригинале -pub(super) struct WrappedLine { - pub text: String, -} - -/// Разбивает текст на строки с учётом максимальной ширины -/// (используется только для search/pinned режимов, основной рендеринг через message_bubble) -pub(super) fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec { - if max_width == 0 { - return vec![WrappedLine { text: text.to_string() }]; - } - - let mut result = Vec::new(); - let mut current_line = String::new(); - let mut current_width = 0; - - let chars: Vec = text.chars().collect(); - let mut word_start = 0; - let mut in_word = false; - - for (i, ch) in chars.iter().enumerate() { - if ch.is_whitespace() { - if in_word { - let word: String = chars[word_start..i].iter().collect(); - let word_width = word.chars().count(); - - if current_width == 0 { - current_line = word; - current_width = word_width; - } else if current_width + 1 + word_width <= max_width { - current_line.push(' '); - current_line.push_str(&word); - current_width += 1 + word_width; - } else { - result.push(WrappedLine { text: current_line }); - current_line = word; - current_width = word_width; - } - in_word = false; - } - } else if !in_word { - word_start = i; - in_word = true; - } - } - - if in_word { - let word: String = chars[word_start..].iter().collect(); - let word_width = word.chars().count(); - - if current_width == 0 { - current_line = word; - } else if current_width + 1 + word_width <= max_width { - current_line.push(' '); - current_line.push_str(&word); - } else { - result.push(WrappedLine { text: current_line }); - current_line = word; - } - } - - if !current_line.is_empty() { - result.push(WrappedLine { text: current_line }); - } - - if result.is_empty() { - result.push(WrappedLine { text: String::new() }); - } - - result -} -/// Рендерит список сообщений с группировкой по дате/отправителю и автоскроллом -fn render_message_list(f: &mut Frame, area: Rect, app: &mut App) { - let content_width = area.width.saturating_sub(2) as usize; - - // Messages с группировкой по дате и отправителю - let mut lines: Vec = Vec::new(); - - // ID выбранного сообщения для подсветки - let selected_msg_id = app.get_selected_message().map(|m| m.id()); - // Номер строки, где начинается выбранное сообщение (для автоскролла) - let mut selected_msg_line: Option = None; - - // ОПТИМИЗАЦИЯ: Убрали массовый preloading всех изображений. - // Теперь загружаем только видимые изображения во втором проходе (см. ниже). - - // Собираем информацию о развёрнутых изображениях (для второго прохода) - #[cfg(feature = "images")] - let mut deferred_images: Vec = Vec::new(); - - // Используем message_grouping для группировки сообщений - let current_messages = app.td_client.current_chat_messages(); - let grouped = group_messages(¤t_messages); - let mut is_first_date = true; - let mut is_first_sender = true; - - for group in grouped { - match group { - MessageGroup::DateSeparator(date) => { - // Рендерим разделитель даты - lines.extend(components::render_date_separator( - date, - content_width, - is_first_date, - )); - is_first_date = false; - is_first_sender = true; // Сбрасываем счётчик заголовков после даты - } - MessageGroup::SenderHeader { is_outgoing, sender_name } => { - // Рендерим заголовок отправителя - lines.extend(components::render_sender_header( - is_outgoing, - &sender_name, - content_width, - is_first_sender, - )); - is_first_sender = false; - } - MessageGroup::Message(msg) => { - // Запоминаем строку начала выбранного сообщения - let is_selected = selected_msg_id == Some(msg.id()); - if is_selected { - selected_msg_line = Some(lines.len()); - } - - // Рендерим сообщение - let bubble_lines = components::render_message_bubble( - msg, - app.config(), - content_width, - selected_msg_id, - app.playback_state.as_ref(), - ); - - // Собираем 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, - ); - let img_width = inline_width as u16; - let bubble_len = bubble_lines.len(); - let placeholder_start = lines.len() + bubble_len - img_height as usize; - - deferred_images.push(components::DeferredImageRender { - message_id: msg.id(), - photo_path: path.clone(), - line_offset: placeholder_start, - x_offset: 0, - width: img_width, - height: img_height, - }); - } - } - - lines.extend(bubble_lines); - } - MessageGroup::Album(album_messages) => { - #[cfg(feature = "images")] - { - let is_selected = album_messages - .iter() - .any(|m| selected_msg_id == Some(m.id())); - if is_selected { - selected_msg_line = Some(lines.len()); - } - - let (bubble_lines, album_deferred) = components::render_album_bubble( - &album_messages, - app.config(), - content_width, - selected_msg_id, - ); - - for mut d in album_deferred { - d.line_offset += lines.len(); - deferred_images.push(d); - } - - lines.extend(bubble_lines); - } - #[cfg(not(feature = "images"))] - { - // Fallback: рендерим каждое сообщение отдельно - for msg in &album_messages { - let is_selected = selected_msg_id == Some(msg.id()); - if is_selected { - selected_msg_line = Some(lines.len()); - } - lines.extend(components::render_message_bubble( - msg, - app.config(), - content_width, - selected_msg_id, - app.playback_state.as_ref(), - )); - } - } - } - } - } - - if lines.is_empty() { - lines.push(Line::from(Span::styled("Нет сообщений", Style::default().fg(Color::Gray)))); - } - - // Вычисляем скролл с учётом пользовательского offset - let visible_height = area.height.saturating_sub(2) as usize; - let total_lines = lines.len(); - - // Базовый скролл (показываем последние сообщения) - let base_scroll = total_lines.saturating_sub(visible_height); - - // Если выбрано сообщение, автоскроллим к нему - let scroll_offset = if app.is_selecting_message() { - if let Some(selected_line) = selected_msg_line { - // Вычисляем нужный скролл, чтобы выбранное сообщение было видно - if selected_line < visible_height / 2 { - // Сообщение в начале — скроллим к началу - 0 - } else if selected_line > total_lines.saturating_sub(visible_height / 2) { - // Сообщение в конце — скроллим к концу - base_scroll - } else { - // Центрируем выбранное сообщение - selected_line.saturating_sub(visible_height / 2) - } - } else { - base_scroll.saturating_sub(app.message_scroll_offset) - } - } else { - base_scroll.saturating_sub(app.message_scroll_offset) - } as u16; - - let messages_widget = Paragraph::new(lines) - .block(Block::default().borders(Borders::ALL)) - .scroll((scroll_offset, 0)); - f.render_widget(messages_widget, area); - - // Второй проход: рендерим изображения поверх placeholder-ов - #[cfg(feature = "images")] - { - use ratatui_image::StatefulImage; - - // THROTTLING: Рендерим изображения максимум 15 FPS (каждые 66ms) - let should_render_images = app - .last_image_render_time - .map(|t| t.elapsed() > std::time::Duration::from_millis(66)) - .unwrap_or(true); - - if !deferred_images.is_empty() && should_render_images { - let content_x = area.x + 1; - let content_y = area.y + 1; - - for d in &deferred_images { - let y_in_content = d.line_offset as i32 - scroll_offset as i32; - - // Пропускаем изображения, которые полностью за пределами видимости - if y_in_content < 0 || y_in_content as usize >= visible_height { - continue; - } - - let img_y = content_y + y_in_content as u16; - let remaining_height = (content_y + visible_height as u16).saturating_sub(img_y); - - // ВАЖНО: Не рендерим частично видимые изображения (убирает сжатие и мигание) - if d.height > remaining_height { - continue; - } - - // Рендерим с ПОЛНОЙ высотой (не сжимаем) - let img_rect = Rect::new(content_x + d.x_offset, img_y, d.width, d.height); - - // ОПТИМИЗАЦИЯ: Загружаем только видимые изображения (не все сразу) - // Используем inline_renderer с Halfblocks для скорости - if let Some(renderer) = &mut app.inline_image_renderer { - // Загружаем только если видимо (early return если уже в кеше) - let _ = renderer.load_image(d.message_id, &d.photo_path); - - if let Some(protocol) = renderer.get_protocol(&d.message_id) { - f.render_stateful_widget(StatefulImage::default(), img_rect, protocol); - } - } - } - - // Обновляем время последнего рендеринга (для throttling) - app.last_image_render_time = Some(std::time::Instant::now()); - } - } -} +pub(crate) use list::wrap_text_with_offsets; pub fn render(f: &mut Frame, area: Rect, app: &mut App) { - // Модальное окно просмотра изображения (приоритет выше всех) #[cfg(feature = "images")] if let Some(modal_state) = app.image_modal.clone() { modals::render_image_viewer(f, app, &modal_state); return; } - // Режим профиля if app.is_profile_mode() { if let Some(profile) = app.get_profile_info() { crate::ui::profile::render(f, area, app, profile); @@ -410,65 +35,52 @@ pub fn render(f: &mut Frame, area: Rect, app: &mut App) { return; } - // Режим поиска по сообщениям if app.is_message_search_mode() { modals::render_search(f, area, app); return; } - // Режим просмотра закреплённых сообщений if app.is_pinned_mode() { modals::render_pinned(f, area, app); return; } if let Some(chat) = app.get_selected_chat().cloned() { - // Вычисляем динамическую высоту инпута на основе длины текста - let input_width = area.width.saturating_sub(4) as usize; // -2 для рамок, -2 для "> " + let input_width = area.width.saturating_sub(4) as usize; let input_lines: u16 = if input_width > 0 { - let len = app.message_input.chars().count() + 2; // +2 для "> " + let len = app.message_input.chars().count() + 2; ((len as f32 / input_width as f32).ceil() as u16).max(1) } else { 1 }; - // Минимум 3 строки (1 контент + 2 рамки), максимум 10 let input_height = (input_lines + 2).clamp(3, 10); - // Проверяем, есть ли закреплённое сообщение let has_pinned = app.td_client.current_pinned_message().is_some(); - let message_chunks = if has_pinned { Layout::default() .direction(Direction::Vertical) .constraints([ - Constraint::Length(3), // Chat header - Constraint::Length(1), // Pinned bar - Constraint::Min(0), // Messages - Constraint::Length(input_height), // Input box (динамическая высота) + Constraint::Length(3), + Constraint::Length(1), + Constraint::Min(0), + Constraint::Length(input_height), ]) .split(area) } else { Layout::default() .direction(Direction::Vertical) .constraints([ - Constraint::Length(3), // Chat header - Constraint::Length(0), // Pinned bar (hidden) - Constraint::Min(0), // Messages - Constraint::Length(input_height), // Input box (динамическая высота) + Constraint::Length(3), + Constraint::Length(0), + Constraint::Min(0), + Constraint::Length(input_height), ]) .split(area) }; - // Chat header с typing status render_chat_header(f, message_chunks[0], app, &chat); - - // Pinned bar (если есть закреплённое сообщение) render_pinned_bar(f, message_chunks[1], app); - - // Messages с группировкой по дате и отправителю render_message_list(f, message_chunks[2], app); - - // Input box с wrap для длинного текста и блочным курсором compose_bar::render(f, message_chunks[3], app); } else { let empty = Paragraph::new("Выберите чат") @@ -478,12 +90,10 @@ pub fn render(f: &mut Frame, area: Rect, app: &mut App) { f.render_widget(empty, area); } - // Модалка подтверждения удаления if app.is_confirm_delete_shown() { modals::render_delete_confirm(f, area); } - // Модалка выбора реакции if let crate::app::ChatState::ReactionPicker { available_reactions, selected_index, .. } = &app.chat_state { diff --git a/src/ui/messages/header.rs b/src/ui/messages/header.rs new file mode 100644 index 0000000..6b8d221 --- /dev/null +++ b/src/ui/messages/header.rs @@ -0,0 +1,55 @@ +use crate::app::App; +use crate::tdlib::{ChatInfo, TdClientTrait}; +use ratatui::{ + layout::Rect, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph}, + Frame, +}; + +pub(super) fn render_chat_header( + f: &mut Frame, + area: Rect, + app: &App, + chat: &ChatInfo, +) { + let typing_action = app + .td_client + .typing_status() + .as_ref() + .map(|(_, action, _)| action.clone()); + + let header_line = if let Some(action) = typing_action { + let mut spans = vec![Span::styled( + format!("👤 {}", chat.title), + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + )]; + if let Some(username) = &chat.username { + spans.push(Span::styled(format!(" {}", username), Style::default().fg(Color::Gray))); + } + spans.push(Span::styled( + format!(" {}", action), + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::ITALIC), + )); + Line::from(spans) + } else { + let header_text = match &chat.username { + Some(username) => format!("👤 {} {}", chat.title, username), + None => format!("👤 {}", chat.title), + }; + Line::from(Span::styled( + header_text, + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + )) + }; + + let header = Paragraph::new(header_line).block(Block::default().borders(Borders::ALL)); + f.render_widget(header, area); +} diff --git a/src/ui/messages/list.rs b/src/ui/messages/list.rs new file mode 100644 index 0000000..0223c83 --- /dev/null +++ b/src/ui/messages/list.rs @@ -0,0 +1,286 @@ +use crate::app::methods::messages::MessageMethods; +use crate::app::App; +use crate::message_grouping::{group_messages, MessageGroup}; +use crate::tdlib::TdClientTrait; +use crate::ui::components; +use ratatui::{ + layout::Rect, + style::{Color, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph}, + Frame, +}; + +/// Информация о строке после переноса: текст и позиция в оригинале. +pub(crate) struct WrappedLine { + pub text: String, +} + +/// Разбивает текст на строки с учётом максимальной ширины. +pub(crate) fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec { + if max_width == 0 { + return vec![WrappedLine { text: text.to_string() }]; + } + + let mut result = Vec::new(); + let mut current_line = String::new(); + let mut current_width = 0; + + let chars: Vec = text.chars().collect(); + let mut word_start = 0; + let mut in_word = false; + + for (i, ch) in chars.iter().enumerate() { + if ch.is_whitespace() { + if in_word { + let word: String = chars[word_start..i].iter().collect(); + let word_width = word.chars().count(); + + if current_width == 0 { + current_line = word; + current_width = word_width; + } else if current_width + 1 + word_width <= max_width { + current_line.push(' '); + current_line.push_str(&word); + current_width += 1 + word_width; + } else { + result.push(WrappedLine { text: current_line }); + current_line = word; + current_width = word_width; + } + in_word = false; + } + } else if !in_word { + word_start = i; + in_word = true; + } + } + + if in_word { + let word: String = chars[word_start..].iter().collect(); + let word_width = word.chars().count(); + + if current_width == 0 { + current_line = word; + } else if current_width + 1 + word_width <= max_width { + current_line.push(' '); + current_line.push_str(&word); + } else { + result.push(WrappedLine { text: current_line }); + current_line = word; + } + } + + if !current_line.is_empty() { + result.push(WrappedLine { text: current_line }); + } + + if result.is_empty() { + result.push(WrappedLine { text: String::new() }); + } + + result +} + +/// Рендерит список сообщений с группировкой по дате/отправителю и автоскроллом. +pub(super) fn render_message_list(f: &mut Frame, area: Rect, app: &mut App) { + let content_width = area.width.saturating_sub(2) as usize; + let mut lines: Vec = Vec::new(); + + let selected_msg_id = app.get_selected_message().map(|m| m.id()); + let mut selected_msg_line: Option = None; + + #[cfg(feature = "images")] + let mut deferred_images: Vec = Vec::new(); + + let current_messages = app.td_client.current_chat_messages(); + let grouped = group_messages(¤t_messages); + let mut is_first_date = true; + let mut is_first_sender = true; + + for group in grouped { + match group { + MessageGroup::DateSeparator(date) => { + lines.extend(components::render_date_separator(date, content_width, is_first_date)); + is_first_date = false; + is_first_sender = true; + } + MessageGroup::SenderHeader { is_outgoing, sender_name } => { + lines.extend(components::render_sender_header( + is_outgoing, + &sender_name, + content_width, + is_first_sender, + )); + is_first_sender = false; + } + MessageGroup::Message(msg) => { + let is_selected = selected_msg_id == Some(msg.id()); + if is_selected { + selected_msg_line = Some(lines.len()); + } + + let bubble_lines = components::render_message_bubble( + msg, + app.config(), + content_width, + selected_msg_id, + app.playback_state.as_ref(), + ); + + #[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, + ); + let img_width = inline_width as u16; + let bubble_len = bubble_lines.len(); + let placeholder_start = lines.len() + bubble_len - img_height as usize; + + deferred_images.push(components::DeferredImageRender { + message_id: msg.id(), + photo_path: path.clone(), + line_offset: placeholder_start, + x_offset: 0, + width: img_width, + height: img_height, + }); + } + } + + lines.extend(bubble_lines); + } + MessageGroup::Album(album_messages) => { + #[cfg(feature = "images")] + { + let is_selected = album_messages + .iter() + .any(|m| selected_msg_id == Some(m.id())); + if is_selected { + selected_msg_line = Some(lines.len()); + } + + let (bubble_lines, album_deferred) = components::render_album_bubble( + &album_messages, + app.config(), + content_width, + selected_msg_id, + ); + + for mut d in album_deferred { + d.line_offset += lines.len(); + deferred_images.push(d); + } + + lines.extend(bubble_lines); + } + #[cfg(not(feature = "images"))] + { + for msg in &album_messages { + let is_selected = selected_msg_id == Some(msg.id()); + if is_selected { + selected_msg_line = Some(lines.len()); + } + lines.extend(components::render_message_bubble( + msg, + app.config(), + content_width, + selected_msg_id, + app.playback_state.as_ref(), + )); + } + } + } + } + } + + if lines.is_empty() { + lines.push(Line::from(Span::styled("Нет сообщений", Style::default().fg(Color::Gray)))); + } + + let visible_height = area.height.saturating_sub(2) as usize; + let total_lines = lines.len(); + let base_scroll = total_lines.saturating_sub(visible_height); + + let scroll_offset = if app.is_selecting_message() { + if let Some(selected_line) = selected_msg_line { + if selected_line < visible_height / 2 { + 0 + } else if selected_line > total_lines.saturating_sub(visible_height / 2) { + base_scroll + } else { + selected_line.saturating_sub(visible_height / 2) + } + } else { + base_scroll.saturating_sub(app.message_scroll_offset) + } + } else { + base_scroll.saturating_sub(app.message_scroll_offset) + } as u16; + + let messages_widget = Paragraph::new(lines) + .block(Block::default().borders(Borders::ALL)) + .scroll((scroll_offset, 0)); + f.render_widget(messages_widget, area); + + #[cfg(feature = "images")] + render_deferred_images(f, area, app, &deferred_images, visible_height, scroll_offset); +} + +#[cfg(feature = "images")] +fn render_deferred_images( + f: &mut Frame, + area: Rect, + app: &mut App, + deferred_images: &[components::DeferredImageRender], + visible_height: usize, + scroll_offset: u16, +) { + use ratatui_image::StatefulImage; + + let should_render_images = app + .last_image_render_time + .map(|t| t.elapsed() > std::time::Duration::from_millis(66)) + .unwrap_or(true); + + if deferred_images.is_empty() || !should_render_images { + return; + } + + let content_x = area.x + 1; + let content_y = area.y + 1; + + for d in deferred_images { + let y_in_content = d.line_offset as i32 - scroll_offset as i32; + + if y_in_content < 0 || y_in_content as usize >= visible_height { + continue; + } + + let img_y = content_y + y_in_content as u16; + let remaining_height = (content_y + visible_height as u16).saturating_sub(img_y); + + if d.height > remaining_height { + continue; + } + + let img_rect = Rect::new(content_x + d.x_offset, img_y, d.width, d.height); + + if let Some(renderer) = &mut app.inline_image_renderer { + let _ = renderer.load_image(d.message_id, &d.photo_path); + + if let Some(protocol) = renderer.get_protocol(&d.message_id) { + f.render_stateful_widget(StatefulImage::default(), img_rect, protocol); + } + } + } + + app.last_image_render_time = Some(std::time::Instant::now()); +} diff --git a/src/ui/messages/pinned.rs b/src/ui/messages/pinned.rs new file mode 100644 index 0000000..5947a80 --- /dev/null +++ b/src/ui/messages/pinned.rs @@ -0,0 +1,38 @@ +use crate::app::App; +use crate::tdlib::TdClientTrait; +use ratatui::{ + layout::Rect, + style::{Color, Style}, + text::{Line, Span}, + widgets::Paragraph, + Frame, +}; + +pub(super) fn render_pinned_bar(f: &mut Frame, area: Rect, app: &App) { + let Some(pinned_msg) = app.td_client.current_pinned_message() else { + return; + }; + + let pinned_preview: String = pinned_msg.text().chars().take(40).collect(); + let ellipsis = if pinned_msg.text().chars().count() > 40 { + "..." + } else { + "" + }; + let pinned_datetime = crate::utils::format_datetime(pinned_msg.date()); + let pinned_text = format!("📌 {} {}{}", pinned_datetime, pinned_preview, ellipsis); + let pinned_hint = "Ctrl+P"; + + let pinned_bar_width = area.width as usize; + let text_len = pinned_text.chars().count(); + let hint_len = pinned_hint.chars().count(); + let padding = pinned_bar_width.saturating_sub(text_len + hint_len + 2); + + let pinned_line = Line::from(vec![ + Span::styled(pinned_text, Style::default().fg(Color::Magenta)), + 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))); + f.render_widget(pinned_bar, area); +} diff --git a/src/ui/modals/pinned.rs b/src/ui/modals/pinned.rs index 081bbc1..6caac5e 100644 --- a/src/ui/modals/pinned.rs +++ b/src/ui/modals/pinned.rs @@ -56,12 +56,7 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) { if idx > 0 { lines.push(Line::from("")); } - lines.extend(render_message_item( - msg, - idx == selected_index, - content_width, - 3, - )); + lines.extend(render_message_item(msg, idx == selected_index, content_width, 3)); } if lines.is_empty() { diff --git a/src/ui/modals/search.rs b/src/ui/modals/search.rs index f01cf8f..e82bc4e 100644 --- a/src/ui/modals/search.rs +++ b/src/ui/modals/search.rs @@ -80,12 +80,7 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) { if idx > 0 { lines.push(Line::from("")); } - lines.extend(render_message_item( - msg, - idx == selected_index, - content_width, - 2, - )); + lines.extend(render_message_item(msg, idx == selected_index, content_width, 2)); } } diff --git a/src/utils/formatting.rs b/src/utils/formatting.rs index 2dfdb86..1cb5088 100644 --- a/src/utils/formatting.rs +++ b/src/utils/formatting.rs @@ -1,60 +1,145 @@ +#[cfg(test)] +use chrono::FixedOffset; use chrono::{DateTime, Local, NaiveDate, Utc}; +use std::time::{SystemTime, UNIX_EPOCH}; -fn as_local_datetime(timestamp: i32) -> Option> { - DateTime::::from_timestamp(timestamp as i64, 0).map(|dt| dt.with_timezone(&Local)) +pub trait LocalTimeSource { + fn now_date(&self) -> NaiveDate; + fn now_timestamp(&self) -> i32; + fn format_timestamp(&self, timestamp: i32, format: &str) -> Option; + fn date_for_timestamp(&self, timestamp: i32) -> Option; +} + +pub struct SystemLocalTime; + +impl LocalTimeSource for SystemLocalTime { + fn now_date(&self) -> NaiveDate { + Local::now().date_naive() + } + + fn now_timestamp(&self) -> i32 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i32 + } + + fn format_timestamp(&self, timestamp: i32, format: &str) -> Option { + DateTime::::from_timestamp(timestamp as i64, 0) + .map(|dt| dt.with_timezone(&Local).format(format).to_string()) + } + + fn date_for_timestamp(&self, timestamp: i32) -> Option { + DateTime::::from_timestamp(timestamp as i64, 0) + .map(|dt| dt.with_timezone(&Local).date_naive()) + } +} + +#[derive(Debug, Clone)] +#[cfg(test)] +pub struct FixedLocalTime { + offset: FixedOffset, + now: DateTime, +} + +#[cfg(test)] +impl FixedLocalTime { + fn new(offset: FixedOffset, now_timestamp: i32) -> Self { + let now = DateTime::::from_timestamp(now_timestamp as i64, 0) + .expect("valid fixed timestamp") + .with_timezone(&offset); + Self { offset, now } + } +} + +#[cfg(test)] +impl LocalTimeSource for FixedLocalTime { + fn now_date(&self) -> NaiveDate { + self.now.date_naive() + } + + fn now_timestamp(&self) -> i32 { + self.now.timestamp() as i32 + } + + fn format_timestamp(&self, timestamp: i32, format: &str) -> Option { + DateTime::::from_timestamp(timestamp as i64, 0) + .map(|dt| dt.with_timezone(&self.offset).format(format).to_string()) + } + + fn date_for_timestamp(&self, timestamp: i32) -> Option { + DateTime::::from_timestamp(timestamp as i64, 0) + .map(|dt| dt.with_timezone(&self.offset).date_naive()) + } +} + +fn system_time() -> SystemLocalTime { + SystemLocalTime } /// Форматирование timestamp во время HH:MM в системной таймзоне. pub fn format_timestamp(timestamp: i32) -> String { - as_local_datetime(timestamp) - .map(|dt| dt.format("%H:%M").to_string()) + format_timestamp_with(timestamp, &system_time()) +} + +pub fn format_timestamp_with(timestamp: i32, time: &impl LocalTimeSource) -> String { + time.format_timestamp(timestamp, "%H:%M") .unwrap_or_else(|| "00:00".to_string()) } /// Форматирование timestamp в дату для разделителя. pub fn format_date(timestamp: i32) -> String { - let Some(msg_dt) = as_local_datetime(timestamp) else { + format_date_with(timestamp, &system_time()) +} + +pub fn format_date_with(timestamp: i32, time: &impl LocalTimeSource) -> String { + let Some(msg_day) = time.date_for_timestamp(timestamp) else { return "01.01.1970".to_string(); }; - let msg_day = msg_dt.date_naive(); - let today = Local::now().date_naive(); + let today = time.now_date(); if msg_day == today { "Сегодня".to_string() } else if Some(msg_day) == today.pred_opt() { "Вчера".to_string() } else { - msg_dt.format("%d.%m.%Y").to_string() + time.format_timestamp(timestamp, "%d.%m.%Y") + .unwrap_or_else(|| "01.01.1970".to_string()) } } /// Получить день из timestamp для группировки. /// Возвращает число дней с 1970-01-01 в системной таймзоне. pub fn get_day(timestamp: i32) -> i64 { + get_day_with(timestamp, &system_time()) +} + +pub fn get_day_with(timestamp: i32, time: &impl LocalTimeSource) -> i64 { let epoch = NaiveDate::from_ymd_opt(1970, 1, 1).expect("valid epoch date"); - as_local_datetime(timestamp) - .map(|dt| dt.date_naive().signed_duration_since(epoch).num_days()) + time.date_for_timestamp(timestamp) + .map(|date| date.signed_duration_since(epoch).num_days()) .unwrap_or(0) } /// Форматирование timestamp в полную дату и время (DD.MM.YYYY HH:MM) в системной таймзоне. pub fn format_datetime(timestamp: i32) -> String { - as_local_datetime(timestamp) - .map(|dt| dt.format("%d.%m.%Y %H:%M").to_string()) + format_datetime_with(timestamp, &system_time()) +} + +pub fn format_datetime_with(timestamp: i32, time: &impl LocalTimeSource) -> String { + time.format_timestamp(timestamp, "%d.%m.%Y %H:%M") .unwrap_or_else(|| "01.01.1970 00:00".to_string()) } /// Форматирование "был(а) онлайн" из timestamp pub fn format_was_online(timestamp: i32) -> String { - use std::time::{SystemTime, UNIX_EPOCH}; - - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_secs() as i32; + format_was_online_with(timestamp, &system_time()) +} +pub fn format_was_online_with(timestamp: i32, time: &impl LocalTimeSource) -> String { + let now = time.now_timestamp(); let diff = now - timestamp; if diff < 60 { @@ -67,8 +152,8 @@ pub fn format_was_online(timestamp: i32) -> String { format!("был(а) {} ч. назад", hours) } else { // Показываем локальную дату - let datetime = as_local_datetime(timestamp) - .map(|dt| dt.format("%d.%m %H:%M").to_string()) + let datetime = time + .format_timestamp(timestamp, "%d.%m %H:%M") .unwrap_or_else(|| "давно".to_string()); format!("был(а) {}", datetime) } @@ -78,83 +163,69 @@ pub fn format_was_online(timestamp: i32) -> String { mod tests { use super::*; + fn fixed_time() -> FixedLocalTime { + FixedLocalTime::new( + FixedOffset::east_opt(3 * 3600).unwrap(), + 1_640_448_000, // 25.12.2021 03:00:00 +03:00 + ) + } + #[test] - fn test_format_timestamp_matches_local_timezone() { + fn test_format_timestamp_uses_supplied_timezone() { let timestamp = 1640000000; - let expected = as_local_datetime(timestamp) - .unwrap() - .format("%H:%M") - .to_string(); - assert_eq!(format_timestamp(timestamp), expected); + assert_eq!(format_timestamp_with(timestamp, &fixed_time()), "14:33"); } #[test] fn test_get_day() { - assert_eq!(get_day(0), 0); - assert_eq!(get_day(86400), 1); + let time = FixedLocalTime::new(FixedOffset::east_opt(0).unwrap(), 3 * 86_400); + assert_eq!(get_day_with(0, &time), 0); + assert_eq!(get_day_with(86400, &time), 1); } #[test] fn test_get_day_grouping() { + let time = fixed_time(); let msg1 = 1640000000; let msg2 = msg1 + 3600; - assert_eq!(get_day(msg1), get_day(msg2)); + assert_eq!(get_day_with(msg1, &time), get_day_with(msg2, &time)); let msg3 = msg1 + 172800; - assert_ne!(get_day(msg1), get_day(msg3)); + assert_ne!(get_day_with(msg1, &time), get_day_with(msg3, &time)); } #[test] fn test_format_datetime() { let timestamp = 1640000000; - let result = format_datetime(timestamp); - - assert_eq!(result.chars().filter(|&c| c == '.').count(), 2); - assert!(result.contains(":")); + assert_eq!(format_datetime_with(timestamp, &fixed_time()), "20.12.2021 14:33"); } #[test] fn test_format_date_today() { - use std::time::{SystemTime, UNIX_EPOCH}; - - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_secs() as i32; - - let result = format_date(now); + let time = fixed_time(); + let result = format_date_with(time.now_timestamp(), &time); assert_eq!(result, "Сегодня"); } #[test] fn test_format_date_yesterday() { - use std::time::{SystemTime, UNIX_EPOCH}; - - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_secs() as i32; - - let yesterday = now - 86400; - let result = format_date(yesterday); + let time = fixed_time(); + let yesterday = time.now_timestamp() - 86400; + let result = format_date_with(yesterday, &time); assert_eq!(result, "Вчера"); } #[test] fn test_format_date_old() { let old_timestamp = 1640000000; - let result = format_date(old_timestamp); - - assert!(result.contains('.'), "Expected date format with dots"); - assert_ne!(result, "Сегодня"); - assert_ne!(result, "Вчера"); - assert_eq!(result.split('.').count(), 3); + assert_eq!(format_date_with(old_timestamp, &fixed_time()), "20.12.2021"); } #[test] fn test_format_date_epoch() { let epoch = 0; - let result = format_date(epoch); + let time = FixedLocalTime::new(FixedOffset::east_opt(0).unwrap(), 3 * 86_400); + let result = format_date_with(epoch, &time); assert!(result.contains('.')); assert!(result.contains("1970")); @@ -162,57 +233,37 @@ mod tests { #[test] fn test_format_was_online_just_now() { - use std::time::{SystemTime, UNIX_EPOCH}; - - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_secs() as i32; - + let time = fixed_time(); + let now = time.now_timestamp(); let recent = now - 30; - let result = format_was_online(recent); + let result = format_was_online_with(recent, &time); assert_eq!(result, "был(а) только что"); } #[test] fn test_format_was_online_minutes_ago() { - use std::time::{SystemTime, UNIX_EPOCH}; - - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_secs() as i32; - + let time = fixed_time(); + let now = time.now_timestamp(); let mins_ago = now - (15 * 60); - let result = format_was_online(mins_ago); + let result = format_was_online_with(mins_ago, &time); assert_eq!(result, "был(а) 15 мин. назад"); } #[test] fn test_format_was_online_hours_ago() { - use std::time::{SystemTime, UNIX_EPOCH}; - - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_secs() as i32; - + let time = fixed_time(); + let now = time.now_timestamp(); let hours_ago = now - (5 * 3600); - let result = format_was_online(hours_ago); + let result = format_was_online_with(hours_ago, &time); assert_eq!(result, "был(а) 5 ч. назад"); } #[test] fn test_format_was_online_days_ago() { - use std::time::{SystemTime, UNIX_EPOCH}; - - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_secs() as i32; - + let time = fixed_time(); + let now = time.now_timestamp(); let days_ago = now - (3 * 86400); - let result = format_was_online(days_ago); + let result = format_was_online_with(days_ago, &time); assert!(result.starts_with("был(а)")); assert!(result.contains('.') || result.contains(':')); @@ -221,7 +272,7 @@ mod tests { #[test] fn test_format_was_online_very_old() { let old = 1577836800; - let result = format_was_online(old); + let result = format_was_online_with(old, &fixed_time()); assert!(result.starts_with("был(а)")); assert!(result.contains('.')); diff --git a/src/utils/tdlib.rs b/src/utils/tdlib.rs index 681fe34..6d9c1f7 100644 --- a/src/utils/tdlib.rs +++ b/src/utils/tdlib.rs @@ -9,15 +9,17 @@ extern "C" { /// Отключаем логи TDLib синхронно, до создания клиента pub fn disable_tdlib_logs() { let request = r#"{"@type":"setLogVerbosityLevel","new_verbosity_level":0}"#; - let c_request = CString::new(request).unwrap(); - unsafe { - let _ = td_execute(c_request.as_ptr()); + if let Ok(c_request) = CString::new(request) { + unsafe { + let _ = td_execute(c_request.as_ptr()); + } } // Также перенаправляем логи в никуда let request2 = r#"{"@type":"setLogStream","log_stream":{"@type":"logStreamEmpty"}}"#; - let c_request2 = CString::new(request2).unwrap(); - unsafe { - let _ = td_execute(c_request2.as_ptr()); + if let Ok(c_request2) = CString::new(request2) { + unsafe { + let _ = td_execute(c_request2.as_ptr()); + } } } diff --git a/tests/account_switcher.rs b/tests/account_switcher.rs index 26449db..1ef2a58 100644 --- a/tests/account_switcher.rs +++ b/tests/account_switcher.rs @@ -3,7 +3,6 @@ mod helpers; use helpers::app_builder::TestAppBuilder; -use helpers::test_data::create_test_chat; use tele_tui::app::AccountSwitcherState; // ============ Open/Close Tests ============ diff --git a/tests/chat_list.rs b/tests/chat_list.rs index 9695123..6ad6da1 100644 --- a/tests/chat_list.rs +++ b/tests/chat_list.rs @@ -56,9 +56,6 @@ fn snapshot_chat_with_unread_count() { #[test] fn test_incoming_message_shows_unread_badge() { - use tele_tui::tdlib::ChatInfo; - use tele_tui::types::ChatId; - // Создаём чат БЕЗ непрочитанных сообщений let chat = TestChatBuilder::new("Friend", 999) .unread_count(0) @@ -97,7 +94,7 @@ fn test_incoming_message_shows_unread_badge() { #[tokio::test] async fn test_opening_chat_clears_unread_badge() { use helpers::test_data::TestMessageBuilder; - use tele_tui::tdlib::TdClientTrait; + use tele_tui::types::{ChatId, MessageId}; // Создаём чат с 3 непрочитанными сообщениями @@ -188,7 +185,7 @@ async fn test_opening_chat_clears_unread_badge() { #[tokio::test] async fn test_opening_chat_loads_many_messages() { use helpers::test_data::TestMessageBuilder; - use tele_tui::tdlib::TdClientTrait; + use tele_tui::types::ChatId; // Создаём чат с 50 сообщениями @@ -205,7 +202,7 @@ async fn test_opening_chat_loads_many_messages() { }) .collect(); - let mut app = TestAppBuilder::new() + let app = TestAppBuilder::new() .with_chat(chat) .with_messages(888, messages) .build(); @@ -230,7 +227,6 @@ async fn test_opening_chat_loads_many_messages() { #[tokio::test] async fn test_chat_history_chunked_loading() { - use tele_tui::tdlib::TdClientTrait; use tele_tui::types::ChatId; // Создаём чат с 120 сообщениями (больше чем TDLIB_MESSAGE_LIMIT = 50) @@ -247,7 +243,7 @@ async fn test_chat_history_chunked_loading() { }) .collect(); - let mut app = TestAppBuilder::new() + let app = TestAppBuilder::new() .with_chat(chat) .with_messages(999, messages) .build(); @@ -295,7 +291,6 @@ async fn test_chat_history_chunked_loading() { #[tokio::test] async fn test_chat_history_loads_all_without_limit() { - use tele_tui::tdlib::TdClientTrait; use tele_tui::types::ChatId; // Создаём чат с 200 сообщениями (4 чанка по 50) @@ -311,7 +306,7 @@ async fn test_chat_history_loads_all_without_limit() { }) .collect(); - let mut app = TestAppBuilder::new() + let app = TestAppBuilder::new() .with_chat(chat) .with_messages(1001, messages) .build(); @@ -331,8 +326,7 @@ async fn test_chat_history_loads_all_without_limit() { #[tokio::test] async fn test_load_older_messages_pagination() { - use tele_tui::tdlib::TdClientTrait; - use tele_tui::types::{ChatId, MessageId}; + use tele_tui::types::ChatId; // Создаём чат со 150 сообщениями let chat = TestChatBuilder::new("Paginated Chat", 1002) @@ -347,7 +341,7 @@ async fn test_load_older_messages_pagination() { }) .collect(); - let mut app = TestAppBuilder::new() + let app = TestAppBuilder::new() .with_chat(chat) .with_messages(1002, messages) .build(); @@ -490,9 +484,6 @@ fn snapshot_chat_search_mode() { #[test] fn snapshot_chat_with_online_status() { - use tele_tui::tdlib::UserOnlineStatus; - use tele_tui::types::ChatId; - let chat = TestChatBuilder::new("Alice", 123) .last_message("Hey there!") .build(); diff --git a/tests/config.rs b/tests/config.rs index aad793e..93266fe 100644 --- a/tests/config.rs +++ b/tests/config.rs @@ -167,8 +167,7 @@ mod credentials_tests { // Примечание: этот тест может зафейлиться если есть credentials файл, // так как он имеет приоритет. Для полноценного тестирования нужно // моковать файловую систему или использовать временные директории. - if result.is_ok() { - let (api_id, api_hash) = result.unwrap(); + if let Ok((api_id, api_hash)) = result { // Может быть либо из файла, либо из env assert!(api_id > 0); assert!(!api_hash.is_empty()); @@ -210,14 +209,13 @@ mod credentials_tests { let result = Config::load_credentials(); // Должна быть ошибка - if result.is_ok() { + if let Err(err_msg) = result { + // Проверяем формат ошибки + assert!(!err_msg.is_empty(), "Error message should not be empty"); + } else { // Возможно env переменные установлены глобально и не удаляются // Тест пропускается eprintln!("Warning: credentials loaded despite removing env vars"); - } else { - // Проверяем формат ошибки - let err_msg = result.unwrap_err(); - assert!(!err_msg.is_empty(), "Error message should not be empty"); } // Восстанавливаем env переменные diff --git a/tests/copy.rs b/tests/copy.rs index cb174a0..a688c6e 100644 --- a/tests/copy.rs +++ b/tests/copy.rs @@ -113,7 +113,6 @@ fn format_message_for_test(msg: &tele_tui::tdlib::MessageInfo) -> String { #[cfg(all(test, feature = "clipboard"))] mod clipboard_tests { - use super::*; /// Test: Проверка что clipboard функции не падают /// Примечание: Реальное тестирование clipboard требует GUI окружения diff --git a/tests/delete_message.rs b/tests/delete_message.rs index 49cefbf..ce1ef99 100644 --- a/tests/delete_message.rs +++ b/tests/delete_message.rs @@ -96,12 +96,12 @@ async fn test_can_only_delete_own_messages_for_all() { // Проверяем флаги удаления let messages = client.get_messages(123); - assert_eq!(messages[0].can_be_deleted_for_all_users(), true); // Наше - assert_eq!(messages[1].can_be_deleted_for_all_users(), false); // Чужое + assert!(messages[0].can_be_deleted_for_all_users()); // Наше + assert!(!messages[1].can_be_deleted_for_all_users()); // Чужое // Оба можно удалить для себя - assert_eq!(messages[0].can_be_deleted_only_for_self(), true); - assert_eq!(messages[1].can_be_deleted_only_for_self(), true); + assert!(messages[0].can_be_deleted_only_for_self()); + assert!(messages[1].can_be_deleted_only_for_self()); } /// Test: Удаление несуществующего сообщения (ничего не происходит) diff --git a/tests/drafts.rs b/tests/drafts.rs index 69f0c27..1798621 100644 --- a/tests/drafts.rs +++ b/tests/drafts.rs @@ -4,7 +4,6 @@ mod helpers; use helpers::test_data::{create_test_chat, TestChatBuilder}; use std::collections::HashMap; -use tele_tui::types::{ChatId, MessageId}; /// Простая структура для хранения черновиков (как в реальном App) struct DraftManager { @@ -105,11 +104,11 @@ async fn test_draft_indicator_in_chat_list() { let mut drafts = DraftManager::new(); // Создаём несколько чатов - let chat1 = create_test_chat("Mom", 123); + let _chat1 = create_test_chat("Mom", 123); let chat2 = TestChatBuilder::new("Boss", 456) .draft("Draft: Meeting notes") .build(); - let chat3 = create_test_chat("Friend", 789); + let _chat3 = create_test_chat("Friend", 789); // В реальном App: chat.draft_text устанавливается из DraftManager // Здесь просто проверяем что у chat2 есть draft_text поле diff --git a/tests/e2e_smoke.rs b/tests/e2e_smoke.rs index 3a317ac..7c87cdb 100644 --- a/tests/e2e_smoke.rs +++ b/tests/e2e_smoke.rs @@ -34,8 +34,10 @@ fn test_minimum_terminal_size() { const MIN_HEIGHT: u16 = 20; // Проверяем что константы установлены разумно - assert!(MIN_WIDTH >= 80, "Минимальная ширина должна быть >= 80"); - assert!(MIN_HEIGHT >= 20, "Минимальная высота должна быть >= 20"); + const { + assert!(MIN_WIDTH >= 80, "Минимальная ширина должна быть >= 80"); + assert!(MIN_HEIGHT >= 20, "Минимальная высота должна быть >= 20"); + }; // Проверяем граничные случаи let too_small_width = MIN_WIDTH - 1; @@ -51,13 +53,13 @@ fn test_app_constants() { use tele_tui::constants::*; // Проверяем что лимиты установлены - assert!(MAX_MESSAGES_IN_CHAT > 0, "Лимит сообщений должен быть > 0"); - assert!(MAX_CHATS > 0, "Лимит чатов должен быть > 0"); - assert!(MAX_USER_CACHE_SIZE > 0, "Размер кэша пользователей должен быть > 0"); - - // Проверяем что лимиты разумные - assert!(MAX_MESSAGES_IN_CHAT <= 1000, "Слишком большой лимит сообщений"); - assert!(MAX_CHATS <= 500, "Слишком большой лимит чатов"); + const { + assert!(MAX_MESSAGES_IN_CHAT > 0, "Лимит сообщений должен быть > 0"); + assert!(MAX_CHATS > 0, "Лимит чатов должен быть > 0"); + assert!(MAX_USER_CACHE_SIZE > 0, "Размер кэша пользователей должен быть > 0"); + assert!(MAX_MESSAGES_IN_CHAT <= 1000, "Слишком большой лимит сообщений"); + assert!(MAX_CHATS <= 500, "Слишком большой лимит чатов"); + }; } /// Тест: Graceful shutdown флаг diff --git a/tests/e2e_user_journey.rs b/tests/e2e_user_journey.rs index e086223..6cfb145 100644 --- a/tests/e2e_user_journey.rs +++ b/tests/e2e_user_journey.rs @@ -5,7 +5,7 @@ mod helpers; use helpers::fake_tdclient::{FakeTdClient, TdUpdate}; use helpers::test_data::{TestChatBuilder, TestMessageBuilder}; use tele_tui::tdlib::NetworkState; -use tele_tui::types::{ChatId, MessageId}; +use tele_tui::types::ChatId; /// Тест 1: App Launch → Auth → Chat List /// Симулирует полный путь пользователя от запуска до загрузки чатов diff --git a/tests/edit_message.rs b/tests/edit_message.rs index ecb77af..4a4e498 100644 --- a/tests/edit_message.rs +++ b/tests/edit_message.rs @@ -81,8 +81,8 @@ async fn test_can_only_edit_own_messages() { // Проверяем флаги let messages = client.get_messages(123); - assert_eq!(messages[0].can_be_edited(), true); // Наше сообщение - assert_eq!(messages[1].can_be_edited(), false); // Чужое сообщение + assert!(messages[0].can_be_edited()); // Наше сообщение + assert!(!messages[1].can_be_edited()); // Чужое сообщение } /// Test: Множественные редактирования одного сообщения diff --git a/tests/footer.rs b/tests/footer.rs index 382e51a..17f5db3 100644 --- a/tests/footer.rs +++ b/tests/footer.rs @@ -26,7 +26,7 @@ fn snapshot_footer_chat_list() { fn snapshot_footer_open_chat() { let chat = create_test_chat("Mom", 123); - let mut app = TestAppBuilder::new() + let app = TestAppBuilder::new() .with_chat(chat) .selected_chat(123) .build(); @@ -43,7 +43,7 @@ fn snapshot_footer_open_chat() { fn snapshot_footer_network_waiting() { let chat = create_test_chat("Mom", 123); - let mut app = TestAppBuilder::new().with_chat(chat).build(); + let app = TestAppBuilder::new().with_chat(chat).build(); // Set network state to WaitingForNetwork *app.td_client.network_state.lock().unwrap() = NetworkState::WaitingForNetwork; @@ -60,7 +60,7 @@ fn snapshot_footer_network_waiting() { fn snapshot_footer_network_connecting_proxy() { let chat = create_test_chat("Mom", 123); - let mut app = TestAppBuilder::new().with_chat(chat).build(); + let app = TestAppBuilder::new().with_chat(chat).build(); // Set network state to ConnectingToProxy *app.td_client.network_state.lock().unwrap() = NetworkState::ConnectingToProxy; @@ -77,7 +77,7 @@ fn snapshot_footer_network_connecting_proxy() { fn snapshot_footer_network_connecting() { let chat = create_test_chat("Mom", 123); - let mut app = TestAppBuilder::new().with_chat(chat).build(); + let app = TestAppBuilder::new().with_chat(chat).build(); // Set network state to Connecting *app.td_client.network_state.lock().unwrap() = NetworkState::Connecting; @@ -94,7 +94,7 @@ fn snapshot_footer_network_connecting() { fn snapshot_footer_search_mode() { let chat = create_test_chat("Mom", 123); - let mut app = TestAppBuilder::new() + let app = TestAppBuilder::new() .with_chat(chat) .searching("query") .build(); diff --git a/tests/helpers/app_builder.rs b/tests/helpers/app_builder.rs index 5301d8c..742c328 100644 --- a/tests/helpers/app_builder.rs +++ b/tests/helpers/app_builder.rs @@ -144,19 +144,13 @@ impl TestAppBuilder { /// Добавить сообщение для чата pub fn with_message(mut self, chat_id: i64, message: MessageInfo) -> Self { - self.messages - .entry(chat_id) - .or_insert_with(Vec::new) - .push(message); + self.messages.entry(chat_id).or_default().push(message); self } /// Добавить несколько сообщений для чата pub fn with_messages(mut self, chat_id: i64, messages: Vec) -> Self { - self.messages - .entry(chat_id) - .or_insert_with(Vec::new) - .extend(messages); + self.messages.entry(chat_id).or_default().extend(messages); self } diff --git a/tests/helpers/fake_tdclient.rs b/tests/helpers/fake_tdclient.rs index c598546..393fee4 100644 --- a/tests/helpers/fake_tdclient.rs +++ b/tests/helpers/fake_tdclient.rs @@ -1,873 +1,15 @@ -// Fake TDLib client for testing - -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::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, - }, - ChatAction { - chat_id: ChatId, - user_id: UserId, - action: String, - }, - MessageInteractionInfo { - chat_id: ChatId, - message_id: MessageId, - reactions: Vec, - }, - ConnectionState { - state: NetworkState, - }, - ChatReadOutbox { - chat_id: ChatId, - last_read_outbox_message_id: MessageId, - }, - ChatDraftMessage { - chat_id: ChatId, - draft_text: Option, - }, -} - -/// Упрощённый mock TDLib клиента для тестов -#[allow(dead_code)] -pub struct FakeTdClient { - // Данные - pub chats: Arc>>, - pub messages: Arc>>>, - pub folders: Arc>>, - pub user_names: Arc>>, - pub profiles: Arc>>, - pub drafts: Arc>>, - pub available_reactions: Arc>>, - - // Состояние - pub network_state: Arc>, - pub typing_chat_id: Arc>>, - pub current_chat_id: Arc>>, - pub current_pinned_message: Arc>>, - pub auth_state: Arc>, - - // История действий (для проверки в тестах) - pub sent_messages: Arc>>, - pub edited_messages: Arc>>, - pub deleted_messages: Arc>>, - pub forwarded_messages: Arc>>, - pub searched_queries: Arc>>, - pub viewed_messages: Arc)>>>, // (chat_id, message_ids) - pub chat_actions: Arc>>, // (chat_id, action) - pub pending_view_messages: Arc)>>>, // Очередь для отметки как прочитанные - - // Update channel для симуляции событий - pub update_tx: Arc>>>, - - // Скачанные файлы (file_id -> local_path) - pub downloaded_files: Arc>>, - - // Настройки поведения - pub simulate_delays: bool, - pub fail_next_operation: Arc>, -} - -#[derive(Debug, Clone)] -#[allow(dead_code)] -pub struct SentMessage { - pub chat_id: i64, - pub text: String, - pub reply_to: Option, - pub reply_info: Option, -} - -#[derive(Debug, Clone)] -#[allow(dead_code)] -pub struct EditedMessage { - pub chat_id: i64, - pub message_id: MessageId, - pub new_text: String, -} - -#[derive(Debug, Clone)] -#[allow(dead_code)] -pub struct DeletedMessages { - pub chat_id: i64, - pub message_ids: Vec, - pub revoke: bool, -} - -#[derive(Debug, Clone)] -#[allow(dead_code)] -pub struct ForwardedMessages { - pub from_chat_id: i64, - pub to_chat_id: i64, - pub message_ids: Vec, -} - -#[derive(Debug, Clone)] -#[allow(dead_code)] -pub struct SearchQuery { - pub chat_id: i64, - pub query: String, - pub results_count: usize, -} - -impl Default for FakeTdClient { - fn default() -> Self { - Self::new() - } -} - -impl Clone for FakeTdClient { - fn clone(&self) -> Self { - Self { - chats: Arc::clone(&self.chats), - messages: Arc::clone(&self.messages), - folders: Arc::clone(&self.folders), - user_names: Arc::clone(&self.user_names), - profiles: Arc::clone(&self.profiles), - drafts: Arc::clone(&self.drafts), - available_reactions: Arc::clone(&self.available_reactions), - network_state: Arc::clone(&self.network_state), - typing_chat_id: Arc::clone(&self.typing_chat_id), - current_chat_id: Arc::clone(&self.current_chat_id), - current_pinned_message: Arc::clone(&self.current_pinned_message), - auth_state: Arc::clone(&self.auth_state), - sent_messages: Arc::clone(&self.sent_messages), - edited_messages: Arc::clone(&self.edited_messages), - deleted_messages: Arc::clone(&self.deleted_messages), - forwarded_messages: Arc::clone(&self.forwarded_messages), - searched_queries: Arc::clone(&self.searched_queries), - viewed_messages: Arc::clone(&self.viewed_messages), - chat_actions: Arc::clone(&self.chat_actions), - pending_view_messages: Arc::clone(&self.pending_view_messages), - downloaded_files: Arc::clone(&self.downloaded_files), - update_tx: Arc::clone(&self.update_tx), - simulate_delays: self.simulate_delays, - fail_next_operation: Arc::clone(&self.fail_next_operation), - } - } -} - -#[allow(dead_code)] -impl FakeTdClient { - pub fn new() -> Self { - Self { - chats: Arc::new(Mutex::new(vec![])), - messages: Arc::new(Mutex::new(HashMap::new())), - folders: Arc::new(Mutex::new(vec![FolderInfo { id: 0, name: "All".to_string() }])), - user_names: Arc::new(Mutex::new(HashMap::new())), - 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(), - ])), - network_state: Arc::new(Mutex::new(NetworkState::Ready)), - typing_chat_id: Arc::new(Mutex::new(None)), - current_chat_id: Arc::new(Mutex::new(None)), - current_pinned_message: Arc::new(Mutex::new(None)), - auth_state: Arc::new(Mutex::new(AuthState::Ready)), - sent_messages: Arc::new(Mutex::new(vec![])), - edited_messages: Arc::new(Mutex::new(vec![])), - deleted_messages: Arc::new(Mutex::new(vec![])), - forwarded_messages: Arc::new(Mutex::new(vec![])), - searched_queries: Arc::new(Mutex::new(vec![])), - viewed_messages: Arc::new(Mutex::new(vec![])), - chat_actions: Arc::new(Mutex::new(vec![])), - pending_view_messages: Arc::new(Mutex::new(vec![])), - downloaded_files: Arc::new(Mutex::new(HashMap::new())), - update_tx: Arc::new(Mutex::new(None)), - simulate_delays: false, - fail_next_operation: Arc::new(Mutex::new(false)), - } - } - - /// Создать update channel для получения событий - pub fn with_update_channel(self) -> (Self, mpsc::UnboundedReceiver) { - let (tx, rx) = mpsc::unbounded_channel(); - *self.update_tx.lock().unwrap() = Some(tx); - (self, rx) - } - - /// Включить симуляцию задержек (как в реальном TDLib) - pub fn with_delays(mut self) -> Self { - self.simulate_delays = true; - self - } - - // ==================== Builder Methods ==================== - - /// Добавить чат - pub fn with_chat(self, chat: ChatInfo) -> Self { - self.chats.lock().unwrap().push(chat); - self - } - - /// Добавить несколько чатов - pub fn with_chats(self, chats: Vec) -> Self { - self.chats.lock().unwrap().extend(chats); - self - } - - /// Добавить сообщение в чат - pub fn with_message(self, chat_id: i64, message: MessageInfo) -> Self { - self.messages - .lock() - .unwrap() - .entry(chat_id) - .or_insert_with(Vec::new) - .push(message); - self - } - - /// Добавить несколько сообщений в чат - pub fn with_messages(self, chat_id: i64, messages: Vec) -> Self { - 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 - } - - /// Добавить пользователя - pub fn with_user(self, id: i64, name: &str) -> Self { - self.user_names.lock().unwrap().insert(id, name.to_string()); - self - } - - /// Добавить профиль - pub fn with_profile(self, chat_id: i64, profile: ProfileInfo) -> Self { - self.profiles.lock().unwrap().insert(chat_id, profile); - self - } - - /// Установить состояние сети - pub fn with_network_state(self, state: NetworkState) -> Self { - *self.network_state.lock().unwrap() = state; - self - } - - /// Установить состояние авторизации - pub fn with_auth_state(self, state: AuthState) -> Self { - *self.auth_state.lock().unwrap() = state; - self - } - - /// Добавить скачанный файл (для 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 - } - - /// Установить доступные реакции - pub fn with_available_reactions(self, reactions: Vec) -> Self { - *self.available_reactions.lock().unwrap() = reactions; - self - } - - // ==================== Async TDLib Operations ==================== - - /// Загрузить список чатов - pub async fn load_chats(&self, limit: usize) -> Result, String> { - if self.should_fail() { - return Err("Failed to load chats".to_string()); - } - - if self.simulate_delays { - tokio::time::sleep(tokio::time::Duration::from_millis(50)).await; - } - - let chats = self - .chats - .lock() - .unwrap() - .iter() - .take(limit) - .cloned() - .collect(); - Ok(chats) - } - - /// Открыть чат - pub async fn open_chat(&self, chat_id: ChatId) -> Result<(), String> { - if self.should_fail() { - return Err("Failed to open chat".to_string()); - } - - *self.current_chat_id.lock().unwrap() = Some(chat_id.as_i64()); - Ok(()) - } - - /// Получить историю чата - pub async fn get_chat_history( - &self, - chat_id: ChatId, - limit: i32, - ) -> Result, String> { - if self.should_fail() { - return Err("Failed to load history".to_string()); - } - - if self.simulate_delays { - tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; - } - - let messages = self - .messages - .lock() - .unwrap() - .get(&chat_id.as_i64()) - .map(|msgs| msgs.iter().take(limit as usize).cloned().collect()) - .unwrap_or_default(); - - Ok(messages) - } - - /// Загрузить старые сообщения - pub async fn load_older_messages( - &self, - chat_id: ChatId, - from_message_id: MessageId, - ) -> Result, String> { - if self.should_fail() { - return Err("Failed to load older messages".to_string()); - } - - let messages = self.messages.lock().unwrap(); - let chat_messages = messages.get(&chat_id.as_i64()).ok_or("Chat not found")?; - - // Найти индекс сообщения и вернуть предыдущие - if let Some(idx) = chat_messages.iter().position(|m| m.id() == from_message_id) { - let older: Vec<_> = chat_messages.iter().take(idx).cloned().collect(); - Ok(older) - } else { - Ok(vec![]) - } - } - - /// Отправить сообщение - pub async fn send_message( - &self, - chat_id: ChatId, - text: String, - reply_to: Option, - reply_info: Option, - ) -> Result { - if self.should_fail() { - return Err("Failed to send message".to_string()); - } - - if self.simulate_delays { - tokio::time::sleep(tokio::time::Duration::from_millis(200)).await; - } - - let message_id = MessageId::new((self.sent_messages.lock().unwrap().len() as i64) + 1000); - - self.sent_messages.lock().unwrap().push(SentMessage { - chat_id: chat_id.as_i64(), - text: text.clone(), - reply_to, - reply_info: reply_info.clone(), - }); - - let message = MessageInfo::new( - message_id, - "You".to_string(), - true, // is_outgoing - text.clone(), - vec![], // entities - chrono::Utc::now().timestamp() as i32, - 0, - false, // is_read (станет true после Update) - true, // can_be_edited - true, // can_be_deleted_only_for_self - true, // can_be_deleted_for_all_users - reply_info, - None, // forward_from - vec![], // reactions - ); - - // Добавляем в историю - self.messages - .lock() - .unwrap() - .entry(chat_id.as_i64()) - .or_insert_with(Vec::new) - .push(message.clone()); - - // Отправляем Update::NewMessage - self.send_update(TdUpdate::NewMessage { chat_id, message: message.clone() }); - - Ok(message) - } - - /// Редактировать сообщение - pub async fn edit_message( - &self, - chat_id: ChatId, - message_id: MessageId, - new_text: String, - ) -> Result { - if self.should_fail() { - return Err("Failed to edit message".to_string()); - } - - if self.simulate_delays { - tokio::time::sleep(tokio::time::Duration::from_millis(150)).await; - } - - self.edited_messages.lock().unwrap().push(EditedMessage { - chat_id: chat_id.as_i64(), - message_id, - new_text: new_text.clone(), - }); - - // Обновляем сообщение - let mut messages = self.messages.lock().unwrap(); - if let Some(chat_msgs) = messages.get_mut(&chat_id.as_i64()) { - if let Some(msg) = chat_msgs.iter_mut().find(|m| m.id() == message_id) { - msg.content.text = new_text.clone(); - msg.metadata.edit_date = msg.metadata.date + 60; - - let updated = msg.clone(); - drop(messages); // Освобождаем lock перед отправкой update - - // Отправляем Update - self.send_update(TdUpdate::MessageContent { chat_id, message_id, new_text }); - - return Ok(updated); - } - } - - Err("Message not found".to_string()) - } - - /// Удалить сообщения - pub async fn delete_messages( - &self, - chat_id: ChatId, - message_ids: Vec, - revoke: bool, - ) -> Result<(), String> { - if self.should_fail() { - return Err("Failed to delete messages".to_string()); - } - - if self.simulate_delays { - tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; - } - - self.deleted_messages.lock().unwrap().push(DeletedMessages { - chat_id: chat_id.as_i64(), - message_ids: message_ids.clone(), - revoke, - }); - - // Удаляем из истории - let mut messages = self.messages.lock().unwrap(); - if let Some(chat_msgs) = messages.get_mut(&chat_id.as_i64()) { - chat_msgs.retain(|m| !message_ids.contains(&m.id())); - } - drop(messages); - - // Отправляем Update - self.send_update(TdUpdate::DeleteMessages { chat_id, message_ids }); - - Ok(()) - } - - /// Переслать сообщения - pub async fn forward_messages( - &self, - to_chat_id: ChatId, - from_chat_id: ChatId, - message_ids: Vec, - ) -> Result<(), String> { - if self.should_fail() { - return Err("Failed to forward messages".to_string()); - } - - if self.simulate_delays { - tokio::time::sleep(tokio::time::Duration::from_millis(150)).await; - } - - 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, - }); - - Ok(()) - } - - /// Поиск сообщений в чате - pub async fn search_messages( - &self, - chat_id: ChatId, - query: &str, - ) -> Result, String> { - if self.should_fail() { - return Err("Failed to search messages".to_string()); - } - - let messages = self.messages.lock().unwrap(); - let results: Vec<_> = messages - .get(&chat_id.as_i64()) - .map(|msgs| { - msgs.iter() - .filter(|m| m.text().to_lowercase().contains(&query.to_lowercase())) - .cloned() - .collect() - }) - .unwrap_or_default(); - - self.searched_queries.lock().unwrap().push(SearchQuery { - chat_id: chat_id.as_i64(), - query: query.to_string(), - results_count: results.len(), - }); - - Ok(results) - } - - /// Установить черновик - pub async fn set_draft_message(&self, chat_id: ChatId, text: String) -> Result<(), String> { - 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.send_update(TdUpdate::ChatDraftMessage { - chat_id, - draft_text: if text.is_empty() { None } else { Some(text) }, - }); - - Ok(()) - } - - /// Отправить действие в чате (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())); - - if action == "Typing" { - *self.typing_chat_id.lock().unwrap() = Some(chat_id.as_i64()); - } else if action == "Cancel" { - *self.typing_chat_id.lock().unwrap() = None; - } - } - - /// Получить доступные реакции для сообщения - pub async fn get_message_available_reactions( - &self, - _chat_id: ChatId, - _message_id: MessageId, - ) -> Result, String> { - if self.should_fail() { - return Err("Failed to get available reactions".to_string()); - } - - Ok(self.available_reactions.lock().unwrap().clone()) - } - - /// Установить/удалить реакцию - pub async fn toggle_reaction( - &self, - chat_id: ChatId, - message_id: MessageId, - emoji: String, - ) -> Result<(), String> { - if self.should_fail() { - return Err("Failed to toggle reaction".to_string()); - } - - // Обновляем реакции на сообщении - let mut messages = self.messages.lock().unwrap(); - if let Some(chat_msgs) = messages.get_mut(&chat_id.as_i64()) { - if let Some(msg) = chat_msgs.iter_mut().find(|m| m.id() == message_id) { - let reactions = &mut msg.interactions.reactions; - - // Toggle logic - 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) { - // Добавляем себя к существующей реакции - reaction.is_chosen = true; - reaction.count += 1; - } else { - // Добавляем новую реакцию - reactions.push(ReactionInfo { - emoji: emoji.clone(), - count: 1, - is_chosen: true, - }); - } - - let updated_reactions = reactions.clone(); - drop(messages); - - // Отправляем Update - self.send_update(TdUpdate::MessageInteractionInfo { - chat_id, - message_id, - reactions: updated_reactions, - }); - } - } - - Ok(()) - } - - /// Скачать файл (mock) - pub async fn download_file(&self, file_id: i32) -> Result { - if self.should_fail() { - return Err("Failed to download file".to_string()); - } - - self.downloaded_files - .lock() - .unwrap() - .get(&file_id) - .cloned() - .ok_or_else(|| format!("File {} not found", file_id)) - } - - /// Получить информацию о профиле - pub async fn get_profile_info(&self, chat_id: ChatId) -> Result { - if self.should_fail() { - return Err("Failed to get profile info".to_string()); - } - - self.profiles - .lock() - .unwrap() - .get(&chat_id.as_i64()) - .cloned() - .ok_or_else(|| "Profile not found".to_string()) - } - - /// Отметить сообщения как просмотренные - pub async fn view_messages(&self, chat_id: ChatId, message_ids: Vec) { - self.viewed_messages - .lock() - .unwrap() - .push((chat_id.as_i64(), message_ids.iter().map(|id| id.as_i64()).collect())); - } - - /// Загрузить чаты папки - pub async fn load_folder_chats(&self, _folder_id: i32, _limit: usize) -> Result<(), String> { - if self.should_fail() { - return Err("Failed to load folder chats".to_string()); - } - - Ok(()) - } - - // ==================== Helper Methods ==================== - - /// Отправить update в канал (если он установлен) - fn send_update(&self, update: TdUpdate) { - if let Some(tx) = self.update_tx.lock().unwrap().as_ref() { - let _ = tx.send(update); - } - } - - /// Проверить нужно ли симулировать ошибку - fn should_fail(&self) -> bool { - let mut fail = self.fail_next_operation.lock().unwrap(); - if *fail { - *fail = false; // Сбрасываем после первого использования - true - } else { - false - } - } - - /// Симулировать ошибку в следующей операции - pub fn fail_next(&self) { - *self.fail_next_operation.lock().unwrap() = true; - } - - /// Симулировать входящее сообщение - pub fn simulate_incoming_message(&self, chat_id: ChatId, text: String, sender_name: &str) { - let message_id = MessageId::new(9000 + chrono::Utc::now().timestamp()); - - let message = MessageInfo::new( - message_id, - sender_name.to_string(), - false, // is_outgoing - text, - vec![], - chrono::Utc::now().timestamp() as i32, - 0, - false, - false, - false, - true, - None, - None, - vec![], - ); - - // Добавляем в историю - self.messages - .lock() - .unwrap() - .entry(chat_id.as_i64()) - .or_insert_with(Vec::new) - .push(message.clone()); - - // Отправляем Update - self.send_update(TdUpdate::NewMessage { chat_id, message }); - } - - /// Симулировать 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() }); - } - - /// Симулировать изменение состояния сети - pub fn simulate_network_change(&self, state: NetworkState) { - *self.network_state.lock().unwrap() = state.clone(); - self.send_update(TdUpdate::ConnectionState { state }); - } - - /// Симулировать прочтение сообщений - pub fn simulate_read_outbox(&self, chat_id: ChatId, last_read_message_id: MessageId) { - self.send_update(TdUpdate::ChatReadOutbox { - chat_id, - last_read_outbox_message_id: last_read_message_id, - }); - } - - // ==================== Getters for Test Assertions ==================== - - /// Получить все чаты - pub fn get_chats(&self) -> Vec { - self.chats.lock().unwrap().clone() - } - - /// Получить все папки - pub fn get_folders(&self) -> Vec { - self.folders.lock().unwrap().clone() - } - - /// Получить сообщения чата - pub fn get_messages(&self, chat_id: i64) -> Vec { - self.messages - .lock() - .unwrap() - .get(&chat_id) - .cloned() - .unwrap_or_default() - } - - /// Получить отправленные сообщения - pub fn get_sent_messages(&self) -> Vec { - self.sent_messages.lock().unwrap().clone() - } - - /// Получить отредактированные сообщения - pub fn get_edited_messages(&self) -> Vec { - self.edited_messages.lock().unwrap().clone() - } - - /// Получить удалённые сообщения - pub fn get_deleted_messages(&self) -> Vec { - self.deleted_messages.lock().unwrap().clone() - } - - /// Получить пересланные сообщения - pub fn get_forwarded_messages(&self) -> Vec { - self.forwarded_messages.lock().unwrap().clone() - } - - /// Получить поисковые запросы - pub fn get_search_queries(&self) -> Vec { - self.searched_queries.lock().unwrap().clone() - } - - /// Получить просмотренные сообщения - pub fn get_viewed_messages(&self) -> Vec<(i64, Vec)> { - self.viewed_messages.lock().unwrap().clone() - } - - /// Получить действия в чатах - pub fn get_chat_actions(&self) -> Vec<(i64, String)> { - self.chat_actions.lock().unwrap().clone() - } - - /// Получить текущее состояние сети - pub fn get_network_state(&self) -> NetworkState { - self.network_state.lock().unwrap().clone() - } - - /// Получить ID текущего открытого чата - pub fn get_current_chat_id(&self) -> Option { - *self.current_chat_id.lock().unwrap() - } - - /// Установить update channel для получения событий - pub fn set_update_channel(&self, tx: mpsc::UnboundedSender) { - *self.update_tx.lock().unwrap() = Some(tx); - } - - /// Очистить всю историю действий - pub fn clear_all_history(&self) { - self.sent_messages.lock().unwrap().clear(); - self.edited_messages.lock().unwrap().clear(); - self.deleted_messages.lock().unwrap().clear(); - self.forwarded_messages.lock().unwrap().clear(); - self.searched_queries.lock().unwrap().clear(); - self.viewed_messages.lock().unwrap().clear(); - self.chat_actions.lock().unwrap().clear(); - } -} +// Fake TDLib client for testing. + +mod builders; +mod inspect; +mod operations; +mod state; + +#[allow(unused_imports)] +pub use state::{ + DeletedMessages, EditedMessage, FakeTdClient, ForwardedMessages, PendingViewMessages, + SearchQuery, SentMessage, TdUpdate, ViewedMessages, +}; #[cfg(test)] mod tests { @@ -952,12 +94,10 @@ mod tests { let (client, mut rx) = FakeTdClient::new().with_update_channel(); let chat_id = ChatId::new(123); - // Отправляем сообщение let _ = client .send_message(chat_id, "Test".to_string(), None, None) .await; - // Проверяем что получили Update if let Some(update) = rx.recv().await { match update { TdUpdate::NewMessage { chat_id: updated_chat, .. } => { @@ -977,14 +117,12 @@ mod tests { client.simulate_incoming_message(chat_id, "Hello from Bob".to_string(), "Bob"); - // Проверяем Update if let Some(TdUpdate::NewMessage { message, .. }) = rx.recv().await { assert_eq!(message.text(), "Hello from Bob"); assert_eq!(message.sender_name(), "Bob"); assert!(!message.is_outgoing()); } - // Проверяем что сообщение добавилось assert_eq!(client.get_messages(123).len(), 1); } @@ -993,16 +131,13 @@ mod tests { let client = FakeTdClient::new(); let chat_id = ChatId::new(123); - // Устанавливаем флаг ошибки client.fail_next(); - // Следующая операция должна упасть 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; diff --git a/tests/helpers/fake_tdclient/builders.rs b/tests/helpers/fake_tdclient/builders.rs new file mode 100644 index 0000000..a1a2bb1 --- /dev/null +++ b/tests/helpers/fake_tdclient/builders.rs @@ -0,0 +1,86 @@ +use super::{FakeTdClient, TdUpdate}; +use tele_tui::tdlib::types::FolderInfo; +use tele_tui::tdlib::{AuthState, ChatInfo, MessageInfo, NetworkState, ProfileInfo}; +use tokio::sync::mpsc; + +#[allow(dead_code)] +impl FakeTdClient { + /// Create an update channel for receiving simulated TDLib events. + pub fn with_update_channel(self) -> (Self, mpsc::UnboundedReceiver) { + let (tx, rx) = mpsc::unbounded_channel(); + *self.update_tx.lock().unwrap() = Some(tx); + (self, rx) + } + + /// Enable simulated delays, closer to real TDLib behavior. + pub fn with_delays(mut self) -> Self { + self.simulate_delays = true; + self + } + + pub fn with_chat(self, chat: ChatInfo) -> Self { + self.chats.lock().unwrap().push(chat); + self + } + + pub fn with_chats(self, chats: Vec) -> Self { + self.chats.lock().unwrap().extend(chats); + self + } + + pub fn with_message(self, chat_id: i64, message: MessageInfo) -> Self { + self.messages + .lock() + .unwrap() + .entry(chat_id) + .or_default() + .push(message); + self + } + + pub fn with_messages(self, chat_id: i64, messages: Vec) -> Self { + 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 + } + + pub fn with_user(self, id: i64, name: &str) -> Self { + self.user_names.lock().unwrap().insert(id, name.to_string()); + self + } + + pub fn with_profile(self, chat_id: i64, profile: ProfileInfo) -> Self { + self.profiles.lock().unwrap().insert(chat_id, profile); + self + } + + pub fn with_network_state(self, state: NetworkState) -> Self { + *self.network_state.lock().unwrap() = state; + self + } + + pub fn with_auth_state(self, state: AuthState) -> Self { + *self.auth_state.lock().unwrap() = state; + self + } + + pub fn with_downloaded_file(self, file_id: i32, path: &str) -> Self { + self.downloaded_files + .lock() + .unwrap() + .insert(file_id, path.to_string()); + self + } + + pub fn with_available_reactions(self, reactions: Vec) -> Self { + *self.available_reactions.lock().unwrap() = reactions; + self + } +} diff --git a/tests/helpers/fake_tdclient/inspect.rs b/tests/helpers/fake_tdclient/inspect.rs new file mode 100644 index 0000000..87059f6 --- /dev/null +++ b/tests/helpers/fake_tdclient/inspect.rs @@ -0,0 +1,92 @@ +use super::{ + DeletedMessages, EditedMessage, FakeTdClient, ForwardedMessages, SearchQuery, SentMessage, + TdUpdate, +}; +use tele_tui::tdlib::types::FolderInfo; +use tele_tui::tdlib::{ChatInfo, MessageInfo, NetworkState}; +use tokio::sync::mpsc; + +#[allow(dead_code)] +impl FakeTdClient { + pub fn get_chats(&self) -> Vec { + self.chats.lock().unwrap().clone() + } + + pub fn get_folders(&self) -> Vec { + self.folders.lock().unwrap().clone() + } + + pub fn get_messages(&self, chat_id: i64) -> Vec { + self.messages + .lock() + .unwrap() + .get(&chat_id) + .cloned() + .unwrap_or_default() + } + + pub fn get_sent_messages(&self) -> Vec { + self.sent_messages.lock().unwrap().clone() + } + + pub fn get_edited_messages(&self) -> Vec { + self.edited_messages.lock().unwrap().clone() + } + + pub fn get_deleted_messages(&self) -> Vec { + self.deleted_messages.lock().unwrap().clone() + } + + pub fn get_forwarded_messages(&self) -> Vec { + self.forwarded_messages.lock().unwrap().clone() + } + + pub fn get_search_queries(&self) -> Vec { + self.searched_queries.lock().unwrap().clone() + } + + pub fn get_viewed_messages(&self) -> Vec<(i64, Vec)> { + self.viewed_messages.lock().unwrap().clone() + } + + pub fn get_chat_actions(&self) -> Vec<(i64, String)> { + self.chat_actions.lock().unwrap().clone() + } + + pub fn get_network_state(&self) -> NetworkState { + self.network_state.lock().unwrap().clone() + } + + pub fn get_current_chat_id(&self) -> Option { + *self.current_chat_id.lock().unwrap() + } + + pub fn set_current_pinned_message(&mut self, msg: Option) { + *self.current_pinned_message.lock().unwrap() = msg; + } + + pub async fn process_pending_view_messages(&mut self) { + let mut pending = self.pending_view_messages.lock().unwrap(); + for (chat_id, message_ids) in pending.drain(..) { + let ids: Vec = message_ids.iter().map(|id| id.as_i64()).collect(); + self.viewed_messages + .lock() + .unwrap() + .push((chat_id.as_i64(), ids)); + } + } + + pub fn set_update_channel(&self, tx: mpsc::UnboundedSender) { + *self.update_tx.lock().unwrap() = Some(tx); + } + + pub fn clear_all_history(&self) { + self.sent_messages.lock().unwrap().clear(); + self.edited_messages.lock().unwrap().clear(); + self.deleted_messages.lock().unwrap().clear(); + self.forwarded_messages.lock().unwrap().clear(); + self.searched_queries.lock().unwrap().clear(); + self.viewed_messages.lock().unwrap().clear(); + self.chat_actions.lock().unwrap().clear(); + } +} diff --git a/tests/helpers/fake_tdclient/operations.rs b/tests/helpers/fake_tdclient/operations.rs new file mode 100644 index 0000000..b087617 --- /dev/null +++ b/tests/helpers/fake_tdclient/operations.rs @@ -0,0 +1,458 @@ +use super::{ + DeletedMessages, EditedMessage, FakeTdClient, ForwardedMessages, SearchQuery, SentMessage, + TdUpdate, +}; +use tele_tui::tdlib::types::ReactionInfo; +use tele_tui::tdlib::{ChatInfo, MessageInfo, ProfileInfo, ReplyInfo}; +use tele_tui::types::{ChatId, MessageId, UserId}; + +#[allow(dead_code)] +impl FakeTdClient { + pub async fn load_chats(&self, limit: usize) -> Result, String> { + if self.should_fail() { + return Err("Failed to load chats".to_string()); + } + + if self.simulate_delays { + tokio::time::sleep(tokio::time::Duration::from_millis(50)).await; + } + + let chats = self + .chats + .lock() + .unwrap() + .iter() + .take(limit) + .cloned() + .collect(); + Ok(chats) + } + + pub async fn open_chat(&self, chat_id: ChatId) -> Result<(), String> { + if self.should_fail() { + return Err("Failed to open chat".to_string()); + } + + *self.current_chat_id.lock().unwrap() = Some(chat_id.as_i64()); + Ok(()) + } + + pub async fn get_chat_history( + &self, + chat_id: ChatId, + limit: i32, + ) -> Result, String> { + if self.should_fail() { + return Err("Failed to load history".to_string()); + } + + if self.simulate_delays { + tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + } + + let messages = self + .messages + .lock() + .unwrap() + .get(&chat_id.as_i64()) + .map(|msgs| msgs.iter().take(limit as usize).cloned().collect()) + .unwrap_or_default(); + + Ok(messages) + } + + pub async fn load_older_messages( + &self, + chat_id: ChatId, + from_message_id: MessageId, + ) -> Result, String> { + if self.should_fail() { + return Err("Failed to load older messages".to_string()); + } + + let messages = self.messages.lock().unwrap(); + let chat_messages = messages.get(&chat_id.as_i64()).ok_or("Chat not found")?; + + if let Some(idx) = chat_messages.iter().position(|m| m.id() == from_message_id) { + let older = chat_messages.iter().take(idx).cloned().collect(); + Ok(older) + } else { + Ok(vec![]) + } + } + + pub async fn send_message( + &self, + chat_id: ChatId, + text: String, + reply_to: Option, + reply_info: Option, + ) -> Result { + if self.should_fail() { + return Err("Failed to send message".to_string()); + } + + if self.simulate_delays { + tokio::time::sleep(tokio::time::Duration::from_millis(200)).await; + } + + let message_id = MessageId::new((self.sent_messages.lock().unwrap().len() as i64) + 1000); + + self.sent_messages.lock().unwrap().push(SentMessage { + chat_id: chat_id.as_i64(), + text: text.clone(), + reply_to, + reply_info: reply_info.clone(), + }); + + let message = MessageInfo::new( + message_id, + "You".to_string(), + true, + text, + vec![], + chrono::Utc::now().timestamp() as i32, + 0, + false, + true, + true, + true, + reply_info, + None, + vec![], + ); + + self.messages + .lock() + .unwrap() + .entry(chat_id.as_i64()) + .or_default() + .push(message.clone()); + + self.send_update(TdUpdate::NewMessage { chat_id, message: Box::new(message.clone()) }); + + Ok(message) + } + + pub async fn edit_message( + &self, + chat_id: ChatId, + message_id: MessageId, + new_text: String, + ) -> Result { + if self.should_fail() { + return Err("Failed to edit message".to_string()); + } + + if self.simulate_delays { + tokio::time::sleep(tokio::time::Duration::from_millis(150)).await; + } + + self.edited_messages.lock().unwrap().push(EditedMessage { + chat_id: chat_id.as_i64(), + message_id, + new_text: new_text.clone(), + }); + + let mut messages = self.messages.lock().unwrap(); + if let Some(chat_msgs) = messages.get_mut(&chat_id.as_i64()) { + if let Some(msg) = chat_msgs.iter_mut().find(|m| m.id() == message_id) { + msg.content.text = new_text.clone(); + msg.metadata.edit_date = msg.metadata.date + 60; + + let updated = msg.clone(); + drop(messages); + + self.send_update(TdUpdate::MessageContent { chat_id, message_id, new_text }); + + return Ok(updated); + } + } + + Err("Message not found".to_string()) + } + + pub async fn delete_messages( + &self, + chat_id: ChatId, + message_ids: Vec, + revoke: bool, + ) -> Result<(), String> { + if self.should_fail() { + return Err("Failed to delete messages".to_string()); + } + + if self.simulate_delays { + tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + } + + self.deleted_messages.lock().unwrap().push(DeletedMessages { + chat_id: chat_id.as_i64(), + message_ids: message_ids.clone(), + revoke, + }); + + let mut messages = self.messages.lock().unwrap(); + if let Some(chat_msgs) = messages.get_mut(&chat_id.as_i64()) { + chat_msgs.retain(|m| !message_ids.contains(&m.id())); + } + drop(messages); + + self.send_update(TdUpdate::DeleteMessages { chat_id, message_ids }); + + Ok(()) + } + + pub async fn forward_messages( + &self, + to_chat_id: ChatId, + from_chat_id: ChatId, + message_ids: Vec, + ) -> Result<(), String> { + if self.should_fail() { + return Err("Failed to forward messages".to_string()); + } + + if self.simulate_delays { + tokio::time::sleep(tokio::time::Duration::from_millis(150)).await; + } + + 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, + }); + + Ok(()) + } + + pub async fn search_messages( + &self, + chat_id: ChatId, + query: &str, + ) -> Result, String> { + if self.should_fail() { + return Err("Failed to search messages".to_string()); + } + + let messages = self.messages.lock().unwrap(); + let results: Vec<_> = messages + .get(&chat_id.as_i64()) + .map(|msgs| { + msgs.iter() + .filter(|m| m.text().to_lowercase().contains(&query.to_lowercase())) + .cloned() + .collect() + }) + .unwrap_or_default(); + + self.searched_queries.lock().unwrap().push(SearchQuery { + chat_id: chat_id.as_i64(), + query: query.to_string(), + results_count: results.len(), + }); + + Ok(results) + } + + pub async fn set_draft_message(&self, chat_id: ChatId, text: String) -> Result<(), String> { + 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.send_update(TdUpdate::ChatDraftMessage { + chat_id, + draft_text: if text.is_empty() { None } else { Some(text) }, + }); + + Ok(()) + } + + pub async fn send_chat_action(&self, chat_id: ChatId, action: String) { + 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()); + } else if action == "Cancel" { + *self.typing_chat_id.lock().unwrap() = None; + } + } + + pub async fn get_message_available_reactions( + &self, + _chat_id: ChatId, + _message_id: MessageId, + ) -> Result, String> { + if self.should_fail() { + return Err("Failed to get available reactions".to_string()); + } + + Ok(self.available_reactions.lock().unwrap().clone()) + } + + pub async fn toggle_reaction( + &self, + chat_id: ChatId, + message_id: MessageId, + emoji: String, + ) -> Result<(), String> { + if self.should_fail() { + return Err("Failed to toggle reaction".to_string()); + } + + let mut messages = self.messages.lock().unwrap(); + if let Some(chat_msgs) = messages.get_mut(&chat_id.as_i64()) { + if let Some(msg) = chat_msgs.iter_mut().find(|m| m.id() == message_id) { + let reactions = &mut msg.interactions.reactions; + + if let Some(pos) = reactions + .iter() + .position(|reaction| reaction.emoji == emoji && reaction.is_chosen) + { + reactions.remove(pos); + } else if let Some(reaction) = reactions + .iter_mut() + .find(|reaction| reaction.emoji == emoji) + { + reaction.is_chosen = true; + reaction.count += 1; + } else { + reactions.push(ReactionInfo { + emoji: emoji.clone(), + count: 1, + is_chosen: true, + }); + } + + let updated_reactions = reactions.clone(); + drop(messages); + + self.send_update(TdUpdate::MessageInteractionInfo { + chat_id, + message_id, + reactions: updated_reactions, + }); + } + } + + Ok(()) + } + + pub async fn download_file(&self, file_id: i32) -> Result { + if self.should_fail() { + return Err("Failed to download file".to_string()); + } + + self.downloaded_files + .lock() + .unwrap() + .get(&file_id) + .cloned() + .ok_or_else(|| format!("File {} not found", file_id)) + } + + pub async fn get_profile_info(&self, chat_id: ChatId) -> Result { + if self.should_fail() { + return Err("Failed to get profile info".to_string()); + } + + self.profiles + .lock() + .unwrap() + .get(&chat_id.as_i64()) + .cloned() + .ok_or_else(|| "Profile not found".to_string()) + } + + pub async fn view_messages(&self, chat_id: ChatId, message_ids: Vec) { + self.viewed_messages + .lock() + .unwrap() + .push((chat_id.as_i64(), message_ids.iter().map(|id| id.as_i64()).collect())); + } + + pub async fn load_folder_chats(&self, _folder_id: i32, _limit: usize) -> Result<(), String> { + if self.should_fail() { + return Err("Failed to load folder chats".to_string()); + } + + Ok(()) + } + + fn send_update(&self, update: TdUpdate) { + if let Some(tx) = self.update_tx.lock().unwrap().as_ref() { + let _ = tx.send(update); + } + } + + fn should_fail(&self) -> bool { + let mut fail = self.fail_next_operation.lock().unwrap(); + if *fail { + *fail = false; + true + } else { + false + } + } + + pub fn fail_next(&self) { + *self.fail_next_operation.lock().unwrap() = true; + } + + pub fn simulate_incoming_message(&self, chat_id: ChatId, text: String, sender_name: &str) { + let message_id = MessageId::new(9000 + chrono::Utc::now().timestamp()); + + let message = MessageInfo::new( + message_id, + sender_name.to_string(), + false, + text, + vec![], + chrono::Utc::now().timestamp() as i32, + 0, + false, + false, + false, + true, + None, + None, + vec![], + ); + + self.messages + .lock() + .unwrap() + .entry(chat_id.as_i64()) + .or_default() + .push(message.clone()); + + self.send_update(TdUpdate::NewMessage { chat_id, message: Box::new(message) }); + } + + pub fn simulate_typing(&self, chat_id: ChatId, user_id: UserId) { + self.send_update(TdUpdate::ChatAction { chat_id, user_id, action: "Typing".to_string() }); + } + + pub fn simulate_network_change(&self, state: tele_tui::tdlib::NetworkState) { + *self.network_state.lock().unwrap() = state.clone(); + self.send_update(TdUpdate::ConnectionState { state }); + } + + pub fn simulate_read_outbox(&self, chat_id: ChatId, last_read_message_id: MessageId) { + self.send_update(TdUpdate::ChatReadOutbox { + chat_id, + last_read_outbox_message_id: last_read_message_id, + }); + } +} diff --git a/tests/helpers/fake_tdclient/state.rs b/tests/helpers/fake_tdclient/state.rs new file mode 100644 index 0000000..581275c --- /dev/null +++ b/tests/helpers/fake_tdclient/state.rs @@ -0,0 +1,201 @@ +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::types::{ChatId, MessageId, UserId}; +use tokio::sync::mpsc; + +pub type ViewedMessages = Vec<(i64, Vec)>; +pub type PendingViewMessages = Vec<(ChatId, Vec)>; + +/// Update events from TDLib, simplified for tests. +#[derive(Debug, Clone)] +#[allow(dead_code)] +pub enum TdUpdate { + NewMessage { + chat_id: ChatId, + message: Box, + }, + MessageContent { + chat_id: ChatId, + message_id: MessageId, + new_text: String, + }, + DeleteMessages { + chat_id: ChatId, + message_ids: Vec, + }, + ChatAction { + chat_id: ChatId, + user_id: UserId, + action: String, + }, + MessageInteractionInfo { + chat_id: ChatId, + message_id: MessageId, + reactions: Vec, + }, + ConnectionState { + state: NetworkState, + }, + ChatReadOutbox { + chat_id: ChatId, + last_read_outbox_message_id: MessageId, + }, + ChatDraftMessage { + chat_id: ChatId, + draft_text: Option, + }, +} + +/// Simplified mock TDLib client for tests. +#[allow(dead_code)] +pub struct FakeTdClient { + pub chats: Arc>>, + pub messages: Arc>>>, + pub folders: Arc>>, + pub user_names: Arc>>, + pub profiles: Arc>>, + pub drafts: Arc>>, + pub available_reactions: Arc>>, + + pub network_state: Arc>, + pub typing_chat_id: Arc>>, + pub current_chat_id: Arc>>, + pub current_pinned_message: Arc>>, + pub auth_state: Arc>, + + pub sent_messages: Arc>>, + pub edited_messages: Arc>>, + pub deleted_messages: Arc>>, + pub forwarded_messages: Arc>>, + pub searched_queries: Arc>>, + pub viewed_messages: Arc>, + pub chat_actions: Arc>>, + pub pending_view_messages: Arc>, + + pub update_tx: Arc>>>, + pub downloaded_files: Arc>>, + + pub simulate_delays: bool, + pub fail_next_operation: Arc>, +} + +#[derive(Debug, Clone)] +#[allow(dead_code)] +pub struct SentMessage { + pub chat_id: i64, + pub text: String, + pub reply_to: Option, + pub reply_info: Option, +} + +#[derive(Debug, Clone)] +#[allow(dead_code)] +pub struct EditedMessage { + pub chat_id: i64, + pub message_id: MessageId, + pub new_text: String, +} + +#[derive(Debug, Clone)] +#[allow(dead_code)] +pub struct DeletedMessages { + pub chat_id: i64, + pub message_ids: Vec, + pub revoke: bool, +} + +#[derive(Debug, Clone)] +#[allow(dead_code)] +pub struct ForwardedMessages { + pub from_chat_id: i64, + pub to_chat_id: i64, + pub message_ids: Vec, +} + +#[derive(Debug, Clone)] +#[allow(dead_code)] +pub struct SearchQuery { + pub chat_id: i64, + pub query: String, + pub results_count: usize, +} + +impl Default for FakeTdClient { + fn default() -> Self { + Self::new() + } +} + +impl Clone for FakeTdClient { + fn clone(&self) -> Self { + Self { + chats: Arc::clone(&self.chats), + messages: Arc::clone(&self.messages), + folders: Arc::clone(&self.folders), + user_names: Arc::clone(&self.user_names), + profiles: Arc::clone(&self.profiles), + drafts: Arc::clone(&self.drafts), + available_reactions: Arc::clone(&self.available_reactions), + network_state: Arc::clone(&self.network_state), + typing_chat_id: Arc::clone(&self.typing_chat_id), + current_chat_id: Arc::clone(&self.current_chat_id), + current_pinned_message: Arc::clone(&self.current_pinned_message), + auth_state: Arc::clone(&self.auth_state), + sent_messages: Arc::clone(&self.sent_messages), + edited_messages: Arc::clone(&self.edited_messages), + deleted_messages: Arc::clone(&self.deleted_messages), + forwarded_messages: Arc::clone(&self.forwarded_messages), + searched_queries: Arc::clone(&self.searched_queries), + viewed_messages: Arc::clone(&self.viewed_messages), + chat_actions: Arc::clone(&self.chat_actions), + pending_view_messages: Arc::clone(&self.pending_view_messages), + downloaded_files: Arc::clone(&self.downloaded_files), + update_tx: Arc::clone(&self.update_tx), + simulate_delays: self.simulate_delays, + fail_next_operation: Arc::clone(&self.fail_next_operation), + } + } +} + +#[allow(dead_code)] +impl FakeTdClient { + pub fn new() -> Self { + Self { + chats: Arc::new(Mutex::new(vec![])), + messages: Arc::new(Mutex::new(HashMap::new())), + folders: Arc::new(Mutex::new(vec![FolderInfo { id: 0, name: "All".to_string() }])), + user_names: Arc::new(Mutex::new(HashMap::new())), + 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(), + ])), + network_state: Arc::new(Mutex::new(NetworkState::Ready)), + typing_chat_id: Arc::new(Mutex::new(None)), + current_chat_id: Arc::new(Mutex::new(None)), + current_pinned_message: Arc::new(Mutex::new(None)), + auth_state: Arc::new(Mutex::new(AuthState::Ready)), + sent_messages: Arc::new(Mutex::new(vec![])), + edited_messages: Arc::new(Mutex::new(vec![])), + deleted_messages: Arc::new(Mutex::new(vec![])), + forwarded_messages: Arc::new(Mutex::new(vec![])), + searched_queries: Arc::new(Mutex::new(vec![])), + viewed_messages: Arc::new(Mutex::new(vec![])), + chat_actions: Arc::new(Mutex::new(vec![])), + pending_view_messages: Arc::new(Mutex::new(vec![])), + downloaded_files: Arc::new(Mutex::new(HashMap::new())), + update_tx: Arc::new(Mutex::new(None)), + simulate_delays: false, + fail_next_operation: Arc::new(Mutex::new(false)), + } + } +} diff --git a/tests/helpers/fake_tdclient_impl.rs b/tests/helpers/fake_tdclient_impl.rs index 4e99226..cf5e2fc 100644 --- a/tests/helpers/fake_tdclient_impl.rs +++ b/tests/helpers/fake_tdclient_impl.rs @@ -1,10 +1,14 @@ -//! Implementation of TdClientTrait for FakeTdClient +//! Test implementation of the TDLib client traits for FakeTdClient. use super::fake_tdclient::FakeTdClient; use async_trait::async_trait; +use std::borrow::Cow; use std::path::PathBuf; use tdlib_rs::enums::{ChatAction, Update}; -use tele_tui::tdlib::TdClientTrait; +use tele_tui::tdlib::{ + AccountClient, AuthClient, ChatActionClient, ChatClient, ClientState, FileClient, + MessageClient, NotificationClient, ReactionClient, UpdateClient, UserClient, +}; use tele_tui::tdlib::{ AuthState, ChatInfo, FolderInfo, MessageInfo, ProfileInfo, ReplyInfo, UserCache, UserOnlineStatus, @@ -12,8 +16,7 @@ use tele_tui::tdlib::{ use tele_tui::types::{ChatId, MessageId, UserId}; #[async_trait] -impl TdClientTrait for FakeTdClient { - // ============ Auth methods (not implemented for fake) ============ +impl AuthClient for FakeTdClient { async fn send_phone_number(&self, _phone: String) -> Result<(), String> { Ok(()) } @@ -25,10 +28,11 @@ impl TdClientTrait for FakeTdClient { async fn send_password(&self, _password: String) -> Result<(), String> { Ok(()) } +} - // ============ Chat methods ============ +#[async_trait] +impl ChatClient for FakeTdClient { async fn load_chats(&mut self, limit: i32) -> Result<(), String> { - // FakeTdClient loads chats but returns void let _ = FakeTdClient::load_chats(self, limit as usize).await?; Ok(()) } @@ -38,7 +42,6 @@ impl TdClientTrait for FakeTdClient { } async fn leave_chat(&self, _chat_id: ChatId) -> Result<(), String> { - // Not implemented for fake client Ok(()) } @@ -46,18 +49,54 @@ impl TdClientTrait for FakeTdClient { FakeTdClient::get_profile_info(self, chat_id).await } - // ============ Chat actions ============ + fn chats(&self) -> &[ChatInfo] { + &[] + } + + fn folders(&self) -> &[FolderInfo] { + &[] + } + + fn main_chat_list_position(&self) -> i32 { + 0 + } + + fn set_main_chat_list_position(&mut self, _position: i32) {} + + fn update_chats(&mut self, updater: F) + where + F: FnOnce(&mut Vec), + { + updater(&mut self.chats.lock().unwrap()); + } + + fn update_folders(&mut self, updater: F) + where + F: FnOnce(&mut Vec), + { + updater(&mut self.folders.lock().unwrap()); + } +} + +#[async_trait] +impl ChatActionClient for FakeTdClient { async fn send_chat_action(&self, chat_id: ChatId, action: ChatAction) { - let action_str = format!("{:?}", action); - FakeTdClient::send_chat_action(self, chat_id, action_str).await; + FakeTdClient::send_chat_action(self, chat_id, format!("{:?}", action)).await; } fn clear_stale_typing_status(&mut self) -> bool { - // Not implemented for fake false } - // ============ Message methods ============ + fn typing_status(&self) -> Option<&(UserId, String, std::time::Instant)> { + None + } + + fn set_typing_status(&mut self, _status: Option<(UserId, String, std::time::Instant)>) {} +} + +#[async_trait] +impl MessageClient for FakeTdClient { async fn get_chat_history( &mut self, chat_id: ChatId, @@ -75,13 +114,10 @@ impl TdClientTrait for FakeTdClient { } async fn get_pinned_messages(&mut self, _chat_id: ChatId) -> Result, String> { - // Not implemented for fake Ok(vec![]) } - async fn load_current_pinned_message(&mut self, _chat_id: ChatId) { - // Not implemented for fake - } + async fn load_current_pinned_message(&mut self, _chat_id: ChatId) {} async fn search_messages( &self, @@ -132,16 +168,77 @@ impl TdClientTrait for FakeTdClient { FakeTdClient::set_draft_message(self, chat_id, text).await } - fn push_message(&mut self, _msg: MessageInfo) { - // Not used in fake client + fn current_chat_messages(&self) -> Cow<'_, [MessageInfo]> { + if let Some(chat_id) = *self.current_chat_id.lock().unwrap() { + Cow::Owned(self.get_messages(chat_id)) + } else { + Cow::Owned(Vec::new()) + } } - async fn fetch_missing_reply_info(&mut self) { - // Not used in fake client + fn current_chat_id(&self) -> Option { + self.get_current_chat_id().map(ChatId::new) } + fn current_pinned_message(&self) -> Option { + self.current_pinned_message.lock().unwrap().clone() + } + + fn push_message(&mut self, msg: MessageInfo) { + if let Some(chat_id) = *self.current_chat_id.lock().unwrap() { + self.messages + .lock() + .unwrap() + .entry(chat_id) + .or_default() + .push(msg); + } + } + + fn clear_current_chat_messages(&mut self) { + if let Some(chat_id) = *self.current_chat_id.lock().unwrap() { + self.messages.lock().unwrap().remove(&chat_id); + } + } + + fn set_current_chat_messages(&mut self, messages: Vec) { + if let Some(chat_id) = *self.current_chat_id.lock().unwrap() { + self.messages.lock().unwrap().insert(chat_id, messages); + } + } + + fn update_current_chat_messages(&mut self, updater: F) + where + F: FnOnce(&mut Vec), + { + if let Some(chat_id) = *self.current_chat_id.lock().unwrap() { + let mut all_messages = self.messages.lock().unwrap(); + updater(all_messages.entry(chat_id).or_default()); + } + } + + fn set_current_chat_id(&mut self, chat_id: Option) { + *self.current_chat_id.lock().unwrap() = chat_id.map(|id| id.as_i64()); + } + + fn set_current_pinned_message(&mut self, msg: Option) { + *self.current_pinned_message.lock().unwrap() = msg; + } + + fn pending_view_messages(&self) -> &[(ChatId, Vec)] { + &[] + } + + fn enqueue_pending_view_messages(&mut self, chat_id: ChatId, message_ids: Vec) { + self.pending_view_messages + .lock() + .unwrap() + .push((chat_id, message_ids)); + } + + async fn fetch_missing_reply_info(&mut self) {} + async fn process_pending_view_messages(&mut self) { - // Перемещаем pending в viewed для проверки в тестах let mut pending = self.pending_view_messages.lock().unwrap(); for (chat_id, message_ids) in pending.drain(..) { let ids: Vec = message_ids.iter().map(|id| id.as_i64()).collect(); @@ -151,18 +248,35 @@ impl TdClientTrait for FakeTdClient { .push((chat_id.as_i64(), ids)); } } +} - // ============ User methods ============ +#[async_trait] +impl UserClient for FakeTdClient { fn get_user_status_by_chat_id(&self, _chat_id: ChatId) -> Option<&UserOnlineStatus> { - // Not implemented for fake None } - async fn process_pending_user_ids(&mut self) { - // Not used in fake client + fn pending_user_ids(&self) -> &[UserId] { + &[] } - // ============ Reaction methods ============ + fn user_cache(&self) -> &UserCache { + use std::sync::OnceLock; + static EMPTY_CACHE: OnceLock = OnceLock::new(); + EMPTY_CACHE.get_or_init(|| UserCache::new(0)) + } + + fn update_user_cache(&mut self, _updater: F) + where + F: FnOnce(&mut UserCache), + { + } + + async fn process_pending_user_ids(&mut self) {} +} + +#[async_trait] +impl ReactionClient for FakeTdClient { async fn get_message_available_reactions( &self, chat_id: ChatId, @@ -179,29 +293,30 @@ impl TdClientTrait for FakeTdClient { ) -> Result<(), String> { FakeTdClient::toggle_reaction(self, chat_id, message_id, reaction).await } +} - // ============ File methods ============ +#[async_trait] +impl FileClient for FakeTdClient { async fn download_file(&self, file_id: i32) -> Result { FakeTdClient::download_file(self, file_id).await } async fn download_voice_note(&self, file_id: i32) -> Result { - // Fake implementation: return a fake path Ok(format!("/tmp/fake_voice_{}.ogg", file_id)) } +} - // ============ Getters (immutable) ============ +#[async_trait] +impl ClientState for FakeTdClient { fn client_id(&self) -> i32 { - 0 // Fake client ID + 0 } async fn get_me(&self) -> Result { - Ok(12345) // Fake user ID + Ok(12345) } fn auth_state(&self) -> &AuthState { - // Can't return reference from Arc, need to use a different approach - // For now, return a static reference based on the current state use std::sync::OnceLock; static AUTH_STATE_READY: AuthState = AuthState::Ready; static AUTH_STATE_WAIT_PHONE: OnceLock = OnceLock::new(); @@ -222,133 +337,24 @@ impl TdClientTrait for FakeTdClient { } } - fn chats(&self) -> &[ChatInfo] { - // FakeTdClient uses Arc, can't return direct reference - // This is a limitation - we'll need to work around it - &[] - } - - fn folders(&self) -> &[FolderInfo] { - &[] - } - - fn current_chat_messages(&self) -> Vec { - if let Some(chat_id) = *self.current_chat_id.lock().unwrap() { - return self.get_messages(chat_id); - } - Vec::new() - } - - fn current_chat_id(&self) -> Option { - self.get_current_chat_id().map(ChatId::new) - } - - fn current_pinned_message(&self) -> Option { - self.current_pinned_message.lock().unwrap().clone() - } - - fn typing_status(&self) -> Option<&(UserId, String, std::time::Instant)> { - None - } - - fn pending_view_messages(&self) -> &[(ChatId, Vec)] { - &[] - } - - fn pending_user_ids(&self) -> &[UserId] { - &[] - } - - fn main_chat_list_position(&self) -> i32 { - 0 - } - - fn user_cache(&self) -> &UserCache { - // Not implemented for fake - return empty cache - use std::sync::OnceLock; - static EMPTY_CACHE: OnceLock = OnceLock::new(); - EMPTY_CACHE.get_or_init(|| UserCache::new(0)) - } - fn network_state(&self) -> tele_tui::tdlib::types::NetworkState { FakeTdClient::get_network_state(self) } +} - // ============ Setters (mutable) ============ - fn chats_mut(&mut self) -> &mut Vec { - // Can't return mutable reference from Arc - // This is a design limitation - we need a different approach - panic!("chats_mut not supported for FakeTdClient - use get_chats() instead") - } +impl NotificationClient for FakeTdClient { + fn configure_notifications(&mut self, _config: &tele_tui::config::NotificationsConfig) {} - fn folders_mut(&mut self) -> &mut Vec { - panic!("folders_mut not supported for FakeTdClient") - } + fn sync_notification_muted_chats(&mut self) {} +} - fn current_chat_messages_mut(&mut self) -> &mut Vec { - panic!("current_chat_messages_mut not supported for FakeTdClient") - } - - fn clear_current_chat_messages(&mut self) { - if let Some(chat_id) = *self.current_chat_id.lock().unwrap() { - self.messages.lock().unwrap().remove(&chat_id); - } - } - - fn set_current_chat_messages(&mut self, messages: Vec) { - if let Some(chat_id) = *self.current_chat_id.lock().unwrap() { - self.messages.lock().unwrap().insert(chat_id, messages); - } - } - - fn set_current_chat_id(&mut self, chat_id: Option) { - *self.current_chat_id.lock().unwrap() = chat_id.map(|id| id.as_i64()); - } - - fn set_current_pinned_message(&mut self, msg: Option) { - *self.current_pinned_message.lock().unwrap() = msg; - } - - fn set_typing_status(&mut self, _status: Option<(UserId, String, std::time::Instant)>) { - // Not implemented - } - - fn enqueue_pending_view_messages(&mut self, chat_id: ChatId, message_ids: Vec) { - self.pending_view_messages - .lock() - .unwrap() - .push((chat_id, message_ids)); - } - - fn pending_user_ids_mut(&mut self) -> &mut Vec { - panic!("pending_user_ids_mut not supported for FakeTdClient") - } - - fn set_main_chat_list_position(&mut self, _position: i32) { - // Not implemented - } - - fn user_cache_mut(&mut self) -> &mut UserCache { - panic!("user_cache_mut not supported for FakeTdClient") - } - - // ============ Notification methods ============ - fn configure_notifications(&mut self, _config: &tele_tui::config::NotificationsConfig) { - // Not implemented for fake client (notifications are not tested) - } - - fn sync_notification_muted_chats(&mut self) { - // Not implemented for fake client (notifications are not tested) - } - - // ============ Account switching ============ +#[async_trait] +impl AccountClient for FakeTdClient { async fn recreate_client(&mut self, _db_path: PathBuf) -> Result<(), String> { - // No-op for fake client Ok(()) } - - // ============ Update handling ============ - fn handle_update(&mut self, _update: Update) { - // Not implemented for fake client - } +} + +impl UpdateClient for FakeTdClient { + fn handle_update(&mut self, _update: Update) {} } diff --git a/tests/helpers/mod.rs b/tests/helpers/mod.rs index 0a51768..1eb1b9c 100644 --- a/tests/helpers/mod.rs +++ b/tests/helpers/mod.rs @@ -6,7 +6,4 @@ mod fake_tdclient_impl; // TdClientTrait implementation for FakeTdClient pub mod snapshot_utils; pub mod test_data; -pub use app_builder::TestAppBuilder; pub use fake_tdclient::FakeTdClient; -pub use snapshot_utils::{buffer_to_string, render_to_buffer}; -pub use test_data::{create_test_chat, create_test_message, create_test_user}; diff --git a/tests/helpers/test_data.rs b/tests/helpers/test_data.rs index 9d655da..4fdc605 100644 --- a/tests/helpers/test_data.rs +++ b/tests/helpers/test_data.rs @@ -219,20 +219,22 @@ impl TestMessageBuilder { } /// Хелперы для быстрого создания тестовых данных - pub fn create_test_chat(title: &str, id: i64) -> ChatInfo { TestChatBuilder::new(title, id).build() } +#[allow(dead_code)] pub fn create_test_message(content: &str, id: i64) -> MessageInfo { TestMessageBuilder::new(content, id).build() } +#[allow(dead_code)] pub fn create_test_user(name: &str, id: i64) -> (i64, String) { (id, name.to_string()) } /// Хелпер для создания профиля +#[allow(dead_code)] pub fn create_test_profile(title: &str, chat_id: i64) -> ProfileInfo { ProfileInfo { chat_id: ChatId::new(chat_id), diff --git a/tests/modals.rs b/tests/modals.rs index 99421a3..48f71d3 100644 --- a/tests/modals.rs +++ b/tests/modals.rs @@ -8,7 +8,6 @@ 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() { diff --git a/tests/navigation.rs b/tests/navigation.rs index d58807d..83d74e1 100644 --- a/tests/navigation.rs +++ b/tests/navigation.rs @@ -4,7 +4,6 @@ mod helpers; use helpers::fake_tdclient::FakeTdClient; use helpers::test_data::{create_test_chat, TestMessageBuilder}; -use tele_tui::types::{ChatId, MessageId}; /// Test: Навигация вверх/вниз по списку чатов #[tokio::test] @@ -177,8 +176,7 @@ async fn test_russian_layout_navigation() { selected_index -= 1; assert_eq!(selected_index, 1); - // Проверяем что логика работает одинаково - assert!(true); // Реальный тест был бы в input handler + // Реальный end-to-end тест этого mapping живет в input handler. } /// Test: Подгрузка старых сообщений при скролле вверх diff --git a/tests/profile.rs b/tests/profile.rs index 18ab32c..57c194d 100644 --- a/tests/profile.rs +++ b/tests/profile.rs @@ -5,7 +5,7 @@ mod helpers; use helpers::fake_tdclient::FakeTdClient; use helpers::test_data::create_test_chat; use tele_tui::tdlib::ProfileInfo; -use tele_tui::types::{ChatId, MessageId}; +use tele_tui::types::ChatId; /// Test: Открытие профиля в личном чате (i) #[tokio::test] @@ -96,7 +96,7 @@ async fn test_profile_shows_channel_info() { #[tokio::test] async fn test_close_profile_with_esc() { // Профиль открыт - let profile_mode = true; + let _profile_mode = true; // Пользователь нажал Esc let profile_mode = false; diff --git a/tests/reactions.rs b/tests/reactions.rs index 8d1e12c..eb74ebb 100644 --- a/tests/reactions.rs +++ b/tests/reactions.rs @@ -2,8 +2,12 @@ mod helpers; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use helpers::app_builder::TestAppBuilder; use helpers::fake_tdclient::FakeTdClient; use helpers::test_data::TestMessageBuilder; +use tele_tui::config::Command; +use tele_tui::input::handlers::chat::handle_message_selection; use tele_tui::types::ChatId; /// Test: Добавление реакции к сообщению @@ -29,7 +33,26 @@ async fn test_add_reaction_to_message() { assert_eq!(messages[0].reactions().len(), 1); assert_eq!(messages[0].reactions()[0].emoji, "👍"); assert_eq!(messages[0].reactions()[0].count, 1); - assert_eq!(messages[0].reactions()[0].is_chosen, true); + assert!(messages[0].reactions()[0].is_chosen); +} + +#[tokio::test] +async fn test_react_without_selected_chat_does_not_panic() { + let msg = TestMessageBuilder::new("React safely", 100).build(); + let mut app = TestAppBuilder::new() + .with_message(123, msg) + .selecting_message(0) + .build(); + *app.td_client.current_chat_id.lock().unwrap() = Some(123); + + handle_message_selection( + &mut app, + KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE), + Some(Command::ReactMessage), + ) + .await; + + assert_eq!(app.error_message.as_deref(), Some("Чат не выбран")); } /// Test: Удаление реакции (toggle) - вторичное нажатие @@ -47,7 +70,7 @@ async fn test_toggle_reaction_removes_it() { // Проверяем что реакция есть let messages_before = client.get_messages(123); assert_eq!(messages_before[0].reactions().len(), 1); - assert_eq!(messages_before[0].reactions()[0].is_chosen, true); + assert!(messages_before[0].reactions()[0].is_chosen); let msg_id = messages_before[0].id(); @@ -116,7 +139,7 @@ async fn test_reactions_from_multiple_users() { assert_eq!(reaction.emoji, "👍"); assert_eq!(reaction.count, 3); - assert_eq!(reaction.is_chosen, false); + assert!(!reaction.is_chosen); } /// Test: Своя реакция (is_chosen = true) @@ -134,7 +157,7 @@ async fn test_own_reaction_is_chosen() { let messages = client.get_messages(123); let reaction = &messages[0].reactions()[0]; - assert_eq!(reaction.is_chosen, true); + assert!(reaction.is_chosen); // В UI это будет отображаться в рамках: [❤️] } @@ -153,7 +176,7 @@ async fn test_other_reaction_not_chosen() { let messages = client.get_messages(123); let reaction = &messages[0].reactions()[0]; - assert_eq!(reaction.is_chosen, false); + assert!(!reaction.is_chosen); // В UI это будет отображаться без рамок: 😂 2 } @@ -182,7 +205,7 @@ async fn test_reaction_counter_increases() { let messages = client.get_messages(123); assert_eq!(messages[0].reactions()[0].count, 2); - assert_eq!(messages[0].reactions()[0].is_chosen, true); + assert!(messages[0].reactions()[0].is_chosen); } /// Test: Обновление реакции - мы добавили свою к существующим @@ -199,7 +222,7 @@ async fn test_update_reaction_we_add_ours() { let messages_before = client.get_messages(123); assert_eq!(messages_before[0].reactions()[0].count, 2); - assert_eq!(messages_before[0].reactions()[0].is_chosen, false); + assert!(!messages_before[0].reactions()[0].is_chosen); let msg_id = messages_before[0].id(); @@ -213,7 +236,7 @@ async fn test_update_reaction_we_add_ours() { let reaction = &messages[0].reactions()[0]; assert_eq!(reaction.count, 3); - assert_eq!(reaction.is_chosen, true); + assert!(reaction.is_chosen); } /// Test: Реакция с count=1 отображается только emoji @@ -272,5 +295,5 @@ async fn test_reactions_on_multiple_messages() { assert_eq!(messages[2].reactions().len(), 2); assert_eq!(messages[2].reactions()[0].emoji, "😂"); assert_eq!(messages[2].reactions()[1].emoji, "🔥"); - assert_eq!(messages[2].reactions()[1].is_chosen, true); + assert!(messages[2].reactions()[1].is_chosen); } diff --git a/tests/reply_forward.rs b/tests/reply_forward.rs index c989e2f..4a4af5e 100644 --- a/tests/reply_forward.rs +++ b/tests/reply_forward.rs @@ -4,7 +4,6 @@ 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::types::{ChatId, MessageId}; diff --git a/tests/search.rs b/tests/search.rs index 5fb9d12..ded0ad6 100644 --- a/tests/search.rs +++ b/tests/search.rs @@ -4,7 +4,6 @@ mod helpers; use helpers::fake_tdclient::FakeTdClient; use helpers::test_data::{create_test_chat, TestChatBuilder, TestMessageBuilder}; -use tele_tui::types::{ChatId, MessageId}; /// Test: Поиск по чатам фильтрует по названию #[tokio::test] @@ -213,7 +212,6 @@ async fn test_cancel_search_restores_normal_mode() { let client = client.with_chats(vec![chat1, chat2]); // Симулируем: пользователь начал поиск - let mut is_searching = true; let mut search_query = "mom".to_string(); // Фильтруем @@ -227,7 +225,7 @@ async fn test_cancel_search_restores_normal_mode() { assert_eq!(filtered.len(), 1); // Пользователь нажал Esc - is_searching = false; + let is_searching = false; search_query.clear(); // После отмены видим все чаты diff --git a/tests/send_message.rs b/tests/send_message.rs index 9f3c2ae..1ad184b 100644 --- a/tests/send_message.rs +++ b/tests/send_message.rs @@ -30,7 +30,7 @@ async fn test_send_text_message() { assert_eq!(messages.len(), 1); assert_eq!(messages[0].id(), msg.id()); assert_eq!(messages[0].text(), "Hello, Mom!"); - assert_eq!(messages[0].is_outgoing(), true); + assert!(messages[0].is_outgoing()); } /// Test: Отправка нескольких сообщений обновляет список @@ -170,8 +170,8 @@ async fn test_receive_incoming_message() { // Проверяем что в списке 2 сообщения let messages = client.get_messages(123); assert_eq!(messages.len(), 2); - assert_eq!(messages[0].is_outgoing(), true); // Наше сообщение - assert_eq!(messages[1].is_outgoing(), false); // Входящее + assert!(messages[0].is_outgoing()); // Наше сообщение + assert!(!messages[1].is_outgoing()); // Входящее assert_eq!(messages[1].text(), "Hey there!"); assert_eq!(messages[1].sender_name(), "Alice"); }